@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211

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 (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +36 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -0,0 +1,416 @@
1
+ import type { WhereNode } from '@spooky-sync/query-builder';
2
+ import type { OrderBy, Row } from './cache-engine';
3
+ import { stableKey } from './relation-resolver';
4
+
5
+ /**
6
+ * Translate the BOUNDED set of SurrealQL statements the client actually emits
7
+ * into engine-neutral operations the SQLite backend executes. This is NOT a
8
+ * general SurrealQL parser — it recognizes exactly the shapes produced by the
9
+ * `surql` helper and the handful of literal queries in the codebase. Anything
10
+ * unrecognized throws (with the offending SQL) so gaps surface loudly during a
11
+ * trial rather than silently returning wrong data.
12
+ *
13
+ * The arbitrary user SELECT (`config.surql`, with nested `.related()`
14
+ * subqueries) is intentionally NOT handled here — it flows through
15
+ * `engine.select(plan)` instead, so the translator never faces an unbounded
16
+ * query shape.
17
+ */
18
+
19
+ export type SqlOp =
20
+ | { kind: 'getById'; id: unknown; select?: string[]; value?: string }
21
+ | {
22
+ kind: 'selectByIds';
23
+ ids: unknown[];
24
+ select?: string[];
25
+ orderBy?: OrderBy;
26
+ value?: string;
27
+ limit?: number;
28
+ start?: number;
29
+ }
30
+ | {
31
+ kind: 'selectTable';
32
+ table: string;
33
+ where?: WhereNode[];
34
+ orderBy?: OrderBy;
35
+ select?: string[];
36
+ value?: string;
37
+ limit?: number;
38
+ start?: number;
39
+ }
40
+ /** `SELECT count() FROM t [WHERE …] GROUP ALL` — one `{ count }` row. */
41
+ | { kind: 'count'; table: string; where?: WhereNode[] }
42
+ /** `INFO FOR DB` — the table list, in SurrealDB's `{ tables: {…} }` shape. */
43
+ | { kind: 'infoForDb' }
44
+ | { kind: 'upsert'; id: unknown; data: Row; mode: 'replace' | 'merge' }
45
+ | { kind: 'updateSet'; id: unknown; sets: SetClause[]; returnNone: boolean }
46
+ | { kind: 'delete'; id: unknown }
47
+ | { kind: 'deleteAll'; table: string }
48
+ | { kind: 'let'; var: string; inner: SqlOp }
49
+ | { kind: 'return'; entries: { key: string; var: string }[] }
50
+ | { kind: 'noop' };
51
+
52
+ export interface SetClause {
53
+ path: string;
54
+ op: '=' | '+=' | '-=';
55
+ value: unknown;
56
+ }
57
+
58
+ export interface TranslatedQuery {
59
+ /** True for a `BEGIN TRANSACTION … COMMIT` block — the engine prepends a
60
+ * `null` begin-result so `surql.seal`'s `idx+1` extraction still lines up. */
61
+ transaction: boolean;
62
+ ops: SqlOp[];
63
+ }
64
+
65
+ const rid = (v: unknown): unknown => v;
66
+
67
+ /** Table name from a record id value (`table:id` string or RecordId). */
68
+ export function tableOf(id: unknown): string {
69
+ return stableKey(id).split(':')[0];
70
+ }
71
+
72
+ export function translateSurql(sql: string, vars: Record<string, unknown>): TranslatedQuery {
73
+ const trimmed = sql.trim().replace(/;\s*$/, '');
74
+
75
+ if (/^BEGIN\s+TRANSACTION/i.test(trimmed)) {
76
+ const inner = trimmed
77
+ .replace(/^BEGIN\s+TRANSACTION\s*;?/i, '')
78
+ .replace(/;?\s*COMMIT\s+TRANSACTION\s*$/i, '');
79
+ const ops = splitStatements(inner).map((s) => translateStatement(s, vars));
80
+ return { transaction: true, ops };
81
+ }
82
+
83
+ // A plain multi-statement string (e.g. `DEFINE DATABASE x; USE DB x`) — split
84
+ // and translate each. Single statement is just the one-element case.
85
+ const parts = splitStatements(trimmed);
86
+ return { transaction: false, ops: parts.map((s) => translateStatement(s, vars)) };
87
+ }
88
+
89
+ function splitStatements(block: string): string[] {
90
+ // Statements in the client's tx blocks never contain a bare `;` inside a
91
+ // string/paren, so a simple split is sufficient for this bounded vocabulary.
92
+ return block
93
+ .split(';')
94
+ .map((s) => s.trim())
95
+ .filter(Boolean);
96
+ }
97
+
98
+ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp {
99
+ const s = stmt.trim();
100
+
101
+ // LET $var = ( <inner statement> ) — bind the inner result into query scope.
102
+ let mLet = /^LET\s+\$(\w+)\s*=\s*\(([\s\S]+)\)$/i.exec(s);
103
+ if (mLet) {
104
+ return { kind: 'let', var: mLet[1], inner: translateStatement(mLet[2].trim(), vars) };
105
+ }
106
+
107
+ // RETURN { key: $var, ... } — build an object from scope/vars.
108
+ let mRet = /^RETURN\s+\{([\s\S]+)\}$/i.exec(s);
109
+ if (mRet) {
110
+ const entries = splitTopLevel(mRet[1], ',').map((pair) => {
111
+ const c = pair.indexOf(':');
112
+ const key = pair.slice(0, c).trim();
113
+ const v = pair.slice(c + 1).trim();
114
+ return { key, var: v.startsWith('$') ? v.slice(1) : v };
115
+ });
116
+ return { kind: 'return', entries };
117
+ }
118
+
119
+ // `INFO FOR DB` is the ONE INFO statement with a real answer here: the
120
+ // DevTools Database explorer enumerates tables with it. Answered from
121
+ // `sqlite_master` (see the engine) instead of being swallowed by the DDL
122
+ // noop below, which left the explorer with an empty table list.
123
+ if (/^INFO\s+FOR\s+DB(\s+STRUCTURE)?$/i.test(s)) return { kind: 'infoForDb' };
124
+
125
+ // ---- schema / session DDL: no-ops on a schemaless engine --------------
126
+ // SQLite creates tables lazily and has no namespaces/DB DDL, so DEFINE /
127
+ // REMOVE / USE / INFO / RETURN statements are safely ignored.
128
+ if (/^(DEFINE|REMOVE|USE|INFO|RETURN|CANCEL|COMMIT|BEGIN)\b/i.test(s)) {
129
+ return { kind: 'noop' };
130
+ }
131
+
132
+ // ---- writes -----------------------------------------------------------
133
+ let m =
134
+ /^CREATE\s+ONLY\s+\$(\w+)\s+CONTENT\s+\$(\w+)$/i.exec(s) ||
135
+ /^UPSERT\s+ONLY\s+\$(\w+)\s+REPLACE\s+\$(\w+)$/i.exec(s);
136
+ if (m) {
137
+ return { kind: 'upsert', id: rid(vars[m[1]]), data: asRow(vars[m[2]]), mode: 'replace' };
138
+ }
139
+
140
+ // The `ONLY` variants come from `surql`; the bare `UPDATE <id> MERGE $x` form
141
+ // (with a LITERAL record id) is what the DevTools row editor emits.
142
+ m =
143
+ idRe(String.raw`^UPSERT\s+ONLY\s+%ID%\s+MERGE\s+\$(\w+)$`).exec(s) ||
144
+ idRe(String.raw`^UPDATE\s+(?:ONLY\s+)?%ID%\s+MERGE\s+\$(\w+)$`).exec(s);
145
+ if (m) {
146
+ return { kind: 'upsert', id: idOperand(m[1], vars), data: asRow(vars[m[2]]), mode: 'merge' };
147
+ }
148
+
149
+ // CREATE ONLY $id SET a = ..., b = ... (createSet / createMutation)
150
+ m = /^CREATE\s+ONLY\s+\$(\w+)\s+SET\s+(.+)$/i.exec(s);
151
+ if (m) {
152
+ const data: Row = {};
153
+ for (const { path, value } of parseSetClauses(m[2], vars)) setPath(data, path, value);
154
+ return { kind: 'upsert', id: rid(vars[m[1]]), data, mode: 'replace' };
155
+ }
156
+
157
+ // UPDATE $id SET a.b = $x, c = $y [RETURN NONE]
158
+ m = idRe(String.raw`^UPDATE\s+%ID%\s+SET\s+(.+?)(\s+RETURN\s+NONE)?$`).exec(s);
159
+ if (m) {
160
+ return {
161
+ kind: 'updateSet',
162
+ id: idOperand(m[1], vars),
163
+ sets: parseSetClauses(m[2], vars),
164
+ returnNone: !!m[3],
165
+ };
166
+ }
167
+
168
+ m = /^DELETE\s+([A-Za-z_]\w*)$/i.exec(s); // DELETE <table> (no `:` — a table)
169
+ if (m) return { kind: 'deleteAll', table: m[1] };
170
+
171
+ // DELETE $id, and `DELETE <table>:<id>` from the DevTools row actions.
172
+ m = idRe(String.raw`^DELETE\s+%ID%$`).exec(s);
173
+ if (m) return { kind: 'delete', id: idOperand(m[1], vars) };
174
+
175
+ // ---- reads ------------------------------------------------------------
176
+ // The paging tail (`LIMIT n [START m]`) is peeled off first so it can't be
177
+ // swallowed by the lazily-matched WHERE/ORDER BY groups below. Everything
178
+ // after this point sees a window-free statement.
179
+ const { head, limit, start } = peelWindow(s, vars);
180
+
181
+ // SELECT count() FROM <table> [WHERE ...] GROUP ALL — the row-count query the
182
+ // DevTools Database explorer pages with. GROUP ALL is required: without it
183
+ // SurrealDB counts per row, which is a different result this can't fake.
184
+ m = /^SELECT\s+count\(\)\s+FROM\s+([A-Za-z_]\w*)(?:\s+WHERE\s+(.+?))?\s+GROUP\s+ALL$/i.exec(head);
185
+ if (m) {
186
+ return { kind: 'count', table: m[1], where: m[2] ? parseWhere(m[2], vars) : undefined };
187
+ }
188
+
189
+ // SELECT [VALUE] <proj> FROM ONLY <id>
190
+ m = idRe(String.raw`^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+ONLY\s+%ID%$`).exec(head);
191
+ if (m) {
192
+ const value = m[1] ? m[2].trim() : undefined;
193
+ return { kind: 'getById', id: idOperand(m[3], vars), select: projFields(m[2], !!m[1]), value };
194
+ }
195
+
196
+ // SELECT [VALUE] <proj> FROM $arrayParam
197
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(head);
198
+ if (m) {
199
+ const value = m[1] ? m[2].trim() : undefined;
200
+ return {
201
+ kind: 'selectByIds',
202
+ ids: (vars[m[3]] as unknown[]) ?? [],
203
+ select: projFields(m[2], !!m[1]),
204
+ value,
205
+ limit,
206
+ start,
207
+ };
208
+ }
209
+
210
+ // SELECT <proj> FROM <table> [WHERE ...] [ORDER BY ...]
211
+ m =
212
+ /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+([A-Za-z_]\w*)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(.+?))?$/i.exec(
213
+ head
214
+ );
215
+ if (m) {
216
+ return {
217
+ kind: 'selectTable',
218
+ table: m[3],
219
+ where: m[4] ? parseWhere(m[4], vars) : undefined,
220
+ orderBy: m[5] ? parseOrderBy(m[5]) : undefined,
221
+ select: projFields(m[2], !!m[1]),
222
+ value: m[1] ? m[2].trim() : undefined,
223
+ limit,
224
+ start,
225
+ };
226
+ }
227
+
228
+ throw new Error(`SqliteCacheEngine: unsupported SurrealQL for translation: ${stmt}`);
229
+ }
230
+
231
+ // ==================== clause parsers ====================
232
+
233
+ /**
234
+ * Split a SELECT's trailing `LIMIT n [START m]` off its head.
235
+ *
236
+ * Peeled rather than folded into the SELECT regexes because those match WHERE
237
+ * and ORDER BY lazily: with the window left in place the WHERE group happily
238
+ * expands over `… LIMIT 20`, which is how `SELECT * FROM t LIMIT 20 START 0`
239
+ * ended up unmatched entirely.
240
+ *
241
+ * A tail sitting inside a string literal (`WHERE note = 'LIMIT 5'`) is left
242
+ * alone — an odd quote count in the head means the match is inside a string.
243
+ */
244
+ function peelWindow(
245
+ stmt: string,
246
+ vars: Record<string, unknown>
247
+ ): { head: string; limit?: number; start?: number } {
248
+ let head = stmt;
249
+ let limit: number | undefined;
250
+ let start: number | undefined;
251
+
252
+ const peel = (re: RegExp): string | undefined => {
253
+ const m = re.exec(head);
254
+ if (!m) return undefined;
255
+ const before = head.slice(0, m.index);
256
+ if (countQuotes(before) % 2 !== 0) return undefined;
257
+ head = before;
258
+ return m[1];
259
+ };
260
+
261
+ // START is the outer token, so it comes off first.
262
+ const startTok = peel(/\s+START(?:\s+AT)?\s+(\$?\w+)\s*$/i);
263
+ const limitTok = peel(/\s+LIMIT(?:\s+BY)?\s+(\$?\w+)\s*$/i);
264
+ if (startTok !== undefined) start = numOperand(startTok, vars);
265
+ if (limitTok !== undefined) limit = numOperand(limitTok, vars);
266
+ return { head, limit, start };
267
+ }
268
+
269
+ function countQuotes(s: string): number {
270
+ let n = 0;
271
+ for (let i = 0; i < s.length; i++) {
272
+ if (s[i] === "'" && s[i - 1] !== '\\') n++;
273
+ }
274
+ return n;
275
+ }
276
+
277
+ /** A `LIMIT`/`START` operand: a literal integer or a `$var` holding one. */
278
+ function numOperand(token: string, vars: Record<string, unknown>): number | undefined {
279
+ const raw = token.startsWith('$') ? vars[token.slice(1)] : token;
280
+ const n = Number(raw);
281
+ return Number.isFinite(n) ? n : undefined;
282
+ }
283
+
284
+ /**
285
+ * A single-record operand: `$var` or a LITERAL `table:id`.
286
+ *
287
+ * A bare identifier deliberately does NOT match: in SurrealQL that is a table,
288
+ * and `UPDATE game MERGE $x` rewrites every row of `game` — a shape this
289
+ * vocabulary does not implement. Not matching means such a statement reaches
290
+ * the "unsupported SurrealQL" throw instead of quietly writing one row called
291
+ * `game`.
292
+ */
293
+ const ID_TOKEN = String.raw`(\$\w+|[A-Za-z_]\w*:\S+)`;
294
+
295
+ /** Build a statement regex, `%ID%` expanding to {@link ID_TOKEN}. */
296
+ function idRe(pattern: string): RegExp {
297
+ return new RegExp(pattern.replace('%ID%', ID_TOKEN), 'i');
298
+ }
299
+
300
+ /**
301
+ * Resolve an {@link ID_TOKEN} match. Literals matter because the DevTools row
302
+ * editor inlines the id (`UPDATE game:abc MERGE $updates`) rather than binding
303
+ * it; `stableKey` treats that string and a `RecordId` identically downstream.
304
+ */
305
+ function idOperand(token: string, vars: Record<string, unknown>): unknown {
306
+ const t = token.trim();
307
+ return t.startsWith('$') ? rid(vars[t.slice(1)]) : t;
308
+ }
309
+
310
+ function projFields(proj: string, isValue: boolean): string[] | undefined {
311
+ const p = proj.trim();
312
+ if (isValue) return undefined; // VALUE returns a scalar, no projection object
313
+ if (p === '*') return undefined;
314
+ return p.split(',').map((f) => f.trim());
315
+ }
316
+
317
+ function asRow(v: unknown): Row {
318
+ return (v && typeof v === 'object' ? v : {}) as Row;
319
+ }
320
+
321
+ /** Parse `a = $x, b.c = 'lit', _00_rv += 1` into path/op/value triples. */
322
+ function parseSetClauses(clause: string, vars: Record<string, unknown>): SetClause[] {
323
+ return splitTopLevel(clause, ',').map((part) => {
324
+ const m = /^(.+?)\s*(\+=|-=|=)\s*([\s\S]+)$/.exec(part.trim());
325
+ if (!m) throw new Error(`SqliteCacheEngine: cannot parse SET clause: ${part}`);
326
+ return { path: m[1].trim(), op: m[2] as SetClause['op'], value: literalOrVar(m[3], vars) };
327
+ });
328
+ }
329
+
330
+ function literalOrVar(token: string, vars: Record<string, unknown>): unknown {
331
+ const t = token.trim();
332
+ if (t.startsWith('$')) return vars[t.slice(1)];
333
+ if (/^'.*'$/.test(t) || /^".*"$/.test(t)) return t.slice(1, -1);
334
+ if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
335
+ if (t === 'true') return true;
336
+ if (t === 'false') return false;
337
+ if (t === 'NONE' || t === 'NULL' || t === 'null') return null;
338
+ return t;
339
+ }
340
+
341
+ function parseWhere(clause: string, vars: Record<string, unknown>): WhereNode[] {
342
+ // Only simple AND-of-equality/comparison is emitted by the client's raw
343
+ // queries; OR/nested go through plan-based select().
344
+ return splitTopLevel(clause, 'AND').map((cond) => {
345
+ const m = /^(\S+)\s*(=|!=|>=|<=|>|<)\s*(.+)$/.exec(cond.trim());
346
+ if (!m) throw new Error(`SqliteCacheEngine: cannot parse WHERE condition: ${cond}`);
347
+ return { field: m[1], op: m[2] as WhereComparisonOp, value: literalOrVar(m[3], vars) };
348
+ });
349
+ }
350
+
351
+ type WhereComparisonOp = '=' | '!=' | '>=' | '<=' | '>' | '<';
352
+
353
+ function parseOrderBy(clause: string): OrderBy {
354
+ return clause.split(',').map((c) => {
355
+ const [f, dir] = c.trim().split(/\s+/);
356
+ return [f, (dir ?? 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc'];
357
+ });
358
+ }
359
+
360
+ /** Split on a top-level separator, ignoring parens/quotes (bounded inputs). */
361
+ function splitTopLevel(input: string, sep: string): string[] {
362
+ const out: string[] = [];
363
+ let depth = 0;
364
+ let inStr: string | null = null;
365
+ let cur = '';
366
+ const isWord = /[A-Za-z]/.test(sep);
367
+ for (let i = 0; i < input.length; i++) {
368
+ const ch = input[i];
369
+ if (inStr) {
370
+ cur += ch;
371
+ if (ch === inStr) inStr = null;
372
+ continue;
373
+ }
374
+ if (ch === "'" || ch === '"') {
375
+ inStr = ch;
376
+ cur += ch;
377
+ continue;
378
+ }
379
+ if (ch === '(') depth++;
380
+ if (ch === ')') depth--;
381
+ const matches = isWord
382
+ ? depth === 0 && input.slice(i, i + sep.length).toUpperCase() === sep && /\s/.test(input[i - 1] ?? ' ')
383
+ : depth === 0 && ch === sep;
384
+ if (matches) {
385
+ out.push(cur.trim());
386
+ cur = '';
387
+ i += sep.length - 1;
388
+ continue;
389
+ }
390
+ cur += ch;
391
+ }
392
+ if (cur.trim()) out.push(cur.trim());
393
+ return out;
394
+ }
395
+
396
+ /** Read a possibly-dotted path (`a.b.c`) from an object. */
397
+ export function getPath(obj: Row, path: string): unknown {
398
+ let cur: unknown = obj;
399
+ for (const k of path.split('.')) {
400
+ if (cur == null || typeof cur !== 'object') return undefined;
401
+ cur = (cur as Row)[k];
402
+ }
403
+ return cur;
404
+ }
405
+
406
+ /** Set a possibly-dotted path (`a.b.c`) on an object. */
407
+ export function setPath(obj: Row, path: string, value: unknown): void {
408
+ const parts = path.split('.');
409
+ let cur: Row = obj;
410
+ for (let i = 0; i < parts.length - 1; i++) {
411
+ const k = parts[i];
412
+ if (typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {};
413
+ cur = cur[k] as Row;
414
+ }
415
+ cur[parts[parts.length - 1]] = value;
416
+ }
@@ -0,0 +1,161 @@
1
+ import type { QueryPlan } from '@spooky-sync/query-builder';
2
+ import { LocalDatabaseService } from './local';
3
+ import { resolveRelations } from './relation-resolver';
4
+ import {
5
+ renderBaseSelectSurql,
6
+ renderRelationFetchSurql,
7
+ } from './plan-render';
8
+ import type {
9
+ EngineTx,
10
+ Id,
11
+ LocalCacheEngine,
12
+ OrderBy,
13
+ RelationFetch,
14
+ Row,
15
+ } from './cache-engine';
16
+ import { stableKey } from './relation-resolver';
17
+ import { surql } from '../../utils/surql';
18
+ import { RecordId } from 'surrealdb';
19
+
20
+ /**
21
+ * Default local cache backend: the in-browser SurrealDB-WASM store. Implemented
22
+ * as a subclass of {@link LocalDatabaseService} so it remains a 100% drop-in for
23
+ * every existing `this.local.query(...)` / `execute` / `switchStore` / `epoch`
24
+ * call site (zero behavior change), while adding the engine-neutral verb surface
25
+ * (`select`/`selectByIds`/`getById`/CRUD) used by the pluggable path.
26
+ *
27
+ * Relations are resolved with the SAME shared {@link resolveRelations} as the
28
+ * SQLite backend, so the two engines decompose `.related()` identically.
29
+ */
30
+ export class SurrealCacheEngine extends LocalDatabaseService implements LocalCacheEngine {
31
+ /** SurrealDB needs its SurrealQL schema provisioned locally. */
32
+ readonly usesSurqlSchema = true;
33
+
34
+ readonly engineKind = 'surrealdb' as const;
35
+
36
+ /** {@link LocalCacheEngine} alias for {@link LocalDatabaseService.switchStore}. */
37
+ switchBucket(bucketId: string): Promise<void> {
38
+ return this.switchStore(bucketId);
39
+ }
40
+
41
+ async fetchRelation(req: RelationFetch): Promise<Row[]> {
42
+ const { sql, vars } = renderRelationFetchSurql(req);
43
+ const [rows] = await this.query<[Row[]]>(sql, vars);
44
+ return rows ?? [];
45
+ }
46
+
47
+ async select(plan: QueryPlan, params: Record<string, unknown> = {}): Promise<Row[]> {
48
+ // Window materialization: base rows are exactly `plan.ids`, ordered.
49
+ if (plan.ids) {
50
+ const result = await this.selectByIds(plan.table, plan.ids, {
51
+ select: plan.select,
52
+ orderBy: plan.orderBy,
53
+ });
54
+ await resolveRelations(result, plan.relations, this);
55
+ return result;
56
+ }
57
+ const { sql, vars } = renderBaseSelectSurql(plan, params);
58
+ const [rows] = await this.query<[Row[]]>(sql, vars);
59
+ const result = rows ?? [];
60
+ await resolveRelations(result, plan.relations, this);
61
+ return result;
62
+ }
63
+
64
+ async selectByIds(
65
+ table: string,
66
+ ids: Id[],
67
+ opts?: { select?: string[]; orderBy?: OrderBy }
68
+ ): Promise<Row[]> {
69
+ if (ids.length === 0) return [];
70
+ const projection =
71
+ opts?.select && opts.select.length > 0 ? ['id', ...opts.select].join(', ') : '*';
72
+ let sql = `SELECT ${projection} FROM $__ids`;
73
+ if (opts?.orderBy && opts.orderBy.length > 0) {
74
+ sql += ` ORDER BY ${opts.orderBy.map(([f, d]) => `${f} ${d}`).join(', ')}`;
75
+ }
76
+ const [rows] = await this.query<[Row[]]>(`${sql};`, { __ids: ids });
77
+ const result = rows ?? [];
78
+ // Preserve caller's id order when no explicit ORDER BY (SurrealDB does not
79
+ // guarantee `FROM $ids` order).
80
+ if (!opts?.orderBy || opts.orderBy.length === 0) {
81
+ const pos = new Map(ids.map((id, i) => [stableKey(id), i]));
82
+ result.sort(
83
+ (a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0)
84
+ );
85
+ }
86
+ return result;
87
+ }
88
+
89
+ /**
90
+ * Coerce the contract's `Id` (a RecordId, a stable `table:id` string, or a
91
+ * bare id + the verb's `table` param) to a real RecordId. SurrealDB binds
92
+ * `$__id` verbatim: a plain string makes `FROM ONLY $__id` "select" the
93
+ * string itself (a truthy non-row) and `UPSERT $__id` an InternalError, so
94
+ * string ids silently broke every id-verb on this engine.
95
+ */
96
+ private toRecordId(table: string, id: Id): unknown {
97
+ if (typeof id !== 'string') return id;
98
+ const raw = id.startsWith(`${table}:`) ? id.slice(table.length + 1) : id;
99
+ return new RecordId(table, raw);
100
+ }
101
+
102
+ /**
103
+ * `(id, _00_rv)` of every row per table. Runs on the main thread against the
104
+ * embedded engine (no worker here), which is why the circuit prime caps how
105
+ * much it is willing to read through this path.
106
+ */
107
+ async scanVersions(tables: string[]): Promise<Record<string, [string, number][]>> {
108
+ const out: Record<string, [string, number][]> = {};
109
+ for (const table of tables) {
110
+ try {
111
+ const [rows] = await this.query<[{ id: Id; _00_rv?: unknown }[]]>(
112
+ `SELECT id, _00_rv FROM ${table};`
113
+ );
114
+ out[table] = (rows ?? []).map((r) => [stableKey(r.id), Number(r._00_rv) || 0]);
115
+ } catch {
116
+ out[table] = [];
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ async getById(table: string, id: Id): Promise<Row | null> {
123
+ const [row] = await this.query<[Row | null]>('SELECT * FROM ONLY $__id;', {
124
+ __id: this.toRecordId(table, id),
125
+ });
126
+ // `FROM ONLY <non-record>` echoes the value back; only a real row counts.
127
+ return row && typeof row === 'object' ? row : null;
128
+ }
129
+
130
+ async upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void> {
131
+ const sql = mode === 'merge' ? surql.upsertMerge('__id', '__data') : surql.upsert('__id', '__data');
132
+ await this.query(surql.seal(sql), { __id: this.toRecordId(table, id), __data: data });
133
+ }
134
+
135
+ async patch(table: string, id: Id, patches: unknown[]): Promise<void> {
136
+ await this.query(surql.seal('UPDATE ONLY $__id PATCH $__patches'), {
137
+ __id: this.toRecordId(table, id),
138
+ __patches: patches,
139
+ });
140
+ }
141
+
142
+ async delete(table: string, id: Id): Promise<void> {
143
+ await this.query(surql.seal(surql.delete('__id')), { __id: this.toRecordId(table, id) });
144
+ }
145
+
146
+ /**
147
+ * Serialized (not strictly atomic) transaction: verbs run in order on the
148
+ * same serialized query queue. The SurrealDB-WASM store already funnels every
149
+ * `query()` through one queue, so these never interleave with other work;
150
+ * true multi-statement atomicity is not required by the current call sites
151
+ * (single-record CRDT / mutation writes).
152
+ */
153
+ async transaction<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T> {
154
+ const tx: EngineTx = {
155
+ upsert: (t, id, data, mode) => this.upsert(t, id, data, mode),
156
+ patch: (t, id, patches) => this.patch(t, id, patches),
157
+ delete: (t, id) => this.delete(t, id),
158
+ };
159
+ return fn(tx);
160
+ }
161
+ }
@@ -1,5 +1,5 @@
1
- import pino, { Level, type Logger as PinoLogger, type LoggerOptions } from 'pino';
2
- import { PinoTransmit } from '../../types';
1
+ import pino, { type Level, type Logger as PinoLogger, type LoggerOptions } from 'pino';
2
+ import type { PinoTransmit } from '../../types';
3
3
 
4
4
  export type Logger = PinoLogger;
5
5
 
@@ -7,6 +7,7 @@ export function createLogger(level: Level = 'info', transmit?: PinoTransmit): Lo
7
7
  const browserConfig: LoggerOptions['browser'] = {
8
8
  asObject: true,
9
9
  write: (o: any) => {
10
+ // oxlint-disable-next-line no-console
10
11
  console.log(JSON.stringify(o));
11
12
  },
12
13
  };
@@ -1,5 +1,5 @@
1
- import { Logger } from 'pino';
2
- import { PersistenceClient } from '../../types';
1
+ import type { Logger } from 'pino';
2
+ import type { PersistenceClient } from '../../types';
3
3
 
4
4
  export class LocalStoragePersistenceClient implements PersistenceClient {
5
5
  private logger: Logger;
@@ -1,5 +1,5 @@
1
- import { Logger } from 'pino';
2
- import { PersistenceClient } from '../../types';
1
+ import type { Logger } from 'pino';
2
+ import type { PersistenceClient } from '../../types';
3
3
 
4
4
  export class ResilientPersistenceClient implements PersistenceClient {
5
5
  private logger: Logger;
@@ -20,10 +20,17 @@ export class ResilientPersistenceClient implements PersistenceClient {
20
20
  return await this.inner.get<T>(key);
21
21
  } catch (e) {
22
22
  this.logger.warn(
23
- { key, error: e, Category: 'spooky-client::ResilientPersistenceClient::get' },
23
+ { key, error: e, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
24
24
  'Persistence read failed, dropping key'
25
25
  );
26
- await this.inner.remove(key).catch(() => {});
26
+ // Best-effort cleanup of the corrupt key; if removal also fails, the key
27
+ // just gets dropped again on the next failed read, so the failure is safe.
28
+ await this.inner.remove(key).catch((removeErr) => {
29
+ this.logger.debug(
30
+ { key, error: removeErr, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
31
+ 'Failed to drop corrupt persistence key'
32
+ );
33
+ });
27
34
  return null;
28
35
  }
29
36
  }