dsh-plugin-dbgraph-service 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The on-disk index format: where the files live, the schema version, the DDL, and the sidecar the
3
+ * system prompt reads synchronously.
4
+ *
5
+ * Three files, all under the global index root (`~/.dsh/dbgraph`):
6
+ *
7
+ * - `connections.json` — the connection list. Structure and host facts only; every secret lives in
8
+ * the host's credential seam, referenced by name.
9
+ * - `<connection>/index.db` — one SQLite file per connection, replaced wholesale on every run. That
10
+ * single decision removes a whole class of bugs: no incremental diffing, no deletes, no
11
+ * half-updated index. A run writes `<connection>/index.db.<pid>-<nonce>.tmp` and renames it into
12
+ * place, so two runs never share a staging file.
13
+ * - `status.json` — a digest of every connection's index state plus its object list, small enough to
14
+ * read synchronously on every prompt assembly. It exists so the model learns the schema without
15
+ * spending a tool call, and so nothing opens SQLite during assembly.
16
+ *
17
+ * The index holds structure and never a business value. The one data-derived field anywhere is
18
+ * `columns.enum_values_json`, a bounded sample of distinct literals (see `DEFAULT_ENUM_SAMPLE_LIMIT`).
19
+ * @module dsh-plugin-dbgraph-service/schema
20
+ */
21
+ /** Directory name under the harness home that holds every dbgraph file. */
22
+ export declare const INDEX_ROOT_DIRNAME = "dbgraph";
23
+ /** File name of one connection's index, inside its own directory. */
24
+ export declare const INDEX_FILE_NAME = "index.db";
25
+ /** The connection list, inside the index root. */
26
+ export declare const CONNECTIONS_FILE_NAME = "connections.json";
27
+ /** The prompt digest sidecar, inside the index root. */
28
+ export declare const STATUS_FILE_NAME = "status.json";
29
+ /** Schema version stored in `meta.schema_version`. A store refuses a file it does not know. */
30
+ export declare const INDEX_SCHEMA_VERSION = 1;
31
+ /** Default number of distinct literals sampled per column for `columns.enum_values_json`. */
32
+ export declare const DEFAULT_ENUM_SAMPLE_LIMIT = 10;
33
+ /**
34
+ * Default object count carried in `status.json` for the prompt digest. A connection with more
35
+ * objects than this lists the largest ones and says how many were left out.
36
+ */
37
+ export declare const DEFAULT_STATUS_DIGEST_OBJECTS = 120;
38
+ /** Default hours after which an L2 snapshot is called stale in the prompt. */
39
+ export declare const DEFAULT_INDEX_STALE_HOURS = 6;
40
+ /**
41
+ * The statements that create a fresh index. Applied in order inside one transaction; the writer
42
+ * executes them against a `.tmp` file that only becomes the index after the rename.
43
+ *
44
+ * `search` keeps its rows (a contentful FTS5 table) so the identity columns read back; the inline
45
+ * comment beside the DDL below records why a contentless table was rejected rather than kept.
46
+ */
47
+ export declare const INDEX_SCHEMA_DDL: readonly string[];
48
+ /**
49
+ * The `meta` keys every writer writes and every reader looks up. One list, so the JavaScript and the
50
+ * native writer cannot drift: the reader treats an absent key as unknown rather than as a default.
51
+ */
52
+ export declare const INDEX_META_KEYS: {
53
+ /** {@link INDEX_SCHEMA_VERSION} the file was written for. */
54
+ readonly schemaVersion: "schema_version";
55
+ /** Connection id the file describes. */
56
+ readonly connection: "connection";
57
+ /** Database family. */
58
+ readonly engine: "engine";
59
+ /** Database name, or file path. */
60
+ readonly database: "database";
61
+ /** Server version string, when the server reported one. */
62
+ readonly serverVersion: "server_version";
63
+ /** `l1` or `l2` — which level the snapshot covers. */
64
+ readonly level: "level";
65
+ /** Epoch milliseconds when the snapshot was taken off the server. */
66
+ readonly fetchedAt: "fetched_at";
67
+ /** Epoch milliseconds of the last structural snapshot. */
68
+ readonly l1IndexedAt: "l1_indexed_at";
69
+ /** Epoch milliseconds of the last enrichment pass; absent on an L1-only file. */
70
+ readonly l2IndexedAt: "l2_indexed_at";
71
+ /** `js` or `rust` — which writer produced this file. */
72
+ readonly writer: "writer";
73
+ /** Epoch milliseconds when the file was written. */
74
+ readonly writtenAt: "written_at";
75
+ };
76
+ /** One entry of the prompt digest: everything needed to name a table without a query. */
77
+ export interface StatusDigestEntry {
78
+ /** Object name, schema-qualified when the engine has a schema. */
79
+ readonly object: string;
80
+ /** Object comment, when indexed. */
81
+ readonly comment?: string;
82
+ /** Column count. */
83
+ readonly columns: number;
84
+ }
85
+ /** One connection's state as the prompt sees it. */
86
+ export interface StatusConnectionEntry {
87
+ /** Database family, for the sentence's parenthetical. */
88
+ readonly engine: string;
89
+ /** Human label, when configured. */
90
+ readonly label?: string;
91
+ /** Epoch milliseconds of the last structural snapshot. */
92
+ readonly l1IndexedAt?: number;
93
+ /** Epoch milliseconds of the last enrichment pass. */
94
+ readonly l2IndexedAt?: number;
95
+ /** Objects in the index. */
96
+ readonly objects: number;
97
+ /** Columns in the index. */
98
+ readonly columns: number;
99
+ /** Fraction of columns carrying a comment, 0..1. */
100
+ readonly commentCoverage: number;
101
+ /** Largest objects, capped at `DEFAULT_STATUS_DIGEST_OBJECTS`. */
102
+ readonly digest: readonly StatusDigestEntry[];
103
+ /** Objects left out of {@link digest}. */
104
+ readonly digestOmitted: number;
105
+ }
106
+ /**
107
+ * The sidecar `status.json` holds. Written after the index rename, so a reader never describes an
108
+ * index that is not there yet.
109
+ */
110
+ export interface StatusFile {
111
+ /** Format version. A reader that sees a newer one falls back to the weaker sentence. */
112
+ readonly version: number;
113
+ /** Epoch milliseconds of the last write. */
114
+ readonly updatedAt: number;
115
+ /** Per-connection entries, keyed by connection id. */
116
+ readonly connections: Readonly<Record<string, StatusConnectionEntry>>;
117
+ }
118
+ /** One persisted connection, as `connections.json` stores it. Never carries a secret. */
119
+ export interface StoredConnection {
120
+ /** Connection identity. */
121
+ readonly id: string;
122
+ /** Database family. */
123
+ readonly engine: string;
124
+ /** Human label. */
125
+ readonly label?: string;
126
+ /** Host. */
127
+ readonly host?: string;
128
+ /** Port. */
129
+ readonly port?: number;
130
+ /** Database name, file path, or Redis database index. */
131
+ readonly database?: string;
132
+ /** Login user. */
133
+ readonly user?: string;
134
+ /** Credential-seam reference holding the password. */
135
+ readonly credentialRef?: string;
136
+ /** Sensitive-column handling for this connection. */
137
+ readonly sensitiveColumns?: string;
138
+ /** Extra sensitive column patterns. */
139
+ readonly sensitivePatterns?: readonly string[];
140
+ /** Epoch milliseconds when the connection was added. */
141
+ readonly addedAt?: number;
142
+ }
143
+ /** The `connections.json` document. */
144
+ export interface ConnectionsFile {
145
+ /** Format version. */
146
+ readonly version: number;
147
+ /** Configured connections, in the order the user arranged them. */
148
+ readonly connections: readonly StoredConnection[];
149
+ }
150
+ //# sourceMappingURL=schema.d.ts.map
package/lib/schema.js ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The on-disk index format: where the files live, the schema version, the DDL, and the sidecar the
3
+ * system prompt reads synchronously.
4
+ *
5
+ * Three files, all under the global index root (`~/.dsh/dbgraph`):
6
+ *
7
+ * - `connections.json` — the connection list. Structure and host facts only; every secret lives in
8
+ * the host's credential seam, referenced by name.
9
+ * - `<connection>/index.db` — one SQLite file per connection, replaced wholesale on every run. That
10
+ * single decision removes a whole class of bugs: no incremental diffing, no deletes, no
11
+ * half-updated index. A run writes `<connection>/index.db.<pid>-<nonce>.tmp` and renames it into
12
+ * place, so two runs never share a staging file.
13
+ * - `status.json` — a digest of every connection's index state plus its object list, small enough to
14
+ * read synchronously on every prompt assembly. It exists so the model learns the schema without
15
+ * spending a tool call, and so nothing opens SQLite during assembly.
16
+ *
17
+ * The index holds structure and never a business value. The one data-derived field anywhere is
18
+ * `columns.enum_values_json`, a bounded sample of distinct literals (see `DEFAULT_ENUM_SAMPLE_LIMIT`).
19
+ * @module dsh-plugin-dbgraph-service/schema
20
+ */
21
+ /** Directory name under the harness home that holds every dbgraph file. */
22
+ export const INDEX_ROOT_DIRNAME = 'dbgraph';
23
+ /** File name of one connection's index, inside its own directory. */
24
+ export const INDEX_FILE_NAME = 'index.db';
25
+ /** The connection list, inside the index root. */
26
+ export const CONNECTIONS_FILE_NAME = 'connections.json';
27
+ /** The prompt digest sidecar, inside the index root. */
28
+ export const STATUS_FILE_NAME = 'status.json';
29
+ /** Schema version stored in `meta.schema_version`. A store refuses a file it does not know. */
30
+ export const INDEX_SCHEMA_VERSION = 1;
31
+ /** Default number of distinct literals sampled per column for `columns.enum_values_json`. */
32
+ export const DEFAULT_ENUM_SAMPLE_LIMIT = 10;
33
+ /**
34
+ * Default object count carried in `status.json` for the prompt digest. A connection with more
35
+ * objects than this lists the largest ones and says how many were left out.
36
+ */
37
+ export const DEFAULT_STATUS_DIGEST_OBJECTS = 120;
38
+ /** Default hours after which an L2 snapshot is called stale in the prompt. */
39
+ export const DEFAULT_INDEX_STALE_HOURS = 6;
40
+ /**
41
+ * The statements that create a fresh index. Applied in order inside one transaction; the writer
42
+ * executes them against a `.tmp` file that only becomes the index after the rename.
43
+ *
44
+ * `search` keeps its rows (a contentful FTS5 table) so the identity columns read back; the inline
45
+ * comment beside the DDL below records why a contentless table was rejected rather than kept.
46
+ */
47
+ export const INDEX_SCHEMA_DDL = [
48
+ `CREATE TABLE IF NOT EXISTS meta (
49
+ key TEXT PRIMARY KEY,
50
+ value TEXT NOT NULL
51
+ )`,
52
+ `CREATE TABLE IF NOT EXISTS objects (
53
+ id INTEGER PRIMARY KEY,
54
+ kind TEXT NOT NULL,
55
+ schema_name TEXT NOT NULL DEFAULT '',
56
+ name TEXT NOT NULL,
57
+ comment TEXT,
58
+ row_estimate INTEGER,
59
+ updated_at INTEGER NOT NULL
60
+ )`,
61
+ `CREATE UNIQUE INDEX IF NOT EXISTS objects_identity ON objects (schema_name, name)`,
62
+ `CREATE TABLE IF NOT EXISTS columns (
63
+ id INTEGER PRIMARY KEY,
64
+ object_id INTEGER NOT NULL REFERENCES objects (id) ON DELETE CASCADE,
65
+ ordinal INTEGER NOT NULL,
66
+ name TEXT NOT NULL,
67
+ type TEXT NOT NULL,
68
+ type_class TEXT NOT NULL,
69
+ nullable INTEGER NOT NULL DEFAULT 1,
70
+ default_value TEXT,
71
+ comment TEXT,
72
+ is_primary_key INTEGER NOT NULL DEFAULT 0,
73
+ is_unique INTEGER NOT NULL DEFAULT 0,
74
+ enum_values_json TEXT,
75
+ updated_at INTEGER NOT NULL
76
+ )`,
77
+ `CREATE UNIQUE INDEX IF NOT EXISTS columns_identity ON columns (object_id, name)`,
78
+ `CREATE TABLE IF NOT EXISTS edges (
79
+ id INTEGER PRIMARY KEY,
80
+ src_object INTEGER NOT NULL REFERENCES objects (id) ON DELETE CASCADE,
81
+ src_column_id INTEGER REFERENCES columns (id) ON DELETE CASCADE,
82
+ kind TEXT NOT NULL,
83
+ dst_object INTEGER REFERENCES objects (id) ON DELETE CASCADE,
84
+ dst_column_id INTEGER REFERENCES columns (id) ON DELETE CASCADE,
85
+ name TEXT
86
+ )`,
87
+ `CREATE INDEX IF NOT EXISTS edges_src ON edges (src_object)`,
88
+ `CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst_object)`,
89
+ `CREATE TABLE IF NOT EXISTS terms (
90
+ term TEXT NOT NULL,
91
+ weight REAL NOT NULL,
92
+ object_id INTEGER REFERENCES objects (id) ON DELETE CASCADE,
93
+ column_id INTEGER REFERENCES columns (id) ON DELETE CASCADE
94
+ )`,
95
+ `CREATE INDEX IF NOT EXISTS terms_term ON terms (term)`,
96
+ // FTS5, contentful, with the two identity columns declared UNINDEXED.
97
+ //
98
+ // Two facts were measured on node v22.21.0 / SQLite 3.50.4 rather than assumed, and they pick this
99
+ // shape over the alternatives:
100
+ //
101
+ // 1. A **contentless** table (`content = ''`) answers with rowid and rank only — every declared
102
+ // column, UNINDEXED ones included, reads back as NULL. Storing `object_id`/`column_id` there is
103
+ // useless, so a contentless design has to smuggle identity through rowid.
104
+ // 2. A **contentful** table with `object_id UNINDEXED` returns those columns verbatim
105
+ // (`{rowid: 7, object_id: 3, column_id: 42}`). UNINDEXED means "stored but not tokenized", not
106
+ // "stored but unreadable".
107
+ //
108
+ // So: one table, readable ids, no rowid arithmetic. The size a contentless table would save does not
109
+ // pay for that.
110
+ //
111
+ // Note the FTS5 column grammar while adding a column: `column-name [UNINDEXED]`. A type name in
112
+ // front of UNINDEXED is a parse error (`parse error in "object_id INTEGER UNINDEXED"`).
113
+ //
114
+ // `text` is **not** the raw name and comment — it is the token stream {@link tokenizeForSearch}
115
+ // produces, because unicode61 treats a run of CJK as one token: store `广告点击` verbatim and a
116
+ // search for `广告` matches nothing. Verified.
117
+ `CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5 (
118
+ text,
119
+ object_id UNINDEXED,
120
+ column_id UNINDEXED,
121
+ tokenize = 'unicode61'
122
+ )`,
123
+ ];
124
+ /**
125
+ * The `meta` keys every writer writes and every reader looks up. One list, so the JavaScript and the
126
+ * native writer cannot drift: the reader treats an absent key as unknown rather than as a default.
127
+ */
128
+ export const INDEX_META_KEYS = {
129
+ /** {@link INDEX_SCHEMA_VERSION} the file was written for. */
130
+ schemaVersion: 'schema_version',
131
+ /** Connection id the file describes. */
132
+ connection: 'connection',
133
+ /** Database family. */
134
+ engine: 'engine',
135
+ /** Database name, or file path. */
136
+ database: 'database',
137
+ /** Server version string, when the server reported one. */
138
+ serverVersion: 'server_version',
139
+ /** `l1` or `l2` — which level the snapshot covers. */
140
+ level: 'level',
141
+ /** Epoch milliseconds when the snapshot was taken off the server. */
142
+ fetchedAt: 'fetched_at',
143
+ /** Epoch milliseconds of the last structural snapshot. */
144
+ l1IndexedAt: 'l1_indexed_at',
145
+ /** Epoch milliseconds of the last enrichment pass; absent on an L1-only file. */
146
+ l2IndexedAt: 'l2_indexed_at',
147
+ /** `js` or `rust` — which writer produced this file. */
148
+ writer: 'writer',
149
+ /** Epoch milliseconds when the file was written. */
150
+ writtenAt: 'written_at',
151
+ };
152
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The one tokenizer both index writers and the resolver must agree on.
3
+ *
4
+ * FTS5's `unicode61` tokenizer splits on non-alphanumerics, which means a run of Chinese becomes a
5
+ * **single** token: store `广告点击` verbatim and `MATCH '广告'` returns nothing (verified on SQLite
6
+ * 3.50.4). So the indexed text is not the raw name and comment — it is the token stream this module
7
+ * produces, and the query side is the same function's output joined with `OR`.
8
+ *
9
+ * Two rules, deliberately few:
10
+ *
11
+ * - **ASCII identifiers** split on every change of character class, so `createdAt` → `created`, `at`
12
+ * and `password_hash` → `password`, `hash`; everything is lowercased.
13
+ * - **CJK runs** split into overlapping bigrams (`广告点击` → `广告`, `告点`, `点击`). A run of one
14
+ * character is its own token.
15
+ *
16
+ * The native writer reimplements these two rules in Rust. `packages/store/tests/tokenize-parity.spec.ts`
17
+ * is what keeps the two implementations honest: it asserts token-for-token equality on a fixture
18
+ * corpus, so a change here that is not mirrored there fails the build rather than silently degrading
19
+ * recall.
20
+ * @module dsh-plugin-dbgraph-service/tokenize
21
+ */
22
+ /**
23
+ * The token stream indexed for one text field, and the vocabulary a query is built from.
24
+ * @param input - a name, a comment, or a user's words.
25
+ * @returns deduplicated tokens in first-seen order; empty when the input holds no usable token.
26
+ */
27
+ export declare function tokenizeForSearch(input: string): string[];
28
+ /**
29
+ * Build the `MATCH` expression for a set of user words.
30
+ *
31
+ * Each token is quoted, because a bare token that happens to be an FTS5 keyword (`or`, `not`, `near`)
32
+ * is a syntax error. Tokens join with `OR`, not a space: in FTS5 a space means "same phrase, in
33
+ * order", which is far stricter than "any of these words".
34
+ * @param words - the user's words, already tokenized or not.
35
+ * @param maxTokens - ceiling on terms in one expression (default 32).
36
+ * @returns an FTS5 `MATCH` expression, or an empty string when the words hold no usable token.
37
+ */
38
+ export declare function searchMatchQuery(words: readonly string[], maxTokens?: number): string;
39
+ /**
40
+ * The indexable text for one object or column: its name and its comment, tokenized and joined.
41
+ * @param parts - the fields to fold in, in the order they should contribute tokens.
42
+ * @returns a space-joined token stream, ready for the `search.text` column.
43
+ */
44
+ export declare function searchDocument(parts: readonly (string | undefined)[]): string;
45
+ //# sourceMappingURL=tokenize.d.ts.map
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The one tokenizer both index writers and the resolver must agree on.
3
+ *
4
+ * FTS5's `unicode61` tokenizer splits on non-alphanumerics, which means a run of Chinese becomes a
5
+ * **single** token: store `广告点击` verbatim and `MATCH '广告'` returns nothing (verified on SQLite
6
+ * 3.50.4). So the indexed text is not the raw name and comment — it is the token stream this module
7
+ * produces, and the query side is the same function's output joined with `OR`.
8
+ *
9
+ * Two rules, deliberately few:
10
+ *
11
+ * - **ASCII identifiers** split on every change of character class, so `createdAt` → `created`, `at`
12
+ * and `password_hash` → `password`, `hash`; everything is lowercased.
13
+ * - **CJK runs** split into overlapping bigrams (`广告点击` → `广告`, `告点`, `点击`). A run of one
14
+ * character is its own token.
15
+ *
16
+ * The native writer reimplements these two rules in Rust. `packages/store/tests/tokenize-parity.spec.ts`
17
+ * is what keeps the two implementations honest: it asserts token-for-token equality on a fixture
18
+ * corpus, so a change here that is not mirrored there fails the build rather than silently degrading
19
+ * recall.
20
+ * @module dsh-plugin-dbgraph-service/tokenize
21
+ */
22
+ /** CJK ideographs and kana — the scripts whose tokens need bigram expansion. */
23
+ const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
24
+ /**
25
+ * Classify one character.
26
+ * @param ch - the character.
27
+ * @returns its class.
28
+ */
29
+ function classOf(ch) {
30
+ if (CJK.test(ch))
31
+ return 'cjk';
32
+ if (ch >= 'a' && ch <= 'z')
33
+ return 'lower';
34
+ if (ch >= 'A' && ch <= 'Z')
35
+ return 'upper';
36
+ if (ch >= '0' && ch <= '9')
37
+ return 'digit';
38
+ return 'other';
39
+ }
40
+ /**
41
+ * Split one identifier-ish run into its ASCII parts, at camel-case and letter/digit boundaries.
42
+ *
43
+ * The boundary needs one character of lookahead, which is why this walks indices instead of folding
44
+ * over characters: `createdAt` splits before `A` (a lower-case letter precedes an upper-case one),
45
+ * while `HTMLParser` splits before `P` (an upper-case run is followed by a lower-case letter). A
46
+ * class-change-per-character rule gets the first one wrong, emitting `created` `a` `t`.
47
+ * @param word - the run to split.
48
+ * @returns lowercase parts, in order.
49
+ */
50
+ function splitIdentifier(word) {
51
+ const chars = [...word];
52
+ const parts = [];
53
+ let current = '';
54
+ for (let index = 0; index < chars.length; index += 1) {
55
+ const ch = chars[index];
56
+ const previous = index === 0 ? '' : chars[index - 1];
57
+ const next = index + 1 < chars.length ? chars[index + 1] : '';
58
+ if (current !== '' && isBoundary(previous, ch, next)) {
59
+ parts.push(current);
60
+ current = '';
61
+ }
62
+ current += ch;
63
+ }
64
+ if (current !== '')
65
+ parts.push(current);
66
+ return parts.map(part => part.toLowerCase());
67
+ }
68
+ /**
69
+ * Whether a new part starts at `ch`.
70
+ * @param previous - the character before `ch`, or `''` at the start of the run.
71
+ * @param ch - the character being placed.
72
+ * @param next - the character after `ch`, or `''` at the end of the run.
73
+ * @returns true when `ch` begins a new part.
74
+ */
75
+ function isBoundary(previous, ch, next) {
76
+ const chClass = classOf(ch);
77
+ const previousClass = classOf(previous === '' ? ' ' : previous);
78
+ if (chClass !== previousClass) {
79
+ if (chClass === 'digit')
80
+ return true;
81
+ if (chClass === 'upper')
82
+ return previousClass === 'lower' || previousClass === 'digit';
83
+ // A lower-case letter after an upper-case run does not start a part by itself — the part already
84
+ // started at the upper-case letter that precedes it (see the acronym rule below).
85
+ if (chClass === 'lower' && previousClass === 'upper')
86
+ return false;
87
+ return true;
88
+ }
89
+ // Acronym boundary: inside a run of capitals, the last capital before a lower-case letter starts a
90
+ // new part, so `HTMLParser` → `HTML` + `Parser` rather than one run.
91
+ if (chClass === 'upper' && next !== '' && classOf(next) === 'lower')
92
+ return true;
93
+ return false;
94
+ }
95
+ /**
96
+ * Expand a CJK run into overlapping bigrams. A single-character run is its own token.
97
+ * @param run - the run, one or more CJK characters.
98
+ * @returns bigrams, in order.
99
+ */
100
+ function cjkBigrams(run) {
101
+ const chars = [...run];
102
+ if (chars.length === 1)
103
+ return chars;
104
+ const out = [];
105
+ for (let index = 0; index + 1 < chars.length; index += 1) {
106
+ out.push(`${chars[index]}${chars[index + 1]}`);
107
+ }
108
+ return out;
109
+ }
110
+ /**
111
+ * The token stream indexed for one text field, and the vocabulary a query is built from.
112
+ * @param input - a name, a comment, or a user's words.
113
+ * @returns deduplicated tokens in first-seen order; empty when the input holds no usable token.
114
+ */
115
+ export function tokenizeForSearch(input) {
116
+ const seen = new Set();
117
+ const tokens = [];
118
+ const push = (token) => {
119
+ if (token === '' || seen.has(token))
120
+ return;
121
+ seen.add(token);
122
+ tokens.push(token);
123
+ };
124
+ const normalized = input.normalize('NFKC');
125
+ let ascii = '';
126
+ let cjk = '';
127
+ const flushAscii = () => {
128
+ if (ascii === '')
129
+ return;
130
+ for (const part of splitIdentifier(ascii))
131
+ push(part);
132
+ ascii = '';
133
+ };
134
+ const flushCjk = () => {
135
+ if (cjk === '')
136
+ return;
137
+ for (const bigram of cjkBigrams(cjk))
138
+ push(bigram);
139
+ cjk = '';
140
+ };
141
+ for (const ch of normalized) {
142
+ const cls = classOf(ch);
143
+ if (cls === 'cjk') {
144
+ flushAscii();
145
+ cjk += ch;
146
+ continue;
147
+ }
148
+ flushCjk();
149
+ if (cls === 'other') {
150
+ flushAscii();
151
+ continue;
152
+ }
153
+ ascii += ch;
154
+ }
155
+ flushAscii();
156
+ flushCjk();
157
+ return tokens;
158
+ }
159
+ /**
160
+ * Build the `MATCH` expression for a set of user words.
161
+ *
162
+ * Each token is quoted, because a bare token that happens to be an FTS5 keyword (`or`, `not`, `near`)
163
+ * is a syntax error. Tokens join with `OR`, not a space: in FTS5 a space means "same phrase, in
164
+ * order", which is far stricter than "any of these words".
165
+ * @param words - the user's words, already tokenized or not.
166
+ * @param maxTokens - ceiling on terms in one expression (default 32).
167
+ * @returns an FTS5 `MATCH` expression, or an empty string when the words hold no usable token.
168
+ */
169
+ export function searchMatchQuery(words, maxTokens = 32) {
170
+ const tokens = [];
171
+ const seen = new Set();
172
+ for (const word of words) {
173
+ for (const token of tokenizeForSearch(word)) {
174
+ if (seen.has(token))
175
+ continue;
176
+ seen.add(token);
177
+ tokens.push(token);
178
+ if (tokens.length >= maxTokens) {
179
+ return tokens.map(token => `"${token.replace(/"/g, '""')}"`).join(' OR ');
180
+ }
181
+ }
182
+ }
183
+ return tokens.map(token => `"${token.replace(/"/g, '""')}"`).join(' OR ');
184
+ }
185
+ /**
186
+ * The indexable text for one object or column: its name and its comment, tokenized and joined.
187
+ * @param parts - the fields to fold in, in the order they should contribute tokens.
188
+ * @returns a space-joined token stream, ready for the `search.text` column.
189
+ */
190
+ export function searchDocument(parts) {
191
+ const tokens = [];
192
+ const seen = new Set();
193
+ for (const part of parts) {
194
+ if (part === undefined || part === '')
195
+ continue;
196
+ for (const token of tokenizeForSearch(part)) {
197
+ if (seen.has(token))
198
+ continue;
199
+ seen.add(token);
200
+ tokens.push(token);
201
+ }
202
+ }
203
+ return tokens.join(' ');
204
+ }
205
+ //# sourceMappingURL=tokenize.js.map