@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.320

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 (259) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2034 -137
  4. package/dist/index.js +15137 -2434
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +337 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +379 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1811 -38
  19. package/dist/worker-edges.js +439 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +180 -0
  32. package/src/boot/boot.saga.ts +142 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +51 -0
  46. package/src/client/services.ts +404 -0
  47. package/src/client/sp00ky-client.test.ts +303 -0
  48. package/src/client/sp00ky-client.ts +586 -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 +163 -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 +478 -0
  62. package/src/live/index.ts +1013 -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 +169 -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 +466 -19
  72. package/src/modules/crdt/crdt-field.ts +294 -0
  73. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  74. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  75. package/src/modules/crdt/index.ts +463 -0
  76. package/src/modules/crdt/loro-loader.ts +25 -0
  77. package/src/modules/devtools/flags.ts +349 -0
  78. package/src/modules/devtools/index.ts +746 -95
  79. package/src/modules/devtools/mutations.test.ts +92 -0
  80. package/src/modules/devtools/mutations.ts +160 -0
  81. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  82. package/src/modules/devtools/state-shape.test.ts +263 -0
  83. package/src/modules/devtools/storage-info.test.ts +79 -0
  84. package/src/modules/devtools/storage-info.ts +168 -0
  85. package/src/modules/devtools/versions.test.ts +74 -0
  86. package/src/modules/devtools/versions.ts +110 -0
  87. package/src/modules/feature-flag/index.test.ts +298 -0
  88. package/src/modules/feature-flag/index.ts +316 -0
  89. package/src/modules/ref-tables.test.ts +91 -0
  90. package/src/modules/ref-tables.ts +88 -0
  91. package/src/modules/web-push/index.test.ts +824 -0
  92. package/src/modules/web-push/index.ts +1027 -0
  93. package/src/mutation/jobs.test.ts +25 -0
  94. package/src/mutation/jobs.ts +41 -0
  95. package/src/mutation/mutation-id.test.ts +37 -0
  96. package/src/mutation/mutation-id.ts +35 -0
  97. package/src/mutation/pending-writes.integration.test.ts +175 -0
  98. package/src/mutation/push.saga.test.ts +285 -0
  99. package/src/mutation/push.saga.ts +217 -0
  100. package/src/mutation/rows.test.ts +231 -0
  101. package/src/mutation/rows.ts +427 -0
  102. package/src/mutation/tray.saga.test.ts +63 -0
  103. package/src/mutation/tray.saga.ts +48 -0
  104. package/src/mutation/write.saga.test.ts +316 -0
  105. package/src/mutation/write.saga.ts +268 -0
  106. package/src/otel/index.ts +10 -7
  107. package/src/pure.ts +65 -0
  108. package/src/push/types.ts +174 -0
  109. package/src/query/env.test.ts +23 -0
  110. package/src/query/env.ts +56 -0
  111. package/src/query/fetch.saga.test.ts +199 -0
  112. package/src/query/fetch.saga.ts +120 -0
  113. package/src/query/hash.test.ts +14 -0
  114. package/src/query/hash.ts +18 -0
  115. package/src/query/id-set-plan.test.ts +132 -0
  116. package/src/query/lifecycle.saga.test.ts +274 -0
  117. package/src/query/lifecycle.saga.ts +159 -0
  118. package/src/query/materialize.saga.test.ts +115 -0
  119. package/src/query/materialize.saga.ts +84 -0
  120. package/src/query/materialize.test.ts +58 -0
  121. package/src/query/materialize.ts +57 -0
  122. package/src/query/membership.saga.test.ts +350 -0
  123. package/src/query/membership.saga.ts +278 -0
  124. package/src/query/membership.test.ts +118 -0
  125. package/src/query/membership.ts +201 -0
  126. package/src/query/register.saga.test.ts +311 -0
  127. package/src/query/register.saga.ts +229 -0
  128. package/src/query/render-set.test.ts +47 -0
  129. package/src/query/render-set.ts +71 -0
  130. package/src/query/sql.test.ts +81 -0
  131. package/src/query/sql.ts +112 -0
  132. package/src/query/window-query.test.ts +70 -0
  133. package/src/query/window-query.ts +195 -0
  134. package/src/services/blobs/blob-cache.test.ts +359 -0
  135. package/src/services/blobs/blob-cache.ts +603 -0
  136. package/src/services/blobs/blob-manifest.ts +227 -0
  137. package/src/services/blobs/blob-store.test.ts +77 -0
  138. package/src/services/blobs/blob-store.ts +359 -0
  139. package/src/services/blobs/blob.fixture.ts +90 -0
  140. package/src/services/blobs/index.ts +70 -0
  141. package/src/services/database/cache-engine.ts +199 -0
  142. package/src/services/database/connection-supervisor.test.ts +318 -0
  143. package/src/services/database/connection-supervisor.ts +422 -0
  144. package/src/services/database/database.query-timeout.test.ts +140 -0
  145. package/src/services/database/database.responses.test.ts +43 -0
  146. package/src/services/database/database.ts +146 -61
  147. package/src/services/database/engine-factory.ts +33 -0
  148. package/src/services/database/errors.ts +34 -0
  149. package/src/services/database/events/index.ts +2 -1
  150. package/src/services/database/index.ts +7 -0
  151. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  152. package/src/services/database/local-migrator.integration.test.ts +110 -0
  153. package/src/services/database/local-migrator.ts +59 -16
  154. package/src/services/database/local.test.ts +64 -0
  155. package/src/services/database/local.ts +487 -63
  156. package/src/services/database/plan-render.test.ts +159 -0
  157. package/src/services/database/plan-render.ts +108 -0
  158. package/src/services/database/relation-resolver.test.ts +484 -0
  159. package/src/services/database/relation-resolver.ts +0 -0
  160. package/src/services/database/remote.ts +255 -6
  161. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  162. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  163. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  164. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  165. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  166. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  167. package/src/services/database/sqlite-lock-verify.ts +45 -0
  168. package/src/services/database/sqlite-open.test.ts +150 -0
  169. package/src/services/database/sqlite-open.ts +164 -0
  170. package/src/services/database/sqlite-plan-sql.test.ts +145 -0
  171. package/src/services/database/sqlite-plan-sql.ts +182 -0
  172. package/src/services/database/sqlite-projection.test.ts +121 -0
  173. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  174. package/src/services/database/sqlite-select.test.ts +261 -0
  175. package/src/services/database/sqlite-select.ts +131 -0
  176. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  177. package/src/services/database/sqlite-transport.ts +224 -0
  178. package/src/services/database/sqlite-worker.ts +437 -0
  179. package/src/services/database/surql-translate.ts +416 -0
  180. package/src/services/database/surreal-cache-engine.ts +161 -0
  181. package/src/services/logger/index.test.ts +132 -0
  182. package/src/services/logger/index.ts +189 -7
  183. package/src/services/persistence/localstorage.ts +2 -2
  184. package/src/services/persistence/resilient.ts +10 -3
  185. package/src/services/persistence/surrealdb.ts +4 -4
  186. package/src/services/stream-processor/index.ts +783 -68
  187. package/src/services/stream-processor/permissions.test.ts +47 -0
  188. package/src/services/stream-processor/permissions.ts +53 -0
  189. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  190. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  191. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  192. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  193. package/src/services/stream-processor/wasm-types.ts +58 -2
  194. package/src/services/tabs/broker-client.ts +283 -0
  195. package/src/services/tabs/broker.test.ts +327 -0
  196. package/src/services/tabs/coordinator.test.ts +386 -0
  197. package/src/services/tabs/coordinator.ts +638 -0
  198. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  199. package/src/services/tabs/leader-locks.ts +75 -0
  200. package/src/services/tabs/protocol.ts +262 -0
  201. package/src/services/tabs/support.ts +36 -0
  202. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  203. package/src/sp00ky.ts +5 -395
  204. package/src/state/client-state.ts +200 -0
  205. package/src/state/lifecycle.test.ts +141 -0
  206. package/src/state/lifecycle.ts +99 -0
  207. package/src/state/reducers.test.ts +304 -0
  208. package/src/state/reducers.ts +526 -0
  209. package/src/state/selectors.test.ts +175 -0
  210. package/src/state/selectors.ts +222 -0
  211. package/src/sw/index.test.ts +582 -0
  212. package/src/sw/index.ts +607 -0
  213. package/src/sw/store.ts +96 -0
  214. package/src/sync/connection.saga.test.ts +86 -0
  215. package/src/sync/connection.saga.ts +85 -0
  216. package/src/sync/live.saga.test.ts +178 -0
  217. package/src/sync/live.saga.ts +107 -0
  218. package/src/sync/policy.health.test.ts +56 -0
  219. package/src/sync/policy.test.ts +659 -0
  220. package/src/sync/policy.ts +502 -0
  221. package/src/sync/poll.saga.test.ts +51 -0
  222. package/src/sync/poll.saga.ts +36 -0
  223. package/src/sync/tabs.saga.test.ts +78 -0
  224. package/src/sync/tabs.saga.ts +96 -0
  225. package/src/testing/adapters.ts +133 -0
  226. package/src/testing/build.ts +63 -0
  227. package/src/testing/fake-services.ts +48 -0
  228. package/src/testing/run-pure.test.ts +92 -0
  229. package/src/testing/run-pure.ts +128 -0
  230. package/src/testing/services.ts +17 -0
  231. package/src/types.ts +624 -10
  232. package/src/utils/blurhash.ts +90 -0
  233. package/src/utils/edges.ts +39 -0
  234. package/src/utils/error-classification.test.ts +54 -0
  235. package/src/utils/error-classification.ts +25 -0
  236. package/src/utils/index.ts +78 -12
  237. package/src/utils/parser.test.ts +67 -102
  238. package/src/utils/parser.ts +64 -2
  239. package/src/utils/semver.test.ts +32 -0
  240. package/src/utils/semver.ts +30 -0
  241. package/src/utils/sha256.ts +15 -0
  242. package/src/utils/surql.ts +30 -18
  243. package/src/utils/utils.test.ts +3 -2
  244. package/tsdown.config.ts +117 -3
  245. package/vitest.config.ts +48 -0
  246. package/src/modules/cache/index.ts +0 -241
  247. package/src/modules/cache/types.ts +0 -19
  248. package/src/modules/data/data.test.ts +0 -58
  249. package/src/modules/data/index.ts +0 -879
  250. package/src/modules/sync/engine.ts +0 -173
  251. package/src/modules/sync/events/index.ts +0 -77
  252. package/src/modules/sync/index.ts +0 -3
  253. package/src/modules/sync/queue/index.ts +0 -2
  254. package/src/modules/sync/queue/queue-down.ts +0 -89
  255. package/src/modules/sync/queue/queue-up.ts +0 -223
  256. package/src/modules/sync/scheduler.ts +0 -84
  257. package/src/modules/sync/sync.ts +0 -421
  258. package/src/modules/sync/utils.test.ts +0 -311
  259. 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,137 @@ 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
