@spooky-sync/core 0.0.1-canary.172 → 0.0.1-canary.174

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/dist/index.js CHANGED
@@ -1693,6 +1693,7 @@ function translateStatement(stmt, vars) {
1693
1693
  };
1694
1694
  })
1695
1695
  };
1696
+ if (/^INFO\s+FOR\s+DB(\s+STRUCTURE)?$/i.test(s)) return { kind: "infoForDb" };
1696
1697
  if (/^(DEFINE|REMOVE|USE|INFO|RETURN|CANCEL|COMMIT|BEGIN)\b/i.test(s)) return { kind: "noop" };
1697
1698
  let m = /^CREATE\s+ONLY\s+\$(\w+)\s+CONTENT\s+\$(\w+)$/i.exec(s) || /^UPSERT\s+ONLY\s+\$(\w+)\s+REPLACE\s+\$(\w+)$/i.exec(s);
1698
1699
  if (m) return {
@@ -1701,10 +1702,10 @@ function translateStatement(stmt, vars) {
1701
1702
  data: asRow(vars[m[2]]),
1702
1703
  mode: "replace"
1703
1704
  };
1704
- m = /^UPSERT\s+ONLY\s+\$(\w+)\s+MERGE\s+\$(\w+)$/i.exec(s) || /^UPDATE\s+ONLY\s+\$(\w+)\s+MERGE\s+\$(\w+)$/i.exec(s);
1705
+ m = idRe(String.raw`^UPSERT\s+ONLY\s+%ID%\s+MERGE\s+\$(\w+)$`).exec(s) || idRe(String.raw`^UPDATE\s+(?:ONLY\s+)?%ID%\s+MERGE\s+\$(\w+)$`).exec(s);
1705
1706
  if (m) return {
1706
1707
  kind: "upsert",
1707
- id: rid(vars[m[1]]),
1708
+ id: idOperand(m[1], vars),
1708
1709
  data: asRow(vars[m[2]]),
1709
1710
  mode: "merge"
1710
1711
  };
@@ -1719,54 +1720,132 @@ function translateStatement(stmt, vars) {
1719
1720
  mode: "replace"
1720
1721
  };
1721
1722
  }
1722
- m = /^UPDATE\s+\$(\w+)\s+SET\s+(.+?)(\s+RETURN\s+NONE)?$/i.exec(s);
1723
+ m = idRe(String.raw`^UPDATE\s+%ID%\s+SET\s+(.+?)(\s+RETURN\s+NONE)?$`).exec(s);
1723
1724
  if (m) return {
1724
1725
  kind: "updateSet",
1725
- id: rid(vars[m[1]]),
1726
+ id: idOperand(m[1], vars),
1726
1727
  sets: parseSetClauses(m[2], vars),
1727
1728
  returnNone: !!m[3]
1728
1729
  };
1729
- m = /^DELETE\s+\$(\w+)$/i.exec(s);
1730
- if (m) return {
1731
- kind: "delete",
1732
- id: rid(vars[m[1]])
1733
- };
1734
1730
  m = /^DELETE\s+([A-Za-z_]\w*)$/i.exec(s);
1735
1731
  if (m) return {
1736
1732
  kind: "deleteAll",
1737
1733
  table: m[1]
1738
1734
  };
1739
- m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+ONLY\s+\$(\w+)$/i.exec(s);
1735
+ m = idRe(String.raw`^DELETE\s+%ID%$`).exec(s);
1736
+ if (m) return {
1737
+ kind: "delete",
1738
+ id: idOperand(m[1], vars)
1739
+ };
1740
+ const { head, limit, start } = peelWindow(s, vars);
1741
+ m = /^SELECT\s+count\(\)\s+FROM\s+([A-Za-z_]\w*)(?:\s+WHERE\s+(.+?))?\s+GROUP\s+ALL$/i.exec(head);
1742
+ if (m) return {
1743
+ kind: "count",
1744
+ table: m[1],
1745
+ where: m[2] ? parseWhere(m[2], vars) : void 0
1746
+ };
1747
+ m = idRe(String.raw`^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+ONLY\s+%ID%$`).exec(head);
1740
1748
  if (m) {
1741
1749
  const value = m[1] ? m[2].trim() : void 0;
1742
1750
  return {
1743
1751
  kind: "getById",
1744
- id: rid(vars[m[3]]),
1752
+ id: idOperand(m[3], vars),
1745
1753
  select: projFields(m[2], !!m[1]),
1746
1754
  value
1747
1755
  };
1748
1756
  }
