@spooky-sync/core 0.0.1-canary.103 → 0.0.1-canary.105
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 +180 -3
- package/dist/index.js +2212 -1620
- package/dist/types.d.ts +15 -4
- package/package.json +3 -3
- package/src/modules/cache/index.ts +35 -2
- package/src/modules/crdt/crdt-field.ts +9 -2
- package/src/modules/crdt/index.ts +7 -2
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.status.test.ts +143 -2
- package/src/modules/data/index.ts +229 -13
- package/src/modules/devtools/index.ts +8 -5
- package/src/modules/ref-tables.test.ts +26 -1
- package/src/modules/ref-tables.ts +19 -0
- package/src/modules/sync/queue/queue-down.ts +6 -0
- package/src/modules/sync/queue/queue-up.ts +14 -0
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +33 -4
- package/src/modules/sync/sync.ts +76 -5
- package/src/services/database/local-migrator.ts +6 -6
- package/src/services/database/local.test.ts +31 -0
- package/src/services/database/local.ts +298 -72
- package/src/services/stream-processor/index.ts +48 -2
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/sp00ky.auth-order.test.ts +27 -0
- package/src/sp00ky.ts +132 -2
- package/src/types.ts +15 -4
package/dist/index.d.ts
CHANGED
|
@@ -54,9 +54,62 @@ declare abstract class AbstractDatabaseService {
|
|
|
54
54
|
declare class LocalDatabaseService extends AbstractDatabaseService {
|
|
55
55
|
private config;
|
|
56
56
|
protected eventType: "DATABASE_LOCAL_QUERY";
|
|
57
|
+
/** Bucket currently open. Set by `connect`/`switchStore`. */
|
|
58
|
+
private bucketId;
|
|
59
|
+
/**
|
|
60
|
+
* Monotonic store generation. Bumped on every `switchStore`. Async chains
|
|
61
|
+
* that read from the store, await something remote, and then write back
|
|
62
|
+
* (sync poll, SSP stream updates) capture this at chain start and drop
|
|
63
|
+
* their write when it no longer matches — a stale-epoch write would land
|
|
64
|
+
* another user's data in the new bucket.
|
|
65
|
+
*/
|
|
66
|
+
private storeEpoch;
|
|
67
|
+
/** Gate that `query()`/`execute()` await; closed for the switch window. */
|
|
68
|
+
private gate;
|
|
69
|
+
/** The incoming client while a switch is in flight (for unload cleanup). */
|
|
70
|
+
private pendingSwitchClient;
|
|
57
71
|
constructor(config: Sp00kyConfig<any>['database'], logger: Logger$1);
|
|
58
72
|
getConfig(): Sp00kyConfig<any>['database'];
|
|
59
|
-
|
|
73
|
+
get currentBucketId(): string;
|
|
74
|
+
get epoch(): number;
|
|
75
|
+
/**
|
|
76
|
+
* Close the query gate for a bucket switch. Every `query()`/`execute()`
|
|
77
|
+
* issued after this waits until the returned release fn runs — so work
|
|
78
|
+
* triggered mid-switch (sibling auth subscribers registering queries)
|
|
79
|
+
* lands on the NEW bucket instead of racing the swap. The migrator uses
|
|
80
|
+
* `queryUngated()` to provision the new bucket while the gate is closed.
|
|
81
|
+
*/
|
|
82
|
+
beginSwitch(): () => void;
|
|
83
|
+
query<T extends unknown[]>(query: string, vars?: Record<string, unknown>, opts?: {
|
|
84
|
+
epoch?: number;
|
|
85
|
+
}): Promise<T>;
|
|
86
|
+
execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>, opts?: {
|
|
87
|
+
epoch?: number;
|
|
88
|
+
}): Promise<T>;
|
|
89
|
+
/** Gate-bypassing query — ONLY for the switch path itself (schema
|
|
90
|
+
* provisioning must run while the gate is closed, or it deadlocks). */
|
|
91
|
+
queryUngated<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T>;
|
|
92
|
+
connect(bucketId?: string): Promise<void>;
|
|
93
|
+
/**
|
|
94
|
+
* Switch the local store to another user's bucket. Opens the NEW bucket on a
|
|
95
|
+
* second client first (with the same 3-tier recovery), then atomically swaps
|
|
96
|
+
* `this.client` and closes the old one — a failed open never leaves the
|
|
97
|
+
* service on a dead client. Bumps the store epoch so in-flight old-bucket
|
|
98
|
+
* async chains can detect they're stale.
|
|
99
|
+
*
|
|
100
|
+
* Callers own the drain/rebind choreography (close the gate, quiesce sync +
|
|
101
|
+
* timers BEFORE calling this; re-provision + rebind AFTER).
|
|
102
|
+
*/
|
|
103
|
+
switchStore(bucketId: string): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Open `storeUrl` on `client` with tiered recovery:
|
|
106
|
+
* tier 1 retries the same store (transient idb-handle races — preserves the
|
|
107
|
+
* cache), tier 2 drops THIS bucket's IndexedDB store and reconnects fresh,
|
|
108
|
+
* tier 3 falls back to `mem://` for the session. Only ever drops the bucket
|
|
109
|
+
* being opened — other users' buckets hold their own caches AND un-pushed
|
|
110
|
+
* mutation outboxes, which must survive another bucket's corruption.
|
|
111
|
+
*/
|
|
112
|
+
private openWithRecovery;
|
|
60
113
|
private unloadCloseRegistered;
|
|
61
114
|
/**
|
|
62
115
|
* Close the local DB on page unload so the SurrealDB-WASM worker releases its
|
|
@@ -67,6 +120,7 @@ declare class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
67
120
|
* reload, making warm loads as slow as cold ones. `pagehide` is the reliable
|
|
68
121
|
* unload signal (fires on bfcache + normal navigation); `close()` is async but
|
|
69
122
|
* the WASM worker initiates the IndexedDB connection teardown synchronously.
|
|
123
|
+
* Also closes a mid-switch incoming client so its fresh handle doesn't linger.
|
|
70
124
|
*/
|
|
71
125
|
private registerUnloadClose;
|
|
72
126
|
private openStore;
|
|
@@ -187,6 +241,8 @@ declare class StreamProcessorService {
|
|
|
187
241
|
private batching;
|
|
188
242
|
private batchBuffer;
|
|
189
243
|
private sessionAuth;
|
|
244
|
+
private stateKeySuffix;
|
|
245
|
+
private stateGeneration;
|
|
190
246
|
constructor(events: EventSystem<StreamProcessorEvents>, db: LocalDatabaseService, persistenceClient: PersistenceClient, logger: Logger);
|
|
191
247
|
/**
|
|
192
248
|
* Add a receiver for stream updates.
|
|
@@ -234,6 +290,19 @@ declare class StreamProcessorService {
|
|
|
234
290
|
* This must be called before using other methods.
|
|
235
291
|
*/
|
|
236
292
|
init(): Promise<void>;
|
|
293
|
+
/** Route the persisted circuit snapshot to a per-bucket key. */
|
|
294
|
+
setStateKeySuffix(bucketId: string): void;
|
|
295
|
+
private stateKey;
|
|
296
|
+
/**
|
|
297
|
+
* Drop the current WASM processor and start a fresh, empty circuit. Used on
|
|
298
|
+
* local-bucket switches: the old circuit holds the previous user's rows AND
|
|
299
|
+
* views registered with the previous `$auth` context, so neither may survive.
|
|
300
|
+
* Deliberately does NOT `loadState()` — a persisted snapshot references views
|
|
301
|
+
* under a dead sessionId salt; the DataModule rebind re-registers every live
|
|
302
|
+
* view against this fresh processor. Caller must re-seed `setPermissions`
|
|
303
|
+
* afterwards (a fresh circuit default-denies every table).
|
|
304
|
+
*/
|
|
305
|
+
reset(): Promise<void>;
|
|
237
306
|
loadState(): Promise<void>;
|
|
238
307
|
/**
|
|
239
308
|
* Seed per-table `select` permission predicates ({ [table]: whereText }).
|
|
@@ -309,6 +378,9 @@ declare class CacheModule implements StreamUpdateReceiver {
|
|
|
309
378
|
*/
|
|
310
379
|
onStreamUpdate(update: StreamUpdate): void;
|
|
311
380
|
lookup(recordId: string): number;
|
|
381
|
+
/** Drop the version cache on a bucket switch — a stale version would make
|
|
382
|
+
* the sync diff skip fetching a body the new bucket legitimately needs. */
|
|
383
|
+
clearVersionLookups(): void;
|
|
312
384
|
/**
|
|
313
385
|
* Save a single record to local DB and ingest into DBSP
|
|
314
386
|
* Used by mutations (create/update)
|
|
@@ -360,6 +432,8 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
360
432
|
private statusSubscriptions;
|
|
361
433
|
private mutationCallbacks;
|
|
362
434
|
private debounceTimers;
|
|
435
|
+
private pendingStreamUpdates;
|
|
436
|
+
private fetchDepth;
|
|
363
437
|
private logger;
|
|
364
438
|
/**
|
|
365
439
|
* Optional observer notified whenever a query's fetch status changes.
|
|
@@ -432,6 +506,15 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
432
506
|
* query is unknown.
|
|
433
507
|
*/
|
|
434
508
|
setQueryStatus(queryHash: string, status: QueryStatus): void;
|
|
509
|
+
/**
|
|
510
|
+
* Enter a fetch cycle for a query. Refcounted: registration and concurrent
|
|
511
|
+
* poll/LIVE sync rounds can overlap on the same hash, and only the OUTERMOST
|
|
512
|
+
* cycle may flip the status — 0→1 emits `fetching`, and `endFetching`'s 1→0
|
|
513
|
+
* emits `idle`. Always pair with `endFetching` in a `finally`.
|
|
514
|
+
*/
|
|
515
|
+
beginFetching(queryHash: string): void;
|
|
516
|
+
/** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
|
|
517
|
+
endFetching(queryHash: string): void;
|
|
435
518
|
/**
|
|
436
519
|
* Subscribe to mutations (for sync)
|
|
437
520
|
*/
|
|
@@ -440,6 +523,14 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
440
523
|
* Handle stream updates from DBSP (via CacheModule)
|
|
441
524
|
*/
|
|
442
525
|
onStreamUpdate(update: StreamUpdate): Promise<void>;
|
|
526
|
+
/**
|
|
527
|
+
* Process a query's pending (debounced) stream update NOW instead of on the
|
|
528
|
+
* trailing edge. Called by the sync engine before it flips a query back to
|
|
529
|
+
* `idle`, so the status change never races ahead of the rows it fetched.
|
|
530
|
+
* No-op when nothing is pending. The pending entry is removed before the
|
|
531
|
+
* await so a concurrently-firing timer can't process it twice.
|
|
532
|
+
*/
|
|
533
|
+
flushPendingStreamUpdate(queryHash: string): Promise<void>;
|
|
443
534
|
private materializeRecords;
|
|
444
535
|
private processStreamUpdate;
|
|
445
536
|
/**
|
|
@@ -528,6 +619,28 @@ declare class DataModule<S extends SchemaStructure> {
|
|
|
528
619
|
getActiveQueryHashes(): QueryHash[];
|
|
529
620
|
updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void>;
|
|
530
621
|
updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void>;
|
|
622
|
+
/**
|
|
623
|
+
* Cancel every armed timer ahead of a local-bucket switch: stream-update
|
|
624
|
+
* debounce timers (their pending updates carry the OLD bucket's id-sets) and
|
|
625
|
+
* per-query TTL heartbeats (they'd refresh the previous user's remote
|
|
626
|
+
* `_00_query` rows under the new session). The rebind re-arms heartbeats.
|
|
627
|
+
*/
|
|
628
|
+
quiesce(): void;
|
|
629
|
+
/**
|
|
630
|
+
* Re-home every active query in a freshly-opened bucket, KEEPING its hash —
|
|
631
|
+
* `useQuery` subscriptions are keyed by hash and don't re-register on auth
|
|
632
|
+
* changes, so the hooks must stay attached. Per query:
|
|
633
|
+
* 1. reset the sync arrays + hydration flag and drop the previous user's
|
|
634
|
+
* records, notifying subscribers with the new-bucket materialization
|
|
635
|
+
* (usually empty) so their rows leave the UI immediately;
|
|
636
|
+
* 2. recreate the `_00_query` row in the new bucket;
|
|
637
|
+
* 3. re-register the SSP view on the (fresh, post-reset) processor — this
|
|
638
|
+
* also rebinds the view to the NEW `$auth` context;
|
|
639
|
+
* 4. restart the TTL heartbeat.
|
|
640
|
+
* Returns the hashes so the caller can enqueue remote re-registration, which
|
|
641
|
+
* refills records from the server via the normal register→sync→notify path.
|
|
642
|
+
*/
|
|
643
|
+
rebindAfterBucketSwitch(): Promise<QueryHash[]>;
|
|
531
644
|
/**
|
|
532
645
|
* Called after a query's initial sync completes.
|
|
533
646
|
* Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
|
|
@@ -624,6 +737,7 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
624
737
|
private liveQueryUnsubscribe;
|
|
625
738
|
private listRefPollTimer;
|
|
626
739
|
private listRefPollRunning;
|
|
740
|
+
private listRefPollInFlight;
|
|
627
741
|
readonly refSyncIntervalMs: number;
|
|
628
742
|
private listRefIdleStreak;
|
|
629
743
|
private stillRemoteStreaks;
|
|
@@ -674,6 +788,25 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
674
788
|
* @throws Error if already initialized.
|
|
675
789
|
*/
|
|
676
790
|
init(): Promise<void>;
|
|
791
|
+
/**
|
|
792
|
+
* Quiesce all sync activity ahead of a local-bucket switch. After this
|
|
793
|
+
* resolves, nothing in the sync module writes to the local store: the poll
|
|
794
|
+
* loop is stopped AND its in-flight tick awaited, LIVE is killed, debounce
|
|
795
|
+
* timers are cancelled (their outbox rows are already persisted), and the
|
|
796
|
+
* scheduler has drained its in-flight queue item — including that item's
|
|
797
|
+
* outbox-row delete, which must land in the OLD bucket. Queued down-events
|
|
798
|
+
* are dropped (they reference old-bucket query rows; the post-switch rebind
|
|
799
|
+
* re-enqueues registrations). The old user's un-pushed outbox is deliberately
|
|
800
|
+
* NOT drained: the remote session already belongs to the next user.
|
|
801
|
+
*/
|
|
802
|
+
prepareBucketSwitch(): Promise<void>;
|
|
803
|
+
/**
|
|
804
|
+
* Resume syncing against the freshly-opened bucket: reload the mutation
|
|
805
|
+
* outbox from ITS `_00_pending_mutations` (the new user's own un-pushed
|
|
806
|
+
* offline work) and restart the scheduler. LIVE + the list_ref poll restart
|
|
807
|
+
* via the `setCurrentUserId` call that follows in the auth listener.
|
|
808
|
+
*/
|
|
809
|
+
completeBucketSwitch(): Promise<void>;
|
|
677
810
|
/**
|
|
678
811
|
* Push the authenticated user's record id from the parent client's
|
|
679
812
|
* auth subscription. Tears down the existing `_00_list_ref` LIVE (if
|
|
@@ -894,7 +1027,15 @@ declare class CrdtField {
|
|
|
894
1027
|
/** Whether the LoroDoc was loaded from saved CRDT state */
|
|
895
1028
|
hasContent(): boolean;
|
|
896
1029
|
startSync(local: LocalDatabaseService, remote: RemoteDatabaseService, recordId: string, sessionId: string, debounceMs: number): void;
|
|
897
|
-
|
|
1030
|
+
/**
|
|
1031
|
+
* Stop syncing this field. Flushes one final remote push by default so the
|
|
1032
|
+
* last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
|
|
1033
|
+
* the remote session already belongs to the NEXT user, and pushing this
|
|
1034
|
+
* (previous user's) snapshot under it would clobber the record remotely.
|
|
1035
|
+
*/
|
|
1036
|
+
stopSync(options?: {
|
|
1037
|
+
flush?: boolean;
|
|
1038
|
+
}): void;
|
|
898
1039
|
importRemote(state: Uint8Array): void;
|
|
899
1040
|
exportSnapshot(): Uint8Array;
|
|
900
1041
|
/** Push this session's cursor blob into the parent row at
|
|
@@ -969,7 +1110,14 @@ declare class CrdtManager {
|
|
|
969
1110
|
*/
|
|
970
1111
|
open(table: string, recordId: string, field: string, fallbackText?: string): Promise<CrdtField>;
|
|
971
1112
|
close(table: string, recordId: string, field: string): void;
|
|
972
|
-
|
|
1113
|
+
/**
|
|
1114
|
+
* Close every open field + table LIVE. Fields flush a final remote push by
|
|
1115
|
+
* default; pass `{ flush: false }` on a bucket switch, where that flush
|
|
1116
|
+
* would push the previous user's snapshot under the next user's session.
|
|
1117
|
+
*/
|
|
1118
|
+
closeAll(options?: {
|
|
1119
|
+
flush?: boolean;
|
|
1120
|
+
}): void;
|
|
973
1121
|
/** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
|
|
974
1122
|
* every open CrdtField on `table`. */
|
|
975
1123
|
private ensureTableSubscription;
|
|
@@ -1115,6 +1263,35 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1115
1263
|
*/
|
|
1116
1264
|
private setupCallbacks;
|
|
1117
1265
|
init(): Promise<void>;
|
|
1266
|
+
private bucketSwitchChain;
|
|
1267
|
+
private pendingBucketTarget;
|
|
1268
|
+
/**
|
|
1269
|
+
* Ensure the local store is this user's bucket, switching if needed. Called
|
|
1270
|
+
* from the auth listener on every auth flip; concurrent calls are chained
|
|
1271
|
+
* and superseded intermediates are skipped (latest target wins).
|
|
1272
|
+
*/
|
|
1273
|
+
private ensureLocalBucket;
|
|
1274
|
+
/**
|
|
1275
|
+
* The bucket-switch choreography: drain → swap → rebind.
|
|
1276
|
+
*
|
|
1277
|
+
* Drain: sync quiesced (poll/LIVE stopped, in-flight round awaited so its
|
|
1278
|
+
* outbox delete lands in the OLD bucket, debounce timers cancelled),
|
|
1279
|
+
* DataModule timers cleared, CRDT fields closed WITHOUT their final flush
|
|
1280
|
+
* (the remote session already belongs to the next user).
|
|
1281
|
+
*
|
|
1282
|
+
* Swap: gate closes so any local query issued mid-switch (sibling auth
|
|
1283
|
+
* subscribers, FeatureFlagModule) waits and then runs against the NEW
|
|
1284
|
+
* bucket; store swaps open-new-before-close-old; schema provisions
|
|
1285
|
+
* (no-op for a returning bucket); stale `_00_query` rows are wiped (dead
|
|
1286
|
+
* sessionId-salted hashes with stale arrays — record bodies stay warm);
|
|
1287
|
+
* SSP resets to a fresh circuit with re-seeded permissions.
|
|
1288
|
+
*
|
|
1289
|
+
* Rebind: auth token re-persisted (the surrealdb persistence client wrote it
|
|
1290
|
+
* into the OLD bucket's `_00_kv` before this listener ran), active queries
|
|
1291
|
+
* re-homed keeping their hashes, sync resumed on the new bucket's own
|
|
1292
|
+
* outbox, and every query re-registered remotely to refill from the server.
|
|
1293
|
+
*/
|
|
1294
|
+
private doSwitchBucket;
|
|
1118
1295
|
close(): Promise<void>;
|
|
1119
1296
|
/**
|
|
1120
1297
|
* Subscribe to a feature flag for the current user. Returns a
|