@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331
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 +84 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2080 -142
- package/dist/index.js +16009 -2480
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +373 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +477 -0
- package/dist/types.d.ts +1857 -38
- package/dist/worker-edges.js +480 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +32 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +80 -0
- package/src/boot/preload.saga.ts +40 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +434 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +167 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +512 -0
- package/src/live/index.ts +1059 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +228 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +476 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +469 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +757 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -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 +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +301 -0
- package/src/mutation/push.saga.ts +225 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +527 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +407 -0
- package/src/query/membership.saga.ts +282 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +311 -0
- package/src/query/register.saga.ts +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -0
- 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 +209 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +156 -61
- 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/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -63
- 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 +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -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 +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -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 +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +806 -67
- 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 +234 -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 +58 -2
- package/src/services/tabs/broker-client.ts +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -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 +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +208 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +306 -0
- package/src/state/reducers.ts +531 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +258 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +633 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +89 -12
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +29 -18
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { projectedDataSql, project, reviveRow, serializeRow } from './sqlite-plan-sql';
|
|
4
|
+
import type { Row } from './cache-engine';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `projectedDataSql` is the one change in this area that alters what SQLite
|
|
8
|
+
* actually returns, and every row read goes through it — so it is verified
|
|
9
|
+
* against a REAL SQLite, not the scripted responder the parity tests use.
|
|
10
|
+
*
|
|
11
|
+
* The contract: byte-identical to parsing the whole row and running `project`
|
|
12
|
+
* over it, which is what both engine paths did before. The traps it has to
|
|
13
|
+
* clear are an ABSENT key (must stay absent, not become an explicit null — the
|
|
14
|
+
* difference `json_object`/`json_extract` would have introduced), a STORED
|
|
15
|
+
* null, nested objects/arrays, the `{__u8}` tag that carries binary, and a
|
|
16
|
+
* TOP-LEVEL boolean (SQLite has no boolean type, so an unguarded projection
|
|
17
|
+
* hands `true` back as the integer 1).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function makeDb(rows: Row[]) {
|
|
21
|
+
const db = new DatabaseSync(':memory:');
|
|
22
|
+
db.exec('CREATE TABLE "game" (id TEXT PRIMARY KEY, data TEXT NOT NULL)');
|
|
23
|
+
const ins = db.prepare('INSERT INTO "game" (id,data) VALUES (?,?)');
|
|
24
|
+
for (const r of rows) ins.run(String(r.id), serializeRow(r));
|
|
25
|
+
return db;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What the code did before: read the whole row, parse it, then project. */
|
|
29
|
+
function referenceRows(db: DatabaseSync, ids: string[], fields: string[]): Row[] {
|
|
30
|
+
const ph = ids.map(() => '?').join(', ');
|
|
31
|
+
return db
|
|
32
|
+
.prepare(`SELECT data FROM "game" WHERE id IN (${ph}) ORDER BY id ASC`)
|
|
33
|
+
.all(...ids)
|
|
34
|
+
.map((r) => project(reviveRow((r as { data: string }).data), fields));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What the code does now: let SQLite narrow the row before it is ever parsed. */
|
|
38
|
+
function projectedRows(db: DatabaseSync, ids: string[], fields: string[]): Row[] {
|
|
39
|
+
const bind: unknown[] = [];
|
|
40
|
+
const col = projectedDataSql(fields, bind);
|
|
41
|
+
bind.push(...ids);
|
|
42
|
+
const ph = ids.map(() => '?').join(', ');
|
|
43
|
+
return db
|
|
44
|
+
.prepare(`SELECT ${col} FROM "game" WHERE id IN (${ph}) ORDER BY id ASC`)
|
|
45
|
+
.all(...(bind as string[]))
|
|
46
|
+
.map((r) => reviveRow((r as { data: string }).data));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FIXTURE: Row[] = [
|
|
50
|
+
{ id: 'game:1', white: 'player_name:PN_a', pgn: '[Event "x"]\n1. e4 e5', sort_index: -5 },
|
|
51
|
+
{ id: 'game:2', white: null, pgn: 'heavy', sort_index: -3 }, // STORED null
|
|
52
|
+
{ id: 'game:3', pgn: 'heavy', sort_index: -4 }, // 'white' ABSENT
|
|
53
|
+
{ id: 'game:4', white: 'w', pgn: 'heavy', sort_index: -1, meta: { a: 1, b: [2, 3] } },
|
|
54
|
+
{ id: 'game:5', white: 'w', pgn: 'heavy', sort_index: 0, rated: true, draw: false },
|
|
55
|
+
];
|
|
56
|
+
const IDS = ['game:1', 'game:2', 'game:3', 'game:4', 'game:5'];
|
|
57
|
+
const FIELDS = ['white', 'sort_index', 'meta', 'rated', 'draw'];
|
|
58
|
+
|
|
59
|
+
describe('projectedDataSql against real SQLite', () => {
|
|
60
|
+
it('is byte-identical to parse-then-project', () => {
|
|
61
|
+
const db = makeDb(FIXTURE);
|
|
62
|
+
expect(projectedRows(db, IDS, FIELDS)).toEqual(referenceRows(db, IDS, FIELDS));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('never returns the fields that were not asked for', () => {
|
|
66
|
+
// The point of the change: `pgn` must not leave SQLite, so it is never
|
|
67
|
+
// parsed, never crosses the worker boundary, never reaches the store.
|
|
68
|
+
const db = makeDb(FIXTURE);
|
|
69
|
+
for (const row of projectedRows(db, IDS, FIELDS)) expect('pgn' in row).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('keeps an absent key absent and a stored null null', () => {
|
|
73
|
+
const db = makeDb(FIXTURE);
|
|
74
|
+
const byId = new Map(projectedRows(db, IDS, FIELDS).map((r) => [r.id, r]));
|
|
75
|
+
expect('white' in byId.get('game:3')!).toBe(false); // absent stays absent
|
|
76
|
+
expect('white' in byId.get('game:2')!).toBe(true); // stored null is a real key
|
|
77
|
+
expect(byId.get('game:2')!.white).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('round-trips nested objects and arrays as JSON, not as strings', () => {
|
|
81
|
+
const db = makeDb(FIXTURE);
|
|
82
|
+
const row = projectedRows(db, IDS, FIELDS).find((r) => r.id === 'game:4')!;
|
|
83
|
+
expect(row.meta).toEqual({ a: 1, b: [2, 3] });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('round-trips binary through the {__u8} tag', () => {
|
|
87
|
+
const db = makeDb([{ id: 'game:9', blob: new Uint8Array([0, 1, 2]), pgn: 'heavy' }]);
|
|
88
|
+
const [row] = projectedRows(db, ['game:9'], ['blob']);
|
|
89
|
+
expect(row!.blob).toEqual(new Uint8Array([0, 1, 2]));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('always includes id, even when it is not in the field list', () => {
|
|
93
|
+
const db = makeDb(FIXTURE);
|
|
94
|
+
for (const row of projectedRows(db, IDS, ['sort_index'])) expect(row.id).toBeTruthy();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('round-trips a top-level boolean as a boolean, not as 1/0', () => {
|
|
98
|
+
// The bug this guards: `json_each` yields SQLite integers for JSON booleans,
|
|
99
|
+
// so a projected row read `rated: 1` and every `row.rated === true` in a
|
|
100
|
+
// consuming app went false as soon as the screen painted from the cache.
|
|
101
|
+
// WhitePawn's /explore detail page read exactly that as "this collection was
|
|
102
|
+
// unpublished or deleted by its author".
|
|
103
|
+
const db = makeDb(FIXTURE);
|
|
104
|
+
const row = projectedRows(db, IDS, FIELDS).find((r) => r.id === 'game:5')!;
|
|
105
|
+
expect(row.rated).toBe(true);
|
|
106
|
+
expect(row.draw).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('leaves a boolean nested in an object or array alone', () => {
|
|
110
|
+
const db = makeDb([{ id: 'game:7', flags: { on: true, off: false }, list: [true, false] }]);
|
|
111
|
+
const [row] = projectedRows(db, ['game:7'], ['flags', 'list']);
|
|
112
|
+
expect(row!.flags).toEqual({ on: true, off: false });
|
|
113
|
+
expect(row!.list).toEqual([true, false]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('yields an empty object, not NULL, for a row sharing none of the keys', () => {
|
|
117
|
+
// Without COALESCE the subquery returns NULL here and reviveRow throws.
|
|
118
|
+
const db = makeDb([{ id: 'game:8', pgn: 'heavy' }]);
|
|
119
|
+
expect(projectedRows(db, ['game:8'], ['nothing_matches'])).toEqual([{ id: 'game:8' }]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
3
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
4
|
+
import { executeSelect, type SelectDb } from './sqlite-select';
|
|
5
|
+
import { serializeRow } from './sqlite-plan-sql';
|
|
6
|
+
import type { Row } from './cache-engine';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Integration: `executeSelect` (the worker-side plan executor) against a REAL
|
|
10
|
+
* in-memory SQLite (the same @sqlite.org/sqlite-wasm build the worker loads).
|
|
11
|
+
* The scripted-responder tests in `sqlite-select.test.ts` prove parity of the
|
|
12
|
+
* statement sequence; these prove the SQL itself is correct — json_extract
|
|
13
|
+
* filtering, ORDER BY, LIMIT/OFFSET, IN matching, per-parent relation
|
|
14
|
+
* order/limit, and the JSON round-trip of row bodies.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
let db: any;
|
|
18
|
+
let selectDb: SelectDb;
|
|
19
|
+
|
|
20
|
+
function seed(table: string, rows: Row[]): void {
|
|
21
|
+
db.exec({ sql: `CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
|
|
22
|
+
selectDb.knownTables.add(table);
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
db.exec({
|
|
25
|
+
sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?)`,
|
|
26
|
+
bind: [row.id, serializeRow(row)],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
const sqlite3: any = await sqlite3InitModule();
|
|
33
|
+
db = new sqlite3.oo1.DB(':memory:', 'c');
|
|
34
|
+
selectDb = {
|
|
35
|
+
exec: (sql, bind) =>
|
|
36
|
+
db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' }) as { data: string }[],
|
|
37
|
+
run: (sql, bind) => {
|
|
38
|
+
db.exec({ sql, bind });
|
|
39
|
+
},
|
|
40
|
+
knownTables: new Set<string>(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
seed('thread', [
|
|
44
|
+
{ id: 'thread:A', title: 'alpha', createdAt: 3, author: 'user:1' },
|
|
45
|
+
{ id: 'thread:B', title: 'beta', createdAt: 1, author: 'user:2' },
|
|
46
|
+
{ id: 'thread:C', title: 'gamma', createdAt: 2, author: 'user:1' },
|
|
47
|
+
]);
|
|
48
|
+
seed('comment', [
|
|
49
|
+
{ id: 'comment:1', thread: 'thread:B', body: 'b-old', votes: 1, author: 'user:1' },
|
|
50
|
+
{ id: 'comment:2', thread: 'thread:B', body: 'b-new', votes: 5, author: 'user:2' },
|
|
51
|
+
{ id: 'comment:3', thread: 'thread:B', body: 'b-mid', votes: 3, author: 'user:1' },
|
|
52
|
+
{ id: 'comment:4', thread: 'thread:A', body: 'a-only', votes: 2, author: 'user:2' },
|
|
53
|
+
]);
|
|
54
|
+
seed('user', [
|
|
55
|
+
{ id: 'user:1', name: 'ada' },
|
|
56
|
+
{ id: 'user:2', name: 'grace' },
|
|
57
|
+
]);
|
|
58
|
+
seed('snapshot', [{ id: 'snapshot:1', blob: new Uint8Array([1, 2, 250]), kind: 'crdt' }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(() => {
|
|
62
|
+
db?.close();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('executeSelect against real SQLite', () => {
|
|
66
|
+
it('filters via json_extract, slaved to params over the baked literal (aa4af79b)', async () => {
|
|
67
|
+
const plan: QueryPlan = {
|
|
68
|
+
table: 'thread',
|
|
69
|
+
where: [{ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }],
|
|
70
|
+
};
|
|
71
|
+
const withParam = await executeSelect(plan, { id: 'thread:B' }, selectDb);
|
|
72
|
+
expect(withParam.rows.map((r) => r.id)).toEqual(['thread:B']);
|
|
73
|
+
|
|
74
|
+
const withoutParam = await executeSelect(plan, {}, selectDb);
|
|
75
|
+
expect(withoutParam.rows.map((r) => r.id)).toEqual(['thread:A']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('filters on a non-id field and binds a RecordId-shaped value as table:id', async () => {
|
|
79
|
+
const plan: QueryPlan = {
|
|
80
|
+
table: 'thread',
|
|
81
|
+
where: [{ field: 'author', op: '=', value: { tb: 'user', id: '1' } }],
|
|
82
|
+
orderBy: [['createdAt', 'asc']],
|
|
83
|
+
};
|
|
84
|
+
const { rows } = await executeSelect(plan, {}, selectDb);
|
|
85
|
+
expect(rows.map((r) => r.id)).toEqual(['thread:C', 'thread:A']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('applies ORDER BY / LIMIT / OFFSET in SQL', async () => {
|
|
89
|
+
const plan: QueryPlan = {
|
|
90
|
+
table: 'thread',
|
|
91
|
+
orderBy: [['createdAt', 'desc']],
|
|
92
|
+
limit: 2,
|
|
93
|
+
offset: 1,
|
|
94
|
+
};
|
|
95
|
+
const { rows } = await executeSelect(plan, {}, selectDb);
|
|
96
|
+
expect(rows.map((r) => r.id)).toEqual(['thread:C', 'thread:B']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('supports OR groups', async () => {
|
|
100
|
+
const plan: QueryPlan = {
|
|
101
|
+
table: 'thread',
|
|
102
|
+
where: [
|
|
103
|
+
{
|
|
104
|
+
or: [
|
|
105
|
+
{ field: 'title', op: '=', value: 'alpha' },
|
|
106
|
+
{ field: 'title', op: '=', value: 'beta' },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
orderBy: [['title', 'asc']],
|
|
111
|
+
};
|
|
112
|
+
const { rows } = await executeSelect(plan, {}, selectDb);
|
|
113
|
+
expect(rows.map((r) => r.id)).toEqual(['thread:A', 'thread:B']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('resolves a nested relation tree with per-parent order/limit, one batch per level', async () => {
|
|
117
|
+
const plan: QueryPlan = {
|
|
118
|
+
table: 'thread',
|
|
119
|
+
where: [{ field: 'id', op: '=', value: 'thread:B' }],
|
|
120
|
+
relations: [
|
|
121
|
+
{
|
|
122
|
+
alias: 'comments',
|
|
123
|
+
table: 'comment',
|
|
124
|
+
cardinality: 'many',
|
|
125
|
+
foreignKeyField: 'thread',
|
|
126
|
+
orderBy: [['votes', 'desc']],
|
|
127
|
+
limit: 2,
|
|
128
|
+
relations: [
|
|
129
|
+
{ alias: 'author', table: 'user', cardinality: 'one', foreignKeyField: 'author' },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
const { rows, relationFetches } = await executeSelect(plan, {}, selectDb);
|
|
135
|
+
expect(rows).toHaveLength(1);
|
|
136
|
+
const comments = rows[0].comments as Row[];
|
|
137
|
+
// Top-2 by votes, per parent.
|
|
138
|
+
expect(comments.map((c) => c.id)).toEqual(['comment:2', 'comment:3']);
|
|
139
|
+
// Nested `one` relation attached on each child.
|
|
140
|
+
expect(comments.map((c) => (c.author as Row).name)).toEqual(['grace', 'ada']);
|
|
141
|
+
// Level-ordered batching: one fetch for comments + one for users.
|
|
142
|
+
expect(relationFetches).toBe(2);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('materializes an ids window preserving ids order, or ORDER BY when given', async () => {
|
|
146
|
+
const windowPlan: QueryPlan = { table: 'comment', ids: ['comment:3', 'comment:1'] };
|
|
147
|
+
const inIdsOrder = await executeSelect(windowPlan, {}, selectDb);
|
|
148
|
+
expect(inIdsOrder.rows.map((r) => r.id)).toEqual(['comment:3', 'comment:1']);
|
|
149
|
+
|
|
150
|
+
const ordered = await executeSelect(
|
|
151
|
+
{ ...windowPlan, orderBy: [['votes', 'asc']] },
|
|
152
|
+
{},
|
|
153
|
+
selectDb
|
|
154
|
+
);
|
|
155
|
+
expect(ordered.rows.map((r) => r.id)).toEqual(['comment:1', 'comment:3']);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('trims to the projection while keeping id', async () => {
|
|
159
|
+
const plan: QueryPlan = {
|
|
160
|
+
table: 'thread',
|
|
161
|
+
where: [{ field: 'id', op: '=', value: 'thread:A' }],
|
|
162
|
+
select: ['title'],
|
|
163
|
+
};
|
|
164
|
+
const { rows } = await executeSelect(plan, {}, selectDb);
|
|
165
|
+
expect(rows).toEqual([{ id: 'thread:A', title: 'alpha' }]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('revives Uint8Array bodies through the {__u8} tag round-trip', async () => {
|
|
169
|
+
const plan: QueryPlan = {
|
|
170
|
+
table: 'snapshot',
|
|
171
|
+
where: [{ field: 'kind', op: '=', value: 'crdt' }],
|
|
172
|
+
};
|
|
173
|
+
const { rows } = await executeSelect(plan, {}, selectDb);
|
|
174
|
+
expect(rows).toHaveLength(1);
|
|
175
|
+
expect(rows[0].blob).toBeInstanceOf(Uint8Array);
|
|
176
|
+
expect([...(rows[0].blob as Uint8Array)]).toEqual([1, 2, 250]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('creates a missing table on first touch instead of failing', async () => {
|
|
180
|
+
const plan: QueryPlan = { table: 'never_written', where: [{ field: 'x', op: '=', value: 1 }] };
|
|
181
|
+
const { rows } = await executeSelect(plan, {}, selectDb);
|
|
182
|
+
expect(rows).toEqual([]);
|
|
183
|
+
expect(selectDb.knownTables.has('never_written')).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
4
|
+
import { executeSelect, type SelectDb } from './sqlite-select';
|
|
5
|
+
import { SqliteCacheEngine } from './sqlite-cache-engine';
|
|
6
|
+
import { stubTransport } from './sqlite-transport.fixture';
|
|
7
|
+
import { stableKey } from './relation-resolver';
|
|
8
|
+
import type { Row } from './cache-engine';
|
|
9
|
+
|
|
10
|
+
function makeLogger(): any {
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
13
|
+
l.child = () => l;
|
|
14
|
+
return l;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A tiny scripted "SQL responder" for the EXACT statement shapes the SQLite
|
|
19
|
+
* renderers emit (base select by id, id-window IN, relation json_extract IN).
|
|
20
|
+
* Not a SQL engine — just enough to make legacy-vs-worker parity observable.
|
|
21
|
+
*/
|
|
22
|
+
function makeResponder(tables: Record<string, Row[]>) {
|
|
23
|
+
return (sql: string, bind: unknown[] = []): { data: string }[] => {
|
|
24
|
+
const table = /FROM "([^"]+)"/.exec(sql)?.[1];
|
|
25
|
+
let rows = table ? (tables[table] ?? []) : [];
|
|
26
|
+
const rel = /WHERE json_extract\(data, '\$\.([A-Za-z0-9_.]+)'\) IN/.exec(sql);
|
|
27
|
+
if (rel) {
|
|
28
|
+
rows = rows.filter((r) => bind.includes(r[rel[1]]));
|
|
29
|
+
} else if (/WHERE id IN/.test(sql)) {
|
|
30
|
+
rows = rows.filter((r) => bind.includes(r.id));
|
|
31
|
+
} else if (/WHERE id = \?/.test(sql)) {
|
|
32
|
+
rows = rows.filter((r) => r.id === bind[0]);
|
|
33
|
+
}
|
|
34
|
+
return rows.map((r) => ({ data: JSON.stringify(r) }));
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FIXTURE: Record<string, Row[]> = {
|
|
39
|
+
thread: [
|
|
40
|
+
{ id: 'thread:A', title: 'thread A', author: 'user:1' },
|
|
41
|
+
{ id: 'thread:B', title: 'thread B', author: 'user:2' },
|
|
42
|
+
],
|
|
43
|
+
comment: [
|
|
44
|
+
{ id: 'comment:1', thread: 'thread:B', body: 'first' },
|
|
45
|
+
{ id: 'comment:2', thread: 'thread:B', body: 'second' },
|
|
46
|
+
{ id: 'comment:3', thread: 'thread:A', body: 'other thread' },
|
|
47
|
+
],
|
|
48
|
+
game: [
|
|
49
|
+
{ id: 'game:476gsdlvro7cme8nwied', white: 'player_name:a' },
|
|
50
|
+
{ id: 'game:stgyqp7fm3sc4tmyc8fm', white: 'player_name:b' },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The `.one()` detail-view plan shape: id baked as a literal AND slaved to
|
|
55
|
+
* the `id` param (aa4af79b), plus a `many` relation. */
|
|
56
|
+
const DETAIL_PLAN: QueryPlan = {
|
|
57
|
+
table: 'thread',
|
|
58
|
+
where: [{ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }],
|
|
59
|
+
relations: [
|
|
60
|
+
{ alias: 'comments', table: 'comment', cardinality: 'many', foreignKeyField: 'thread' },
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function makeSelectDb(
|
|
65
|
+
tables: Record<string, Row[]>,
|
|
66
|
+
calls: { sql: string; bind?: unknown[] }[]
|
|
67
|
+
): SelectDb {
|
|
68
|
+
const respond = makeResponder(tables);
|
|
69
|
+
return {
|
|
70
|
+
exec: (sql, bind) => {
|
|
71
|
+
calls.push({ sql, bind });
|
|
72
|
+
return respond(sql, bind ?? []);
|
|
73
|
+
},
|
|
74
|
+
run: (sql, bind) => {
|
|
75
|
+
calls.push({ sql, bind });
|
|
76
|
+
},
|
|
77
|
+
knownTables: new Set<string>(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An engine whose worker answers exec/run against the fixture (legacy path)
|
|
82
|
+
* and optionally the one-hop `select` op, recording every message. */
|
|
83
|
+
function makeEngine(opts: {
|
|
84
|
+
workerSelect: boolean;
|
|
85
|
+
answerSelect?: boolean;
|
|
86
|
+
calls?: { type: string; sql?: string; bind?: unknown[]; payload?: any }[];
|
|
87
|
+
}) {
|
|
88
|
+
const respond = makeResponder(FIXTURE);
|
|
89
|
+
const calls = opts.calls ?? [];
|
|
90
|
+
const engine = new SqliteCacheEngine(
|
|
91
|
+
{ namespace: 'n', database: 'd' } as any,
|
|
92
|
+
makeLogger(),
|
|
93
|
+
{ useOpfs: false, workerSelect: opts.workerSelect }
|
|
94
|
+
);
|
|
95
|
+
stubTransport(engine, async (type, payload) => {
|
|
96
|
+
calls.push({ type, sql: payload?.sql, bind: payload?.bind, payload });
|
|
97
|
+
if (type === 'open') return { persisted: false };
|
|
98
|
+
if (type === 'exec') return { rows: respond(payload.sql, payload.bind ?? []) };
|
|
99
|
+
if (type === 'run' || type === 'batch' || type === 'close') return {};
|
|
100
|
+
if (type === 'select') {
|
|
101
|
+
if (!opts.answerSelect) throw new Error(`sqlite worker: unknown message ${type}`);
|
|
102
|
+
const dbCalls: { sql: string; bind?: unknown[] }[] = [];
|
|
103
|
+
return executeSelect(payload.plan, payload.params ?? {}, makeSelectDb(FIXTURE, dbCalls));
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`sqlite worker: unknown message ${type}`);
|
|
106
|
+
});
|
|
107
|
+
return { engine, calls };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('executeSelect (worker-side plan execution)', () => {
|
|
111
|
+
it('slaves the base filter to params over the baked literal (aa4af79b guard)', async () => {
|
|
112
|
+
const calls: { sql: string; bind?: unknown[] }[] = [];
|
|
113
|
+
const { rows } = await executeSelect(DETAIL_PLAN, { id: 'thread:B' }, makeSelectDb(FIXTURE, calls));
|
|
114
|
+
// Filtered by the PARAM (thread:B), not the baked literal (thread:A).
|
|
115
|
+
expect(rows).toHaveLength(1);
|
|
116
|
+
expect(rows[0].id).toBe('thread:B');
|
|
117
|
+
expect(rows[0].comments).toEqual([
|
|
118
|
+
{ id: 'comment:1', thread: 'thread:B', body: 'first' },
|
|
119
|
+
{ id: 'comment:2', thread: 'thread:B', body: 'second' },
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('falls back to the baked literal when the param key is absent', async () => {
|
|
124
|
+
const calls: { sql: string; bind?: unknown[] }[] = [];
|
|
125
|
+
const { rows } = await executeSelect(DETAIL_PLAN, {}, makeSelectDb(FIXTURE, calls));
|
|
126
|
+
expect(rows).toHaveLength(1);
|
|
127
|
+
expect(rows[0].id).toBe('thread:A');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('counts relation fetches and honors the ids window ordering', async () => {
|
|
131
|
+
const calls: { sql: string; bind?: unknown[] }[] = [];
|
|
132
|
+
const plan: QueryPlan = {
|
|
133
|
+
table: 'comment',
|
|
134
|
+
ids: ['comment:2', 'comment:1'],
|
|
135
|
+
};
|
|
136
|
+
const { rows, relationFetches } = await executeSelect(plan, {}, makeSelectDb(FIXTURE, calls));
|
|
137
|
+
expect(relationFetches).toBe(0);
|
|
138
|
+
// ids order preserved (no ORDER BY).
|
|
139
|
+
expect(rows.map((r) => r.id)).toEqual(['comment:2', 'comment:1']);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('worker-select parity with the legacy multi-hop path', () => {
|
|
144
|
+
it('produces identical rows AND an identical SQL statement sequence', async () => {
|
|
145
|
+
// Legacy: engine drives one worker round-trip per statement.
|
|
146
|
+
const legacy = makeEngine({ workerSelect: false });
|
|
147
|
+
await legacy.engine.connect('anon');
|
|
148
|
+
const legacyRows = await legacy.engine.select(DETAIL_PLAN, { id: 'thread:B' });
|
|
149
|
+
const legacySql = legacy.calls
|
|
150
|
+
.filter((c) => c.type === 'exec' || c.type === 'run')
|
|
151
|
+
.map((c) => ({ sql: c.sql, bind: c.bind ?? [] }));
|
|
152
|
+
|
|
153
|
+
// Worker path: same plan executed in one hop via executeSelect.
|
|
154
|
+
const workerCalls: { sql: string; bind?: unknown[] }[] = [];
|
|
155
|
+
const { rows: workerRows } = await executeSelect(
|
|
156
|
+
DETAIL_PLAN,
|
|
157
|
+
{ id: 'thread:B' },
|
|
158
|
+
makeSelectDb(FIXTURE, workerCalls)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(workerRows).toEqual(legacyRows);
|
|
162
|
+
expect(workerCalls.map((c) => ({ sql: c.sql, bind: c.bind ?? [] }))).toEqual(legacySql);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('SqliteCacheEngine.select via worker (one hop)', () => {
|
|
167
|
+
it('sends exactly one select message, zero exec/run', async () => {
|
|
168
|
+
const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
|
|
169
|
+
await engine.connect('anon');
|
|
170
|
+
const rows = await engine.select(DETAIL_PLAN, { id: 'thread:B' });
|
|
171
|
+
expect(rows).toHaveLength(1);
|
|
172
|
+
expect(rows[0].id).toBe('thread:B');
|
|
173
|
+
const afterOpen = calls.filter((c) => c.type !== 'open');
|
|
174
|
+
expect(afterOpen.map((c) => c.type)).toEqual(['select']);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('normalizes RecordId param VALUES to strings but never drops param KEYS', async () => {
|
|
178
|
+
const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
|
|
179
|
+
await engine.connect('anon');
|
|
180
|
+
await engine.select(DETAIL_PLAN, { id: new RecordId('thread', 'B'), untouched: 7 });
|
|
181
|
+
const sel = calls.find((c) => c.type === 'select')!;
|
|
182
|
+
// VALUE normalized (a class instance would lose its prototype in the real
|
|
183
|
+
// structured clone); KEY present — comparisonSql's paramRef resolution
|
|
184
|
+
// checks hasOwnProperty, and a dropped key silently falls back to the
|
|
185
|
+
// baked literal (the aa4af79b crossed-results class).
|
|
186
|
+
expect(sel.payload.params).toEqual({ id: 'thread:B', untouched: 7 });
|
|
187
|
+
expect(Object.prototype.hasOwnProperty.call(sel.payload.params, 'id')).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('normalizes a window plan\'s RecordId ids to the same strings the legacy path binds', async () => {
|
|
191
|
+
const rid = new RecordId('comment', '2');
|
|
192
|
+
const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
|
|
193
|
+
await engine.connect('anon');
|
|
194
|
+
const rows = await engine.select({ table: 'comment', ids: [rid, 'comment:1'] }, {});
|
|
195
|
+
const sel = calls.find((c) => c.type === 'select')!;
|
|
196
|
+
// The raw spelling the rows are stored under, never the SDK's escaped
|
|
197
|
+
// `comment:⟨2⟩`: that one matched no stored row.
|
|
198
|
+
expect(sel.payload.plan.ids).toEqual(['comment:2', 'comment:1']);
|
|
199
|
+
expect(rows.map((r) => r.id)).toEqual(['comment:2', 'comment:1']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('finds a window row whose id starts with a digit (SDK 2.1.0 escapes it)', async () => {
|
|
203
|
+
// `materializeEffect` parses every window id into a RecordId, and SDK
|
|
204
|
+
// 2.1.0's `toString()` spells this one `game:⟨476g…⟩`. Keyed that way the
|
|
205
|
+
// row went missing, the window came back short and read as the list end.
|
|
206
|
+
const { engine } = makeEngine({ workerSelect: true, answerSelect: true });
|
|
207
|
+
await engine.connect('anon');
|
|
208
|
+
const ids = [new RecordId('game', '476gsdlvro7cme8nwied'), new RecordId('game', 'stgyqp7fm3sc4tmyc8fm')];
|
|
209
|
+
const rows = await engine.select({ table: 'game', ids }, {});
|
|
210
|
+
expect(rows.map((r) => r.id)).toEqual(['game:476gsdlvro7cme8nwied', 'game:stgyqp7fm3sc4tmyc8fm']);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('normalizes RecordId values baked inside the plan\'s where tree', async () => {
|
|
214
|
+
const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
|
|
215
|
+
await engine.connect('anon');
|
|
216
|
+
const plan: QueryPlan = {
|
|
217
|
+
table: 'thread',
|
|
218
|
+
where: [{ field: 'author', op: '=', value: new RecordId('user', 'u1') }],
|
|
219
|
+
relations: [
|
|
220
|
+
{
|
|
221
|
+
alias: 'comments',
|
|
222
|
+
table: 'comment',
|
|
223
|
+
cardinality: 'many',
|
|
224
|
+
foreignKeyField: 'thread',
|
|
225
|
+
where: [{ field: 'author', op: '=', value: new RecordId('user', 'u1') }],
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
};
|
|
229
|
+
await engine.select(plan, {});
|
|
230
|
+
const sel = calls.find((c) => c.type === 'select')!;
|
|
231
|
+
// Both the top-level where and the relation sub-where cross the boundary
|
|
232
|
+
// as strings — a RecordId instance would structured-clone to `{}`.
|
|
233
|
+
expect(sel.payload.plan.where[0].value).toBe('user:u1');
|
|
234
|
+
expect(sel.payload.plan.relations[0].where[0].value).toBe('user:u1');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('folds the worker-reported relationFetches into __sqliteStats', async () => {
|
|
238
|
+
const { engine } = makeEngine({ workerSelect: true, answerSelect: true });
|
|
239
|
+
await engine.connect('anon');
|
|
240
|
+
const before = (globalThis as any).__sqliteStats?.relationFetches ?? 0;
|
|
241
|
+
await engine.select(DETAIL_PLAN, { id: 'thread:B' });
|
|
242
|
+
// DETAIL_PLAN has one relation level → executeSelect reports 1 fetch.
|
|
243
|
+
expect((globalThis as any).__sqliteStats.relationFetches).toBe(before + 1);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('degrades to the legacy path when the worker lacks the select op', async () => {
|
|
247
|
+
const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: false });
|
|
248
|
+
await engine.connect('anon');
|
|
249
|
+
const rows = await engine.select(DETAIL_PLAN, { id: 'thread:B' });
|
|
250
|
+
expect(rows).toHaveLength(1);
|
|
251
|
+
expect(rows[0].id).toBe('thread:B');
|
|
252
|
+
// First attempt was 'select'; the rejection flipped the flag and the
|
|
253
|
+
// legacy path completed with exec/run hops.
|
|
254
|
+
expect(calls.some((c) => c.type === 'select')).toBe(true);
|
|
255
|
+
expect(calls.some((c) => c.type === 'exec')).toBe(true);
|
|
256
|
+
// The flag stays off: a second select never retries the worker op.
|
|
257
|
+
const before = calls.filter((c) => c.type === 'select').length;
|
|
258
|
+
await engine.select(DETAIL_PLAN, { id: 'thread:A' });
|
|
259
|
+
expect(calls.filter((c) => c.type === 'select').length).toBe(before);
|
|
260
|
+
});
|
|
261
|
+
});
|