fix: resume queued delivery after sign-in (#266)
This commit is contained in:
parent
65d1f9f194
commit
acd2d82d10
|
|
@ -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.
|
is stored for the confirmed account in the private background outbox database.
|
||||||
Successful deliveries produce privacy-safe receipts that open the created issue or
|
Successful deliveries produce privacy-safe receipts that open the created issue or
|
||||||
source conversation, while permanent validation failures open Drafts for recovery.
|
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.
|
Notification text never includes issue titles, comment bodies, or validation details.
|
||||||
The preference is off by default, unsupported or denied browsers retain foreground
|
The preference is off by default, unsupported or denied browsers retain foreground
|
||||||
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
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 };
|
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = Number(error?.status || 0);
|
const status = Number(error?.status || 0);
|
||||||
|
if (status === 401) {
|
||||||
|
await store.release(item.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (status >= 400 && status < 500) {
|
if (status >= 400 && status < 500) {
|
||||||
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
||||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||||
|
|
|
||||||
|
|
@ -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-v17';
|
const CACHE = 'stackchain-dashboard-shell-v18';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
|
|
@ -96,6 +96,10 @@ self.addEventListener('sync', event => {
|
||||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
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 => {
|
self.addEventListener('notificationclick', event => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const route = String(event.notification.data?.route || '');
|
const route = String(event.notification.data?.route || '');
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
sessionStorage: root.sessionStorage,
|
sessionStorage: root.sessionStorage,
|
||||||
indexedDB: root.indexedDB,
|
indexedDB: root.indexedDB,
|
||||||
caches: root.caches,
|
caches: root.caches,
|
||||||
|
serviceWorker: root.navigator?.serviceWorker,
|
||||||
location: root.location,
|
location: root.location,
|
||||||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||||
});
|
});
|
||||||
|
|
@ -19,13 +20,14 @@
|
||||||
const attach = () => {
|
const attach = () => {
|
||||||
const button = root.document.getElementById('sign-out');
|
const button = root.document.getElementById('sign-out');
|
||||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||||
|
boundary.resumeQueuedWork();
|
||||||
};
|
};
|
||||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||||
else attach();
|
else attach();
|
||||||
root.stackchainSession = boundary;
|
root.stackchainSession = boundary;
|
||||||
}
|
}
|
||||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
})(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 = () => {},
|
onExpired = () => {},
|
||||||
}) {
|
}) {
|
||||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
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
|
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():
|
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
|
||||||
def run_session_scenario(scenario: str) -> dict:
|
def run_session_scenario(scenario: str) -> dict:
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
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 = {{
|
const storage = {{
|
||||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||||
get length() {{ return this.values.size; }},
|
get length() {{ return this.values.size; }},
|
||||||
|
|
@ -33,6 +33,7 @@ const boundary = createSessionBoundary({{
|
||||||
sessionStorage: storage,
|
sessionStorage: storage,
|
||||||
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
|
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); }} }},
|
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; }} }},
|
location: {{ assign: value => {{ state.assigned = value; }} }},
|
||||||
}});
|
}});
|
||||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
(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"
|
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
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,11 @@ async function dispatchSync(tag) {{
|
||||||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||||
if (pending) await pending;
|
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) {{
|
async function dispatchNotificationClick(route) {{
|
||||||
let pending;
|
let pending;
|
||||||
listeners.notificationclick({{
|
listeners.notificationclick({{
|
||||||
|
|
@ -86,10 +91,10 @@ async function dispatchNotificationClick(route) {{
|
||||||
return json.loads(completed.stdout)
|
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()
|
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():
|
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
|
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():
|
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user