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",
}