@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.131

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 (90) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1171 -372
  3. package/dist/index.js +5726 -1277
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-worker.d.ts +1 -0
  7. package/dist/sqlite-worker.js +107 -0
  8. package/dist/types.d.ts +746 -0
  9. package/package.json +39 -8
  10. package/skills/sp00ky-core/SKILL.md +258 -0
  11. package/skills/sp00ky-core/references/auth.md +98 -0
  12. package/skills/sp00ky-core/references/config.md +76 -0
  13. package/src/build-globals.d.ts +12 -0
  14. package/src/events/events.test.ts +2 -1
  15. package/src/events/index.ts +3 -0
  16. package/src/index.ts +9 -2
  17. package/src/modules/auth/events/index.ts +2 -1
  18. package/src/modules/auth/index.ts +59 -20
  19. package/src/modules/cache/index.ts +77 -32
  20. package/src/modules/cache/types.ts +2 -2
  21. package/src/modules/crdt/crdt-field.ts +288 -0
  22. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  23. package/src/modules/crdt/index.ts +357 -0
  24. package/src/modules/data/data.hydration.test.ts +150 -0
  25. package/src/modules/data/data.rebind.test.ts +147 -0
  26. package/src/modules/data/data.recurring.test.ts +137 -0
  27. package/src/modules/data/data.status.test.ts +249 -0
  28. package/src/modules/data/index.ts +1234 -127
  29. package/src/modules/data/window-query.test.ts +52 -0
  30. package/src/modules/data/window-query.ts +154 -0
  31. package/src/modules/devtools/index.ts +191 -30
  32. package/src/modules/devtools/versions.test.ts +74 -0
  33. package/src/modules/devtools/versions.ts +81 -0
  34. package/src/modules/feature-flag/index.test.ts +120 -0
  35. package/src/modules/feature-flag/index.ts +209 -0
  36. package/src/modules/ref-tables.test.ts +91 -0
  37. package/src/modules/ref-tables.ts +88 -0
  38. package/src/modules/sync/engine.ts +101 -37
  39. package/src/modules/sync/events/index.ts +9 -2
  40. package/src/modules/sync/queue/queue-down.ts +12 -5
  41. package/src/modules/sync/queue/queue-up.ts +29 -14
  42. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  43. package/src/modules/sync/scheduler.ts +73 -7
  44. package/src/modules/sync/sync.health.test.ts +149 -0
  45. package/src/modules/sync/sync.subquery.test.ts +82 -0
  46. package/src/modules/sync/sync.ts +1017 -62
  47. package/src/modules/sync/utils.test.ts +269 -2
  48. package/src/modules/sync/utils.ts +182 -17
  49. package/src/otel/index.ts +127 -0
  50. package/src/services/database/cache-engine.ts +143 -0
  51. package/src/services/database/database.ts +11 -11
  52. package/src/services/database/engine-factory.ts +32 -0
  53. package/src/services/database/events/index.ts +2 -1
  54. package/src/services/database/index.ts +6 -0
  55. package/src/services/database/local-migrator.ts +28 -27
  56. package/src/services/database/local.test.ts +64 -0
  57. package/src/services/database/local.ts +452 -66
  58. package/src/services/database/plan-render.test.ts +133 -0
  59. package/src/services/database/plan-render.ts +100 -0
  60. package/src/services/database/relation-resolver.test.ts +413 -0
  61. package/src/services/database/relation-resolver.ts +0 -0
  62. package/src/services/database/remote.ts +13 -13
  63. package/src/services/database/sqlite-cache-engine.test.ts +85 -0
  64. package/src/services/database/sqlite-cache-engine.ts +699 -0
  65. package/src/services/database/sqlite-worker.ts +116 -0
  66. package/src/services/database/surql-translate.ts +291 -0
  67. package/src/services/database/surreal-cache-engine.ts +139 -0
  68. package/src/services/logger/index.ts +6 -101
  69. package/src/services/persistence/localstorage.ts +2 -2
  70. package/src/services/persistence/resilient.ts +41 -0
  71. package/src/services/persistence/surrealdb.ts +10 -10
  72. package/src/services/stream-processor/index.ts +295 -38
  73. package/src/services/stream-processor/permissions.test.ts +47 -0
  74. package/src/services/stream-processor/permissions.ts +53 -0
  75. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  76. package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
  77. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  78. package/src/services/stream-processor/wasm-types.ts +18 -2
  79. package/src/sp00ky.auth-order.test.ts +92 -0
  80. package/src/sp00ky.init-query.test.ts +185 -0
  81. package/src/sp00ky.ts +1016 -0
  82. package/src/types.ts +258 -15
  83. package/src/utils/error-classification.test.ts +44 -0
  84. package/src/utils/error-classification.ts +7 -0
  85. package/src/utils/index.ts +35 -13
  86. package/src/utils/parser.ts +3 -2
  87. package/src/utils/surql.ts +24 -15
  88. package/src/utils/withRetry.test.ts +1 -1
  89. package/tsdown.config.ts +77 -1
  90. package/src/spooky.ts +0 -392
