@spooky-sync/core 0.0.1-canary.27 → 0.0.1-canary.271

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 (231) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2279 -81
  4. package/dist/index.js +13490 -2585
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +939 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +187 -0
  23. package/src/boot/bucket-switch.saga.ts +131 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +96 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +386 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +528 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +61 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +161 -0
  44. package/src/kernel/events.ts +56 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +9 -2
  53. package/src/modules/auth/impersonation-banner.ts +443 -0
  54. package/src/modules/auth/impersonation.test.ts +355 -0
  55. package/src/modules/auth/impersonation.ts +179 -0
  56. package/src/modules/auth/index.ts +344 -27
  57. package/src/modules/crdt/crdt-field.ts +294 -0
  58. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  59. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  60. package/src/modules/crdt/index.ts +463 -0
  61. package/src/modules/crdt/loro-loader.ts +25 -0
  62. package/src/modules/devtools/flags.ts +349 -0
  63. package/src/modules/devtools/index.ts +529 -47
  64. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  65. package/src/modules/devtools/state-shape.test.ts +149 -0
  66. package/src/modules/devtools/storage-info.test.ts +79 -0
  67. package/src/modules/devtools/storage-info.ts +168 -0
  68. package/src/modules/devtools/versions.test.ts +74 -0
  69. package/src/modules/devtools/versions.ts +110 -0
  70. package/src/modules/feature-flag/index.test.ts +251 -0
  71. package/src/modules/feature-flag/index.ts +316 -0
  72. package/src/modules/ref-tables.test.ts +91 -0
  73. package/src/modules/ref-tables.ts +88 -0
  74. package/src/mutation/jobs.test.ts +25 -0
  75. package/src/mutation/jobs.ts +41 -0
  76. package/src/mutation/mutation-id.test.ts +37 -0
  77. package/src/mutation/mutation-id.ts +35 -0
  78. package/src/mutation/push.saga.test.ts +285 -0
  79. package/src/mutation/push.saga.ts +215 -0
  80. package/src/mutation/rows.test.ts +201 -0
  81. package/src/mutation/rows.ts +377 -0
  82. package/src/mutation/tray.saga.test.ts +63 -0
  83. package/src/mutation/tray.saga.ts +48 -0
  84. package/src/mutation/write.saga.test.ts +163 -0
  85. package/src/mutation/write.saga.ts +166 -0
  86. package/src/otel/index.ts +13 -10
  87. package/src/query/env.test.ts +23 -0
  88. package/src/query/env.ts +56 -0
  89. package/src/query/fetch.saga.test.ts +156 -0
  90. package/src/query/fetch.saga.ts +115 -0
  91. package/src/query/hash.test.ts +14 -0
  92. package/src/query/hash.ts +18 -0
  93. package/src/query/id-set-plan.test.ts +132 -0
  94. package/src/query/lifecycle.saga.test.ts +122 -0
  95. package/src/query/lifecycle.saga.ts +101 -0
  96. package/src/query/materialize.saga.test.ts +82 -0
  97. package/src/query/materialize.saga.ts +75 -0
  98. package/src/query/materialize.test.ts +50 -0
  99. package/src/query/materialize.ts +44 -0
  100. package/src/query/membership.saga.test.ts +322 -0
  101. package/src/query/membership.saga.ts +262 -0
  102. package/src/query/membership.test.ts +118 -0
  103. package/src/query/membership.ts +171 -0
  104. package/src/query/register.saga.test.ts +293 -0
  105. package/src/query/register.saga.ts +226 -0
  106. package/src/query/render-set.test.ts +36 -0
  107. package/src/query/render-set.ts +60 -0
  108. package/src/query/sql.test.ts +62 -0
  109. package/src/query/sql.ts +79 -0
  110. package/src/query/window-query.test.ts +70 -0
  111. package/src/query/window-query.ts +195 -0
  112. package/src/services/blobs/blob-cache.test.ts +359 -0
  113. package/src/services/blobs/blob-cache.ts +603 -0
  114. package/src/services/blobs/blob-manifest.ts +227 -0
  115. package/src/services/blobs/blob-store.test.ts +77 -0
  116. package/src/services/blobs/blob-store.ts +359 -0
  117. package/src/services/blobs/blob.fixture.ts +90 -0
  118. package/src/services/blobs/index.ts +70 -0
  119. package/src/services/database/cache-engine.ts +199 -0
  120. package/src/services/database/connection-supervisor.test.ts +318 -0
  121. package/src/services/database/connection-supervisor.ts +422 -0
  122. package/src/services/database/database.query-timeout.test.ts +140 -0
  123. package/src/services/database/database.responses.test.ts +43 -0
  124. package/src/services/database/database.ts +145 -62
  125. package/src/services/database/engine-factory.ts +33 -0
  126. package/src/services/database/errors.ts +34 -0
  127. package/src/services/database/events/index.ts +2 -1
  128. package/src/services/database/index.ts +7 -0
  129. package/src/services/database/local-migrator.ts +30 -27
  130. package/src/services/database/local.test.ts +64 -0
  131. package/src/services/database/local.ts +491 -67
  132. package/src/services/database/plan-render.test.ts +159 -0
  133. package/src/services/database/plan-render.ts +108 -0
  134. package/src/services/database/relation-resolver.test.ts +467 -0
  135. package/src/services/database/relation-resolver.ts +0 -0
  136. package/src/services/database/remote.ts +222 -15
  137. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  138. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  139. package/src/services/database/sqlite-cache-engine.ts +1366 -0
  140. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  141. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  142. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  143. package/src/services/database/sqlite-lock-verify.ts +45 -0
  144. package/src/services/database/sqlite-open.test.ts +150 -0
  145. package/src/services/database/sqlite-open.ts +164 -0
  146. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  147. package/src/services/database/sqlite-plan-sql.ts +153 -0
  148. package/src/services/database/sqlite-projection.test.ts +121 -0
  149. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  150. package/src/services/database/sqlite-select.test.ts +246 -0
  151. package/src/services/database/sqlite-select.ts +131 -0
  152. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  153. package/src/services/database/sqlite-transport.ts +224 -0
  154. package/src/services/database/sqlite-worker.ts +437 -0
  155. package/src/services/database/surql-translate.ts +416 -0
  156. package/src/services/database/surreal-cache-engine.ts +161 -0
  157. package/src/services/logger/index.ts +3 -2
  158. package/src/services/persistence/localstorage.ts +2 -2
  159. package/src/services/persistence/resilient.ts +11 -4
  160. package/src/services/persistence/surrealdb.ts +10 -10
  161. package/src/services/stream-processor/index.ts +796 -84
  162. package/src/services/stream-processor/permissions.test.ts +47 -0
  163. package/src/services/stream-processor/permissions.ts +53 -0
  164. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  165. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  166. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  167. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  168. package/src/services/stream-processor/wasm-types.ts +59 -3
  169. package/src/services/tabs/broker-client.ts +283 -0
  170. package/src/services/tabs/broker.test.ts +327 -0
  171. package/src/services/tabs/coordinator.test.ts +365 -0
  172. package/src/services/tabs/coordinator.ts +633 -0
  173. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  174. package/src/services/tabs/leader-locks.ts +75 -0
  175. package/src/services/tabs/protocol.ts +262 -0
  176. package/src/services/tabs/support.ts +36 -0
  177. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  178. package/src/sp00ky.ts +5 -0
  179. package/src/state/client-state.ts +191 -0
  180. package/src/state/lifecycle.test.ts +118 -0
  181. package/src/state/lifecycle.ts +86 -0
  182. package/src/state/reducers.test.ts +283 -0
  183. package/src/state/reducers.ts +488 -0
  184. package/src/state/selectors.test.ts +149 -0
  185. package/src/state/selectors.ts +184 -0
  186. package/src/sync/connection.saga.test.ts +86 -0
  187. package/src/sync/connection.saga.ts +85 -0
  188. package/src/sync/live.saga.test.ts +178 -0
  189. package/src/sync/live.saga.ts +107 -0
  190. package/src/sync/policy.health.test.ts +56 -0
  191. package/src/sync/policy.test.ts +659 -0
  192. package/src/sync/policy.ts +502 -0
  193. package/src/sync/poll.saga.test.ts +51 -0
  194. package/src/sync/poll.saga.ts +36 -0
  195. package/src/sync/tabs.saga.test.ts +74 -0
  196. package/src/sync/tabs.saga.ts +93 -0
  197. package/src/testing/adapters.ts +133 -0
  198. package/src/testing/build.ts +63 -0
  199. package/src/testing/fake-services.ts +34 -0
  200. package/src/testing/run-pure.test.ts +92 -0
  201. package/src/testing/run-pure.ts +128 -0
  202. package/src/testing/services.ts +17 -0
  203. package/src/types.ts +603 -14
  204. package/src/utils/blurhash.ts +90 -0
  205. package/src/utils/error-classification.test.ts +54 -0
  206. package/src/utils/error-classification.ts +25 -0
  207. package/src/utils/index.ts +79 -13
  208. package/src/utils/parser.test.ts +49 -120
  209. package/src/utils/parser.ts +32 -2
  210. package/src/utils/semver.test.ts +32 -0
  211. package/src/utils/semver.ts +30 -0
  212. package/src/utils/surql.ts +30 -18
  213. package/src/utils/utils.test.ts +2 -1
  214. package/src/utils/withRetry.test.ts +1 -1
  215. package/tsdown.config.ts +86 -1
  216. package/vitest.config.ts +48 -0
  217. package/src/modules/cache/index.ts +0 -241
  218. package/src/modules/cache/types.ts +0 -19
  219. package/src/modules/data/data.test.ts +0 -58
  220. package/src/modules/data/index.ts +0 -879
  221. package/src/modules/sync/engine.ts +0 -173
  222. package/src/modules/sync/events/index.ts +0 -77
  223. package/src/modules/sync/index.ts +0 -3
  224. package/src/modules/sync/queue/index.ts +0 -2
  225. package/src/modules/sync/queue/queue-down.ts +0 -89
  226. package/src/modules/sync/queue/queue-up.ts +0 -223
  227. package/src/modules/sync/scheduler.ts +0 -84
  228. package/src/modules/sync/sync.ts +0 -421
  229. package/src/modules/sync/utils.test.ts +0 -311
  230. package/src/modules/sync/utils.ts +0 -167
  231. package/src/spooky.ts +0 -395
