@pylonsync/sync 0.3.188 → 0.3.192
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/package.json +1 -1
- package/src/ids.ts +60 -0
- package/src/index.ts +165 -716
- package/src/local-store.ts +309 -0
- package/src/mutation-queue.ts +153 -0
- package/src/transport.ts +205 -0
- package/src/types.ts +136 -0
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
// the optimistic ghosts before
|
|
987
|
-
//
|
|
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
|
-
*
|
|
1552
|
-
*
|
|
1553
|
-
*
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1556
|
-
*
|
|
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
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
|
1793
|
-
//
|
|
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
|
-
//
|
|
2166
|
-
//
|
|
2167
|
-
//
|
|
2168
|
-
//
|
|
2169
|
-
|
|
2170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|