@4fu/arbor-journal-sqlite 0.0.0-stage → 2026.1106.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,660 @@
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 { DatabaseSync } from "node:sqlite";
22
+ import { randomUUID } from "node:crypto";
23
+ import { rm } from "node:fs/promises";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { arborError } from "@4fu/arbor-errors";
27
+ import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from "@4fu/arbor-interfaces";
28
+ import { FsBlobStore, blobIdOf } from "./blob-store.js";
29
+ import { indexText, resolveSegmenter } from "./segmenter.js";
30
+ import { IMPLICIT_CITE_WEIGHT, TRIGRAM_TABLE, WORD_TABLE, createSchema } from "./schema.js";
31
+ import { buildFilters, likePattern, ranked, substring, trigramMatch, wordMatch } from "./search.js";
32
+ import { storedBlobRef, storedExternalRef, storedRef, storedWindowRef } from "./stored.js";
33
+ /** A query shorter than this cannot use the trigram index. */
34
+ export const MIN_TRIGRAM_CHARS = 3;
35
+ /** How many related items one read appends (docs/prompting.md). */
36
+ const RELATED_LIMIT = 3;
37
+ const AROUND_BEFORE = 5;
38
+ const AROUND_AFTER = 5;
39
+ function toNumber(value) {
40
+ if (typeof value === "number")
41
+ return value;
42
+ if (typeof value === "bigint")
43
+ return Number(value);
44
+ throw arborError({
45
+ code: "internal",
46
+ rule: "journal.storage.value",
47
+ message: "journal: storage returned a value that is not a number",
48
+ details: { value: String(value) },
49
+ });
50
+ }
51
+ /** One transaction: every append is one atomic step of the one global order. */
52
+ function inTransaction(db, work) {
53
+ db.exec("BEGIN IMMEDIATE");
54
+ try {
55
+ const result = work();
56
+ db.exec("COMMIT");
57
+ return result;
58
+ }
59
+ catch (error) {
60
+ try {
61
+ db.exec("ROLLBACK");
62
+ }
63
+ catch {
64
+ // The transaction is already gone; the original error is what matters.
65
+ }
66
+ throw error;
67
+ }
68
+ }
69
+ export function parseEventRef(ref) {
70
+ const match = /^r(\d+)$/.exec(ref);
71
+ return match === null ? null : Number(match[1]);
72
+ }
73
+ export function eventRefOf(seq) {
74
+ return `r${seq}`;
75
+ }
76
+ function clampLimit(limit) {
77
+ if (limit === undefined)
78
+ return DEFAULT_PAGE_LIMIT;
79
+ return Math.min(Math.max(1, Math.floor(limit)), MAX_PAGE_LIMIT);
80
+ }
81
+ /** The opening words of a body, for a one-line result. */
82
+ export function summaryOf(body, max = 80) {
83
+ if (body === null || body === "")
84
+ return "";
85
+ const flat = body.replace(/\s+/g, " ").trim();
86
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
87
+ }
88
+ /** The blob id space, kept in the journal's own database. */
89
+ class SqlBlobRegistry {
90
+ db;
91
+ directory;
92
+ constructor(db, directory) {
93
+ this.db = db;
94
+ this.directory = directory;
95
+ }
96
+ idFor(sha256) {
97
+ const row = this.db.prepare("SELECT id FROM blobs WHERE sha256 = ?").get(sha256);
98
+ return row === undefined ? null : toNumber(row.id);
99
+ }
100
+ allocate(sha256, size, mime, time) {
101
+ const info = this.db
102
+ .prepare("INSERT INTO blobs (sha256, size, mime, path, time) VALUES (?, ?, ?, ?, ?)")
103
+ .run(sha256, size, mime ?? null, join(this.directory, sha256), time ?? Date.now());
104
+ return toNumber(info.lastInsertRowid);
105
+ }
106
+ entryFor(id) {
107
+ const row = this.db
108
+ .prepare("SELECT path, size, mime, time FROM blobs WHERE id = ?")
109
+ .get(id);
110
+ if (row === undefined)
111
+ return null;
112
+ return {
113
+ path: row.path,
114
+ size: toNumber(row.size),
115
+ time: toNumber(row.time),
116
+ ...(row.mime === null ? {} : { mime: row.mime }),
117
+ };
118
+ }
119
+ forget(id) {
120
+ this.db.prepare("DELETE FROM blobs WHERE id = ?").run(id);
121
+ }
122
+ }
123
+ export class SqliteJournal {
124
+ blobs;
125
+ path;
126
+ db;
127
+ segmenter;
128
+ clock;
129
+ ownedBlobDirectory;
130
+ closed = false;
131
+ constructor(options = {}) {
132
+ this.path = options.path ?? ":memory:";
133
+ this.db = new DatabaseSync(this.path);
134
+ createSchema(this.db);
135
+ this.segmenter = options.segmenter ?? resolveSegmenter();
136
+ this.clock = options.now ?? (() => Date.now());
137
+ if (options.blobs !== undefined) {
138
+ this.blobs = options.blobs;
139
+ this.ownedBlobDirectory = null;
140
+ }
141
+ else if (this.path === ":memory:") {
142
+ // An in-memory journal has no directory of its own; give it a temporary
143
+ // one and take it away on close.
144
+ const root = join(tmpdir(), `arbor-journal-${randomUUID()}`);
145
+ this.blobs = new FsBlobStore(join(root, "blobs"), new SqlBlobRegistry(this.db, join(root, "blobs")));
146
+ this.ownedBlobDirectory = root;
147
+ }
148
+ else {
149
+ const directory = `${this.path}.blobs`;
150
+ this.blobs = new FsBlobStore(directory, new SqlBlobRegistry(this.db, directory));
151
+ this.ownedBlobDirectory = null;
152
+ }
153
+ }
154
+ async append(input) {
155
+ this.ensureOpen();
156
+ const time = input.time ?? this.clock();
157
+ const external = input.external ?? null;
158
+ // One transaction decides both whether the outside message is already
159
+ // stored and what the next sequence number is, so a redelivery that races
160
+ // another append is still stored once.
161
+ const seq = inTransaction(this.db, () => {
162
+ if (external !== null) {
163
+ const stored = this.db
164
+ .prepare("SELECT seq FROM events WHERE external = ?")
165
+ .get(external);
166
+ if (stored !== undefined)
167
+ return toNumber(stored.seq);
168
+ }
169
+ const info = this.db
170
+ .prepare(`INSERT INTO events (window, time, kind, external, body, redacted)
171
+ VALUES (?, ?, ?, ?, ?, NULL)`)
172
+ .run(input.window ?? null, time, input.kind, external, input.body ?? null);
173
+ const assigned = toNumber(info.lastInsertRowid);
174
+ this.insertChildren(assigned, input, deriveLinks(input.links, input.refs, eventRefOf(assigned), time));
175
+ return assigned;
176
+ });
177
+ return this.readEvent(seq);
178
+ }
179
+ async get(ref) {
180
+ this.ensureOpen();
181
+ const seq = parseEventRef(ref);
182
+ if (seq === null)
183
+ throw invalidEventRef("journal.get", ref);
184
+ return this.readEvent(seq);
185
+ }
186
+ async getMany(refs) {
187
+ this.ensureOpen();
188
+ const events = [];
189
+ for (const ref of refs) {
190
+ const seq = parseEventRef(ref);
191
+ if (seq === null)
192
+ throw invalidEventRef("journal.getMany", ref);
193
+ const event = this.readEvent(seq);
194
+ if (event !== null)
195
+ events.push(event);
196
+ }
197
+ return events;
198
+ }
199
+ async list(options = {}) {
200
+ this.ensureOpen();
201
+ const limit = clampLimit(options.limit);
202
+ const offset = Math.max(0, options.offset ?? 0);
203
+ const filters = filtersOf(options);
204
+ const built = buildFilters(filters);
205
+ const rows = this.db
206
+ .prepare(`SELECT e.seq AS seq FROM events e WHERE 1 = 1${built.sql} ORDER BY e.seq LIMIT ? OFFSET ?`)
207
+ .all(...built.params, limit, offset);
208
+ const events = [];
209
+ for (const row of rows) {
210
+ const event = this.readEvent(toNumber(row.seq));
211
+ if (event !== null)
212
+ events.push(event);
213
+ }
214
+ return {
215
+ items: events,
216
+ total: this.countEvents(filters),
217
+ limit,
218
+ nextCall: listCall(options, offset, limit),
219
+ };
220
+ }
221
+ async around(ref, options = {}) {
222
+ this.ensureOpen();
223
+ const seq = parseEventRef(ref);
224
+ if (seq === null)
225
+ throw invalidEventRef("journal.around", ref);
226
+ const before = options.before ?? AROUND_BEFORE;
227
+ const after = options.after ?? AROUND_AFTER;
228
+ const rows = this.db
229
+ .prepare("SELECT seq FROM events WHERE seq >= ? AND seq <= ? ORDER BY seq")
230
+ .all(Math.max(1, seq - before), seq + after);
231
+ const events = [];
232
+ for (const row of rows) {
233
+ const event = this.readEvent(toNumber(row.seq));
234
+ if (event !== null)
235
+ events.push(event);
236
+ }
237
+ return events;
238
+ }
239
+ async search(query, options = {}) {
240
+ this.ensureOpen();
241
+ const limit = clampLimit(options.limit);
242
+ const offset = Math.max(0, options.offset ?? 0);
243
+ const trimmed = query.trim();
244
+ if (trimmed === "") {
245
+ return { items: [], total: 0, limit, nextCall: searchCall(trimmed, offset, limit) };
246
+ }
247
+ const filters = filtersOf(options);
248
+ const codepoints = Array.from(trimmed).length;
249
+ let substrings = [];
250
+ let words = [];
251
+ if (codepoints >= MIN_TRIGRAM_CHARS) {
252
+ substrings = ranked(this.db, TRIGRAM_TABLE, trigramMatch(trimmed), filters);
253
+ const tokens = this.segmenter.segment(trimmed);
254
+ if (tokens.length > 0) {
255
+ words = ranked(this.db, WORD_TABLE, wordMatch(tokens), filters);
256
+ }
257
+ }
258
+ else {
259
+ // A one- or two-character query has no trigram of its own; the substring
260
+ // scan is its fallback.
261
+ substrings = substring(this.db, likePattern(trimmed), filters);
262
+ }
263
+ const merged = mergeHits(substrings, words);
264
+ const page = merged.slice(offset, offset + limit);
265
+ const items = page.map((hit) => this.toSearchHit(hit));
266
+ return {
267
+ items,
268
+ total: merged.length,
269
+ limit,
270
+ nextCall: searchCall(trimmed, offset, limit),
271
+ };
272
+ }
273
+ async links(ref, options = {}) {
274
+ this.ensureOpen();
275
+ const limit = Math.min(Math.max(1, Math.floor(options.limit ?? RELATED_LIMIT)), MAX_PAGE_LIMIT);
276
+ const offset = Math.max(0, options.offset ?? 0);
277
+ const rows = this.db
278
+ .prepare(`SELECT seq, from_ref, to_ref, kind, weight, time FROM links
279
+ WHERE to_ref = ? OR from_ref = ?
280
+ ORDER BY weight DESC, time DESC, seq DESC`)
281
+ .all(ref, ref);
282
+ const strongest = new Map();
283
+ for (const row of rows) {
284
+ const outgoing = row.from_ref === ref;
285
+ const other = storedRef(outgoing ? row.to_ref : row.from_ref);
286
+ const weight = toNumber(row.weight);
287
+ const previous = strongest.get(other);
288
+ if (previous !== undefined && previous.weight >= weight)
289
+ continue;
290
+ const resolved = this.resolveRelated(other, toNumber(row.time));
291
+ strongest.set(other, {
292
+ ref: other,
293
+ kind: row.kind,
294
+ weight,
295
+ direction: outgoing ? "out" : "in",
296
+ window: resolved.window,
297
+ time: resolved.time,
298
+ summary: resolved.summary,
299
+ });
300
+ }
301
+ const all = [...strongest.values()].sort((a, b) => b.weight - a.weight || b.time - a.time);
302
+ return {
303
+ items: all.slice(offset, offset + limit),
304
+ total: all.length,
305
+ limit,
306
+ nextCall: `journal.links(${ref}, offset=${offset + limit})`,
307
+ };
308
+ }
309
+ async trace(ref, options = {}) {
310
+ this.ensureOpen();
311
+ const maxHops = Math.max(0, Math.floor(options.maxHops ?? 8));
312
+ const maxNodes = Math.max(1, Math.floor(options.maxNodes ?? 64));
313
+ const visited = new Set();
314
+ let budget = maxNodes;
315
+ const build = (current, hopsLeft) => {
316
+ if (visited.has(current) || budget <= 0) {
317
+ return { ref: current, window: null, time: 0, summary: "", origin: false, from: [] };
318
+ }
319
+ visited.add(current);
320
+ budget -= 1;
321
+ const event = this.readEventByRef(current);
322
+ const sources = budget > 0 && hopsLeft > 0 ? this.sourcesOf(current) : [];
323
+ return {
324
+ ref: current,
325
+ ...(event === null ? {} : { kind: event.kind }),
326
+ window: event?.window ?? null,
327
+ time: event?.time ?? 0,
328
+ summary: summaryOf(event?.body ?? null),
329
+ // An outside message is where a chain came from; so is a ref the walk
330
+ // stopped on because it is not an event.
331
+ origin: event === null || event.external !== null,
332
+ from: sources
333
+ .map((source) => build(source, hopsLeft - 1))
334
+ .filter((node) => node.ref !== current),
335
+ };
336
+ };
337
+ return build(ref, maxHops);
338
+ }
339
+ async watermark(window, target) {
340
+ this.ensureOpen();
341
+ const row = this.db
342
+ .prepare("SELECT seq FROM watermarks WHERE window = ? AND target = ?")
343
+ .get(window, target);
344
+ return row === undefined ? null : toNumber(row.seq);
345
+ }
346
+ async advanceWatermark(window, target, seq) {
347
+ this.ensureOpen();
348
+ this.db
349
+ .prepare(`INSERT INTO watermarks (window, target, seq) VALUES (?, ?, ?)
350
+ ON CONFLICT (window, target) DO UPDATE SET seq = MAX(seq, excluded.seq)`)
351
+ .run(window, target, seq);
352
+ return (await this.watermark(window, target)) ?? seq;
353
+ }
354
+ async targetsBehind(window) {
355
+ this.ensureOpen();
356
+ const touched = this.db
357
+ .prepare(`SELECT et.target AS target FROM event_targets et
358
+ JOIN events e ON e.seq = et.seq
359
+ WHERE e.window = ?
360
+ GROUP BY et.target`)
361
+ .all(window);
362
+ const gaps = [];
363
+ for (const row of touched) {
364
+ const latest = this.latestOn(row.target);
365
+ const watermark = (await this.watermark(window, row.target)) ?? 0;
366
+ if (latest <= watermark)
367
+ continue;
368
+ const behind = this.db
369
+ .prepare("SELECT count(*) AS n FROM event_targets WHERE target = ? AND seq > ?")
370
+ .get(row.target, watermark);
371
+ gaps.push({
372
+ target: row.target,
373
+ latest,
374
+ watermark,
375
+ count: toNumber(behind.n),
376
+ });
377
+ }
378
+ return gaps.sort((a, b) => b.latest - a.latest || a.target.localeCompare(b.target));
379
+ }
380
+ async redact(ref, reason) {
381
+ this.ensureOpen();
382
+ const seq = parseEventRef(ref);
383
+ if (seq === null)
384
+ throw invalidEventRef("journal.redact", ref);
385
+ const covered = this.readEvent(seq);
386
+ if (covered === null) {
387
+ throw arborError({
388
+ code: "not_found",
389
+ rule: "journal.redact.event",
390
+ message: `journal.redact: ${ref} is not in the journal`,
391
+ nextAction: "check the ref with journal.get, or search for the event",
392
+ });
393
+ }
394
+ const time = this.clock();
395
+ const redaction = inTransaction(this.db, () => {
396
+ const info = this.db
397
+ .prepare(`INSERT INTO events (window, time, kind, external, body, redacted)
398
+ VALUES (?, ?, 'redaction', NULL, ?, NULL)`)
399
+ .run(null, time, reason);
400
+ const by = toNumber(info.lastInsertRowid);
401
+ // The redaction names the event it covers, but records no link: a trace
402
+ // walks back to where something came from, not to what erased it.
403
+ this.db.prepare("INSERT OR IGNORE INTO event_refs (seq, ref) VALUES (?, ?)").run(by, ref);
404
+ this.db.prepare(`DELETE FROM ${TRIGRAM_TABLE} WHERE rowid = ?`).run(seq);
405
+ this.db.prepare(`DELETE FROM ${WORD_TABLE} WHERE rowid = ?`).run(seq);
406
+ this.db
407
+ .prepare("UPDATE events SET body = NULL, redacted = ? WHERE seq = ?")
408
+ .run(JSON.stringify({ by: eventRefOf(by), reason, time }), seq);
409
+ this.db
410
+ .prepare("INSERT OR REPLACE INTO redactions (covers, by, reason, time) VALUES (?, ?, ?, ?)")
411
+ .run(seq, by, reason, time);
412
+ // Media the event held are erased with it, unless another event holds
413
+ // the same content.
414
+ const held = this.db
415
+ .prepare("SELECT blob FROM event_blobs WHERE seq = ?")
416
+ .all(seq);
417
+ this.db.prepare("DELETE FROM event_blobs WHERE seq = ?").run(seq);
418
+ const erased = [];
419
+ for (const row of held) {
420
+ const shared = this.db
421
+ .prepare("SELECT count(*) AS n FROM event_blobs WHERE blob = ?")
422
+ .get(row.blob);
423
+ if (toNumber(shared.n) > 0)
424
+ continue;
425
+ this.db.prepare("DELETE FROM blobs WHERE id = ?").run(blobIdOf(row.blob));
426
+ erased.push(row.blob);
427
+ }
428
+ return { by, erased };
429
+ });
430
+ for (const blob of redaction.erased)
431
+ await this.blobs.delete(storedBlobRef(blob));
432
+ return this.readEvent(redaction.by);
433
+ }
434
+ async close() {
435
+ if (this.closed)
436
+ return;
437
+ this.closed = true;
438
+ this.db.close();
439
+ if (this.ownedBlobDirectory !== null) {
440
+ await rm(this.ownedBlobDirectory, { recursive: true, force: true });
441
+ }
442
+ }
443
+ // --- internals ---------------------------------------------------------
444
+ ensureOpen() {
445
+ if (this.closed) {
446
+ throw arborError({
447
+ code: "unavailable",
448
+ rule: "journal.closed",
449
+ message: "journal: the journal is closed",
450
+ nextAction: "open a journal before reading or appending",
451
+ });
452
+ }
453
+ }
454
+ insertChildren(seq, input, links) {
455
+ const insertTarget = this.db.prepare("INSERT OR IGNORE INTO event_targets (seq, target) VALUES (?, ?)");
456
+ for (const target of input.targets ?? [])
457
+ insertTarget.run(seq, target);
458
+ const insertRef = this.db.prepare("INSERT OR IGNORE INTO event_refs (seq, ref) VALUES (?, ?)");
459
+ for (const ref of input.refs ?? [])
460
+ insertRef.run(seq, ref);
461
+ const insertBlob = this.db.prepare("INSERT OR IGNORE INTO event_blobs (seq, blob) VALUES (?, ?)");
462
+ for (const blob of input.blobs ?? [])
463
+ insertBlob.run(seq, blob);
464
+ const insertLink = this.db.prepare(`INSERT OR IGNORE INTO links (seq, from_ref, to_ref, kind, weight, time)
465
+ VALUES (?, ?, ?, ?, ?, ?)`);
466
+ for (const link of links) {
467
+ insertLink.run(seq, link.from, link.to, link.kind, link.weight, link.time);
468
+ }
469
+ if (input.body !== null && input.body !== undefined && input.body !== "") {
470
+ this.db
471
+ .prepare(`INSERT INTO ${TRIGRAM_TABLE} (rowid, body) VALUES (?, ?)`)
472
+ .run(seq, input.body);
473
+ this.db
474
+ .prepare(`INSERT INTO ${WORD_TABLE} (rowid, body) VALUES (?, ?)`)
475
+ .run(seq, indexText(input.body, this.segmenter));
476
+ }
477
+ }
478
+ readEvent(seq) {
479
+ const row = this.db
480
+ .prepare(`SELECT seq, window, time, kind, external, body, redacted
481
+ FROM events WHERE seq = ?`)
482
+ .get(seq);
483
+ if (row === undefined)
484
+ return null;
485
+ const targets = this.strings("event_targets", "target", seq);
486
+ const refs = this.strings("event_refs", "ref", seq);
487
+ const blobs = this.strings("event_blobs", "blob", seq);
488
+ const linkRows = this.db
489
+ .prepare("SELECT kind, from_ref, to_ref, weight, time FROM links WHERE seq = ?")
490
+ .all(seq);
491
+ return {
492
+ ref: eventRefOf(toNumber(row.seq)),
493
+ seq: toNumber(row.seq),
494
+ window: storedWindowRef(row.window),
495
+ time: toNumber(row.time),
496
+ kind: row.kind,
497
+ targets,
498
+ refs: refs.map(storedRef),
499
+ links: linkRows.map((link) => ({
500
+ kind: link.kind,
501
+ from: storedRef(link.from_ref),
502
+ to: storedRef(link.to_ref),
503
+ weight: toNumber(link.weight),
504
+ time: toNumber(link.time),
505
+ })),
506
+ external: storedExternalRef(row.external),
507
+ blobs: blobs.map(storedBlobRef),
508
+ body: row.body,
509
+ redacted: row.redacted === null ? null : JSON.parse(row.redacted),
510
+ };
511
+ }
512
+ readEventByRef(ref) {
513
+ const seq = parseEventRef(ref);
514
+ return seq === null ? null : this.readEvent(seq);
515
+ }
516
+ /** One column of one table, for the child rows of an event. */
517
+ strings(table, column, seq) {
518
+ const rows = this.db
519
+ .prepare(`SELECT ${column} AS value FROM ${table} WHERE seq = ?`)
520
+ .all(seq);
521
+ return rows.map((row) => row.value);
522
+ }
523
+ countEvents(filters) {
524
+ const built = buildFilters(filters);
525
+ const row = this.db
526
+ .prepare(`SELECT count(*) AS n FROM events e WHERE 1 = 1${built.sql}`)
527
+ .get(...built.params);
528
+ return toNumber(row.n);
529
+ }
530
+ latestOn(target) {
531
+ const row = this.db
532
+ .prepare("SELECT MAX(seq) AS latest FROM event_targets WHERE target = ?")
533
+ .get(target);
534
+ return row?.latest == null ? 0 : toNumber(row.latest);
535
+ }
536
+ /** Where a ref came from: the links that go out of it, strongest first. */
537
+ sourcesOf(ref) {
538
+ const rows = this.db
539
+ .prepare(`SELECT to_ref, weight, time FROM links WHERE from_ref = ?
540
+ ORDER BY weight DESC, time DESC LIMIT 8`)
541
+ .all(ref);
542
+ return rows.map((row) => storedRef(row.to_ref));
543
+ }
544
+ resolveRelated(ref, fallbackTime) {
545
+ const event = this.readEventByRef(ref);
546
+ if (event === null)
547
+ return { window: null, time: fallbackTime, summary: "" };
548
+ return { window: event.window, time: event.time, summary: summaryOf(event.body) };
549
+ }
550
+ toSearchHit(hit) {
551
+ const event = this.readEvent(hit.seq);
552
+ if (event === null) {
553
+ throw arborError({
554
+ code: "internal",
555
+ rule: "journal.search.hit",
556
+ message: "journal.search: a matched event cannot be read",
557
+ nextAction: "run the search again; the journal may have been rebuilt",
558
+ });
559
+ }
560
+ return {
561
+ ref: event.ref,
562
+ seq: event.seq,
563
+ window: event.window,
564
+ kind: event.kind,
565
+ time: event.time,
566
+ match: hit.match,
567
+ score: hit.score,
568
+ summary: summaryOf(event.body),
569
+ };
570
+ }
571
+ }
572
+ /** The links one append records: what it declared, plus a bare cite per ref. */
573
+ export function deriveLinks(declared, refs, from, time) {
574
+ const links = [];
575
+ const seen = new Set();
576
+ for (const link of declared ?? []) {
577
+ if (seen.has(link.to))
578
+ continue;
579
+ seen.add(link.to);
580
+ links.push({
581
+ kind: link.kind,
582
+ from,
583
+ to: link.to,
584
+ weight: link.weight ?? 1,
585
+ time,
586
+ });
587
+ }
588
+ for (const ref of refs ?? []) {
589
+ if (seen.has(ref))
590
+ continue;
591
+ seen.add(ref);
592
+ links.push({ kind: "cite", from, to: ref, weight: IMPLICIT_CITE_WEIGHT, time });
593
+ }
594
+ return links;
595
+ }
596
+ /**
597
+ * Merge the two indexes: exact substring hits first, then word hits that are
598
+ * not already there, each ordered by its own relevance.
599
+ */
600
+ function mergeHits(substrings, words) {
601
+ const seen = new Set();
602
+ const merged = [];
603
+ const push = (hits, match) => {
604
+ const best = Math.min(...hits.map((hit) => hit.rank));
605
+ for (const hit of hits) {
606
+ if (seen.has(hit.seq))
607
+ continue;
608
+ seen.add(hit.seq);
609
+ merged.push({ seq: hit.seq, match, score: normalize(hit.rank, best) });
610
+ }
611
+ };
612
+ push(substrings, "substring");
613
+ push(words, "word");
614
+ return merged;
615
+ }
616
+ /** Relevance in 0..1 within one index; higher is better. */
617
+ function normalize(rank, best) {
618
+ if (best === 0)
619
+ return 1;
620
+ return Math.max(0, Math.min(1, rank / best));
621
+ }
622
+ function filtersOf(options) {
623
+ return {
624
+ kinds: options.kinds?.map(String),
625
+ window: options.window ?? undefined,
626
+ targets: options.targets?.map(String),
627
+ external: options.external ?? undefined,
628
+ afterSeq: options.afterSeq,
629
+ beforeSeq: options.beforeSeq,
630
+ };
631
+ }
632
+ function invalidEventRef(rule, ref) {
633
+ return arborError({
634
+ code: "invalid_input",
635
+ rule,
636
+ message: `journal: ${ref} is not an event ref`,
637
+ nextAction: "pass a ref an event carries, such as r902",
638
+ details: { ref },
639
+ });
640
+ }
641
+ function listCall(options, offset, limit) {
642
+ const parts = [];
643
+ if (options.window !== undefined)
644
+ parts.push(`window=${options.window}`);
645
+ if (options.kinds !== undefined && options.kinds.length > 0)
646
+ parts.push(`kinds=${options.kinds.join(",")}`);
647
+ if (options.targets !== undefined && options.targets.length > 0) {
648
+ parts.push(`targets=${options.targets.join(",")}`);
649
+ }
650
+ if (options.afterSeq !== undefined)
651
+ parts.push(`afterSeq=r${options.afterSeq}`);
652
+ if (options.beforeSeq !== undefined)
653
+ parts.push(`beforeSeq=r${options.beforeSeq}`);
654
+ parts.push(`offset=${offset + limit}`);
655
+ return `journal.list(${parts.join(", ")})`;
656
+ }
657
+ function searchCall(query, offset, limit) {
658
+ return `journal.search(${JSON.stringify(query)}, offset=${offset + limit})`;
659
+ }
660
+ export { FsBlobStore, blobIdOf } from "./blob-store.js";
package/stored.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Reading refs back out of storage.
3
+ *
4
+ * The journal stores refs as text. A ref that does not match the vocabulary in
5
+ * docs/journal.md#refs means the database was written by something else, so
6
+ * reading it fails in the core's error shape instead of handing a caller a
7
+ * string that is not a ref.
8
+ */
9
+ import type { BlobRef, ExternalRef, Ref, WindowRef } from "@4fu/arbor-interfaces";
10
+ /** Any ref. */
11
+ export declare function storedRef(value: string): Ref;
12
+ export declare function storedWindowRef(value: string | null): WindowRef | null;
13
+ export declare function storedBlobRef(value: string): BlobRef;
14
+ export declare function storedExternalRef(value: string | null): ExternalRef | null;