@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 +21 -0
- package/README.md +168 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +144 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
- package/src/__tests__/sqlite-store.test.ts +289 -0
- package/src/index.ts +335 -0
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`.
|
package/dist/index.d.ts
ADDED
|
@@ -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":"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"}
|
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
|
+
}
|