@spooky-sync/core 0.0.1-canary.9 → 0.0.1-canary.91

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 (65) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +907 -339
  3. package/dist/index.js +2837 -402
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +543 -0
  7. package/package.json +39 -9
  8. package/skills/sp00ky-core/SKILL.md +258 -0
  9. package/skills/sp00ky-core/references/auth.md +98 -0
  10. package/skills/sp00ky-core/references/config.md +76 -0
  11. package/src/build-globals.d.ts +12 -0
  12. package/src/events/events.test.ts +2 -1
  13. package/src/events/index.ts +3 -0
  14. package/src/index.ts +9 -2
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +59 -20
  17. package/src/modules/cache/index.ts +41 -29
  18. package/src/modules/cache/types.ts +2 -2
  19. package/src/modules/crdt/crdt-field.ts +281 -0
  20. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  21. package/src/modules/crdt/index.ts +352 -0
  22. package/src/modules/data/data.status.test.ts +108 -0
  23. package/src/modules/data/index.ts +732 -109
  24. package/src/modules/data/window-query.test.ts +52 -0
  25. package/src/modules/data/window-query.ts +130 -0
  26. package/src/modules/devtools/index.ts +115 -21
  27. package/src/modules/devtools/versions.test.ts +74 -0
  28. package/src/modules/devtools/versions.ts +81 -0
  29. package/src/modules/feature-flag/index.ts +166 -0
  30. package/src/modules/ref-tables.test.ts +66 -0
  31. package/src/modules/ref-tables.ts +69 -0
  32. package/src/modules/sync/engine.ts +101 -37
  33. package/src/modules/sync/events/index.ts +9 -2
  34. package/src/modules/sync/queue/queue-down.ts +5 -4
  35. package/src/modules/sync/queue/queue-up.ts +14 -13
  36. package/src/modules/sync/scheduler.ts +40 -3
  37. package/src/modules/sync/sync.ts +893 -59
  38. package/src/modules/sync/utils.test.ts +269 -2
  39. package/src/modules/sync/utils.ts +182 -17
  40. package/src/otel/index.ts +127 -0
  41. package/src/services/database/database.ts +11 -11
  42. package/src/services/database/events/index.ts +2 -1
  43. package/src/services/database/local-migrator.ts +21 -21
  44. package/src/services/database/local.test.ts +33 -0
  45. package/src/services/database/local.ts +192 -32
  46. package/src/services/database/remote.ts +13 -13
  47. package/src/services/logger/index.ts +6 -101
  48. package/src/services/persistence/localstorage.ts +2 -2
  49. package/src/services/persistence/resilient.ts +41 -0
  50. package/src/services/persistence/surrealdb.ts +9 -9
  51. package/src/services/stream-processor/index.ts +248 -37
  52. package/src/services/stream-processor/permissions.test.ts +47 -0
  53. package/src/services/stream-processor/permissions.ts +53 -0
  54. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  55. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  56. package/src/services/stream-processor/wasm-types.ts +18 -2
  57. package/src/sp00ky.auth-order.test.ts +65 -0
  58. package/src/sp00ky.ts +648 -0
  59. package/src/types.ts +192 -15
  60. package/src/utils/index.ts +35 -13
  61. package/src/utils/parser.ts +3 -2
  62. package/src/utils/surql.ts +24 -15
  63. package/src/utils/withRetry.test.ts +1 -1
  64. package/tsdown.config.ts +55 -1
  65. package/src/spooky.ts +0 -392
@@ -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 { LocalDatabaseService } 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,6 +62,19 @@ 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: '' };
49
78
 
