parent
66968d90c0
commit
a6550f8b1f
|
|
@ -376,11 +376,12 @@
|
||||||
onStatus: (state, detail = {}) => {
|
onStatus: (state, detail = {}) => {
|
||||||
const status = qs('#today-sync-status');
|
const status = qs('#today-sync-status');
|
||||||
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
||||||
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
(state === 'recovered' ? `Recovered ${detail.discarded} Today edit${detail.discarded === 1 ? '' : 's'} · discarded ${detail.discarded} unreadable device record${detail.discarded === 1 ? '' : 's'}.` :
|
||||||
(state === 'pending' ? 'Today saved on this device · sync pending.' :
|
(state === 'retrying' ? `Today saved on this device · retrying in ${Math.ceil(detail.delayMs / 1000)}s.` :
|
||||||
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
(state === 'pending' ? 'Today saved on this device · sync pending.' :
|
||||||
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
||||||
'Today sync unavailable · changes stay on this device.'))));
|
(state === 'expired' ? `Today edit expired after 30 days offline${detail.count > 1 ? 's' : ''} · account plan kept.` :
|
||||||
|
'Today sync unavailable · changes stay on this device.')))));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
todaySync.startLifecycle({ window, document });
|
todaySync.startLifecycle({ window, document });
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
let retryTimer = null;
|
let retryTimer = null;
|
||||||
let retryAttempt = 0;
|
let retryAttempt = 0;
|
||||||
let expiredCount = 0;
|
let expiredCount = 0;
|
||||||
|
let discardedCount = 0;
|
||||||
const knownOperationKeys = new Set();
|
const knownOperationKeys = new Set();
|
||||||
|
|
||||||
function cancelRetry() {
|
function cancelRetry() {
|
||||||
|
|
@ -108,10 +109,28 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
const candidate = storage.key?.(index);
|
const candidate = storage.key?.(index);
|
||||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||||
}
|
}
|
||||||
const records = [...keys].map(recordKey => {
|
const records = [];
|
||||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
for (const recordKey of keys) {
|
||||||
return record ? { ...record, recordKey } : null;
|
let record;
|
||||||
}).filter(record => record?.operation);
|
try {
|
||||||
|
record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||||
|
} catch (_error) {
|
||||||
|
storage.removeItem(recordKey);
|
||||||
|
knownOperationKeys.delete(recordKey);
|
||||||
|
discardedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const operation = record?.operation;
|
||||||
|
const valid = operation && typeof operation.operation_id === 'string' &&
|
||||||
|
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) &&
|
||||||
|
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
|
||||||
|
if (valid) records.push({ ...record, recordKey });
|
||||||
|
else {
|
||||||
|
storage.removeItem(recordKey);
|
||||||
|
knownOperationKeys.delete(recordKey);
|
||||||
|
discardedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||||
now() - Number(record.queued_at) > maxOfflineMs);
|
now() - Number(record.queued_at) > maxOfflineMs);
|
||||||
expired.forEach(record => {
|
expired.forEach(record => {
|
||||||
|
|
@ -272,8 +291,12 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
operations = pending();
|
operations = pending();
|
||||||
}
|
}
|
||||||
adopt(plan);
|
adopt(plan);
|
||||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
|
const stillPending = pending().length;
|
||||||
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
|
const recovered = discardedCount;
|
||||||
|
onStatus?.(stillPending ? 'pending' : hadConflict ? 'full' :
|
||||||
|
expiredCount ? 'expired' : recovered ? 'recovered' : 'saved',
|
||||||
|
expiredCount ? { count: expiredCount } : recovered ? { discarded: recovered } : {});
|
||||||
|
if (!stillPending) discardedCount = 0;
|
||||||
retryAttempt = 0;
|
retryAttempt = 0;
|
||||||
cancelRetry();
|
cancelRetry();
|
||||||
return !hadConflict;
|
return !hadConflict;
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,26 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
|
|
||||||
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
|
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
|
||||||
assert today == ["issue:acme/mobile:41:", "issue:acme/mobile:42:"]
|
assert today == ["issue:acme/mobile:41:", "issue:acme/mobile:42:"]
|
||||||
|
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const prefix = 'stackchain.today-sync.v1.timmy.operation.';
|
||||||
|
const snapshot = JSON.parse(localStorage.getItem('stackchain.today-sync-snapshot.v1.timmy') || '{}');
|
||||||
|
localStorage.setItem(prefix + 'broken', '{not-json');
|
||||||
|
localStorage.setItem(prefix + 'recover-remove', JSON.stringify({
|
||||||
|
queued_at: Date.now(),
|
||||||
|
operation: {
|
||||||
|
operation_id: 'recover-remove', action: 'remove',
|
||||||
|
item_id: 'issue:acme/mobile:42:', direction: null, base_revision: snapshot.revision,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}""")
|
||||||
|
page.reload(wait_until="networkidle")
|
||||||
|
expect(page.locator("#today-sync-status")).to_have_text(
|
||||||
|
"Recovered 1 Today edit · discarded 1 unreadable device record."
|
||||||
|
)
|
||||||
|
assert page.evaluate("""() => !Object.keys(localStorage).some(
|
||||||
|
key => key.startsWith('stackchain.today-sync.v1.timmy.operation.')
|
||||||
|
)""")
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
assert browser_errors == []
|
assert browser_errors == []
|
||||||
assert failed_responses == []
|
assert failed_responses == []
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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"
|
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
|
||||||
|
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||||
|
|
||||||
|
|
||||||
def test_two_tabs_cannot_erase_each_others_today_edits_or_double_drain():
|
def test_two_tabs_cannot_erase_each_others_today_edits_or_double_drain():
|
||||||
|
|
@ -108,6 +109,44 @@ process.stdout.write(JSON.stringify(sync.pending().map(operation => operation.op
|
||||||
).stdout) == ["z-add-first", "a-move-second"]
|
).stdout) == ["z-add-first", "a-move-second"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_queue_record_does_not_hide_or_strand_valid_today_edits():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const prefix = 'stackchain.today-sync.v1.timmy.operation.';
|
||||||
|
const values = new Map([
|
||||||
|
[prefix + 'first', JSON.stringify({{queued_at: 1000, operation: {{operation_id:'first', action:'add', item_id:'issue:r:1:'}}}})],
|
||||||
|
[prefix + 'broken', '{{not-json'],
|
||||||
|
[prefix + 'invalid', JSON.stringify({{queued_at: 1000, operation: {{operation_id:'invalid', action:'erase-everything'}}}})],
|
||||||
|
[prefix + 'second', JSON.stringify({{queued_at: 1001, operation: {{operation_id:'second', action:'move', item_id:'issue:r:2:', direction:'up'}}}})],
|
||||||
|
]);
|
||||||
|
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 patches=[]; const statuses=[];
|
||||||
|
const sync=createTodaySync({{storage,getLogin:()=> 'timmy',
|
||||||
|
fetchJson:async(_url,options={{}})=>{{const operations=JSON.parse(options.body).operations;patches.push(...operations);return {{revision:1,ids:['issue:r:1:','issue:r:2:'],accepted_operation_ids:operations.map(x=>x.operation_id)}}}},
|
||||||
|
onRemoteIds:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.discarded||0])}});
|
||||||
|
(async()=>{{const result=await sync.flush();process.stdout.write(JSON.stringify({{
|
||||||
|
result,patches,statuses,remaining:[...values.keys()].filter(key=>key.startsWith(prefix)),pending:sync.pending()
|
||||||
|
}}));}})();
|
||||||
|
"""
|
||||||
|
result = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert result["result"] is True
|
||||||
|
assert [item["operation_id"] for item in result["patches"]] == ["first", "second"]
|
||||||
|
assert result["pending"] == []
|
||||||
|
assert result["remaining"] == []
|
||||||
|
assert result["statuses"][-1] == ["recovered", 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_sync_reports_queue_recovery_instead_of_plain_saved_status():
|
||||||
|
source = DASHBOARD.read_text()
|
||||||
|
|
||||||
|
assert "Recovered ${detail.discarded} Today edit" in source
|
||||||
|
assert "discarded ${detail.discarded} unreadable device record" in source
|
||||||
|
|
||||||
|
|
||||||
def test_today_capacity_configuration_syncs_offline_and_adopts_remote_plan():
|
def test_today_capacity_configuration_syncs_offline_and_adopts_remote_plan():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user