@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/src/sw.ts
CHANGED
|
@@ -24,6 +24,11 @@ const BUILD_TIMESTAMP: string = '__BUILD_TIME__';
|
|
|
24
24
|
const CACHE_NAME = 'pm-web-' + (BUILD_TIMESTAMP !== '__BUILD_TIME__' ? BUILD_TIMESTAMP : Date.now().toString(36));
|
|
25
25
|
const MUTATION_DB = 'pm-web-offline';
|
|
26
26
|
const MUTATION_STORE = 'mutations';
|
|
27
|
+
// The signed-in account the page broadcasts lives in its own store so the
|
|
28
|
+
// mutation records and the session can be written and read independently,
|
|
29
|
+
// exactly like a real browser would keep them.
|
|
30
|
+
const SESSION_STORE = 'session';
|
|
31
|
+
const SESSION_KEY = 'current';
|
|
27
32
|
|
|
28
33
|
const STATIC_ASSETS: readonly string[] = [
|
|
29
34
|
'/',
|
|
@@ -42,6 +47,7 @@ const STATIC_ASSETS: readonly string[] = [
|
|
|
42
47
|
'/src/i18n.js',
|
|
43
48
|
'/src/i18n/de.json',
|
|
44
49
|
'/src/i18n/en.json',
|
|
50
|
+
'/src/offline-recovery.js',
|
|
45
51
|
'/src/state.js',
|
|
46
52
|
'/src/theme.js',
|
|
47
53
|
'/src/types.js',
|
|
@@ -113,6 +119,18 @@ interface QueuedMutation {
|
|
|
113
119
|
path: string;
|
|
114
120
|
body: string | null;
|
|
115
121
|
timestamp: number;
|
|
122
|
+
/** Account that queued the mutation. Absent on legacy records. */
|
|
123
|
+
ownerId?: string | null;
|
|
124
|
+
/** Workspace (project id) the queued path targets. Absent on legacy records. */
|
|
125
|
+
workspace?: string | null;
|
|
126
|
+
/** Server-enforced exactly-once identity for the replay. */
|
|
127
|
+
idempotencyKey?: string;
|
|
128
|
+
/** Durable recovery state; absence on legacy records means queued. */
|
|
129
|
+
state?: 'queued' | 'needs-attention';
|
|
130
|
+
/** Terminal replay problem requiring owner action. */
|
|
131
|
+
attentionReason?: 'outcome-unknown' | 'repeated-5xx';
|
|
132
|
+
/** Consecutive server failures survive worker restarts. */
|
|
133
|
+
serverFailures?: number;
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
interface StoredMutation {
|
|
@@ -120,6 +138,73 @@ interface StoredMutation {
|
|
|
120
138
|
path: string;
|
|
121
139
|
body: string | null;
|
|
122
140
|
timestamp: number;
|
|
141
|
+
/** Account that queued the mutation; `null` when no session was known. */
|
|
142
|
+
ownerId: string | null;
|
|
143
|
+
/** Workspace (project id) the queued path targets; `null` when account-level. */
|
|
144
|
+
workspace: string | null;
|
|
145
|
+
/** Server-enforced exactly-once identity for the replay. */
|
|
146
|
+
idempotencyKey: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The signed-in account as persisted in the `session` store. */
|
|
150
|
+
interface StoredSession {
|
|
151
|
+
key: string;
|
|
152
|
+
userId: string | null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Shape of the `/api/auth/me` response body the flush bootstrap reads. */
|
|
156
|
+
interface MeBody {
|
|
157
|
+
user?: { id?: string };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Why one queued mutation was refused during a flush. */
|
|
161
|
+
type ReplayBlockReason = 'no-session' | 'unknown-owner' | 'owner-mismatch' | 'auth-route'
|
|
162
|
+
| 'outcome-unknown' | 'repeated-5xx' | 'workspace-order';
|
|
163
|
+
|
|
164
|
+
/** A queued mutation a flush refused, surfaced to the page for explicit recovery. */
|
|
165
|
+
interface BlockedMutation {
|
|
166
|
+
id: number;
|
|
167
|
+
method: string;
|
|
168
|
+
path: string;
|
|
169
|
+
ownerId: string | null;
|
|
170
|
+
workspace: string | null;
|
|
171
|
+
reason: ReplayBlockReason;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Extract the workspace (project id) a queued API path targets, or `null` for
|
|
176
|
+
* account-level mutations.
|
|
177
|
+
*
|
|
178
|
+
* Every project-scoped mutation the SPA issues lives under
|
|
179
|
+
* `/projects/<projectId>/…`, so the first path segment after `/projects/` is
|
|
180
|
+
* the workspace the record is bound to. The value is stamped at queue time
|
|
181
|
+
* and replayed verbatim in MUTATIONS_BLOCKED reports, so an operator can see
|
|
182
|
+
* which workspace a stranded record belongs to without re-deriving it.
|
|
183
|
+
*
|
|
184
|
+
* @param path - The queued API path (relative to `/api`).
|
|
185
|
+
* @returns The project id the path targets, or `null` when the path is account-level.
|
|
186
|
+
*/
|
|
187
|
+
function workspaceFromPath(path: string): string | null {
|
|
188
|
+
const match = /^\/projects\/([^/]+)/.exec(path);
|
|
189
|
+
return match ? match[1] : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Generate a fresh idempotency key for one queued mutation.
|
|
194
|
+
*
|
|
195
|
+
* The key is a random UUID-shaped string so a replayed record can be
|
|
196
|
+
* deduplicated server-side even when the response to the first attempt was
|
|
197
|
+
* lost after the server committed. It is generated before the first attempt and
|
|
198
|
+
* persisted with the record so every retry of the same record reuses the
|
|
199
|
+
* same key and a genuinely different mutation never collides with it.
|
|
200
|
+
*
|
|
201
|
+
* @returns A random 128-bit key formatted as a UUID string.
|
|
202
|
+
*/
|
|
203
|
+
function newIdempotencyKey(): string {
|
|
204
|
+
const bytes = new Uint8Array(16);
|
|
205
|
+
crypto.getRandomValues(bytes);
|
|
206
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
207
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
123
208
|
}
|
|
124
209
|
|
|
125
210
|
// Minimal Background Sync event typing. The `WebWorker` lib does not ship
|
|
@@ -133,13 +218,18 @@ interface SyncEvent extends ExtendableEvent {
|
|
|
133
218
|
* index exist. Resolves with the ready database handle. */
|
|
134
219
|
function openMutationDB(): Promise<IDBDatabase> {
|
|
135
220
|
return new Promise((resolve, reject) => {
|
|
136
|
-
const request = indexedDB.open(MUTATION_DB,
|
|
221
|
+
const request = indexedDB.open(MUTATION_DB, 2);
|
|
137
222
|
request.onupgradeneeded = () => {
|
|
138
223
|
const db = request.result;
|
|
139
224
|
if (!db.objectStoreNames.contains(MUTATION_STORE)) {
|
|
140
225
|
const store = db.createObjectStore(MUTATION_STORE, { keyPath: 'id', autoIncrement: true });
|
|
141
226
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
142
227
|
}
|
|
228
|
+
// Version 2 added the session store; an existing version-1 database is
|
|
229
|
+
// upgraded in place so a returning browser keeps its queued mutations.
|
|
230
|
+
if (!db.objectStoreNames.contains(SESSION_STORE)) {
|
|
231
|
+
db.createObjectStore(SESSION_STORE, { keyPath: 'key' });
|
|
232
|
+
}
|
|
143
233
|
};
|
|
144
234
|
request.onsuccess = () => resolve(request.result);
|
|
145
235
|
request.onerror = () => reject(request.error);
|
|
@@ -160,18 +250,78 @@ function transactionDone(tx: IDBTransaction): Promise<void> {
|
|
|
160
250
|
});
|
|
161
251
|
}
|
|
162
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Read the account the page last broadcast as signed in, or `null` when no
|
|
255
|
+
* session is known.
|
|
256
|
+
*
|
|
257
|
+
* The session lives in its own IndexedDB store so it survives service-worker
|
|
258
|
+
* restarts: a worker can be killed at any time, and a mutation queued after a
|
|
259
|
+
* restart must still be stamped with the account that is signed in, not with
|
|
260
|
+
* whatever in-memory state a freshly booted worker happens to have.
|
|
261
|
+
*
|
|
262
|
+
* @returns The persisted user id, or `null` when nothing was broadcast or the
|
|
263
|
+
* store cannot be read (both mean ownership is unknown, never guessed).
|
|
264
|
+
*/
|
|
265
|
+
async function readSession(): Promise<string | null> {
|
|
266
|
+
try {
|
|
267
|
+
const db = await openMutationDB();
|
|
268
|
+
const tx = db.transaction(SESSION_STORE, 'readonly');
|
|
269
|
+
const store = tx.objectStore(SESSION_STORE);
|
|
270
|
+
const session = await new Promise<StoredSession | undefined>((resolve, reject) => {
|
|
271
|
+
const request = store.get(SESSION_KEY);
|
|
272
|
+
request.onsuccess = () => resolve(request.result as StoredSession | undefined);
|
|
273
|
+
request.onerror = () => reject(request.error);
|
|
274
|
+
});
|
|
275
|
+
return session?.userId ?? null;
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Persist the account the page reports as signed in (or `null` on logout).
|
|
283
|
+
*
|
|
284
|
+
* Called from the AUTH_SESSION message so every later queueMutation stamps its
|
|
285
|
+
* records with this account. A storage failure leaves the previous value in
|
|
286
|
+
* place rather than silently clearing ownership.
|
|
287
|
+
*
|
|
288
|
+
* @param userId - The signed-in user id, or `null` when nobody is signed in.
|
|
289
|
+
*/
|
|
290
|
+
async function saveSession(userId: string | null): Promise<void> {
|
|
291
|
+
const db = await openMutationDB();
|
|
292
|
+
const tx = db.transaction(SESSION_STORE, 'readwrite');
|
|
293
|
+
tx.objectStore(SESSION_STORE).put({ key: SESSION_KEY, userId });
|
|
294
|
+
await transactionDone(tx);
|
|
295
|
+
}
|
|
296
|
+
|
|
163
297
|
/**
|
|
164
298
|
* Queue a mutation for later replay. Returns `true` only when the mutation has
|
|
165
299
|
* been durably persisted to IndexedDB; `false` when persistence failed so the
|
|
166
300
|
* caller can respond with an explicit error instead of claiming it was queued.
|
|
301
|
+
*
|
|
302
|
+
* The record is durably bound to the account the page last broadcast as signed
|
|
303
|
+
* in and to the workspace its path targets, and carries a fresh idempotency
|
|
304
|
+
* key, so a flush can never replay it under a different signed-in user and a
|
|
305
|
+
* retried record can never be applied twice server-side. When no session is
|
|
306
|
+
* known the record is still queued, with `ownerId: null`: ownership is
|
|
307
|
+
* recorded as unknown and the record is surfaced for explicit recovery
|
|
308
|
+
* instead of being guessed (and replayed under whoever happens to be signed
|
|
309
|
+
* in next).
|
|
310
|
+
*
|
|
311
|
+
* @param originatingAccount - Identity captured before the first attempt;
|
|
312
|
+
* direct queue calls without an attempt read the current stored session.
|
|
167
313
|
*/
|
|
168
314
|
async function queueMutation(
|
|
169
315
|
method: string,
|
|
170
316
|
path: string,
|
|
171
317
|
body: unknown,
|
|
318
|
+
idempotencyKey = newIdempotencyKey(),
|
|
319
|
+
originatingAccount?: string | null,
|
|
172
320
|
): Promise<boolean> {
|
|
321
|
+
if (/^\/auth(?:\/|\?|$)/i.test(path)) return false;
|
|
173
322
|
try {
|
|
174
323
|
const db = await openMutationDB();
|
|
324
|
+
const ownerId = originatingAccount === undefined ? await readSession() : originatingAccount;
|
|
175
325
|
const tx = db.transaction(MUTATION_STORE, 'readwrite');
|
|
176
326
|
const store = tx.objectStore(MUTATION_STORE);
|
|
177
327
|
const record: StoredMutation = {
|
|
@@ -179,6 +329,9 @@ async function queueMutation(
|
|
|
179
329
|
path,
|
|
180
330
|
body: body !== undefined ? JSON.stringify(body) : null,
|
|
181
331
|
timestamp: Date.now(),
|
|
332
|
+
ownerId,
|
|
333
|
+
workspace: workspaceFromPath(path),
|
|
334
|
+
idempotencyKey,
|
|
182
335
|
};
|
|
183
336
|
store.add(record);
|
|
184
337
|
// Await the transaction commit (not just the request dispatch) so the
|
|
@@ -240,22 +393,66 @@ async function clearMutation(id: number): Promise<boolean> {
|
|
|
240
393
|
}
|
|
241
394
|
}
|
|
242
395
|
|
|
243
|
-
/**
|
|
244
|
-
*
|
|
396
|
+
/**
|
|
397
|
+
* Decide whether one queued mutation may be replayed under the current
|
|
398
|
+
* session, and if not, why.
|
|
399
|
+
*
|
|
400
|
+
* The three refusals are deliberately distinct so a surfaced report tells the
|
|
401
|
+
* user what to do: `no-session` means nobody is signed in (log in and flush
|
|
402
|
+
* again), `unknown-owner` means the record predates ownership tracking (adopt
|
|
403
|
+
* it explicitly), and `owner-mismatch` means the record belongs to a different
|
|
404
|
+
* account and must wait for that account — it is never reassigned silently.
|
|
405
|
+
*
|
|
406
|
+
* @param mut - The queued mutation as IndexedDB returns it.
|
|
407
|
+
* @param currentUserId - The account `/api/auth/me` reports as signed in, or
|
|
408
|
+
* `null` when nobody is.
|
|
409
|
+
* @returns The refusal reason, or `null` when the record may be replayed.
|
|
410
|
+
*/
|
|
411
|
+
function replayBlockReason(
|
|
412
|
+
mut: QueuedMutation,
|
|
413
|
+
currentUserId: string | null,
|
|
414
|
+
): ReplayBlockReason | null {
|
|
415
|
+
if (/^\/auth(?:\/|\?|$)/i.test(mut.path)) return 'auth-route';
|
|
416
|
+
if (currentUserId === null) return 'no-session';
|
|
417
|
+
// Legacy records queued before ownership tracking have no ownerId at all.
|
|
418
|
+
if (mut.ownerId === undefined || mut.ownerId === null) return 'unknown-owner';
|
|
419
|
+
if (mut.ownerId !== currentUserId) return 'owner-mismatch';
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Replay each workspace in FIFO order, removing successes, then post-message the
|
|
245
424
|
* connected clients with how many were replayed or remain. A storage read
|
|
246
425
|
* failure is never treated as a drained queue: the initial read failure
|
|
247
426
|
* aborts the flush, and a final read failure reports a partial result rather
|
|
248
|
-
* than claiming all mutations were replayed.
|
|
249
|
-
|
|
427
|
+
* than claiming all mutations were replayed.
|
|
428
|
+
*
|
|
429
|
+
* Identity is authoritative from the server: `/api/auth/me` names the account
|
|
430
|
+
* that is signed in, and only records that account queued are replayed.
|
|
431
|
+
* Records owned by a different account, records whose ownership is unknown
|
|
432
|
+
* (legacy, or queued with no known session), and every record while logged
|
|
433
|
+
* out are never replayed and never deleted — they are kept and surfaced via
|
|
434
|
+
* MUTATIONS_BLOCKED so the page can offer explicit recovery. An owned failed
|
|
435
|
+
* or attention record blocks only later records in its workspace. Account-level
|
|
436
|
+
* work is a barrier across workspaces: it cannot overtake earlier unresolved
|
|
437
|
+
* owned work, and unresolved account-level work blocks all later owned work.
|
|
438
|
+
* Different owners have separate ordering. Replayed records carry their key,
|
|
439
|
+
* so a retry after an ambiguous (lost) response is applied exactly once
|
|
440
|
+
* server-side. */
|
|
441
|
+
async function replayQueuedMutations(): Promise<void> {
|
|
250
442
|
const read = await getQueuedMutations();
|
|
251
443
|
if (!read.ok) {
|
|
252
444
|
console.warn('Failed to read mutation queue:', read.error);
|
|
253
445
|
return;
|
|
254
446
|
}
|
|
255
447
|
const mutations = read.mutations;
|
|
256
|
-
if (mutations.length === 0)
|
|
448
|
+
if (mutations.length === 0) {
|
|
449
|
+
const clients = await sw.clients.matchAll();
|
|
450
|
+
clients.forEach((client) => { client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked: [] }); });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
257
453
|
|
|
258
454
|
let replayCsrfToken: string | null;
|
|
455
|
+
let currentUserId: string | null = null;
|
|
259
456
|
try {
|
|
260
457
|
const bootstrap = await fetch('/api/auth/me', {
|
|
261
458
|
method: 'GET',
|
|
@@ -267,19 +464,50 @@ async function flushMutationQueue(): Promise<void> {
|
|
|
267
464
|
console.warn('Cannot replay offline mutations without a CSRF bootstrap token');
|
|
268
465
|
return;
|
|
269
466
|
}
|
|
467
|
+
// The bootstrap response is also the authoritative identity check: the
|
|
468
|
+
// signed-in account decides which records may replay. A 401 (or any
|
|
469
|
+
// unparseable body) means nobody is signed in, so every record is kept.
|
|
470
|
+
const me = await bootstrap.json().catch(() => null) as MeBody | null;
|
|
471
|
+
const bootstrapUserId = me?.user?.id;
|
|
472
|
+
currentUserId = typeof bootstrapUserId === 'string' ? bootstrapUserId : null;
|
|
270
473
|
} catch {
|
|
271
474
|
// The network failed during token bootstrap; preserve the entire queue.
|
|
272
475
|
return;
|
|
273
476
|
}
|
|
274
477
|
|
|
478
|
+
const blocked: BlockedMutation[] = [];
|
|
479
|
+
const blockedWorkspaces = new Set<string | null>();
|
|
275
480
|
let replayed = 0;
|
|
276
481
|
for (const mut of mutations) {
|
|
482
|
+
// Derive legacy scope from the path rather than assuming it is independent.
|
|
483
|
+
const workspace = workspaceFromPath(mut.path);
|
|
484
|
+
const reason = replayBlockReason(mut, currentUserId)
|
|
485
|
+
?? (mut.state === 'needs-attention' ? mut.attentionReason ?? 'outcome-unknown' : null)
|
|
486
|
+
?? (blockedWorkspaces.has(null) || (workspace === null ? blockedWorkspaces.size > 0 : blockedWorkspaces.has(workspace))
|
|
487
|
+
? 'workspace-order' : null);
|
|
488
|
+
if (reason) {
|
|
489
|
+
// Never replayed, never deleted: kept for its owner and surfaced so the
|
|
490
|
+
// page can offer explicit recovery. Skipping does not stop the loop, so
|
|
491
|
+
// blocked records cannot wedge unrelated owned records behind them.
|
|
492
|
+
blocked.push({
|
|
493
|
+
id: mut.id,
|
|
494
|
+
method: mut.method,
|
|
495
|
+
path: mut.path,
|
|
496
|
+
ownerId: mut.ownerId ?? null,
|
|
497
|
+
workspace,
|
|
498
|
+
reason,
|
|
499
|
+
});
|
|
500
|
+
if (mut.ownerId === currentUserId && currentUserId !== null) blockedWorkspaces.add(workspace);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
277
503
|
try {
|
|
278
504
|
const opts: RequestInit = {
|
|
279
505
|
method: mut.method,
|
|
280
506
|
headers: {
|
|
281
507
|
'Content-Type': 'application/json',
|
|
282
508
|
'X-CSRF-Token': replayCsrfToken,
|
|
509
|
+
'X-PM-Expected-Account': mut.ownerId!,
|
|
510
|
+
...(mut.idempotencyKey ? { 'Idempotency-Key': mut.idempotencyKey } : {}),
|
|
283
511
|
},
|
|
284
512
|
credentials: 'include',
|
|
285
513
|
};
|
|
@@ -295,8 +523,27 @@ async function flushMutationQueue(): Promise<void> {
|
|
|
295
523
|
replayed++;
|
|
296
524
|
} else {
|
|
297
525
|
console.warn('Offline mutation failed:', mut.method, mut.path, res.status);
|
|
298
|
-
|
|
299
|
-
|
|
526
|
+
const body = await res.json().catch(() => null) as { code?: unknown } | null;
|
|
527
|
+
let attention: QueuedMutation['attentionReason'];
|
|
528
|
+
if (body?.code === 'PM_IDEMPOTENCY_OUTCOME_UNKNOWN') attention = 'outcome-unknown';
|
|
529
|
+
if (res.status >= 500) {
|
|
530
|
+
mut.serverFailures = (mut.serverFailures ?? 0) + 1;
|
|
531
|
+
if (mut.serverFailures >= 3) attention = 'repeated-5xx';
|
|
532
|
+
} else if (!attention && mut.serverFailures) {
|
|
533
|
+
mut.serverFailures = 0;
|
|
534
|
+
}
|
|
535
|
+
if (attention) {
|
|
536
|
+
mut.state = 'needs-attention';
|
|
537
|
+
mut.attentionReason = attention;
|
|
538
|
+
blocked.push({ id: mut.id, method: mut.method, path: mut.path,
|
|
539
|
+
ownerId: mut.ownerId ?? null, workspace, reason: attention });
|
|
540
|
+
}
|
|
541
|
+
// Persist before allowing independent workspace replay; failure stops the flush.
|
|
542
|
+
const db = await openMutationDB();
|
|
543
|
+
const tx = db.transaction(MUTATION_STORE, 'readwrite');
|
|
544
|
+
tx.objectStore(MUTATION_STORE).put(mut);
|
|
545
|
+
await transactionDone(tx);
|
|
546
|
+
blockedWorkspaces.add(workspace);
|
|
300
547
|
}
|
|
301
548
|
} catch {
|
|
302
549
|
// Network failed again — stop processing
|
|
@@ -307,6 +554,10 @@ async function flushMutationQueue(): Promise<void> {
|
|
|
307
554
|
// Notify clients about replayed mutations
|
|
308
555
|
const remainingRead = await getQueuedMutations();
|
|
309
556
|
const clients = await sw.clients.matchAll();
|
|
557
|
+
// Empty reports remove a previously rendered or dismissed recovery set.
|
|
558
|
+
clients.forEach((client) => {
|
|
559
|
+
client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked });
|
|
560
|
+
});
|
|
310
561
|
if (!remainingRead.ok) {
|
|
311
562
|
// Could not re-read the queue — do NOT claim all mutations were replayed.
|
|
312
563
|
// Report a partial result with the known replayed count so unreplayed
|
|
@@ -332,13 +583,90 @@ async function flushMutationQueue(): Promise<void> {
|
|
|
332
583
|
clients.forEach((client) => {
|
|
333
584
|
client.postMessage({
|
|
334
585
|
type: 'MUTATIONS_PARTIAL',
|
|
335
|
-
replayed
|
|
586
|
+
replayed,
|
|
336
587
|
remaining: remaining.length,
|
|
337
588
|
});
|
|
338
589
|
});
|
|
339
590
|
}
|
|
340
591
|
}
|
|
341
592
|
|
|
593
|
+
/** Pending queue operations share one chain across messages, sync and online events. */
|
|
594
|
+
let queueOperation: Promise<void> = Promise.resolve();
|
|
595
|
+
|
|
596
|
+
/** Serialize queue adoption and replay, recovering the chain after an operation fails. */
|
|
597
|
+
function runQueueOperation(operation: () => Promise<void>): Promise<void> {
|
|
598
|
+
const pending = queueOperation.then(async () => {
|
|
599
|
+
await sessionUpdate.catch(() => {});
|
|
600
|
+
await operation();
|
|
601
|
+
});
|
|
602
|
+
queueOperation = pending.catch(() => {});
|
|
603
|
+
return pending;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Flush under the same single-flight chain used for explicit recovery. */
|
|
607
|
+
function flushMutationQueue(): Promise<void> {
|
|
608
|
+
return runQueueOperation(replayQueuedMutations);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Explicitly rebind unknown-owner records to the signed-in account.
|
|
613
|
+
*
|
|
614
|
+
* The page offers this after a flush surfaces records with the `unknown-owner`
|
|
615
|
+
* reason: the signed-in user states that this queued work is theirs. Only
|
|
616
|
+
* records whose ownership is unknown are adoptable — a record already owned by
|
|
617
|
+
* another account is never reassigned, because its true owner may still
|
|
618
|
+
* return to replay it.
|
|
619
|
+
*
|
|
620
|
+
* @param ids - Only these record ids are adopted, or all unknown records when absent.
|
|
621
|
+
* @param ownerId - The approving page's account, verified against the server session.
|
|
622
|
+
* @returns Whether the current server session approved adoption.
|
|
623
|
+
*/
|
|
624
|
+
function adoptUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise<boolean> {
|
|
625
|
+
return updateRecoveryRecords(ownerId, (store, mut) => {
|
|
626
|
+
if ((mut.ownerId === undefined || mut.ownerId === null)
|
|
627
|
+
&& (ids === undefined || ids.includes(mut.id))
|
|
628
|
+
&& !/^\/auth(?:\/|\?|$)/i.test(mut.path)) {
|
|
629
|
+
store.put({ ...mut, ownerId, idempotencyKey: mut.idempotencyKey || newIdempotencyKey() });
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Adopt records through the common queue-operation chain used by flushes. */
|
|
635
|
+
function rebindUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise<void> {
|
|
636
|
+
return runQueueOperation(async () => { await adoptUnknownOwnerRecords(ids, ownerId); });
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Apply owner-approved retry with a fresh key or discard atomically; never change another owner's work. */
|
|
640
|
+
function recoverAttentionRecord(id: number, ownerId: string, action: 'retry-new' | 'discard'): Promise<boolean> {
|
|
641
|
+
return updateRecoveryRecords(ownerId, (store, mut) => {
|
|
642
|
+
if (mut.id !== id || mut.ownerId !== ownerId || mut.state !== 'needs-attention') return;
|
|
643
|
+
if (action === 'discard') store.delete(id);
|
|
644
|
+
else store.put({ ...mut, state: 'queued', attentionReason: undefined, serverFailures: 0, idempotencyKey: newIdempotencyKey() });
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Verify the approving account, then read and update recovery records in one live IndexedDB transaction. */
|
|
649
|
+
async function updateRecoveryRecords(ownerId: string, update: (store: IDBObjectStore, mut: QueuedMutation) => void): Promise<boolean> {
|
|
650
|
+
const approval = await fetch('/api/auth/me', {
|
|
651
|
+
credentials: 'include', cache: 'no-store', headers: { 'X-PM-Expected-Account': ownerId },
|
|
652
|
+
});
|
|
653
|
+
if (!approval.ok) return false;
|
|
654
|
+
const me = await approval.json().catch(() => null) as MeBody | null;
|
|
655
|
+
if (me?.user?.id !== ownerId) return false;
|
|
656
|
+
const db = await openMutationDB();
|
|
657
|
+
const tx = db.transaction(MUTATION_STORE, 'readwrite');
|
|
658
|
+
const done = transactionDone(tx);
|
|
659
|
+
const store = tx.objectStore(MUTATION_STORE);
|
|
660
|
+
const request = store.getAll();
|
|
661
|
+
request.onsuccess = () => {
|
|
662
|
+
for (const mut of request.result as QueuedMutation[]) {
|
|
663
|
+
update(store, mut);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
await done;
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
|
|
342
670
|
// ── Install ──
|
|
343
671
|
sw.addEventListener('install', (event: ExtendableEvent) => {
|
|
344
672
|
event.waitUntil(
|
|
@@ -365,32 +693,53 @@ sw.addEventListener('fetch', (event: FetchEvent) => {
|
|
|
365
693
|
|
|
366
694
|
// API calls: try network, queue mutations if offline
|
|
367
695
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/healthz')) {
|
|
696
|
+
// Session responses must reach the browser with their live cookie changes.
|
|
697
|
+
// Remove caller keys too; failed auth requests are never queued.
|
|
698
|
+
if (/^\/api\/auth(?:\/|$)/i.test(url.pathname)) {
|
|
699
|
+
const headers = new Headers(event.request.headers);
|
|
700
|
+
headers.delete('Idempotency-Key');
|
|
701
|
+
headers.delete('X-PM-Expected-Account');
|
|
702
|
+
event.respondWith(fetch(new Request(event.request, { headers })));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
368
705
|
// Queue write operations (POST, PUT, PATCH, DELETE) when offline
|
|
369
706
|
if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
|
|
707
|
+
const headers = new Headers(event.request.headers);
|
|
708
|
+
if (!headers.has('Idempotency-Key')) headers.set('Idempotency-Key', newIdempotencyKey());
|
|
370
709
|
event.respondWith(
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
body
|
|
381
|
-
|
|
382
|
-
|
|
710
|
+
(async () => {
|
|
711
|
+
await sessionUpdate.catch(() => {});
|
|
712
|
+
// Capture identity before sending, never after a lost response.
|
|
713
|
+
const ownerId = headers.get('X-PM-Expected-Account') ?? await readSession();
|
|
714
|
+
if (ownerId !== null) headers.set('X-PM-Expected-Account', ownerId);
|
|
715
|
+
const request = new Request(event.request, { headers });
|
|
716
|
+
const queuedRequest = request.clone();
|
|
717
|
+
return fetch(request).catch(async () => {
|
|
718
|
+
// Network failed — queue the mutation for later.
|
|
719
|
+
let body: unknown = undefined;
|
|
720
|
+
try {
|
|
721
|
+
body = await queuedRequest.json();
|
|
722
|
+
} catch { /* no body */ }
|
|
723
|
+
const queued = await queueMutation(
|
|
724
|
+
event.request.method,
|
|
725
|
+
url.pathname.replace('/api', '') + url.search,
|
|
726
|
+
body,
|
|
727
|
+
headers.get('Idempotency-Key') ?? newIdempotencyKey(),
|
|
728
|
+
ownerId,
|
|
729
|
+
);
|
|
730
|
+
if (queued) {
|
|
731
|
+
return new Response(
|
|
732
|
+
JSON.stringify({ queued: true, message: 'Request queued for when you are back online' }),
|
|
733
|
+
{ status: 202, headers: { 'Content-Type': 'application/json' } },
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
// Persistence failed — do not claim the mutation was queued.
|
|
383
737
|
return new Response(
|
|
384
|
-
JSON.stringify({
|
|
385
|
-
{ status:
|
|
738
|
+
JSON.stringify({ error: 'Offline and unable to queue mutation', queued: false }),
|
|
739
|
+
{ status: 503, headers: { 'Content-Type': 'application/json' } },
|
|
386
740
|
);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
return new Response(
|
|
390
|
-
JSON.stringify({ error: 'Offline and unable to queue mutation', queued: false }),
|
|
391
|
-
{ status: 503, headers: { 'Content-Type': 'application/json' } },
|
|
392
|
-
);
|
|
393
|
-
}),
|
|
741
|
+
});
|
|
742
|
+
})(),
|
|
394
743
|
);
|
|
395
744
|
return;
|
|
396
745
|
}
|
|
@@ -489,9 +838,14 @@ sw.addEventListener('fetch', (event: FetchEvent) => {
|
|
|
489
838
|
);
|
|
490
839
|
});
|
|
491
840
|
|
|
841
|
+
// Serialise session writes and recovery so adoption cannot read an older login.
|
|
842
|
+
let sessionUpdate: Promise<void> = Promise.resolve();
|
|
843
|
+
|
|
492
844
|
// ── Messages ──
|
|
493
845
|
sw.addEventListener('message', (event: ExtendableMessageEvent) => {
|
|
494
|
-
const data = event.data as
|
|
846
|
+
const data = event.data as
|
|
847
|
+
| { type?: string; urls?: string[]; userId?: unknown; ids?: unknown; ownerId?: unknown; id?: unknown; action?: unknown }
|
|
848
|
+
| null;
|
|
495
849
|
if (data && data.type === 'SKIP_WAITING') {
|
|
496
850
|
sw.skipWaiting();
|
|
497
851
|
}
|
|
@@ -500,7 +854,44 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => {
|
|
|
500
854
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(urls).catch(() => {}));
|
|
501
855
|
}
|
|
502
856
|
if (data && data.type === 'FLUSH_QUEUE') {
|
|
503
|
-
|
|
857
|
+
event.waitUntil(sessionUpdate.then(() => flushMutationQueue()).catch(() => {
|
|
858
|
+
console.warn('Failed to flush offline mutations');
|
|
859
|
+
}));
|
|
860
|
+
}
|
|
861
|
+
if (data && data.type === 'AUTH_SESSION') {
|
|
862
|
+
// The page names the signed-in account so queued mutations are bound to
|
|
863
|
+
// it. `null` (logout) is a valid broadcast: it means ownership of later
|
|
864
|
+
// queues is unknown, never the next signed-in account.
|
|
865
|
+
const userId = typeof data.userId === 'string' ? data.userId : null;
|
|
866
|
+
sessionUpdate = sessionUpdate.catch(() => {}).then(() => saveSession(userId));
|
|
867
|
+
event.waitUntil(sessionUpdate.catch(() => {
|
|
868
|
+
console.warn('Failed to persist offline session');
|
|
869
|
+
}));
|
|
870
|
+
}
|
|
871
|
+
if (data && data.type === 'REBIND_RECORDS') {
|
|
872
|
+
// Explicit recovery: adopt unknown-owner records for the signed-in
|
|
873
|
+
// account. The ids are optional; without them every unknown-owner record
|
|
874
|
+
// is adopted.
|
|
875
|
+
const ids = Array.isArray(data.ids)
|
|
876
|
+
? data.ids.filter((id): id is number => typeof id === 'number')
|
|
877
|
+
: undefined;
|
|
878
|
+
const ownerId = data.ownerId;
|
|
879
|
+
if (typeof ownerId !== 'string' || ownerId.length === 0) return;
|
|
880
|
+
event.waitUntil(runQueueOperation(async () => {
|
|
881
|
+
if (await adoptUnknownOwnerRecords(ids, ownerId)) await replayQueuedMutations();
|
|
882
|
+
}).catch(() => {
|
|
883
|
+
console.warn('Failed to adopt offline mutations');
|
|
884
|
+
}));
|
|
885
|
+
}
|
|
886
|
+
if (data && data.type === 'RECOVER_RECORD') {
|
|
887
|
+
const { id, ownerId, action } = data;
|
|
888
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id) || typeof ownerId !== 'string' || ownerId.length === 0
|
|
889
|
+
|| (action !== 'retry-new' && action !== 'discard')) return;
|
|
890
|
+
event.waitUntil(runQueueOperation(async () => {
|
|
891
|
+
if (await recoverAttentionRecord(id, ownerId, action)) await replayQueuedMutations();
|
|
892
|
+
}).catch(() => {
|
|
893
|
+
console.warn('Failed to recover offline mutation');
|
|
894
|
+
}));
|
|
504
895
|
}
|
|
505
896
|
});
|
|
506
897
|
|
|
@@ -533,5 +924,12 @@ sw.addEventListener('online', () => {
|
|
|
533
924
|
// rather than keeping both.
|
|
534
925
|
const __testGlobals = globalThis as unknown as Record<string, unknown>;
|
|
535
926
|
if (__testGlobals.__swTestHarness) {
|
|
536
|
-
__testGlobals.__swInternals = {
|
|
927
|
+
__testGlobals.__swInternals = {
|
|
928
|
+
getQueuedMutations,
|
|
929
|
+
flushMutationQueue,
|
|
930
|
+
queueMutation,
|
|
931
|
+
clearMutation,
|
|
932
|
+
saveSession,
|
|
933
|
+
rebindRecords: rebindUnknownOwnerRecords,
|
|
934
|
+
};
|
|
537
935
|
}
|
package/public/src/views/auth.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// AUTH VIEW
|
|
3
3
|
// ═══════════════════════════════════════════════════════════════
|
|
4
4
|
import { state } from '../state.js';
|
|
5
|
-
import { api } from '../api.js';
|
|
5
|
+
import { api, syncServiceWorkerSession } from '../api.js';
|
|
6
6
|
import { bootApp } from '../app.js';
|
|
7
7
|
import { t, translateError } from '../i18n.js';
|
|
8
8
|
/** Store the signed-in user outside the async submit handler. */
|
|
@@ -99,6 +99,7 @@ export async function submitAuth(e) {
|
|
|
99
99
|
data = await api('POST', '/auth/register', { email, password, displayName: name || email.split('@')[0] });
|
|
100
100
|
}
|
|
101
101
|
rememberSignedInUser(data.user);
|
|
102
|
+
syncServiceWorkerSession(data.user);
|
|
102
103
|
await bootApp();
|
|
103
104
|
}
|
|
104
105
|
catch (err) {
|
|
@@ -121,6 +122,7 @@ export async function logout() {
|
|
|
121
122
|
state.user = null;
|
|
122
123
|
state.projects = [];
|
|
123
124
|
state.currentProject = null;
|
|
125
|
+
syncServiceWorkerSession(null);
|
|
124
126
|
showAuth();
|
|
125
127
|
}
|
|
126
128
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,YAAY;AACZ,kEAAkE;AAClE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"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"}
|