@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 +21 -0
- package/README.md +44 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/load-sqlite.d.ts +34 -0
- package/dist/load-sqlite.js +55 -0
- package/dist/sqlite-memory-provider.d.ts +29 -0
- package/dist/sqlite-memory-provider.js +223 -0
- package/package.json +53 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|