+ * Hook run right before a statement is sent, inside its slot. The remote
91
+ * service uses it to hold statements while a connect is in flight, so a
92
+ * query never runs on a socket that is open but has not had its namespace
93
+ * and token applied yet. The default does nothing.
94
+ */
95
+ protected async beforeQuery(): Promise<void> {}
39
96
 
40
97
  /**
41
- * Execute a query with serialized execution to prevent WASM transaction issues.
98
+ * Execute a statement under {@link maxConcurrentQueries}.
99
+ *
100
+ * A statement that never settles would hold its slot forever, and with a
101
+ * limit of 1 that blocks every later statement behind it. {@link
102
+ * queryTimeoutMs} bounds each one: on expiry this promise rejects and the
103
+ * slot is released, even though the underlying RPC is still parked in the
104
+ * SDK's pending map.
42
105
  */
43
106
  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
- });
107
+ return this.runStatement<T>(query, vars, () => this.client.query(query, vars) as unknown as Promise<T>);
108
+ }
109
+
110
+ /**
111
+ * Like {@link query} but never rejects on a statement error: every
112
+ * statement answers on its own as `{ status: 'OK', result }` or
113
+ * `{ status: 'ERR', error }`. This is what lets a batched outbox push judge
114
+ * each mutation separately.
115
+ */
116
+ async queryResponses(
117
+ query: string,
118
+ vars?: Record<string, unknown>
119
+ ): Promise<Array<{ status: 'OK'; result: unknown } | { status: 'ERR'; error: string }>> {
120
+ return this.runStatement(query, vars, async () => {
121
+ const responses = await this.client.query(query, vars).responses();
122
+ return responses.map((r) =>
123
+ r.success
124
+ ? { status: 'OK' as const, result: r.result }
125
+ : { status: 'ERR' as const, error: r.error instanceof Error ? r.error.message : String(r.error) }
126
+ );
96
127
  });
97
128
  }
