@pylonsync/sync 0.21.0 → 0.22.0

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
@@ -8,7 +8,7 @@ export type { ReplicaPersistence } from "./persistence";
8
8
  export { buildRequest, pylonFetch, pylonFetchRaw, PylonHttpError, resolveBaseUrl, } from "./transport";
9
9
  export type { PylonRequestInit, TransportConfig } from "./transport";
10
10
  export { LocalStore } from "./local-store";
11
- export { MutationQueue, type MutationQueuePersistence, type PendingMutation, } from "./mutation-queue";
11
+ export { MutationQueue, MutationRejectedError, type MutationQueuePersistence, type PendingMutation, } from "./mutation-queue";
12
12
  export { generateId } from "./ids";
13
13
  export type { ChangeEvent, ClientChange, PullResponse, PushOpResult, PushResponse, ReactiveMessage, ReactiveSpec, ResolvedSession, Row, SyncConnectionStatus, SyncCursor, TransportType, } from "./types";
14
14
  export { defaultStorage, createWriteThroughStorage, type Storage, } from "./storage";
@@ -171,6 +171,19 @@ export declare class SyncEngine {
171
171
  private _initialSyncSettled;
172
172
  isInitialSyncSettled(): boolean;
173
173
  private _initialSyncFallback;
174
+ /**
175
+ * True once a pull completed against the server in this run since the
176
+ * last replica reset. Unlike `isInitialSyncSettled()`, a warm cache does
177
+ * not set it and there is no fallback deadline: it stays false while the
178
+ * replica holds only what IndexedDB had (possibly stale or partial) and
179
+ * while the server is unreachable. A replica reset (identity or org
180
+ * switch, 410 resync) clears it until the re-pull lands. Follower tabs
181
+ * take it from the leader.
182
+ */
183
+ private _synced;
184
+ isSynced(): boolean;
185
+ /** Flip `_synced` true (idempotent), notify, and tell follower tabs. */
186
+ private markSynced;
174
187
  /** Flip `_initialSyncSettled` true (idempotent) + notify so `useQuery`
175
188
  * re-reads and drops its loading state. */
176
189
  private markInitialSyncSettled;
@@ -502,6 +515,39 @@ export declare class SyncEngine {
502
515
  * against the local store.
503
516
  */
504
517
  private enqueueApply;
518
+ /** Live frames waiting for their apply to start. See `enqueueApply`. */
519
+ private liveBatch;
520
+ /** Bumped by every replica reset. An apply step records the epoch it
521
+ * was queued in and drops its work when a reset happened since: its
522
+ * changes belong to the replica that was wiped. */
523
+ private replicaEpoch;
524
+ /** > 0 while `resetReplicaInner` runs. Live frames that arrive then
525
+ * come from the socket of the replica being wiped; the pull that
526
+ * follows every reset delivers anything current. */
527
+ private resetDepth;
528
+ /**
529
+ * True from a token change until a session refresh says who the new
530
+ * token belongs to. While true, owned writes are not pushed (the
531
+ * owner can't be checked) and a push retries the refresh with backoff.
532
+ */
533
+ private identityPending;
534
+ /** Bearer token the committed session was fetched with (leader). A
535
+ * push whose current token differs holds owned writes: the token may
536
+ * belong to someone else. `undefined` until a session is committed. */
537
+ private sessionToken;
538
+ private identityRetryTimer;
539
+ /** Delayed push / pull retries scheduled through `later()`. `stop()`
540
+ * clears them, so a stopped engine makes no further requests. */
541
+ private retryTimers;
542
+ private identityRetryAttempts;
543
+ /** Chain one apply step behind every queued one. Anything chained
544
+ * here closes the open live batch first, so later live frames can
545
+ * never be applied ahead of it. Errors stay scoped to the step. */
546
+ private chainApply;
547
+ /** Apply one batch: seq-filter, apply to memory, persist rows and the
548
+ * advanced cursor, and fan out to follower tabs. Runs on the apply
549
+ * queue only. */
550
+ private applyBatch;
505
551
  /**
506
552
  * Reconcile path. Routes through the same applyQueue as WS/pull so
507
553
  * a reconcile batch can't interleave with a fresher change event
@@ -561,6 +607,7 @@ export declare class SyncEngine {
561
607
  * replayed as the incoming one (cross-identity write leak).
562
608
  */
563
609
  private resetReplicaInner;
610
+ private resetReplicaSteps;
564
611
  /**
565
612
  * localStorage key for the auth token, namespaced by appName. Matches
566
613
  * the key the React package's `configureClient` writes to so the sync
@@ -698,6 +745,9 @@ export declare class SyncEngine {
698
745
  */
699
746
  observeEntity(entity: string): void;
700
747
  reconcile(entities?: string[]): Promise<void>;
748
+ /** Scoped reconcile batch that has not started running yet. */
749
+ private scopedReconcileBatch;
750
+ private scopedReconcileSeq;
701
751
  private reconcileInner;
702
752
  /** Fetch every row for an entity. Uses cursor pagination so big tables
703
753
  * don't blow past server-side limits; loops until `has_more` is false
@@ -721,7 +771,9 @@ export declare class SyncEngine {
721
771
  * On tenant flip this also resets the replica — same logic as the
722
772
  * token-flip path, for the same reason (visible set changed).
723
773
  */
724
- refreshResolvedSession(): Promise<void>;
774
+ refreshResolvedSession(opts?: {
775
+ replicaAlreadyReset?: boolean;
776
+ }): Promise<void>;
725
777
  /**
726
778
  * Pure HTTP fetch of /api/auth/me → ResolvedSession. Unlike
727
779
  * `refreshResolvedSession`, this does NOT gate on `isMultiTabLeader`
@@ -763,6 +815,29 @@ export declare class SyncEngine {
763
815
  * the newer one.
764
816
  */
765
817
  private applySessionTransition;
818
+ /** Reset the replica and pull from zero in ONE op-queue slot, so no
819
+ * live frame can move the cursor between the reset and the pull's
820
+ * hold (the pull would then run as a delta and miss rows). */
821
+ private resetAndPull;
822
+ /** The user a write made now belongs to: the resolved session, or,
823
+ * before `/api/auth/me` answered this run, the identity the replica
824
+ * was tagged with on disk. `undefined` = unknown. */
825
+ private currentOwner;
826
+ /**
827
+ * Decide what push does with a queued write, by its owner:
828
+ * - `send`: it belongs to the signed-in user (or its owner is unknown,
829
+ * an older client's write), or a guest's write after that guest
830
+ * signed in (the server merges the guest's rows into the account).
831
+ * - `hold`: nobody is signed in, or a token change has not resolved
832
+ * yet. The write waits for the next identity.
833
+ * - `discard`: another user is signed in. The write is dropped so one
834
+ * user's writes never push as another's.
835
+ */
836
+ private pushable;
837
+ /** A push found writes waiting on an unresolved token change. Retry the
838
+ * session refresh with backoff (1 s, 2 s, ... 30 s) until it answers;
839
+ * the refresh then pushes them. */
840
+ private scheduleIdentityRetry;
766
841
  private rawFetch;
767
842
  /**
768
843
  * Public alias for `refreshResolvedSession`. Almost never needed by
@@ -829,9 +904,9 @@ export declare class SyncEngine {
829
904
  clearOrg(): Promise<void>;
830
905
  /**
831
906
  * Revoke the current session server-side (DELETE /api/auth/session)
832
- * and refresh — leaves the caller anonymous. Local sync stops on
833
- * the next pull cycle; replica content stays in IndexedDB so a
834
- * subsequent sign-in as the same user is instant.
907
+ * and refresh — leaves the caller anonymous. The refresh sees the
908
+ * user flip, wipes the replica (memory and IndexedDB), and re-pulls
909
+ * as the anonymous identity.
835
910
  */
836
911
  signOut(): Promise<void>;
837
912
  /** Relay mode: fetch a fresh signed connect target from the machine
@@ -853,7 +928,13 @@ export declare class SyncEngine {
853
928
  * serializes against pull / reconcile / resetReplica so a push can't
854
929
  * observe a half-reset cursor or a mid-reconcile replica. */
855
930
  push(): Promise<void>;
931
+ /** Op ids of pending mutations that some push already sent (or
932
+ * forwarded to the leader). */
933
+ private attemptedOps;
856
934
  private pushInner;
935
+ /** Run `fn` after `ms` unless the engine stops first. A retry from a
936
+ * stopped engine would otherwise still reach the network. */
937
+ private later;
857
938
  /**
858
939
  * Mark a pending mutation as failed AND undo its optimistic ghost
859
940
  * in the local replica. Without the rollback step, a server-
@@ -874,6 +955,14 @@ export declare class SyncEngine {
874
955
  */
875
956
  private failPushedMutation;
876
957
  /** Insert a row with optimistic local update.
958
+ *
959
+ * The row is in the local store before this returns a promise. The
960
+ * promise resolves with the row id once the server applies the write,
961
+ * or once the write is queued because the server could not be reached
962
+ * (offline, 5xx); a queued write that the server rejects later shows up
963
+ * as a failed mutation. The promise rejects with a
964
+ * `MutationRejectedError` when the server rejects the write (policy
965
+ * denial, validation); the optimistic row is removed first.
877
966
  *
878
967
  * Invariant: the optimistic ghost and the canonical server row
879
968
  * share a single id. The client mints a Pylon-shaped id, threads
@@ -881,9 +970,16 @@ export declare class SyncEngine {
881
970
  * canonical insert. Test:
882
971
  * `insert_optimistic_ghost_and_server_row_share_id`. */
883
972
  insert(entity: string, data: Row): Promise<string>;
884
- /** Update a row with optimistic local update. */
973
+ /** Push, then wait for the mutation's outcome. The wait starts before
974
+ * the push so an outcome that lands during the push is not missed. */
975
+ private sendAndAwait;
976
+ /** Update a row with optimistic local update. Settles like `insert`:
977
+ * rejects with a `MutationRejectedError` (after restoring the prior
978
+ * value) when the server rejects the write. */
885
979
  update(entity: string, id: string, data: Partial<Row>): Promise<void>;
886
- /** Delete a row with optimistic local update. */
980
+ /** Delete a row with optimistic local update. Settles like `insert`:
981
+ * rejects with a `MutationRejectedError` (after restoring the row)
982
+ * when the server rejects the delete. */
887
983
  delete(entity: string, id: string): Promise<void>;
888
984
  /** Load a page of data from an entity with cursor-based pagination. */
889
985
  loadPage(entity: string, options?: {
@@ -1012,6 +1108,11 @@ export declare class SyncEngine {
1012
1108
  * `room-unsubscribe`. Used by the `useRoom` hook's manual `leave()`
1013
1109
  * action so a deliberate exit propagates to the server immediately. */
1014
1110
  unsubscribeRoom(roomId: string): void;
1111
+ /** Re-send `room-subscribe` for a room this tab is subscribed to and
1112
+ * clear its error. Call after rejoining the room over HTTP following a
1113
+ * `NOT_IN_ROOM` error; the server replies with a fresh snapshot. A
1114
+ * follower asks the leader, which owns the wire subscription. */
1115
+ resubscribeRoom(roomId: string): void;
1015
1116
  /** Read the current cached members snapshot for `roomId`. Returns
1016
1117
  * `null` when no snapshot has landed yet (distinct from `[]` for
1017
1118
  * an empty room). */
@@ -76,6 +76,13 @@ export declare class LocalStore {
76
76
  * a cursor after — otherwise a crash can save the cursor before
77
77
  * rows hit disk, causing permanent missed changes on restart. */
78
78
  applyChanges(changes: ChangeEvent[]): void;
79
+ /**
80
+ * Apply to memory and notify once, WITHOUT persisting. Returns each
81
+ * change with `data` set to the full merged row (deletes unchanged),
82
+ * ready to write to disk. The engine uses it to write a whole batch in
83
+ * one transaction (`ReplicaPersistence.saveBatch`).
84
+ */
85
+ applyInMemory(changes: ChangeEvent[]): ChangeEvent[];
79
86
  /**
80
87
  * Apply + persist, awaiting disk writes before returning. Callers
81
88
  * that are about to advance a cursor based on `changes` MUST use
@@ -40,7 +40,17 @@ export interface MultiTabOrchestratorHooks {
40
40
  onMutationsFailed(ops: {
41
41
  opId: string;
42
42
  error: string;
43
+ code?: string;
43
44
  }[]): void;
45
+ /** Leader → followers: the leader completed a pull (see
46
+ * `SyncEngine.isSynced`). */
47
+ onSyncedReceived(): void;
48
+ /** Follower → leader: a tab that just started asks whether the leader
49
+ * has synced. */
50
+ onSyncStatusRequested(): void;
51
+ /** Leader → followers: the leader's push failed transiently and
52
+ * these op ids stay queued for retry. */
53
+ onMutationsQueued(opIds: string[]): void;
44
54
  /** Leader → follower: a binary frame from the WS. Engine routes
45
55
  * to its local binary handlers. */
46
56
  onBinaryReceived(bytes: Uint8Array): void;
@@ -56,6 +66,9 @@ export interface MultiTabOrchestratorHooks {
56
66
  * `room-unsubscribe` when both the local refcount and the forwarder
57
67
  * set are empty. */
58
68
  onRoomSubUnregister?(roomId: string, fromTabId: string): void;
69
+ /** Follower → leader: the follower rejoined a room after a
70
+ * `NOT_IN_ROOM` error; resend the wire `room-subscribe`. */
71
+ onRoomSubResubscribe?(roomId: string): void;
59
72
  /** Leader → followers: a room-snapshot landed on the WS. Followers
60
73
  * apply it to their local room registry so their subscribers fire. */
61
74
  onRoomFanoutSnapshot?(roomId: string, members: unknown): void;
@@ -123,6 +136,7 @@ export declare class MultiTabOrchestrator {
123
136
  broadcastMutationsFailed(ops: {
124
137
  opId: string;
125
138
  error: string;
139
+ code?: string;
126
140
  }[]): void;
127
141
  /** Leader → followers: a reactive-result / reactive-error landed on
128
142
  * the WS. Routed by sub_id to whatever tab owns the local handler. */
@@ -1,9 +1,42 @@
1
1
  import type { ClientChange, Row } from "./types";
2
+ /**
3
+ * Thrown by `SyncEngine.insert` / `update` / `delete` when the server
4
+ * rejects the write (policy denial, validation, conflict). The optimistic
5
+ * change is rolled back before the promise rejects.
6
+ *
7
+ * `code` is the server's error code when it sent one (`POLICY_DENIED`,
8
+ * `VALIDATION_FAILED`, ...). `DISCARDED` means the write never reached the
9
+ * server: an identity change dropped it from the queue.
10
+ */
11
+ export declare class MutationRejectedError extends Error {
12
+ readonly code: string;
13
+ readonly opId: string;
14
+ readonly entity: string;
15
+ readonly rowId: string;
16
+ readonly kind: "insert" | "update" | "delete";
17
+ constructor(message: string, opts: {
18
+ code?: string;
19
+ opId: string;
20
+ entity: string;
21
+ rowId: string;
22
+ kind: "insert" | "update" | "delete";
23
+ });
24
+ }
2
25
  export interface PendingMutation {
3
26
  id: string;
4
27
  change: ClientChange;
5
28
  status: "pending" | "applied" | "failed";
6
29
  error?: string;
30
+ /** Server error code for a failed mutation, when the server sent one. */
31
+ errorCode?: string;
32
+ /** User id the write was made as (`null` = anonymous). Persisted with
33
+ * the queue so a write is only ever pushed as the user who made it.
34
+ * `undefined` = unknown (queued by an older client). */
35
+ owner?: string | null;
36
+ /** The write was made before this run knew who is signed in (no
37
+ * resolved session and no saved identity). It is held until the first
38
+ * session resolves, which stamps `owner`. */
39
+ ownerPending?: boolean;
7
40
  /** Pre-mutation snapshot of the affected row, captured at optimistic-
8
41
  * apply time for `update`/`delete`. On a server rejection,
9
42
  * `failPushedMutation` restores this so the local replica reverts to
@@ -23,6 +56,9 @@ export interface MutationQueuePersistence {
23
56
  export declare class MutationQueue {
24
57
  private queue;
25
58
  private persistence?;
59
+ /** Callers waiting for a mutation's outcome, by op id. Memory-only:
60
+ * a promise cannot outlive the page that created it. */
61
+ private waiters;
26
62
  constructor(persistence?: MutationQueuePersistence);
27
63
  /**
28
64
  * Attach a persistence backend after construction. The SyncEngine
@@ -41,7 +77,7 @@ export declare class MutationQueue {
41
77
  * entry with the same op_id is already queued — a follower
42
78
  * retrying its forward of the same op shouldn't double-queue on
43
79
  * the leader. */
44
- add(change: ClientChange, prevRow?: Row | null): string;
80
+ add(change: ClientChange, prevRow?: Row | null, owner?: string | null, ownerPending?: boolean): string;
45
81
  pending(): PendingMutation[];
46
82
  /** Look up a queued mutation by op id (any status). Used by the
47
83
  * follower's mutations-failed handler to reach the captured
@@ -61,13 +97,32 @@ export declare class MutationQueue {
61
97
  * the user is still trying to push.
62
98
  */
63
99
  pendingRowKeys(): Set<string>;
100
+ /**
101
+ * Resolve once the server applies the mutation or the mutation is
102
+ * queued behind a transient failure (`settleQueued`); reject with a
103
+ * `MutationRejectedError` once the server rejects it. Settles at once
104
+ * when the mutation already reached a terminal state.
105
+ */
106
+ waitForOutcome(id: string): Promise<void>;
107
+ /** Resolve the waiters of a mutation that stays queued after a
108
+ * transient push failure (offline, 5xx). The mutation stays pending
109
+ * and is retried; a later rejection shows up as `status: "failed"`. */
110
+ settleQueued(id: string): void;
64
111
  markApplied(id: string): void;
65
- markFailed(id: string, error: string): void;
112
+ markFailed(id: string, error: string, errorCode?: string): void;
113
+ private resolveWaiters;
66
114
  /**
67
115
  * Prune applied mutations. Failed mutations are KEPT so the UI can
68
116
  * surface them to the user and so retries are possible.
69
117
  */
70
118
  clear(): void;
119
+ /** Stamp every write whose owner was pending with the user the first
120
+ * resolved session names. */
121
+ stampPendingOwner(owner: string | null): void;
122
+ /** Drop one mutation that must never be pushed (it belongs to another
123
+ * identity) and reject its waiters with code `DISCARDED`. Does not
124
+ * touch the local store. */
125
+ discard(id: string): void;
71
126
  /** Remove a specific mutation by id. Used by the UI after user
72
127
  * ack of failures. */
73
128
  remove(id: string): void;
@@ -20,6 +20,14 @@ export interface ReplicaPersistence {
20
20
  saveRow(entity: string, id: string, data: Row): Promise<boolean>;
21
21
  deleteRow(entity: string, id: string): Promise<boolean>;
22
22
  clear(): Promise<boolean>;
23
+ /**
24
+ * Optional: write a batch of row changes, in order, and (when given) the
25
+ * cursor in ONE atomic transaction. Resolves `true` when the whole batch
26
+ * committed, `false` when it did not (nothing, cursor included, may be
27
+ * assumed on disk). The engine uses it to commit many live change frames
28
+ * at once; without it the engine writes row by row, then the cursor.
29
+ */
30
+ saveBatch?(changes: ChangeEvent[], cursor: SyncCursor | null): Promise<boolean>;
23
31
  }
24
32
  /**
25
33
  * IndexedDB-backed persistence for the sync store.
@@ -56,6 +64,10 @@ export declare class IndexedDBPersistence implements ReplicaPersistence {
56
64
  /** Save a row to IndexedDB. Resolves `true` when the write is durable,
57
65
  * `false` if it degraded (no DB / aborted) — see `commit`. */
58
66
  saveRow(entity: string, id: string, data: Row): Promise<boolean>;
67
+ /** Write row puts/deletes in order plus the cursor in ONE readwrite
68
+ * transaction. Rows and cursor commit or abort together, so the
69
+ * on-disk cursor can never get ahead of the on-disk rows. */
70
+ saveBatch(changes: ChangeEvent[], cursor: SyncCursor | null): Promise<boolean>;
59
71
  /** Fetch a row from IndexedDB by key. Used by `persistChange` on update
60
72
  * events to merge the patch against what's already on disk. */
61
73
  getRow(entity: string, id: string): Promise<Row | null>;
@@ -102,6 +102,11 @@ export declare class RoomSubscriptions {
102
102
  /** Every room currently tracked. Used by `replay()` and by the
103
103
  * multi-tab seed-on-promotion. */
104
104
  roomIds(): string[];
105
+ /** Clear a room's error and resend its `room-subscribe`. Used after
106
+ * the caller rejoined the room over HTTP following a `NOT_IN_ROOM`
107
+ * reply: the server answers the new subscribe with a snapshot. No-op
108
+ * for a room with no local subscriber. */
109
+ resubscribe(roomId: string): void;
105
110
  /** Resend `room-subscribe` for every active room. Called by the
106
111
  * engine's `onConnected` hook after the WS reopens — the server
107
112
  * forgets per-client subs across disconnects, so without this
@@ -126,6 +126,13 @@ export declare class TestServer {
126
126
  * reset) or an HTTP `status` (e.g. 403 permanent rejection, 503
127
127
  * transient). Lets tests exercise the transient-vs-permanent
128
128
  * classification in pushInner. */
129
+ /** When set, `/api/sync/push` waits for this promise before it
130
+ * answers. Lets a test queue a write while a push is in flight. */
131
+ pushGate: Promise<void> | null;
132
+ /** Entities whose pushed writes are rejected per op, like a write
133
+ * policy denial on the real server (HTTP 200 with a per-op error). */
134
+ private deniedWrites;
135
+ denyWrites(entity: string, code?: string, message?: string): void;
129
136
  private nextPushOutcome;
130
137
  primeNextPushOutcome(o: {
131
138
  kind: "network";
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.21.0",
6
+ "version": "0.22.0",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -314,18 +314,21 @@ describe("Fix C: race election || auth/me", () => {
314
314
  const result = await (
315
315
  engine as unknown as {
316
316
  fetchSessionBootstrap(): Promise<{
317
- userId: string | null;
318
- tenantId: string | null;
319
- isAdmin: boolean;
320
- roles: string[];
317
+ session: {
318
+ userId: string | null;
319
+ tenantId: string | null;
320
+ isAdmin: boolean;
321
+ roles: string[];
322
+ };
323
+ token: string | null;
321
324
  } | null>;
322
325
  }
323
326
  ).fetchSessionBootstrap();
324
327
 
325
328
  expect(authMeHits).toBe(1);
326
329
  expect(result).not.toBeNull();
327
- expect(result?.userId).toBe("u1");
328
- expect(result?.tenantId).toBe("org-x");
330
+ expect(result?.session.userId).toBe("u1");
331
+ expect(result?.session.tenantId).toBe("org-x");
329
332
  });
330
333
 
331
334
  test("fetchSessionBootstrap returns null on non-ok response", async () => {
@@ -504,8 +504,10 @@ describe("IDB warm-load hydration", () => {
504
504
  // so we assert it does NOT advance to 50 below.
505
505
  const onDiskBefore = (await persistence.loadCursor())?.last_seq ?? 0;
506
506
 
507
- // Abort the next ENTITIES (row) readwrite, leaving the separate
508
- // CURSOR-store tx alone — mirrors a quota abort on a row write.
507
+ // Abort the next readwrite that writes rows — mirrors a quota abort
508
+ // on a row write. The batch path writes rows and cursor in one
509
+ // transaction, so the cursor write aborts with it; a separate
510
+ // cursor-only transaction is left alone.
509
511
  const db = persistence.connection!;
510
512
  const realTx = db.transaction.bind(db);
511
513
  let armed = true;
@@ -517,15 +519,7 @@ describe("IDB warm-load hydration", () => {
517
519
  const touchesEntities = Array.isArray(stores)
518
520
  ? stores.includes("entities")
519
521
  : stores === "entities";
520
- const touchesCursors = Array.isArray(stores)
521
- ? stores.includes("cursors")
522
- : stores === "cursors";
523
- if (
524
- armed &&
525
- String(args[1]) === "readwrite" &&
526
- touchesEntities &&
527
- !touchesCursors
528
- ) {
522
+ if (armed && String(args[1]) === "readwrite" && touchesEntities) {
529
523
  armed = false;
530
524
  queueMicrotask(() => {
531
525
  try {
@@ -0,0 +1,174 @@
1
+ // Identity changes must not wipe a follower twice, let the old socket's
2
+ // frames into the new replica, or throw away a signed-out user's offline
3
+ // writes. Found in review of the sign-in re-sync fix.
4
+
5
+ import "fake-indexeddb/auto";
6
+ import { afterEach, describe, expect, test } from "bun:test";
7
+
8
+ import { IndexedDBPersistence, SyncEngine, type ChangeEvent } from "./index";
9
+ import { createTestEnv, type TestEnv } from "./test-harness";
10
+
11
+ const session = (userId: string | null) => ({
12
+ userId,
13
+ tenantId: null,
14
+ isAdmin: false,
15
+ roles: [],
16
+ avatarUrl: null,
17
+ });
18
+
19
+ describe("identity reset safety", () => {
20
+ let env: TestEnv | null = null;
21
+ const realFetch = globalThis.fetch;
22
+ afterEach(async () => {
23
+ if (env) await env.dispose();
24
+ env = null;
25
+ globalThis.fetch = realFetch;
26
+ });
27
+
28
+ test("a follower keeps the leader's re-pulled rows when the session broadcast arrives", async () => {
29
+ const engine = new SyncEngine({ baseUrl: "http://test.invalid", persist: false, multiTab: false });
30
+ const internals = engine as unknown as {
31
+ isMultiTabLeader: boolean;
32
+ multiTabHooks: () => { onSessionReceived: (s: unknown) => void };
33
+ sessionChain: Promise<void>;
34
+ enqueueApply: (c: unknown[], t?: unknown, o?: unknown) => Promise<void>;
35
+ };
36
+ internals.isMultiTabLeader = false;
37
+ const hooks = internals.multiTabHooks();
38
+ hooks.onSessionReceived(session(null));
39
+ await internals.sessionChain;
40
+ // The leader reset, re-pulled, and broadcast the rows...
41
+ await internals.enqueueApply(
42
+ [{ seq: 5, entity: "Deal", row_id: "d1", kind: "insert", data: { id: "d1" }, timestamp: "" }],
43
+ { last_seq: 5 },
44
+ { fromBroadcast: true },
45
+ );
46
+ // ...then its session.
47
+ hooks.onSessionReceived(session("u1"));
48
+ await internals.sessionChain;
49
+ await new Promise((r) => setTimeout(r, 20));
50
+ expect(engine.store.list("Deal")).toHaveLength(1);
51
+ expect(engine.getCursor().last_seq).toBe(5);
52
+ expect(engine.resolvedSession().userId).toBe("u1");
53
+ });
54
+
55
+ test("a frame from the old socket during a reset does not land in the new replica", async () => {
56
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
57
+ const url = String(input);
58
+ const body = url.includes("/api/sync/pull")
59
+ ? { changes: [], cursor: { last_seq: 0 }, has_more: false }
60
+ : url.includes("/api/auth/me")
61
+ ? { user_id: "uA" }
62
+ : {};
63
+ return new Response(JSON.stringify(body), { status: 200 });
64
+ }) as typeof fetch;
65
+ const persistence = new IndexedDBPersistence(`leak-${Math.random().toString(36).slice(2)}`);
66
+ const engine = new SyncEngine({
67
+ baseUrl: "http://test.invalid",
68
+ persistence,
69
+ multiTab: false,
70
+ transport: "poll",
71
+ pollInterval: 1e9,
72
+ reconcileOnVisibility: false,
73
+ });
74
+ await engine.start();
75
+ const host = (engine as unknown as {
76
+ transportHost(): { onChangeEvent(ev: ChangeEvent): void };
77
+ }).transportHost();
78
+ // A batch that starts before the reset and is still writing when it runs.
79
+ host.onChangeEvent({ seq: 800, entity: "Secret", row_id: "s0", kind: "insert", data: { id: "s0" }, timestamp: "" });
80
+ // Hold the reset inside its disk clear so the next frame arrives while
81
+ // it runs.
82
+ let releaseClear!: () => void;
83
+ const clearGate = new Promise<void>((r) => (releaseClear = r));
84
+ const realClear = persistence.clear.bind(persistence);
85
+ let clearStarted = false;
86
+ persistence.clear = async () => {
87
+ clearStarted = true;
88
+ await clearGate;
89
+ return realClear();
90
+ };
91
+ const reset = engine.resetReplica({ wipeMutations: true });
92
+ while (!clearStarted) await new Promise((r) => setTimeout(r, 1));
93
+ host.onChangeEvent({ seq: 900, entity: "Secret", row_id: "s1", kind: "insert", data: { id: "s1" }, timestamp: "" });
94
+ releaseClear();
95
+ await reset;
96
+ await new Promise((r) => setTimeout(r, 50));
97
+ expect(engine.store.list("Secret")).toHaveLength(0);
98
+ expect(engine.getCursor().last_seq).toBe(0);
99
+ const disk = await persistence.loadSnapshot();
100
+ expect(disk.entities.Secret ?? []).toHaveLength(0);
101
+ expect(disk.cursor?.last_seq ?? 0).toBe(0);
102
+ engine.stop();
103
+ });
104
+
105
+ test("an expired session holds offline writes; the same user signing back in pushes them", async () => {
106
+ env = createTestEnv({ transport: "poll" });
107
+ env.signIn({ userId: "u1" });
108
+ await env.start();
109
+ env.server.primeNextPushOutcome({ kind: "network" });
110
+ const id = await env.engine.insert("Note", { title: "offline draft" });
111
+ expect(env.engine.pendingCount()).toBe(1);
112
+
113
+ // The token expires: /api/auth/me now answers anonymous.
114
+ if (env.token) env.server.revoke(env.token);
115
+ await env.engine.notifySessionChanged();
116
+ await env.flush();
117
+ expect(env.engine.resolvedSession().userId).toBeNull();
118
+ expect(env.engine.pendingCount()).toBe(1);
119
+ await env.engine.push();
120
+ expect(env.server.receivedPushKeys.filter((k) => k === `Note/${id}`)).toHaveLength(1);
121
+
122
+ env.signIn({ userId: "u1" });
123
+ await env.engine.notifySessionChanged();
124
+ await env.flush(50);
125
+ expect(env.engine.pendingCount()).toBe(0);
126
+ expect(env.server.receivedPushKeys.filter((k) => k === `Note/${id}`)).toHaveLength(2);
127
+ });
128
+
129
+ test("a different user signing in after an expired session discards the held writes", async () => {
130
+ env = createTestEnv({ transport: "poll" });
131
+ env.signIn({ userId: "u1" });
132
+ await env.start();
133
+ env.server.primeNextPushOutcome({ kind: "network" });
134
+ await env.engine.insert("Note", { title: "u1 draft" });
135
+ if (env.token) env.server.revoke(env.token);
136
+ await env.engine.notifySessionChanged();
137
+ await env.flush();
138
+
139
+ env.signIn({ userId: "u2" });
140
+ await env.engine.notifySessionChanged();
141
+ await env.flush(50);
142
+ expect(env.engine.pendingCount()).toBe(0);
143
+ expect(env.server.receivedPushKeys.filter((k) => k.startsWith("Note/"))).toHaveLength(1);
144
+ });
145
+
146
+ test("a follower's write resolves as queued when the leader never answers", async () => {
147
+ const engine = new SyncEngine({ baseUrl: "http://test.invalid", persist: false, multiTab: false });
148
+ const internals = engine as unknown as {
149
+ isMultiTabLeader: boolean;
150
+ broadcastToTabs: (p: unknown) => void;
151
+ };
152
+ internals.isMultiTabLeader = false;
153
+ internals.broadcastToTabs = () => {};
154
+ const started = Date.now();
155
+ const id = await engine.insert("Note", { title: "x" });
156
+ expect(typeof id).toBe("string");
157
+ expect(Date.now() - started).toBeGreaterThanOrEqual(4_900);
158
+ expect(engine.store.get("Note", id)).not.toBeNull();
159
+ }, 10_000);
160
+
161
+ test("an op the server returns no result for resolves as queued", async () => {
162
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
163
+ const url = String(input);
164
+ if (url.includes("/api/sync/push")) {
165
+ return new Response(JSON.stringify({ applied: 0, errors: [], results: [], cursor: { last_seq: 0 } }), { status: 200 });
166
+ }
167
+ return new Response("{}", { status: 200 });
168
+ }) as typeof fetch;
169
+ const engine = new SyncEngine({ baseUrl: "http://test.invalid", persist: false, multiTab: false });
170
+ const id = await engine.insert("Note", { title: "x" });
171
+ expect(engine.store.get("Note", id)).not.toBeNull();
172
+ expect(engine.pendingCount()).toBe(1);
173
+ });
174
+ });