1749
- m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(s);
1757
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(head);
1750
1758
  if (m) {
1751
1759
  const value = m[1] ? m[2].trim() : void 0;
1752
1760
  return {
1753
1761
  kind: "selectByIds",
1754
1762
  ids: vars[m[3]] ?? [],
1755
1763
  select: projFields(m[2], !!m[1]),
1756
- value
1764
+ value,
1765
+ limit,
1766
+ start
1757
1767
  };
1758
1768
  }
1759
- m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+([A-Za-z_]\w*)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(.+?))?$/i.exec(s);
1769
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+([A-Za-z_]\w*)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(.+?))?$/i.exec(head);
1760
1770
  if (m) return {
1761
1771
  kind: "selectTable",
1762
1772
  table: m[3],
1763
1773
  where: m[4] ? parseWhere(m[4], vars) : void 0,
1764
1774
  orderBy: m[5] ? parseOrderBy(m[5]) : void 0,
1765
1775
  select: projFields(m[2], !!m[1]),
1766
- value: m[1] ? m[2].trim() : void 0
1776
+ value: m[1] ? m[2].trim() : void 0,
1777
+ limit,
1778
+ start
1767
1779
  };
1768
1780
  throw new Error(`SqliteCacheEngine: unsupported SurrealQL for translation: ${stmt}`);
1769
1781
  }
1782
+ /**
1783
+ * Split a SELECT's trailing `LIMIT n [START m]` off its head.
1784
+ *
1785
+ * Peeled rather than folded into the SELECT regexes because those match WHERE
1786
+ * and ORDER BY lazily: with the window left in place the WHERE group happily
1787
+ * expands over `… LIMIT 20`, which is how `SELECT * FROM t LIMIT 20 START 0`
1788
+ * ended up unmatched entirely.
1789
+ *
1790
+ * A tail sitting inside a string literal (`WHERE note = 'LIMIT 5'`) is left
1791
+ * alone — an odd quote count in the head means the match is inside a string.
1792
+ */
1793
+ function peelWindow(stmt, vars) {
1794
+ let head = stmt;
1795
+ let limit;
1796
+ let start;
1797
+ const peel = (re) => {
1798
+ const m = re.exec(head);
1799
+ if (!m) return void 0;
1800
+ const before = head.slice(0, m.index);
1801
+ if (countQuotes(before) % 2 !== 0) return void 0;
1802
+ head = before;
1803
+ return m[1];
1804
+ };
1805
+ const startTok = peel(/\s+START(?:\s+AT)?\s+(\$?\w+)\s*$/i);
1806
+ const limitTok = peel(/\s+LIMIT(?:\s+BY)?\s+(\$?\w+)\s*$/i);
1807
+ if (startTok !== void 0) start = numOperand(startTok, vars);
1808
+ if (limitTok !== void 0) limit = numOperand(limitTok, vars);
1809
+ return {
1810
+ head,
1811
+ limit,
1812
+ start
1813
+ };
1814
+ }
1815
+ function countQuotes(s) {
1816
+ let n = 0;
1817
+ for (let i = 0; i < s.length; i++) if (s[i] === "'" && s[i - 1] !== "\\") n++;
1818
+ return n;
1819
+ }
1820
+ /** A `LIMIT`/`START` operand: a literal integer or a `$var` holding one. */
1821
+ function numOperand(token, vars) {
1822
+ const raw = token.startsWith("$") ? vars[token.slice(1)] : token;
1823
+ const n = Number(raw);
1824
+ return Number.isFinite(n) ? n : void 0;
1825
+ }
1826
+ /**
1827
+ * A single-record operand: `$var` or a LITERAL `table:id`.
1828
+ *
1829
+ * A bare identifier deliberately does NOT match: in SurrealQL that is a table,
1830
+ * and `UPDATE game MERGE $x` rewrites every row of `game` — a shape this
1831
+ * vocabulary does not implement. Not matching means such a statement reaches
1832
+ * the "unsupported SurrealQL" throw instead of quietly writing one row called
1833
+ * `game`.
1834
+ */
1835
+ const ID_TOKEN = String.raw`(\$\w+|[A-Za-z_]\w*:\S+)`;
1836
+ /** Build a statement regex, `%ID%` expanding to {@link ID_TOKEN}. */
1837
+ function idRe(pattern) {
1838
+ return new RegExp(pattern.replace("%ID%", ID_TOKEN), "i");
1839
+ }
1840
+ /**
1841
+ * Resolve an {@link ID_TOKEN} match. Literals matter because the DevTools row
1842
+ * editor inlines the id (`UPDATE game:abc MERGE $updates`) rather than binding
1843
+ * it; `stableKey` treats that string and a `RecordId` identically downstream.
1844
+ */
1845
+ function idOperand(token, vars) {
1846
+ const t = token.trim();
1847
+ return t.startsWith("$") ? rid(vars[t.slice(1)]) : t;
1848
+ }
1770
1849
  function projFields(proj, isValue) {
1771
1850
  const p = proj.trim();
1772
1851
  if (isValue) return void 0;
@@ -2680,14 +2759,44 @@ var SqliteCacheEngine = class {
2680
2759
  select: op.select,
2681
2760
  orderBy: op.orderBy
2682
2761
  });
