@pylonsync/sync 0.2.4
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 +13 -0
- package/src/index.ts +1543 -0
- package/src/persistence.ts +283 -0
- package/src/storage.ts +119 -0
- package/tsconfig.json +4 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1543 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// pylon sync client
|
|
3
|
+
//
|
|
4
|
+
// Server-authoritative sync with optimistic mutations and an offline write
|
|
5
|
+
// queue. Field-level LWW on update; tombstones on delete. NOT CRDT-backed;
|
|
6
|
+
// see docs/SYNC.md for convergence semantics and what this is/isn't good
|
|
7
|
+
// for. Concurrent same-field writes resolve by server arrival order.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export { IndexedDBPersistence, persistChange } from "./persistence";
|
|
11
|
+
export {
|
|
12
|
+
defaultStorage,
|
|
13
|
+
createWriteThroughStorage,
|
|
14
|
+
type Storage,
|
|
15
|
+
} from "./storage";
|
|
16
|
+
|
|
17
|
+
import { defaultStorage } from "./storage";
|
|
18
|
+
|
|
19
|
+
export interface ChangeEvent {
|
|
20
|
+
seq: number;
|
|
21
|
+
entity: string;
|
|
22
|
+
row_id: string;
|
|
23
|
+
kind: "insert" | "update" | "delete";
|
|
24
|
+
data?: Record<string, unknown>;
|
|
25
|
+
timestamp: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SyncCursor {
|
|
29
|
+
last_seq: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PullResponse {
|
|
33
|
+
changes: ChangeEvent[];
|
|
34
|
+
cursor: SyncCursor;
|
|
35
|
+
has_more: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Server-resolved auth/session state. Shape mirrors what `/api/auth/me`
|
|
40
|
+
* returns (which is `AuthContext` from the Rust side, with camelCase
|
|
41
|
+
* normalization on the way out).
|
|
42
|
+
*
|
|
43
|
+
* `userId=null` means anonymous. `tenantId=null` means the user hasn't
|
|
44
|
+
* selected an org yet (or the backend is single-tenant).
|
|
45
|
+
*/
|
|
46
|
+
export interface ResolvedSession {
|
|
47
|
+
userId: string | null;
|
|
48
|
+
tenantId: string | null;
|
|
49
|
+
isAdmin: boolean;
|
|
50
|
+
roles: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PushResponse {
|
|
54
|
+
applied: number;
|
|
55
|
+
errors: string[];
|
|
56
|
+
cursor: SyncCursor;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ClientChange {
|
|
60
|
+
entity: string;
|
|
61
|
+
row_id: string;
|
|
62
|
+
kind: "insert" | "update" | "delete";
|
|
63
|
+
data?: Record<string, unknown>;
|
|
64
|
+
/**
|
|
65
|
+
* Client-minted idempotency key. The server tracks recently-seen op_ids
|
|
66
|
+
* and returns a no-op success for replays. Supply this on every retry of
|
|
67
|
+
* the same logical mutation — the `MutationQueue` does so automatically.
|
|
68
|
+
*/
|
|
69
|
+
op_id?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Local store — in-memory replica of server state
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export type Row = Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
export class LocalStore {
|
|
79
|
+
private tables: Map<string, Map<string, Row>> = new Map();
|
|
80
|
+
/**
|
|
81
|
+
* Tombstones: `(entity, row_id) -> deletedAt seq`. A row whose id is in
|
|
82
|
+
* here has been deleted; any insert/update event older than the tombstone
|
|
83
|
+
* is ignored so an out-of-order replay cannot resurrect it.
|
|
84
|
+
*
|
|
85
|
+
* Without tombstones, a delete followed by a reconnect-driven replay of
|
|
86
|
+
* the original insert would re-materialize the row — "last write wins"
|
|
87
|
+
* was decided by arrival order instead of event sequence.
|
|
88
|
+
*
|
|
89
|
+
* The tombstone seq comes from the server's `ChangeEvent.seq`. Client-
|
|
90
|
+
* triggered optimistic deletes use `Number.MAX_SAFE_INTEGER` so they
|
|
91
|
+
* dominate anything a concurrent pull could replay.
|
|
92
|
+
*/
|
|
93
|
+
private tombstones: Map<string, Map<string, number>> = new Map();
|
|
94
|
+
private listeners: Set<() => void> = new Set();
|
|
95
|
+
|
|
96
|
+
/** Get all rows for an entity. */
|
|
97
|
+
list(entity: string): Row[] {
|
|
98
|
+
const table = this.tables.get(entity);
|
|
99
|
+
if (!table) return [];
|
|
100
|
+
return Array.from(table.values());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Get a row by ID. */
|
|
104
|
+
get(entity: string, id: string): Row | null {
|
|
105
|
+
return this.tables.get(entity)?.get(id) ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Check if `(entity, id)` has a tombstone. */
|
|
109
|
+
private isTombstoned(entity: string, id: string, at_seq?: number): boolean {
|
|
110
|
+
const tombSeq = this.tombstones.get(entity)?.get(id);
|
|
111
|
+
if (tombSeq === undefined) return false;
|
|
112
|
+
// If the caller didn't tell us when their change happened, treat as
|
|
113
|
+
// "this change is older than the tombstone". Safer default.
|
|
114
|
+
if (at_seq === undefined) return true;
|
|
115
|
+
return at_seq < tombSeq;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private recordTombstone(entity: string, id: string, seq: number): void {
|
|
119
|
+
if (!this.tombstones.has(entity)) {
|
|
120
|
+
this.tombstones.set(entity, new Map());
|
|
121
|
+
}
|
|
122
|
+
const existing = this.tombstones.get(entity)!.get(id);
|
|
123
|
+
if (existing === undefined || seq > existing) {
|
|
124
|
+
this.tombstones.get(entity)!.set(id, seq);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Apply a change event to the local store. */
|
|
129
|
+
applyChange(change: ChangeEvent): void {
|
|
130
|
+
if (!this.tables.has(change.entity)) {
|
|
131
|
+
this.tables.set(change.entity, new Map());
|
|
132
|
+
}
|
|
133
|
+
const table = this.tables.get(change.entity)!;
|
|
134
|
+
|
|
135
|
+
// Drop insert/update events that arrive AFTER a delete for the same row.
|
|
136
|
+
// The tombstone map records the seq of the delete; anything strictly
|
|
137
|
+
// older than that seq is a stale resurrect and must be ignored.
|
|
138
|
+
if (
|
|
139
|
+
(change.kind === "insert" || change.kind === "update") &&
|
|
140
|
+
this.isTombstoned(change.entity, change.row_id, change.seq)
|
|
141
|
+
) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
switch (change.kind) {
|
|
146
|
+
case "insert":
|
|
147
|
+
if (change.data) {
|
|
148
|
+
// Spread data FIRST, then force id = change.row_id. Previously
|
|
149
|
+
// id came first and was overridden by any id field in data,
|
|
150
|
+
// which let a crafted/buggy server event corrupt the replica's
|
|
151
|
+
// primary key on reload.
|
|
152
|
+
table.set(change.row_id, {
|
|
153
|
+
...change.data,
|
|
154
|
+
id: change.row_id,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
case "update":
|
|
159
|
+
if (change.data) {
|
|
160
|
+
const existing = table.get(change.row_id) ?? { id: change.row_id };
|
|
161
|
+
table.set(change.row_id, {
|
|
162
|
+
...existing,
|
|
163
|
+
...change.data,
|
|
164
|
+
id: change.row_id, // authoritative — ignore any id in data
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
case "delete":
|
|
169
|
+
table.delete(change.row_id);
|
|
170
|
+
this.recordTombstone(change.entity, change.row_id, change.seq);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Apply multiple changes synchronously. Persistence runs fire-and-forget.
|
|
176
|
+
* Prefer [`applyChangesAsync`] when you plan to advance a cursor after —
|
|
177
|
+
* otherwise a crash can save the cursor before rows hit disk, causing
|
|
178
|
+
* permanent missed changes on restart. */
|
|
179
|
+
applyChanges(changes: ChangeEvent[]): void {
|
|
180
|
+
for (const change of changes) {
|
|
181
|
+
this.applyChange(change);
|
|
182
|
+
}
|
|
183
|
+
this.notify();
|
|
184
|
+
|
|
185
|
+
if (this._persistFn) {
|
|
186
|
+
for (const change of changes) {
|
|
187
|
+
// Persist from the post-merge row in memory so updates don't
|
|
188
|
+
// overwrite the on-disk mirror with just the patched columns.
|
|
189
|
+
// `applyChange` already merged update.data into the existing row
|
|
190
|
+
// (see case "update" above); the raw `change.data` only contains
|
|
191
|
+
// the patch and would drop every other column on save.
|
|
192
|
+
const merged = this.hydrateFromMemory(change);
|
|
193
|
+
void this._persistFn(merged);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Apply + persist, awaiting disk writes before returning. Callers that are
|
|
200
|
+
* about to advance a cursor based on `changes` MUST use this path —
|
|
201
|
+
* otherwise cursor durability is broken: a crash between the memory apply
|
|
202
|
+
* and the eventual disk write can persist a cursor that's ahead of the
|
|
203
|
+
* replica, skipping those rows forever on restart.
|
|
204
|
+
*/
|
|
205
|
+
async applyChangesAsync(changes: ChangeEvent[]): Promise<void> {
|
|
206
|
+
for (const change of changes) {
|
|
207
|
+
this.applyChange(change);
|
|
208
|
+
}
|
|
209
|
+
this.notify();
|
|
210
|
+
if (this._persistFn) {
|
|
211
|
+
const results = changes.map((c) => this._persistFn!(this.hydrateFromMemory(c)));
|
|
212
|
+
await Promise.all(results.map((r) => (r instanceof Promise ? r : Promise.resolve())));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reshape a change event so its `data` field matches the row as it now
|
|
218
|
+
* exists in memory after `applyChange` merged the patch. Persistence
|
|
219
|
+
* callers (IndexedDB) save the full row, which only works if they
|
|
220
|
+
* receive the full row. Deletes pass through untouched.
|
|
221
|
+
*/
|
|
222
|
+
private hydrateFromMemory(change: ChangeEvent): ChangeEvent {
|
|
223
|
+
if (change.kind === "delete") return change;
|
|
224
|
+
const merged = this.tables.get(change.entity)?.get(change.row_id);
|
|
225
|
+
if (!merged) return change;
|
|
226
|
+
return { ...change, data: merged };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Set a persistence callback for auto-saving changes. The return type is
|
|
230
|
+
* Promise<void> so callers can await. Void-returning callbacks are still
|
|
231
|
+
* accepted for backwards compatibility (just not awaitable). */
|
|
232
|
+
_persistFn: ((change: ChangeEvent) => void | Promise<void>) | null = null;
|
|
233
|
+
|
|
234
|
+
/** Subscribe to store changes. Returns unsubscribe function. */
|
|
235
|
+
subscribe(listener: () => void): () => void {
|
|
236
|
+
this.listeners.add(listener);
|
|
237
|
+
return () => this.listeners.delete(listener);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
notify(): void {
|
|
241
|
+
for (const listener of this.listeners) {
|
|
242
|
+
listener();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Apply an optimistic insert. Returns a temporary ID. */
|
|
247
|
+
optimisticInsert(entity: string, data: Row): string {
|
|
248
|
+
const tempId = `_pending_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
249
|
+
if (!this.tables.has(entity)) {
|
|
250
|
+
this.tables.set(entity, new Map());
|
|
251
|
+
}
|
|
252
|
+
this.tables.get(entity)!.set(tempId, { id: tempId, ...data });
|
|
253
|
+
this.notify();
|
|
254
|
+
return tempId;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Apply an optimistic update. */
|
|
258
|
+
optimisticUpdate(entity: string, id: string, data: Partial<Row>): void {
|
|
259
|
+
const table = this.tables.get(entity);
|
|
260
|
+
if (!table) return;
|
|
261
|
+
const existing = table.get(id);
|
|
262
|
+
if (existing) {
|
|
263
|
+
table.set(id, { ...existing, ...data });
|
|
264
|
+
this.notify();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Apply an optimistic delete. */
|
|
269
|
+
optimisticDelete(entity: string, id: string): void {
|
|
270
|
+
this.tables.get(entity)?.delete(id);
|
|
271
|
+
// Client-side deletes dominate any concurrent server replay until the
|
|
272
|
+
// server confirms; use MAX_SAFE_INTEGER as the tombstone seq. When the
|
|
273
|
+
// server's real delete event arrives it will refresh the tombstone with
|
|
274
|
+
// the authoritative seq (via `recordTombstone`'s max-of).
|
|
275
|
+
this.recordTombstone(entity, id, Number.MAX_SAFE_INTEGER);
|
|
276
|
+
this.notify();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Drop every table + tombstone in-place, then notify. Used by the sync
|
|
281
|
+
* engine's `resetReplica()` on identity flip (token or tenant changed —
|
|
282
|
+
* the old replica reflects a different visible set). Kept on
|
|
283
|
+
* `LocalStore` so the `tables`/`tombstones` maps stay private.
|
|
284
|
+
*/
|
|
285
|
+
clearAll(): void {
|
|
286
|
+
this.tables.clear();
|
|
287
|
+
this.tombstones.clear();
|
|
288
|
+
this.notify();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Pending mutation queue — offline-safe write queue
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
export interface PendingMutation {
|
|
297
|
+
id: string;
|
|
298
|
+
change: ClientChange;
|
|
299
|
+
status: "pending" | "applied" | "failed";
|
|
300
|
+
error?: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Optional persistence backend for the mutation queue. The default
|
|
305
|
+
* IndexedDB persistence layer provides `savePending`/`loadPending`/etc.
|
|
306
|
+
* Callers can supply a custom backend for tests or alternative storage.
|
|
307
|
+
*/
|
|
308
|
+
export interface MutationQueuePersistence {
|
|
309
|
+
saveAll(mutations: PendingMutation[]): Promise<void>;
|
|
310
|
+
loadAll(): Promise<PendingMutation[]>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Offline-safe write queue.
|
|
315
|
+
*
|
|
316
|
+
* Before: the queue was memory-only. A tab crash or refresh silently lost
|
|
317
|
+
* every pending write. Now: if a `persistence` backend is provided the queue
|
|
318
|
+
* writes-through on every mutation, and `hydrate()` restores pending/failed
|
|
319
|
+
* mutations on startup. Applied mutations are pruned during `clear()`.
|
|
320
|
+
*
|
|
321
|
+
* The `id` scheme is stable (timestamp + random suffix) and is also used
|
|
322
|
+
* as the server-side `op_id` for idempotent replay. A retried push carrying
|
|
323
|
+
* the same id will short-circuit on the server instead of re-applying.
|
|
324
|
+
*/
|
|
325
|
+
export class MutationQueue {
|
|
326
|
+
private queue: PendingMutation[] = [];
|
|
327
|
+
private persistence?: MutationQueuePersistence;
|
|
328
|
+
|
|
329
|
+
constructor(persistence?: MutationQueuePersistence) {
|
|
330
|
+
this.persistence = persistence;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Attach a persistence backend after construction. The SyncEngine
|
|
335
|
+
* uses this to swap in IndexedDB-backed persistence once the DB
|
|
336
|
+
* has opened (after the constructor runs). Public so it doesn't
|
|
337
|
+
* need a `// @ts-expect-error` to reach in from the same package.
|
|
338
|
+
*/
|
|
339
|
+
attachPersistence(persistence: MutationQueuePersistence): void {
|
|
340
|
+
this.persistence = persistence;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Load persisted queue state. Call once at startup. */
|
|
344
|
+
async hydrate(): Promise<void> {
|
|
345
|
+
if (!this.persistence) return;
|
|
346
|
+
try {
|
|
347
|
+
const loaded = await this.persistence.loadAll();
|
|
348
|
+
// Merge in-memory with on-disk. An `add()` that ran while hydrate
|
|
349
|
+
// was awaiting `loadAll()` will already have flushed a snapshot
|
|
350
|
+
// that didn't include the loaded rows — re-flush after merge so
|
|
351
|
+
// disk matches memory again. Without this, a crash between the
|
|
352
|
+
// interleaved add-flush and the next mutation would leave the
|
|
353
|
+
// on-disk snapshot missing the loaded mutations.
|
|
354
|
+
const existingIds = new Set(this.queue.map((m) => m.id));
|
|
355
|
+
let mergedAny = false;
|
|
356
|
+
for (const m of loaded) {
|
|
357
|
+
if (!existingIds.has(m.id)) {
|
|
358
|
+
this.queue.push(m);
|
|
359
|
+
mergedAny = true;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (mergedAny) this.flush();
|
|
363
|
+
} catch (err) {
|
|
364
|
+
// Broken storage shouldn't prevent the app from running — warn and
|
|
365
|
+
// degrade to memory-only mode.
|
|
366
|
+
console.warn("[sync] mutation-queue hydrate failed:", err);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Add a pending mutation. Returns the op_id used for server idempotency. */
|
|
371
|
+
add(change: ClientChange): string {
|
|
372
|
+
const id = `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
373
|
+
// Attach op_id on the outgoing ClientChange itself so the server can dedupe.
|
|
374
|
+
const changeWithOp: ClientChange = { ...change, op_id: id };
|
|
375
|
+
this.queue.push({ id, change: changeWithOp, status: "pending" });
|
|
376
|
+
this.flush();
|
|
377
|
+
return id;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
pending(): PendingMutation[] {
|
|
381
|
+
return this.queue.filter((m) => m.status === "pending");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
markApplied(id: string): void {
|
|
385
|
+
const m = this.queue.find((m) => m.id === id);
|
|
386
|
+
if (m) m.status = "applied";
|
|
387
|
+
this.flush();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
markFailed(id: string, error: string): void {
|
|
391
|
+
const m = this.queue.find((m) => m.id === id);
|
|
392
|
+
if (m) {
|
|
393
|
+
m.status = "failed";
|
|
394
|
+
m.error = error;
|
|
395
|
+
}
|
|
396
|
+
this.flush();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Prune applied mutations. Failed mutations are KEPT so the UI can surface
|
|
401
|
+
* them to the user and so retries are possible. Previously this dropped
|
|
402
|
+
* failed mutations too, silently discarding server rejections.
|
|
403
|
+
*/
|
|
404
|
+
clear(): void {
|
|
405
|
+
this.queue = this.queue.filter(
|
|
406
|
+
(m) => m.status === "pending" || m.status === "failed",
|
|
407
|
+
);
|
|
408
|
+
this.flush();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Remove a specific mutation by id. Used by the UI after user ack of failures. */
|
|
412
|
+
remove(id: string): void {
|
|
413
|
+
this.queue = this.queue.filter((m) => m.id !== id);
|
|
414
|
+
this.flush();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Fire-and-forget persistence write. Errors are logged but not thrown. */
|
|
418
|
+
private flush(): void {
|
|
419
|
+
if (!this.persistence) return;
|
|
420
|
+
// Snapshot the queue before the async write so we don't race a later mutation.
|
|
421
|
+
const snapshot = this.queue.slice();
|
|
422
|
+
this.persistence.saveAll(snapshot).catch((err) => {
|
|
423
|
+
console.warn("[sync] mutation-queue persist failed:", err);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Sync engine — coordinates pull, push, local store, mutation queue
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
export type TransportType = "websocket" | "sse" | "poll";
|
|
433
|
+
|
|
434
|
+
export interface SyncEngineConfig {
|
|
435
|
+
baseUrl: string;
|
|
436
|
+
/** Transport type. Default: "websocket". Falls back to polling if connection fails. */
|
|
437
|
+
transport?: TransportType;
|
|
438
|
+
/** WebSocket URL. Default: derived from baseUrl (ws://). */
|
|
439
|
+
wsUrl?: string;
|
|
440
|
+
/** Poll interval in ms (only used when transport is "poll"). Default 1000. */
|
|
441
|
+
pollInterval?: number;
|
|
442
|
+
/** Reconnect delay in ms. Default 1000. */
|
|
443
|
+
reconnectDelay?: number;
|
|
444
|
+
/** Auth token for requests. */
|
|
445
|
+
token?: string;
|
|
446
|
+
/** Enable IndexedDB persistence. Data survives page refresh. Default: true in browser. */
|
|
447
|
+
persist?: boolean;
|
|
448
|
+
/** App name for IndexedDB database naming. Default: "default". */
|
|
449
|
+
appName?: string;
|
|
450
|
+
/**
|
|
451
|
+
* Sync key-value adapter for hot-path state (auth token, client_id).
|
|
452
|
+
* Default: localStorage on the web, in-memory no-op elsewhere. Non-browser
|
|
453
|
+
* hosts (RN, Tauri, Workers) inject an adapter to persist these values.
|
|
454
|
+
*/
|
|
455
|
+
storage?: import("./storage").Storage;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Generate a stable client_id. Prefers a persisted id from `storage`
|
|
460
|
+
* (so a reload keeps the same identifier) and falls back to a fresh UUID.
|
|
461
|
+
*/
|
|
462
|
+
function generateClientId(storage: import("./storage").Storage): string {
|
|
463
|
+
const key = "pylon:client_id";
|
|
464
|
+
const existing = storage.get(key);
|
|
465
|
+
if (existing) return existing;
|
|
466
|
+
const fresh = newUuidLike();
|
|
467
|
+
storage.set(key, fresh);
|
|
468
|
+
return fresh;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function newUuidLike(): string {
|
|
472
|
+
try {
|
|
473
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
474
|
+
return crypto.randomUUID();
|
|
475
|
+
}
|
|
476
|
+
} catch {
|
|
477
|
+
/* fall through */
|
|
478
|
+
}
|
|
479
|
+
// Fallback: 20 hex chars from random + time.
|
|
480
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
481
|
+
const t = Date.now().toString(36);
|
|
482
|
+
return `cl_${t}_${rand}`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export class SyncEngine {
|
|
486
|
+
private config: SyncEngineConfig;
|
|
487
|
+
private cursor: SyncCursor = { last_seq: 0 };
|
|
488
|
+
private running = false;
|
|
489
|
+
private ws: WebSocket | null = null;
|
|
490
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
491
|
+
/** Monotonic attempt counter for exponential backoff. Reset to 0 on a
|
|
492
|
+
* successful connection so the next reconnect starts fresh rather than
|
|
493
|
+
* inheriting the previous storm's cooldown. */
|
|
494
|
+
private reconnectAttempts = 0;
|
|
495
|
+
private persistence: import("./persistence").IndexedDBPersistence | null = null;
|
|
496
|
+
|
|
497
|
+
readonly store: LocalStore;
|
|
498
|
+
readonly mutations: MutationQueue;
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Stable per-client identifier. Minted on first construction, not
|
|
502
|
+
* necessarily persisted (depends on what the host provides).
|
|
503
|
+
* Included on every PushRequest so the server can correlate retries and
|
|
504
|
+
* track per-client diagnostics. Not auth — do not trust this to identify
|
|
505
|
+
* a user.
|
|
506
|
+
*/
|
|
507
|
+
readonly clientId: string;
|
|
508
|
+
|
|
509
|
+
/** Presence state for this client. */
|
|
510
|
+
private presenceData: Record<string, unknown> = {};
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Token observed on the last pull. When the token changes (anonymous →
|
|
514
|
+
* signed in, or user A → user B), the set of rows the server will expose
|
|
515
|
+
* changes — so the cursor from the previous identity is meaningless.
|
|
516
|
+
* Compared on every pull; a mismatch triggers an automatic resync.
|
|
517
|
+
*
|
|
518
|
+
* Uses `undefined` as the "never observed" sentinel so we can distinguish
|
|
519
|
+
* "first pull ever" from "explicitly anonymous". A first pull doesn't
|
|
520
|
+
* reset (nothing to reset), but every later transition — including
|
|
521
|
+
* null→token → does.
|
|
522
|
+
*/
|
|
523
|
+
private lastSeenToken: string | null | undefined = undefined;
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Latest server-resolved auth/session state. Refreshed on every pull()
|
|
527
|
+
* by fetching /api/auth/me in parallel. Exposed to consumers via
|
|
528
|
+
* `resolvedSession` so React hooks can subscribe via the store.
|
|
529
|
+
*
|
|
530
|
+
* Subscribers re-render when this updates — we reuse the store's
|
|
531
|
+
* notifier rather than introduce a second pub/sub so every change the
|
|
532
|
+
* app cares about goes through one channel.
|
|
533
|
+
*/
|
|
534
|
+
private _resolvedSession: ResolvedSession = {
|
|
535
|
+
userId: null,
|
|
536
|
+
tenantId: null,
|
|
537
|
+
isAdmin: false,
|
|
538
|
+
roles: [],
|
|
539
|
+
};
|
|
540
|
+
private lastSeenTenant: string | null | undefined = undefined;
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Timer for the "stable connection" check. On `onopen` we start a 5s
|
|
544
|
+
* timer; if the socket stays up that long we reset reconnectAttempts.
|
|
545
|
+
* If it closes first, the timer gets cleared and the backoff grows so
|
|
546
|
+
* the client can't hammer the server on auth failures.
|
|
547
|
+
*/
|
|
548
|
+
private wsStableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Registered consumers for binary WebSocket frames. SyncEngine itself
|
|
552
|
+
* doesn't decode binary — it just owns the WS connection and routes
|
|
553
|
+
* frames to whoever signed up via [`onBinaryFrame`]. The first
|
|
554
|
+
* consumer is `@pylonsync/loro` for CRDT snapshots / updates;
|
|
555
|
+
* future binary use cases (file streaming, etc.) register the same
|
|
556
|
+
* way so this layer stays use-case-agnostic.
|
|
557
|
+
*
|
|
558
|
+
* Set rather than Array so a hot-reload re-registration of the same
|
|
559
|
+
* handler doesn't double-invoke. Caller-provided handler identity
|
|
560
|
+
* is the dedup key.
|
|
561
|
+
*/
|
|
562
|
+
private binaryHandlers: Set<(bytes: Uint8Array) => void> = new Set();
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Active CRDT subscriptions, keyed `${entity}\x00${rowId}`. Tracked
|
|
566
|
+
* here so a WS reconnect can re-send the same subscriptions to the
|
|
567
|
+
* fresh socket — the server clears its per-client subscription state
|
|
568
|
+
* on disconnect (in `WsHub::handle_ws_connection`'s Close path), so
|
|
569
|
+
* without re-sending the binary frames would stop arriving on the
|
|
570
|
+
* new connection.
|
|
571
|
+
*
|
|
572
|
+
* Refcount-aware via `crdtSubscribers` so two `useLoroDoc` callers on
|
|
573
|
+
* the same row don't unsubscribe each other when one unmounts.
|
|
574
|
+
*/
|
|
575
|
+
private crdtSubscriptions: Set<string> = new Set();
|
|
576
|
+
private crdtSubscribers: Map<string, number> = new Map();
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Register a binary-frame handler. Returns an unsubscribe fn that
|
|
580
|
+
* pulls the handler back out — call on hook unmount / module
|
|
581
|
+
* teardown so handlers don't leak.
|
|
582
|
+
*
|
|
583
|
+
* Multiple handlers can register concurrently; each gets called for
|
|
584
|
+
* every binary frame the WS receives. Handlers should be cheap and
|
|
585
|
+
* non-throwing — exceptions are caught and logged but the message
|
|
586
|
+
* is otherwise dropped for that handler.
|
|
587
|
+
*/
|
|
588
|
+
onBinaryFrame(handler: (bytes: Uint8Array) => void): () => void {
|
|
589
|
+
this.binaryHandlers.add(handler);
|
|
590
|
+
return () => {
|
|
591
|
+
this.binaryHandlers.delete(handler);
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Read the cached resolved session. Null user = anonymous. */
|
|
596
|
+
resolvedSession(): ResolvedSession {
|
|
597
|
+
return this._resolvedSession;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Sync key-value adapter for hot-path state (token, client_id). */
|
|
601
|
+
readonly storage: import("./storage").Storage;
|
|
602
|
+
|
|
603
|
+
constructor(config: SyncEngineConfig) {
|
|
604
|
+
this.config = config;
|
|
605
|
+
this.store = new LocalStore();
|
|
606
|
+
this.mutations = new MutationQueue();
|
|
607
|
+
this.storage = config.storage ?? defaultStorage();
|
|
608
|
+
this.clientId = generateClientId(this.storage);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Hydrate the local store with server-rendered data.
|
|
613
|
+
* Call this before start() to avoid a redundant initial pull.
|
|
614
|
+
* Typically used for SSR: server fetches data + cursor, passes to client.
|
|
615
|
+
*/
|
|
616
|
+
hydrate(data: HydrationData): void {
|
|
617
|
+
for (const [entity, rows] of Object.entries(data.entities)) {
|
|
618
|
+
for (const row of rows) {
|
|
619
|
+
const id = (row as Record<string, unknown>).id as string;
|
|
620
|
+
if (id) {
|
|
621
|
+
this.store.applyChange({
|
|
622
|
+
seq: 0,
|
|
623
|
+
entity,
|
|
624
|
+
row_id: id,
|
|
625
|
+
kind: "insert",
|
|
626
|
+
data: row as Record<string, unknown>,
|
|
627
|
+
timestamp: "",
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (data.cursor) {
|
|
633
|
+
this.cursor = data.cursor;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Start the sync engine. Loads persisted data, pulls updates, then connects for real-time. */
|
|
638
|
+
async start(): Promise<void> {
|
|
639
|
+
if (this.running) return;
|
|
640
|
+
this.running = true;
|
|
641
|
+
|
|
642
|
+
// Load persisted data if available.
|
|
643
|
+
const shouldPersist = this.config.persist !== false && typeof indexedDB !== "undefined";
|
|
644
|
+
if (shouldPersist) {
|
|
645
|
+
try {
|
|
646
|
+
const { IndexedDBPersistence, persistChange } = await import("./persistence");
|
|
647
|
+
this.persistence = new IndexedDBPersistence(this.config.appName);
|
|
648
|
+
await this.persistence.open();
|
|
649
|
+
|
|
650
|
+
// Load cached data into the store.
|
|
651
|
+
const cached = await this.persistence.loadAllEntities();
|
|
652
|
+
let hydrated = false;
|
|
653
|
+
for (const [entity, rows] of Object.entries(cached)) {
|
|
654
|
+
for (const row of rows) {
|
|
655
|
+
const id = (row as Record<string, unknown>).id as string;
|
|
656
|
+
if (id) {
|
|
657
|
+
this.store.applyChange({ seq: 0, entity, row_id: id, kind: "insert", data: row, timestamp: "" });
|
|
658
|
+
hydrated = true;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// applyChange() doesn't notify — it's the low-level primitive.
|
|
663
|
+
// Fire one notify after the hydration loop so useSyncExternalStore
|
|
664
|
+
// subscribers re-read. Without this, if the subsequent pull returns
|
|
665
|
+
// no changes (replica already at cursor), subscribers stay stuck on
|
|
666
|
+
// their initial empty snapshot until the first WS event arrives.
|
|
667
|
+
if (hydrated) this.store.notify();
|
|
668
|
+
|
|
669
|
+
// Load cursor.
|
|
670
|
+
const savedCursor = await this.persistence.loadCursor();
|
|
671
|
+
if (savedCursor) {
|
|
672
|
+
this.cursor = savedCursor;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Auto-save changes to IndexedDB. Returns a Promise so the async
|
|
676
|
+
// apply path (applyChangesAsync) can await the write before the
|
|
677
|
+
// cursor advances — the fix for "cursor ahead of replica" on crash.
|
|
678
|
+
const persistence = this.persistence;
|
|
679
|
+
this.store._persistFn = async (change: ChangeEvent) => {
|
|
680
|
+
const { persistChange } = await import("./persistence");
|
|
681
|
+
if (persistence) await persistChange(persistence, change);
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// Hydrate the mutation queue from disk. Any offline writes queued
|
|
685
|
+
// before the tab was closed come back as pending here and will be
|
|
686
|
+
// pushed on the next `push()` tick. Without this, `MutationQueue`
|
|
687
|
+
// stayed memory-only and offline mutations were silently lost.
|
|
688
|
+
try {
|
|
689
|
+
const { IndexedDBMutationPersistence } = await import("./persistence");
|
|
690
|
+
const mqPersistence = new IndexedDBMutationPersistence(persistence);
|
|
691
|
+
this.mutations.attachPersistence(mqPersistence);
|
|
692
|
+
await this.mutations.hydrate();
|
|
693
|
+
} catch {
|
|
694
|
+
// Queue persistence optional — memory-only still works.
|
|
695
|
+
}
|
|
696
|
+
} catch {
|
|
697
|
+
// IndexedDB not available — continue without persistence.
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Seed the server-resolved session before the first pull so
|
|
702
|
+
// `useSession` subscribers see the right tenant from frame one, and
|
|
703
|
+
// `lastSeenTenant` is populated before any subsequent flip can race
|
|
704
|
+
// with it.
|
|
705
|
+
await this.refreshResolvedSession();
|
|
706
|
+
|
|
707
|
+
// Pull from server, then connect real-time transport.
|
|
708
|
+
await this.pull();
|
|
709
|
+
|
|
710
|
+
// Save cursor after pull.
|
|
711
|
+
if (this.persistence) {
|
|
712
|
+
await this.persistence.saveCursor(this.cursor);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const transport = this.config.transport ?? "websocket";
|
|
716
|
+
if (transport === "websocket") {
|
|
717
|
+
this.connectWs();
|
|
718
|
+
} else if (transport === "sse") {
|
|
719
|
+
this.connectSse();
|
|
720
|
+
} else if (transport === "poll") {
|
|
721
|
+
this.startPolling();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
726
|
+
|
|
727
|
+
private startPolling(): void {
|
|
728
|
+
const interval = this.config.pollInterval ?? 1000;
|
|
729
|
+
this.pollTimer = setInterval(() => {
|
|
730
|
+
this.push().then(() => this.pull());
|
|
731
|
+
}, interval);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Stop the sync engine. */
|
|
735
|
+
stop(): void {
|
|
736
|
+
this.running = false;
|
|
737
|
+
if (this.ws) {
|
|
738
|
+
this.ws.close();
|
|
739
|
+
this.ws = null;
|
|
740
|
+
}
|
|
741
|
+
if (this.reconnectTimer) {
|
|
742
|
+
clearTimeout(this.reconnectTimer);
|
|
743
|
+
this.reconnectTimer = null;
|
|
744
|
+
}
|
|
745
|
+
if (this.pollTimer) {
|
|
746
|
+
clearInterval(this.pollTimer);
|
|
747
|
+
this.pollTimer = null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Connect to the WebSocket server for real-time updates. */
|
|
752
|
+
private connectWs(): void {
|
|
753
|
+
if (!this.running) return;
|
|
754
|
+
|
|
755
|
+
const wsUrl = this.config.wsUrl ?? this.deriveWsUrl();
|
|
756
|
+
// Browser WebSocket has no header API — the server accepts the token
|
|
757
|
+
// as a `bearer.<percent-encoded-token>` subprotocol (RFC 6455 §1.9).
|
|
758
|
+
// Native clients can still set Authorization: Bearer via headers.
|
|
759
|
+
const token =
|
|
760
|
+
this.config.token ??
|
|
761
|
+
this.storage.get(this.tokenStorageKey()) ??
|
|
762
|
+
undefined;
|
|
763
|
+
try {
|
|
764
|
+
if (token) {
|
|
765
|
+
const proto = `bearer.${encodeURIComponent(token)}`;
|
|
766
|
+
this.ws = new WebSocket(wsUrl, proto);
|
|
767
|
+
} else {
|
|
768
|
+
this.ws = new WebSocket(wsUrl);
|
|
769
|
+
}
|
|
770
|
+
} catch {
|
|
771
|
+
this.scheduleReconnect();
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Backoff reset is delayed — a socket that opens then closes inside
|
|
776
|
+
// a few seconds (auth failure, server 1008) would otherwise let the
|
|
777
|
+
// reconnect loop fire at ~2/sec forever. Only call the connection
|
|
778
|
+
// "stable" after it's stayed up long enough to have been doing work.
|
|
779
|
+
this.ws.onopen = () => {
|
|
780
|
+
if (this.wsStableTimer) clearTimeout(this.wsStableTimer);
|
|
781
|
+
this.wsStableTimer = setTimeout(() => {
|
|
782
|
+
this.reconnectAttempts = 0;
|
|
783
|
+
this.wsStableTimer = null;
|
|
784
|
+
}, 5_000);
|
|
785
|
+
// Re-send any active CRDT subscriptions across the new socket.
|
|
786
|
+
// The server purged them on disconnect (`unsubscribe_all`), so
|
|
787
|
+
// without this resync a tab that was subscribed before a network
|
|
788
|
+
// blip would silently stop receiving binary CRDT frames.
|
|
789
|
+
for (const key of this.crdtSubscriptions) {
|
|
790
|
+
const [entity, rowId] = key.split("\x00");
|
|
791
|
+
this.sendWs({ type: "crdt-subscribe", entity, rowId });
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// Bind binaryType BEFORE installing the handler so the first
|
|
796
|
+
// server-pushed binary frame (CRDT snapshot or update) decodes
|
|
797
|
+
// correctly. Default in browsers is "blob"; we want raw bytes
|
|
798
|
+
// synchronously available so the binary-handler closure doesn't
|
|
799
|
+
// need to await a Blob.arrayBuffer() round-trip.
|
|
800
|
+
this.ws.binaryType = "arraybuffer";
|
|
801
|
+
|
|
802
|
+
this.ws.onmessage = (event) => {
|
|
803
|
+
// Binary frame: route to whatever consumer registered via
|
|
804
|
+
// onBinaryFrame(). Pylon's CRDT broadcast (server-side
|
|
805
|
+
// notify_crdt) ships every CRDT-mode write as a binary
|
|
806
|
+
// [type|entity|row_id|payload] frame; @pylonsync/loro is the
|
|
807
|
+
// intended decoder. SyncEngine itself stays binary-agnostic so
|
|
808
|
+
// the next binary use case (file streaming, video chunks…)
|
|
809
|
+
// can register without churning this layer.
|
|
810
|
+
if (event.data instanceof ArrayBuffer) {
|
|
811
|
+
for (const handler of this.binaryHandlers) {
|
|
812
|
+
try {
|
|
813
|
+
handler(new Uint8Array(event.data));
|
|
814
|
+
} catch (err) {
|
|
815
|
+
console.warn("[sync] binary handler threw:", err);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
const msg = JSON.parse(event.data as string);
|
|
823
|
+
|
|
824
|
+
// Sync change event. Persist BEFORE advancing the cursor so a crash
|
|
825
|
+
// can't leave `last_seq` ahead of the replica on disk.
|
|
826
|
+
if (msg.seq && msg.entity && msg.kind) {
|
|
827
|
+
const change = msg as ChangeEvent;
|
|
828
|
+
if (change.seq > this.cursor.last_seq) {
|
|
829
|
+
void this.store.applyChangesAsync([change]).then(async () => {
|
|
830
|
+
this.cursor = { last_seq: change.seq };
|
|
831
|
+
if (this.persistence) {
|
|
832
|
+
await this.persistence.saveCursor(this.cursor);
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Presence event.
|
|
840
|
+
if (msg.type === "presence") {
|
|
841
|
+
this.store.notify();
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
} catch {
|
|
845
|
+
// Ignore malformed messages.
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
this.ws.onclose = () => {
|
|
850
|
+
this.ws = null;
|
|
851
|
+
// Socket closed before the stable-window timer fired — treat this
|
|
852
|
+
// as an unstable connection and DO NOT reset reconnectAttempts.
|
|
853
|
+
// The growing backoff protects the server from a tight loop.
|
|
854
|
+
if (this.wsStableTimer) {
|
|
855
|
+
clearTimeout(this.wsStableTimer);
|
|
856
|
+
this.wsStableTimer = null;
|
|
857
|
+
}
|
|
858
|
+
this.scheduleReconnect();
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
this.ws.onerror = () => {
|
|
862
|
+
// onclose will fire after this.
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private scheduleReconnect(): void {
|
|
867
|
+
if (!this.running) return;
|
|
868
|
+
this.reconnectAttempts += 1;
|
|
869
|
+
const delay = this.computeBackoff();
|
|
870
|
+
this.reconnectTimer = setTimeout(() => {
|
|
871
|
+
this.reconnectTimer = null;
|
|
872
|
+
// Pull any missed changes, then reconnect.
|
|
873
|
+
this.pull().then(() => this.connectWs());
|
|
874
|
+
}, delay);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Exponential backoff with full jitter for reconnects.
|
|
879
|
+
*
|
|
880
|
+
* Thundering-herd fix: when the server restarts, every connected client
|
|
881
|
+
* fires `onclose` at nearly the same instant. Without jitter they all
|
|
882
|
+
* reconnect at `baseDelay` and hammer the newly-booted server; after a
|
|
883
|
+
* few cycles the reconnect waves align and the server never recovers.
|
|
884
|
+
*
|
|
885
|
+
* Full-jitter (`delay = random(0, exp)`) spreads clients evenly across
|
|
886
|
+
* the backoff window so the second-wave load is flat, not spiky.
|
|
887
|
+
* Algorithm from AWS Architecture Blog "Exponential Backoff and Jitter"
|
|
888
|
+
* — the "Full Jitter" variant, which has the lowest collision rate.
|
|
889
|
+
*
|
|
890
|
+
* The `reconnectDelay` config value seeds the exponential base. Max
|
|
891
|
+
* delay caps at 30s so users don't wait minutes on a long outage.
|
|
892
|
+
*/
|
|
893
|
+
private computeBackoff(): number {
|
|
894
|
+
const base = this.config.reconnectDelay ?? 1000;
|
|
895
|
+
const maxDelay = 30_000;
|
|
896
|
+
// exp = base * 2^(attempts-1), clamped to maxDelay
|
|
897
|
+
const attempt = Math.max(1, this.reconnectAttempts);
|
|
898
|
+
const exp = Math.min(maxDelay, base * Math.pow(2, attempt - 1));
|
|
899
|
+
// Full jitter: delay is uniform random in [0, exp].
|
|
900
|
+
return Math.floor(Math.random() * exp);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Connect via Server-Sent Events. */
|
|
904
|
+
private connectSse(): void {
|
|
905
|
+
if (!this.running) return;
|
|
906
|
+
|
|
907
|
+
const base = this.config.baseUrl;
|
|
908
|
+
const url = new URL(base);
|
|
909
|
+
const port = parseInt(url.port || "4321", 10);
|
|
910
|
+
const sseUrl = `http://${url.hostname}:${port + 2}/events`;
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
const es = new EventSource(sseUrl);
|
|
914
|
+
es.onmessage = (event) => {
|
|
915
|
+
try {
|
|
916
|
+
const msg = JSON.parse(event.data);
|
|
917
|
+
if (msg.seq && msg.entity && msg.kind) {
|
|
918
|
+
const change = msg as ChangeEvent;
|
|
919
|
+
if (change.seq > this.cursor.last_seq) {
|
|
920
|
+
void this.store.applyChangesAsync([change]).then(async () => {
|
|
921
|
+
this.cursor = { last_seq: change.seq };
|
|
922
|
+
if (this.persistence) {
|
|
923
|
+
await this.persistence.saveCursor(this.cursor);
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
} catch {
|
|
929
|
+
// Ignore malformed events.
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
es.onerror = () => {
|
|
933
|
+
es.close();
|
|
934
|
+
// Same jittered backoff as the WS path so SSE clients don't form
|
|
935
|
+
// a second reconnect wave on server restart.
|
|
936
|
+
this.reconnectAttempts += 1;
|
|
937
|
+
setTimeout(() => {
|
|
938
|
+
if (this.running) {
|
|
939
|
+
this.pull().then(() => this.connectSse());
|
|
940
|
+
}
|
|
941
|
+
}, this.computeBackoff());
|
|
942
|
+
};
|
|
943
|
+
} catch {
|
|
944
|
+
// EventSource not available — fall back to polling.
|
|
945
|
+
this.startPolling();
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
private deriveWsUrl(): string {
|
|
950
|
+
const base = this.config.baseUrl;
|
|
951
|
+
const url = new URL(base);
|
|
952
|
+
const isHttps = url.protocol === "https:";
|
|
953
|
+
const scheme = isHttps ? "wss" : "ws";
|
|
954
|
+
|
|
955
|
+
// HTTPS deploys (Fly/Vercel/Cloudflare) terminate TLS at a single
|
|
956
|
+
// public port — we can't assume port+1 is exposed. Callers should
|
|
957
|
+
// override via `wsUrl` in the sync-engine config (or set
|
|
958
|
+
// VITE_PYLON_WS_URL in Vite apps) when the WebSocket listens on a
|
|
959
|
+
// different hostname or a separate Fly service.
|
|
960
|
+
//
|
|
961
|
+
// If the base URL has an explicit port (e.g. http://localhost:4321)
|
|
962
|
+
// we keep the historical port+1 convention — that's what `pylon dev`
|
|
963
|
+
// hands to the developer on a single box. Otherwise we assume the
|
|
964
|
+
// WebSocket is reachable at the same hostname on the same scheme
|
|
965
|
+
// (most production proxies multiplex WS on 443 via the Upgrade
|
|
966
|
+
// header, and a future pylon build will do the same).
|
|
967
|
+
if (url.port) {
|
|
968
|
+
const port = parseInt(url.port, 10);
|
|
969
|
+
return `${scheme}://${url.hostname}:${port + 1}`;
|
|
970
|
+
}
|
|
971
|
+
return `${scheme}://${url.hostname}`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Drop local cursor + store + notify. Safe to call from any state.
|
|
976
|
+
* Used by:
|
|
977
|
+
* - the 410 RESYNC_REQUIRED handler (server says our cursor is stale)
|
|
978
|
+
* - the identity-change detector in pull() (new auth = new visible set)
|
|
979
|
+
* - callers that need to force a clean re-pull (tests, sign-out flows)
|
|
980
|
+
*
|
|
981
|
+
* Does NOT issue the subsequent pull — callers decide when to re-pull.
|
|
982
|
+
* That keeps the lifecycle explicit: a caller can reset, swap config,
|
|
983
|
+
* then pull.
|
|
984
|
+
*/
|
|
985
|
+
async resetReplica(): Promise<void> {
|
|
986
|
+
this.cursor = { last_seq: 0 };
|
|
987
|
+
this.store.clearAll();
|
|
988
|
+
if (this.persistence) {
|
|
989
|
+
try {
|
|
990
|
+
await this.persistence.saveCursor(this.cursor);
|
|
991
|
+
} catch {
|
|
992
|
+
/* best-effort */
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* localStorage key for the auth token, namespaced by appName. Matches
|
|
999
|
+
* the key the React package's `configureClient` writes to so the sync
|
|
1000
|
+
* engine and the hooks agree on where the token lives.
|
|
1001
|
+
*/
|
|
1002
|
+
private tokenStorageKey(): string {
|
|
1003
|
+
const app = this.config.appName || "default";
|
|
1004
|
+
return app === "default" ? "pylon_token" : `pylon:${app}:token`;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** Current auth token from config or the storage adapter. Null when neither has one. */
|
|
1008
|
+
private currentToken(): string | null {
|
|
1009
|
+
if (this.config.token) return this.config.token;
|
|
1010
|
+
return this.storage.get(this.tokenStorageKey());
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Pull changes from the server. */
|
|
1014
|
+
async pull(): Promise<void> {
|
|
1015
|
+
// Identity change detection. If the token flipped since the last pull
|
|
1016
|
+
// (anonymous → signed in, user A → user B, signed in → signed out),
|
|
1017
|
+
// the server's visible set changed under us and the cursor we saved
|
|
1018
|
+
// reflects the previous identity. Reset before pulling so we rebuild
|
|
1019
|
+
// the replica from seq=0 under the new identity.
|
|
1020
|
+
const tokenNow = this.currentToken();
|
|
1021
|
+
if (
|
|
1022
|
+
this.lastSeenToken !== undefined &&
|
|
1023
|
+
this.lastSeenToken !== tokenNow
|
|
1024
|
+
) {
|
|
1025
|
+
await this.resetReplica();
|
|
1026
|
+
// Token flipped → the cached tenant is for the previous user. Pull
|
|
1027
|
+
// the fresh session in parallel with the cursor catch-up below.
|
|
1028
|
+
void this.refreshResolvedSession();
|
|
1029
|
+
}
|
|
1030
|
+
this.lastSeenToken = tokenNow;
|
|
1031
|
+
|
|
1032
|
+
try {
|
|
1033
|
+
const resp = await this.request<PullResponse>(
|
|
1034
|
+
"GET",
|
|
1035
|
+
`/api/sync/pull?since=${this.cursor.last_seq}`
|
|
1036
|
+
);
|
|
1037
|
+
// Successful response — clear the 410 circuit breaker.
|
|
1038
|
+
this.consecutive_410s = 0;
|
|
1039
|
+
if (resp.changes.length > 0) {
|
|
1040
|
+
// Await disk writes before touching the cursor so a crash here can't
|
|
1041
|
+
// persist a cursor that's ahead of what actually landed in IndexedDB.
|
|
1042
|
+
await this.store.applyChangesAsync(resp.changes);
|
|
1043
|
+
}
|
|
1044
|
+
// Always advance the cursor to whatever the server reports, not just
|
|
1045
|
+
// when changes land. If a read policy filters out every event in a
|
|
1046
|
+
// window the server still moves its last_seq forward; clamping to only
|
|
1047
|
+
// "non-empty" responses pins the client at `since=0` forever and turns
|
|
1048
|
+
// every reconnect into another pull for the same empty window.
|
|
1049
|
+
if (resp.cursor && resp.cursor.last_seq > this.cursor.last_seq) {
|
|
1050
|
+
this.cursor = resp.cursor;
|
|
1051
|
+
if (this.persistence) {
|
|
1052
|
+
await this.persistence.saveCursor(this.cursor);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
// If there are more, pull again immediately.
|
|
1056
|
+
if (resp.has_more) {
|
|
1057
|
+
await this.pull();
|
|
1058
|
+
}
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
// Swallow network + transient errors so the poll/reconnect loop
|
|
1061
|
+
// keeps trying — but on 429 bump the backoff counter so the next
|
|
1062
|
+
// reconnect waits noticeably longer. Without this, a rate-limited
|
|
1063
|
+
// pull triggers onclose → scheduleReconnect → pull → 429 in a
|
|
1064
|
+
// tight loop that the server reads as abuse.
|
|
1065
|
+
const status = (err as { status?: number })?.status;
|
|
1066
|
+
if (status === 429) {
|
|
1067
|
+
this.reconnectAttempts += 3;
|
|
1068
|
+
}
|
|
1069
|
+
// 410 RESYNC_REQUIRED: cursor is from a previous server lifetime, or
|
|
1070
|
+
// it fell off the retention window. Drop local state + cursor and
|
|
1071
|
+
// re-pull from seq=0. The server replays all current entity rows as
|
|
1072
|
+
// seed events on startup so the fresh pull reconstructs state.
|
|
1073
|
+
//
|
|
1074
|
+
// Circuit breaker: if the immediate re-pull ALSO 410s, accept it.
|
|
1075
|
+
// Don't recurse — that's the infinite loop we used to ship before
|
|
1076
|
+
// the cursor=0 server fix landed (or against an old server binary
|
|
1077
|
+
// that hasn't been rebuilt yet). Track 410 retries against an
|
|
1078
|
+
// exponential backoff so a misconfigured server can't melt our CPU.
|
|
1079
|
+
if (status === 410) {
|
|
1080
|
+
const attempt = this.consecutive_410s;
|
|
1081
|
+
this.consecutive_410s += 1;
|
|
1082
|
+
if (attempt === 0) {
|
|
1083
|
+
await this.resetReplica();
|
|
1084
|
+
await this.pull();
|
|
1085
|
+
} else {
|
|
1086
|
+
// Already retried once and still 410. Stop. Schedule a
|
|
1087
|
+
// back-off retry tied to the WS reconnect path so we don't
|
|
1088
|
+
// spam the server. Resets when any pull succeeds.
|
|
1089
|
+
const delayMs = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5));
|
|
1090
|
+
console.warn(
|
|
1091
|
+
`[pylon] persistent 410 RESYNC_REQUIRED (attempt ${attempt + 1}); backing off ${delayMs}ms`,
|
|
1092
|
+
);
|
|
1093
|
+
setTimeout(() => {
|
|
1094
|
+
// Trigger one more attempt; either it succeeds (which resets
|
|
1095
|
+
// the counter) or it 410s again (which extends the backoff).
|
|
1096
|
+
void this.pull();
|
|
1097
|
+
}, delayMs);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Consecutive 410 RESYNC_REQUIRED responses since the last successful
|
|
1104
|
+
* pull. Used by the circuit breaker in pull() to bound the retry
|
|
1105
|
+
* storm against a misconfigured server. Resets to 0 on any pull
|
|
1106
|
+
* that doesn't throw a 410. */
|
|
1107
|
+
private consecutive_410s = 0;
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Fetch `/api/auth/me` and update the cached `_resolvedSession`. Callers:
|
|
1111
|
+
* - `start()` — initial load
|
|
1112
|
+
* - the token-flip branch in `pull()`
|
|
1113
|
+
* - `notifySessionChanged()` — app code invokes this after it mutates
|
|
1114
|
+
* server session state (login, logout, `/api/auth/select-org`) so the
|
|
1115
|
+
* cached session + React subscribers update immediately instead of
|
|
1116
|
+
* waiting for the next pull/reconnect cycle.
|
|
1117
|
+
*
|
|
1118
|
+
* On tenant flip this also resets the replica — same logic as the
|
|
1119
|
+
* token-flip path, for the same reason (visible set changed).
|
|
1120
|
+
*/
|
|
1121
|
+
async refreshResolvedSession(): Promise<void> {
|
|
1122
|
+
try {
|
|
1123
|
+
const res = await this.rawFetch("/api/auth/me");
|
|
1124
|
+
if (!res.ok) return;
|
|
1125
|
+
const raw = (await res.json()) as {
|
|
1126
|
+
user_id?: string | null;
|
|
1127
|
+
tenant_id?: string | null;
|
|
1128
|
+
is_admin?: boolean;
|
|
1129
|
+
roles?: string[];
|
|
1130
|
+
};
|
|
1131
|
+
const next: ResolvedSession = {
|
|
1132
|
+
userId: raw.user_id ?? null,
|
|
1133
|
+
tenantId: raw.tenant_id ?? null,
|
|
1134
|
+
isAdmin: raw.is_admin ?? false,
|
|
1135
|
+
roles: raw.roles ?? [],
|
|
1136
|
+
};
|
|
1137
|
+
const tenantNow = next.tenantId;
|
|
1138
|
+
// First observation seeds lastSeenTenant without a reset — we have
|
|
1139
|
+
// nothing to invalidate yet. Subsequent changes flip the replica.
|
|
1140
|
+
if (
|
|
1141
|
+
this.lastSeenTenant !== undefined &&
|
|
1142
|
+
this.lastSeenTenant !== tenantNow
|
|
1143
|
+
) {
|
|
1144
|
+
await this.resetReplica();
|
|
1145
|
+
}
|
|
1146
|
+
this.lastSeenTenant = tenantNow;
|
|
1147
|
+
const prev = this._resolvedSession;
|
|
1148
|
+
const changed =
|
|
1149
|
+
prev.userId !== next.userId ||
|
|
1150
|
+
prev.tenantId !== next.tenantId ||
|
|
1151
|
+
prev.isAdmin !== next.isAdmin ||
|
|
1152
|
+
prev.roles.join(",") !== next.roles.join(",");
|
|
1153
|
+
if (changed) {
|
|
1154
|
+
this._resolvedSession = next;
|
|
1155
|
+
// Piggy-back on the store notifier so `useSession` re-renders via
|
|
1156
|
+
// useSyncExternalStore without a second pub/sub channel.
|
|
1157
|
+
this.store.notify();
|
|
1158
|
+
}
|
|
1159
|
+
} catch {
|
|
1160
|
+
// Swallow — /api/auth/me errors are transient and the next pull
|
|
1161
|
+
// will retry. Don't take down the sync loop for this.
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private async rawFetch(path: string): Promise<Response> {
|
|
1166
|
+
const headers: Record<string, string> = {};
|
|
1167
|
+
const token = this.currentToken();
|
|
1168
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
1169
|
+
return fetch(`${this.config.baseUrl}${path}`, { headers });
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Public alias for `refreshResolvedSession`. Call after anything that
|
|
1174
|
+
* mutates the server session (sign-in, sign-out, `/api/auth/select-org`)
|
|
1175
|
+
* so the cached session and React subscribers pick up the change without
|
|
1176
|
+
* waiting for the next pull.
|
|
1177
|
+
*/
|
|
1178
|
+
notifySessionChanged(): Promise<void> {
|
|
1179
|
+
return this.refreshResolvedSession();
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* In-flight push promise. Used as a mutex so a slow push can't be restarted
|
|
1184
|
+
* by the poll timer or a user mutation, which would resend the same batch
|
|
1185
|
+
* and cause duplicate writes on the server. The mutation `op_id` keeps
|
|
1186
|
+
* that safe at the protocol level (the server deduplicates), but shipping
|
|
1187
|
+
* the same batch twice is still wasted bandwidth — hold them instead.
|
|
1188
|
+
*
|
|
1189
|
+
* Callers always get the SAME promise while a push is running; chain a
|
|
1190
|
+
* `.then(() => next push)` if you need a follow-up push after this one.
|
|
1191
|
+
*/
|
|
1192
|
+
private inFlightPush: Promise<void> | null = null;
|
|
1193
|
+
|
|
1194
|
+
/** Push pending mutations to the server. Coalesces concurrent callers. */
|
|
1195
|
+
async push(): Promise<void> {
|
|
1196
|
+
if (this.inFlightPush) {
|
|
1197
|
+
return this.inFlightPush;
|
|
1198
|
+
}
|
|
1199
|
+
const work = this.pushInner().finally(() => {
|
|
1200
|
+
this.inFlightPush = null;
|
|
1201
|
+
});
|
|
1202
|
+
this.inFlightPush = work;
|
|
1203
|
+
return work;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
private async pushInner(): Promise<void> {
|
|
1207
|
+
const pending = this.mutations.pending();
|
|
1208
|
+
if (pending.length === 0) return;
|
|
1209
|
+
|
|
1210
|
+
try {
|
|
1211
|
+
const resp = await this.request<PushResponse>("POST", "/api/sync/push", {
|
|
1212
|
+
changes: pending.map((m) => m.change),
|
|
1213
|
+
client_id: this.clientId,
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
// Mark mutations based on response.
|
|
1217
|
+
for (let i = 0; i < pending.length; i++) {
|
|
1218
|
+
if (i < resp.applied) {
|
|
1219
|
+
this.mutations.markApplied(pending[i].id);
|
|
1220
|
+
} else if (resp.errors[i - resp.applied]) {
|
|
1221
|
+
this.mutations.markFailed(pending[i].id, resp.errors[i - resp.applied]);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
this.mutations.clear();
|
|
1226
|
+
} catch {
|
|
1227
|
+
// Will retry on next tick. op_id makes retries idempotent on the server.
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** Insert a row with optimistic local update. */
|
|
1232
|
+
async insert(entity: string, data: Row): Promise<string> {
|
|
1233
|
+
const tempId = this.store.optimisticInsert(entity, data);
|
|
1234
|
+
this.mutations.add({
|
|
1235
|
+
entity,
|
|
1236
|
+
row_id: tempId,
|
|
1237
|
+
kind: "insert",
|
|
1238
|
+
data,
|
|
1239
|
+
});
|
|
1240
|
+
await this.push();
|
|
1241
|
+
return tempId;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** Update a row with optimistic local update. */
|
|
1245
|
+
async update(entity: string, id: string, data: Partial<Row>): Promise<void> {
|
|
1246
|
+
this.store.optimisticUpdate(entity, id, data);
|
|
1247
|
+
this.mutations.add({
|
|
1248
|
+
entity,
|
|
1249
|
+
row_id: id,
|
|
1250
|
+
kind: "update",
|
|
1251
|
+
data: data as Row,
|
|
1252
|
+
});
|
|
1253
|
+
await this.push();
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Delete a row with optimistic local update. */
|
|
1257
|
+
async delete(entity: string, id: string): Promise<void> {
|
|
1258
|
+
this.store.optimisticDelete(entity, id);
|
|
1259
|
+
this.mutations.add({
|
|
1260
|
+
entity,
|
|
1261
|
+
row_id: id,
|
|
1262
|
+
kind: "delete",
|
|
1263
|
+
});
|
|
1264
|
+
await this.push();
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// -----------------------------------------------------------------------
|
|
1268
|
+
// Infinite scroll / cursor pagination
|
|
1269
|
+
// -----------------------------------------------------------------------
|
|
1270
|
+
|
|
1271
|
+
/** Load a page of data from an entity with cursor-based pagination. */
|
|
1272
|
+
async loadPage(
|
|
1273
|
+
entity: string,
|
|
1274
|
+
options?: { limit?: number; offset?: number; order?: Record<string, "asc" | "desc"> }
|
|
1275
|
+
): Promise<{ data: Row[]; total: number; hasMore: boolean }> {
|
|
1276
|
+
const limit = options?.limit ?? 20;
|
|
1277
|
+
const offset = options?.offset ?? 0;
|
|
1278
|
+
|
|
1279
|
+
const filter: Record<string, unknown> = {
|
|
1280
|
+
$limit: limit,
|
|
1281
|
+
$offset: offset,
|
|
1282
|
+
};
|
|
1283
|
+
if (options?.order) {
|
|
1284
|
+
filter.$order = options.order;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const resp = await this.request<Row[]>(
|
|
1288
|
+
"POST",
|
|
1289
|
+
`/api/query/${entity}`,
|
|
1290
|
+
filter
|
|
1291
|
+
);
|
|
1292
|
+
|
|
1293
|
+
const data = Array.isArray(resp) ? resp : [];
|
|
1294
|
+
return {
|
|
1295
|
+
data,
|
|
1296
|
+
total: data.length, // Server doesn't return total in filtered query
|
|
1297
|
+
hasMore: data.length === limit,
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Create an infinite query that appends pages.
|
|
1303
|
+
* Returns an object with loadMore() and the current accumulated data.
|
|
1304
|
+
*/
|
|
1305
|
+
createInfiniteQuery(entity: string, options?: { pageSize?: number; order?: Record<string, "asc" | "desc"> }) {
|
|
1306
|
+
const pageSize = options?.pageSize ?? 20;
|
|
1307
|
+
let allRows: Row[] = [];
|
|
1308
|
+
let offset = 0;
|
|
1309
|
+
let hasMore = true;
|
|
1310
|
+
let loading = false;
|
|
1311
|
+
|
|
1312
|
+
const listeners = new Set<() => void>();
|
|
1313
|
+
|
|
1314
|
+
const notify = () => {
|
|
1315
|
+
for (const fn of listeners) fn();
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
return {
|
|
1319
|
+
/** Load the next page. */
|
|
1320
|
+
loadMore: async () => {
|
|
1321
|
+
if (!hasMore || loading) return;
|
|
1322
|
+
loading = true;
|
|
1323
|
+
try {
|
|
1324
|
+
const page = await this.loadPage(entity, { limit: pageSize, offset, order: options?.order });
|
|
1325
|
+
allRows = [...allRows, ...page.data];
|
|
1326
|
+
offset += page.data.length;
|
|
1327
|
+
hasMore = page.hasMore;
|
|
1328
|
+
notify();
|
|
1329
|
+
} finally {
|
|
1330
|
+
loading = false;
|
|
1331
|
+
}
|
|
1332
|
+
},
|
|
1333
|
+
/** Get current accumulated rows. */
|
|
1334
|
+
get data() { return allRows; },
|
|
1335
|
+
/** Whether more pages are available. */
|
|
1336
|
+
get hasMore() { return hasMore; },
|
|
1337
|
+
/** Whether currently loading. */
|
|
1338
|
+
get loading() { return loading; },
|
|
1339
|
+
/** Subscribe to changes. */
|
|
1340
|
+
subscribe: (fn: () => void) => {
|
|
1341
|
+
listeners.add(fn);
|
|
1342
|
+
return () => listeners.delete(fn);
|
|
1343
|
+
},
|
|
1344
|
+
/** Reset and start over. */
|
|
1345
|
+
reset: () => {
|
|
1346
|
+
allRows = [];
|
|
1347
|
+
offset = 0;
|
|
1348
|
+
hasMore = true;
|
|
1349
|
+
},
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** Get the current cursor position. */
|
|
1354
|
+
getCursor(): SyncCursor {
|
|
1355
|
+
return { ...this.cursor };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/** Whether the WebSocket is currently connected. */
|
|
1359
|
+
get connected(): boolean {
|
|
1360
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// -----------------------------------------------------------------------
|
|
1364
|
+
// Presence
|
|
1365
|
+
// -----------------------------------------------------------------------
|
|
1366
|
+
|
|
1367
|
+
/** Set this client's presence data and broadcast it. */
|
|
1368
|
+
setPresence(data: Record<string, unknown>): void {
|
|
1369
|
+
this.presenceData = data;
|
|
1370
|
+
this.sendWs({
|
|
1371
|
+
type: "presence",
|
|
1372
|
+
event: "update",
|
|
1373
|
+
data: this.presenceData,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** Send a topic message to all connected clients. */
|
|
1378
|
+
publishTopic(topic: string, data: unknown): void {
|
|
1379
|
+
this.sendWs({
|
|
1380
|
+
type: "topic",
|
|
1381
|
+
topic,
|
|
1382
|
+
data,
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Subscribe this client to binary CRDT updates for one row. Refcounted
|
|
1388
|
+
* so two `useLoroDoc` consumers on the same `(entity, rowId)` don't
|
|
1389
|
+
* unsubscribe each other on unmount — only the last `unsubscribeCrdt`
|
|
1390
|
+
* call ships the unsubscribe message to the server.
|
|
1391
|
+
*
|
|
1392
|
+
* The first subscriber for a row sends the `crdt-subscribe` over WS,
|
|
1393
|
+
* which prompts the server to ship the current snapshot back as a
|
|
1394
|
+
* binary frame so the new tab converges to the latest state.
|
|
1395
|
+
*
|
|
1396
|
+
* Idempotent at the WS level: re-calling for the same row with no
|
|
1397
|
+
* intervening unsubscribe just bumps the refcount.
|
|
1398
|
+
*/
|
|
1399
|
+
subscribeCrdt(entity: string, rowId: string): void {
|
|
1400
|
+
const key = `${entity}\x00${rowId}`;
|
|
1401
|
+
const prev = this.crdtSubscribers.get(key) ?? 0;
|
|
1402
|
+
this.crdtSubscribers.set(key, prev + 1);
|
|
1403
|
+
if (prev === 0) {
|
|
1404
|
+
this.crdtSubscriptions.add(key);
|
|
1405
|
+
this.sendWs({ type: "crdt-subscribe", entity, rowId });
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Decrement the refcount for a row. When it hits zero we ship a
|
|
1411
|
+
* `crdt-unsubscribe` to the server and forget the row, so a future
|
|
1412
|
+
* reconnect won't try to resubscribe.
|
|
1413
|
+
*
|
|
1414
|
+
* Calling `unsubscribeCrdt` more times than `subscribeCrdt` is a
|
|
1415
|
+
* no-op rather than an error — keeps React's StrictMode double-
|
|
1416
|
+
* invocation in dev from over-decrementing past zero.
|
|
1417
|
+
*/
|
|
1418
|
+
unsubscribeCrdt(entity: string, rowId: string): void {
|
|
1419
|
+
const key = `${entity}\x00${rowId}`;
|
|
1420
|
+
const prev = this.crdtSubscribers.get(key) ?? 0;
|
|
1421
|
+
if (prev <= 0) return;
|
|
1422
|
+
if (prev === 1) {
|
|
1423
|
+
this.crdtSubscribers.delete(key);
|
|
1424
|
+
this.crdtSubscriptions.delete(key);
|
|
1425
|
+
this.sendWs({ type: "crdt-unsubscribe", entity, rowId });
|
|
1426
|
+
} else {
|
|
1427
|
+
this.crdtSubscribers.set(key, prev - 1);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
private sendWs(msg: unknown): void {
|
|
1432
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
1433
|
+
this.ws.send(JSON.stringify(msg));
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
1438
|
+
const headers: Record<string, string> = {};
|
|
1439
|
+
if (body) headers["Content-Type"] = "application/json";
|
|
1440
|
+
// Prefer the token explicitly configured on the engine; fall back to
|
|
1441
|
+
// the conventional localStorage key that `@pylonsync/react`'s auth
|
|
1442
|
+
// helpers store. Without this fallback, the sync engine runs as an
|
|
1443
|
+
// anonymous caller and gets rate-limited into a 429 reconnect storm
|
|
1444
|
+
// once the anon bucket fills.
|
|
1445
|
+
const token =
|
|
1446
|
+
this.config.token ??
|
|
1447
|
+
this.storage.get(this.tokenStorageKey()) ??
|
|
1448
|
+
undefined;
|
|
1449
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
1450
|
+
|
|
1451
|
+
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
|
1452
|
+
method,
|
|
1453
|
+
headers,
|
|
1454
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
if (!res.ok) {
|
|
1458
|
+
// Surface the status so the caller can distinguish transient
|
|
1459
|
+
// (429/503) from permanent (400/404) failures — the reconnect
|
|
1460
|
+
// loop uses this to decide whether to back off.
|
|
1461
|
+
const err = new Error(`Sync request failed: ${res.status}`) as Error & {
|
|
1462
|
+
status?: number;
|
|
1463
|
+
};
|
|
1464
|
+
err.status = res.status;
|
|
1465
|
+
throw err;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
return res.json() as Promise<T>;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// ---------------------------------------------------------------------------
|
|
1473
|
+
// SSR / Hydration types
|
|
1474
|
+
// ---------------------------------------------------------------------------
|
|
1475
|
+
|
|
1476
|
+
/** Data shape for hydrating the client from server-rendered content. */
|
|
1477
|
+
export interface HydrationData {
|
|
1478
|
+
/** Map of entity name -> rows fetched on the server. */
|
|
1479
|
+
entities: Record<string, Record<string, unknown>[]>;
|
|
1480
|
+
/** The sync cursor at the time of server fetch. */
|
|
1481
|
+
cursor?: SyncCursor;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Server-side helper: fetch entities from the pylon API and return
|
|
1486
|
+
* hydration data that can be passed to the client's SyncEngine.hydrate().
|
|
1487
|
+
*
|
|
1488
|
+
* Use this in Next.js server components, getServerSideProps, or route handlers.
|
|
1489
|
+
*/
|
|
1490
|
+
export async function getServerData(
|
|
1491
|
+
baseUrl: string,
|
|
1492
|
+
entities: string[],
|
|
1493
|
+
options?: { token?: string }
|
|
1494
|
+
): Promise<HydrationData> {
|
|
1495
|
+
const headers: Record<string, string> = {};
|
|
1496
|
+
if (options?.token) {
|
|
1497
|
+
headers["Authorization"] = `Bearer ${options.token}`;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const entityData: Record<string, Record<string, unknown>[]> = {};
|
|
1501
|
+
|
|
1502
|
+
for (const entity of entities) {
|
|
1503
|
+
try {
|
|
1504
|
+
const res = await fetch(`${baseUrl}/api/entities/${entity}`, { headers });
|
|
1505
|
+
if (res.ok) {
|
|
1506
|
+
entityData[entity] = (await res.json()) as Record<string, unknown>[];
|
|
1507
|
+
} else {
|
|
1508
|
+
entityData[entity] = [];
|
|
1509
|
+
}
|
|
1510
|
+
} catch {
|
|
1511
|
+
entityData[entity] = [];
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// Get current sync cursor.
|
|
1516
|
+
let cursor: SyncCursor = { last_seq: 0 };
|
|
1517
|
+
try {
|
|
1518
|
+
const res = await fetch(`${baseUrl}/api/sync/pull?since=0&limit=0`, { headers });
|
|
1519
|
+
if (res.ok) {
|
|
1520
|
+
const pull = (await res.json()) as PullResponse;
|
|
1521
|
+
cursor = pull.cursor;
|
|
1522
|
+
}
|
|
1523
|
+
} catch {
|
|
1524
|
+
// Use beginning cursor.
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
return { entities: entityData, cursor };
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// ---------------------------------------------------------------------------
|
|
1531
|
+
// Convenience factory
|
|
1532
|
+
// ---------------------------------------------------------------------------
|
|
1533
|
+
|
|
1534
|
+
/** Create a sync engine connected to the pylon dev server. */
|
|
1535
|
+
export function createSyncEngine(
|
|
1536
|
+
baseUrl = "http://localhost:4321",
|
|
1537
|
+
options?: Partial<SyncEngineConfig>,
|
|
1538
|
+
): SyncEngine {
|
|
1539
|
+
return new SyncEngine({
|
|
1540
|
+
...(options ?? {}),
|
|
1541
|
+
baseUrl,
|
|
1542
|
+
});
|
|
1543
|
+
}
|