@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 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: { meta: "app_meta", records: "app_records" },
123
+ tables: { docMeta: "app_doc_meta", records: "app_records", storeMeta: "app_store_meta" },
124
124
  })
125
125
  ```
126
126
 
127
- Default: `{ meta: "kyneta_meta", records: "kyneta_records" }`. Either or both names may be overridden.
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 pairs.
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
- `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.
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
- // v2.0
141
- new SqliteStore(adapter) // tables: kyneta_meta, kyneta_records
140
+ // current
141
+ new SqliteStore(adapter) // tables: kyneta_doc_meta, kyneta_records, kyneta_store_meta
142
142
  new SqliteStore(adapter, {
143
- tables: { meta: "app_meta", records: "app_records" },
143
+ tables: { docMeta: "app_doc_meta", records: "app_records", storeMeta: "app_store_meta" },
144
144
  })
145
145
  ```
146
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`.
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 two tables on first use:
151
+ The store creates three tables on first use:
152
152
 
153
- - **`tables.meta`** (default `kyneta_meta`) — materialized metadata index. `doc_id TEXT PRIMARY KEY`, `data TEXT NOT NULL` (JSON-encoded `StoreMeta`). `WITHOUT ROWID`.
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 (`kyneta_meta` and `kyneta_records`).
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. Either or both names may be overridden.
68
+ * instances in one database. Any subset of names may be overridden.
68
69
  */
69
70
  tables?: Partial<TableNames>;
70
71
  }
@@ -1 +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;;;;;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;;;;;;;EA8BR,MAAA,GAAS,OAAO,CAAC,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"}
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.meta} (
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.meta} (doc_id, data) VALUES (?, ?)`, docId, plan.upsertMeta.data);
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.meta} (doc_id, data) VALUES (?, ?)`, docId, plan.upsertMeta.data);
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.meta} WHERE doc_id = ?`, docId);
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.meta} WHERE doc_id = ?`, docId);
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.meta} WHERE doc_id LIKE ? ESCAPE '\\'`, `${escapeLike(prefix)}%`) : this.#adapter.iterate(`SELECT doc_id FROM ${this.#tables.meta}`);
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": "1.7.0",
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": "^1.7.0",
32
- "@kyneta/schema": "^1.7.0",
33
- "@kyneta/sql-store-core": "^1.7.0"
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/sql-store-core": "^1.7.0",
43
- "@kyneta/schema": "^1.7.0",
44
- "@kyneta/exchange": "^1.7.0"
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, 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
- }
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 { adapter, arm } = makeFaultyAdapter(base)
99
- const store = new SqliteStore(adapter)
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: { meta: "a_meta", records: "a_records" },
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: { meta: "b_meta", records: "b_records" },
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: { meta: "app1_meta", records: "app1_records" },
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: { meta: "app2_meta", records: "app2_records" },
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 (`kyneta_meta` and `kyneta_records`).
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. Either or both names may be overridden.
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.meta} (
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.meta} (doc_id, data) VALUES (?, ?)`,
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.meta} (doc_id, data) VALUES (?, ?)`,
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.meta} WHERE doc_id = ?`,
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.meta} WHERE doc_id = ?`,
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.meta} WHERE doc_id LIKE ? ESCAPE '\\'`,
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.meta}`,
358
+ `SELECT doc_id FROM ${this.#tables.docMeta}`,
302
359
  )
303
360
  for (const row of rows) {
304
361
  yield row.doc_id