@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.130
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 +1171 -372
- package/dist/index.js +5716 -1278
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +107 -0
- package/dist/types.d.ts +746 -0
- package/package.json +39 -8
- 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 +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +288 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +357 -0
- package/src/modules/data/data.hydration.test.ts +150 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.recurring.test.ts +137 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1228 -127
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- 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/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +452 -66
- package/src/services/database/plan-render.test.ts +133 -0
- package/src/services/database/plan-render.ts +100 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +85 -0
- package/src/services/database/sqlite-cache-engine.ts +699 -0
- package/src/services/database/sqlite-worker.ts +116 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +122 -0
- 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 +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- 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.reset.test.ts +104 -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 +92 -0
- package/src/sp00ky.init-query.test.ts +185 -0
- package/src/sp00ky.ts +1016 -0
- package/src/types.ts +258 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- 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 +77 -1
- package/src/spooky.ts +0 -392
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// `StreamProcessorService.reset()` is the SSP half of a local-bucket switch:
|
|
4
|
+
// the old circuit holds the previous user's rows and views registered with the
|
|
5
|
+
// previous `$auth`, so reset must swap in a FRESH processor (no state load),
|
|
6
|
+
// and a `saveState` racing the reset must not persist the old circuit into the
|
|
7
|
+
// new bucket's key.
|
|
8
|
+
|
|
9
|
+
const processorInstances: any[] = [];
|
|
10
|
+
|
|
11
|
+
vi.mock('@spooky-sync/ssp-wasm', () => ({
|
|
12
|
+
default: vi.fn(async () => {}),
|
|
13
|
+
Sp00kyProcessor: vi.fn(() => {
|
|
14
|
+
const instance = {
|
|
15
|
+
ingest: vi.fn(() => []),
|
|
16
|
+
register_view: vi.fn(() => ({ query_id: 'q1', result_data: [] })),
|
|
17
|
+
unregister_view: vi.fn(),
|
|
18
|
+
set_permissions: vi.fn(),
|
|
19
|
+
save_state: vi.fn(() => 'state-bytes'),
|
|
20
|
+
load_state: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
processorInstances.push(instance);
|
|
23
|
+
return instance;
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { StreamProcessorService } from './index';
|
|
28
|
+
|
|
29
|
+
const silentLogger = {
|
|
30
|
+
child: () => silentLogger,
|
|
31
|
+
trace: () => {},
|
|
32
|
+
debug: () => {},
|
|
33
|
+
info: () => {},
|
|
34
|
+
warn: () => {},
|
|
35
|
+
error: () => {},
|
|
36
|
+
} as any;
|
|
37
|
+
|
|
38
|
+
function makeService() {
|
|
39
|
+
const persisted: Record<string, unknown> = {};
|
|
40
|
+
const persistence = {
|
|
41
|
+
set: vi.fn(async (key: string, value: unknown) => {
|
|
42
|
+
persisted[key] = value;
|
|
43
|
+
}),
|
|
44
|
+
get: vi.fn(async () => null),
|
|
45
|
+
remove: vi.fn(async () => {}),
|
|
46
|
+
};
|
|
47
|
+
const service = new StreamProcessorService({} as any, {} as any, persistence as any, silentLogger);
|
|
48
|
+
return { service, persistence, persisted };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
processorInstances.length = 0;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('StreamProcessorService.reset', () => {
|
|
56
|
+
it('swaps in a fresh processor without loading persisted state', async () => {
|
|
57
|
+
const { service } = makeService();
|
|
58
|
+
await service.init();
|
|
59
|
+
expect(processorInstances).toHaveLength(1);
|
|
60
|
+
const first = processorInstances[0];
|
|
61
|
+
|
|
62
|
+
await service.reset();
|
|
63
|
+
expect(processorInstances).toHaveLength(2);
|
|
64
|
+
const second = processorInstances[1];
|
|
65
|
+
// Fresh circuit: nothing loaded into it (a persisted snapshot references
|
|
66
|
+
// views under a dead sessionId salt and the previous user's data).
|
|
67
|
+
expect(second.load_state).not.toHaveBeenCalled();
|
|
68
|
+
|
|
69
|
+
// New registrations land on the fresh processor, not the old circuit.
|
|
70
|
+
service.registerQueryPlan({
|
|
71
|
+
queryHash: 'q1',
|
|
72
|
+
surql: 'SELECT * FROM thing;',
|
|
73
|
+
params: {},
|
|
74
|
+
ttl: '10m',
|
|
75
|
+
lastActiveAt: new Date(),
|
|
76
|
+
localArray: [],
|
|
77
|
+
remoteArray: [],
|
|
78
|
+
meta: { tableName: 'thing' },
|
|
79
|
+
} as any);
|
|
80
|
+
expect(second.register_view).toHaveBeenCalled();
|
|
81
|
+
expect(first.register_view).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('routes persisted state to the per-bucket key', async () => {
|
|
85
|
+
const { service, persistence } = makeService();
|
|
86
|
+
await service.init();
|
|
87
|
+
service.setStateKeySuffix('u1');
|
|
88
|
+
await service.saveState();
|
|
89
|
+
expect(persistence.set).toHaveBeenCalledWith('_00_stream_processor_state:u1', 'state-bytes');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('drops a saveState that raced a reset (old circuit never persists into the new key)', async () => {
|
|
93
|
+
const { service, persistence } = makeService();
|
|
94
|
+
await service.init();
|
|
95
|
+
// The snapshot is taken, then a reset lands before the persist step —
|
|
96
|
+
// simulated by bumping the generation from inside save_state().
|
|
97
|
+
processorInstances[0].save_state.mockImplementation(() => {
|
|
98
|
+
void service.reset();
|
|
99
|
+
return 'stale-circuit';
|
|
100
|
+
});
|
|
101
|
+
await service.saveState();
|
|
102
|
+
expect(persistence.set).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -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,92 @@
|
|
|
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
|
+
|
|
66
|
+
it('writes the boot-bucket hint synchronously and switches buckets before sync.setCurrentUserId', () => {
|
|
67
|
+
const match = source.match(
|
|
68
|
+
/this\.auth\.subscribe\(\s*async\s*\(\s*userId\s*[^)]*\)\s*=>\s*\{([\s\S]*?)\n {6}\}\s*\)/
|
|
69
|
+
);
|
|
70
|
+
expect(match).not.toBeNull();
|
|
71
|
+
const stripped = match![1]
|
|
72
|
+
.split('\n')
|
|
73
|
+
.map((line) => line.replace(/\/\/.*$/, ''))
|
|
74
|
+
.join('\n');
|
|
75
|
+
|
|
76
|
+
const hintIdx = stripped.indexOf('writeBootBucketHint(');
|
|
77
|
+
const bucketIdx = stripped.indexOf('this.ensureLocalBucket(userId)');
|
|
78
|
+
const syncUserIdx = stripped.indexOf('this.sync.setCurrentUserId(userId)');
|
|
79
|
+
const firstAwaitIdx = stripped.search(/\bawait\b/);
|
|
80
|
+
|
|
81
|
+
// The hint must be written before the first await: a reload landing
|
|
82
|
+
// mid-switch has to boot straight into the target bucket.
|
|
83
|
+
expect(hintIdx, 'writeBootBucketHint must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
|
|
84
|
+
expect(hintIdx, 'writeBootBucketHint must run before the first await').toBeLessThan(firstAwaitIdx);
|
|
85
|
+
|
|
86
|
+
// The bucket switch must complete before the sync module re-registers
|
|
87
|
+
// LIVE/poll for the new user — those immediately write to the local store.
|
|
88
|
+
expect(bucketIdx, 'ensureLocalBucket must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
|
|
89
|
+
expect(syncUserIdx, 'sync.setCurrentUserId must appear in the auth.subscribe body').toBeGreaterThanOrEqual(0);
|
|
90
|
+
expect(bucketIdx, 'ensureLocalBucket must run before sync.setCurrentUserId').toBeLessThan(syncUserIdx);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { Sp00kyClient } from './sp00ky';
|
|
5
|
+
|
|
6
|
+
// Guards for the local-first paint contract of `Sp00kyClient.initQuery`:
|
|
7
|
+
// the hash must resolve (and the hook paint from local cache) without any
|
|
8
|
+
// network on the awaited path, while instant-hydrate + the `register`
|
|
9
|
+
// down-event run in a background chain that (a) keeps hydrate strictly
|
|
10
|
+
// before the enqueue, (b) skips the duplicate one-shot fetch for freshly
|
|
11
|
+
// preloaded queries, (c) never rejects, and (d) is shared by concurrent
|
|
12
|
+
// mounts of the same query.
|
|
13
|
+
//
|
|
14
|
+
// Structural half: like sp00ky.auth-order.test.ts, a regex over the source
|
|
15
|
+
// catches the ordering regressions a runtime mock can't cheaply cover
|
|
16
|
+
// (Sp00kyClient's constructor drags in SurrealDB + the WASM SSP).
|
|
17
|
+
// Behavioral half: `finishQueryInit`/`initQuery` only touch a handful of
|
|
18
|
+
// injected fields, so a bare `Object.create(Sp00kyClient.prototype)` with
|
|
19
|
+
// stubs exercises the real method bodies without the constructor.
|
|
20
|
+
|
|
21
|
+
describe('Sp00kyClient.initQuery structural invariants', () => {
|
|
22
|
+
const source = readFileSync(resolve(__dirname, 'sp00ky.ts'), 'utf-8');
|
|
23
|
+
|
|
24
|
+
const methodBody = (name: string): string => {
|
|
25
|
+
const match = source.match(new RegExp(`private async ${name}[\\s\\S]*?\\n \\}`));
|
|
26
|
+
expect(match, `expected a "private async ${name}" method in sp00ky.ts`).not.toBeNull();
|
|
27
|
+
// Strip line comments so prose mentioning awaits/calls can't trip the checks.
|
|
28
|
+
return match![0]
|
|
29
|
+
.split('\n')
|
|
30
|
+
.map((line) => line.replace(/\/\/.*$/, ''))
|
|
31
|
+
.join('\n');
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
it('initQuery returns the hash with no network on the awaited path', () => {
|
|
35
|
+
const body = methodBody('initQuery');
|
|
36
|
+
expect(body).toContain('return hash');
|
|
37
|
+
expect(body, 'initQuery must not await the remote (paint path is network-free)').not.toMatch(
|
|
38
|
+
/await this\.remote\./
|
|
39
|
+
);
|
|
40
|
+
expect(body, 'the register enqueue belongs to the background chain').not.toMatch(
|
|
41
|
+
/await this\.sync\.enqueueDownEvent/
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('finishQueryInit hydrates strictly before enqueuing register', () => {
|
|
46
|
+
const body = methodBody('finishQueryInit');
|
|
47
|
+
const fetchIdx = body.indexOf('this.remote.query');
|
|
48
|
+
const enqueueIdx = body.indexOf('this.sync.enqueueDownEvent');
|
|
49
|
+
expect(fetchIdx).toBeGreaterThanOrEqual(0);
|
|
50
|
+
expect(enqueueIdx).toBeGreaterThanOrEqual(0);
|
|
51
|
+
expect(
|
|
52
|
+
fetchIdx,
|
|
53
|
+
'hydrate must settle before the register enqueue so a stale one-shot snapshot can never land after the authoritative _00_list_ref overwrite'
|
|
54
|
+
).toBeLessThan(enqueueIdx);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('finishQueryInit captures the bucket epoch before the remote fetch', () => {
|
|
58
|
+
const body = methodBody('finishQueryInit');
|
|
59
|
+
const epochIdx = body.indexOf('this.local.epoch');
|
|
60
|
+
const fetchIdx = body.indexOf('this.remote.query');
|
|
61
|
+
expect(epochIdx).toBeGreaterThanOrEqual(0);
|
|
62
|
+
expect(epochIdx, 'epoch must be read before the fetch to fence bucket switches').toBeLessThan(
|
|
63
|
+
fetchIdx
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('Sp00kyClient.finishQueryInit behavior', () => {
|
|
69
|
+
const hash = 'sha-hash';
|
|
70
|
+
const q: any = { hash: 42, selectQuery: { query: 'SELECT * FROM user', vars: {} } };
|
|
71
|
+
|
|
72
|
+
let client: any;
|
|
73
|
+
let calls: string[];
|
|
74
|
+
let enqueued: any[];
|
|
75
|
+
let epoch: number;
|
|
76
|
+
let preloadFresh: boolean;
|
|
77
|
+
let cold: boolean;
|
|
78
|
+
let remoteImpl: () => Promise<any>;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
calls = [];
|
|
82
|
+
enqueued = [];
|
|
83
|
+
epoch = 1;
|
|
84
|
+
preloadFresh = false;
|
|
85
|
+
cold = true;
|
|
86
|
+
remoteImpl = async () => {
|
|
87
|
+
calls.push('fetch');
|
|
88
|
+
return [[{ id: 'user:a' }]];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const noop = () => {};
|
|
92
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
93
|
+
logger.child = () => logger;
|
|
94
|
+
|
|
95
|
+
client = Object.create(Sp00kyClient.prototype);
|
|
96
|
+
Object.assign(client, {
|
|
97
|
+
config: { instantHydrate: true, schema: { tables: [{ name: 'user', columns: {} }] } },
|
|
98
|
+
logger,
|
|
99
|
+
preloadedHashes: new Set<number>(),
|
|
100
|
+
pendingQueryInits: new Map<string, Promise<void>>(),
|
|
101
|
+
local: {
|
|
102
|
+
get epoch() {
|
|
103
|
+
return epoch;
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
remote: { query: (...args: any[]) => remoteImpl() },
|
|
107
|
+
sync: {
|
|
108
|
+
enqueueDownEvent: (event: any) => {
|
|
109
|
+
calls.push('enqueue');
|
|
110
|
+
enqueued.push(event);
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
dataModule: {
|
|
114
|
+
isCold: () => cold,
|
|
115
|
+
isPreloadFresh: async () => preloadFresh,
|
|
116
|
+
applyHydration: async () => {
|
|
117
|
+
calls.push('hydrate');
|
|
118
|
+
},
|
|
119
|
+
query: async () => hash,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('cold path: fetch → hydrate → enqueue, in order', async () => {
|
|
125
|
+
await client.finishQueryInit(hash, q, {});
|
|
126
|
+
expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
|
|
127
|
+
expect(enqueued).toEqual([{ type: 'register', payload: { hash } }]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('session-preloaded query skips the fetch but still enqueues register', async () => {
|
|
131
|
+
client.preloadedHashes.add(q.hash);
|
|
132
|
+
await client.finishQueryInit(hash, q, {});
|
|
133
|
+
expect(calls).toEqual(['enqueue']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('fresh preload marker skips the fetch but still enqueues register', async () => {
|
|
137
|
+
preloadFresh = true;
|
|
138
|
+
await client.finishQueryInit(hash, q, {});
|
|
139
|
+
expect(calls).toEqual(['enqueue']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('warm query (not cold) goes straight to enqueue', async () => {
|
|
143
|
+
cold = false;
|
|
144
|
+
await client.finishQueryInit(hash, q, {});
|
|
145
|
+
expect(calls).toEqual(['enqueue']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('a rejected hydrate fetch still enqueues register and never rejects', async () => {
|
|
149
|
+
remoteImpl = async () => {
|
|
150
|
+
calls.push('fetch');
|
|
151
|
+
throw new Error('offline');
|
|
152
|
+
};
|
|
153
|
+
await expect(client.finishQueryInit(hash, q, {})).resolves.toBeUndefined();
|
|
154
|
+
expect(calls).toEqual(['fetch', 'enqueue']);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('a bucket switch during the fetch skips applyHydration', async () => {
|
|
158
|
+
remoteImpl = async () => {
|
|
159
|
+
calls.push('fetch');
|
|
160
|
+
epoch = 2; // switch lands while the fetch is in flight
|
|
161
|
+
return [[{ id: 'user:a' }]];
|
|
162
|
+
};
|
|
163
|
+
await client.finishQueryInit(hash, q, {});
|
|
164
|
+
expect(calls).toEqual(['fetch', 'enqueue']);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('instantHydrate: false disables the hydrate fetch entirely', async () => {
|
|
168
|
+
client.config.instantHydrate = false;
|
|
169
|
+
await client.finishQueryInit(hash, q, {});
|
|
170
|
+
expect(calls).toEqual(['enqueue']);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('concurrent initQuery calls for the same hash share one background chain', async () => {
|
|
174
|
+
const [h1, h2] = await Promise.all([
|
|
175
|
+
client.initQuery('user', q, '10m'),
|
|
176
|
+
client.initQuery('user', q, '10m'),
|
|
177
|
+
]);
|
|
178
|
+
expect(h1).toBe(hash);
|
|
179
|
+
expect(h2).toBe(hash);
|
|
180
|
+
// Let the shared chain drain.
|
|
181
|
+
await Promise.all([...client.pendingQueryInits.values()]);
|
|
182
|
+
expect(calls).toEqual(['fetch', 'hydrate', 'enqueue']);
|
|
183
|
+
expect(client.pendingQueryInits.size).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
});
|