2762
+ if (op.start !== void 0 || op.limit !== void 0) {
2763
+ const from = op.start ?? 0;
2764
+ rows = rows.slice(from, op.limit === void 0 ? void 0 : from + op.limit);
2765
+ }
2683
2766
  if (op.value) return rows.map((r) => r[op.value]);
2684
2767
  return rows;
2685
2768
  }
2686
2769
  case "selectTable": {
2687
- const rows = await this.rawSelectTable(op.table, op.where, op.orderBy);
2770
+ const rows = await this.rawSelectTable(op.table, op.where, op.orderBy, {
2771
+ limit: op.limit,
2772
+ start: op.start
2773
+ });
2688
2774
  if (op.value) return rows.map((r) => r[op.value]);
2689
2775
  return op.select ? rows.map((r) => project(r, op.select)) : rows;
2690
2776
  }
2777
+ case "count": {
2778
+ await this.ensureTable(op.table);
2779
+ const bind = [];
2780
+ let sql = `SELECT COUNT(*) AS n FROM "${op.table}"`;
2781
+ if (op.where && op.where.length > 0) sql += ` WHERE ${renderWhereSql(op.where, bind, {})}`;
2782
+ const { rows } = await this.call("exec", {
2783
+ sql,
2784
+ bind
2785
+ });
2786
+ return [{ count: rows?.[0]?.n ?? 0 }];
2787
+ }
2788
+ case "infoForDb": {
2789
+ const { rows } = await this.call("exec", { sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" });
2790
+ const tables = {};
2791
+ for (const r of rows ?? []) tables[r.name] = `DEFINE TABLE ${r.name} SCHEMALESS`;
2792
+ return {
2793
+ tables,
2794
+ analyzers: {},
2795
+ functions: {},
2796
+ params: {},
2797
+ users: {}
2798
+ };
2799
+ }
2691
2800
  case "upsert":
2692
2801
  await this.upsert(tableOf(op.id), op.id, op.data, op.mode);
2693
2802
  return pureWriteOpResult(op);
@@ -2774,12 +2883,15 @@ var SqliteCacheEngine = class {
2774
2883
  }
2775
2884
  if (stmts.length > 0) await this.call("batch", stmts);
2776
2885
  }
2777
- async rawSelectTable(table, where, orderBy) {
2886
+ async rawSelectTable(table, where, orderBy, window) {
2778
2887
  await this.ensureTable(table);
2779
2888
  const bind = [];
2780
2889
  let sql = `SELECT data FROM "${table}"`;
2781
2890
  if (where && where.length > 0) sql += ` WHERE ${renderWhereSql(where, bind, {})}`;
2782
2891
  if (orderBy && orderBy.length > 0) sql += renderOrderSql(orderBy);
2892
+ if (window?.limit !== void 0) sql += ` LIMIT ${Number(window.limit)}`;
2893
+ else if (window?.start !== void 0) sql += " LIMIT -1";
2894
+ if (window?.start !== void 0) sql += ` OFFSET ${Number(window.start)}`;
2783
2895
  return this.execRows(sql, bind);
2784
2896
  }
2785
2897
  async transaction(fn) {
@@ -6754,8 +6866,8 @@ function selfAllowlistedVariant(flag, userId) {
6754
6866
 
6755
6867
  //#endregion
6756
6868
  //#region src/modules/devtools/index.ts
6757
- const CORE_VERSION = "0.0.1-canary.172";
6758
- const WASM_VERSION = "0.0.1-canary.172";
6869
+ const CORE_VERSION = "0.0.1-canary.174";
6870
+ const WASM_VERSION = "0.0.1-canary.174";
6759
6871
  const SURREAL_VERSION = "3.0.3";
6760
6872
  var DevToolsService = class DevToolsService {
6761
6873
  eventsHistory = [];
@@ -6980,6 +7092,7 @@ var DevToolsService = class DevToolsService {
6980
7092
  database: {
6981
7093
  tables: this.localTables.length ? this.localTables : this.schema.tables.map((t) => t.name),
6982
7094
  tableData: {},
7095
+ engine: this.databaseService.engineKind ?? "custom",
6983
7096
  storage: this.databaseService.storageHealth ?? {
6984
7097
  status: "unknown",
6985
7098
  fallback: false
@@ -11185,7 +11298,7 @@ var Sp00kyClient = class {
11185
11298
  return new TabsCoordinator({
11186
11299
  tabId,
11187
11300
  fingerprint: computeTabsFingerprint({
11188
- coreVersion: "0.0.1-canary.172",
11301
+ coreVersion: "0.0.1-canary.174",
11189
11302
  schemaHash: hash53(this.config.schemaSurql),
11190
11303
  endpoint: this.config.database.endpoint ?? "",
11191
11304
  namespace: this.config.database.namespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.172",
3
+ "version": "0.0.1-canary.174",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.172",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.172",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.174",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.174",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
@@ -370,6 +370,10 @@ export class DevToolsService implements StreamUpdateReceiver {
370
370
  ? this.localTables
371
371
  : this.schema.tables.map((t) => t.name),
372
372
  tableData: {},
373
+ // Which backend answers "Local". The Database explorer labels its source
374
+ // picker with it and explains translation failures against `sqlite`,
375
+ // whose SurrealQL vocabulary is a bounded subset.
376
+ engine: this.databaseService.engineKind ?? 'custom',
373
377
  // Durability of the local store. `fallback: true` means persistence was
374
378
  // requested but the dataset is actually sitting in RAM.
375
379
  storage: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
@@ -906,14 +906,44 @@ export class SqliteCacheEngine implements LocalStore {
906
906
  select: op.select,
907
907
  orderBy: op.orderBy,
908
908
  });
909
+ // Windowed in JS, not SQL: the id list is already the whole result set,
910
+ // and its order is restored after the fetch (see selectByIds).
911
+ if (op.start !== undefined || op.limit !== undefined) {
912
+ const from = op.start ?? 0;
913
+ rows = rows.slice(from, op.limit === undefined ? undefined : from + op.limit);
914
+ }
909
915
  if (op.value) return rows.map((r) => r[op.value!]);
910
916
  return rows;
911
917
  }
912
918
  case 'selectTable': {
913
- const rows = await this.rawSelectTable(op.table, op.where, op.orderBy);
919
+ const rows = await this.rawSelectTable(op.table, op.where, op.orderBy, {
920
+ limit: op.limit,
921
+ start: op.start,
922
+ });
914
923
  if (op.value) return rows.map((r) => r[op.value!]);
915
924
  return op.select ? rows.map((r) => project(r, op.select!)) : rows;
916
925
  }
926
+ case 'count': {
927
+ await this.ensureTable(op.table);
928
+ const bind: unknown[] = [];
929
+ let sql = `SELECT COUNT(*) AS n FROM "${op.table}"`;
930
+ if (op.where && op.where.length > 0) sql += ` WHERE ${renderWhereSql(op.where, bind, {})}`;
931
+ const { rows } = await this.call<{ rows: { n: number }[] }>('exec', { sql, bind });
932
+ // `GROUP ALL` collapses to a single `{ count }` row on SurrealDB; match
933
+ // it exactly so callers can read `rows[0].count` on either engine.
934
+ return [{ count: rows?.[0]?.n ?? 0 }];
935
+ }
936
+ case 'infoForDb': {
937
+ const { rows } = await this.call<{ rows: { name: string }[] }>('exec', {
938
+ sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
939
+ });
940
+ // SurrealDB answers with the DEFINE statement per table. Nothing here
941
+ // parses it (the DevTools explorer only reads the keys), so a truthful
942
+ // schemaless stand-in beats inventing field definitions.
943
+ const tables: Record<string, string> = {};
944
+ for (const r of rows ?? []) tables[r.name] = `DEFINE TABLE ${r.name} SCHEMALESS`;
945
+ return { tables, analyzers: {}, functions: {}, params: {}, users: {} };
946
+ }
917
947
  case 'upsert':
918
948
  await this.upsert(tableOf(op.id), op.id, op.data, op.mode);
919
949
  // Cheap return — no read-back. The full merged row is only needed by a
@@ -1018,13 +1048,19 @@ export class SqliteCacheEngine implements LocalStore {
1018
1048
  private async rawSelectTable(
1019
1049
  table: string,
1020
1050
  where?: WhereNode[],
1021
- orderBy?: OrderBy
1051
+ orderBy?: OrderBy,
1052
+ window?: { limit?: number; start?: number }
1022
1053
  ): Promise<Row[]> {
1023
1054
  await this.ensureTable(table);
1024
1055
  const bind: unknown[] = [];
1025
1056
  let sql = `SELECT data FROM "${table}"`;
1026
1057
  if (where && where.length > 0) sql += ` WHERE ${renderWhereSql(where, bind, {})}`;
1027
1058
  if (orderBy && orderBy.length > 0) sql += renderOrderSql(orderBy);
1059
+ // SQLite has no bare OFFSET, so a START without a LIMIT needs `LIMIT -1`
1060
+ // (its documented "no limit" sentinel) to stay valid SQL.
1061
+ if (window?.limit !== undefined) sql += ` LIMIT ${Number(window.limit)}`;
1062
+ else if (window?.start !== undefined) sql += ' LIMIT -1';
1063
+ if (window?.start !== undefined) sql += ` OFFSET ${Number(window.start)}`;
1028
1064
  return this.execRows(sql, bind);
1029
1065
  }
1030
1066
 
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
3
+ import { SqliteCacheEngine } from './sqlite-cache-engine';
4
+ import { stubTransport } from './sqlite-transport.fixture';
5
+ import { serializeRow } from './sqlite-plan-sql';
6
+ import type { Row } from './cache-engine';
7
+
8
+ /**
9
+ * Integration: the DevTools Database explorer's statements run end-to-end
10
+ * (translate → engine SQL → REAL in-memory SQLite, the same
11
+ * @sqlite.org/sqlite-wasm build the worker loads). The unit test asserts the
12
+ * translation; this asserts the SQL actually executes and returns the right
13
+ * rows — the paging window in particular is easy to render into invalid SQLite
14
+ * (there is no bare OFFSET).
15
+ */
16
+
17
+ let db: any;
18
+
19
+ function makeLogger(): any {
20
+ const noop = () => {};
21
+ const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
22
+ l.child = () => l;
23
+ return l;
24
+ }
25
+
26
+ /** An engine whose transport is a real SQLite database. */
27
+ function realEngine(): SqliteCacheEngine {
28
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
29
+ stubTransport(engine, (type, payload: any) => {
30
+ switch (type) {
31
+ case 'open':
32
+ for (const t of payload.systemTables ?? []) {
33
+ db.exec({
34
+ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
35
+ });
36
+ }
37
+ return { persisted: true };
38
+ case 'exec':
39
+ return {
40
+ rows: db.exec({
41
+ sql: payload.sql,
42
+ bind: payload.bind,
43
+ rowMode: 'object',
44
+ returnValue: 'resultRows',
45
+ }),
46
+ };
47
+ case 'run':
48
+ db.exec({ sql: payload.sql, bind: payload.bind });
49
+ return {};
50
+ case 'batch':
51
+ for (const stmt of payload as { sql: string; bind?: unknown[] }[]) {
52
+ db.exec({ sql: stmt.sql, bind: stmt.bind });
53
+ }
54
+ return {};
55
+ default:
56
+ return {};
57
+ }
58
+ });
59
+ return engine;
60
+ }
61
+
62
+ function seed(table: string, rows: Row[]): void {
63
+ db.exec({
64
+ sql: `CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
65
+ });
66
+ for (const row of rows) {
67
+ db.exec({
68
+ sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?)`,
69
+ bind: [row.id, serializeRow(row)],
70
+ });
71
+ }
72
+ }
73
+
74
+ beforeAll(async () => {
75
+ const sqlite3: any = await sqlite3InitModule();
76
+ db = new sqlite3.oo1.DB(':memory:', 'c');
77
+ seed(
78
+ 'game_insight',
79
+ Array.from({ length: 5 }, (_, i) => ({
80
+ id: `game_insight:${i}`,
81
+ n: i,
82
+ tag: i % 2 ? 'odd' : 'even',
83
+ }))
84
+ );
85
+ });
86
+
87
+ describe('DevTools explorer against real SQLite', () => {
88
+ it('pages a table with LIMIT/START', async () => {
89
+ const engine = realEngine();
90
+ await engine.connect('anon');
91
+
92
+ const [page1] = await engine.query<[Row[]]>('SELECT * FROM game_insight LIMIT 2 START 0');
93
+ const [page2] = await engine.query<[Row[]]>('SELECT * FROM game_insight LIMIT 2 START 2');
94
+ const [tail] = await engine.query<[Row[]]>('SELECT * FROM game_insight LIMIT 20 START 4');
95
+
96
+ expect(page1.map((r) => r.id)).toEqual(['game_insight:0', 'game_insight:1']);
97
+ expect(page2.map((r) => r.id)).toEqual(['game_insight:2', 'game_insight:3']);
98
+ expect(tail.map((r) => r.id)).toEqual(['game_insight:4']);
99
+ });
100
+
101
+ it('counts rows, with and without a filter', async () => {
102
+ const engine = realEngine();
103
+ await engine.connect('anon');
104
+
105
+ const [all] = await engine.query<[{ count: number }[]]>(
106
+ 'SELECT count() FROM game_insight GROUP ALL'
107
+ );
108
+ const [odd] = await engine.query<[{ count: number }[]]>(
109
+ "SELECT count() FROM game_insight WHERE tag = 'odd' GROUP ALL"
110
+ );
111
+
112
+ expect(all).toEqual([{ count: 5 }]);
113
+ expect(odd).toEqual([{ count: 2 }]);
114
+ });
115
+
116
+ it('lists tables via INFO FOR DB', async () => {
117
+ const engine = realEngine();
118
+ await engine.connect('anon');
119
+
120
+ const [info] = await engine.query<[{ tables: Record<string, string> }]>('INFO FOR DB');
121
+
122
+ expect(Object.keys(info.tables)).toContain('game_insight');
123
+ // Seeded system tables show up too — the panel's "internal" toggle filters
124
+ // them, the engine must not pre-filter.
125
+ expect(Object.keys(info.tables)).toContain('_00_query');
126
+ expect(Object.keys(info.tables).some((t) => t.startsWith('sqlite_'))).toBe(false);
127
+ });
128
+
129
+ it('edits and deletes a row by literal record id', async () => {
130
+ const engine = realEngine();
131
+ await engine.connect('anon');
132
+
133
+ await engine.query('UPDATE game_insight:1 MERGE $updates', { updates: { tag: 'edited' } });
134
+ const [edited] = await engine.query<[Row[]]>('SELECT * FROM game_insight LIMIT 20 START 1');
135
+ expect(edited[0]).toMatchObject({ id: 'game_insight:1', n: 1, tag: 'edited' });
136
+
137
+ await engine.query('DELETE game_insight:1');
138
+ const [after] = await engine.query<[{ count: number }[]]>(
139
+ 'SELECT count() FROM game_insight GROUP ALL'
140
+ );
141
+ expect(after).toEqual([{ count: 4 }]);
142
+ });
143
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SqliteCacheEngine } from './sqlite-cache-engine';
3
+ import { stubTransport } from './sqlite-transport.fixture';
4
+ import { translateSurql } from './surql-translate';
5
+
6
+ /**
7
+ * The statements the DevTools Database explorer emits against the LOCAL store.
8
+ * None of them come from `surql`, so they only ever exercised the SurrealDB
9
+ * engine: on SQLite the paging read threw "unsupported SurrealQL for
10
+ * translation: SELECT * FROM game_insight LIMIT 20 START 0", the table list came
11
+ * back empty (`INFO FOR DB` lowered to a noop) and row edit/delete never
12
+ * matched (they inline a literal record id).
13
+ */
14
+
15
+ function makeLogger(): any {
16
+ const noop = () => {};
17
+ const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
18
+ l.child = () => l;
19
+ return l;
20
+ }
21
+
22
+ /** Records every exec/run SQL so the paging window can be asserted. */
23
+ function recordingEngine(rows: Record<string, unknown[]> = {}) {
24
+ const sql: string[] = [];
25
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
26
+ stubTransport(engine, (type, payload: any) => {
27
+ if (type === 'open') return { persisted: true };
28
+ if (type === 'exec' || type === 'run') {
29
+ sql.push(payload.sql);
30
+ for (const [needle, out] of Object.entries(rows)) {
31
+ if (payload.sql.includes(needle)) return { rows: out };
32
+ }
33
+ return { rows: [] };
34
+ }
35
+ return {};
36
+ });
37
+ return { engine, sql };
38
+ }
39
+
40
+ describe('DevTools table paging (LIMIT/START)', () => {
41
+ it('translates the window instead of throwing', () => {
42
+ const { ops } = translateSurql('SELECT * FROM game_insight LIMIT 20 START 0', {});
43
+ expect(ops).toEqual([
44
+ {
45
+ kind: 'selectTable',
46
+ table: 'game_insight',
47
+ where: undefined,
48
+ orderBy: undefined,
49
+ select: undefined,
50
+ value: undefined,
51
+ limit: 20,
52
+ start: 0,
53
+ },
54
+ ]);
55
+ });
56
+
57
+ it('renders LIMIT/OFFSET in SQL, page 3 of 20', async () => {
58
+ const { engine, sql } = recordingEngine();
59
+ await engine.connect('anon');
60
+ await engine.query('SELECT * FROM game_insight LIMIT 20 START 40');
61
+ expect(sql.some((s) => /SELECT data FROM "game_insight" LIMIT 20 OFFSET 40$/.test(s))).toBe(
62
+ true
63
+ );
64
+ });
65
+
66
+ it('keeps WHERE and ORDER BY alongside the window', () => {
67
+ const { ops } = translateSurql(
68
+ 'SELECT * FROM game WHERE done = true ORDER BY date desc LIMIT 10 START 30',
69
+ {}
70
+ );
71
+ expect(ops[0]).toMatchObject({
72
+ kind: 'selectTable',
73
+ table: 'game',
74
+ where: [{ field: 'done', op: '=', value: true }],
75
+ orderBy: [['date', 'desc']],
76
+ limit: 10,
77
+ start: 30,
78
+ });
79
+ });
80
+
81
+ it('leaves a LIMIT that is part of a string literal alone', () => {
82
+ const { ops } = translateSurql("SELECT * FROM game WHERE note = 'LIMIT 5'", {});
83
+ expect(ops[0]).toMatchObject({
84
+ kind: 'selectTable',
85
+ where: [{ field: 'note', op: '=', value: 'LIMIT 5' }],
86
+ limit: undefined,
87
+ start: undefined,
88
+ });
89
+ });
90
+
91
+ it('a START with no LIMIT still renders valid SQLite', async () => {
92
+ const { engine, sql } = recordingEngine();
93
+ await engine.connect('anon');
94
+ await engine.query('SELECT * FROM game START 5');
95
+ // SQLite has no bare OFFSET — `LIMIT -1` is its "everything" sentinel.
96
+ expect(sql.some((s) => s.endsWith('LIMIT -1 OFFSET 5'))).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe('DevTools row count', () => {
101
+ it("answers `SELECT count() FROM t GROUP ALL` in SurrealDB's shape", async () => {
102
+ const { engine } = recordingEngine({ 'COUNT(*)': [{ n: 137 }] });
103
+ await engine.connect('anon');
104
+ const res = await engine.query<[{ count: number }[]]>(
105
+ 'SELECT count() FROM game_insight GROUP ALL'
106
+ );
107
+ expect(res[0]).toEqual([{ count: 137 }]);
108
+ });
109
+
110
+ it('counts zero rows as 0, not undefined', async () => {
111
+ const { engine } = recordingEngine();
112
+ await engine.connect('anon');
113
+ const res = await engine.query<[{ count: number }[]]>('SELECT count() FROM empty GROUP ALL');
114
+ expect(res[0]).toEqual([{ count: 0 }]);
115
+ });
116
+ });
117
+
118
+ describe('DevTools table list', () => {
119
+ it('answers INFO FOR DB from sqlite_master, minus SQLite internals', async () => {
120
+ const { engine } = recordingEngine({
121
+ sqlite_master: [{ name: 'game' }, { name: '_00_query' }],
122
+ });
123
+ await engine.connect('anon');
124
+ const [info] = await engine.query<[{ tables: Record<string, string> }]>('INFO FOR DB');
125
+ expect(Object.keys(info.tables)).toEqual(['game', '_00_query']);
126
+ });
127
+ });
128
+
129
+ describe('DevTools row edit / delete (literal record ids)', () => {
130
+ it('UPDATE <table>:<id> MERGE $updates writes that row', async () => {
131
+ const { engine, sql } = recordingEngine();
132
+ await engine.connect('anon');
133
+ await engine.query('UPDATE game:abc MERGE $updates', { updates: { white: 'hikaru' } });
134
+ const write = sql.find((s) => s.startsWith('INSERT INTO "game"'));
135
+ expect(write).toBeDefined();
136
+ expect(write).toContain('json_patch');
137
+ });
138
+
139
+ it('DELETE <table>:<id> deletes one row, DELETE <table> still clears the table', () => {
140
+ expect(translateSurql('DELETE game:abc', {}).ops[0]).toEqual({
141
+ kind: 'delete',
142
+ id: 'game:abc',
143
+ });
144
+ expect(translateSurql('DELETE game', {}).ops[0]).toEqual({ kind: 'deleteAll', table: 'game' });
145
+ });
146
+
147
+ it('refuses a table-wide MERGE rather than writing a row named after the table', () => {
148
+ // `UPDATE game MERGE $x` means every row of `game` in SurrealQL. Unsupported
149
+ // here — and it must SAY so, not silently create `game:undefined`.
150
+ expect(() => translateSurql('UPDATE game MERGE $x', { x: {} })).toThrow(
151
+ /unsupported SurrealQL/
152
+ );
153
+ });
154
+ });
@@ -18,8 +18,29 @@ import { stableKey } from './relation-resolver';
18
18
 
19
19
  export type SqlOp =
20
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 }
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' }
23
44
  | { kind: 'upsert'; id: unknown; data: Row; mode: 'replace' | 'merge' }
24
45
  | { kind: 'updateSet'; id: unknown; sets: SetClause[]; returnNone: boolean }
25
46
  | { kind: 'delete'; id: unknown }
@@ -95,6 +116,12 @@ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp
95
116
  return { kind: 'return', entries };
96
117
  }
97
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
+
98
125
  // ---- schema / session DDL: no-ops on a schemaless engine --------------
99
126
  // SQLite creates tables lazily and has no namespaces/DB DDL, so DEFINE /
100
127
  // REMOVE / USE / INFO / RETURN statements are safely ignored.
@@ -110,11 +137,13 @@ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp
110
137
  return { kind: 'upsert', id: rid(vars[m[1]]), data: asRow(vars[m[2]]), mode: 'replace' };
111
138
  }
112
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.
113
142
  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);
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);
116
145
  if (m) {
117
- return { kind: 'upsert', id: rid(vars[m[1]]), data: asRow(vars[m[2]]), mode: 'merge' };
146
+ return { kind: 'upsert', id: idOperand(m[1], vars), data: asRow(vars[m[2]]), mode: 'merge' };
118
147
  }
119
148
 
120
149
  // CREATE ONLY $id SET a = ..., b = ... (createSet / createMutation)
@@ -126,32 +155,46 @@ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp
126
155
  }
127
156
 
128
157
  // 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);
158
+ m = idRe(String.raw`^UPDATE\s+%ID%\s+SET\s+(.+?)(\s+RETURN\s+NONE)?$`).exec(s);
130
159
  if (m) {
131
160
  return {
132
161
  kind: 'updateSet',
133
- id: rid(vars[m[1]]),
162
+ id: idOperand(m[1], vars),
134
163
  sets: parseSetClauses(m[2], vars),
135
164
  returnNone: !!m[3],
136
165
  };
137
166
  }
138
167
 
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>
168
+ m = /^DELETE\s+([A-Za-z_]\w*)$/i.exec(s); // DELETE <table> (no `:` — a table)
143
169
  if (m) return { kind: 'deleteAll', table: m[1] };
144
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
+
145
175
  // ---- reads ------------------------------------------------------------
146
- // SELECT [VALUE] <proj> FROM ONLY $id
147
- m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+ONLY\s+\$(\w+)$/i.exec(s);
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);
148
191
  if (m) {
149
192
  const value = m[1] ? m[2].trim() : undefined;
150
- return { kind: 'getById', id: rid(vars[m[3]]), select: projFields(m[2], !!m[1]), value };
193
+ return { kind: 'getById', id: idOperand(m[3], vars), select: projFields(m[2], !!m[1]), value };
151
194
  }
152
195
 
153
196
  // SELECT [VALUE] <proj> FROM $arrayParam
154
- m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(s);
197
+ m = /^SELECT\s+(VALUE\s+)?(.+?)\s+FROM\s+\$(\w+)$/i.exec(head);
155
198
  if (m) {
156
199
  const value = m[1] ? m[2].trim() : undefined;
157
200
  return {
@@ -159,13 +202,16 @@ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp
159
202
  ids: (vars[m[3]] as unknown[]) ?? [],
160
203
  select: projFields(m[2], !!m[1]),
161
204
  value,
205
+ limit,
206
+ start,
162
207
  };
163
208
  }
164
209
 
165
210
  // 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
- );
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
+ );
169
215
  if (m) {
170
216
  return {
171
217
  kind: 'selectTable',
@@ -174,6 +220,8 @@ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp
174
220
  orderBy: m[5] ? parseOrderBy(m[5]) : undefined,
175
221
  select: projFields(m[2], !!m[1]),
176
222
  value: m[1] ? m[2].trim() : undefined,
223
+ limit,
224
+ start,
177
225
  };
178
226
  }
179
227
 
@@ -182,6 +230,83 @@ function translateStatement(stmt: string, vars: Record<string, unknown>): SqlOp
182
230
 
183
231
  // ==================== clause parsers ====================
184
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
+
185
310
  function projFields(proj: string, isValue: boolean): string[] | undefined {
186
311
  const p = proj.trim();
187
312
  if (isValue) return undefined; // VALUE returns a scalar, no projection object