@@ -0,0 +1,41 @@
1
+ import type { Logger } from 'pino';
2
+ import type { PersistenceClient } from '../../types';
3
+
4
+ export class ResilientPersistenceClient implements PersistenceClient {
5
+ private logger: Logger;
6
+
7
+ constructor(
8
+ private inner: PersistenceClient,
9
+ logger: Logger
10
+ ) {
11
+ this.logger = logger.child({ service: 'ResilientPersistenceClient' });
12
+ }
13
+
14
+ set<T>(key: string, value: T): Promise<void> {
15
+ return this.inner.set(key, value);
16
+ }
17
+
18
+ async get<T>(key: string): Promise<T | null> {
19
+ try {
20
+ return await this.inner.get<T>(key);
21
+ } catch (e) {
22
+ this.logger.warn(
23
+ { key, error: e, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
24
+ 'Persistence read failed, dropping key'
25
+ );
26
+ // Best-effort cleanup of the corrupt key; if removal also fails, the key
27
+ // just gets dropped again on the next failed read, so the failure is safe.
28
+ await this.inner.remove(key).catch((removeErr) => {
29
+ this.logger.debug(
30
+ { key, error: removeErr, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
31
+ 'Failed to drop corrupt persistence key'
32
+ );
33
+ });
34
+ return null;
35
+ }
36
+ }
37
+
38
+ remove(key: string): Promise<void> {
39
+ return this.inner.remove(key);
40
+ }
41
+ }
@@ -1,13 +1,13 @@
1
- import { PersistenceClient } from '../../types';
1
+ import type { PersistenceClient } from '../../types';
2
2
  import { parseRecordIdString, surql } from '../../utils/index';
3
- import { Logger } from 'pino';
4
- import { AbstractDatabaseService } from '../database/database';
3
+ import type { Logger } from 'pino';
4
+ import type { LocalStore } from '../database/cache-engine';
5
5
 
6
6
  export class SurrealDBPersistenceClient implements PersistenceClient {
7
7
  private logger: Logger;
8
8
 
9
9
  constructor(
10
- private db: AbstractDatabaseService,
10
+ private db: LocalStore,
11
11
  logger: Logger
12
12
  ) {
13
13
  this.logger = logger.child({ service: 'PersistenceClient:SurrealDb' });
@@ -15,11 +15,11 @@ export class SurrealDBPersistenceClient implements PersistenceClient {
15
15
 
16
16
  async set<T>(key: string, val: T) {
17
17
  try {
18
- const id = parseRecordIdString(`_spooky_kv:${key}`);
18
+ const id = parseRecordIdString(`_00_kv:${key}`);
19
19
  await this.db.query(surql.seal(surql.upsert('id', 'data')), { id, data: { val } });
20
20
  } catch (error) {
21
21
  this.logger.error(
22
- { error, Category: 'spooky-client::SurrealDBPersistenceClient::set' },
22
+ { error, Category: 'sp00ky-client::SurrealDBPersistenceClient::set' },
23
23
  'Failed to set KV'
24
24
  );
25
25
  throw error;
@@ -28,7 +28,7 @@ export class SurrealDBPersistenceClient implements PersistenceClient {
28
28
 
29
29
  async get<T>(key: string) {
30
30
  try {
31
- const id = parseRecordIdString(`_spooky_kv:${key}`);
31
+ const id = parseRecordIdString(`_00_kv:${key}`);
32
32
  const [result] = await this.db.query<[{ val: T }]>(
33
33
  surql.seal(surql.selectById('id', ['val'])),
34
34
  {
@@ -41,7 +41,7 @@ export class SurrealDBPersistenceClient implements PersistenceClient {
41
41
  return result.val;
42
42
  } catch (error) {
43
43
  this.logger.warn(
44
- { error, Category: 'spooky-client::SurrealDBPersistenceClient::get' },
44
+ { error, Category: 'sp00ky-client::SurrealDBPersistenceClient::get' },
45
45
  'Failed to get KV'
46
46
  );
47
47
  return null;
@@ -50,11 +50,11 @@ export class SurrealDBPersistenceClient implements PersistenceClient {
50
50
 
51
51
  async remove(key: string) {
52
52
  try {
53
- const id = parseRecordIdString(`_spooky_kv:${key}`);
53
+ const id = parseRecordIdString(`_00_kv:${key}`);
54
54
  await this.db.query(surql.seal(surql.delete('id')), { id });
55
55
  } catch (err) {
56
56
  this.logger.info(
57
- { err, Category: 'spooky-client::SurrealDBPersistenceClient::remove' },
57
+ { err, Category: 'sp00ky-client::SurrealDBPersistenceClient::remove' },
58
58
  'Failed to delete KV'
59
59
  );
60
60
  }
@@ -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)
@@ -46,10 +62,33 @@ export class StreamProcessorService {
46
62
  private processor: WasmProcessor | undefined;
47
63
  private isInitialized = false;
48
64
  private receivers: StreamUpdateReceiver[] = [];
65
+ // When true, `notifyUpdates` coalesces updates into `batchBuffer` (keyed by
66
+ // queryHash) instead of dispatching them. Used to collapse the per-record
67
+ // stream updates produced by a batched ingest into a single notification per
68
+ // query, so the UI updates once after the whole batch rather than row-by-row.
69
+ private batching = false;
70
+ private batchBuffer: Map<string, StreamUpdate> = new Map();
71
+ // Current session's auth identity, injected into every `register_view`'s
72
+ // params so the in-browser SSP can resolve `$auth`/`$access` in table
73
+ // permission predicates (mirrors the server's `fn::query::register`). Empty
74
+ // strings when logged out — a non-null `auth` keeps `permission_inject` from
75
+ // rejecting `$auth`-gated tables; the predicate just degrades to its public
76
+ // branch. Set via `setSessionAuth` on every auth state change.
77
+ private sessionAuth: { authId: string; access: string } = { authId: '', access: '' };
78
+ // Persisted-state key suffix (the local bucket id) so each user's circuit
79
+ // snapshot lands in their own key. Only matters for localStorage-backed
80
+ // persistence — the surrealdb persistence client already writes into the
81
+ // per-user bucket itself.
82
+ private stateKeySuffix = '';
83
+ // Bumped by `reset()`. A `saveState` captured before a reset must not persist
84
+ // the OLD processor's circuit (holding the previous user's rows) after the
85
+ // switch — the fire-and-forget calls in `ingest`/`flushCoalescing` can land
86
+ // late.
87
+ private stateGeneration = 0;
49
88
 
50
89
  constructor(
51
90
  public events: EventSystem<StreamProcessorEvents>,
52
- private db: LocalDatabaseService,
91
+ private db: LocalStore,
53
92
  private persistenceClient: PersistenceClient,
54
93
  logger: Logger
55
94
  ) {
@@ -65,6 +104,32 @@ export class StreamProcessorService {
65
104
  }
66
105
 
67
106
  private notifyUpdates(updates: StreamUpdate[]) {
107
+ if (this.batching) {
108
+ // Coalesce by queryHash instead of dispatching. The WASM `result_data`
109
+ // (localArray) is the full materialized array, so last-write-wins
110
+ // already reflects every prior ingest in the batch. We sum the
111
+ // materialization times so the single recorded sample reflects the
112
+ // batch's total work, and emit `op: 'CREATE'` on flush so the coalesced
113
+ // update takes DataModule's immediate (non-debounced) path.
114
+ for (const update of updates) {
115
+ const prev = this.batchBuffer.get(update.queryHash);
116
+ const sum = (a?: number, b?: number) => (a ?? 0) + (b ?? 0);
117
+ this.batchBuffer.set(update.queryHash, {
118
+ ...update,
119
+ op: 'CREATE',
120
+ materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
121
+ storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
122
+ circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
123
+ transformMs: sum(prev?.transformMs, update.transformMs),
124
+ });
125
+ }
126
+ return;
127
+ }
128
+
129
+ this.dispatchUpdates(updates);
130
+ }
131
+
132
+ private dispatchUpdates(updates: StreamUpdate[]) {
68
133
  for (const update of updates) {
69
134
  for (const receiver of this.receivers) {
70
135
  receiver.onStreamUpdate(update);
@@ -72,6 +137,72 @@ export class StreamProcessorService {
72
137
  }
73
138
  }
74
139
 
140
+ /**
141
+ * Ingest a batch of record changes as a single bulk operation, firing only
142
+ * one coalesced `StreamUpdate` per affected query once every record has been
143
+ * ingested (instead of one update per record). Use this whenever multiple
144
+ * records land at once — e.g. sync fetching N missing rows — so a list query
145
+ * re-runs and the UI re-renders once for the whole batch rather than
146
+ * row-by-row.
147
+ *
148
+ * Internally opens a coalescing window, ingests each record, then flushes;
149
+ * processor state is persisted once for the whole batch. No-op for an empty
150
+ * batch.
151
+ */
152
+ ingestMany(
153
+ records: Array<{
154
+ table: string;
155
+ op: 'CREATE' | 'UPDATE' | 'DELETE';
156
+ id: string;
157
+ record: any;
158
+ }>
159
+ ): void {
160
+ if (records.length === 0) return;
161
+
162
+ this.beginCoalescing();
163
+ try {
164
+ for (const record of records) {
165
+ this.ingest(record.table, record.op, record.id, record.record);
166
+ }
167
+ } finally {
168
+ this.flushCoalescing();
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Open a coalescing window. While open, the per-record stream updates
174
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
175
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
176
+ * `ingestMany` so the window always closes — otherwise the processor stays
177
+ * stuck buffering forever.
178
+ *
179
+ * No-op if a window is already open (nested batches aren't expected here).
180
+ */
181
+ private beginCoalescing() {
182
+ if (this.batching) return;
183
+ this.batching = true;
184
+ this.batchBuffer.clear();
185
+ }
186
+
187
+ /**
188
+ * Close the coalescing window and flush: dispatch one coalesced
189
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
190
+ * for the whole batch (instead of once per ingest).
191
+ */
192
+ private flushCoalescing() {
193
+ if (!this.batching) return;
194
+ this.batching = false;
195
+ const buffered = Array.from(this.batchBuffer.values());
196
+ this.batchBuffer.clear();
197
+ if (buffered.length > 0) {
198
+ this.dispatchUpdates(buffered);
199
+ }
200
+ // The processor state after the last ingest is cumulative, so a single
201
+ // snapshot covers the whole batch. Kept fire-and-forget like the per-ingest
202
+ // call it replaces.
203
+ this.saveState();
204
+ }
205
+
75
206
  /**
76
207
  * Initialize the WASM module and processor.
77
208
  * This must be called before using other methods.
@@ -80,35 +211,67 @@ export class StreamProcessorService {
80
211
  if (this.isInitialized) return;
81
212
 
82
213
  this.logger.info(
83
- { Category: 'spooky-client::StreamProcessorService::init' },
214
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
84
215
  'Initializing WASM...'
85
216
  );
86
217
  try {
87
218
  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;
219
+ // We cast the generated Sp00kyProcessor to our interface which is safer
220
+ this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
90
221
 
91
222
  // Try to load state
92
223
  await this.loadState();
93
224
 
94
225
  this.isInitialized = true;
95
226
  this.logger.info(
96
- { Category: 'spooky-client::StreamProcessorService::init' },
227
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
97
228
  'Initialized successfully'
98
229
  );
99
230
  } catch (e) {
100
231
  this.logger.error(
101
- { error: e, Category: 'spooky-client::StreamProcessorService::init' },
232
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::init' },
102
233
  'Failed to initialize'
103
234
  );
104
235
  throw e;
105
236
  }
106
237
  }
107
238
 
239
+ /** Route the persisted circuit snapshot to a per-bucket key. */
240
+ setStateKeySuffix(bucketId: string) {
241
+ this.stateKeySuffix = bucketId;
242
+ }
243
+
244
+ private stateKey(): string {
245
+ return this.stateKeySuffix
246
+ ? `_00_stream_processor_state:${this.stateKeySuffix}`
247
+ : '_00_stream_processor_state';
248
+ }
249
+
250
+ /**
251
+ * Drop the current WASM processor and start a fresh, empty circuit. Used on
252
+ * local-bucket switches: the old circuit holds the previous user's rows AND
253
+ * views registered with the previous `$auth` context, so neither may survive.
254
+ * Deliberately does NOT `loadState()` — a persisted snapshot references views
255
+ * under a dead sessionId salt; the DataModule rebind re-registers every live
256
+ * view against this fresh processor. Caller must re-seed `setPermissions`
257
+ * afterwards (a fresh circuit default-denies every table).
258
+ */
259
+ async reset(): Promise<void> {
260
+ if (!this.isInitialized) return;
261
+ this.stateGeneration++;
262
+ this.batching = false;
263
+ this.batchBuffer.clear();
264
+ this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
265
+ this.logger.info(
266
+ { Category: 'sp00ky-client::StreamProcessorService::reset' },
267
+ 'Stream processor reset (fresh circuit)'
268
+ );
269
+ }
270
+
108
271
  async loadState() {
109
272
  if (!this.processor) return;
110
273
  try {
111
- const result = await this.persistenceClient.get('_spooky_stream_processor_state');
274
+ const result = await this.persistenceClient.get(this.stateKey());
112
275
 
113
276
  // Check if we have a valid result from the query
114
277
  if (
@@ -122,51 +285,101 @@ export class StreamProcessorService {
122
285
  this.logger.info(
123
286
  {
124
287
  stateLength: state.length,
125
- Category: 'spooky-client::StreamProcessorService::loadState',
288
+ Category: 'sp00ky-client::StreamProcessorService::loadState',
126
289
  },
127
290
  'Loading state from DB'
128
291
  );
129
292
  // Assuming processor has a load_state method matching the save_state behavior
130
293
  // 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);
294
+ if (typeof this.processor.load_state === 'function') {
295
+ this.processor.load_state(state);
133
296
  } else {
134
297
  this.logger.warn(
135
- { Category: 'spooky-client::StreamProcessorService::loadState' },
298
+ { Category: 'sp00ky-client::StreamProcessorService::loadState' },
136
299
  'load_state method not found on processor'
137
300
  );
138
301
  }
139
302
  } else {
140
303
  this.logger.info(
141
- { Category: 'spooky-client::StreamProcessorService::loadState' },
304
+ { Category: 'sp00ky-client::StreamProcessorService::loadState' },
142
305
  'No saved state found'
143
306
  );
144
307
  }
145
308
  } catch (e) {
146
309
  this.logger.error(
147
- { error: e, Category: 'spooky-client::StreamProcessorService::loadState' },
310
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::loadState' },
148
311
  'Failed to load state'
149
312
  );
150
313
  }
151
314
  }
152
315
 
316
+ /**
317
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
318
+ * Must run after the processor exists and before any `register_view`, else
319
+ * non-`_00_` tables are default-denied and registration fails.
320
+ */
321
+ setPermissions(permissions: Record<string, string>) {
322
+ if (!this.processor) return;
323
+ if (typeof this.processor.set_permissions !== 'function') {
324
+ this.logger.warn(
325
+ { Category: 'sp00ky-client::StreamProcessorService::setPermissions' },
326
+ 'set_permissions not found on processor (stale WASM build?)'
327
+ );
328
+ return;
329
+ }
330
+ this.processor.set_permissions(permissions);
331
+ this.logger.info(
332
+ {
333
+ tables: Object.keys(permissions).length,
334
+ Category: 'sp00ky-client::StreamProcessorService::setPermissions',
335
+ },
336
+ 'Seeded table permissions'
337
+ );
338
+ }
339
+
340
+ /**
341
+ * Set the current session's auth identity for permission injection,
342
+ * mirroring the server's `fn::query::register`
343
+ * (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
344
+ * Stored as strings (empty when logged out) and applied to every
345
+ * `register_view` in {@link registerQueryPlan}. Must be set before a
346
+ * `$auth`-gated query registers (and re-set on auth state changes), or the
347
+ * in-browser SSP's `permission_inject` rejects it with
348
+ * "requires $auth but registration params lack it".
349
+ */
350
+ setSessionAuth(authId: string | null, access: string | null) {
351
+ this.sessionAuth = { authId: authId ?? '', access: access ?? '' };
352
+ this.logger.debug(
353
+ {
354
+ authId: this.sessionAuth.authId,
355
+ access: this.sessionAuth.access,
356
+ Category: 'sp00ky-client::StreamProcessorService::setSessionAuth',
357
+ },
358
+ 'Session auth context updated'
359
+ );
360
+ }
361
+
153
362
  async saveState() {
154
363
  if (!this.processor) return;
364
+ const generation = this.stateGeneration;
155
365
  try {
156
366
  // 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();
367
+ if (typeof this.processor.save_state === 'function') {
368
+ const state = this.processor.save_state();
369
+ // A reset raced this snapshot — persisting it would write the previous
370
+ // user's circuit into the new bucket's key. Drop it.
371
+ if (generation !== this.stateGeneration) return;
159
372
  if (state) {
160
- await this.persistenceClient.set('_spooky_stream_processor_state', state);
373
+ await this.persistenceClient.set(this.stateKey(), state);
161
374
  this.logger.trace(
162
- { Category: 'spooky-client::StreamProcessorService::saveState' },
375
+ { Category: 'sp00ky-client::StreamProcessorService::saveState' },
163
376
  'State saved'
164
377
  );
165
378
  }
166
379
  }
167
380
  } catch (e) {
168
381
  this.logger.error(
169
- { error: e, Category: 'spooky-client::StreamProcessorService::saveState' },
382
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::saveState' },
170
383
  'Failed to save state'
171
384
  );
172
385
  }
@@ -188,14 +401,14 @@ export class StreamProcessorService {
188
401
  table,
189
402
  op,
190
403
  id,
191
- Category: 'spooky-client::StreamProcessorService::ingest',
404
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
192
405
  },
193
406
  'Ingesting into ssp'
194
407
  );
195
408
 
196
409
  if (!this.processor) {
197
410
  this.logger.warn(
198
- { Category: 'spooky-client::StreamProcessorService::ingest' },
411
+ { Category: 'sp00ky-client::StreamProcessorService::ingest' },
199
412
  'Not initialized, skipping ingest'
200
413
  );
201
414
  return [];
@@ -204,14 +417,17 @@ export class StreamProcessorService {
204
417
  try {
205
418
  const normalizedRecord = this.normalizeValue(record);
206
419
 
420
+ const t0 = performance.now();
207
421
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
422
+ const materializationTimeMs = performance.now() - t0;
208
423
  this.logger.debug(
209
424
  {
210
425
  table,
211
426
  op,
212
427
  id,
213
428
  rawUpdates: rawUpdates.length,
214
- Category: 'spooky-client::StreamProcessorService::ingest',
429
+ materializationTimeMs,
430
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
215
431
  },
216
432
  'Ingesting into ssp done'
217
433
  );
@@ -221,15 +437,23 @@ export class StreamProcessorService {
221
437
  queryHash: u.query_id,
222
438
  localArray: u.result_data,
223
439
  op: op,
440
+ materializationTimeMs,
441
+ storeApplyMs: u.timing_store_apply_ms,
442
+ circuitStepMs: u.timing_circuit_step_ms,
443
+ transformMs: u.timing_transform_ms,
224
444
  }));
225
445
  // Direct handler call instead of event
226
446
  this.notifyUpdates(updates);
227
447
  }
228
- this.saveState();
448
+ // While batching (inside `ingestMany`), `flushCoalescing` persists once
449
+ // for the whole batch — skip the redundant per-record snapshot here.
450
+ if (!this.batching) {
451
+ this.saveState();
452
+ }
229
453
  return rawUpdates;
230
454
  } catch (e) {
231
455
  this.logger.error(
232
- { error: e, Category: 'spooky-client::StreamProcessorService::ingest' },
456
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::ingest' },
233
457
  'Ingesting into ssp failed'
234
458
  );
235
459
  }
@@ -243,7 +467,7 @@ export class StreamProcessorService {
243
467
  registerQueryPlan(queryPlan: QueryPlanConfig) {
244
468
  if (!this.processor) {
245
469
  this.logger.warn(
246
- { Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
470
+ { Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
247
471
  'Not initialized, skipping registration'
248
472
  );
249
473
  return;
@@ -254,7 +478,7 @@ export class StreamProcessorService {
254
478
  queryHash: queryPlan.queryHash,
255
479
  surql: queryPlan.surql,
256
480
  params: queryPlan.params,
257
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
481
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
258
482
  },
259
483
  'Registering query plan'
260
484
  );
@@ -262,17 +486,30 @@ export class StreamProcessorService {
262
486
  try {
263
487
  const normalizedParams = this.normalizeValue(queryPlan.params);
264
488
 
489
+ // Mirror the server's `fn::query::register` auth injection so the
490
+ // in-browser SSP can resolve `$auth`/`$access` in a table's permission
491
+ // predicate. Without this, any `$auth`-gated table (e.g. `thread`) is
492
+ // rejected by `permission_inject` and its local view never materializes.
493
+ // Injected only into the params handed to the in-browser SSP — never into
494
+ // the persisted `queryState.config.params` / query hash / server payload
495
+ // (the server does its own injection), so the view id stays shared.
496
+ const paramsWithAuth = {
497
+ ...(normalizedParams as Record<string, unknown>),
498
+ auth: { id: this.sessionAuth.authId },
499
+ access: this.sessionAuth.access,
500
+ };
501
+
265
502
  const initialUpdate = this.processor.register_view({
266
503
  id: queryPlan.queryHash,
267
504
  surql: queryPlan.surql,
268
- params: normalizedParams,
505
+ params: paramsWithAuth,
269
506
  clientId: 'local',
270
507
  ttl: queryPlan.ttl.toString(),
271
508
  lastActiveAt: new Date().toISOString(),
272
509
  });
273
510
 
274
511
  this.logger.debug(
275
- { initialUpdate, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
512
+ { initialUpdate, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
276
513
  'register_view result'
277
514
  );
278
515
 
@@ -282,6 +519,11 @@ export class StreamProcessorService {
282
519
  const update: StreamUpdate = {
283
520
  queryHash: initialUpdate.query_id,
284
521
  localArray: initialUpdate.result_data,
522
+ registration: {
523
+ parseMs: initialUpdate.timing_parse_ms ?? 0,
524
+ planMs: initialUpdate.timing_plan_ms ?? 0,
525
+ snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
526
+ },
285
527
  };
286
528
  this.saveState();
287
529
  this.logger.debug(
@@ -289,14 +531,14 @@ export class StreamProcessorService {
289
531
  queryHash: queryPlan.queryHash,
290
532
  surql: queryPlan.surql,
291
533
  params: queryPlan.params,
292
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
534
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
293
535
  },
294
536
  'Registered query plan'
295
537
  );
296
538
  return update;
297
539
  } catch (e) {
298
540
  this.logger.error(
299
- { error: e, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
541
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
300
542
  'Error registering query plan'
301
543
  );
302
544
  throw e;
@@ -313,7 +555,7 @@ export class StreamProcessorService {
313
555
  this.saveState();
314
556
  } catch (e) {
315
557
  this.logger.error(
316
- { error: e, Category: 'spooky-client::StreamProcessorService::unregisterQueryPlan' },
558
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },
317
559
  'Error unregistering query plan'
318
560
  );
319
561
  }
@@ -323,6 +565,21 @@ export class StreamProcessorService {
323
565
  if (value === null || value === undefined) return value;
324
566
 
325
567
  if (typeof value === 'object') {
568
+ // CRDT snapshots arrive as `Uint8Array` (or `ArrayBuffer` /
569
+ // typed-array views). `serde_wasm_bindgen::from_value` rejects
570
+ // those when deserializing into `serde_json::Value` (JSON has no
571
+ // binary variant), and the SSP can't filter on opaque bytes
572
+ // anyway. Replace with `null` so the row still flows through the
573
+ // ingest path with its other columns intact, and downstream
574
+ // predicates referencing the bytes column simply don't match.
575
+ if (
576
+ value instanceof Uint8Array ||
577
+ value instanceof ArrayBuffer ||
578
+ ArrayBuffer.isView(value)
579
+ ) {
580
+ return null;
581
+ }
582
+
326
583
  // RecordId detection using duck typing (constructor.name may be minified)
327
584
  // SurrealDB's RecordId has: table (getter returning Table), id, and toString()
328
585
  // Check for table getter that has its own toString AND id property
@@ -334,7 +591,7 @@ export class StreamProcessorService {
334
591
  if (hasTable && hasId && hasToString && isNotPlainObject) {
335
592
  const result = value.toString();
336
593
  this.logger.trace(
337
- { result, Category: 'spooky-client::StreamProcessorService::normalizeValue' },
594
+ { result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
338
595
  'RecordId detected'
339
596
  );
340
597
  return result;