@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.290

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 (254) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2027 -137
  4. package/dist/index.js +14564 -2472
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +329 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +358 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1700 -38
  19. package/dist/worker-edges.js +423 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +82 -0
  30. package/src/boot/auth-flip.saga.ts +62 -0
  31. package/src/boot/boot.saga.test.ts +158 -0
  32. package/src/boot/boot.saga.ts +131 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.ts +366 -0
  46. package/src/client/sp00ky-client.test.ts +271 -0
  47. package/src/client/sp00ky-client.ts +541 -0
  48. package/src/events/events.test.ts +2 -1
  49. package/src/events/index.ts +3 -0
  50. package/src/index.ts +69 -1
  51. package/src/kernel/constants.test.ts +19 -0
  52. package/src/kernel/constants.ts +80 -0
  53. package/src/kernel/effects.test.ts +40 -0
  54. package/src/kernel/effects.ts +161 -0
  55. package/src/kernel/events.ts +58 -0
  56. package/src/kernel/interpreter.test.ts +116 -0
  57. package/src/kernel/interpreter.ts +148 -0
  58. package/src/kernel/saga.test.ts +89 -0
  59. package/src/kernel/saga.ts +73 -0
  60. package/src/live/index.test.ts +478 -0
  61. package/src/live/index.ts +1013 -0
  62. package/src/modules/app-release/index.test.ts +125 -0
  63. package/src/modules/app-release/index.ts +209 -0
  64. package/src/modules/auth/auth.local-first.test.ts +141 -0
  65. package/src/modules/auth/events/index.ts +9 -2
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.test.ts +355 -0
  68. package/src/modules/auth/impersonation.ts +179 -0
  69. package/src/modules/auth/index.ts +386 -17
  70. package/src/modules/crdt/crdt-field.ts +294 -0
  71. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  72. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  73. package/src/modules/crdt/index.ts +463 -0
  74. package/src/modules/crdt/loro-loader.ts +25 -0
  75. package/src/modules/devtools/flags.ts +349 -0
  76. package/src/modules/devtools/index.ts +519 -37
  77. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  78. package/src/modules/devtools/state-shape.test.ts +149 -0
  79. package/src/modules/devtools/storage-info.test.ts +79 -0
  80. package/src/modules/devtools/storage-info.ts +168 -0
  81. package/src/modules/devtools/versions.test.ts +74 -0
  82. package/src/modules/devtools/versions.ts +110 -0
  83. package/src/modules/feature-flag/index.test.ts +251 -0
  84. package/src/modules/feature-flag/index.ts +316 -0
  85. package/src/modules/ref-tables.test.ts +91 -0
  86. package/src/modules/ref-tables.ts +88 -0
  87. package/src/modules/web-push/index.test.ts +824 -0
  88. package/src/modules/web-push/index.ts +1027 -0
  89. package/src/mutation/jobs.test.ts +25 -0
  90. package/src/mutation/jobs.ts +41 -0
  91. package/src/mutation/mutation-id.test.ts +37 -0
  92. package/src/mutation/mutation-id.ts +35 -0
  93. package/src/mutation/pending-writes.integration.test.ts +175 -0
  94. package/src/mutation/push.saga.test.ts +285 -0
  95. package/src/mutation/push.saga.ts +217 -0
  96. package/src/mutation/rows.test.ts +231 -0
  97. package/src/mutation/rows.ts +427 -0
  98. package/src/mutation/tray.saga.test.ts +63 -0
  99. package/src/mutation/tray.saga.ts +48 -0
  100. package/src/mutation/write.saga.test.ts +316 -0
  101. package/src/mutation/write.saga.ts +268 -0
  102. package/src/otel/index.ts +10 -7
  103. package/src/pure.ts +65 -0
  104. package/src/push/types.ts +174 -0
  105. package/src/query/env.test.ts +23 -0
  106. package/src/query/env.ts +56 -0
  107. package/src/query/fetch.saga.test.ts +199 -0
  108. package/src/query/fetch.saga.ts +120 -0
  109. package/src/query/hash.test.ts +14 -0
  110. package/src/query/hash.ts +18 -0
  111. package/src/query/id-set-plan.test.ts +132 -0
  112. package/src/query/lifecycle.saga.test.ts +274 -0
  113. package/src/query/lifecycle.saga.ts +159 -0
  114. package/src/query/materialize.saga.test.ts +115 -0
  115. package/src/query/materialize.saga.ts +84 -0
  116. package/src/query/materialize.test.ts +58 -0
  117. package/src/query/materialize.ts +57 -0
  118. package/src/query/membership.saga.test.ts +350 -0
  119. package/src/query/membership.saga.ts +278 -0
  120. package/src/query/membership.test.ts +118 -0
  121. package/src/query/membership.ts +201 -0
  122. package/src/query/register.saga.test.ts +293 -0
  123. package/src/query/register.saga.ts +226 -0
  124. package/src/query/render-set.test.ts +47 -0
  125. package/src/query/render-set.ts +71 -0
  126. package/src/query/sql.test.ts +63 -0
  127. package/src/query/sql.ts +93 -0
  128. package/src/query/window-query.test.ts +70 -0
  129. package/src/query/window-query.ts +195 -0
  130. package/src/services/blobs/blob-cache.test.ts +359 -0
  131. package/src/services/blobs/blob-cache.ts +603 -0
  132. package/src/services/blobs/blob-manifest.ts +227 -0
  133. package/src/services/blobs/blob-store.test.ts +77 -0
  134. package/src/services/blobs/blob-store.ts +359 -0
  135. package/src/services/blobs/blob.fixture.ts +90 -0
  136. package/src/services/blobs/index.ts +70 -0
  137. package/src/services/database/cache-engine.ts +199 -0
  138. package/src/services/database/connection-supervisor.test.ts +318 -0
  139. package/src/services/database/connection-supervisor.ts +422 -0
  140. package/src/services/database/database.query-timeout.test.ts +140 -0
  141. package/src/services/database/database.responses.test.ts +43 -0
  142. package/src/services/database/database.ts +144 -61
  143. package/src/services/database/engine-factory.ts +33 -0
  144. package/src/services/database/errors.ts +34 -0
  145. package/src/services/database/events/index.ts +2 -1
  146. package/src/services/database/index.ts +7 -0
  147. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  148. package/src/services/database/local-migrator.integration.test.ts +110 -0
  149. package/src/services/database/local-migrator.ts +59 -16
  150. package/src/services/database/local.test.ts +64 -0
  151. package/src/services/database/local.ts +487 -63
  152. package/src/services/database/plan-render.test.ts +159 -0
  153. package/src/services/database/plan-render.ts +108 -0
  154. package/src/services/database/relation-resolver.test.ts +467 -0
  155. package/src/services/database/relation-resolver.ts +0 -0
  156. package/src/services/database/remote.ts +213 -6
  157. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  158. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  159. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  160. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  161. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  162. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  163. package/src/services/database/sqlite-lock-verify.ts +45 -0
  164. package/src/services/database/sqlite-open.test.ts +150 -0
  165. package/src/services/database/sqlite-open.ts +164 -0
  166. package/src/services/database/sqlite-plan-sql.test.ts +139 -0
  167. package/src/services/database/sqlite-plan-sql.ts +179 -0
  168. package/src/services/database/sqlite-projection.test.ts +121 -0
  169. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  170. package/src/services/database/sqlite-select.test.ts +246 -0
  171. package/src/services/database/sqlite-select.ts +131 -0
  172. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  173. package/src/services/database/sqlite-transport.ts +224 -0
  174. package/src/services/database/sqlite-worker.ts +437 -0
  175. package/src/services/database/surql-translate.ts +416 -0
  176. package/src/services/database/surreal-cache-engine.ts +161 -0
  177. package/src/services/logger/index.ts +3 -2
  178. package/src/services/persistence/localstorage.ts +2 -2
  179. package/src/services/persistence/resilient.ts +10 -3
  180. package/src/services/persistence/surrealdb.ts +4 -4
  181. package/src/services/stream-processor/index.ts +779 -67
  182. package/src/services/stream-processor/permissions.test.ts +47 -0
  183. package/src/services/stream-processor/permissions.ts +53 -0
  184. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  185. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  186. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  187. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  188. package/src/services/stream-processor/wasm-types.ts +58 -2
  189. package/src/services/tabs/broker-client.ts +283 -0
  190. package/src/services/tabs/broker.test.ts +327 -0
  191. package/src/services/tabs/coordinator.test.ts +386 -0
  192. package/src/services/tabs/coordinator.ts +638 -0
  193. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  194. package/src/services/tabs/leader-locks.ts +75 -0
  195. package/src/services/tabs/protocol.ts +262 -0
  196. package/src/services/tabs/support.ts +36 -0
  197. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  198. package/src/sp00ky.ts +5 -395
  199. package/src/state/client-state.ts +200 -0
  200. package/src/state/lifecycle.test.ts +118 -0
  201. package/src/state/lifecycle.ts +86 -0
  202. package/src/state/reducers.test.ts +304 -0
  203. package/src/state/reducers.ts +526 -0
  204. package/src/state/selectors.test.ts +167 -0
  205. package/src/state/selectors.ts +216 -0
  206. package/src/sw/index.test.ts +582 -0
  207. package/src/sw/index.ts +607 -0
  208. package/src/sw/store.ts +96 -0
  209. package/src/sync/connection.saga.test.ts +86 -0
  210. package/src/sync/connection.saga.ts +85 -0
  211. package/src/sync/live.saga.test.ts +178 -0
  212. package/src/sync/live.saga.ts +107 -0
  213. package/src/sync/policy.health.test.ts +56 -0
  214. package/src/sync/policy.test.ts +659 -0
  215. package/src/sync/policy.ts +502 -0
  216. package/src/sync/poll.saga.test.ts +51 -0
  217. package/src/sync/poll.saga.ts +36 -0
  218. package/src/sync/tabs.saga.test.ts +78 -0
  219. package/src/sync/tabs.saga.ts +96 -0
  220. package/src/testing/adapters.ts +133 -0
  221. package/src/testing/build.ts +63 -0
  222. package/src/testing/fake-services.ts +34 -0
  223. package/src/testing/run-pure.test.ts +92 -0
  224. package/src/testing/run-pure.ts +128 -0
  225. package/src/testing/services.ts +17 -0
  226. package/src/types.ts +624 -10
  227. package/src/utils/blurhash.ts +90 -0
  228. package/src/utils/edges.ts +39 -0
  229. package/src/utils/error-classification.test.ts +54 -0
  230. package/src/utils/error-classification.ts +25 -0
  231. package/src/utils/index.ts +75 -9
  232. package/src/utils/parser.test.ts +67 -102
  233. package/src/utils/parser.ts +64 -2
  234. package/src/utils/semver.test.ts +32 -0
  235. package/src/utils/semver.ts +30 -0
  236. package/src/utils/sha256.ts +15 -0
  237. package/src/utils/surql.ts +30 -18
  238. package/src/utils/utils.test.ts +2 -1
  239. package/tsdown.config.ts +117 -3
  240. package/vitest.config.ts +48 -0
  241. package/src/modules/cache/index.ts +0 -241
  242. package/src/modules/cache/types.ts +0 -19
  243. package/src/modules/data/data.test.ts +0 -58
  244. package/src/modules/data/index.ts +0 -879
  245. package/src/modules/sync/engine.ts +0 -173
  246. package/src/modules/sync/events/index.ts +0 -77
  247. package/src/modules/sync/index.ts +0 -3
  248. package/src/modules/sync/queue/index.ts +0 -2
  249. package/src/modules/sync/queue/queue-down.ts +0 -89
  250. package/src/modules/sync/queue/queue-up.ts +0 -223
  251. package/src/modules/sync/scheduler.ts +0 -84
  252. package/src/modules/sync/sync.ts +0 -421
  253. package/src/modules/sync/utils.test.ts +0 -311
  254. package/src/modules/sync/utils.ts +0 -167
