@4fu/arbor-journal-sqlite 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.
package/search.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Lexical search over the journal's events (docs/journal.md#search).
3
+ *
4
+ * Three indexes, merged:
5
+ *
6
+ * - a word index, segmented and ranked with BM25;
7
+ * - a trigram index for exact substrings, which is what makes a Chinese
8
+ * substring findable;
9
+ * - a substring scan, for one- and two-character queries and for queries the
10
+ * trigram index cannot answer.
11
+ *
12
+ * Exact substring hits come first, then word hits that are not already there.
13
+ */
14
+ import type { DatabaseSync, SQLInputValue } from "node:sqlite";
15
+ export interface SearchFilters {
16
+ kinds?: readonly string[];
17
+ window?: string;
18
+ targets?: readonly string[];
19
+ external?: string;
20
+ afterSeq?: number;
21
+ beforeSeq?: number;
22
+ }
23
+ /** One matching row, with its raw relevance. Lower `rank` is better. */
24
+ export interface Ranked {
25
+ seq: number;
26
+ rank: number;
27
+ }
28
+ export interface FilterSql {
29
+ sql: string;
30
+ params: SQLInputValue[];
31
+ }
32
+ /**
33
+ * The filters a read applies, as SQL over `events e`. A target filter is an
34
+ * `EXISTS`, so one event with three targets still counts once.
35
+ */
36
+ export declare function buildFilters(filters: SearchFilters): FilterSql;
37
+ /** Every match from one FTS5 index, ordered by relevance. */
38
+ export declare function ranked(db: DatabaseSync, table: string, match: string, filters: SearchFilters): Ranked[];
39
+ /** Every substring match, newest first. The scan has no relevance of its own. */
40
+ export declare function substring(db: DatabaseSync, pattern: string, filters: SearchFilters): Ranked[];
41
+ /** Escape a literal for `LIKE ... ESCAPE '\'`. */
42
+ export declare function escapeLike(value: string): string;
43
+ export declare function likePattern(query: string): string;
44
+ /** Quote one term for an FTS5 `MATCH`, so outside text is never syntax. */
45
+ export declare function quoteFts(value: string): string;
46
+ /** The trigram index takes one phrase. */
47
+ export declare function trigramMatch(query: string): string;
48
+ /** The word index takes the query's segments, all of them required. */
49
+ export declare function wordMatch(tokens: readonly string[]): string;
package/search.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Lexical search over the journal's events (docs/journal.md#search).
3
+ *
4
+ * Three indexes, merged:
5
+ *
6
+ * - a word index, segmented and ranked with BM25;
7
+ * - a trigram index for exact substrings, which is what makes a Chinese
8
+ * substring findable;
9
+ * - a substring scan, for one- and two-character queries and for queries the
10
+ * trigram index cannot answer.
11
+ *
12
+ * Exact substring hits come first, then word hits that are not already there.
13
+ */
14
+ /**
15
+ * The filters a read applies, as SQL over `events e`. A target filter is an
16
+ * `EXISTS`, so one event with three targets still counts once.
17
+ */
18
+ export function buildFilters(filters) {
19
+ const clauses = [];
20
+ const params = [];
21
+ if (filters.kinds !== undefined && filters.kinds.length > 0) {
22
+ clauses.push(`AND e.kind IN (${filters.kinds.map(() => "?").join(", ")})`);
23
+ params.push(...filters.kinds);
24
+ }
25
+ if (filters.window !== undefined) {
26
+ clauses.push("AND e.window = ?");
27
+ params.push(filters.window);
28
+ }
29
+ if (filters.external !== undefined) {
30
+ clauses.push("AND e.external = ?");
31
+ params.push(filters.external);
32
+ }
33
+ for (const target of filters.targets ?? []) {
34
+ clauses.push("AND EXISTS (SELECT 1 FROM event_targets et WHERE et.seq = e.seq AND et.target = ?)");
35
+ params.push(target);
36
+ }
37
+ if (filters.afterSeq !== undefined) {
38
+ clauses.push("AND e.seq > ?");
39
+ params.push(filters.afterSeq);
40
+ }
41
+ if (filters.beforeSeq !== undefined) {
42
+ clauses.push("AND e.seq < ?");
43
+ params.push(filters.beforeSeq);
44
+ }
45
+ return { sql: clauses.length === 0 ? "" : ` ${clauses.join(" ")}`, params };
46
+ }
47
+ /** Every match from one FTS5 index, ordered by relevance. */
48
+ export function ranked(db, table, match, filters) {
49
+ const built = buildFilters(filters);
50
+ const rows = db
51
+ .prepare(`SELECT f.rowid AS seq, bm25(${table}) AS rank
52
+ FROM ${table} f JOIN events e ON e.seq = f.rowid
53
+ WHERE ${table} MATCH ?${built.sql}
54
+ ORDER BY rank, f.rowid`)
55
+ .all(match, ...built.params);
56
+ return rows.map((row) => ({ seq: Number(row.seq), rank: Number(row.rank) }));
57
+ }
58
+ /** Every substring match, newest first. The scan has no relevance of its own. */
59
+ export function substring(db, pattern, filters) {
60
+ const built = buildFilters(filters);
61
+ const rows = db
62
+ .prepare(`SELECT e.seq AS seq, 1.0 AS rank FROM events e
63
+ WHERE e.body LIKE ? ESCAPE '\\'${built.sql}
64
+ ORDER BY e.seq DESC`)
65
+ .all(pattern, ...built.params);
66
+ return rows.map((row) => ({ seq: Number(row.seq), rank: Number(row.rank) }));
67
+ }
68
+ /** Escape a literal for `LIKE ... ESCAPE '\'`. */
69
+ export function escapeLike(value) {
70
+ return value.replace(/[\\%_]/g, (character) => `\\${character}`);
71
+ }
72
+ export function likePattern(query) {
73
+ return `%${escapeLike(query)}%`;
74
+ }
75
+ /** Quote one term for an FTS5 `MATCH`, so outside text is never syntax. */
76
+ export function quoteFts(value) {
77
+ return `"${value.replace(/"/g, '""')}"`;
78
+ }
79
+ /** The trigram index takes one phrase. */
80
+ export function trigramMatch(query) {
81
+ return quoteFts(query);
82
+ }
83
+ /** The word index takes the query's segments, all of them required. */
84
+ export function wordMatch(tokens) {
85
+ return tokens
86
+ .filter((token) => token.length > 0)
87
+ .map(quoteFts)
88
+ .join(" ");
89
+ }
package/segmenter.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Word segmentation for the journal's word index (docs/journal.md#search).
3
+ *
4
+ * The spec calls for jieba segmentation with BM25, and the default is jieba
5
+ * when `@4fu/arbor-segmenter-jieba` is installed: `resolveSegmenter` loads it, and
6
+ * the journal uses what it returns when the caller names no segmenter. The
7
+ * package is an optional peer dependency, so a deployment that leaves it out
8
+ * keeps working.
9
+ *
10
+ * What is left here is the approximation that answers when jieba is not there:
11
+ * Chinese, Japanese and Korean runs are indexed per character and per bigram,
12
+ * and every other script per word. Character and bigram indexing is what makes
13
+ * a two-character Chinese query find the phrase it appears in, without a
14
+ * dictionary.
15
+ */
16
+ import type { Segmenter } from "@4fu/arbor-interfaces";
17
+ export declare const fallbackSegmenter: Segmenter;
18
+ /**
19
+ * The segmenter a journal uses when the caller names none: jieba when the
20
+ * package is installed, the per-character-and-bigram approximation otherwise.
21
+ *
22
+ * `load` replaces how the jieba package is loaded. A host that resolves modules
23
+ * its own way passes its own; so does a test that has no jieba package to load.
24
+ * A load that fails falls back rather than stopping the journal, because the
25
+ * package is optional: it may be missing, or its prebuilt binary may not load
26
+ * on this platform.
27
+ */
28
+ export declare function resolveSegmenter(load?: () => Segmenter | null): Segmenter;
29
+ /** The text the word index stores: the segments, one per line. */
30
+ export declare function indexText(text: string, segmenter: Segmenter): string;
package/segmenter.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Word segmentation for the journal's word index (docs/journal.md#search).
3
+ *
4
+ * The spec calls for jieba segmentation with BM25, and the default is jieba
5
+ * when `@4fu/arbor-segmenter-jieba` is installed: `resolveSegmenter` loads it, and
6
+ * the journal uses what it returns when the caller names no segmenter. The
7
+ * package is an optional peer dependency, so a deployment that leaves it out
8
+ * keeps working.
9
+ *
10
+ * What is left here is the approximation that answers when jieba is not there:
11
+ * Chinese, Japanese and Korean runs are indexed per character and per bigram,
12
+ * and every other script per word. Character and bigram indexing is what makes
13
+ * a two-character Chinese query find the phrase it appears in, without a
14
+ * dictionary.
15
+ */
16
+ import { createRequire } from "node:module";
17
+ /** The package that holds the jieba segmenter, when a deployment installed it. */
18
+ const JIEBA_PACKAGE = "@4fu/arbor-segmenter-jieba";
19
+ /** CJK unified ideographs, extensions, compatibility, kana and hangul. */
20
+ function isCjk(codePoint) {
21
+ return ((codePoint >= 0x3400 && codePoint <= 0x4dbf) ||
22
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) ||
23
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
24
+ (codePoint >= 0x3040 && codePoint <= 0x30ff) ||
25
+ (codePoint >= 0xac00 && codePoint <= 0xd7af) ||
26
+ (codePoint >= 0x20000 && codePoint <= 0x2a6df) ||
27
+ (codePoint >= 0x2a700 && codePoint <= 0x2ebef));
28
+ }
29
+ /** Whitespace and punctuation separate words; everything else is part of one. */
30
+ const SEPARATOR = /[\s\p{P}\p{S}\p{C}\p{Z}]/u;
31
+ export const fallbackSegmenter = {
32
+ segment(text) {
33
+ const chars = Array.from(text);
34
+ const tokens = [];
35
+ let index = 0;
36
+ while (index < chars.length) {
37
+ const first = chars[index];
38
+ const codePoint = first.codePointAt(0);
39
+ if (isCjk(codePoint)) {
40
+ const run = [];
41
+ while (index < chars.length) {
42
+ const char = chars[index];
43
+ const point = char.codePointAt(0);
44
+ if (!isCjk(point))
45
+ break;
46
+ run.push(char);
47
+ index += 1;
48
+ }
49
+ tokens.push(...run);
50
+ for (let offset = 0; offset + 1 < run.length; offset += 1) {
51
+ tokens.push(run[offset] + run[offset + 1]);
52
+ }
53
+ continue;
54
+ }
55
+ if (SEPARATOR.test(first)) {
56
+ index += 1;
57
+ continue;
58
+ }
59
+ const run = [];
60
+ while (index < chars.length) {
61
+ const char = chars[index];
62
+ const point = char.codePointAt(0);
63
+ if (isCjk(point) || SEPARATOR.test(char))
64
+ break;
65
+ run.push(char);
66
+ index += 1;
67
+ }
68
+ tokens.push(run.join("").toLowerCase());
69
+ }
70
+ return tokens;
71
+ },
72
+ };
73
+ /**
74
+ * Load the jieba segmenter, or `null` when it is not there: the package is not
75
+ * installed, or it holds no segmenter.
76
+ */
77
+ function loadJieba() {
78
+ const require = createRequire(import.meta.url);
79
+ const loaded = require(JIEBA_PACKAGE);
80
+ const candidate = loaded?.jiebaSegmenter;
81
+ if (candidate === null || typeof candidate !== "object")
82
+ return null;
83
+ const shape = candidate;
84
+ return typeof shape.segment === "function" ? candidate : null;
85
+ }
86
+ /**
87
+ * The segmenter a journal uses when the caller names none: jieba when the
88
+ * package is installed, the per-character-and-bigram approximation otherwise.
89
+ *
90
+ * `load` replaces how the jieba package is loaded. A host that resolves modules
91
+ * its own way passes its own; so does a test that has no jieba package to load.
92
+ * A load that fails falls back rather than stopping the journal, because the
93
+ * package is optional: it may be missing, or its prebuilt binary may not load
94
+ * on this platform.
95
+ */
96
+ export function resolveSegmenter(load = loadJieba) {
97
+ try {
98
+ return load() ?? fallbackSegmenter;
99
+ }
100
+ catch {
101
+ return fallbackSegmenter;
102
+ }
103
+ }
104
+ /** The text the word index stores: the segments, one per line. */
105
+ export function indexText(text, segmenter) {
106
+ return segmenter.segment(text).join("\n");
107
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The default `Journal`: SQLite (docs/journal.md).
3
+ *
4
+ * What this implementation owns:
5
+ *
6
+ * - the one global order: `seq`, assigned at append and only growing, with the
7
+ * ref `r<seq>`;
8
+ * - targets, native ids with a unique index, refs and the links the host
9
+ * records between them;
10
+ * - media held by id, with the bytes in a blob store beside the database;
11
+ * - lexical search: a word index with BM25, a trigram index for exact
12
+ * substrings, and a substring scan for one- and two-character queries,
13
+ * merged with exact substring hits first;
14
+ * - watermarks per window and target, and the targets a window is behind on;
15
+ * - redaction, the one thing that erases content.
16
+ *
17
+ * It is synchronous inside: one `DatabaseSync`, one transaction per append, so
18
+ * the sequence an append assigns is atomic. The interface is asynchronous
19
+ * because a `Journal` may live behind a process boundary.
20
+ */
21
+ import type { AroundOptions, BlobStore, Event, EventInput, EventPage, EventRef, Journal, Link, LinkInput, LinkPage, LinksOptions, ListOptions, Ref, SearchOptions, SearchPage, Segmenter, Target, TargetGap, TraceNode, TraceOptions, WindowRef } from "@4fu/arbor-interfaces";
22
+ /** A query shorter than this cannot use the trigram index. */
23
+ export declare const MIN_TRIGRAM_CHARS = 3;
24
+ export interface SqliteJournalOptions {
25
+ /** A file path, or `:memory:`. */
26
+ path?: string;
27
+ /** Media storage. Defaults to a directory beside the database. */
28
+ blobs?: BlobStore;
29
+ /** Word segmentation for the word index. */
30
+ segmenter?: Segmenter;
31
+ /** The clock. Tests pass a fixed one. */
32
+ now?: () => number;
33
+ }
34
+ export declare function parseEventRef(ref: string): number | null;
35
+ export declare function eventRefOf(seq: number): EventRef;
36
+ /** The opening words of a body, for a one-line result. */
37
+ export declare function summaryOf(body: string | null, max?: number): string;
38
+ export declare class SqliteJournal implements Journal {
39
+ readonly blobs: BlobStore;
40
+ readonly path: string;
41
+ private readonly db;
42
+ private readonly segmenter;
43
+ private readonly clock;
44
+ private readonly ownedBlobDirectory;
45
+ private closed;
46
+ constructor(options?: SqliteJournalOptions);
47
+ append(input: EventInput): Promise<Event>;
48
+ get(ref: EventRef): Promise<Event | null>;
49
+ getMany(refs: readonly EventRef[]): Promise<Event[]>;
50
+ list(options?: ListOptions): Promise<EventPage>;
51
+ around(ref: EventRef, options?: AroundOptions): Promise<Event[]>;
52
+ search(query: string, options?: SearchOptions): Promise<SearchPage>;
53
+ links(ref: Ref, options?: LinksOptions): Promise<LinkPage>;
54
+ trace(ref: Ref, options?: TraceOptions): Promise<TraceNode>;
55
+ watermark(window: WindowRef, target: Target): Promise<number | null>;
56
+ advanceWatermark(window: WindowRef, target: Target, seq: number): Promise<number>;
57
+ targetsBehind(window: WindowRef): Promise<TargetGap[]>;
58
+ redact(ref: EventRef, reason: string): Promise<Event>;
59
+ close(): Promise<void>;
60
+ private ensureOpen;
61
+ private insertChildren;
62
+ private readEvent;
63
+ private readEventByRef;
64
+ /** One column of one table, for the child rows of an event. */
65
+ private strings;
66
+ private countEvents;
67
+ private latestOn;
68
+ /** Where a ref came from: the links that go out of it, strongest first. */
69
+ private sourcesOf;
70
+ private resolveRelated;
71
+ private toSearchHit;
72
+ }
73
+ /** The links one append records: what it declared, plus a bare cite per ref. */
74
+ export declare function deriveLinks(declared: readonly LinkInput[] | undefined, refs: readonly Ref[] | undefined, from: EventRef, time: number): Link[];
75
+ export { FsBlobStore, blobIdOf } from "./blob-store.ts";