@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
|
@@ -62,4 +62,31 @@ describe('Sp00kyClient.auth.subscribe ordering invariant', () => {
|
|
|
62
62
|
).toBeLessThan(firstAwaitIdx);
|
|
63
63
|
}
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
it('writes the boot-bucket hint synchronously and switches buckets before sync.setCurrentUserId', () => {
|
|
67
|
+
const match = source.match(
|
|
68
|
+
/this\.auth\.subscribe\(\s*async\s*\(\s*userId\s*[^)]*\)\s*=>\s*\{([\s\S]*?)\n {6}\}\s*\)/
|
|
69
|
+
);
|
|
70
|
+
expect(match).not.toBeNull();
|
|
71
|
+
const stripped = match![1]
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map((line) => line.replace(/\/\/.*$/, ''))
|
|
74
|
+
.join('\n');
|
|
75
|
+
|
|
76
|
+
const hintIdx = stripped.indexOf('writeBootBucketHint(');
|
|
77
|
+
const bucketIdx = stripped.indexOf('this.ensureLocalBucket(userId)');
|
|
78
|
+
const syncUserIdx = stripped.indexOf('this.sync.setCurrentUserId(userId)');
|
|
79
|
+
const firstAwaitIdx = stripped.search(/\bawait\b/);
|
|
80
|
+
|
|
81
|
+
// The hint must be written before the first await: a reload landing
|
|
82
|
+
// mid-switch has to boot straight into the target bucket.
|
|
83
|
+
expect(hintIdx, 'writeBootBucketHint must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
|
|
84
|
+
expect(hintIdx, 'writeBootBucketHint must run before the first await').toBeLessThan(firstAwaitIdx);
|
|
85
|
+
|
|
86
|
+
// The bucket switch must complete before the sync module re-registers
|
|
87
|
+
// LIVE/poll for the new user — those immediately write to the local store.
|
|
88
|
+
expect(bucketIdx, 'ensureLocalBucket must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
|
|
89
|
+
expect(syncUserIdx, 'sync.setCurrentUserId must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
|
|
90
|
+
expect(bucketIdx, 'ensureLocalBucket must run before sync.setCurrentUserId').toBeLessThan(syncUserIdx);
|
|
91
|
+
});
|
|
65
92
|
});
|
package/src/sp00ky.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { CrdtManager, CrdtField } from './modules/crdt/index';
|
|
|
42
42
|
import { FeatureFlagModule, FeatureFlagHandle } from './modules/feature-flag/index';
|
|
43
43
|
import type { FeatureFlagOptions } from './modules/feature-flag/index';
|
|
44
44
|
import { LocalStoragePersistenceClient } from './services/persistence/localstorage';
|
|
45
|
+
import { ANON_USER_ID, bucketIdForUser } from './modules/ref-tables';
|
|
45
46
|
import { parseParams, encodeRecordId } from './utils/index';
|
|
46
47
|
import { SurrealDBPersistenceClient } from './services/persistence/surrealdb';
|
|
47
48
|
import { ResilientPersistenceClient } from './services/persistence/resilient';
|
|
@@ -87,6 +88,33 @@ export class BucketHandle {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Boot hint for which local bucket to open before auth resolves. Written to
|
|
93
|
+
* PLAIN localStorage (never the configured persistenceClient): the surrealdb
|
|
94
|
+
* persistence client stores its keys INSIDE a bucket, and the whole point of
|
|
95
|
+
* the hint is to pick the bucket before any bucket is open. A warm reload of a
|
|
96
|
+
* signed-in user thus opens their own bucket immediately — zero switches.
|
|
97
|
+
* Losing the hint is fail-closed: boot lands on the anon bucket and the auth
|
|
98
|
+
* callback switches to the user's bucket (cache + outbox intact).
|
|
99
|
+
*/
|
|
100
|
+
const LAST_BUCKET_KEY = 'sp00ky:last_bucket';
|
|
101
|
+
|
|
102
|
+
function readBootBucketHint(): string | null {
|
|
103
|
+
try {
|
|
104
|
+
return typeof localStorage !== 'undefined' ? localStorage.getItem(LAST_BUCKET_KEY) : null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeBootBucketHint(bucketId: string): void {
|
|
111
|
+
try {
|
|
112
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(LAST_BUCKET_KEY, bucketId);
|
|
113
|
+
} catch {
|
|
114
|
+
/* private-mode storage errors: boot just falls back to the anon bucket */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
export class Sp00kyClient<S extends SchemaStructure> {
|
|
91
119
|
private local: LocalDatabaseService;
|
|
92
120
|
private remote: RemoteDatabaseService;
|
|
@@ -344,9 +372,12 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
344
372
|
'Sp00kyClient initialization started'
|
|
345
373
|
);
|
|
346
374
|
try {
|
|
347
|
-
|
|
375
|
+
// Open the bucket the last session used (per-user local stores). If auth
|
|
376
|
+
// resolves to a different user below, the auth callback switches buckets.
|
|
377
|
+
const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
|
|
378
|
+
await this.local.connect(bootBucket);
|
|
348
379
|
this.logger.debug(
|
|
349
|
-
{ Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
380
|
+
{ bootBucket, Category: 'sp00ky-client::Sp00kyClient::init' },
|
|
350
381
|
'Local database connected'
|
|
351
382
|
);
|
|
352
383
|
|
|
@@ -359,6 +390,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
359
390
|
'Remote database connected'
|
|
360
391
|
);
|
|
361
392
|
|
|
393
|
+
this.streamProcessor.setStateKeySuffix(bootBucket);
|
|
362
394
|
await this.streamProcessor.init();
|
|
363
395
|
// Seed table `select` permissions from the schema before any query is
|
|
364
396
|
// registered — otherwise the SSP default-denies every non-`_00_` table.
|
|
@@ -414,6 +446,13 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
414
446
|
this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null,
|
|
415
447
|
this.auth.access
|
|
416
448
|
);
|
|
449
|
+
// Record the target bucket synchronously (still before the first
|
|
450
|
+
// `await`) so a reload mid-switch boots straight into the right store.
|
|
451
|
+
writeBootBucketHint(bucketIdForUser(userId));
|
|
452
|
+
// FIRST await: swap the local store to this user's bucket. Serialized
|
|
453
|
+
// + latest-target-wins internally; no-op when the bucket already
|
|
454
|
+
// matches (the boot-hint warm path).
|
|
455
|
+
await this.ensureLocalBucket(userId);
|
|
417
456
|
const next = await this.fetchSessionId();
|
|
418
457
|
this.dataModule.setSessionId(next);
|
|
419
458
|
this.crdtManager.setSessionId(next);
|
|
@@ -449,6 +488,97 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
449
488
|
}
|
|
450
489
|
}
|
|
451
490
|
|
|
491
|
+
// Serializes bucket switches from rapid auth flips; `pendingBucketTarget`
|
|
492
|
+
// makes intermediate targets collapse (A→anon→B never opens the anon bucket).
|
|
493
|
+
private bucketSwitchChain: Promise<void> = Promise.resolve();
|
|
494
|
+
private pendingBucketTarget: string | null = null;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Ensure the local store is this user's bucket, switching if needed. Called
|
|
498
|
+
* from the auth listener on every auth flip; concurrent calls are chained
|
|
499
|
+
* and superseded intermediates are skipped (latest target wins).
|
|
500
|
+
*/
|
|
501
|
+
private ensureLocalBucket(userId: string | null): Promise<void> {
|
|
502
|
+
const target = bucketIdForUser(userId);
|
|
503
|
+
this.pendingBucketTarget = target;
|
|
504
|
+
this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
|
|
505
|
+
if (this.pendingBucketTarget !== target) return; // superseded by a newer flip
|
|
506
|
+
if (this.local.currentBucketId === target) return;
|
|
507
|
+
await this.doSwitchBucket(target);
|
|
508
|
+
});
|
|
509
|
+
// Isolate chain failures per-caller: a failed switch must not poison every
|
|
510
|
+
// future switch. The caller (auth listener) logs it.
|
|
511
|
+
const result = this.bucketSwitchChain;
|
|
512
|
+
this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {});
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The bucket-switch choreography: drain → swap → rebind.
|
|
518
|
+
*
|
|
519
|
+
* Drain: sync quiesced (poll/LIVE stopped, in-flight round awaited so its
|
|
520
|
+
* outbox delete lands in the OLD bucket, debounce timers cancelled),
|
|
521
|
+
* DataModule timers cleared, CRDT fields closed WITHOUT their final flush
|
|
522
|
+
* (the remote session already belongs to the next user).
|
|
523
|
+
*
|
|
524
|
+
* Swap: gate closes so any local query issued mid-switch (sibling auth
|
|
525
|
+
* subscribers, FeatureFlagModule) waits and then runs against the NEW
|
|
526
|
+
* bucket; store swaps open-new-before-close-old; schema provisions
|
|
527
|
+
* (no-op for a returning bucket); stale `_00_query` rows are wiped (dead
|
|
528
|
+
* sessionId-salted hashes with stale arrays — record bodies stay warm);
|
|
529
|
+
* SSP resets to a fresh circuit with re-seeded permissions.
|
|
530
|
+
*
|
|
531
|
+
* Rebind: auth token re-persisted (the surrealdb persistence client wrote it
|
|
532
|
+
* into the OLD bucket's `_00_kv` before this listener ran), active queries
|
|
533
|
+
* re-homed keeping their hashes, sync resumed on the new bucket's own
|
|
534
|
+
* outbox, and every query re-registered remotely to refill from the server.
|
|
535
|
+
*/
|
|
536
|
+
private async doSwitchBucket(target: string): Promise<void> {
|
|
537
|
+
this.logger.info(
|
|
538
|
+
{ target, from: this.local.currentBucketId, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
539
|
+
'Switching local bucket'
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
await this.sync.prepareBucketSwitch();
|
|
543
|
+
this.dataModule.quiesce();
|
|
544
|
+
this.crdtManager.closeAll({ flush: false });
|
|
545
|
+
|
|
546
|
+
const reopen = this.local.beginSwitch();
|
|
547
|
+
try {
|
|
548
|
+
await this.local.switchStore(target);
|
|
549
|
+
await this.migrator.provision(this.config.schemaSurql);
|
|
550
|
+
await this.local.queryUngated('DELETE _00_query;');
|
|
551
|
+
this.streamProcessor.setStateKeySuffix(target);
|
|
552
|
+
await this.streamProcessor.reset();
|
|
553
|
+
this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
|
|
554
|
+
this.cache.clearVersionLookups();
|
|
555
|
+
} finally {
|
|
556
|
+
reopen();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (this.auth.token) {
|
|
560
|
+
try {
|
|
561
|
+
await this.persistenceClient.set('sp00ky_auth_token', this.auth.token);
|
|
562
|
+
} catch (e) {
|
|
563
|
+
this.logger.warn(
|
|
564
|
+
{ error: e, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
565
|
+
'Failed to re-persist auth token into the new bucket'
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const hashes = await this.dataModule.rebindAfterBucketSwitch();
|
|
571
|
+
await this.sync.completeBucketSwitch();
|
|
572
|
+
for (const hash of hashes) {
|
|
573
|
+
this.sync.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
this.logger.info(
|
|
577
|
+
{ target, queries: hashes.length, Category: 'sp00ky-client::Sp00kyClient::doSwitchBucket' },
|
|
578
|
+
'Local bucket switch complete'
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
452
582
|
async close() {
|
|
453
583
|
await this.featureFlags.closeAll();
|
|
454
584
|
this.crdtManager.closeAll();
|
package/src/types.ts
CHANGED
|
@@ -255,10 +255,13 @@ export type QueryConfigRecord = QueryConfig & { id: string };
|
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
257
|
* Runtime fetch status of a live query.
|
|
258
|
-
* - `idle`: not currently fetching
|
|
259
|
-
*
|
|
260
|
-
* query
|
|
261
|
-
*
|
|
258
|
+
* - `idle`: registered, initial sync completed, and not currently fetching
|
|
259
|
+
* missing records — the materialized rows are authoritative (a windowed
|
|
260
|
+
* query's short result really is the end of the list).
|
|
261
|
+
* - `fetching`: the query is registering (a query is born `fetching` until its
|
|
262
|
+
* initial remote sync completes) or the sync engine is fetching/ingesting
|
|
263
|
+
* missing records for it. Any pending debounced result is flushed BEFORE the
|
|
264
|
+
* flip back to `idle`, so idle status never races ahead of the rows.
|
|
262
265
|
*/
|
|
263
266
|
export type QueryStatus = 'idle' | 'fetching';
|
|
264
267
|
|
|
@@ -273,12 +276,20 @@ export interface QueryState {
|
|
|
273
276
|
/** Set once `applyHydration` has run for this query, so the cold instant-hydrate
|
|
274
277
|
* path fires at most once per query (see DataModule.isCold/applyHydration). */
|
|
275
278
|
hydrated?: boolean;
|
|
279
|
+
/** Set once `notifyQuerySynced` has emitted for this registration lifetime.
|
|
280
|
+
* Ephemeral (unlike the persisted `updateCount`), so a re-registered query
|
|
281
|
+
* always emits at least once even when its records are unchanged — otherwise
|
|
282
|
+
* an empty re-registered window would never notify and stay "loading". */
|
|
283
|
+
syncNotified?: boolean;
|
|
276
284
|
/** Timer for TTL expiration. */
|
|
277
285
|
ttlTimer: NodeJS.Timeout | null;
|
|
278
286
|
/** TTL duration in milliseconds. */
|
|
279
287
|
ttlDurationMs: number;
|
|
280
288
|
/** Number of times the query has been updated. */
|
|
281
289
|
updateCount: number;
|
|
290
|
+
/** Timestamp (ms) of the last user-visible update, or null before the first
|
|
291
|
+
* one. Surfaced to DevTools as `lastUpdate` — must NOT be stamped on read. */
|
|
292
|
+
lastUpdatedAt: number | null;
|
|
282
293
|
/**
|
|
283
294
|
* Rolling window of the most recent materialization-step latencies (ms).
|
|
284
295
|
* Capped at MATERIALIZATION_SAMPLE_WINDOW; used to recompute p55/p90/p99
|