@unbrained/pm-web 2026.9.26 → 2026.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +63 -0
- package/dist/app.js +8 -5
- package/dist/app.js.map +1 -1
- package/dist/db.d.ts +2 -2
- package/dist/db.js +30 -2
- package/dist/db.js.map +1 -1
- package/dist/idempotency.d.ts +59 -0
- package/dist/idempotency.js +361 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/routes/groups.js +10 -1
- package/dist/routes/groups.js.map +1 -1
- package/dist/routes/pm.js +125 -108
- package/dist/routes/pm.js.map +1 -1
- package/dist/services/package-catalog.js +1 -1
- package/dist/services/package-catalog.js.map +1 -1
- package/dist/services/pm-runner.d.ts +3 -2
- package/dist/services/pm-runner.js +19 -6
- package/dist/services/pm-runner.js.map +1 -1
- package/manifest.json +2 -2
- package/package.json +23 -21
- package/public/src/api.js +79 -0
- package/public/src/api.js.map +1 -1
- package/public/src/api.ts +77 -0
- package/public/src/app.js +3 -1
- package/public/src/app.js.map +1 -1
- package/public/src/app.ts +3 -1
- package/public/src/offline-recovery.js +65 -0
- package/public/src/offline-recovery.js.map +1 -0
- package/public/src/offline-recovery.ts +68 -0
- package/public/src/sw.ts +431 -33
- package/public/src/views/auth.js +3 -1
- package/public/src/views/auth.js.map +1 -1
- package/public/src/views/auth.ts +3 -1
- package/public/src/views/graph.js +8 -2
- package/public/src/views/graph.js.map +1 -1
- package/public/src/views/graph.ts +9 -2
- package/public/sw.js +379 -26
- package/sql/schema.sql +25 -0
package/public/sw.js
CHANGED
|
@@ -23,6 +23,11 @@ const BUILD_TIMESTAMP = '__BUILD_TIME__';
|
|
|
23
23
|
const CACHE_NAME = 'pm-web-' + (BUILD_TIMESTAMP !== '__BUILD_TIME__' ? BUILD_TIMESTAMP : Date.now().toString(36));
|
|
24
24
|
const MUTATION_DB = 'pm-web-offline';
|
|
25
25
|
const MUTATION_STORE = 'mutations';
|
|
26
|
+
// The signed-in account the page broadcasts lives in its own store so the
|
|
27
|
+
// mutation records and the session can be written and read independently,
|
|
28
|
+
// exactly like a real browser would keep them.
|
|
29
|
+
const SESSION_STORE = 'session';
|
|
30
|
+
const SESSION_KEY = 'current';
|
|
26
31
|
const STATIC_ASSETS = [
|
|
27
32
|
'/',
|
|
28
33
|
'/styles.css',
|
|
@@ -40,6 +45,7 @@ const STATIC_ASSETS = [
|
|
|
40
45
|
'/src/i18n.js',
|
|
41
46
|
'/src/i18n/de.json',
|
|
42
47
|
'/src/i18n/en.json',
|
|
48
|
+
'/src/offline-recovery.js',
|
|
43
49
|
'/src/state.js',
|
|
44
50
|
'/src/theme.js',
|
|
45
51
|
'/src/types.js',
|
|
@@ -101,18 +107,57 @@ const OFFLINE_HTML = `<!DOCTYPE html>
|
|
|
101
107
|
</div>
|
|
102
108
|
</body>
|
|
103
109
|
</html>`;
|
|
110
|
+
/**
|
|
111
|
+
* Extract the workspace (project id) a queued API path targets, or `null` for
|
|
112
|
+
* account-level mutations.
|
|
113
|
+
*
|
|
114
|
+
* Every project-scoped mutation the SPA issues lives under
|
|
115
|
+
* `/projects/<projectId>/…`, so the first path segment after `/projects/` is
|
|
116
|
+
* the workspace the record is bound to. The value is stamped at queue time
|
|
117
|
+
* and replayed verbatim in MUTATIONS_BLOCKED reports, so an operator can see
|
|
118
|
+
* which workspace a stranded record belongs to without re-deriving it.
|
|
119
|
+
*
|
|
120
|
+
* @param path - The queued API path (relative to `/api`).
|
|
121
|
+
* @returns The project id the path targets, or `null` when the path is account-level.
|
|
122
|
+
*/
|
|
123
|
+
function workspaceFromPath(path) {
|
|
124
|
+
const match = /^\/projects\/([^/]+)/.exec(path);
|
|
125
|
+
return match ? match[1] : null;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Generate a fresh idempotency key for one queued mutation.
|
|
129
|
+
*
|
|
130
|
+
* The key is a random UUID-shaped string so a replayed record can be
|
|
131
|
+
* deduplicated server-side even when the response to the first attempt was
|
|
132
|
+
* lost after the server committed. It is generated before the first attempt and
|
|
133
|
+
* persisted with the record so every retry of the same record reuses the
|
|
134
|
+
* same key and a genuinely different mutation never collides with it.
|
|
135
|
+
*
|
|
136
|
+
* @returns A random 128-bit key formatted as a UUID string.
|
|
137
|
+
*/
|
|
138
|
+
function newIdempotencyKey() {
|
|
139
|
+
const bytes = new Uint8Array(16);
|
|
140
|
+
crypto.getRandomValues(bytes);
|
|
141
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
142
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
143
|
+
}
|
|
104
144
|
/** Open (creating on first run) the IndexedDB database backing the offline
|
|
105
145
|
* mutation queue, ensuring the `mutations` object store and its timestamp
|
|
106
146
|
* index exist. Resolves with the ready database handle. */
|
|
107
147
|
function openMutationDB() {
|
|
108
148
|
return new Promise((resolve, reject) => {
|
|
109
|
-
const request = indexedDB.open(MUTATION_DB,
|
|
149
|
+
const request = indexedDB.open(MUTATION_DB, 2);
|
|
110
150
|
request.onupgradeneeded = () => {
|
|
111
151
|
const db = request.result;
|
|
112
152
|
if (!db.objectStoreNames.contains(MUTATION_STORE)) {
|
|
113
153
|
const store = db.createObjectStore(MUTATION_STORE, { keyPath: 'id', autoIncrement: true });
|
|
114
154
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
115
155
|
}
|
|
156
|
+
// Version 2 added the session store; an existing version-1 database is
|
|
157
|
+
// upgraded in place so a returning browser keeps its queued mutations.
|
|
158
|
+
if (!db.objectStoreNames.contains(SESSION_STORE)) {
|
|
159
|
+
db.createObjectStore(SESSION_STORE, { keyPath: 'key' });
|
|
160
|
+
}
|
|
116
161
|
};
|
|
117
162
|
request.onsuccess = () => resolve(request.result);
|
|
118
163
|
request.onerror = () => reject(request.error);
|
|
@@ -131,14 +176,72 @@ function transactionDone(tx) {
|
|
|
131
176
|
tx.onerror = () => reject(tx.error ?? new Error('IDB transaction error'));
|
|
132
177
|
});
|
|
133
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Read the account the page last broadcast as signed in, or `null` when no
|
|
181
|
+
* session is known.
|
|
182
|
+
*
|
|
183
|
+
* The session lives in its own IndexedDB store so it survives service-worker
|
|
184
|
+
* restarts: a worker can be killed at any time, and a mutation queued after a
|
|
185
|
+
* restart must still be stamped with the account that is signed in, not with
|
|
186
|
+
* whatever in-memory state a freshly booted worker happens to have.
|
|
187
|
+
*
|
|
188
|
+
* @returns The persisted user id, or `null` when nothing was broadcast or the
|
|
189
|
+
* store cannot be read (both mean ownership is unknown, never guessed).
|
|
190
|
+
*/
|
|
191
|
+
async function readSession() {
|
|
192
|
+
try {
|
|
193
|
+
const db = await openMutationDB();
|
|
194
|
+
const tx = db.transaction(SESSION_STORE, 'readonly');
|
|
195
|
+
const store = tx.objectStore(SESSION_STORE);
|
|
196
|
+
const session = await new Promise((resolve, reject) => {
|
|
197
|
+
const request = store.get(SESSION_KEY);
|
|
198
|
+
request.onsuccess = () => resolve(request.result);
|
|
199
|
+
request.onerror = () => reject(request.error);
|
|
200
|
+
});
|
|
201
|
+
return session?.userId ?? null;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Persist the account the page reports as signed in (or `null` on logout).
|
|
209
|
+
*
|
|
210
|
+
* Called from the AUTH_SESSION message so every later queueMutation stamps its
|
|
211
|
+
* records with this account. A storage failure leaves the previous value in
|
|
212
|
+
* place rather than silently clearing ownership.
|
|
213
|
+
*
|
|
214
|
+
* @param userId - The signed-in user id, or `null` when nobody is signed in.
|
|
215
|
+
*/
|
|
216
|
+
async function saveSession(userId) {
|
|
217
|
+
const db = await openMutationDB();
|
|
218
|
+
const tx = db.transaction(SESSION_STORE, 'readwrite');
|
|
219
|
+
tx.objectStore(SESSION_STORE).put({ key: SESSION_KEY, userId });
|
|
220
|
+
await transactionDone(tx);
|
|
221
|
+
}
|
|
134
222
|
/**
|
|
135
223
|
* Queue a mutation for later replay. Returns `true` only when the mutation has
|
|
136
224
|
* been durably persisted to IndexedDB; `false` when persistence failed so the
|
|
137
225
|
* caller can respond with an explicit error instead of claiming it was queued.
|
|
226
|
+
*
|
|
227
|
+
* The record is durably bound to the account the page last broadcast as signed
|
|
228
|
+
* in and to the workspace its path targets, and carries a fresh idempotency
|
|
229
|
+
* key, so a flush can never replay it under a different signed-in user and a
|
|
230
|
+
* retried record can never be applied twice server-side. When no session is
|
|
231
|
+
* known the record is still queued, with `ownerId: null`: ownership is
|
|
232
|
+
* recorded as unknown and the record is surfaced for explicit recovery
|
|
233
|
+
* instead of being guessed (and replayed under whoever happens to be signed
|
|
234
|
+
* in next).
|
|
235
|
+
*
|
|
236
|
+
* @param originatingAccount - Identity captured before the first attempt;
|
|
237
|
+
* direct queue calls without an attempt read the current stored session.
|
|
138
238
|
*/
|
|
139
|
-
async function queueMutation(method, path, body) {
|
|
239
|
+
async function queueMutation(method, path, body, idempotencyKey = newIdempotencyKey(), originatingAccount) {
|
|
240
|
+
if (/^\/auth(?:\/|\?|$)/i.test(path))
|
|
241
|
+
return false;
|
|
140
242
|
try {
|
|
141
243
|
const db = await openMutationDB();
|
|
244
|
+
const ownerId = originatingAccount === undefined ? await readSession() : originatingAccount;
|
|
142
245
|
const tx = db.transaction(MUTATION_STORE, 'readwrite');
|
|
143
246
|
const store = tx.objectStore(MUTATION_STORE);
|
|
144
247
|
const record = {
|
|
@@ -146,6 +249,9 @@ async function queueMutation(method, path, body) {
|
|
|
146
249
|
path,
|
|
147
250
|
body: body !== undefined ? JSON.stringify(body) : null,
|
|
148
251
|
timestamp: Date.now(),
|
|
252
|
+
ownerId,
|
|
253
|
+
workspace: workspaceFromPath(path),
|
|
254
|
+
idempotencyKey,
|
|
149
255
|
};
|
|
150
256
|
store.add(record);
|
|
151
257
|
// Await the transaction commit (not just the request dispatch) so the
|
|
@@ -199,22 +305,65 @@ async function clearMutation(id) {
|
|
|
199
305
|
return false;
|
|
200
306
|
}
|
|
201
307
|
}
|
|
202
|
-
/**
|
|
203
|
-
*
|
|
308
|
+
/**
|
|
309
|
+
* Decide whether one queued mutation may be replayed under the current
|
|
310
|
+
* session, and if not, why.
|
|
311
|
+
*
|
|
312
|
+
* The three refusals are deliberately distinct so a surfaced report tells the
|
|
313
|
+
* user what to do: `no-session` means nobody is signed in (log in and flush
|
|
314
|
+
* again), `unknown-owner` means the record predates ownership tracking (adopt
|
|
315
|
+
* it explicitly), and `owner-mismatch` means the record belongs to a different
|
|
316
|
+
* account and must wait for that account — it is never reassigned silently.
|
|
317
|
+
*
|
|
318
|
+
* @param mut - The queued mutation as IndexedDB returns it.
|
|
319
|
+
* @param currentUserId - The account `/api/auth/me` reports as signed in, or
|
|
320
|
+
* `null` when nobody is.
|
|
321
|
+
* @returns The refusal reason, or `null` when the record may be replayed.
|
|
322
|
+
*/
|
|
323
|
+
function replayBlockReason(mut, currentUserId) {
|
|
324
|
+
if (/^\/auth(?:\/|\?|$)/i.test(mut.path))
|
|
325
|
+
return 'auth-route';
|
|
326
|
+
if (currentUserId === null)
|
|
327
|
+
return 'no-session';
|
|
328
|
+
// Legacy records queued before ownership tracking have no ownerId at all.
|
|
329
|
+
if (mut.ownerId === undefined || mut.ownerId === null)
|
|
330
|
+
return 'unknown-owner';
|
|
331
|
+
if (mut.ownerId !== currentUserId)
|
|
332
|
+
return 'owner-mismatch';
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
/** Replay each workspace in FIFO order, removing successes, then post-message the
|
|
204
336
|
* connected clients with how many were replayed or remain. A storage read
|
|
205
337
|
* failure is never treated as a drained queue: the initial read failure
|
|
206
338
|
* aborts the flush, and a final read failure reports a partial result rather
|
|
207
|
-
* than claiming all mutations were replayed.
|
|
208
|
-
|
|
339
|
+
* than claiming all mutations were replayed.
|
|
340
|
+
*
|
|
341
|
+
* Identity is authoritative from the server: `/api/auth/me` names the account
|
|
342
|
+
* that is signed in, and only records that account queued are replayed.
|
|
343
|
+
* Records owned by a different account, records whose ownership is unknown
|
|
344
|
+
* (legacy, or queued with no known session), and every record while logged
|
|
345
|
+
* out are never replayed and never deleted — they are kept and surfaced via
|
|
346
|
+
* MUTATIONS_BLOCKED so the page can offer explicit recovery. An owned failed
|
|
347
|
+
* or attention record blocks only later records in its workspace. Account-level
|
|
348
|
+
* work is a barrier across workspaces: it cannot overtake earlier unresolved
|
|
349
|
+
* owned work, and unresolved account-level work blocks all later owned work.
|
|
350
|
+
* Different owners have separate ordering. Replayed records carry their key,
|
|
351
|
+
* so a retry after an ambiguous (lost) response is applied exactly once
|
|
352
|
+
* server-side. */
|
|
353
|
+
async function replayQueuedMutations() {
|
|
209
354
|
const read = await getQueuedMutations();
|
|
210
355
|
if (!read.ok) {
|
|
211
356
|
console.warn('Failed to read mutation queue:', read.error);
|
|
212
357
|
return;
|
|
213
358
|
}
|
|
214
359
|
const mutations = read.mutations;
|
|
215
|
-
if (mutations.length === 0)
|
|
360
|
+
if (mutations.length === 0) {
|
|
361
|
+
const clients = await sw.clients.matchAll();
|
|
362
|
+
clients.forEach((client) => { client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked: [] }); });
|
|
216
363
|
return;
|
|
364
|
+
}
|
|
217
365
|
let replayCsrfToken;
|
|
366
|
+
let currentUserId = null;
|
|
218
367
|
try {
|
|
219
368
|
const bootstrap = await fetch('/api/auth/me', {
|
|
220
369
|
method: 'GET',
|
|
@@ -226,19 +375,51 @@ async function flushMutationQueue() {
|
|
|
226
375
|
console.warn('Cannot replay offline mutations without a CSRF bootstrap token');
|
|
227
376
|
return;
|
|
228
377
|
}
|
|
378
|
+
// The bootstrap response is also the authoritative identity check: the
|
|
379
|
+
// signed-in account decides which records may replay. A 401 (or any
|
|
380
|
+
// unparseable body) means nobody is signed in, so every record is kept.
|
|
381
|
+
const me = await bootstrap.json().catch(() => null);
|
|
382
|
+
const bootstrapUserId = me?.user?.id;
|
|
383
|
+
currentUserId = typeof bootstrapUserId === 'string' ? bootstrapUserId : null;
|
|
229
384
|
}
|
|
230
385
|
catch {
|
|
231
386
|
// The network failed during token bootstrap; preserve the entire queue.
|
|
232
387
|
return;
|
|
233
388
|
}
|
|
389
|
+
const blocked = [];
|
|
390
|
+
const blockedWorkspaces = new Set();
|
|
234
391
|
let replayed = 0;
|
|
235
392
|
for (const mut of mutations) {
|
|
393
|
+
// Derive legacy scope from the path rather than assuming it is independent.
|
|
394
|
+
const workspace = workspaceFromPath(mut.path);
|
|
395
|
+
const reason = replayBlockReason(mut, currentUserId)
|
|
396
|
+
?? (mut.state === 'needs-attention' ? mut.attentionReason ?? 'outcome-unknown' : null)
|
|
397
|
+
?? (blockedWorkspaces.has(null) || (workspace === null ? blockedWorkspaces.size > 0 : blockedWorkspaces.has(workspace))
|
|
398
|
+
? 'workspace-order' : null);
|
|
399
|
+
if (reason) {
|
|
400
|
+
// Never replayed, never deleted: kept for its owner and surfaced so the
|
|
401
|
+
// page can offer explicit recovery. Skipping does not stop the loop, so
|
|
402
|
+
// blocked records cannot wedge unrelated owned records behind them.
|
|
403
|
+
blocked.push({
|
|
404
|
+
id: mut.id,
|
|
405
|
+
method: mut.method,
|
|
406
|
+
path: mut.path,
|
|
407
|
+
ownerId: mut.ownerId ?? null,
|
|
408
|
+
workspace,
|
|
409
|
+
reason,
|
|
410
|
+
});
|
|
411
|
+
if (mut.ownerId === currentUserId && currentUserId !== null)
|
|
412
|
+
blockedWorkspaces.add(workspace);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
236
415
|
try {
|
|
237
416
|
const opts = {
|
|
238
417
|
method: mut.method,
|
|
239
418
|
headers: {
|
|
240
419
|
'Content-Type': 'application/json',
|
|
241
420
|
'X-CSRF-Token': replayCsrfToken,
|
|
421
|
+
'X-PM-Expected-Account': mut.ownerId,
|
|
422
|
+
...(mut.idempotencyKey ? { 'Idempotency-Key': mut.idempotencyKey } : {}),
|
|
242
423
|
},
|
|
243
424
|
credentials: 'include',
|
|
244
425
|
};
|
|
@@ -256,8 +437,30 @@ async function flushMutationQueue() {
|
|
|
256
437
|
}
|
|
257
438
|
else {
|
|
258
439
|
console.warn('Offline mutation failed:', mut.method, mut.path, res.status);
|
|
259
|
-
|
|
260
|
-
|
|
440
|
+
const body = await res.json().catch(() => null);
|
|
441
|
+
let attention;
|
|
442
|
+
if (body?.code === 'PM_IDEMPOTENCY_OUTCOME_UNKNOWN')
|
|
443
|
+
attention = 'outcome-unknown';
|
|
444
|
+
if (res.status >= 500) {
|
|
445
|
+
mut.serverFailures = (mut.serverFailures ?? 0) + 1;
|
|
446
|
+
if (mut.serverFailures >= 3)
|
|
447
|
+
attention = 'repeated-5xx';
|
|
448
|
+
}
|
|
449
|
+
else if (!attention && mut.serverFailures) {
|
|
450
|
+
mut.serverFailures = 0;
|
|
451
|
+
}
|
|
452
|
+
if (attention) {
|
|
453
|
+
mut.state = 'needs-attention';
|
|
454
|
+
mut.attentionReason = attention;
|
|
455
|
+
blocked.push({ id: mut.id, method: mut.method, path: mut.path,
|
|
456
|
+
ownerId: mut.ownerId ?? null, workspace, reason: attention });
|
|
457
|
+
}
|
|
458
|
+
// Persist before allowing independent workspace replay; failure stops the flush.
|
|
459
|
+
const db = await openMutationDB();
|
|
460
|
+
const tx = db.transaction(MUTATION_STORE, 'readwrite');
|
|
461
|
+
tx.objectStore(MUTATION_STORE).put(mut);
|
|
462
|
+
await transactionDone(tx);
|
|
463
|
+
blockedWorkspaces.add(workspace);
|
|
261
464
|
}
|
|
262
465
|
}
|
|
263
466
|
catch {
|
|
@@ -268,6 +471,10 @@ async function flushMutationQueue() {
|
|
|
268
471
|
// Notify clients about replayed mutations
|
|
269
472
|
const remainingRead = await getQueuedMutations();
|
|
270
473
|
const clients = await sw.clients.matchAll();
|
|
474
|
+
// Empty reports remove a previously rendered or dismissed recovery set.
|
|
475
|
+
clients.forEach((client) => {
|
|
476
|
+
client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked });
|
|
477
|
+
});
|
|
271
478
|
if (!remainingRead.ok) {
|
|
272
479
|
// Could not re-read the queue — do NOT claim all mutations were replayed.
|
|
273
480
|
// Report a partial result with the known replayed count so unreplayed
|
|
@@ -294,12 +501,87 @@ async function flushMutationQueue() {
|
|
|
294
501
|
clients.forEach((client) => {
|
|
295
502
|
client.postMessage({
|
|
296
503
|
type: 'MUTATIONS_PARTIAL',
|
|
297
|
-
replayed
|
|
504
|
+
replayed,
|
|
298
505
|
remaining: remaining.length,
|
|
299
506
|
});
|
|
300
507
|
});
|
|
301
508
|
}
|
|
302
509
|
}
|
|
510
|
+
/** Pending queue operations share one chain across messages, sync and online events. */
|
|
511
|
+
let queueOperation = Promise.resolve();
|
|
512
|
+
/** Serialize queue adoption and replay, recovering the chain after an operation fails. */
|
|
513
|
+
function runQueueOperation(operation) {
|
|
514
|
+
const pending = queueOperation.then(async () => {
|
|
515
|
+
await sessionUpdate.catch(() => { });
|
|
516
|
+
await operation();
|
|
517
|
+
});
|
|
518
|
+
queueOperation = pending.catch(() => { });
|
|
519
|
+
return pending;
|
|
520
|
+
}
|
|
521
|
+
/** Flush under the same single-flight chain used for explicit recovery. */
|
|
522
|
+
function flushMutationQueue() {
|
|
523
|
+
return runQueueOperation(replayQueuedMutations);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Explicitly rebind unknown-owner records to the signed-in account.
|
|
527
|
+
*
|
|
528
|
+
* The page offers this after a flush surfaces records with the `unknown-owner`
|
|
529
|
+
* reason: the signed-in user states that this queued work is theirs. Only
|
|
530
|
+
* records whose ownership is unknown are adoptable — a record already owned by
|
|
531
|
+
* another account is never reassigned, because its true owner may still
|
|
532
|
+
* return to replay it.
|
|
533
|
+
*
|
|
534
|
+
* @param ids - Only these record ids are adopted, or all unknown records when absent.
|
|
535
|
+
* @param ownerId - The approving page's account, verified against the server session.
|
|
536
|
+
* @returns Whether the current server session approved adoption.
|
|
537
|
+
*/
|
|
538
|
+
function adoptUnknownOwnerRecords(ids, ownerId) {
|
|
539
|
+
return updateRecoveryRecords(ownerId, (store, mut) => {
|
|
540
|
+
if ((mut.ownerId === undefined || mut.ownerId === null)
|
|
541
|
+
&& (ids === undefined || ids.includes(mut.id))
|
|
542
|
+
&& !/^\/auth(?:\/|\?|$)/i.test(mut.path)) {
|
|
543
|
+
store.put({ ...mut, ownerId, idempotencyKey: mut.idempotencyKey || newIdempotencyKey() });
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/** Adopt records through the common queue-operation chain used by flushes. */
|
|
548
|
+
function rebindUnknownOwnerRecords(ids, ownerId) {
|
|
549
|
+
return runQueueOperation(async () => { await adoptUnknownOwnerRecords(ids, ownerId); });
|
|
550
|
+
}
|
|
551
|
+
/** Apply owner-approved retry with a fresh key or discard atomically; never change another owner's work. */
|
|
552
|
+
function recoverAttentionRecord(id, ownerId, action) {
|
|
553
|
+
return updateRecoveryRecords(ownerId, (store, mut) => {
|
|
554
|
+
if (mut.id !== id || mut.ownerId !== ownerId || mut.state !== 'needs-attention')
|
|
555
|
+
return;
|
|
556
|
+
if (action === 'discard')
|
|
557
|
+
store.delete(id);
|
|
558
|
+
else
|
|
559
|
+
store.put({ ...mut, state: 'queued', attentionReason: undefined, serverFailures: 0, idempotencyKey: newIdempotencyKey() });
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
/** Verify the approving account, then read and update recovery records in one live IndexedDB transaction. */
|
|
563
|
+
async function updateRecoveryRecords(ownerId, update) {
|
|
564
|
+
const approval = await fetch('/api/auth/me', {
|
|
565
|
+
credentials: 'include', cache: 'no-store', headers: { 'X-PM-Expected-Account': ownerId },
|
|
566
|
+
});
|
|
567
|
+
if (!approval.ok)
|
|
568
|
+
return false;
|
|
569
|
+
const me = await approval.json().catch(() => null);
|
|
570
|
+
if (me?.user?.id !== ownerId)
|
|
571
|
+
return false;
|
|
572
|
+
const db = await openMutationDB();
|
|
573
|
+
const tx = db.transaction(MUTATION_STORE, 'readwrite');
|
|
574
|
+
const done = transactionDone(tx);
|
|
575
|
+
const store = tx.objectStore(MUTATION_STORE);
|
|
576
|
+
const request = store.getAll();
|
|
577
|
+
request.onsuccess = () => {
|
|
578
|
+
for (const mut of request.result) {
|
|
579
|
+
update(store, mut);
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
await done;
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
303
585
|
// ── Install ──
|
|
304
586
|
sw.addEventListener('install', (event) => {
|
|
305
587
|
event.waitUntil(caches.open(CACHE_NAME).then((cache) => Promise.all(STATIC_ASSETS.map((asset) => cache.add(asset).catch(() => null)))));
|
|
@@ -315,22 +597,43 @@ sw.addEventListener('fetch', (event) => {
|
|
|
315
597
|
const url = new URL(event.request.url);
|
|
316
598
|
// API calls: try network, queue mutations if offline
|
|
317
599
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/healthz')) {
|
|
600
|
+
// Session responses must reach the browser with their live cookie changes.
|
|
601
|
+
// Remove caller keys too; failed auth requests are never queued.
|
|
602
|
+
if (/^\/api\/auth(?:\/|$)/i.test(url.pathname)) {
|
|
603
|
+
const headers = new Headers(event.request.headers);
|
|
604
|
+
headers.delete('Idempotency-Key');
|
|
605
|
+
headers.delete('X-PM-Expected-Account');
|
|
606
|
+
event.respondWith(fetch(new Request(event.request, { headers })));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
318
609
|
// Queue write operations (POST, PUT, PATCH, DELETE) when offline
|
|
319
610
|
if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
611
|
+
const headers = new Headers(event.request.headers);
|
|
612
|
+
if (!headers.has('Idempotency-Key'))
|
|
613
|
+
headers.set('Idempotency-Key', newIdempotencyKey());
|
|
614
|
+
event.respondWith((async () => {
|
|
615
|
+
await sessionUpdate.catch(() => { });
|
|
616
|
+
// Capture identity before sending, never after a lost response.
|
|
617
|
+
const ownerId = headers.get('X-PM-Expected-Account') ?? await readSession();
|
|
618
|
+
if (ownerId !== null)
|
|
619
|
+
headers.set('X-PM-Expected-Account', ownerId);
|
|
620
|
+
const request = new Request(event.request, { headers });
|
|
621
|
+
const queuedRequest = request.clone();
|
|
622
|
+
return fetch(request).catch(async () => {
|
|
623
|
+
// Network failed — queue the mutation for later.
|
|
624
|
+
let body = undefined;
|
|
625
|
+
try {
|
|
626
|
+
body = await queuedRequest.json();
|
|
627
|
+
}
|
|
628
|
+
catch { /* no body */ }
|
|
629
|
+
const queued = await queueMutation(event.request.method, url.pathname.replace('/api', '') + url.search, body, headers.get('Idempotency-Key') ?? newIdempotencyKey(), ownerId);
|
|
630
|
+
if (queued) {
|
|
631
|
+
return new Response(JSON.stringify({ queued: true, message: 'Request queued for when you are back online' }), { status: 202, headers: { 'Content-Type': 'application/json' } });
|
|
632
|
+
}
|
|
633
|
+
// Persistence failed — do not claim the mutation was queued.
|
|
634
|
+
return new Response(JSON.stringify({ error: 'Offline and unable to queue mutation', queued: false }), { status: 503, headers: { 'Content-Type': 'application/json' } });
|
|
635
|
+
});
|
|
636
|
+
})());
|
|
334
637
|
return;
|
|
335
638
|
}
|
|
336
639
|
// GET/HEAD API calls: network-only, return offline JSON error
|
|
@@ -418,6 +721,8 @@ sw.addEventListener('fetch', (event) => {
|
|
|
418
721
|
}
|
|
419
722
|
})());
|
|
420
723
|
});
|
|
724
|
+
// Serialise session writes and recovery so adoption cannot read an older login.
|
|
725
|
+
let sessionUpdate = Promise.resolve();
|
|
421
726
|
// ── Messages ──
|
|
422
727
|
sw.addEventListener('message', (event) => {
|
|
423
728
|
const data = event.data;
|
|
@@ -429,7 +734,48 @@ sw.addEventListener('message', (event) => {
|
|
|
429
734
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(urls).catch(() => { }));
|
|
430
735
|
}
|
|
431
736
|
if (data && data.type === 'FLUSH_QUEUE') {
|
|
432
|
-
|
|
737
|
+
event.waitUntil(sessionUpdate.then(() => flushMutationQueue()).catch(() => {
|
|
738
|
+
console.warn('Failed to flush offline mutations');
|
|
739
|
+
}));
|
|
740
|
+
}
|
|
741
|
+
if (data && data.type === 'AUTH_SESSION') {
|
|
742
|
+
// The page names the signed-in account so queued mutations are bound to
|
|
743
|
+
// it. `null` (logout) is a valid broadcast: it means ownership of later
|
|
744
|
+
// queues is unknown, never the next signed-in account.
|
|
745
|
+
const userId = typeof data.userId === 'string' ? data.userId : null;
|
|
746
|
+
sessionUpdate = sessionUpdate.catch(() => { }).then(() => saveSession(userId));
|
|
747
|
+
event.waitUntil(sessionUpdate.catch(() => {
|
|
748
|
+
console.warn('Failed to persist offline session');
|
|
749
|
+
}));
|
|
750
|
+
}
|
|
751
|
+
if (data && data.type === 'REBIND_RECORDS') {
|
|
752
|
+
// Explicit recovery: adopt unknown-owner records for the signed-in
|
|
753
|
+
// account. The ids are optional; without them every unknown-owner record
|
|
754
|
+
// is adopted.
|
|
755
|
+
const ids = Array.isArray(data.ids)
|
|
756
|
+
? data.ids.filter((id) => typeof id === 'number')
|
|
757
|
+
: undefined;
|
|
758
|
+
const ownerId = data.ownerId;
|
|
759
|
+
if (typeof ownerId !== 'string' || ownerId.length === 0)
|
|
760
|
+
return;
|
|
761
|
+
event.waitUntil(runQueueOperation(async () => {
|
|
762
|
+
if (await adoptUnknownOwnerRecords(ids, ownerId))
|
|
763
|
+
await replayQueuedMutations();
|
|
764
|
+
}).catch(() => {
|
|
765
|
+
console.warn('Failed to adopt offline mutations');
|
|
766
|
+
}));
|
|
767
|
+
}
|
|
768
|
+
if (data && data.type === 'RECOVER_RECORD') {
|
|
769
|
+
const { id, ownerId, action } = data;
|
|
770
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id) || typeof ownerId !== 'string' || ownerId.length === 0
|
|
771
|
+
|| (action !== 'retry-new' && action !== 'discard'))
|
|
772
|
+
return;
|
|
773
|
+
event.waitUntil(runQueueOperation(async () => {
|
|
774
|
+
if (await recoverAttentionRecord(id, ownerId, action))
|
|
775
|
+
await replayQueuedMutations();
|
|
776
|
+
}).catch(() => {
|
|
777
|
+
console.warn('Failed to recover offline mutation');
|
|
778
|
+
}));
|
|
433
779
|
}
|
|
434
780
|
});
|
|
435
781
|
// ── Background sync ──
|
|
@@ -459,5 +805,12 @@ sw.addEventListener('online', () => {
|
|
|
459
805
|
// rather than keeping both.
|
|
460
806
|
const __testGlobals = globalThis;
|
|
461
807
|
if (__testGlobals.__swTestHarness) {
|
|
462
|
-
__testGlobals.__swInternals = {
|
|
808
|
+
__testGlobals.__swInternals = {
|
|
809
|
+
getQueuedMutations,
|
|
810
|
+
flushMutationQueue,
|
|
811
|
+
queueMutation,
|
|
812
|
+
clearMutation,
|
|
813
|
+
saveSession,
|
|
814
|
+
rebindRecords: rebindUnknownOwnerRecords,
|
|
815
|
+
};
|
|
463
816
|
}
|
package/sql/schema.sql
CHANGED
|
@@ -101,6 +101,31 @@ CREATE TABLE IF NOT EXISTS pm_github_item_links (
|
|
|
101
101
|
);
|
|
102
102
|
CREATE INDEX IF NOT EXISTS pm_github_item_links_project ON pm_github_item_links(project_id);
|
|
103
103
|
|
|
104
|
+
-- Idempotency keys: one row per (account, key) recording the outcome of a
|
|
105
|
+
-- mutating request that carried that key, so a retry after an ambiguous (lost)
|
|
106
|
+
-- response replays the stored outcome instead of re-executing. A NULL
|
|
107
|
+
-- status_code marks an execution still in flight (or crashed before
|
|
108
|
+
-- responding); see src/idempotency.ts for the unknown-outcome policy.
|
|
109
|
+
CREATE TABLE IF NOT EXISTS pm_idempotency_keys (
|
|
110
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
111
|
+
user_id UUID NOT NULL REFERENCES pm_users(id) ON DELETE CASCADE,
|
|
112
|
+
idempotency_key TEXT NOT NULL,
|
|
113
|
+
method TEXT NOT NULL,
|
|
114
|
+
path TEXT NOT NULL,
|
|
115
|
+
request_fingerprint TEXT NOT NULL,
|
|
116
|
+
outcome_state TEXT NOT NULL DEFAULT 'pending' CHECK (outcome_state IN ('pending', 'completed', 'outcome_unknown')),
|
|
117
|
+
status_code INTEGER,
|
|
118
|
+
response_body TEXT,
|
|
119
|
+
response_content_type TEXT,
|
|
120
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
121
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
122
|
+
UNIQUE (user_id, idempotency_key)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_pm_idempotency_created_at ON pm_idempotency_keys (created_at) WHERE status_code IS NOT NULL;
|
|
126
|
+
CREATE INDEX IF NOT EXISTS idx_pm_idempotency_settled_at ON pm_idempotency_keys (updated_at) WHERE status_code IS NOT NULL;
|
|
127
|
+
CREATE INDEX IF NOT EXISTS idx_pm_idempotency_pending_at ON pm_idempotency_keys (created_at) WHERE status_code IS NULL;
|
|
128
|
+
|
|
104
129
|
-- Bootstrap admin promotion is now applied at runtime via PM_WEB_BOOTSTRAP_ADMIN_EMAIL (see src/db.ts).
|
|
105
130
|
|
|
106
131
|
-- Update trigger
|