@pylonsync/sync 0.22.25 → 0.22.27

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/dist/index.d.ts CHANGED
@@ -346,18 +346,6 @@ export declare class SyncEngine {
346
346
  * is the dedup key.
347
347
  */
348
348
  private binaryHandlers;
349
- /**
350
- * Latest server CRDT SNAPSHOT frame per `entity|rowId`, kept so a
351
- * follower tab that registers interest in an ALREADY-subscribed row
352
- * gets state immediately. The server sends its catch-up snapshot only
353
- * when a fresh `crdt-subscribe` goes over the WS — when the leader
354
- * (or another follower) already holds the subscription, no wire
355
- * traffic happens and, pre-cache, the new tab's LoroDoc stayed empty
356
- * until the next live edit. Server broadcasts are always FULL merged
357
- * snapshots (CRDT_FRAME_SNAPSHOT), so one frame per row is complete
358
- * state. Bounded FIFO — see LAST_CRDT_FRAMES_MAX.
359
- */
360
- private lastCrdtFrames;
361
349
  /**
362
350
  * Server-side ephemeral subscriptions (CRDT row subs, reactive query
363
351
  * subs, future kinds). Owns the WS replay bookkeeping — each kind
@@ -930,18 +918,18 @@ export declare class SyncEngine {
930
918
  * policy as `request()` — keeps the auth flows on the same
931
919
  * authentication footing as data sync. */
932
920
  private authMutate;
933
- /** Push pending mutations to the server. Coalesces concurrent callers
934
- * via the op queue's keyed dedupe — a slow push can't be restarted
935
- * by the poll timer or a user mutation, which would resend the same
936
- * batch (the mutation `op_id` keeps that safe at the protocol level,
937
- * but shipping the same batch twice is still wasted bandwidth). Also
938
- * serializes against pull / reconcile / resetReplica so a push can't
939
- * observe a half-reset cursor or a mid-reconcile replica. */
921
+ private inFlightPush;
922
+ private newPushIds;
923
+ private pushReplayRequested;
924
+ private pushRetryTimer;
925
+ /** Explicit recovery retries all pending writes. Concurrent callers share
926
+ * the complete drain, including writes added during the request. */
940
927
  push(): Promise<void>;
941
- /** Op ids of pending mutations that some push already sent (or
942
- * forwarded to the leader). */
943
- private attemptedOps;
928
+ private pushNew;
929
+ private drainPush;
944
930
  private pushInner;
931
+ private cancelPushRetry;
932
+ private schedulePushRetry;
945
933
  /** Run `fn` after `ms` unless the engine stops first. A retry from a
946
934
  * stopped engine would otherwise still reach the network. */
947
935
  private later;
@@ -1153,9 +1141,6 @@ export declare class SyncEngine {
1153
1141
  * follower tab forwarded a CRDT sub, mirror over the multi-tab
1154
1142
  * channel so followers see Loro updates too. */
1155
1143
  private dispatchBinaryFrame;
1156
- /** Drop the cached snapshot for a revoked row so it can't be
1157
- * replayed to a late-joining tab after policy said no. */
1158
- private evictCrdtFrame;
1159
1144
  private request;
1160
1145
  }
1161
1146
  /** Data shape for hydrating the client from server-rendered content. */
@@ -1174,16 +1159,7 @@ export interface HydrationData {
1174
1159
  export declare function getServerData(baseUrl: string, entities: string[], options?: {
1175
1160
  token?: string;
1176
1161
  }): Promise<HydrationData>;
1177
- /**
1178
- * Stable equality check for reconciler diffs. Keys are sorted so
1179
- * `{a:1,b:2}` and `{b:2,a:1}` compare equal — without that, every
1180
- * reconcile pass would think every row had changed (insertion order
1181
- * varies by mutation path on the server). Recursive on objects only;
1182
- * arrays and primitives use their natural shape.
1183
- */
1184
- /** Cap on cached per-row CRDT snapshots (see lastCrdtFrames). 64 rows of
1185
- * collaborative state is far beyond what one browser session edits at
1186
- * once; FIFO eviction keeps a long-lived leader tab bounded. */
1162
+ /** @deprecated The engine no longer caches binary frames. */
1187
1163
  export declare const LAST_CRDT_FRAMES_MAX = 64;
1188
1164
  /**
1189
1165
  * Header-only peek at a binary CRDT frame: returns `"entity|rowId"` for
@@ -1199,7 +1175,7 @@ export declare const LAST_CRDT_FRAMES_MAX = 64;
1199
1175
  * this parser and the Swift one change with it.
1200
1176
  *
1201
1177
  * The engine still treats the PAYLOAD as opaque — this reads only the
1202
- * routing header so the follower-catch-up cache can be keyed per row.
1178
+ * routing header. A snapshot type does not prove the payload is complete.
1203
1179
  */
1204
1180
  export declare function crdtFrameKey(bytes: Uint8Array): string | null;
1205
1181
  /**
@@ -25,8 +25,19 @@ export declare class LocalStore {
25
25
  */
26
26
  private optimisticTombstones;
27
27
  private listeners;
28
+ private versions;
29
+ private rowVersions;
30
+ private revision;
31
+ private resetVersion;
32
+ /** Changes only when this entity changes or the replica is cleared. */
33
+ version(entity: string): number;
34
+ /** Changes only when this row changes or the replica is cleared. */
35
+ rowVersion(entity: string, id: string): number;
36
+ private touch;
28
37
  /** Get all rows for an entity. */
29
38
  list(entity: string): Row[];
39
+ /** Iterate current rows without copying the table. Consume synchronously. */
40
+ rows(entity: string): IterableIterator<Row>;
30
41
  /** Get a row by ID. */
31
42
  get(entity: string, id: string): Row | null;
32
43
  /** Snapshot of every entity name with at least one local row. Used
@@ -113,9 +124,11 @@ export declare class LocalStore {
113
124
  * Void-returning callbacks are accepted for backwards compatibility
114
125
  * (treated as durable / fire-and-forget). */
115
126
  _persistFn: ((change: ChangeEvent) => void | Promise<boolean>) | null;
116
- /** Subscribe to store changes. Returns unsubscribe function. */
117
- subscribe(listener: () => void): () => void;
118
- notify(): void;
127
+ /** Subscribe globally or to one entity. Global status/reset signals reach both. */
128
+ subscribe(listener: (entity?: string) => void, entity?: string): () => void;
129
+ /** No entity means a global status or reset signal. */
130
+ notify(entity?: string): void;
131
+ private notifyEntities;
119
132
  /** Apply an optimistic insert. Returns a temporary id. */
120
133
  optimisticInsert(entity: string, data: Row): string;
121
134
  /**
@@ -186,6 +199,8 @@ export declare class LocalStore {
186
199
  * the engine's cursor stays pinned to the change-log position the
187
200
  * reconcile started from.
188
201
  */
202
+ applyReconcileInMemory(entity: string, upserts: Row[], removalIds: string[], tombstoneSeq: number): ChangeEvent[];
203
+ /** Apply and persist with the single-row fallback for standalone stores. */
189
204
  applyReconcileBatch(entity: string, upserts: Row[], removalIds: string[], tombstoneSeq: number): Promise<void>;
190
205
  /**
191
206
  * Drop every table + tombstone in-place, then notify. Used by the
@@ -46,12 +46,14 @@ export interface PendingMutation {
46
46
  }
47
47
  /**
48
48
  * Optional persistence backend. The default IndexedDB persistence
49
- * layer provides `savePending`/`loadPending`. Callers can supply a
50
- * custom backend for tests or alternative storage.
49
+ * layer supports incremental writes with `saveChanges`. Custom backends
50
+ * can keep the full-snapshot `saveAll` contract.
51
51
  */
52
52
  export interface MutationQueuePersistence {
53
53
  saveAll(mutations: PendingMutation[]): Promise<void>;
54
54
  loadAll(): Promise<PendingMutation[]>;
55
+ /** Optional ordered, atomic delta write. Unchanged entries stay on disk. */
56
+ saveChanges?(upserts: PendingMutation[], removedIds: string[]): Promise<void>;
55
57
  }
56
58
  export declare class MutationQueue {
57
59
  private queue;
@@ -109,6 +111,8 @@ export declare class MutationQueue {
109
111
  * and is retried; a later rejection shows up as `status: "failed"`. */
110
112
  settleQueued(id: string): void;
111
113
  markApplied(id: string): void;
114
+ /** Persist one response batch once, then settle each caller. */
115
+ markAppliedMany(ids: string[]): void;
112
116
  markFailed(id: string, error: string, errorCode?: string): void;
113
117
  private resolveWaiters;
114
118
  /**
@@ -143,10 +143,8 @@ export declare function persistChange(persistence: ReplicaPersistence, change: C
143
143
  * `MutationQueue` into the same database as the entity mirror so everything
144
144
  * the app needs to resume a session lives in one place.
145
145
  *
146
- * `saveAll` writes the entire queue on every change. That's O(n) per write,
147
- * but `n` is bounded by "how many mutations the user queued while offline",
148
- * which is tiny in practice. If that ever becomes a bottleneck, switch to
149
- * per-id `put`/`delete` — the schema (`keyPath: "id"`) already supports it.
146
+ * Incremental writes update only changed IDs. Full snapshots support initial
147
+ * hydration and identity reset, and remain available to custom backends.
150
148
  */
151
149
  export declare class IndexedDBMutationPersistence implements MutationQueuePersistence {
152
150
  private readonly parent;
@@ -154,5 +152,6 @@ export declare class IndexedDBMutationPersistence implements MutationQueuePersis
154
152
  constructor(parent: IndexedDBPersistence);
155
153
  private handle;
156
154
  saveAll(mutations: PendingMutation[]): Promise<void>;
155
+ saveChanges(upserts: PendingMutation[], removedIds: string[]): Promise<void>;
157
156
  loadAll(): Promise<PendingMutation[]>;
158
157
  }
@@ -18,6 +18,8 @@ export declare class ServerSubscriptions {
18
18
  unregister(key: string, unsubscribeMessage: unknown): void;
19
19
  /** Whether `key` is currently registered. */
20
20
  has(key: string): boolean;
21
+ /** Request fresh state for an existing subscription. */
22
+ refresh(key: string): void;
21
23
  /** Re-send every registered subscribe message. Called from
22
24
  * `ws.onopen` after the socket reconnects — the server purges
23
25
  * per-client subscription state on disconnect, so without this
@@ -5,13 +5,6 @@ import type { ReactiveMessage } from "./types";
5
5
  export interface SubscriptionCoordinatorContext {
6
6
  isLeader(): boolean;
7
7
  broadcastToTabs(payload: unknown): void;
8
- /** Leader-side replay of the cached CRDT snapshot for a row (see
9
- * SyncEngine.lastCrdtFrames). Called when a follower registers
10
- * interest in a row whose WS subscription is ALREADY alive — the
11
- * server only ships its catch-up snapshot on a fresh
12
- * `crdt-subscribe`, so without the replay the new tab's doc stays
13
- * empty until the next live edit. */
14
- replayCrdtFrame?(entity: string, rowId: string): void;
15
8
  }
16
9
  export declare class SubscriptionCoordinator {
17
10
  private readonly serverSubs;
@@ -99,6 +92,7 @@ export declare class SubscriptionCoordinator {
99
92
  * The engine gates its binary-broadcast on this so we don't fan
100
93
  * binary frames to every tab when no follower cares about CRDT. */
101
94
  hasCrdtForwarders(): boolean;
95
+ hasCrdtForwardersFor(entity: string, rowId: string): boolean;
102
96
  }
103
97
  /** Internal CRDT key format: `${entity}\x00${rowId}`. Centralized so
104
98
  * the engine's binary-frame parser (and any future routing logic)
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.25",
6
+ "version": "0.22.27",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -367,36 +367,22 @@ describe("Fix C: race election || auth/me", () => {
367
367
  });
368
368
 
369
369
  test("two tabs racing election: only the leader applies its bootstrap session", async () => {
370
- // Multi-tab integration: two engines share a BroadcastChannel (Bun
371
- // provides one in its test runtime). Both kick off
372
- // fetchSessionBootstrap in parallel. Both fetches land — the
373
- // network fetch isn't leader-gated; that's the whole speedup —
374
- // but only the engine that won the election routes the result
375
- // into the resolver via applySessionTransition. The follower's
376
- // result is discarded by start().
377
- //
378
- // This test pins the invariant: a follower MUST NOT commit a
379
- // session it fetched during the parallel bootstrap. If a future
380
- // refactor "simplifies" the bootstrap back to gating the fetch on
381
- // isMultiTabLeader, no behavior changes here (the test still
382
- // passes); if the refactor instead drops the leader check at the
383
- // APPLY point, follower starts double-committing — and this test
384
- // fails because the follower would call applySessionTransition
385
- // which broadcasts back to the leader.
386
-
370
+ // The follower's later response must not replace the leader's session.
387
371
  const appName = "boot-race-" + Math.random().toString(36).slice(2);
388
372
  let authMeHits = 0;
373
+ let releaseFollower!: () => void;
374
+ const followerResponse = new Promise<void>((resolve) => { releaseFollower = resolve; });
389
375
  const original = globalThis.fetch;
390
376
  globalThis.fetch = (async (input: RequestInfo | URL) => {
391
377
  const url = typeof input === "string" ? input : input.toString();
392
378
  if (url.endsWith("/api/auth/me")) {
393
- authMeHits += 1;
394
- await new Promise((r) => setTimeout(r, 10));
379
+ const request = ++authMeHits;
380
+ if (request === 2) await followerResponse;
395
381
  return {
396
382
  ok: true,
397
383
  status: 200,
398
384
  json: async () => ({
399
- user_id: "u1",
385
+ user_id: request === 1 ? "leader-user" : "stale-follower-user",
400
386
  tenant_id: null,
401
387
  is_admin: false,
402
388
  roles: [],
@@ -452,6 +438,7 @@ describe("Fix C: race election || auth/me", () => {
452
438
  const startB = b.start();
453
439
 
454
440
  await Promise.all([startA, startB]);
441
+ releaseFollower();
455
442
  // Let any trailing broadcasts / poll ticks settle.
456
443
  await new Promise((r) => setTimeout(r, 100));
457
444
 
@@ -469,42 +456,54 @@ describe("Fix C: race election || auth/me", () => {
469
456
  const aSession = a.session.signature();
470
457
  const bSession = b.session.signature();
471
458
  expect(bSession).toBe(aSession);
472
- // Both saw auth/me at most once each (no retry storm).
473
- expect(authMeHits).toBeGreaterThanOrEqual(1);
474
- expect(authMeHits).toBeLessThanOrEqual(2);
459
+ expect(a.session.resolved().userId).toBe("leader-user");
460
+ expect(authMeHits).toBe(2);
475
461
 
476
462
  a.stop();
477
463
  b.stop();
478
464
  });
479
465
  });
480
466
 
481
- // ---------------------------------------------------------------------------
482
- // Fix D — Fire-and-forget saveCursor on bootstrap
483
- //
484
- // The behavioral change is purely "didn't block start()". Hard to assert
485
- // directly without timing flakiness; instead pin the invariant that
486
- // start() completes even when saveCursor is artificially slow.
487
- // ---------------------------------------------------------------------------
488
-
489
- describe("Fix D: fire-and-forget bootstrap saveCursor", () => {
490
- // This is the lightest-weight assertion that pins the fix: the
491
- // bootstrap call site uses `void this.persistence.saveCursor(...)`,
492
- // not `await`. We can't easily install a slow persistence layer into
493
- // an engine that explicitly opted out of persistence, so the
494
- // regression coverage here is the AST shape — verified by reading
495
- // the file and asserting the call is not awaited.
496
- test("source uses void (not await) for the bootstrap saveCursor", async () => {
497
- const path = new URL("./index.ts", import.meta.url).pathname;
498
- const src = await Bun.file(path).text();
499
- // Find the bootstrap save block.
500
- const marker = "// Save cursor after pull.";
501
- const idx = src.indexOf(marker);
502
- expect(idx).toBeGreaterThan(0);
503
- const window = src.slice(idx, idx + 800);
504
- // The fixed form uses `void this.persistence.saveCursor`; the
505
- // pre-fix form was `await this.persistence.saveCursor`. Pin both
506
- // sides of that change so a revert fails clearly.
507
- expect(window).toContain("void this.persistence.saveCursor(this.cursor)");
508
- expect(window).not.toContain("await this.persistence.saveCursor");
467
+ describe("bootstrap cursor persistence", () => {
468
+ test("startup completes while the final cursor save is pending", async () => {
469
+ let release!: (saved: boolean) => void;
470
+ const pendingSave = new Promise<boolean>((resolve) => { release = resolve; });
471
+ let saves = 0;
472
+ const restoreFetch = installFetch(async (url) => ({
473
+ status: 200,
474
+ body: url.includes("/api/auth/me")
475
+ ? { user_id: null }
476
+ : { changes: [], cursor: { last_seq: 0 }, has_more: false },
477
+ }));
478
+ const engine = new SyncEngine({
479
+ baseUrl: "http://stub.invalid",
480
+ multiTab: false,
481
+ transport: "poll",
482
+ pollInterval: 1e9,
483
+ persistence: {
484
+ open: async () => {},
485
+ loadSnapshot: async () => ({ entities: {}, cursor: null, hadCache: false }),
486
+ loadIdentity: async () => null,
487
+ saveIdentity: async () => true,
488
+ saveCursor: () => { saves += 1; return pendingSave; },
489
+ saveRow: async () => true,
490
+ deleteRow: async () => true,
491
+ clear: async () => true,
492
+ },
493
+ });
494
+ let timeout: ReturnType<typeof setTimeout> | undefined;
495
+ try {
496
+ const result = await Promise.race([
497
+ engine.start().then(() => "started"),
498
+ new Promise<string>((resolve) => { timeout = setTimeout(() => resolve("blocked"), 1000); }),
499
+ ]);
500
+ expect(result).toBe("started");
501
+ expect(saves).toBeGreaterThan(0);
502
+ } finally {
503
+ clearTimeout(timeout);
504
+ release(true);
505
+ engine.stop();
506
+ restoreFetch();
507
+ }
509
508
  });
510
509
  });