@skanl/brambo-memory-sqlite 0.1.1

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 SKANL
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 ADDED
@@ -0,0 +1,44 @@
1
+ # @skanl/brambo-memory-sqlite
2
+
3
+ The embedded-SQLite `MemoryProvider`, on `node:sqlite`'s `DatabaseSync`.
4
+
5
+ **No new dependency.** `node:sqlite` is the platform, measured working on Node 24.14.1 and Node
6
+ 26.8.1 — the exact two versions CI runs. Brambo ships exactly one non-`@skanl/brambo-*` runtime dependency
7
+ in total (`jsonc-parser`, in `@skanl/brambo-projection`) and this package does not make it two.
8
+
9
+ ```ts
10
+ import { SqliteMemoryProvider } from '@skanl/brambo-memory-sqlite'
11
+
12
+ const provider = await SqliteMemoryProvider.open({ databasePath: '/tmp/brambo-memory.db' })
13
+ await provider.save({
14
+ payload: 'the deploy script needs BRAMBO_HOME set',
15
+ provenance: { agentId: 'claude-code', workspaceId: 'ws-7', recordedAt: new Date().toISOString() },
16
+ })
17
+ const recent = await provider.search({ workspaceId: 'ws-7' })
18
+ await provider.dispose()
19
+ ```
20
+
21
+ The store is one `entries` table whose `sequence` is `INTEGER PRIMARY KEY AUTOINCREMENT`, and the
22
+ format version lives in `PRAGMA user_version`, which is where SQLite already keeps exactly this
23
+ fact. Append-only is enforced by omission and by SQL: `sqlite-memory-provider.ts` contains no
24
+ UPDATE and no DELETE. `user_version = 0` is a database nobody has stamped — an absent store, so it
25
+ is stamped and served (AD-5); any other value is refused with
26
+ `BRAMBO_CONTRACT_MEMORY_STORE_VERSION_MISMATCH`, and the refused open closes its connection and
27
+ creates no table.
28
+
29
+ ## The experimental warning
30
+
31
+ Node 24 prints `ExperimentalWarning: SQLite is an experimental feature` when `node:sqlite` LOADS,
32
+ not when a database opens — so a static import would put it on stderr for a consumer that never
33
+ touches a store, and stderr is a contract surface for `brambo run`. `src/load-sqlite.ts` therefore
34
+ does two things: it imports the module lazily on first `open()`, and it confines that one warning
35
+ for the duration of that one import. The filter is narrow (type AND text), so every other warning
36
+ still reaches the user. `test/load-sqlite.test.ts` proves all of it in child processes, with two
37
+ controls: a plain import that MUST show the warning, and an unrelated `ExperimentalWarning` that
38
+ MUST survive the confinement.
39
+
40
+ ## Conformance
41
+
42
+ `packages/contracts/src/contract-suite/memory-clauses.ts` holds the clauses, and
43
+ `test/contract.test.ts` runs every one of them against this provider. The identical array runs
44
+ against `@skanl/brambo-memory-filesystem`; that swap is FR-16 and scenario S2.
@@ -0,0 +1 @@
1
+ export { SqliteMemoryProvider, type SqliteMemoryProviderOptions } from './sqlite-memory-provider.ts';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { SqliteMemoryProvider } from './sqlite-memory-provider.js';
@@ -0,0 +1,34 @@
1
+ type SqliteModule = typeof import('node:sqlite');
2
+ /**
3
+ * Loads `node:sqlite` LAZILY and with its experimental warning confined.
4
+ *
5
+ * Two facts, both measured on Node 24.14.1 (the version CI runs):
6
+ *
7
+ * 1. The warning fires when the MODULE LOADS, not when a database is opened. A
8
+ * static `import { DatabaseSync } from 'node:sqlite'` therefore prints
9
+ * `ExperimentalWarning: SQLite is an experimental feature and might change at
10
+ * any time` on stderr the moment anything imports this package — including a
11
+ * consumer that never touches a store. stderr is a contract surface for
12
+ * `brambo run`, so that is brambo's output, not SQLite's.
13
+ * 2. `process.emitWarning` is where it comes out, and the emission lands during
14
+ * the awaited import (Node schedules it on `process.nextTick`, which drains
15
+ * before this function's continuation). So a patch installed around the
16
+ * import and removed after it catches exactly that warning and nothing else.
17
+ *
18
+ * Both halves are applied because they answer different questions: the lazy
19
+ * import means nothing loads until a consumer actually opens a store, and the
20
+ * confinement means opening one is silent too. The filter is narrow — the type
21
+ * AND the text — so any other warning, experimental or not, still reaches the
22
+ * user. `packages/memory-sqlite/test/load-sqlite.test.ts` drives that with a
23
+ * control warning that must survive.
24
+ *
25
+ * ponytail: the patch is a global mutation, held for the duration of ONE
26
+ * memoised import. Ceiling: a warning emitted by unrelated code inside that
27
+ * window is still handed to the original emitter, but a concurrent patcher that
28
+ * replaces `process.emitWarning` in the same window would have its replacement
29
+ * restored away — which is why the restore checks that the function it is
30
+ * replacing is still ours. Upgrade path: delete this file's body down to a plain
31
+ * `import('node:sqlite')` the release `node:sqlite` stops being experimental in.
32
+ */
33
+ export declare function loadSqlite(): Promise<SqliteModule>;
34
+ export {};
@@ -0,0 +1,55 @@
1
+ let pending;
2
+ /**
3
+ * Loads `node:sqlite` LAZILY and with its experimental warning confined.
4
+ *
5
+ * Two facts, both measured on Node 24.14.1 (the version CI runs):
6
+ *
7
+ * 1. The warning fires when the MODULE LOADS, not when a database is opened. A
8
+ * static `import { DatabaseSync } from 'node:sqlite'` therefore prints
9
+ * `ExperimentalWarning: SQLite is an experimental feature and might change at
10
+ * any time` on stderr the moment anything imports this package — including a
11
+ * consumer that never touches a store. stderr is a contract surface for
12
+ * `brambo run`, so that is brambo's output, not SQLite's.
13
+ * 2. `process.emitWarning` is where it comes out, and the emission lands during
14
+ * the awaited import (Node schedules it on `process.nextTick`, which drains
15
+ * before this function's continuation). So a patch installed around the
16
+ * import and removed after it catches exactly that warning and nothing else.
17
+ *
18
+ * Both halves are applied because they answer different questions: the lazy
19
+ * import means nothing loads until a consumer actually opens a store, and the
20
+ * confinement means opening one is silent too. The filter is narrow — the type
21
+ * AND the text — so any other warning, experimental or not, still reaches the
22
+ * user. `packages/memory-sqlite/test/load-sqlite.test.ts` drives that with a
23
+ * control warning that must survive.
24
+ *
25
+ * ponytail: the patch is a global mutation, held for the duration of ONE
26
+ * memoised import. Ceiling: a warning emitted by unrelated code inside that
27
+ * window is still handed to the original emitter, but a concurrent patcher that
28
+ * replaces `process.emitWarning` in the same window would have its replacement
29
+ * restored away — which is why the restore checks that the function it is
30
+ * replacing is still ours. Upgrade path: delete this file's body down to a plain
31
+ * `import('node:sqlite')` the release `node:sqlite` stops being experimental in.
32
+ */
33
+ export function loadSqlite() {
34
+ pending ??= importWithWarningConfined();
35
+ return pending;
36
+ }
37
+ async function importWithWarningConfined() {
38
+ const original = process.emitWarning;
39
+ const patched = (warning, ...rest) => {
40
+ const first = rest[0];
41
+ const type = typeof first === 'string' ? first : first?.type;
42
+ const text = typeof warning === 'string' ? warning : warning.message;
43
+ if (type === 'ExperimentalWarning' && text.includes('SQLite'))
44
+ return;
45
+ original.call(process, warning, ...rest);
46
+ };
47
+ process.emitWarning = patched;
48
+ try {
49
+ return await import('node:sqlite');
50
+ }
51
+ finally {
52
+ if (process.emitWarning === patched)
53
+ process.emitWarning = original;
54
+ }
55
+ }
@@ -0,0 +1,29 @@
1
+ import type { MemoryEntry, MemoryProvider, MemorySaveRequest, MemorySearchQuery, MemorySearchResult, MemoryStoreInfo, MemoryTimeline } from '@skanl/brambo-contracts';
2
+ export interface SqliteMemoryProviderOptions {
3
+ /** Path to the SQLite database file. Its directory must already exist. */
4
+ readonly databasePath: string;
5
+ }
6
+ /**
7
+ * Embedded-SQLite MemoryProvider on `node:sqlite`'s `DatabaseSync`.
8
+ *
9
+ * NO new dependency: `node:sqlite` is the platform, measured working on Node
10
+ * 24.14.1 and 26.8.1 — the exact two versions CI runs. Its experimental warning
11
+ * is handled in `load-sqlite.ts`, which is also why this module imports the
12
+ * class TYPE only and the runtime binding arrives through `loadSqlite()`.
13
+ *
14
+ * Append-only is enforced by omission and by SQL: this file contains no UPDATE
15
+ * and no DELETE, and the format version lives in `PRAGMA user_version`, which is
16
+ * where SQLite already keeps exactly this fact.
17
+ */
18
+ export declare class SqliteMemoryProvider implements MemoryProvider {
19
+ #private;
20
+ private constructor();
21
+ static open(options: SqliteMemoryProviderOptions): Promise<SqliteMemoryProvider>;
22
+ save(request: MemorySaveRequest): Promise<MemoryEntry>;
23
+ search(query: MemorySearchQuery): Promise<MemorySearchResult>;
24
+ timeline(): Promise<MemoryTimeline>;
25
+ describe(): Promise<MemoryStoreInfo>;
26
+ overwrite(entryId: string): Promise<never>;
27
+ /** Idempotent, and destroys nothing: the database file outlives every provider. */
28
+ dispose(): Promise<void>;
29
+ }
@@ -0,0 +1,223 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { MEMORY_FORMAT_VERSION, BramboError, BRAMBO_ERROR_CODES, memoryOverwriteUnsupported, memoryStoreVersionMismatch, validateMemorySaveRequest, } from '@skanl/brambo-contracts';
3
+ import { loadSqlite } from './load-sqlite.js';
4
+ /**
5
+ * The store, as one table. `sequence` is `INTEGER PRIMARY KEY AUTOINCREMENT`
6
+ * rather than a plain rowid alias: AUTOINCREMENT is what guarantees the counter
7
+ * never goes backwards, and an append-only log with a reused sequence number is
8
+ * a log that reorders itself.
9
+ */
10
+ const SCHEMA = `
11
+ CREATE TABLE IF NOT EXISTS entries (
12
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ id TEXT NOT NULL UNIQUE,
14
+ payload TEXT NOT NULL,
15
+ agent_id TEXT NOT NULL,
16
+ workspace_id TEXT NOT NULL,
17
+ recorded_at TEXT NOT NULL,
18
+ supersedes TEXT
19
+ )`;
20
+ const COLUMNS = 'sequence, id, payload, agent_id, workspace_id, recorded_at, supersedes';
21
+ /**
22
+ * Embedded-SQLite MemoryProvider on `node:sqlite`'s `DatabaseSync`.
23
+ *
24
+ * NO new dependency: `node:sqlite` is the platform, measured working on Node
25
+ * 24.14.1 and 26.8.1 — the exact two versions CI runs. Its experimental warning
26
+ * is handled in `load-sqlite.ts`, which is also why this module imports the
27
+ * class TYPE only and the runtime binding arrives through `loadSqlite()`.
28
+ *
29
+ * Append-only is enforced by omission and by SQL: this file contains no UPDATE
30
+ * and no DELETE, and the format version lives in `PRAGMA user_version`, which is
31
+ * where SQLite already keeps exactly this fact.
32
+ */
33
+ export class SqliteMemoryProvider {
34
+ #databasePath;
35
+ #db;
36
+ #disposed = false;
37
+ constructor(databasePath, db) {
38
+ this.#databasePath = databasePath;
39
+ this.#db = db;
40
+ }
41
+ static async open(options) {
42
+ const databasePath = options?.databasePath;
43
+ if (typeof databasePath !== 'string' || databasePath.trim().length === 0) {
44
+ throw new BramboError(BRAMBO_ERROR_CODES.contractMemoryStoreUnavailable, 'SqliteMemoryProvider requires a non-empty string databasePath');
45
+ }
46
+ const { DatabaseSync: Database } = await loadSqlite();
47
+ let db;
48
+ try {
49
+ db = new Database(databasePath);
50
+ }
51
+ catch (error) {
52
+ throw unavailable('open', databasePath, error);
53
+ }
54
+ try {
55
+ const found = readUserVersion(db, databasePath);
56
+ // 0 is SQLite's default for a database nobody has stamped: an absent store,
57
+ // not a wrong one (AD-5). Any OTHER value is a store this build refuses —
58
+ // version by reject, never migrate.
59
+ if (found !== 0 && found !== MEMORY_FORMAT_VERSION) {
60
+ throw memoryStoreVersionMismatch(databasePath, found);
61
+ }
62
+ db.exec(SCHEMA);
63
+ if (found === 0)
64
+ db.exec(`PRAGMA user_version = ${String(MEMORY_FORMAT_VERSION)}`);
65
+ }
66
+ catch (error) {
67
+ // A database opened but not accepted must not be left open, or a caller
68
+ // that retries after fixing the store meets a lock it cannot explain.
69
+ try {
70
+ db.close();
71
+ }
72
+ catch {
73
+ // The refusal below is the verdict; a close failure must not replace it.
74
+ }
75
+ throw error instanceof BramboError ? error : unavailable('initialise', databasePath, error);
76
+ }
77
+ return new SqliteMemoryProvider(databasePath, db);
78
+ }
79
+ async save(request) {
80
+ this.#assertActive();
81
+ const valid = validateMemorySaveRequest(request);
82
+ if (valid.supersedes !== undefined) {
83
+ const existing = this.#db.prepare('SELECT 1 AS present FROM entries WHERE id = ?').get(valid.supersedes);
84
+ if (existing === undefined) {
85
+ throw new BramboError(BRAMBO_ERROR_CODES.contractMemoryUnknownEntry, `memory store '${this.#databasePath}' holds no entry '${valid.supersedes}' to supersede`);
86
+ }
87
+ }
88
+ const id = randomUUID();
89
+ let inserted;
90
+ try {
91
+ inserted = this.#db
92
+ .prepare('INSERT INTO entries (id, payload, agent_id, workspace_id, recorded_at, supersedes) VALUES (?, ?, ?, ?, ?, ?)')
93
+ .run(id, valid.payload, valid.provenance.agentId, valid.provenance.workspaceId, valid.provenance.recordedAt, valid.supersedes ?? null);
94
+ }
95
+ catch (error) {
96
+ throw unavailable('append to', this.#databasePath, error);
97
+ }
98
+ return Object.freeze({
99
+ id,
100
+ sequence: Number(inserted.lastInsertRowid),
101
+ payload: valid.payload,
102
+ provenance: Object.freeze({
103
+ agentId: valid.provenance.agentId,
104
+ workspaceId: valid.provenance.workspaceId,
105
+ recordedAt: valid.provenance.recordedAt,
106
+ }),
107
+ ...(valid.supersedes === undefined ? {} : { supersedes: valid.supersedes }),
108
+ });
109
+ }
110
+ async search(query) {
111
+ this.#assertActive();
112
+ const conditions = [];
113
+ const parameters = [];
114
+ if (query?.workspaceId !== undefined) {
115
+ conditions.push('workspace_id = ?');
116
+ parameters.push(query.workspaceId);
117
+ }
118
+ if (query?.agentId !== undefined) {
119
+ conditions.push('agent_id = ?');
120
+ parameters.push(query.agentId);
121
+ }
122
+ if (query?.contains !== undefined) {
123
+ // `instr`, not `LIKE`: LIKE is case-INSENSITIVE for ASCII by default and
124
+ // would disagree with `String.prototype.includes` in the filesystem
125
+ // provider on exactly the inputs FR-16 says must behave identically.
126
+ // Measured on the empty needle too — `instr(x, '') = 1`, matching
127
+ // `''.includes('') === true`.
128
+ conditions.push('instr(payload, ?) > 0');
129
+ parameters.push(query.contains);
130
+ }
131
+ const where = conditions.length === 0 ? '' : ` WHERE ${conditions.join(' AND ')}`;
132
+ const rows = this.#db.prepare(`SELECT ${COLUMNS} FROM entries${where} ORDER BY sequence ASC`).all(...parameters);
133
+ const entries = rows.map((row) => this.#toEntry(row));
134
+ return { entries, matched: entries.length };
135
+ }
136
+ async timeline() {
137
+ this.#assertActive();
138
+ const rows = this.#db.prepare(`SELECT ${COLUMNS} FROM entries ORDER BY sequence ASC`).all();
139
+ return { entries: rows.map((row) => this.#toEntry(row)) };
140
+ }
141
+ async describe() {
142
+ this.#assertActive();
143
+ const row = this.#db
144
+ .prepare('SELECT COUNT(*) AS entry_count, MIN(recorded_at) AS first_write, MAX(recorded_at) AS last_write FROM entries')
145
+ .get();
146
+ const entryCount = Number(row?.['entry_count'] ?? 0);
147
+ const first = row?.['first_write'];
148
+ const last = row?.['last_write'];
149
+ return {
150
+ formatVersion: MEMORY_FORMAT_VERSION,
151
+ entryCount,
152
+ // MIN/MAX over an empty table are SQL NULL. Absent, not null and not '' (AD-5).
153
+ ...(typeof first === 'string' && typeof last === 'string' ? { firstWriteAt: first, lastWriteAt: last } : {}),
154
+ };
155
+ }
156
+ async overwrite(entryId) {
157
+ this.#assertActive();
158
+ throw memoryOverwriteUnsupported(entryId);
159
+ }
160
+ /** Idempotent, and destroys nothing: the database file outlives every provider. */
161
+ async dispose() {
162
+ if (this.#disposed)
163
+ return;
164
+ this.#disposed = true;
165
+ try {
166
+ this.#db.close();
167
+ }
168
+ catch (error) {
169
+ throw unavailable('close', this.#databasePath, error);
170
+ }
171
+ }
172
+ #assertActive() {
173
+ if (this.#disposed) {
174
+ throw new BramboError(BRAMBO_ERROR_CODES.contractProviderDisposed, 'memory provider has been disposed and no longer serves its store');
175
+ }
176
+ }
177
+ /**
178
+ * Rows arrive as `SQLOutputValue`, a union that includes `null` and numbers.
179
+ * Every column is read through a type check rather than a cast, so a database
180
+ * someone else wrote into surfaces as a coded store failure instead of an
181
+ * entry with `null` where its workspace id should be — which is precisely the
182
+ * provenance leak D5 exists to prevent, arriving through the back door.
183
+ */
184
+ #toEntry(row) {
185
+ const sequence = row['sequence'];
186
+ if (typeof sequence !== 'number' && typeof sequence !== 'bigint') {
187
+ throw unavailable('read', this.#databasePath, new Error("column 'sequence' is not an integer"));
188
+ }
189
+ const supersedes = row['supersedes'];
190
+ if (supersedes !== null && typeof supersedes !== 'string') {
191
+ throw unavailable('read', this.#databasePath, new Error("column 'supersedes' is neither NULL nor text"));
192
+ }
193
+ return Object.freeze({
194
+ id: this.#text(row, 'id'),
195
+ sequence: Number(sequence),
196
+ payload: this.#text(row, 'payload'),
197
+ provenance: Object.freeze({
198
+ agentId: this.#text(row, 'agent_id'),
199
+ workspaceId: this.#text(row, 'workspace_id'),
200
+ recordedAt: this.#text(row, 'recorded_at'),
201
+ }),
202
+ ...(supersedes === null ? {} : { supersedes }),
203
+ });
204
+ }
205
+ #text(row, column) {
206
+ const value = row[column];
207
+ if (typeof value !== 'string') {
208
+ throw unavailable('read', this.#databasePath, new Error(`column '${column}' is not text`));
209
+ }
210
+ return value;
211
+ }
212
+ }
213
+ function readUserVersion(db, databasePath) {
214
+ const row = db.prepare('PRAGMA user_version').get();
215
+ const found = row?.['user_version'];
216
+ if (typeof found !== 'number' && typeof found !== 'bigint') {
217
+ throw unavailable('read the format version of', databasePath, new Error('PRAGMA user_version returned no integer'));
218
+ }
219
+ return Number(found);
220
+ }
221
+ function unavailable(operation, path, error) {
222
+ return new BramboError(BRAMBO_ERROR_CODES.contractMemoryStoreUnavailable, `memory store failed to ${operation} '${path}': ${error instanceof Error ? error.message : String(error)}`, { cause: error });
223
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@skanl/brambo-memory-sqlite",
3
+ "version": "0.1.1",
4
+ "description": "The embedded-SQLite MemoryProvider, on node:sqlite's DatabaseSync.",
5
+ "keywords": [
6
+ "ai-agent",
7
+ "brambo",
8
+ "memory",
9
+ "sqlite",
10
+ "persistence"
11
+ ],
12
+ "homepage": "https://github.com/SKANL/brambo#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/SKANL/brambo/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/SKANL/brambo.git",
19
+ "directory": "packages/memory-sqlite"
20
+ },
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "brambo-source": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "@skanl/brambo-contracts": "0.1.1"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.13.3",
41
+ "typescript": "~7.0.2",
42
+ "vitest": "^4.1.11"
43
+ },
44
+ "files": [
45
+ "dist"
46
+ ],
47
+ "scripts": {
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest run",
50
+ "lint": "eslint .",
51
+ "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
52
+ }
53
+ }