@spooky-sync/core 0.0.1-canary.171 → 0.0.1-canary.173
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.d.ts +2 -2
- package/dist/index.js +165 -27
- package/dist/types.d.ts +23 -0
- package/package.json +3 -3
- package/src/modules/data/data.membership.test.ts +67 -0
- package/src/modules/data/index.ts +57 -2
- package/src/modules/devtools/flags.ts +2 -2
- package/src/modules/devtools/index.ts +7 -3
- package/src/services/database/sqlite-cache-engine.ts +38 -2
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/surql-translate.ts +143 -18
- package/src/sp00ky.ts +3 -3
- package/src/types.ts +23 -0
package/dist/index.d.ts
CHANGED
|
@@ -2157,10 +2157,10 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
2157
2157
|
* Nothing is sent to the server — the `_00_user_feature` assignment is
|
|
2158
2158
|
* untouched, so clearing restores whatever the server says. Persisted to
|
|
2159
2159
|
* localStorage, survives reloads, and applies while signed out. Backs the
|
|
2160
|
-
* DevTools
|
|
2160
|
+
* DevTools Access tab, and is a convenient hook for tests.
|
|
2161
2161
|
*
|
|
2162
2162
|
* To change a flag for OTHER users you need admin rights (`spky admin add`)
|
|
2163
|
-
* and the DevTools
|
|
2163
|
+
* and the DevTools Access tab, or `spky flag`.
|
|
2164
2164
|
*/
|
|
2165
2165
|
setFeatureOverride(key: string, variant: string | null, payload?: unknown): void;
|
|
2166
2166
|
/** Drop every local feature flag override set via `setFeatureOverride`. */
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
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(
|
|
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(
|
|
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) {
|
|
@@ -3069,6 +3181,13 @@ function mutationOwnerTabId(mutationId) {
|
|
|
3069
3181
|
|
|
3070
3182
|
//#endregion
|
|
3071
3183
|
//#region src/modules/data/index.ts
|
|
3184
|
+
/**
|
|
3185
|
+
* How many consecutive empty `_00_list_ref` reads it takes to believe a query
|
|
3186
|
+
* really is empty, before any non-empty set has been seen this session. One
|
|
3187
|
+
* read is the registration race (the SSP flushes a view's initial edges
|
|
3188
|
+
* asynchronously); the second comes from the poll a cycle later.
|
|
3189
|
+
*/
|
|
3190
|
+
const EMPTY_MEMBERSHIP_CONFIRMATIONS = 2;
|
|
3072
3191
|
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
3073
3192
|
function pushSample(samples, ms) {
|
|
3074
3193
|
samples.push(ms);
|
|
@@ -3899,9 +4018,25 @@ var DataModule = class {
|
|
|
3899
4018
|
}, "Query to update remote array not found");
|
|
3900
4019
|
return;
|
|
3901
4020
|
}
|
|
4021
|
+
if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
|
|
4022
|
+
const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
|
|
4023
|
+
queryState.config.emptyReads = emptyReads;
|
|
4024
|
+
if (emptyReads < EMPTY_MEMBERSHIP_CONFIRMATIONS) {
|
|
4025
|
+
this.logger.debug({
|
|
4026
|
+
hash,
|
|
4027
|
+
emptyReads,
|
|
4028
|
+
Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
|
|
4029
|
+
}, "Ignoring unconfirmed empty membership (server may not have flushed list_ref yet)");
|
|
4030
|
+
return;
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
3902
4033
|
const epoch = this.local.epoch;
|
|
3903
4034
|
queryState.config.remoteArray = remoteArray;
|
|
3904
4035
|
queryState.config.membershipKnown = true;
|
|
4036
|
+
if (remoteArray.length > 0) {
|
|
4037
|
+
queryState.config.remoteSeen = true;
|
|
4038
|
+
queryState.config.emptyReads = 0;
|
|
4039
|
+
}
|
|
3905
4040
|
if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
|
|
3906
4041
|
try {
|
|
3907
4042
|
await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
|
|
@@ -3951,9 +4086,11 @@ var DataModule = class {
|
|
|
3951
4086
|
config.remoteArray = [];
|
|
3952
4087
|
config.subqueryRemoteArray = void 0;
|
|
3953
4088
|
config.membershipKnown = false;
|
|
4089
|
+
config.remoteSeen = false;
|
|
4090
|
+
config.emptyReads = 0;
|
|
3954
4091
|
if (config.membershipKey) {
|
|
3955
4092
|
const durable = await this.getWindowMembership(config.membershipKey);
|
|
3956
|
-
if (durable) {
|
|
4093
|
+
if (durable?.length) {
|
|
3957
4094
|
config.remoteArray = durable;
|
|
3958
4095
|
config.membershipKnown = true;
|
|
3959
4096
|
}
|
|
@@ -4381,7 +4518,7 @@ var DataModule = class {
|
|
|
4381
4518
|
};
|
|
4382
4519
|
if (membershipKey && !config.remoteArray?.length) {
|
|
4383
4520
|
const durable = await this.getWindowMembership(membershipKey);
|
|
4384
|
-
if (durable) {
|
|
4521
|
+
if (durable?.length) {
|
|
4385
4522
|
config.remoteArray = durable;
|
|
4386
4523
|
config.membershipKnown = true;
|
|
4387
4524
|
}
|
|
@@ -6577,7 +6714,7 @@ var FlagsAdminService = class {
|
|
|
6577
6714
|
this.deps = deps;
|
|
6578
6715
|
}
|
|
6579
6716
|
/**
|
|
6580
|
-
* Everything the
|
|
6717
|
+
* Everything the Access tab renders, in one round trip per source.
|
|
6581
6718
|
*
|
|
6582
6719
|
* Each section fails independently: a remote read that throws downgrades to
|
|
6583
6720
|
* `isAdmin: false` plus an `error`, while local assignments and overrides
|
|
@@ -6609,7 +6746,7 @@ var FlagsAdminService = class {
|
|
|
6609
6746
|
try {
|
|
6610
6747
|
snapshot.isAdmin = statementRows(await this.deps.remote.query("SELECT VALUE id FROM _00_admin WHERE user = $auth.id LIMIT 1")).length > 0;
|
|
6611
6748
|
} catch (err) {
|
|
6612
|
-
snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the
|
|
6749
|
+
snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the Access tab, run \`spky migrate\` (or redeploy) to apply the internal schema.`;
|
|
6613
6750
|
return snapshot;
|
|
6614
6751
|
}
|
|
6615
6752
|
if (!snapshot.isAdmin) return snapshot;
|
|
@@ -6729,8 +6866,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
6729
6866
|
|
|
6730
6867
|
//#endregion
|
|
6731
6868
|
//#region src/modules/devtools/index.ts
|
|
6732
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
6733
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
6869
|
+
const CORE_VERSION = "0.0.1-canary.173";
|
|
6870
|
+
const WASM_VERSION = "0.0.1-canary.173";
|
|
6734
6871
|
const SURREAL_VERSION = "3.0.3";
|
|
6735
6872
|
var DevToolsService = class DevToolsService {
|
|
6736
6873
|
eventsHistory = [];
|
|
@@ -6955,6 +7092,7 @@ var DevToolsService = class DevToolsService {
|
|
|
6955
7092
|
database: {
|
|
6956
7093
|
tables: this.localTables.length ? this.localTables : this.schema.tables.map((t) => t.name),
|
|
6957
7094
|
tableData: {},
|
|
7095
|
+
engine: this.databaseService.engineKind ?? "custom",
|
|
6958
7096
|
storage: this.databaseService.storageHealth ?? {
|
|
6959
7097
|
status: "unknown",
|
|
6960
7098
|
fallback: false
|
|
@@ -7088,7 +7226,7 @@ var DevToolsService = class DevToolsService {
|
|
|
7088
7226
|
return data;
|
|
7089
7227
|
}
|
|
7090
7228
|
/**
|
|
7091
|
-
* Hand the FeatureFlagModule to the
|
|
7229
|
+
* Hand the FeatureFlagModule to the Access tab so it can read and write local
|
|
7092
7230
|
* overrides. Called from `Sp00kyClient` once both are constructed; until then
|
|
7093
7231
|
* the override methods are no-ops that report an empty map.
|
|
7094
7232
|
*/
|
|
@@ -11160,7 +11298,7 @@ var Sp00kyClient = class {
|
|
|
11160
11298
|
return new TabsCoordinator({
|
|
11161
11299
|
tabId,
|
|
11162
11300
|
fingerprint: computeTabsFingerprint({
|
|
11163
|
-
coreVersion: "0.0.1-canary.
|
|
11301
|
+
coreVersion: "0.0.1-canary.173",
|
|
11164
11302
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11165
11303
|
endpoint: this.config.database.endpoint ?? "",
|
|
11166
11304
|
namespace: this.config.database.namespace,
|
|
@@ -11506,10 +11644,10 @@ var Sp00kyClient = class {
|
|
|
11506
11644
|
* Nothing is sent to the server — the `_00_user_feature` assignment is
|
|
11507
11645
|
* untouched, so clearing restores whatever the server says. Persisted to
|
|
11508
11646
|
* localStorage, survives reloads, and applies while signed out. Backs the
|
|
11509
|
-
* DevTools
|
|
11647
|
+
* DevTools Access tab, and is a convenient hook for tests.
|
|
11510
11648
|
*
|
|
11511
11649
|
* To change a flag for OTHER users you need admin rights (`spky admin add`)
|
|
11512
|
-
* and the DevTools
|
|
11650
|
+
* and the DevTools Access tab, or `spky flag`.
|
|
11513
11651
|
*/
|
|
11514
11652
|
setFeatureOverride(key, variant, payload) {
|
|
11515
11653
|
this.featureFlags.setLocalOverride(key, variant, payload);
|
package/dist/types.d.ts
CHANGED
|
@@ -815,6 +815,29 @@ interface QueryConfig {
|
|
|
815
815
|
* `remoteArray.length === 0` check cannot tell those apart.
|
|
816
816
|
*/
|
|
817
817
|
membershipKnown?: boolean;
|
|
818
|
+
/**
|
|
819
|
+
* Whether a NON-EMPTY id-set has arrived from the server for this query in
|
|
820
|
+
* this session. Gates whether an empty read may be believed.
|
|
821
|
+
*
|
|
822
|
+
* The server publishes `_00_list_ref` asynchronously — the SSP queues a
|
|
823
|
+
* view's initial edges to a coalescing flusher and returns from
|
|
824
|
+
* `fn::query::register` before they land — so an empty read right after
|
|
825
|
+
* registration says nothing about the query being empty. Believing it (and
|
|
826
|
+
* mirroring it to the durable `_00_window` row) blanked lists and kept them
|
|
827
|
+
* blank across reloads. Once a real set has been seen, a later empty one is a
|
|
828
|
+
* genuine transition and must be honoured, or removed rows resurrect.
|
|
829
|
+
*
|
|
830
|
+
* In-memory only: a fresh session must re-earn the right to believe empties.
|
|
831
|
+
*/
|
|
832
|
+
remoteSeen?: boolean;
|
|
833
|
+
/**
|
|
834
|
+
* Consecutive empty id-sets read from the server while `remoteSeen` is still
|
|
835
|
+
* false. Bounds how long an unconfirmed empty may be ignored, so a window
|
|
836
|
+
* that genuinely emptied while this device was away is believed on the second
|
|
837
|
+
* read instead of rendering stale rows forever. Reset by any non-empty set.
|
|
838
|
+
* In-memory only.
|
|
839
|
+
*/
|
|
840
|
+
emptyReads?: number;
|
|
818
841
|
/**
|
|
819
842
|
* Key of this query's durable `_00_window` membership row: a hash of
|
|
820
843
|
* `{surql, params}` WITHOUT the `session::id()` salt that `id` carries, so it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.173",
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.173",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.173",
|
|
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",
|
|
@@ -280,6 +280,73 @@ describe('membership-authoritative rendering', () => {
|
|
|
280
280
|
expect(ids(fresh.records)).toEqual(['thread:a', 'thread:b', 'thread:c']);
|
|
281
281
|
});
|
|
282
282
|
|
|
283
|
+
it('ignores an empty id-set until a real one has been seen', async () => {
|
|
284
|
+
// The registration race: the SSP queues a view's initial edges to a
|
|
285
|
+
// coalescing flusher and returns from `fn::query::register` before they
|
|
286
|
+
// land, so this read says nothing about the query being empty. Believing
|
|
287
|
+
// it rendered a blank list AND persisted the blankness.
|
|
288
|
+
const { dm, state, local, hash } = setup({
|
|
289
|
+
membershipKey: 'stable-key',
|
|
290
|
+
remoteArray: [['thread:a', 1]],
|
|
291
|
+
membershipKnown: true,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
295
|
+
|
|
296
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
297
|
+
expect(local.windowRows.has('stable-key')).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('believes an empty id-set once a non-empty one has arrived', async () => {
|
|
301
|
+
// The genuine transition — the last row left the window. Honouring it is
|
|
302
|
+
// what stops a removed row resurrecting from the local body cache.
|
|
303
|
+
const { dm, state, local, hash } = setup({ membershipKey: 'stable-key' });
|
|
304
|
+
|
|
305
|
+
await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
|
|
306
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
307
|
+
|
|
308
|
+
expect(state.config.membershipKnown).toBe(true);
|
|
309
|
+
expect(state.config.remoteArray).toEqual([]);
|
|
310
|
+
expect(local.windowRows.get('stable-key')).toMatchObject({ ids: [] });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('believes a repeated empty id-set even with nothing seen this session', async () => {
|
|
314
|
+
// A window that really did empty while this device was away: the durable
|
|
315
|
+
// seed must not render forever, so the second read is taken at face value.
|
|
316
|
+
const { dm, state, hash } = setup({
|
|
317
|
+
membershipKey: 'stable-key',
|
|
318
|
+
remoteArray: [['thread:a', 1]],
|
|
319
|
+
membershipKnown: true,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
323
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
324
|
+
|
|
325
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
326
|
+
expect(state.config.remoteArray).toEqual([]);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('does not seed membership from an empty durable row', async () => {
|
|
330
|
+
// Self-heals devices poisoned before the guard existed: an empty durable
|
|
331
|
+
// row is indistinguishable from "never had membership", so it must fall
|
|
332
|
+
// back to the scan rather than paint an empty list.
|
|
333
|
+
const { dm, local } = setup({ membershipKey: 'stable-key' });
|
|
334
|
+
local.windowRows.set('stable-key', { ids: [] });
|
|
335
|
+
|
|
336
|
+
const fresh = await (dm as any).createNewQuery({
|
|
337
|
+
recordId: new RecordId('_00_query', 'h-poisoned'),
|
|
338
|
+
surql: 'SELECT * FROM thread WHERE done = false;',
|
|
339
|
+
params: {},
|
|
340
|
+
ttl: '10m',
|
|
341
|
+
tableName: 'thread',
|
|
342
|
+
plan,
|
|
343
|
+
membershipKey: 'stable-key',
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(fresh.config.membershipKnown).toBeFalsy();
|
|
347
|
+
expect(ids(fresh.records)).toEqual(['thread:a', 'thread:b', 'thread:c']);
|
|
348
|
+
});
|
|
349
|
+
|
|
283
350
|
it('derives the membership key without the session salt', async () => {
|
|
284
351
|
const { dm } = setup();
|
|
285
352
|
const key = () => (dm as any).calculateMembershipKey({ surql: 'X', params: {} });
|
|
@@ -52,6 +52,14 @@ import {
|
|
|
52
52
|
} from './window-query';
|
|
53
53
|
import { mintMutationId } from './mutation-id';
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* How many consecutive empty `_00_list_ref` reads it takes to believe a query
|
|
57
|
+
* really is empty, before any non-empty set has been seen this session. One
|
|
58
|
+
* read is the registration race (the SSP flushes a view's initial edges
|
|
59
|
+
* asynchronously); the second comes from the poll a cycle later.
|
|
60
|
+
*/
|
|
61
|
+
const EMPTY_MEMBERSHIP_CONFIRMATIONS = 2;
|
|
62
|
+
|
|
55
63
|
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
56
64
|
function pushSample(samples: number[], ms: number): void {
|
|
57
65
|
samples.push(ms);
|
|
@@ -1198,12 +1206,49 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1198
1206
|
);
|
|
1199
1207
|
return;
|
|
1200
1208
|
}
|
|
1209
|
+
// An empty id-set is only believable once a real one has arrived in this
|
|
1210
|
+
// session. `_00_list_ref` is published asynchronously — the SSP queues a
|
|
1211
|
+
// view's initial edges to a coalescing flusher and returns from
|
|
1212
|
+
// `fn::query::register` before they land — so an empty read right after
|
|
1213
|
+
// registration is routinely just "not flushed yet", for a query that has
|
|
1214
|
+
// rows. Taking it at face value latched `membershipKnown` on an empty set,
|
|
1215
|
+
// which renders nothing (no scan fallback), and mirrored `[]` into the
|
|
1216
|
+
// durable `_00_window` row, which kept the list blank across reloads.
|
|
1217
|
+
//
|
|
1218
|
+
// Ignoring it entirely (rather than storing `[]` with the latch withheld)
|
|
1219
|
+
// is deliberate: on a cold start `remoteArray` is seeded from the durable
|
|
1220
|
+
// row, and overwriting that with `[]` would blank the very rows the seed
|
|
1221
|
+
// exists to paint. The `_00_list_ref` poll re-reads within ~500ms and
|
|
1222
|
+
// delivers the real set.
|
|
1223
|
+
// Bounded, though: a query seeded from the durable row on a cold start has
|
|
1224
|
+
// `remoteSeen === false`, and if its window really did empty while this
|
|
1225
|
+
// device was away, the server will keep answering `[]`. Believe it on the
|
|
1226
|
+
// second such read — one poll cycle (~500ms) past the flush window — so
|
|
1227
|
+
// stale rows can't render forever.
|
|
1228
|
+
if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
|
|
1229
|
+
const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
|
|
1230
|
+
queryState.config.emptyReads = emptyReads;
|
|
1231
|
+
if (emptyReads < EMPTY_MEMBERSHIP_CONFIRMATIONS) {
|
|
1232
|
+
this.logger.debug(
|
|
1233
|
+
{ hash, emptyReads, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
|
|
1234
|
+
'Ignoring unconfirmed empty membership (server may not have flushed list_ref yet)'
|
|
1235
|
+
);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1201
1240
|
const epoch = this.local.epoch;
|
|
1202
1241
|
queryState.config.remoteArray = remoteArray;
|
|
1203
1242
|
// The single point where authoritative membership arrives (registration and
|
|
1204
1243
|
// the `_00_list_ref` poll both land here), so it is where "we now know the
|
|
1205
1244
|
// membership" is latched and where the durable mirror is written.
|
|
1206
1245
|
queryState.config.membershipKnown = true;
|
|
1246
|
+
if (remoteArray.length > 0) {
|
|
1247
|
+
// Earns the right to believe a later empty set — that transition is a
|
|
1248
|
+
// genuine removal and must be honoured, or removed rows resurrect.
|
|
1249
|
+
queryState.config.remoteSeen = true;
|
|
1250
|
+
queryState.config.emptyReads = 0;
|
|
1251
|
+
}
|
|
1207
1252
|
if (queryState.config.membershipKey) {
|
|
1208
1253
|
await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
|
|
1209
1254
|
}
|
|
@@ -1269,9 +1314,15 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1269
1314
|
// unknown so the first paint falls back to a scan instead of rendering an
|
|
1270
1315
|
// empty list until the server answers.
|
|
1271
1316
|
config.membershipKnown = false;
|
|
1317
|
+
// The new bucket's server sets have not been seen yet — an empty read
|
|
1318
|
+
// must be re-confirmed there too.
|
|
1319
|
+
config.remoteSeen = false;
|
|
1320
|
+
config.emptyReads = 0;
|
|
1272
1321
|
if (config.membershipKey) {
|
|
1273
1322
|
const durable = await this.getWindowMembership(config.membershipKey);
|
|
1274
|
-
|
|
1323
|
+
// Length-checked for the same reason as the cold-start read above: an
|
|
1324
|
+
// empty durable row is indistinguishable from "never had membership".
|
|
1325
|
+
if (durable?.length) {
|
|
1275
1326
|
config.remoteArray = durable;
|
|
1276
1327
|
config.membershipKnown = true;
|
|
1277
1328
|
}
|
|
@@ -1957,7 +2008,11 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1957
2008
|
// removed row reappearing after a reload, and it works with no network.
|
|
1958
2009
|
if (membershipKey && !config.remoteArray?.length) {
|
|
1959
2010
|
const durable = await this.getWindowMembership(membershipKey);
|
|
1960
|
-
|
|
2011
|
+
// `durable.length` on purpose: an empty durable row cannot be told apart
|
|
2012
|
+
// from one written before this device ever saw a real id-set, and treating
|
|
2013
|
+
// it as known means the first paint is empty with no scan fallback. It
|
|
2014
|
+
// also self-heals devices poisoned by the pre-fix `writeWindowMembership`.
|
|
2015
|
+
if (durable?.length) {
|
|
1961
2016
|
config.remoteArray = durable;
|
|
1962
2017
|
config.membershipKnown = true;
|
|
1963
2018
|
}
|
|
@@ -150,7 +150,7 @@ export class FlagsAdminService {
|
|
|
150
150
|
constructor(private deps: FlagsAdminDeps) {}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
* Everything the
|
|
153
|
+
* Everything the Access tab renders, in one round trip per source.
|
|
154
154
|
*
|
|
155
155
|
* Each section fails independently: a remote read that throws downgrades to
|
|
156
156
|
* `isAdmin: false` plus an `error`, while local assignments and overrides
|
|
@@ -196,7 +196,7 @@ export class FlagsAdminService {
|
|
|
196
196
|
} catch (err) {
|
|
197
197
|
// A missing `_00_admin` table means the deployment hasn't applied the
|
|
198
198
|
// internal schema yet. Say so — otherwise it reads as "not an admin".
|
|
199
|
-
snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the
|
|
199
|
+
snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the Access tab, run \`spky migrate\` (or redeploy) to apply the internal schema.`;
|
|
200
200
|
return snapshot;
|
|
201
201
|
}
|
|
202
202
|
|
|
@@ -86,7 +86,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
86
86
|
private localTablesFetching = false;
|
|
87
87
|
private localTablesAt = 0;
|
|
88
88
|
|
|
89
|
-
// Feature flag admin, backing the panel's
|
|
89
|
+
// Feature flag admin, backing the panel's Access tab. The local-override
|
|
90
90
|
// store is injected later (`setFeatureFlagOverrides`) because the
|
|
91
91
|
// FeatureFlagModule is built after this service.
|
|
92
92
|
private featureOverrides: LocalOverrideStore | null = null;
|
|
@@ -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 },
|
|
@@ -545,7 +549,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
545
549
|
}
|
|
546
550
|
|
|
547
551
|
/**
|
|
548
|
-
* Hand the FeatureFlagModule to the
|
|
552
|
+
* Hand the FeatureFlagModule to the Access tab so it can read and write local
|
|
549
553
|
* overrides. Called from `Sp00kyClient` once both are constructed; until then
|
|
550
554
|
* the override methods are no-ops that report an empty map.
|
|
551
555
|
*/
|
|
@@ -558,7 +562,7 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
558
562
|
(window as any).__00__ = {
|
|
559
563
|
version: this.version,
|
|
560
564
|
getState: () => this.getState(),
|
|
561
|
-
// ---- Feature flags (
|
|
565
|
+
// ---- Feature flags (Access tab) --------------------------------
|
|
562
566
|
// Remote reads/writes are admin-gated by SurrealDB, not here: a
|
|
563
567
|
// non-admin gets an empty flag list, and the `fn::feature::*` calls
|
|
564
568
|
// are denied outright. The override methods are purely local and
|
|
@@ -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
|
-
| {
|
|
22
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
147
|
-
|
|
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:
|
|
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(
|
|
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 =
|
|
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
|
package/src/sp00ky.ts
CHANGED
|
@@ -465,7 +465,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
465
465
|
this.dataModule
|
|
466
466
|
);
|
|
467
467
|
|
|
468
|
-
// Let the DevTools
|
|
468
|
+
// Let the DevTools Access tab read and write local flag overrides. Done
|
|
469
469
|
// here rather than via the constructor because FeatureFlagModule is built
|
|
470
470
|
// above and DevToolsService takes its deps positionally.
|
|
471
471
|
this.devTools.setFeatureFlagOverrides(this.featureFlags);
|
|
@@ -1065,10 +1065,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1065
1065
|
* Nothing is sent to the server — the `_00_user_feature` assignment is
|
|
1066
1066
|
* untouched, so clearing restores whatever the server says. Persisted to
|
|
1067
1067
|
* localStorage, survives reloads, and applies while signed out. Backs the
|
|
1068
|
-
* DevTools
|
|
1068
|
+
* DevTools Access tab, and is a convenient hook for tests.
|
|
1069
1069
|
*
|
|
1070
1070
|
* To change a flag for OTHER users you need admin rights (`spky admin add`)
|
|
1071
|
-
* and the DevTools
|
|
1071
|
+
* and the DevTools Access tab, or `spky flag`.
|
|
1072
1072
|
*/
|
|
1073
1073
|
setFeatureOverride(key: string, variant: string | null, payload?: unknown): void {
|
|
1074
1074
|
this.featureFlags.setLocalOverride(key, variant, payload);
|
package/src/types.ts
CHANGED
|
@@ -488,6 +488,29 @@ export interface QueryConfig {
|
|
|
488
488
|
* `remoteArray.length === 0` check cannot tell those apart.
|
|
489
489
|
*/
|
|
490
490
|
membershipKnown?: boolean;
|
|
491
|
+
/**
|
|
492
|
+
* Whether a NON-EMPTY id-set has arrived from the server for this query in
|
|
493
|
+
* this session. Gates whether an empty read may be believed.
|
|
494
|
+
*
|
|
495
|
+
* The server publishes `_00_list_ref` asynchronously — the SSP queues a
|
|
496
|
+
* view's initial edges to a coalescing flusher and returns from
|
|
497
|
+
* `fn::query::register` before they land — so an empty read right after
|
|
498
|
+
* registration says nothing about the query being empty. Believing it (and
|
|
499
|
+
* mirroring it to the durable `_00_window` row) blanked lists and kept them
|
|
500
|
+
* blank across reloads. Once a real set has been seen, a later empty one is a
|
|
501
|
+
* genuine transition and must be honoured, or removed rows resurrect.
|
|
502
|
+
*
|
|
503
|
+
* In-memory only: a fresh session must re-earn the right to believe empties.
|
|
504
|
+
*/
|
|
505
|
+
remoteSeen?: boolean;
|
|
506
|
+
/**
|
|
507
|
+
* Consecutive empty id-sets read from the server while `remoteSeen` is still
|
|
508
|
+
* false. Bounds how long an unconfirmed empty may be ignored, so a window
|
|
509
|
+
* that genuinely emptied while this device was away is believed on the second
|
|
510
|
+
* read instead of rendering stale rows forever. Reset by any non-empty set.
|
|
511
|
+
* In-memory only.
|
|
512
|
+
*/
|
|
513
|
+
emptyReads?: number;
|
|
491
514
|
/**
|
|
492
515
|
* Key of this query's durable `_00_window` membership row: a hash of
|
|
493
516
|
* `{surql, params}` WITHOUT the `session::id()` salt that `id` carries, so it
|