@spooky-sync/core 0.0.1-canary.13 → 0.0.1-canary.131

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 (90) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1171 -372
  3. package/dist/index.js +5726 -1277
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-worker.d.ts +1 -0
  7. package/dist/sqlite-worker.js +107 -0
  8. package/dist/types.d.ts +746 -0
  9. package/package.json +39 -8
  10. package/skills/sp00ky-core/SKILL.md +258 -0
  11. package/skills/sp00ky-core/references/auth.md +98 -0
  12. package/skills/sp00ky-core/references/config.md +76 -0
  13. package/src/build-globals.d.ts +12 -0
  14. package/src/events/events.test.ts +2 -1
  15. package/src/events/index.ts +3 -0
  16. package/src/index.ts +9 -2
  17. package/src/modules/auth/events/index.ts +2 -1
  18. package/src/modules/auth/index.ts +59 -20
  19. package/src/modules/cache/index.ts +77 -32
  20. package/src/modules/cache/types.ts +2 -2
  21. package/src/modules/crdt/crdt-field.ts +288 -0
  22. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  23. package/src/modules/crdt/index.ts +357 -0
  24. package/src/modules/data/data.hydration.test.ts +150 -0
  25. package/src/modules/data/data.rebind.test.ts +147 -0
  26. package/src/modules/data/data.recurring.test.ts +137 -0
  27. package/src/modules/data/data.status.test.ts +249 -0
  28. package/src/modules/data/index.ts +1234 -127
  29. package/src/modules/data/window-query.test.ts +52 -0
  30. package/src/modules/data/window-query.ts +154 -0
  31. package/src/modules/devtools/index.ts +191 -30
  32. package/src/modules/devtools/versions.test.ts +74 -0
  33. package/src/modules/devtools/versions.ts +81 -0
  34. package/src/modules/feature-flag/index.test.ts +120 -0
  35. package/src/modules/feature-flag/index.ts +209 -0
  36. package/src/modules/ref-tables.test.ts +91 -0
  37. package/src/modules/ref-tables.ts +88 -0
  38. package/src/modules/sync/engine.ts +101 -37
  39. package/src/modules/sync/events/index.ts +9 -2
  40. package/src/modules/sync/queue/queue-down.ts +12 -5
  41. package/src/modules/sync/queue/queue-up.ts +29 -14
  42. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  43. package/src/modules/sync/scheduler.ts +73 -7
  44. package/src/modules/sync/sync.health.test.ts +149 -0
  45. package/src/modules/sync/sync.subquery.test.ts +82 -0
  46. package/src/modules/sync/sync.ts +1017 -62
  47. package/src/modules/sync/utils.test.ts +269 -2
  48. package/src/modules/sync/utils.ts +182 -17
  49. package/src/otel/index.ts +127 -0
  50. package/src/services/database/cache-engine.ts +143 -0
  51. package/src/services/database/database.ts +11 -11
  52. package/src/services/database/engine-factory.ts +32 -0
  53. package/src/services/database/events/index.ts +2 -1
  54. package/src/services/database/index.ts +6 -0
  55. package/src/services/database/local-migrator.ts +28 -27
  56. package/src/services/database/local.test.ts +64 -0
  57. package/src/services/database/local.ts +452 -66
  58. package/src/services/database/plan-render.test.ts +133 -0
  59. package/src/services/database/plan-render.ts +100 -0
  60. package/src/services/database/relation-resolver.test.ts +413 -0
  61. package/src/services/database/relation-resolver.ts +0 -0
  62. package/src/services/database/remote.ts +13 -13
  63. package/src/services/database/sqlite-cache-engine.test.ts +85 -0
  64. package/src/services/database/sqlite-cache-engine.ts +699 -0
  65. package/src/services/database/sqlite-worker.ts +116 -0
  66. package/src/services/database/surql-translate.ts +291 -0
  67. package/src/services/database/surreal-cache-engine.ts +139 -0
  68. package/src/services/logger/index.ts +6 -101
  69. package/src/services/persistence/localstorage.ts +2 -2
  70. package/src/services/persistence/resilient.ts +41 -0
  71. package/src/services/persistence/surrealdb.ts +10 -10
  72. package/src/services/stream-processor/index.ts +295 -38
  73. package/src/services/stream-processor/permissions.test.ts +47 -0
  74. package/src/services/stream-processor/permissions.ts +53 -0
  75. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  76. package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
  77. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  78. package/src/services/stream-processor/wasm-types.ts +18 -2
  79. package/src/sp00ky.auth-order.test.ts +92 -0
  80. package/src/sp00ky.init-query.test.ts +185 -0
  81. package/src/sp00ky.ts +1016 -0
  82. package/src/types.ts +258 -15
  83. package/src/utils/error-classification.test.ts +44 -0
  84. package/src/utils/error-classification.ts +7 -0
  85. package/src/utils/index.ts +35 -13
  86. package/src/utils/parser.ts +3 -2
  87. package/src/utils/surql.ts +24 -15
  88. package/src/utils/withRetry.test.ts +1 -1
  89. package/tsdown.config.ts +77 -1
  90. package/src/spooky.ts +0 -392
