@spooky-sync/core 0.0.1-canary.7 → 0.0.1-canary.71

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 (64) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +299 -369
  3. package/dist/index.js +2278 -399
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +460 -0
  7. package/package.json +37 -7
  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 +3 -2
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +17 -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 +662 -108
  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 +89 -21
  27. package/src/modules/devtools/versions.test.ts +74 -0
  28. package/src/modules/devtools/versions.ts +81 -0
  29. package/src/modules/ref-tables.test.ts +56 -0
  30. package/src/modules/ref-tables.ts +57 -0
  31. package/src/modules/sync/engine.ts +97 -37
  32. package/src/modules/sync/events/index.ts +3 -2
  33. package/src/modules/sync/queue/queue-down.ts +5 -4
  34. package/src/modules/sync/queue/queue-up.ts +14 -13
  35. package/src/modules/sync/scheduler.ts +2 -2
  36. package/src/modules/sync/sync.ts +553 -58
  37. package/src/modules/sync/utils.test.ts +239 -2
  38. package/src/modules/sync/utils.ts +166 -17
  39. package/src/otel/index.ts +127 -0
  40. package/src/services/database/database.ts +11 -11
  41. package/src/services/database/events/index.ts +2 -1
  42. package/src/services/database/local-migrator.ts +21 -21
  43. package/src/services/database/local.test.ts +33 -0
  44. package/src/services/database/local.ts +192 -32
  45. package/src/services/database/remote.ts +13 -13
  46. package/src/services/logger/index.ts +6 -101
  47. package/src/services/persistence/localstorage.ts +2 -2
  48. package/src/services/persistence/resilient.ts +41 -0
  49. package/src/services/persistence/surrealdb.ts +9 -9
  50. package/src/services/stream-processor/index.ts +205 -36
  51. package/src/services/stream-processor/permissions.test.ts +47 -0
  52. package/src/services/stream-processor/permissions.ts +53 -0
  53. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  54. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  55. package/src/services/stream-processor/wasm-types.ts +18 -2
  56. package/src/sp00ky.auth-order.test.ts +65 -0
  57. package/src/sp00ky.ts +582 -0
  58. package/src/types.ts +132 -13
  59. package/src/utils/index.ts +35 -13
  60. package/src/utils/parser.ts +3 -2
  61. package/src/utils/surql.ts +24 -15
  62. package/src/utils/withRetry.test.ts +1 -1
  63. package/tsdown.config.ts +55 -1
  64. package/src/spooky.ts +0 -392
@@ -1,7 +1,7 @@
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 { AbstractDatabaseService } from '../database/database';
5
5
 
