@kyneta/sqlite-store 1.7.0 → 2.0.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/README.md +11 -10
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -8
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/sqlite-store.test.ts +75 -42
- package/src/index.ts +66 -9
package/README.md
CHANGED
|
@@ -120,38 +120,39 @@ Not needed for Cloudflare DO — the platform manages durability.
|
|
|
120
120
|
|
|
121
121
|
```ts
|
|
122
122
|
const store = new SqliteStore(adapter, {
|
|
123
|
-
tables: {
|
|
123
|
+
tables: { docMeta: "app_doc_meta", records: "app_records", storeMeta: "app_store_meta" },
|
|
124
124
|
})
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
-
Default: `{
|
|
127
|
+
Default: `{ docMeta: "kyneta_doc_meta", records: "kyneta_records", storeMeta: "kyneta_store_meta" }`. Any subset of names may be overridden.
|
|
128
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
|
|
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 sets.
|
|
130
130
|
|
|
131
131
|
## Migration from v1.x
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
The current API uses `tables: { docMeta, records, storeMeta }` (replacing v1.x's `tablePrefix`), defaulting to `kyneta_doc_meta` / `kyneta_records` / `kyneta_store_meta`. There is no compatibility shim.
|
|
134
134
|
|
|
135
135
|
```ts
|
|
136
136
|
// v1.x
|
|
137
137
|
new SqliteStore(adapter) // tables: meta, records
|
|
138
138
|
new SqliteStore(adapter, { tablePrefix: "app_" }) // tables: app_meta, app_records
|
|
139
139
|
|
|
140
|
-
//
|
|
141
|
-
new SqliteStore(adapter)
|
|
140
|
+
// current
|
|
141
|
+
new SqliteStore(adapter) // tables: kyneta_doc_meta, kyneta_records, kyneta_store_meta
|
|
142
142
|
new SqliteStore(adapter, {
|
|
143
|
-
tables: {
|
|
143
|
+
tables: { docMeta: "app_doc_meta", records: "app_records", storeMeta: "app_store_meta" },
|
|
144
144
|
})
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
To keep using existing table names, pass them explicitly via the `tables` option, or rename via `ALTER TABLE`.
|
|
148
148
|
|
|
149
149
|
## Schema
|
|
150
150
|
|
|
151
|
-
The store creates
|
|
151
|
+
The store creates three tables on first use:
|
|
152
152
|
|
|
153
|
-
- **`tables.
|
|
153
|
+
- **`tables.docMeta`** (default `kyneta_doc_meta`) — per-document materialized metadata index. `doc_id TEXT PRIMARY KEY`, `data TEXT NOT NULL` (JSON-encoded `StoreMeta`). `WITHOUT ROWID`.
|
|
154
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
|
+
- **`tables.storeMeta`** (default `kyneta_store_meta`) — store-global metadata. `key TEXT PRIMARY KEY`, `value TEXT NOT NULL`. Holds the on-disk format version (under `key = "format"`), gated on open. `WITHOUT ROWID`.
|
|
155
156
|
|
|
156
157
|
## Store interface
|
|
157
158
|
|
package/dist/index.d.ts
CHANGED
|
@@ -60,11 +60,12 @@ interface BunSqliteDatabase {
|
|
|
60
60
|
}
|
|
61
61
|
interface SqliteStoreOptions {
|
|
62
62
|
/**
|
|
63
|
-
* Override the default table names (`
|
|
63
|
+
* Override the default table names (`kyneta_doc_meta`, `kyneta_records`,
|
|
64
|
+
* `kyneta_store_meta`).
|
|
64
65
|
*
|
|
65
66
|
* Use when co-locating Exchange tables alongside application tables in
|
|
66
67
|
* the same SQLite database, or when running multiple isolated Exchange
|
|
67
|
-
* instances in one database.
|
|
68
|
+
* instances in one database. Any subset of names may be overridden.
|
|
68
69
|
*/
|
|
69
70
|
tables?: Partial<TableNames>;
|
|
70
71
|
}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;AAwCA;;;;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;;;;;AAnBpE;AAmBP;;;;;;;iBAgCgB,aAAA,CAAc,EAAA,EAAI,iBAAA,GAAoB,aAAa;AAhCQ;AAAA,UAyDjE,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;AA/BiE;AAAA,UAmCzD,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;;;;;;;;EA+BR,MAAA,GAAS,OAAO,CAAC,UAAA;AAAA;AAAA,cAON,WAAA,YAAuB,KAAA;EAAA;cAKtB,OAAA,EAAS,aAAA,EAAe,OAAA,GAAS,kBAAA;EAgFvC,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
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { SeqNoTracker } from "@kyneta/exchange";
|
|
2
|
-
import { fromRow, planAppend, planReplace, resolveTables } from "@kyneta/sql-store-core";
|
|
1
|
+
import { STORE_META_FORMAT_KEY, SeqNoTracker, StoreFormatVersionError, decideStoreFormat, parseStoreFormat } from "@kyneta/exchange";
|
|
2
|
+
import { STORE_FORMAT_VERSION, fromRow, planAppend, planReplace, resolveTables } from "@kyneta/sql-store-core";
|
|
3
3
|
//#region src/index.ts
|
|
4
4
|
/**
|
|
5
5
|
* Wrap a `better-sqlite3` Database as a `SqliteAdapter`.
|
|
@@ -65,10 +65,11 @@ var SqliteStore = class {
|
|
|
65
65
|
this.#adapter = adapter;
|
|
66
66
|
this.#tables = resolveTables(options);
|
|
67
67
|
this.#ensureSchema();
|
|
68
|
+
this.#assertFormat();
|
|
68
69
|
}
|
|
69
70
|
#ensureSchema() {
|
|
70
71
|
this.#adapter.exec(`
|
|
71
|
-
CREATE TABLE IF NOT EXISTS ${this.#tables.
|
|
72
|
+
CREATE TABLE IF NOT EXISTS ${this.#tables.docMeta} (
|
|
72
73
|
doc_id TEXT PRIMARY KEY,
|
|
73
74
|
data TEXT NOT NULL
|
|
74
75
|
) WITHOUT ROWID
|
|
@@ -83,6 +84,35 @@ var SqliteStore = class {
|
|
|
83
84
|
PRIMARY KEY (doc_id, seq)
|
|
84
85
|
) WITHOUT ROWID
|
|
85
86
|
`);
|
|
87
|
+
this.#adapter.exec(`
|
|
88
|
+
CREATE TABLE IF NOT EXISTS ${this.#tables.storeMeta} (
|
|
89
|
+
key TEXT PRIMARY KEY,
|
|
90
|
+
value TEXT NOT NULL
|
|
91
|
+
) WITHOUT ROWID
|
|
92
|
+
`);
|
|
93
|
+
}
|
|
94
|
+
#assertFormat() {
|
|
95
|
+
const [row] = this.#adapter.iterate(`SELECT value FROM ${this.#tables.storeMeta} WHERE key = ?`, STORE_META_FORMAT_KEY);
|
|
96
|
+
const parsed = row === void 0 ? null : parseStoreFormat(row.value);
|
|
97
|
+
if (parsed === "malformed") throw new StoreFormatVersionError({
|
|
98
|
+
reason: "malformed-version",
|
|
99
|
+
backend: "sqlite",
|
|
100
|
+
stored: null,
|
|
101
|
+
current: STORE_FORMAT_VERSION
|
|
102
|
+
});
|
|
103
|
+
const [hasData] = this.#adapter.iterate(`SELECT 1 AS one FROM ${this.#tables.docMeta} LIMIT 1`);
|
|
104
|
+
const decision = decideStoreFormat({
|
|
105
|
+
current: STORE_FORMAT_VERSION,
|
|
106
|
+
stored: parsed,
|
|
107
|
+
storeHasData: hasData !== void 0
|
|
108
|
+
});
|
|
109
|
+
if (decision.action === "refuse") throw new StoreFormatVersionError({
|
|
110
|
+
reason: decision.reason,
|
|
111
|
+
backend: "sqlite",
|
|
112
|
+
stored: parsed,
|
|
113
|
+
current: STORE_FORMAT_VERSION
|
|
114
|
+
});
|
|
115
|
+
if (decision.action === "stamp") this.#adapter.exec(`INSERT INTO ${this.#tables.storeMeta} (key, value) VALUES (?, ?)`, STORE_META_FORMAT_KEY, JSON.stringify(decision.value));
|
|
86
116
|
}
|
|
87
117
|
async append(docId, record) {
|
|
88
118
|
const plan = planAppend(docId, record, await this.currentMeta(docId), await this.#seqNos.next(docId, async () => {
|
|
@@ -90,7 +120,7 @@ var SqliteStore = class {
|
|
|
90
120
|
return row?.max_seq ?? null;
|
|
91
121
|
}));
|
|
92
122
|
this.#adapter.transaction(() => {
|
|
93
|
-
if (plan.upsertMeta !== null) this.#adapter.exec(`INSERT OR REPLACE INTO ${this.#tables.
|
|
123
|
+
if (plan.upsertMeta !== null) this.#adapter.exec(`INSERT OR REPLACE INTO ${this.#tables.docMeta} (doc_id, data) VALUES (?, ?)`, docId, plan.upsertMeta.data);
|
|
94
124
|
const { row } = plan.insertRecord;
|
|
95
125
|
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
126
|
});
|
|
@@ -103,24 +133,24 @@ var SqliteStore = class {
|
|
|
103
133
|
this.#adapter.transaction(() => {
|
|
104
134
|
this.#adapter.exec(`DELETE FROM ${this.#tables.records} WHERE doc_id = ?`, docId);
|
|
105
135
|
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.
|
|
136
|
+
this.#adapter.exec(`INSERT OR REPLACE INTO ${this.#tables.docMeta} (doc_id, data) VALUES (?, ?)`, docId, plan.upsertMeta.data);
|
|
107
137
|
});
|
|
108
138
|
this.#seqNos.reset(docId, records.length - 1);
|
|
109
139
|
}
|
|
110
140
|
async delete(docId) {
|
|
111
141
|
this.#adapter.transaction(() => {
|
|
112
142
|
this.#adapter.exec(`DELETE FROM ${this.#tables.records} WHERE doc_id = ?`, docId);
|
|
113
|
-
this.#adapter.exec(`DELETE FROM ${this.#tables.
|
|
143
|
+
this.#adapter.exec(`DELETE FROM ${this.#tables.docMeta} WHERE doc_id = ?`, docId);
|
|
114
144
|
});
|
|
115
145
|
this.#seqNos.remove(docId);
|
|
116
146
|
}
|
|
117
147
|
async currentMeta(docId) {
|
|
118
|
-
const [row] = this.#adapter.iterate(`SELECT data FROM ${this.#tables.
|
|
148
|
+
const [row] = this.#adapter.iterate(`SELECT data FROM ${this.#tables.docMeta} WHERE doc_id = ?`, docId);
|
|
119
149
|
if (row === void 0) return null;
|
|
120
150
|
return JSON.parse(row.data);
|
|
121
151
|
}
|
|
122
152
|
async *listDocIds(prefix) {
|
|
123
|
-
const rows = prefix !== void 0 ? this.#adapter.iterate(`SELECT doc_id FROM ${this.#tables.
|
|
153
|
+
const rows = prefix !== void 0 ? this.#adapter.iterate(`SELECT doc_id FROM ${this.#tables.docMeta} WHERE doc_id LIKE ? ESCAPE '\\'`, `${escapeLike(prefix)}%`) : this.#adapter.iterate(`SELECT doc_id FROM ${this.#tables.docMeta}`);
|
|
124
154
|
for (const row of rows) yield row.doc_id;
|
|
125
155
|
}
|
|
126
156
|
async close() {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +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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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["#adapter","#seqNos","#tables","#ensureSchema","#assertFormat"],"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 decideStoreFormat,\n parseStoreFormat,\n SeqNoTracker,\n STORE_META_FORMAT_KEY,\n type Store,\n StoreFormatVersionError,\n type StoreMeta,\n type StoreRecord,\n} from \"@kyneta/exchange\"\nimport {\n fromRow,\n planAppend,\n planReplace,\n type RowShape,\n resolveTables,\n STORE_FORMAT_VERSION,\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_doc_meta`, `kyneta_records`,\n * `kyneta_store_meta`).\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. Any subset of 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 this.#assertFormat()\n }\n\n #ensureSchema(): void {\n this.#adapter.exec(`\n CREATE TABLE IF NOT EXISTS ${this.#tables.docMeta} (\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 this.#adapter.exec(`\n CREATE TABLE IF NOT EXISTS ${this.#tables.storeMeta} (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n ) WITHOUT ROWID\n `)\n }\n\n // Bootstrap reader: consult the store-format marker before trusting any\n // bytes. Stamps a brand-new store, accepts a compatible one, or throws.\n #assertFormat(): void {\n const [row] = this.#adapter.iterate<{ value: string }>(\n `SELECT value FROM ${this.#tables.storeMeta} WHERE key = ?`,\n STORE_META_FORMAT_KEY,\n )\n const parsed = row === undefined ? null : parseStoreFormat(row.value)\n if (parsed === \"malformed\") {\n throw new StoreFormatVersionError({\n reason: \"malformed-version\",\n backend: \"sqlite\",\n stored: null,\n current: STORE_FORMAT_VERSION,\n })\n }\n\n const [hasData] = this.#adapter.iterate<{ one: number }>(\n `SELECT 1 AS one FROM ${this.#tables.docMeta} LIMIT 1`,\n )\n\n const decision = decideStoreFormat({\n current: STORE_FORMAT_VERSION,\n stored: parsed,\n storeHasData: hasData !== undefined,\n })\n\n if (decision.action === \"refuse\") {\n throw new StoreFormatVersionError({\n reason: decision.reason,\n backend: \"sqlite\",\n stored: parsed,\n current: STORE_FORMAT_VERSION,\n })\n }\n if (decision.action === \"stamp\") {\n this.#adapter.exec(\n `INSERT INTO ${this.#tables.storeMeta} (key, value) VALUES (?, ?)`,\n STORE_META_FORMAT_KEY,\n JSON.stringify(decision.value),\n )\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.docMeta} (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.docMeta} (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.docMeta} 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.docMeta} 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.docMeta} WHERE doc_id LIKE ? ESCAPE '\\\\'`,\n `${escapeLike(prefix)}%`,\n )\n : this.#adapter.iterate<{ doc_id: string }>(\n `SELECT doc_id FROM ${this.#tables.docMeta}`,\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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyneta/sqlite-store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "SQLite storage backend for @kyneta/exchange — universal persistent storage",
|
|
5
5
|
"author": "Duane Johnson",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@kyneta/exchange": "^
|
|
32
|
-
"@kyneta/schema": "^
|
|
33
|
-
"@kyneta/sql-store-core": "^
|
|
31
|
+
"@kyneta/exchange": "^2.0.0",
|
|
32
|
+
"@kyneta/schema": "^2.0.0",
|
|
33
|
+
"@kyneta/sql-store-core": "^2.0.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/better-sqlite3": "^7.6.12",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"tsdown": "^0.22.0",
|
|
40
40
|
"typescript": "^5.9.2",
|
|
41
41
|
"vitest": "^4.0.17",
|
|
42
|
-
"@kyneta/
|
|
43
|
-
"@kyneta/schema": "^
|
|
44
|
-
"@kyneta/
|
|
42
|
+
"@kyneta/exchange": "^2.0.0",
|
|
43
|
+
"@kyneta/schema": "^2.0.0",
|
|
44
|
+
"@kyneta/sql-store-core": "^2.0.0"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "tsdown",
|
|
@@ -3,51 +3,18 @@
|
|
|
3
3
|
import * as fs from "node:fs"
|
|
4
4
|
import * as os from "node:os"
|
|
5
5
|
import * as path from "node:path"
|
|
6
|
+
import { StoreFormatVersionError } from "@kyneta/exchange"
|
|
6
7
|
import {
|
|
7
8
|
collectAll,
|
|
8
9
|
describeStore,
|
|
10
|
+
makeArmedFault,
|
|
9
11
|
makeEntryRecord,
|
|
10
12
|
makeMetaRecord,
|
|
11
13
|
plainMeta,
|
|
12
14
|
} from "@kyneta/exchange/testing"
|
|
13
15
|
import Database from "better-sqlite3"
|
|
14
16
|
import { afterAll, describe, expect, it } from "vitest"
|
|
15
|
-
import { fromBetterSqlite3,
|
|
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
|
-
}
|
|
17
|
+
import { fromBetterSqlite3, SqliteStore } from "../index.js"
|
|
51
18
|
|
|
52
19
|
// ---------------------------------------------------------------------------
|
|
53
20
|
// Temp file management
|
|
@@ -95,8 +62,8 @@ describeStore(
|
|
|
95
62
|
const file = makeTmpFile()
|
|
96
63
|
const db = new Database(file)
|
|
97
64
|
const base = fromBetterSqlite3(db)
|
|
98
|
-
const {
|
|
99
|
-
const store = new SqliteStore(
|
|
65
|
+
const { proxy, arm } = makeArmedFault(base, { exec: 1 })
|
|
66
|
+
const store = new SqliteStore(proxy)
|
|
100
67
|
return {
|
|
101
68
|
store,
|
|
102
69
|
injectFault: arm,
|
|
@@ -117,10 +84,18 @@ describeStore(
|
|
|
117
84
|
const adapter = fromBetterSqlite3(db)
|
|
118
85
|
return {
|
|
119
86
|
storeA: new SqliteStore(adapter, {
|
|
120
|
-
tables: {
|
|
87
|
+
tables: {
|
|
88
|
+
docMeta: "a_meta",
|
|
89
|
+
records: "a_records",
|
|
90
|
+
storeMeta: "a_store_meta",
|
|
91
|
+
},
|
|
121
92
|
}),
|
|
122
93
|
storeB: new SqliteStore(adapter, {
|
|
123
|
-
tables: {
|
|
94
|
+
tables: {
|
|
95
|
+
docMeta: "b_meta",
|
|
96
|
+
records: "b_records",
|
|
97
|
+
storeMeta: "b_store_meta",
|
|
98
|
+
},
|
|
124
99
|
}),
|
|
125
100
|
// Both stores share `adapter`; closing it once tears down both.
|
|
126
101
|
cleanup: async () => {
|
|
@@ -237,10 +212,18 @@ describe("SqliteStore — tables isolation", () => {
|
|
|
237
212
|
const adapter = fromBetterSqlite3(db)
|
|
238
213
|
|
|
239
214
|
const store1 = new SqliteStore(adapter, {
|
|
240
|
-
tables: {
|
|
215
|
+
tables: {
|
|
216
|
+
docMeta: "app1_meta",
|
|
217
|
+
records: "app1_records",
|
|
218
|
+
storeMeta: "app1_store_meta",
|
|
219
|
+
},
|
|
241
220
|
})
|
|
242
221
|
const store2 = new SqliteStore(adapter, {
|
|
243
|
-
tables: {
|
|
222
|
+
tables: {
|
|
223
|
+
docMeta: "app2_meta",
|
|
224
|
+
records: "app2_records",
|
|
225
|
+
storeMeta: "app2_store_meta",
|
|
226
|
+
},
|
|
244
227
|
})
|
|
245
228
|
|
|
246
229
|
await store1.append("doc-1", makeMetaRecord())
|
|
@@ -287,3 +270,53 @@ describe("SqliteStore — listDocIds with LIKE-special characters", () => {
|
|
|
287
270
|
await store.close()
|
|
288
271
|
})
|
|
289
272
|
})
|
|
273
|
+
|
|
274
|
+
// Capture the error thrown by a (synchronous) store open, for asserting its
|
|
275
|
+
// typed `reason` discriminant — the class alone can't distinguish refusals.
|
|
276
|
+
function captureError(open: () => unknown): unknown {
|
|
277
|
+
try {
|
|
278
|
+
open()
|
|
279
|
+
return undefined
|
|
280
|
+
} catch (e) {
|
|
281
|
+
return e
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
describe("SqliteStore — store-format gate", () => {
|
|
286
|
+
it("refuses a store whose stamped major is incompatible", () => {
|
|
287
|
+
const db = new Database(":memory:")
|
|
288
|
+
// First open stamps {major:1,minor:0}. Keep the connection open — an
|
|
289
|
+
// in-memory db's data lives only while the connection is open.
|
|
290
|
+
new SqliteStore(fromBetterSqlite3(db))
|
|
291
|
+
// Tamper the marker to a future major.
|
|
292
|
+
db.prepare(
|
|
293
|
+
"UPDATE kyneta_store_meta SET value = ? WHERE key = 'format'",
|
|
294
|
+
).run(JSON.stringify({ major: 99, minor: 0 }))
|
|
295
|
+
|
|
296
|
+
const err = captureError(() => new SqliteStore(fromBetterSqlite3(db)))
|
|
297
|
+
expect(err).toBeInstanceOf(StoreFormatVersionError)
|
|
298
|
+
expect((err as StoreFormatVersionError).reason).toBe("incompatible-major")
|
|
299
|
+
db.close()
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it("refuses an unversioned store that already holds documents", () => {
|
|
303
|
+
const db = new Database(":memory:")
|
|
304
|
+
// Hand-create the doc-meta + records tables (no store_meta marker) and
|
|
305
|
+
// insert a document row — simulating a foreign / pre-marker store.
|
|
306
|
+
db.exec(`
|
|
307
|
+
CREATE TABLE kyneta_doc_meta (doc_id TEXT PRIMARY KEY, data TEXT NOT NULL) WITHOUT ROWID;
|
|
308
|
+
CREATE TABLE kyneta_records (
|
|
309
|
+
doc_id TEXT NOT NULL, seq INTEGER NOT NULL, kind TEXT NOT NULL,
|
|
310
|
+
payload TEXT, blob BLOB, PRIMARY KEY (doc_id, seq)
|
|
311
|
+
) WITHOUT ROWID;
|
|
312
|
+
INSERT INTO kyneta_doc_meta (doc_id, data) VALUES ('doc-1', '{}');
|
|
313
|
+
`)
|
|
314
|
+
|
|
315
|
+
const err = captureError(() => new SqliteStore(fromBetterSqlite3(db)))
|
|
316
|
+
expect(err).toBeInstanceOf(StoreFormatVersionError)
|
|
317
|
+
expect((err as StoreFormatVersionError).reason).toBe(
|
|
318
|
+
"unversioned-existing-data",
|
|
319
|
+
)
|
|
320
|
+
db.close()
|
|
321
|
+
})
|
|
322
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
type DocId,
|
|
12
|
+
decideStoreFormat,
|
|
13
|
+
parseStoreFormat,
|
|
12
14
|
SeqNoTracker,
|
|
15
|
+
STORE_META_FORMAT_KEY,
|
|
13
16
|
type Store,
|
|
17
|
+
StoreFormatVersionError,
|
|
14
18
|
type StoreMeta,
|
|
15
19
|
type StoreRecord,
|
|
16
20
|
} from "@kyneta/exchange"
|
|
@@ -20,6 +24,7 @@ import {
|
|
|
20
24
|
planReplace,
|
|
21
25
|
type RowShape,
|
|
22
26
|
resolveTables,
|
|
27
|
+
STORE_FORMAT_VERSION,
|
|
23
28
|
type TableNames,
|
|
24
29
|
} from "@kyneta/sql-store-core"
|
|
25
30
|
|
|
@@ -141,11 +146,12 @@ interface BunSqliteDatabase {
|
|
|
141
146
|
|
|
142
147
|
export interface SqliteStoreOptions {
|
|
143
148
|
/**
|
|
144
|
-
* Override the default table names (`
|
|
149
|
+
* Override the default table names (`kyneta_doc_meta`, `kyneta_records`,
|
|
150
|
+
* `kyneta_store_meta`).
|
|
145
151
|
*
|
|
146
152
|
* Use when co-locating Exchange tables alongside application tables in
|
|
147
153
|
* the same SQLite database, or when running multiple isolated Exchange
|
|
148
|
-
* instances in one database.
|
|
154
|
+
* instances in one database. Any subset of names may be overridden.
|
|
149
155
|
*/
|
|
150
156
|
tables?: Partial<TableNames>
|
|
151
157
|
}
|
|
@@ -163,11 +169,12 @@ export class SqliteStore implements Store {
|
|
|
163
169
|
this.#adapter = adapter
|
|
164
170
|
this.#tables = resolveTables(options)
|
|
165
171
|
this.#ensureSchema()
|
|
172
|
+
this.#assertFormat()
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
#ensureSchema(): void {
|
|
169
176
|
this.#adapter.exec(`
|
|
170
|
-
CREATE TABLE IF NOT EXISTS ${this.#tables.
|
|
177
|
+
CREATE TABLE IF NOT EXISTS ${this.#tables.docMeta} (
|
|
171
178
|
doc_id TEXT PRIMARY KEY,
|
|
172
179
|
data TEXT NOT NULL
|
|
173
180
|
) WITHOUT ROWID
|
|
@@ -182,6 +189,56 @@ export class SqliteStore implements Store {
|
|
|
182
189
|
PRIMARY KEY (doc_id, seq)
|
|
183
190
|
) WITHOUT ROWID
|
|
184
191
|
`)
|
|
192
|
+
this.#adapter.exec(`
|
|
193
|
+
CREATE TABLE IF NOT EXISTS ${this.#tables.storeMeta} (
|
|
194
|
+
key TEXT PRIMARY KEY,
|
|
195
|
+
value TEXT NOT NULL
|
|
196
|
+
) WITHOUT ROWID
|
|
197
|
+
`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Bootstrap reader: consult the store-format marker before trusting any
|
|
201
|
+
// bytes. Stamps a brand-new store, accepts a compatible one, or throws.
|
|
202
|
+
#assertFormat(): void {
|
|
203
|
+
const [row] = this.#adapter.iterate<{ value: string }>(
|
|
204
|
+
`SELECT value FROM ${this.#tables.storeMeta} WHERE key = ?`,
|
|
205
|
+
STORE_META_FORMAT_KEY,
|
|
206
|
+
)
|
|
207
|
+
const parsed = row === undefined ? null : parseStoreFormat(row.value)
|
|
208
|
+
if (parsed === "malformed") {
|
|
209
|
+
throw new StoreFormatVersionError({
|
|
210
|
+
reason: "malformed-version",
|
|
211
|
+
backend: "sqlite",
|
|
212
|
+
stored: null,
|
|
213
|
+
current: STORE_FORMAT_VERSION,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const [hasData] = this.#adapter.iterate<{ one: number }>(
|
|
218
|
+
`SELECT 1 AS one FROM ${this.#tables.docMeta} LIMIT 1`,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const decision = decideStoreFormat({
|
|
222
|
+
current: STORE_FORMAT_VERSION,
|
|
223
|
+
stored: parsed,
|
|
224
|
+
storeHasData: hasData !== undefined,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
if (decision.action === "refuse") {
|
|
228
|
+
throw new StoreFormatVersionError({
|
|
229
|
+
reason: decision.reason,
|
|
230
|
+
backend: "sqlite",
|
|
231
|
+
stored: parsed,
|
|
232
|
+
current: STORE_FORMAT_VERSION,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
if (decision.action === "stamp") {
|
|
236
|
+
this.#adapter.exec(
|
|
237
|
+
`INSERT INTO ${this.#tables.storeMeta} (key, value) VALUES (?, ?)`,
|
|
238
|
+
STORE_META_FORMAT_KEY,
|
|
239
|
+
JSON.stringify(decision.value),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
185
242
|
}
|
|
186
243
|
|
|
187
244
|
// -------------------------------------------------------------------------
|
|
@@ -205,7 +262,7 @@ export class SqliteStore implements Store {
|
|
|
205
262
|
this.#adapter.transaction(() => {
|
|
206
263
|
if (plan.upsertMeta !== null) {
|
|
207
264
|
this.#adapter.exec(
|
|
208
|
-
`INSERT OR REPLACE INTO ${this.#tables.
|
|
265
|
+
`INSERT OR REPLACE INTO ${this.#tables.docMeta} (doc_id, data) VALUES (?, ?)`,
|
|
209
266
|
docId,
|
|
210
267
|
plan.upsertMeta.data,
|
|
211
268
|
)
|
|
@@ -253,7 +310,7 @@ export class SqliteStore implements Store {
|
|
|
253
310
|
}
|
|
254
311
|
|
|
255
312
|
this.#adapter.exec(
|
|
256
|
-
`INSERT OR REPLACE INTO ${this.#tables.
|
|
313
|
+
`INSERT OR REPLACE INTO ${this.#tables.docMeta} (doc_id, data) VALUES (?, ?)`,
|
|
257
314
|
docId,
|
|
258
315
|
plan.upsertMeta.data,
|
|
259
316
|
)
|
|
@@ -274,7 +331,7 @@ export class SqliteStore implements Store {
|
|
|
274
331
|
docId,
|
|
275
332
|
)
|
|
276
333
|
this.#adapter.exec(
|
|
277
|
-
`DELETE FROM ${this.#tables.
|
|
334
|
+
`DELETE FROM ${this.#tables.docMeta} WHERE doc_id = ?`,
|
|
278
335
|
docId,
|
|
279
336
|
)
|
|
280
337
|
})
|
|
@@ -283,7 +340,7 @@ export class SqliteStore implements Store {
|
|
|
283
340
|
|
|
284
341
|
async currentMeta(docId: DocId): Promise<StoreMeta | null> {
|
|
285
342
|
const [row] = this.#adapter.iterate<{ data: string }>(
|
|
286
|
-
`SELECT data FROM ${this.#tables.
|
|
343
|
+
`SELECT data FROM ${this.#tables.docMeta} WHERE doc_id = ?`,
|
|
287
344
|
docId,
|
|
288
345
|
)
|
|
289
346
|
if (row === undefined) return null
|
|
@@ -294,11 +351,11 @@ export class SqliteStore implements Store {
|
|
|
294
351
|
const rows =
|
|
295
352
|
prefix !== undefined
|
|
296
353
|
? this.#adapter.iterate<{ doc_id: string }>(
|
|
297
|
-
`SELECT doc_id FROM ${this.#tables.
|
|
354
|
+
`SELECT doc_id FROM ${this.#tables.docMeta} WHERE doc_id LIKE ? ESCAPE '\\'`,
|
|
298
355
|
`${escapeLike(prefix)}%`,
|
|
299
356
|
)
|
|
300
357
|
: this.#adapter.iterate<{ doc_id: string }>(
|
|
301
|
-
`SELECT doc_id FROM ${this.#tables.
|
|
358
|
+
`SELECT doc_id FROM ${this.#tables.docMeta}`,
|
|
302
359
|
)
|
|
303
360
|
for (const row of rows) {
|
|
304
361
|
yield row.doc_id
|