Merge pull request 'Restore background delivery after sign-in following outbox purge' (#426) from timmy/425-resume-outbox-after-purge into main
This commit is contained in:
commit
ad27db6846
|
|
@ -205,6 +205,7 @@ function createBackgroundIssueSync({
|
||||||
batch = work => work(), requestTimeoutMs = 15000,
|
batch = work => work(), requestTimeoutMs = 15000,
|
||||||
}) {
|
}) {
|
||||||
let purgeRequested = false;
|
let purgeRequested = false;
|
||||||
|
let activePurge = null;
|
||||||
let activeFlush = null;
|
let activeFlush = null;
|
||||||
const activeRequests = new Set();
|
const activeRequests = new Set();
|
||||||
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
||||||
|
|
@ -422,15 +423,26 @@ function createBackgroundIssueSync({
|
||||||
return activeFlush;
|
return activeFlush;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purge() {
|
function purge() {
|
||||||
|
if (activePurge) return activePurge;
|
||||||
purgeRequested = true;
|
purgeRequested = true;
|
||||||
activeRequests.forEach(controller => controller.abort());
|
activeRequests.forEach(controller => controller.abort());
|
||||||
if (activeFlush) await activeFlush.catch(() => {});
|
activePurge = (async () => {
|
||||||
await store.close?.();
|
if (activeFlush) await activeFlush.catch(() => {});
|
||||||
|
await store.close?.();
|
||||||
|
})();
|
||||||
|
return activePurge;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume() {
|
||||||
|
const pendingPurge = activePurge;
|
||||||
|
if (pendingPurge) await pendingPurge;
|
||||||
|
if (activePurge === pendingPurge) activePurge = null;
|
||||||
|
purgeRequested = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
flush, send, purge,
|
flush, send, purge, resume,
|
||||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||||
snapshot: () => store.snapshot(),
|
snapshot: () => store.snapshot(),
|
||||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||||
|
|
|
||||||
|
|
@ -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-v69';
|
const CACHE = 'stackchain-dashboard-shell-v70';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
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;
|
||||||
|
|
@ -217,7 +217,10 @@ self.addEventListener('sync', event => {
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('message', event => {
|
self.addEventListener('message', event => {
|
||||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||||
|
await issueSync.resume();
|
||||||
|
await flushAndNotify();
|
||||||
|
})());
|
||||||
if (event.data?.type === 'stackchain-session-lease') {
|
if (event.data?.type === 'stackchain-session-lease') {
|
||||||
event.waitUntil(storeOfflineLease(event.data.expiresAt));
|
event.waitUntil(storeOfflineLease(event.data.expiresAt));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -811,6 +811,51 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||||
assert "error" not in output
|
assert "error" not in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_after_purge_reopens_background_delivery_with_fresh_identity():
|
||||||
|
script = f"""
|
||||||
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
const state={{closed:0,identityLookups:0,mutations:0,keys:[]}};
|
||||||
|
let queued={{
|
||||||
|
id:'capture-after-login',ownerLogin:'timmy',status:'queued',repository:'stackchain/dashboard',
|
||||||
|
title:'Recovered capture',body:'Evidence',labelIds:[],operationId:'op-after-login',
|
||||||
|
}};
|
||||||
|
const store={{
|
||||||
|
close:async()=>{{state.closed+=1;}},
|
||||||
|
claimBatch:async login=>{{
|
||||||
|
if(queued?.ownerLogin!==login)return [];
|
||||||
|
const claimed=queued;queued=null;return [claimed];
|
||||||
|
}},
|
||||||
|
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||||
|
}};
|
||||||
|
const fetchJson=async(url,options={{}})=>{{
|
||||||
|
if(url==='api/v1/background-identity'){{state.identityLookups+=1;return {{login:'timmy'}};}}
|
||||||
|
state.mutations+=1;state.keys.push(options.headers['Idempotency-Key']);
|
||||||
|
return {{repository:'stackchain/dashboard',number:425,title:'Recovered capture'}};
|
||||||
|
}};
|
||||||
|
(async()=>{{
|
||||||
|
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||||
|
await sync.purge();
|
||||||
|
const blocked=await sync.flush();
|
||||||
|
await sync.resume();
|
||||||
|
const delivered=await sync.flush();
|
||||||
|
process.stdout.write(JSON.stringify({{state,blocked,delivered}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["state"] == {
|
||||||
|
"closed": 1,
|
||||||
|
"identityLookups": 1,
|
||||||
|
"mutations": 1,
|
||||||
|
"keys": ["op-after-login"],
|
||||||
|
}
|
||||||
|
assert output["blocked"]["login"] == ""
|
||||||
|
assert output["delivered"]["login"] == "timmy"
|
||||||
|
assert output["delivered"]["confirmed"] == [
|
||||||
|
{"repository": "stackchain/dashboard", "number": 425, "title": "Recovered capture"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
|
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|
|
||||||
|
|
@ -347,5 +347,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-v69" in source
|
assert "stackchain-dashboard-shell-v70" in source
|
||||||
assert "BASE + 'static/later-sync.js'" 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 { 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-v69" in worker
|
assert "stackchain-dashboard-shell-v70" 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-v69" in worker
|
assert "stackchain-dashboard-shell-v70" in worker
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v69" in source
|
assert "stackchain-dashboard-shell-v70" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
const listeners = {{}};
|
const listeners = {{}};
|
||||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||||
const storedResponses = new Map();
|
const storedResponses = new Map();
|
||||||
storedResponses.set(
|
storedResponses.set(
|
||||||
'https://forge.example/dashboard/__offline-session-lease',
|
'https://forge.example/dashboard/__offline-session-lease',
|
||||||
|
|
@ -47,8 +47,9 @@ const context = {{
|
||||||
}},
|
}},
|
||||||
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
||||||
__issueSync: {{
|
__issueSync: {{
|
||||||
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
|
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
|
||||||
purge: async () => {{ state.outboxPurges += 1; }},
|
purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }},
|
||||||
|
resume: async () => {{ state.backgroundResumes += 1; state.outboxLifecycle.push('resume'); }},
|
||||||
getReceiptPreference: async login => state.receiptLogin === login,
|
getReceiptPreference: async login => state.receiptLogin === login,
|
||||||
}},
|
}},
|
||||||
}},
|
}},
|
||||||
|
|
@ -121,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v69" in source
|
assert "stackchain-dashboard-shell-v70" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -130,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v69" in source
|
assert "stackchain-dashboard-shell-v70" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -138,7 +139,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
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-v69" in source
|
assert "stackchain-dashboard-shell-v70" 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
|
||||||
|
|
||||||
|
|
@ -146,14 +147,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-v69" in source
|
assert "stackchain-dashboard-shell-v70" 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-v69" in source
|
assert "stackchain-dashboard-shell-v70" 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
|
||||||
|
|
@ -162,21 +163,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-v69" in source
|
assert "stackchain-dashboard-shell-v70" 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-v69" in source
|
assert "stackchain-dashboard-shell-v70" 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-v69" in source
|
assert "stackchain-dashboard-shell-v70" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -240,6 +241,21 @@ def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||||
assert result["backgroundFlushes"] == 1
|
assert result["backgroundFlushes"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
const replies = [];
|
||||||
|
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:value=>replies.push(value)}]);
|
||||||
|
await dispatchMessage({type:'stackchain-resume-outbox'});
|
||||||
|
process.stdout.write(JSON.stringify({state,replies}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["replies"] == [{"ok": True}]
|
||||||
|
assert result["state"]["outboxLifecycle"] == ["purge", "resume", "flush"]
|
||||||
|
assert result["state"]["backgroundResumes"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
|
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
@ -342,7 +358,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v69" in source
|
assert "stackchain-dashboard-shell-v70" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,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-v69" in source
|
assert "stackchain-dashboard-shell-v70" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user