@@ -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,74 +50,142 @@ 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: 'spooky-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: 'spooky-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: 'spooky-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
+ this.logger.debug(
137
+ { query, vars, Category: 'sp00ky-client::Database::query' },
138
+ 'Executing query'
139
+ );
140
+ // "timed out" in the message is load-bearing: `classifySyncError`
141
+ // keys off it to classify this as `network` so the sync queues
142
+ // retry rather than rolling the mutation back.
143
+ const result = await withTimeout(dispatch(), this.queryTimeoutMs, () => this.timeoutError(query));
144
+ const duration = performance.now() - startTime;
145
+
146
+ this.events.emit(this.eventType, {
147
+ query,
148
+ vars,
149
+ duration,
150
+ success: true,
151
+ timestamp: Date.now(),
152
+ });
153
+
154
+ this.logger.trace(
155
+ { query, result, Category: 'sp00ky-client::Database::query' },
156
+ 'Query executed successfully'
157
+ );
158
+ return result;
159
+ } catch (err) {
160
+ const duration = performance.now() - startTime;
161
+
162
+ this.events.emit(this.eventType, {
163
+ query,
164
+ vars,
165
+ duration,
166
+ success: false,
167
+ error: err instanceof Error ? err.message : String(err),
168
+ timestamp: Date.now(),
169
+ });
170
+
171
+ this.logger.error(
172
+ { query, vars, err, Category: 'sp00ky-client::Database::query' },
173
+ 'Query execution failed'
174
+ );
175
+ throw err;
176
+ }
177
+ } finally {
178
+ this.releaseQuerySlot();
179
+ }
180
+ }
181
+
99
182
  async execute<T>(query: SealedQuery<T>, vars?: Record<string, unknown>): Promise<T> {
100
183
  const raw = await this.query<unknown[]>(query.sql, vars);
101
184
  return query.extract(raw);
102
185
  }
