@pylonsync/sync 0.21.0 → 0.22.1
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 +108 -7
- package/dist/local-store.d.ts +7 -0
- package/dist/multi-tab-orchestrator.d.ts +14 -0
- package/dist/mutation-queue.d.ts +57 -2
- package/dist/persistence.d.ts +12 -0
- package/dist/room-subscriptions.d.ts +5 -0
- package/dist/test-harness/server.d.ts +7 -0
- package/package.json +1 -1
- package/src/bootstrap-speedup.test.ts +9 -6
- package/src/idb-warm-load.test.ts +5 -11
- package/src/identity-reset-safety.test.ts +174 -0
- package/src/identity-resync.test.ts +120 -0
- package/src/index.ts +701 -152
- package/src/live-batch-persist.test.ts +180 -0
- package/src/local-store.ts +14 -0
- package/src/multi-tab-orchestrator.test.ts +3 -0
- package/src/multi-tab-orchestrator.ts +39 -3
- package/src/mutation-outcome.test.ts +203 -0
- package/src/mutation-owner-token.test.ts +131 -0
- package/src/mutation-owner.test.ts +217 -0
- package/src/mutation-queue.ts +177 -3
- package/src/persistence.ts +31 -0
- package/src/reconcile.test.ts +6 -3
- package/src/room-subscriptions.test.ts +32 -0
- package/src/room-subscriptions.ts +11 -0
- package/src/scenarios.test.ts +9 -3
- package/src/synced-flag.test.ts +117 -0
- package/src/test-harness/server.ts +15 -0
- package/src/test-harness/transport.ts +1 -0
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(
|
|
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.
|
|
833
|
-
*
|
|
834
|
-
*
|
|
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
|
-
/**
|
|
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). */
|
package/dist/local-store.d.ts
CHANGED
|
@@ -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. */
|
package/dist/mutation-queue.d.ts
CHANGED
|
@@ -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;
|
package/dist/persistence.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
508
|
-
//
|
|
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
|
-
|
|
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
|
+
});
|