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

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.
@@ -5,7 +5,8 @@ import type { Logger } from 'pino';
5
5
  import type { LocalStore } from '../database/index';
6
6
  import type { WasmProcessor, WasmStreamUpdate } from './wasm-types';
7
7
  import type { Duration } from 'surrealdb';
8
- import type { PersistenceClient, QueryTimeToLive, RecordVersionArray } from '../../types';
8
+ import type { QueryTimeToLive, RecordVersionArray } from '../../types';
9
+ import { encodeRecordId } from '../../utils/index';
9
10
 
10
11
  // Simple interface for query plan registration (replaces Incantation class)
11
12
  interface QueryPlanConfig {
@@ -57,19 +58,56 @@ export interface StreamUpdateReceiver {
57
58
  onStreamUpdate(update: StreamUpdate): void;
58
59
  }
59
60
 
61
+ /** One row change in the shape `ingestMany` consumes. */
62
+ export interface IngestRecord {
63
+ table: string;
64
+ /** `MERGE` overlays the given fields on the stored row (projection widening). */
65
+ op: 'CREATE' | 'UPDATE' | 'DELETE' | 'MERGE';
66
+ id: string;
67
+ record: any;
68
+ }
69
+
60
70
  /**
61
- * Read a circuit snapshot out of the pre-`persistCircuit` persisted shape
62
- * (`[[{ state }]]`, a raw SurrealDB result). Returns null for anything else.
71
+ * What the boot-time prime needs from the client: which tables to walk, how
72
+ * to recognise a snapshot written under a different schema, and which rows'
73
+ * local `_00_rv` must not be reported as the server's.
63
74
  */
64
- function extractLegacyState(result: unknown): string | null {
65
- if (
66
- Array.isArray(result) &&
67
- Array.isArray(result[0]) &&
68
- typeof result[0][0]?.state === 'string'
69
- ) {
70
- return result[0][0].state;
71
- }
72
- return null;
75
+ export interface CircuitPrimeContext {
76
+ tables: string[];
77
+ schemaHash: string;
78
+ /** Encoded ids with an unsettled local mutation (their `_00_rv` was bumped
79
+ * locally and may exceed the server's next version). */
80
+ pendingIds: Set<string>;
81
+ /** Receives every `(id, rv)` the prime put into the circuit, per table, so
82
+ * the sync layer can skip re-downloading bodies it already has. */
83
+ onVersions?: (table: string, entries: [string, number][]) => void;
84
+ }
85
+
86
+ /** Storage key of the circuit snapshot inside the local store. */
87
+ export const CIRCUIT_SNAPSHOT_KEY = 'circuit';
88
+ /** Bump when the bytes `load_store_state` reads change shape. */
89
+ export const CIRCUIT_SNAPSHOT_FORMAT = 1;
90
+ /** Rows per `ingest_many` call. Bounds the transient the wasm side allocates
91
+ * to parse a batch (measured: one 7700-row call of 20 KB bodies peaked at
92
+ * 500 MB, 128-row chunks at 335 MB, and under projection at 24 MB). */
93
+ const INGEST_CHUNK = 128;
94
+ /** Ids per `selectByIds` when priming bodies out of the local store. */
95
+ const PRIME_CHUNK = 256;
96
+ /** Rows the SurrealDB (main-thread, IndexedDB) engine is allowed to prime
97
+ * through; above this the circuit boots empty as it always did. */
98
+ const SURREAL_PRIME_ROW_CAP = 20_000;
99
+ /** Below this many changed rows the checkpoint timer stays quiet. */
100
+ const CHECKPOINT_MIN_ROWS = 50;
101
+
102
+ /** Stable `table:id` for a row id that may already be a string. */
103
+ function idString(id: unknown): string {
104
+ return typeof id === 'string' ? id : encodeRecordId(id as any);
105
+ }
106
+
107
+ function chunks<T>(items: T[], size: number): T[][] {
108
+ const out: T[][] = [];
109
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
110
+ return out;
73
111
  }
74
112
 
75
113
  export class StreamProcessorService {
@@ -90,51 +128,42 @@ export class StreamProcessorService {
90
128
  // rejecting `$auth`-gated tables; the predicate just degrades to its public
91
129
  // branch. Set via `setSessionAuth` on every auth state change.
92
130
  private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
93
- // Persisted-state key suffix (the local bucket id) so each user's circuit
94
- // snapshot lands in their own key. Only matters for localStorage-backed
95
- // persistence — the surrealdb persistence client already writes into the
96
- // per-user bucket itself.
97
- private stateKeySuffix = '';
98
- // Bumped by `reset()`. A `saveState` captured before a reset must not persist
99
- // the OLD processor's circuit (holding the previous user's rows) after the
100
- // switch — the fire-and-forget calls in `ingest`/`flushCoalescing` can land
101
- // late.
131
+ // Bumped by `reset()`. Anything captured before a reset (a checkpoint's
132
+ // bytes, a prime's chunk, a widening read) must not land on the NEW
133
+ // processor, which belongs to a different bucket.
102
134
  private stateGeneration = 0;
103
- // Shared-tabs: follower circuits are in-memory ONLY. Persisting them would
104
- // stomp the leader's snapshot under the same key (the pre-existing cross-tab
105
- // localStorage hazard); a promoted follower flips this back on.
135
+ // Shared-tabs: follower circuits are in-memory ONLY. Only the tab that owns
136
+ // the store writes its snapshot; a promoted follower flips this back on.
106
137
  private persistState = true;
107
- // Snapshot persistence is OPT-IN and off by default (`persistCircuit`).
108
- //
109
- // `Circuit::save` deep-clones the WHOLE store (every row of every ingested
110
- // table, full bodies) plus every view cache and JSON-encodes the result. It
111
- // used to run from `ingest`, `flushCoalescing`, `registerQueryPlan` and
112
- // `unregisterQueryPlan`, i.e. once per sync batch AND once per query
113
- // register/unregister. On a 3.7k-game collection whose list registers and
114
- // drops one windowed query per 50 rows scrolled, that measured 220 whole-store
115
- // serializations and ~1 GB of transient JSON for a single scroll, 5.5x the
116
- // wall time of the same ingests without it, and it doubled the wasm heap
117
- // high-water mark (wasm32 dlmalloc never returns pages, so the peak is
118
- // permanent). It also ran the encode a second time in
119
- // `LocalStoragePersistenceClient.set` and wrote it synchronously on the main
120
- // thread. All of it was dead weight: `loadState`'s shape check could never
121
- // match what either shipped persistence client returns, so the browser never
122
- // restored a snapshot, and first paint comes from the local SQLite store
123
- // anyway.
124
- //
125
- // When enabled, we mark the circuit dirty and let a checkpoint timer (plus a
126
- // `pagehide` flush) do at most one snapshot per interval, mirroring
127
- // `ssp-node`'s "NEVER per-ingest" rule.
138
+ // Snapshot persistence. Store-only bytes, written by the checkpoint timer
139
+ // and on the page going hidden, never per ingest: `save_store_state` walks
140
+ // every row. The snapshot is what lets a reload restore the circuit and
141
+ // step in only what changed since (`reconcile`), instead of re-downloading
142
+ // and re-ingesting the whole working set.
128
143
  private persistCircuit = false;
129
144
  private checkpointMs = 30_000;
130
145
  private checkpointTimer: ReturnType<typeof setInterval> | null = null;
131
146
  private snapshotDirty = false;
132
- private pagehideHandler: (() => void) | null = null;
147
+ private dirtyRows = 0;
148
+ private hideHandler: (() => void) | null = null;
149
+ private checkpointInFlight: Promise<void> | null = null;
150
+ // Keep only the fields registered plans evaluate per stored row. The client
151
+ // renders bodies from the local store, so a 20 KB game row in the circuit
152
+ // is ~150 B of predicate/sort fields under projection.
153
+ private projection = true;
154
+ // Prime state: `primed` resolves when the boot-time prime (snapshot restore
155
+ // + reconcile, or a full read of the local store) has finished, aborted or
156
+ // been skipped. Sync waits on it before its first diff.
157
+ private primed: Promise<void> = Promise.resolve();
158
+ private schemaHash: string | null = null;
159
+ // Projection widening, serialised: fields a new view evaluates that stored
160
+ // rows lack are merged in table by table.
161
+ private widenQueue: Promise<void> = Promise.resolve();
162
+ private widenPending: Map<string, Set<string>> = new Map();
133
163
 
134
164
  constructor(
135
165
  public events: EventSystem<StreamProcessorEvents>,
136
166
  private db: LocalStore,
137
- private persistenceClient: PersistenceClient,
138
167
  logger: Logger
139
168
  ) {
140
169
  this.logger = logger.child({ name: 'StreamProcessorService' });
@@ -183,101 +212,89 @@ export class StreamProcessorService {
183
212
  }
184
213
 
185
214
  /**
186
- * Ingest a batch of record changes as a single bulk operation, firing only
187
- * one coalesced `StreamUpdate` per affected query once every record has been
188
- * ingested (instead of one update per record). Use this whenever multiple
189
- * records land at once — e.g. sync fetching N missing rows — so a list query
190
- * re-runs and the UI re-renders once for the whole batch rather than
191
- * row-by-row.
215
+ * Ingest a batch of record changes, firing one coalesced `StreamUpdate` per
216
+ * affected query once every record has been ingested. Use this whenever
217
+ * multiple records land at once (sync fetching N rows, the boot prime).
192
218
  *
193
- * Internally opens a coalescing window, ingests each record, then flushes;
194
- * processor state is persisted once for the whole batch. No-op for an empty
195
- * batch.
219
+ * The batch is fed to the wasm side in chunks of {@link INGEST_CHUNK}: one
220
+ * circuit step per chunk (a step walks every registered view, so per-record
221
+ * ingest paid that fixed cost N times), but never the whole batch at once,
222
+ * because the wasm side has to hold every parsed row of a call at the same
223
+ * time and wasm32 dlmalloc never returns that peak.
224
+ *
225
+ * Returns the records that were ingested. A chunk that fails is reported
226
+ * and skipped, not retried (a retry would double-apply whatever the failed
227
+ * step already committed), and the remaining chunks still run.
196
228
  */
197
- ingestMany(
198
- records: Array<{
199
- table: string;
200
- op: 'CREATE' | 'UPDATE' | 'DELETE';
201
- id: string;
202
- record: any;
203
- }>
204
- ): void {
205
- if (records.length === 0) return;
229
+ ingestMany(records: IngestRecord[]): IngestRecord[] {
230
+ if (records.length === 0) return [];
206
231
 
207
232
  if (!this.processor) {
208
233
  this.logger.warn(
209
234
  { Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
210
235
  'Not initialized, skipping ingest'
211
236
  );
212
- return;
237
+ return [];
213
238
  }
214
239
 
215
- // One circuit step for the whole batch when the WASM build offers it. A
216
- // step walks every registered view, so the per-record path pays that fixed
217
- // cost N times: a cold sync landing a few thousand rows spent seconds of
218
- // main-thread time almost entirely on step overhead. Older builds have no
219
- // `ingest_many`, hence the loop below.
220
240
  const bulkIngest = this.processor.ingest_many;
221
- if (typeof bulkIngest === 'function') {
222
- this.logger.debug(
223
- { count: records.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
224
- 'Ingesting batch into ssp'
225
- );
241
+ if (typeof bulkIngest !== 'function') {
242
+ // Stale wasm build: per-record steps, still coalesced into one update.
243
+ this.beginCoalescing();
226
244
  try {
227
- const items = records.map((record) => ({
228
- table: record.table,
229
- op: record.op,
230
- id: record.id,
231
- record: this.normalizeValue(record.record),
232
- }));
233
- const t0 = performance.now();
234
- const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
235
- const materializationTimeMs = performance.now() - t0;
236
- this.logger.debug(
237
- {
238
- count: records.length,
239
- rawUpdates: rawUpdates.length,
240
- materializationTimeMs,
241
- Category: 'sp00ky-client::StreamProcessorService::ingestMany',
242
- },
243
- 'Ingesting batch into ssp done'
244
- );
245
- if (rawUpdates.length > 0) {
246
- // `op: 'CREATE'` for the same reason the coalesced flush uses it: the
247
- // batch's update takes DataModule's immediate (non-debounced) path.
248
- this.dispatchUpdates(
249
- rawUpdates.map((u: WasmStreamUpdate) => ({
250
- queryHash: u.query_id,
251
- localArray: u.result_data,
252
- op: 'CREATE' as const,
253
- materializationTimeMs,
254
- storeApplyMs: u.timing_store_apply_ms,
255
- circuitStepMs: u.timing_circuit_step_ms,
256
- transformMs: u.timing_transform_ms,
257
- }))
258
- );
245
+ for (const record of records) {
246
+ this.ingest(record.table, record.op, record.id, record.record);
259
247
  }
260
- } catch (e) {
261
- // Same contract as the single-record path: report and move on rather
262
- // than re-running the batch, which would double-apply whatever the
263
- // failed step already committed to the store.
264
- this.logger.error(
265
- { error: e, count: records.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
266
- 'Ingesting batch into ssp failed'
267
- );
248
+ } finally {
249
+ this.flushCoalescing();
268
250
  }
269
- this.markSnapshotDirty();
270
- return;
251
+ return records;
271
252
  }
272
253
 
254
+ const ingested: IngestRecord[] = [];
273
255
  this.beginCoalescing();
274
256
  try {
275
- for (const record of records) {
276
- this.ingest(record.table, record.op, record.id, record.record);
257
+ for (const chunk of chunks(records, INGEST_CHUNK)) {
258
+ try {
259
+ const items = chunk.map((record) => ({
260
+ table: record.table,
261
+ op: record.op,
262
+ id: record.id,
263
+ record: this.normalizeValue(record.record),
264
+ }));
265
+ const t0 = performance.now();
266
+ const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
267
+ const materializationTimeMs = performance.now() - t0;
268
+ if (rawUpdates.length > 0) {
269
+ this.notifyUpdates(
270
+ rawUpdates.map((u: WasmStreamUpdate) => ({
271
+ queryHash: u.query_id,
272
+ localArray: u.result_data,
273
+ op: 'CREATE' as const,
274
+ materializationTimeMs,
275
+ storeApplyMs: u.timing_store_apply_ms,
276
+ circuitStepMs: u.timing_circuit_step_ms,
277
+ transformMs: u.timing_transform_ms,
278
+ }))
279
+ );
280
+ }
281
+ ingested.push(...chunk);
282
+ } catch (e) {
283
+ this.logger.error(
284
+ { error: e, count: chunk.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
285
+ 'Ingesting chunk into ssp failed'
286
+ );
287
+ }
277
288
  }
278
289
  } finally {
279
290
  this.flushCoalescing();
280
291
  }
292
+ this.logger.debug(
293
+ { count: records.length, ingested: ingested.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
294
+ 'Ingested batch into ssp'
295
+ );
296
+ this.markSnapshotDirty(ingested.length);
297
+ return ingested;
281
298
  }
282
299
 
283
300
  /**
@@ -308,7 +325,6 @@ export class StreamProcessorService {
308
325
  if (buffered.length > 0) {
309
326
  this.dispatchUpdates(buffered);
310
327
  }
311
- this.markSnapshotDirty();
312
328
  }
313
329
 
314
330
  /**
@@ -326,9 +342,7 @@ export class StreamProcessorService {
326
342
  await init(); // Initialize the WASM module (web target)
327
343
  // We cast the generated Sp00kyProcessor to our interface which is safer
328
344
  this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
329
-
330
- // Try to load state
331
- await this.loadState();
345
+ this.applyProjection();
332
346
 
333
347
  this.isInitialized = true;
334
348
  this.logger.info(
@@ -344,25 +358,16 @@ export class StreamProcessorService {
344
358
  }
345
359
  }
346
360
 
347
- /** Route the persisted circuit snapshot to a per-bucket key. */
348
- setStateKeySuffix(bucketId: string) {
349
- this.stateKeySuffix = bucketId;
350
- }
351
-
352
- private stateKey(): string {
353
- return this.stateKeySuffix
354
- ? `_00_stream_processor_state:${this.stateKeySuffix}`
355
- : '_00_stream_processor_state';
356
- }
357
-
358
361
  /**
359
362
  * Drop the current WASM processor and start a fresh, empty circuit. Used on
360
363
  * local-bucket switches: the old circuit holds the previous user's rows AND
361
364
  * views registered with the previous `$auth` context, so neither may survive.
362
- * Deliberately does NOT `loadState()` — a persisted snapshot references views
363
- * under a dead sessionId salt; the DataModule rebind re-registers every live
364
- * view against this fresh processor. Caller must re-seed `setPermissions`
365
- * afterwards (a fresh circuit default-denies every table).
365
+ * Deliberately loads nothing: the snapshot in the store being swapped away
366
+ * from belongs to the previous bucket; the caller primes the new bucket's
367
+ * circuit (`primeFromLocal`) once its store is open, and the DataModule
368
+ * rebind re-registers every live view against this fresh processor. Caller
369
+ * must re-seed `setPermissions` afterwards (a fresh circuit default-denies
370
+ * every table).
366
371
  */
367
372
  async reset(): Promise<void> {
368
373
  if (!this.isInitialized) return;
@@ -370,8 +375,11 @@ export class StreamProcessorService {
370
375
  this.batching = false;
371
376
  this.batchBuffer.clear();
372
377
  this.snapshotDirty = false;
378
+ this.dirtyRows = 0;
379
+ this.widenPending.clear();
373
380
  const previous = this.processor;
374
381
  this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
382
+ this.applyProjection();
375
383
  this.freeProcessor(previous);
376
384
  this.logger.info(
377
385
  { Category: 'sp00ky-client::StreamProcessorService::reset' },
@@ -418,9 +426,9 @@ export class StreamProcessorService {
418
426
  }
419
427
 
420
428
  /**
421
- * Opt into snapshot persistence (`persistCircuit`). Off by default: see the
422
- * `persistCircuit` field comment for why per-ingest snapshots were removed.
423
- * Must be called before `init()` for a snapshot to be restored at boot.
429
+ * Snapshot persistence (`persistCircuit`). When on, the circuit's store is
430
+ * written to the local store on a checkpoint interval and when the page
431
+ * goes hidden, and restored by {@link primeFromLocal} on the next boot.
424
432
  */
425
433
  configureCircuitPersistence(enabled: boolean, checkpointMs?: number): void {
426
434
  this.persistCircuit = enabled;
@@ -429,87 +437,349 @@ export class StreamProcessorService {
429
437
  }
430
438
 
431
439
  /**
432
- * Record that the circuit changed. Cheap and O(1), the expensive snapshot is
433
- * deferred to the checkpoint timer, and skipped entirely when
434
- * `persistCircuit` is off (the default).
440
+ * Field projection (`circuitProjection`, default on). Takes effect on the
441
+ * next processor (`init`/`reset`) and on rows written after that.
442
+ */
443
+ configureProjection(enabled: boolean): void {
444
+ this.projection = enabled;
445
+ this.applyProjection();
446
+ }
447
+
448
+ private applyProjection(): void {
449
+ if (!this.processor || typeof this.processor.set_projection !== 'function') return;
450
+ try {
451
+ this.processor.set_projection(this.projection);
452
+ } catch (e) {
453
+ this.logger.warn(
454
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::applyProjection' },
455
+ 'set_projection failed'
456
+ );
457
+ }
458
+ }
459
+
460
+ /** Resolves once the boot-time prime has finished (or was skipped). */
461
+ whenPrimed(): Promise<void> {
462
+ return this.primed;
463
+ }
464
+
465
+ /**
466
+ * Fill the circuit from the LOCAL store, in the background.
467
+ *
468
+ * With a usable snapshot: install it under whatever views have registered
469
+ * meanwhile (`load_store_state` re-primes them), then `reconcile` each table
470
+ * against the store's `(id, rv)` list so rows deleted since the checkpoint
471
+ * are stepped out and only rows added or changed since are read back and
472
+ * ingested. Without one: read every row and ingest it, chunked.
473
+ *
474
+ * Either way the circuit ends up equal to the local store without touching
475
+ * the network, so the first sync diff is a real delta rather than "fetch
476
+ * everything". The returned promise never rejects; `whenPrimed` gates on it.
477
+ */
478
+ primeFromLocal(ctx: CircuitPrimeContext): Promise<void> {
479
+ this.schemaHash = ctx.schemaHash;
480
+ const run = this.runPrime(ctx).catch((e) => {
481
+ this.logger.warn(
482
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
483
+ 'Circuit prime failed; the circuit fills from sync instead'
484
+ );
485
+ });
486
+ this.primed = run;
487
+ return run;
488
+ }
489
+
490
+ private async runPrime(ctx: CircuitPrimeContext): Promise<void> {
491
+ const processor = this.processor;
492
+ if (!processor || typeof this.db.scanVersions !== 'function') return;
493
+ const generation = this.stateGeneration;
494
+ const epoch = this.db.epoch;
495
+ // A bucket switch replaces the processor and the store under us; every
496
+ // step re-checks so a stale chunk never lands in the new bucket.
497
+ const alive = () =>
498
+ this.processor === processor && generation === this.stateGeneration && epoch === this.db.epoch;
499
+ const t0 = performance.now();
500
+
501
+ let restored = false;
502
+ if (
503
+ this.persistCircuit &&
504
+ typeof this.db.getSnapshot === 'function' &&
505
+ typeof processor.load_store_state === 'function'
506
+ ) {
507
+ const snapshot = await this.db.getSnapshot(CIRCUIT_SNAPSHOT_KEY);
508
+ if (!alive()) return;
509
+ if (snapshot) {
510
+ const { meta } = snapshot;
511
+ if (meta.formatVersion !== CIRCUIT_SNAPSHOT_FORMAT || meta.schemaHash !== ctx.schemaHash) {
512
+ this.logger.info(
513
+ { meta, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
514
+ 'Circuit snapshot is from another format or schema; priming from rows'
515
+ );
516
+ } else {
517
+ try {
518
+ const tLoad = performance.now();
519
+ const updates = processor.load_store_state(snapshot.bytes);
520
+ this.dispatchWasmUpdates(updates);
521
+ restored = true;
522
+ this.logger.info(
523
+ {
524
+ bytes: snapshot.bytes.byteLength,
525
+ views: updates.length,
526
+ loadMs: Math.round(performance.now() - tLoad),
527
+ Category: 'sp00ky-client::StreamProcessorService::primeFromLocal',
528
+ },
529
+ 'Circuit snapshot restored'
530
+ );
531
+ } catch (e) {
532
+ this.logger.warn(
533
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
534
+ 'Circuit snapshot unreadable; priming from rows'
535
+ );
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ // Read the versions AFTER the snapshot: a batch the leader writes between
542
+ // the two can only make the store side newer, which reconcile handles.
543
+ const versions = await this.db.scanVersions(ctx.tables);
544
+ if (!alive()) return;
545
+ const total = Object.values(versions).reduce((n, v) => n + v.length, 0);
546
+ if (!restored && this.db.engineKind !== 'sqlite' && total > SURREAL_PRIME_ROW_CAP) {
547
+ this.logger.warn(
548
+ { total, cap: SURREAL_PRIME_ROW_CAP, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
549
+ 'Too many cached rows to prime through the main-thread engine; circuit fills from sync'
550
+ );
551
+ return;
552
+ }
553
+
554
+ let ingested = 0;
555
+ let fetched = 0;
556
+ let deleted = 0;
557
+ for (const table of ctx.tables) {
558
+ const entries = versions[table] ?? [];
559
+ let toFetch: string[];
560
+ if (restored && typeof processor.reconcile === 'function') {
561
+ const result = processor.reconcile(table, entries);
562
+ this.dispatchWasmUpdates(result.updates);
563
+ deleted += result.deleted;
564
+ toFetch = result.fetch;
565
+ } else {
566
+ toFetch = entries.map(([id]) => id);
567
+ }
568
+ for (const chunk of chunks(toFetch, PRIME_CHUNK)) {
569
+ const rows = await this.db.selectByIds(table, chunk);
570
+ if (!alive()) return;
571
+ fetched += rows.length;
572
+ ingested += this.ingestMany(
573
+ rows.map((row) => ({
574
+ table,
575
+ op: 'CREATE' as const,
576
+ id: idString(row.id),
577
+ record: row,
578
+ }))
579
+ ).length;
580
+ }
581
+ if (entries.length > 0) {
582
+ ctx.onVersions?.(
583
+ table,
584
+ entries.filter(([id]) => !ctx.pendingIds.has(id))
585
+ );
586
+ }
587
+ }
588
+ // A prime from rows leaves the store with no snapshot to fall back on;
589
+ // let the next checkpoint write one even if nothing else changes.
590
+ if (!restored && ingested > 0) this.markSnapshotDirty(Math.max(ingested, CHECKPOINT_MIN_ROWS));
591
+ this.logger.info(
592
+ {
593
+ restored,
594
+ rows: total,
595
+ fetched,
596
+ ingested,
597
+ deleted,
598
+ ms: Math.round(performance.now() - t0),
599
+ Category: 'sp00ky-client::StreamProcessorService::primeFromLocal',
600
+ },
601
+ 'Circuit primed from the local store'
602
+ );
603
+ }
604
+
605
+ /** Publish wasm updates produced outside an ingest (restore, reconcile). */
606
+ private dispatchWasmUpdates(updates: WasmStreamUpdate[] | undefined): void {
607
+ if (!updates || updates.length === 0) return;
608
+ this.dispatchUpdates(
609
+ updates.map((u) => ({
610
+ queryHash: u.query_id,
611
+ localArray: u.result_data,
612
+ op: 'CREATE' as const,
613
+ }))
614
+ );
615
+ }
616
+
617
+ /**
618
+ * Record that the circuit changed by `rows` rows. Cheap; the snapshot is
619
+ * deferred to the checkpoint timer and skipped entirely when `persistCircuit`
620
+ * is off.
435
621
  */
436
- private markSnapshotDirty(): void {
622
+ private markSnapshotDirty(rows = 1): void {
437
623
  if (!this.persistCircuit || !this.persistState) return;
438
624
  this.snapshotDirty = true;
625
+ this.dirtyRows += rows;
439
626
  this.startCheckpoints();
440
627
  }
441
628
 
442
629
  private startCheckpoints(): void {
443
630
  if (this.checkpointTimer) return;
444
631
  this.checkpointTimer = setInterval(() => {
445
- if (!this.snapshotDirty) return;
446
- this.snapshotDirty = false;
447
- void this.saveState();
632
+ if (!this.snapshotDirty || this.dirtyRows < CHECKPOINT_MIN_ROWS) return;
633
+ void this.checkpoint('interval');
448
634
  }, this.checkpointMs);
449
635
  // Node/test environments have no `window`; the interval alone is enough there.
450
- if (typeof window !== 'undefined' && !this.pagehideHandler) {
451
- this.pagehideHandler = () => {
636
+ if (typeof window !== 'undefined' && !this.hideHandler) {
637
+ // `visibilitychange: hidden` is the reliable last chance: a 100 MB write
638
+ // cannot finish inside `pagehide`, which is kept only as best effort.
639
+ this.hideHandler = () => {
452
640
  if (!this.snapshotDirty) return;
453
- this.snapshotDirty = false;
454
- void this.saveState();
641
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible') return;
642
+ void this.checkpoint('hidden');
455
643
  };
456
- window.addEventListener('pagehide', this.pagehideHandler);
644
+ window.addEventListener('visibilitychange', this.hideHandler);
645
+ window.addEventListener('pagehide', this.hideHandler);
457
646
  }
458
647
  }
459
648
 
460
- /** Stop checkpointing and drop the `pagehide` listener. */
649
+ /** Stop checkpointing and drop the visibility listeners. */
461
650
  stopCheckpoints(): void {
462
651
  if (this.checkpointTimer) {
463
652
  clearInterval(this.checkpointTimer);
464
653
  this.checkpointTimer = null;
465
654
  }
466
- if (this.pagehideHandler && typeof window !== 'undefined') {
467
- window.removeEventListener('pagehide', this.pagehideHandler);
655
+ if (this.hideHandler && typeof window !== 'undefined') {
656
+ window.removeEventListener('visibilitychange', this.hideHandler);
657
+ window.removeEventListener('pagehide', this.hideHandler);
468
658
  }
469
- this.pagehideHandler = null;
659
+ this.hideHandler = null;
470
660
  this.snapshotDirty = false;
661
+ this.dirtyRows = 0;
662
+ }
663
+
664
+ /**
665
+ * Write the circuit's store to the local store as a snapshot. Compacts the
666
+ * row arena first when dead bytes outweigh live ones. Serialised: a second
667
+ * call while one is in flight joins it. No-op unless persistence is on, this
668
+ * tab owns the store, and the engine can hold a snapshot.
669
+ */
670
+ checkpoint(reason = 'manual'): Promise<void> {
671
+ if (this.checkpointInFlight) return this.checkpointInFlight;
672
+ const run = this.runCheckpoint(reason).finally(() => {
673
+ this.checkpointInFlight = null;
674
+ });
675
+ this.checkpointInFlight = run;
676
+ return run;
471
677
  }
472
678
 
473
- async loadState() {
474
- if (!this.processor || !this.persistState || !this.persistCircuit) return;
679
+ private async runCheckpoint(reason: string): Promise<void> {
680
+ const processor = this.processor;
681
+ if (!processor || !this.persistState || !this.persistCircuit || !this.schemaHash) return;
682
+ if (typeof this.db.putSnapshot !== 'function' || typeof processor.save_store_state !== 'function') {
683
+ return;
684
+ }
685
+ const generation = this.stateGeneration;
686
+ const epoch = this.db.epoch;
687
+ const rows = this.dirtyRows;
688
+ this.snapshotDirty = false;
689
+ this.dirtyRows = 0;
475
690
  try {
476
- const result = await this.persistenceClient.get(this.stateKey());
477
-
478
- // `save_state` returns a JSON string and every PersistenceClient round-trips
479
- // it as one: localStorage via JSON.parse(getItem(...)), surrealdb via
480
- // `_00_kv:<key>.val`. This used to test for a raw SurrealDB result shape
481
- // (`result[0][0].state`), which no shipped client can ever produce, so the
482
- // browser wrote a snapshot on every ingest and never restored one. The legacy
483
- // shape is still accepted so an old persisted row still loads.
484
- const state = typeof result === 'string' ? result : extractLegacyState(result);
485
- if (state) {
486
- this.logger.info(
487
- {
488
- stateLength: state.length,
489
- Category: 'sp00ky-client::StreamProcessorService::loadState',
490
- },
491
- 'Loading state from DB'
492
- );
493
- // Assuming processor has a load_state method matching the save_state behavior
494
- // If not, we might need to adjust based on the actual WASM API
495
- if (typeof this.processor.load_state === 'function') {
496
- this.processor.load_state(state);
497
- } else {
498
- this.logger.warn(
499
- { Category: 'sp00ky-client::StreamProcessorService::loadState' },
500
- 'load_state method not found on processor'
501
- );
502
- }
503
- } else {
504
- this.logger.info(
505
- { Category: 'sp00ky-client::StreamProcessorService::loadState' },
506
- 'No saved state found'
507
- );
691
+ const t0 = performance.now();
692
+ let reclaimed = 0;
693
+ if (
694
+ typeof processor.compact === 'function' &&
695
+ typeof processor.dead_bytes === 'function' &&
696
+ typeof processor.live_bytes === 'function'
697
+ ) {
698
+ const dead = processor.dead_bytes();
699
+ if (dead > 1_000_000 && dead > processor.live_bytes()) reclaimed = processor.compact();
508
700
  }
701
+ const bytes = processor.save_store_state();
702
+ const meta = {
703
+ formatVersion: CIRCUIT_SNAPSHOT_FORMAT,
704
+ schemaHash: this.schemaHash,
705
+ savedAt: Date.now(),
706
+ maxRv: typeof processor.max_row_versions === 'function' ? processor.max_row_versions() : undefined,
707
+ };
708
+ // A reset or bucket switch landed while we serialised: this snapshot
709
+ // describes the OLD bucket's circuit and must not be written into the
710
+ // new bucket's store.
711
+ if (generation !== this.stateGeneration || epoch !== this.db.epoch) return;
712
+ await this.db.putSnapshot(CIRCUIT_SNAPSHOT_KEY, bytes, meta);
713
+ this.logger.info(
714
+ {
715
+ reason,
716
+ rows,
717
+ bytes: bytes.byteLength,
718
+ reclaimed,
719
+ ms: Math.round(performance.now() - t0),
720
+ Category: 'sp00ky-client::StreamProcessorService::checkpoint',
721
+ },
722
+ 'Circuit snapshot written'
723
+ );
509
724
  } catch (e) {
510
- this.logger.error(
511
- { error: e, Category: 'sp00ky-client::StreamProcessorService::loadState' },
512
- 'Failed to load state'
725
+ this.logger.warn(
726
+ { error: e, reason, Category: 'sp00ky-client::StreamProcessorService::checkpoint' },
727
+ 'Circuit snapshot failed'
728
+ );
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Projection widening: a newly registered view evaluates fields that rows
734
+ * already in the circuit were stored without. Merge just those fields in,
735
+ * table by table, from the local store. The view registered against what
736
+ * was present and converges as the merges step through.
737
+ */
738
+ private scheduleWiden(missing: Record<string, string[]>): void {
739
+ for (const [table, fields] of Object.entries(missing)) {
740
+ const set = this.widenPending.get(table) ?? new Set<string>();
741
+ for (const f of fields) set.add(f);
742
+ this.widenPending.set(table, set);
743
+ }
744
+ this.widenQueue = this.widenQueue.then(() => this.runWiden()).catch((e) => {
745
+ this.logger.warn(
746
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::widen' },
747
+ 'Projection widening failed'
748
+ );
749
+ });
750
+ }
751
+
752
+ private async runWiden(): Promise<void> {
753
+ const processor = this.processor;
754
+ if (!processor || typeof this.db.scanVersions !== 'function') {
755
+ this.widenPending.clear();
756
+ return;
757
+ }
758
+ const generation = this.stateGeneration;
759
+ const epoch = this.db.epoch;
760
+ while (this.widenPending.size > 0) {
761
+ const [table, fields] = this.widenPending.entries().next().value as [string, Set<string>];
762
+ this.widenPending.delete(table);
763
+ const select = Array.from(fields);
764
+ const versions = await this.db.scanVersions([table]);
765
+ if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
766
+ const ids = (versions[table] ?? []).map(([id]) => id);
767
+ let merged = 0;
768
+ for (const chunk of chunks(ids, PRIME_CHUNK)) {
769
+ const rows = await this.db.selectByIds(table, chunk, { select });
770
+ if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
771
+ merged += this.ingestMany(
772
+ rows.map((row) => ({
773
+ table,
774
+ op: 'MERGE' as const,
775
+ id: idString(row.id),
776
+ record: row,
777
+ }))
778
+ ).length;
779
+ }
780
+ this.logger.info(
781
+ { table, fields: select, merged, Category: 'sp00ky-client::StreamProcessorService::widen' },
782
+ 'Widened projected rows with newly evaluated fields'
513
783
  );
514
784
  }
515
785
  }
@@ -560,32 +830,6 @@ export class StreamProcessorService {
560
830
  );
561
831
  }
562
832
 
563
- async saveState() {
564
- if (!this.processor || !this.persistState || !this.persistCircuit) return;
565
- const generation = this.stateGeneration;
566
- try {
567
- // Assuming processor has a save_state method that returns the state string/bytes
568
- if (typeof this.processor.save_state === 'function') {
569
- const state = this.processor.save_state();
570
- // A reset raced this snapshot — persisting it would write the previous
571
- // user's circuit into the new bucket's key. Drop it.
572
- if (generation !== this.stateGeneration) return;
573
- if (state) {
574
- await this.persistenceClient.set(this.stateKey(), state);
575
- this.logger.trace(
576
- { Category: 'sp00ky-client::StreamProcessorService::saveState' },
577
- 'State saved'
578
- );
579
- }
580
- }
581
- } catch (e) {
582
- this.logger.error(
583
- { error: e, Category: 'sp00ky-client::StreamProcessorService::saveState' },
584
- 'Failed to save state'
585
- );
586
- }
587
- }
588
-
589
833
  /**
590
834
  * Ingest a record change into the processor.
591
835
  * Emits 'stream_update' event if materialized views are affected.
@@ -593,7 +837,7 @@ export class StreamProcessorService {
593
837
  */
594
838
  ingest(
595
839
  table: string,
596
- op: 'CREATE' | 'UPDATE' | 'DELETE',
840
+ op: IngestRecord['op'],
597
841
  id: string,
598
842
  record: any
599
843
  ): WasmStreamUpdate[] {
@@ -637,7 +881,8 @@ export class StreamProcessorService {
637
881
  const updates: StreamUpdate[] = rawUpdates.map((u: WasmStreamUpdate) => ({
638
882
  queryHash: u.query_id,
639
883
  localArray: u.result_data,
640
- op: op,
884
+ // A MERGE is a content update as far as consumers are concerned.
885
+ op: op === 'MERGE' ? 'UPDATE' : op,
641
886
  materializationTimeMs,
642
887
  storeApplyMs: u.timing_store_apply_ms,
643
888
  circuitStepMs: u.timing_circuit_step_ms,
@@ -726,7 +971,9 @@ export class StreamProcessorService {
726
971
  snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
727
972
  },
728
973
  };
729
- this.markSnapshotDirty();
974
+ if (initialUpdate.missing_fields && Object.keys(initialUpdate.missing_fields).length > 0) {
975
+ this.scheduleWiden(initialUpdate.missing_fields);
976
+ }
730
977
  this.logger.debug(
731
978
  {
732
979
  queryHash: queryPlan.queryHash,
@@ -753,7 +1000,6 @@ export class StreamProcessorService {
753
1000
  if (!this.processor) return;
754
1001
  try {
755
1002
  this.processor.unregister_view(queryHash);
756
- this.markSnapshotDirty();
757
1003
  } catch (e) {
758
1004
  this.logger.error(
759
1005
  { error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },