fix: keep planning edits lossless across tabs (#373)
All checks were successful
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-09 05:31:14 +00:00
parent 475acbb92f
commit 639ce911be
11 changed files with 190 additions and 50 deletions

View File

@ -135,6 +135,7 @@
let activeMyWork = []; let activeMyWork = [];
let laterMyWork = []; let laterMyWork = [];
let todayMyWork = []; let todayMyWork = [];
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const todayWork = createTodayWork({ const todayWork = createTodayWork({
storage: localStorage, storage: localStorage,
getLogin: () => planningOwnerLogin, getLogin: () => planningOwnerLogin,
@ -143,6 +144,7 @@
storage: localStorage, storage: localStorage,
getLogin: () => planningOwnerLogin, getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson, fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteIds: ids => { onRemoteIds: ids => {
if (!planningOwnerLogin || !todayWork.replace(ids)) return; if (!planningOwnerLogin || !todayWork.replace(ids)) return;
refreshMyWorkView(); refreshMyWorkView();
@ -177,6 +179,7 @@
storage: localStorage, storage: localStorage,
getLogin: () => planningOwnerLogin, getLogin: () => planningOwnerLogin,
fetchJson: fetchReviewJson, fetchJson: fetchReviewJson,
coordinator: outboxCoordinator,
onRemoteRecords: records => { onRemoteRecords: records => {
if (!planningOwnerLogin || !laterWork.adopt(records)) return; if (!planningOwnerLogin || !laterWork.adopt(records)) return;
refreshMyWorkView(); refreshMyWorkView();
@ -242,7 +245,6 @@
await registration.sync.register('stackchain-issue-outbox-v1'); await registration.sync.register('stackchain-issue-outbox-v1');
}; };
} }
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
const issueOutbox = createIssueOutbox({ const issueOutbox = createIssueOutbox({
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
backgroundSync: backgroundIssueSync, backgroundSync: backgroundIssueSync,

View File

@ -1,4 +1,4 @@
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) { setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
const prefix = 'stackchain.later-sync.v1.'; const prefix = 'stackchain.later-sync.v1.';
const migrationPrefix = 'stackchain.later-sync-migrated.v1.'; const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
@ -8,6 +8,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
let channelKey = ''; let channelKey = '';
let retryTimer = null; let retryTimer = null;
let retryAttempt = 0; let retryAttempt = 0;
const knownOperationKeys = new Set();
function cancelRetry() { function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer); if (retryTimer !== null) clearTimer?.(retryTimer);
@ -84,25 +85,52 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
function pending() { function pending() {
const storageKey = key(); const storageKey = key();
if (!storageKey || !storage) return []; if (!storageKey || !storage) return [];
const recordPrefix = storageKey + '.operation.';
try { try {
const value = JSON.parse(storage.getItem(storageKey) || '[]'); const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(value) ? value.filter(operation => if (Array.isArray(legacy)) {
legacy.forEach((operation, index) => {
if (!operation?.operation_id) return;
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
knownOperationKeys.add(recordKey);
});
if (legacy.length) storage.removeItem(storageKey);
}
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
for (let index = 0; index < Number(storage.length || 0); index += 1) {
const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
}
const records = [...keys].map(recordKey => {
const record = JSON.parse(storage.getItem(recordKey) || 'null');
return record ? { ...record, recordKey } : null;
}).filter(record => record?.operation)
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
left.operation.operation_id.localeCompare(right.operation.operation_id));
const latestByItem = new Map();
records.forEach(record => latestByItem.set(record.operation.item_id, record));
records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
storage.removeItem(record.recordKey);
knownOperationKeys.delete(record.recordKey);
});
return records.filter(record => latestByItem.get(record.operation.item_id) === record)
.map(record => record.operation).filter(operation =>
operation && typeof operation.operation_id === 'string' && operation && typeof operation.operation_id === 'string' &&
['defer', 'restore'].includes(operation.action) && ['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
typeof operation.item_id === 'string' && (operation.action === 'restore' || typeof operation.wake_at === 'string'));
(operation.action === 'restore' || typeof operation.wake_at === 'string')
) : [];
} catch (_error) { } catch (_error) {
return []; return [];
} }
} }
function save(operations) { function removeOperation(operationId) {
const storageKey = key(); const storageKey = key();
if (!storageKey || !storage) return false; if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try { try {
if (operations.length) storage.setItem(storageKey, JSON.stringify(operations)); storage.removeItem(recordKey);
else storage.removeItem(storageKey); knownOperationKeys.delete(recordKey);
return true; return true;
} catch (_error) { } catch (_error) {
return false; return false;
@ -118,9 +146,19 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
function enqueue(action, itemId, wakeAt = null) { function enqueue(action, itemId, wakeAt = null) {
if (!['defer', 'restore'].includes(action) || !itemId || if (!['defer', 'restore'].includes(action) || !itemId ||
(action === 'defer' && typeof wakeAt !== 'string')) return false; (action === 'defer' && typeof wakeAt !== 'string')) return false;
const operations = pending().filter(operation => operation.item_id !== itemId); pending().filter(operation => operation.item_id === itemId)
operations.push({ operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt }); .forEach(operation => removeOperation(operation.operation_id));
const saved = save(operations); const operation = { operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt };
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('later');
} catch (_error) { /* Report the persistence failure below. */ }
onStatus?.(saved ? 'pending' : 'error'); onStatus?.(saved ? 'pending' : 'error');
return saved; return saved;
} }
@ -156,9 +194,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(operation), body: JSON.stringify(operation),
}); });
const remaining = pending(); if (pending().some(candidate => candidate.operation_id === operation.operation_id) &&
const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id); !removeOperation(operation.operation_id)) {
if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
throw new Error('Could not persist Later delivery receipt'); throw new Error('Could not persist Later delivery receipt');
} }
operations = pending(); operations = pending();
@ -176,7 +213,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
} }
function flush() { function flush() {
if (!flushing) flushing = run().finally(() => { flushing = null; }); if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('later', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing; return flushing;
} }
@ -187,6 +227,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
); );
} }
coordinator?.subscribe(change => {
if (change.queue === 'later' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle }; return { enqueue, migrate, flush, pending, startLifecycle };
} }

