@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,110 @@
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { RecordId, Surreal } from 'surrealdb';
5
+ import { LocalMigrator } from './local-migrator';
6
+ import * as rows from '../../mutation/rows';
7
+
8
+ /**
9
+ * Integration: the client schema the CLI emits, provisioned by the real
10
+ * LocalMigrator into a real SurrealDB-WASM store, then every local write the
11
+ * outbox makes. The meta tables are read from the CLI's source file, so this
12
+ * cannot drift from what `spky generate` ships; the per-table `_00_rv` field
13
+ * is appended the way the codegen does it.
14
+ *
15
+ * `_00_pending_mutations` used to be SCHEMAFULL there, without the fields the
16
+ * client writes (`tableName`, `createdAt`, `v`, `beforeRecord`): SurrealDB
17
+ * failed every create, update and delete transaction on this engine.
18
+ */
19
+
20
+ const META = new URL('../../../../../apps/cli/src/meta_tables_client.surql', import.meta.url);
21
+
22
+ async function clientSchema(userSchema: string): Promise<string> {
23
+ const schema = `${userSchema}\n${await readFile(fileURLToPath(META), 'utf8')}`;
24
+ const tables = [...schema.matchAll(/DEFINE TABLE (?:IF NOT EXISTS )?(\w+)/g)].map((m) => m[1]);
25
+ return schema + tables.map((t) => `\nDEFINE FIELD _00_rv ON TABLE ${t} TYPE int DEFAULT 0 PERMISSIONS FOR select, create, update WHERE true;`).join('');
26
+ }
27
+
28
+ const DOC_V1 = `DEFINE TABLE doc SCHEMAFULL PERMISSIONS FOR select, create, update, delete WHERE true;
29
+ DEFINE FIELD title ON TABLE doc TYPE option<string>;`;
30
+ const DOC_V2 = `${DOC_V1}
31
+ DEFINE FIELD body ON TABLE doc TYPE option<string>;`;
32
+
33
+ const doc = new RecordId('doc', 'd1');
34
+ const mid = (n: string) => new RecordId('_00_pending_mutations', n);
35
+ const noop = () => {};
36
+
37
+ describe('client schema on the SurrealDB-WASM engine', () => {
38
+ let db: Surreal;
39
+ let migrator: LocalMigrator;
40
+
41
+ beforeAll(async () => {
42
+ // The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
43
+ // and Node's fetch has no `file:` scheme: serve that one read from disk.
44
+ const realFetch = globalThis.fetch;
45
+ vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
46
+ const url = input instanceof URL ? input : new URL(String(input));
47
+ if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
48
+ return realFetch(input as RequestInfo, init);
49
+ });
50
+ const { createWasmEngines } = await import('@surrealdb/wasm');
51
+ db = new Surreal({ engines: createWasmEngines() });
52
+ await db.connect('mem://');
53
+ await db.use({ namespace: 'n', database: 'd' });
54
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
55
+ logger.child = () => logger;
56
+ const store: any = { queryUngated: (sql: string, vars?: Record<string, unknown>) => db.query(sql, vars), getConfig: () => ({ database: 'd' }) };
57
+ migrator = new LocalMigrator(store, logger);
58
+ await migrator.provision(await clientSchema(DOC_V1));
59
+ }, 60_000);
60
+
61
+ afterAll(async () => {
62
+ await db?.close();
63
+ vi.unstubAllGlobals();
64
+ });
65
+
66
+ const exec = (tx: rows.LocalTx) => db.query(tx.query.sql, tx.vars);
67
+ const outbox = async () => ((await db.query(rows.loadPendingRows())) as unknown[][])[0].map(rows.parsePendingRow);
68
+
69
+ it('every outbox write commits', async () => {
70
+ await exec(rows.planCreateTx({ recordId: doc, mutationId: mid('1'), table: 'doc', data: { title: 'a' }, now: 1 }));
71
+ await exec(rows.planUpdateTx({ recordId: doc, mutationId: mid('2'), table: 'doc', data: { title: 'b' }, before: { id: doc, title: 'a' }, now: 2 }));
72
+ const wid = new RecordId('_00_pending_writes', 'w1');
73
+ await exec(rows.planLocalOnlyUpdateTx({ recordId: doc, data: { title: 'c' }, mirror: { id: wid, row: { recordId: doc, tableName: 'doc', data: { title: 'c' }, beforeRecord: null, flushBy: 9 } } }));
74
+ await exec(rows.planDeferredOutboxRowTx({ recordId: doc, mutationId: mid('3'), table: 'doc', data: { title: 'c' }, before: null, now: 3, mirrorId: wid }));
75
+ await exec(rows.planDeleteTx({ recordId: doc, mutationId: mid('4'), table: 'doc', before: { id: doc, title: 'c' }, now: 4 }));
76
+
77
+ const pending = await outbox();
78
+ expect(pending.map((r) => [r?.mutationType, r?.tableName, r?.v])).toEqual([
79
+ ['create', 'doc', 2],
80
+ ['update', 'doc', 2],
81
+ ['update', 'doc', 2],
82
+ ['delete', 'doc', 2],
83
+ ]);
84
+ expect(pending[1]?.beforeRecord).toEqual({ id: doc, title: 'a' });
85
+
86
+ // A rejected one moves to the tray; an accepted one is deleted.
87
+ await exec(rows.moveToFailedTx(rows.buildFailedRow(pending[1]!, { message: 'denied', kind: 'application' }, pending[1]!.beforeRecord ?? null, 1, 5, 'full')));
88
+ const del = rows.deletePendingRow(pending[0]!.id);
89
+ await db.query(del.sql, del.vars);
90
+ expect((await outbox()).map((r) => r?.mutationType)).toEqual(['update', 'delete']);
91
+ const [tray] = (await db.query(rows.loadFailedRows())) as [Array<Record<string, unknown>>];
92
+ expect(tray).toEqual([expect.objectContaining({ mutationType: 'update', tableName: 'doc', error: { message: 'denied', kind: 'application' } })]);
93
+ });
94
+
95
+ it('a schema change keeps the outbox, the tray and pending writes, and drops the cache', async () => {
96
+ await exec(rows.planLocalOnlyUpdateTx({ recordId: doc, data: { title: 'd' }, mirror: { id: new RecordId('_00_pending_writes', 'w2'), row: { recordId: doc, tableName: 'doc', data: { title: 'd' }, beforeRecord: null, flushBy: 9 } } }));
97
+ const ids = async () =>
98
+ ((await db.query('SELECT VALUE id FROM _00_pending_mutations; SELECT VALUE id FROM _00_failed_mutations; SELECT VALUE id FROM _00_pending_writes')) as RecordId[][]).map((r) =>
99
+ r.map(String).sort()
100
+ );
101
+ const before = await ids();
102
+ expect(before.map((r) => r.length)).toEqual([2, 1, 1]);
103
+
104
+ await migrator.provision(await clientSchema(DOC_V2));
105
+ expect(await ids()).toEqual(before);
106
+ expect(((await db.query('SELECT * FROM doc')) as unknown[][])[0]).toEqual([]);
107
+ await exec(rows.planCreateTx({ recordId: new RecordId('doc', 'd2'), mutationId: mid('5'), table: 'doc', data: { title: 'e', body: 'x' }, now: 6 }));
108
+ expect(await outbox()).toHaveLength(3);
109
+ });
110
+ });
@@ -1,6 +1,6 @@
1
- import type { Surreal } from 'surrealdb';
2
- import { Logger, createLogger } from '../logger/index';
3
- import { LocalDatabaseService } from './local';
1
+ import type { Logger} from '../logger/index';
2
+ import { createLogger } from '../logger/index';
3
+ import type { LocalStore } from './cache-engine';
4
4
 
