Guard IndexedDB private work cleanup #1001
|
|
@ -12,11 +12,14 @@
|
|||
caches:root.caches,
|
||||
storageManager:root.navigator?.storage,
|
||||
privateDatabases:root.stackchainPrivateDatabases,
|
||||
inspectPrivateDatabases:root.inspectStackchainPrivateDatabases,
|
||||
clearPrivateDeviceData:root.stackchainPrivateDeviceData,
|
||||
});
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function createDeviceStorage(options) {
|
||||
let privateItemCount = 0;
|
||||
let privateRecordCount = 0;
|
||||
let inventoryUnavailable = false;
|
||||
let fullClearArmed = false;
|
||||
const ownedCaches = async () => (await options.caches?.keys?.() || [])
|
||||
.filter(name => name.startsWith('stackchain-dashboard-'));
|
||||
|
|
@ -44,11 +47,15 @@
|
|||
const itemCount = ownedStorageCount(options.localStorage)
|
||||
+ (options.sessionStorage === options.localStorage ? 0 : ownedStorageCount(options.sessionStorage));
|
||||
privateItemCount = itemCount;
|
||||
const inventory = await options.inspectPrivateDatabases?.(options.privateDatabases)
|
||||
|| { recordCount: 0, unavailable: true };
|
||||
privateRecordCount = inventory.recordCount;
|
||||
inventoryUnavailable = inventory.unavailable;
|
||||
const cacheCount = (await ownedCaches()).length;
|
||||
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||
+ `${options.privateDatabases.length} private work stores · `
|
||||
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
|
||||
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
|
||||
return { itemCount, cacheCount };
|
||||
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
|
||||
}
|
||||
|
||||
async function clearCaches() {
|
||||
|
|
@ -65,7 +72,7 @@
|
|||
}
|
||||
|
||||
async function clearAll() {
|
||||
if (privateItemCount > 0 && !fullClearArmed) {
|
||||
if ((privateItemCount > 0 || privateRecordCount > 0 || inventoryUnavailable) && !fullClearArmed) {
|
||||
fullClearArmed = true;
|
||||
options.clearAllButton.textContent = 'Confirm: clear private work';
|
||||
options.summary.textContent = 'Private drafts or queued work may not be synced. Press confirm to clear them from this device.';
|
||||
|
|
@ -77,7 +84,7 @@
|
|||
privateItemCount = 0;
|
||||
fullClearArmed = false;
|
||||
options.clearAllButton.textContent = 'Clear all private data';
|
||||
options.detail.textContent = '0 private browser items · 0 private work stores · 0 cached app copies';
|
||||
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
|
||||
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
|
||||
|
|
|
|||
|
|
@ -1649,6 +1649,7 @@
|
|||
<script src="static/mobile-insights.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/private-data-inventory.js"></script>
|
||||
<script src="static/private-device-data.js"></script>
|
||||
<script src="static/device-storage.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
|
|
|
|||
43
frontend/private-data-inventory.js
Normal file
43
frontend/private-data-inventory.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.inspectStackchainPrivateDatabases = factory(root.indexedDB);
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createPrivateDataInspector(indexedDB) {
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private storage inventory failed.'));
|
||||
request.onblocked = () => reject(new Error('Private storage inventory was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function countDatabase(name) {
|
||||
const database = await requestResult(indexedDB.open(name));
|
||||
try {
|
||||
const storeNames = Array.from(database.objectStoreNames);
|
||||
if (!storeNames.length) return 0;
|
||||
const transaction = database.transaction(storeNames, 'readonly');
|
||||
const counts = await Promise.all(storeNames.map(storeName =>
|
||||
requestResult(transaction.objectStore(storeName).count())
|
||||
));
|
||||
return counts.reduce((total, count) => total + Number(count || 0), 0);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
return async function inspectPrivateDatabases(registeredNames) {
|
||||
if (!indexedDB?.databases) return { recordCount: 0, unavailable: true };
|
||||
try {
|
||||
const existing = new Set((await indexedDB.databases()).map(database => database.name));
|
||||
const counts = await Promise.all(
|
||||
registeredNames.filter(name => existing.has(name)).map(countDatabase)
|
||||
);
|
||||
return {
|
||||
recordCount: counts.reduce((total, count) => total + count, 0),
|
||||
unavailable: false,
|
||||
};
|
||||
} catch (_error) {
|
||||
return { recordCount: 0, unavailable: true };
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
@ -118,6 +118,7 @@ const SHELL = [
|
|||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.js',
|
||||
BASE + 'static/private-device-data.js',
|
||||
BASE + 'static/device-storage.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
|||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": (
|
||||
"static/install-app.js", "static/private-device-data.js",
|
||||
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
||||
"static/device-storage.js", "static/mobile-device-setup.js",
|
||||
),
|
||||
"security-center": ("static/security-center.js",),
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
page.locator("#open-device-setup").click()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_visible()
|
||||
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
|
||||
expect(page.locator("#device-storage-detail")).to_contain_text("private work stores")
|
||||
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
|
||||
for selector in ("#clear-device-caches", "#clear-private-device-data"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class Target {
|
|||
}
|
||||
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
|
||||
let purgeCalls=0, purgeError='';
|
||||
let inventory={recordCount:0, unavailable:false};
|
||||
const localStorage={
|
||||
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
|
||||
get length(){return this.values.size;},
|
||||
|
|
@ -29,6 +30,7 @@ const caches={
|
|||
const controller=createDeviceStorage({
|
||||
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
|
||||
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
|
||||
inspectPrivateDatabases:async()=>inventory,
|
||||
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
|
||||
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
|
||||
});
|
||||
|
|
@ -47,7 +49,7 @@ process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.t
|
|||
|
||||
assert result == {
|
||||
"summary": "2 MB of 10 MB browser storage used.",
|
||||
"detail": "1 private browser item · 2 private work stores · 1 cached app copy",
|
||||
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -67,7 +69,7 @@ process.stdout.write(JSON.stringify({
|
|||
"caches": ["other-app"],
|
||||
"storage": ["stackchain.draft.1", "other.preference"],
|
||||
"status": "Cached app copies cleared. Private work was kept.",
|
||||
"detail": "1 private browser item · 2 private work stores · 0 cached app copies",
|
||||
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -77,7 +79,7 @@ await controller.start();
|
|||
await clearAllButton.dispatch('click');
|
||||
const warning={calls:purgeCalls,label:clearAllButton.textContent,status:summary.textContent};
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent}));
|
||||
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent,detail:detail.textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
|
|
@ -88,6 +90,49 @@ process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.tex
|
|||
},
|
||||
"calls": 1,
|
||||
"status": "All Stackchain private data was cleared from this device.",
|
||||
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
def test_indexeddb_only_work_requires_confirmation_before_purge():
|
||||
result = run_scenario("""
|
||||
localStorage.values.clear();
|
||||
inventory={recordCount:3, unavailable:false};
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:purgeCalls,
|
||||
label:clearAllButton.textContent,
|
||||
status:summary.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
||||
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_unavailable_indexeddb_inventory_requires_confirmation_before_purge():
|
||||
result = run_scenario("""
|
||||
localStorage.values.clear();
|
||||
inventory={recordCount:0, unavailable:true};
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:purgeCalls,
|
||||
label:clearAllButton.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
79
tests/test_private_data_inventory.py
Normal file
79
tests/test_private_data_inventory.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "private-data-inventory.js"
|
||||
|
||||
|
||||
def run_inventory(databases: dict[str, list[int]], registered: list[str]) -> dict:
|
||||
script = r"""
|
||||
const createInspector = require(__MODULE__);
|
||||
const databases = __DATABASES__;
|
||||
const indexedDB = {
|
||||
async databases() { return Object.keys(databases).map(name => ({name})); },
|
||||
open(name) {
|
||||
const request = {};
|
||||
queueMicrotask(() => {
|
||||
const stores = databases[name];
|
||||
const db = {
|
||||
objectStoreNames: stores.map((_records, index) => `store-${index}`),
|
||||
transaction(names) {
|
||||
return {
|
||||
objectStore(storeName) {
|
||||
return {
|
||||
count() {
|
||||
const countRequest = {};
|
||||
const index = Number(storeName.split('-')[1]);
|
||||
queueMicrotask(() => {
|
||||
countRequest.result = stores[index];
|
||||
countRequest.onsuccess?.();
|
||||
});
|
||||
return countRequest;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
request.result = db;
|
||||
request.onsuccess?.();
|
||||
});
|
||||
return request;
|
||||
},
|
||||
};
|
||||
createInspector(indexedDB)(__REGISTERED__)
|
||||
.then(result => process.stdout.write(JSON.stringify(result)))
|
||||
.catch(error => { console.error(error); process.exit(1); });
|
||||
"""
|
||||
script = (
|
||||
script.replace("__MODULE__", json.dumps(str(MODULE)))
|
||||
.replace("__DATABASES__", json.dumps(databases))
|
||||
.replace("__REGISTERED__", json.dumps(registered))
|
||||
)
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_inventory_counts_records_without_creating_absent_registered_databases():
|
||||
result = run_inventory(
|
||||
{"private-a": [2, 1], "other-app": [99]},
|
||||
["private-a", "private-empty", "private-missing"],
|
||||
)
|
||||
|
||||
assert result == {"recordCount": 3, "unavailable": False}
|
||||
|
||||
|
||||
def test_inventory_is_conservatively_unavailable_when_enumeration_is_unsupported():
|
||||
script = f"""
|
||||
const createInspector = require({json.dumps(str(MODULE))});
|
||||
createInspector({{}})(['private-a'])
|
||||
.then(result => process.stdout.write(JSON.stringify(result)))
|
||||
.catch(error => {{ console.error(error.message); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {"recordCount": 0, "unavailable": True}
|
||||
|
|
@ -44,3 +44,12 @@ def test_every_private_data_purge_context_consumes_the_shared_registry():
|
|||
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
assert html.index('static/private-data-registry.js') < html.index('static/session.js')
|
||||
|
||||
|
||||
def test_private_inventory_loads_after_registry_and_before_storage_controller():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
registry = html.index('static/private-data-registry.js')
|
||||
inventory = html.index('static/private-data-inventory.js')
|
||||
controller = html.index('static/device-storage.js')
|
||||
|
||||
assert registry < inventory < controller
|
||||
|
|
|
|||
|
|
@ -1055,6 +1055,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/mobile-insights.js",
|
||||
"/dashboard/static/mobile-app-shortcuts.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/private-data-inventory.js",
|
||||
"/dashboard/static/private-device-data.js",
|
||||
"/dashboard/static/device-storage.js",
|
||||
"/dashboard/static/mobile-device-setup.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user