@pylonsync/sync 0.3.189 → 0.3.193

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/src/index.ts CHANGED
@@ -8,7 +8,44 @@
8
8
  // projection + convergence model.
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
+ import {
12
+ pylonFetch,
13
+ PylonHttpError,
14
+ type TransportConfig,
15
+ } from "./transport";
16
+ import { LocalStore } from "./local-store";
17
+ import { MutationQueue } from "./mutation-queue";
18
+ import { generateClientId, generateId } from "./ids";
11
19
  export { IndexedDBPersistence, persistChange } from "./persistence";
20
+ export {
21
+ buildRequest,
22
+ pylonFetch,
23
+ pylonFetchRaw,
24
+ PylonHttpError,
25
+ resolveBaseUrl,
26
+ } from "./transport";
27
+ export type { PylonRequestInit, TransportConfig } from "./transport";
28
+ export { LocalStore } from "./local-store";
29
+ export {
30
+ MutationQueue,
31
+ type MutationQueuePersistence,
32
+ type PendingMutation,
33
+ } from "./mutation-queue";
34
+ export { generateId } from "./ids";
35
+ export type {
36
+ ChangeEvent,
37
+ ClientChange,
38
+ PullResponse,
39
+ PushOpResult,
40
+ PushResponse,
41
+ ReactiveMessage,
42
+ ReactiveSpec,
43
+ ResolvedSession,
44
+ Row,
45
+ SyncConnectionStatus,
46
+ SyncCursor,
47
+ TransportType,
48
+ } from "./types";
12
49
  export {
13
50
  defaultStorage,
14
51
  createWriteThroughStorage,
@@ -17,579 +54,29 @@ export {
17
54
 
18
55
  import { defaultStorage } from "./storage";
19
56
 
20
- export interface ChangeEvent {
21
- seq: number;
22
- entity: string;
23
- row_id: string;
24
- kind: "insert" | "update" | "delete";
25
- data?: Record<string, unknown>;
26
- timestamp: string;
27
- }
28
-
29
- export interface SyncCursor {
30
- last_seq: number;
31
- }
32
-
33
- export interface PullResponse {
34
- changes: ChangeEvent[];
35
- cursor: SyncCursor;
36
- has_more: boolean;
37
- }
38
-
39
- /**
40
- * Server-resolved auth/session state. Shape mirrors what `/api/auth/me`
41
- * returns (which is `AuthContext` from the Rust side, with camelCase
42
- * normalization on the way out).
43
- *
44
- * `userId=null` means anonymous. `tenantId=null` means the user hasn't
45
- * selected an org yet (or the backend is single-tenant).
46
- */
47
- export interface ResolvedSession {
48
- userId: string | null;
49
- tenantId: string | null;
50
- isAdmin: boolean;
51
- roles: string[];
52
- }
53
-
54
- /**
55
- * Per-op result entry for `/api/sync/push`. Returned in the `results`
56
- * array so the client can map each mutation back to its op_id and
57
- * know exactly which applied / deduped / failed. Server emits these
58
- * in arrival order (one per input change). Codex P1: the previous
59
- * `{applied, deduped, errors}` count-based shape lost per-op
60
- * mapping — clients had to guess by ordering and got it wrong on
61
- * partial failures, stranding optimistic ghosts.
62
- */
63
- export interface PushOpResult {
64
- /** op_id from the request, if the client supplied one. */
65
- op_id?: string | null;
66
- status: "applied" | "deduped" | "error";
67
- /** Assigned seq when `status === "applied"`. */
68
- seq?: number;
69
- /** Server's error message when `status === "error"`. */
70
- error?: string;
71
- }
72
-
73
- export interface PushResponse {
74
- applied: number;
75
- deduped: number;
76
- errors: string[];
77
- /** Per-op results in arrival order. Prefer this over the count
78
- * fields for status mapping. */
79
- results?: PushOpResult[];
80
- cursor: SyncCursor;
81
- }
82
-
83
- export interface ClientChange {
84
- entity: string;
85
- row_id: string;
86
- kind: "insert" | "update" | "delete";
87
- data?: Record<string, unknown>;
88
- /**
89
- * Client-minted idempotency key. The server tracks recently-seen op_ids
90
- * and returns a no-op success for replays. Supply this on every retry of
91
- * the same logical mutation — the `MutationQueue` does so automatically.
92
- */
93
- op_id?: string;
94
- }
95
-
96
- /**
97
- * Reactive subscription spec — what the server needs to replay a
98
- * subscription if the client reconnects. Cached client-side so the
99
- * `ws.onopen` reconnect sweep can re-register every active sub
100
- * without the React hooks having to know about reconnect lifecycle.
101
- */
102
- export interface ReactiveSpec {
103
- fn_name: string;
104
- args: unknown;
105
- }
106
-
107
- /**
108
- * Push message routed to a reactive subscription handler. `result`
109
- * fires on initial run + every time the server's re-run produces a
110
- * value whose hash differs from the last push. `error` fires when
111
- * the server can't execute the handler (function not registered,
112
- * reactive runtime unavailable, runtime error in user code).
113
- */
114
- export type ReactiveMessage =
115
- | { kind: "result"; result: unknown }
116
- | { kind: "error"; code: string; message: string };
117
-
118
- // ---------------------------------------------------------------------------
119
- // Local store — in-memory replica of server state
120
- // ---------------------------------------------------------------------------
121
-
122
- export type Row = Record<string, unknown>;
123
-
124
- export class LocalStore {
125
- private tables: Map<string, Map<string, Row>> = new Map();
126
- /**
127
- * Tombstones: `(entity, row_id) -> deletedAt seq`. A row whose id is in
128
- * here has been deleted; any insert/update event older than the tombstone
129
- * is ignored so an out-of-order replay cannot resurrect it.
130
- *
131
- * Without tombstones, a delete followed by a reconnect-driven replay of
132
- * the original insert would re-materialize the row — "last write wins"
133
- * was decided by arrival order instead of event sequence.
134
- *
135
- * The tombstone seq comes from the server's `ChangeEvent.seq`. Client-
136
- * triggered optimistic deletes use `Number.MAX_SAFE_INTEGER` so they
137
- * dominate anything a concurrent pull could replay.
138
- */
139
- private tombstones: Map<string, Map<string, number>> = new Map();
140
- /**
141
- * Pending optimistic deletes — `(entity, row_id)` pairs the local
142
- * client has dropped but the server hasn't yet confirmed. Stored
143
- * separately from `tombstones` because the optimistic "block any
144
- * incoming insert/update for this row" guard runs at infinite
145
- * seq, but the real server delete seq (typically 4–6 digits)
146
- * would never max-merge past `Number.MAX_SAFE_INTEGER`. The old
147
- * design left MAX_SAFE_INTEGER permanently in `tombstones` for
148
- * any optimistically-deleted id, so a future server-issued insert
149
- * with seq=N could never pass the `seq < tombstoneSeq` check —
150
- * the row id was blocked for the lifetime of the replica.
151
- */
152
- private optimisticTombstones: Map<string, Set<string>> = new Map();
153
- private listeners: Set<() => void> = new Set();
154
-
155
- /** Get all rows for an entity. */
156
- list(entity: string): Row[] {
157
- const table = this.tables.get(entity);
158
- if (!table) return [];
159
- return Array.from(table.values());
160
- }
161
-
162
- /** Get a row by ID. */
163
- get(entity: string, id: string): Row | null {
164
- return this.tables.get(entity)?.get(id) ?? null;
165
- }
166
-
167
- /** Snapshot of every entity name with at least one local row. Used by
168
- * `SyncEngine.reconcile` to know which tables to diff against the
169
- * server's current truth. Returning a fresh array lets callers iterate
170
- * without holding a reference into the live map. */
171
- entityNames(): string[] {
172
- const names: string[] = [];
173
- for (const [name, table] of this.tables) {
174
- if (table.size > 0) names.push(name);
175
- }
176
- return names;
177
- }
178
-
179
- /**
180
- * Remove a row recorded as deleted by the server-truth reconciler.
181
- * Records a tombstone at `tombstoneSeq` so a stale insert/update
182
- * replayed afterwards (e.g. from a slow WS frame) doesn't resurrect
183
- * it. Callers pass the current sync cursor as `tombstoneSeq` — any
184
- * future change events will have higher seqs and pass the tombstone
185
- * check; older replays will be filtered.
186
- *
187
- * Differs from `optimisticDelete` which uses `MAX_SAFE_INTEGER` (the
188
- * caller is asserting it knows the future). Reconciliation only knows
189
- * what the server currently shows; a row re-created server-side later
190
- * MUST be allowed back in.
191
- */
192
- reconcileRemove(entity: string, id: string, tombstoneSeq: number): boolean {
193
- const table = this.tables.get(entity);
194
- if (!table || !table.has(id)) return false;
195
- table.delete(id);
196
- this.recordTombstone(entity, id, tombstoneSeq);
197
- return true;
198
- }
199
-
200
- /** Check if `(entity, id)` has a tombstone. */
201
- private isTombstoned(entity: string, id: string, at_seq?: number): boolean {
202
- // Pending optimistic delete — block everything until the server's
203
- // real delete arrives and supersedes us.
204
- if (this.optimisticTombstones.get(entity)?.has(id)) return true;
205
- const tombSeq = this.tombstones.get(entity)?.get(id);
206
- if (tombSeq === undefined) return false;
207
- // If the caller didn't tell us when their change happened, treat as
208
- // "this change is older than the tombstone". Safer default.
209
- if (at_seq === undefined) return true;
210
- return at_seq < tombSeq;
211
- }
212
-
213
- private recordTombstone(entity: string, id: string, seq: number): void {
214
- // A real (server-issued) tombstone supersedes any pending optimistic
215
- // entry for this id. Without this drop, the optimistic
216
- // MAX_SAFE_INTEGER entry would persist forever and block future
217
- // re-creations of the same id (the case codex flagged P1).
218
- this.optimisticTombstones.get(entity)?.delete(id);
219
- if (!this.tombstones.has(entity)) {
220
- this.tombstones.set(entity, new Map());
221
- }
222
- const existing = this.tombstones.get(entity)!.get(id);
223
- if (existing === undefined || seq > existing) {
224
- this.tombstones.get(entity)!.set(id, seq);
225
- }
226
- }
227
-
228
- /** Apply a change event to the local store. */
229
- applyChange(change: ChangeEvent): void {
230
- if (!this.tables.has(change.entity)) {
231
- this.tables.set(change.entity, new Map());
232
- }
233
- const table = this.tables.get(change.entity)!;
234
-
235
- // Drop insert/update events that arrive AFTER a delete for the same row.
236
- // The tombstone map records the seq of the delete; anything strictly
237
- // older than that seq is a stale resurrect and must be ignored.
238
- if (
239
- (change.kind === "insert" || change.kind === "update") &&
240
- this.isTombstoned(change.entity, change.row_id, change.seq)
241
- ) {
242
- return;
243
- }
244
-
245
- switch (change.kind) {
246
- case "insert":
247
- if (change.data) {
248
- // Spread data FIRST, then force id = change.row_id. Previously
249
- // id came first and was overridden by any id field in data,
250
- // which let a crafted/buggy server event corrupt the replica's
251
- // primary key on reload.
252
- table.set(change.row_id, {
253
- ...change.data,
254
- id: change.row_id,
255
- });
256
- }
257
- break;
258
- case "update":
259
- if (change.data) {
260
- const existing = table.get(change.row_id) ?? { id: change.row_id };
261
- table.set(change.row_id, {
262
- ...existing,
263
- ...change.data,
264
- id: change.row_id, // authoritative — ignore any id in data
265
- });
266
- }
267
- break;
268
- case "delete":
269
- table.delete(change.row_id);
270
- this.recordTombstone(change.entity, change.row_id, change.seq);
271
- break;
272
- }
273
- }
274
-
275
- /** Apply multiple changes synchronously. Persistence runs fire-and-forget.
276
- * Prefer [`applyChangesAsync`] when you plan to advance a cursor after —
277
- * otherwise a crash can save the cursor before rows hit disk, causing
278
- * permanent missed changes on restart. */
279
- applyChanges(changes: ChangeEvent[]): void {
280
- for (const change of changes) {
281
- this.applyChange(change);
282
- }
283
- this.notify();
284
-
285
- if (this._persistFn) {
286
- for (const change of changes) {
287
- // Persist from the post-merge row in memory so updates don't
288
- // overwrite the on-disk mirror with just the patched columns.
289
- // `applyChange` already merged update.data into the existing row
290
- // (see case "update" above); the raw `change.data` only contains
291
- // the patch and would drop every other column on save.
292
- const merged = this.hydrateFromMemory(change);
293
- void this._persistFn(merged);
294
- }
295
- }
296
- }
297
-
298
- /**
299
- * Apply + persist, awaiting disk writes before returning. Callers that are
300
- * about to advance a cursor based on `changes` MUST use this path —
301
- * otherwise cursor durability is broken: a crash between the memory apply
302
- * and the eventual disk write can persist a cursor that's ahead of the
303
- * replica, skipping those rows forever on restart.
304
- */
305
- async applyChangesAsync(changes: ChangeEvent[]): Promise<void> {
306
- for (const change of changes) {
307
- this.applyChange(change);
308
- }
309
- this.notify();
310
- if (this._persistFn) {
311
- // Persist sequentially in arrival order — `Promise.all` would
312
- // fire every IndexedDB write concurrently and the IDB scheduler
313
- // can resolve them out of order. An `update → delete` pair on
314
- // the same row would race the delete behind the update on disk,
315
- // leaving a stale row in the persisted replica while the cursor
316
- // advanced past the delete. Sequencing here matches the in-memory
317
- // apply order, which itself is sequenced by the engine's
318
- // `applyQueue`.
319
- for (const change of changes) {
320
- const result = this._persistFn(this.hydrateFromMemory(change));
321
- if (result instanceof Promise) {
322
- await result;
323
- }
324
- }
325
- }
326
- }
327
-
328
- /**
329
- * Reshape a change event so its `data` field matches the row as it now
330
- * exists in memory after `applyChange` merged the patch. Persistence
331
- * callers (IndexedDB) save the full row, which only works if they
332
- * receive the full row. Deletes pass through untouched.
333
- */
334
- private hydrateFromMemory(change: ChangeEvent): ChangeEvent {
335
- if (change.kind === "delete") return change;
336
- const merged = this.tables.get(change.entity)?.get(change.row_id);
337
- if (!merged) return change;
338
- return { ...change, data: merged };
339
- }
340
-
341
- /** Set a persistence callback for auto-saving changes. The return type is
342
- * Promise<void> so callers can await. Void-returning callbacks are still
343
- * accepted for backwards compatibility (just not awaitable). */
344
- _persistFn: ((change: ChangeEvent) => void | Promise<void>) | null = null;
345
-
346
- /** Subscribe to store changes. Returns unsubscribe function. */
347
- subscribe(listener: () => void): () => void {
348
- this.listeners.add(listener);
349
- return () => this.listeners.delete(listener);
350
- }
351
-
352
- notify(): void {
353
- for (const listener of this.listeners) {
354
- listener();
355
- }
356
- }
357
-
358
- /** Apply an optimistic insert. Returns a temporary ID. */
359
- optimisticInsert(entity: string, data: Row): string {
360
- const tempId = `_pending_${Date.now()}_${Math.random().toString(36).slice(2)}`;
361
- if (!this.tables.has(entity)) {
362
- this.tables.set(entity, new Map());
363
- }
364
- this.tables.get(entity)!.set(tempId, { id: tempId, ...data });
365
- this.notify();
366
- return tempId;
367
- }
368
-
369
- /**
370
- * Apply an optimistic insert with a caller-provided id.
371
- *
372
- * Used by `useMutation({ optimistic })`: the React hook generates a
373
- * Pylon-shaped id (40-char hex via `generateId()`), threads it
374
- * through the mutation args as `_optimisticId`, and the server
375
- * function honors it on `ctx.db.insert("Entity", { id, ... })`.
376
- * Because the optimistic ghost and the canonical row share the same
377
- * `row_id`, the WS broadcast that follows the mutation lands as a
378
- * field-level merge on top of the optimistic — no delete-then-replace
379
- * flash, no temp-row swap.
380
- *
381
- * Different from `optimisticInsert` (above) which mints a `_pending_`
382
- * id the server can't possibly know about. Use that for fire-and-
383
- * forget UI affordances, and this one whenever the canonical insert
384
- * needs to map back to the same row.
385
- */
386
- optimisticInsertWithId(entity: string, id: string, data: Row): void {
387
- if (!this.tables.has(entity)) {
388
- this.tables.set(entity, new Map());
389
- }
390
- this.tables.get(entity)!.set(id, { ...data, id });
391
- this.notify();
392
- }
393
-
394
- /**
395
- * Roll back an optimistic insert without leaving a tombstone.
396
- *
397
- * Counterpart to `optimisticInsertWithId`. When a mutation rejects,
398
- * we want the ghost row gone but we do NOT want a tombstone — a
399
- * future legitimate insert with the same id (e.g. user retries the
400
- * mutation, or a workflow eventually creates the row) must not be
401
- * blocked. `optimisticDelete` records a MAX_SAFE_INTEGER tombstone
402
- * which is the wrong semantic here; this is just a plain remove.
403
- */
404
- rollbackOptimisticInsert(entity: string, id: string): void {
405
- const removed = this.tables.get(entity)?.delete(id);
406
- if (removed) this.notify();
407
- }
408
-
409
- /** Apply an optimistic update. */
410
- optimisticUpdate(entity: string, id: string, data: Partial<Row>): void {
411
- const table = this.tables.get(entity);
412
- if (!table) return;
413
- const existing = table.get(id);
414
- if (existing) {
415
- table.set(id, { ...existing, ...data });
416
- this.notify();
417
- }
418
- }
419
-
420
- /** Apply an optimistic delete. */
421
- optimisticDelete(entity: string, id: string): void {
422
- this.tables.get(entity)?.delete(id);
423
- // Optimistic delete: block any incoming insert/update for this id
424
- // until the server's authoritative delete arrives. Tracked in
425
- // `optimisticTombstones` rather than `tombstones` so the real
426
- // server seq can supersede it cleanly — the previous design wrote
427
- // MAX_SAFE_INTEGER into `tombstones` and `recordTombstone`'s
428
- // max-merge would never replace it with the smaller real seq,
429
- // leaving the id permanently quarantined.
430
- if (!this.optimisticTombstones.has(entity)) {
431
- this.optimisticTombstones.set(entity, new Set());
432
- }
433
- this.optimisticTombstones.get(entity)!.add(id);
434
- this.notify();
435
- }
436
-
437
- /**
438
- * Drop every table + tombstone in-place, then notify. Used by the sync
439
- * engine's `resetReplica()` on identity flip (token or tenant changed —
440
- * the old replica reflects a different visible set). Kept on
441
- * `LocalStore` so the `tables`/`tombstones` maps stay private.
442
- */
443
- clearAll(): void {
444
- this.tables.clear();
445
- this.tombstones.clear();
446
- this.optimisticTombstones.clear();
447
- this.notify();
448
- }
449
- }
450
-
451
- // ---------------------------------------------------------------------------
452
- // Pending mutation queue — offline-safe write queue
453
- // ---------------------------------------------------------------------------
454
-
455
- export interface PendingMutation {
456
- id: string;
457
- change: ClientChange;
458
- status: "pending" | "applied" | "failed";
459
- error?: string;
460
- }
461
-
462
- /**
463
- * Optional persistence backend for the mutation queue. The default
464
- * IndexedDB persistence layer provides `savePending`/`loadPending`/etc.
465
- * Callers can supply a custom backend for tests or alternative storage.
466
- */
467
- export interface MutationQueuePersistence {
468
- saveAll(mutations: PendingMutation[]): Promise<void>;
469
- loadAll(): Promise<PendingMutation[]>;
470
- }
471
-
472
- /**
473
- * Offline-safe write queue.
474
- *
475
- * Before: the queue was memory-only. A tab crash or refresh silently lost
476
- * every pending write. Now: if a `persistence` backend is provided the queue
477
- * writes-through on every mutation, and `hydrate()` restores pending/failed
478
- * mutations on startup. Applied mutations are pruned during `clear()`.
479
- *
480
- * The `id` scheme is stable (timestamp + random suffix) and is also used
481
- * as the server-side `op_id` for idempotent replay. A retried push carrying
482
- * the same id will short-circuit on the server instead of re-applying.
483
- */
484
- export class MutationQueue {
485
- private queue: PendingMutation[] = [];
486
- private persistence?: MutationQueuePersistence;
487
-
488
- constructor(persistence?: MutationQueuePersistence) {
489
- this.persistence = persistence;
490
- }
491
-
492
- /**
493
- * Attach a persistence backend after construction. The SyncEngine
494
- * uses this to swap in IndexedDB-backed persistence once the DB
495
- * has opened (after the constructor runs). Public so it doesn't
496
- * need a `// @ts-expect-error` to reach in from the same package.
497
- */
498
- attachPersistence(persistence: MutationQueuePersistence): void {
499
- this.persistence = persistence;
500
- }
501
-
502
- /** Load persisted queue state. Call once at startup. */
503
- async hydrate(): Promise<void> {
504
- if (!this.persistence) return;
505
- try {
506
- const loaded = await this.persistence.loadAll();
507
- // Merge in-memory with on-disk. An `add()` that ran while hydrate
508
- // was awaiting `loadAll()` will already have flushed a snapshot
509
- // that didn't include the loaded rows — re-flush after merge so
510
- // disk matches memory again. Without this, a crash between the
511
- // interleaved add-flush and the next mutation would leave the
512
- // on-disk snapshot missing the loaded mutations.
513
- const existingIds = new Set(this.queue.map((m) => m.id));
514
- let mergedAny = false;
515
- for (const m of loaded) {
516
- if (!existingIds.has(m.id)) {
517
- this.queue.push(m);
518
- mergedAny = true;
519
- }
520
- }
521
- if (mergedAny) this.flush();
522
- } catch (err) {
523
- // Broken storage shouldn't prevent the app from running — warn and
524
- // degrade to memory-only mode.
525
- console.warn("[sync] mutation-queue hydrate failed:", err);
526
- }
527
- }
528
-
529
- /** Add a pending mutation. Returns the op_id used for server idempotency. */
530
- add(change: ClientChange): string {
531
- const id = `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
532
- // Attach op_id on the outgoing ClientChange itself so the server can dedupe.
533
- const changeWithOp: ClientChange = { ...change, op_id: id };
534
- this.queue.push({ id, change: changeWithOp, status: "pending" });
535
- this.flush();
536
- return id;
537
- }
57
+ // Type-only imports for the SyncEngine implementation that follows.
58
+ // Public exports of these types live at the top of this file (re-
59
+ // exported from `./types`).
60
+ import type {
61
+ ChangeEvent,
62
+ ClientChange,
63
+ PullResponse,
64
+ PushOpResult,
65
+ PushResponse,
66
+ ReactiveMessage,
67
+ ReactiveSpec,
68
+ ResolvedSession,
69
+ Row,
70
+ SyncConnectionStatus,
71
+ SyncCursor,
72
+ TransportType,
73
+ } from "./types";
538
74
 
539
- pending(): PendingMutation[] {
540
- return this.queue.filter((m) => m.status === "pending");
541
- }
542
-
543
- markApplied(id: string): void {
544
- const m = this.queue.find((m) => m.id === id);
545
- if (m) m.status = "applied";
546
- this.flush();
547
- }
548
-
549
- markFailed(id: string, error: string): void {
550
- const m = this.queue.find((m) => m.id === id);
551
- if (m) {
552
- m.status = "failed";
553
- m.error = error;
554
- }
555
- this.flush();
556
- }
557
-
558
- /**
559
- * Prune applied mutations. Failed mutations are KEPT so the UI can surface
560
- * them to the user and so retries are possible. Previously this dropped
561
- * failed mutations too, silently discarding server rejections.
562
- */
563
- clear(): void {
564
- this.queue = this.queue.filter(
565
- (m) => m.status === "pending" || m.status === "failed",
566
- );
567
- this.flush();
568
- }
569
-
570
- /** Remove a specific mutation by id. Used by the UI after user ack of failures. */
571
- remove(id: string): void {
572
- this.queue = this.queue.filter((m) => m.id !== id);
573
- this.flush();
574
- }
575
-
576
- /** Fire-and-forget persistence write. Errors are logged but not thrown. */
577
- private flush(): void {
578
- if (!this.persistence) return;
579
- // Snapshot the queue before the async write so we don't race a later mutation.
580
- const snapshot = this.queue.slice();
581
- this.persistence.saveAll(snapshot).catch((err) => {
582
- console.warn("[sync] mutation-queue persist failed:", err);
583
- });
584
- }
585
- }
586
75
 
587
76
  // ---------------------------------------------------------------------------
588
77
  // Sync engine — coordinates pull, push, local store, mutation queue
589
78
  // ---------------------------------------------------------------------------
590
79
 
591
- export type TransportType = "websocket" | "sse" | "poll";
592
-
593
80
  export interface SyncEngineConfig {
594
81
  baseUrl: string;
595
82
  /** Transport type. Default: "websocket". Falls back to polling if connection fails. */
@@ -664,66 +151,6 @@ export interface SyncEngineConfig {
664
151
  * the mutation (e.g. to reference the row from another optimistic
665
152
  * insert in the same gesture).
666
153
  */
667
- let idCounter = 0;
668
- export function generateId(): string {
669
- // BigInt to dodge the 2^53 ceiling — `Date.now() * 1_000_000` busts
670
- // Number.MAX_SAFE_INTEGER for any timestamp past 1973. Hex output is
671
- // padded to 32 chars so it lex-sorts at width boundaries (a 39-char
672
- // id sorts before a 40-char one even when the suffix is larger,
673
- // which would corrupt cursor pagination).
674
- const nanos = BigInt(Date.now()) * 1_000_000n;
675
- const seq = idCounter++ >>> 0;
676
- return nanos.toString(16).padStart(32, "0") + seq.toString(16).padStart(8, "0");
677
- }
678
-
679
- function generateClientId(storage: import("./storage").Storage): string {
680
- const key = "pylon:client_id";
681
- const existing = storage.get(key);
682
- if (existing) return existing;
683
- const fresh = newUuidLike();
684
- storage.set(key, fresh);
685
- return fresh;
686
- }
687
-
688
- function newUuidLike(): string {
689
- try {
690
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
691
- return crypto.randomUUID();
692
- }
693
- } catch {
694
- /* fall through */
695
- }
696
- // Fallback: 20 hex chars from random + time.
697
- const rand = Math.random().toString(36).slice(2, 10);
698
- const t = Date.now().toString(36);
699
- return `cl_${t}_${rand}`;
700
- }
701
-
702
- /**
703
- * Coarse connection state for UI consumers.
704
- *
705
- * - `connecting` — engine is starting up; first WS handshake hasn't
706
- * completed yet. Apps typically render their initial
707
- * skeleton during this state.
708
- * - `connected` — WS is open and we've stayed open long enough to
709
- * consider it stable (5s on the wire). Live queries
710
- * are receiving real-time updates.
711
- * - `reconnecting` — WS dropped (network blip, Fly autostop) and the
712
- * engine is backing off + retrying. Live queries
713
- * keep returning the last-known data; mutations
714
- * queue locally and replay on the next connect.
715
- * - `offline` — engine has been stopped via `engine.stop()` or
716
- * was never started. No retries pending.
717
- *
718
- * The `useSyncStatus` hook in `@pylonsync/react` subscribes to this
719
- * via the existing store notify channel so re-renders happen
720
- * automatically without a separate event bus.
721
- */
722
- export type SyncConnectionStatus =
723
- | "connecting"
724
- | "connected"
725
- | "reconnecting"
726
- | "offline";
727
154
 
728
155
  export class SyncEngine {
729
156
  private config: SyncEngineConfig;
@@ -978,14 +405,13 @@ export class SyncEngine {
978
405
 
979
406
  // Hydrate the mutation queue from disk. Any offline writes
980
407
  // queued before the tab was closed come back as pending here.
981
- // Codex P1: previously these only got pushed on the next
982
- // `push()` tick (polling mode) or when a NEW local mutation
983
- // triggered push. In WebSocket-only mode there's no polling,
984
- // and if the user reloads without making a fresh mutation,
985
- // pull+reconcile (which run shortly after this) can sweep
986
- // the optimistic ghosts before push() ever fires. Fire push
987
- // explicitly here so hydrated offline mutations reach the
988
- // server before reconcile inspects local state.
408
+ //
409
+ // Invariant: hydrated offline mutations reach the server
410
+ // before reconcile inspects local state. Test:
411
+ // `hydrated_offline_mutations_survive_startup_reconcile`.
412
+ // Without an explicit push here, WS-only mode (no polling)
413
+ // would let pull+reconcile sweep the optimistic ghosts before
414
+ // push() ever fires.
989
415
  try {
990
416
  const { IndexedDBMutationPersistence } = await import("./persistence");
991
417
  const mqPersistence = new IndexedDBMutationPersistence(persistence);
@@ -1548,69 +974,36 @@ export class SyncEngine {
1548
974
  }
1549
975
 
1550
976
  /** Shared by `fn()` and any future entity-mutation wrappers. POSTs
1551
- * with the engine's auth, parses JSON, observes
1552
- * `X-Pylon-Change-Seq`, and triggers a one-shot pull when the
1553
- * server says it produced events past our local cursor. The pull
1554
- * short-circuits cheaply (`{changes:[]}`) if WS broadcast already
1555
- * caught us up — so the worst case is one extra in-flight pull
1556
- * per mutation, never a stale render. */
977
+ * through the central transport, observes `X-Pylon-Change-Seq`,
978
+ * and triggers a one-shot pull when the server says it produced
979
+ * events past our local cursor. The pull short-circuits cheaply
980
+ * (`{changes:[]}`) if WS broadcast already caught us up — so the
981
+ * worst case is one extra in-flight pull per mutation, never a
982
+ * stale render. */
1557
983
  private async requestWithChangeSync<T>(
1558
984
  method: string,
1559
985
  path: string,
1560
986
  body?: unknown,
1561
987
  ): Promise<T> {
1562
- const headers: Record<string, string> = {};
1563
- if (body !== undefined) headers["Content-Type"] = "application/json";
1564
- const token =
1565
- this.config.token ??
1566
- this.storage.get(this.tokenStorageKey()) ??
1567
- undefined;
1568
- if (token) headers["Authorization"] = `Bearer ${token}`;
1569
- const res = await fetch(`${this.config.baseUrl}${path}`, {
1570
- method,
1571
- headers,
1572
- credentials: "include",
1573
- body: body !== undefined ? JSON.stringify(body) : undefined,
1574
- });
1575
- // Read the change-seq header BEFORE consuming the body — some
1576
- // fetch polyfills consume headers lazily and discard them after
1577
- // the body stream is drained.
1578
- const seqHeader = res.headers.get("x-pylon-change-seq");
1579
- const text = await res.text();
1580
- let parsed: unknown = null;
1581
- if (text) {
1582
- try {
1583
- parsed = JSON.parse(text);
1584
- } catch {
1585
- // Non-JSON body (HTML proxy error, 204, etc.) — fall through;
1586
- // the !res.ok branch synthesises an Error from the status.
1587
- }
1588
- }
1589
- if (!res.ok) {
1590
- const err = new Error(
1591
- (parsed as { error?: { message?: string } } | null)?.error?.message ??
1592
- `${method} ${path} failed: ${res.status}`,
1593
- ) as Error & { status?: number; code?: string };
1594
- err.status = res.status;
1595
- const code = (parsed as { error?: { code?: string } } | null)?.error
1596
- ?.code;
1597
- if (code) err.code = code;
1598
- throw err;
1599
- }
1600
- // Opportunistic pull when the server reports a seq we haven't
1601
- // applied locally yet. Fire-and-forget — the caller doesn't
1602
- // block on this; useQuery hooks pick up the new data via the
1603
- // store notify whenever the pull lands. Skipped when the seq
1604
- // is already covered (the common case: a write that doesn't
1605
- // affect the caller's visible set, or one whose WS event
1606
- // already raced the response).
1607
- if (seqHeader) {
1608
- const seq = Number(seqHeader);
1609
- if (Number.isFinite(seq) && seq > this.cursor.last_seq) {
1610
- void this.pull();
1611
- }
1612
- }
1613
- return parsed as T;
988
+ return pylonFetch<T>(
989
+ {
990
+ baseUrl: this.config.baseUrl,
991
+ getToken: () =>
992
+ this.config.token ??
993
+ this.storage.get(this.tokenStorageKey()) ??
994
+ undefined,
995
+ onChangeSeq: (seq) => {
996
+ if (seq > this.cursor.last_seq) {
997
+ void this.pull();
998
+ }
999
+ },
1000
+ },
1001
+ path,
1002
+ {
1003
+ method: method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
1004
+ json: body,
1005
+ },
1006
+ );
1614
1007
  }
1615
1008
 
1616
1009
  /** Pull changes from the server. */
@@ -1789,8 +1182,8 @@ export class SyncEngine {
1789
1182
  // reconcile. If a WS event lands while this entity is being
1790
1183
  // pulled, our snapshot is already stale — applying it would
1791
1184
  // overwrite a newer authoritative row. Skip apply in that case
1792
- // and rely on the WS event (which has the correct seq) plus the
1793
- // next reconcile trigger to converge. Codex P1.
1185
+ // and rely on the WS event plus the next reconcile trigger to
1186
+ // converge.
1794
1187
  const cursorBeforeFetch = this.cursor.last_seq;
1795
1188
  let serverRows: Row[];
1796
1189
  try {
@@ -1849,12 +1242,25 @@ export class SyncEngine {
1849
1242
  serverRows: Row[],
1850
1243
  tombstoneSeq: number,
1851
1244
  ): Promise<void> {
1245
+ // Invariant: rows with in-flight or failed mutations are
1246
+ // off-limits to reconcile. Neither the "server row missing from
1247
+ // local snapshot" apply branch nor the "local row missing from
1248
+ // server snapshot" tombstone branch may touch them. A hydrated
1249
+ // offline mutation that hasn't been pushed yet would otherwise
1250
+ // look like a phantom local-only row and get tombstoned before
1251
+ // push has a chance to ship it.
1252
+ // Test: `hydrated_offline_mutations_survive_startup_reconcile`.
1253
+ const pendingKeys = this.mutations.pendingRowKeys();
1852
1254
  const serverIds = new Set<string>();
1853
1255
  const changes: ChangeEvent[] = [];
1854
1256
  for (const row of serverRows) {
1855
1257
  const id = (row as { id?: unknown }).id;
1856
1258
  if (typeof id !== "string" || id.length === 0) continue;
1857
1259
  serverIds.add(id);
1260
+ // Skip any row whose canonical state is still being decided
1261
+ // by an in-flight mutation — applying the server snapshot
1262
+ // would clobber the user's pending edit.
1263
+ if (pendingKeys.has(`${entity}/${id}`)) continue;
1858
1264
  const local = this.store.get(entity, id);
1859
1265
  if (!local) {
1860
1266
  changes.push({
@@ -1901,6 +1307,11 @@ export class SyncEngine {
1901
1307
  for (const local of locals) {
1902
1308
  const id = (local as { id?: unknown }).id;
1903
1309
  if (typeof id !== "string") continue;
1310
+ // Pending mutations protect the row from the removal pass too
1311
+ // — a queued insert that hasn't been pushed yet would otherwise
1312
+ // look like a phantom local-only row and get tombstoned, only
1313
+ // for push() to later resurrect it.
1314
+ if (pendingKeys.has(`${entity}/${id}`)) continue;
1904
1315
  if (!serverIds.has(id)) {
1905
1316
  removalChanges.push({
1906
1317
  seq: tombstoneSeq,
@@ -2162,13 +1573,12 @@ export class SyncEngine {
2162
1573
  client_id: this.clientId,
2163
1574
  });
2164
1575
 
2165
- // Prefer the per-op `results` array (added 0.3.188). Match each
2166
- // by op_id when present, else fall back to positional matching.
2167
- // Codex P1: previous count-based mapping ("first N applied,
2168
- // next M failed") got partial failures wrong — when op 2 of 3
2169
- // failed, op 3 was incorrectly marked failed and a successful
2170
- // retry-after-lost-response came back deduped but stayed
2171
- // pending forever.
1576
+ // Per-op `results` mapping: match by op_id when present, fall
1577
+ // back to positional. Invariant: a partial-failure batch lands
1578
+ // the correct status on each mutation by id, never by position.
1579
+ // Test: `push_partial_failure_maps_results_by_op_id`.
1580
+ let maxAppliedSeq = 0;
1581
+ let hasInFlightDedupe = false;
2172
1582
  if (Array.isArray(resp.results)) {
2173
1583
  const byOpId = new Map<string, PushOpResult>();
2174
1584
  for (const r of resp.results) {
@@ -2180,10 +1590,29 @@ export class SyncEngine {
2180
1590
  (m.change.op_id ? byOpId.get(m.change.op_id) : undefined) ??
2181
1591
  resp.results[i];
2182
1592
  if (!r) continue;
2183
- if (r.status === "applied" || r.status === "deduped") {
1593
+ // applied: first-time commit at r.seq.
1594
+ // replayed: same op_id arrived again after a confirmed apply;
1595
+ // r.seq is the original write's seq. Both are terminal-success
1596
+ // from the client's perspective.
1597
+ // deduped: legacy server response — treat as replayed.
1598
+ if (r.status === "applied" || r.status === "replayed" || r.status === "deduped") {
2184
1599
  this.mutations.markApplied(m.id);
1600
+ if (typeof r.seq === "number" && r.seq > maxAppliedSeq) {
1601
+ maxAppliedSeq = r.seq;
1602
+ }
1603
+ } else if (r.status === "pending") {
1604
+ // A concurrent push carrying this op_id is still in
1605
+ // flight on the server. Keep the mutation queued; a
1606
+ // later push() will retry. The client must NOT mark
1607
+ // applied here — the in-flight writer might fail and
1608
+ // forget the claim, leaving the row un-committed.
1609
+ hasInFlightDedupe = true;
2185
1610
  } else if (r.status === "error") {
2186
- this.mutations.markFailed(m.id, r.error ?? "unknown");
1611
+ const msg =
1612
+ typeof r.error === "string"
1613
+ ? r.error
1614
+ : r.error?.message ?? "unknown";
1615
+ this.mutations.markFailed(m.id, msg);
2187
1616
  }
2188
1617
  }
2189
1618
  } else {
@@ -2203,24 +1632,44 @@ export class SyncEngine {
2203
1632
  }
2204
1633
 
2205
1634
  this.mutations.clear();
1635
+
1636
+ // Catch-up pull: if the server confirmed an apply at a seq
1637
+ // ahead of our local cursor, request the delta now so the
1638
+ // local replica picks up server-side defaults / plugin fields
1639
+ // / linked rows without waiting for the WS broadcast (the WS
1640
+ // event is the happy path; this is the fallback for
1641
+ // dropped/delayed frames).
1642
+ if (maxAppliedSeq > this.cursor.last_seq) {
1643
+ // Fire-and-forget — pull() is internally serialized via
1644
+ // inFlightPull so concurrent triggers from WS + this branch
1645
+ // coalesce.
1646
+ void this.pull();
1647
+ }
1648
+ // If any op came back with status="pending" (a concurrent push
1649
+ // is still in flight on the server for the same op_id), schedule
1650
+ // a retry shortly. The first writer will either Commit (and
1651
+ // we'll get the canonical seq on next push, or pick it up via
1652
+ // WS rebroadcast) or Fail (the entry is forgotten, our retry
1653
+ // takes the Proceed slot). 250ms is short enough that user
1654
+ // perception doesn't notice, long enough to not hot-loop.
1655
+ if (hasInFlightDedupe) {
1656
+ setTimeout(() => {
1657
+ void this.push();
1658
+ }, 250);
1659
+ }
2206
1660
  } catch {
2207
1661
  // Will retry on next tick. op_id makes retries idempotent on the server.
2208
1662
  }
2209
1663
  }
2210
1664
 
2211
- /** Insert a row with optimistic local update. */
1665
+ /** Insert a row with optimistic local update.
1666
+ *
1667
+ * Invariant: the optimistic ghost and the canonical server row
1668
+ * share a single id. The client mints a Pylon-shaped id, threads
1669
+ * it through the data payload, and the server honors it on the
1670
+ * canonical insert. Test:
1671
+ * `insert_optimistic_ghost_and_server_row_share_id`. */
2212
1672
  async insert(entity: string, data: Row): Promise<string> {
2213
- // Codex P1: previously this minted a `_pending_<random>` local id
2214
- // via `optimisticInsert(entity, data)` but sent `data` to the
2215
- // server WITHOUT that id. The server generated its own canonical
2216
- // id, broadcast under that id, and the local replica ended up
2217
- // with two rows: the `_pending_` ghost (never cleared, because
2218
- // server's broadcast doesn't reference it) and the canonical row.
2219
- // Fix: mint a real Pylon-shaped id client-side, force the data
2220
- // payload to carry it, optimistic-insert under that exact id,
2221
- // and queue the mutation with the same id. Server honors the
2222
- // provided id, broadcasts under it, the optimistic ghost is the
2223
- // canonical row.
2224
1673
  const id = generateId();
2225
1674
  const dataWithId = { ...data, id };
2226
1675
  this.store.optimisticInsertWithId(entity, id, dataWithId);