@spooky-sync/core 0.0.1-canary.15 → 0.0.1-canary.151
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.
- package/AGENTS.md +56 -0
- package/dist/index.d.ts +1192 -372
- package/dist/index.js +5772 -1270
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-plan-sql.js +194 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +202 -0
- package/dist/types.d.ts +755 -0
- package/package.json +40 -8
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +16 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +361 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1134 -128
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +215 -0
- package/src/services/database/sqlite-cache-engine.ts +829 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +279 -0
- package/src/services/database/sqlite-select.ts +113 -0
- package/src/services/database/sqlite-worker.ts +160 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +139 -0
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1000 -0
- package/src/types.ts +267 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
2
|
+
import { resolveRelations, stableKey } from './relation-resolver';
|
|
3
|
+
import { renderOrderSql, renderWhereSql, reviveRow, project } from './sqlite-plan-sql';
|
|
4
|
+
import type { OrderBy, RelationFetch, Row, RowFetcher } from './cache-engine';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worker-side execution of a whole {@link QueryPlan} — table creation, base
|
|
8
|
+
* select (either the `ids` window or where/order/limit), projection, and the
|
|
9
|
+
* full `.related()` tree via the SHARED `resolveRelations` — against an
|
|
10
|
+
* injected DB handle. Runs inside `sqlite-worker.ts` so the engine pays ONE
|
|
11
|
+
* postMessage round-trip per select instead of one per table/relation level;
|
|
12
|
+
* extracted into its own module so the logic is unit-testable off-worker
|
|
13
|
+
* (parity with the engine's legacy multi-hop path).
|
|
14
|
+
*
|
|
15
|
+
* Semantics mirror `SqliteCacheEngine.selectLegacy` exactly: same SQL, same
|
|
16
|
+
* ordering rules, same projection, same resolver.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The slice of the worker's DB surface `executeSelect` needs. */
|
|
20
|
+
export interface SelectDb {
|
|
21
|
+
/** Run a row-returning statement; rows come back as `{ data: <json> }`. */
|
|
22
|
+
exec(sql: string, bind?: unknown[]): { data: string }[];
|
|
23
|
+
/** Run a statement for effect only (CREATE TABLE). */
|
|
24
|
+
run(sql: string, bind?: unknown[]): void;
|
|
25
|
+
/** Tables already CREATEd on this handle (caller owns the lifecycle). */
|
|
26
|
+
knownTables: Set<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureTable(db: SelectDb, table: string): void {
|
|
30
|
+
if (db.knownTables.has(table)) return;
|
|
31
|
+
db.run(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`);
|
|
32
|
+
db.knownTables.add(table);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function execRows(db: SelectDb, sql: string, bind: unknown[]): Row[] {
|
|
36
|
+
return db.exec(sql, bind).map((r) => reviveRow(r.data));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Mirrors the engine's `selectByIds`: fetch by primary id, preserving `ids`
|
|
40
|
+
* order unless an ORDER BY overrides it. */
|
|
41
|
+
function selectByIds(
|
|
42
|
+
db: SelectDb,
|
|
43
|
+
table: string,
|
|
44
|
+
ids: unknown[],
|
|
45
|
+
opts?: { select?: string[]; orderBy?: OrderBy }
|
|
46
|
+
): Row[] {
|
|
47
|
+
if (ids.length === 0) return [];
|
|
48
|
+
ensureTable(db, table);
|
|
49
|
+
const keys = ids.map(stableKey);
|
|
50
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
51
|
+
let sql = `SELECT data FROM "${table}" WHERE id IN (${placeholders})`;
|
|
52
|
+
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
53
|
+
let rows = execRows(db, sql, keys);
|
|
54
|
+
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
55
|
+
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
56
|
+
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
57
|
+
}
|
|
58
|
+
return opts?.select ? rows.map((r) => project(r, opts.select!)) : rows;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Mirrors the engine's `fetchRelation` SQL exactly. */
|
|
62
|
+
function fetchRelation(db: SelectDb, req: RelationFetch): Row[] {
|
|
63
|
+
ensureTable(db, req.table);
|
|
64
|
+
const keys = req.keys.map(stableKey);
|
|
65
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
66
|
+
const bind: unknown[] = [...keys];
|
|
67
|
+
const lhs = req.matchField === 'id' ? 'id' : `json_extract(data, '$.${req.matchField}')`;
|
|
68
|
+
let sql = `SELECT data FROM "${req.table}" WHERE ${lhs} IN (${placeholders})`;
|
|
69
|
+
if (req.where && req.where.length > 0) {
|
|
70
|
+
sql += ` AND ${renderWhereSql(req.where, bind, {})}`;
|
|
71
|
+
}
|
|
72
|
+
if (req.orderBy && req.orderBy.length > 0) sql += renderOrderSql(req.orderBy);
|
|
73
|
+
const rows = execRows(db, sql, bind);
|
|
74
|
+
return req.select ? rows.map((r) => project(r, req.select!)) : rows;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function executeSelect(
|
|
78
|
+
plan: QueryPlan,
|
|
79
|
+
params: Record<string, unknown>,
|
|
80
|
+
db: SelectDb
|
|
81
|
+
): Promise<{ rows: Row[]; relationFetches: number }> {
|
|
82
|
+
// Per-call fetch counter (the engine folds it into `__sqliteStats`).
|
|
83
|
+
const counter = { n: 0 };
|
|
84
|
+
const fetcher: RowFetcher = {
|
|
85
|
+
fetchRelation: (req) => {
|
|
86
|
+
counter.n++;
|
|
87
|
+
return Promise.resolve(fetchRelation(db, req));
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
// Window materialization: base rows are exactly `plan.ids`, ordered.
|
|
91
|
+
if (plan.ids) {
|
|
92
|
+
const rows = selectByIds(db, plan.table, plan.ids, {
|
|
93
|
+
select: plan.select,
|
|
94
|
+
orderBy: plan.orderBy,
|
|
95
|
+
});
|
|
96
|
+
await resolveRelations(rows, plan.relations, fetcher);
|
|
97
|
+
return { rows, relationFetches: counter.n };
|
|
98
|
+
}
|
|
99
|
+
ensureTable(db, plan.table);
|
|
100
|
+
const bind: unknown[] = [];
|
|
101
|
+
let sql = `SELECT data FROM "${plan.table}"`;
|
|
102
|
+
if (plan.where && plan.where.length > 0) {
|
|
103
|
+
sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
104
|
+
}
|
|
105
|
+
if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
|
|
106
|
+
if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
107
|
+
if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
108
|
+
const rows = execRows(db, sql, bind);
|
|
109
|
+
// Optional projection trimming to match `SELECT <fields>`.
|
|
110
|
+
const projected = plan.select ? rows.map((r) => project(r, plan.select!)) : rows;
|
|
111
|
+
await resolveRelations(projected, plan.relations, fetcher);
|
|
112
|
+
return { rows: projected, relationFetches: counter.n };
|
|
113
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
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: 'select', payload: { plan, params } } -> { id, ok, rows, relationFetches }
|
|
17
|
+
* { id, type: 'close' }
|
|
18
|
+
*
|
|
19
|
+
* `select` executes a whole QueryPlan — table creation, base select, the full
|
|
20
|
+
* `.related()` tree (shared `resolveRelations`), and JSON row parsing — in ONE
|
|
21
|
+
* round-trip, returning structured-clone row objects. This is the first-load
|
|
22
|
+
* hot path; the per-statement ops remain for the write/shim paths.
|
|
23
|
+
*/
|
|
24
|
+
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
25
|
+
import { executeSelect, type SelectDb } from './sqlite-select';
|
|
26
|
+
|
|
27
|
+
interface Stmt {
|
|
28
|
+
sql: string;
|
|
29
|
+
bind?: unknown[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let db: {
|
|
33
|
+
exec: (opts: { sql: string; bind?: unknown[]; rowMode?: string; returnValue?: string }) => unknown;
|
|
34
|
+
close: () => void;
|
|
35
|
+
} | null = null;
|
|
36
|
+
|
|
37
|
+
async function open(
|
|
38
|
+
dbName: string,
|
|
39
|
+
useOpfs: boolean,
|
|
40
|
+
systemTables: readonly string[] = []
|
|
41
|
+
): Promise<{ persisted: boolean }> {
|
|
42
|
+
const sqlite3: any = await sqlite3InitModule();
|
|
43
|
+
let persisted = false;
|
|
44
|
+
if (useOpfs && sqlite3.installOpfsSAHPoolVfs) {
|
|
45
|
+
try {
|
|
46
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({ name: `sp00ky-${dbName}` });
|
|
47
|
+
db = new pool.OpfsSAHPoolDb(`/${dbName}.sqlite3`);
|
|
48
|
+
persisted = true;
|
|
49
|
+
} catch {
|
|
50
|
+
// fall through to in-memory
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!db) db = new sqlite3.oo1.DB(':memory:', 'c');
|
|
54
|
+
// Physically create the internal `_00_*` tables the client reads before any
|
|
55
|
+
// write (DEFINE is a noop on this engine, so the migrator can't). Prevents
|
|
56
|
+
// "no such table: _00_query" on a fresh bucket right after signup.
|
|
57
|
+
for (const t of systemTables) {
|
|
58
|
+
db!.exec({ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
|
|
59
|
+
}
|
|
60
|
+
// Retry (rather than instantly failing with SQLITE_BUSY=5) if a lock is held.
|
|
61
|
+
// Combined with the engine's single-flight op queue, overlap is avoided.
|
|
62
|
+
// `cache_size` is negated → KiB (here 32 MiB) so SQLite's page cache can't
|
|
63
|
+
// grow unbounded and starve a wasm-heavy renderer.
|
|
64
|
+
try {
|
|
65
|
+
db!.exec({ sql: 'PRAGMA busy_timeout = 5000; PRAGMA cache_size = -32000;' });
|
|
66
|
+
} catch {
|
|
67
|
+
/* pragma best-effort */
|
|
68
|
+
}
|
|
69
|
+
return { persisted };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function exec(sql: string, bind?: unknown[]): unknown[] {
|
|
73
|
+
if (!db) throw new Error('sqlite: DB not open');
|
|
74
|
+
return db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' }) as unknown[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function run(sql: string, bind?: unknown[]): void {
|
|
78
|
+
if (!db) throw new Error('sqlite: DB not open');
|
|
79
|
+
db.exec({ sql, bind });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function batch(stmts: Stmt[]): void {
|
|
83
|
+
if (!db) throw new Error('sqlite: DB not open');
|
|
84
|
+
db.exec({ sql: 'BEGIN' });
|
|
85
|
+
try {
|
|
86
|
+
for (const s of stmts) db.exec({ sql: s.sql, bind: s.bind });
|
|
87
|
+
db.exec({ sql: 'COMMIT' });
|
|
88
|
+
} catch (e) {
|
|
89
|
+
try {
|
|
90
|
+
db.exec({ sql: 'ROLLBACK' });
|
|
91
|
+
} catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ==================== worker-side plan execution ('select') ====================
|
|
99
|
+
|
|
100
|
+
/** DB handle for `executeSelect` (see `sqlite-select.ts`, the unit-testable
|
|
101
|
+
* plan executor). `knownTables` is cleared on open/close — a bucket switch
|
|
102
|
+
* spawns a fresh worker anyway. */
|
|
103
|
+
const selectDb: SelectDb = {
|
|
104
|
+
exec: (sql, bind) => exec(sql, bind) as { data: string }[],
|
|
105
|
+
run,
|
|
106
|
+
knownTables: new Set<string>(),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
self.onmessage = async (ev: MessageEvent) => {
|
|
110
|
+
const { id, type, payload } = ev.data ?? {};
|
|
111
|
+
// `wt` (worker time) lets the main thread split a round-trip into actual DB
|
|
112
|
+
// work vs postMessage/queue overhead (see `__sqliteStats`).
|
|
113
|
+
const t0 = performance.now();
|
|
114
|
+
try {
|
|
115
|
+
let result: unknown;
|
|
116
|
+
switch (type) {
|
|
117
|
+
case 'open':
|
|
118
|
+
selectDb.knownTables.clear();
|
|
119
|
+
result = await open(payload.dbName, payload.useOpfs, payload.systemTables);
|
|
120
|
+
// The freshly seeded system tables exist — record them so the select
|
|
121
|
+
// path doesn't redundantly re-issue CREATE TABLE for each.
|
|
122
|
+
for (const t of (payload.systemTables ?? []) as string[]) selectDb.knownTables.add(t);
|
|
123
|
+
break;
|
|
124
|
+
case 'select':
|
|
125
|
+
result = await executeSelect(payload.plan, payload.params ?? {}, selectDb);
|
|
126
|
+
break;
|
|
127
|
+
case 'exec':
|
|
128
|
+
result = { rows: exec(payload.sql, payload.bind) };
|
|
129
|
+
break;
|
|
130
|
+
case 'run':
|
|
131
|
+
run(payload.sql, payload.bind);
|
|
132
|
+
result = {};
|
|
133
|
+
break;
|
|
134
|
+
case 'batch':
|
|
135
|
+
batch(payload as Stmt[]);
|
|
136
|
+
result = {};
|
|
137
|
+
break;
|
|
138
|
+
case 'close':
|
|
139
|
+
db?.close();
|
|
140
|
+
db = null;
|
|
141
|
+
selectDb.knownTables.clear();
|
|
142
|
+
result = {};
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
throw new Error(`sqlite worker: unknown message ${type}`);
|
|
146
|
+
}
|
|
147
|
+
(self as unknown as Worker).postMessage({
|
|
148
|
+
id,
|
|
149
|
+
ok: true,
|
|
150
|
+
wt: performance.now() - t0,
|
|
151
|
+
...(result as object),
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
(self as unknown as Worker).postMessage({
|
|
155
|
+
id,
|
|
156
|
+
ok: false,
|
|
157
|
+
error: err instanceof Error ? err.message : String(err),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
};
|
|
@@ -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
|
+
}
|