@4fu/arbor-notes-sqlite 2026.1106.0 → 2026.1107.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.
package/schema.d.ts CHANGED
@@ -23,12 +23,17 @@
23
23
  * it froze them at, so a window's prefix never changes under it.
24
24
  * - `notes_fts` - trigram index for exact substrings over title, summary and
25
25
  * body.
26
+ * - `notes_words` - word index for BM25 over the same three columns, segmented
27
+ * (docs/journal.md#search).
26
28
  */
27
29
  import type { DatabaseSync } from "node:sqlite";
30
+ import type { Segmenter } from "@4fu/arbor-interfaces";
28
31
  /** Bumped when a change makes an existing database unusable. */
29
- export declare const SCHEMA_VERSION = 1;
32
+ export declare const SCHEMA_VERSION = 2;
30
33
  /** The FTS5 table note search reads. */
31
34
  export declare const FTS_TABLE = "notes_fts";
32
- /** Create the schema when it is missing, and check the version when it is not. */
33
- export declare function createSchema(db: DatabaseSync): void;
35
+ /** The word index note search reads. */
36
+ export declare const WORD_TABLE = "notes_words";
37
+ /** Create the schema when it is missing, migrate an older one, and record the version. */
38
+ export declare function createSchema(db: DatabaseSync, segmenter?: Segmenter): void;
34
39
  export declare function readMeta(db: DatabaseSync, key: string): string | null;
package/schema.js CHANGED
@@ -23,11 +23,25 @@
23
23
  * it froze them at, so a window's prefix never changes under it.
24
24
  * - `notes_fts` - trigram index for exact substrings over title, summary and
25
25
  * body.
26
+ * - `notes_words` - word index for BM25 over the same three columns, segmented
27
+ * (docs/journal.md#search).
26
28
  */
29
+ import { indexText, resolveSegmenter } from "@4fu/arbor-lexical";
27
30
  /** Bumped when a change makes an existing database unusable. */
28
- export const SCHEMA_VERSION = 1;
31
+ export const SCHEMA_VERSION = 2;
29
32
  /** The FTS5 table note search reads. */
30
33
  export const FTS_TABLE = "notes_fts";
34
+ /** The word index note search reads. */
35
+ export const WORD_TABLE = "notes_words";
36
+ /** The trigram index: exact substrings, for Chinese and mixed text. */
37
+ const TRIGRAM_INDEX = `CREATE VIRTUAL TABLE IF NOT EXISTS ${FTS_TABLE}
38
+ USING fts5(title, summary, body, tokenize = 'trigram')`;
39
+ /**
40
+ * The word index: one column per searched field, each holding that field's
41
+ * segments, one per line. The columns are what the BM25 weights rank by.
42
+ */
43
+ const WORD_INDEX = `CREATE VIRTUAL TABLE IF NOT EXISTS ${WORD_TABLE}
44
+ USING fts5(title, summary, body, tokenize = 'unicode61')`;
31
45
  const STATEMENTS = [
32
46
  `PRAGMA journal_mode = WAL`,
33
47
  `PRAGMA foreign_keys = ON`,
@@ -114,25 +128,61 @@ const STATEMENTS = [
114
128
  time INTEGER NOT NULL,
115
129
  PRIMARY KEY (epoch, note)
116
130
  )`,
117
- // Exact substrings, for Chinese and mixed text.
118
- `CREATE VIRTUAL TABLE IF NOT EXISTS ${FTS_TABLE}
119
- USING fts5(title, summary, body, tokenize = 'trigram')`,
131
+ TRIGRAM_INDEX,
132
+ WORD_INDEX,
120
133
  ];
121
- /** Create the schema when it is missing, and check the version when it is not. */
122
- export function createSchema(db) {
134
+ /**
135
+ * Version 2 added the word index (docs/journal.md#search): a segmented word
136
+ * index with BM25 beside the trigram index, which is what lets a multi-word
137
+ * query find a note that holds the words apart from each other. Every note,
138
+ * every ref and the trigram index stay, so a version-1 database opens where it
139
+ * left off: the notes it already holds are segmented into the new index in the
140
+ * same step that records the version, and a delete leaves a note out of it.
141
+ */
142
+ function migrateToVersion2(db, segmenter) {
143
+ // One transaction: the index and the version that says it is there land
144
+ // together, so a database that stops half way through is still the version it
145
+ // was and migrates again, whole, instead of half-indexing twice.
146
+ db.exec("BEGIN IMMEDIATE");
147
+ try {
148
+ db.exec(WORD_INDEX);
149
+ const insert = db.prepare(`INSERT INTO ${WORD_TABLE} (rowid, title, summary, body) VALUES (?, ?, ?, ?)`);
150
+ const rows = db
151
+ .prepare("SELECT id, title, summary, body FROM notes WHERE status <> 'deleted'")
152
+ .all();
153
+ for (const row of rows) {
154
+ insert.run(Number(row.id), indexText(row.title, segmenter), indexText(row.summary, segmenter), indexText(row.body, segmenter));
155
+ }
156
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
157
+ db.exec("COMMIT");
158
+ }
159
+ catch (error) {
160
+ try {
161
+ db.exec("ROLLBACK");
162
+ }
163
+ catch {
164
+ // The transaction is already gone; the original error is what matters.
165
+ }
166
+ throw error;
167
+ }
168
+ }
169
+ /** Create the schema when it is missing, migrate an older one, and record the version. */
170
+ export function createSchema(db, segmenter = resolveSegmenter()) {
123
171
  // The version lives in the one table that has to exist before anything else.
124
172
  db.exec(`CREATE TABLE IF NOT EXISTS meta (
125
173
  key TEXT PRIMARY KEY,
126
174
  value TEXT NOT NULL
127
175
  )`);
128
176
  const version = readMeta(db, "schema_version");
129
- if (version !== null && Number(version) !== SCHEMA_VERSION) {
130
- throw new Error(`notes schema ${version} is not supported by this build (${SCHEMA_VERSION}); migrate or rebuild the database`);
177
+ if (version !== null && Number(version) > SCHEMA_VERSION) {
178
+ throw new Error(`notes schema ${version} is newer than this build (${SCHEMA_VERSION}); upgrade arbor, or migrate the database`);
131
179
  }
180
+ if (Number(version) === SCHEMA_VERSION - 1)
181
+ migrateToVersion2(db, segmenter);
132
182
  for (const statement of STATEMENTS)
133
183
  db.exec(statement);
134
- if (version === null) {
135
- db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
184
+ if (version === null || Number(version) !== SCHEMA_VERSION) {
185
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
136
186
  }
137
187
  }
138
188
  export function readMeta(db, key) {
package/search.d.ts CHANGED
@@ -1,27 +1,60 @@
1
1
  /**
2
2
  * Lexical search over notes (docs/journal.md#search, docs/memory.md).
3
3
  *
4
- * Lists show summaries and never bodies, so note search is a lookup of the
5
- * notes whose title, summary or body holds the query as an exact substring:
6
- * the trigram index answers three characters and more, which is what makes a
7
- * Chinese substring findable, and a `LIKE` scan answers a one- or
8
- * two-character query, which has no trigram of its own. Results come back in id
9
- * order, so a page is byte-stable however it is paged.
4
+ * The channels the journal has, merged the same way, and one more when a
5
+ * deployment installed it:
6
+ *
7
+ * - a word index, segmented and ranked with BM25 over title, summary and body,
8
+ * the title and the summary weighted above the body;
9
+ * - a trigram index for exact substrings, which is what makes a Chinese
10
+ * substring findable;
11
+ * - a `LIKE` scan, for one- and two-character queries, which have no trigram of
12
+ * their own;
13
+ * - a semantic channel, note text embedded and searched by meaning, when
14
+ * `@4fu/arbor-search-zvec` is installed.
15
+ *
16
+ * Exact substring hits come first, then word hits that are not already there,
17
+ * then the hits only the semantic channel found, each channel by its own
18
+ * relevance. The `LIKE` scan has no relevance of its own, so it answers in id
19
+ * order and a page is byte-stable however it is paged.
10
20
  */
11
21
  import type { DatabaseSync, SQLInputValue } from "node:sqlite";
12
- /** A query shorter than this cannot use the trigram index. */
13
- export declare const MIN_TRIGRAM_CHARS = 3;
22
+ import type { NoteStatus, Segmenter } from "@4fu/arbor-interfaces";
23
+ import { MIN_TRIGRAM_CHARS, escapeLike, likePattern, quoteFts } from "@4fu/arbor-lexical";
24
+ import type { Ranked } from "@4fu/arbor-lexical";
25
+ export { MIN_TRIGRAM_CHARS, escapeLike, likePattern, quoteFts };
26
+ /**
27
+ * The BM25 column weights both note indexes are ranked with, in the order the
28
+ * FTS5 tables carry their columns: title, summary, body. The title names the
29
+ * note and the summary is its one line, so a hit in either counts for more
30
+ * than one in the body: ten times as much for the title, four times for the
31
+ * summary.
32
+ */
33
+ export declare const COLUMN_WEIGHTS = "10.0, 4.0, 1.0";
14
34
  /** One condition over `notes n`, and the values it is bound to. */
15
35
  export interface SubstringClause {
16
36
  sql: string;
17
37
  params: readonly SQLInputValue[];
18
38
  }
19
- /** The notes holding the query, as a condition over `notes n`. */
39
+ /** The notes holding the query as an exact substring, as a condition over `notes n`. */
20
40
  export declare function substringClause(query: string): SubstringClause;
21
- /** The ids of several notes that hold the query, by id. */
22
- export declare function holdingIds(db: DatabaseSync, query: string, ids: readonly number[]): number[];
23
- /** Escape a literal for `LIKE ... ESCAPE '\'`. */
24
- export declare function escapeLike(value: string): string;
25
- export declare function likePattern(query: string): string;
26
- /** Quote one phrase for an FTS5 `MATCH`, so outside text is never syntax. */
27
- export declare function quoteFts(value: string): string;
41
+ /**
42
+ * The notes a text query matches, in the order search returns them: exact
43
+ * substring hits first, then word hits that are not already there, each channel
44
+ * by its own relevance, then the hits only the semantic channel found. A
45
+ * one- or two-character query has no trigram and no word match of its own, so
46
+ * the fallback scan answers it in id order, and the semantic channel's hits
47
+ * follow it.
48
+ *
49
+ * `semantic` is the channel's own candidates as ranks, in the order it gave
50
+ * them: the note store asks it for the notes nearest the query and applies its
51
+ * own filters to what comes back, so a channel that filters less than the store
52
+ * does still answers the read it was asked (docs/memory.md#search).
53
+ */
54
+ export declare function matchedIds(db: DatabaseSync, query: string, segmenter: Segmenter, status?: NoteStatus, semantic?: readonly Ranked[]): number[];
55
+ /**
56
+ * The ids of several notes that hold the query: an exact substring or the
57
+ * query's words. `about` and `linked` bound a lookup to the notes the journal
58
+ * resolved, in their own weight order, so this answers a set and not an order.
59
+ */
60
+ export declare function holdingIds(db: DatabaseSync, query: string, ids: readonly number[], segmenter: Segmenter): number[];
package/search.js CHANGED
@@ -1,50 +1,127 @@
1
1
  /**
2
2
  * Lexical search over notes (docs/journal.md#search, docs/memory.md).
3
3
  *
4
- * Lists show summaries and never bodies, so note search is a lookup of the
5
- * notes whose title, summary or body holds the query as an exact substring:
6
- * the trigram index answers three characters and more, which is what makes a
7
- * Chinese substring findable, and a `LIKE` scan answers a one- or
8
- * two-character query, which has no trigram of its own. Results come back in id
9
- * order, so a page is byte-stable however it is paged.
4
+ * The channels the journal has, merged the same way, and one more when a
5
+ * deployment installed it:
6
+ *
7
+ * - a word index, segmented and ranked with BM25 over title, summary and body,
8
+ * the title and the summary weighted above the body;
9
+ * - a trigram index for exact substrings, which is what makes a Chinese
10
+ * substring findable;
11
+ * - a `LIKE` scan, for one- and two-character queries, which have no trigram of
12
+ * their own;
13
+ * - a semantic channel, note text embedded and searched by meaning, when
14
+ * `@4fu/arbor-search-zvec` is installed.
15
+ *
16
+ * Exact substring hits come first, then word hits that are not already there,
17
+ * then the hits only the semantic channel found, each channel by its own
18
+ * relevance. The `LIKE` scan has no relevance of its own, so it answers in id
19
+ * order and a page is byte-stable however it is paged.
10
20
  */
11
- import { FTS_TABLE } from "./schema.js";
12
- /** A query shorter than this cannot use the trigram index. */
13
- export const MIN_TRIGRAM_CHARS = 3;
14
- /** The notes holding the query, as a condition over `notes n`. */
21
+ import { MIN_TRIGRAM_CHARS, escapeLike, likePattern, mergeHits, quoteFts, trigramMatch, wordMatch, } from "@4fu/arbor-lexical";
22
+ import { FTS_TABLE, WORD_TABLE } from "./schema.js";
23
+ export { MIN_TRIGRAM_CHARS, escapeLike, likePattern, quoteFts };
24
+ /**
25
+ * The BM25 column weights both note indexes are ranked with, in the order the
26
+ * FTS5 tables carry their columns: title, summary, body. The title names the
27
+ * note and the summary is its one line, so a hit in either counts for more
28
+ * than one in the body: ten times as much for the title, four times for the
29
+ * summary.
30
+ */
31
+ export const COLUMN_WEIGHTS = "10.0, 4.0, 1.0";
32
+ /** The `LIKE` scan's condition, for a query the trigram index cannot answer. */
33
+ function likeClause(query) {
34
+ const pattern = likePattern(query);
35
+ return {
36
+ sql: `(n.title LIKE ? ESCAPE '\\' OR n.summary LIKE ? ESCAPE '\\' OR n.body LIKE ? ESCAPE '\\')`,
37
+ params: [pattern, pattern, pattern],
38
+ };
39
+ }
40
+ /** The word index's condition: every one of the query's segments. */
41
+ function wordClause(tokens) {
42
+ return {
43
+ sql: `n.id IN (SELECT rowid FROM ${WORD_TABLE} WHERE ${WORD_TABLE} MATCH ?)`,
44
+ params: [wordMatch(tokens)],
45
+ };
46
+ }
47
+ /** The notes holding the query as an exact substring, as a condition over `notes n`. */
15
48
  export function substringClause(query) {
16
49
  if (Array.from(query).length >= MIN_TRIGRAM_CHARS) {
17
50
  return {
18
51
  sql: `n.id IN (SELECT rowid FROM ${FTS_TABLE} WHERE ${FTS_TABLE} MATCH ?)`,
19
- params: [quoteFts(query)],
52
+ params: [trigramMatch(query)],
20
53
  };
21
54
  }
22
- const pattern = likePattern(query);
23
- return {
24
- sql: `(n.title LIKE ? ESCAPE '\\' OR n.summary LIKE ? ESCAPE '\\' OR n.body LIKE ? ESCAPE '\\')`,
25
- params: [pattern, pattern, pattern],
26
- };
55
+ return likeClause(query);
27
56
  }
28
- /** The ids of several notes that hold the query, by id. */
29
- export function holdingIds(db, query, ids) {
57
+ /** Every match from one index, by relevance, with the status filter applied. */
58
+ function ranked(db, table, match, status) {
59
+ const filtered = status === undefined ? "" : " AND n.status = ?";
60
+ const params = status === undefined ? [match] : [match, status];
61
+ const rows = db
62
+ .prepare(`SELECT f.rowid AS rowid, bm25(${table}, ${COLUMN_WEIGHTS}) AS rank
63
+ FROM ${table} f JOIN notes n ON n.id = f.rowid
64
+ WHERE ${table} MATCH ?${filtered}
65
+ ORDER BY rank, f.rowid`)
66
+ .all(...params);
67
+ return rows.map((row) => ({ rowid: Number(row.rowid), rank: Number(row.rank) }));
68
+ }
69
+ /** The notes a one- or two-character query holds, in id order. */
70
+ function scanIds(db, query, status) {
71
+ const clause = likeClause(query);
72
+ // A delete leaves the row and marks it, and the scan reads the notes table
73
+ // itself, so it leaves the deleted ones out on its own (docs/memory.md).
74
+ const filtered = status === undefined ? " AND n.status <> 'deleted'" : " AND n.status = ?";
75
+ const rows = db
76
+ .prepare(`SELECT n.id AS id FROM notes n WHERE ${clause.sql}${filtered} ORDER BY n.id`)
77
+ .all(...clause.params, ...(status === undefined ? [] : [status]));
78
+ return rows.map((row) => Number(row.id));
79
+ }
80
+ /**
81
+ * The notes a text query matches, in the order search returns them: exact
82
+ * substring hits first, then word hits that are not already there, each channel
83
+ * by its own relevance, then the hits only the semantic channel found. A
84
+ * one- or two-character query has no trigram and no word match of its own, so
85
+ * the fallback scan answers it in id order, and the semantic channel's hits
86
+ * follow it.
87
+ *
88
+ * `semantic` is the channel's own candidates as ranks, in the order it gave
89
+ * them: the note store asks it for the notes nearest the query and applies its
90
+ * own filters to what comes back, so a channel that filters less than the store
91
+ * does still answers the read it was asked (docs/memory.md#search).
92
+ */
93
+ export function matchedIds(db, query, segmenter, status, semantic = []) {
94
+ if (Array.from(query).length < MIN_TRIGRAM_CHARS) {
95
+ // A one- or two-character query has no trigram and no word match of its
96
+ // own: the scan answers it in id order, and the semantic channel's hits
97
+ // follow it.
98
+ const scanned = scanIds(db, query, status).map((rowid) => ({ rowid, rank: 0 }));
99
+ return mergeHits({ channel: "substring", hits: scanned }, { channel: "semantic", hits: semantic }).map((hit) => hit.rowid);
100
+ }
101
+ const substrings = ranked(db, FTS_TABLE, trigramMatch(query), status);
102
+ const match = wordMatch(segmenter.segment(query));
103
+ const words = match === "" ? [] : ranked(db, WORD_TABLE, match, status);
104
+ return mergeHits({ channel: "substring", hits: substrings }, { channel: "word", hits: words }, { channel: "semantic", hits: semantic }).map((hit) => hit.rowid);
105
+ }
106
+ /**
107
+ * The ids of several notes that hold the query: an exact substring or the
108
+ * query's words. `about` and `linked` bound a lookup to the notes the journal
109
+ * resolved, in their own weight order, so this answers a set and not an order.
110
+ */
111
+ export function holdingIds(db, query, ids, segmenter) {
30
112
  if (ids.length === 0)
31
113
  return [];
32
- const clause = substringClause(query);
114
+ const clauses = [substringClause(query)];
115
+ if (Array.from(query).length >= MIN_TRIGRAM_CHARS) {
116
+ const tokens = segmenter.segment(query);
117
+ if (wordMatch(tokens) !== "")
118
+ clauses.push(wordClause(tokens));
119
+ }
33
120
  const rows = db
34
121
  .prepare(`SELECT n.id AS id FROM notes n
35
- WHERE ${clause.sql} AND n.id IN (${ids.map(() => "?").join(", ")})
122
+ WHERE (${clauses.map((clause) => clause.sql).join(" OR ")})
123
+ AND n.id IN (${ids.map(() => "?").join(", ")})
36
124
  ORDER BY n.id`)
37
- .all(...clause.params, ...ids);
125
+ .all(...clauses.flatMap((clause) => clause.params), ...ids);
38
126
  return rows.map((row) => Number(row.id));
39
127
  }
40
- /** Escape a literal for `LIKE ... ESCAPE '\'`. */
41
- export function escapeLike(value) {
42
- return value.replace(/[\\%_]/g, (character) => `\\${character}`);
43
- }
44
- export function likePattern(query) {
45
- return `%${escapeLike(query)}%`;
46
- }
47
- /** Quote one phrase for an FTS5 `MATCH`, so outside text is never syntax. */
48
- export function quoteFts(value) {
49
- return `"${value.replace(/"/g, '""')}"`;
50
- }
package/semantic.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The semantic channel the note store uses when the caller names none
3
+ * (docs/memory.md#search).
4
+ *
5
+ * The spec calls for note text to be embedded and searched by meaning beside the
6
+ * lexical indexes, and the default is zvec when `@4fu/arbor-search-zvec` is
7
+ * installed: `resolveSemanticIndex` loads it, and the store uses what it returns
8
+ * when the caller names no channel. The package is an optional peer dependency,
9
+ * so a deployment that leaves it out keeps working.
10
+ *
11
+ * What is left here is the absence that answers when the package is not there:
12
+ * no channel, and search is exactly lexical. That is also the answer when the
13
+ * package is installed but its vector store will not open - zvec's prebuilt
14
+ * binary does not load on this platform, or another host holds the collection -
15
+ * and when no embedding model is available, because the package says so by
16
+ * handing back nothing.
17
+ */
18
+ import type { SemanticIndex } from "@4fu/arbor-interfaces";
19
+ /**
20
+ * The semantic channel a note store uses when the caller names none: the zvec
21
+ * one when the package is installed and its store opens, and none otherwise.
22
+ *
23
+ * `load` replaces how the zvec package is loaded. A host that resolves modules
24
+ * its own way passes its own; so does a test that has no zvec package to load. A
25
+ * load that fails falls back rather than stopping the store, because the package
26
+ * is optional: it may be missing, or its prebuilt binary may not load on this
27
+ * platform.
28
+ */
29
+ export declare function resolveSemanticIndex(path: string, load?: (path: string) => SemanticIndex | null): SemanticIndex | null;
package/semantic.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The semantic channel the note store uses when the caller names none
3
+ * (docs/memory.md#search).
4
+ *
5
+ * The spec calls for note text to be embedded and searched by meaning beside the
6
+ * lexical indexes, and the default is zvec when `@4fu/arbor-search-zvec` is
7
+ * installed: `resolveSemanticIndex` loads it, and the store uses what it returns
8
+ * when the caller names no channel. The package is an optional peer dependency,
9
+ * so a deployment that leaves it out keeps working.
10
+ *
11
+ * What is left here is the absence that answers when the package is not there:
12
+ * no channel, and search is exactly lexical. That is also the answer when the
13
+ * package is installed but its vector store will not open - zvec's prebuilt
14
+ * binary does not load on this platform, or another host holds the collection -
15
+ * and when no embedding model is available, because the package says so by
16
+ * handing back nothing.
17
+ */
18
+ import { createRequire } from "node:module";
19
+ /** The package that holds the zvec semantic index, when a deployment installed it. */
20
+ const ZVEC_PACKAGE = "@4fu/arbor-search-zvec";
21
+ /**
22
+ * Load the zvec semantic index over a store beside the notes database's own, or
23
+ * `null` when it is not there: the package is not installed, or it holds no
24
+ * index.
25
+ */
26
+ function loadZvec(path) {
27
+ const require = createRequire(import.meta.url);
28
+ const loaded = require(ZVEC_PACKAGE);
29
+ const create = loaded?.createZvecIndex;
30
+ if (typeof create !== "function")
31
+ return null;
32
+ const index = create({ path });
33
+ if (index === null || typeof index !== "object")
34
+ return null;
35
+ const shape = index;
36
+ if (typeof shape.add !== "function" || typeof shape.search !== "function")
37
+ return null;
38
+ return index;
39
+ }
40
+ /**
41
+ * The semantic channel a note store uses when the caller names none: the zvec
42
+ * one when the package is installed and its store opens, and none otherwise.
43
+ *
44
+ * `load` replaces how the zvec package is loaded. A host that resolves modules
45
+ * its own way passes its own; so does a test that has no zvec package to load. A
46
+ * load that fails falls back rather than stopping the store, because the package
47
+ * is optional: it may be missing, or its prebuilt binary may not load on this
48
+ * platform.
49
+ */
50
+ export function resolveSemanticIndex(path, load = loadZvec) {
51
+ try {
52
+ return load(path);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }