@spooky-sync/core 0.0.1-canary.103 → 0.0.1-canary.104

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.
@@ -101,6 +101,10 @@ export class Sp00kySync<S extends SchemaStructure> {
101
101
  // LIVE is delivering events and speeds it back up when LIVE quiets.
102
102
  private listRefPollTimer: ReturnType<typeof setTimeout> | null = null;
103
103
  private listRefPollRunning: boolean = false;
104
+ // The currently-executing poll tick, if any. `stopListRefPoll` only stops
105
+ // future ticks; a bucket switch must also AWAIT the in-flight one so its
106
+ // local writes land in the store it started against.
107
+ private listRefPollInFlight: Promise<void> | null = null;
104
108
  public readonly refSyncIntervalMs: number;
105
109
 
106
110
  // Consecutive poll cycles that observed NO list_ref change. Drives the
@@ -389,6 +393,47 @@ export class Sp00kySync<S extends SchemaStructure> {
389
393
  }
390
394
  }
391
395
 
396
+ /**
397
+ * Quiesce all sync activity ahead of a local-bucket switch. After this
398
+ * resolves, nothing in the sync module writes to the local store: the poll
399
+ * loop is stopped AND its in-flight tick awaited, LIVE is killed, debounce
400
+ * timers are cancelled (their outbox rows are already persisted), and the
401
+ * scheduler has drained its in-flight queue item — including that item's
402
+ * outbox-row delete, which must land in the OLD bucket. Queued down-events
403
+ * are dropped (they reference old-bucket query rows; the post-switch rebind
404
+ * re-enqueues registrations). The old user's un-pushed outbox is deliberately
405
+ * NOT drained: the remote session already belongs to the next user.
406
+ */
407
+ public async prepareBucketSwitch(): Promise<void> {
408
+ this.stopSelfHeal();
409
+ this.stopListRefPoll();
410
+ if (this.listRefPollInFlight) await this.listRefPollInFlight;
411
+ await this.killRefLiveQuery();
412
+ this.upQueue.clearDebounceTimers();
413
+ await this.scheduler.pause();
414
+ this.downQueue.clear();
415
+ this.stillRemoteStreaks.clear();
416
+ this.logger.info(
417
+ { Category: 'sp00ky-client::Sp00kySync::prepareBucketSwitch' },
418
+ 'Sync quiesced for bucket switch'
419
+ );
420
+ }
421
+
422
+ /**
423
+ * Resume syncing against the freshly-opened bucket: reload the mutation
424
+ * outbox from ITS `_00_pending_mutations` (the new user's own un-pushed
425
+ * offline work) and restart the scheduler. LIVE + the list_ref poll restart
426
+ * via the `setCurrentUserId` call that follows in the auth listener.
427
+ */
428
+ public async completeBucketSwitch(): Promise<void> {
429
+ await this.upQueue.loadFromDatabase();
430
+ this.scheduler.resume();
431
+ this.logger.info(
432
+ { Category: 'sp00ky-client::Sp00kySync::completeBucketSwitch' },
433
+ 'Sync resumed after bucket switch'
434
+ );
435
+ }
436
+
392
437
  /**
393
438
  * Push the authenticated user's record id from the parent client's
394
439
  * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
@@ -461,9 +506,14 @@ export class Sp00kySync<S extends SchemaStructure> {
461
506
  this.listRefPollTimer = setTimeout(async () => {
462
507
  if (!this.listRefPollRunning) return;
463
508
  let changed = false;
464
- try {
509
+ const tick = (async () => {
465
510
  changed = await this.pollListRefForActiveQueries();
511
+ })();
512
+ this.listRefPollInFlight = tick.catch(() => {});
513
+ try {
514
+ await tick;
466
515
  } finally {
516
+ this.listRefPollInFlight = null;
467
517
  if (!this.listRefPollRunning) return;
468
518
  // Reset the idle streak on any observed change so the poll snaps
469
519
  // back to the fast base cadence; otherwise grow it so a quiet page
@@ -1037,7 +1087,7 @@ export class Sp00kySync<S extends SchemaStructure> {
1037
1087
 
1038
1088
  const fetching = diff.added.length + diff.updated.length > 0;
1039
1089
  if (fetching) {
1040
- this.dataModule.setQueryStatus(hash, 'fetching');
1090
+ this.dataModule.beginFetching(hash);
1041
1091
  }
1042
1092
  try {
1043
1093
  const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
@@ -1079,7 +1129,18 @@ export class Sp00kySync<S extends SchemaStructure> {
1079
1129
  }
1080
1130
  } finally {
1081
1131
  if (fetching) {
1082
- this.dataModule.setQueryStatus(hash, 'idle');
1132
+ // Land the coalesced result BEFORE flipping to idle: the final stream
1133
+ // update sits on a debounce timer, and an `idle` that races ahead of it
1134
+ // would let consumers treat a partially-filled window as authoritative.
1135
+ try {
1136
+ await this.dataModule.flushPendingStreamUpdate(hash);
1137
+ } catch (err) {
1138
+ this.logger.warn(
1139
+ { err, hash, Category: 'sp00ky-client::Sp00kySync::runSyncForQuery' },
1140
+ 'Failed to flush pending stream update before idle'
1141
+ );
1142
+ }
1143
+ this.dataModule.endFetching(hash);
1083
1144
  }
1084
1145
  }
1085
1146
  }
@@ -1114,6 +1175,12 @@ export class Sp00kySync<S extends SchemaStructure> {
1114
1175
  }
1115
1176
 
1116
1177
  private async registerQuery(queryHash: string) {
1178
+ // Hold `fetching` across the WHOLE registration (remote view creation +
1179
+ // initial sync + post-sync notify). A query is born `fetching` in
1180
+ // createNewQuery; this refcounted cycle is what resolves it to `idle` — so
1181
+ // consumers (e.g. useQuery's `isSettled`) never see an idle query whose
1182
+ // window is still empty/partially materialized.
1183
+ this.dataModule.beginFetching(queryHash);
1117
1184
  try {
1118
1185
  this.logger.debug(
1119
1186
  { queryHash, Category: 'sp00ky-client::Sp00kySync::registerQuery' },
@@ -1121,8 +1188,10 @@ export class Sp00kySync<S extends SchemaStructure> {
1121
1188
  );
1122
1189
  await this.createRemoteQuery(queryHash);
1123
1190
  await this.syncQuery(queryHash);
1124
- // Always notify after sync completes handles empty result sets
1125
- // where no stream updates fire but the UI needs to stop loading
1191
+ // Land any still-debounced stream result, then always notify handles
1192
+ // empty result sets where no stream updates fire but the UI needs to
1193
+ // stop loading.
1194
+ await this.dataModule.flushPendingStreamUpdate(queryHash);
1126
1195
  await this.dataModule.notifyQuerySynced(queryHash);
1127
1196
  } catch (e) {
1128
1197
  this.logger.error(
@@ -1130,6 +1199,8 @@ export class Sp00kySync<S extends SchemaStructure> {
1130
1199
  'registerQuery error'
1131
1200
  );
1132
1201
  throw e;
1202
+ } finally {
1203
+ this.dataModule.endFetching(queryHash);
1133
1204
  }
1134
1205
  }
1135
1206
 
@@ -71,7 +71,7 @@ export class LocalMigrator {
71
71
  { Category: 'sp00ky-client::LocalMigrator::provision' },
72
72
  `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`
73
73
  );
74
- await this.localDb.query(statement);
74
+ await this.localDb.queryUngated(statement);
75
75
  this.logger.info(
76
76
  { Category: 'sp00ky-client::LocalMigrator::provision' },
77
77
  `[Provisioning] (${i + 1}/${statements.length}) Done`
@@ -90,7 +90,7 @@ export class LocalMigrator {
90
90
 
91
91
  private async isSchemaUpToDate(hash: string): Promise<boolean> {
92
92
  try {
93
- const [lastSchemaRecord] = await this.localDb.query<any>(
93
+ const [lastSchemaRecord] = await this.localDb.queryUngated<any>(
94
94
  `SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`
95
95
  );
96
96
  return lastSchemaRecord?.hash === hash;
@@ -101,13 +101,13 @@ export class LocalMigrator {
101
101
 
102
102
  private async recreateDatabase(database: string) {
103
103
  try {
104
- await this.localDb.query(`DEFINE DATABASE _00_temp;`);
104
+ await this.localDb.queryUngated(`DEFINE DATABASE _00_temp;`);
105
105
  } catch (_e) {
106
106
  // Ignore if exists
107
107
  }
108
108
 
109
109
  try {
110
- await this.localDb.query(`
110
+ await this.localDb.queryUngated(`
111
111
  USE DB _00_temp;
112
112
  REMOVE DATABASE ${database};
113
113
  `);
@@ -115,7 +115,7 @@ export class LocalMigrator {
115
115
  // Ignore error if database doesn't exist
116
116
  }
117
117
 
118
- await this.localDb.query(`
118
+ await this.localDb.queryUngated(`
119
119
  DEFINE DATABASE ${database};
120
120
  USE DB ${database};
121
121
  `);
@@ -195,7 +195,7 @@ export class LocalMigrator {
195
195
  }
196
196
 
197
197
  private async createHashRecord(hash: string) {
198
- await this.localDb.query(
198
+ await this.localDb.queryUngated(
199
199
  `UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
200
200
  { hash }
201
201
  );
@@ -31,3 +31,34 @@ describe('isLocalStoreOpenError', () => {
31
31
  expect(isLocalStoreOpenError(undefined)).toBe(false);
32
32
  });
33
33
  });
34
+
35
+ // Per-user local buckets: URL/name derivation and the SCOPE of the tier-2
36
+ // corruption drop. The drop must only ever match the failing bucket's store —
37
+ // a substring wipe would take every user's cache AND their un-pushed mutation
38
+ // outboxes down with one corrupt store.
39
+ import { bucketStoreName, bucketStoreUrl, matchesBucketStore } from './local';
40
+
41
+ describe('bucket store naming', () => {
42
+ it('derives the store url/name from the bucket id', () => {
43
+ expect(bucketStoreUrl('abc')).toBe('indxdb://sp00ky-abc');
44
+ expect(bucketStoreName('abc')).toBe('sp00ky-abc');
45
+ expect(bucketStoreUrl('anon')).toBe('indxdb://sp00ky-anon');
46
+ });
47
+ });
48
+
49
+ describe('matchesBucketStore', () => {
50
+ it('matches the exact store and derived names', () => {
51
+ expect(matchesBucketStore('sp00ky-abc', 'sp00ky-abc')).toBe(true);
52
+ expect(matchesBucketStore('sp00ky-abc-wal', 'sp00ky-abc')).toBe(true);
53
+ expect(matchesBucketStore('surrealdb/sp00ky-abc', 'sp00ky-abc')).toBe(true);
54
+ expect(matchesBucketStore('SP00KY-ABC', 'sp00ky-abc')).toBe(true);
55
+ });
56
+
57
+ it("never matches another bucket's store", () => {
58
+ expect(matchesBucketStore('sp00ky-abcdef', 'sp00ky-abc')).toBe(false);
59
+ expect(matchesBucketStore('sp00ky-anon', 'sp00ky-abc')).toBe(false);
60
+ expect(matchesBucketStore('sp00ky', 'sp00ky-abc')).toBe(false);
61
+ // The legacy shared store is not any bucket's store.
62
+ expect(matchesBucketStore('sp00ky', 'sp00ky-anon')).toBe(false);
63
+ });
64
+ });