103
186
 
104
187
  async close(): Promise<void> {
105
- this.logger.info({ Category: 'spooky-client::Database::close' }, 'Closing database connection');
188
+ this.logger.info({ Category: 'sp00ky-client::Database::close' }, 'Closing database connection');
106
189
  await this.client.close();
107
190
  }
108
191
  }
@@ -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';
@@ -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;
@@ -19,12 +19,12 @@ export class LocalMigrator {
19
19
  private logger: Logger;
20
20
 
21
21
  constructor(
22
- private localDb: LocalDatabaseService,
22
+ private localDb: LocalStore,
23
23
  logger: Logger
24
24
  ) {
25
25
  this.logger = logger.child({ service: 'LocalMigrator' });
26
- logger?.child({ service: 'LocalMigrator' }) ??
27
- createLogger('info').child({ service: 'LocalMigrator' });
26
+ void (logger?.child({ service: 'LocalMigrator' }) ??
27
+ createLogger('info').child({ service: 'LocalMigrator' }));
28
28
  }
29
29
 
30
30
  async provision(schemaSurql: string): Promise<void> {
@@ -34,7 +34,7 @@ export class LocalMigrator {
34
34
 
35
35
  if (await this.isSchemaUpToDate(hash)) {
36
36
  this.logger.info(
37
- { Category: 'spooky-client::LocalMigrator::provision' },
37
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
38
38
  '[Provisioning] Schema is up to date, skipping migration'
39
39
  );
40
40
  return;
@@ -43,10 +43,13 @@ export class LocalMigrator {
43
43
  await this.recreateDatabase(database);
44
44
 
45
45
  const systemSchema = `
46
- DEFINE TABLE IF NOT EXISTS _spooky_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
47
- DEFINE TABLE IF NOT EXISTS _spooky_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
- DEFINE TABLE IF NOT EXISTS _spooky_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
- DEFINE TABLE IF NOT EXISTS _spooky_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
46
+ 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_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
+ DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
+ DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
50
+ DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
51
+ DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
52
+ DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
50
53
  `;
51
54
  const fullSchema = schemaSurql + '\n' + systemSchema;
52
55
 
@@ -60,7 +63,7 @@ export class LocalMigrator {
60
63
  // SKIP INDEXES: WASM engine hangs on DEFINE INDEX (confirmed)
61
64
  if (cleanStatement.toUpperCase().startsWith('DEFINE INDEX')) {
62
65
  this.logger.warn(
63
- { Category: 'spooky-client::LocalMigrator::provision' },
66
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
64
67
  `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`
65
68
  );
66
69
  continue;
@@ -68,17 +71,17 @@ export class LocalMigrator {
68
71
 
69
72
  try {
70
73
  this.logger.info(
71
- { Category: 'spooky-client::LocalMigrator::provision' },
74
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
72
75
  `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`
73
76
  );
74
- await this.localDb.query(statement);
77
+ await this.localDb.queryUngated(statement);
75
78
  this.logger.info(
76
- { Category: 'spooky-client::LocalMigrator::provision' },
79
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
77
80
  `[Provisioning] (${i + 1}/${statements.length}) Done`
78
81
  );
79
82
  } catch (e) {
80
83
  this.logger.error(
81
- { Category: 'spooky-client::LocalMigrator::provision' },
84
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
82
85
  `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`
83
86
  );
84
87
  throw e;
@@ -90,32 +93,32 @@ export class LocalMigrator {
90
93
 
91
94
  private async isSchemaUpToDate(hash: string): Promise<boolean> {
92
95
  try {
93
- const [lastSchemaRecord] = await this.localDb.query<any>(
94
- `SELECT hash, created_at FROM ONLY _spooky_schema ORDER BY created_at DESC LIMIT 1;`
96
+ const [lastSchemaRecord] = await this.localDb.queryUngated<any>(
97
+ `SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`
95
98
  );
96
99
  return lastSchemaRecord?.hash === hash;
97
- } catch (error) {
100
+ } catch (_error) {
98
101
  return false;
99
102
  }
100
103
  }
101
104
 
102
105
  private async recreateDatabase(database: string) {
103
106
  try {
104
- await this.localDb.query(`DEFINE DATABASE _spooky_temp;`);
105
- } catch (e) {
107
+ await this.localDb.queryUngated(`DEFINE DATABASE _00_temp;`);
108
+ } catch (_e) {
106
109
  // Ignore if exists
107
110
  }
108
111
 
109
112
  try {
110
- await this.localDb.query(`
111
- USE DB _spooky_temp;
113
+ await this.localDb.queryUngated(`
114
+ USE DB _00_temp;
112
115
  REMOVE DATABASE ${database};
113
116
  `);
114
- } catch (e) {
117
+ } catch (_e) {
115
118
  // Ignore error if database doesn't exist
116
119
  }
117
120
 
118
- await this.localDb.query(`
121
+ await this.localDb.queryUngated(`
119
122
  DEFINE DATABASE ${database};
120
123
  USE DB ${database};
121
124
  `);
@@ -195,8 +198,8 @@ export class LocalMigrator {
195
198
  }
196
199
 
197
200
  private async createHashRecord(hash: string) {
198
- await this.localDb.query(
199
- `UPSERT _spooky_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
201
+ await this.localDb.queryUngated(
202
+ `UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
200
203
  { hash }
201
204
  );
202
205
  }
@@ -0,0 +1,64 @@
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
+ });