@spooky-sync/core 0.0.1-canary.204 → 0.0.1-canary.206

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.
@@ -8,6 +8,9 @@ import { SyncEventTypes, createSyncEventSystem } from './events/index';
8
8
  import { encodeRecordId } from '../../utils/index';
9
9
  import { cleanRecord } from '../../utils/parser';
10
10
 
11
+ /** Ids per remote `SELECT * FROM $ids` round trip. */
12
+ const FETCH_CHUNK = 500;
13
+
11
14
  /**
12
15
  * SyncEngine handles the core sync operations: fetching remote records,
13
16
  * caching them locally, and ingesting into DBSP.
@@ -55,10 +58,16 @@ export class SyncEngine {
55
58
  stillRemoteIds = await this.handleRemovedRecords(removed);
56
59
  }
57
60
 
58
- // Fetch added/updated records from remote
59
- const toFetch = [...added, ...updated];
60
- const idsToFetch = toFetch.map((x) => x.id);
61
- if (idsToFetch.length === 0) {
61
+ // Fetch added/updated records from remote. Skip ids whose body the local
62
+ // store already holds at this version (the circuit prime seeded the memo
63
+ // from `_00_rv`): after a reload every id in the server's list_ref used to
64
+ // classify as `added` against an empty circuit, and this fetched the
65
+ // whole working set again.
66
+ const toFetch = [...added, ...updated].filter((item) => {
67
+ const localVersion = this.cache.lookup(encodeRecordId(item.id));
68
+ return !(localVersion && item.version <= localVersion);
69
+ });
70
+ if (toFetch.length === 0) {
62
71
  return { remoteFetchMs: 0, stillRemoteIds };
63
72
  }
64
73
 
@@ -67,66 +76,75 @@ export class SyncEngine {
67
76
  for (const item of toFetch) {
68
77
  versionMap.set(encodeRecordId(item.id), item.version);
69
78
  }
79
+ const addedIds = new Set(added.map((item) => encodeRecordId(item.id)));
70
80
 
71
81
  // Fetch records from remote — avoid SELECT *, <subquery> FROM $param
72
82
  // pattern which drops the * fields in SurrealDB v3 (known bug).
73
83
  // Versions are already known from the diff's list_ref data.
74
- const remoteFetchStart = performance.now();
75
- const [remoteResults] = await this.remote.query<[RecordWithId[]]>(
76
- 'SELECT * FROM $idsToFetch',
77
- { idsToFetch }
78
- );
79
- const remoteFetchMs = performance.now() - remoteFetchStart;
80
-
81
- // Prepare batch for cache (which handles both DB and DBSP)
82
- const cacheBatch: CacheRecord[] = [];
83
-
84
- for (const record of remoteResults) {
85
- if (!record?.id) {
86
- this.logger.warn(
87
- {
88
- record,
89
- idsToFetch,
90
- Category: 'sp00ky-client::SyncEngine::syncRecords',
91
- },
92
- 'Remote record has no id (possibly deleted). Skipping record'
93
- );
94
- continue;
95
- }
96
- const fullId = encodeRecordId(record.id);
97
- const table = record.id.table.toString();
98
- const isAdded = added.some((item) => encodeRecordId(item.id) === fullId);
99
- const version = versionMap.get(fullId) ?? 0;
100
-
101
- const localVersion = this.cache.lookup(fullId);
102
- if (localVersion && version <= localVersion) {
103
- this.logger.info(
104
- {
105
- recordId: fullId,
106
- version,
107
- localVersion,
108
- Category: 'sp00ky-client::SyncEngine::syncRecords',
109
- },
110
- 'Local version is higher than remote version. Skipping record'
111
- );
112
- continue;
84
+ //
85
+ // Chunked so a genuinely cold load lands progressively: one response
86
+ // holding thousands of bodies, then one transaction MERGEing all of them,
87
+ // was a single main-thread stall and a memory spike on top of it.
88
+ let remoteFetchMs = 0;
89
+ const remoteResults: RecordWithId[] = [];
90
+ for (let i = 0; i < toFetch.length; i += FETCH_CHUNK) {
91
+ const idsToFetch = toFetch.slice(i, i + FETCH_CHUNK).map((x) => x.id);
92
+ const remoteFetchStart = performance.now();
93
+ const [chunkResults] = await this.remote.query<[RecordWithId[]]>(
94
+ 'SELECT * FROM $idsToFetch',
95
+ { idsToFetch }
96
+ );
97
+ remoteFetchMs += performance.now() - remoteFetchStart;
98
+
99
+ // Prepare batch for cache (which handles both DB and DBSP)
100
+ const cacheBatch: CacheRecord[] = [];
101
+ for (const record of chunkResults ?? []) {
102
+ if (!record?.id) {
103
+ this.logger.warn(
104
+ {
105
+ record,
106
+ idsToFetch,
107
+ Category: 'sp00ky-client::SyncEngine::syncRecords',
108
+ },
109
+ 'Remote record has no id (possibly deleted). Skipping record'
110
+ );
111
+ continue;
112
+ }
113
+ remoteResults.push(record);
114
+ const fullId = encodeRecordId(record.id);
115
+ const table = record.id.table.toString();
116
+ const version = versionMap.get(fullId) ?? 0;
117
+
118
+ const localVersion = this.cache.lookup(fullId);
119
+ if (localVersion && version <= localVersion) {
120
+ this.logger.info(
121
+ {
122
+ recordId: fullId,
123
+ version,
124
+ localVersion,
125
+ Category: 'sp00ky-client::SyncEngine::syncRecords',
126
+ },
127
+ 'Local version is higher than remote version. Skipping record'
128
+ );
129
+ continue;
130
+ }
131
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
132
+ const cleanedRecord = tableSchema
133
+ ? cleanRecord(tableSchema.columns, record)
134
+ : record;
135
+
136
+ cacheBatch.push({
137
+ table,
138
+ op: addedIds.has(fullId) ? 'CREATE' : 'UPDATE',
139
+ record: cleanedRecord as RecordWithId,
140
+ version,
141
+ });
113
142
  }
114
- const tableSchema = this.schema.tables.find((t) => t.name === table);
115
- const cleanedRecord = tableSchema
116
- ? cleanRecord(tableSchema.columns, record)
117
- : record;
118
-
119
- cacheBatch.push({
120
- table,
121
- op: isAdded ? 'CREATE' : 'UPDATE',
122
- record: cleanedRecord as RecordWithId,
123
- version,
124
- });
125
- }
126
143
 
127
- // Use CacheModule to handle both local DB and DBSP ingestion
128
- if (cacheBatch.length > 0) {
129
- await this.cache.saveBatch(cacheBatch);
144
+ // Use CacheModule to handle both local DB and DBSP ingestion
145
+ if (cacheBatch.length > 0) {
146
+ await this.cache.saveBatch(cacheBatch);
147
+ }
130
148
  }
131
149
 
132
150
  this.events.emit(SyncEventTypes.RemoteDataIngested, {
@@ -527,14 +527,40 @@ export class Sp00kySync<S extends SchemaStructure> {
527
527
  this.tabId = tabId;
528
528
  }
529
529
 
530
- /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
531
- * relay LIVE events and rollbacks to followers via `hub`. Idempotent for a
532
- * boot-time leader; a runtime promotion (failover) reloads the outbox,
533
- * which now holds EVERY tab's rows, and restarts LIVE under this session. */
534
- public async promoteToLeader(hub: LeaderSyncHub): Promise<void> {
530
+ /** In-flight {@link resumeLeaderDuties}, so a second call joins the first
531
+ * instead of double-draining the outbox. */
532
+ private leaderDutiesInFlight: Promise<void> | null = null;
533
+
534
+ /** Resolves once the in-browser circuit has been primed from the local
535
+ * store. Every sync diff waits on it: diffing against an empty circuit
536
+ * classifies the whole working set as missing and re-downloads it. */
537
+ private primeGate: () => Promise<void> = () => Promise.resolve();
538
+ /** The prime we last waited on. `whenPrimed` hands out one promise per
539
+ * prime, so a new identity means a new prime (boot, bucket switch) ran. */
540
+ private settledPrime: Promise<void> | null = null;
541
+
542
+ setPrimeGate(gate: () => Promise<void>): void {
543
+ this.primeGate = gate;
544
+ this.settledPrime = null;
545
+ }
546
+
547
+ /**
548
+ * Leader WIRING only, and deliberately synchronous.
549
+ *
550
+ * The coordinator publishes the leader role and tells the broker
551
+ * `leader-ready` the moment the store is adopted, and the broker can mint a
552
+ * follower's ports on the very next tick. So the follower-message handler
553
+ * has to be live before this returns, or a mutation forwarded in that window
554
+ * is dropped. Everything that can block (outbox reload, LIVE restart) moved
555
+ * to {@link resumeLeaderDuties}: a promotion that waits on the network holds
556
+ * `leader-ready` back, and a broker whose leader never reports ready serves
557
+ * no follower ports and re-elects no one, which wedges the whole namespace.
558
+ */
559
+ public promoteToLeader(hub: LeaderSyncHub): void {
535
560
  this.tabRole = 'leader';
536
561
  this.hub = hub;
537
562
  this.forwarder = null;
563
+ this.leaderDutiesInFlight = null;
538
564
  hub.onFollowerMessage = (tabId, msg) => {
539
565
  void tabId;
540
566
  switch (msg.type) {
@@ -548,19 +574,34 @@ export class Sp00kySync<S extends SchemaStructure> {
548
574
  break;
549
575
  }
550
576
  };
551
- if (this.isInit) {
577
+ }
578
+
579
+ /** Leader duties: drain the shared outbox, own the single list_ref LIVE,
580
+ * relay LIVE events and rollbacks to followers. Idempotent for a boot-time
581
+ * leader; a runtime promotion (failover) reloads the outbox, which now
582
+ * holds EVERY tab's rows, and restarts LIVE under this session. Runs in the
583
+ * background off the promotion path, so however long it takes (or if it
584
+ * never finishes) the tab is already a working leader. */
585
+ public resumeLeaderDuties(): Promise<void> {
586
+ if (this.leaderDutiesInFlight) return this.leaderDutiesInFlight;
587
+ const run = (async () => {
588
+ if (!this.isInit || this.tabRole !== 'leader') return;
552
589
  await this.upQueue.loadFromDatabase();
553
590
  void this.scheduler.syncUp();
554
591
  if (this.currentUserId || this.anonLiveEnabled) {
555
592
  this.startListRefPoll();
556
593
  await this.restartRefLiveQuery().catch((err) => {
557
594
  this.logger.warn(
558
- { err, Category: 'sp00ky-client::Sp00kySync::promoteToLeader' },
595
+ { err, Category: 'sp00ky-client::Sp00kySync::resumeLeaderDuties' },
559
596
  'LIVE restart failed on promotion; poll fallback covers it'
560
597
  );
561
598
  });
562
599
  }
563
- }
600
+ })();
601
+ this.leaderDutiesInFlight = run;
602
+ return run.finally(() => {
603
+ if (this.leaderDutiesInFlight === run) this.leaderDutiesInFlight = null;
604
+ });
564
605
  }
565
606
 
566
607
  /** Follower duties: no outbox drain, no LIVE. Mutations forward to the
@@ -570,6 +611,9 @@ export class Sp00kySync<S extends SchemaStructure> {
570
611
  this.tabRole = 'follower';
571
612
  this.hub = null;
572
613
  this.forwarder = forwarder;
614
+ // A still-running resumeLeaderDuties self-cancels on its `tabRole` guard;
615
+ // drop the handle so a later re-promotion starts a fresh drain.
616
+ this.leaderDutiesInFlight = null;
573
617
  void this.killRefLiveQuery();
574
618
  forwarder.onLeaderMessage = (msg) => {
575
619
  switch (msg.type) {
@@ -1531,6 +1575,19 @@ export class Sp00kySync<S extends SchemaStructure> {
1531
1575
  * means the single resulting UI update lands after this completes.
1532
1576
  */
1533
1577
  private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
1578
+ // The diff was computed against whatever the circuit held at call time; if
1579
+ // the boot prime is still filling it, wait and recompute from the primed
1580
+ // `localArray` so the delta is real rather than "everything".
1581
+ const prime = this.primeGate();
1582
+ if (prime !== this.settledPrime) {
1583
+ await prime;
1584
+ this.settledPrime = prime;
1585
+ const fresh = this.dataModule.getQueryByHash(hash);
1586
+ if (!fresh) return;
1587
+ const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
1588
+ if (!recomputed) return;
1589
+ diff = recomputed;
1590
+ }
1534
1591
  // Don't let sync re-add a record the user just deleted locally. The remote
1535
1592
  // delete is queued in the outbox, so until it's processed the server's
1536
1593
  // `_00_list_ref` still lists the record — the diff then classifies it as
@@ -138,6 +138,39 @@ export interface LocalStore extends LocalCacheEngine {
138
138
  /** Fires immediately with the current snapshot, then on every change.
139
139
  * Returns an unsubscribe function. */
140
140
  subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
141
+ /**
142
+ * Every cached row's `(id, _00_rv)` per table, ids in stable `table:id`
143
+ * form. The in-browser circuit primes and reconciles itself from this on
144
+ * boot instead of re-downloading the working set. OPTIONAL: an engine
145
+ * without it boots the circuit empty, as before.
146
+ */
147
+ scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
148
+ /**
149
+ * Circuit snapshot storage, keyed. Lives in the same durable store as the
150
+ * rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
151
+ * readable by follower tabs over the port transport. OPTIONAL: an engine
152
+ * without it primes from `scanVersions` alone.
153
+ */
154
+ getSnapshot?(key: string): Promise<StoredSnapshot | null>;
155
+ putSnapshot?(key: string, bytes: Uint8Array, meta: SnapshotMeta): Promise<void>;
156
+ deleteSnapshot?(key: string): Promise<void>;
157
+ }
158
+
159
+ /** Describes a stored circuit snapshot; what decides whether it is usable. */
160
+ export interface SnapshotMeta {
161
+ /** Bump when the wire shape the circuit reads changes. */
162
+ formatVersion: number;
163
+ /** Hash of the schema the rows were projected under. */
164
+ schemaHash: string;
165
+ savedAt: number;
166
+ /** Highest `_00_rv` per table at save time (diagnostics). */
167
+ maxRv?: Record<string, number>;
168
+ [key: string]: unknown;
169
+ }
170
+
171
+ export interface StoredSnapshot {
172
+ bytes: Uint8Array;
173
+ meta: SnapshotMeta;
141
174
  }
142
175
 
143
176
  /** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
@@ -556,3 +556,61 @@ describe('create() tx result shaping (fast path parity)', () => {
556
556
  expect(outboxRow.data).toEqual(payload);
557
557
  });
558
558
  });
559
+
560
+
561
+ // The circuit snapshot + version scan the boot-time prime reads through this
562
+ // engine. Bytes and meta must round-trip as ONE batch (a reader must never see
563
+ // new bytes with old meta) and a table that does not exist yet scans as empty.
564
+ describe('SqliteCacheEngine circuit snapshot + scanVersions', () => {
565
+ it('stores bytes and meta in one batch and reads them back', async () => {
566
+ const stored = new Map<string, unknown>();
567
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
568
+ const types: string[] = [];
569
+ stubTransport(engine, (type: string, payload: any) => {
570
+ types.push(type);
571
+ if (type === 'open') return { persisted: true };
572
+ if (type === 'batch') {
573
+ for (const stmt of payload as { sql: string; bind: unknown[] }[]) {
574
+ stored.set(stmt.bind[0] as string, stmt.bind[1]);
575
+ }
576
+ return {};
577
+ }
578
+ if (type === 'exec') {
579
+ const ids = (payload.bind ?? []) as string[];
580
+ return { rows: ids.filter((id) => stored.has(id)).map((id) => ({ id, data: stored.get(id) })) };
581
+ }
582
+ return {};
583
+ });
584
+ await engine.connect('anon');
585
+
586
+ expect(await engine.getSnapshot('circuit')).toBeNull();
587
+ const bytes = new Uint8Array([1, 2, 3]);
588
+ await engine.putSnapshot('circuit', bytes, { formatVersion: 1, schemaHash: 'h', savedAt: 5 });
589
+ expect(types.filter((t) => t === 'batch')).toHaveLength(1);
590
+ const snap = await engine.getSnapshot('circuit');
591
+ expect(snap?.bytes).toEqual(bytes);
592
+ expect(snap?.meta).toEqual({ formatVersion: 1, schemaHash: 'h', savedAt: 5 });
593
+ });
594
+
595
+ it('scans (id, rv) per table and reads a missing table as empty', async () => {
596
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
597
+ stubTransport(engine, (type: string, payload: any) => {
598
+ if (type === 'open') return { persisted: true };
599
+ if (type === 'exec') {
600
+ if (String(payload.sql).includes('"thing"')) {
601
+ return { rows: [{ id: 'thing:a', rv: 3 }, { id: 'thing:b', rv: null }] };
602
+ }
603
+ throw new Error('no such table');
604
+ }
605
+ return {};
606
+ });
607
+ await engine.connect('anon');
608
+ expect(await engine.scanVersions(['thing', 'ghost'])).toEqual({
609
+ thing: [
610
+ ['thing:a', 3],
611
+ ['thing:b', 0],
612
+ ],
613
+ ghost: [],
614
+ });
615
+ });
616
+ });
@@ -10,6 +10,7 @@ import {
10
10
  } from './sqlite-plan-sql';
11
11
  import type { Logger } from '../logger/index';
12
12
  import type { Sp00kyConfig, StorageHealth } from '../../types';
13
+ import type { SnapshotMeta, StoredSnapshot } from './cache-engine';
13
14
  import type { SealedQuery } from '../../utils/surql';
14
15
  import { resolveRelations, stableKey } from './relation-resolver';
15
16
  import {
@@ -50,8 +51,13 @@ import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-in
50
51
  * on a fresh bucket throws "no such table". Keep in sync with the systemSchema
51
52
  * block in `local-migrator.ts`.
52
53
  */
54
+ const SNAPSHOT_TABLE = '_00_circuit_snapshot';
55
+
53
56
  const SYSTEM_TABLES = [
54
57
  '_00_stream_processor_state',
58
+ // In-browser circuit snapshot: one BLOB row (`circuit`) + a JSON meta row.
59
+ // Excluded from DevTools' table listing, since its data column is not JSON.
60
+ '_00_circuit_snapshot',
55
61
  '_00_query',
56
62
  '_00_preload',
57
63
  '_00_window',
@@ -197,7 +203,7 @@ export class SqliteCacheEngine implements LocalStore {
197
203
  const { rows: tables } = await this.call<{ rows: { name: string }[] }>('exec', {
198
204
  sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
199
205
  });
200
- const names = (tables ?? []).map((r) => r.name);
206
+ const names = (tables ?? []).map((r) => r.name).filter((n) => n !== SNAPSHOT_TABLE);
201
207
  if (names.length) {
202
208
  // Names come from sqlite_master itself; double-quoting is enough.
203
209
  const sql = names
@@ -837,6 +843,70 @@ export class SqliteCacheEngine implements LocalStore {
837
843
  return rows[0] ?? null;
838
844
  }
839
845
 
846
+ /**
847
+ * `(id, _00_rv)` of every row per table, straight off the worker: no body
848
+ * parse, one round trip per table. A table that does not exist yet reads as
849
+ * empty rather than failing the whole scan.
850
+ */
851
+ async scanVersions(tables: string[]): Promise<Record<string, [string, number][]>> {
852
+ const out: Record<string, [string, number][]> = {};
853
+ for (const table of tables) {
854
+ try {
855
+ const { rows } = await this.call<{ rows: { id: string; rv: unknown }[] }>('exec', {
856
+ sql: `SELECT id, json_extract(data, '$._00_rv') AS rv FROM "${table.replace(/"/g, '""')}"`,
857
+ });
858
+ out[table] = (rows ?? []).map((r) => [r.id, Number(r.rv) || 0]);
859
+ } catch {
860
+ out[table] = [];
861
+ }
862
+ }
863
+ return out;
864
+ }
865
+
866
+ async getSnapshot(key: string): Promise<StoredSnapshot | null> {
867
+ const { rows } = await this.call<{ rows: { id: string; data: unknown }[] }>('exec', {
868
+ sql: `SELECT id, data FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
869
+ bind: [key, `${key}:meta`],
870
+ });
871
+ let bytes: Uint8Array | null = null;
872
+ let meta: SnapshotMeta | null = null;
873
+ for (const r of rows ?? []) {
874
+ if (r.id === key) {
875
+ if (r.data instanceof Uint8Array) bytes = r.data;
876
+ else if (r.data instanceof ArrayBuffer) bytes = new Uint8Array(r.data);
877
+ else if (typeof r.data === 'string') bytes = new TextEncoder().encode(r.data);
878
+ } else if (typeof r.data === 'string') {
879
+ try {
880
+ meta = JSON.parse(r.data) as SnapshotMeta;
881
+ } catch {
882
+ meta = null;
883
+ }
884
+ }
885
+ }
886
+ return bytes && meta ? { bytes, meta } : null;
887
+ }
888
+
889
+ async putSnapshot(key: string, bytes: Uint8Array, meta: SnapshotMeta): Promise<void> {
890
+ // One transaction, so a reader never sees new bytes with old meta.
891
+ await this.call('batch', [
892
+ {
893
+ sql: `INSERT OR REPLACE INTO "${SNAPSHOT_TABLE}" (id, data) VALUES (?, ?)`,
894
+ bind: [key, bytes],
895
+ },
896
+ {
897
+ sql: `INSERT OR REPLACE INTO "${SNAPSHOT_TABLE}" (id, data) VALUES (?, ?)`,
898
+ bind: [`${key}:meta`, JSON.stringify(meta)],
899
+ },
900
+ ]);
901
+ }
902
+
903
+ async deleteSnapshot(key: string): Promise<void> {
904
+ await this.call('run', {
905
+ sql: `DELETE FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
906
+ bind: [key, `${key}:meta`],
907
+ });
908
+ }
909
+
840
910
  // ---- writes --------------------------------------------------------------
841
911
 
842
912
  async upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void> {
@@ -99,6 +99,26 @@ export class SurrealCacheEngine extends LocalDatabaseService implements LocalCac
99
99
  return new RecordId(table, raw);
100
100
  }
101
101
 
102
+ /**
103
+ * `(id, _00_rv)` of every row per table. Runs on the main thread against the
104
+ * embedded engine (no worker here), which is why the circuit prime caps how
105
+ * much it is willing to read through this path.
106
+ */
107
+ async scanVersions(tables: string[]): Promise<Record<string, [string, number][]>> {
108
+ const out: Record<string, [string, number][]> = {};
109
+ for (const table of tables) {
110
+ try {
111
+ const [rows] = await this.query<[{ id: Id; _00_rv?: unknown }[]]>(
112
+ `SELECT id, _00_rv FROM ${table};`
113
+ );
114
+ out[table] = (rows ?? []).map((r) => [stableKey(r.id), Number(r._00_rv) || 0]);
115
+ } catch {
116
+ out[table] = [];
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
102
122
  async getById(table: string, id: Id): Promise<Row | null> {
103
123
  const [row] = await this.query<[Row | null]>('SELECT * FROM ONLY $__id;', {
104
124
  __id: this.toRecordId(table, id),