ugly-app 0.1.1040 → 0.1.1041
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/README.md +1 -1
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/server/sqlite/SqliteFts.d.ts +6 -3
- package/dist/server/sqlite/SqliteFts.d.ts.map +1 -1
- package/dist/server/sqlite/SqliteFts.js +85 -7
- package/dist/server/sqlite/SqliteFts.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/version.ts +1 -1
- package/src/server/sqlite/SqliteFts.test.ts +197 -0
- package/src/server/sqlite/SqliteFts.ts +110 -8
package/README.md
CHANGED
|
@@ -616,7 +616,7 @@ export const collections = defineCollections({
|
|
|
616
616
|
- `trackKeys?` — fields usable as NATS routing keys for scoped `trackDocs` subscriptions.
|
|
617
617
|
- `getter?(ids)` — batch resolver for collections with no local table (e.g. `userPublic` resolves via ugly.bot).
|
|
618
618
|
- `skipReadValidation?` — opt out of per-row zod validation on reads for very hot collections. Writes are always validated. Global kill-switch: `UGLY_DB_VALIDATE_READS=0`.
|
|
619
|
-
- `search?: { fields, language? }` — full-text index over the named JSONB paths (Neon: Postgres FTS; D1: SQLite FTS5, ranked by bm25).
|
|
619
|
+
- `search?: { fields, language? }` — full-text index over the named JSONB paths (Neon: Postgres FTS; D1: SQLite FTS5, ranked by bm25). On D1 this provisions two extra tables per collection — the `"<collection>_fts"` mirror and a small `"<collection>_ftsid"` `_id`→rowid map. The map is not optional bookkeeping: FTS5 can only seek on `rowid`, so without it every write full-scans the whole index (it once accounted for 98.8% of the account's D1 rows read). Don't hand-write against `_fts`; go through `searchDocs`.
|
|
620
620
|
- `vector?: { dimensions, metric?, filterable? }` — ANN index. Vector supplied out-of-band at write time (`setDoc(c, doc, { vec })`) — never stored in the doc JSON. Query with `getDocs(c, filter, { near })`.
|
|
621
621
|
|
|
622
622
|
All documents extend `DBObject`: `{ _id, version, created, updated }`.
|
package/dist/cli/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.1.
|
|
1
|
+
export declare const CLI_VERSION = "0.1.1041";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/cli/version.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { SqliteExec } from './SqliteExec.js';
|
|
2
2
|
/** `<collection>_fts` — the FTS5 mirror table for a collection (name validated). */
|
|
3
3
|
export declare function ftsTableName(collection: string): string;
|
|
4
|
+
/** `<collection>_ftsid` — the `_id → rowid` map for the mirror (see header). */
|
|
5
|
+
export declare function ftsIdTableName(collection: string): string;
|
|
4
6
|
/**
|
|
5
7
|
* FTS5 tokenizer for a Postgres-style `language`. FTS5 ships no snowball
|
|
6
8
|
* stemmers; map `english` → the built-in `porter` stemmer (closest to PG
|
|
@@ -9,13 +11,14 @@ export declare function ftsTableName(collection: string): string;
|
|
|
9
11
|
* documented recall difference vs Neon's snowball configs, not a bug.
|
|
10
12
|
*/
|
|
11
13
|
export declare function ftsTokenizer(language?: string): string;
|
|
12
|
-
/** Idempotent `CREATE VIRTUAL TABLE IF NOT EXISTS "<c>_fts" USING fts5(...)
|
|
14
|
+
/** Idempotent `CREATE VIRTUAL TABLE IF NOT EXISTS "<c>_fts" USING fts5(...)`,
|
|
15
|
+
* its `_id → rowid` map, and the one-time adoption of a pre-map mirror. */
|
|
13
16
|
export declare function ensureFtsTable(exec: SqliteExec, collection: string, fields: string[], language?: string): Promise<void>;
|
|
14
|
-
/** Mirror one doc into the FTS index (delete-then-insert, keyed by `
|
|
17
|
+
/** Mirror one doc into the FTS index (delete-then-insert, keyed by `rowid`). */
|
|
15
18
|
export declare function ftsUpsert(exec: SqliteExec, collection: string, fields: string[], doc: Record<string, unknown> & {
|
|
16
19
|
_id: string;
|
|
17
20
|
}): Promise<void>;
|
|
18
|
-
/** Remove a doc from the FTS index. */
|
|
21
|
+
/** Remove a doc from the FTS index and release its rowid. */
|
|
19
22
|
export declare function ftsDelete(exec: SqliteExec, collection: string, id: string): Promise<void>;
|
|
20
23
|
/**
|
|
21
24
|
* Turn free-text user input into a safe FTS5 MATCH expression. Mirrors
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SqliteFts.d.ts","sourceRoot":"","sources":["../../../src/server/sqlite/SqliteFts.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SqliteFts.d.ts","sourceRoot":"","sources":["../../../src/server/sqlite/SqliteFts.ts"],"names":[],"mappings":"AAuCA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAQlD,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAGvD;AAED,gFAAgF;AAChF,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAGzD;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAKtD;AAyDD;4EAC4E;AAC5E,wBAAsB,cAAc,CAClC,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EAAE,EAChB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAiBf;AAuBD,gFAAgF;AAChF,wBAAsB,SAAS,CAC7B,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EAAE,EAChB,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAC7C,OAAO,CAAC,IAAI,CAAC,CAYf;AAED,6DAA6D;AAC7D,wBAAsB,SAAS,CAC7B,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,MAAM,EAClB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,IAAI,CAAC,CAcf;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ7D;AAqBD,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EACtC,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EAAE,EAChB,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,CAAC,EAAE,CAAC,CAmCd"}
|
|
@@ -10,11 +10,33 @@
|
|
|
10
10
|
* source of truth; the FTS table is a pure mirror kept in sync in JS on every
|
|
11
11
|
* write (no triggers — VACUUM-safe and identical on better-sqlite3 and D1).
|
|
12
12
|
*
|
|
13
|
+
* # Why a side `"<collection>_ftsid"` map exists
|
|
14
|
+
*
|
|
15
|
+
* An FTS5 virtual table has exactly ONE key its query planner can seek on:
|
|
16
|
+
* `rowid`. Every other column — `_id` included, even declared UNINDEXED — is a
|
|
17
|
+
* full-index SCAN. The mirror was originally maintained with
|
|
18
|
+
* `DELETE FROM "<c>_fts" WHERE _id = ?`, so EVERY document write read every row
|
|
19
|
+
* of the index: O(collection size) per write, growing quadratically as a
|
|
20
|
+
* collection and its write rate grow together. Measured on the live account
|
|
21
|
+
* over 30 days that one statement shape was 107.3 B of 108.6 B D1 rows read
|
|
22
|
+
* (98.8%) across three apps — ugly-style's `catalogProduct_fts` alone did
|
|
23
|
+
* 1.5 M deletes × 54 K rows = 81 B — and was ~60% of the Cloudflare bill.
|
|
24
|
+
*
|
|
25
|
+
* So the mirror is keyed by rowid, and `"<collection>_ftsid"` holds the stable
|
|
26
|
+
* `_id → rowid` mapping: `rid INTEGER PRIMARY KEY` (a rowid alias, so it
|
|
27
|
+
* survives VACUUM, unlike the base table's implicit rowid) and a UNIQUE `_id`.
|
|
28
|
+
* Both directions are B-tree seeks, so a write reads ~2 rows instead of ~54,000.
|
|
29
|
+
*
|
|
30
|
+
* The map cannot be derived from the base table on demand: `DB.routedDeleteDoc`
|
|
31
|
+
* deletes the base row BEFORE calling `deleteSearch`, so by then there is no row
|
|
32
|
+
* left to read a rowid from. It has to be tracked independently.
|
|
33
|
+
*
|
|
13
34
|
* Ranking uses `bm25()` (lower = better; SQLite returns negative scores), which
|
|
14
35
|
* is NOT numerically comparable to Postgres `ts_rank`. Stemming differs too
|
|
15
36
|
* (`porter`/`unicode61` vs snowball) — a documented recall difference, not a bug.
|
|
16
37
|
*/
|
|
17
38
|
import { translateFilter } from './SqliteFilter.js';
|
|
39
|
+
import { ensureD1MigrationsTable } from './SqliteMigrations.js';
|
|
18
40
|
const VALID_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
19
41
|
function assertSafeName(name) {
|
|
20
42
|
if (!VALID_NAME.test(name))
|
|
@@ -25,6 +47,11 @@ export function ftsTableName(collection) {
|
|
|
25
47
|
assertSafeName(collection);
|
|
26
48
|
return `${collection}_fts`;
|
|
27
49
|
}
|
|
50
|
+
/** `<collection>_ftsid` — the `_id → rowid` map for the mirror (see header). */
|
|
51
|
+
export function ftsIdTableName(collection) {
|
|
52
|
+
assertSafeName(collection);
|
|
53
|
+
return `${collection}_ftsid`;
|
|
54
|
+
}
|
|
28
55
|
/**
|
|
29
56
|
* FTS5 tokenizer for a Postgres-style `language`. FTS5 ships no snowball
|
|
30
57
|
* stemmers; map `english` → the built-in `porter` stemmer (closest to PG
|
|
@@ -63,28 +90,79 @@ function textValue(doc, field) {
|
|
|
63
90
|
return v.map(scalarText).join(' ');
|
|
64
91
|
return scalarText(v);
|
|
65
92
|
}
|
|
66
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Adopt an FTS table that predates the `_ftsid` map by copying its EXISTING
|
|
95
|
+
* rowids into the map, so legacy rows keep working with rowid-keyed writes —
|
|
96
|
+
* no rebuild, no reindex, no window where the collection is unsearchable.
|
|
97
|
+
*
|
|
98
|
+
* One full scan of the mirror, once per collection ever. The run-once marker
|
|
99
|
+
* goes in `_migrations` (same table the D1 migration runner uses) and is written
|
|
100
|
+
* only AFTER the copy succeeds, so a partial failure simply re-runs — the copy
|
|
101
|
+
* is `INSERT OR IGNORE`, so re-running converges instead of double-applying.
|
|
102
|
+
*/
|
|
103
|
+
async function seedFtsIdMap(exec, collection) {
|
|
104
|
+
const marker = `fts_rowid_map:${collection}`;
|
|
105
|
+
await ensureD1MigrationsTable(exec);
|
|
106
|
+
const done = await exec.all(`SELECT name FROM _migrations WHERE name = ?`, [marker]);
|
|
107
|
+
if (done.length > 0)
|
|
108
|
+
return;
|
|
109
|
+
await exec.run(`INSERT OR IGNORE INTO "${ftsIdTableName(collection)}" (rid, _id) ` +
|
|
110
|
+
`SELECT rowid, _id FROM "${ftsTableName(collection)}"`);
|
|
111
|
+
await exec.run(`INSERT OR IGNORE INTO _migrations (name, applied_at) VALUES (?, ?)`, [marker, Date.now()]);
|
|
112
|
+
}
|
|
113
|
+
/** Idempotent `CREATE VIRTUAL TABLE IF NOT EXISTS "<c>_fts" USING fts5(...)`,
|
|
114
|
+
* its `_id → rowid` map, and the one-time adoption of a pre-map mirror. */
|
|
67
115
|
export async function ensureFtsTable(exec, collection, fields, language) {
|
|
68
116
|
const table = ftsTableName(collection);
|
|
69
117
|
const cols = safeFields(fields).map((f) => `"${f}"`);
|
|
70
118
|
const colDefs = ['_id UNINDEXED', ...cols].join(', ');
|
|
71
119
|
await exec.run(`CREATE VIRTUAL TABLE IF NOT EXISTS "${table}" USING fts5(` +
|
|
72
120
|
`${colDefs}, tokenize='${ftsTokenizer(language)}')`);
|
|
121
|
+
// `rid INTEGER PRIMARY KEY` aliases the rowid, so ids are stable across
|
|
122
|
+
// VACUUM; UNIQUE `_id` makes the reverse lookup a seek, not a scan.
|
|
123
|
+
await exec.run(`CREATE TABLE IF NOT EXISTS "${ftsIdTableName(collection)}" (
|
|
124
|
+
rid INTEGER PRIMARY KEY,
|
|
125
|
+
_id TEXT NOT NULL UNIQUE
|
|
126
|
+
)`);
|
|
127
|
+
await seedFtsIdMap(exec, collection);
|
|
73
128
|
}
|
|
74
|
-
/**
|
|
129
|
+
/** The mirror rowid for `_id`, allocating one on first sight. Two B-tree seeks. */
|
|
130
|
+
async function ftsRowid(exec, collection, id) {
|
|
131
|
+
const idTable = ftsIdTableName(collection);
|
|
132
|
+
await exec.run(`INSERT OR IGNORE INTO "${idTable}" (_id) VALUES (?)`, [id]);
|
|
133
|
+
const rows = await exec.all(`SELECT rid FROM "${idTable}" WHERE _id = ?`, [id]);
|
|
134
|
+
const row = rows[0];
|
|
135
|
+
// The INSERT above guarantees a row; a miss means the map table is gone or
|
|
136
|
+
// the write was rolled back under us — surface it rather than mirror blind.
|
|
137
|
+
if (!row) {
|
|
138
|
+
throw new Error(`[fts] no mirror rowid for ${collection}/${id}`);
|
|
139
|
+
}
|
|
140
|
+
return row.rid;
|
|
141
|
+
}
|
|
142
|
+
/** Mirror one doc into the FTS index (delete-then-insert, keyed by `rowid`). */
|
|
75
143
|
export async function ftsUpsert(exec, collection, fields, doc) {
|
|
76
144
|
const table = ftsTableName(collection);
|
|
77
145
|
const cols = safeFields(fields);
|
|
78
|
-
|
|
146
|
+
const rid = await ftsRowid(exec, collection, doc._id);
|
|
147
|
+
await exec.run(`DELETE FROM "${table}" WHERE rowid = ?`, [rid]);
|
|
79
148
|
const colList = ['_id', ...cols].map((c) => `"${c}"`).join(', ');
|
|
80
149
|
const marks = ['_id', ...cols].map(() => '?').join(', ');
|
|
81
150
|
const values = [doc._id, ...cols.map((f) => textValue(doc, f))];
|
|
82
|
-
await exec.run(`INSERT INTO "${table}" (${colList}) VALUES (${marks})`, values);
|
|
151
|
+
await exec.run(`INSERT INTO "${table}" (rowid, ${colList}) VALUES (?, ${marks})`, [rid, ...values]);
|
|
83
152
|
}
|
|
84
|
-
/** Remove a doc from the FTS index. */
|
|
153
|
+
/** Remove a doc from the FTS index and release its rowid. */
|
|
85
154
|
export async function ftsDelete(exec, collection, id) {
|
|
86
|
-
const
|
|
87
|
-
await exec.
|
|
155
|
+
const idTable = ftsIdTableName(collection);
|
|
156
|
+
const rows = await exec.all(`SELECT rid FROM "${idTable}" WHERE _id = ?`, [id]);
|
|
157
|
+
// Unmapped id: nothing was ever mirrored under it, so there is nothing to
|
|
158
|
+
// delete. (Never fall back to `WHERE _id = ?` — that is the full scan.)
|
|
159
|
+
const rid = rows[0]?.rid;
|
|
160
|
+
if (rid === undefined)
|
|
161
|
+
return;
|
|
162
|
+
await exec.run(`DELETE FROM "${ftsTableName(collection)}" WHERE rowid = ?`, [
|
|
163
|
+
rid,
|
|
164
|
+
]);
|
|
165
|
+
await exec.run(`DELETE FROM "${idTable}" WHERE rid = ?`, [rid]);
|
|
88
166
|
}
|
|
89
167
|
/**
|
|
90
168
|
* Turn free-text user input into a safe FTS5 MATCH expression. Mirrors
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SqliteFts.js","sourceRoot":"","sources":["../../../src/server/sqlite/SqliteFts.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"SqliteFts.js","sourceRoot":"","sources":["../../../src/server/sqlite/SqliteFts.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ugly-app",
|
|
3
3
|
"packageManager": "pnpm@10.34.5",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.1041",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"comment:files": "Allowlist what ships to npm. dist = runtime; src = sourcemap targets (dist/*.js.map reference ../../src/); templates = CLI scaffold. Everything else at repo root (.pgdata local Postgres, coverage, assets/icons sources, test/, test-results/) is excluded by omission. The !negations strip the scaffold's installed deps + cruft (templates/node_modules is 200MB+ and must never ship). package.json/README/LICENSE ship automatically.",
|
|
7
7
|
"files": [
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"preinstall": "npx only-allow pnpm",
|
|
75
|
-
"prebuild": "node -e \"const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));require('fs').writeFileSync('src/cli/version.ts','// Auto-generated by prebuild
|
|
75
|
+
"prebuild": "node -e \"const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));require('fs').writeFileSync('src/cli/version.ts','// Auto-generated by prebuild — do not edit manually\\nexport const CLI_VERSION = '+JSON.stringify(p.version)+';\\n');\" && rm -rf dist",
|
|
76
76
|
"build": "tsc && chmod +x dist/cli/index.js && cp src/markdown/client/*.css dist/markdown/client/ && mkdir -p dist/cli/publish/recipes && cp -R src/cli/publish/recipes/. dist/cli/publish/recipes/ && mkdir -p dist/native/server/taskHost && cp src/native/server/taskHost/*.mjs dist/native/server/taskHost/ && esbuild src/inspect/agent/bundle-entry.ts --bundle --format=iife --platform=browser --minify --outfile=dist/inspect/agent-bundle.js --footer:js='//# sourceURL=ugly-inspect-agent.js' && esbuild src/inspect/host-bundle-entry.ts --bundle --format=iife --platform=browser --minify --outfile=dist/inspect/host-bundle.js",
|
|
77
77
|
"test": "vitest run",
|
|
78
78
|
"test:e2e": "E2E=1 vitest run tests/cli/e2e-integration.test.ts",
|
package/src/cli/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by prebuild — do not edit manually
|
|
2
|
-
export const CLI_VERSION = "0.1.
|
|
2
|
+
export const CLI_VERSION = "0.1.1041";
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FTS5 mirror maintenance — rowid-keyed upsert/delete.
|
|
3
|
+
*
|
|
4
|
+
* The cost regression these tests exist to prevent: an FTS5 virtual table can
|
|
5
|
+
* only satisfy an equality constraint on `rowid`. A `DELETE … WHERE _id = ?`
|
|
6
|
+
* (where `_id` is an ordinary UNINDEXED FTS column) is answered by a FULL SCAN
|
|
7
|
+
* of the index, so every document write costs O(rows-in-collection) rows read.
|
|
8
|
+
* In production that single statement was 98.8% of all D1 rows read.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
ensureFtsTable,
|
|
13
|
+
ftsUpsert,
|
|
14
|
+
ftsDelete,
|
|
15
|
+
ftsTableName,
|
|
16
|
+
ftsIdTableName,
|
|
17
|
+
} from './SqliteFts.js';
|
|
18
|
+
import { makeBetterSqliteExec, type SqliteExec } from './SqliteExec.js';
|
|
19
|
+
|
|
20
|
+
/** Wraps an exec and records every statement it runs. */
|
|
21
|
+
function spyOn(exec: SqliteExec): { exec: SqliteExec; sql: string[] } {
|
|
22
|
+
const sql: string[] = [];
|
|
23
|
+
return {
|
|
24
|
+
sql,
|
|
25
|
+
exec: {
|
|
26
|
+
all: (s, p) => {
|
|
27
|
+
sql.push(s);
|
|
28
|
+
return exec.all(s, p);
|
|
29
|
+
},
|
|
30
|
+
run: (s, p) => {
|
|
31
|
+
sql.push(s);
|
|
32
|
+
return exec.run(s, p);
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FIELDS = ['title', 'body'];
|
|
39
|
+
|
|
40
|
+
async function ftsRows(
|
|
41
|
+
exec: SqliteExec,
|
|
42
|
+
c: string,
|
|
43
|
+
): Promise<{ rowid: number; _id: string }[]> {
|
|
44
|
+
return exec.all(`SELECT rowid, _id FROM "${ftsTableName(c)}" ORDER BY rowid`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('ftsUpsert / ftsDelete are rowid-keyed', () => {
|
|
48
|
+
let exec: SqliteExec;
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
exec = await makeBetterSqliteExec();
|
|
52
|
+
await ensureFtsTable(exec, 'article', FIELDS);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('creates the _id→rowid map table alongside the FTS table', async () => {
|
|
56
|
+
const rows = await exec.all<{ name: string }>(
|
|
57
|
+
`SELECT name FROM sqlite_master WHERE name IN (?, ?)`,
|
|
58
|
+
[ftsTableName('article'), ftsIdTableName('article')],
|
|
59
|
+
);
|
|
60
|
+
expect(rows.map((r) => r.name).sort()).toEqual(
|
|
61
|
+
['article_fts', 'article_ftsid'].sort(),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('re-upserting the same _id replaces in place (no duplicate rows)', async () => {
|
|
66
|
+
await ftsUpsert(exec, 'article', FIELDS, {
|
|
67
|
+
_id: 'a1',
|
|
68
|
+
title: 'first',
|
|
69
|
+
body: 'x',
|
|
70
|
+
});
|
|
71
|
+
await ftsUpsert(exec, 'article', FIELDS, {
|
|
72
|
+
_id: 'a1',
|
|
73
|
+
title: 'second',
|
|
74
|
+
body: 'x',
|
|
75
|
+
});
|
|
76
|
+
const rows = await ftsRows(exec, 'article');
|
|
77
|
+
expect(rows).toHaveLength(1);
|
|
78
|
+
const hit = await exec.all<{ title: string }>(
|
|
79
|
+
`SELECT title FROM "article_fts" WHERE "article_fts" MATCH ?`,
|
|
80
|
+
['"second"'],
|
|
81
|
+
);
|
|
82
|
+
expect(hit).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('reuses the SAME rowid across upserts of one _id', async () => {
|
|
86
|
+
await ftsUpsert(exec, 'article', FIELDS, { _id: 'a1', title: 'v1' });
|
|
87
|
+
const [first] = await ftsRows(exec, 'article');
|
|
88
|
+
await ftsUpsert(exec, 'article', FIELDS, { _id: 'a1', title: 'v2' });
|
|
89
|
+
const [second] = await ftsRows(exec, 'article');
|
|
90
|
+
expect(second.rowid).toBe(first.rowid);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('gives distinct _ids distinct rowids', async () => {
|
|
94
|
+
await ftsUpsert(exec, 'article', FIELDS, { _id: 'a1', title: 'one' });
|
|
95
|
+
await ftsUpsert(exec, 'article', FIELDS, { _id: 'a2', title: 'two' });
|
|
96
|
+
const rows = await ftsRows(exec, 'article');
|
|
97
|
+
expect(rows).toHaveLength(2);
|
|
98
|
+
expect(rows[0].rowid).not.toBe(rows[1].rowid);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('ftsDelete removes the row and frees the map entry', async () => {
|
|
102
|
+
await ftsUpsert(exec, 'article', FIELDS, { _id: 'a1', title: 'gone' });
|
|
103
|
+
await ftsDelete(exec, 'article', 'a1');
|
|
104
|
+
expect(await ftsRows(exec, 'article')).toHaveLength(0);
|
|
105
|
+
const map = await exec.all(`SELECT * FROM "article_ftsid"`);
|
|
106
|
+
expect(map).toHaveLength(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('ftsDelete on an unknown _id is a no-op', async () => {
|
|
110
|
+
await ftsUpsert(exec, 'article', FIELDS, { _id: 'a1', title: 'keep' });
|
|
111
|
+
await ftsDelete(exec, 'article', 'nope');
|
|
112
|
+
expect(await ftsRows(exec, 'article')).toHaveLength(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// The actual cost guard.
|
|
116
|
+
it('never filters the FTS table by _id (that is a full index scan)', async () => {
|
|
117
|
+
const s = spyOn(exec);
|
|
118
|
+
await ftsUpsert(s.exec, 'article', FIELDS, { _id: 'a1', title: 'x' });
|
|
119
|
+
await ftsUpsert(s.exec, 'article', FIELDS, { _id: 'a1', title: 'y' });
|
|
120
|
+
await ftsDelete(s.exec, 'article', 'a1');
|
|
121
|
+
const offenders = s.sql.filter((q) =>
|
|
122
|
+
/"article_fts"\s+WHERE\s+_id/i.test(q),
|
|
123
|
+
);
|
|
124
|
+
expect(offenders).toEqual([]);
|
|
125
|
+
// …and every FTS mutation is keyed by rowid instead.
|
|
126
|
+
const ftsWrites = s.sql.filter((q) =>
|
|
127
|
+
/(DELETE|INSERT).+"article_fts"/i.test(q),
|
|
128
|
+
);
|
|
129
|
+
expect(ftsWrites.length).toBeGreaterThan(0);
|
|
130
|
+
for (const q of ftsWrites) expect(q).toMatch(/rowid/i);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('legacy FTS tables (written before the map existed)', () => {
|
|
135
|
+
it('seeds the map from existing rowids, so upsert replaces instead of duplicating', async () => {
|
|
136
|
+
const exec = await makeBetterSqliteExec();
|
|
137
|
+
// Build the table the OLD way: no map, rowids auto-assigned by FTS5.
|
|
138
|
+
await exec.run(
|
|
139
|
+
`CREATE VIRTUAL TABLE "article_fts" USING fts5(_id UNINDEXED, "title", "body", tokenize='porter unicode61 remove_diacritics 2')`,
|
|
140
|
+
);
|
|
141
|
+
for (const id of ['old1', 'old2', 'old3']) {
|
|
142
|
+
await exec.run(
|
|
143
|
+
`INSERT INTO "article_fts" (_id, "title", "body") VALUES (?, ?, ?)`,
|
|
144
|
+
[id, `title ${id}`, 'body'],
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await ensureFtsTable(exec, 'article', FIELDS);
|
|
149
|
+
|
|
150
|
+
const map = await exec.all<{ rid: number; _id: string }>(
|
|
151
|
+
`SELECT rid, _id FROM "article_ftsid" ORDER BY rid`,
|
|
152
|
+
);
|
|
153
|
+
expect(map.map((m) => m._id)).toEqual(['old1', 'old2', 'old3']);
|
|
154
|
+
|
|
155
|
+
// A legacy doc must now update in place, not duplicate.
|
|
156
|
+
await ftsUpsert(exec, 'article', FIELDS, {
|
|
157
|
+
_id: 'old2',
|
|
158
|
+
title: 'rewritten',
|
|
159
|
+
body: 'body',
|
|
160
|
+
});
|
|
161
|
+
const rows = await ftsRows(exec, 'article');
|
|
162
|
+
expect(rows).toHaveLength(3);
|
|
163
|
+
expect(rows.filter((r) => r._id === 'old2')).toHaveLength(1);
|
|
164
|
+
|
|
165
|
+
// …and a legacy doc must be deletable.
|
|
166
|
+
await ftsDelete(exec, 'article', 'old1');
|
|
167
|
+
expect(await ftsRows(exec, 'article')).toHaveLength(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('seeding is idempotent and does not re-scan once recorded', async () => {
|
|
171
|
+
const exec = await makeBetterSqliteExec();
|
|
172
|
+
await exec.run(
|
|
173
|
+
`CREATE VIRTUAL TABLE "article_fts" USING fts5(_id UNINDEXED, "title", "body", tokenize='porter unicode61 remove_diacritics 2')`,
|
|
174
|
+
);
|
|
175
|
+
await exec.run(
|
|
176
|
+
`INSERT INTO "article_fts" (_id, "title", "body") VALUES (?, ?, ?)`,
|
|
177
|
+
['old1', 't', 'b'],
|
|
178
|
+
);
|
|
179
|
+
await ensureFtsTable(exec, 'article', FIELDS);
|
|
180
|
+
|
|
181
|
+
const s = spyOn(exec);
|
|
182
|
+
await ensureFtsTable(s.exec, 'article', FIELDS);
|
|
183
|
+
const seeded = s.sql.filter((q) =>
|
|
184
|
+
/INSERT\s+OR\s+IGNORE.+SELECT\s+rowid/i.test(q),
|
|
185
|
+
);
|
|
186
|
+
expect(seeded).toEqual([]);
|
|
187
|
+
const map = await exec.all(`SELECT * FROM "article_ftsid"`);
|
|
188
|
+
expect(map).toHaveLength(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('a brand-new empty collection is not treated as legacy', async () => {
|
|
192
|
+
const exec = await makeBetterSqliteExec();
|
|
193
|
+
await ensureFtsTable(exec, 'article', FIELDS);
|
|
194
|
+
await ftsUpsert(exec, 'article', FIELDS, { _id: 'a1', title: 'x' });
|
|
195
|
+
expect(await ftsRows(exec, 'article')).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -10,11 +10,33 @@
|
|
|
10
10
|
* source of truth; the FTS table is a pure mirror kept in sync in JS on every
|
|
11
11
|
* write (no triggers — VACUUM-safe and identical on better-sqlite3 and D1).
|
|
12
12
|
*
|
|
13
|
+
* # Why a side `"<collection>_ftsid"` map exists
|
|
14
|
+
*
|
|
15
|
+
* An FTS5 virtual table has exactly ONE key its query planner can seek on:
|
|
16
|
+
* `rowid`. Every other column — `_id` included, even declared UNINDEXED — is a
|
|
17
|
+
* full-index SCAN. The mirror was originally maintained with
|
|
18
|
+
* `DELETE FROM "<c>_fts" WHERE _id = ?`, so EVERY document write read every row
|
|
19
|
+
* of the index: O(collection size) per write, growing quadratically as a
|
|
20
|
+
* collection and its write rate grow together. Measured on the live account
|
|
21
|
+
* over 30 days that one statement shape was 107.3 B of 108.6 B D1 rows read
|
|
22
|
+
* (98.8%) across three apps — ugly-style's `catalogProduct_fts` alone did
|
|
23
|
+
* 1.5 M deletes × 54 K rows = 81 B — and was ~60% of the Cloudflare bill.
|
|
24
|
+
*
|
|
25
|
+
* So the mirror is keyed by rowid, and `"<collection>_ftsid"` holds the stable
|
|
26
|
+
* `_id → rowid` mapping: `rid INTEGER PRIMARY KEY` (a rowid alias, so it
|
|
27
|
+
* survives VACUUM, unlike the base table's implicit rowid) and a UNIQUE `_id`.
|
|
28
|
+
* Both directions are B-tree seeks, so a write reads ~2 rows instead of ~54,000.
|
|
29
|
+
*
|
|
30
|
+
* The map cannot be derived from the base table on demand: `DB.routedDeleteDoc`
|
|
31
|
+
* deletes the base row BEFORE calling `deleteSearch`, so by then there is no row
|
|
32
|
+
* left to read a rowid from. It has to be tracked independently.
|
|
33
|
+
*
|
|
13
34
|
* Ranking uses `bm25()` (lower = better; SQLite returns negative scores), which
|
|
14
35
|
* is NOT numerically comparable to Postgres `ts_rank`. Stemming differs too
|
|
15
36
|
* (`porter`/`unicode61` vs snowball) — a documented recall difference, not a bug.
|
|
16
37
|
*/
|
|
17
38
|
import { translateFilter } from './SqliteFilter.js';
|
|
39
|
+
import { ensureD1MigrationsTable } from './SqliteMigrations.js';
|
|
18
40
|
import type { SqliteExec } from './SqliteExec.js';
|
|
19
41
|
|
|
20
42
|
const VALID_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
@@ -29,6 +51,12 @@ export function ftsTableName(collection: string): string {
|
|
|
29
51
|
return `${collection}_fts`;
|
|
30
52
|
}
|
|
31
53
|
|
|
54
|
+
/** `<collection>_ftsid` — the `_id → rowid` map for the mirror (see header). */
|
|
55
|
+
export function ftsIdTableName(collection: string): string {
|
|
56
|
+
assertSafeName(collection);
|
|
57
|
+
return `${collection}_ftsid`;
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
/**
|
|
33
61
|
* FTS5 tokenizer for a Postgres-style `language`. FTS5 ships no snowball
|
|
34
62
|
* stemmers; map `english` → the built-in `porter` stemmer (closest to PG
|
|
@@ -67,7 +95,39 @@ function textValue(doc: Record<string, unknown>, field: string): string {
|
|
|
67
95
|
return scalarText(v);
|
|
68
96
|
}
|
|
69
97
|
|
|
70
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Adopt an FTS table that predates the `_ftsid` map by copying its EXISTING
|
|
100
|
+
* rowids into the map, so legacy rows keep working with rowid-keyed writes —
|
|
101
|
+
* no rebuild, no reindex, no window where the collection is unsearchable.
|
|
102
|
+
*
|
|
103
|
+
* One full scan of the mirror, once per collection ever. The run-once marker
|
|
104
|
+
* goes in `_migrations` (same table the D1 migration runner uses) and is written
|
|
105
|
+
* only AFTER the copy succeeds, so a partial failure simply re-runs — the copy
|
|
106
|
+
* is `INSERT OR IGNORE`, so re-running converges instead of double-applying.
|
|
107
|
+
*/
|
|
108
|
+
async function seedFtsIdMap(
|
|
109
|
+
exec: SqliteExec,
|
|
110
|
+
collection: string,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const marker = `fts_rowid_map:${collection}`;
|
|
113
|
+
await ensureD1MigrationsTable(exec);
|
|
114
|
+
const done = await exec.all<{ name: string }>(
|
|
115
|
+
`SELECT name FROM _migrations WHERE name = ?`,
|
|
116
|
+
[marker],
|
|
117
|
+
);
|
|
118
|
+
if (done.length > 0) return;
|
|
119
|
+
await exec.run(
|
|
120
|
+
`INSERT OR IGNORE INTO "${ftsIdTableName(collection)}" (rid, _id) ` +
|
|
121
|
+
`SELECT rowid, _id FROM "${ftsTableName(collection)}"`,
|
|
122
|
+
);
|
|
123
|
+
await exec.run(
|
|
124
|
+
`INSERT OR IGNORE INTO _migrations (name, applied_at) VALUES (?, ?)`,
|
|
125
|
+
[marker, Date.now()],
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Idempotent `CREATE VIRTUAL TABLE IF NOT EXISTS "<c>_fts" USING fts5(...)`,
|
|
130
|
+
* its `_id → rowid` map, and the one-time adoption of a pre-map mirror. */
|
|
71
131
|
export async function ensureFtsTable(
|
|
72
132
|
exec: SqliteExec,
|
|
73
133
|
collection: string,
|
|
@@ -81,9 +141,39 @@ export async function ensureFtsTable(
|
|
|
81
141
|
`CREATE VIRTUAL TABLE IF NOT EXISTS "${table}" USING fts5(` +
|
|
82
142
|
`${colDefs}, tokenize='${ftsTokenizer(language)}')`,
|
|
83
143
|
);
|
|
144
|
+
// `rid INTEGER PRIMARY KEY` aliases the rowid, so ids are stable across
|
|
145
|
+
// VACUUM; UNIQUE `_id` makes the reverse lookup a seek, not a scan.
|
|
146
|
+
await exec.run(
|
|
147
|
+
`CREATE TABLE IF NOT EXISTS "${ftsIdTableName(collection)}" (
|
|
148
|
+
rid INTEGER PRIMARY KEY,
|
|
149
|
+
_id TEXT NOT NULL UNIQUE
|
|
150
|
+
)`,
|
|
151
|
+
);
|
|
152
|
+
await seedFtsIdMap(exec, collection);
|
|
84
153
|
}
|
|
85
154
|
|
|
86
|
-
/**
|
|
155
|
+
/** The mirror rowid for `_id`, allocating one on first sight. Two B-tree seeks. */
|
|
156
|
+
async function ftsRowid(
|
|
157
|
+
exec: SqliteExec,
|
|
158
|
+
collection: string,
|
|
159
|
+
id: string,
|
|
160
|
+
): Promise<number> {
|
|
161
|
+
const idTable = ftsIdTableName(collection);
|
|
162
|
+
await exec.run(`INSERT OR IGNORE INTO "${idTable}" (_id) VALUES (?)`, [id]);
|
|
163
|
+
const rows = await exec.all<{ rid: number }>(
|
|
164
|
+
`SELECT rid FROM "${idTable}" WHERE _id = ?`,
|
|
165
|
+
[id],
|
|
166
|
+
);
|
|
167
|
+
const row = rows[0];
|
|
168
|
+
// The INSERT above guarantees a row; a miss means the map table is gone or
|
|
169
|
+
// the write was rolled back under us — surface it rather than mirror blind.
|
|
170
|
+
if (!row) {
|
|
171
|
+
throw new Error(`[fts] no mirror rowid for ${collection}/${id}`);
|
|
172
|
+
}
|
|
173
|
+
return row.rid;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Mirror one doc into the FTS index (delete-then-insert, keyed by `rowid`). */
|
|
87
177
|
export async function ftsUpsert(
|
|
88
178
|
exec: SqliteExec,
|
|
89
179
|
collection: string,
|
|
@@ -92,24 +182,36 @@ export async function ftsUpsert(
|
|
|
92
182
|
): Promise<void> {
|
|
93
183
|
const table = ftsTableName(collection);
|
|
94
184
|
const cols = safeFields(fields);
|
|
95
|
-
|
|
185
|
+
const rid = await ftsRowid(exec, collection, doc._id);
|
|
186
|
+
await exec.run(`DELETE FROM "${table}" WHERE rowid = ?`, [rid]);
|
|
96
187
|
const colList = ['_id', ...cols].map((c) => `"${c}"`).join(', ');
|
|
97
188
|
const marks = ['_id', ...cols].map(() => '?').join(', ');
|
|
98
189
|
const values = [doc._id, ...cols.map((f) => textValue(doc, f))];
|
|
99
190
|
await exec.run(
|
|
100
|
-
`INSERT INTO "${table}" (${colList}) VALUES (${marks})`,
|
|
101
|
-
values,
|
|
191
|
+
`INSERT INTO "${table}" (rowid, ${colList}) VALUES (?, ${marks})`,
|
|
192
|
+
[rid, ...values],
|
|
102
193
|
);
|
|
103
194
|
}
|
|
104
195
|
|
|
105
|
-
/** Remove a doc from the FTS index. */
|
|
196
|
+
/** Remove a doc from the FTS index and release its rowid. */
|
|
106
197
|
export async function ftsDelete(
|
|
107
198
|
exec: SqliteExec,
|
|
108
199
|
collection: string,
|
|
109
200
|
id: string,
|
|
110
201
|
): Promise<void> {
|
|
111
|
-
const
|
|
112
|
-
await exec.
|
|
202
|
+
const idTable = ftsIdTableName(collection);
|
|
203
|
+
const rows = await exec.all<{ rid: number }>(
|
|
204
|
+
`SELECT rid FROM "${idTable}" WHERE _id = ?`,
|
|
205
|
+
[id],
|
|
206
|
+
);
|
|
207
|
+
// Unmapped id: nothing was ever mirrored under it, so there is nothing to
|
|
208
|
+
// delete. (Never fall back to `WHERE _id = ?` — that is the full scan.)
|
|
209
|
+
const rid = rows[0]?.rid;
|
|
210
|
+
if (rid === undefined) return;
|
|
211
|
+
await exec.run(`DELETE FROM "${ftsTableName(collection)}" WHERE rowid = ?`, [
|
|
212
|
+
rid,
|
|
213
|
+
]);
|
|
214
|
+
await exec.run(`DELETE FROM "${idTable}" WHERE rid = ?`, [rid]);
|
|
113
215
|
}
|
|
114
216
|
|
|
115
217
|
/**
|