Complete private outbox purge before device sign-out #294

Merged
timmy merged 1 commits from timmy/293-complete-device-outbox-purge into main 2026-08-08 11:16:14 +00:00
6 changed files with 170 additions and 16 deletions

View File

@ -8,7 +8,14 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
request.result.createObjectStore('issues', { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
databasePromise = undefined;
};
resolve(db);
};
request.onerror = () => reject(request.error);
});
return databasePromise;
@ -17,7 +24,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const transact = async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('issues', 'readwrite');
@ -38,6 +45,13 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
});
});
};
transact.close = async () => {
if (!databasePromise) return;
const db = await databasePromise;
db.close();
databasePromise = undefined;
};
return transact;
}
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
@ -149,10 +163,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
setReceiptPreference,
getReceiptPreference,
close: () => transact.close?.(),
};
}
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
let purgeRequested = false;
let activeFlush = null;
function receiptFor(item, status, delivered = {}) {
if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
@ -238,6 +255,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
}
async function send(item, currentLogin) {
if (purgeRequested) return { blocked: true };
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
await store.upsert(item);
const claimed = await store.claim(item.id, currentLogin);
@ -245,7 +263,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
return deliver(claimed);
}
async function flush() {
async function runFlush() {
const identity = await fetchJson(base + 'api/v1/background-identity', {
headers: { Accept: 'application/json' }, cache: 'no-store',
});
@ -267,8 +285,21 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
return { confirmed, blocked, attention, login, receipts };
}
function flush() {
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
if (activeFlush) return activeFlush;
activeFlush = runFlush().finally(() => { activeFlush = null; });
return activeFlush;
}
async function purge() {
purgeRequested = true;
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
}
return {
flush, send,
flush, send, purge,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v24';
const CACHE = 'stackchain-dashboard-shell-v25';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
@ -100,6 +100,14 @@ self.addEventListener('sync', event => {
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
event.ports?.[0]?.postMessage({ ok: true });
} catch (error) {
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
}
})());
});
self.addEventListener('notificationclick', event => {

View File

@ -13,9 +13,14 @@
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
location: root.location,
confirmAction: message => root.confirm(message),
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
onClearError: error => {
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this sites data.');
},
});
root.fetch = boundary.fetch;
const attach = () => {
@ -30,8 +35,9 @@
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location, confirmAction,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
onExpired = () => {},
onClearError = () => {},
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@ -73,11 +79,44 @@
} catch (_error) { /* Cookie invalidation still protects server data. */ }
}
async function stopWorkerOutbox() {
const registration = await serviceWorker?.ready;
if (!registration?.active || !MessageChannel) return;
await new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
channel.port1.onmessage = event => {
clearTimeout(timeout);
if (event.data?.ok) resolve();
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
};
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
});
}
function deletePrivateOutbox() {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
async function clearPrivateDeviceData() {
removeDashboardStorage(localStorage);
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
catch (_error) { /* Continue clearing other dashboard state. */ }
try {
await stopWorkerOutbox();
await deletePrivateOutbox();
} catch (_error) {
const error = new Error('Could not clear private queued work from this device.');
onClearError(error);
throw error;
}
try {
const keys = await caches?.keys?.() || [];
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));

View File

@ -194,6 +194,39 @@ const transaction=work=>{{const run=tail.then(()=>work({{
]
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const state={{opens:0,closes:0}};
const indexedDB={{lastDb:null,open:()=>{{
state.opens += 1;
const request={{result:null,onsuccess:null,onerror:null,onupgradeneeded:null}};
const db={{
objectStoreNames:{{contains:()=>true}},
close:()=>{{state.closes += 1;}},
transaction:()=>{{
const tx={{oncomplete:null,onerror:null,onabort:null,error:null,objectStore:()=>({{
getAll:()=>{{const get={{onsuccess:null,onerror:null,result:[]}};queueMicrotask(()=>get.onsuccess?.());return get;}},
}})}};
setTimeout(()=>tx.oncomplete?.(),0);
return tx;
}},
}};
indexedDB.lastDb=db;request.result=db;queueMicrotask(()=>request.onsuccess?.());return request;
}}}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{indexedDB}});
await store.snapshot();
indexedDB.lastDb.onversionchange?.();
await store.snapshot();
process.stdout.write(JSON.stringify(state));
}})();
"""
output = run_node(script)
assert output == {"opens": 2, "closes": 1}
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});

View File

@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [], confirmations: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@ -31,10 +31,24 @@ const boundary = createSessionBoundary({{
}},
localStorage: storage,
sessionStorage: storage,
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
indexedDB: {{ deleteDatabase: name => {{
state.deletedDatabases.push(name);
const request = {{ onsuccess: null, onerror: null, onblocked: null, error: null }};
setTimeout(() => {{
if (state.failDeletion) {{ request.error = new Error('database blocked'); request.onerror?.(); }}
else {{ state.deletionCompleted = true; request.onsuccess?.(); }}
}}, 20);
return request;
}} }},
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
location: {{ assign: value => {{ state.assigned = value; }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: (message, ports = []) => {{ state.workerMessages.push(message); ports[0]?.postMessage({{ok:true}}); }} }} }}) }},
MessageChannel: class {{ constructor() {{
const first = {{ onmessage: null, postMessage: data => queueMicrotask(() => second.onmessage?.({{data}})) }};
const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }};
this.port1 = first; this.port2 = second;
}} }},
location: {{ assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }} }},
onClearError: error => state.clearErrors.push(error.message),
confirmAction: message => {{ state.confirmations.push(message); return true; }},
}});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
@ -72,6 +86,21 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
assert result["assignedAfterDeletion"] is True
def test_sign_out_stays_on_page_and_reports_failed_private_outbox_deletion():
result = run_session_scenario(
"""
state.failDeletion = true;
try { await boundary.signOut(); } catch (error) { state.signOutError = error.message; }
process.stdout.write(JSON.stringify(state));
"""
)
assert result["assigned"] == ""
assert result["clearErrors"] == ["Could not clear private queued work from this device."]
assert result["signOutError"] == "Could not clear private queued work from this device."
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():

View File

@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
@ -34,6 +34,7 @@ const context = {{
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
purge: async () => {{ state.outboxPurges += 1; }},
getReceiptPreference: async login => state.receiptLogin === login,
}},
}},
@ -68,9 +69,9 @@ async function dispatchSync(tag) {{
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchMessage(data) {{
async function dispatchMessage(data, ports = []) {{
let pending;
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route) {{
@ -94,7 +95,7 @@ async function dispatchNotificationClick(route) {{
def test_mobile_install_flow_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v24" in source
assert "stackchain-dashboard-shell-v25" in source
assert "BASE + 'static/install-app.js'" in source
@ -121,6 +122,19 @@ def test_authenticated_page_message_resumes_queued_background_delivery():
assert result["backgroundFlushes"] == 1
def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
result = run_worker_scenario(
"""
const replies = [];
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage: value => replies.push(value)}]);
process.stdout.write(JSON.stringify({state,replies}));
"""
)
assert result["state"]["outboxPurges"] == 1
assert result["replies"] == [{"ok": True}]
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
result = run_worker_scenario(
"""