@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201

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.
Files changed (148) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2184 -54
  3. package/dist/index.js +11515 -2399
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +421 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +688 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +35 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/events/index.ts +2 -1
  25. package/src/modules/auth/index.ts +59 -20
  26. package/src/modules/cache/index.ts +112 -32
  27. package/src/modules/cache/types.ts +2 -2
  28. package/src/modules/crdt/crdt-field.ts +294 -0
  29. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  30. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  31. package/src/modules/crdt/index.ts +463 -0
  32. package/src/modules/crdt/loro-loader.ts +25 -0
  33. package/src/modules/data/data.hydration.test.ts +142 -0
  34. package/src/modules/data/data.membership.test.ts +462 -0
  35. package/src/modules/data/data.rebind.test.ts +147 -0
  36. package/src/modules/data/data.run.test.ts +113 -0
  37. package/src/modules/data/data.settled-writes.test.ts +206 -0
  38. package/src/modules/data/data.status.test.ts +249 -0
  39. package/src/modules/data/id-set-plan.test.ts +122 -0
  40. package/src/modules/data/index.ts +1580 -130
  41. package/src/modules/data/mutation-id.test.ts +25 -0
  42. package/src/modules/data/mutation-id.ts +35 -0
  43. package/src/modules/data/window-query.test.ts +52 -0
  44. package/src/modules/data/window-query.ts +194 -0
  45. package/src/modules/devtools/flags.ts +349 -0
  46. package/src/modules/devtools/index.ts +386 -37
  47. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  48. package/src/modules/devtools/storage-info.test.ts +79 -0
  49. package/src/modules/devtools/storage-info.ts +168 -0
  50. package/src/modules/devtools/versions.test.ts +74 -0
  51. package/src/modules/devtools/versions.ts +110 -0
  52. package/src/modules/feature-flag/index.test.ts +251 -0
  53. package/src/modules/feature-flag/index.ts +308 -0
  54. package/src/modules/ref-tables.test.ts +91 -0
  55. package/src/modules/ref-tables.ts +88 -0
  56. package/src/modules/sync/engine.ts +101 -37
  57. package/src/modules/sync/events/index.ts +9 -2
  58. package/src/modules/sync/queue/queue-down.test.ts +107 -0
  59. package/src/modules/sync/queue/queue-down.ts +35 -6
  60. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  61. package/src/modules/sync/queue/queue-up.ts +241 -57
  62. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  63. package/src/modules/sync/scheduler.retry.test.ts +156 -0
  64. package/src/modules/sync/scheduler.ts +158 -11
  65. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  66. package/src/modules/sync/sync.health.test.ts +149 -0
  67. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  68. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  69. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  70. package/src/modules/sync/sync.subquery.test.ts +82 -0
  71. package/src/modules/sync/sync.ts +1558 -99
  72. package/src/modules/sync/utils.test.ts +269 -2
  73. package/src/modules/sync/utils.ts +201 -17
  74. package/src/otel/index.ts +13 -10
  75. package/src/services/blobs/blob-cache.test.ts +359 -0
  76. package/src/services/blobs/blob-cache.ts +603 -0
  77. package/src/services/blobs/blob-manifest.ts +227 -0
  78. package/src/services/blobs/blob-store.test.ts +77 -0
  79. package/src/services/blobs/blob-store.ts +359 -0
  80. package/src/services/blobs/blob.fixture.ts +90 -0
  81. package/src/services/blobs/index.ts +70 -0
  82. package/src/services/database/cache-engine.ts +160 -0
  83. package/src/services/database/connection-supervisor.test.ts +289 -0
  84. package/src/services/database/connection-supervisor.ts +415 -0
  85. package/src/services/database/database.query-timeout.test.ts +83 -0
  86. package/src/services/database/database.ts +32 -12
  87. package/src/services/database/engine-factory.ts +33 -0
  88. package/src/services/database/events/index.ts +2 -1
  89. package/src/services/database/index.ts +7 -0
  90. package/src/services/database/local-migrator.ts +30 -27
  91. package/src/services/database/local.test.ts +64 -0
  92. package/src/services/database/local.ts +478 -67
  93. package/src/services/database/plan-render.test.ts +159 -0
  94. package/src/services/database/plan-render.ts +108 -0
  95. package/src/services/database/relation-resolver.test.ts +413 -0
  96. package/src/services/database/relation-resolver.ts +0 -0
  97. package/src/services/database/remote.ts +110 -14
  98. package/src/services/database/sqlite-cache-engine.test.ts +558 -0
  99. package/src/services/database/sqlite-cache-engine.ts +1257 -0
  100. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  101. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  102. package/src/services/database/sqlite-open.test.ts +150 -0
  103. package/src/services/database/sqlite-open.ts +164 -0
  104. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  105. package/src/services/database/sqlite-plan-sql.ts +106 -0
  106. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  107. package/src/services/database/sqlite-select.test.ts +246 -0
  108. package/src/services/database/sqlite-select.ts +121 -0
  109. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  110. package/src/services/database/sqlite-transport.ts +221 -0
  111. package/src/services/database/sqlite-worker.ts +437 -0
  112. package/src/services/database/surql-translate.ts +416 -0
  113. package/src/services/database/surreal-cache-engine.ts +141 -0
  114. package/src/services/logger/index.ts +3 -2
  115. package/src/services/persistence/localstorage.ts +2 -2
  116. package/src/services/persistence/resilient.ts +11 -4
  117. package/src/services/persistence/surrealdb.ts +10 -10
  118. package/src/services/stream-processor/index.ts +444 -52
  119. package/src/services/stream-processor/permissions.test.ts +47 -0
  120. package/src/services/stream-processor/permissions.ts +53 -0
  121. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  122. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  123. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  124. package/src/services/stream-processor/wasm-types.ts +23 -2
  125. package/src/services/tabs/broker-client.ts +283 -0
  126. package/src/services/tabs/broker.test.ts +278 -0
  127. package/src/services/tabs/coordinator.test.ts +244 -0
  128. package/src/services/tabs/coordinator.ts +576 -0
  129. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  130. package/src/services/tabs/leader-locks.ts +75 -0
  131. package/src/services/tabs/protocol.ts +242 -0
  132. package/src/services/tabs/support.ts +36 -0
  133. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  134. package/src/sp00ky.auth-order.test.ts +92 -0
  135. package/src/sp00ky.init-query.test.ts +183 -0
  136. package/src/sp00ky.ts +1543 -0
  137. package/src/types.ts +496 -13
  138. package/src/utils/blurhash.ts +90 -0
  139. package/src/utils/error-classification.test.ts +44 -0
  140. package/src/utils/error-classification.ts +7 -0
  141. package/src/utils/index.ts +73 -13
  142. package/src/utils/parser.ts +3 -2
  143. package/src/utils/semver.test.ts +32 -0
  144. package/src/utils/semver.ts +30 -0
  145. package/src/utils/surql.ts +30 -18
  146. package/src/utils/withRetry.test.ts +1 -1
  147. package/tsdown.config.ts +86 -1
  148. package/src/spooky.ts +0 -395
