@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
@@ -1,16 +1,31 @@
1
- import { Surreal, SurrealTransaction } from 'surrealdb';
2
- import { createLogger, Logger } from '../logger/index';
3
- import {
1
+ import type { Surreal, SurrealTransaction } from 'surrealdb';
2
+ import type { Logger } from '../logger/index';
3
+ import type {
4
4
  DatabaseEventSystem,
5
- DatabaseEventTypes,
6
- DatabaseQueryEventPayload,
7
- } from './events/index';
8
- import { SealedQuery } from '../../utils/surql';
5
+ DatabaseEventTypes} from './events/index';
6
+ import type { SealedQuery } from '../../utils/surql';
7
+ import { withTimeout } from '../../utils/index';
9
8
 
10
9
  export abstract class AbstractDatabaseService {
11
10
  protected client: Surreal;
12
11
  protected logger: Logger;
13
12
  protected events: DatabaseEventSystem;
13
+ /**
14
+ * Per-query deadline in ms; `0` disables. The remote service sets it from
15
+ * `queryTimeoutMs` (see `RemoteDatabaseService`), the local one from
16
+ * `localOpTimeoutMs` (see `LocalDatabaseService`): a local query can be
17
+ * legitimately slow, but it must never be endless - every query waits on the
18
+ * previous link of {@link query}'s chain, and one that never settled wedged
19
+ * every later local op behind it.
20
+ */
21
+ protected queryTimeoutMs = 0;
22
+
23
+ /** The error a deadline expiry rejects with; the local service substitutes
24
+ * its typed `LocalOpTimeoutError`. "timed out" in the message is
25
+ * load-bearing either way: `classifySyncError` keys off it. */
26
+ protected timeoutError(_query: string): Error {
27
+ return new Error(`Remote query timed out after ${this.queryTimeoutMs}ms`);
28
+ }
14
29
  protected abstract eventType:
15
30
  | typeof DatabaseEventTypes.LocalQuery
16
31
  | typeof DatabaseEventTypes.RemoteQuery;
@@ -35,67 +50,147 @@ export abstract class AbstractDatabaseService {
35
50
  return this.client.beginTransaction();
36
51
  }
37
52
 
38
- private queryQueue: Promise<void> = Promise.resolve();
53
+ /**
54
+ * How many statements may be in flight at once.
55
+ *
56
+ * `1` serializes every statement behind the previous one. The local engines
57
+ * need that: overlapping ops trip the WASM / SQLite transaction layer. The
58
+ * remote service raises it (`RemoteDatabaseService`): a SurrealDB socket
59
+ * multiplexes requests by id, and serializing them capped the client at one
60
+ * statement per round trip (~5/s at 170ms RTT), so the `_00_list_ref` poll
61
+ * alone could hold every registration behind it and one slow one-shot read
62
+ * stalled the whole client. The scheduler's `MAX_CONCURRENT_DOWN` only means
63
+ * something when this is above 1.
64
+ */
65
+ protected maxConcurrentQueries = 1;
66
+ private inFlightQueries = 0;
67
+ private queryWaiters: Array<() => void> = [];
68
+
69
+ /** FIFO slot acquisition, so `maxConcurrentQueries = 1` keeps strict order. */
70
+ private acquireQuerySlot(): Promise<void> {
71
+ if (this.inFlightQueries < this.maxConcurrentQueries) {
72
+ this.inFlightQueries++;
73
+ return Promise.resolve();
74
+ }
75
+ return new Promise<void>((resolve) => {
76
+ this.queryWaiters.push(() => {
77
+ this.inFlightQueries++;
78
+ resolve();
79
+ });
80
+ });
81
+ }
82
+
83
+ private releaseQuerySlot(): void {
84
+ this.inFlightQueries--;
85
+ const next = this.queryWaiters.shift();
86
+ if (next) next();
87
+ }
88
+
89
+ /**
90
+ * Resolve once every statement issued before this call has settled: take
91
+ * every slot (FIFO, so earlier statements go first), then hand them back.
92
+ * Bounded by {@link queryTimeoutMs}, like the statements it waits on.
93
+ */
94
+ protected async drainStatements(): Promise<void> {
95
+ for (let i = 0; i < this.maxConcurrentQueries; i++) await this.acquireQuerySlot();
96
+ for (let i = 0; i < this.maxConcurrentQueries; i++) this.releaseQuerySlot();
97
+ }
39
98
 
40
99
  /**
41
- * Execute a query with serialized execution to prevent WASM transaction issues.
100
+ * Hook run right before a statement is sent, inside its slot. The remote
101
+ * service uses it to hold statements while a connect is in flight, so a
102
+ * query never runs on a socket that is open but has not had its namespace
103
+ * and token applied yet. The default does nothing.
104
+ */
105
+ protected async beforeQuery(): Promise<void> {}
106
+
107
+ /**
108
+ * Execute a statement under {@link maxConcurrentQueries}.
109
+ *
110
+ * A statement that never settles would hold its slot forever, and with a
111
+ * limit of 1 that blocks every later statement behind it. {@link
112
+ * queryTimeoutMs} bounds each one: on expiry this promise rejects and the
113
+ * slot is released, even though the underlying RPC is still parked in the
114
+ * SDK's pending map.
42
115
  */
43
116
  async query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T> {
44
- return new Promise((resolve, reject) => {
45
- this.queryQueue = this.queryQueue
46
- .then(async () => {
47
- const startTime = performance.now();
48
- try {
49
- this.logger.debug(
50
- { query, vars, Category: 'sp00ky-client::Database::query' },
51
- 'Executing query'
52
- );
53
- const pending = this.client.query(query, vars);
54
- // In SurrealDB 2.0, .query() collects results by default.
55
- // We cast to T directly as proper typing depends on the caller knowing the return structure.
56
- const result = (await pending) as unknown as T;
57
- const duration = performance.now() - startTime;
58
-
59
- // Emit query event
60
- this.events.emit(this.eventType, {
61
- query,
62
- vars,
63
- duration,
64
- success: true,
65
- timestamp: Date.now(),
66
- });
67
-
68
- resolve(result);
69
- this.logger.trace(
70
- { query, result, Category: 'sp00ky-client::Database::query' },
71
- 'Query executed successfully'
72
- );
73
- } catch (err) {
74
- const duration = performance.now() - startTime;
75
-
76
- // Emit query event with error
77
- this.events.emit(this.eventType, {
78
- query,
79
- vars,
80
- duration,
81
- success: false,
82
- error: err instanceof Error ? err.message : String(err),
83
- timestamp: Date.now(),
84
- });
85
-
86
- this.logger.error(
87
- { query, vars, err, Category: 'sp00ky-client::Database::query' },
88
- 'Query execution failed'
89
- );
90
- reject(err);
91
- }
92
- })
93
- .catch(() => {
94
- // Ignore queue errors to keep the chain alive; the specific promise was rejected above.
95
- });
117
+ return this.runStatement<T>(query, vars, () => this.client.query(query, vars) as unknown as Promise<T>);
118
+ }
119
+
120
+ /**
121
+ * Like {@link query} but never rejects on a statement error: every
122
+ * statement answers on its own as `{ status: 'OK', result }` or
123
+ * `{ status: 'ERR', error }`. This is what lets a batched outbox push judge
124
+ * each mutation separately.
125
+ */
126
+ async queryResponses(
127
+ query: string,
128
+ vars?: Record<string, unknown>
129
+ ): Promise<Array<{ status: 'OK'; result: unknown } | { status: 'ERR'; error: string }>> {
130
+ return this.runStatement(query, vars, async () => {
131
+ const responses = await this.client.query(query, vars).responses();
132
+ return responses.map((r) =>
133
+ r.success
134
+ ? { status: 'OK' as const, result: r.result }
135
+ : { status: 'ERR' as const, error: r.error instanceof Error ? r.error.message : String(r.error) }
136
+ );
96
137
  });
97
138
  }
98
139
 
140
+ private async runStatement<T>(query: string, vars: Record<string, unknown> | undefined, dispatch: () => Promise<T>): Promise<T> {
141
+ await this.acquireQuerySlot();
142
+ try {
143
+ await this.beforeQuery();
144
+ const startTime = performance.now();
145
+ try {
146
+ // Trace: the finished statement is logged at debug by the client,
147
+ // from the query event below. This line is for a statement that hangs.
148
+ this.logger.trace(
149
+ { query, vars, Category: 'sp00ky-client::Database::query' },
150
+ 'Executing query'
151
+ );
152
+ // "timed out" in the message is load-bearing: `classifySyncError`
153
+ // keys off it to classify this as `network` so the sync queues
154
+ // retry rather than rolling the mutation back.
155
+ const result = await withTimeout(dispatch(), this.queryTimeoutMs, () => this.timeoutError(query));
156
+ const duration = performance.now() - startTime;
157
+
158
+ this.events.emit(this.eventType, {
159
+ query,
160
+ vars,
161
+ duration,
162
+ success: true,
163
+ timestamp: Date.now(),
164
+ });
165
+
166
+ this.logger.trace(
167
+ { query, result, Category: 'sp00ky-client::Database::query' },
168
+ 'Query executed successfully'
169
+ );
170
+ return result;
171
+ } catch (err) {
172
+ const duration = performance.now() - startTime;
173
+
174
+ this.events.emit(this.eventType, {
175
+ query,
176
+ vars,
177
+ duration,
178
+ success: false,
179
+ error: err instanceof Error ? err.message : String(err),
180
+ timestamp: Date.now(),
181
+ });
182
+
183
+ this.logger.error(
184
+ { query, vars, err, Category: 'sp00ky-client::Database::query' },
185
+ 'Query execution failed'
186
+ );
187
+ throw err;
188
+ }
189
+ } finally {
190
+ this.releaseQuerySlot();
191
+ }
192
+ }
193
+
99
194
  async execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T> {
100
195
  const raw = await this.query<unknown[]>(query.sql, vars);
101
196
  return query.extract(raw);
@@ -0,0 +1,33 @@
1
+ import type { Sp00kyConfig } from '../../types';
2
+ import type { Logger } from '../logger/index';
3
+ import type { LocalEngineChoice, LocalStore } from './cache-engine';
4
+ import { SurrealCacheEngine } from './surreal-cache-engine';
5
+ import { SqliteCacheEngine } from './sqlite-cache-engine';
6
+
7
+ /**
8
+ * Build the local cache engine for the given `localEngine` config choice.
9
+ *
10
+ * - `'surrealdb'` / unset → {@link SurrealCacheEngine} (subclass of
11
+ * `LocalDatabaseService`; the historical behavior, verbatim).
12
+ * - `'sqlite'` → {@link SqliteCacheEngine} (SQLite-WASM Worker + OPFS). Backs
13
+ * `this.local` through the SurrealQL-vocabulary shim + verb surface.
14
+ * - a custom object → used as-is (must satisfy {@link LocalStore}).
15
+ */
16
+ export function createLocalEngine(
17
+ choice: LocalEngineChoice | undefined,
18
+ config: Sp00kyConfig<any>['database'],
19
+ logger: Logger,
20
+ opts: { shared?: boolean } = {}
21
+ ): LocalStore {
22
+ if (choice === undefined || choice === 'surrealdb') {
23
+ return new SurrealCacheEngine(config, logger);
24
+ }
25
+ if (choice === 'sqlite') {
26
+ // Mirror the SurrealDB engine's `store` semantics: 'memory' → in-memory
27
+ // SQLite (transient), 'indexeddb' → OPFS-backed (durable).
28
+ const useOpfs = (config.store ?? 'memory') !== 'memory';
29
+ return new SqliteCacheEngine(config, logger, { useOpfs, shared: opts.shared });
30
+ }
31
+ // Custom engine instance — trust it to satisfy LocalStore.
32
+ return choice as unknown as LocalStore;
33
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A local-store operation that did not answer within its deadline.
3
+ *
4
+ * The local write path (`db.create` / `db.update` / `db.delete`, every local
5
+ * query behind them) used to have no deadline anywhere: the SQLite worker
6
+ * transport parks a call until the worker replies, the surrealdb engine's
7
+ * query chain waits on the previous link, and `withRetry` retries without a
8
+ * clock. One op that never settled (a worker starved behind a long select, a
9
+ * lock verification awaiting `navigator.locks.query()` forever) left the
10
+ * caller's promise pending for the tab's lifetime - a chat composer that never
11
+ * re-enabled, a call that never got past "Connecting".
12
+ *
13
+ * The message says "timed out" on purpose: `classifySyncError` keys off it and
14
+ * treats the failure as transient (re-queue), never as an application error
15
+ * that rolls the mutation back. `retryable: false` keeps `withRetry` from
16
+ * spinning on it: the op is still running in the engine, retrying queues a
17
+ * second copy behind it.
18
+ */
19
+ export class LocalOpTimeoutError extends Error {
20
+ override readonly name = 'LocalOpTimeoutError';
21
+ readonly retryable = false;
22
+ readonly op: string;
23
+ readonly timeoutMs: number;
24
+
25
+ constructor(op: string, timeoutMs: number) {
26
+ super(`Local database operation timed out after ${timeoutMs}ms (${op})`);
27
+ this.op = op;
28
+ this.timeoutMs = timeoutMs;
29
+ }
30
+ }
31
+
32
+ /** Default deadline for one local-store operation. Generous: a cold 4k-row
33
+ * select on a throttled tab is seconds, not tens of seconds. */
34
+ export const DEFAULT_LOCAL_OP_TIMEOUT_MS = 30_000;
@@ -1,4 +1,5 @@
1
- import { createEventSystem, EventDefinition, EventSystem } from '../../../events/index';
1
+ import type { EventDefinition, EventSystem } from '../../../events/index';
2
+ import { createEventSystem } from '../../../events/index';
2
3
 
3
4
  export const DatabaseEventTypes = {
4
5
  LocalQuery: 'DATABASE_LOCAL_QUERY',
@@ -1,5 +1,12 @@
1
1
  export * from './database';
2
2
  export * from './local';
3
3
  export * from './remote';
4
+ export * from './connection-supervisor';
4
5
  export * from './local-migrator';
5
6
  export * from './events/index';
7
+ export * from './cache-engine';
8
+ export * from './surreal-cache-engine';
9
+ export * from './sqlite-cache-engine';
10
+ export * from './relation-resolver';
11
+ export * from './plan-render';
12
+ export { createLocalEngine } from './engine-factory';
@@ -0,0 +1,236 @@
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 sqlite3InitModule from '@sqlite.org/sqlite-wasm';
5
+ import { RecordId, Surreal } from 'surrealdb';
6
+ import { landChunk } from '../../query/fetch.saga';
7
+ import { defaultEnv } from '../../query/env';
8
+ import { runPure } from '../../testing/run-pure';
9
+ import { buildState } from '../../testing/build';
10
+ import type { Vars } from '../../kernel/effects';
11
+ import type { SealedQuery } from '../../utils/surql';
12
+ import { SqliteCacheEngine } from './sqlite-cache-engine';
13
+ import { stubTransport } from './sqlite-transport.fixture';
14
+ import type { Row } from './cache-engine';
15
+
16
+ /**
17
+ * Integration: a field cleared on the server (`UPDATE broadcast:x UNSET club`)
18
+ * is ABSENT from the body the server returns, and the body is landed with
19
+ * MERGE, which keeps every key it does not name. These land real bodies
20
+ * through `landChunk` and run the exact transaction it emits on BOTH local
21
+ * engines: a real in-memory SQLite (the worker's @sqlite.org/sqlite-wasm
22
+ * build) behind the SqliteCacheEngine, and a real SurrealDB-WASM `mem://`
23
+ * store provisioned with a typed schema, as the local migrator does.
24
+ *
25
+ * On each: the cleared optional field is gone, local `_00_*` bookkeeping
26
+ * survives (still a MERGE, not a REPLACE), and a required field the body
27
+ * lacks is kept rather than cleared.
28
+ */
29
+
30
+ const env = defaultEnv({
31
+ tables: [
32
+ {
33
+ name: 'broadcast',
34
+ columns: {
35
+ title: { type: 'string', optional: false },
36
+ secret: { type: 'string', optional: false },
37
+ club: { type: 'string', recordId: true, optional: true },
38
+ note: { type: 'string', optional: true },
39
+ },
40
+ },
41
+ ],
42
+ } as any);
43
+
44
+ type Exec = (query: SealedQuery<unknown>, vars: Vars) => Promise<unknown>;
45
+
46
+ /** Land `rows` at `version` through the real saga, executing its write with `exec`. */
47
+ async function land(
48
+ exec: Exec,
49
+ rows: Array<Record<string, unknown>>,
50
+ version: number
51
+ ): Promise<void> {
52
+ const ids = rows.map((r) => String(r.id));
53
+ const out = await runPure(
54
+ landChunk(env, ids, rows, new Map(ids.map((id) => [id, version] as const)), buildState(), 0),
55
+ {
56
+ handlers: {
57
+ 'local.execute': (e: any) => exec(e.query, e.vars),
58
+ 'ssp.ingest': () => undefined,
59
+ },
60
+ }
61
+ );
62
+ expect(out.emitted).not.toContainEqual(expect.objectContaining({ message: 'body write failed' }));
63
+ expect(out.result).toBe(true);
64
+ }
65
+
66
+ const x = new RecordId('broadcast', 'x');
67
+ const fresh = new RecordId('broadcast', 'fresh');
68
+ const club = new RecordId('club', 'c1');
69
+
70
+ const noop = () => {};
71
+
72
+ // ==================== SQLite ====================
73
+
74
+ describe('landed body clears an absent optional field: SQLite engine', () => {
75
+ let db: any;
76
+
77
+ function realEngine(): SqliteCacheEngine {
78
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
79
+ logger.child = () => logger;
80
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
81
+ stubTransport(engine, (type, payload: any) => {
82
+ switch (type) {
83
+ case 'open':
84
+ return { persisted: true };
85
+ case 'exec':
86
+ return {
87
+ rows: db.exec({
88
+ sql: payload.sql,
89
+ bind: payload.bind,
90
+ rowMode: 'object',
91
+ returnValue: 'resultRows',
92
+ }),
93
+ };
94
+ case 'run':
95
+ db.exec({ sql: payload.sql, bind: payload.bind });
96
+ return {};
97
+ case 'batch':
98
+ for (const stmt of payload as { sql: string; bind?: unknown[] }[])
99
+ db.exec({ sql: stmt.sql, bind: stmt.bind });
100
+ return {};
101
+ default:
102
+ return {};
103
+ }
104
+ });
105
+ return engine;
106
+ }
107
+
108
+ beforeAll(async () => {
109
+ const sqlite3: any = await sqlite3InitModule();
110
+ db = new sqlite3.oo1.DB(':memory:', 'c');
111
+ });
112
+
113
+ afterAll(() => db?.close());
114
+
115
+ it('removes the cleared key on the batched fast path, keeps _00_* and required fields', async () => {
116
+ const engine = realEngine();
117
+ await engine.connect('anon');
118
+ const exec: Exec = (q, v) => engine.execute(q, v);
119
+
120
+ await land(exec, [{ id: x, title: 't1', secret: 's', club, note: 'n' }], 1);
121
+ await engine.upsert('broadcast', x, { _00_local: 'keep' }, 'merge');
122
+ const before = (await engine.getById('broadcast', x)) as Row;
123
+ expect(before.club).toBeDefined();
124
+
125
+ // The server unset `club`; `secret` is hidden from this reader (required,
126
+ // so its absence is not a clear).
127
+ await land(exec, [{ id: x, title: 't2', note: 'n' }], 2);
128
+ const after = (await engine.getById('broadcast', x)) as Row;
129
+ expect('club' in after).toBe(false);
130
+ expect(after).toMatchObject({
131
+ id: 'broadcast:x',
132
+ title: 't2',
133
+ note: 'n',
134
+ secret: 's',
135
+ _00_local: 'keep',
136
+ _00_rv: 2,
137
+ });
138
+
139
+ // A fresh row stores no placeholder for the cleared key.
140
+ await land(exec, [{ id: fresh, title: 'f', secret: 's' }], 1);
141
+ const inserted = (await engine.getById('broadcast', fresh)) as Row;
142
+ expect(Object.keys(inserted).toSorted()).toEqual(['_00_rv', 'id', 'secret', 'title']);
143
+ });
144
+
145
+ it('removes the cleared key on the single-statement merge path too', async () => {
146
+ const engine = realEngine();
147
+ await engine.connect('anon');
148
+
149
+ const y = new RecordId('broadcast', 'y');
150
+ await engine.query('UPSERT ONLY $id MERGE $c', {
151
+ id: y,
152
+ c: { club, note: 'n2', _00_local: 'keep' },
153
+ });
154
+ expect((await engine.getById('broadcast', y))?.club).toBeDefined();
155
+ await engine.query('UPSERT ONLY $id MERGE $c', { id: y, c: { club: undefined, note: 'n3' } });
156
+ const row = (await engine.getById('broadcast', y)) as Row;
157
+ expect('club' in row).toBe(false);
158
+ expect(row.note).toBe('n3');
159
+ expect(row._00_local).toBe('keep');
160
+ });
161
+ });
162
+
163
+ // ==================== SurrealDB-WASM ====================
164
+
165
+ describe('landed body clears an absent optional field: SurrealDB-WASM engine', () => {
166
+ let db: Surreal;
167
+
168
+ beforeAll(async () => {
169
+ // The wasm engine loads its binary with `fetch(new URL(..., import.meta.url))`,
170
+ // and Node's fetch has no `file:` scheme: serve that one read from disk.
171
+ const realFetch = globalThis.fetch;
172
+ vi.stubGlobal('fetch', async (input: unknown, init?: RequestInit) => {
173
+ const url = input instanceof URL ? input : new URL(String(input));
174
+ if (url.protocol === 'file:') return new Response(await readFile(fileURLToPath(url)));
175
+ return realFetch(input as RequestInfo, init);
176
+ });
177
+ const { createWasmEngines } = await import('@surrealdb/wasm');
178
+ db = new Surreal({ engines: createWasmEngines() });
179
+ await db.connect('mem://');
180
+ await db.use({ namespace: 'n', database: 'd' });
181
+ await db.query(`
182
+ DEFINE TABLE club SCHEMAFULL;
183
+ DEFINE TABLE broadcast SCHEMAFULL;
184
+ DEFINE FIELD title ON broadcast TYPE string;
185
+ DEFINE FIELD secret ON broadcast TYPE string;
186
+ DEFINE FIELD club ON broadcast TYPE option<record<club>>;
187
+ DEFINE FIELD note ON broadcast TYPE option<string>;
188
+ DEFINE FIELD _00_rv ON broadcast TYPE option<int>;
189
+ DEFINE FIELD _00_local ON broadcast TYPE option<string>;
190
+ `);
191
+ }, 60_000);
192
+
193
+ afterAll(async () => {
194
+ await db?.close();
195
+ vi.unstubAllGlobals();
196
+ });
197
+
198
+ const exec: Exec = (q, v) => db.query(q.sql, v) as unknown as Promise<unknown>;
199
+ const read = async (id: RecordId<string>): Promise<Record<string, unknown>> => {
200
+ const [row] = (await db.query('SELECT * FROM ONLY $id', { id })) as [Record<string, unknown>];
201
+ return row;
202
+ };
203
+
204
+ it('the undefined clear reaches SurrealDB as NONE and the MERGE removes the field', async () => {
205
+ await land(exec, [{ id: x, title: 't1', secret: 's', club, note: 'n' }], 1);
206
+ await db.query("UPDATE $id SET _00_local = 'keep'", { id: x });
207
+ expect(String((await read(x)).club)).toBe('club:c1');
208
+
209
+ // Would fail the whole transaction if the clear were NULL (a coercion error
210
+ // on `option<record<club>>`) or named the required `secret` as NONE.
211
+ await land(exec, [{ id: x, title: 't2', note: 'n' }], 2);
212
+ const after = await read(x);
213
+ expect('club' in after).toBe(false);
214
+ expect(after).toMatchObject({
215
+ title: 't2',
216
+ note: 'n',
217
+ secret: 's',
218
+ _00_local: 'keep',
219
+ _00_rv: 2,
220
+ });
221
+
222
+ await land(exec, [{ id: fresh, title: 'f', secret: 's' }], 1);
223
+ expect(Object.keys(await read(fresh)).toSorted()).toEqual(['_00_rv', 'id', 'secret', 'title']);
224
+ });
225
+
226
+ it('why the clear is NONE on optional columns only: NULL and a required NONE both fail', async () => {
227
+ const z = new RecordId('broadcast', 'z');
228
+ await db.query('CREATE $id CONTENT $c', { id: z, c: { title: 't', secret: 's', club } });
229
+ await expect(
230
+ db.query('UPSERT ONLY $id MERGE $c', { id: z, c: { club: null } })
231
+ ).rejects.toThrow(/club/);
232
+ await expect(
233
+ db.query('UPSERT ONLY $id MERGE $c', { id: z, c: { secret: undefined } })
234
+ ).rejects.toThrow(/secret/);
235
+ });
236
+ });