diff --git a/README.md b/README.md index 72e97a6..8da7b14 100644 --- a/README.md +++ b/README.md @@ -111,15 +111,21 @@ data** deletes the snapshot, and opting out deletes it automatically. API responses and mutations are never cached by the service worker. New issue captures, issue comments, pull-request comments, and unread-update replies use bounded local -outboxes when connectivity or a retryable server failure prevents delivery. Drafts -shows queued and needs-attention messages with explicit send/discard controls; reconnect -flushes messages sequentially with their original idempotency keys. Tabs coordinate each -queue through the browser lock manager (with an expiring local lease fallback), so an -installed PWA and a browser tab cannot submit the same operation concurrently. Queue -changes are broadcast to other tabs and refresh Drafts without polling; an abandoned -lease can be reclaimed after expiry. State-sensitive actions such as reviews, merges, -closures, labels, milestones, and assignments are never queued. Service-worker upgrades -are atomic and remove only older +outboxes when connectivity or a retryable server failure prevents delivery. Issue +captures are also mirrored into IndexedDB and registered with Background Sync, so a +supporting installed browser can deliver them after every dashboard client has closed. +The worker verifies the current Gitea login, shares an atomic delivery claim with the +foreground path, and preserves the original idempotency key. Browsers without +IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows +queued and needs-attention messages with explicit send/discard controls; reopening the +dashboard reconciles worker completions and permanent failures into the visible outbox. +Foreground delivery flushes messages sequentially with their original idempotency keys. +Tabs coordinate each queue through the browser lock manager (with an expiring local +lease fallback), so an installed PWA and a browser tab cannot submit the same operation +concurrently. Queue changes are broadcast to other tabs and refresh Drafts without +polling; an abandoned lease can be reclaimed after expiry. State-sensitive actions such +as reviews, merges, closures, labels, milestones, and assignments are never queued. +Service-worker upgrades are atomic and remove only older `stackchain-dashboard-*` caches, preserving unrelated caches on the same origin. Run the test suite with: diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js new file mode 100644 index 0000000..f72f7b1 --- /dev/null +++ b/frontend/background-issue-sync.js @@ -0,0 +1,206 @@ +function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-outbox-v1') { + let databasePromise; + function database() { + if (!databasePromise) databasePromise = new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains('issues')) { + request.result.createObjectStore('issues', { keyPath: 'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + return databasePromise; + } + const requested = request => new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + return async work => { + const db = await database(); + return new Promise((resolve, reject) => { + const transaction = db.transaction('issues', 'readwrite'); + const objectStore = transaction.objectStore('issues'); + let result; + let failed = false; + transaction.oncomplete = () => failed ? undefined : resolve(result); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted')); + Promise.resolve(work({ + getAll: () => requested(objectStore.getAll()), + put: value => requested(objectStore.put(value)), + delete: id => requested(objectStore.delete(id)), + })).then(value => { result = value; }).catch(error => { + failed = true; + try { transaction.abort(); } catch (_abortError) { reject(error); } + reject(error); + }); + }); + }; +} + +function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) { + const transact = transaction || createIndexedDbTransaction(indexedDB); + + async function reconcile(items) { + return transact(async records => { + const existing = await records.getAll(); + const incoming = new Map(items.map(item => [item.id, { ...item }])); + for (const current of existing) { + const replacement = incoming.get(current.id); + if (!replacement) { + if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) { + await records.delete(current.id); + } + continue; + } + if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) || + (current.status === 'attention' && replacement.status === 'attention') || + current.status === 'sent') { + incoming.set(current.id, current); + } + } + for (const item of incoming.values()) await records.put(item); + }); + } + + async function claimNext(ownerLogin) { + return transact(async records => { + const timestamp = Number(now()); + const items = await records.getAll(); + const item = items.find(candidate => candidate.ownerLogin === ownerLogin && + (candidate.status === 'queued' || candidate.status === 'sending') && + (candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp)); + if (!item) return null; + const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs }; + await records.put(claimed); + return claimed; + }); + } + + async function claim(id, ownerLogin) { + return transact(async records => { + const timestamp = Number(now()); + const item = (await records.getAll()).find(candidate => candidate.id === id); + if (!item || item.ownerLogin !== ownerLogin || + !['queued', 'sending'].includes(item.status) || + (item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null; + const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs }; + await records.put(claimed); + return claimed; + }); + } + + async function upsert(item) { + return transact(async records => { + const current = (await records.getAll()).find(candidate => candidate.id === item.id); + if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) || + (current.status === 'attention' && item.status === 'attention') || + current.status === 'sent')) return current; + await records.put({ ...item }); + return item; + }); + } + + async function update(id, transform) { + return transact(async records => { + const item = (await records.getAll()).find(candidate => candidate.id === id); + if (item) await records.put(transform(item)); + }); + } + + return { + reconcile, + upsert, + claim, + claimNext, + complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })), + release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })), + fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })), + snapshot: () => transact(records => records.getAll()), + countBlocked: ownerLogin => transact(async records => + (await records.getAll()).filter(item => item.status !== 'sent' && item.ownerLogin !== ownerLogin).length), + }; +} + +function createBackgroundIssueSync({ store, fetchJson, base = '' }) { + function issueRequest(item) { + const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/'); + return { + url: base + 'api/v1/repos/' + repository + '/issues', + options: { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': item.operationId, + }, + body: JSON.stringify({ + title: item.title, + body: item.body, + label_ids: item.labelIds, + ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), + ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), + }), + }, + }; + } + + async function deliver(item) { + const request = issueRequest(item); + try { + const issue = await fetchJson(request.url, request.options); + await store.complete(item.id); + return { issue }; + } catch (error) { + const status = Number(error?.status || 0); + if (status >= 400 && status < 500) { + await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240)); + return { attention: true, error }; + } + await store.release(item.id); + throw error; + } + } + + async function send(item, currentLogin) { + if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true }; + await store.upsert(item); + const claimed = await store.claim(item.id, currentLogin); + if (!claimed) return { busy: true }; + return deliver(claimed); + } + + async function flush() { + const identity = await fetchJson(base + 'api/v1/background-identity', { + headers: { Accept: 'application/json' }, cache: 'no-store', + }); + const login = String(identity?.login || '').trim(); + const confirmed = []; + let attention = 0; + if (!login) return { confirmed, blocked: 0, attention }; + while (true) { + const item = await store.claimNext(login); + if (!item) break; + const result = await deliver(item); + if (result.issue) confirmed.push(result.issue); + if (result.attention) attention += 1; + } + const blocked = store.countBlocked ? await store.countBlocked(login) : 0; + return { confirmed, blocked, attention }; + } + + return { + flush, send, + reconcile: items => store.reconcile(items), + snapshot: () => store.snapshot(), + }; +} + +createBackgroundIssueSync.createIssueSyncStore = createIssueSyncStore; +if (typeof module !== 'undefined' && module.exports) module.exports = createBackgroundIssueSync; +if (typeof globalThis !== 'undefined') { + globalThis.createBackgroundIssueSync = createBackgroundIssueSync; + globalThis.createIssueSyncStore = createIssueSyncStore; +} diff --git a/frontend/index.html b/frontend/index.html index f39bc8c..5d536ff 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -741,6 +741,7 @@ textarea { resize: vertical; min-height: 120px; } + @@ -894,11 +895,28 @@ textarea { resize: vertical; min-height: 120px; } loadMilestones: item => issueController.loadMilestones(item), }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); + let backgroundIssueSync = null; + if ('indexedDB' in window) { + const backgroundIssueStore = createIssueSyncStore(); + backgroundIssueSync = createBackgroundIssueSync({ + store: backgroundIssueStore, fetchJson: fetchReviewJson, + }); + backgroundIssueSync.requestSync = async () => { + if (!('serviceWorker' in navigator)) return; + const registration = await navigator.serviceWorker.ready; + if (registration.sync) await registration.sync.register('stackchain-issue-outbox-v1'); + }; + } const outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); const issueOutbox = createIssueOutbox({ storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, + backgroundSync: backgroundIssueSync, getOwnerLogin: () => confirmedOwnerLogin, }); + if (backgroundIssueSync) { + backgroundIssueSync.snapshot().then(records => issueOutbox.reconcileBackground(records)) + .catch(() => { /* The foreground localStorage outbox remains available. */ }); + } const authoredOutbox = createAuthoredOutbox({ storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, getOwnerLogin: () => confirmedOwnerLogin, diff --git a/frontend/issue-outbox.js b/frontend/issue-outbox.js index 3ce7483..45a803c 100644 --- a/frontend/issue-outbox.js +++ b/frontend/issue-outbox.js @@ -1,4 +1,4 @@ -function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) { +function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) { const storageKey = 'stackchain.issue-outbox.v1'; const operationId = createOperationId || (() => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) @@ -16,6 +16,11 @@ function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () function write(items) { storage?.setItem(storageKey, JSON.stringify({ version: 2, items })); coordinator?.notify('issue'); + if (backgroundSync?.reconcile) { + Promise.resolve(backgroundSync.reconcile(items)) + .then(() => items.length ? backgroundSync.requestSync?.() : undefined) + .catch(() => { /* Foreground reconnect remains the compatibility fallback. */ }); + } } function enqueue(draft) { @@ -74,18 +79,30 @@ function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () const repository = item.repository.split('/').map(encodeURIComponent).join('/'); const request = (async () => { try { - const issue = await fetchJson('api/v1/repos/' + repository + '/issues', { - method: 'POST', - headers: { - Accept: 'application/json', 'Content-Type': 'application/json', - 'Idempotency-Key': item.operationId, - }, - body: JSON.stringify({ - title: item.title, body: item.body, label_ids: item.labelIds, - ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), - ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), - }), - }); + let issue; + if (backgroundSync?.send) { + const delivery = await backgroundSync.send(item, currentLogin); + if (delivery.attention) { + const error = delivery.error || new Error('Issue needs attention'); + error.status = Number(error.status || 422); + throw error; + } + issue = delivery.issue; + } else { + issue = await fetchJson('api/v1/repos/' + repository + '/issues', { + method: 'POST', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'Idempotency-Key': item.operationId, + }, + body: JSON.stringify({ + title: item.title, body: item.body, label_ids: item.labelIds, + ...(item.milestoneId ? { milestone_id: item.milestoneId } : {}), + ...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}), + }), + }); + } + if (!issue) return { blocked: true }; discard(item.id); return { issue }; } catch (error) { @@ -139,7 +156,21 @@ function createIssueOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () return retryItem(id, currentLogin); } - return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) }; + function reconcileBackground(records) { + const statuses = new Map((records || []).map(item => [item.id, item])); + const items = read().flatMap(item => { + const background = statuses.get(item.id); + if (background?.status === 'sent') return []; + if (background?.status === 'attention') return [{ + ...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240), + }]; + return [item]; + }); + write(items); + return items; + } + + return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) }; } if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 57774f9..7222766 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,7 @@ -const CACHE = 'stackchain-dashboard-shell-v11'; -const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const BASE = new URL('./', self.location.href).pathname; +importScripts(BASE + 'static/background-issue-sync.js'); +const CACHE = 'stackchain-dashboard-shell-v12'; +const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, BASE + 'manifest.webmanifest', @@ -25,8 +26,24 @@ const SHELL = [ BASE + 'static/work-route.js', BASE + 'static/context-poller.js', BASE + 'static/mobile-task-dock.js', + BASE + 'static/background-issue-sync.js', ]; +async function fetchJson(url, options) { + const response = await fetch(new URL(url, self.location.origin), options); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.'); + error.status = response.status; + throw error; + } + return payload; +} + +const issueSync = self.__issueSync || createBackgroundIssueSync({ + store: createIssueSyncStore(), fetchJson, base: BASE, +}); + self.addEventListener('install', event => { event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting())); }); @@ -38,6 +55,10 @@ self.addEventListener('activate', event => { )).then(() => self.clients.claim())); }); +self.addEventListener('sync', event => { + if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(issueSync.flush()); +}); + self.addEventListener('fetch', event => { const request = event.request; if (request.method !== 'GET' || request.url.includes('/api/')) return; diff --git a/src/main.py b/src/main.py index 061e09c..3e236bc 100644 --- a/src/main.py +++ b/src/main.py @@ -426,7 +426,7 @@ app.include_router(frontend_router) @app.middleware("http") async def prevent_live_api_caching(request, call_next): response = await call_next(request) - if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} or request.url.path.startswith("/api/v1/work/") or ( + if request.url.path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} or request.url.path.startswith("/api/v1/work/") or ( request.url.path.startswith("/api/v1/repos/") and request.url.path.endswith("/review") ) or request.url.path.startswith("/api/v1/notifications") or ( @@ -533,6 +533,23 @@ async def context() -> JSONResponse: return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data)) +@app.get("/api/v1/background-identity") +async def background_identity() -> JSONResponse: + """Return only the account key required to safely drain a browser outbox.""" + try: + user = await current_user() + login = user.get("login") if isinstance(user, dict) else None + if not isinstance(login, str) or not login.strip(): + raise ValueError("Gitea user response did not include a login") + except Exception: + return JSONResponse( + {"error": "Gitea identity is temporarily unavailable"}, + status_code=503, + headers={"Retry-After": "1"}, + ) + return JSONResponse({"login": login}) + + @app.get("/api/v1/search") async def global_search( q: str = Query(min_length=2, max_length=100), diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py new file mode 100644 index 0000000..ccb3b1e --- /dev/null +++ b/tests/test_background_issue_sync.py @@ -0,0 +1,315 @@ +import json +import subprocess +from pathlib import Path + +import httpx +import pytest + +from src import main +from src.views import dashboard + + +SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js" + + +def run_node(script: str) -> dict: + completed = subprocess.run( + ["node", "-e", script], capture_output=True, check=True, text=True + ) + return json.loads(completed.stdout) + + +def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const item = {{ + id:'capture-1', operationId:'capture-1', ownerLogin:'timmy', status:'queued', + repository:'stackchain/api', title:'Offline report', body:'Full context', + labelIds:[3], milestoneId:4, dueDate:'2026-08-09', queuedAt:100, +}}; +const state = {{item, completed:[], released:[], failed:[], calls:[]}}; +const store = {{ + claimNext: async owner => state.item && state.item.ownerLogin === owner ? {{...state.item}} : null, + complete: async id => {{ state.completed.push(id); state.item = null; }}, + release: async id => state.released.push(id), + fail: async (id, message) => state.failed.push({{id,message}}), +}}; +const fetchJson = async (url, options = {{}}) => {{ + state.calls.push({{url,options}}); + if (url === 'api/v1/background-identity') return {{login:'timmy'}}; + return {{repository:'stackchain/api',number:251,title:'Offline report'}}; +}}; +(async () => {{ + const sync = createBackgroundIssueSync({{store,fetchJson}}); + const result = await sync.flush(); + process.stdout.write(JSON.stringify({{state,result}})); +}})(); +""" + output = run_node(script) + + assert output["result"]["confirmed"][0]["number"] == 251 + assert output["state"]["completed"] == ["capture-1"] + assert output["state"]["released"] == [] + assert output["state"]["failed"] == [] + assert output["state"]["calls"][0]["url"] == "api/v1/background-identity" + mutation = output["state"]["calls"][1] + assert mutation["url"] == "api/v1/repos/stackchain/api/issues" + assert mutation["options"]["headers"]["Idempotency-Key"] == "capture-1" + assert json.loads(mutation["options"]["body"]) == { + "title": "Offline report", + "body": "Full context", + "label_ids": [3], + "milestone_id": 4, + "due_date": "2026-08-09T23:59:59Z", + } + + +def test_closed_app_sync_leaves_another_accounts_issue_queued(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const state = {{owners:[],mutations:0}}; +const store = {{ + claimNext: async owner => {{ state.owners.push(owner); return null; }}, + countBlocked: async owner => owner === 'alexander' ? 1 : 0, + complete: async () => {{}}, +}}; +const fetchJson = async url => {{ + if (url === 'api/v1/background-identity') return {{login:'alexander'}}; + state.mutations += 1; +}}; +(async () => {{ + const result = await createBackgroundIssueSync({{store,fetchJson}}).flush(); + process.stdout.write(JSON.stringify({{state,result}})); +}})(); +""" + output = run_node(script) + + assert output["state"] == {"owners": ["alexander"], "mutations": 0} + assert output["result"]["confirmed"] == [] + assert output["result"]["blocked"] == 1 + + +def test_transient_delivery_failure_releases_claim_and_requests_another_sync(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +let item = {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}}; +const state = {{released:[],completed:[]}}; +const store = {{ + claimNext: async () => item ? (item = null, {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}}) : null, + release: async id => state.released.push(id), + complete: async id => state.completed.push(id), +}}; +const fetchJson = async url => {{ + if (url === 'api/v1/background-identity') return {{login:'timmy'}}; + const error = new Error('Gitea unavailable'); error.status = 503; throw error; +}}; +(async () => {{ + let error = null; + try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }} + catch (caught) {{ error = caught.message; }} + process.stdout.write(JSON.stringify({{state,error}})); +}})(); +""" + output = run_node(script) + + assert output["state"]["released"] == ["capture-2"] + assert output["state"]["completed"] == [] + assert output["error"] == "Gitea unavailable" + + +def test_permanent_delivery_failure_marks_issue_for_foreground_attention(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +let claimed = false; +const state = {{failed:[],released:[]}}; +const store = {{ + claimNext: async () => claimed ? null : (claimed = true, {{id:'capture-3',operationId:'capture-3',ownerLogin:'timmy',repository:'o/r',title:'Invalid',body:'',labelIds:[]}}), + fail: async (id, message) => state.failed.push({{id,message}}), + release: async id => state.released.push(id), + countBlocked: async () => 0, +}}; +const fetchJson = async url => {{ + if (url === 'api/v1/background-identity') return {{login:'timmy'}}; + const error = new Error('Title is invalid'); error.status = 422; throw error; +}}; +(async () => {{ + const result = await createBackgroundIssueSync({{store,fetchJson}}).flush(); + process.stdout.write(JSON.stringify({{state,result}})); +}})(); +""" + output = run_node(script) + + assert output["state"]["failed"] == [ + {"id": "capture-3", "message": "Title is invalid"} + ] + assert output["state"]["released"] == [] + assert output["result"]["attention"] == 1 + + +def test_issue_store_atomically_grants_one_delivery_claim(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const records = new Map(); +let tail = Promise.resolve(); +const transaction = work => {{ + const run = tail.then(() => work({{ + getAll: async () => [...records.values()].map(value => ({{...value}})), + put: async value => records.set(value.id, {{...value}}), + delete: async id => records.delete(id), + }})); + tail = run.catch(() => {{}}); + return run; +}}; +(async () => {{ + const store = createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>1000,claimMs:5000}}); + await store.reconcile([{{id:'same',operationId:'same',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Once',labelIds:[]}}]); + const claims = await Promise.all([store.claimNext('timmy'),store.claimNext('timmy')]); + process.stdout.write(JSON.stringify({{claims,records:[...records.values()]}})); +}})(); +""" + output = run_node(script) + + assert sum(claim is not None for claim in output["claims"]) == 1 + assert output["records"][0]["status"] == "sending" + assert output["records"][0]["claimUntil"] == 6000 + + +def test_foreground_and_worker_race_still_posts_one_issue(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const records = new Map(); let tail = Promise.resolve(); +const transaction = work => {{ + const run = tail.then(() => work({{ + getAll:async()=>[...records.values()].map(value=>({{...value}})), + put:async value=>records.set(value.id,{{...value}}), delete:async id=>records.delete(id), + }})); + tail=run.catch(()=>{{}}); return run; +}}; +const item={{id:'race',operationId:'race',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Exactly once',body:'',labelIds:[]}}; +let mutations=0; +const fetchJson=async url=>{{ + if(url==='api/v1/background-identity') return {{login:'timmy'}}; + mutations+=1; return {{number:77}}; +}}; +(async()=>{{ + const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}}); + await store.reconcile([item]); + const sync=createBackgroundIssueSync({{store,fetchJson}}); + const [foreground,worker]=await Promise.all([sync.send(item,'timmy'),sync.flush()]); + process.stdout.write(JSON.stringify({{foreground,worker,mutations,remaining:[...records.values()]}})); +}})(); +""" + output = run_node(script) + + assert output["mutations"] == 1 + assert len(output["remaining"]) == 1 + assert output["remaining"][0]["status"] == "sent" + delivered = int(bool(output["foreground"].get("issue"))) + len( + output["worker"]["confirmed"] + ) + assert delivered == 1 + + +def test_foreground_send_upserts_before_claim_when_mirror_is_still_pending(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +let record = null; const state={{upserts:0,mutations:0}}; +const store={{ + upsert:async item=>{{state.upserts+=1;record={{...item}};}}, + claim:async(id,owner)=>record?.id===id&&record?.ownerLogin===owner?{{...record}}:null, + complete:async()=>{{record=null;}}, release:async()=>{{}}, fail:async()=>{{}}, +}}; +const fetchJson=async()=>{{state.mutations+=1;return {{number:88}};}}; +(async()=>{{ + const sync=createBackgroundIssueSync({{store,fetchJson}}); + const result=await sync.send({{id:'early',operationId:'early',ownerLogin:'timmy',repository:'o/r',title:'Fast',body:'',labelIds:[]}},'timmy'); + process.stdout.write(JSON.stringify({{state,result,record}})); +}})(); +""" + output = run_node(script) + + assert output["state"] == {"upserts": 1, "mutations": 1} + assert output["result"]["issue"]["number"] == 88 + assert output["record"] is None + + +def test_completed_delivery_leaves_non_replayable_tombstone_for_next_page(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const records=new Map();let tail=Promise.resolve(); +const transaction=work=>{{const run=tail.then(()=>work({{ + getAll:async()=>[...records.values()].map(value=>({{...value}})), + put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id), +}}));tail=run.catch(()=>{{}});return run;}}; +(async()=>{{ + const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}}); + await store.reconcile([{{id:'done',ownerLogin:'timmy',status:'queued'}}]); + await store.claimNext('timmy'); + await store.complete('done'); + const replay=await store.claimNext('timmy'); + const snapshot=await store.snapshot(); + process.stdout.write(JSON.stringify({{replay,snapshot}})); +}})(); +""" + output = run_node(script) + + assert output["replay"] is None + assert output["snapshot"] == [ + {"id": "done", "ownerLogin": "timmy", "status": "sent", "claimUntil": 0} + ] + + +def test_user_edit_resets_worker_attention_item_for_retry(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +const records=new Map([['edit',{{id:'edit',ownerLogin:'timmy',status:'attention',title:'Bad',error:'Invalid'}}]]); +let tail=Promise.resolve(); +const transaction=work=>{{const run=tail.then(()=>work({{ + getAll:async()=>[...records.values()].map(value=>({{...value}})), + put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id), +}}));tail=run.catch(()=>{{}});return run;}}; +(async()=>{{ + const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}}); + await store.reconcile([{{id:'edit',ownerLogin:'timmy',status:'queued',title:'Fixed'}}]); + const claimed=await store.claimNext('timmy'); + process.stdout.write(JSON.stringify(claimed)); +}})(); +""" + output = run_node(script) + + assert output["title"] == "Fixed" + assert output["status"] == "sending" + assert "error" not in output + + +@pytest.mark.anyio +async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch): + calls = 0 + + async def user(): + nonlocal calls + calls += 1 + return {"id": 7, "login": "timmy", "email": "private@example.com"} + + monkeypatch.setattr(main, "current_user", user) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/background-identity") + + assert response.status_code == 200 + assert response.json() == {"login": "timmy"} + assert response.headers["cache-control"] == "no-store" + assert calls == 1 + + +@pytest.mark.anyio +async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback(): + html = await dashboard() + + assert '' in html + assert "const backgroundIssueStore = createIssueSyncStore();" in html + assert "createBackgroundIssueSync({" in html + assert "backgroundSync: backgroundIssueSync" in html + assert "registration.sync.register('stackchain-issue-outbox-v1')" in html + assert "backgroundIssueSync.snapshot().then(records => issueOutbox.reconcileBackground(records))" in html + assert "if ('indexedDB' in window)" in html diff --git a/tests/test_issue_outbox.py b/tests/test_issue_outbox.py index 518f64e..9c2d1c2 100644 --- a/tests/test_issue_outbox.py +++ b/tests/test_issue_outbox.py @@ -163,6 +163,100 @@ Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.strin assert sum(len(result["confirmed"]) for result in output["results"]) >= 1 +def test_issue_outbox_mirrors_queue_and_registers_background_sync(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const state = {{snapshots:[],syncs:0}}; +const backgroundSync = {{ + reconcile: async items => state.snapshots.push(items.map(item => ({{...item}}))), + requestSync: async () => {{ state.syncs += 1; }}, +}}; +const outbox = createIssueOutbox({{ + storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'background-1', +}}); +outbox.enqueue({{repository:'stackchain/api',title:'Close the app',body:'Still deliver'}}); +setTimeout(() => process.stdout.write(JSON.stringify(state)), 0); +""" + output = run_node(script) + + assert output["snapshots"][0][0]["operationId"] == "background-1" + assert output["snapshots"][0][0]["ownerLogin"] == "timmy" + assert output["syncs"] == 1 + + +def test_foreground_delivery_uses_same_atomic_background_claim(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const state = {{backgroundCalls:0,directCalls:0}}; +const backgroundSync = {{ + reconcile: async () => {{}}, requestSync: async () => {{}}, + send: async (item, login) => {{ state.backgroundCalls += 1; return {{issue:{{number:9}}, claimed:item.id + ':' + login}}; }}, +}}; +const outbox = createIssueOutbox({{ + storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'shared-claim', + fetchJson:async () => {{ state.directCalls += 1; return {{number:10}}; }}, +}}); +outbox.enqueue({{repository:'o/r',title:'One owner'}}); +outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{state,result,remaining:outbox.list()}}))); +""" + output = run_node(script) + + assert output["state"] == {"backgroundCalls": 1, "directCalls": 0} + assert output["result"]["confirmed"][0]["number"] == 9 + assert output["remaining"] == [] + + +def test_foreground_background_delivery_surfaces_permanent_failure_immediately(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const error=new Error('Title is invalid');error.status=422; +const backgroundSync={{reconcile:async()=>{{}},requestSync:async()=>{{}},send:async()=>({{attention:true,error}})}}; +const outbox=createIssueOutbox({{storage,backgroundSync,getOwnerLogin:()=>'timmy',createOperationId:()=>'invalid'}}); +outbox.enqueue({{repository:'o/r',title:'Bad'}}); +outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,items:outbox.list()}}))); +""" + output = run_node(script) + + assert output["items"][0]["status"] == "attention" + assert output["items"][0]["error"] == "Title is invalid" + assert output["result"]["confirmed"] == [] + + +def test_page_reconciles_worker_success_and_attention_into_visible_outbox(): + script = f""" +const createIssueOutbox = require({json.dumps(str(OUTBOX))}); +const values=new Map(); +const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:2,items:[ + {{id:'sent',operationId:'sent',ownerLogin:'timmy',status:'queued',title:'Done'}}, + {{id:'bad',operationId:'bad',ownerLogin:'timmy',status:'queued',title:'Fix me'}}, +]}})); +const outbox=createIssueOutbox({{storage}}); +outbox.reconcileBackground([ + {{id:'sent',status:'sent'}}, + {{id:'bad',status:'attention',error:'Title is invalid'}}, +]); +process.stdout.write(JSON.stringify(outbox.list())); +""" + output = run_node(script) + + assert output == [ + { + "id": "bad", + "operationId": "bad", + "ownerLogin": "timmy", + "status": "attention", + "title": "Fix me", + "error": "Title is invalid", + } + ] + + @pytest.mark.anyio async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions(): html = await dashboard() diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 264207f..0c44276 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], failFetch: false, fetchStatus: 200, cachedBody: null }}; +const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }}; const cache = {{ addAll: async urls => {{ state.added = urls; }}, match: async request => state.cachedBody === null ? null : new Response(state.cachedBody), @@ -27,7 +27,9 @@ const context = {{ addEventListener: (name, handler) => {{ listeners[name] = handler; }}, skipWaiting: async () => {{ state.skipped = true; }}, clients: {{ claim: async () => {{ state.claimed = true; }} }}, + __issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }}, }}, + importScripts: () => {{}}, caches: {{ open: async () => cache, keys: async () => ['stackchain-dashboard-old', 'another-app-cache'], @@ -53,6 +55,11 @@ async function dispatch(name, request) {{ if (pending) await pending; return response ? await response : null; }} +async function dispatchSync(tag) {{ + let pending; + listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }}); + if (pending) await pending; +}} (async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }}); @@ -63,10 +70,22 @@ async function dispatch(name, request) {{ return json.loads(completed.stdout) -def test_mobile_attention_queue_ships_in_a_new_shell_cache(): +def test_background_issue_sync_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v11" in source + assert "stackchain-dashboard-shell-v12" in source + + +def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): + result = run_worker_scenario( + """ + await dispatchSync('stackchain-issue-outbox-v1'); + await dispatchSync('another-app-sync'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["backgroundFlushes"] == 1 def test_install_precaches_complete_subpath_scoped_app_shell(): @@ -103,6 +122,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/work-route.js", "/dashboard/static/context-poller.js", "/dashboard/static/mobile-task-dock.js", + "/dashboard/static/background-issue-sync.js", }