5
5
  export interface SchemaRecord {
6
6
  hash: string;
@@ -15,16 +15,24 @@ export const sha1 = async (str: string): Promise<string> => {
15
15
  .join('');
16
16
  };
17
17
 
18
+ /**
19
+ * Local-only state a schema change must not throw away: the outbox, the
20
+ * failed-mutation tray, debounced patches not yet in the outbox and CRDT
21
+ * documents with edits not yet sent. Cached rows refill from the server;
22
+ * these exist nowhere else.
23
+ */
24
+ const KEPT_TABLES = ['_00_pending_mutations', '_00_failed_mutations', '_00_pending_writes', '_00_crdt_local'] as const;
25
+
18
26
  export class LocalMigrator {
19
27
  private logger: Logger;
20
28
 
21
29
  constructor(
22
- private localDb: LocalDatabaseService,
30
+ private localDb: LocalStore,
23
31
  logger: Logger
24
32
  ) {
25
33
  this.logger = logger.child({ service: 'LocalMigrator' });
26
- logger?.child({ service: 'LocalMigrator' }) ??
27
- createLogger('info').child({ service: 'LocalMigrator' });
34
+ void (logger?.child({ service: 'LocalMigrator' }) ??
35
+ createLogger('info').child({ service: 'LocalMigrator' }));
28
36
  }
29
37
 
30
38
  async provision(schemaSurql: string): Promise<void> {
@@ -40,13 +48,19 @@ export class LocalMigrator {
40
48
  return;
41
49
  }
42
50
 
51
+ const kept = await this.readKeptRows();
43
52
  await this.recreateDatabase(database);
44
53
 
45
54
  const systemSchema = `
46
55
  DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
47
- DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
56
+ DEFINE TABLE IF NOT EXISTS _00_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
57
+ DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
58
+ DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
59
  DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
60
  DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
61
+ DEFINE TABLE IF NOT EXISTS _00_pending_writes SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
62
+ DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
63
+ DEFINE TABLE IF NOT EXISTS _00_crdt_local SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
50
64
  `;
51
65
  const fullSchema = schemaSurql + '\n' + systemSchema;
52
66
 
@@ -71,7 +85,7 @@ export class LocalMigrator {
71
85
  { Category: 'sp00ky-client::LocalMigrator::provision' },
72
86
  `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`
73
87
  );
74
- await this.localDb.query(statement);
88
+ await this.localDb.queryUngated(statement);
75
89
  this.logger.info(
76
90
  { Category: 'sp00ky-client::LocalMigrator::provision' },
77
91
  `[Provisioning] (${i + 1}/${statements.length}) Done`
@@ -85,37 +99,68 @@ export class LocalMigrator {
85
99
  }
86
100
  }
87
101
 
102
+ await this.restoreKeptRows(kept);
88
103
  await this.createHashRecord(hash);
89
104
  }
90
105
 
106
+ private async readKeptRows(): Promise<Array<[string, unknown[]]>> {
107
+ const kept: Array<[string, unknown[]]> = [];
108
+ for (const table of KEPT_TABLES) {
109
+ try {
110
+ const [rows] = await this.localDb.queryUngated<[unknown[]]>(`SELECT * FROM ${table}`);
111
+ if (Array.isArray(rows) && rows.length > 0) kept.push([table, rows]);
112
+ } catch {
113
+ // A fresh store has no such table yet: nothing to keep.
114
+ }
115
+ }
116
+ return kept;
117
+ }
118
+
119
+ private async restoreKeptRows(kept: Array<[string, unknown[]]>): Promise<void> {
120
+ for (const [table, rows] of kept) {
121
+ try {
122
+ await this.localDb.queryUngated(`INSERT INTO ${table} $rows`, { rows });
123
+ this.logger.info(
124
+ { table, count: rows.length, Category: 'sp00ky-client::LocalMigrator::provision' },
125
+ '[Provisioning] Kept local rows across the schema change'
126
+ );
127
+ } catch (error) {
128
+ this.logger.error(
129
+ { table, count: rows.length, error, Category: 'sp00ky-client::LocalMigrator::provision' },
130
+ '[Provisioning] Could not restore local rows after the schema change; they are lost'
131
+ );
132
+ }
133
+ }
134
+ }
135
+
91
136
  private async isSchemaUpToDate(hash: string): Promise<boolean> {
92
137
  try {
93
- const [lastSchemaRecord] = await this.localDb.query<any>(
138
+ const [lastSchemaRecord] = await this.localDb.queryUngated<any>(
94
139
  `SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`
95
140
  );
96
141
  return lastSchemaRecord?.hash === hash;
97
- } catch (error) {
142
+ } catch (_error) {
98
143
  return false;
99
144
  }
100
145
  }
101
146
 
102
147
  private async recreateDatabase(database: string) {
103
148
  try {
104
- await this.localDb.query(`DEFINE DATABASE _00_temp;`);
105
- } catch (e) {
149
+ await this.localDb.queryUngated(`DEFINE DATABASE _00_temp;`);
150
+ } catch (_e) {
106
151
  // Ignore if exists
107
152
  }
108
153
 
109
154
  try {
110
- await this.localDb.query(`
155
+ await this.localDb.queryUngated(`
111
156
  USE DB _00_temp;
112
157
  REMOVE DATABASE ${database};
113
158
  `);
114
- } catch (e) {
159
+ } catch (_e) {
115
160
  // Ignore error if database doesn't exist
116
161
  }
117
162
 
118
- await this.localDb.query(`
163
+ await this.localDb.queryUngated(`
119
164
  DEFINE DATABASE ${database};
120
165
  USE DB ${database};
121
166
  `);
@@ -195,7 +240,7 @@ export class LocalMigrator {
195
240
  }
196
241
 
197
242
  private async createHashRecord(hash: string) {
198
- await this.localDb.query(
243
+ await this.localDb.queryUngated(
199
244
  `UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
200
245
  { hash }
201
246
  );
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isLocalStoreOpenError } from './local';
3
+
4
+ // `LocalDatabaseService.connect` recovers (drops the store + reconnects, falling
5
+ // back to in-memory) only when the failure is the SurrealDB-WASM IndexedDB
6
+ // open/transaction error — NOT for unrelated errors. This pins the message match
7
+ // against the real error the engine throws so a recovery path doesn't silently
8
+ // stop triggering (or start swallowing unrelated failures).
9
+ describe('isLocalStoreOpenError', () => {
10
+ it('matches the real SurrealDB-WASM IndexedDB key-value store error', () => {
11
+ const real = new Error(
12
+ 'There was a problem with the key-value store: There was a problem with a ' +
13
+ 'transaction: An IndexedDB error occured: idb error'
14
+ );
15
+ expect(isLocalStoreOpenError(real)).toBe(true);
16
+ });
17
+
18
+ it('matches on the individual signals (indexeddb / idb error / key-value store)', () => {
19
+ expect(isLocalStoreOpenError(new Error('IndexedDB is not available'))).toBe(true);
20
+ expect(isLocalStoreOpenError(new Error('idb error'))).toBe(true);
21
+ expect(isLocalStoreOpenError(new Error('problem with the key-value store'))).toBe(true);
22
+ // Non-Error inputs are stringified.
23
+ expect(isLocalStoreOpenError('An IndexedDB error occured')).toBe(true);
24
+ });
25
+
26
+ it('does NOT match unrelated errors (so we never clear the store spuriously)', () => {
27
+ expect(isLocalStoreOpenError(new Error('WebSocket connection refused'))).toBe(false);
28
+ expect(isLocalStoreOpenError(new Error('Parse error: unexpected token'))).toBe(false);
29
+ expect(isLocalStoreOpenError(new Error('permission denied'))).toBe(false);
30
+ expect(isLocalStoreOpenError(null)).toBe(false);
31
+ expect(isLocalStoreOpenError(undefined)).toBe(false);
32
+ });
33
+ });
34
+
35
+ // Per-user local buckets: URL/name derivation and the SCOPE of the tier-2
36
+ // corruption drop. The drop must only ever match the failing bucket's store —
37
+ // a substring wipe would take every user's cache AND their un-pushed mutation
38
+ // outboxes down with one corrupt store.
39
+ import { bucketStoreName, bucketStoreUrl, matchesBucketStore } from './local';
40
+
41
+ describe('bucket store naming', () => {
42
+ it('derives the store url/name from the bucket id', () => {
43
+ expect(bucketStoreUrl('abc')).toBe('indxdb://sp00ky-abc');
44
+ expect(bucketStoreName('abc')).toBe('sp00ky-abc');
45
+ expect(bucketStoreUrl('anon')).toBe('indxdb://sp00ky-anon');
46
+ });
47
+ });
48
+
49
+ describe('matchesBucketStore', () => {
50
+ it('matches the exact store and derived names', () => {
51
+ expect(matchesBucketStore('sp00ky-abc', 'sp00ky-abc')).toBe(true);
52
+ expect(matchesBucketStore('sp00ky-abc-wal', 'sp00ky-abc')).toBe(true);
53
+ expect(matchesBucketStore('surrealdb/sp00ky-abc', 'sp00ky-abc')).toBe(true);
54
+ expect(matchesBucketStore('SP00KY-ABC', 'sp00ky-abc')).toBe(true);
55
+ });
56
+
57
+ it("never matches another bucket's store", () => {
58
+ expect(matchesBucketStore('sp00ky-abcdef', 'sp00ky-abc')).toBe(false);
59
+ expect(matchesBucketStore('sp00ky-anon', 'sp00ky-abc')).toBe(false);
60
+ expect(matchesBucketStore('sp00ky', 'sp00ky-abc')).toBe(false);
61
+ // The legacy shared store is not any bucket's store.
62
+ expect(matchesBucketStore('sp00ky', 'sp00ky-anon')).toBe(false);
63
+ });
64
+ });
65
+
66
+ // A bucket switch that lands while the boot open is still in flight (a reload
67
+ // of a signed-in tab, with IndexedDB slow to open) must wait for it. Run
68
+ // concurrently, the switch closed the client `connect` was opening and both
69
+ // provisioned a store at once, so boot never finished.
70
+ import { vi } from 'vitest';
71
+ import { Surreal } from 'surrealdb';
72
+ import { LocalDatabaseService } from './local';
73
+
74
+ vi.mock('@surrealdb/wasm', () => ({ createWasmWorkerEngines: () => ({}) }));
75
+
76
+ describe('LocalDatabaseService open and switch', () => {
77
+ it('a switch waits for the in-flight connect instead of closing the client it opens', async () => {
78
+ const opens: Array<{ url: string; done: () => void }> = [];
79
+ const closed: string[] = [];
80
+ const urlOf = new WeakMap<object, string>();
81
+ vi.spyOn(Surreal.prototype, 'connect').mockImplementation(function (this: Surreal, url: string | URL) {
82
+ urlOf.set(this, String(url));
83
+ return new Promise<true>((resolve) => opens.push({ url: String(url), done: () => resolve(true) }));
84
+ });
85
+ vi.spyOn(Surreal.prototype, 'use').mockResolvedValue({});
86
+ vi.spyOn(Surreal.prototype, 'close').mockImplementation(async function (this: Surreal) {
87
+ closed.push(urlOf.get(this) ?? 'placeholder');
88
+ return true as const;
89
+ });
90
+ const logger: any = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
91
+ logger.child = () => logger;
92
+ const local = new LocalDatabaseService({ namespace: 'n', database: 'd', store: 'indexeddb' } as any, logger);
93
+
94
+ const booting = local.connect('u1');
95
+ await vi.waitFor(() => expect(opens).toHaveLength(1));
96
+ const switching = local.switchStore('anon');
97
+ await new Promise((r) => setTimeout(r, 10));
98
+ expect(opens.map((o) => o.url)).toEqual(['indxdb://sp00ky-u1']);
99
+ expect(closed).toEqual([]);
100
+
101
+ opens[0].done();
102
+ await booting;
103
+ await vi.waitFor(() => expect(opens).toHaveLength(2));
104
+ expect(opens[1].url).toBe('indxdb://sp00ky-anon');
105
+ opens[1].done();
106
+ await switching;
107
+ expect(closed).toEqual(['indxdb://sp00ky-u1']);
108
+ expect(local.currentBucketId).toBe('anon');
109
+ vi.restoreAllMocks();
110
+ });
111
+
112
+ // The engine streams a statement's result from its worker; closing the
113
+ // client under it left the statement pending until its 30 s deadline, and
114
+ // every later local statement queued behind it (sign-in hung on "Loading").
115
+ it.each(['memory', 'indexeddb'] as const)('%s: a switch lets a statement in flight finish before it closes the client', async (store) => {
116
+ let answer!: (rows: unknown[]) => void;
117
+ const events: string[] = [];
118
+ vi.spyOn(Surreal.prototype, 'connect').mockResolvedValue(true);
119
+ vi.spyOn(Surreal.prototype, 'use').mockResolvedValue({});
120
+ vi.spyOn(Surreal.prototype, 'query').mockImplementation((() =>
121
+ new Promise((r) => {
122
+ answer = (rows) => {
123
+ events.push('answered');
124
+ r(rows);
125
+ };
126
+ })) as any);
127
+ vi.spyOn(Surreal.prototype, 'close').mockImplementation(async () => {
128
+ events.push('close');
129
+ return true as const;
130
+ });
131
+ const logger: any = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
132
+ logger.child = () => logger;
133
+ const local = new LocalDatabaseService({ namespace: 'n', database: 'd', store } as any, logger);
134
+ await local.connect('anon');
135
+
136
+ const reading = local.query('SELECT * FROM thing');
137
+ await vi.waitFor(() => expect(answer).toBeDefined());
138
+ const release = local.beginSwitch();
139
+ const switching = local.switchStore('u1');
140
+ await new Promise((r) => setTimeout(r, 10));
141
+ expect(events).toEqual([]);
142
+
143
+ answer([[]]);
144
+ await reading;
145
+ await switching;
146
+ release();
147
+ expect(events).toEqual(['answered', 'close']);
148
+ vi.restoreAllMocks();
149
+ });
150
+ });