Keep Today and Later edits lossless across browser tabs #374
|
|
@ -135,6 +135,7 @@
|
|||
let activeMyWork = [];
|
||||
let laterMyWork = [];
|
||||
let todayMyWork = [];
|
||||
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
||||
const todayWork = createTodayWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
|
|
@ -143,6 +144,7 @@
|
|||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
fetchJson: fetchReviewJson,
|
||||
coordinator: outboxCoordinator,
|
||||
onRemoteIds: ids => {
|
||||
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
|
||||
refreshMyWorkView();
|
||||
|
|
@ -177,6 +179,7 @@
|
|||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
fetchJson: fetchReviewJson,
|
||||
coordinator: outboxCoordinator,
|
||||
onRemoteRecords: records => {
|
||||
if (!planningOwnerLogin || !laterWork.adopt(records)) return;
|
||||
refreshMyWorkView();
|
||||
|
|
@ -242,7 +245,6 @@
|
|||
await registration.sync.register('stackchain-issue-outbox-v1');
|
||||
};
|
||||
}
|
||||
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
||||
const issueOutbox = createIssueOutbox({
|
||||
storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator,
|
||||
backgroundSync: backgroundIssueSync,
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
const prefix = 'stackchain.later-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
||||
|
|
@ -8,6 +8,7 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
|||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
|
|
@ -84,25 +85,52 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
|||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
return Array.isArray(value) ? value.filter(operation =>
|
||||
operation && typeof operation.operation_id === 'string' &&
|
||||
['defer', 'restore'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string' &&
|
||||
(operation.action === 'restore' || typeof operation.wake_at === 'string')
|
||||
) : [];
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
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' &&
|
||||
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
|
||||
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function save(operations) {
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
if (operations.length) storage.setItem(storageKey, JSON.stringify(operations));
|
||||
else storage.removeItem(storageKey);
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
|
|
@ -118,9 +146,19 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
|||
function enqueue(action, itemId, wakeAt = null) {
|
||||
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
const operations = pending().filter(operation => operation.item_id !== itemId);
|
||||
operations.push({ operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt });
|
||||
const saved = save(operations);
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
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');
|
||||
return saved;
|
||||
}
|
||||
|
|
@ -156,9 +194,8 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(operation),
|
||||
});
|
||||
const remaining = pending();
|
||||
const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id);
|
||||
if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
|
||||
if (pending().some(candidate => candidate.operation_id === operation.operation_id) &&
|
||||
!removeOperation(operation.operation_id)) {
|
||||
throw new Error('Could not persist Later delivery receipt');
|
||||
}
|
||||
operations = pending();
|
||||
|
|
@ -176,7 +213,10 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ function createOutboxCoordinator({
|
|||
let sequence = 0;
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
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 NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const SHELL = [
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
const prefix = 'stackchain.today-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
|
||||
|
|
@ -8,6 +8,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
|||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
|
|
@ -79,24 +80,42 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
|||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
return Array.isArray(value) ? value.filter(operation =>
|
||||
operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move'].includes(operation.action) &&
|
||||
typeof operation.item_id === 'string'
|
||||
) : [];
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function save(operations) {
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
if (operations.length) storage.setItem(storageKey, JSON.stringify(operations));
|
||||
else storage.removeItem(storageKey);
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
|
|
@ -117,8 +136,17 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
|||
onStatus?.('pending');
|
||||
return true;
|
||||
}
|
||||
operations.push({ operation_id: operationId(), action, item_id: itemId, direction });
|
||||
const saved = save(operations);
|
||||
const operation = { operation_id: operationId(), action, item_id: itemId, direction };
|
||||
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');
|
||||
return saved;
|
||||
}
|
||||
|
|
@ -156,17 +184,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
|||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 409) throw error;
|
||||
const rejected = pending();
|
||||
if (!save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id))) {
|
||||
if (!removeOperation(operation.operation_id)) {
|
||||
throw new Error('Could not persist rejected Today operation');
|
||||
}
|
||||
hadConflict = true;
|
||||
operations = pending();
|
||||
continue;
|
||||
}
|
||||
const remaining = pending();
|
||||
const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id);
|
||||
if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
|
||||
if (pending().some(candidate => candidate.operation_id === operation.operation_id) &&
|
||||
!removeOperation(operation.operation_id)) {
|
||||
throw new Error('Could not persist Today delivery receipt');
|
||||
}
|
||||
operations = pending();
|
||||
|
|
@ -184,7 +210,10 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
|||
|
||||
LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
|
||||
|
||||
|
||||
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():
|
||||
script = f"""
|
||||
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():
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 pre { max-width:100%; overflow-x:auto;" 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
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -110,13 +110,15 @@ const seen=[];
|
|||
const unsubscribe=second.subscribe(change=>seen.push(change));
|
||||
first.notify('issue');
|
||||
first.notify('authored');
|
||||
first.notify('today');
|
||||
first.notify('later');
|
||||
unsubscribe();
|
||||
first.notify('issue');
|
||||
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
|
||||
"""
|
||||
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"]["tabId"] == "first"
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
|
|||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
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/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():
|
||||
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
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
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.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():
|
||||
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
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
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
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,29 @@ from pathlib import Path
|
|||
|
||||
|
||||
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():
|
||||
|
|
@ -63,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
|||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user