@@ -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,139 @@
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
+ serializeMergeRow,
10
+ reviveRow,
11
+ project,
12
+ } from './sqlite-plan-sql';
13
+
14
+ describe('comparisonSql param slaving (aa4af79b)', () => {
15
+ it('prefers params[paramRef] over the baked literal', () => {
16
+ const bind: unknown[] = [];
17
+ const sql = comparisonSql(
18
+ { field: 'id', op: '=', value: 'thread:A', paramRef: 'id' },
19
+ bind,
20
+ { id: 'thread:B' }
21
+ );
22
+ expect(sql).toBe('id = ?');
23
+ expect(bind).toEqual(['thread:B']);
24
+ });
25
+
26
+ it('falls back to the baked literal when the param key is absent', () => {
27
+ const bind: unknown[] = [];
28
+ comparisonSql({ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }, bind, {});
29
+ expect(bind).toEqual(['thread:A']);
30
+ });
31
+
32
+ it('a pure $-ref (no baked value) always reads the param', () => {
33
+ const bind: unknown[] = [];
34
+ comparisonSql({ field: 'owner', op: '=', value: undefined, paramRef: 'auth' }, bind, {
35
+ auth: 'user:1',
36
+ });
37
+ expect(bind).toEqual(['user:1']);
38
+ });
39
+
40
+ it('renders non-id fields via json_extract and honors swap', () => {
41
+ const bind: unknown[] = [];
42
+ const sql = comparisonSql({ field: 'votes', op: '<', value: 5, swap: true }, bind, {});
43
+ expect(sql).toBe(`? < json_extract(data, '$.votes')`);
44
+ expect(bind).toEqual([5]);
45
+ });
46
+ });
47
+
48
+ describe('renderWhereSql', () => {
49
+ it('joins top-level nodes with AND and parenthesizes OR groups', () => {
50
+ const nodes: WhereNode[] = [
51
+ { field: 'kind', op: '=', value: 'a' },
52
+ {
53
+ or: [
54
+ { field: 'votes', op: '>', value: 1 },
55
+ { field: 'votes', op: '=', value: 0 },
56
+ ],
57
+ },
58
+ ];
59
+ const bind: unknown[] = [];
60
+ const sql = renderWhereSql(nodes, bind, {});
61
+ expect(sql).toBe(
62
+ `json_extract(data, '$.kind') = ? AND (json_extract(data, '$.votes') > ? OR json_extract(data, '$.votes') = ?)`
63
+ );
64
+ expect(bind).toEqual(['a', 1, 0]);
65
+ });
66
+ });
67
+
68
+ describe('renderOrderSql', () => {
69
+ it('renders multi-key ordering', () => {
70
+ expect(renderOrderSql([['a', 'desc'], ['b', 'asc']])).toBe(
71
+ ` ORDER BY json_extract(data, '$.a') DESC, json_extract(data, '$.b') ASC`
72
+ );
73
+ });
74
+ });
75
+
76
+ describe('scalar', () => {
77
+ it('binds RecordId-shaped objects as table:id strings', () => {
78
+ expect(scalar({ tb: 'user', id: '1' })).toBe('user:1');
79
+ expect(scalar('plain')).toBe('plain');
80
+ expect(scalar(3)).toBe(3);
81
+ expect(scalar(null)).toBeNull();
82
+ expect(scalar(undefined)).toBeNull();
83
+ });
84
+ });
85
+
86
+ describe('serializeRow / reviveRow round-trip', () => {
87
+ it('tags Uint8Array as {__u8} and revives it', () => {
88
+ const json = serializeRow({ id: 's:1', blob: new Uint8Array([0, 128, 255]) });
89
+ expect(json).toContain('"__u8"');
90
+ const back = reviveRow(json);
91
+ expect(back.blob).toBeInstanceOf(Uint8Array);
92
+ expect([...(back.blob as Uint8Array)]).toEqual([0, 128, 255]);
93
+ });
94
+
95
+ it('serializes RecordId-shaped links to strings and takes the fast parse path otherwise', () => {
96
+ const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
97
+ expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
98
+ });
99
+ });
100
+
101
+ describe('project', () => {
102
+ it('keeps id plus the listed fields only, skipping absent ones', () => {
103
+ expect(project({ id: 'a', x: 1, y: 2 }, ['x', 'missing'])).toEqual({ id: 'a', x: 1 });
104
+ });
105
+ });
106
+
107
+ describe('serializeMergeRow', () => {
108
+ it('reuses one string for insert and patch when nothing is cleared', () => {
109
+ const row = { id: 't:1', a: 1, b: null };
110
+ const { insert, patch } = serializeMergeRow(row);
111
+ expect(insert).toBe(serializeRow(row));
112
+ expect(patch).toBe(insert);
113
+ });
114
+
115
+ // json_patch (RFC 7396) removes a member whose patch value is null; JSON drops
116
+ // an undefined one, which is how a cleared field used to survive the MERGE.
117
+ it('carries a top-level undefined as a null member in the patch only', () => {
118
+ const { insert, patch } = serializeMergeRow({ id: 't:1', a: 1, club: undefined, 'we"ird': undefined });
119
+ expect(JSON.parse(insert)).toStrictEqual({ id: 't:1', a: 1 });
120
+ expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', a: 1, club: null, 'we"ird': null });
121
+ });
122
+
123
+ it('handles a row with nothing but cleared keys', () => {
124
+ const { insert, patch } = serializeMergeRow({ club: undefined });
125
+ expect(insert).toBe('{}');
126
+ expect(JSON.parse(patch)).toStrictEqual({ club: null });
127
+ });
128
+
129
+ it('leaves nested undefined dropped (only top-level keys clear)', () => {
130
+ const { patch } = serializeMergeRow({ id: 't:1', meta: { x: undefined, y: 1 } });
131
+ expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', meta: { y: 1 } });
132
+ });
133
+
134
+ it('does not append to a root the replacer collapsed to a scalar', () => {
135
+ const { insert, patch } = serializeMergeRow({ tb: 'user', id: 'u1', gone: undefined });
136
+ expect(insert).toBe('"user:u1"');
137
+ expect(patch).toBe(insert);
138
+ });
139
+ });
@@ -0,0 +1,179 @@
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
+ /**
74
+ * Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
75
+ * fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
76
+ *
77
+ * A top-level own key holding `undefined` means "clear this field", which is
78
+ * what SurrealDB's MERGE does with it (the SDK sends it as NONE). JSON drops
79
+ * such a key, so a patch built by {@link serializeRow} alone never mentioned
80
+ * it and the stored value survived every merge: a field cleared on the server
81
+ * stayed set locally. The patch therefore carries each one as a `null` member,
82
+ * which `json_patch` removes; the insert row simply lacks it, exactly as
83
+ * before. Appended to the already-serialized text, so the common case costs no
84
+ * second stringify. Nested `undefined` stays dropped (never a clear here).
85
+ */
86
+ export function serializeMergeRow(row: Row): { insert: string; patch: string } {
87
+ const insert = serializeRow(row);
88
+ const cleared: string[] = [];
89
+ for (const key of Object.keys(row)) {
90
+ if (row[key] === undefined) cleared.push(`${JSON.stringify(key)}:null`);
91
+ }
92
+ // Not an object text (the replacer collapsed the root): nothing to append to.
93
+ if (cleared.length === 0 || insert[0] !== '{') return { insert, patch: insert };
94
+ const members = cleared.join(',');
95
+ const patch = insert === '{}' ? `{${members}}` : `${insert.slice(0, -1)},${members}}`;
96
+ return { insert, patch };
97
+ }
98
+
99
+ export function reviveRow(json: string): Row {
100
+ // Fast path: the per-key reviver is only needed to rebuild `Uint8Array`s from
101
+ // `{__u8}` tags. Most rows (e.g. game bodies) have none — a plain parse avoids
102
+ // invoking a JS callback for every key of every row on the read hot path.
103
+ if (json.indexOf('"__u8"') === -1) return JSON.parse(json);
104
+ return JSON.parse(json, (_k, v) => {
105
+ if (v && typeof v === 'object' && typeof (v as any).__u8 === 'string') {
106
+ return fromBase64((v as any).__u8);
107
+ }
108
+ return v;
109
+ });
110
+ }
111
+
112
+ /**
113
+ * SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
114
+ * `id`), so SQLite never returns — and neither side ever parses — the fields the
115
+ * caller did not ask for. On a game list that is every row's `pgn`, which was
116
+ * being read, parsed and thrown away 50 rows at a time for eight rendered fields.
117
+ *
118
+ * Byte-identical to running {@link project} over the fully-parsed row, which is
119
+ * what both paths did before, and verified so against real SQLite:
120
+ * `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
121
+ * rather than becoming an explicit null — the difference `json_object`/
122
+ * `json_extract` would have introduced. A stored null, nested objects and
123
+ * arrays, and `{__u8}` blob tags all round-trip unchanged, because
124
+ * `json_group_object` understands `json_each`'s `value` column as JSON rather
125
+ * than as text. `COALESCE` covers a row sharing none of the requested keys,
126
+ * where the subquery yields NULL and `reviveRow` would throw.
127
+ *
128
+ * The one value `je.value` does NOT carry back is a TOP-LEVEL boolean. SQLite
129
+ * has no boolean type, so `json_each` hands `true`/`false` out as the integers
130
+ * 1/0 and `json_group_object` writes integers back. A projected row therefore
131
+ * read `published: 1` where the same row read whole read `published: true`,
132
+ * and every `=== true` in an app turned false the moment a screen painted from
133
+ * the local cache. (A boolean NESTED in an object or array survives: those come
134
+ * back as whole JSON documents, untouched.) `je.type` still knows what it was,
135
+ * so the two boolean types are rebuilt as JSON literals on the way out.
136
+ *
137
+ * Deliberately unaliased, so the emitted statement keeps the exact shape the
138
+ * callers already produce (`FROM "t" WHERE id IN (…)`).
139
+ *
140
+ * Binds one parameter per key, pushed onto `bind` — these land in the SELECT
141
+ * clause, so they must be bound BEFORE any WHERE parameters.
142
+ */
143
+ export function projectedDataSql(fields: string[], bind: unknown[]): string {
144
+ const keys = ['id', ...fields];
145
+ for (const k of keys) bind.push(k);
146
+ const placeholders = keys.map(() => '?').join(', ');
147
+ // `json('true')` / `json('false')` return JSON-subtyped values, so
148
+ // `json_group_object` embeds them as the literals `true` / `false` rather
149
+ // than as quoted text.
150
+ const value =
151
+ `CASE je.type WHEN 'true' THEN json('true') ` +
152
+ `WHEN 'false' THEN json('false') ELSE je.value END`;
153
+ return (
154
+ `COALESCE((SELECT json_group_object(je.key, ${value}) ` +
155
+ `FROM json_each(data) je WHERE je.key IN (${placeholders})), '{}') AS data`
156
+ );
157
+ }
158
+
159
+ export function project(row: Row, fields: string[]): Row {
160
+ const out: Row = {};
161
+ for (const f of ['id', ...fields]) if (f in row) out[f] = row[f];
162
+ return out;
163
+ }
164
+
165
+ export function toBase64(bytes: Uint8Array): string {
166
+ let bin = '';
167
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
168
+ return typeof btoa !== 'undefined' ? btoa(bin) : Buffer.from(bytes).toString('base64');
169
+ }
170
+
171
+ export function fromBase64(b64: string): Uint8Array {
172
+ if (typeof atob !== 'undefined') {
173
+ const bin = atob(b64);
174
+ const out = new Uint8Array(bin.length);
175
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
176
+ return out;
177
+ }
178
+ return new Uint8Array(Buffer.from(b64, 'base64'));
179
+ }
@@ -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
+ });