6
6
  export class SurrealDBPersistenceClient implements PersistenceClient {
7
7
  private logger: Logger;
@@ -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 { 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,12 @@ 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();
49
71
 
50
72
  constructor(
51
73
  public events: EventSystem<StreamProcessorEvents>,
@@ -65,6 +87,32 @@ export class StreamProcessorService {
65
87
  }
66
88
 
67
89
  private notifyUpdates(updates: StreamUpdate[]) {
90
+ if (this.batching) {
91
+ // Coalesce by queryHash instead of dispatching. The WASM `result_data`
92
+ // (localArray) is the full materialized array, so last-write-wins
93
+ // already reflects every prior ingest in the batch. We sum the
94
+ // materialization times so the single recorded sample reflects the
95
+ // batch's total work, and emit `op: 'CREATE'` on flush so the coalesced
96
+ // update takes DataModule's immediate (non-debounced) path.
97
+ for (const update of updates) {
98
+ const prev = this.batchBuffer.get(update.queryHash);
99
+ const sum = (a?: number, b?: number) => (a ?? 0) + (b ?? 0);
100
+ this.batchBuffer.set(update.queryHash, {
101
+ ...update,
102
+ op: 'CREATE',
103
+ materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
104
+ storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
105
+ circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
106
+ transformMs: sum(prev?.transformMs, update.transformMs),
107
+ });
108
+ }
109
+ return;
110
+ }
111
+
112
+ this.dispatchUpdates(updates);
113
+ }
114
+
115
+ private dispatchUpdates(updates: StreamUpdate[]) {
68
116
  for (const update of updates) {
69
117
  for (const receiver of this.receivers) {
70
118
  receiver.onStreamUpdate(update);
@@ -72,6 +120,72 @@ export class StreamProcessorService {
72
120
  }
73
121
  }
74
122
 
123
+ /**
124
+ * Ingest a batch of record changes as a single bulk operation, firing only
125
+ * one coalesced `StreamUpdate` per affected query once every record has been
126
+ * ingested (instead of one update per record). Use this whenever multiple
127
+ * records land at once — e.g. sync fetching N missing rows — so a list query
128
+ * re-runs and the UI re-renders once for the whole batch rather than
129
+ * row-by-row.
130
+ *
131
+ * Internally opens a coalescing window, ingests each record, then flushes;
132
+ * processor state is persisted once for the whole batch. No-op for an empty
133
+ * batch.
134
+ */
135
+ ingestMany(
136
+ records: Array<{
137
+ table: string;
138
+ op: 'CREATE' | 'UPDATE' | 'DELETE';
139
+ id: string;
140
+ record: any;
141
+ }>
142
+ ): void {
143
+ if (records.length === 0) return;
144
+
145
+ this.beginCoalescing();
146
+ try {
147
+ for (const record of records) {
148
+ this.ingest(record.table, record.op, record.id, record.record);
149
+ }
150
+ } finally {
151
+ this.flushCoalescing();
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Open a coalescing window. While open, the per-record stream updates
157
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
158
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
159
+ * `ingestMany` so the window always closes — otherwise the processor stays
160
+ * stuck buffering forever.
161
+ *
162
+ * No-op if a window is already open (nested batches aren't expected here).
163
+ */
164
+ private beginCoalescing() {
165
+ if (this.batching) return;
166
+ this.batching = true;
167
+ this.batchBuffer.clear();
168
+ }
169
+
170
+ /**
171
+ * Close the coalescing window and flush: dispatch one coalesced
172
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
173
+ * for the whole batch (instead of once per ingest).
174
+ */
175
+ private flushCoalescing() {
176
+ if (!this.batching) return;
177
+ this.batching = false;
178
+ const buffered = Array.from(this.batchBuffer.values());
179
+ this.batchBuffer.clear();
180
+ if (buffered.length > 0) {
181
+ this.dispatchUpdates(buffered);
182
+ }
183
+ // The processor state after the last ingest is cumulative, so a single
184
+ // snapshot covers the whole batch. Kept fire-and-forget like the per-ingest
185
+ // call it replaces.
186
+ this.saveState();
187
+ }
188
+
75
189
  /**
76
190
  * Initialize the WASM module and processor.
77
191
  * This must be called before using other methods.
@@ -80,25 +194,25 @@ export class StreamProcessorService {
80
194
  if (this.isInitialized) return;
81
195
 
82
196
  this.logger.info(
83
- { Category: 'spooky-client::StreamProcessorService::init' },
197
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
84
198
  'Initializing WASM...'
85
199
  );
86
200
  try {
87
201
  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;
202
+ // We cast the generated Sp00kyProcessor to our interface which is safer
203
+ this.processor = new Sp00kyProcessor() as unknown as WasmProcessor;
90
204
 
91
205
  // Try to load state
92
206
  await this.loadState();
93
207
 
94
208
  this.isInitialized = true;
95
209
  this.logger.info(
96
- { Category: 'spooky-client::StreamProcessorService::init' },
210
+ { Category: 'sp00ky-client::StreamProcessorService::init' },
97
211
  'Initialized successfully'
98
212
  );
99
213
  } catch (e) {
100
214
  this.logger.error(
101
- { error: e, Category: 'spooky-client::StreamProcessorService::init' },
215
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::init' },
102
216
  'Failed to initialize'
103
217
  );
104
218
  throw e;
@@ -108,7 +222,7 @@ export class StreamProcessorService {
108
222
  async loadState() {
109
223
  if (!this.processor) return;
110
224
  try {
111
- const result = await this.persistenceClient.get('_spooky_stream_processor_state');
225
+ const result = await this.persistenceClient.get('_00_stream_processor_state');
112
226
 
113
227
  // Check if we have a valid result from the query
114
228
  if (
@@ -122,51 +236,75 @@ export class StreamProcessorService {
122
236
  this.logger.info(
123
237
  {
124
238
  stateLength: state.length,
125
- Category: 'spooky-client::StreamProcessorService::loadState',
239
+ Category: 'sp00ky-client::StreamProcessorService::loadState',
126
240
  },
127
241
  'Loading state from DB'
128
242
  );
129
243
  // Assuming processor has a load_state method matching the save_state behavior
130
244
  // 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);
245
+ if (typeof this.processor.load_state === 'function') {
246
+ this.processor.load_state(state);
133
247
  } else {
134
248
  this.logger.warn(
135
- { Category: 'spooky-client::StreamProcessorService::loadState' },
249
+ { Category: 'sp00ky-client::StreamProcessorService::loadState' },
136
250
  'load_state method not found on processor'
137
251
  );
138
252
  }
139
253
  } else {
140
254
  this.logger.info(
141
- { Category: 'spooky-client::StreamProcessorService::loadState' },
255
+ { Category: 'sp00ky-client::StreamProcessorService::loadState' },
142
256
  'No saved state found'
143
257
  );
144
258
  }
145
259
  } catch (e) {
146
260
  this.logger.error(
147
- { error: e, Category: 'spooky-client::StreamProcessorService::loadState' },
261
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::loadState' },
148
262
  'Failed to load state'
149
263
  );
150
264
  }
151
265
  }
152
266
 
267
+ /**
268
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
269
+ * Must run after the processor exists and before any `register_view`, else
270
+ * non-`_00_` tables are default-denied and registration fails.
271
+ */
272
+ setPermissions(permissions: Record<string, string>) {
273
+ if (!this.processor) return;
274
+ if (typeof this.processor.set_permissions !== 'function') {
275
+ this.logger.warn(
276
+ { Category: 'sp00ky-client::StreamProcessorService::setPermissions' },
277
+ 'set_permissions not found on processor (stale WASM build?)'
278
+ );
279
+ return;
280
+ }
281
+ this.processor.set_permissions(permissions);
282
+ this.logger.info(
283
+ {
284
+ tables: Object.keys(permissions).length,
285
+ Category: 'sp00ky-client::StreamProcessorService::setPermissions',
286
+ },
287
+ 'Seeded table permissions'
288
+ );
289
+ }
290
+
153
291
  async saveState() {
154
292
  if (!this.processor) return;
155
293
  try {
156
294
  // 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();
295
+ if (typeof this.processor.save_state === 'function') {
296
+ const state = this.processor.save_state();
159
297
  if (state) {
160
- await this.persistenceClient.set('_spooky_stream_processor_state', state);
298
+ await this.persistenceClient.set('_00_stream_processor_state', state);
161
299
  this.logger.trace(
162
- { Category: 'spooky-client::StreamProcessorService::saveState' },
300
+ { Category: 'sp00ky-client::StreamProcessorService::saveState' },
163
301
  'State saved'
164
302
  );
165
303
  }
166
304
  }
167
305
  } catch (e) {
168
306
  this.logger.error(
169
- { error: e, Category: 'spooky-client::StreamProcessorService::saveState' },
307
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::saveState' },
170
308
  'Failed to save state'
171
309
  );
172
310
  }
@@ -188,14 +326,14 @@ export class StreamProcessorService {
188
326
  table,
189
327
  op,
190
328
  id,
191
- Category: 'spooky-client::StreamProcessorService::ingest',
329
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
192
330
  },
193
331
  'Ingesting into ssp'
194
332
  );
195
333
 
196
334
  if (!this.processor) {
197
335
  this.logger.warn(
198
- { Category: 'spooky-client::StreamProcessorService::ingest' },
336
+ { Category: 'sp00ky-client::StreamProcessorService::ingest' },
199
337
  'Not initialized, skipping ingest'
200
338
  );
201
339
  return [];
@@ -204,14 +342,17 @@ export class StreamProcessorService {
204
342
  try {
205
343
  const normalizedRecord = this.normalizeValue(record);
206
344
 
345
+ const t0 = performance.now();
207
346
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
347
+ const materializationTimeMs = performance.now() - t0;
208
348
  this.logger.debug(
209
349
  {
210
350
  table,
211
351
  op,
212
352
  id,
213
353
  rawUpdates: rawUpdates.length,
214
- Category: 'spooky-client::StreamProcessorService::ingest',
354
+ materializationTimeMs,
355
+ Category: 'sp00ky-client::StreamProcessorService::ingest',
215
356
  },
216
357
  'Ingesting into ssp done'
217
358
  );
@@ -221,15 +362,23 @@ export class StreamProcessorService {
221
362
  queryHash: u.query_id,
222
363
  localArray: u.result_data,
223
364
  op: op,
365
+ materializationTimeMs,
366
+ storeApplyMs: u.timing_store_apply_ms,
367
+ circuitStepMs: u.timing_circuit_step_ms,
368
+ transformMs: u.timing_transform_ms,
224
369
  }));
225
370
  // Direct handler call instead of event
226
371
  this.notifyUpdates(updates);
227
372
  }
228
- this.saveState();
373
+ // While batching (inside `ingestMany`), `flushCoalescing` persists once
374
+ // for the whole batch — skip the redundant per-record snapshot here.
375
+ if (!this.batching) {
376
+ this.saveState();
377
+ }
229
378
  return rawUpdates;
230
379
  } catch (e) {
231
380
  this.logger.error(
232
- { error: e, Category: 'spooky-client::StreamProcessorService::ingest' },
381
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::ingest' },
233
382
  'Ingesting into ssp failed'
234
383
  );
235
384
  }
@@ -243,7 +392,7 @@ export class StreamProcessorService {
243
392
  registerQueryPlan(queryPlan: QueryPlanConfig) {
244
393
  if (!this.processor) {
245
394
  this.logger.warn(
246
- { Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
395
+ { Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
247
396
  'Not initialized, skipping registration'
248
397
  );
249
398
  return;
@@ -254,7 +403,7 @@ export class StreamProcessorService {
254
403
  queryHash: queryPlan.queryHash,
255
404
  surql: queryPlan.surql,
256
405
  params: queryPlan.params,
257
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
406
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
258
407
  },
259
408
  'Registering query plan'
260
409
  );
@@ -272,7 +421,7 @@ export class StreamProcessorService {
272
421
  });
273
422
 
274
423
  this.logger.debug(
275
- { initialUpdate, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
424
+ { initialUpdate, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
276
425
  'register_view result'
277
426
  );
278
427
 
@@ -282,6 +431,11 @@ export class StreamProcessorService {
282
431
  const update: StreamUpdate = {
283
432
  queryHash: initialUpdate.query_id,
284
433
  localArray: initialUpdate.result_data,
434
+ registration: {
435
+ parseMs: initialUpdate.timing_parse_ms ?? 0,
436
+ planMs: initialUpdate.timing_plan_ms ?? 0,
437
+ snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
438
+ },
285
439
  };
286
440
  this.saveState();
287
441
  this.logger.debug(
@@ -289,14 +443,14 @@ export class StreamProcessorService {
289
443
  queryHash: queryPlan.queryHash,
290
444
  surql: queryPlan.surql,
291
445
  params: queryPlan.params,
292
- Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
446
+ Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan',
293
447
  },
294
448
  'Registered query plan'
295
449
  );
296
450
  return update;
297
451
  } catch (e) {
298
452
  this.logger.error(
299
- { error: e, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
453
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::registerQueryPlan' },
300
454
  'Error registering query plan'
301
455
  );
302
456
  throw e;
@@ -313,7 +467,7 @@ export class StreamProcessorService {
313
467
  this.saveState();
314
468
  } catch (e) {
315
469
  this.logger.error(
316
- { error: e, Category: 'spooky-client::StreamProcessorService::unregisterQueryPlan' },
470
+ { error: e, Category: 'sp00ky-client::StreamProcessorService::unregisterQueryPlan' },
317
471
  'Error unregistering query plan'
318
472
  );
319
473
  }
@@ -323,6 +477,21 @@ export class StreamProcessorService {
323
477
  if (value === null || value === undefined) return value;
324
478
 
325
479
  if (typeof value === 'object') {
480
+ // CRDT snapshots arrive as `Uint8Array` (or `ArrayBuffer` /
481
+ // typed-array views). `serde_wasm_bindgen::from_value` rejects
482
+ // those when deserializing into `serde_json::Value` (JSON has no
483
+ // binary variant), and the SSP can't filter on opaque bytes
484
+ // anyway. Replace with `null` so the row still flows through the
485
+ // ingest path with its other columns intact, and downstream
486
+ // predicates referencing the bytes column simply don't match.
487
+ if (
488
+ value instanceof Uint8Array ||
489
+ value instanceof ArrayBuffer ||
490
+ ArrayBuffer.isView(value)
491
+ ) {
492
+ return null;
493
+ }
494
+
326
495
  // RecordId detection using duck typing (constructor.name may be minified)
327
496
  // SurrealDB's RecordId has: table (getter returning Table), id, and toString()
328
497
  // Check for table getter that has its own toString AND id property
@@ -334,7 +503,7 @@ export class StreamProcessorService {
334
503
  if (hasTable && hasId && hasToString && isNotPlainObject) {
335
504
  const result = value.toString();
336
505
  this.logger.trace(
337
- { result, Category: 'spooky-client::StreamProcessorService::normalizeValue' },
506
+ { result, Category: 'sp00ky-client::StreamProcessorService::normalizeValue' },
338
507
  'RecordId detected'
339
508
  );
340
509
  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
+ }