@pylonsync/sync 0.22.47 → 0.22.58
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 +171 -2
- package/dist/multi-tab-orchestrator.d.ts +4 -0
- package/dist/mutation-queue.d.ts +62 -1
- package/dist/test-harness/env.d.ts +2 -0
- package/dist/test-harness/server.d.ts +104 -0
- package/dist/types.d.ts +10 -0
- package/dist/upload-queue.d.ts +190 -0
- package/dist/upload.d.ts +37 -0
- package/package.json +1 -1
- package/src/first-sync-requests.test.ts +147 -0
- package/src/index.ts +598 -58
- package/src/multi-tab-orchestrator.test.ts +2 -0
- package/src/multi-tab-orchestrator.ts +13 -0
- package/src/mutation-queue.ts +162 -7
- package/src/offline-conflict.test.ts +110 -0
- package/src/outbox.test.ts +333 -0
- package/src/session-before-pull.test.ts +51 -0
- package/src/session-dedupe.test.ts +77 -0
- package/src/snapshot-catch-up.test.ts +101 -0
- package/src/test-harness/env.ts +3 -0
- package/src/test-harness/server.ts +145 -0
- package/src/test-harness/transport.ts +26 -0
- package/src/types.ts +10 -0
- package/src/upload-queue.ts +593 -0
- package/src/upload.ts +130 -0
- package/src/uploads.test.ts +222 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { LocalStore } from "./local-store";
|
|
2
|
-
import { MutationQueue } from "./mutation-queue";
|
|
2
|
+
import { MutationQueue, type Outbox } from "./mutation-queue";
|
|
3
|
+
import { UploadManager, type QueuedUploadHandle, type UploadAttachTarget } from "./upload-queue";
|
|
4
|
+
import type { FileVisibility } from "./upload";
|
|
3
5
|
import { type RoomError, type RoomMember, type RoomSubscriber } from "./room-subscriptions";
|
|
4
6
|
import { SessionResolver } from "./session-resolver";
|
|
5
7
|
export { RoomSubscriptions, rowRoom, type RoomError, type RoomErrorCode, type RoomMember, type RoomMessage, type RoomMessageSubscriber, type RoomSubscriber, } from "./room-subscriptions";
|
|
@@ -8,8 +10,30 @@ export type { ReplicaPersistence } from "./persistence";
|
|
|
8
10
|
export { BEARER_REJECTED_HEADER, bearerRejected, buildRequest, clearRejectedToken, pylonFetch, pylonFetchRaw, PylonHttpError, resolveBaseUrl, } from "./transport";
|
|
9
11
|
export type { PylonRequestInit, TransportConfig } from "./transport";
|
|
10
12
|
export { LocalStore } from "./local-store";
|
|
11
|
-
export { MutationQueue, MutationRejectedError, type MutationQueuePersistence, type PendingMutation, } from "./mutation-queue";
|
|
13
|
+
export { MutationQueue, MutationRejectedError, type MutationQueuePersistence, type Outbox, type OutboxItem, type PendingMutation, } from "./mutation-queue";
|
|
14
|
+
/** The store channel the outbox notifies on. `store.subscribe(cb,
|
|
15
|
+
* OUTBOX_CHANNEL)` hears outbox changes and no entity changes. */
|
|
16
|
+
export declare const OUTBOX_CHANNEL = "$outbox";
|
|
17
|
+
/** The store channel `syncProgress()` changes notify on. */
|
|
18
|
+
export declare const SYNC_PROGRESS_CHANNEL = "$sync-progress";
|
|
19
|
+
/** How far the current pull has got. See `SyncEngine.syncProgress()`. */
|
|
20
|
+
export interface SyncProgress {
|
|
21
|
+
/** A pull is running. */
|
|
22
|
+
pulling: boolean;
|
|
23
|
+
/** The pull is a snapshot of current rows (first sync, or a far-behind
|
|
24
|
+
* catch-up) rather than the change log. */
|
|
25
|
+
snapshot: boolean;
|
|
26
|
+
/** Rows received by this pull so far. */
|
|
27
|
+
rows: number;
|
|
28
|
+
/** Pages received by this pull so far. */
|
|
29
|
+
pages: number;
|
|
30
|
+
/** A pull has completed against the server since the last replica
|
|
31
|
+
* reset (`isSynced()`). */
|
|
32
|
+
synced: boolean;
|
|
33
|
+
}
|
|
12
34
|
export { generateId } from "./ids";
|
|
35
|
+
export { uploadBytes, type FileVisibility, type UploadBytesOptions, type UploadedFile, } from "./upload";
|
|
36
|
+
export { DEFAULT_MAX_QUEUED_UPLOAD_BYTES, IndexedDBUploadStore, LOCAL_FILE_PREFIX, localUploadId, MemoryUploadStore, UploadQueueFullError, UploadRejectedError, type QueuedUpload, type QueuedUploadHandle, type UploadAttachTarget, type UploadStore, } from "./upload-queue";
|
|
13
37
|
export type { ChangeEvent, ClientChange, PullResponse, PushOpResult, PushResponse, ReactiveMessage, ReactiveSpec, ResolvedSession, Row, SyncConnectionStatus, SyncCursor, TransportType, } from "./types";
|
|
14
38
|
export { defaultStorage, createWriteThroughStorage, type Storage, } from "./storage";
|
|
15
39
|
import type { ReactiveMessage, ResolvedSession, Row, SyncConnectionStatus, SyncCursor, TransportType } from "./types";
|
|
@@ -64,6 +88,19 @@ export interface SyncEngineConfig {
|
|
|
64
88
|
* becomes visible).
|
|
65
89
|
*/
|
|
66
90
|
reconcileOnVisibility?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* The automatic sweep (on connect and when the tab becomes visible)
|
|
93
|
+
* downloads every replicated row to catch changes the change log does not
|
|
94
|
+
* carry. It runs only when the app was offline (the connection dropped,
|
|
95
|
+
* or it started from a cached replica) and the replica last matched the
|
|
96
|
+
* server (a complete snapshot, or a full sweep) at least this long ago;
|
|
97
|
+
* or after a snapshot that could not remove deleted rows itself. It
|
|
98
|
+
* never runs right after a complete snapshot or merely because the tab
|
|
99
|
+
* came to the foreground. Server hints (`sync-reconcile`) reconcile at
|
|
100
|
+
* once, and an explicit `reconcile()` call always runs. Default 300000
|
|
101
|
+
* (5 minutes).
|
|
102
|
+
*/
|
|
103
|
+
autoReconcileIntervalMs?: number;
|
|
67
104
|
/**
|
|
68
105
|
* Keep the local replica across ORG switches (same user). Default
|
|
69
106
|
* `true`: a tenant flip wipes the replica and re-bootstraps, because
|
|
@@ -105,6 +142,9 @@ export interface SyncEngineConfig {
|
|
|
105
142
|
* its own leader (the pre-multi-tab semantics).
|
|
106
143
|
*/
|
|
107
144
|
multiTab?: boolean;
|
|
145
|
+
/** Cap on the bytes `queueUpload` keeps on the device. Default 200 MB.
|
|
146
|
+
* Past it `queueUpload` throws `UploadQueueFullError`. */
|
|
147
|
+
maxQueuedUploadBytes?: number;
|
|
108
148
|
}
|
|
109
149
|
export declare class SyncEngine {
|
|
110
150
|
private config;
|
|
@@ -159,6 +199,16 @@ export declare class SyncEngine {
|
|
|
159
199
|
*/
|
|
160
200
|
private _synced;
|
|
161
201
|
isSynced(): boolean;
|
|
202
|
+
private _progress;
|
|
203
|
+
/**
|
|
204
|
+
* How far the current pull has got: whether one is running, whether it
|
|
205
|
+
* is a snapshot, and the rows and pages received. Apps render from the
|
|
206
|
+
* session as soon as it resolves and show this while the replica fills.
|
|
207
|
+
* A new object on every change, so it works as a `useSyncExternalStore`
|
|
208
|
+
* snapshot. Follower tabs report `pulling: false` (the leader pulls).
|
|
209
|
+
*/
|
|
210
|
+
syncProgress(): SyncProgress;
|
|
211
|
+
private setProgress;
|
|
162
212
|
/** Flip `_synced` true (idempotent), notify, and tell follower tabs. */
|
|
163
213
|
private markSynced;
|
|
164
214
|
/** Flip `_initialSyncSettled` true (idempotent) + notify so `useQuery`
|
|
@@ -212,6 +262,8 @@ export declare class SyncEngine {
|
|
|
212
262
|
private persistDegraded;
|
|
213
263
|
readonly store: LocalStore;
|
|
214
264
|
readonly mutations: MutationQueue;
|
|
265
|
+
/** Files queued with `queueUpload`, sent when there is a network. */
|
|
266
|
+
readonly uploads: UploadManager;
|
|
215
267
|
/**
|
|
216
268
|
* Stable per-client identifier. Minted on first construction, not
|
|
217
269
|
* necessarily persisted (depends on what the host provides).
|
|
@@ -399,6 +451,65 @@ export declare class SyncEngine {
|
|
|
399
451
|
* dev HUD's sync row; also handy for a "saving…" indicator.
|
|
400
452
|
*/
|
|
401
453
|
pendingCount(): number;
|
|
454
|
+
private outboxCache;
|
|
455
|
+
/**
|
|
456
|
+
* Writes made here that the server has not applied yet (`pending`:
|
|
457
|
+
* offline, or waiting for a sign-in), and writes the server rejected
|
|
458
|
+
* (`failed`, with its error). Oldest first. The same object is
|
|
459
|
+
* returned until the queue changes, so it can back
|
|
460
|
+
* `useSyncExternalStore`. Changes notify store subscribers on
|
|
461
|
+
* `OUTBOX_CHANNEL`.
|
|
462
|
+
*/
|
|
463
|
+
outbox(): Outbox;
|
|
464
|
+
/**
|
|
465
|
+
* Send a failed write again, with `data` (when given) merged over the
|
|
466
|
+
* fields it set. The write is applied locally again at once (the
|
|
467
|
+
* rejection rolled it back) and queued under a new op id. Settles like
|
|
468
|
+
* `insert`: resolves with the new op id once the server applies it or
|
|
469
|
+
* it is queued offline, and rejects with a `MutationRejectedError` when
|
|
470
|
+
* the server rejects it again (it is then failed again, with the new
|
|
471
|
+
* error).
|
|
472
|
+
*
|
|
473
|
+
* A write is only ever sent as the user who made it. Throws
|
|
474
|
+
* `MutationRejectedError` with code `OWNER_MISMATCH` when someone else
|
|
475
|
+
* is signed in, and `OWNER_UNKNOWN` when it is not yet known who is
|
|
476
|
+
* signed in (or the write predates owner tracking). Dismiss those.
|
|
477
|
+
*/
|
|
478
|
+
retryWrite(id: string, data?: Row): Promise<string>;
|
|
479
|
+
/** Drop a failed write after the user has seen it. Its local change
|
|
480
|
+
* was already rolled back. Returns false when there is no failed write
|
|
481
|
+
* with that id; a pending write cannot be dismissed. */
|
|
482
|
+
dismissWrite(id: string): boolean;
|
|
483
|
+
/**
|
|
484
|
+
* Keep a file on the device and upload it when there is a network, then
|
|
485
|
+
* set it on the record it belongs to. Returns at once.
|
|
486
|
+
*
|
|
487
|
+
* At once, `attach.entity`/`attach.id` gets `attach.field` (and
|
|
488
|
+
* `attach.urlField`, when given) = `local:<localId>` as a queued write,
|
|
489
|
+
* so the record shows the file from the device (`uploadLocalUrl`). When
|
|
490
|
+
* the upload finishes, a second queued write sets the file's id (and
|
|
491
|
+
* URL). Both are ordinary writes: policies and owner rules apply.
|
|
492
|
+
*
|
|
493
|
+
* The file is sent only as the user who queued it. Throws
|
|
494
|
+
* `UploadQueueFullError` past `maxQueuedUploadBytes`. Pending and failed
|
|
495
|
+
* uploads show in `outbox()` with kind `"upload"`; retry and dismiss them
|
|
496
|
+
* with `retryWrite` / `dismissWrite`.
|
|
497
|
+
*/
|
|
498
|
+
queueUpload(file: Blob, opts: {
|
|
499
|
+
attach: UploadAttachTarget;
|
|
500
|
+
filename?: string;
|
|
501
|
+
contentType?: string;
|
|
502
|
+
visibility?: FileVisibility;
|
|
503
|
+
}): Promise<QueuedUploadHandle>;
|
|
504
|
+
/** An object URL for a queued file (`local:<localId>` without the
|
|
505
|
+
* prefix), or null when no queued upload has that id. */
|
|
506
|
+
uploadLocalUrl(localId: string): string | null;
|
|
507
|
+
/** Apply the queued writes to the replica again after a reload. An
|
|
508
|
+
* optimistic change lives only in memory, so without this a row
|
|
509
|
+
* written offline vanished from the screen on reload (it was still
|
|
510
|
+
* queued and still reached the server later). Oldest first, so an
|
|
511
|
+
* update of a row inserted offline finds the row. */
|
|
512
|
+
private reapplyPendingWrites;
|
|
402
513
|
/**
|
|
403
514
|
* Mutate connection status + notify subscribers. Idempotent — same-
|
|
404
515
|
* status calls are a no-op so the WS onopen → connected transition
|
|
@@ -500,6 +611,9 @@ export declare class SyncEngine {
|
|
|
500
611
|
* push whose current token differs holds owned writes: the token may
|
|
501
612
|
* belong to someone else. `undefined` until a session is committed. */
|
|
502
613
|
private sessionToken;
|
|
614
|
+
/** The `/api/auth/me` request in flight, and the token it was sent with.
|
|
615
|
+
* `refreshSessionIfStale` joins it instead of sending another. */
|
|
616
|
+
private sessionFetch;
|
|
503
617
|
private identityRetryTimer;
|
|
504
618
|
/** Delayed push / pull retries scheduled through `later()`. `stop()`
|
|
505
619
|
* clears them, so a stopped engine makes no further requests. */
|
|
@@ -616,6 +730,21 @@ export declare class SyncEngine {
|
|
|
616
730
|
* queue. */
|
|
617
731
|
pull(): Promise<void>;
|
|
618
732
|
private pullInner;
|
|
733
|
+
/** Rows a reconcile fetches per entity, at most. */
|
|
734
|
+
private static readonly RECONCILE_ROW_CAP;
|
|
735
|
+
/** The server's page maximum for replication reads (`?sync=1`). */
|
|
736
|
+
private static readonly MAX_RECONCILE_PAGE_ROWS;
|
|
737
|
+
/** Rows the first page of a pull asks for. Later pages double. */
|
|
738
|
+
private static readonly FIRST_PULL_PAGE_ROWS;
|
|
739
|
+
/** The server's page maximum (MAX_PAGE_ROWS in routes/sync.rs). */
|
|
740
|
+
private static readonly MAX_PULL_PAGE_ROWS;
|
|
741
|
+
/**
|
|
742
|
+
* After a complete snapshot: delete local rows of `entities` that the
|
|
743
|
+
* snapshot did not send. Rows with writes not yet on the server are kept.
|
|
744
|
+
* The deletes are tombstoned at the snapshot's seq, so an older event
|
|
745
|
+
* cannot bring a row back.
|
|
746
|
+
*/
|
|
747
|
+
private sweepAfterSnapshot;
|
|
619
748
|
/** Consecutive 410 RESYNC_REQUIRED responses since the last successful
|
|
620
749
|
* pull. Used by the circuit breaker in pull() to bound the retry
|
|
621
750
|
* storm against a misconfigured server. Resets to 0 on any pull
|
|
@@ -636,6 +765,30 @@ export declare class SyncEngine {
|
|
|
636
765
|
* cleared on read so a subsequent reconnect-after-disconnect still
|
|
637
766
|
* runs reconcile normally. */
|
|
638
767
|
private lastPullStartedFromZero;
|
|
768
|
+
/** When the replica last matched the server: a complete snapshot
|
|
769
|
+
* (local rows it did not hold deleted) or a full reconcile. 0 = never
|
|
770
|
+
* this run. */
|
|
771
|
+
private matchedServerAt;
|
|
772
|
+
/** The replica epoch a complete snapshot filled. An entity with no
|
|
773
|
+
* rows in it is empty on the server too. */
|
|
774
|
+
private snapshotEpoch;
|
|
775
|
+
/** Whether an automatic sweep (connect, visibility) should run. */
|
|
776
|
+
/** The connection dropped (or this run started from a cached replica)
|
|
777
|
+
* since the replica last matched the server. */
|
|
778
|
+
private offlineSinceMatch;
|
|
779
|
+
/** A snapshot replaced cached rows without saying which entities it
|
|
780
|
+
* covers, so rows deleted on the server may still be here. */
|
|
781
|
+
private sweepNeeded;
|
|
782
|
+
/**
|
|
783
|
+
* Whether an automatic sweep (connect, visibility) should run: after a
|
|
784
|
+
* snapshot that could not sweep its own deletes, or when the app was
|
|
785
|
+
* offline since the replica last matched the server and the last match
|
|
786
|
+
* is `autoReconcileIntervalMs` old. Never right after a complete
|
|
787
|
+
* snapshot, and not merely because the app came to the foreground.
|
|
788
|
+
*/
|
|
789
|
+
private autoReconcileDue;
|
|
790
|
+
/** The replica matches the server now. */
|
|
791
|
+
private markMatchedServer;
|
|
639
792
|
/** Timestamp of the last `reconcile()` invocation. Used to debounce —
|
|
640
793
|
* reconcile runs on connect, WS reconnect, AND visibility-change, so
|
|
641
794
|
* a quick tab-flick after a normal reconnect shouldn't refetch every
|
|
@@ -709,6 +862,11 @@ export declare class SyncEngine {
|
|
|
709
862
|
* entities where the client missed an insert get the row back.
|
|
710
863
|
*/
|
|
711
864
|
observeEntity(entity: string): void;
|
|
865
|
+
/** Whether an entity with no local rows may still have rows on the
|
|
866
|
+
* server that no pull will deliver. Not when a snapshot is coming (the
|
|
867
|
+
* cursor is 0 and no pull has completed since the last reset) or one
|
|
868
|
+
* already filled this replica: a snapshot sends every visible row. */
|
|
869
|
+
private emptyMayBeStale;
|
|
712
870
|
reconcile(entities?: string[]): Promise<void>;
|
|
713
871
|
/** Entities to reconcile that have not reconciled yet: named by a server
|
|
714
872
|
* hint (`sync-reconcile`), or skipped by an earlier reconcile. */
|
|
@@ -790,6 +948,7 @@ export declare class SyncEngine {
|
|
|
790
948
|
* will retry. Errors must not abort bootstrap.
|
|
791
949
|
*/
|
|
792
950
|
private fetchSessionBootstrap;
|
|
951
|
+
private fetchSession;
|
|
793
952
|
/**
|
|
794
953
|
* A stored token that /api/auth/me resolves to no user is either expired
|
|
795
954
|
* or — the common footgun — a FOREIGN token: two Pylon apps sharing an
|
|
@@ -868,6 +1027,16 @@ export declare class SyncEngine {
|
|
|
868
1027
|
* before the WS round-trip lands.
|
|
869
1028
|
*/
|
|
870
1029
|
notifySessionChanged(): Promise<void>;
|
|
1030
|
+
/**
|
|
1031
|
+
* Refresh the session only when it is not known for the current token:
|
|
1032
|
+
* no answer yet this run, or the token changed since the last answer.
|
|
1033
|
+
* A request already in flight for the token is joined, not repeated.
|
|
1034
|
+
* `useSession` calls this on mount, so a screen with many session
|
|
1035
|
+
* readers makes at most one `/api/auth/me` request per resolve.
|
|
1036
|
+
* After a server-side session change, use `notifySessionChanged`,
|
|
1037
|
+
* which always fetches.
|
|
1038
|
+
*/
|
|
1039
|
+
refreshSessionIfStale(): Promise<void>;
|
|
871
1040
|
/**
|
|
872
1041
|
* Drop a row from the local replica because the server signaled
|
|
873
1042
|
* that the current subscriber's read policy was revoked for it.
|
|
@@ -51,6 +51,10 @@ export interface MultiTabOrchestratorHooks {
|
|
|
51
51
|
/** Leader → followers: the leader's push failed transiently and
|
|
52
52
|
* these op ids stay queued for retry. */
|
|
53
53
|
onMutationsQueued(opIds: string[]): void;
|
|
54
|
+
/** Another tab retried or dismissed these failed writes. */
|
|
55
|
+
onMutationsRemoved(opIds: string[]): void;
|
|
56
|
+
/** Another tab changed the upload queue in storage. */
|
|
57
|
+
onUploadsChanged(): void;
|
|
54
58
|
/** Leader → follower: a binary frame from the WS. Engine routes
|
|
55
59
|
* to its local binary handlers. */
|
|
56
60
|
onBinaryReceived(bytes: Uint8Array): void;
|
package/dist/mutation-queue.d.ts
CHANGED
|
@@ -29,6 +29,9 @@ export interface PendingMutation {
|
|
|
29
29
|
error?: string;
|
|
30
30
|
/** Server error code for a failed mutation, when the server sent one. */
|
|
31
31
|
errorCode?: string;
|
|
32
|
+
/** When the write was made, in ms since the epoch. Missing on writes
|
|
33
|
+
* queued by an older client; those sort first. */
|
|
34
|
+
createdAt?: number;
|
|
32
35
|
/** User id the write was made as (`null` = anonymous). Persisted with
|
|
33
36
|
* the queue so a write is only ever pushed as the user who made it.
|
|
34
37
|
* `undefined` = unknown (queued by an older client). */
|
|
@@ -44,6 +47,32 @@ export interface PendingMutation {
|
|
|
44
47
|
* before the mutation; `undefined` = not captured (inserts). */
|
|
45
48
|
prevRow?: Row | null;
|
|
46
49
|
}
|
|
50
|
+
/** One write as the outbox shows it. */
|
|
51
|
+
export interface OutboxItem {
|
|
52
|
+
/** Op id. Pass it to `retryWrite` / `dismissWrite`. */
|
|
53
|
+
id: string;
|
|
54
|
+
entity: string;
|
|
55
|
+
rowId: string;
|
|
56
|
+
/** `"upload"` is a file from `queueUpload`; `entity`/`rowId` is the
|
|
57
|
+
* record it attaches to. */
|
|
58
|
+
kind: "insert" | "update" | "delete" | "upload";
|
|
59
|
+
/** The fields the write sets. Empty for a delete. For an upload:
|
|
60
|
+
* `field`, `urlField`, `filename`, `contentType`, `size`. */
|
|
61
|
+
data: Row;
|
|
62
|
+
/** When the write was made, in ms since the epoch (0 when unknown). */
|
|
63
|
+
createdAt: number;
|
|
64
|
+
/** The server's message, for a failed write. */
|
|
65
|
+
error?: string;
|
|
66
|
+
/** The server's error code (`POLICY_DENIED`, `VALIDATION_FAILED`, ...),
|
|
67
|
+
* for a failed write. */
|
|
68
|
+
errorCode?: string;
|
|
69
|
+
}
|
|
70
|
+
/** Writes not yet on the server, and writes the server rejected. */
|
|
71
|
+
export interface Outbox {
|
|
72
|
+
pending: OutboxItem[];
|
|
73
|
+
failed: OutboxItem[];
|
|
74
|
+
}
|
|
75
|
+
export declare function toOutboxItem(m: PendingMutation): OutboxItem;
|
|
47
76
|
/**
|
|
48
77
|
* Optional persistence backend. The default IndexedDB persistence
|
|
49
78
|
* layer supports incremental writes with `saveChanges`. Custom backends
|
|
@@ -61,7 +90,18 @@ export declare class MutationQueue {
|
|
|
61
90
|
/** Callers waiting for a mutation's outcome, by op id. Memory-only:
|
|
62
91
|
* a promise cannot outlive the page that created it. */
|
|
63
92
|
private waiters;
|
|
93
|
+
/** Called after every change to what the queue holds or to a write's
|
|
94
|
+
* status. The engine turns it into a store notification so the outbox
|
|
95
|
+
* re-renders. */
|
|
96
|
+
private changeListener;
|
|
97
|
+
/** Bumped on every change; lets readers cache a snapshot. */
|
|
98
|
+
private _version;
|
|
64
99
|
constructor(persistence?: MutationQueuePersistence);
|
|
100
|
+
/** Set the one listener called after each queue change. */
|
|
101
|
+
setChangeListener(listener: (() => void) | null): void;
|
|
102
|
+
/** Changes so far. Equal versions mean the same contents. */
|
|
103
|
+
version(): number;
|
|
104
|
+
private changed;
|
|
65
105
|
/**
|
|
66
106
|
* Attach a persistence backend after construction. The SyncEngine
|
|
67
107
|
* swaps in IndexedDB-backed persistence once the DB has opened.
|
|
@@ -79,8 +119,27 @@ export declare class MutationQueue {
|
|
|
79
119
|
* entry with the same op_id is already queued — a follower
|
|
80
120
|
* retrying its forward of the same op shouldn't double-queue on
|
|
81
121
|
* the leader. */
|
|
82
|
-
add(change: ClientChange, prevRow?: Row | null, owner?: string | null, ownerPending?: boolean): string;
|
|
122
|
+
add(change: ClientChange, prevRow?: Row | null, owner?: string | null, ownerPending?: boolean, createdAt?: number): string;
|
|
83
123
|
pending(): PendingMutation[];
|
|
124
|
+
/** Writes the server rejected, oldest first. They stay until the user
|
|
125
|
+
* retries or dismisses them. */
|
|
126
|
+
failed(): PendingMutation[];
|
|
127
|
+
/**
|
|
128
|
+
* Queue a failed write again under a new op id, with `data` (when
|
|
129
|
+
* given) merged over the original change's fields. The failed entry is
|
|
130
|
+
* removed in the same persistence write, so a reload never sees both.
|
|
131
|
+
* The new write keeps the original's owner and `createdAt`. `prevRow`
|
|
132
|
+
* is the row before the caller re-applied the write locally, for the
|
|
133
|
+
* rollback if the server rejects it again. Returns the new op id.
|
|
134
|
+
*
|
|
135
|
+
* The server forgets a rejected op id (it never applied it), so the new
|
|
136
|
+
* id cannot be applied alongside the old one.
|
|
137
|
+
*/
|
|
138
|
+
retry(id: string, data: Row | undefined, prevRow: Row | null | undefined): string;
|
|
139
|
+
/** Drop a failed write after the user has seen it. Returns false when
|
|
140
|
+
* there is no failed write with that id (pending writes cannot be
|
|
141
|
+
* dismissed: they may already be on the server). */
|
|
142
|
+
dismiss(id: string): boolean;
|
|
84
143
|
/** Look up a queued mutation by op id (any status). Used by the
|
|
85
144
|
* follower's mutations-failed handler to reach the captured
|
|
86
145
|
* `prevRow` for rollback. */
|
|
@@ -145,3 +204,5 @@ export declare class MutationQueue {
|
|
|
145
204
|
/** Fire-and-forget persistence write. */
|
|
146
205
|
private flush;
|
|
147
206
|
}
|
|
207
|
+
/** Oldest first; writes without `createdAt` (older clients) first of all. */
|
|
208
|
+
export declare function byCreatedAt(list: PendingMutation[]): PendingMutation[];
|
|
@@ -28,6 +28,8 @@ export interface CreateTestEnvOptions {
|
|
|
28
28
|
* wall-clock waiting on every reconnect scenario; pass a small
|
|
29
29
|
* value when the test specifically exercises the reconnect path. */
|
|
30
30
|
reconnectDelay?: number;
|
|
31
|
+
/** Passed through to the engine config (SyncEngineConfig). */
|
|
32
|
+
autoReconcileIntervalMs?: number;
|
|
31
33
|
}
|
|
32
34
|
export interface TestEnv {
|
|
33
35
|
server: TestServer;
|
|
@@ -129,10 +129,16 @@ export declare class TestServer {
|
|
|
129
129
|
/** When set, `/api/sync/push` waits for this promise before it
|
|
130
130
|
* answers. Lets a test queue a write while a push is in flight. */
|
|
131
131
|
pushGate: Promise<void> | null;
|
|
132
|
+
/** The file endpoints (`/api/files/init`, PUT, `/api/files/confirm`). */
|
|
133
|
+
readonly files: TestFileServer;
|
|
132
134
|
/** Entities whose pushed writes are rejected per op, like a write
|
|
133
135
|
* policy denial on the real server (HTTP 200 with a per-op error). */
|
|
134
136
|
private deniedWrites;
|
|
135
137
|
denyWrites(entity: string, code?: string, message?: string): void;
|
|
138
|
+
/** Reject a pushed insert/update of `entity` whose fields match
|
|
139
|
+
* `predicate`, like a write policy that checks a field value. */
|
|
140
|
+
private conditionalDenials;
|
|
141
|
+
rejectWritesWhere(entity: string, predicate: (data: Row) => boolean, code?: string, message?: string): void;
|
|
136
142
|
private nextPushOutcome;
|
|
137
143
|
primeNextPushOutcome(o: {
|
|
138
144
|
kind: "network";
|
|
@@ -205,6 +211,26 @@ export declare class TestServer {
|
|
|
205
211
|
* the response the engine sees, matching what a real server does
|
|
206
212
|
* when the session mutates mid-request. */
|
|
207
213
|
listEntityRows(entity: string, token: string | undefined): Promise<Row[]>;
|
|
214
|
+
/** When true, /api/sync/pull answers like the real server's snapshot
|
|
215
|
+
* path: a pull from 0 (or a delta pull with `snapshot_ok=1` and a cursor
|
|
216
|
+
* `farBehind` accepts) gets the current visible rows as insert events,
|
|
217
|
+
* paged by `limit`, flagged `snapshot`, with `snapshot_entities` on the
|
|
218
|
+
* last page. Off by default: older tests expect the log replay. */
|
|
219
|
+
snapshotMode: boolean;
|
|
220
|
+
/** In `snapshotMode`: delta cursors answered with a snapshot. */
|
|
221
|
+
farBehind: (since: number) => boolean;
|
|
222
|
+
/** In `snapshotMode`: leave `snapshot_entities` off the last page, like a
|
|
223
|
+
* server from before it was added. */
|
|
224
|
+
omitSnapshotEntities: boolean;
|
|
225
|
+
/** One snapshot page for `snapshotMode`. `offset` is the continuation. */
|
|
226
|
+
snapshotPage(token: string | undefined, offset: number, limit: number): {
|
|
227
|
+
changes: ChangeEvent[];
|
|
228
|
+
cursor: SyncCursor;
|
|
229
|
+
has_more: boolean;
|
|
230
|
+
snapshot: true;
|
|
231
|
+
snapshot_after: string | null;
|
|
232
|
+
snapshot_entities?: string[];
|
|
233
|
+
};
|
|
208
234
|
/** /api/sync/pull — every visible change since `since`. */
|
|
209
235
|
pull(token: string | undefined, since: number): Promise<{
|
|
210
236
|
changes: ChangeEvent[];
|
|
@@ -223,3 +249,81 @@ export declare class TestServer {
|
|
|
223
249
|
private bumpSeq;
|
|
224
250
|
private appendLog;
|
|
225
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* In-memory stand-in for the runtime's file routes. Records every request
|
|
254
|
+
* so a test can count uploads, and lets a test refuse a file (`rejectInit`)
|
|
255
|
+
* or drop the connection during the next PUT (`dropNextPut`).
|
|
256
|
+
*/
|
|
257
|
+
export declare class TestFileServer {
|
|
258
|
+
private readonly server;
|
|
259
|
+
private nextId;
|
|
260
|
+
private slots;
|
|
261
|
+
readonly stored: Map<string, {
|
|
262
|
+
filename: string;
|
|
263
|
+
size: number;
|
|
264
|
+
owner: string | null;
|
|
265
|
+
}>;
|
|
266
|
+
initCount: number;
|
|
267
|
+
putCount: number;
|
|
268
|
+
private rejection;
|
|
269
|
+
private dropPut;
|
|
270
|
+
constructor(server: TestServer);
|
|
271
|
+
/** Refuse every init with this status, like a type or size check. */
|
|
272
|
+
rejectInit(status: number, code: string, message: string): void;
|
|
273
|
+
clearRejection(): void;
|
|
274
|
+
/** When set, every PUT waits for it before it answers. */
|
|
275
|
+
putGate: Promise<void> | null;
|
|
276
|
+
/** The next PUT fails like a dropped connection (no status). */
|
|
277
|
+
dropNextPut(): void;
|
|
278
|
+
init(token: string | undefined, body: {
|
|
279
|
+
filename?: string;
|
|
280
|
+
size?: number;
|
|
281
|
+
}): {
|
|
282
|
+
status: number;
|
|
283
|
+
body: {
|
|
284
|
+
error: {
|
|
285
|
+
code: string;
|
|
286
|
+
message: string;
|
|
287
|
+
};
|
|
288
|
+
uploadUrl?: undefined;
|
|
289
|
+
assetId?: undefined;
|
|
290
|
+
};
|
|
291
|
+
} | {
|
|
292
|
+
status: number;
|
|
293
|
+
body: {
|
|
294
|
+
uploadUrl: string;
|
|
295
|
+
assetId: string;
|
|
296
|
+
error?: undefined;
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
put(id: string, bytes: unknown): {
|
|
300
|
+
status: number;
|
|
301
|
+
body: {
|
|
302
|
+
error: {
|
|
303
|
+
code: string;
|
|
304
|
+
};
|
|
305
|
+
};
|
|
306
|
+
} | {
|
|
307
|
+
status: number;
|
|
308
|
+
body: null;
|
|
309
|
+
};
|
|
310
|
+
confirm(token: string | undefined, id: string): {
|
|
311
|
+
status: number;
|
|
312
|
+
body: {
|
|
313
|
+
error: {
|
|
314
|
+
code: string;
|
|
315
|
+
};
|
|
316
|
+
id?: undefined;
|
|
317
|
+
url?: undefined;
|
|
318
|
+
size?: undefined;
|
|
319
|
+
};
|
|
320
|
+
} | {
|
|
321
|
+
status: number;
|
|
322
|
+
body: {
|
|
323
|
+
id: string;
|
|
324
|
+
url: string;
|
|
325
|
+
size: number;
|
|
326
|
+
error?: undefined;
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -13,6 +13,16 @@ export interface PullResponse {
|
|
|
13
13
|
changes: ChangeEvent[];
|
|
14
14
|
cursor: SyncCursor;
|
|
15
15
|
has_more: boolean;
|
|
16
|
+
/** Set on every page of a snapshot (current rows, not the log): a pull
|
|
17
|
+
* from 0, or a far-behind catch-up the client accepted with
|
|
18
|
+
* `snapshot_ok=1`. */
|
|
19
|
+
snapshot?: boolean;
|
|
20
|
+
/** Continuation of a snapshot that spans pages. Opaque; send it back
|
|
21
|
+
* verbatim. */
|
|
22
|
+
snapshot_after?: string | null;
|
|
23
|
+
/** On a snapshot's last page: the entities it holds every visible row
|
|
24
|
+
* of. Rows of these entities that it did not send are gone. */
|
|
25
|
+
snapshot_entities?: string[];
|
|
16
26
|
}
|
|
17
27
|
/**
|
|
18
28
|
* Server-resolved auth/session state. Shape mirrors what `/api/auth/me`
|