@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.
Files changed (266) hide show
  1. package/AGENTS.md +84 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2080 -142
  4. package/dist/index.js +16009 -2480
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +373 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +392 -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 +477 -0
  18. package/dist/types.d.ts +1857 -38
  19. package/dist/worker-edges.js +480 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +48 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +28 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +173 -0
  27. package/skills/sp00ky-core/SKILL.md +32 -7
  28. package/skills/sp00ky-core/references/config.md +7 -1
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +203 -0
  32. package/src/boot/boot.saga.ts +146 -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 +80 -0
  36. package/src/boot/preload.saga.ts +40 -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 +195 -0
  40. package/src/client/bucket-handle.ts +352 -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.test.ts +74 -0
  46. package/src/client/services.ts +434 -0
  47. package/src/client/sp00ky-client.test.ts +341 -0
  48. package/src/client/sp00ky-client.ts +625 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +167 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +512 -0
  62. package/src/live/index.ts +1059 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +228 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +476 -22
  72. package/src/modules/crdt/acceptance.ts +75 -0
  73. package/src/modules/crdt/bytes.ts +50 -0
  74. package/src/modules/crdt/crdt-field.ts +125 -0
  75. package/src/modules/crdt/crdt.test.ts +469 -0
  76. package/src/modules/crdt/hash.ts +103 -0
  77. package/src/modules/crdt/index.ts +757 -0
  78. package/src/modules/crdt/loro-loader.ts +25 -0
  79. package/src/modules/crdt/mirror.ts +37 -0
  80. package/src/modules/devtools/flags.ts +349 -0
  81. package/src/modules/devtools/index.ts +746 -95
  82. package/src/modules/devtools/mutations.test.ts +92 -0
  83. package/src/modules/devtools/mutations.ts +160 -0
  84. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  85. package/src/modules/devtools/state-shape.test.ts +263 -0
  86. package/src/modules/devtools/storage-info.test.ts +79 -0
  87. package/src/modules/devtools/storage-info.ts +168 -0
  88. package/src/modules/devtools/versions.test.ts +74 -0
  89. package/src/modules/devtools/versions.ts +110 -0
  90. package/src/modules/feature-flag/index.test.ts +298 -0
  91. package/src/modules/feature-flag/index.ts +316 -0
  92. package/src/modules/ref-tables.test.ts +93 -0
  93. package/src/modules/ref-tables.ts +91 -0
  94. package/src/modules/web-push/index.test.ts +824 -0
  95. package/src/modules/web-push/index.ts +1027 -0
  96. package/src/mutation/jobs.test.ts +25 -0
  97. package/src/mutation/jobs.ts +41 -0
  98. package/src/mutation/mutation-id.test.ts +37 -0
  99. package/src/mutation/mutation-id.ts +35 -0
  100. package/src/mutation/pending-writes.integration.test.ts +175 -0
  101. package/src/mutation/push.saga.test.ts +301 -0
  102. package/src/mutation/push.saga.ts +225 -0
  103. package/src/mutation/rows.test.ts +231 -0
  104. package/src/mutation/rows.ts +427 -0
  105. package/src/mutation/tray.saga.test.ts +63 -0
  106. package/src/mutation/tray.saga.ts +48 -0
  107. package/src/mutation/write.saga.test.ts +316 -0
  108. package/src/mutation/write.saga.ts +268 -0
  109. package/src/otel/index.ts +10 -7
  110. package/src/pure.ts +66 -0
  111. package/src/push/types.ts +174 -0
  112. package/src/query/anon-query-isolation.integration.test.ts +527 -0
  113. package/src/query/env.test.ts +23 -0
  114. package/src/query/env.ts +65 -0
  115. package/src/query/fetch.saga.test.ts +199 -0
  116. package/src/query/fetch.saga.ts +120 -0
  117. package/src/query/hash.test.ts +14 -0
  118. package/src/query/hash.ts +18 -0
  119. package/src/query/id-set-plan.test.ts +132 -0
  120. package/src/query/lifecycle.saga.test.ts +274 -0
  121. package/src/query/lifecycle.saga.ts +186 -0
  122. package/src/query/list-ref-live.integration.test.ts +203 -0
  123. package/src/query/materialize.saga.test.ts +115 -0
  124. package/src/query/materialize.saga.ts +84 -0
  125. package/src/query/materialize.test.ts +58 -0
  126. package/src/query/materialize.ts +57 -0
  127. package/src/query/membership.saga.test.ts +407 -0
  128. package/src/query/membership.saga.ts +282 -0
  129. package/src/query/membership.test.ts +118 -0
  130. package/src/query/membership.ts +201 -0
  131. package/src/query/register.saga.test.ts +311 -0
  132. package/src/query/register.saga.ts +235 -0
  133. package/src/query/render-set.test.ts +47 -0
  134. package/src/query/render-set.ts +71 -0
  135. package/src/query/sql.test.ts +118 -0
  136. package/src/query/sql.ts +157 -0
  137. package/src/query/window-query.test.ts +70 -0
  138. package/src/query/window-query.ts +208 -0
  139. package/src/services/blobs/blob-cache.test.ts +359 -0
  140. package/src/services/blobs/blob-cache.ts +603 -0
  141. package/src/services/blobs/blob-manifest.ts +227 -0
  142. package/src/services/blobs/blob-store.test.ts +77 -0
  143. package/src/services/blobs/blob-store.ts +359 -0
  144. package/src/services/blobs/blob.fixture.ts +90 -0
  145. package/src/services/blobs/index.ts +70 -0
  146. package/src/services/database/cache-engine.ts +209 -0
  147. package/src/services/database/connection-supervisor.test.ts +318 -0
  148. package/src/services/database/connection-supervisor.ts +422 -0
  149. package/src/services/database/database.query-timeout.test.ts +140 -0
  150. package/src/services/database/database.responses.test.ts +43 -0
  151. package/src/services/database/database.ts +156 -61
  152. package/src/services/database/engine-factory.ts +33 -0
  153. package/src/services/database/errors.ts +34 -0
  154. package/src/services/database/events/index.ts +2 -1
  155. package/src/services/database/index.ts +7 -0
  156. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  157. package/src/services/database/local-migrator.integration.test.ts +110 -0
  158. package/src/services/database/local-migrator.ts +61 -16
  159. package/src/services/database/local.test.ts +150 -0
  160. package/src/services/database/local.ts +508 -63
  161. package/src/services/database/plan-render.test.ts +159 -0
  162. package/src/services/database/plan-render.ts +108 -0
  163. package/src/services/database/relation-resolver.test.ts +484 -0
  164. package/src/services/database/relation-resolver.ts +0 -0
  165. package/src/services/database/remote.ts +285 -6
  166. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  167. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  168. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  169. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  170. package/src/services/database/sqlite-column-reset.ts +91 -0
  171. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  172. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  173. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  174. package/src/services/database/sqlite-lock-verify.ts +45 -0
  175. package/src/services/database/sqlite-open.test.ts +150 -0
  176. package/src/services/database/sqlite-open.ts +164 -0
  177. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  178. package/src/services/database/sqlite-plan-sql.ts +196 -0
  179. package/src/services/database/sqlite-projection.test.ts +121 -0
  180. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  181. package/src/services/database/sqlite-select.test.ts +261 -0
  182. package/src/services/database/sqlite-select.ts +131 -0
  183. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  184. package/src/services/database/sqlite-transport.ts +224 -0
  185. package/src/services/database/sqlite-worker.ts +437 -0
  186. package/src/services/database/surql-translate.ts +417 -0
  187. package/src/services/database/surreal-cache-engine.ts +163 -0
  188. package/src/services/logger/index.test.ts +132 -0
  189. package/src/services/logger/index.ts +189 -7
  190. package/src/services/persistence/localstorage.ts +2 -2
  191. package/src/services/persistence/resilient.ts +10 -3
  192. package/src/services/persistence/surrealdb.ts +4 -4
  193. package/src/services/stream-processor/index.ts +806 -67
  194. package/src/services/stream-processor/permissions.test.ts +47 -0
  195. package/src/services/stream-processor/permissions.ts +53 -0
  196. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  197. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  198. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  199. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  200. package/src/services/stream-processor/wasm-types.ts +58 -2
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -395
  211. package/src/state/client-state.ts +208 -0
  212. package/src/state/lifecycle.test.ts +141 -0
  213. package/src/state/lifecycle.ts +99 -0
  214. package/src/state/reducers.test.ts +306 -0
  215. package/src/state/reducers.ts +531 -0
  216. package/src/state/selectors.test.ts +188 -0
  217. package/src/state/selectors.ts +258 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +178 -0
  224. package/src/sync/live.saga.ts +107 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +633 -10
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +39 -0
  241. package/src/utils/error-classification.test.ts +54 -0
  242. package/src/utils/error-classification.ts +25 -0
  243. package/src/utils/index.ts +89 -12
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +64 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +29 -18
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -879
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -77
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -89
  262. package/src/modules/sync/queue/queue-up.ts +0 -223
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -421
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { openDb } from './sqlite-open';
3
+
4
+ class FakeDb {
5
+ constructor(public arg: unknown) {}
6
+ exec() {
7
+ return [];
8
+ }
9
+ close() {}
10
+ }
11
+
12
+ /** Minimal stand-in for the initialized sqlite-wasm module. `install` is the
13
+ * `installOpfsSAHPoolVfs` behavior under test; omit it to model a build that
14
+ * lacks the SAHPool VFS entirely. */
15
+ function makeSqlite3(install?: (opts: any) => Promise<unknown>) {
16
+ const sqlite3: any = { oo1: { DB: FakeDb } };
17
+ if (install) sqlite3.installOpfsSAHPoolVfs = vi.fn(install);
18
+ return sqlite3;
19
+ }
20
+
21
+ const pool = { OpfsSAHPoolDb: FakeDb };
22
+ /** What a pool locked by another tab of the app actually throws. */
23
+ function lockedError(): Error {
24
+ const e = new Error('Access Handles cannot be acquired');
25
+ e.name = 'NoModificationAllowedError';
26
+ return e;
27
+ }
28
+
29
+ const noSleep = () => Promise.resolve();
30
+
31
+ let errSpy: ReturnType<typeof vi.spyOn>;
32
+ beforeEach(() => {
33
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
34
+ });
35
+ afterEach(() => {
36
+ errSpy.mockRestore();
37
+ });
38
+
39
+ // The bare `catch {}` this replaces turned every OPFS failure into a silent
40
+ // full-RAM database: no log, no reason, no way for the app to know its writes
41
+ // die on reload. Each case below pins one half of the fix: keep trying when
42
+ // retrying can plausibly work, and when it can't, say so loudly and hand the
43
+ // reason back.
44
+ describe('openDb', () => {
45
+ it('opens the OPFS pool on the first attempt', async () => {
46
+ const sqlite3 = makeSqlite3(async () => pool);
47
+ const res = await openDb(sqlite3, 'user:abc', true, { sleep: noSleep });
48
+
49
+ expect(res.persisted).toBe(true);
50
+ expect(res.opfsError).toBeUndefined();
51
+ expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(1);
52
+ // The pool is named per bucket, and the first try must NOT force a re-init
53
+ // (that would throw away a pool another attempt is legitimately using).
54
+ expect(sqlite3.installOpfsSAHPoolVfs.mock.calls[0][0]).toEqual({ name: 'sp00ky-user:abc' });
55
+ expect(errSpy).not.toHaveBeenCalled();
56
+ });
57
+
58
+ // The tab-closing race: the old tab still holds the sync access handles when
59
+ // the new one boots. Without a retry that tab is stuck in RAM for its whole
60
+ // lifetime, even though the lock frees milliseconds later.
61
+ it('retries a locked pool and succeeds, forcing re-init after the first failure', async () => {
62
+ let calls = 0;
63
+ const sqlite3 = makeSqlite3(async () => {
64
+ if (++calls < 3) throw lockedError();
65
+ return pool;
66
+ });
67
+ const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
68
+
69
+ expect(res.persisted).toBe(true);
70
+ expect(res.opfsError).toBeUndefined();
71
+ expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(3);
72
+ // sqlite-wasm caches the first rejection against the VFS name, so retries
73
+ // that don't ask for a real re-init just replay it.
74
+ const [first, second, third] = sqlite3.installOpfsSAHPoolVfs.mock.calls.map((c: any[]) => c[0]);
75
+ expect(first.forceReinitIfPreviouslyFailed).toBeUndefined();
76
+ expect(second.forceReinitIfPreviouslyFailed).toBe(true);
77
+ expect(third.forceReinitIfPreviouslyFailed).toBe(true);
78
+ expect(errSpy).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('falls back loudly after exhausting the retries, keeping the reason', async () => {
82
+ const sqlite3 = makeSqlite3(async () => {
83
+ throw lockedError();
84
+ });
85
+ const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
86
+
87
+ expect(res.persisted).toBe(false);
88
+ // The DOMException name is the diagnostic part: it names the lock.
89
+ expect(res.opfsError).toContain('NoModificationAllowedError');
90
+ expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(3);
91
+ expect(errSpy).toHaveBeenCalledTimes(1);
92
+ expect(String(errSpy.mock.calls[0][0])).toContain('IN MEMORY');
93
+ // Still a usable handle: losing durability must not break the app.
94
+ expect(res.db).toBeDefined();
95
+ });
96
+
97
+ // An insecure context has no sync access handles at all, so retrying just
98
+ // adds boot latency to a foregone conclusion.
99
+ it('does not retry when the OPFS APIs are missing entirely', async () => {
100
+ const sqlite3 = makeSqlite3(async () => {
101
+ throw new Error('Missing required OPFS APIs.');
102
+ });
103
+ const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
104
+
105
+ expect(res.persisted).toBe(false);
106
+ expect(res.opfsError).toContain('Missing required OPFS APIs');
107
+ expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(1);
108
+ expect(errSpy).toHaveBeenCalledTimes(1);
109
+ });
110
+
111
+ it('reports a build without the SAHPool VFS without calling anything', async () => {
112
+ const sqlite3 = makeSqlite3();
113
+ const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
114
+
115
+ expect(res.persisted).toBe(false);
116
+ expect(res.opfsError).toContain('installOpfsSAHPoolVfs');
117
+ expect(errSpy).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ // `store: 'memory'` is a configuration choice, not a degradation, so it must
121
+ // stay silent and carry no error for the UI to warn about.
122
+ it('opens in memory quietly when persistence was not requested', async () => {
123
+ const sqlite3 = makeSqlite3(async () => pool);
124
+ const res = await openDb(sqlite3, 'main', false, { sleep: noSleep });
125
+
126
+ expect(res.persisted).toBe(false);
127
+ expect(res.opfsError).toBeUndefined();
128
+ expect(sqlite3.installOpfsSAHPoolVfs).not.toHaveBeenCalled();
129
+ expect(errSpy).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it('honors maxAttempts and waits the configured backoff between tries', async () => {
133
+ const slept: number[] = [];
134
+ const sqlite3 = makeSqlite3(async () => {
135
+ throw lockedError();
136
+ });
137
+ const res = await openDb(sqlite3, 'main', true, {
138
+ maxAttempts: 4,
139
+ backoffMs: [10, 20],
140
+ sleep: async (ms) => {
141
+ slept.push(ms);
142
+ },
143
+ });
144
+
145
+ expect(res.persisted).toBe(false);
146
+ expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(4);
147
+ // One sleep per gap (never after the last attempt), last delay repeating.
148
+ expect(slept).toEqual([10, 20, 20]);
149
+ });
150
+ });
@@ -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,164 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import type { WhereNode } from '@spooky-sync/query-builder';
4
+ import {
5
+ comparisonSql,
6
+ renderWhereSql,
7
+ renderOrderSql,
8
+ scalar,
9
+ serializeRow,
10
+ serializeMergeRow,
11
+ reviveRow,
12
+ project,
13
+ } from './sqlite-plan-sql';
14
+
15
+ describe('comparisonSql param slaving (aa4af79b)', () => {
16
+ it('prefers params[paramRef] over the baked literal', () => {
17
+ const bind: unknown[] = [];
18
+ const sql = comparisonSql(
19
+ { field: 'id', op: '=', value: 'thread:A', paramRef: 'id' },
20
+ bind,
21
+ { id: 'thread:B' }
22
+ );
23
+ expect(sql).toBe('id = ?');
24
+ expect(bind).toEqual(['thread:B']);
25
+ });
26
+
27
+ it('falls back to the baked literal when the param key is absent', () => {
28
+ const bind: unknown[] = [];
29
+ comparisonSql({ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }, bind, {});
30
+ expect(bind).toEqual(['thread:A']);
31
+ });
32
+
33
+ it('a pure $-ref (no baked value) always reads the param', () => {
34
+ const bind: unknown[] = [];
35
+ comparisonSql({ field: 'owner', op: '=', value: undefined, paramRef: 'auth' }, bind, {
36
+ auth: 'user:1',
37
+ });
38
+ expect(bind).toEqual(['user:1']);
39
+ });
40
+
41
+ it('renders non-id fields via json_extract and honors swap', () => {
42
+ const bind: unknown[] = [];
43
+ const sql = comparisonSql({ field: 'votes', op: '<', value: 5, swap: true }, bind, {});
44
+ expect(sql).toBe(`? < json_extract(data, '$.votes')`);
45
+ expect(bind).toEqual([5]);
46
+ });
47
+ });
48
+
49
+ describe('renderWhereSql', () => {
50
+ it('joins top-level nodes with AND and parenthesizes OR groups', () => {
51
+ const nodes: WhereNode[] = [
52
+ { field: 'kind', op: '=', value: 'a' },
53
+ {
54
+ or: [
55
+ { field: 'votes', op: '>', value: 1 },
56
+ { field: 'votes', op: '=', value: 0 },
57
+ ],
58
+ },
59
+ ];
60
+ const bind: unknown[] = [];
61
+ const sql = renderWhereSql(nodes, bind, {});
62
+ expect(sql).toBe(
63
+ `json_extract(data, '$.kind') = ? AND (json_extract(data, '$.votes') > ? OR json_extract(data, '$.votes') = ?)`
64
+ );
65
+ expect(bind).toEqual(['a', 1, 0]);
66
+ });
67
+ });
68
+
69
+ describe('renderOrderSql', () => {
70
+ it('renders multi-key ordering', () => {
71
+ expect(renderOrderSql([['a', 'desc'], ['b', 'asc']])).toBe(
72
+ ` ORDER BY json_extract(data, '$.a') DESC, json_extract(data, '$.b') ASC`
73
+ );
74
+ });
75
+ });
76
+
77
+ describe('scalar', () => {
78
+ it('binds RecordId-shaped objects as table:id strings', () => {
79
+ expect(scalar({ tb: 'user', id: '1' })).toBe('user:1');
80
+ expect(scalar('plain')).toBe('plain');
81
+ expect(scalar(3)).toBe(3);
82
+ expect(scalar(null)).toBeNull();
83
+ expect(scalar(undefined)).toBeNull();
84
+ });
85
+ });
86
+
87
+ describe('serializeRow / reviveRow round-trip', () => {
88
+ it('tags Uint8Array as {__u8} and revives it', () => {
89
+ const json = serializeRow({ id: 's:1', blob: new Uint8Array([0, 128, 255]) });
90
+ expect(json).toContain('"__u8"');
91
+ const back = reviveRow(json);
92
+ expect(back.blob).toBeInstanceOf(Uint8Array);
93
+ expect([...(back.blob as Uint8Array)]).toEqual([0, 128, 255]);
94
+ });
95
+
96
+ // The surrealdb SDK decodes a `bytes` column as an ArrayBuffer, which
97
+ // JSON.stringify writes as `{}`: synced CRDT log rows (and presence states)
98
+ // landed with no data, so every other client's edit was refused as empty.
99
+ it('keeps the bytes of every binary shape: an SDK ArrayBuffer, a view into a larger buffer', () => {
100
+ const backing = new Uint8Array([9, 9, 1, 2, 250, 9]);
101
+ const json = serializeRow({
102
+ id: 'thread__crdt_log:1',
103
+ data: backing.slice(2, 5).buffer,
104
+ window: backing.subarray(2, 5),
105
+ dv: new DataView(backing.buffer, 2, 3),
106
+ nested: { state: [new Int8Array([1, 2, -6]).buffer] },
107
+ });
108
+ const back = reviveRow(json) as any;
109
+ for (const bytes of [back.data, back.window, back.dv, back.nested.state[0]]) {
110
+ expect(bytes).toBeInstanceOf(Uint8Array);
111
+ expect([...bytes]).toEqual([1, 2, 250]);
112
+ }
113
+ });
114
+
115
+ it('serializes RecordId-shaped links to strings and takes the fast parse path otherwise', () => {
116
+ const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
117
+ expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
118
+ });
119
+
120
+ it('stores an SDK RecordId link raw, not as its escaped toJSON', () => {
121
+ const json = serializeRow({ id: 'game:1', white: new RecordId('player_name', '3abc'), tags: [new RecordId('t', '9z')] });
122
+ expect(reviveRow(json)).toEqual({ id: 'game:1', white: 'player_name:3abc', tags: ['t:9z'] });
123
+ });
124
+ });
125
+
126
+ describe('project', () => {
127
+ it('keeps id plus the listed fields only, skipping absent ones', () => {
128
+ expect(project({ id: 'a', x: 1, y: 2 }, ['x', 'missing'])).toEqual({ id: 'a', x: 1 });
129
+ });
130
+ });
131
+
132
+ describe('serializeMergeRow', () => {
133
+ it('reuses one string for insert and patch when nothing is cleared', () => {
134
+ const row = { id: 't:1', a: 1, b: null };
135
+ const { insert, patch } = serializeMergeRow(row);
136
+ expect(insert).toBe(serializeRow(row));
137
+ expect(patch).toBe(insert);
138
+ });
139
+
140
+ // json_patch (RFC 7396) removes a member whose patch value is null; JSON drops
141
+ // an undefined one, which is how a cleared field used to survive the MERGE.
142
+ it('carries a top-level undefined as a null member in the patch only', () => {
143
+ const { insert, patch } = serializeMergeRow({ id: 't:1', a: 1, club: undefined, 'we"ird': undefined });
144
+ expect(JSON.parse(insert)).toStrictEqual({ id: 't:1', a: 1 });
145
+ expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', a: 1, club: null, 'we"ird': null });
146
+ });
147
+
148
+ it('handles a row with nothing but cleared keys', () => {
149
+ const { insert, patch } = serializeMergeRow({ club: undefined });
150
+ expect(insert).toBe('{}');
151
+ expect(JSON.parse(patch)).toStrictEqual({ club: null });
152
+ });
153
+
154
+ it('leaves nested undefined dropped (only top-level keys clear)', () => {
155
+ const { patch } = serializeMergeRow({ id: 't:1', meta: { x: undefined, y: 1 } });
156
+ expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', meta: { y: 1 } });
157
+ });
158
+
159
+ it('does not append to a root the replacer collapsed to a scalar', () => {
160
+ const { insert, patch } = serializeMergeRow({ tb: 'user', id: 'u1', gone: undefined });
161
+ expect(insert).toBe('"user:u1"');
162
+ expect(patch).toBe(insert);
163
+ });
164
+ });
@@ -0,0 +1,196 @@
1
+ import type { WhereComparison, WhereNode } from '@spooky-sync/query-builder';
2
+ import { recordIdKey, 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, function (this: Record<string, unknown>, k, v) {
64
+ // `v` has already been through `toJSON()`, and a RecordId's is escaped
65
+ // SurrealQL (`game:⟨476g…⟩`), so key the original value instead.
66
+ const raw = this[k];
67
+ if (raw && typeof raw === 'object') {
68
+ const bytes = bytesOf(raw);
69
+ if (bytes) return { __u8: toBase64(bytes) };
70
+ const key = recordIdKey(raw);
71
+ if (key !== null) return key;
72
+ }
73
+ return v;
74
+ });
75
+ }
76
+
77
+ /**
78
+ * The bytes of a binary value, whatever shape it arrived in. Rows from the
79
+ * server carry a `bytes` column as an `ArrayBuffer` (the surrealdb SDK's CBOR
80
+ * decode), not a `Uint8Array`, and `JSON.stringify` turns an `ArrayBuffer`
81
+ * into `{}`: every synced CRDT log row and presence state was stored empty.
82
+ */
83
+ function bytesOf(value: object): Uint8Array | null {
84
+ if (value instanceof Uint8Array) return value;
85
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
86
+ if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
92
+ * fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
93
+ *
94
+ * A top-level own key holding `undefined` means "clear this field", which is
95
+ * what SurrealDB's MERGE does with it (the SDK sends it as NONE). JSON drops
96
+ * such a key, so a patch built by {@link serializeRow} alone never mentioned
97
+ * it and the stored value survived every merge: a field cleared on the server
98
+ * stayed set locally. The patch therefore carries each one as a `null` member,
99
+ * which `json_patch` removes; the insert row simply lacks it, exactly as
100
+ * before. Appended to the already-serialized text, so the common case costs no
101
+ * second stringify. Nested `undefined` stays dropped (never a clear here).
102
+ */
103
+ export function serializeMergeRow(row: Row): { insert: string; patch: string } {
104
+ const insert = serializeRow(row);
105
+ const cleared: string[] = [];
106
+ for (const key of Object.keys(row)) {
107
+ if (row[key] === undefined) cleared.push(`${JSON.stringify(key)}:null`);
108
+ }
109
+ // Not an object text (the replacer collapsed the root): nothing to append to.
110
+ if (cleared.length === 0 || insert[0] !== '{') return { insert, patch: insert };
111
+ const members = cleared.join(',');
112
+ const patch = insert === '{}' ? `{${members}}` : `${insert.slice(0, -1)},${members}}`;
113
+ return { insert, patch };
114
+ }
115
+
116
+ export function reviveRow(json: string): Row {
117
+ // Fast path: the per-key reviver is only needed to rebuild `Uint8Array`s from
118
+ // `{__u8}` tags. Most rows (e.g. game bodies) have none — a plain parse avoids
119
+ // invoking a JS callback for every key of every row on the read hot path.
120
+ if (json.indexOf('"__u8"') === -1) return JSON.parse(json);
121
+ return JSON.parse(json, (_k, v) => {
122
+ if (v && typeof v === 'object' && typeof (v as any).__u8 === 'string') {
123
+ return fromBase64((v as any).__u8);
124
+ }
125
+ return v;
126
+ });
127
+ }
128
+
129
+ /**
130
+ * SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
131
+ * `id`), so SQLite never returns — and neither side ever parses — the fields the
132
+ * caller did not ask for. On a game list that is every row's `pgn`, which was
133
+ * being read, parsed and thrown away 50 rows at a time for eight rendered fields.
134
+ *
135
+ * Byte-identical to running {@link project} over the fully-parsed row, which is
136
+ * what both paths did before, and verified so against real SQLite:
137
+ * `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
138
+ * rather than becoming an explicit null — the difference `json_object`/
139
+ * `json_extract` would have introduced. A stored null, nested objects and
140
+ * arrays, and `{__u8}` blob tags all round-trip unchanged, because
141
+ * `json_group_object` understands `json_each`'s `value` column as JSON rather
142
+ * than as text. `COALESCE` covers a row sharing none of the requested keys,
143
+ * where the subquery yields NULL and `reviveRow` would throw.
144
+ *
145
+ * The one value `je.value` does NOT carry back is a TOP-LEVEL boolean. SQLite
146
+ * has no boolean type, so `json_each` hands `true`/`false` out as the integers
147
+ * 1/0 and `json_group_object` writes integers back. A projected row therefore
148
+ * read `published: 1` where the same row read whole read `published: true`,
149
+ * and every `=== true` in an app turned false the moment a screen painted from
150
+ * the local cache. (A boolean NESTED in an object or array survives: those come
151
+ * back as whole JSON documents, untouched.) `je.type` still knows what it was,
152
+ * so the two boolean types are rebuilt as JSON literals on the way out.
153
+ *
154
+ * Deliberately unaliased, so the emitted statement keeps the exact shape the
155
+ * callers already produce (`FROM "t" WHERE id IN (…)`).
156
+ *
157
+ * Binds one parameter per key, pushed onto `bind` — these land in the SELECT
158
+ * clause, so they must be bound BEFORE any WHERE parameters.
159
+ */
160
+ export function projectedDataSql(fields: string[], bind: unknown[]): string {
161
+ const keys = ['id', ...fields];
162
+ for (const k of keys) bind.push(k);
163
+ const placeholders = keys.map(() => '?').join(', ');
164
+ // `json('true')` / `json('false')` return JSON-subtyped values, so
165
+ // `json_group_object` embeds them as the literals `true` / `false` rather
166
+ // than as quoted text.
167
+ const value =
168
+ `CASE je.type WHEN 'true' THEN json('true') ` +
169
+ `WHEN 'false' THEN json('false') ELSE je.value END`;
170
+ return (
171
+ `COALESCE((SELECT json_group_object(je.key, ${value}) ` +
172
+ `FROM json_each(data) je WHERE je.key IN (${placeholders})), '{}') AS data`
173
+ );
174
+ }
175
+
176
+ export function project(row: Row, fields: string[]): Row {
177
+ const out: Row = {};
178
+ for (const f of ['id', ...fields]) if (f in row) out[f] = row[f];
179
+ return out;
180
+ }
181
+
182
+ export function toBase64(bytes: Uint8Array): string {
183
+ let bin = '';
184
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
185
+ return typeof btoa !== 'undefined' ? btoa(bin) : Buffer.from(bytes).toString('base64');
186
+ }
187
+
188
+ export function fromBase64(b64: string): Uint8Array {
189
+ if (typeof atob !== 'undefined') {
190
+ const bin = atob(b64);
191
+ const out = new Uint8Array(bin.length);
192
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
193
+ return out;
194
+ }
195
+ return new Uint8Array(Buffer.from(b64, 'base64'));
196
+ }