View File

@ -15,7 +15,7 @@ function createOutboxCoordinator({
let sequence = 0; let sequence = 0;
function validChange(value) { function validChange(value) {
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null; return value && ['issue', 'authored', 'today', 'later'].includes(value.queue) ? value : null;
} }
function publish(change) { function publish(change) {

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v52'; const CACHE = 'stackchain-dashboard-shell-v53';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [ const SHELL = [

View File

@ -1,4 +1,4 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) { setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
const prefix = 'stackchain.today-sync.v1.'; const prefix = 'stackchain.today-sync.v1.';
const migrationPrefix = 'stackchain.today-sync-migrated.v1.'; const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
@ -8,6 +8,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
let channelKey = ''; let channelKey = '';
let retryTimer = null; let retryTimer = null;
let retryAttempt = 0; let retryAttempt = 0;
const knownOperationKeys = new Set();
function cancelRetry() { function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer); if (retryTimer !== null) clearTimer?.(retryTimer);
@ -79,24 +80,42 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
function pending() { function pending() {
const storageKey = key(); const storageKey = key();
if (!storageKey || !storage) return []; if (!storageKey || !storage) return [];
const recordPrefix = storageKey + '.operation.';
try { try {
const value = JSON.parse(storage.getItem(storageKey) || '[]'); const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
return Array.isArray(value) ? value.filter(operation => if (Array.isArray(legacy)) {
operation && typeof operation.operation_id === 'string' && legacy.forEach((operation, index) => {
['add', 'remove', 'move'].includes(operation.action) && if (!operation?.operation_id) return;
typeof operation.item_id === 'string' const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
) : []; storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
knownOperationKeys.add(recordKey);
});
if (legacy.length) storage.removeItem(storageKey);
}
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
for (let index = 0; index < Number(storage.length || 0); index += 1) {
const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
}
return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null'))
.filter(record => record?.operation)
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
left.operation.operation_id.localeCompare(right.operation.operation_id))
.map(record => record.operation)
.filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) { } catch (_error) {
return []; return [];
} }
} }
function save(operations) { function removeOperation(operationId) {
const storageKey = key(); const storageKey = key();
if (!storageKey || !storage) return false; if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try { try {
if (operations.length) storage.setItem(storageKey, JSON.stringify(operations)); storage.removeItem(recordKey);
else storage.removeItem(storageKey); knownOperationKeys.delete(recordKey);
return true; return true;
} catch (_error) { } catch (_error) {
return false; return false;
@ -117,8 +136,17 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
onStatus?.('pending'); onStatus?.('pending');
return true; return true;
} }
operations.push({ operation_id: operationId(), action, item_id: itemId, direction }); const operation = { operation_id: operationId(), action, item_id: itemId, direction };
const saved = save(operations); const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('today');
} catch (_error) { /* Report the persistence failure below. */ }
onStatus?.(saved ? 'pending' : 'error'); onStatus?.(saved ? 'pending' : 'error');
return saved; return saved;
} }
@ -156,17 +184,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
}); });
} catch (error) { } catch (error) {
if (error?.status !== 409) throw error; if (error?.status !== 409) throw error;
const rejected = pending(); if (!removeOperation(operation.operation_id)) {
if (!save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id))) {
throw new Error('Could not persist rejected Today operation'); throw new Error('Could not persist rejected Today operation');
} }
hadConflict = true; hadConflict = true;
operations = pending(); operations = pending();
continue; continue;
} }
const remaining = pending(); if (pending().some(candidate => candidate.operation_id === operation.operation_id) &&
const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id); !removeOperation(operation.operation_id)) {
if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
throw new Error('Could not persist Today delivery receipt'); throw new Error('Could not persist Today delivery receipt');
} }
operations = pending(); operations = pending();
@ -184,7 +210,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
} }
function flush() { function flush() {
if (!flushing) flushing = run().finally(() => { flushing = null; }); if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('today', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing; return flushing;
} }
@ -195,6 +224,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
); );
} }
coordinator?.subscribe(change => {
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle }; return { enqueue, migrate, flush, pending, startLifecycle };
} }

