@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -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 +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- 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 +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -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.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- 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 +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -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 +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -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 +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -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 +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- 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 +796 -84
- 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 +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -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,186 @@
|
|
|
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({} as any, {} as any, makeLogger());
|
|
42
|
+
|
|
43
|
+
const ingestedByQuery = new Map<string, Array<[string, number]>>();
|
|
44
|
+
const mockProcessor: Partial<WasmProcessor> = {
|
|
45
|
+
ingest: (_table, _op, id, record: any): WasmStreamUpdate[] => {
|
|
46
|
+
const version = record?._00_rv ?? 1;
|
|
47
|
+
const updates: WasmStreamUpdate[] = [];
|
|
48
|
+
for (const queryHash of queryHashesFor(id)) {
|
|
49
|
+
const arr = ingestedByQuery.get(queryHash) ?? [];
|
|
50
|
+
arr.push([id, version]);
|
|
51
|
+
ingestedByQuery.set(queryHash, arr);
|
|
52
|
+
updates.push({ query_id: queryHash, result_hash: '', result_data: [...arr] });
|
|
53
|
+
}
|
|
54
|
+
return updates;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
// processor is private and normally set by init() via WASM; inject the stub.
|
|
58
|
+
(svc as any).processor = mockProcessor;
|
|
59
|
+
return svc;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('StreamProcessor ingestMany bulk insert', () => {
|
|
63
|
+
let receiver: RecordingReceiver;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
receiver = new RecordingReceiver();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('emits one update per record when ingesting one-by-one (baseline)', () => {
|
|
70
|
+
const svc = makeService(() => ['q1']);
|
|
71
|
+
svc.addReceiver(receiver);
|
|
72
|
+
|
|
73
|
+
svc.ingest('user', 'CREATE', 'user:1', { id: 'user:1', _00_rv: 1 });
|
|
74
|
+
svc.ingest('user', 'CREATE', 'user:2', { id: 'user:2', _00_rv: 1 });
|
|
75
|
+
svc.ingest('user', 'CREATE', 'user:3', { id: 'user:3', _00_rv: 1 });
|
|
76
|
+
|
|
77
|
+
expect(receiver.received).toHaveLength(3);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('coalesces a bulk insert into a single update per query with the final array', () => {
|
|
81
|
+
const svc = makeService(() => ['q1']);
|
|
82
|
+
svc.addReceiver(receiver);
|
|
83
|
+
|
|
84
|
+
svc.ingestMany([
|
|
85
|
+
{ table: 'user', op: 'CREATE', id: 'user:1', record: { id: 'user:1', _00_rv: 1 } },
|
|
86
|
+
{ table: 'user', op: 'CREATE', id: 'user:2', record: { id: 'user:2', _00_rv: 1 } },
|
|
87
|
+
{ table: 'user', op: 'CREATE', id: 'user:3', record: { id: 'user:3', _00_rv: 1 } },
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
// Nothing dispatched until the whole batch is ingested, then exactly one
|
|
91
|
+
// coalesced update.
|
|
92
|
+
expect(receiver.received).toHaveLength(1);
|
|
93
|
+
const update = receiver.received[0];
|
|
94
|
+
expect(update.queryHash).toBe('q1');
|
|
95
|
+
// Last-write-wins carries the full materialized array (all 3 records).
|
|
96
|
+
expect(update.localArray).toHaveLength(3);
|
|
97
|
+
// Coalesced updates take DataModule's immediate (non-debounced) path.
|
|
98
|
+
expect(update.op).toBe('CREATE');
|
|
99
|
+
expect(typeof update.materializationTimeMs).toBe('number');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('coalesces independently per affected query', () => {
|
|
103
|
+
// user:1 -> q1, user:2 -> q1 & q2, user:3 -> q2
|
|
104
|
+
const svc = makeService((id) =>
|
|
105
|
+
id === 'user:1' ? ['q1'] : id === 'user:2' ? ['q1', 'q2'] : ['q2']
|
|
106
|
+
);
|
|
107
|
+
svc.addReceiver(receiver);
|
|
108
|
+
|
|
109
|
+
svc.ingestMany([
|
|
110
|
+
{ table: 'user', op: 'CREATE', id: 'user:1', record: { id: 'user:1', _00_rv: 1 } },
|
|
111
|
+
{ table: 'user', op: 'CREATE', id: 'user:2', record: { id: 'user:2', _00_rv: 1 } },
|
|
112
|
+
{ table: 'user', op: 'CREATE', id: 'user:3', record: { id: 'user:3', _00_rv: 1 } },
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
expect(receiver.received).toHaveLength(2);
|
|
116
|
+
const byHash = Object.fromEntries(receiver.received.map((u) => [u.queryHash, u]));
|
|
117
|
+
expect(Object.keys(byHash).sort()).toEqual(['q1', 'q2']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('is a no-op for an empty batch and leaves the window closed', () => {
|
|
121
|
+
const svc = makeService(() => ['q1']);
|
|
122
|
+
svc.addReceiver(receiver);
|
|
123
|
+
|
|
124
|
+
svc.ingestMany([]);
|
|
125
|
+
|
|
126
|
+
expect(receiver.received).toHaveLength(0);
|
|
127
|
+
// A subsequent single ingest dispatches normally (window never opened).
|
|
128
|
+
svc.ingest('user', 'CREATE', 'user:1', { id: 'user:1', _00_rv: 1 });
|
|
129
|
+
expect(receiver.received).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// A WASM build that exposes `ingest_many` takes one circuit step for the whole
|
|
133
|
+
// batch. The per-record loop above stays as the fallback for older builds.
|
|
134
|
+
describe('with a WASM build that supports bulk ingest', () => {
|
|
135
|
+
function makeBulkService() {
|
|
136
|
+
const svc = new StreamProcessorService({} as any, {} as any, makeLogger());
|
|
137
|
+
const calls: { items: any[] }[] = [];
|
|
138
|
+
const mockProcessor: Partial<WasmProcessor> = {
|
|
139
|
+
ingest: () => {
|
|
140
|
+
throw new Error('per-record ingest must not be used when ingest_many exists');
|
|
141
|
+
},
|
|
142
|
+
ingest_many: (items): WasmStreamUpdate[] => {
|
|
143
|
+
calls.push({ items });
|
|
144
|
+
return [
|
|
145
|
+
{
|
|
146
|
+
query_id: 'q1',
|
|
147
|
+
result_data: items.map((i) => [i.id, 1] as [string, number]),
|
|
148
|
+
timing_circuit_step_ms: 4,
|
|
149
|
+
} as WasmStreamUpdate,
|
|
150
|
+
];
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
(svc as any).processor = mockProcessor;
|
|
154
|
+
return { svc, calls };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
it('takes a single bulk call and dispatches one update per affected query', () => {
|
|
158
|
+
const { svc, calls } = makeBulkService();
|
|
159
|
+
svc.addReceiver(receiver);
|
|
160
|
+
|
|
161
|
+
svc.ingestMany([
|
|
162
|
+
{ table: 'user', op: 'CREATE', id: 'user:1', record: { id: 'user:1', _00_rv: 1 } },
|
|
163
|
+
{ table: 'user', op: 'CREATE', id: 'user:2', record: { id: 'user:2', _00_rv: 1 } },
|
|
164
|
+
{ table: 'user', op: 'CREATE', id: 'user:3', record: { id: 'user:3', _00_rv: 1 } },
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
expect(calls).toHaveLength(1);
|
|
168
|
+
expect(calls[0].items.map((i) => i.id)).toEqual(['user:1', 'user:2', 'user:3']);
|
|
169
|
+
expect(receiver.received).toHaveLength(1);
|
|
170
|
+
expect(receiver.received[0].queryHash).toBe('q1');
|
|
171
|
+
expect(receiver.received[0].localArray).toHaveLength(3);
|
|
172
|
+
expect(receiver.received[0].op).toBe('CREATE');
|
|
173
|
+
expect(receiver.received[0].circuitStepMs).toBe(4);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('never calls the bulk path for an empty batch', () => {
|
|
177
|
+
const { svc, calls } = makeBulkService();
|
|
178
|
+
svc.addReceiver(receiver);
|
|
179
|
+
|
|
180
|
+
svc.ingestMany([]);
|
|
181
|
+
|
|
182
|
+
expect(calls).toHaveLength(0);
|
|
183
|
+
expect(receiver.received).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// The boot-time prime: the circuit fills from the LOCAL store (a snapshot plus
|
|
4
|
+
// a reconcile against `(id, rv)`, or every cached row) instead of from the
|
|
5
|
+
// network. Before this, a reload left the circuit empty, every id in the
|
|
6
|
+
// server's list_ref classified as `added`, and the whole working set was
|
|
7
|
+
// re-downloaded, re-written and re-ingested.
|
|
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
|
+
ingest_many: vi.fn(() => []),
|
|
17
|
+
register_view: vi.fn(() => ({ query_id: 'q1', result_hash: '', result_data: [] })),
|
|
18
|
+
unregister_view: vi.fn(),
|
|
19
|
+
set_permissions: vi.fn(),
|
|
20
|
+
set_projection: vi.fn(),
|
|
21
|
+
save_store_state: vi.fn(() => new Uint8Array([1])),
|
|
22
|
+
load_store_state: vi.fn(() => [{ query_id: 'q1', result_hash: '', result_data: [['thing:a', 1]] }]),
|
|
23
|
+
reconcile: vi.fn(() => ({ fetch: [], deleted: 0, updates: [] })),
|
|
24
|
+
free: vi.fn(),
|
|
25
|
+
};
|
|
26
|
+
processorInstances.push(instance);
|
|
27
|
+
return instance;
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { StreamProcessorService, CIRCUIT_SNAPSHOT_FORMAT } from './index';
|
|
32
|
+
import type { StreamUpdate } from './index';
|
|
33
|
+
|
|
34
|
+
const silentLogger = {
|
|
35
|
+
child: () => silentLogger,
|
|
36
|
+
trace: () => {},
|
|
37
|
+
debug: () => {},
|
|
38
|
+
info: () => {},
|
|
39
|
+
warn: () => {},
|
|
40
|
+
error: () => {},
|
|
41
|
+
} as any;
|
|
42
|
+
|
|
43
|
+
function row(id: string, rv: number) {
|
|
44
|
+
return { id, _00_rv: rv, title: id };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeService(opts: { snapshot?: { bytes: Uint8Array; meta: any } | null; engineKind?: string } = {}) {
|
|
48
|
+
const versions: Record<string, [string, number][]> = {
|
|
49
|
+
thing: [
|
|
50
|
+
['thing:a', 1],
|
|
51
|
+
['thing:b', 2],
|
|
52
|
+
],
|
|
53
|
+
other: [['other:x', 5]],
|
|
54
|
+
};
|
|
55
|
+
const db = {
|
|
56
|
+
epoch: 0,
|
|
57
|
+
engineKind: opts.engineKind ?? 'sqlite',
|
|
58
|
+
scanVersions: vi.fn(async (tables: string[]) =>
|
|
59
|
+
Object.fromEntries(tables.map((t) => [t, versions[t] ?? []]))
|
|
60
|
+
),
|
|
61
|
+
selectByIds: vi.fn(async (table: string, ids: string[]) =>
|
|
62
|
+
ids.map((id) => row(id, versions[table]?.find(([i]) => i === id)?.[1] ?? 0))
|
|
63
|
+
),
|
|
64
|
+
getSnapshot: vi.fn(async () => opts.snapshot ?? null),
|
|
65
|
+
putSnapshot: vi.fn(async () => {}),
|
|
66
|
+
};
|
|
67
|
+
const service = new StreamProcessorService({} as any, db as any, silentLogger);
|
|
68
|
+
service.configureCircuitPersistence(true);
|
|
69
|
+
const received: StreamUpdate[] = [];
|
|
70
|
+
service.addReceiver({ onStreamUpdate: (u) => received.push(u) });
|
|
71
|
+
return { service, db, received };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const goodMeta = { formatVersion: CIRCUIT_SNAPSHOT_FORMAT, schemaHash: 'h', savedAt: 1 };
|
|
75
|
+
const ctx = () => ({
|
|
76
|
+
tables: ['thing', 'other'],
|
|
77
|
+
schemaHash: 'h',
|
|
78
|
+
pendingIds: new Set<string>(),
|
|
79
|
+
onVersions: vi.fn(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
processorInstances.length = 0;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('StreamProcessorService.primeFromLocal', () => {
|
|
87
|
+
it('without a snapshot, ingests every cached row and reports their versions', async () => {
|
|
88
|
+
const { service, db } = makeService();
|
|
89
|
+
await service.init();
|
|
90
|
+
const c = ctx();
|
|
91
|
+
await service.primeFromLocal(c);
|
|
92
|
+
const p = processorInstances[0];
|
|
93
|
+
expect(p.load_store_state).not.toHaveBeenCalled();
|
|
94
|
+
expect(db.selectByIds).toHaveBeenCalledWith('thing', ['thing:a', 'thing:b']);
|
|
95
|
+
expect(db.selectByIds).toHaveBeenCalledWith('other', ['other:x']);
|
|
96
|
+
const items = p.ingest_many.mock.calls.flatMap((call: any[]) => call[0]);
|
|
97
|
+
expect(items.map((i: any) => [i.table, i.op, i.id])).toEqual([
|
|
98
|
+
['thing', 'CREATE', 'thing:a'],
|
|
99
|
+
['thing', 'CREATE', 'thing:b'],
|
|
100
|
+
['other', 'CREATE', 'other:x'],
|
|
101
|
+
]);
|
|
102
|
+
expect(c.onVersions).toHaveBeenCalledWith('thing', [
|
|
103
|
+
['thing:a', 1],
|
|
104
|
+
['thing:b', 2],
|
|
105
|
+
]);
|
|
106
|
+
await expect(service.whenPrimed()).resolves.toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('with a snapshot, restores it under the registered views and only fetches what reconcile asks for', async () => {
|
|
110
|
+
const { service, db, received } = makeService({ snapshot: { bytes: new Uint8Array([7]), meta: goodMeta } });
|
|
111
|
+
await service.init();
|
|
112
|
+
const p = processorInstances[0];
|
|
113
|
+
p.reconcile.mockImplementation((table: string) =>
|
|
114
|
+
table === 'thing'
|
|
115
|
+
? { fetch: ['thing:b'], deleted: 1, updates: [{ query_id: 'q1', result_hash: '', result_data: [] }] }
|
|
116
|
+
: { fetch: [], deleted: 0, updates: [] }
|
|
117
|
+
);
|
|
118
|
+
await service.primeFromLocal(ctx());
|
|
119
|
+
expect(p.load_store_state).toHaveBeenCalledWith(new Uint8Array([7]));
|
|
120
|
+
// The restore's re-primed view result and reconcile's retraction reached
|
|
121
|
+
// the receivers.
|
|
122
|
+
expect(received.map((u) => u.queryHash)).toEqual(['q1', 'q1']);
|
|
123
|
+
expect(p.reconcile).toHaveBeenCalledWith('thing', [
|
|
124
|
+
['thing:a', 1],
|
|
125
|
+
['thing:b', 2],
|
|
126
|
+
]);
|
|
127
|
+
expect(db.selectByIds).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(db.selectByIds).toHaveBeenCalledWith('thing', ['thing:b']);
|
|
129
|
+
const items = p.ingest_many.mock.calls.flatMap((call: any[]) => call[0]);
|
|
130
|
+
expect(items.map((i: any) => i.id)).toEqual(['thing:b']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('ignores a snapshot written under another schema or format', async () => {
|
|
134
|
+
const { service } = makeService({
|
|
135
|
+
snapshot: { bytes: new Uint8Array([7]), meta: { ...goodMeta, schemaHash: 'other' } },
|
|
136
|
+
});
|
|
137
|
+
await service.init();
|
|
138
|
+
await service.primeFromLocal(ctx());
|
|
139
|
+
expect(processorInstances[0].load_store_state).not.toHaveBeenCalled();
|
|
140
|
+
expect(processorInstances[0].ingest_many).toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('leaves out ids with a pending local mutation when reporting versions', async () => {
|
|
144
|
+
const { service } = makeService();
|
|
145
|
+
await service.init();
|
|
146
|
+
const c = ctx();
|
|
147
|
+
c.pendingIds.add('thing:b');
|
|
148
|
+
await service.primeFromLocal(c);
|
|
149
|
+
expect(c.onVersions).toHaveBeenCalledWith('thing', [['thing:a', 1]]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('aborts when the store epoch moves under it (bucket switch)', async () => {
|
|
153
|
+
const { service, db } = makeService();
|
|
154
|
+
await service.init();
|
|
155
|
+
db.scanVersions.mockImplementation(async () => {
|
|
156
|
+
db.epoch++;
|
|
157
|
+
return { thing: [['thing:a', 1]], other: [] };
|
|
158
|
+
});
|
|
159
|
+
const c = ctx();
|
|
160
|
+
await service.primeFromLocal(c);
|
|
161
|
+
expect(db.selectByIds).not.toHaveBeenCalled();
|
|
162
|
+
expect(processorInstances[0].ingest_many).not.toHaveBeenCalled();
|
|
163
|
+
expect(c.onVersions).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('is a no-op on an engine without scanVersions', async () => {
|
|
167
|
+
const service = new StreamProcessorService({} as any, {} as any, silentLogger);
|
|
168
|
+
await service.init();
|
|
169
|
+
await service.primeFromLocal(ctx());
|
|
170
|
+
expect(processorInstances[0].ingest_many).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('widens projected rows when a new view evaluates fields they lack', async () => {
|
|
174
|
+
const { service, db } = makeService();
|
|
175
|
+
await service.init();
|
|
176
|
+
const p = processorInstances[0];
|
|
177
|
+
p.register_view.mockReturnValue({
|
|
178
|
+
query_id: 'q2',
|
|
179
|
+
result_hash: '',
|
|
180
|
+
result_data: [],
|
|
181
|
+
missing_fields: { thing: ['score'] },
|
|
182
|
+
});
|
|
183
|
+
service.registerQueryPlan({
|
|
184
|
+
queryHash: 'q2',
|
|
185
|
+
surql: 'SELECT * FROM thing ORDER BY score;',
|
|
186
|
+
params: {},
|
|
187
|
+
ttl: '10m',
|
|
188
|
+
lastActiveAt: new Date(),
|
|
189
|
+
localArray: [],
|
|
190
|
+
remoteArray: [],
|
|
191
|
+
meta: { tableName: 'thing' },
|
|
192
|
+
} as any);
|
|
193
|
+
await vi.waitFor(() => expect(p.ingest_many).toHaveBeenCalled());
|
|
194
|
+
expect(db.selectByIds).toHaveBeenCalledWith('thing', ['thing:a', 'thing:b'], { select: ['score'] });
|
|
195
|
+
const items = p.ingest_many.mock.calls.flatMap((call: any[]) => call[0]);
|
|
196
|
+
expect(items.map((i: any) => i.op)).toEqual(['MERGE', 'MERGE']);
|
|
197
|
+
});
|
|
198
|
+
});
|