50
79
  constructor(
51
80
  public events: EventSystem<StreamProcessorEvents>,
@@ -65,6 +94,32 @@ export class StreamProcessorService {
65
94
  }
66
95
 
67
96
  private notifyUpdates(updates: StreamUpdate[]) {
97
+ if (this.batching) {
98
+ // Coalesce by queryHash instead of dispatching. The WASM `result_data`
99
+ // (localArray) is the full materialized array, so last-write-wins
100
+ // already reflects every prior ingest in the batch. We sum the
101
+ // materialization times so the single recorded sample reflects the
102
+ // batch's total work, and emit `op: 'CREATE'` on flush so the coalesced
103
+ // update takes DataModule's immediate (non-debounced) path.
104
+ for (const update of updates) {
105
+ const prev = this.batchBuffer.get(update.queryHash);
106
+ const sum = (a?: number, b?: number) => (a ?? 0) + (b ?? 0);
107
+ this.batchBuffer.set(update.queryHash, {
108
+ ...update,
109
+ op: 'CREATE',
110
+ materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
111
+ storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
112
+ circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
113
+ transformMs: sum(prev?.transformMs, update.transformMs),
114
+ });
115
+ }
116
+ return;
117
+ }
118
+
119
+ this.dispatchUpdates(updates);
120
+ }
121
+
122
+ private dispatchUpdates(updates: StreamUpdate[]) {
68
123
  for (const update of updates) {
69
124
  for (const receiver of this.receivers) {
70
125
  receiver.onStreamUpdate(update);
@@ -72,6 +127,72 @@ export class StreamProcessorService {
72
127
  }
73
128
  }
74
129
 
130
+ /**
131
+ * Ingest a batch of record changes as a single bulk operation, firing only
132
+ * one coalesced `StreamUpdate` per affected query once every record has been
133
+ * ingested (instead of one update per record). Use this whenever multiple
134
+ * records land at once — e.g. sync fetching N missing rows — so a list query
135
+ * re-runs and the UI re-renders once for the whole batch rather than
136
+ * row-by-row.
137
+ *
138
+ * Internally opens a coalescing window, ingests each record, then flushes;
139
+ * processor state is persisted once for the whole batch. No-op for an empty
140
+ * batch.
141
+ */
142
+ ingestMany(
143
+ records: Array<{
144
+ table: string;
145
+ op: 'CREATE' | 'UPDATE' | 'DELETE';
146
+ id: string;
147
+ record: any;
148
+ }>
149
+ ): void {
150
+ if (records.length === 0) return;
151
+
152
+ this.beginCoalescing();
153
+ try {
154
+ for (const record of records) {
155
+ this.ingest(record.table, record.op, record.id, record.record);
156
+ }
157
+ } finally {
158
+ this.flushCoalescing();
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Open a coalescing window. While open, the per-record stream updates
164
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
165
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
166
+ * `ingestMany` so the window always closes — otherwise the processor stays
167
+ * stuck buffering forever.
168
+ *
169
+ * No-op if a window is already open (nested batches aren't expected here).
170
+ */
171
+ private beginCoalescing() {
172
+ if (this.batching) return;
173
+ this.batching = true;
174
+ this.batchBuffer.clear();
175
+ }
176
+
177
+ /**
178
+ * Close the coalescing window and flush: dispatch one coalesced
179
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
180
+ * for the whole batch (instead of once per ingest).
181
+ */
182
+ private flushCoalescing() {
183
+ if (!this.batching) return;
184
+ this.batching = false;
185
+ const buffered = Array.from(this.batchBuffer.values());
186
+ this.batchBuffer.clear();
187
+ if (buffered.length > 0) {
188
+ this.dispatchUpdates(buffered);
189
+ }
190
+ // The processor state after the last ingest is cumulative, so a single
191
+ // snapshot covers the whole batch. Kept fire-and-forget like the per-ingest
192
+ // call it replaces.
193
+ this.saveState();
194
+ }
195
+
75
196
  /**
76
197
  * Initialize the WASM module and processor.
77
198
  * This must be called before using other methods.
@@ -80,25 +201,25 @@ export class StreamProcessorService {
80
201
  if (this.isInitialized) return;
81
202
 
82
203
  this.logger.info(
83
- { Category: 'spooky-client::StreamProcessorService::init' },
204
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
84
205
  'Initializing WASM...'
85
206
  );
86
207
  try {
87
208
  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;
209
+ // We cast the generated Sp00kyProcessor to our interface which is safer
210
+ this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
90
211
 
91
212
  // Try to load state
92
213
  await this.loadState();
93
214
 
94
215
  this.isInitialized = true;
95
216
  this.logger.info(
96
- { Category: 'spooky-client::StreamProcessorService::init' },
217
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
97
218
  'Initialized successfully'
98
219
  );
99
220
  } catch (e) {
100
221
  this.logger.error(
101
- { error: e, Category: 'spooky-client::StreamProcessorService::init' },
222
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::init' },
102
223
  'Failed to initialize'
103
224
  );
104
225
  throw e;
@@ -108,7 +229,7 @@ export class StreamProcessorService {
108
229
  async loadState() {
109
230
  if (!this.processor) return;
110
231
  try {
111
- const result = await this.persistenceClient.get('_spooky_stream_processor_state');
232
+ const result = await this.persistenceClient.get('_00_stream_processor_state');
112
233
 
113
234
  // Check if we have a valid result from the query
114
235
  if (
@@ -122,51 +243,97 @@ export class StreamProcessorService {
122
243
  this.logger.info(
123
244
  {
124
245
  stateLength: state.length,
125
- Category: 'spooky-client::StreamProcessorService::loadState',
246
+ Category: 'sp00ky-client::StreamProcessorService::loadState',
126
247
  },
127
248
  'Loading state from DB'
128
249
  );
129
250
  // Assuming processor has a load_state method matching the save_state behavior
130
251
  // 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);
252
+ if (typeof this.processor.load_state === 'function') {
253
+ this.processor.load_state(state);
133
254
  } else {
134
255
  this.logger.warn(
135
- { Category: 'spooky-client::StreamProcessorService::loadState' },
256
+ { Category: 'sp00ky-client::StreamProcessorService::loadState' },
136
257
  'load_state method not found on processor'
137
258
  );
138
259
  }
139
260
  } else {
140
261
  this.logger.info(
141
- { Category: 'spooky-client::StreamProcessorService::loadState' },
262
+ { Category: 'sp00ky-client::StreamProcessorService::loadState' },
142
263
  'No saved state found'
143
264
  );
144
265
  }
145
266
  } catch (e) {
146
267
  this.logger.error(
147
- { error: e, Category: 'spooky-client::StreamProcessorService::loadState' },
268
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::loadState' },
148
269
  'Failed to load state'
149
270
  );
150
271
  }
151
272
  }
152
273
 
274
+ /**
275
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
276
+ * Must run after the processor exists and before any `register_view`, else
277
+ * non-`_00_` tables are default-denied and registration fails.
278
+ */
279
+ setPermissions(permissions: Record<string, string>) {
280
+ if (!this.processor) return;
281
+ if (typeof this.processor.set_permissions !== 'function') {
282
+ this.logger.warn(
283
+ { Category: 'sp00ky-client::StreamProcessorService::setPermissions' },
284
+ 'set_permissions not found on processor (stale WASM build?)'
285
+ );
286
+ return;
287
+ }
288
+ this.processor.set_permissions(permissions);
289
+ this.logger.info(
290
+ {
291
+ tables: Object.keys(permissions).length,
292
+ Category: 'sp00ky-client::StreamProcessorService::setPermissions',
293
+ },
294
+ 'Seeded table permissions'
295
+ );
296
+ }
297
+
298
+ /**
299
+ * Set the current session's auth identity for permission injection,
300
+ * mirroring the server's `fn::query::register`
301
+ * (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
302
+ * Stored as strings (empty when logged out) and applied to every
303
+ * `register_view` in {@link registerQueryPlan}. Must be set before a
304
+ * `$auth`-gated query registers (and re-set on auth state changes), or the
305
+ * in-browser SSP's `permission_inject` rejects it with
306
+ * "requires $auth but registration params lack it".
307
+ */
308
+ setSessionAuth(authId: string | null, access: string | null) {
309
+ this.sessionAuth = { authId: authId ?? '', access: access ?? '' };
310
+ this.logger.debug(
311
+ {
312
+ authId: this.sessionAuth.authId,
313
+ access: this.sessionAuth.access,
314
+ Category: 'sp00ky-client::StreamProcessorService::setSessionAuth',
315
+ },
316
+ 'Session auth context updated'
317
+ );
318
+ }
319
+
153
320
  async saveState() {
154
321
  if (!this.processor) return;
155
322
  try {
156
323
  // 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();
324
+ if (typeof this.processor.save_state === 'function') {
325
+ const state = this.processor.save_state();
159
326
  if (state) {
160
- await this.persistenceClient.set('_spooky_stream_processor_state', state);
327
+ await this.persistenceClient.set('_00_stream_processor_state', state);
161
328
  this.logger.trace(
162
- { Category: 'spooky-client::StreamProcessorService::saveState' },
329
+ { Category: 'sp00ky-client::StreamProcessorService::saveState' },
163
330
  'State saved'
164
331
  );
165
332
  }
166
333
  }
167
334
  } catch (e) {
168
335
  this.logger.error(
169
- { error: e, Category: 'spooky-client::StreamProcessorService::saveState' },
336
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::saveState' },
170
337
  'Failed to save state'
171
338
  );
172
339
  }
@@ -188,14 +355,14 @@ export class StreamProcessorService {
188
355
  table,
189
356
  op,
190
357
  id,
191
- Category: 'spooky-client::StreamProcessorService::ingest',
358
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
192
359
  },
193
360
  'Ingesting into ssp'
194
361
  );
195
362
 
196
363
  if (!this.processor) {
197
364
  this.logger.warn(
198
- { Category: 'spooky-client::StreamProcessorService::ingest' },
365
+ { Category: 'sp00ky-client::StreamProcessorService::ingest' },
199
366
  'Not initialized, skipping ingest'
200
367
  );
201
368
  return [];
@@ -204,14 +371,17 @@ export class StreamProcessorService {
204
371
  try {
205
372
  const normalizedRecord = this.normalizeValue(record);
206
373
 
374
+ const t0 = performance.now();
207
375
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
376
+ const materializationTimeMs = performance.now() - t0;
208
377
  this.logger.debug(
209
378
  {
210
379
  table,
211
380
  op,
212
381
  id,
213
382
  rawUpdates: rawUpdates.length,
214
- Category: 'spooky-client::StreamProcessorService::ingest',
383
+ materializationTimeMs,
384
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
215
385
  },
216
386
  'Ingesting into ssp done'
217
387
  );
@@ -221,15 +391,23 @@ export class StreamProcessorService {
221
391
  queryHash: u.query_id,
222
392
  localArray: u.result_data,
223
393
  op: op,
394
+ materializationTimeMs,
395
+ storeApplyMs: u.timing_store_apply_ms,
396
+ circuitStepMs: u.timing_circuit_step_ms,
397
+ transformMs: u.timing_transform_ms,
224
398
  }));
225
399
  // Direct handler call instead of event
226
400
  this.notifyUpdates(updates);
227
401
  }
228
- this.saveState();
402
+ // While batching (inside `ingestMany`), `flushCoalescing` persists once
403
+ // for the whole batch — skip the redundant per-record snapshot here.
404
+ if (!this.batching) {
405
+ this.saveState();
406
+ }
229
407
  return rawUpdates;
230
408
  } catch (e) {
231
409
  this.logger.error(
232
- { error: e, Category: 'spooky-client::StreamProcessorService::ingest' },
410
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::ingest' },
233
411
  'Ingesting into ssp failed'
234
412
  );
235
413
  }
@@ -243,7 +421,7 @@ export class StreamProcessorService {
243
421
  registerQueryPlan(queryPlan: QueryPlanConfig) {
244
422
  if (!this.processor) {
245
423
  this.logger.warn(
246
- { Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
424
+ { Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
247
425
  'Not initialized, skipping registration'
248
426
  );
249
427
  return;
@@ -254,7 +432,7 @@ export class StreamProcessorService {
254
432
  queryHash: queryPlan.queryHash,
255
433
  surql: queryPlan.surql,
256
434
  params: queryPlan.params,
257
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
435
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
258
436
  },
259
437
  'Registering query plan'
260
438
  );
@@ -262,17 +440,30 @@ export class StreamProcessorService {
262
440
  try {
263
441
  const normalizedParams = this.normalizeValue(queryPlan.params);
264
442
 
443
+ // Mirror the server's `fn::query::register` auth injection so the
444
+ // in-browser SSP can resolve `$auth`/`$access` in a table's permission
445
+ // predicate. Without this, any `$auth`-gated table (e.g. `thread`) is
446
+ // rejected by `permission_inject` and its local view never materializes.
447
+ // Injected only into the params handed to the in-browser SSP — never into
448
+ // the persisted `queryState.config.params` / query hash / server payload
449
+ // (the server does its own injection), so the view id stays shared.
450
+ const paramsWithAuth = {
451
+ ...(normalizedParams as Record<string, unknown>),
452
+ auth: { id: this.sessionAuth.authId },
453
+ access: this.sessionAuth.access,
454
+ };
455
+
265
456
  const initialUpdate = this.processor.register_view({
266
457
  id: queryPlan.queryHash,
267
458
  surql: queryPlan.surql,
268
- params: normalizedParams,
459
+ params: paramsWithAuth,
269
460
  clientId: 'local',
270
461
  ttl: queryPlan.ttl.toString(),
271
462
  lastActiveAt: new Date().toISOString(),
272
463
  });
273
464
 
274
465
  this.logger.debug(
275
- { initialUpdate, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
466
+ { initialUpdate, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
276
467
  'register_view result'
277
468
  );
278
469
 
@@ -282,6 +473,11 @@ export class StreamProcessorService {
282
473
  const update: StreamUpdate = {
283
474
  queryHash: initialUpdate.query_id,
284
475
  localArray: initialUpdate.result_data,
476
+ registration: {
477
+ parseMs: initialUpdate.timing_parse_ms ?? 0,
478
+ planMs: initialUpdate.timing_plan_ms ?? 0,
479
+ snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
480
+ },
285
481
  };
286
482
  this.saveState();
287
483
  this.logger.debug(
@@ -289,14 +485,14 @@ export class StreamProcessorService {
289
485
  queryHash: queryPlan.queryHash,
290
486
  surql: queryPlan.surql,
291
487
  params: queryPlan.params,
292
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
488
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
293
489
  },
294
490
  'Registered query plan'
295
491
  );
296
492
  return update;
297
493
  } catch (e) {
298
494
  this.logger.error(
299
- { error: e, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
495
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
300
496
  'Error registering query plan'
301
497
  );
302
498
  throw e;
@@ -313,7 +509,7 @@ export class StreamProcessorService {
313
509
  this.saveState();
314
510
  } catch (e) {
315
511
  this.logger.error(
316
- { error: e, Category: 'spooky-client::StreamProcessorService::unregisterQueryPlan' },
512
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },
317
513
  'Error unregistering query plan'
318
514
  );
319
515
  }
@@ -323,6 +519,21 @@ export class StreamProcessorService {
323
519
  if (value === null || value === undefined) return value;
324
520
 
325
521
  if (typeof value === 'object') {
522
+ // CRDT snapshots arrive as `Uint8Array` (or `ArrayBuffer` /
523
+ // typed-array views). `serde_wasm_bindgen::from_value` rejects
524
+ // those when deserializing into `serde_json::Value` (JSON has no
525
+ // binary variant), and the SSP can't filter on opaque bytes
526
+ // anyway. Replace with `null` so the row still flows through the
527
+ // ingest path with its other columns intact, and downstream
528
+ // predicates referencing the bytes column simply don't match.
529
+ if (
530
+ value instanceof Uint8Array ||
531
+ value instanceof ArrayBuffer ||
532
+ ArrayBuffer.isView(value)
533
+ ) {
534
+ return null;
535
+ }
536
+
326
537
  // RecordId detection using duck typing (constructor.name may be minified)
327
538
  // SurrealDB's RecordId has: table (getter returning Table), id, and toString()
328
539
  // Check for table getter that has its own toString AND id property
@@ -334,7 +545,7 @@ export class StreamProcessorService {
334
545
  if (hasTable && hasId && hasToString && isNotPlainObject) {
335
546
  const result = value.toString();
336
547
  this.logger.trace(
337
- { result, Category: 'spooky-client::StreamProcessorService::normalizeValue' },
548
+ { result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
338
549
  'RecordId detected'
339
550
  );
340
551
  return result;
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractSelectPermissions } from './permissions';
3
+
4
+ describe('extractSelectPermissions', () => {
5
+ it('maps a permissive multi-action table to true', () => {
6
+ const surql = `DEFINE TABLE game SCHEMAFULL
7
+ PERMISSIONS FOR select, create, update, delete WHERE true;`;
8
+ expect(extractSelectPermissions(surql)).toEqual({ game: 'true' });
9
+ });
10
+
11
+ it('handles FULL and NONE', () => {
12
+ const surql = `
13
+ DEFINE TABLE a PERMISSIONS FULL;
14
+ DEFINE TABLE b PERMISSIONS NONE;`;
15
+ expect(extractSelectPermissions(surql)).toEqual({ a: 'true', b: 'false' });
16
+ });
17
+
18
+ it('extracts the select predicate from multiple FOR groups', () => {
19
+ const surql = `DEFINE TABLE doc SCHEMAFULL
20
+ PERMISSIONS
21
+ FOR create, update, delete WHERE owner = $auth.id
22
+ FOR select WHERE owner = $auth.id OR public = true;`;
23
+ expect(extractSelectPermissions(surql)).toEqual({
24
+ doc: 'owner = $auth.id OR public = true',
25
+ });
26
+ });
27
+
28
+ it('returns false when permissions exist but none grant select', () => {
29
+ const surql = `DEFINE TABLE log PERMISSIONS FOR create WHERE true;`;
30
+ expect(extractSelectPermissions(surql)).toEqual({ log: 'false' });
31
+ });
32
+
33
+ it('treats a missing PERMISSIONS clause as default-deny', () => {
34
+ const surql = `DEFINE TABLE bare SCHEMALESS;`;
35
+ expect(extractSelectPermissions(surql)).toEqual({ bare: 'false' });
36
+ });
37
+
38
+ it('ignores commented-out clauses and handles OVERWRITE / IF NOT EXISTS', () => {
39
+ const surql = `
40
+ -- DEFINE TABLE ghost PERMISSIONS FULL;
41
+ DEFINE TABLE OVERWRITE u PERMISSIONS FOR select WHERE true;
42
+ DEFINE TABLE IF NOT EXISTS v PERMISSIONS FULL;`;
43
+ const perms = extractSelectPermissions(surql);
44
+ expect(perms).toEqual({ u: 'true', v: 'true' });
45
+ expect(perms.ghost).toBeUndefined();
46
+ });
47
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Extract per-table `select` permission predicates from a raw `.surql` schema.
3
+ *
4
+ * The in-browser SSP default-denies any non-`_00_` table that has no permission
5
+ * predicate registered (see `permission_inject::build_predicate`). The client
6
+ * already holds the full schema text (`config.schemaSurql`), so we parse each
7
+ * `DEFINE TABLE … PERMISSIONS …` clause and hand the `select` predicate to the
8
+ * processor via `set_permissions` at boot — mirroring the native path that
9
+ * seeds the same map from `INFO FOR DB`.
10
+ *
11
+ * Returns `{ [table]: whereText }` where `whereText` is the raw SurrealQL
12
+ * expression (`'true'` for `FULL`, `'false'` for `NONE` or a table with no
13
+ * `select` permission).
14
+ */
15
+ export function extractSelectPermissions(schemaSurql: string): Record<string, string> {
16
+ const out: Record<string, string> = {};
17
+ if (!schemaSurql) return out;
18
+
19
+ // Drop line comments so a `--` comment can't contain a stray PERMISSIONS/FOR.
20
+ const cleaned = schemaSurql.replace(/--[^\n]*/g, '');
21
+
22
+ // One entry per `DEFINE TABLE … ;` statement.
23
+ const tableStmt = /DEFINE\s+TABLE\s+(?:OVERWRITE\s+|IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][\w]*)\b([^;]*);/gi;
24
+ let m: RegExpExecArray | null;
25
+ while ((m = tableStmt.exec(cleaned)) !== null) {
26
+ const table = m[1];
27
+ const body = m[2];
28
+ out[table] = selectPredicateFromBody(body);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function selectPredicateFromBody(body: string): string {
34
+ const permIdx = body.search(/\bPERMISSIONS\b/i);
35
+ if (permIdx === -1) return 'false'; // no clause → SurrealDB default-deny
36
+ const perms = body.slice(permIdx + 'PERMISSIONS'.length).trim();
37
+
38
+ if (/^FULL\b/i.test(perms)) return 'true';
39
+ if (/^NONE\b/i.test(perms)) return 'false';
40
+
41
+ // `FOR <actions> WHERE <expr>` groups, possibly several. Split on the FOR
42
+ // boundary; the leading empty segment (before the first FOR) is ignored.
43
+ const groups = perms.split(/\bFOR\b/i).map((g) => g.trim()).filter(Boolean);
44
+ for (const group of groups) {
45
+ const where = group.search(/\bWHERE\b/i);
46
+ if (where === -1) continue;
47
+ const actions = group.slice(0, where).toLowerCase();
48
+ if (/\bselect\b/.test(actions)) {
49
+ return group.slice(where + 'WHERE'.length).trim();
50
+ }
51
+ }
52
+ return 'false'; // permissions present but none grant select
53
+ }