@@ -1,10 +1,11 @@
1
- import init, { SpookyProcessor } from '@spooky-sync/ssp-wasm';
2
- import { EventDefinition, EventSystem } from '../../events/index';
3
- import { Logger } from 'pino';
4
- import { LocalDatabaseService } from '../database/index';
5
- import { WasmProcessor, WasmStreamUpdate } from './wasm-types';
6
- import { Duration } from 'surrealdb';
7
- import { PersistenceClient, QueryTimeToLive, RecordVersionArray } from '../../types';
1
+ // oxlint-disable-next-line no-named-as-default -- WASM module default export convention
2
+ import init, { Sp00kyProcessor } from '@spooky-sync/ssp-wasm';
3
+ import type { EventDefinition, EventSystem } from '../../events/index';
4
+ import type { Logger } from 'pino';
5
+ import type { LocalStore } from '../database/index';
6
+ import type { WasmProcessor, WasmStreamUpdate } from './wasm-types';
7
+ import type { Duration } from 'surrealdb';
8
+ import type { PersistenceClient, QueryTimeToLive, RecordVersionArray } from '../../types';
8
9
 
9
10
  // Simple interface for query plan registration (replaces Incantation class)
10
11
  interface QueryPlanConfig {
@@ -27,6 +28,21 @@ export interface StreamUpdate {
27
28
  queryHash: string;
28
29
  localArray: RecordVersionArray;
29
30
  op?: 'CREATE' | 'UPDATE' | 'DELETE'; // Operation type for conditional debouncing
31
+ /**
32
+ * End-to-end ingest latency for the WASM call that produced this update,
33
+ * in milliseconds. Populated by StreamProcessorService.ingest. Undefined
34
+ * for the initial register_view snapshot.
35
+ */
36
+ materializationTimeMs?: number;
37
+ /** SSP internal sub-phase timings (ms) for this ingest, from the WASM binding. */
38
+ storeApplyMs?: number;
39
+ circuitStepMs?: number;
40
+ transformMs?: number;
41
+ /**
42
+ * One-shot registration timings (ms). Only set on the StreamUpdate returned
43
+ * by `registerQueryPlan` (the register_view snapshot), not on ingest updates.
44
+ */
45
+ registration?: { parseMs: number; planMs: number; snapshotMs: number };
30
46
  }
31
47
 
32
48
  // Define events map (kept for DevTools compatibility)
@@ -41,15 +57,83 @@ export interface StreamUpdateReceiver {
41
57
  onStreamUpdate(update: StreamUpdate): void;
42
58
  }
43
59
 
60
+ /**
61
+ * Read a circuit snapshot out of the pre-`persistCircuit` persisted shape
62
+ * (`[[{ state }]]`, a raw SurrealDB result). Returns null for anything else.
63
+ */
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;
73
+ }
74
+
44
75
  export class StreamProcessorService {
45
76
  private logger: Logger;
46
77
  private processor: WasmProcessor | undefined;
47
78
  private isInitialized = false;
48
79
  private receivers: StreamUpdateReceiver[] = [];
80
+ // When true, `notifyUpdates` coalesces updates into `batchBuffer` (keyed by
81
+ // queryHash) instead of dispatching them. Used to collapse the per-record
82
+ // stream updates produced by a batched ingest into a single notification per
83
+ // query, so the UI updates once after the whole batch rather than row-by-row.
84
+ private batching = false;
85
+ private batchBuffer: Map<string, StreamUpdate> = new Map();
86
+ // Current session's auth identity, injected into every `register_view`'s
87
+ // params so the in-browser SSP can resolve `$auth`/`$access` in table
88
+ // permission predicates (mirrors the server's `fn::query::register`). Empty
89
+ // strings when logged out — a non-null `auth` keeps `permission_inject` from
90
+ // rejecting `$auth`-gated tables; the predicate just degrades to its public
91
+ // branch. Set via `setSessionAuth` on every auth state change.
92
+ 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.
102
+ 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.
106
+ 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.
128
+ private persistCircuit = false;
129
+ private checkpointMs = 30_000;
130
+ private checkpointTimer: ReturnType<typeof setInterval> | null = null;
131
+ private snapshotDirty = false;
132
+ private pagehideHandler: (() => void) | null = null;
49
133
 
50
134
  constructor(
51
135
  public events: EventSystem<StreamProcessorEvents>,
52
- private db: LocalDatabaseService,
136
+ private db: LocalStore,
53
137
  private persistenceClient: PersistenceClient,
54
138
  logger: Logger
55
139
  ) {
@@ -65,6 +149,32 @@ export class StreamProcessorService {
65
149
  }
66
150
 
67
151
  private notifyUpdates(updates: StreamUpdate[]) {
152
+ if (this.batching) {
153
+ // Coalesce by queryHash instead of dispatching. The WASM `result_data`
154
+ // (localArray) is the full materialized array, so last-write-wins
155
+ // already reflects every prior ingest in the batch. We sum the
156
+ // materialization times so the single recorded sample reflects the
157
+ // batch's total work, and emit `op: 'CREATE'` on flush so the coalesced
158
+ // update takes DataModule's immediate (non-debounced) path.
159
+ for (const update of updates) {
160
+ const prev = this.batchBuffer.get(update.queryHash);
161
+ const sum = (a?: number, b?: number) => (a ?? 0) + (b ?? 0);
162
+ this.batchBuffer.set(update.queryHash, {
163
+ ...update,
164
+ op: 'CREATE',
165
+ materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
166
+ storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
167
+ circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
168
+ transformMs: sum(prev?.transformMs, update.transformMs),
169
+ });
170
+ }
171
+ return;
172
+ }
173
+
174
+ this.dispatchUpdates(updates);
175
+ }
176
+
177
+ private dispatchUpdates(updates: StreamUpdate[]) {
68
178
  for (const update of updates) {
69
179
  for (const receiver of this.receivers) {
70
180
  receiver.onStreamUpdate(update);
@@ -72,6 +182,69 @@ export class StreamProcessorService {
72
182
  }
73
183
  }
74
184
 
185
+ /**
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.
192
+ *
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.
196
+ */
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;
206
+
207
+ this.beginCoalescing();
208
+ try {
209
+ for (const record of records) {
210
+ this.ingest(record.table, record.op, record.id, record.record);
211
+ }
212
+ } finally {
213
+ this.flushCoalescing();
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Open a coalescing window. While open, the per-record stream updates
219
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
220
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
221
+ * `ingestMany` so the window always closes — otherwise the processor stays
222
+ * stuck buffering forever.
223
+ *
224
+ * No-op if a window is already open (nested batches aren't expected here).
225
+ */
226
+ private beginCoalescing() {
227
+ if (this.batching) return;
228
+ this.batching = true;
229
+ this.batchBuffer.clear();
230
+ }
231
+
232
+ /**
233
+ * Close the coalescing window and flush: dispatch one coalesced
234
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
235
+ * for the whole batch (instead of once per ingest).
236
+ */
237
+ private flushCoalescing() {
238
+ if (!this.batching) return;
239
+ this.batching = false;
240
+ const buffered = Array.from(this.batchBuffer.values());
241
+ this.batchBuffer.clear();
242
+ if (buffered.length > 0) {
243
+ this.dispatchUpdates(buffered);
244
+ }
245
+ this.markSnapshotDirty();
246
+ }
247
+
75
248
  /**
76
249
  * Initialize the WASM module and processor.
77
250
  * This must be called before using other methods.
@@ -80,93 +253,268 @@ export class StreamProcessorService {
80
253
  if (this.isInitialized) return;
81
254
 
82
255
  this.logger.info(
83
- { Category: 'spooky-client::StreamProcessorService::init' },
256
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
84
257
  'Initializing WASM...'
85
258
  );
86
259
  try {
87
260
  await init(); // Initialize the WASM module (web target)
88
- // We cast the generated SpookyProcessor to our interface which is safer
89
- this.processor = new SpookyProcessor() as unknown as WasmProcessor;
261
+ // We cast the generated Sp00kyProcessor to our interface which is safer
262
+ this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
90
263
 
91
264
  // Try to load state
92
265
  await this.loadState();
93
266
 
94
267
  this.isInitialized = true;
95
268
  this.logger.info(
96
- { Category: 'spooky-client::StreamProcessorService::init' },
269
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
97
270
  'Initialized successfully'
98
271
  );
99
272
  } catch (e) {
100
273
  this.logger.error(
101
- { error: e, Category: 'spooky-client::StreamProcessorService::init' },
274
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::init' },
102
275
  'Failed to initialize'
103
276
  );
104
277
  throw e;
105
278
  }
106
279
  }
107
280
 
108
- async loadState() {
109
- if (!this.processor) return;
281
+ /** Route the persisted circuit snapshot to a per-bucket key. */
282
+ setStateKeySuffix(bucketId: string) {
283
+ this.stateKeySuffix = bucketId;
284
+ }
285
+
286
+ private stateKey(): string {
287
+ return this.stateKeySuffix
288
+ ? `_00_stream_processor_state:${this.stateKeySuffix}`
289
+ : '_00_stream_processor_state';
290
+ }
291
+
292
+ /**
293
+ * Drop the current WASM processor and start a fresh, empty circuit. Used on
294
+ * local-bucket switches: the old circuit holds the previous user's rows AND
295
+ * views registered with the previous `$auth` context, so neither may survive.
296
+ * Deliberately does NOT `loadState()` — a persisted snapshot references views
297
+ * under a dead sessionId salt; the DataModule rebind re-registers every live
298
+ * view against this fresh processor. Caller must re-seed `setPermissions`
299
+ * afterwards (a fresh circuit default-denies every table).
300
+ */
301
+ async reset(): Promise<void> {
302
+ if (!this.isInitialized) return;
303
+ this.stateGeneration++;
304
+ this.batching = false;
305
+ this.batchBuffer.clear();
306
+ this.snapshotDirty = false;
307
+ const previous = this.processor;
308
+ this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
309
+ this.freeProcessor(previous);
310
+ this.logger.info(
311
+ { Category: 'sp00ky-client::StreamProcessorService::reset' },
312
+ 'Stream processor reset (fresh circuit)'
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Release the wasm circuit and stop checkpointing. Call when the client is
318
+ * torn down; a recreated client (provider remount, HMR) would otherwise stack
319
+ * one full circuit per instance.
320
+ */
321
+ dispose(): void {
322
+ this.stopCheckpoints();
323
+ const previous = this.processor;
324
+ this.processor = undefined;
325
+ this.isInitialized = false;
326
+ this.batching = false;
327
+ this.batchBuffer.clear();
328
+ this.receivers = [];
329
+ this.freeProcessor(previous);
330
+ }
331
+
332
+ /**
333
+ * Explicitly run the wasm-bindgen destructor. Guarded: stale wasm builds may
334
+ * not expose `free`, and a double free must not take the app down.
335
+ */
336
+ private freeProcessor(processor: WasmProcessor | undefined): void {
337
+ if (!processor || typeof processor.free !== 'function') return;
110
338
  try {
111
- const result = await this.persistenceClient.get('_spooky_stream_processor_state');
339
+ processor.free();
340
+ } catch (e) {
341
+ this.logger.debug(
342
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::freeProcessor' },
343
+ 'Failed to free previous wasm circuit'
344
+ );
345
+ }
346
+ }
112
347
 
113
- // Check if we have a valid result from the query
114
- if (
115
- Array.isArray(result) &&
116
- result.length > 0 &&
117
- Array.isArray(result[0]) &&
118
- result[0].length > 0 &&
119
- result[0][0]?.state
120
- ) {
121
- const state = result[0][0].state;
348
+ /** Toggle circuit-state persistence (shared-tabs follower/leader role). */
349
+ setPersistenceEnabled(enabled: boolean): void {
350
+ this.persistState = enabled;
351
+ if (!enabled) this.stopCheckpoints();
352
+ }
353
+
354
+ /**
355
+ * Opt into snapshot persistence (`persistCircuit`). Off by default: see the
356
+ * `persistCircuit` field comment for why per-ingest snapshots were removed.
357
+ * Must be called before `init()` for a snapshot to be restored at boot.
358
+ */
359
+ configureCircuitPersistence(enabled: boolean, checkpointMs?: number): void {
360
+ this.persistCircuit = enabled;
361
+ if (checkpointMs && checkpointMs > 0) this.checkpointMs = checkpointMs;
362
+ if (!enabled) this.stopCheckpoints();
363
+ }
364
+
365
+ /**
366
+ * Record that the circuit changed. Cheap and O(1), the expensive snapshot is
367
+ * deferred to the checkpoint timer, and skipped entirely when
368
+ * `persistCircuit` is off (the default).
369
+ */
370
+ private markSnapshotDirty(): void {
371
+ if (!this.persistCircuit || !this.persistState) return;
372
+ this.snapshotDirty = true;
373
+ this.startCheckpoints();
374
+ }
375
+
376
+ private startCheckpoints(): void {
377
+ if (this.checkpointTimer) return;
378
+ this.checkpointTimer = setInterval(() => {
379
+ if (!this.snapshotDirty) return;
380
+ this.snapshotDirty = false;
381
+ void this.saveState();
382
+ }, this.checkpointMs);
383
+ // Node/test environments have no `window`; the interval alone is enough there.
384
+ if (typeof window !== 'undefined' && !this.pagehideHandler) {
385
+ this.pagehideHandler = () => {
386
+ if (!this.snapshotDirty) return;
387
+ this.snapshotDirty = false;
388
+ void this.saveState();
389
+ };
390
+ window.addEventListener('pagehide', this.pagehideHandler);
391
+ }
392
+ }
393
+
394
+ /** Stop checkpointing and drop the `pagehide` listener. */
395
+ stopCheckpoints(): void {
396
+ if (this.checkpointTimer) {
397
+ clearInterval(this.checkpointTimer);
398
+ this.checkpointTimer = null;
399
+ }
400
+ if (this.pagehideHandler && typeof window !== 'undefined') {
401
+ window.removeEventListener('pagehide', this.pagehideHandler);
402
+ }
403
+ this.pagehideHandler = null;
404
+ this.snapshotDirty = false;
405
+ }
406
+
407
+ async loadState() {
408
+ if (!this.processor || !this.persistState || !this.persistCircuit) return;
409
+ try {
410
+ const result = await this.persistenceClient.get(this.stateKey());
411
+
412
+ // `save_state` returns a JSON string and every PersistenceClient round-trips
413
+ // it as one: localStorage via JSON.parse(getItem(...)), surrealdb via
414
+ // `_00_kv:<key>.val`. This used to test for a raw SurrealDB result shape
415
+ // (`result[0][0].state`), which no shipped client can ever produce, so the
416
+ // browser wrote a snapshot on every ingest and never restored one. The legacy
417
+ // shape is still accepted so an old persisted row still loads.
418
+ const state = typeof result === 'string' ? result : extractLegacyState(result);
419
+ if (state) {
122
420
  this.logger.info(
123
421
  {
124
422
  stateLength: state.length,
125
- Category: 'spooky-client::StreamProcessorService::loadState',
423
+ Category: 'sp00ky-client::StreamProcessorService::loadState',
126
424
  },
127
425
  'Loading state from DB'
128
426
  );
129
427
  // Assuming processor has a load_state method matching the save_state behavior
130
428
  // If not, we might need to adjust based on the actual WASM API
131
- if (typeof (this.processor as any).load_state === 'function') {
132
- (this.processor as any).load_state(state);
429
+ if (typeof this.processor.load_state === 'function') {
430
+ this.processor.load_state(state);
133
431
  } else {
134
432
  this.logger.warn(
135
- { Category: 'spooky-client::StreamProcessorService::loadState' },
433
+ { Category: 'sp00ky-client::StreamProcessorService::loadState' },
136
434
  'load_state method not found on processor'
137
435
  );
138
436
  }
139
437
  } else {
140
438
  this.logger.info(
141
- { Category: 'spooky-client::StreamProcessorService::loadState' },
439
+ { Category: 'sp00ky-client::StreamProcessorService::loadState' },
142
440
  'No saved state found'
143
441
  );
144
442
  }
145
443
  } catch (e) {
146
444
  this.logger.error(
147
- { error: e, Category: 'spooky-client::StreamProcessorService::loadState' },
445
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::loadState' },
148
446
  'Failed to load state'
149
447
  );
150
448
  }
151
449
  }
152
450
 
153
- async saveState() {
451
+ /**
452
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
453
+ * Must run after the processor exists and before any `register_view`, else
454
+ * non-`_00_` tables are default-denied and registration fails.
455
+ */
456
+ setPermissions(permissions: Record<string, string>) {
154
457
  if (!this.processor) return;
458
+ if (typeof this.processor.set_permissions !== 'function') {
459
+ this.logger.warn(
460
+ { Category: 'sp00ky-client::StreamProcessorService::setPermissions' },
461
+ 'set_permissions not found on processor (stale WASM build?)'
462
+ );
463
+ return;
464
+ }
465
+ this.processor.set_permissions(permissions);
466
+ this.logger.info(
467
+ {
468
+ tables: Object.keys(permissions).length,
469
+ Category: 'sp00ky-client::StreamProcessorService::setPermissions',
470
+ },
471
+ 'Seeded table permissions'
472
+ );
473
+ }
474
+
475
+ /**
476
+ * Set the current session's auth identity for permission injection,
477
+ * mirroring the server's `fn::query::register`
478
+ * (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
479
+ * Stored as strings (empty when logged out) and applied to every
480
+ * `register_view` in {@link registerQueryPlan}. Must be set before a
481
+ * `$auth`-gated query registers (and re-set on auth state changes), or the
482
+ * in-browser SSP's `permission_inject` rejects it with
483
+ * "requires $auth but registration params lack it".
484
+ */
485
+ setSessionAuth(authId: string | null, access: string | null) {
486
+ this.sessionAuth = { authId: authId ?? '', access: access ?? '' };
487
+ this.logger.debug(
488
+ {
489
+ authId: this.sessionAuth.authId,
490
+ access: this.sessionAuth.access,
491
+ Category: 'sp00ky-client::StreamProcessorService::setSessionAuth',
492
+ },
493
+ 'Session auth context updated'
494
+ );
495
+ }
496
+
497
+ async saveState() {
498
+ if (!this.processor || !this.persistState || !this.persistCircuit) return;
499
+ const generation = this.stateGeneration;
155
500
  try {
156
501
  // Assuming processor has a save_state method that returns the state string/bytes
157
- if (typeof (this.processor as any).save_state === 'function') {
158
- const state = (this.processor as any).save_state();
502
+ if (typeof this.processor.save_state === 'function') {
503
+ const state = this.processor.save_state();
504
+ // A reset raced this snapshot — persisting it would write the previous
505
+ // user's circuit into the new bucket's key. Drop it.
506
+ if (generation !== this.stateGeneration) return;
159
507
  if (state) {
160
- await this.persistenceClient.set('_spooky_stream_processor_state', state);
508
+ await this.persistenceClient.set(this.stateKey(), state);
161
509
  this.logger.trace(
162
- { Category: 'spooky-client::StreamProcessorService::saveState' },
510
+ { Category: 'sp00ky-client::StreamProcessorService::saveState' },
163
511
  'State saved'
164
512
  );
165
513
  }
166
514
  }
167
515
  } catch (e) {
168
516
  this.logger.error(
169
- { error: e, Category: 'spooky-client::StreamProcessorService::saveState' },
517
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::saveState' },
170
518
  'Failed to save state'
171
519
  );
172
520
  }
@@ -188,14 +536,14 @@ export class StreamProcessorService {
188
536
  table,
189
537
  op,
190
538
  id,
191
- Category: 'spooky-client::StreamProcessorService::ingest',
539
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
192
540
  },
193
541
  'Ingesting into ssp'
194
542
  );
195
543
 
196
544
  if (!this.processor) {
197
545
  this.logger.warn(
198
- { Category: 'spooky-client::StreamProcessorService::ingest' },
546
+ { Category: 'sp00ky-client::StreamProcessorService::ingest' },
199
547
  'Not initialized, skipping ingest'
200
548
  );
201
549
  return [];
@@ -204,14 +552,17 @@ export class StreamProcessorService {
204
552
  try {
205
553
  const normalizedRecord = this.normalizeValue(record);
206
554
 
555
+ const t0 = performance.now();
207
556
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
557
+ const materializationTimeMs = performance.now() - t0;
208
558
  this.logger.debug(
209
559
  {
210
560
  table,
211
561
  op,
212
562
  id,
213
563
  rawUpdates: rawUpdates.length,
214
- Category: 'spooky-client::StreamProcessorService::ingest',
564
+ materializationTimeMs,
565
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
215
566
  },
216
567
  'Ingesting into ssp done'
217
568
  );
@@ -221,15 +572,23 @@ export class StreamProcessorService {
221
572
  queryHash: u.query_id,
222
573
  localArray: u.result_data,
223
574
  op: op,
575
+ materializationTimeMs,
576
+ storeApplyMs: u.timing_store_apply_ms,
577
+ circuitStepMs: u.timing_circuit_step_ms,
578
+ transformMs: u.timing_transform_ms,
224
579
  }));
225
580
  // Direct handler call instead of event
226
581
  this.notifyUpdates(updates);
227
582
  }
228
- this.saveState();
583
+ // While batching (inside `ingestMany`), `flushCoalescing` marks dirty once
584
+ // for the whole batch, skip the redundant per-record mark here.
585
+ if (!this.batching) {
586
+ this.markSnapshotDirty();
587
+ }
229
588
  return rawUpdates;
230
589
  } catch (e) {
231
590
  this.logger.error(
232
- { error: e, Category: 'spooky-client::StreamProcessorService::ingest' },
591
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::ingest' },
233
592
  'Ingesting into ssp failed'
234
593
  );
235
594
  }
@@ -243,7 +602,7 @@ export class StreamProcessorService {
243
602
  registerQueryPlan(queryPlan: QueryPlanConfig) {
244
603
  if (!this.processor) {
245
604
  this.logger.warn(
246
- { Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
605
+ { Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
247
606
  'Not initialized, skipping registration'
248
607
  );
249
608
  return;
@@ -254,7 +613,7 @@ export class StreamProcessorService {
254
613
  queryHash: queryPlan.queryHash,
255
614
  surql: queryPlan.surql,
256
615
  params: queryPlan.params,
257
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
616
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
258
617
  },
259
618
  'Registering query plan'
260
619
  );
@@ -262,17 +621,30 @@ export class StreamProcessorService {
262
621
  try {
263
622
  const normalizedParams = this.normalizeValue(queryPlan.params);
264
623
 
624
+ // Mirror the server's `fn::query::register` auth injection so the
625
+ // in-browser SSP can resolve `$auth`/`$access` in a table's permission
626
+ // predicate. Without this, any `$auth`-gated table (e.g. `thread`) is
627
+ // rejected by `permission_inject` and its local view never materializes.
628
+ // Injected only into the params handed to the in-browser SSP — never into
629
+ // the persisted `queryState.config.params` / query hash / server payload
630
+ // (the server does its own injection), so the view id stays shared.
631
+ const paramsWithAuth = {
632
+ ...(normalizedParams as Record<string, unknown>),
633
+ auth: { id: this.sessionAuth.authId },
634
+ access: this.sessionAuth.access,
635
+ };
636
+
265
637
  const initialUpdate = this.processor.register_view({
266
638
  id: queryPlan.queryHash,
267
639
  surql: queryPlan.surql,
268
- params: normalizedParams,
640
+ params: paramsWithAuth,
269
641
  clientId: 'local',
270
642
  ttl: queryPlan.ttl.toString(),
271
643
  lastActiveAt: new Date().toISOString(),
272
644
  });
273
645
 
274
646
  this.logger.debug(
275
- { initialUpdate, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
647
+ { initialUpdate, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
276
648
  'register_view result'
277
649
  );
278
650
 
@@ -282,21 +654,26 @@ export class StreamProcessorService {
282
654
  const update: StreamUpdate = {
283
655
  queryHash: initialUpdate.query_id,
284
656
  localArray: initialUpdate.result_data,
657
+ registration: {
658
+ parseMs: initialUpdate.timing_parse_ms ?? 0,
659
+ planMs: initialUpdate.timing_plan_ms ?? 0,
660
+ snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
661
+ },
285
662
  };
286
- this.saveState();
663
+ this.markSnapshotDirty();
287
664
  this.logger.debug(
288
665
  {
289
666
  queryHash: queryPlan.queryHash,
290
667
  surql: queryPlan.surql,
291
668
  params: queryPlan.params,
292
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
669
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
293
670
  },
294
671
  'Registered query plan'
295
672
  );
296
673
  return update;
297
674
  } catch (e) {
298
675
  this.logger.error(
299
- { error: e, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
676
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
300
677
  'Error registering query plan'
301
678
  );
302
679
  throw e;
@@ -310,10 +687,10 @@ export class StreamProcessorService {
310
687
  if (!this.processor) return;
311
688
  try {
312
689
  this.processor.unregister_view(queryHash);
313
- this.saveState();
690
+ this.markSnapshotDirty();
314
691
  } catch (e) {
315
692
  this.logger.error(
316
- { error: e, Category: 'spooky-client::StreamProcessorService::unregisterQueryPlan' },
693
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },
317
694
  'Error unregistering query plan'
318
695
  );
319
696
  }
@@ -323,6 +700,21 @@ export class StreamProcessorService {
323
700
  if (value === null || value === undefined) return value;
324
701
 
325
702
  if (typeof value === 'object') {
703
+ // CRDT snapshots arrive as `Uint8Array` (or `ArrayBuffer` /
704
+ // typed-array views). `serde_wasm_bindgen::from_value` rejects
705
+ // those when deserializing into `serde_json::Value` (JSON has no
706
+ // binary variant), and the SSP can't filter on opaque bytes
707
+ // anyway. Replace with `null` so the row still flows through the
708
+ // ingest path with its other columns intact, and downstream
709
+ // predicates referencing the bytes column simply don't match.
710
+ if (
711
+ value instanceof Uint8Array ||
712
+ value instanceof ArrayBuffer ||
713
+ ArrayBuffer.isView(value)
714
+ ) {
715
+ return null;
716
+ }
717
+
326
718
  // RecordId detection using duck typing (constructor.name may be minified)
327
719
  // SurrealDB's RecordId has: table (getter returning Table), id, and toString()
328
720
  // Check for table getter that has its own toString AND id property
@@ -334,7 +726,7 @@ export class StreamProcessorService {
334
726
  if (hasTable && hasId && hasToString && isNotPlainObject) {
335
727
  const result = value.toString();
336
728
  this.logger.trace(
337
- { result, Category: 'spooky-client::StreamProcessorService::normalizeValue' },
729
+ { result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
338
730
  'RecordId detected'
339
731
  );
340
732
  return result;