View File

@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js" LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js" LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
def run_node(script): def run_node(script):
@ -19,6 +20,41 @@ def run_node(script):
) )
def test_two_tabs_cannot_erase_each_others_later_edits_or_double_drain():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const createCoordinator=require({json.dumps(str(COORDINATOR))});
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const channels=[];const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}};
const held=new Set();const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}};
let sequence=0,releaseFirst;const delivered=[];
const make=tab=>createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};const operation=JSON.parse(options.body);delivered.push(operation);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,records:{{[operation.item_id]:operation.wake_at}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
const first=make('first'),second=make('second');first.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('defer','issue:r:2:','2026-08-11T09:00:00.000Z');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})();
"""
result = run_node(script)
assert sorted(operation["operation_id"] for operation in result["delivered"]) == ["first-1", "second-2"]
assert len(result["delivered"]) == 2
assert result["pending"] == []
assert not any(".operation." in key for key in result["keys"])
def test_concurrent_later_records_for_one_item_deliver_only_the_newest_intent():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const prefix='stackchain.later-sync.v1.timmy.operation.';
values.set(prefix+'old',JSON.stringify({{queued_at:100,operation:{{operation_id:'old',action:'defer',item_id:'issue:r:2:',wake_at:'2026-08-10T09:00:00.000Z'}}}}));
values.set(prefix+'new',JSON.stringify({{queued_at:101,operation:{{operation_id:'new',action:'restore',item_id:'issue:r:2:',wake_at:null}}}}));
const delivered=[];const sync=createLaterSync({{storage,getLogin:()=> 'timmy',fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};delivered.push(JSON.parse(options.body));return {{revision:1,records:{{}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,pending:sync.pending(),keys:[...values.keys()]}}));}})();
"""
result = run_node(script)
assert [operation["operation_id"] for operation in result["delivered"]] == ["new"]
assert result["pending"] == []
assert not any(".operation." in key for key in result["keys"])
def test_offline_deferral_replays_once_and_adopts_server_records(): def test_offline_deferral_replays_once_and_adopts_server_records():
script = f""" script = f"""
const createLaterSync = require({json.dumps(str(LATER_SYNC))}); const createLaterSync = require({json.dumps(str(LATER_SYNC))});
@ -233,5 +269,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell(): def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v52" in source assert "stackchain-dashboard-shell-v53" in source
assert "BASE + 'static/later-sync.js'" in source assert "BASE + 'static/later-sync.js'" in source

