Compare commits
No commits in common. "4bc2881a4be9303e1820fd367f6cb488bd9a43f6" and "7ec3980713743342c7c37c790f5136ac6bea025e" have entirely different histories.
4bc2881a4b
...
7ec3980713
|
|
@ -33,10 +33,7 @@ the private content. Issue capture and authored mobile actions (issue
|
||||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||||
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
|
five-item Today plan syncs across the operator's devices. After a healthy, fully paginated
|
||||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
|
||||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
|
||||||
offline operations. After a healthy, fully paginated
|
|
||||||
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
||||||
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
||||||
leave the plan unchanged, and offline retirements replay after reconnect. When
|
leave the plan unchanged, and offline retirements replay after reconnect. When
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,6 @@
|
||||||
'Today sync unavailable · changes stay on this device.'));
|
'Today sync unavailable · changes stay on this device.'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
todaySync.startLifecycle({ window, document });
|
|
||||||
const laterWork = createLaterWork({
|
const laterWork = createLaterWork({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
|
|
|
||||||
|
|
@ -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-v47';
|
const CACHE = 'stackchain-dashboard-shell-v46';
|
||||||
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 = [
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,13 @@
|
||||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel }) {
|
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId }) {
|
||||||
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.';
|
||||||
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
|
|
||||||
let flushing = null;
|
let flushing = null;
|
||||||
let channel = null;
|
|
||||||
let channelKey = '';
|
|
||||||
|
|
||||||
function key() {
|
function key() {
|
||||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||||
return login ? prefix + encodeURIComponent(login) : '';
|
return login ? prefix + encodeURIComponent(login) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotKey() {
|
|
||||||
const storageKey = key();
|
|
||||||
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function savedRevision() {
|
|
||||||
try {
|
|
||||||
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
|
||||||
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
|
||||||
} catch (_error) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function adopt(plan, broadcast = true) {
|
|
||||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
|
||||||
if (plan.revision < savedRevision()) return false;
|
|
||||||
try {
|
|
||||||
storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
|
|
||||||
} catch (_error) {
|
|
||||||
// A storage quota failure must not prevent the current tab from using server truth.
|
|
||||||
}
|
|
||||||
onRemoteIds?.(plan.ids);
|
|
||||||
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureChannel() {
|
|
||||||
const storageKey = key();
|
|
||||||
if (!storageKey || channelKey === storageKey) return;
|
|
||||||
channel?.close?.();
|
|
||||||
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
|
||||||
? name => new globalThis.window.BroadcastChannel(name)
|
|
||||||
: null);
|
|
||||||
channelKey = storageKey;
|
|
||||||
channel = factory?.('stackchain-today-' + storageKey.slice(prefix.length)) || null;
|
|
||||||
channel?.addEventListener?.('message', event => {
|
|
||||||
if (key() === storageKey) adopt(event.data, false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function pending() {
|
function pending() {
|
||||||
const storageKey = key();
|
const storageKey = key();
|
||||||
if (!storageKey || !storage) return [];
|
if (!storageKey || !storage) return [];
|
||||||
|
|
@ -115,7 +71,6 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!key()) return false;
|
if (!key()) return false;
|
||||||
ensureChannel();
|
|
||||||
try {
|
try {
|
||||||
let plan = await fetchJson('api/v1/today');
|
let plan = await fetchJson('api/v1/today');
|
||||||
const operations = pending();
|
const operations = pending();
|
||||||
|
|
@ -130,14 +85,14 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||||
if (error?.status !== 409) throw error;
|
if (error?.status !== 409) throw error;
|
||||||
const rejected = pending();
|
const rejected = pending();
|
||||||
save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id));
|
save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id));
|
||||||
adopt(plan);
|
onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []);
|
||||||
onStatus?.('full');
|
onStatus?.('full');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const remaining = pending();
|
const remaining = pending();
|
||||||
if (remaining[0]?.operation_id === operation.operation_id) save(remaining.slice(1));
|
if (remaining[0]?.operation_id === operation.operation_id) save(remaining.slice(1));
|
||||||
}
|
}
|
||||||
adopt(plan);
|
onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []);
|
||||||
onStatus?.(pending().length ? 'pending' : 'saved');
|
onStatus?.(pending().length ? 'pending' : 'saved');
|
||||||
return true;
|
return true;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
|
@ -151,14 +106,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
|
||||||
return flushing;
|
return flushing;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
return { enqueue, migrate, flush, pending };
|
||||||
windowObject?.addEventListener?.('online', flush);
|
|
||||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
|
||||||
documentObject.hidden ? false : flush()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||||
|
|
|
||||||
|
|
@ -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-v47" in worker
|
assert "stackchain-dashboard-shell-v46" 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]))
|
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-v47" in worker
|
assert "stackchain-dashboard-shell-v46" in worker
|
||||||
|
|
|
||||||
|
|
@ -108,30 +108,23 @@ async function dispatchNotificationClick(route) {{
|
||||||
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-v47" in source
|
assert "stackchain-dashboard-shell-v46" 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
|
||||||
|
|
||||||
|
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
|
||||||
source = WORKER.read_text()
|
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v47" 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-v47" in source
|
assert "stackchain-dashboard-shell-v46" 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-v47" in source
|
assert "stackchain-dashboard-shell-v46" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,139 +171,3 @@ sync.enqueue('add', '6');
|
||||||
"ids": ["1", "2", "3", "4", "5"],
|
"ids": ["1", "2", "3", "4", "5"],
|
||||||
"status": "full",
|
"status": "full",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_tabs_ignore_an_older_response_after_a_newer_revision_is_broadcast():
|
|
||||||
script = f"""
|
|
||||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
|
||||||
const listeners = [];
|
|
||||||
const createChannel = () => ({{
|
|
||||||
addEventListener: (_name, listener) => listeners.push(listener),
|
|
||||||
postMessage: data => listeners.forEach(listener => listener({{data}})),
|
|
||||||
}});
|
|
||||||
let resolveOld;
|
|
||||||
let resolveNew;
|
|
||||||
const oldHistory = [];
|
|
||||||
const newHistory = [];
|
|
||||||
const oldTab = createTodaySync({{
|
|
||||||
storage, getLogin:()=> 'timmy', createChannel,
|
|
||||||
fetchJson:()=>new Promise(resolve=>{{resolveOld=resolve}}),
|
|
||||||
onRemoteIds:ids=>oldHistory.push(ids), onStatus:()=>{{}},
|
|
||||||
}});
|
|
||||||
const newTab = createTodaySync({{
|
|
||||||
storage, getLogin:()=> 'timmy', createChannel,
|
|
||||||
fetchJson:()=>new Promise(resolve=>{{resolveNew=resolve}}),
|
|
||||||
onRemoteIds:ids=>newHistory.push(ids), onStatus:()=>{{}},
|
|
||||||
}});
|
|
||||||
(async()=>{{
|
|
||||||
const oldFlush = oldTab.flush();
|
|
||||||
const newFlush = newTab.flush();
|
|
||||||
await Promise.resolve();
|
|
||||||
resolveNew({{revision:2,ids:['new']}});
|
|
||||||
await newFlush;
|
|
||||||
resolveOld({{revision:1,ids:['old']}});
|
|
||||||
await oldFlush;
|
|
||||||
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
result = json.loads(
|
|
||||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["oldHistory"][-1] == ["new"]
|
|
||||||
assert result["newHistory"][-1] == ["new"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_returning_online_replays_pending_operations():
|
|
||||||
script = f"""
|
|
||||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
|
||||||
const values = new Map();
|
|
||||||
const handlers = {{}};
|
|
||||||
const requests = [];
|
|
||||||
let revision = 1;
|
|
||||||
const sync = createTodaySync({{
|
|
||||||
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
|
||||||
getLogin:()=> 'timmy', createOperationId:()=> 'offline-add',
|
|
||||||
fetchJson:async (_url, options={{}})=>{{
|
|
||||||
requests.push(options.method || 'GET');
|
|
||||||
if (options.method) return {{revision:++revision,ids:['issue:r:1:']}};
|
|
||||||
return {{revision,ids:[]}};
|
|
||||||
}},
|
|
||||||
onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
|
||||||
}});
|
|
||||||
sync.enqueue('add', 'issue:r:1:');
|
|
||||||
sync.startLifecycle({{
|
|
||||||
window: {{addEventListener:(name, handler)=>{{handlers[name]=handler}}}},
|
|
||||||
document: {{addEventListener:()=>{{}}, hidden:false}},
|
|
||||||
}});
|
|
||||||
(async()=>{{
|
|
||||||
await handlers.online();
|
|
||||||
process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
result = json.loads(
|
|
||||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"requests": ["GET", "PATCH"], "pending": []}
|
|
||||||
|
|
||||||
|
|
||||||
def test_foregrounding_a_stale_tab_refreshes_the_saved_plan():
|
|
||||||
script = f"""
|
|
||||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
|
||||||
const handlers = {{}};
|
|
||||||
const adopted = [];
|
|
||||||
const documentObject = {{
|
|
||||||
hidden:true,
|
|
||||||
addEventListener:(name, handler)=>{{handlers[name]=handler}},
|
|
||||||
}};
|
|
||||||
const sync = createTodaySync({{
|
|
||||||
storage: {{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
|
|
||||||
getLogin:()=> 'timmy',
|
|
||||||
fetchJson:async ()=>({{revision:7,ids:['issue:r:7:']}}),
|
|
||||||
onRemoteIds:ids=>adopted.push(ids), onStatus:()=>{{}},
|
|
||||||
}});
|
|
||||||
sync.startLifecycle({{
|
|
||||||
window: {{addEventListener:()=>{{}}}}, document:documentObject,
|
|
||||||
}});
|
|
||||||
(async()=>{{
|
|
||||||
await handlers.visibilitychange();
|
|
||||||
documentObject.hidden = false;
|
|
||||||
await handlers.visibilitychange();
|
|
||||||
process.stdout.write(JSON.stringify(adopted));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
result = json.loads(
|
|
||||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == [["issue:r:7:"]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_old_account_broadcasts_are_ignored_after_identity_changes():
|
|
||||||
script = f"""
|
|
||||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
|
||||||
let login = 'timmy';
|
|
||||||
let listener;
|
|
||||||
const adopted = [];
|
|
||||||
const sync = createTodaySync({{
|
|
||||||
storage: {{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
|
|
||||||
getLogin:()=>login,
|
|
||||||
createChannel:()=>({{addEventListener:(_name, handler)=>{{listener=handler}},postMessage:()=>{{}},close:()=>{{}}}}),
|
|
||||||
fetchJson:async ()=>({{revision:1,ids:['timmy-plan']}}),
|
|
||||||
onRemoteIds:ids=>adopted.push(ids), onStatus:()=>{{}},
|
|
||||||
}});
|
|
||||||
(async()=>{{
|
|
||||||
await sync.flush();
|
|
||||||
login = 'alexander';
|
|
||||||
listener({{data:{{revision:2,ids:['timmy-secret']}}}});
|
|
||||||
process.stdout.write(JSON.stringify(adopted));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
result = json.loads(
|
|
||||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == [["timmy-plan"]]
|
|
||||||
|
|
|
||||||
|
|
@ -173,13 +173,6 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||||
assert "Another device filled Today · showing its saved plan." in html
|
assert "Another device filled Today · showing its saved plan." in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_dashboard_activates_today_lifecycle_convergence():
|
|
||||||
html = await dashboard()
|
|
||||||
|
|
||||||
assert "todaySync.startLifecycle({ window, document });" in html
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
|
async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user