@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210

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.
Files changed (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Opening the worker's SQLite handle: the OPFS SAHPool VFS when durable
3
+ * storage was asked for, an in-memory DB only as a last resort. Extracted from
4
+ * `sqlite-worker.ts` (which imports the wasm module at module scope and so
5
+ * can't be loaded in a unit test) to keep the retry/fallback policy testable
6
+ * off-worker, the same split as `sqlite-select.ts`.
7
+ *
8
+ * Why retry: SAHPool holds an EXCLUSIVE sync access handle on every file in
9
+ * its pool, so only one client per pool name can have it open. A second tab of
10
+ * the same app therefore fails init, and `installOpfsSAHPoolVfs` CACHES that
11
+ * rejection per VFS name, so a later call only gets a real second chance when
12
+ * it passes `forceReinitIfPreviouslyFailed`. Retrying with that flag turns the
13
+ * common "the other tab is still closing" race into a success instead of a
14
+ * permanent in-memory session.
15
+ *
16
+ * Why the noise: `:memory:` holds the whole dataset in RAM (the
17
+ * OOM-on-wasm-heavy-pages failure mode the OPFS store exists to avoid) and
18
+ * drops every local write on reload. Host apps run pino at their own level,
19
+ * some at `fatal`, so the fallback ALSO writes to `console.error` from inside
20
+ * the worker, and the reason travels back to the engine as `opfsError` for the
21
+ * app to surface.
22
+ */
23
+
24
+ /** The DB surface the worker uses (a `sqlite3.oo1.DB` or an `OpfsSAHPoolDb`). */
25
+ export interface SqliteDbHandle {
26
+ exec: (opts: { sql: string; bind?: unknown[]; rowMode?: string; returnValue?: string }) => unknown;
27
+ close: () => void;
28
+ }
29
+
30
+ /** The slice of the OpfsSAHPoolUtil the worker needs for teardown. */
31
+ export interface SqlitePoolHandle {
32
+ /** Unregisters the VFS and releases every sync access handle, leaving the
33
+ * files intact, so another worker can open the pool without waiting for
34
+ * this worker to be garbage collected. Throws while files are open. */
35
+ pauseVfs?: () => unknown;
36
+ }
37
+
38
+ export interface OpenDbResult {
39
+ db: SqliteDbHandle;
40
+ /** True only when the handle is backed by OPFS and survives a reload. */
41
+ persisted: boolean;
42
+ /** Why persistence failed. Set only when OPFS was requested and fell back. */
43
+ opfsError?: string;
44
+ /** Pool util, present only for an OPFS-backed handle. */
45
+ pool?: SqlitePoolHandle;
46
+ }
47
+
48
+ export interface OpenDbOptions {
49
+ /** Total OPFS init attempts, including the first. Default 3. */
50
+ maxAttempts?: number;
51
+ /** Delay before each retry; the last entry repeats. Default [250, 500]. */
52
+ backoffMs?: number[];
53
+ /**
54
+ * Throw (`opfs-unavailable: <reason>`) instead of falling back to memory.
55
+ * Used by shared-tabs leader promotion: a silently-in-memory LEADER would
56
+ * put every tab's data in RAM, so promotion prefers failing the election
57
+ * (the broker retries, possibly on another tab) over degrading. The broker
58
+ * grants an explicit memory fallback only after repeated failed cycles.
59
+ */
60
+ disallowMemoryFallback?: boolean;
61
+ /** Injectable for tests. */
62
+ sleep?: (ms: number) => Promise<void>;
63
+ }
64
+
65
+ const DEFAULT_MAX_ATTEMPTS = 3;
66
+ /** Bounded on purpose: this runs on the boot path, before the first query. */
67
+ const DEFAULT_BACKOFF_MS = [250, 500];
68
+
69
+ /**
70
+ * Leader-promotion profile (~5.8s worst case). A dead leader's sync access
71
+ * handles release when the browser garbage-collects its worker, typically
72
+ * well under a second but not synchronously with the Web Lock release the
73
+ * election observed, so promotion retries longer than a cold boot.
74
+ */
75
+ export const PROMOTION_OPEN_OPTIONS: Pick<OpenDbOptions, 'maxAttempts' | 'backoffMs'> = {
76
+ maxAttempts: 10,
77
+ backoffMs: [50, 100, 200, 400, 800, 1000],
78
+ };
79
+
80
+ /** Failures no retry can fix: the APIs aren't there at all (insecure context,
81
+ * or a browser without sync access handles). Fall back immediately. */
82
+ const UNRETRYABLE = ['Missing required OPFS APIs'];
83
+
84
+ const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
85
+
86
+ /** Keep the DOMException name (e.g. `NoModificationAllowedError` for a pool
87
+ * locked by another tab): it is the most diagnostic part of the failure. */
88
+ function errMessage(e: unknown): string {
89
+ if (e instanceof Error) return e.name && e.name !== 'Error' ? `${e.name}: ${e.message}` : e.message;
90
+ return String(e);
91
+ }
92
+
93
+ function fallbackToMemory(
94
+ sqlite3: any,
95
+ dbName: string,
96
+ reason: string,
97
+ attempts: number
98
+ ): OpenDbResult {
99
+ const tried = attempts > 0 ? ` after ${attempts} attempt${attempts === 1 ? '' : 's'}` : '';
100
+ // Deliberately console, not the logger: host apps configure pino's level (some
101
+ // run `fatal`), and losing durability must never be filtered into silence.
102
+ // oxlint-disable-next-line no-console
103
+ console.error(
104
+ `[sp00ky] OPFS persistence unavailable for "${dbName}"${tried}: ${reason}. The local SQLite ` +
105
+ 'cache is running IN MEMORY, which keeps the whole dataset in RAM and loses every local ' +
106
+ 'write on reload. The usual cause is another tab of this app holding the storage lock, so ' +
107
+ 'closing the other tabs and reloading restores persistence.'
108
+ );
109
+ return { db: new sqlite3.oo1.DB(':memory:', 'c'), persisted: false, opfsError: reason };
110
+ }
111
+
112
+ /**
113
+ * Open `dbName`'s handle. Never throws for a storage problem: a caller that
114
+ * asked for persistence and can't have it gets a working in-memory handle plus
115
+ * `persisted: false` and an `opfsError` to report.
116
+ */
117
+ export async function openDb(
118
+ sqlite3: any,
119
+ dbName: string,
120
+ useOpfs: boolean,
121
+ opts: OpenDbOptions = {}
122
+ ): Promise<OpenDbResult> {
123
+ // Memory was the configured choice (`store: 'memory'`), not a failure, so no
124
+ // error and no noise.
125
+ if (!useOpfs) return { db: new sqlite3.oo1.DB(':memory:', 'c'), persisted: false };
126
+
127
+ if (!sqlite3.installOpfsSAHPoolVfs) {
128
+ if (opts.disallowMemoryFallback) {
129
+ throw new Error('opfs-unavailable: sqlite-wasm build has no installOpfsSAHPoolVfs');
130
+ }
131
+ return fallbackToMemory(sqlite3, dbName, 'sqlite-wasm build has no installOpfsSAHPoolVfs', 0);
132
+ }
133
+
134
+ const maxAttempts = Math.max(1, opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
135
+ const backoffMs = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
136
+ const sleep = opts.sleep ?? defaultSleep;
137
+
138
+ let lastError = 'unknown error';
139
+ let attempts = 0;
140
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
141
+ attempts = attempt;
142
+ try {
143
+ // `initialCapacity` stays at the sqlite-wasm default (6 files): one pool
144
+ // per bucket holds a single DB plus its journals, so preallocating more
145
+ // OPFS files would only be waste. A "SAH pool is full" error still
146
+ // reaches the caller verbatim via `opfsError`.
147
+ const pool = await sqlite3.installOpfsSAHPoolVfs({
148
+ name: `sp00ky-${dbName}`,
149
+ // The first failure is cached against the VFS name, so a retry that
150
+ // doesn't ask for a real re-init just replays the same rejection.
151
+ ...(attempt > 1 ? { forceReinitIfPreviouslyFailed: true } : {}),
152
+ });
153
+ return { db: new pool.OpfsSAHPoolDb(`/${dbName}.sqlite3`), persisted: true, pool };
154
+ } catch (e) {
155
+ lastError = errMessage(e);
156
+ if (attempt === maxAttempts || UNRETRYABLE.some((m) => lastError.includes(m))) break;
157
+ await sleep(backoffMs[Math.min(attempt - 1, backoffMs.length - 1)] ?? 0);
158
+ }
159
+ }
160
+ if (opts.disallowMemoryFallback) {
161
+ throw new Error(`opfs-unavailable: ${lastError} (after ${attempts} attempts)`);
162
+ }
163
+ return fallbackToMemory(sqlite3, dbName, lastError, attempts);
164
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { WhereNode } from '@spooky-sync/query-builder';
3
+ import {
4
+ comparisonSql,
5
+ renderWhereSql,
6
+ renderOrderSql,
7
+ scalar,
8
+ serializeRow,
9
+ reviveRow,
10
+ project,
11
+ } from './sqlite-plan-sql';
12
+
13
+ describe('comparisonSql param slaving (aa4af79b)', () => {
14
+ it('prefers params[paramRef] over the baked literal', () => {
15
+ const bind: unknown[] = [];
16
+ const sql = comparisonSql(
17
+ { field: 'id', op: '=', value: 'thread:A', paramRef: 'id' },
18
+ bind,
19
+ { id: 'thread:B' }
20
+ );
21
+ expect(sql).toBe('id = ?');
22
+ expect(bind).toEqual(['thread:B']);
23
+ });
24
+
25
+ it('falls back to the baked literal when the param key is absent', () => {
26
+ const bind: unknown[] = [];
27
+ comparisonSql({ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }, bind, {});
28
+ expect(bind).toEqual(['thread:A']);
29
+ });
30
+
31
+ it('a pure $-ref (no baked value) always reads the param', () => {
32
+ const bind: unknown[] = [];
33
+ comparisonSql({ field: 'owner', op: '=', value: undefined, paramRef: 'auth' }, bind, {
34
+ auth: 'user:1',
35
+ });
36
+ expect(bind).toEqual(['user:1']);
37
+ });
38
+
39
+ it('renders non-id fields via json_extract and honors swap', () => {
40
+ const bind: unknown[] = [];
41
+ const sql = comparisonSql({ field: 'votes', op: '<', value: 5, swap: true }, bind, {});
42
+ expect(sql).toBe(`? < json_extract(data, '$.votes')`);
43
+ expect(bind).toEqual([5]);
44
+ });
45
+ });
46
+
47
+ describe('renderWhereSql', () => {
48
+ it('joins top-level nodes with AND and parenthesizes OR groups', () => {
49
+ const nodes: WhereNode[] = [
50
+ { field: 'kind', op: '=', value: 'a' },
51
+ {
52
+ or: [
53
+ { field: 'votes', op: '>', value: 1 },
54
+ { field: 'votes', op: '=', value: 0 },
55
+ ],
56
+ },
57
+ ];
58
+ const bind: unknown[] = [];
59
+ const sql = renderWhereSql(nodes, bind, {});
60
+ expect(sql).toBe(
61
+ `json_extract(data, '$.kind') = ? AND (json_extract(data, '$.votes') > ? OR json_extract(data, '$.votes') = ?)`
62
+ );
63
+ expect(bind).toEqual(['a', 1, 0]);
64
+ });
65
+ });
66
+
67
+ describe('renderOrderSql', () => {
68
+ it('renders multi-key ordering', () => {
69
+ expect(renderOrderSql([['a', 'desc'], ['b', 'asc']])).toBe(
70
+ ` ORDER BY json_extract(data, '$.a') DESC, json_extract(data, '$.b') ASC`
71
+ );
72
+ });
73
+ });
74
+
75
+ describe('scalar', () => {
76
+ it('binds RecordId-shaped objects as table:id strings', () => {
77
+ expect(scalar({ tb: 'user', id: '1' })).toBe('user:1');
78
+ expect(scalar('plain')).toBe('plain');
79
+ expect(scalar(3)).toBe(3);
80
+ expect(scalar(null)).toBeNull();
81
+ expect(scalar(undefined)).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('serializeRow / reviveRow round-trip', () => {
86
+ it('tags Uint8Array as {__u8} and revives it', () => {
87
+ const json = serializeRow({ id: 's:1', blob: new Uint8Array([0, 128, 255]) });
88
+ expect(json).toContain('"__u8"');
89
+ const back = reviveRow(json);
90
+ expect(back.blob).toBeInstanceOf(Uint8Array);
91
+ expect([...(back.blob as Uint8Array)]).toEqual([0, 128, 255]);
92
+ });
93
+
94
+ it('serializes RecordId-shaped links to strings and takes the fast parse path otherwise', () => {
95
+ const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
96
+ expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
97
+ });
98
+ });
99
+
100
+ describe('project', () => {
101
+ it('keeps id plus the listed fields only, skipping absent ones', () => {
102
+ expect(project({ id: 'a', x: 1, y: 2 }, ['x', 'missing'])).toEqual({ id: 'a', x: 1 });
103
+ });
104
+ });
@@ -0,0 +1,138 @@
1
+ import type { WhereComparison, WhereNode } from '@spooky-sync/query-builder';
2
+ import { stableKey } from './relation-resolver';
3
+ import type { OrderBy, Row } from './cache-engine';
4
+
5
+ /**
6
+ * SQL rendering + value (de)serialization for the SQLite cache backend.
7
+ * Extracted from `sqlite-cache-engine.ts` so BOTH sides of the worker boundary
8
+ * can use it: the engine (main thread) for the legacy/shim paths, and
9
+ * `sqlite-worker.ts` for worker-side plan execution (`select`). Pure module —
10
+ * no DOM, no worker, no engine imports — mirroring `plan-render.ts`.
11
+ */
12
+
13
+ export function renderOrderSql(orderBy: OrderBy): string {
14
+ return ` ORDER BY ${orderBy
15
+ .map(([f, d]) => `json_extract(data, '$.${f}') ${d === 'desc' ? 'DESC' : 'ASC'}`)
16
+ .join(', ')}`;
17
+ }
18
+
19
+ export function comparisonSql(
20
+ c: WhereComparison,
21
+ bind: unknown[],
22
+ params: Record<string, unknown>
23
+ ): string {
24
+ const lhs = c.field === 'id' ? 'id' : `json_extract(data, '$.${c.field}')`;
25
+ // Prefer the query's own param so a filter materializes from `params` (the
26
+ // query's identity), not a baked literal. A pure `$`-ref has no `value`; a
27
+ // slave-mode node keeps `value` as a fallback for a param absent from params.
28
+ const useParam =
29
+ c.paramRef !== undefined &&
30
+ (c.value === undefined || Object.prototype.hasOwnProperty.call(params, c.paramRef));
31
+ const value = useParam ? params[c.paramRef!] : c.value;
32
+ bind.push(scalar(value));
33
+ const op = c.op === '!=' ? '!=' : c.op;
34
+ return c.swap ? `? ${op} ${lhs}` : `${lhs} ${op} ?`;
35
+ }
36
+
37
+ export function renderWhereSql(
38
+ nodes: WhereNode[],
39
+ bind: unknown[],
40
+ params: Record<string, unknown>
41
+ ): string {
42
+ return nodes
43
+ .map((node) => {
44
+ if ('or' in node) {
45
+ return `(${node.or.map((c) => comparisonSql(c, bind, params)).join(' OR ')})`;
46
+ }
47
+ return comparisonSql(node, bind, params);
48
+ })
49
+ .join(' AND ');
50
+ }
51
+
52
+ // ==================== value (de)serialization ====================
53
+
54
+ /** A comparable scalar for SQL binding: record links → `table:id`, everything
55
+ * else passed through (numbers/strings/bools). */
56
+ export function scalar(value: unknown): unknown {
57
+ if (value == null) return null;
58
+ if (typeof value === 'object') return stableKey(value);
59
+ return value;
60
+ }
61
+
62
+ export function serializeRow(row: Row): string {
63
+ return JSON.stringify(row, (_k, v) => {
64
+ if (v instanceof Uint8Array) return { __u8: toBase64(v) };
65
+ if (v && typeof v === 'object') {
66
+ const rid = v as { tb?: unknown; id?: unknown };
67
+ if (rid.tb !== undefined && rid.id !== undefined) return stableKey(v);
68
+ }
69
+ return v;
70
+ });
71
+ }
72
+
73
+ export function reviveRow(json: string): Row {
74
+ // Fast path: the per-key reviver is only needed to rebuild `Uint8Array`s from
75
+ // `{__u8}` tags. Most rows (e.g. game bodies) have none — a plain parse avoids
76
+ // invoking a JS callback for every key of every row on the read hot path.
77
+ if (json.indexOf('"__u8"') === -1) return JSON.parse(json);
78
+ return JSON.parse(json, (_k, v) => {
79
+ if (v && typeof v === 'object' && typeof (v as any).__u8 === 'string') {
80
+ return fromBase64((v as any).__u8);
81
+ }
82
+ return v;
83
+ });
84
+ }
85
+
86
+ /**
87
+ * SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
88
+ * `id`), so SQLite never returns — and neither side ever parses — the fields the
89
+ * caller did not ask for. On a game list that is every row's `pgn`, which was
90
+ * being read, parsed and thrown away 50 rows at a time for eight rendered fields.
91
+ *
92
+ * Byte-identical to running {@link project} over the fully-parsed row, which is
93
+ * what both paths did before, and verified so against real SQLite:
94
+ * `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
95
+ * rather than becoming an explicit null — the difference `json_object`/
96
+ * `json_extract` would have introduced. A stored null, nested objects and
97
+ * arrays, and `{__u8}` blob tags all round-trip unchanged, because
98
+ * `json_group_object` understands `json_each`'s `value` column as JSON rather
99
+ * than as text. `COALESCE` covers a row sharing none of the requested keys,
100
+ * where the subquery yields NULL and `reviveRow` would throw.
101
+ *
102
+ * Deliberately unaliased, so the emitted statement keeps the exact shape the
103
+ * callers already produce (`FROM "t" WHERE id IN (…)`).
104
+ *
105
+ * Binds one parameter per key, pushed onto `bind` — these land in the SELECT
106
+ * clause, so they must be bound BEFORE any WHERE parameters.
107
+ */
108
+ export function projectedDataSql(fields: string[], bind: unknown[]): string {
109
+ const keys = ['id', ...fields];
110
+ for (const k of keys) bind.push(k);
111
+ const placeholders = keys.map(() => '?').join(', ');
112
+ return (
113
+ `COALESCE((SELECT json_group_object(je.key, je.value) ` +
114
+ `FROM json_each(data) je WHERE je.key IN (${placeholders})), '{}') AS data`
115
+ );
116
+ }
117
+
118
+ export function project(row: Row, fields: string[]): Row {
119
+ const out: Row = {};
120
+ for (const f of ['id', ...fields]) if (f in row) out[f] = row[f];
121
+ return out;
122
+ }
123
+
124
+ export function toBase64(bytes: Uint8Array): string {
125
+ let bin = '';
126
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
127
+ return typeof btoa !== 'undefined' ? btoa(bin) : Buffer.from(bytes).toString('base64');
128
+ }
129
+
130
+ export function fromBase64(b64: string): Uint8Array {
131
+ if (typeof atob !== 'undefined') {
132
+ const bin = atob(b64);
133
+ const out = new Uint8Array(bin.length);
134
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
135
+ return out;
136
+ }
137
+ return new Uint8Array(Buffer.from(b64, 'base64'));
138
+ }
@@ -0,0 +1,99 @@
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, and the `{__u8}` tag that carries binary.
16
+ */
17
+
18
+ function makeDb(rows: Row[]) {
19
+ const db = new DatabaseSync(':memory:');
20
+ db.exec('CREATE TABLE "game" (id TEXT PRIMARY KEY, data TEXT NOT NULL)');
21
+ const ins = db.prepare('INSERT INTO "game" (id,data) VALUES (?,?)');
22
+ for (const r of rows) ins.run(String(r.id), serializeRow(r));
23
+ return db;
24
+ }
25
+
26
+ /** What the code did before: read the whole row, parse it, then project. */
27
+ function referenceRows(db: DatabaseSync, ids: string[], fields: string[]): Row[] {
28
+ const ph = ids.map(() => '?').join(', ');
29
+ return db
30
+ .prepare(`SELECT data FROM "game" WHERE id IN (${ph}) ORDER BY id ASC`)
31
+ .all(...ids)
32
+ .map((r) => project(reviveRow((r as { data: string }).data), fields));
33
+ }
34
+
35
+ /** What the code does now: let SQLite narrow the row before it is ever parsed. */
36
+ function projectedRows(db: DatabaseSync, ids: string[], fields: string[]): Row[] {
37
+ const bind: unknown[] = [];
38
+ const col = projectedDataSql(fields, bind);
39
+ bind.push(...ids);
40
+ const ph = ids.map(() => '?').join(', ');
41
+ return db
42
+ .prepare(`SELECT ${col} FROM "game" WHERE id IN (${ph}) ORDER BY id ASC`)
43
+ .all(...(bind as string[]))
44
+ .map((r) => reviveRow((r as { data: string }).data));
45
+ }
46
+
47
+ const FIXTURE: Row[] = [
48
+ { id: 'game:1', white: 'player_name:PN_a', pgn: '[Event "x"]\n1. e4 e5', sort_index: -5 },
49
+ { id: 'game:2', white: null, pgn: 'heavy', sort_index: -3 }, // STORED null
50
+ { id: 'game:3', pgn: 'heavy', sort_index: -4 }, // 'white' ABSENT
51
+ { id: 'game:4', white: 'w', pgn: 'heavy', sort_index: -1, meta: { a: 1, b: [2, 3] } },
52
+ ];
53
+ const IDS = ['game:1', 'game:2', 'game:3', 'game:4'];
54
+ const FIELDS = ['white', 'sort_index', 'meta'];
55
+
56
+ describe('projectedDataSql against real SQLite', () => {
57
+ it('is byte-identical to parse-then-project', () => {
58
+ const db = makeDb(FIXTURE);
59
+ expect(projectedRows(db, IDS, FIELDS)).toEqual(referenceRows(db, IDS, FIELDS));
60
+ });
61
+
62
+ it('never returns the fields that were not asked for', () => {
63
+ // The point of the change: `pgn` must not leave SQLite, so it is never
64
+ // parsed, never crosses the worker boundary, never reaches the store.
65
+ const db = makeDb(FIXTURE);
66
+ for (const row of projectedRows(db, IDS, FIELDS)) expect('pgn' in row).toBe(false);
67
+ });
68
+
69
+ it('keeps an absent key absent and a stored null null', () => {
70
+ const db = makeDb(FIXTURE);
71
+ const byId = new Map(projectedRows(db, IDS, FIELDS).map((r) => [r.id, r]));
72
+ expect('white' in byId.get('game:3')!).toBe(false); // absent stays absent
73
+ expect('white' in byId.get('game:2')!).toBe(true); // stored null is a real key
74
+ expect(byId.get('game:2')!.white).toBeNull();
75
+ });
76
+
77
+ it('round-trips nested objects and arrays as JSON, not as strings', () => {
78
+ const db = makeDb(FIXTURE);
79
+ const row = projectedRows(db, IDS, FIELDS).find((r) => r.id === 'game:4')!;
80
+ expect(row.meta).toEqual({ a: 1, b: [2, 3] });
81
+ });
82
+
83
+ it('round-trips binary through the {__u8} tag', () => {
84
+ const db = makeDb([{ id: 'game:9', blob: new Uint8Array([0, 1, 2]), pgn: 'heavy' }]);
85
+ const [row] = projectedRows(db, ['game:9'], ['blob']);
86
+ expect(row!.blob).toEqual(new Uint8Array([0, 1, 2]));
87
+ });
88
+
89
+ it('always includes id, even when it is not in the field list', () => {
90
+ const db = makeDb(FIXTURE);
91
+ for (const row of projectedRows(db, IDS, ['sort_index'])) expect(row.id).toBeTruthy();
92
+ });
93
+
94
+ it('yields an empty object, not NULL, for a row sharing none of the keys', () => {
95
+ // Without COALESCE the subquery returns NULL here and reviveRow throws.
96
+ const db = makeDb([{ id: 'game:8', pgn: 'heavy' }]);
97
+ expect(projectedRows(db, ['game:8'], ['nothing_matches'])).toEqual([{ id: 'game:8' }]);
98
+ });
99
+ });