Resume queued delivery after operator sign-in #267
|
|
@ -152,6 +152,10 @@ work finishes**; permission is requested only from that user gesture and the cho
|
|||
is stored for the confirmed account in the private background outbox database.
|
||||
Successful deliveries produce privacy-safe receipts that open the created issue or
|
||||
source conversation, while permanent validation failures open Drafts for recovery.
|
||||
Authentication expiry during a worker mutation is recoverable: the worker releases the
|
||||
claim without creating an Attention receipt, stops that drain, and keeps the original
|
||||
idempotency key. Loading the authenticated dashboard after signing in asks the worker
|
||||
to resume queued delivery automatically.
|
||||
Notification text never includes issue titles, comment bodies, or validation details.
|
||||
The preference is off by default, unsupported or denied browsers retain foreground
|
||||
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
||||
|
|
|
|||
|
|
@ -224,6 +224,10 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
|||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status === 401) {
|
||||
await store.release(item.id);
|
||||
throw error;
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v17';
|
||||
const CACHE = 'stackchain-dashboard-shell-v18';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const SHELL = [
|
||||
BASE,
|
||||
|
|
@ -96,6 +96,10 @@ self.addEventListener('sync', event => {
|
|||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
const route = String(event.notification.data?.route || '');
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
sessionStorage: root.sessionStorage,
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
location: root.location,
|
||||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||
});
|
||||
|
|
@ -19,13 +20,14 @@
|
|||
const attach = () => {
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
boundary.resumeQueuedWork();
|
||||
};
|
||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||
else attach();
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, location,
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location,
|
||||
onExpired = () => {},
|
||||
}) {
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
|
@ -88,5 +90,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
return { fetch: sessionFetch, signOut, clearPrivateDeviceData };
|
||||
async function resumeQueuedWork() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
|
||||
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
|
||||
}
|
||||
|
||||
return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -260,6 +260,44 @@ const fetchJson = async url => {{
|
|||
assert output["result"]["blocked"] == 1
|
||||
|
||||
|
||||
def test_session_expiry_during_delivery_releases_claim_without_attention():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const queued = [
|
||||
{{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}},
|
||||
{{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}},
|
||||
];
|
||||
const state = {{released:[],failed:[],mutationKeys:[]}};
|
||||
const store = {{
|
||||
claimNext: async () => queued.shift() || null,
|
||||
release: async id => state.released.push(id),
|
||||
fail: async (id,message) => state.failed.push({{id,message}}),
|
||||
countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
state.mutationKeys.push(options.headers['Idempotency-Key']);
|
||||
const error = new Error('Authentication required'); error.status = 401; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
let error = null;
|
||||
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
|
||||
catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"state": {
|
||||
"released": ["capture-auth"],
|
||||
"failed": [],
|
||||
"mutationKeys": ["stable-key"],
|
||||
},
|
||||
"error": {"message": "Authentication required", "status": 401},
|
||||
}
|
||||
|
||||
|
||||
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
|
|||
def run_session_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '' }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [] }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
|
|
@ -33,6 +33,7 @@ const boundary = createSessionBoundary({{
|
|||
sessionStorage: storage,
|
||||
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
|
||||
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
|
||||
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
|
||||
location: {{ assign: value => {{ state.assigned = value; }} }},
|
||||
}});
|
||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
|
|
@ -72,6 +73,17 @@ process.stdout.write(JSON.stringify(state));
|
|||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.resumeQueuedWork();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
|
|
@ -68,6 +68,11 @@ async function dispatchSync(tag) {{
|
|||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchMessage(data) {{
|
||||
let pending;
|
||||
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchNotificationClick(route) {{
|
||||
let pending;
|
||||
listeners.notificationclick({{
|
||||
|
|
@ -86,10 +91,10 @@ async function dispatchNotificationClick(route) {{
|
|||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_background_delivery_receipts_ship_in_a_new_shell_cache():
|
||||
def test_session_resume_flow_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v17" in source
|
||||
assert "stackchain-dashboard-shell-v18" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
|
|
@ -104,6 +109,17 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
|
|||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-resume-outbox'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user