@spooky-sync/core 0.0.1-canary.16 → 0.0.1-canary.161
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 +1406 -364
- package/dist/index.js +7773 -1391
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +420 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +874 -0
- package/package.json +20 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- 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 +16 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +361 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1151 -129
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- 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 +325 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +143 -0
- 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.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +216 -56
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +77 -9
- 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 +1333 -92
- 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 +160 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +33 -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 +478 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -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 +464 -0
- package/src/services/database/sqlite-cache-engine.ts +1154 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +113 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +219 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +141 -0
- package/src/services/logger/index.ts +6 -109
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +444 -52
- 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 +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +192 -0
- package/src/services/tabs/coordinator.ts +567 -0
- package/src/services/tabs/fake-ports.fixture.ts +70 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +239 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +581 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1225 -0
- package/src/types.ts +342 -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/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -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,216 @@
|
|
|
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
|
+
free: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
processorInstances.push(instance);
|
|
24
|
+
return instance;
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { StreamProcessorService } from './index';
|
|
29
|
+
|
|
30
|
+
const silentLogger = {
|
|
31
|
+
child: () => silentLogger,
|
|
32
|
+
trace: () => {},
|
|
33
|
+
debug: () => {},
|
|
34
|
+
info: () => {},
|
|
35
|
+
warn: () => {},
|
|
36
|
+
error: () => {},
|
|
37
|
+
} as any;
|
|
38
|
+
|
|
39
|
+
function makeService() {
|
|
40
|
+
const persisted: Record<string, unknown> = {};
|
|
41
|
+
const persistence = {
|
|
42
|
+
set: vi.fn(async (key: string, value: unknown) => {
|
|
43
|
+
persisted[key] = value;
|
|
44
|
+
}),
|
|
45
|
+
get: vi.fn(async () => null),
|
|
46
|
+
remove: vi.fn(async () => {}),
|
|
47
|
+
};
|
|
48
|
+
const service = new StreamProcessorService(
|
|
49
|
+
{} as any,
|
|
50
|
+
{} as any,
|
|
51
|
+
persistence as any,
|
|
52
|
+
silentLogger
|
|
53
|
+
);
|
|
54
|
+
return { service, persistence, persisted };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
processorInstances.length = 0;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('StreamProcessorService.reset', () => {
|
|
62
|
+
it('swaps in a fresh processor without loading persisted state', async () => {
|
|
63
|
+
const { service } = makeService();
|
|
64
|
+
await service.init();
|
|
65
|
+
expect(processorInstances).toHaveLength(1);
|
|
66
|
+
const first = processorInstances[0];
|
|
67
|
+
|
|
68
|
+
await service.reset();
|
|
69
|
+
expect(processorInstances).toHaveLength(2);
|
|
70
|
+
const second = processorInstances[1];
|
|
71
|
+
// Fresh circuit: nothing loaded into it (a persisted snapshot references
|
|
72
|
+
// views under a dead sessionId salt and the previous user's data).
|
|
73
|
+
expect(second.load_state).not.toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
// New registrations land on the fresh processor, not the old circuit.
|
|
76
|
+
service.registerQueryPlan({
|
|
77
|
+
queryHash: 'q1',
|
|
78
|
+
surql: 'SELECT * FROM thing;',
|
|
79
|
+
params: {},
|
|
80
|
+
ttl: '10m',
|
|
81
|
+
lastActiveAt: new Date(),
|
|
82
|
+
localArray: [],
|
|
83
|
+
remoteArray: [],
|
|
84
|
+
meta: { tableName: 'thing' },
|
|
85
|
+
} as any);
|
|
86
|
+
expect(second.register_view).toHaveBeenCalled();
|
|
87
|
+
expect(first.register_view).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('routes persisted state to the per-bucket key', async () => {
|
|
91
|
+
const { service, persistence } = makeService();
|
|
92
|
+
service.configureCircuitPersistence(true);
|
|
93
|
+
await service.init();
|
|
94
|
+
service.setStateKeySuffix('u1');
|
|
95
|
+
await service.saveState();
|
|
96
|
+
expect(persistence.set).toHaveBeenCalledWith('_00_stream_processor_state:u1', 'state-bytes');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('drops a saveState that raced a reset (old circuit never persists into the new key)', async () => {
|
|
100
|
+
const { service, persistence } = makeService();
|
|
101
|
+
service.configureCircuitPersistence(true);
|
|
102
|
+
await service.init();
|
|
103
|
+
// The snapshot is taken, then a reset lands before the persist step,
|
|
104
|
+
// simulated by bumping the generation from inside save_state().
|
|
105
|
+
processorInstances[0].save_state.mockImplementation(() => {
|
|
106
|
+
void service.reset();
|
|
107
|
+
return 'stale-circuit';
|
|
108
|
+
});
|
|
109
|
+
await service.saveState();
|
|
110
|
+
expect(persistence.set).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('frees the replaced wasm circuit on reset', async () => {
|
|
114
|
+
const { service } = makeService();
|
|
115
|
+
await service.init();
|
|
116
|
+
const first = processorInstances[0];
|
|
117
|
+
await service.reset();
|
|
118
|
+
// V8 cannot see wasm-internal bytes, so the FinalizationRegistry may never
|
|
119
|
+
// run. The old circuit must be freed explicitly or its whole store stays
|
|
120
|
+
// resident for the rest of the session.
|
|
121
|
+
expect(first.free).toHaveBeenCalledTimes(1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// The renderer-OOM guardrail. `Circuit::save` deep-clones the entire store (every
|
|
126
|
+
// row of every ingested table) and JSON-encodes it. It used to run once per
|
|
127
|
+
// ingest batch and once per query register/unregister, which on a large windowed
|
|
128
|
+
// list meant hundreds of whole-store serializations per scroll, for a snapshot
|
|
129
|
+
// the browser could never read back. Snapshots are now opt-in and checkpointed.
|
|
130
|
+
describe('StreamProcessorService circuit snapshots', () => {
|
|
131
|
+
const plan = {
|
|
132
|
+
queryHash: 'q1',
|
|
133
|
+
surql: 'SELECT * FROM thing;',
|
|
134
|
+
params: {},
|
|
135
|
+
ttl: '10m',
|
|
136
|
+
lastActiveAt: new Date(),
|
|
137
|
+
localArray: [],
|
|
138
|
+
remoteArray: [],
|
|
139
|
+
meta: { tableName: 'thing' },
|
|
140
|
+
} as any;
|
|
141
|
+
|
|
142
|
+
it('never snapshots on ingest or register/unregister by default', async () => {
|
|
143
|
+
const { service, persistence } = makeService();
|
|
144
|
+
await service.init();
|
|
145
|
+
const processor = processorInstances[0];
|
|
146
|
+
|
|
147
|
+
service.registerQueryPlan(plan);
|
|
148
|
+
service.ingest('thing', 'CREATE', 'thing:a', { id: 'thing:a' });
|
|
149
|
+
service.ingestMany([
|
|
150
|
+
{ table: 'thing', op: 'CREATE', id: 'thing:b', record: { id: 'thing:b' } },
|
|
151
|
+
{ table: 'thing', op: 'CREATE', id: 'thing:c', record: { id: 'thing:c' } },
|
|
152
|
+
]);
|
|
153
|
+
service.unregisterQueryPlan('q1');
|
|
154
|
+
|
|
155
|
+
expect(processor.save_state).not.toHaveBeenCalled();
|
|
156
|
+
expect(persistence.set).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('still never snapshots inline when persistence is opted in', async () => {
|
|
160
|
+
const { service, persistence } = makeService();
|
|
161
|
+
service.configureCircuitPersistence(true, 30_000);
|
|
162
|
+
await service.init();
|
|
163
|
+
const processor = processorInstances[0];
|
|
164
|
+
|
|
165
|
+
service.registerQueryPlan(plan);
|
|
166
|
+
service.ingest('thing', 'CREATE', 'thing:a', { id: 'thing:a' });
|
|
167
|
+
service.unregisterQueryPlan('q1');
|
|
168
|
+
|
|
169
|
+
// Marked dirty, but the write waits for the checkpoint interval.
|
|
170
|
+
expect(processor.save_state).not.toHaveBeenCalled();
|
|
171
|
+
expect(persistence.set).not.toHaveBeenCalled();
|
|
172
|
+
service.stopCheckpoints();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('writes at most one snapshot per checkpoint tick', async () => {
|
|
176
|
+
vi.useFakeTimers();
|
|
177
|
+
try {
|
|
178
|
+
const { service } = makeService();
|
|
179
|
+
service.configureCircuitPersistence(true, 1000);
|
|
180
|
+
await service.init();
|
|
181
|
+
const processor = processorInstances[0];
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < 50; i++) {
|
|
184
|
+
service.ingest('thing', 'CREATE', `thing:${i}`, { id: `thing:${i}` });
|
|
185
|
+
}
|
|
186
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
187
|
+
expect(processor.save_state).toHaveBeenCalledTimes(1);
|
|
188
|
+
|
|
189
|
+
// Idle interval: nothing changed, so nothing is serialized.
|
|
190
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
191
|
+
expect(processor.save_state).toHaveBeenCalledTimes(1);
|
|
192
|
+
|
|
193
|
+
service.stopCheckpoints();
|
|
194
|
+
} finally {
|
|
195
|
+
vi.useRealTimers();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('restores a snapshot the shipped persistence clients actually return', async () => {
|
|
200
|
+
const { service, persistence } = makeService();
|
|
201
|
+
// Both clients round-trip `save_state`'s output as a bare string. The old
|
|
202
|
+
// shape check looked for a raw SurrealDB result, so it never matched and the
|
|
203
|
+
// browser never restored anything it wrote.
|
|
204
|
+
persistence.get.mockResolvedValue('state-bytes' as any);
|
|
205
|
+
service.configureCircuitPersistence(true);
|
|
206
|
+
await service.init();
|
|
207
|
+
expect(processorInstances[0].load_state).toHaveBeenCalledWith('state-bytes');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('does not restore a snapshot when persistence is off', async () => {
|
|
211
|
+
const { service, persistence } = makeService();
|
|
212
|
+
persistence.get.mockResolvedValue('state-bytes' as any);
|
|
213
|
+
await service.init();
|
|
214
|
+
expect(processorInstances[0].load_state).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -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,22 @@ 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;
|
|
47
|
+
// wasm-bindgen destructor. Releases the circuit (store + every view cache)
|
|
48
|
+
// inside wasm linear memory. Without it the bytes only come back when V8
|
|
49
|
+
// happens to GC the JS wrapper, which it has no reason to hurry since it
|
|
50
|
+
// cannot see how much wasm memory the wrapper is holding.
|
|
51
|
+
free?(): void;
|
|
31
52
|
}
|