@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.
- package/AGENTS.md +56 -0
- package/dist/index.d.ts +854 -339
- package/dist/index.js +2633 -396
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +494 -0
- package/package.json +39 -9
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +9 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +41 -29
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +281 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +352 -0
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +726 -108
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +130 -0
- package/src/modules/devtools/index.ts +89 -21
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.ts +166 -0
- package/src/modules/ref-tables.test.ts +56 -0
- package/src/modules/ref-tables.ts +57 -0
- package/src/modules/sync/engine.ts +97 -37
- package/src/modules/sync/events/index.ts +3 -2
- package/src/modules/sync/queue/queue-down.ts +5 -4
- package/src/modules/sync/queue/queue-up.ts +14 -13
- package/src/modules/sync/scheduler.ts +2 -2
- package/src/modules/sync/sync.ts +676 -58
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/local-migrator.ts +21 -21
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +192 -32
- package/src/services/database/remote.ts +13 -13
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +9 -9
- package/src/services/stream-processor/index.ts +248 -37
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +625 -0
- package/src/types.ts +140 -13
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +55 -1
- 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
|
|
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
|
|
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
|
+
});
|