@spooky-sync/core 0.0.1-canary.8 → 0.0.1-canary.80

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 +854 -339
  3. package/dist/index.js +2633 -396
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +494 -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 +726 -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/feature-flag/index.ts +166 -0
  30. package/src/modules/ref-tables.test.ts +56 -0
  31. package/src/modules/ref-tables.ts +57 -0
  32. package/src/modules/sync/engine.ts +97 -37
  33. package/src/modules/sync/events/index.ts +3 -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 +2 -2
  37. package/src/modules/sync/sync.ts +676 -58
  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 +625 -0
  59. package/src/types.ts +140 -13
  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
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { StreamProcessorService } from './index';
3
+ import type { StreamUpdate, StreamUpdateReceiver } from './index';
4
+ import type { WasmProcessor, WasmStreamUpdate } from './wasm-types';
5
+
6
+ /**
7
+ * Tests for `ingestMany` bulk insert on StreamProcessorService.
8
+ *
9
+ * A batched ingest (e.g. sync fetching N missing records) used to emit one
10
+ * stream update per record, making the UI render row-by-row. ingestMany
11
+ * collapses those into a single coalesced update per affected query.
12
+ */
13
+
14
+ function makeLogger(): any {
15
+ const noop = () => {};
16
+ const logger: any = {
17
+ debug: noop,
18
+ info: noop,
19
+ warn: noop,
20
+ error: noop,
21
+ trace: noop,
22
+ };
23
+ logger.child = () => logger;
24
+ return logger;
25
+ }
26
+
27
+ /** Records every dispatched update so we can count notifications. */
28
+ class RecordingReceiver implements StreamUpdateReceiver {
29
+ public received: StreamUpdate[] = [];
30
+ onStreamUpdate(update: StreamUpdate): void {
31
+ this.received.push(update);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Build a service with a stubbed WASM processor. The processor accumulates
37
+ * ingested ids and returns the *full* materialized array on every call (as the
38
+ * real WASM does), so coalescing's last-write-wins must yield the final array.
39
+ */
40
+ function makeService(queryHashesFor: (id: string) => string[]) {
41
+ const svc = new StreamProcessorService(
42
+ {} as any,
43
+ {} as any,
44
+ { get: async () => undefined, set: async () => {} } as any,
45
+ makeLogger()
46
+ );
47
+
48
+ const ingestedByQuery = new Map<string, Array<[string, number]>>();
49
+ const mockProcessor: Partial<WasmProcessor> = {
50
+ ingest: (_table, _op, id, record: any): WasmStreamUpdate[] => {
51
+ const version = record?._00_rv ?? 1;
52
+ const updates: WasmStreamUpdate[] = [];
53
+ for (const queryHash of queryHashesFor(id)) {
54
+ const arr = ingestedByQuery.get(queryHash) ?? [];
55
+ arr.push([id, version]);
56
+ ingestedByQuery.set(queryHash, arr);
57
+ updates.push({ query_id: queryHash, result_data: [...arr] });
58
+ }
59
+ return updates;
60
+ },
61
+ };
62
+ // processor is private and normally set by init() via WASM; inject the stub.
63
+ (svc as any).processor = mockProcessor;
64
+ return svc;
65
+ }
66
+
67
+ describe('StreamProcessor ingestMany bulk insert', () => {
68
+ let receiver: RecordingReceiver;
69
+
70
+ beforeEach(() => {
71
+ receiver = new RecordingReceiver();
72
+ });
73
+
74
+ it('emits one update per record when ingesting one-by-one (baseline)', () => {
75
+ const svc = makeService(() => ['q1']);
76
+ svc.addReceiver(receiver);
77
+
78
+ svc.ingest('user', 'CREATE', 'user:1', { id: 'user:1', _00_rv: 1 });
79
+ svc.ingest('user', 'CREATE', 'user:2', { id: 'user:2', _00_rv: 1 });
80
+ svc.ingest('user', 'CREATE', 'user:3', { id: 'user:3', _00_rv: 1 });
81
+
82
+ expect(receiver.received).toHaveLength(3);
83
+ });
84
+
85
+ it('coalesces a bulk insert into a single update per query with the final array', () => {
86
+ const svc = makeService(() => ['q1']);
87
+ svc.addReceiver(receiver);
88
+
89
+ svc.ingestMany([
90
+ { table: 'user', op: 'CREATE', id: 'user:1', record: { id: 'user:1', _00_rv: 1 } },
91
+ { table: 'user', op: 'CREATE', id: 'user:2', record: { id: 'user:2', _00_rv: 1 } },
92
+ { table: 'user', op: 'CREATE', id: 'user:3', record: { id: 'user:3', _00_rv: 1 } },
93
+ ]);
94
+
95
+ // Nothing dispatched until the whole batch is ingested, then exactly one
96
+ // coalesced update.
97
+ expect(receiver.received).toHaveLength(1);
98
+ const update = receiver.received[0];
99
+ expect(update.queryHash).toBe('q1');
100
+ // Last-write-wins carries the full materialized array (all 3 records).
101
+ expect(update.localArray).toHaveLength(3);
102
+ // Coalesced updates take DataModule's immediate (non-debounced) path.
103
+ expect(update.op).toBe('CREATE');
104
+ expect(typeof update.materializationTimeMs).toBe('number');
105
+ });
106
+
107
+ it('coalesces independently per affected query', () => {
108
+ // user:1 -> q1, user:2 -> q1 & q2, user:3 -> q2
109
+ const svc = makeService((id) =>
110
+ id === 'user:1' ? ['q1'] : id === 'user:2' ? ['q1', 'q2'] : ['q2']
111
+ );
112
+ svc.addReceiver(receiver);
113
+
114
+ svc.ingestMany([
115
+ { table: 'user', op: 'CREATE', id: 'user:1', record: { id: 'user:1', _00_rv: 1 } },
116
+ { table: 'user', op: 'CREATE', id: 'user:2', record: { id: 'user:2', _00_rv: 1 } },
117
+ { table: 'user', op: 'CREATE', id: 'user:3', record: { id: 'user:3', _00_rv: 1 } },
118
+ ]);
119
+
120
+ expect(receiver.received).toHaveLength(2);
121
+ const byHash = Object.fromEntries(receiver.received.map((u) => [u.queryHash, u]));
122
+ expect(Object.keys(byHash).sort()).toEqual(['q1', 'q2']);
123
+ });
124
+
125
+ it('is a no-op for an empty batch and leaves the window closed', () => {
126
+ const svc = makeService(() => ['q1']);
127
+ svc.addReceiver(receiver);
128
+
129
+ svc.ingestMany([]);
130
+
131
+ expect(receiver.received).toHaveLength(0);
132
+ // A subsequent single ingest dispatches normally (window never opened).
133
+ svc.ingest('user', 'CREATE', 'user:1', { id: 'user:1', _00_rv: 1 });
134
+ expect(receiver.received).toHaveLength(1);
135
+ });
136
+ });
@@ -127,7 +127,7 @@ describe('StreamProcessor Ingest Behavior', () => {
127
127
  const params = { id: new MockRecordId('user', '2dng4ngbicbl0scod87i') };
128
128
  const normalizedParams = normalizeValue(params);
129
129
 
130
- const ingestedRecord = {
130
+ const _ingestedRecord = {
131
131
  id: 'user:2dng4ngbicbl0scod87i',
132
132
  username: 'sara',
133
133
  };
@@ -1,9 +1,17 @@
1
- import { RecordVersionArray } from '../../types';
1
+ import type { RecordVersionArray } from '../../types';
2
2
 
3
3
  export interface WasmStreamUpdate {
4
4
  query_id: string;
5
5
  result_hash: string;
6
6
  result_data: RecordVersionArray; // Match Rust 'result_data' field
7
+ // Per-phase SSP processing time (ms). Ingest path: store_apply/circuit_step/
8
+ // transform. Register path: parse/plan/snapshot. Unused side is 0.
9
+ timing_store_apply_ms?: number;
10
+ timing_circuit_step_ms?: number;
11
+ timing_transform_ms?: number;
12
+ timing_parse_ms?: number;
13
+ timing_plan_ms?: number;
14
+ timing_snapshot_ms?: number;
7
15
  }
8
16
 
9
17
  export interface WasmQueryConfig {
@@ -23,9 +31,17 @@ export interface WasmIngestItem {
23
31
  version?: number;
24
32
  }
25
33
 
26
- // Interface matching the SpookyProcessor class from WASM
34
+ // Interface matching the Sp00kyProcessor class from WASM
27
35
  export interface WasmProcessor {
28
36
  ingest(table: string, op: string, id: string, record: any): WasmStreamUpdate[];
29
37
  register_view(config: WasmQueryConfig): WasmStreamUpdate | undefined;
30
38
  unregister_view(id: string): void;
39
+ // Seed per-table `select` permission predicates ({ [table]: whereText }) so
40
+ // register_view can inject them instead of default-denying the table.
41
+ set_permissions(permissions: Record<string, string>): void;
42
+ // Persistence hooks: present on current WASM builds, absent on stale ones.
43
+ // Always guarded with `typeof x === 'function'` before calling so an older
44
+ // build degrades gracefully instead of throwing.
45
+ load_state?(state: string): void;
46
+ save_state?(): string;
31
47
  }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+
5
+ // Regression guard for the auth-callback ordering bug we fixed in
6
+ // `Sp00kyClient.init()`. The contract:
7
+ //
8
+ // this.auth.subscribe(async (userId) => {
9
+ // this.dataModule.setCurrentUserId(userId); // ← must be first
10
+ // // ... awaits and other work ...
11
+ // });
12
+ //
13
+ // If any future refactor moves `setCurrentUserId` after the first
14
+ // `await`, the AuthProvider's sibling subscriber gets to run with a
15
+ // stale `dataModule.currentUserId` and registers queries against the
16
+ // wrong `_00_query[_user_*]` / `_00_list_ref_user_<id>` tables.
17
+ //
18
+ // A runtime mock test for this is heavy — Sp00kyClient drags in
19
+ // SurrealDB, the WASM SSP, the persistence client, etc. A
20
+ // structural regex test is enough: it catches the only failure mode
21
+ // (someone moves the synchronous setCurrentUserId call) without
22
+ // needing the full graph.
23
+
24
+ describe('Sp00kyClient.auth.subscribe ordering invariant', () => {
25
+ const sourcePath = resolve(__dirname, 'sp00ky.ts');
26
+ const source = readFileSync(sourcePath, 'utf-8');
27
+
28
+ it('source file is readable', () => {
29
+ expect(source.length).toBeGreaterThan(0);
30
+ });
31
+
32
+ it('auth.subscribe sets currentUserId before any await', () => {
33
+ // Find the auth.subscribe arrow body. Match the contents of the
34
+ // outermost `{}` after `this.auth.subscribe(async (userId) => `.
35
+ const match = source.match(
36
+ /this\.auth\.subscribe\(\s*async\s*\(\s*userId\s*[^)]*\)\s*=>\s*\{([\s\S]*?)\n {6}\}\s*\)/
37
+ );
38
+ expect(
39
+ match,
40
+ 'expected an auth.subscribe(async (userId) => { ... }) block in sp00ky.ts'
41
+ ).not.toBeNull();
42
+
43
+ const body = match![1];
44
+
45
+ // Strip line comments so a stray `//` doesn't trick the regex.
46
+ const stripped = body
47
+ .split('\n')
48
+ .map((line) => line.replace(/\/\/.*$/, ''))
49
+ .join('\n');
50
+
51
+ const setUserIdIdx = stripped.indexOf('this.dataModule.setCurrentUserId(userId)');
52
+ expect(
53
+ setUserIdIdx,
54
+ 'dataModule.setCurrentUserId(userId) must appear in the auth.subscribe body'
55
+ ).toBeGreaterThanOrEqual(0);
56
+
57
+ const firstAwaitIdx = stripped.search(/\bawait\b/);
58
+ if (firstAwaitIdx >= 0) {
59
+ expect(
60
+ setUserIdIdx,
61
+ `dataModule.setCurrentUserId(userId) (idx ${setUserIdIdx}) must come BEFORE the first \`await\` (idx ${firstAwaitIdx}) so the AuthProvider's sibling subscriber sees a fresh user id when it fires synchronously after our callback. Moving the call past an \`await\` reintroduces the stale-user-id race documented in docs/surrealdb-bugs/ if a related gap is found, and the prior session's retrospective.`
62
+ ).toBeLessThan(firstAwaitIdx);
63
+ }
64
+ });
65
+ });