View File

@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v52" in worker assert "stackchain-dashboard-shell-v53" in worker

View File

@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v52" in worker assert "stackchain-dashboard-shell-v53" in worker

View File

@ -110,13 +110,15 @@ const seen=[];
const unsubscribe=second.subscribe(change=>seen.push(change)); const unsubscribe=second.subscribe(change=>seen.push(change));
first.notify('issue'); first.notify('issue');
first.notify('authored'); first.notify('authored');
first.notify('today');
first.notify('later');
unsubscribe(); unsubscribe();
first.notify('issue'); first.notify('issue');
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}})); process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
""" """
output = run_node(script) output = run_node(script)
assert [change["queue"] for change in output["seen"]] == ["issue", "authored"] assert [change["queue"] for change in output["seen"]] == ["issue", "authored", "today", "later"]
assert output["signal"]["queue"] == "issue" assert output["signal"]["queue"] == "issue"
assert output["signal"]["tabId"] == "first" assert output["signal"]["tabId"] == "first"

View File

@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v52" in source assert "stackchain-dashboard-shell-v53" in source
assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
@ -116,14 +116,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v52" in source assert "stackchain-dashboard-shell-v53" in source
assert "BASE + 'static/later-picker.js'" in source assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v52" in source assert "stackchain-dashboard-shell-v53" in source
assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source assert "BASE + 'static/install-app.js'" in source
@ -132,21 +132,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v52" in source assert "stackchain-dashboard-shell-v53" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v52" in source assert "stackchain-dashboard-shell-v53" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() source = WORKER.read_text()
assert "stackchain-dashboard-shell-v52" in source assert "stackchain-dashboard-shell-v53" in source
assert "BASE + 'static/update-ownership.js'" in source assert "BASE + 'static/update-ownership.js'" in source

View File

@ -4,6 +4,29 @@ from pathlib import Path
TODAY_SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js" TODAY_SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js"
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
def test_two_tabs_cannot_erase_each_others_today_edits_or_double_drain():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const values = new Map();
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const channels=[]; const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}};
const held=new Set(); const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}};
let sequence=0,releaseFirst;const delivered=[];
const make=tab=>createTodaySync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),
fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,ids:[]}};const operation=JSON.parse(options.body);delivered.push(operation.operation_id);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,ids:delivered}}}},onRemoteIds:()=>{{}},onStatus:()=>{{}}}});
const first=make('first'),second=make('second');first.enqueue('add','issue:r:1:');
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('add','issue:r:2:');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})();
"""
result = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
assert sorted(result["delivered"]) == ["first-1", "second-2"]
assert len(result["delivered"]) == 2
assert result["pending"] == []
assert not any(".operation." in key for key in result["keys"])
def test_operations_enqueued_during_an_inflight_flush_are_drained_before_it_settles(): def test_operations_enqueued_during_an_inflight_flush_are_drained_before_it_settles():
@ -63,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell(): def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v52" in source assert "stackchain-dashboard-shell-v53" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source