@kyneta/sqlite-store 1.5.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) 2025-present Duane Johnson
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,168 @@
1
+ # @kyneta/sqlite-store
2
+
3
+ SQLite storage backend for `@kyneta/exchange` — universal persistent storage for every deployment target.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @kyneta/sqlite-store
9
+ ```
10
+
11
+ Peer dependencies: `@kyneta/exchange`, `@kyneta/schema`, `@kyneta/sql-store-core`.
12
+
13
+ You also need a SQLite binding of your choice — this package has **zero opinion** about which one you use. It works with any object conforming to the `SqliteAdapter` interface.
14
+
15
+ ## Usage
16
+
17
+ ### With `better-sqlite3` (Node.js)
18
+
19
+ ```ts
20
+ import Database from "better-sqlite3"
21
+ import { Exchange } from "@kyneta/exchange"
22
+ import { createSqliteStore, fromBetterSqlite3 } from "@kyneta/sqlite-store"
23
+
24
+ const db = new Database("./data/exchange.db")
25
+ const store = createSqliteStore(fromBetterSqlite3(db))
26
+
27
+ const exchange = new Exchange({
28
+ stores: [store],
29
+ // ...
30
+ })
31
+ ```
32
+
33
+ ### With `bun:sqlite` (Bun)
34
+
35
+ ```ts
36
+ import { Database } from "bun:sqlite"
37
+ import { Exchange } from "@kyneta/exchange"
38
+ import { createSqliteStore, fromBunSqlite } from "@kyneta/sqlite-store"
39
+
40
+ const db = new Database("./data/exchange.db")
41
+ const store = createSqliteStore(fromBunSqlite(db))
42
+
43
+ const exchange = new Exchange({
44
+ stores: [store],
45
+ // ...
46
+ })
47
+ ```
48
+
49
+ ### With Cloudflare Durable Objects
50
+
51
+ DO's `ctx.storage.sql.exec(sql, ...params)` already returns an iterable cursor, so the adapter is essentially pass-through:
52
+
53
+ ```ts
54
+ import { SqliteStore, type SqliteAdapter } from "@kyneta/sqlite-store"
55
+
56
+ function fromCloudflareDoSql(ctx: DurableObjectState): SqliteAdapter {
57
+ return {
58
+ exec: (sql, ...params) => { ctx.storage.sql.exec(sql, ...params) },
59
+ iterate: (sql, ...params) => ctx.storage.sql.exec(sql, ...params),
60
+ // DO request handlers are implicitly transactional — each request runs
61
+ // atomically against storage. `transaction` just runs the function.
62
+ transaction: (fn) => fn(),
63
+ // DO storage doesn't have an explicit close — the actor lifecycle owns it.
64
+ close: () => {},
65
+ }
66
+ }
67
+
68
+ // In your DO class:
69
+ const store = new SqliteStore(fromCloudflareDoSql(this.ctx))
70
+ ```
71
+
72
+ ### With any other binding
73
+
74
+ Any object conforming to `SqliteAdapter` works:
75
+
76
+ ```ts
77
+ import { SqliteStore, type SqliteAdapter } from "@kyneta/sqlite-store"
78
+
79
+ const adapter: SqliteAdapter = {
80
+ exec(sql, ...params) { /* execute a write statement */ },
81
+ iterate(sql, ...params) { /* return an Iterable of rows (lazy) */ },
82
+ transaction(fn) { /* execute fn inside a transaction, return result */ },
83
+ close() { /* release resources */ },
84
+ }
85
+
86
+ const store = new SqliteStore(adapter)
87
+ ```
88
+
89
+ ## `SqliteAdapter` interface
90
+
91
+ ```ts
92
+ interface SqliteAdapter {
93
+ exec(sql: string, ...params: unknown[]): void
94
+ iterate<T = Record<string, unknown>>(
95
+ sql: string,
96
+ ...params: unknown[]
97
+ ): Iterable<T>
98
+ transaction<R>(fn: () => R): R
99
+ close(): void
100
+ }
101
+ ```
102
+
103
+ Four methods: `exec` (write), `iterate` (read, returns a lazy `Iterable<T>`), `transaction` (atomic batch), `close` (release).
104
+
105
+ ## Recommended setup for `better-sqlite3` and `bun:sqlite`
106
+
107
+ Enable WAL mode before constructing the store. Without it, readers block on writes:
108
+
109
+ ```ts
110
+ const db = new Database("./data/exchange.db")
111
+ db.exec("PRAGMA journal_mode = WAL")
112
+ db.exec("PRAGMA synchronous = NORMAL")
113
+ ```
114
+
115
+ Not needed for Cloudflare DO — the platform manages durability.
116
+
117
+ ## Options
118
+
119
+ ### `tables`
120
+
121
+ ```ts
122
+ const store = new SqliteStore(adapter, {
123
+ tables: { meta: "app_meta", records: "app_records" },
124
+ })
125
+ ```
126
+
127
+ Default: `{ meta: "kyneta_meta", records: "kyneta_records" }`. Either or both names may be overridden.
128
+
129
+ Use `tables` when co-locating Exchange tables alongside application tables in the same SQLite database (for example, in a Cloudflare Durable Object that also stores application state), or when running multiple isolated Exchange instances in one database with distinct table-name pairs.
130
+
131
+ ## Migration from v1.x
132
+
133
+ `v2.0.0` replaces the `tablePrefix` option with an explicit `tables` pair, and changes the default table names from `meta` / `records` to `kyneta_meta` / `kyneta_records`. There is no compatibility shim.
134
+
135
+ ```ts
136
+ // v1.x
137
+ new SqliteStore(adapter) // tables: meta, records
138
+ new SqliteStore(adapter, { tablePrefix: "app_" }) // tables: app_meta, app_records
139
+
140
+ // v2.0
141
+ new SqliteStore(adapter) // tables: kyneta_meta, kyneta_records
142
+ new SqliteStore(adapter, {
143
+ tables: { meta: "app_meta", records: "app_records" },
144
+ })
145
+ ```
146
+
147
+ If you have existing data under the v1.x default names (`meta` / `records`), pass `tables: { meta: "meta", records: "records" }` explicitly to keep using them, or rename the tables via `ALTER TABLE`.
148
+
149
+ ## Schema
150
+
151
+ The store creates two tables on first use:
152
+
153
+ - **`tables.meta`** (default `kyneta_meta`) — materialized metadata index. `doc_id TEXT PRIMARY KEY`, `data TEXT NOT NULL` (JSON-encoded `StoreMeta`). `WITHOUT ROWID`.
154
+ - **`tables.records`** (default `kyneta_records`) — per-document append-only record stream. Composite primary key `(doc_id, seq)`. Binary `Uint8Array` payloads are stored in a `BLOB` column; string/JSON payloads in a `TEXT` column. `WITHOUT ROWID`.
155
+
156
+ ## Store interface
157
+
158
+ See the [`Store` interface](../../src/store/store.ts) in `@kyneta/exchange` for the full contract. Seven methods: `append`, `loadAll`, `replace`, `delete`, `currentMeta`, `listDocIds`, `close`.
159
+
160
+ ## Testing
161
+
162
+ The package passes the full `describeStore` conformance suite (17 contract tests) exported from `@kyneta/exchange/testing`, plus SQLite-specific tests for close/reopen persistence, sequence number continuity, replace atomicity, adapter factories, and `tables` isolation.
163
+
164
+ ## See also
165
+
166
+ - [`@kyneta/sql-store-core`](../sql-core/) — pure helpers shared with `postgres-store` and `prisma-store`.
167
+ - [`@kyneta/postgres-store`](../postgres/) — async-native Postgres backend.
168
+ - [`@kyneta/prisma-store`](../prisma/) — backend that takes a caller-supplied `PrismaClient`.
@@ -0,0 +1,85 @@
1
+ import { DocId, Store, StoreMeta, StoreRecord } from "@kyneta/exchange";
2
+ import { TableNames } from "@kyneta/sql-store-core";
3
+
4
+ //#region src/index.d.ts
5
+ /**
6
+ * `iterate` returns `Iterable<T>` rather than `T[]` so `loadAll` can
7
+ * stream million-record stores without materializing them all in
8
+ * memory. Cloudflare DO's `ctx.storage.sql.exec` returns a cursor for
9
+ * the same reason; this shape is chosen to pass through.
10
+ */
11
+ interface SqliteAdapter {
12
+ exec(sql: string, ...params: unknown[]): void;
13
+ iterate<T = Record<string, unknown>>(sql: string, ...params: unknown[]): Iterable<T>;
14
+ transaction<R>(fn: () => R): R;
15
+ close(): void;
16
+ }
17
+ /**
18
+ * Wrap a `better-sqlite3` Database as a `SqliteAdapter`.
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * import Database from "better-sqlite3"
23
+ * import { SqliteStore, fromBetterSqlite3 } from "@kyneta/sqlite-store"
24
+ *
25
+ * const db = new Database("exchange.db")
26
+ * const store = new SqliteStore(fromBetterSqlite3(db))
27
+ * ```
28
+ */
29
+ declare function fromBetterSqlite3(db: BetterSqlite3Database): SqliteAdapter;
30
+ /**
31
+ * Wrap a `bun:sqlite` Database as a `SqliteAdapter`.
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * import { Database } from "bun:sqlite"
36
+ * import { SqliteStore, fromBunSqlite } from "@kyneta/sqlite-store"
37
+ *
38
+ * const db = new Database("exchange.db")
39
+ * const store = new SqliteStore(fromBunSqlite(db))
40
+ * ```
41
+ */
42
+ declare function fromBunSqlite(db: BunSqliteDatabase): SqliteAdapter;
43
+ /** Structural type for a `better-sqlite3` Database instance. */
44
+ interface BetterSqlite3Database {
45
+ prepare(sql: string): {
46
+ run(...params: unknown[]): unknown;
47
+ iterate(...params: unknown[]): IterableIterator<unknown>;
48
+ };
49
+ transaction<R>(fn: () => R): () => R;
50
+ close(): void;
51
+ }
52
+ /** Structural type for a `bun:sqlite` Database instance. */
53
+ interface BunSqliteDatabase {
54
+ run(sql: string, ...params: unknown[]): void;
55
+ query(sql: string): {
56
+ iterate(...params: unknown[]): IterableIterator<unknown>;
57
+ };
58
+ transaction<R>(fn: () => R): () => R;
59
+ close(): void;
60
+ }
61
+ interface SqliteStoreOptions {
62
+ /**
63
+ * Override the default table names (`kyneta_meta` and `kyneta_records`).
64
+ *
65
+ * Use when co-locating Exchange tables alongside application tables in
66
+ * the same SQLite database, or when running multiple isolated Exchange
67
+ * instances in one database. Either or both names may be overridden.
68
+ */
69
+ tables?: Partial<TableNames>;
70
+ }
71
+ declare class SqliteStore implements Store {
72
+ #private;
73
+ constructor(adapter: SqliteAdapter, options?: SqliteStoreOptions);
74
+ append(docId: DocId, record: StoreRecord): Promise<void>;
75
+ loadAll(docId: DocId): AsyncIterable<StoreRecord>;
76
+ replace(docId: DocId, records: StoreRecord[]): Promise<void>;
77
+ delete(docId: DocId): Promise<void>;
78
+ currentMeta(docId: DocId): Promise<StoreMeta | null>;
79
+ listDocIds(prefix?: string): AsyncIterable<DocId>;
80
+ close(): Promise<void>;
81
+ }
82
+ declare function createSqliteStore(adapter: SqliteAdapter, options?: SqliteStoreOptions): Store;
83
+ //#endregion
84
+ export { SqliteAdapter, SqliteStore, SqliteStoreOptions, createSqliteStore, fromBetterSqlite3, fromBunSqlite };
85
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;AAmCA;;;;UAAiB,aAAA;EACf,IAAA,CAAK,GAAA,aAAgB,MAAA;EACrB,OAAA,KAAY,MAAA,mBACV,GAAA,aACG,MAAA,cACF,QAAA,CAAS,CAAA;EACZ,WAAA,IAAe,EAAA,QAAU,CAAA,GAAI,CAAA;EAC7B,KAAA;AAAA;;;;;;;;;;;;;iBAmBc,iBAAA,CAAkB,EAAA,EAAI,qBAAA,GAAwB,aAAA;;;;;;AAA9D;;;;;;;iBAgCgB,aAAA,CAAc,EAAA,EAAI,iBAAA,GAAoB,aAAA;;UAyB5C,qBAAA;EACR,OAAA,CAAQ,GAAA;IACN,GAAA,IAAO,MAAA;IACP,OAAA,IAAW,MAAA,cAAoB,gBAAA;EAAA;EAEjC,WAAA,IAAe,EAAA,QAAU,CAAA,SAAU,CAAA;EACnC,KAAA;AAAA;;UAIQ,iBAAA;EACR,GAAA,CAAI,GAAA,aAAgB,MAAA;EACpB,KAAA,CAAM,GAAA;IACJ,OAAA,IAAW,MAAA,cAAoB,gBAAA;EAAA;EAEjC,WAAA,IAAe,EAAA,QAAU,CAAA,SAAU,CAAA;EACnC,KAAA;AAAA;AAAA,UAOe,kBAAA;EAtBP;;;;;;;EA8BR,MAAA,GAAS,OAAA,CAAQ,UAAA;AAAA;AAAA,cAON,WAAA,YAAuB,KAAA;EAAA;cAKtB,OAAA,EAAS,aAAA,EAAe,OAAA,GAAS,kBAAA;EA6BvC,MAAA,CAAO,KAAA,EAAO,KAAA,EAAO,MAAA,EAAQ,WAAA,GAAc,OAAA;EAkC1C,OAAA,CAAQ,KAAA,EAAO,KAAA,GAAQ,aAAA,CAAc,WAAA;EAStC,OAAA,CAAQ,KAAA,EAAO,KAAA,EAAO,OAAA,EAAS,WAAA,KAAgB,OAAA;EAoC/C,MAAA,CAAO,KAAA,EAAO,KAAA,GAAQ,OAAA;EActB,WAAA,CAAY,KAAA,EAAO,KAAA,GAAQ,OAAA,CAAQ,SAAA;EASlC,UAAA,CAAW,MAAA,YAAkB,aAAA,CAAc,KAAA;EAe5C,KAAA,CAAA,GAAS,OAAA;AAAA;AAAA,iBAsBD,iBAAA,CACd,OAAA,EAAS,aAAA,EACT,OAAA,GAAU,kBAAA,GACT,KAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,144 @@
1
+ import { SeqNoTracker } from "@kyneta/exchange";
2
+ import { fromRow, planAppend, planReplace, resolveTables } from "@kyneta/sql-store-core";
3
+ //#region src/index.ts
4
+ /**
5
+ * Wrap a `better-sqlite3` Database as a `SqliteAdapter`.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import Database from "better-sqlite3"
10
+ * import { SqliteStore, fromBetterSqlite3 } from "@kyneta/sqlite-store"
11
+ *
12
+ * const db = new Database("exchange.db")
13
+ * const store = new SqliteStore(fromBetterSqlite3(db))
14
+ * ```
15
+ */
16
+ function fromBetterSqlite3(db) {
17
+ return {
18
+ exec(sql, ...params) {
19
+ db.prepare(sql).run(...params);
20
+ },
21
+ iterate(sql, ...params) {
22
+ return db.prepare(sql).iterate(...params);
23
+ },
24
+ transaction(fn) {
25
+ return db.transaction(fn)();
26
+ },
27
+ close() {
28
+ db.close();
29
+ }
30
+ };
31
+ }
32
+ /**
33
+ * Wrap a `bun:sqlite` Database as a `SqliteAdapter`.
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * import { Database } from "bun:sqlite"
38
+ * import { SqliteStore, fromBunSqlite } from "@kyneta/sqlite-store"
39
+ *
40
+ * const db = new Database("exchange.db")
41
+ * const store = new SqliteStore(fromBunSqlite(db))
42
+ * ```
43
+ */
44
+ function fromBunSqlite(db) {
45
+ return {
46
+ exec(sql, ...params) {
47
+ db.run(sql, ...params);
48
+ },
49
+ iterate(sql, ...params) {
50
+ return db.query(sql).iterate(...params);
51
+ },
52
+ transaction(fn) {
53
+ return db.transaction(fn)();
54
+ },
55
+ close() {
56
+ db.close();
57
+ }
58
+ };
59
+ }
60
+ var SqliteStore = class {
61
+ #adapter;
62
+ #seqNos = new SeqNoTracker();
63
+ #tables;
64
+ constructor(adapter, options = {}) {
65
+ this.#adapter = adapter;
66
+ this.#tables = resolveTables(options);
67
+ this.#ensureSchema();
68
+ }
69
+ #ensureSchema() {
70
+ this.#adapter.exec(`
71
+ CREATE TABLE IF NOT EXISTS ${this.#tables.meta} (
72
+ doc_id TEXT PRIMARY KEY,
73
+ data TEXT NOT NULL
74
+ ) WITHOUT ROWID
75
+ `);
76
+ this.#adapter.exec(`
77
+ CREATE TABLE IF NOT EXISTS ${this.#tables.records} (
78
+ doc_id TEXT NOT NULL,
79
+ seq INTEGER NOT NULL,
80
+ kind TEXT NOT NULL,
81
+ payload TEXT,
82
+ blob BLOB,
83
+ PRIMARY KEY (doc_id, seq)
84
+ ) WITHOUT ROWID
85
+ `);
86
+ }
87
+ async append(docId, record) {
88
+ const plan = planAppend(docId, record, await this.currentMeta(docId), await this.#seqNos.next(docId, async () => {
89
+ const [row] = this.#adapter.iterate(`SELECT MAX(seq) AS max_seq FROM ${this.#tables.records} WHERE doc_id = ?`, docId);
90
+ return row?.max_seq ?? null;
91
+ }));
92
+ this.#adapter.transaction(() => {
93
+ if (plan.upsertMeta !== null) this.#adapter.exec(`INSERT OR REPLACE INTO ${this.#tables.meta} (doc_id, data) VALUES (?, ?)`, docId, plan.upsertMeta.data);
94
+ const { row } = plan.insertRecord;
95
+ this.#adapter.exec(`INSERT INTO ${this.#tables.records} (doc_id, seq, kind, payload, blob) VALUES (?, ?, ?, ?, ?)`, docId, plan.insertRecord.seq, row.kind, row.payload, row.blob);
96
+ });
97
+ }
98
+ async *loadAll(docId) {
99
+ for (const row of this.#adapter.iterate(`SELECT kind, payload, blob FROM ${this.#tables.records} WHERE doc_id = ? ORDER BY seq`, docId)) yield fromRow(row);
100
+ }
101
+ async replace(docId, records) {
102
+ const plan = planReplace(records, await this.currentMeta(docId));
103
+ this.#adapter.transaction(() => {
104
+ this.#adapter.exec(`DELETE FROM ${this.#tables.records} WHERE doc_id = ?`, docId);
105
+ for (const { seq, row } of plan.records) this.#adapter.exec(`INSERT INTO ${this.#tables.records} (doc_id, seq, kind, payload, blob) VALUES (?, ?, ?, ?, ?)`, docId, seq, row.kind, row.payload, row.blob);
106
+ this.#adapter.exec(`INSERT OR REPLACE INTO ${this.#tables.meta} (doc_id, data) VALUES (?, ?)`, docId, plan.upsertMeta.data);
107
+ });
108
+ this.#seqNos.reset(docId, records.length - 1);
109
+ }
110
+ async delete(docId) {
111
+ this.#adapter.transaction(() => {
112
+ this.#adapter.exec(`DELETE FROM ${this.#tables.records} WHERE doc_id = ?`, docId);
113
+ this.#adapter.exec(`DELETE FROM ${this.#tables.meta} WHERE doc_id = ?`, docId);
114
+ });
115
+ this.#seqNos.remove(docId);
116
+ }
117
+ async currentMeta(docId) {
118
+ const [row] = this.#adapter.iterate(`SELECT data FROM ${this.#tables.meta} WHERE doc_id = ?`, docId);
119
+ if (row === void 0) return null;
120
+ return JSON.parse(row.data);
121
+ }
122
+ async *listDocIds(prefix) {
123
+ const rows = prefix !== void 0 ? this.#adapter.iterate(`SELECT doc_id FROM ${this.#tables.meta} WHERE doc_id LIKE ? ESCAPE '\\'`, `${escapeLike(prefix)}%`) : this.#adapter.iterate(`SELECT doc_id FROM ${this.#tables.meta}`);
124
+ for (const row of rows) yield row.doc_id;
125
+ }
126
+ async close() {
127
+ this.#adapter.close();
128
+ }
129
+ };
130
+ /**
131
+ * SQLite's LIKE treats `%` and `_` as wildcards. Escape them (and the
132
+ * escape char itself) so doc IDs containing those characters are
133
+ * matched literally. The query declares `ESCAPE '\'`.
134
+ */
135
+ function escapeLike(value) {
136
+ return value.replace(/[%_\\]/g, (ch) => `\\${ch}`);
137
+ }
138
+ function createSqliteStore(adapter, options) {
139
+ return new SqliteStore(adapter, options);
140
+ }
141
+ //#endregion
142
+ export { SqliteStore, createSqliteStore, fromBetterSqlite3, fromBunSqlite };
143
+
144
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["#adapter","#seqNos","#tables","#ensureSchema"],"sources":["../src/index.ts"],"sourcesContent":["// SQLite Store backend.\n//\n// Why a thin adapter rather than a direct better-sqlite3 dependency: the\n// adapter shape is deliberately synchronous because every supported\n// SQLite binding is sync (better-sqlite3, bun:sqlite, Cloudflare DO's\n// ctx.storage.sql). Forcing async here would dilute that ergonomics for\n// no benefit, since postgres-store and prisma-store get their own\n// async-native packages.\n\nimport {\n type DocId,\n SeqNoTracker,\n type Store,\n type StoreMeta,\n type StoreRecord,\n} from \"@kyneta/exchange\"\nimport {\n fromRow,\n planAppend,\n planReplace,\n type RowShape,\n resolveTables,\n type TableNames,\n} from \"@kyneta/sql-store-core\"\n\n// ---------------------------------------------------------------------------\n// SqliteAdapter — minimal synchronous database interface\n// ---------------------------------------------------------------------------\n\n/**\n * `iterate` returns `Iterable<T>` rather than `T[]` so `loadAll` can\n * stream million-record stores without materializing them all in\n * memory. Cloudflare DO's `ctx.storage.sql.exec` returns a cursor for\n * the same reason; this shape is chosen to pass through.\n */\nexport interface SqliteAdapter {\n exec(sql: string, ...params: unknown[]): void\n iterate<T = Record<string, unknown>>(\n sql: string,\n ...params: unknown[]\n ): Iterable<T>\n transaction<R>(fn: () => R): R\n close(): void\n}\n\n// ---------------------------------------------------------------------------\n// Adapter factories\n// ---------------------------------------------------------------------------\n\n/**\n * Wrap a `better-sqlite3` Database as a `SqliteAdapter`.\n *\n * @example\n * ```typescript\n * import Database from \"better-sqlite3\"\n * import { SqliteStore, fromBetterSqlite3 } from \"@kyneta/sqlite-store\"\n *\n * const db = new Database(\"exchange.db\")\n * const store = new SqliteStore(fromBetterSqlite3(db))\n * ```\n */\nexport function fromBetterSqlite3(db: BetterSqlite3Database): SqliteAdapter {\n return {\n exec(sql: string, ...params: unknown[]): void {\n db.prepare(sql).run(...params)\n },\n iterate<T = Record<string, unknown>>(\n sql: string,\n ...params: unknown[]\n ): Iterable<T> {\n return db.prepare(sql).iterate(...params) as IterableIterator<T>\n },\n transaction<R>(fn: () => R): R {\n return db.transaction(fn)()\n },\n close(): void {\n db.close()\n },\n }\n}\n\n/**\n * Wrap a `bun:sqlite` Database as a `SqliteAdapter`.\n *\n * @example\n * ```typescript\n * import { Database } from \"bun:sqlite\"\n * import { SqliteStore, fromBunSqlite } from \"@kyneta/sqlite-store\"\n *\n * const db = new Database(\"exchange.db\")\n * const store = new SqliteStore(fromBunSqlite(db))\n * ```\n */\nexport function fromBunSqlite(db: BunSqliteDatabase): SqliteAdapter {\n return {\n exec(sql: string, ...params: unknown[]): void {\n db.run(sql, ...params)\n },\n iterate<T = Record<string, unknown>>(\n sql: string,\n ...params: unknown[]\n ): Iterable<T> {\n return db.query(sql).iterate(...params) as IterableIterator<T>\n },\n transaction<R>(fn: () => R): R {\n return db.transaction(fn)()\n },\n close(): void {\n db.close()\n },\n }\n}\n\n// Minimal structural types for the two primary SQLite bindings.\n// These avoid a hard dependency on `better-sqlite3` or `bun:sqlite` types\n// at runtime — the caller provides the concrete database instance.\n\n/** Structural type for a `better-sqlite3` Database instance. */\ninterface BetterSqlite3Database {\n prepare(sql: string): {\n run(...params: unknown[]): unknown\n iterate(...params: unknown[]): IterableIterator<unknown>\n }\n transaction<R>(fn: () => R): () => R\n close(): void\n}\n\n/** Structural type for a `bun:sqlite` Database instance. */\ninterface BunSqliteDatabase {\n run(sql: string, ...params: unknown[]): void\n query(sql: string): {\n iterate(...params: unknown[]): IterableIterator<unknown>\n }\n transaction<R>(fn: () => R): () => R\n close(): void\n}\n\n// ---------------------------------------------------------------------------\n// SqliteStore options\n// ---------------------------------------------------------------------------\n\nexport interface SqliteStoreOptions {\n /**\n * Override the default table names (`kyneta_meta` and `kyneta_records`).\n *\n * Use when co-locating Exchange tables alongside application tables in\n * the same SQLite database, or when running multiple isolated Exchange\n * instances in one database. Either or both names may be overridden.\n */\n tables?: Partial<TableNames>\n}\n\n// ---------------------------------------------------------------------------\n// SqliteStore\n// ---------------------------------------------------------------------------\n\nexport class SqliteStore implements Store {\n readonly #adapter: SqliteAdapter\n readonly #seqNos = new SeqNoTracker()\n readonly #tables: TableNames\n\n constructor(adapter: SqliteAdapter, options: SqliteStoreOptions = {}) {\n this.#adapter = adapter\n this.#tables = resolveTables(options)\n this.#ensureSchema()\n }\n\n #ensureSchema(): void {\n this.#adapter.exec(`\n CREATE TABLE IF NOT EXISTS ${this.#tables.meta} (\n doc_id TEXT PRIMARY KEY,\n data TEXT NOT NULL\n ) WITHOUT ROWID\n `)\n this.#adapter.exec(`\n CREATE TABLE IF NOT EXISTS ${this.#tables.records} (\n doc_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n kind TEXT NOT NULL,\n payload TEXT,\n blob BLOB,\n PRIMARY KEY (doc_id, seq)\n ) WITHOUT ROWID\n `)\n }\n\n // -------------------------------------------------------------------------\n // Store interface\n // -------------------------------------------------------------------------\n\n async append(docId: DocId, record: StoreRecord): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n const seq = await this.#seqNos.next(docId, async () => {\n const [row] = this.#adapter.iterate<{ max_seq: number | null }>(\n `SELECT MAX(seq) AS max_seq FROM ${this.#tables.records} WHERE doc_id = ?`,\n docId,\n )\n return row?.max_seq ?? null\n })\n\n const plan = planAppend(docId, record, existingMeta, seq)\n\n // Both writes must commit together or neither — a crash between\n // them used to leave meta updated with no corresponding row.\n this.#adapter.transaction(() => {\n if (plan.upsertMeta !== null) {\n this.#adapter.exec(\n `INSERT OR REPLACE INTO ${this.#tables.meta} (doc_id, data) VALUES (?, ?)`,\n docId,\n plan.upsertMeta.data,\n )\n }\n const { row } = plan.insertRecord\n this.#adapter.exec(\n `INSERT INTO ${this.#tables.records} (doc_id, seq, kind, payload, blob) VALUES (?, ?, ?, ?, ?)`,\n docId,\n plan.insertRecord.seq,\n row.kind,\n row.payload,\n row.blob,\n )\n })\n }\n\n async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {\n for (const row of this.#adapter.iterate<RowShape>(\n `SELECT kind, payload, blob FROM ${this.#tables.records} WHERE doc_id = ? ORDER BY seq`,\n docId,\n )) {\n yield fromRow(row)\n }\n }\n\n async replace(docId: DocId, records: StoreRecord[]): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n const plan = planReplace(records, existingMeta)\n\n this.#adapter.transaction(() => {\n this.#adapter.exec(\n `DELETE FROM ${this.#tables.records} WHERE doc_id = ?`,\n docId,\n )\n\n for (const { seq, row } of plan.records) {\n this.#adapter.exec(\n `INSERT INTO ${this.#tables.records} (doc_id, seq, kind, payload, blob) VALUES (?, ?, ?, ?, ?)`,\n docId,\n seq,\n row.kind,\n row.payload,\n row.blob,\n )\n }\n\n this.#adapter.exec(\n `INSERT OR REPLACE INTO ${this.#tables.meta} (doc_id, data) VALUES (?, ?)`,\n docId,\n plan.upsertMeta.data,\n )\n })\n\n // Must run after the transaction commits. If `transaction()` throws,\n // control jumps past this line; the cache stays unmutated. Moving\n // this inside the callback or before the call would corrupt the\n // cache on rollback — the next append would compute a seq that\n // collides with restored rows on the (doc_id, seq) primary key.\n this.#seqNos.reset(docId, records.length - 1)\n }\n\n async delete(docId: DocId): Promise<void> {\n this.#adapter.transaction(() => {\n this.#adapter.exec(\n `DELETE FROM ${this.#tables.records} WHERE doc_id = ?`,\n docId,\n )\n this.#adapter.exec(\n `DELETE FROM ${this.#tables.meta} WHERE doc_id = ?`,\n docId,\n )\n })\n this.#seqNos.remove(docId)\n }\n\n async currentMeta(docId: DocId): Promise<StoreMeta | null> {\n const [row] = this.#adapter.iterate<{ data: string }>(\n `SELECT data FROM ${this.#tables.meta} WHERE doc_id = ?`,\n docId,\n )\n if (row === undefined) return null\n return JSON.parse(row.data) as StoreMeta\n }\n\n async *listDocIds(prefix?: string): AsyncIterable<DocId> {\n const rows =\n prefix !== undefined\n ? this.#adapter.iterate<{ doc_id: string }>(\n `SELECT doc_id FROM ${this.#tables.meta} WHERE doc_id LIKE ? ESCAPE '\\\\'`,\n `${escapeLike(prefix)}%`,\n )\n : this.#adapter.iterate<{ doc_id: string }>(\n `SELECT doc_id FROM ${this.#tables.meta}`,\n )\n for (const row of rows) {\n yield row.doc_id\n }\n }\n\n async close(): Promise<void> {\n this.#adapter.close()\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * SQLite's LIKE treats `%` and `_` as wildcards. Escape them (and the\n * escape char itself) so doc IDs containing those characters are\n * matched literally. The query declares `ESCAPE '\\'`.\n */\nfunction escapeLike(value: string): string {\n return value.replace(/[%_\\\\]/g, ch => `\\\\${ch}`)\n}\n\n// ---------------------------------------------------------------------------\n// Factory function\n// ---------------------------------------------------------------------------\n\nexport function createSqliteStore(\n adapter: SqliteAdapter,\n options?: SqliteStoreOptions,\n): Store {\n return new SqliteStore(adapter, options)\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6DA,SAAgB,kBAAkB,IAA0C;AAC1E,QAAO;EACL,KAAK,KAAa,GAAG,QAAyB;AAC5C,MAAG,QAAQ,IAAI,CAAC,IAAI,GAAG,OAAO;;EAEhC,QACE,KACA,GAAG,QACU;AACb,UAAO,GAAG,QAAQ,IAAI,CAAC,QAAQ,GAAG,OAAO;;EAE3C,YAAe,IAAgB;AAC7B,UAAO,GAAG,YAAY,GAAG,EAAE;;EAE7B,QAAc;AACZ,MAAG,OAAO;;EAEb;;;;;;;;;;;;;;AAeH,SAAgB,cAAc,IAAsC;AAClE,QAAO;EACL,KAAK,KAAa,GAAG,QAAyB;AAC5C,MAAG,IAAI,KAAK,GAAG,OAAO;;EAExB,QACE,KACA,GAAG,QACU;AACb,UAAO,GAAG,MAAM,IAAI,CAAC,QAAQ,GAAG,OAAO;;EAEzC,YAAe,IAAgB;AAC7B,UAAO,GAAG,YAAY,GAAG,EAAE;;EAE7B,QAAc;AACZ,MAAG,OAAO;;EAEb;;AA8CH,IAAa,cAAb,MAA0C;CACxC;CACA,UAAmB,IAAI,cAAc;CACrC;CAEA,YAAY,SAAwB,UAA8B,EAAE,EAAE;AACpE,QAAA,UAAgB;AAChB,QAAA,SAAe,cAAc,QAAQ;AACrC,QAAA,cAAoB;;CAGtB,gBAAsB;AACpB,QAAA,QAAc,KAAK;mCACY,MAAA,OAAa,KAAK;;;;MAI/C;AACF,QAAA,QAAc,KAAK;mCACY,MAAA,OAAa,QAAQ;;;;;;;;MAQlD;;CAOJ,MAAM,OAAO,OAAc,QAAoC;EAU7D,MAAM,OAAO,WAAW,OAAO,QATV,MAAM,KAAK,YAAY,MAAM,EACtC,MAAM,MAAA,OAAa,KAAK,OAAO,YAAY;GACrD,MAAM,CAAC,OAAO,MAAA,QAAc,QAC1B,mCAAmC,MAAA,OAAa,QAAQ,oBACxD,MACD;AACD,UAAO,KAAK,WAAW;IACvB,CAEuD;AAIzD,QAAA,QAAc,kBAAkB;AAC9B,OAAI,KAAK,eAAe,KACtB,OAAA,QAAc,KACZ,0BAA0B,MAAA,OAAa,KAAK,gCAC5C,OACA,KAAK,WAAW,KACjB;GAEH,MAAM,EAAE,QAAQ,KAAK;AACrB,SAAA,QAAc,KACZ,eAAe,MAAA,OAAa,QAAQ,6DACpC,OACA,KAAK,aAAa,KAClB,IAAI,MACJ,IAAI,SACJ,IAAI,KACL;IACD;;CAGJ,OAAO,QAAQ,OAA0C;AACvD,OAAK,MAAM,OAAO,MAAA,QAAc,QAC9B,mCAAmC,MAAA,OAAa,QAAQ,iCACxD,MACD,CACC,OAAM,QAAQ,IAAI;;CAItB,MAAM,QAAQ,OAAc,SAAuC;EAEjE,MAAM,OAAO,YAAY,SADJ,MAAM,KAAK,YAAY,MAAM,CACH;AAE/C,QAAA,QAAc,kBAAkB;AAC9B,SAAA,QAAc,KACZ,eAAe,MAAA,OAAa,QAAQ,oBACpC,MACD;AAED,QAAK,MAAM,EAAE,KAAK,SAAS,KAAK,QAC9B,OAAA,QAAc,KACZ,eAAe,MAAA,OAAa,QAAQ,6DACpC,OACA,KACA,IAAI,MACJ,IAAI,SACJ,IAAI,KACL;AAGH,SAAA,QAAc,KACZ,0BAA0B,MAAA,OAAa,KAAK,gCAC5C,OACA,KAAK,WAAW,KACjB;IACD;AAOF,QAAA,OAAa,MAAM,OAAO,QAAQ,SAAS,EAAE;;CAG/C,MAAM,OAAO,OAA6B;AACxC,QAAA,QAAc,kBAAkB;AAC9B,SAAA,QAAc,KACZ,eAAe,MAAA,OAAa,QAAQ,oBACpC,MACD;AACD,SAAA,QAAc,KACZ,eAAe,MAAA,OAAa,KAAK,oBACjC,MACD;IACD;AACF,QAAA,OAAa,OAAO,MAAM;;CAG5B,MAAM,YAAY,OAAyC;EACzD,MAAM,CAAC,OAAO,MAAA,QAAc,QAC1B,oBAAoB,MAAA,OAAa,KAAK,oBACtC,MACD;AACD,MAAI,QAAQ,KAAA,EAAW,QAAO;AAC9B,SAAO,KAAK,MAAM,IAAI,KAAK;;CAG7B,OAAO,WAAW,QAAuC;EACvD,MAAM,OACJ,WAAW,KAAA,IACP,MAAA,QAAc,QACZ,sBAAsB,MAAA,OAAa,KAAK,mCACxC,GAAG,WAAW,OAAO,CAAC,GACvB,GACD,MAAA,QAAc,QACZ,sBAAsB,MAAA,OAAa,OACpC;AACP,OAAK,MAAM,OAAO,KAChB,OAAM,IAAI;;CAId,MAAM,QAAuB;AAC3B,QAAA,QAAc,OAAO;;;;;;;;AAazB,SAAS,WAAW,OAAuB;AACzC,QAAO,MAAM,QAAQ,YAAW,OAAM,KAAK,KAAK;;AAOlD,SAAgB,kBACd,SACA,SACO;AACP,QAAO,IAAI,YAAY,SAAS,QAAQ"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@kyneta/sqlite-store",
3
+ "version": "1.5.0",
4
+ "description": "SQLite storage backend for @kyneta/exchange — universal persistent storage",
5
+ "author": "Duane Johnson",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/halecraft/kyneta",
10
+ "directory": "packages/exchange/stores/sqlite"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "src"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./src": "./src/index.ts",
30
+ "./src/*": "./src/*"
31
+ },
32
+ "peerDependencies": {
33
+ "@kyneta/exchange": "^1.5.0",
34
+ "@kyneta/schema": "^1.5.0",
35
+ "@kyneta/sql-store-core": "^1.5.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/better-sqlite3": "^7.6.12",
39
+ "@types/node": "^22",
40
+ "better-sqlite3": "^11.9.1",
41
+ "tsdown": "^0.21.9",
42
+ "typescript": "^5.9.2",
43
+ "vitest": "^4.0.17",
44
+ "@kyneta/sql-store-core": "^1.5.0",
45
+ "@kyneta/exchange": "^1.5.0",
46
+ "@kyneta/schema": "^1.5.0"
47
+ },
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "test": "verify logic",
51
+ "verify": "verify"
52
+ }
53
+ }
@@ -0,0 +1,289 @@
1
+ // sqlite-store — conformance + SQLite-specific tests.
2
+
3
+ import * as fs from "node:fs"
4
+ import * as os from "node:os"
5
+ import * as path from "node:path"
6
+ import {
7
+ collectAll,
8
+ describeStore,
9
+ makeEntryRecord,
10
+ makeMetaRecord,
11
+ plainMeta,
12
+ } from "@kyneta/exchange/testing"
13
+ import Database from "better-sqlite3"
14
+ import { afterAll, describe, expect, it } from "vitest"
15
+ import { fromBetterSqlite3, type SqliteAdapter, SqliteStore } from "../index.js"
16
+
17
+ /**
18
+ * Why `arm(n)` instead of a constructor-time `failOnNth`: schema DDL
19
+ * runs `exec` during `SqliteStore` construction. We need the counter
20
+ * latent until after the priming append succeeds, so the conformance
21
+ * test can target a specific subsequent write call.
22
+ */
23
+ function makeFaultyAdapter(base: SqliteAdapter): {
24
+ adapter: SqliteAdapter
25
+ arm: (n: number) => void
26
+ } {
27
+ let armed: number | null = null
28
+ let count = 0
29
+ const adapter: SqliteAdapter = {
30
+ exec(sql, ...params) {
31
+ if (armed !== null) {
32
+ count += 1
33
+ if (count === armed) {
34
+ throw new Error(`fault-injected: exec call #${count}`)
35
+ }
36
+ }
37
+ base.exec(sql, ...params)
38
+ },
39
+ iterate: base.iterate.bind(base),
40
+ transaction: base.transaction.bind(base),
41
+ close: base.close.bind(base),
42
+ }
43
+ return {
44
+ adapter,
45
+ arm: n => {
46
+ armed = n
47
+ count = 0
48
+ },
49
+ }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Temp file management
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const tmpDirs: string[] = []
57
+
58
+ function makeTmpFile(): string {
59
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kyneta-sqlite-test-"))
60
+ const file = path.join(dir, "test.db")
61
+ tmpDirs.push(dir)
62
+ return file
63
+ }
64
+
65
+ afterAll(() => {
66
+ for (const dir of tmpDirs) {
67
+ try {
68
+ fs.rmSync(dir, { recursive: true, force: true })
69
+ } catch {
70
+ // best-effort cleanup
71
+ }
72
+ }
73
+ })
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Conformance suite — validates the full Store contract (17 tests)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describeStore(
80
+ "SqliteStore",
81
+ () => {
82
+ const db = new Database(":memory:")
83
+ return new SqliteStore(fromBetterSqlite3(db))
84
+ },
85
+ {
86
+ cleanup: async backend => {
87
+ await backend.close()
88
+ },
89
+ // The harness counts only after `arm(n)`, so schema DDL during
90
+ // `SqliteStore` construction (a sequence of `exec` calls) doesn't
91
+ // trip the counter. A meta-record append issues 2 execs (meta
92
+ // upsert + record insert) inside one transaction; arming n=2 fires
93
+ // mid-transaction so rollback is observable.
94
+ faultFactory: async () => {
95
+ const file = makeTmpFile()
96
+ const db = new Database(file)
97
+ const base = fromBetterSqlite3(db)
98
+ const { adapter, arm } = makeFaultyAdapter(base)
99
+ const store = new SqliteStore(adapter)
100
+ return {
101
+ store,
102
+ injectFault: arm,
103
+ // freshStore opens a separate connection on the same file. The
104
+ // primary `db` connection is used by `store`; the fresh one is
105
+ // independent so its lifecycle is the caller's.
106
+ freshStore: async () => {
107
+ const freshDb = new Database(file)
108
+ return new SqliteStore(fromBetterSqlite3(freshDb))
109
+ },
110
+ cleanup: async () => {
111
+ await store.close()
112
+ },
113
+ }
114
+ },
115
+ isolationFactory: async () => {
116
+ const db = new Database(":memory:")
117
+ const adapter = fromBetterSqlite3(db)
118
+ return {
119
+ storeA: new SqliteStore(adapter, {
120
+ tables: { meta: "a_meta", records: "a_records" },
121
+ }),
122
+ storeB: new SqliteStore(adapter, {
123
+ tables: { meta: "b_meta", records: "b_records" },
124
+ }),
125
+ // Both stores share `adapter`; closing it once tears down both.
126
+ cleanup: async () => {
127
+ adapter.close()
128
+ },
129
+ }
130
+ },
131
+ },
132
+ )
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // SQLite-specific tests
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe("SqliteStore — persistence across close + reopen", () => {
139
+ it("data, metadata, and seq numbers survive close and reopen", async () => {
140
+ const file = makeTmpFile()
141
+
142
+ const db1 = new Database(file)
143
+ const store1 = new SqliteStore(fromBetterSqlite3(db1))
144
+ await store1.append("doc-1", makeMetaRecord())
145
+ await store1.append("doc-1", makeEntryRecord("entirety", "v1"))
146
+ await store1.append("doc-1", makeEntryRecord("since", "v2"))
147
+ await store1.close()
148
+
149
+ // Reopen, verify persisted data, then append and verify seq continuity
150
+ const db2 = new Database(file)
151
+ const store2 = new SqliteStore(fromBetterSqlite3(db2))
152
+ expect(await store2.currentMeta("doc-1")).toEqual(plainMeta)
153
+
154
+ await store2.append("doc-1", makeEntryRecord("since", "v3"))
155
+
156
+ const records = await collectAll(store2.loadAll("doc-1"))
157
+ expect(records).toHaveLength(4)
158
+ const versions = records
159
+ .filter(r => r.kind === "entry")
160
+ .map(r => (r as { kind: "entry"; version: string }).version)
161
+ expect(versions).toEqual(["v1", "v2", "v3"])
162
+ await store2.close()
163
+ })
164
+ })
165
+
166
+ describe("SqliteStore — adapter factory", () => {
167
+ it("fromBetterSqlite3 exec, iterate, and transaction round-trip", () => {
168
+ const db = new Database(":memory:")
169
+ const adapter = fromBetterSqlite3(db)
170
+
171
+ adapter.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)")
172
+ adapter.exec("INSERT INTO test (id, value) VALUES (?, ?)", 1, "hello")
173
+ adapter.exec("INSERT INTO test (id, value) VALUES (?, ?)", 2, "world")
174
+
175
+ const rows = Array.from(
176
+ adapter.iterate<{ id: number; value: string }>(
177
+ "SELECT * FROM test ORDER BY id",
178
+ ),
179
+ )
180
+ expect(rows).toEqual([
181
+ { id: 1, value: "hello" },
182
+ { id: 2, value: "world" },
183
+ ])
184
+
185
+ adapter.close()
186
+ })
187
+
188
+ it("iterate releases the statement on early termination", () => {
189
+ // Without proper iterator-return semantics, better-sqlite3 throws
190
+ // "This statement is busy" on the second iterate call below.
191
+ const db = new Database(":memory:")
192
+ const adapter = fromBetterSqlite3(db)
193
+
194
+ adapter.exec("CREATE TABLE test (id INTEGER PRIMARY KEY)")
195
+ adapter.exec("INSERT INTO test VALUES (1), (2), (3)")
196
+
197
+ const [first] = adapter.iterate<{ id: number }>(
198
+ "SELECT id FROM test ORDER BY id",
199
+ )
200
+ expect(first?.id).toBe(1)
201
+
202
+ const all = Array.from(
203
+ adapter.iterate<{ id: number }>("SELECT id FROM test ORDER BY id"),
204
+ )
205
+ expect(all).toHaveLength(3)
206
+
207
+ adapter.close()
208
+ })
209
+
210
+ it("fromBetterSqlite3 transaction rolls back on throw", () => {
211
+ const db = new Database(":memory:")
212
+ const adapter = fromBetterSqlite3(db)
213
+
214
+ adapter.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)")
215
+ adapter.exec("INSERT INTO test (id, value) VALUES (?, ?)", 1, "original")
216
+
217
+ expect(() =>
218
+ adapter.transaction(() => {
219
+ adapter.exec("UPDATE test SET value = ? WHERE id = ?", "modified", 1)
220
+ throw new Error("rollback")
221
+ }),
222
+ ).toThrow("rollback")
223
+
224
+ const [row] = adapter.iterate<{ value: string }>(
225
+ "SELECT value FROM test WHERE id = ?",
226
+ 1,
227
+ )
228
+ expect(row?.value).toBe("original")
229
+
230
+ adapter.close()
231
+ })
232
+ })
233
+
234
+ describe("SqliteStore — tables isolation", () => {
235
+ it("two stores with different table names coexist in the same database", async () => {
236
+ const db = new Database(":memory:")
237
+ const adapter = fromBetterSqlite3(db)
238
+
239
+ const store1 = new SqliteStore(adapter, {
240
+ tables: { meta: "app1_meta", records: "app1_records" },
241
+ })
242
+ const store2 = new SqliteStore(adapter, {
243
+ tables: { meta: "app2_meta", records: "app2_records" },
244
+ })
245
+
246
+ await store1.append("doc-1", makeMetaRecord())
247
+ await store1.append("doc-1", makeEntryRecord("entirety", "v1-app1"))
248
+
249
+ await store2.append("doc-1", makeMetaRecord())
250
+ await store2.append("doc-1", makeEntryRecord("entirety", "v1-app2"))
251
+
252
+ const records1 = await collectAll(store1.loadAll("doc-1"))
253
+ const records2 = await collectAll(store2.loadAll("doc-1"))
254
+
255
+ expect(records1).toHaveLength(2)
256
+ expect(records2).toHaveLength(2)
257
+
258
+ const entry1 = records1.find(r => r.kind === "entry")
259
+ const entry2 = records2.find(r => r.kind === "entry")
260
+
261
+ if (entry1?.kind === "entry") expect(entry1.version).toBe("v1-app1")
262
+ if (entry2?.kind === "entry") expect(entry2.version).toBe("v1-app2")
263
+
264
+ adapter.close()
265
+ })
266
+ })
267
+
268
+ describe("SqliteStore — listDocIds with LIKE-special characters", () => {
269
+ it("prefix containing % and _ matches literally, not as wildcards", async () => {
270
+ const db = new Database(":memory:")
271
+ const store = new SqliteStore(fromBetterSqlite3(db))
272
+
273
+ // Create docs with tricky names
274
+ await store.append("100%_done", makeMetaRecord())
275
+ await store.append("100_other", makeMetaRecord())
276
+ await store.append("100xyz", makeMetaRecord())
277
+ await store.append("other", makeMetaRecord())
278
+
279
+ // "100%" should match only "100%_done", not "100_other" or "100xyz"
280
+ const matched = await collectAll(store.listDocIds("100%"))
281
+ expect(matched).toEqual(["100%_done"])
282
+
283
+ // "100_" should match only "100_other", not "100%_done" or "100xyz"
284
+ const matched2 = await collectAll(store.listDocIds("100_"))
285
+ expect(matched2).toEqual(["100_other"])
286
+
287
+ await store.close()
288
+ })
289
+ })
package/src/index.ts ADDED
@@ -0,0 +1,335 @@
1
+ // SQLite Store backend.
2
+ //
3
+ // Why a thin adapter rather than a direct better-sqlite3 dependency: the
4
+ // adapter shape is deliberately synchronous because every supported
5
+ // SQLite binding is sync (better-sqlite3, bun:sqlite, Cloudflare DO's
6
+ // ctx.storage.sql). Forcing async here would dilute that ergonomics for
7
+ // no benefit, since postgres-store and prisma-store get their own
8
+ // async-native packages.
9
+
10
+ import {
11
+ type DocId,
12
+ SeqNoTracker,
13
+ type Store,
14
+ type StoreMeta,
15
+ type StoreRecord,
16
+ } from "@kyneta/exchange"
17
+ import {
18
+ fromRow,
19
+ planAppend,
20
+ planReplace,
21
+ type RowShape,
22
+ resolveTables,
23
+ type TableNames,
24
+ } from "@kyneta/sql-store-core"
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // SqliteAdapter — minimal synchronous database interface
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * `iterate` returns `Iterable<T>` rather than `T[]` so `loadAll` can
32
+ * stream million-record stores without materializing them all in
33
+ * memory. Cloudflare DO's `ctx.storage.sql.exec` returns a cursor for
34
+ * the same reason; this shape is chosen to pass through.
35
+ */
36
+ export interface SqliteAdapter {
37
+ exec(sql: string, ...params: unknown[]): void
38
+ iterate<T = Record<string, unknown>>(
39
+ sql: string,
40
+ ...params: unknown[]
41
+ ): Iterable<T>
42
+ transaction<R>(fn: () => R): R
43
+ close(): void
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Adapter factories
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Wrap a `better-sqlite3` Database as a `SqliteAdapter`.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * import Database from "better-sqlite3"
56
+ * import { SqliteStore, fromBetterSqlite3 } from "@kyneta/sqlite-store"
57
+ *
58
+ * const db = new Database("exchange.db")
59
+ * const store = new SqliteStore(fromBetterSqlite3(db))
60
+ * ```
61
+ */
62
+ export function fromBetterSqlite3(db: BetterSqlite3Database): SqliteAdapter {
63
+ return {
64
+ exec(sql: string, ...params: unknown[]): void {
65
+ db.prepare(sql).run(...params)
66
+ },
67
+ iterate<T = Record<string, unknown>>(
68
+ sql: string,
69
+ ...params: unknown[]
70
+ ): Iterable<T> {
71
+ return db.prepare(sql).iterate(...params) as IterableIterator<T>
72
+ },
73
+ transaction<R>(fn: () => R): R {
74
+ return db.transaction(fn)()
75
+ },
76
+ close(): void {
77
+ db.close()
78
+ },
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Wrap a `bun:sqlite` Database as a `SqliteAdapter`.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * import { Database } from "bun:sqlite"
88
+ * import { SqliteStore, fromBunSqlite } from "@kyneta/sqlite-store"
89
+ *
90
+ * const db = new Database("exchange.db")
91
+ * const store = new SqliteStore(fromBunSqlite(db))
92
+ * ```
93
+ */
94
+ export function fromBunSqlite(db: BunSqliteDatabase): SqliteAdapter {
95
+ return {
96
+ exec(sql: string, ...params: unknown[]): void {
97
+ db.run(sql, ...params)
98
+ },
99
+ iterate<T = Record<string, unknown>>(
100
+ sql: string,
101
+ ...params: unknown[]
102
+ ): Iterable<T> {
103
+ return db.query(sql).iterate(...params) as IterableIterator<T>
104
+ },
105
+ transaction<R>(fn: () => R): R {
106
+ return db.transaction(fn)()
107
+ },
108
+ close(): void {
109
+ db.close()
110
+ },
111
+ }
112
+ }
113
+
114
+ // Minimal structural types for the two primary SQLite bindings.
115
+ // These avoid a hard dependency on `better-sqlite3` or `bun:sqlite` types
116
+ // at runtime — the caller provides the concrete database instance.
117
+
118
+ /** Structural type for a `better-sqlite3` Database instance. */
119
+ interface BetterSqlite3Database {
120
+ prepare(sql: string): {
121
+ run(...params: unknown[]): unknown
122
+ iterate(...params: unknown[]): IterableIterator<unknown>
123
+ }
124
+ transaction<R>(fn: () => R): () => R
125
+ close(): void
126
+ }
127
+
128
+ /** Structural type for a `bun:sqlite` Database instance. */
129
+ interface BunSqliteDatabase {
130
+ run(sql: string, ...params: unknown[]): void
131
+ query(sql: string): {
132
+ iterate(...params: unknown[]): IterableIterator<unknown>
133
+ }
134
+ transaction<R>(fn: () => R): () => R
135
+ close(): void
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // SqliteStore options
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export interface SqliteStoreOptions {
143
+ /**
144
+ * Override the default table names (`kyneta_meta` and `kyneta_records`).
145
+ *
146
+ * Use when co-locating Exchange tables alongside application tables in
147
+ * the same SQLite database, or when running multiple isolated Exchange
148
+ * instances in one database. Either or both names may be overridden.
149
+ */
150
+ tables?: Partial<TableNames>
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // SqliteStore
155
+ // ---------------------------------------------------------------------------
156
+
157
+ export class SqliteStore implements Store {
158
+ readonly #adapter: SqliteAdapter
159
+ readonly #seqNos = new SeqNoTracker()
160
+ readonly #tables: TableNames
161
+
162
+ constructor(adapter: SqliteAdapter, options: SqliteStoreOptions = {}) {
163
+ this.#adapter = adapter
164
+ this.#tables = resolveTables(options)
165
+ this.#ensureSchema()
166
+ }
167
+
168
+ #ensureSchema(): void {
169
+ this.#adapter.exec(`
170
+ CREATE TABLE IF NOT EXISTS ${this.#tables.meta} (
171
+ doc_id TEXT PRIMARY KEY,
172
+ data TEXT NOT NULL
173
+ ) WITHOUT ROWID
174
+ `)
175
+ this.#adapter.exec(`
176
+ CREATE TABLE IF NOT EXISTS ${this.#tables.records} (
177
+ doc_id TEXT NOT NULL,
178
+ seq INTEGER NOT NULL,
179
+ kind TEXT NOT NULL,
180
+ payload TEXT,
181
+ blob BLOB,
182
+ PRIMARY KEY (doc_id, seq)
183
+ ) WITHOUT ROWID
184
+ `)
185
+ }
186
+
187
+ // -------------------------------------------------------------------------
188
+ // Store interface
189
+ // -------------------------------------------------------------------------
190
+
191
+ async append(docId: DocId, record: StoreRecord): Promise<void> {
192
+ const existingMeta = await this.currentMeta(docId)
193
+ const seq = await this.#seqNos.next(docId, async () => {
194
+ const [row] = this.#adapter.iterate<{ max_seq: number | null }>(
195
+ `SELECT MAX(seq) AS max_seq FROM ${this.#tables.records} WHERE doc_id = ?`,
196
+ docId,
197
+ )
198
+ return row?.max_seq ?? null
199
+ })
200
+
201
+ const plan = planAppend(docId, record, existingMeta, seq)
202
+
203
+ // Both writes must commit together or neither — a crash between
204
+ // them used to leave meta updated with no corresponding row.
205
+ this.#adapter.transaction(() => {
206
+ if (plan.upsertMeta !== null) {
207
+ this.#adapter.exec(
208
+ `INSERT OR REPLACE INTO ${this.#tables.meta} (doc_id, data) VALUES (?, ?)`,
209
+ docId,
210
+ plan.upsertMeta.data,
211
+ )
212
+ }
213
+ const { row } = plan.insertRecord
214
+ this.#adapter.exec(
215
+ `INSERT INTO ${this.#tables.records} (doc_id, seq, kind, payload, blob) VALUES (?, ?, ?, ?, ?)`,
216
+ docId,
217
+ plan.insertRecord.seq,
218
+ row.kind,
219
+ row.payload,
220
+ row.blob,
221
+ )
222
+ })
223
+ }
224
+
225
+ async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {
226
+ for (const row of this.#adapter.iterate<RowShape>(
227
+ `SELECT kind, payload, blob FROM ${this.#tables.records} WHERE doc_id = ? ORDER BY seq`,
228
+ docId,
229
+ )) {
230
+ yield fromRow(row)
231
+ }
232
+ }
233
+
234
+ async replace(docId: DocId, records: StoreRecord[]): Promise<void> {
235
+ const existingMeta = await this.currentMeta(docId)
236
+ const plan = planReplace(records, existingMeta)
237
+
238
+ this.#adapter.transaction(() => {
239
+ this.#adapter.exec(
240
+ `DELETE FROM ${this.#tables.records} WHERE doc_id = ?`,
241
+ docId,
242
+ )
243
+
244
+ for (const { seq, row } of plan.records) {
245
+ this.#adapter.exec(
246
+ `INSERT INTO ${this.#tables.records} (doc_id, seq, kind, payload, blob) VALUES (?, ?, ?, ?, ?)`,
247
+ docId,
248
+ seq,
249
+ row.kind,
250
+ row.payload,
251
+ row.blob,
252
+ )
253
+ }
254
+
255
+ this.#adapter.exec(
256
+ `INSERT OR REPLACE INTO ${this.#tables.meta} (doc_id, data) VALUES (?, ?)`,
257
+ docId,
258
+ plan.upsertMeta.data,
259
+ )
260
+ })
261
+
262
+ // Must run after the transaction commits. If `transaction()` throws,
263
+ // control jumps past this line; the cache stays unmutated. Moving
264
+ // this inside the callback or before the call would corrupt the
265
+ // cache on rollback — the next append would compute a seq that
266
+ // collides with restored rows on the (doc_id, seq) primary key.
267
+ this.#seqNos.reset(docId, records.length - 1)
268
+ }
269
+
270
+ async delete(docId: DocId): Promise<void> {
271
+ this.#adapter.transaction(() => {
272
+ this.#adapter.exec(
273
+ `DELETE FROM ${this.#tables.records} WHERE doc_id = ?`,
274
+ docId,
275
+ )
276
+ this.#adapter.exec(
277
+ `DELETE FROM ${this.#tables.meta} WHERE doc_id = ?`,
278
+ docId,
279
+ )
280
+ })
281
+ this.#seqNos.remove(docId)
282
+ }
283
+
284
+ async currentMeta(docId: DocId): Promise<StoreMeta | null> {
285
+ const [row] = this.#adapter.iterate<{ data: string }>(
286
+ `SELECT data FROM ${this.#tables.meta} WHERE doc_id = ?`,
287
+ docId,
288
+ )
289
+ if (row === undefined) return null
290
+ return JSON.parse(row.data) as StoreMeta
291
+ }
292
+
293
+ async *listDocIds(prefix?: string): AsyncIterable<DocId> {
294
+ const rows =
295
+ prefix !== undefined
296
+ ? this.#adapter.iterate<{ doc_id: string }>(
297
+ `SELECT doc_id FROM ${this.#tables.meta} WHERE doc_id LIKE ? ESCAPE '\\'`,
298
+ `${escapeLike(prefix)}%`,
299
+ )
300
+ : this.#adapter.iterate<{ doc_id: string }>(
301
+ `SELECT doc_id FROM ${this.#tables.meta}`,
302
+ )
303
+ for (const row of rows) {
304
+ yield row.doc_id
305
+ }
306
+ }
307
+
308
+ async close(): Promise<void> {
309
+ this.#adapter.close()
310
+ }
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Helpers
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * SQLite's LIKE treats `%` and `_` as wildcards. Escape them (and the
319
+ * escape char itself) so doc IDs containing those characters are
320
+ * matched literally. The query declares `ESCAPE '\'`.
321
+ */
322
+ function escapeLike(value: string): string {
323
+ return value.replace(/[%_\\]/g, ch => `\\${ch}`)
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Factory function
328
+ // ---------------------------------------------------------------------------
329
+
330
+ export function createSqliteStore(
331
+ adapter: SqliteAdapter,
332
+ options?: SqliteStoreOptions,
333
+ ): Store {
334
+ return new SqliteStore(adapter, options)
335
+ }