@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211

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 (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -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 +36 -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/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -1,10 +1,12 @@
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 { QueryTimeToLive, RecordVersionArray } from '../../types';
9
+ import { encodeRecordId } from '../../utils/index';
8
10
 
9
11
  // Simple interface for query plan registration (replaces Incantation class)
10
12
  interface QueryPlanConfig {
@@ -27,6 +29,29 @@ export interface StreamUpdate {
27
29
  queryHash: string;
28
30
  localArray: RecordVersionArray;
29
31
  op?: 'CREATE' | 'UPDATE' | 'DELETE'; // Operation type for conditional debouncing
32
+ /**
33
+ * Client-internal: not from the circuit. A membership-only change that
34
+ * needed no fetch is re-materialized through this same path so it cannot
35
+ * race a real update (DataModule.scheduleRematerialize). Carries the last
36
+ * known `localArray`; consumers that describe an INGEST (persist, metrics,
37
+ * devtools events) skip it.
38
+ */
39
+ synthetic?: boolean;
40
+ /**
41
+ * End-to-end ingest latency for the WASM call that produced this update,
42
+ * in milliseconds. Populated by StreamProcessorService.ingest. Undefined
43
+ * for the initial register_view snapshot.
44
+ */
45
+ materializationTimeMs?: number;
46
+ /** SSP internal sub-phase timings (ms) for this ingest, from the WASM binding. */
47
+ storeApplyMs?: number;
48
+ circuitStepMs?: number;
49
+ transformMs?: number;
50
+ /**
51
+ * One-shot registration timings (ms). Only set on the StreamUpdate returned
52
+ * by `registerQueryPlan` (the register_view snapshot), not on ingest updates.
53
+ */
54
+ registration?: { parseMs: number; planMs: number; snapshotMs: number };
30
55
  }
31
56
 
32
57
  // Define events map (kept for DevTools compatibility)
@@ -41,16 +66,112 @@ export interface StreamUpdateReceiver {
41
66
  onStreamUpdate(update: StreamUpdate): void;
42
67
  }
43
68
 
69
+ /** One row change in the shape `ingestMany` consumes. */
70
+ export interface IngestRecord {
71
+ table: string;
72
+ /** `MERGE` overlays the given fields on the stored row (projection widening). */
73
+ op: 'CREATE' | 'UPDATE' | 'DELETE' | 'MERGE';
74
+ id: string;
75
+ record: any;
76
+ }
77
+
78
+ /**
79
+ * What the boot-time prime needs from the client: which tables to walk, how
80
+ * to recognise a snapshot written under a different schema, and which rows'
81
+ * local `_00_rv` must not be reported as the server's.
82
+ */
83
+ export interface CircuitPrimeContext {
84
+ tables: string[];
85
+ schemaHash: string;
86
+ /** Encoded ids with an unsettled local mutation (their `_00_rv` was bumped
87
+ * locally and may exceed the server's next version). */
88
+ pendingIds: Set<string>;
89
+ /** Receives every `(id, rv)` the prime put into the circuit, per table, so
90
+ * the sync layer can skip re-downloading bodies it already has. */
91
+ onVersions?: (table: string, entries: [string, number][]) => void;
92
+ }
93
+
94
+ /** Storage key of the circuit snapshot inside the local store. */
95
+ export const CIRCUIT_SNAPSHOT_KEY = 'circuit';
96
+ /** Bump when the bytes `load_store_state` reads change shape. */
97
+ export const CIRCUIT_SNAPSHOT_FORMAT = 1;
98
+ /** Rows per `ingest_many` call. Bounds the transient the wasm side allocates
99
+ * to parse a batch (measured: one 7700-row call of 20 KB bodies peaked at
100
+ * 500 MB, 128-row chunks at 335 MB, and under projection at 24 MB). */
101
+ const INGEST_CHUNK = 128;
102
+ /** Ids per `selectByIds` when priming bodies out of the local store. */
103
+ const PRIME_CHUNK = 256;
104
+ /** Rows the SurrealDB (main-thread, IndexedDB) engine is allowed to prime
105
+ * through; above this the circuit boots empty as it always did. */
106
+ const SURREAL_PRIME_ROW_CAP = 20_000;
107
+ /** Below this many changed rows the checkpoint timer stays quiet. */
108
+ const CHECKPOINT_MIN_ROWS = 50;
109
+
110
+ /** Stable `table:id` for a row id that may already be a string. */
111
+ function idString(id: unknown): string {
112
+ return typeof id === 'string' ? id : encodeRecordId(id as any);
113
+ }
114
+
115
+ function chunks<T>(items: T[], size: number): T[][] {
116
+ const out: T[][] = [];
117
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
118
+ return out;
119
+ }
120
+
44
121
  export class StreamProcessorService {
45
122
  private logger: Logger;
46
123
  private processor: WasmProcessor | undefined;
47
124
  private isInitialized = false;
48
125
  private receivers: StreamUpdateReceiver[] = [];
126
+ // When true, `notifyUpdates` coalesces updates into `batchBuffer` (keyed by
127
+ // queryHash) instead of dispatching them. Used to collapse the per-record
128
+ // stream updates produced by a batched ingest into a single notification per
129
+ // query, so the UI updates once after the whole batch rather than row-by-row.
130
+ private batching = false;
131
+ private batchBuffer: Map<string, StreamUpdate> = new Map();
132
+ // Current session's auth identity, injected into every `register_view`'s
133
+ // params so the in-browser SSP can resolve `$auth`/`$access` in table
134
+ // permission predicates (mirrors the server's `fn::query::register`). Empty
135
+ // strings when logged out — a non-null `auth` keeps `permission_inject` from
136
+ // rejecting `$auth`-gated tables; the predicate just degrades to its public
137
+ // branch. Set via `setSessionAuth` on every auth state change.
138
+ private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
139
+ // Bumped by `reset()`. Anything captured before a reset (a checkpoint's
140
+ // bytes, a prime's chunk, a widening read) must not land on the NEW
141
+ // processor, which belongs to a different bucket.
142
+ private stateGeneration = 0;
143
+ // Shared-tabs: follower circuits are in-memory ONLY. Only the tab that owns
144
+ // the store writes its snapshot; a promoted follower flips this back on.
145
+ private persistState = true;
146
+ // Snapshot persistence. Store-only bytes, written by the checkpoint timer
147
+ // and on the page going hidden, never per ingest: `save_store_state` walks
148
+ // every row. The snapshot is what lets a reload restore the circuit and
149
+ // step in only what changed since (`reconcile`), instead of re-downloading
150
+ // and re-ingesting the whole working set.
151
+ private persistCircuit = false;
152
+ private checkpointMs = 30_000;
153
+ private checkpointTimer: ReturnType<typeof setInterval> | null = null;
154
+ private snapshotDirty = false;
155
+ private dirtyRows = 0;
156
+ private hideHandler: (() => void) | null = null;
157
+ private checkpointInFlight: Promise<void> | null = null;
158
+ // Keep only the fields registered plans evaluate per stored row. The client
159
+ // renders bodies from the local store, so a 20 KB game row in the circuit
160
+ // is ~150 B of predicate/sort fields under projection.
161
+ private projection = true;
162
+ // Prime state: `primed` resolves when the boot-time prime (snapshot restore
163
+ // + reconcile, or a full read of the local store) has finished, aborted or
164
+ // been skipped. Sync waits on it before its first diff.
165
+ private primed: Promise<void> = Promise.resolve();
166
+ private schemaHash: string | null = null;
167
+ // Projection widening, serialised: fields a new view evaluates that stored
168
+ // rows lack are merged in table by table.
169
+ private widenQueue: Promise<void> = Promise.resolve();
170
+ private widenPending: Map<string, Set<string>> = new Map();
49
171
 
50
172
  constructor(
51
173
  public events: EventSystem<StreamProcessorEvents>,
52
- private db: LocalDatabaseService,
53
- private persistenceClient: PersistenceClient,
174
+ private db: LocalStore,
54
175
  logger: Logger
55
176
  ) {
56
177
  this.logger = logger.child({ name: 'StreamProcessorService' });
@@ -65,6 +186,32 @@ export class StreamProcessorService {
65
186
  }
66
187
 
67
188
  private notifyUpdates(updates: StreamUpdate[]) {
189
+ if (this.batching) {
190
+ // Coalesce by queryHash instead of dispatching. The WASM `result_data`
191
+ // (localArray) is the full materialized array, so last-write-wins
192
+ // already reflects every prior ingest in the batch. We sum the
193
+ // materialization times so the single recorded sample reflects the
194
+ // batch's total work, and emit `op: 'CREATE'` on flush so the coalesced
195
+ // update takes DataModule's immediate (non-debounced) path.
196
+ for (const update of updates) {
197
+ const prev = this.batchBuffer.get(update.queryHash);
198
+ const sum = (a?: number, b?: number) => (a ?? 0) + (b ?? 0);
199
+ this.batchBuffer.set(update.queryHash, {
200
+ ...update,
201
+ op: 'CREATE',
202
+ materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
203
+ storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
204
+ circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
205
+ transformMs: sum(prev?.transformMs, update.transformMs),
206
+ });
207
+ }
208
+ return;
209
+ }
210
+
211
+ this.dispatchUpdates(updates);
212
+ }
213
+
214
+ private dispatchUpdates(updates: StreamUpdate[]) {
68
215
  for (const update of updates) {
69
216
  for (const receiver of this.receivers) {
70
217
  receiver.onStreamUpdate(update);
@@ -72,6 +219,122 @@ export class StreamProcessorService {
72
219
  }
73
220
  }
74
221
 
222
+ /**
223
+ * Ingest a batch of record changes, firing one coalesced `StreamUpdate` per
224
+ * affected query once every record has been ingested. Use this whenever
225
+ * multiple records land at once (sync fetching N rows, the boot prime).
226
+ *
227
+ * The batch is fed to the wasm side in chunks of {@link INGEST_CHUNK}: one
228
+ * circuit step per chunk (a step walks every registered view, so per-record
229
+ * ingest paid that fixed cost N times), but never the whole batch at once,
230
+ * because the wasm side has to hold every parsed row of a call at the same
231
+ * time and wasm32 dlmalloc never returns that peak.
232
+ *
233
+ * Returns the records that were ingested. A chunk that fails is reported
234
+ * and skipped, not retried (a retry would double-apply whatever the failed
235
+ * step already committed), and the remaining chunks still run.
236
+ */
237
+ ingestMany(records: IngestRecord[]): IngestRecord[] {
238
+ if (records.length === 0) return [];
239
+
240
+ if (!this.processor) {
241
+ this.logger.warn(
242
+ { Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
243
+ 'Not initialized, skipping ingest'
244
+ );
245
+ return [];
246
+ }
247
+
248
+ const bulkIngest = this.processor.ingest_many;
249
+ if (typeof bulkIngest !== 'function') {
250
+ // Stale wasm build: per-record steps, still coalesced into one update.
251
+ this.beginCoalescing();
252
+ try {
253
+ for (const record of records) {
254
+ this.ingest(record.table, record.op, record.id, record.record);
255
+ }
256
+ } finally {
257
+ this.flushCoalescing();
258
+ }
259
+ return records;
260
+ }
261
+
262
+ const ingested: IngestRecord[] = [];
263
+ this.beginCoalescing();
264
+ try {
265
+ for (const chunk of chunks(records, INGEST_CHUNK)) {
266
+ try {
267
+ const items = chunk.map((record) => ({
268
+ table: record.table,
269
+ op: record.op,
270
+ id: record.id,
271
+ record: this.normalizeValue(record.record),
272
+ }));
273
+ const t0 = performance.now();
274
+ const rawUpdates = bulkIngest.call(this.processor, items) ?? [];
275
+ const materializationTimeMs = performance.now() - t0;
276
+ if (rawUpdates.length > 0) {
277
+ this.notifyUpdates(
278
+ rawUpdates.map((u: WasmStreamUpdate) => ({
279
+ queryHash: u.query_id,
280
+ localArray: u.result_data,
281
+ op: 'CREATE' as const,
282
+ materializationTimeMs,
283
+ storeApplyMs: u.timing_store_apply_ms,
284
+ circuitStepMs: u.timing_circuit_step_ms,
285
+ transformMs: u.timing_transform_ms,
286
+ }))
287
+ );
288
+ }
289
+ ingested.push(...chunk);
290
+ } catch (e) {
291
+ this.logger.error(
292
+ { error: e, count: chunk.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
293
+ 'Ingesting chunk into ssp failed'
294
+ );
295
+ }
296
+ }
297
+ } finally {
298
+ this.flushCoalescing();
299
+ }
300
+ this.logger.debug(
301
+ { count: records.length, ingested: ingested.length, Category: 'sp00ky-client::StreamProcessorService::ingestMany' },
302
+ 'Ingested batch into ssp'
303
+ );
304
+ this.markSnapshotDirty(ingested.length);
305
+ return ingested;
306
+ }
307
+
308
+ /**
309
+ * Open a coalescing window. While open, the per-record stream updates
310
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
311
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
312
+ * `ingestMany` so the window always closes — otherwise the processor stays
313
+ * stuck buffering forever.
314
+ *
315
+ * No-op if a window is already open (nested batches aren't expected here).
316
+ */
317
+ private beginCoalescing() {
318
+ if (this.batching) return;
319
+ this.batching = true;
320
+ this.batchBuffer.clear();
321
+ }
322
+
323
+ /**
324
+ * Close the coalescing window and flush: dispatch one coalesced
325
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
326
+ * for the whole batch (instead of once per ingest).
327
+ */
328
+ private flushCoalescing() {
329
+ if (!this.batching) return;
330
+ this.batching = false;
331
+ const buffered = Array.from(this.batchBuffer.values());
332
+ this.batchBuffer.clear();
333
+ if (buffered.length > 0) {
334
+ this.dispatchUpdates(buffered);
335
+ }
336
+ }
337
+
75
338
  /**
76
339
  * Initialize the WASM module and processor.
77
340
  * This must be called before using other methods.
@@ -80,96 +343,499 @@ export class StreamProcessorService {
80
343
  if (this.isInitialized) return;
81
344
 
82
345
  this.logger.info(
83
- { Category: 'spooky-client::StreamProcessorService::init' },
346
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
84
347
  'Initializing WASM...'
85
348
  );
86
349
  try {
87
350
  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;
90
-
91
- // Try to load state
92
- await this.loadState();
351
+ // We cast the generated Sp00kyProcessor to our interface which is safer
352
+ this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
353
+ this.applyProjection();
93
354
 
94
355
  this.isInitialized = true;
95
356
  this.logger.info(
96
- { Category: 'spooky-client::StreamProcessorService::init' },
357
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
97
358
  'Initialized successfully'
98
359
  );
99
360
  } catch (e) {
100
361
  this.logger.error(
101
- { error: e, Category: 'spooky-client::StreamProcessorService::init' },
362
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::init' },
102
363
  'Failed to initialize'
103
364
  );
104
365
  throw e;
105
366
  }
106
367
  }
107
368
 
108
- async loadState() {
109
- if (!this.processor) return;
369
+ /**
370
+ * Drop the current WASM processor and start a fresh, empty circuit. Used on
371
+ * local-bucket switches: the old circuit holds the previous user's rows AND
372
+ * views registered with the previous `$auth` context, so neither may survive.
373
+ * Deliberately loads nothing: the snapshot in the store being swapped away
374
+ * from belongs to the previous bucket; the caller primes the new bucket's
375
+ * circuit (`primeFromLocal`) once its store is open, and the DataModule
376
+ * rebind re-registers every live view against this fresh processor. Caller
377
+ * must re-seed `setPermissions` afterwards (a fresh circuit default-denies
378
+ * every table).
379
+ */
380
+ async reset(): Promise<void> {
381
+ if (!this.isInitialized) return;
382
+ this.stateGeneration++;
383
+ this.batching = false;
384
+ this.batchBuffer.clear();
385
+ this.snapshotDirty = false;
386
+ this.dirtyRows = 0;
387
+ this.widenPending.clear();
388
+ const previous = this.processor;
389
+ this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
390
+ this.applyProjection();
391
+ this.freeProcessor(previous);
392
+ this.logger.info(
393
+ { Category: 'sp00ky-client::StreamProcessorService::reset' },
394
+ 'Stream processor reset (fresh circuit)'
395
+ );
396
+ }
397
+
398
+ /**
399
+ * Release the wasm circuit and stop checkpointing. Call when the client is
400
+ * torn down; a recreated client (provider remount, HMR) would otherwise stack
401
+ * one full circuit per instance.
402
+ */
403
+ dispose(): void {
404
+ this.stopCheckpoints();
405
+ const previous = this.processor;
406
+ this.processor = undefined;
407
+ this.isInitialized = false;
408
+ this.batching = false;
409
+ this.batchBuffer.clear();
410
+ this.receivers = [];
411
+ this.freeProcessor(previous);
412
+ }
413
+
414
+ /**
415
+ * Explicitly run the wasm-bindgen destructor. Guarded: stale wasm builds may
416
+ * not expose `free`, and a double free must not take the app down.
417
+ */
418
+ private freeProcessor(processor: WasmProcessor | undefined): void {
419
+ if (!processor || typeof processor.free !== 'function') return;
110
420
  try {
111
- const result = await this.persistenceClient.get('_spooky_stream_processor_state');
421
+ processor.free();
422
+ } catch (e) {
423
+ this.logger.debug(
424
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::freeProcessor' },
425
+ 'Failed to free previous wasm circuit'
426
+ );
427
+ }
428
+ }
112
429
 
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;
122
- this.logger.info(
123
- {
124
- stateLength: state.length,
125
- Category: 'spooky-client::StreamProcessorService::loadState',
126
- },
127
- 'Loading state from DB'
128
- );
129
- // Assuming processor has a load_state method matching the save_state behavior
130
- // 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);
133
- } else {
134
- this.logger.warn(
135
- { Category: 'spooky-client::StreamProcessorService::loadState' },
136
- 'load_state method not found on processor'
430
+ /** Toggle circuit-state persistence (shared-tabs follower/leader role). */
431
+ setPersistenceEnabled(enabled: boolean): void {
432
+ this.persistState = enabled;
433
+ if (!enabled) this.stopCheckpoints();
434
+ }
435
+
436
+ /**
437
+ * Snapshot persistence (`persistCircuit`). When on, the circuit's store is
438
+ * written to the local store on a checkpoint interval and when the page
439
+ * goes hidden, and restored by {@link primeFromLocal} on the next boot.
440
+ */
441
+ configureCircuitPersistence(enabled: boolean, checkpointMs?: number): void {
442
+ this.persistCircuit = enabled;
443
+ if (checkpointMs && checkpointMs > 0) this.checkpointMs = checkpointMs;
444
+ if (!enabled) this.stopCheckpoints();
445
+ }
446
+
447
+ /**
448
+ * Field projection (`circuitProjection`, default on). Takes effect on the
449
+ * next processor (`init`/`reset`) and on rows written after that.
450
+ */
451
+ configureProjection(enabled: boolean): void {
452
+ this.projection = enabled;
453
+ this.applyProjection();
454
+ }
455
+
456
+ private applyProjection(): void {
457
+ if (!this.processor || typeof this.processor.set_projection !== 'function') return;
458
+ try {
459
+ this.processor.set_projection(this.projection);
460
+ } catch (e) {
461
+ this.logger.warn(
462
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::applyProjection' },
463
+ 'set_projection failed'
464
+ );
465
+ }
466
+ }
467
+
468
+ /** Resolves once the boot-time prime has finished (or was skipped). */
469
+ whenPrimed(): Promise<void> {
470
+ return this.primed;
471
+ }
472
+
473
+ /**
474
+ * Fill the circuit from the LOCAL store, in the background.
475
+ *
476
+ * With a usable snapshot: install it under whatever views have registered
477
+ * meanwhile (`load_store_state` re-primes them), then `reconcile` each table
478
+ * against the store's `(id, rv)` list so rows deleted since the checkpoint
479
+ * are stepped out and only rows added or changed since are read back and
480
+ * ingested. Without one: read every row and ingest it, chunked.
481
+ *
482
+ * Either way the circuit ends up equal to the local store without touching
483
+ * the network, so the first sync diff is a real delta rather than "fetch
484
+ * everything". The returned promise never rejects; `whenPrimed` gates on it.
485
+ */
486
+ primeFromLocal(ctx: CircuitPrimeContext): Promise<void> {
487
+ this.schemaHash = ctx.schemaHash;
488
+ const run = this.runPrime(ctx).catch((e) => {
489
+ this.logger.warn(
490
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
491
+ 'Circuit prime failed; the circuit fills from sync instead'
492
+ );
493
+ });
494
+ this.primed = run;
495
+ return run;
496
+ }
497
+
498
+ private async runPrime(ctx: CircuitPrimeContext): Promise<void> {
499
+ const processor = this.processor;
500
+ if (!processor || typeof this.db.scanVersions !== 'function') return;
501
+ const generation = this.stateGeneration;
502
+ const epoch = this.db.epoch;
503
+ // A bucket switch replaces the processor and the store under us; every
504
+ // step re-checks so a stale chunk never lands in the new bucket.
505
+ const alive = () =>
506
+ this.processor === processor && generation === this.stateGeneration && epoch === this.db.epoch;
507
+ const t0 = performance.now();
508
+
509
+ let restored = false;
510
+ if (
511
+ this.persistCircuit &&
512
+ typeof this.db.getSnapshot === 'function' &&
513
+ typeof processor.load_store_state === 'function'
514
+ ) {
515
+ const snapshot = await this.db.getSnapshot(CIRCUIT_SNAPSHOT_KEY);
516
+ if (!alive()) return;
517
+ if (snapshot) {
518
+ const { meta } = snapshot;
519
+ if (meta.formatVersion !== CIRCUIT_SNAPSHOT_FORMAT || meta.schemaHash !== ctx.schemaHash) {
520
+ this.logger.info(
521
+ { meta, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
522
+ 'Circuit snapshot is from another format or schema; priming from rows'
137
523
  );
524
+ } else {
525
+ try {
526
+ const tLoad = performance.now();
527
+ const updates = processor.load_store_state(snapshot.bytes);
528
+ this.dispatchWasmUpdates(updates);
529
+ restored = true;
530
+ this.logger.info(
531
+ {
532
+ bytes: snapshot.bytes.byteLength,
533
+ views: updates.length,
534
+ loadMs: Math.round(performance.now() - tLoad),
535
+ Category: 'sp00ky-client::StreamProcessorService::primeFromLocal',
536
+ },
537
+ 'Circuit snapshot restored'
538
+ );
539
+ } catch (e) {
540
+ this.logger.warn(
541
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
542
+ 'Circuit snapshot unreadable; priming from rows'
543
+ );
544
+ }
138
545
  }
546
+ }
547
+ }
548
+
549
+ // Read the versions AFTER the snapshot: a batch the leader writes between
550
+ // the two can only make the store side newer, which reconcile handles.
551
+ const versions = await this.db.scanVersions(ctx.tables);
552
+ if (!alive()) return;
553
+ const total = Object.values(versions).reduce((n, v) => n + v.length, 0);
554
+ if (!restored && this.db.engineKind !== 'sqlite' && total > SURREAL_PRIME_ROW_CAP) {
555
+ this.logger.warn(
556
+ { total, cap: SURREAL_PRIME_ROW_CAP, Category: 'sp00ky-client::StreamProcessorService::primeFromLocal' },
557
+ 'Too many cached rows to prime through the main-thread engine; circuit fills from sync'
558
+ );
559
+ return;
560
+ }
561
+
562
+ let ingested = 0;
563
+ let fetched = 0;
564
+ let deleted = 0;
565
+ for (const table of ctx.tables) {
566
+ const entries = versions[table] ?? [];
567
+ let toFetch: string[];
568
+ if (restored && typeof processor.reconcile === 'function') {
569
+ const result = processor.reconcile(table, entries);
570
+ this.dispatchWasmUpdates(result.updates);
571
+ deleted += result.deleted;
572
+ toFetch = result.fetch;
139
573
  } else {
140
- this.logger.info(
141
- { Category: 'spooky-client::StreamProcessorService::loadState' },
142
- 'No saved state found'
574
+ toFetch = entries.map(([id]) => id);
575
+ }
576
+ for (const chunk of chunks(toFetch, PRIME_CHUNK)) {
577
+ const rows = await this.db.selectByIds(table, chunk);
578
+ if (!alive()) return;
579
+ fetched += rows.length;
580
+ ingested += this.ingestMany(
581
+ rows.map((row) => ({
582
+ table,
583
+ op: 'CREATE' as const,
584
+ id: idString(row.id),
585
+ record: row,
586
+ }))
587
+ ).length;
588
+ }
589
+ if (entries.length > 0) {
590
+ ctx.onVersions?.(
591
+ table,
592
+ entries.filter(([id]) => !ctx.pendingIds.has(id))
143
593
  );
144
594
  }
145
- } catch (e) {
146
- this.logger.error(
147
- { error: e, Category: 'spooky-client::StreamProcessorService::loadState' },
148
- 'Failed to load state'
149
- );
150
595
  }
596
+ // A prime from rows leaves the store with no snapshot to fall back on;
597
+ // let the next checkpoint write one even if nothing else changes.
598
+ if (!restored && ingested > 0) this.markSnapshotDirty(Math.max(ingested, CHECKPOINT_MIN_ROWS));
599
+ this.logger.info(
600
+ {
601
+ restored,
602
+ rows: total,
603
+ fetched,
604
+ ingested,
605
+ deleted,
606
+ ms: Math.round(performance.now() - t0),
607
+ Category: 'sp00ky-client::StreamProcessorService::primeFromLocal',
608
+ },
609
+ 'Circuit primed from the local store'
610
+ );
151
611
  }
152
612
 
153
- async saveState() {
154
- if (!this.processor) return;
613
+ /** Publish wasm updates produced outside an ingest (restore, reconcile). */
614
+ private dispatchWasmUpdates(updates: WasmStreamUpdate[] | undefined): void {
615
+ if (!updates || updates.length === 0) return;
616
+ this.dispatchUpdates(
617
+ updates.map((u) => ({
618
+ queryHash: u.query_id,
619
+ localArray: u.result_data,
620
+ op: 'CREATE' as const,
621
+ }))
622
+ );
623
+ }
624
+
625
+ /**
626
+ * Record that the circuit changed by `rows` rows. Cheap; the snapshot is
627
+ * deferred to the checkpoint timer and skipped entirely when `persistCircuit`
628
+ * is off.
629
+ */
630
+ private markSnapshotDirty(rows = 1): void {
631
+ if (!this.persistCircuit || !this.persistState) return;
632
+ this.snapshotDirty = true;
633
+ this.dirtyRows += rows;
634
+ this.startCheckpoints();
635
+ }
636
+
637
+ private startCheckpoints(): void {
638
+ if (this.checkpointTimer) return;
639
+ this.checkpointTimer = setInterval(() => {
640
+ if (!this.snapshotDirty || this.dirtyRows < CHECKPOINT_MIN_ROWS) return;
641
+ void this.checkpoint('interval');
642
+ }, this.checkpointMs);
643
+ // Node/test environments have no `window`; the interval alone is enough there.
644
+ if (typeof window !== 'undefined' && !this.hideHandler) {
645
+ // `visibilitychange: hidden` is the reliable last chance: a 100 MB write
646
+ // cannot finish inside `pagehide`, which is kept only as best effort.
647
+ this.hideHandler = () => {
648
+ if (!this.snapshotDirty) return;
649
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible') return;
650
+ void this.checkpoint('hidden');
651
+ };
652
+ window.addEventListener('visibilitychange', this.hideHandler);
653
+ window.addEventListener('pagehide', this.hideHandler);
654
+ }
655
+ }
656
+
657
+ /** Stop checkpointing and drop the visibility listeners. */
658
+ stopCheckpoints(): void {
659
+ if (this.checkpointTimer) {
660
+ clearInterval(this.checkpointTimer);
661
+ this.checkpointTimer = null;
662
+ }
663
+ if (this.hideHandler && typeof window !== 'undefined') {
664
+ window.removeEventListener('visibilitychange', this.hideHandler);
665
+ window.removeEventListener('pagehide', this.hideHandler);
666
+ }
667
+ this.hideHandler = null;
668
+ this.snapshotDirty = false;
669
+ this.dirtyRows = 0;
670
+ }
671
+
672
+ /**
673
+ * Write the circuit's store to the local store as a snapshot. Compacts the
674
+ * row arena first when dead bytes outweigh live ones. Serialised: a second
675
+ * call while one is in flight joins it. No-op unless persistence is on, this
676
+ * tab owns the store, and the engine can hold a snapshot.
677
+ */
678
+ checkpoint(reason = 'manual'): Promise<void> {
679
+ if (this.checkpointInFlight) return this.checkpointInFlight;
680
+ const run = this.runCheckpoint(reason).finally(() => {
681
+ this.checkpointInFlight = null;
682
+ });
683
+ this.checkpointInFlight = run;
684
+ return run;
685
+ }
686
+
687
+ private async runCheckpoint(reason: string): Promise<void> {
688
+ const processor = this.processor;
689
+ if (!processor || !this.persistState || !this.persistCircuit || !this.schemaHash) return;
690
+ if (typeof this.db.putSnapshot !== 'function' || typeof processor.save_store_state !== 'function') {
691
+ return;
692
+ }
693
+ const generation = this.stateGeneration;
694
+ const epoch = this.db.epoch;
695
+ const rows = this.dirtyRows;
696
+ this.snapshotDirty = false;
697
+ this.dirtyRows = 0;
155
698
  try {
156
- // 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();
159
- if (state) {
160
- await this.persistenceClient.set('_spooky_stream_processor_state', state);
161
- this.logger.trace(
162
- { Category: 'spooky-client::StreamProcessorService::saveState' },
163
- 'State saved'
164
- );
165
- }
699
+ const t0 = performance.now();
700
+ let reclaimed = 0;
701
+ if (
702
+ typeof processor.compact === 'function' &&
703
+ typeof processor.dead_bytes === 'function' &&
704
+ typeof processor.live_bytes === 'function'
705
+ ) {
706
+ const dead = processor.dead_bytes();
707
+ if (dead > 1_000_000 && dead > processor.live_bytes()) reclaimed = processor.compact();
166
708
  }
709
+ const bytes = processor.save_store_state();
710
+ const meta = {
711
+ formatVersion: CIRCUIT_SNAPSHOT_FORMAT,
712
+ schemaHash: this.schemaHash,
713
+ savedAt: Date.now(),
714
+ maxRv: typeof processor.max_row_versions === 'function' ? processor.max_row_versions() : undefined,
715
+ };
716
+ // A reset or bucket switch landed while we serialised: this snapshot
717
+ // describes the OLD bucket's circuit and must not be written into the
718
+ // new bucket's store.
719
+ if (generation !== this.stateGeneration || epoch !== this.db.epoch) return;
720
+ await this.db.putSnapshot(CIRCUIT_SNAPSHOT_KEY, bytes, meta);
721
+ this.logger.info(
722
+ {
723
+ reason,
724
+ rows,
725
+ bytes: bytes.byteLength,
726
+ reclaimed,
727
+ ms: Math.round(performance.now() - t0),
728
+ Category: 'sp00ky-client::StreamProcessorService::checkpoint',
729
+ },
730
+ 'Circuit snapshot written'
731
+ );
167
732
  } catch (e) {
168
- this.logger.error(
169
- { error: e, Category: 'spooky-client::StreamProcessorService::saveState' },
170
- 'Failed to save state'
733
+ this.logger.warn(
734
+ { error: e, reason, Category: 'sp00ky-client::StreamProcessorService::checkpoint' },
735
+ 'Circuit snapshot failed'
736
+ );
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Projection widening: a newly registered view evaluates fields that rows
742
+ * already in the circuit were stored without. Merge just those fields in,
743
+ * table by table, from the local store. The view registered against what
744
+ * was present and converges as the merges step through.
745
+ */
746
+ private scheduleWiden(missing: Record<string, string[]>): void {
747
+ for (const [table, fields] of Object.entries(missing)) {
748
+ const set = this.widenPending.get(table) ?? new Set<string>();
749
+ for (const f of fields) set.add(f);
750
+ this.widenPending.set(table, set);
751
+ }
752
+ this.widenQueue = this.widenQueue.then(() => this.runWiden()).catch((e) => {
753
+ this.logger.warn(
754
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::widen' },
755
+ 'Projection widening failed'
756
+ );
757
+ });
758
+ }
759
+
760
+ private async runWiden(): Promise<void> {
761
+ const processor = this.processor;
762
+ if (!processor || typeof this.db.scanVersions !== 'function') {
763
+ this.widenPending.clear();
764
+ return;
765
+ }
766
+ const generation = this.stateGeneration;
767
+ const epoch = this.db.epoch;
768
+ while (this.widenPending.size > 0) {
769
+ const [table, fields] = this.widenPending.entries().next().value as [string, Set<string>];
770
+ this.widenPending.delete(table);
771
+ const select = Array.from(fields);
772
+ const versions = await this.db.scanVersions([table]);
773
+ if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
774
+ const ids = (versions[table] ?? []).map(([id]) => id);
775
+ let merged = 0;
776
+ for (const chunk of chunks(ids, PRIME_CHUNK)) {
777
+ const rows = await this.db.selectByIds(table, chunk, { select });
778
+ if (this.processor !== processor || generation !== this.stateGeneration || epoch !== this.db.epoch) return;
779
+ merged += this.ingestMany(
780
+ rows.map((row) => ({
781
+ table,
782
+ op: 'MERGE' as const,
783
+ id: idString(row.id),
784
+ record: row,
785
+ }))
786
+ ).length;
787
+ }
788
+ this.logger.info(
789
+ { table, fields: select, merged, Category: 'sp00ky-client::StreamProcessorService::widen' },
790
+ 'Widened projected rows with newly evaluated fields'
791
+ );
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
797
+ * Must run after the processor exists and before any `register_view`, else
798
+ * non-`_00_` tables are default-denied and registration fails.
799
+ */
800
+ setPermissions(permissions: Record<string, string>) {
801
+ if (!this.processor) return;
802
+ if (typeof this.processor.set_permissions !== 'function') {
803
+ this.logger.warn(
804
+ { Category: 'sp00ky-client::StreamProcessorService::setPermissions' },
805
+ 'set_permissions not found on processor (stale WASM build?)'
171
806
  );
807
+ return;
172
808
  }
809
+ this.processor.set_permissions(permissions);
810
+ this.logger.info(
811
+ {
812
+ tables: Object.keys(permissions).length,
813
+ Category: 'sp00ky-client::StreamProcessorService::setPermissions',
814
+ },
815
+ 'Seeded table permissions'
816
+ );
817
+ }
818
+
819
+ /**
820
+ * Set the current session's auth identity for permission injection,
821
+ * mirroring the server's `fn::query::register`
822
+ * (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
823
+ * Stored as strings (empty when logged out) and applied to every
824
+ * `register_view` in {@link registerQueryPlan}. Must be set before a
825
+ * `$auth`-gated query registers (and re-set on auth state changes), or the
826
+ * in-browser SSP's `permission_inject` rejects it with
827
+ * "requires $auth but registration params lack it".
828
+ */
829
+ setSessionAuth(authId: string | null, access: string | null) {
830
+ this.sessionAuth = { authId: authId ?? '', access: access ?? '' };
831
+ this.logger.debug(
832
+ {
833
+ authId: this.sessionAuth.authId,
834
+ access: this.sessionAuth.access,
835
+ Category: 'sp00ky-client::StreamProcessorService::setSessionAuth',
836
+ },
837
+ 'Session auth context updated'
838
+ );
173
839
  }
174
840
 
175
841
  /**
@@ -179,7 +845,7 @@ export class StreamProcessorService {
179
845
  */
180
846
  ingest(
181
847
  table: string,
182
- op: 'CREATE' | 'UPDATE' | 'DELETE',
848
+ op: IngestRecord['op'],
183
849
  id: string,
184
850
  record: any
185
851
  ): WasmStreamUpdate[] {
@@ -188,14 +854,14 @@ export class StreamProcessorService {
188
854
  table,
189
855
  op,
190
856
  id,
191
- Category: 'spooky-client::StreamProcessorService::ingest',
857
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
192
858
  },
193
859
  'Ingesting into ssp'
194
860
  );
195
861
 
196
862
  if (!this.processor) {
197
863
  this.logger.warn(
198
- { Category: 'spooky-client::StreamProcessorService::ingest' },
864
+ { Category: 'sp00ky-client::StreamProcessorService::ingest' },
199
865
  'Not initialized, skipping ingest'
200
866
  );
201
867
  return [];
@@ -204,14 +870,17 @@ export class StreamProcessorService {
204
870
  try {
205
871
  const normalizedRecord = this.normalizeValue(record);
206
872
 
873
+ const t0 = performance.now();
207
874
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
875
+ const materializationTimeMs = performance.now() - t0;
208
876
  this.logger.debug(
209
877
  {
210
878
  table,
211
879
  op,
212
880
  id,
213
881
  rawUpdates: rawUpdates.length,
214
- Category: 'spooky-client::StreamProcessorService::ingest',
882
+ materializationTimeMs,
883
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
215
884
  },
216
885
  'Ingesting into ssp done'
217
886
  );
@@ -220,16 +889,25 @@ export class StreamProcessorService {
220
889
  const updates: StreamUpdate[] = rawUpdates.map((u: WasmStreamUpdate) => ({
221
890
  queryHash: u.query_id,
222
891
  localArray: u.result_data,
223
- op: op,
892
+ // A MERGE is a content update as far as consumers are concerned.
893
+ op: op === 'MERGE' ? 'UPDATE' : op,
894
+ materializationTimeMs,
895
+ storeApplyMs: u.timing_store_apply_ms,
896
+ circuitStepMs: u.timing_circuit_step_ms,
897
+ transformMs: u.timing_transform_ms,
224
898
  }));
225
899
  // Direct handler call instead of event
226
900
  this.notifyUpdates(updates);
227
901
  }
228
- this.saveState();
902
+ // While batching (inside `ingestMany`), `flushCoalescing` marks dirty once
903
+ // for the whole batch, skip the redundant per-record mark here.
904
+ if (!this.batching) {
905
+ this.markSnapshotDirty();
906
+ }
229
907
  return rawUpdates;
230
908
  } catch (e) {
231
909
  this.logger.error(
232
- { error: e, Category: 'spooky-client::StreamProcessorService::ingest' },
910
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::ingest' },
233
911
  'Ingesting into ssp failed'
234
912
  );
235
913
  }
@@ -243,7 +921,7 @@ export class StreamProcessorService {
243
921
  registerQueryPlan(queryPlan: QueryPlanConfig) {
244
922
  if (!this.processor) {
245
923
  this.logger.warn(
246
- { Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
924
+ { Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
247
925
  'Not initialized, skipping registration'
248
926
  );
249
927
  return;
@@ -254,7 +932,7 @@ export class StreamProcessorService {
254
932
  queryHash: queryPlan.queryHash,
255
933
  surql: queryPlan.surql,
256
934
  params: queryPlan.params,
257
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
935
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
258
936
  },
259
937
  'Registering query plan'
260
938
  );
@@ -262,17 +940,30 @@ export class StreamProcessorService {
262
940
  try {
263
941
  const normalizedParams = this.normalizeValue(queryPlan.params);
264
942
 
943
+ // Mirror the server's `fn::query::register` auth injection so the
944
+ // in-browser SSP can resolve `$auth`/`$access` in a table's permission
945
+ // predicate. Without this, any `$auth`-gated table (e.g. `thread`) is
946
+ // rejected by `permission_inject` and its local view never materializes.
947
+ // Injected only into the params handed to the in-browser SSP — never into
948
+ // the persisted `queryState.config.params` / query hash / server payload
949
+ // (the server does its own injection), so the view id stays shared.
950
+ const paramsWithAuth = {
951
+ ...(normalizedParams as Record<string, unknown>),
952
+ auth: { id: this.sessionAuth.authId },
953
+ access: this.sessionAuth.access,
954
+ };
955
+
265
956
  const initialUpdate = this.processor.register_view({
266
957
  id: queryPlan.queryHash,
267
958
  surql: queryPlan.surql,
268
- params: normalizedParams,
959
+ params: paramsWithAuth,
269
960
  clientId: 'local',
270
961
  ttl: queryPlan.ttl.toString(),
271
962
  lastActiveAt: new Date().toISOString(),
272
963
  });
273
964
 
274
965
  this.logger.debug(
275
- { initialUpdate, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
966
+ { initialUpdate, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
276
967
  'register_view result'
277
968
  );
278
969
 
@@ -282,21 +973,28 @@ export class StreamProcessorService {
282
973
  const update: StreamUpdate = {
283
974
  queryHash: initialUpdate.query_id,
284
975
  localArray: initialUpdate.result_data,
976
+ registration: {
977
+ parseMs: initialUpdate.timing_parse_ms ?? 0,
978
+ planMs: initialUpdate.timing_plan_ms ?? 0,
979
+ snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
980
+ },
285
981
  };
286
- this.saveState();
982
+ if (initialUpdate.missing_fields && Object.keys(initialUpdate.missing_fields).length > 0) {
983
+ this.scheduleWiden(initialUpdate.missing_fields);
984
+ }
287
985
  this.logger.debug(
288
986
  {
289
987
  queryHash: queryPlan.queryHash,
290
988
  surql: queryPlan.surql,
291
989
  params: queryPlan.params,
292
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
990
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
293
991
  },
294
992
  'Registered query plan'
295
993
  );
296
994
  return update;
297
995
  } catch (e) {
298
996
  this.logger.error(
299
- { error: e, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
997
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
300
998
  'Error registering query plan'
301
999
  );
302
1000
  throw e;
@@ -310,10 +1008,9 @@ export class StreamProcessorService {
310
1008
  if (!this.processor) return;
311
1009
  try {
312
1010
  this.processor.unregister_view(queryHash);
313
- this.saveState();
314
1011
  } catch (e) {
315
1012
  this.logger.error(
316
- { error: e, Category: 'spooky-client::StreamProcessorService::unregisterQueryPlan' },
1013
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },
317
1014
  'Error unregistering query plan'
318
1015
  );
319
1016
  }
@@ -323,6 +1020,21 @@ export class StreamProcessorService {
323
1020
  if (value === null || value === undefined) return value;
324
1021
 
325
1022
  if (typeof value === 'object') {
1023
+ // CRDT snapshots arrive as `Uint8Array` (or `ArrayBuffer` /
1024
+ // typed-array views). `serde_wasm_bindgen::from_value` rejects
1025
+ // those when deserializing into `serde_json::Value` (JSON has no
1026
+ // binary variant), and the SSP can't filter on opaque bytes
1027
+ // anyway. Replace with `null` so the row still flows through the
1028
+ // ingest path with its other columns intact, and downstream
1029
+ // predicates referencing the bytes column simply don't match.
1030
+ if (
1031
+ value instanceof Uint8Array ||
1032
+ value instanceof ArrayBuffer ||
1033
+ ArrayBuffer.isView(value)
1034
+ ) {
1035
+ return null;
1036
+ }
1037
+
326
1038
  // RecordId detection using duck typing (constructor.name may be minified)
327
1039
  // SurrealDB's RecordId has: table (getter returning Table), id, and toString()
328
1040
  // Check for table getter that has its own toString AND id property
@@ -334,7 +1046,7 @@ export class StreamProcessorService {
334
1046
  if (hasTable && hasId && hasToString && isNotPlainObject) {
335
1047
  const result = value.toString();
336
1048
  this.logger.trace(
337
- { result, Category: 'spooky-client::StreamProcessorService::normalizeValue' },
1049
+ { result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
338
1050
  'RecordId detected'
339
1051
  );
340
1052
  return result;