@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 4fuu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,188 @@
1
- # Temporary Holding Version
1
+ # arbor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ arbor is a framework for an agent without sessions: many context windows run
4
+ at once over one shared record, so work moves in parallel, replies come fast,
5
+ and every window can find everything that was ever done.
6
+
7
+ A deployment composes the pieces it wants and registers plugins for the domains
8
+ it serves. The framework guarantees what an agent cannot guarantee for itself -
9
+ the record, isolation, exactly-once effects, the checks, dispatch - and leaves
10
+ everything that takes judgment, such as git, merges and conflicts, to the agent.
11
+
12
+ ## Core ideas
13
+
14
+ - **No sessions to manage.** Work runs in disposable context windows. Any
15
+ number run at the same time; none of them owns anything, and a window that
16
+ ends loses nothing. The model looks up what it needs instead of carrying it.
17
+ - **One record, shared live.** Every input, tool call, result, send and note
18
+ revision goes into one journal and is visible to every window as
19
+ soon as it is written. Records never merge and are never lost.
20
+ - **Dispatch by a judge.** A judge (Jev by default, replaceable) decides which
21
+ live window takes a new input, or opens a new one. There is no router session
22
+ and no main line.
23
+ - **Current without locks.** Windows run in parallel. New events on a
24
+ conversation a window is in are steered into it as they arrive; only a note
25
+ write is checked against the revision the window read.
26
+ - **Mechanisms in the framework, policy in the agent.** The framework
27
+ guarantees what an agent cannot guarantee for itself - the record, isolation,
28
+ exactly-once effects, the checks, dispatch. Everything that takes judgment,
29
+ such as git, merges and conflicts, is the agent's.
30
+ - **A directory per window.** Each window gets its own directory, and a
31
+ continuation keeps working in its predecessor's directory in place. It is not
32
+ bound to a repository; the agent clones and pushes what it needs.
33
+ - **A self-changing environment.** The model adds and changes skills, MCP
34
+ servers and resident services itself; a `reload` validates the change and
35
+ broadcasts it to every live window as a delta of names and descriptions.
36
+ - **Minimal limits.** The framework sets no policy. Permissions, privacy
37
+ review, budgets and approvals belong to deployments built on it, through
38
+ hooks.
39
+ - **Direct tools plus code mode.** Decisions are direct tools; everything else
40
+ is a module called from `code_exec`. There is no CLI.
41
+ - **Every module is replaceable.** The agent backend (pi by default), the
42
+ journal, notes, workspaces, the judge, channels and the rest are interfaces
43
+ ([docs/architecture.md](docs/architecture.md#interfaces)).
44
+
45
+ ## Install
46
+
47
+ Node 24 or newer. Every package is published separately, so a deployment
48
+ installs the core and the defaults it wants:
49
+
50
+ ```sh
51
+ npm install @4fu/arbor-core @4fu/arbor-journal-sqlite @4fu/arbor-notes-sqlite \
52
+ @4fu/arbor-environment-dir @4fu/arbor-supervisor-process @4fu/arbor-agent-pi \
53
+ @4fu/arbor-workspace-dir
54
+ ```
55
+
56
+ `@4fu/arbor-plugin-http` is optional; it is the example plugin below.
57
+ `@4fu/arbor-segmenter-jieba` is an optional peer of the journal.
58
+
59
+ ## A minimal host
60
+
61
+ The smallest host needs a journal and a backend. Adding the defaults and one
62
+ plugin is what a deployment usually starts from:
63
+
64
+ ```js
65
+ import { mkdtemp } from "node:fs/promises";
66
+ import { tmpdir } from "node:os";
67
+ import { join } from "node:path";
68
+ import { Arbor } from "@4fu/arbor-core";
69
+ import { DirectoryEnvironment } from "@4fu/arbor-environment-dir";
70
+ import { SqliteJournal } from "@4fu/arbor-journal-sqlite";
71
+ import { SqliteNoteStore } from "@4fu/arbor-notes-sqlite";
72
+ import { ProcessSupervisor } from "@4fu/arbor-supervisor-process";
73
+ import { DirectoryWorkspace } from "@4fu/arbor-workspace-dir";
74
+ import { PiAgentBackend } from "@4fu/arbor-agent-pi";
75
+ import { HttpChannel, httpPlugin } from "@4fu/arbor-plugin-http";
76
+
77
+ const root = await mkdtemp(join(tmpdir(), "arbor-"));
78
+
79
+ // Every module below is a default implementation of an interface. Replace any
80
+ // one by passing your own; the host does not change.
81
+ const journal = new SqliteJournal({ path: join(root, "journal.sqlite") });
82
+ const notes = new SqliteNoteStore({ path: join(root, "notes.sqlite"), journal });
83
+ const supervisor = new ProcessSupervisor({ root: join(root, "services") });
84
+ const environment = new DirectoryEnvironment({ root: join(root, "env"), supervisor });
85
+ const workspace = new DirectoryWorkspace({ root: join(root, "workspaces") });
86
+
87
+ // A plugin adds; it never patches the core. This one is a chat channel: a
88
+ // webhook in, a window's sends out, and a page that drives the whole host.
89
+ let arbor;
90
+ const channel = new HttpChannel({ host: "127.0.0.1", port: 8787, board: () => arbor.boardLines() });
91
+ arbor = new Arbor({
92
+ journal,
93
+ notes,
94
+ backend: new PiAgentBackend(),
95
+ workspace,
96
+ environment,
97
+ supervisor,
98
+ plugins: [httpPlugin({ channel })],
99
+ });
100
+
101
+ await arbor.start();
102
+ await channel.listen();
103
+ ```
104
+
105
+ `arbor.open(task)` dispatches one task, and the journal is where everything that
106
+ happened can be read back ([docs/journal.md](docs/journal.md)). The workspace a
107
+ window gets, and how a continuation keeps working in it, are in
108
+ [docs/workspaces.md](docs/workspaces.md).
109
+
110
+ ## Packages
111
+
112
+ | Package | What it is |
113
+ | --- | --- |
114
+ | [`@4fu/arbor-interfaces`](packages/interfaces) | every replaceable interface, plus the core's own shapes. Types only. |
115
+ | [`@4fu/arbor-errors`](packages/errors) | the error shape every refusal uses: the rule and the next action |
116
+ | [`@4fu/arbor-prompt`](packages/prompt) | typed blocks, the one deterministic renderer and the replaceable text table |
117
+ | [`@4fu/arbor-tools`](packages/tools) | the entry points: direct tools, code mode modules, the host API |
118
+ | [`@4fu/arbor-journal-sqlite`](packages/journal-sqlite) | the default `Journal` and `BlobStore` on SQLite |
119
+ | [`@4fu/arbor-notes-sqlite`](packages/notes-sqlite) | the default `NoteStore` on SQLite, beside the journal |
120
+ | [`@4fu/arbor-segmenter-jieba`](packages/segmenter-jieba) | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
121
+ | [`@4fu/arbor-workspace-dir`](packages/workspace-dir) | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place |
122
+ | [`@4fu/arbor-environment-dir`](packages/environment-dir) | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
123
+ | [`@4fu/arbor-supervisor-process`](packages/supervisor-process) | the default `Supervisor`: one host process per service, restart with backoff, logs |
124
+ | [`@4fu/arbor-judge-jev`](packages/judge-jev) | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
125
+ | [`@4fu/arbor-agent-pi`](packages/agent-pi) | the default `AgentBackend`: pi-agent-core in process |
126
+ | [`@4fu/arbor-plugin-http`](packages/plugin-http) | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
127
+ | [`@4fu/arbor-core`](packages/core) | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects and schedules |
128
+
129
+ ### Defaults are examples
130
+
131
+ Every default above is a reference implementation of its interface, written to
132
+ be read and replaced. A real deployment is expected to swap out the ones that do
133
+ not fit: the SQLite journal and notes, the directory workspace, the process
134
+ supervisor, the environment directory, the HTTP test plugin and the Jev judge
135
+ are all examples of what an interface can be satisfied by, not commitments.
136
+ What a deployment keeps is its own choice, and the interfaces are the contract
137
+ ([docs/architecture.md](docs/architecture.md#interfaces)).
138
+
139
+ Each package's own documentation holds the limits of that default, so a
140
+ deployment can see what it would be taking on before it adopts one.
141
+
142
+ ## Documents
143
+
144
+ | Page | Contents |
145
+ | --- | --- |
146
+ | [docs/overview.md](docs/overview.md) | goals, concepts, principles, routes considered |
147
+ | [docs/architecture.md](docs/architecture.md) | layers, interfaces, stack, models, plugins, hooks, data placement |
148
+ | [docs/journal.md](docs/journal.md) | events and their order, targets, watermarks, external ids, refs, tracing, media, redaction |
149
+ | [docs/windows.md](docs/windows.md) | window lifecycle, prompt layout, reminders, continuation |
150
+ | [docs/dispatch.md](docs/dispatch.md) | task sources, judge decisions, insertion timing, backpressure, scheduled tasks |
151
+ | [docs/coordination.md](docs/coordination.md) | the board, expectations, deliveries and broadcasts, staying current, effects |
152
+ | [docs/environment.md](docs/environment.md) | skills, MCP servers and services: using, changing, reload, built-in skills, deltas |
153
+ | [docs/memory.md](docs/memory.md) | notes, the active set, anchors, links |
154
+ | [docs/workspaces.md](docs/workspaces.md) | per-window directories: isolation, continuity, what the agent does |
155
+ | [docs/tools.md](docs/tools.md) | direct tools and code mode modules |
156
+ | [docs/prompting.md](docs/prompting.md) | how every model-facing text is written: layers, notes, deliveries, errors, judge questions, terms |
157
+ | [docs/release.md](docs/release.md) | calendar versioning, the manual release steps, how a package is published |
158
+
159
+ ## Repository layout
160
+
161
+ `pnpm check` typechecks and tests every package on its TypeScript sources.
162
+ `pnpm build` emits the `dist` publishing needs, and `pnpm pack` shows what each
163
+ tarball would contain ([docs/release.md](docs/release.md)).
164
+
165
+ | Path | Layer |
166
+ | --- | --- |
167
+ | `packages/interfaces` | the interfaces layer: every replaceable interface, plus the core's own shapes. Types only. |
168
+ | `packages/errors` | the error shape every refusal uses: the rule and the next action |
169
+ | `packages/journal-sqlite` | the default `Journal` and `BlobStore` on SQLite |
170
+ | `packages/segmenter-jieba` | the default `Segmenter`: jieba's dictionary segmentation for the word index, optional |
171
+ | `packages/notes-sqlite` | the default `NoteStore` on SQLite, beside the journal |
172
+ | `packages/judge-jev` | the default `Judge`: Jev, TypeSafe AI's System One classifier, answering the dispatch decisions |
173
+ | `packages/workspace-dir` | the default `Workspace`: one directory per window, with a continuation working in its predecessor's directory in place, and the lookup of another window's directory it contributes to the catalog |
174
+ | `packages/environment-dir` | the default `Environment`: one `/env` directory, a revision per item, validation and deltas |
175
+ | `packages/supervisor-process` | the default `Supervisor`: one host process per service, restart with backoff, logs |
176
+ | `packages/plugin-http` | the HTTP test channel: a webhook in, sends out, a page that drives the whole host |
177
+ | `packages/prompt` | typed blocks, the one deterministic renderer and the replaceable text table |
178
+ | `packages/agent-pi` | the default `AgentBackend`: pi-agent-core in process |
179
+ | `packages/tools` | the entry points: direct tools, code mode modules, the host API |
180
+ | `packages/core` | the service layer: one implementation of every operation - the window lifecycle, dispatch, coordination, the board, notes access, workspaces, effects, schedules and the snapshot epochs |
181
+
182
+ `mise.toml` pins the toolchain - Node and pnpm - installed under
183
+ `~/.local/share/mise`, and `.agents/setup` prepares a machine from it. A machine
184
+ without mise is expected to carry Node 24 or newer and pnpm 12 already.
185
+
186
+ ## License
187
+
188
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Media and files are stored once by content beside the journal; events hold
3
+ * their refs (docs/journal.md#media). The journal never keeps the bytes.
4
+ *
5
+ * `FsBlobStore` keeps the bytes in one directory. Which id a piece of content
6
+ * has is the journal's business - the ref an event carries is `b7`, and the
7
+ * content hash decides whether two writes are the same content - so the store
8
+ * takes a registry that the journal implements against its own database. A
9
+ * deployment that keeps media elsewhere implements `BlobStore` itself.
10
+ */
11
+ import type { BlobContent, BlobInfo, BlobInput, BlobRef, BlobStore } from "@4fu/arbor-interfaces";
12
+ /** What a store needs to know about one stored blob. */
13
+ export interface BlobEntry {
14
+ path: string;
15
+ size: number;
16
+ mime?: string;
17
+ time: number;
18
+ }
19
+ /** The id space, owned by the journal's database. */
20
+ export interface BlobRegistry {
21
+ /** The id this content hash already has, when it is stored. */
22
+ idFor(sha256: string): number | null;
23
+ /** Record a new blob and return its id. */
24
+ allocate(sha256: string, size: number, mime?: string, time?: number): number;
25
+ /** The stored entry for an id. */
26
+ entryFor(id: number): BlobEntry | null;
27
+ /** Forget an id, so the bytes may be erased. */
28
+ forget(id: number): void;
29
+ }
30
+ export declare function blobRefOf(id: number): BlobRef;
31
+ export declare function blobIdOf(ref: string): number | null;
32
+ export declare class FsBlobStore implements BlobStore {
33
+ private readonly directory;
34
+ private readonly registry;
35
+ /** One write at a time, so two writes of the same content share one id. */
36
+ private queue;
37
+ constructor(directory: string, registry: BlobRegistry);
38
+ put(input: BlobInput): Promise<BlobRef>;
39
+ private write;
40
+ get(ref: BlobRef): Promise<BlobContent | null>;
41
+ has(ref: BlobRef): Promise<boolean>;
42
+ stat(ref: BlobRef): Promise<BlobInfo | null>;
43
+ delete(ref: BlobRef): Promise<void>;
44
+ private pathFor;
45
+ }
package/blob-store.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Media and files are stored once by content beside the journal; events hold
3
+ * their refs (docs/journal.md#media). The journal never keeps the bytes.
4
+ *
5
+ * `FsBlobStore` keeps the bytes in one directory. Which id a piece of content
6
+ * has is the journal's business - the ref an event carries is `b7`, and the
7
+ * content hash decides whether two writes are the same content - so the store
8
+ * takes a registry that the journal implements against its own database. A
9
+ * deployment that keeps media elsewhere implements `BlobStore` itself.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ export function blobRefOf(id) {
15
+ return `b${id}`;
16
+ }
17
+ export function blobIdOf(ref) {
18
+ const match = /^b(\d+)$/.exec(ref);
19
+ return match === null ? null : Number(match[1]);
20
+ }
21
+ function toBuffer(data) {
22
+ if (typeof data === "string")
23
+ return Buffer.from(data, "utf8");
24
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
25
+ }
26
+ export class FsBlobStore {
27
+ directory;
28
+ registry;
29
+ /** One write at a time, so two writes of the same content share one id. */
30
+ queue = Promise.resolve();
31
+ constructor(directory, registry) {
32
+ this.directory = directory;
33
+ this.registry = registry;
34
+ }
35
+ async put(input) {
36
+ const run = this.queue.then(() => this.write(input));
37
+ this.queue = run.catch(() => undefined);
38
+ return run;
39
+ }
40
+ async write(input) {
41
+ const data = toBuffer(input.data);
42
+ const sha256 = createHash("sha256").update(data).digest("hex");
43
+ const existing = this.registry.idFor(sha256);
44
+ if (existing !== null)
45
+ return blobRefOf(existing);
46
+ await mkdir(this.directory, { recursive: true });
47
+ const id = this.registry.allocate(sha256, data.byteLength, input.mime);
48
+ const entry = this.registry.entryFor(id);
49
+ const path = entry?.path ?? this.pathFor(id);
50
+ await writeFile(path, data);
51
+ return blobRefOf(id);
52
+ }
53
+ async get(ref) {
54
+ const id = blobIdOf(ref);
55
+ if (id === null)
56
+ return null;
57
+ const entry = this.registry.entryFor(id);
58
+ if (entry === null)
59
+ return null;
60
+ try {
61
+ const data = await readFile(entry.path);
62
+ return { data: new Uint8Array(data), ...(entry.mime === undefined ? {} : { mime: entry.mime }) };
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ async has(ref) {
69
+ const id = blobIdOf(ref);
70
+ return id !== null && this.registry.entryFor(id) !== null;
71
+ }
72
+ async stat(ref) {
73
+ const id = blobIdOf(ref);
74
+ if (id === null)
75
+ return null;
76
+ const entry = this.registry.entryFor(id);
77
+ if (entry === null)
78
+ return null;
79
+ return {
80
+ ref: blobRefOf(id),
81
+ size: entry.size,
82
+ time: entry.time,
83
+ ...(entry.mime === undefined ? {} : { mime: entry.mime }),
84
+ };
85
+ }
86
+ async delete(ref) {
87
+ const id = blobIdOf(ref);
88
+ if (id === null)
89
+ return;
90
+ const entry = this.registry.entryFor(id);
91
+ if (entry === null)
92
+ return;
93
+ this.registry.forget(id);
94
+ try {
95
+ await unlink(entry.path);
96
+ }
97
+ catch {
98
+ // The bytes are already gone; the id is what an event holds.
99
+ }
100
+ }
101
+ pathFor(id) {
102
+ return join(this.directory, String(id));
103
+ }
104
+ }
package/index.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @4fu/arbor-journal-sqlite - the default `Journal` and `BlobStore`.
3
+ */
4
+ export { SqliteJournal, deriveLinks, parseEventRef, eventRefOf, summaryOf } from "./sqlite-journal.ts";
5
+ export type { SqliteJournalOptions } from "./sqlite-journal.ts";
6
+ export { FsBlobStore, blobIdOf, blobRefOf } from "./blob-store.ts";
7
+ export type { BlobEntry, BlobRegistry } from "./blob-store.ts";
8
+ export { fallbackSegmenter, resolveSegmenter, indexText } from "./segmenter.ts";
9
+ export { SCHEMA_VERSION, IMPLICIT_CITE_WEIGHT, TRIGRAM_TABLE, WORD_TABLE, createSchema, } from "./schema.ts";
10
+ export { buildFilters, escapeLike, likePattern, quoteFts, ranked, substring, trigramMatch, wordMatch, } from "./search.ts";
11
+ export type { FilterSql, Ranked, SearchFilters } from "./search.ts";
12
+ export { MIN_TRIGRAM_CHARS } from "./sqlite-journal.ts";
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @4fu/arbor-journal-sqlite - the default `Journal` and `BlobStore`.
3
+ */
4
+ export { SqliteJournal, deriveLinks, parseEventRef, eventRefOf, summaryOf } from "./sqlite-journal.js";
5
+ export { FsBlobStore, blobIdOf, blobRefOf } from "./blob-store.js";
6
+ export { fallbackSegmenter, resolveSegmenter, indexText } from "./segmenter.js";
7
+ export { SCHEMA_VERSION, IMPLICIT_CITE_WEIGHT, TRIGRAM_TABLE, WORD_TABLE, createSchema, } from "./schema.js";
8
+ export { buildFilters, escapeLike, likePattern, quoteFts, ranked, substring, trigramMatch, wordMatch, } from "./search.js";
9
+ export { MIN_TRIGRAM_CHARS } from "./sqlite-journal.js";
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@4fu/arbor-journal-sqlite",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "The default Journal: append-only events with a global sequence, targets, native ids, refs, blobs and lexical search on SQLite.",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./index.d.ts",
9
+ "default": "./index.js"
10
+ }
11
+ },
12
+ "types": "./index.d.ts",
13
+ "main": "./index.js",
14
+ "files": [
15
+ "blob-store.d.ts",
16
+ "blob-store.js",
17
+ "index.d.ts",
18
+ "index.js",
19
+ "schema.d.ts",
20
+ "schema.js",
21
+ "search.d.ts",
22
+ "search.js",
23
+ "segmenter.d.ts",
24
+ "segmenter.js",
25
+ "sqlite-journal.d.ts",
26
+ "sqlite-journal.js",
27
+ "stored.d.ts",
28
+ "stored.js"
29
+ ],
30
+ "engines": {
31
+ "node": ">=24"
32
+ },
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/4fuu/arbor.git",
37
+ "directory": "packages/journal-sqlite"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@4fu/arbor-errors": "0.1.0",
44
+ "@4fu/arbor-interfaces": "0.1.0"
45
+ },
46
+ "peerDependencies": {
47
+ "@4fu/arbor-segmenter-jieba": "0.1.0"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@4fu/arbor-segmenter-jieba": {
51
+ "optional": true
52
+ }
53
+ }
54
+ }
package/schema.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The SQLite schema for the journal (docs/journal.md).
3
+ *
4
+ * The journal is append-only: nothing is edited, nothing is deleted, nothing
5
+ * merges. The one exception is a redaction, which erases content in place and
6
+ * records, in its own event, that it did.
7
+ *
8
+ * Layout:
9
+ *
10
+ * - `events` - one row per event. `seq` is the one global order, assigned at
11
+ * append and only growing; the ref is `r` + `seq`. `time` is for display.
12
+ * - `event_targets` - what an event touches, one row per target, indexed by
13
+ * target for dispatch candidates, steering and notices.
14
+ * - `event_refs` - the refs an event names.
15
+ * - `event_blobs` - the media an event holds, by id; the bytes live in the
16
+ * blob store beside the journal.
17
+ * - `links` - the links the host records between refs.
18
+ * - `watermarks` - the newest event on a target a window has seen.
19
+ * - `redactions` - which event redacted which, and why.
20
+ * - `blobs` - media stored once by content.
21
+ * - `events_fts` - trigram index for exact substrings.
22
+ * - `events_words` - word index for BM25.
23
+ */
24
+ import type { DatabaseSync } from "node:sqlite";
25
+ /** Bumped when a change makes an existing database unusable. */
26
+ export declare const SCHEMA_VERSION = 2;
27
+ /** A bare cite is weaker than a link the host declared. */
28
+ export declare const IMPLICIT_CITE_WEIGHT = 0.5;
29
+ export declare const TRIGRAM_TABLE = "events_fts";
30
+ export declare const WORD_TABLE = "events_words";
31
+ /** Create the schema when it is missing, migrate an older one, and record the version. */
32
+ export declare function createSchema(db: DatabaseSync): void;
33
+ export declare function readMeta(db: DatabaseSync, key: string): string | null;
package/schema.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The SQLite schema for the journal (docs/journal.md).
3
+ *
4
+ * The journal is append-only: nothing is edited, nothing is deleted, nothing
5
+ * merges. The one exception is a redaction, which erases content in place and
6
+ * records, in its own event, that it did.
7
+ *
8
+ * Layout:
9
+ *
10
+ * - `events` - one row per event. `seq` is the one global order, assigned at
11
+ * append and only growing; the ref is `r` + `seq`. `time` is for display.
12
+ * - `event_targets` - what an event touches, one row per target, indexed by
13
+ * target for dispatch candidates, steering and notices.
14
+ * - `event_refs` - the refs an event names.
15
+ * - `event_blobs` - the media an event holds, by id; the bytes live in the
16
+ * blob store beside the journal.
17
+ * - `links` - the links the host records between refs.
18
+ * - `watermarks` - the newest event on a target a window has seen.
19
+ * - `redactions` - which event redacted which, and why.
20
+ * - `blobs` - media stored once by content.
21
+ * - `events_fts` - trigram index for exact substrings.
22
+ * - `events_words` - word index for BM25.
23
+ */
24
+ /** Bumped when a change makes an existing database unusable. */
25
+ export const SCHEMA_VERSION = 2;
26
+ /** A bare cite is weaker than a link the host declared. */
27
+ export const IMPLICIT_CITE_WEIGHT = 0.5;
28
+ export const TRIGRAM_TABLE = "events_fts";
29
+ export const WORD_TABLE = "events_words";
30
+ const STATEMENTS = [
31
+ `PRAGMA journal_mode = WAL`,
32
+ `PRAGMA foreign_keys = ON`,
33
+ `PRAGMA busy_timeout = 5000`,
34
+ `CREATE TABLE IF NOT EXISTS meta (
35
+ key TEXT PRIMARY KEY,
36
+ value TEXT NOT NULL
37
+ )`,
38
+ `CREATE TABLE IF NOT EXISTS events (
39
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ window TEXT,
41
+ time INTEGER NOT NULL,
42
+ kind TEXT NOT NULL,
43
+ external TEXT,
44
+ body TEXT,
45
+ redacted TEXT
46
+ )`,
47
+ // A redelivered outside message is stored once.
48
+ `CREATE UNIQUE INDEX IF NOT EXISTS events_external ON events (external) WHERE external IS NOT NULL`,
49
+ `CREATE INDEX IF NOT EXISTS events_window ON events (window, seq)`,
50
+ `CREATE INDEX IF NOT EXISTS events_kind ON events (kind, seq)`,
51
+ `CREATE TABLE IF NOT EXISTS event_targets (
52
+ seq INTEGER NOT NULL,
53
+ target TEXT NOT NULL,
54
+ PRIMARY KEY (seq, target)
55
+ )`,
56
+ `CREATE INDEX IF NOT EXISTS event_targets_target ON event_targets (target, seq)`,
57
+ `CREATE TABLE IF NOT EXISTS event_refs (
58
+ seq INTEGER NOT NULL,
59
+ ref TEXT NOT NULL,
60
+ PRIMARY KEY (seq, ref)
61
+ )`,
62
+ `CREATE INDEX IF NOT EXISTS event_refs_ref ON event_refs (ref, seq)`,
63
+ `CREATE TABLE IF NOT EXISTS event_blobs (
64
+ seq INTEGER NOT NULL,
65
+ blob TEXT NOT NULL,
66
+ PRIMARY KEY (seq, blob)
67
+ )`,
68
+ `CREATE TABLE IF NOT EXISTS links (
69
+ seq INTEGER NOT NULL,
70
+ from_ref TEXT NOT NULL,
71
+ to_ref TEXT NOT NULL,
72
+ kind TEXT NOT NULL,
73
+ weight REAL NOT NULL,
74
+ time INTEGER NOT NULL,
75
+ PRIMARY KEY (seq, to_ref, kind)
76
+ )`,
77
+ `CREATE INDEX IF NOT EXISTS links_to ON links (to_ref, weight DESC, seq DESC)`,
78
+ `CREATE INDEX IF NOT EXISTS links_from ON links (from_ref, seq DESC)`,
79
+ `CREATE TABLE IF NOT EXISTS watermarks (
80
+ window TEXT NOT NULL,
81
+ target TEXT NOT NULL,
82
+ seq INTEGER NOT NULL,
83
+ PRIMARY KEY (window, target)
84
+ )`,
85
+ `CREATE TABLE IF NOT EXISTS redactions (
86
+ covers INTEGER PRIMARY KEY,
87
+ by INTEGER NOT NULL,
88
+ reason TEXT NOT NULL,
89
+ time INTEGER NOT NULL
90
+ )`,
91
+ `CREATE TABLE IF NOT EXISTS blobs (
92
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ sha256 TEXT NOT NULL UNIQUE,
94
+ size INTEGER NOT NULL,
95
+ mime TEXT,
96
+ path TEXT NOT NULL,
97
+ time INTEGER NOT NULL
98
+ )`,
99
+ // Exact substrings, for Chinese and mixed text.
100
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${TRIGRAM_TABLE} USING fts5(body, tokenize = 'trigram')`,
101
+ // Words, for BM25.
102
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${WORD_TABLE} USING fts5(body, tokenize = 'unicode61')`,
103
+ ];
104
+ /**
105
+ * Version 2 dropped the `snapshot` column from `events`: the workspace
106
+ * snapshots it named are gone with the plain-directory workspace
107
+ * (docs/workspaces.md). Every event, every ref and every index that does not
108
+ * name the column stays, so a version-1 database opens where it left off
109
+ * instead of being rebuilt.
110
+ */
111
+ function migrateToVersion2(db) {
112
+ const columns = db.prepare("PRAGMA table_info(events)").all();
113
+ if (!columns.some((column) => column.name === "snapshot"))
114
+ return;
115
+ // An index that names the column has to go before the column does.
116
+ const indexes = db.prepare("PRAGMA index_list(events)").all();
117
+ for (const index of indexes) {
118
+ const keys = db.prepare(`PRAGMA index_info(${JSON.stringify(index.name)})`).all();
119
+ if (!keys.some((key) => key.name === "snapshot"))
120
+ continue;
121
+ db.exec(`DROP INDEX ${JSON.stringify(index.name)}`);
122
+ }
123
+ db.exec("ALTER TABLE events DROP COLUMN snapshot");
124
+ }
125
+ /** Create the schema when it is missing, migrate an older one, and record the version. */
126
+ export function createSchema(db) {
127
+ // The version lives in the one table that has to exist before anything else.
128
+ db.exec(`CREATE TABLE IF NOT EXISTS meta (
129
+ key TEXT PRIMARY KEY,
130
+ value TEXT NOT NULL
131
+ )`);
132
+ const version = readMeta(db, "schema_version");
133
+ if (version !== null && Number(version) > SCHEMA_VERSION) {
134
+ throw new Error(`journal schema ${version} is newer than this build (${SCHEMA_VERSION}); upgrade arbor, or migrate the database`);
135
+ }
136
+ if (Number(version) === SCHEMA_VERSION - 1)
137
+ migrateToVersion2(db);
138
+ for (const statement of STATEMENTS)
139
+ db.exec(statement);
140
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
141
+ }
142
+ export function readMeta(db, key) {
143
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
144
+ return row === undefined ? null : row.value;
145
+ }