98
129
 
130
+ private async runStatement<T>(query: string, vars: Record<string, unknown> | undefined, dispatch: () => Promise<T>): Promise<T> {
131
+ await this.acquireQuerySlot();
132
+ try {
133
+ await this.beforeQuery();
134
+ const startTime = performance.now();
135
+ try {
136
+ // Trace: the finished statement is logged at debug by the client,
137
+ // from the query event below. This line is for a statement that hangs.
138
+ this.logger.trace(
139
+ { query, vars, Category: 'sp00ky-client::Database::query' },
140
+ 'Executing query'
141
+ );
142
+ // "timed out" in the message is load-bearing: `classifySyncError`
143
+ // keys off it to classify this as `network` so the sync queues
144
+ // retry rather than rolling the mutation back.
145
+ const result = await withTimeout(dispatch(), this.queryTimeoutMs, () => this.timeoutError(query));
146
+ const duration = performance.now() - startTime;
147
+
148
+ this.events.emit(this.eventType, {
149
+ query,
150
+ vars,
151
+ duration,
152
+ success: true,
153
+ timestamp: Date.now(),
154
+ });
155
+
156
+ this.logger.trace(
157
+ { query, result, Category: 'sp00ky-client::Database::query' },
158
+ 'Query executed successfully'
159
+ );
160
+ return result;
161
+ } catch (err) {
162
+ const duration = performance.now() - startTime;
163
+
164
+ this.events.emit(this.eventType, {
165
+ query,
166
+ vars,
167
+ duration,
168
+ success: false,
169
+ error: err instanceof Error ? err.message : String(err),
170
+ timestamp: Date.now(),
171
+ });
172
+
173
+ this.logger.error(
174
+ { query, vars, err, Category: 'sp00ky-client::Database::query' },
175
+ 'Query execution failed'
176
+ );
177
+ throw err;
178
+ }
179
+ } finally {
180
+ this.releaseQuerySlot();
181
+ }
182
+ }
183
+
99
184
  async execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T> {
100
185
  const raw = await this.query<unknown[]>(query.sql, vars);
101
186
  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
+ });
@@ -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
+ });