@@ -0,0 +1,116 @@
1
+ /// <reference lib="webworker" />
2
+ /**
3
+ * Dedicated Web Worker that owns the SQLite-WASM handle. All DB access is
4
+ * funnelled through here (the main thread never touches the wasm module), which
5
+ * is also what the OPFS VFS requires — file access must happen off the main
6
+ * thread. Persistence uses the **OPFS SAHPool VFS**: durable, and (unlike the
7
+ * classic OPFS VFS) it does NOT require COOP/COEP cross-origin isolation
8
+ * headers, so host apps embedding the client need no server changes. Falls back
9
+ * to an in-memory DB when OPFS is unavailable.
10
+ *
11
+ * Message protocol (request/response keyed by `id`):
12
+ * { id, type: 'open', payload: { dbName, useOpfs } }
13
+ * { id, type: 'exec', payload: { sql, bind } } -> { id, ok, rows }
14
+ * { id, type: 'run', payload: { sql, bind } } -> { id, ok }
15
+ * { id, type: 'batch', payload: [{ sql, bind }] } (atomic BEGIN/COMMIT)
16
+ * { id, type: 'close' }
17
+ */
18
+ import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
19
+
20
+ interface Stmt {
21
+ sql: string;
22
+ bind?: unknown[];
23
+ }
24
+
25
+ let db: {
26
+ exec: (opts: { sql: string; bind?: unknown[]; rowMode?: string; returnValue?: string }) => unknown;
27
+ close: () => void;
28
+ } | null = null;
29
+
30
+ async function open(dbName: string, useOpfs: boolean): Promise<{ persisted: boolean }> {
31
+ const sqlite3: any = await sqlite3InitModule();
32
+ let persisted = false;
33
+ if (useOpfs && sqlite3.installOpfsSAHPoolVfs) {
34
+ try {
35
+ const pool = await sqlite3.installOpfsSAHPoolVfs({ name: `sp00ky-${dbName}` });
36
+ db = new pool.OpfsSAHPoolDb(`/${dbName}.sqlite3`);
37
+ persisted = true;
38
+ } catch {
39
+ // fall through to in-memory
40
+ }
41
+ }
42
+ if (!db) db = new sqlite3.oo1.DB(':memory:', 'c');
43
+ // Retry (rather than instantly failing with SQLITE_BUSY=5) if a lock is held.
44
+ // Combined with the engine's single-flight op queue, overlap is avoided.
45
+ // `cache_size` is negated → KiB (here 32 MiB) so SQLite's page cache can't
46
+ // grow unbounded and starve a wasm-heavy renderer.
47
+ try {
48
+ db!.exec({ sql: 'PRAGMA busy_timeout = 5000; PRAGMA cache_size = -32000;' });
49
+ } catch {
50
+ /* pragma best-effort */
51
+ }
52
+ return { persisted };
53
+ }
54
+
55
+ function exec(sql: string, bind?: unknown[]): unknown[] {
56
+ if (!db) throw new Error('sqlite: DB not open');
57
+ return db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' }) as unknown[];
58
+ }
59
+
60
+ function run(sql: string, bind?: unknown[]): void {
61
+ if (!db) throw new Error('sqlite: DB not open');
62
+ db.exec({ sql, bind });
63
+ }
64
+
65
+ function batch(stmts: Stmt[]): void {
66
+ if (!db) throw new Error('sqlite: DB not open');
67
+ db.exec({ sql: 'BEGIN' });
68
+ try {
69
+ for (const s of stmts) db.exec({ sql: s.sql, bind: s.bind });
70
+ db.exec({ sql: 'COMMIT' });
71
+ } catch (e) {
72
+ try {
73
+ db.exec({ sql: 'ROLLBACK' });
74
+ } catch {
75
+ /* ignore */
76
+ }
77
+ throw e;
78
+ }
79
+ }
80
+
81
+ self.onmessage = async (ev: MessageEvent) => {
82
+ const { id, type, payload } = ev.data ?? {};
83
+ try {
84
+ let result: unknown;
85
+ switch (type) {
86
+ case 'open':
87
+ result = await open(payload.dbName, payload.useOpfs);
88
+ break;
89
+ case 'exec':
90
+ result = { rows: exec(payload.sql, payload.bind) };
91
+ break;
92
+ case 'run':
93
+ run(payload.sql, payload.bind);
94
+ result = {};
95
+ break;
96
+ case 'batch':
97
+ batch(payload as Stmt[]);
98
+ result = {};
99
+ break;
100
+ case 'close':
101
+ db?.close();
102
+ db = null;
103
+ result = {};
104
+ break;
105
+ default:
106
+ throw new Error(`sqlite worker: unknown message ${type}`);
107
+ }
108
+ (self as unknown as Worker).postMessage({ id, ok: true, ...(result as object) });
109
+ } catch (err) {
110
+ (self as unknown as Worker).postMessage({
111
+ id,
112
+ ok: false,
113
+ error: err instanceof Error ? err.message : String(err),
114
+ });
115
+ }
116
+ };
@@ -0,0 +1,291 @@
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
+ | { kind: 'selectByIds'; ids: unknown[]; select?: string[]; orderBy?: OrderBy; value?: string }
22
+ | { kind: 'selectTable'; table: string; where?: WhereNode[]; orderBy?: OrderBy; select?: string[]; value?: string }
23
+ | { kind: 'upsert'; id: unknown; data: Row; mode: 'replace' | 'merge' }
24
+ | { kind: 'updateSet'; id: unknown; sets: SetClause[]; returnNone: boolean }
25
+ | { kind: 'delete'; id: unknown }
26
+ | { kind: 'deleteAll'; table: string }
27
+ | { kind: 'let'; var: string; inner: SqlOp }
28
+ | { kind: 'return'; entries: { key: string; var: string }[] }
29
+ | { kind: 'noop' };
30
+
31
+ export interface SetClause {
32
+ path: string;
33
+ op: '=' | '+=' | '-=';
34
+ value: unknown;
35
+ }
36
+
37
+ export interface TranslatedQuery {
38
+ /** True for a `BEGIN TRANSACTION … COMMIT` block — the engine prepends a
39
+ * `null` begin-result so `surql.seal`'s `idx+1` extraction still lines up. */
40
+ transaction: boolean;
41
+ ops: SqlOp[];
42
+ }
43
+
44
+ const rid = (v: unknown): unknown => v;
45
+
46
+ /** Table name from a record id value (`table:id` string or RecordId). */
47
+ export function tableOf(id: unknown): string {
48
+ return stableKey(id).split(':')[0];
49
+ }
50
+
51
+ export function translateSurql(sql: string, vars: Record<string, unknown>): TranslatedQuery {
52
+ const trimmed = sql.trim().replace(/;\s*$/, '');
53
+
54
+ if (/^BEGIN\s+TRANSACTION/i.test(trimmed)) {
55
+ const inner = trimmed
56
+ .replace(/^BEGIN\s+TRANSACTION\s*;?/i, '')
57
+ .replace(/;?\s*COMMIT\s+TRANSACTION\s*$/i, '');
58
+ const ops = splitStatements(inner).map((s) => translateStatement(s, vars));
59
+ return { transaction: true, ops };
60
+ }
61
+
62
+ // A plain multi-statement string (e.g. `DEFINE DATABASE x; USE DB x`) — split
63
+ // and translate each. Single statement is just the one-element case.
64
+ const parts = splitStatements(trimmed);
65
+ return { transaction: false, ops: parts.map((s) => translateStatement(s, vars)) };
66
+ }
67
+
68
+ function splitStatements(block: string): string[] {
69
+ // Statements in the client's tx blocks never contain a bare `;` inside a
70
+ // string/paren, so a simple split is sufficient for this bounded vocabulary.
71
+ return block
72
+ .split(';')
73
+ .map((s) => s.trim())
74
+ .filter(Boolean);
75
+ }
76
+
77
+ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp {
78
+ const s = stmt.trim();
79
+
80
+ // LET $var = ( <inner statement> ) — bind the inner result into query scope.
81
+ let mLet = /^LET\s+\$(\w+)\s*=\s*\(([\s\S]+)\)$/i.exec(s);
82
+ if (mLet) {
83
+ return { kind: 'let', var: mLet[1], inner: translateStatement(mLet[2].trim(), vars) };
84
+ }
85
+
86
+ // RETURN { key: $var, ... } — build an object from scope/vars.
87
+ let mRet = /^RETURN\s+\{([\s\S]+)\}$/i.exec(s);
88
+ if (mRet) {
89
+ const entries = splitTopLevel(mRet[1], ',').map((pair) => {
90
+ const c = pair.indexOf(':');
91
+ const key = pair.slice(0, c).trim();
92
+ const v = pair.slice(c + 1).trim();
93
+ return { key, var: v.startsWith('$') ? v.slice(1) : v };
94
+ });
95
+ return { kind: 'return', entries };
96
+ }
97
+
98
+ // ---- schema / session DDL: no-ops on a schemaless engine --------------
99
+ // SQLite creates tables lazily and has no namespaces/DB DDL, so DEFINE /
100
+ // REMOVE / USE / INFO / RETURN statements are safely ignored.
101
+ if (/^(DEFINE|REMOVE|USE|INFO|RETURN|CANCEL|COMMIT|BEGIN)\b/i.test(s)) {
102
+ return { kind: 'noop' };
103
+ }
104
+
105
+ // ---- writes -----------------------------------------------------------
106
+ let m =
107
+ /^CREATE\s+ONLY\s+\$(\w+)\s+CONTENT\s+\$(\w+)$/i.exec(s) ||
108
+ /^UPSERT\s+ONLY\s+\$(\w+)\s+REPLACE\s+\$(\w+)$/i.exec(s);
109
+ if (m) {
110
+ return { kind: 'upsert', id: rid(vars[m[1]]), data: asRow(vars[m[2]]), mode: 'replace' };
111
+ }
112
+
113
+ m =
114
+ /^UPSERT\s+ONLY\s+\$(\w+)\s+MERGE\s+\$(\w+)$/i.exec(s) ||
115
+ /^UPDATE\s+ONLY\s+\$(\w+)\s+MERGE\s+\$(\w+)$/i.exec(s);
116
+ if (m) {
117
+ return { kind: 'upsert', id: rid(vars[m[1]]), data: asRow(vars[m[2]]), mode: 'merge' };
118
+ }
119
+
120
+ // CREATE ONLY $id SET a = ..., b = ... (createSet / createMutation)
121
+ m = /^CREATE\s+ONLY\s+\$(\w+)\s+SET\s+(.+)$/i.exec(s);
122
+ if (m) {
123
+ const data: Row = {};
124
+ for (const { path, value } of parseSetClauses(m[2], vars)) setPath(data, path, value);
125
+ return { kind: 'upsert', id: rid(vars[m[1]]), data, mode: 'replace' };
126
+ }
127
+
128
+ // UPDATE $id SET a.b = $x, c = $y [RETURN NONE]
129
+ m = /^UPDATE\s+\$(\w+)\s+SET\s+(.+?)(\s+RETURN\s+NONE)?$/i.exec(s);
130
+ if (m) {
131
+ return {
132
+ kind: 'updateSet',
133
+ id: rid(vars[m[1]]),
134
+ sets: parseSetClauses(m[2], vars),
135
+ returnNone: !!m[3],
136
+ };
137
+ }
138
+
139
+ m = /^DELETE\s+\$(\w+)$/i.exec(s);
140
+ if (m) return { kind: 'delete', id: rid(vars[m[1]]) };
141
+
142
+ m = /^DELETE\s+([A-Za-z_]\w*)$/i.exec(s); // DELETE <table>
143
+ if (m) return { kind: 'deleteAll', table: m[1] };
144
+
145
+ // ---- reads ------------------------------------------------------------
146
+ // SELECT [VALUE] <proj> FROM ONLY $id
147
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+ONLY\s+\$(\w+)$/i.exec(s);
148
+ if (m) {
149
+ const value = m[1] ? m[2].trim() : undefined;
150
+ return { kind: 'getById', id: rid(vars[m[3]]), select: projFields(m[2], !!m[1]), value };
151
+ }
152
+
153
+ // SELECT [VALUE] <proj> FROM $arrayParam
154
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(s);
155
+ if (m) {
156
+ const value = m[1] ? m[2].trim() : undefined;
157
+ return {
158
+ kind: 'selectByIds',
159
+ ids: (vars[m[3]] as unknown[]) ?? [],
160
+ select: projFields(m[2], !!m[1]),
161
+ value,
162
+ };
163
+ }
164
+
165
+ // SELECT <proj> FROM <table> [WHERE ...] [ORDER BY ...]
166
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+([A-Za-z_]\w*)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(.+?))?$/i.exec(
167
+ s
168
+ );
169
+ if (m) {
170
+ return {
171
+ kind: 'selectTable',
172
+ table: m[3],
173
+ where: m[4] ? parseWhere(m[4], vars) : undefined,
174
+ orderBy: m[5] ? parseOrderBy(m[5]) : undefined,
175
+ select: projFields(m[2], !!m[1]),
176
+ value: m[1] ? m[2].trim() : undefined,
177
+ };
178
+ }
179
+
180
+ throw new Error(`SqliteCacheEngine: unsupported SurrealQL for translation: ${stmt}`);
181
+ }
182
+
183
+ // ==================== clause parsers ====================
184
+
185
+ function projFields(proj: string, isValue: boolean): string[] | undefined {
186
+ const p = proj.trim();
187
+ if (isValue) return undefined; // VALUE returns a scalar, no projection object
188
+ if (p === '*') return undefined;
189
+ return p.split(',').map((f) => f.trim());
190
+ }
191
+
192
+ function asRow(v: unknown): Row {
193
+ return (v && typeof v === 'object' ? v : {}) as Row;
194
+ }
195
+
196
+ /** Parse `a = $x, b.c = 'lit', _00_rv += 1` into path/op/value triples. */
197
+ function parseSetClauses(clause: string, vars: Record<string, unknown>): SetClause[] {
198
+ return splitTopLevel(clause, ',').map((part) => {
199
+ const m = /^(.+?)\s*(\+=|-=|=)\s*([\s\S]+)$/.exec(part.trim());
200
+ if (!m) throw new Error(`SqliteCacheEngine: cannot parse SET clause: ${part}`);
201
+ return { path: m[1].trim(), op: m[2] as SetClause['op'], value: literalOrVar(m[3], vars) };
202
+ });
203
+ }
204
+
205
+ function literalOrVar(token: string, vars: Record<string, unknown>): unknown {
206
+ const t = token.trim();
207
+ if (t.startsWith('$')) return vars[t.slice(1)];
208
+ if (/^'.*'$/.test(t) || /^".*"$/.test(t)) return t.slice(1, -1);
209
+ if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
210
+ if (t === 'true') return true;
211
+ if (t === 'false') return false;
212
+ if (t === 'NONE' || t === 'NULL' || t === 'null') return null;
213
+ return t;
214
+ }
215
+
216
+ function parseWhere(clause: string, vars: Record<string, unknown>): WhereNode[] {
217
+ // Only simple AND-of-equality/comparison is emitted by the client's raw
218
+ // queries; OR/nested go through plan-based select().
219
+ return splitTopLevel(clause, 'AND').map((cond) => {
220
+ const m = /^(\S+)\s*(=|!=|>=|<=|>|<)\s*(.+)$/.exec(cond.trim());
221
+ if (!m) throw new Error(`SqliteCacheEngine: cannot parse WHERE condition: ${cond}`);
222
+ return { field: m[1], op: m[2] as WhereComparisonOp, value: literalOrVar(m[3], vars) };
223
+ });
224
+ }
225
+
226
+ type WhereComparisonOp = '=' | '!=' | '>=' | '<=' | '>' | '<';
227
+
228
+ function parseOrderBy(clause: string): OrderBy {
229
+ return clause.split(',').map((c) => {
230
+ const [f, dir] = c.trim().split(/\s+/);
231
+ return [f, (dir ?? 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc'];
232
+ });
233
+ }
234
+
235
+ /** Split on a top-level separator, ignoring parens/quotes (bounded inputs). */
236
+ function splitTopLevel(input: string, sep: string): string[] {
237
+ const out: string[] = [];
238
+ let depth = 0;
239
+ let inStr: string | null = null;
240
+ let cur = '';
241
+ const isWord = /[A-Za-z]/.test(sep);
242
+ for (let i = 0; i < input.length; i++) {
243
+ const ch = input[i];
244
+ if (inStr) {
245
+ cur += ch;
246
+ if (ch === inStr) inStr = null;
247
+ continue;
248
+ }
249
+ if (ch === "'" || ch === '"') {
250
+ inStr = ch;
251
+ cur += ch;
252
+ continue;
253
+ }
254
+ if (ch === '(') depth++;
255
+ if (ch === ')') depth--;
256
+ const matches = isWord
257
+ ? depth === 0 && input.slice(i, i + sep.length).toUpperCase() === sep && /\s/.test(input[i - 1] ?? ' ')
258
+ : depth === 0 && ch === sep;
259
+ if (matches) {
260
+ out.push(cur.trim());
261
+ cur = '';
262
+ i += sep.length - 1;
263
+ continue;
264
+ }
265
+ cur += ch;
266
+ }
267
+ if (cur.trim()) out.push(cur.trim());
268
+ return out;
269
+ }
270
+
271
+ /** Read a possibly-dotted path (`a.b.c`) from an object. */
272
+ export function getPath(obj: Row, path: string): unknown {
273
+ let cur: unknown = obj;
274
+ for (const k of path.split('.')) {
275
+ if (cur == null || typeof cur !== 'object') return undefined;
276
+ cur = (cur as Row)[k];
277
+ }
278
+ return cur;
279
+ }
280
+
281
+ /** Set a possibly-dotted path (`a.b.c`) on an object. */
282
+ export function setPath(obj: Row, path: string, value: unknown): void {
283
+ const parts = path.split('.');
284
+ let cur: Row = obj;
285
+ for (let i = 0; i < parts.length - 1; i++) {
286
+ const k = parts[i];
287
+ if (typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {};
288
+ cur = cur[k] as Row;
289
+ }
290
+ cur[parts[parts.length - 1]] = value;
291
+ }
@@ -0,0 +1,139 @@
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
+ /** {@link LocalCacheEngine} alias for {@link LocalDatabaseService.switchStore}. */
35
+ switchBucket(bucketId: string): Promise<void> {
36
+ return this.switchStore(bucketId);
37
+ }
38
+
39
+ async fetchRelation(req: RelationFetch): Promise<Row[]> {
40
+ const { sql, vars } = renderRelationFetchSurql(req);
41
+ const [rows] = await this.query<[Row[]]>(sql, vars);
42
+ return rows ?? [];
43
+ }
44
+
45
+ async select(plan: QueryPlan, params: Record<string, unknown> = {}): Promise<Row[]> {
46
+ // Window materialization: base rows are exactly `plan.ids`, ordered.
47
+ if (plan.ids) {
48
+ const result = await this.selectByIds(plan.table, plan.ids, {
49
+ select: plan.select,
50
+ orderBy: plan.orderBy,
51
+ });
52
+ await resolveRelations(result, plan.relations, this);
53
+ return result;
54
+ }
55
+ const { sql, vars } = renderBaseSelectSurql(plan, params);
56
+ const [rows] = await this.query<[Row[]]>(sql, vars);
57
+ const result = rows ?? [];
58
+ await resolveRelations(result, plan.relations, this);
59
+ return result;
60
+ }
61
+
62
+ async selectByIds(
63
+ table: string,
64
+ ids: Id[],
65
+ opts?: { select?: string[]; orderBy?: OrderBy }
66
+ ): Promise<Row[]> {
67
+ if (ids.length === 0) return [];
68
+ const projection =
69
+ opts?.select && opts.select.length > 0 ? ['id', ...opts.select].join(', ') : '*';
70
+ let sql = `SELECT ${projection} FROM $__ids`;
71
+ if (opts?.orderBy && opts.orderBy.length > 0) {
72
+ sql += ` ORDER BY ${opts.orderBy.map(([f, d]) => `${f} ${d}`).join(', ')}`;
73
+ }
74
+ const [rows] = await this.query<[Row[]]>(`${sql};`, { __ids: ids });
75
+ const result = rows ?? [];
76
+ // Preserve caller's id order when no explicit ORDER BY (SurrealDB does not
77
+ // guarantee `FROM $ids` order).
78
+ if (!opts?.orderBy || opts.orderBy.length === 0) {
79
+ const pos = new Map(ids.map((id, i) => [stableKey(id), i]));
80
+ result.sort(
81
+ (a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0)
82
+ );
83
+ }
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * Coerce the contract's `Id` (a RecordId, a stable `table:id` string, or a
89
+ * bare id + the verb's `table` param) to a real RecordId. SurrealDB binds
90
+ * `$__id` verbatim: a plain string makes `FROM ONLY $__id` "select" the
91
+ * string itself (a truthy non-row) and `UPSERT $__id` an InternalError, so
92
+ * string ids silently broke every id-verb on this engine.
93
+ */
94
+ private toRecordId(table: string, id: Id): unknown {
95
+ if (typeof id !== 'string') return id;
96
+ const raw = id.startsWith(`${table}:`) ? id.slice(table.length + 1) : id;
97
+ return new RecordId(table, raw);
98
+ }
99
+
100
+ async getById(table: string, id: Id): Promise<Row | null> {
101
+ const [row] = await this.query<[Row | null]>('SELECT * FROM ONLY $__id;', {
102
+ __id: this.toRecordId(table, id),
103
+ });
104
+ // `FROM ONLY <non-record>` echoes the value back; only a real row counts.
105
+ return row && typeof row === 'object' ? row : null;
106
+ }
107
+
108
+ async upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void> {
109
+ const sql = mode === 'merge' ? surql.upsertMerge('__id', '__data') : surql.upsert('__id', '__data');
110
+ await this.query(surql.seal(sql), { __id: this.toRecordId(table, id), __data: data });
111
+ }
112
+
113
+ async patch(table: string, id: Id, patches: unknown[]): Promise<void> {
114
+ await this.query(surql.seal('UPDATE ONLY $__id PATCH $__patches'), {
115
+ __id: this.toRecordId(table, id),
116
+ __patches: patches,
117
+ });
118
+ }
119
+
120
+ async delete(table: string, id: Id): Promise<void> {
121
+ await this.query(surql.seal(surql.delete('__id')), { __id: this.toRecordId(table, id) });
122
+ }
123
+
124
+ /**
125
+ * Serialized (not strictly atomic) transaction: verbs run in order on the
126
+ * same serialized query queue. The SurrealDB-WASM store already funnels every
127
+ * `query()` through one queue, so these never interleave with other work;
128
+ * true multi-statement atomicity is not required by the current call sites
129
+ * (single-record CRDT / mutation writes).
130
+ */
131
+ async transaction<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T> {
132
+ const tx: EngineTx = {
133
+ upsert: (t, id, data, mode) => this.upsert(t, id, data, mode),
134
+ patch: (t, id, patches) => this.patch(t, id, patches),
135
+ delete: (t, id) => this.delete(t, id),
136
+ };
137
+ return fn(tx);
138
+ }
139
+ }
@@ -1,114 +1,19 @@
1
- import pino, { Level, type Logger as PinoLogger, type LoggerOptions } from 'pino';
2
- import { LoggerProvider, BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
3
- import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto';
4
- import { resourceFromAttributes } from '@opentelemetry/resources';
5
- import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
6
- import { createContextKey } from '@opentelemetry/api';
7
-
8
- const CATEGORY_KEY = createContextKey('Category');
1
+ import pino, { type Level, type Logger as PinoLogger, type LoggerOptions } from 'pino';
2
+ import type { PinoTransmit } from '../../types';
9
3
 
10
4
  export type Logger = PinoLogger;
11
5
 
12
- // Map pino levels to OTEL severity numbers
13
- // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#severity-fields
14
- function mapLevelToSeverityNumber(level: string): number {
15
- switch (level) {
16
- case 'trace':
17
- return 1;
18
- case 'debug':
19
- return 5;
20
- case 'info':
21
- return 9;
22
- case 'warn':
23
- return 13;
24
- case 'error':
25
- return 17;
26
- case 'fatal':
27
- return 21;
28
- default:
29
- return 9;
30
- }
31
- }
32
-
33
- export function createLogger(level: Level = 'info', otelEndpoint?: string): Logger {
6
+ export function createLogger(level: Level = 'info', transmit?: PinoTransmit): Logger {
34
7
  const browserConfig: LoggerOptions['browser'] = {
35
8
  asObject: true,
36
9
  write: (o: any) => {
10
+ // oxlint-disable-next-line no-console
37
11
  console.log(JSON.stringify(o));
38
12
  },
39
13
  };
40
14
 
41
- if (otelEndpoint) {
42
- // Initialize OTEL LoggerProvider
43
- const resource = resourceFromAttributes({
44
- [ATTR_SERVICE_NAME]: 'spooky-client',
45
- });
46
-
47
- const exporter = new OTLPLogExporter({
48
- url: otelEndpoint,
49
- });
50
-
51
- // Pass processors in constructor as this SDK version requires it
52
- const loggerProvider = new LoggerProvider({
53
- resource,
54
- processors: [new BatchLogRecordProcessor(exporter)],
55
- });
56
-
57
- const otelLogger: Record<string, ReturnType<typeof loggerProvider.getLogger>> = {};
58
-
59
- const getOtelLogger = (category: string) => {
60
- if (!otelLogger[category]) {
61
- otelLogger[category] = loggerProvider.getLogger(category);
62
- }
63
- return otelLogger[category];
64
- };
65
-
66
- browserConfig.transmit = {
67
- level: level,
68
- send: (levelLabel: string, logEvent: any) => {
69
- try {
70
- const messages = [...logEvent.messages];
71
- const severityNumber = mapLevelToSeverityNumber(levelLabel);
72
-
73
- // Construct the message body
74
- let body = '';
75
- const msg = messages.pop();
76
-
77
- if (typeof msg === 'string') {
78
- body = msg;
79
- } else if (msg) {
80
- body = JSON.stringify(msg);
81
- }
82
-
83
- let category = 'spooky-client::unknown';
84
-
85
- const attributes = {};
86
- for (const msg of messages) {
87
- if (typeof msg === 'object') {
88
- if (msg.Category) {
89
- category = msg.Category;
90
- delete msg.Category;
91
- }
92
- Object.assign(attributes, msg);
93
- }
94
- }
95
-
96
- // Emit to OTEL SDK
97
- getOtelLogger(category).emit({
98
- severityNumber: severityNumber,
99
- severityText: levelLabel.toUpperCase(),
100
- body: body,
101
- attributes: {
102
- ...logEvent.bindings[0],
103
- ...attributes,
104
- },
105
- timestamp: new Date(logEvent.ts),
106
- });
107
- } catch (e) {
108
- console.warn('Failed to transmit log to OTEL endpoint', e);
109
- }
110
- },
111
- };
15
+ if (transmit) {
16
+ browserConfig.transmit = transmit;
112
17
  }
113
18
 
114
19
  return pino({
@@ -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;