@kyneta/prisma-store 1.8.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
@@ -15,10 +15,10 @@ Peer dependencies: `@kyneta/exchange`, `@kyneta/schema`, `@kyneta/sql-store-core
15
15
  Copy [`schema.prisma.example`](./schema.prisma.example) into your existing `schema.prisma` and run `prisma generate` and `prisma migrate dev`:
16
16
 
17
17
  ```prisma
18
- model KynetaMeta {
18
+ model KynetaDocMeta {
19
19
  docId String @id @map("doc_id")
20
20
  data Json
21
- @@map("kyneta_meta")
21
+ @@map("kyneta_doc_meta")
22
22
  }
23
23
 
24
24
  model KynetaRecord {
@@ -30,19 +30,25 @@ model KynetaRecord {
30
30
  @@id([docId, seq])
31
31
  @@map("kyneta_records")
32
32
  }
33
+
34
+ model KynetaStoreMeta {
35
+ key String @id
36
+ value Json
37
+ @@map("kyneta_store_meta")
38
+ }
33
39
  ```
34
40
 
35
- Both models work on Postgres (`Json` → JSONB, `Bytes` → BYTEA), SQLite (`Json` → TEXT, `Bytes` → BLOB), and MySQL (`Json` → JSON, `Bytes` → LONGBLOB).
41
+ All models work on Postgres (`Json` → JSONB, `Bytes` → BYTEA), SQLite (`Json` → TEXT, `Bytes` → BLOB), and MySQL (`Json` → JSON, `Bytes` → LONGBLOB). `KynetaStoreMeta` holds store-global metadata (the on-disk format version), distinct from the per-document `KynetaDocMeta`.
36
42
 
37
43
  ## Usage
38
44
 
39
45
  ```ts
40
46
  import { PrismaClient } from "@prisma/client"
41
47
  import { Exchange } from "@kyneta/exchange"
42
- import { PrismaStore } from "@kyneta/prisma-store"
48
+ import { createPrismaStore } from "@kyneta/prisma-store"
43
49
 
44
50
  const prisma = new PrismaClient()
45
- const store = new PrismaStore({ client: prisma })
51
+ const store = await createPrismaStore({ client: prisma })
46
52
 
47
53
  const exchange = new Exchange({
48
54
  stores: [store],
@@ -54,13 +60,16 @@ const exchange = new Exchange({
54
60
  // await prisma.$disconnect()
55
61
  ```
56
62
 
57
- The model accessors default to `prisma.kynetaMeta` and `prisma.kynetaRecord` (matching `model KynetaMeta` / `model KynetaRecord`). To use different model names:
63
+ `createPrismaStore` runs the **store-format gate** on open: it stamps a `{ major, minor }` version into `KynetaStoreMeta` and, on a later open, throws `StoreFormatVersionError` for an incompatible major or an unversioned store that already holds documents. No automatic migration is performed. (The bare `new PrismaStore({ client })` constructor skips the gate.)
64
+
65
+ The model accessors default to `prisma.kynetaDocMeta`, `prisma.kynetaRecord`, and `prisma.kynetaStoreMeta` (matching the model names above). To use different model names:
58
66
 
59
67
  ```ts
60
- const store = new PrismaStore({
68
+ const store = await createPrismaStore({
61
69
  client: prisma,
62
- metaModel: "appMeta", // matches `model AppMeta`
63
- recordModel: "appRecord", // matches `model AppRecord`
70
+ metaModel: "appDocMeta", // matches `model AppDocMeta`
71
+ recordModel: "appRecord", // matches `model AppRecord`
72
+ storeMetaModel: "appStoreMeta", // matches `model AppStoreMeta`
64
73
  })
65
74
  ```
66
75
 
package/dist/index.d.ts CHANGED
@@ -4,10 +4,12 @@ import { DocId, Store, StoreMeta, StoreRecord } from "@kyneta/exchange";
4
4
  interface PrismaStoreOptions {
5
5
  /** The PrismaClient. Pass `prisma` directly. */
6
6
  client: unknown;
7
- /** Property name on the client. Default matches `model KynetaMeta`. */
7
+ /** Property name on the client. Default matches `model KynetaDocMeta`. */
8
8
  metaModel?: string;
9
9
  /** Property name on the client. Default matches `model KynetaRecord`. */
10
10
  recordModel?: string;
11
+ /** Property name on the client. Default matches `model KynetaStoreMeta`. */
12
+ storeMetaModel?: string;
11
13
  }
12
14
  declare class PrismaStore implements Store {
13
15
  #private;
@@ -19,11 +21,14 @@ declare class PrismaStore implements Store {
19
21
  currentMeta(docId: DocId): Promise<StoreMeta | null>;
20
22
  listDocIds(prefix?: string): AsyncIterable<DocId>;
21
23
  close(): Promise<void>;
24
+ /** Construct + run the store-format gate. Used by `createPrismaStore`. */
25
+ static open(options: PrismaStoreOptions): Promise<Store>;
22
26
  }
23
27
  /**
24
- * Async only for ergonomic parity with `createPostgresStore` does
25
- * no schema validation. Prisma's typed accessors enforce model presence
26
- * at compile time; runtime failures surface on first call.
28
+ * Does no schema validation (Prisma's typed accessors enforce model
29
+ * presence at compile time; runtime failures surface on first call), but
30
+ * does run the store-format gate on open: it stamps a brand-new store,
31
+ * accepts a compatible one, or throws `StoreFormatVersionError`.
27
32
  */
28
33
  declare function createPrismaStore(options: PrismaStoreOptions): Promise<Store>;
29
34
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;UAqFiB,kBAAA;;EAEf,MAAA;EAFiC;EAKjC,SAAA;EALiC;EAQjC,WAAA;AAAA;AAAA,cAOW,WAAA,YAAuB,KAAA;EAAA;cAMtB,OAAA,EAAS,kBAAA;EAgCf,MAAA,CAAO,KAAA,EAAO,KAAA,EAAO,MAAA,EAAQ,WAAA,GAAc,OAAA;EAqC1C,OAAA,CAAQ,KAAA,EAAO,KAAA,GAAQ,aAAA,CAAc,WAAA;EAetC,OAAA,CAAQ,KAAA,EAAO,KAAA,EAAO,OAAA,EAAS,WAAA,KAAgB,OAAA;EAmC/C,MAAA,CAAO,KAAA,EAAO,KAAA,GAAQ,OAAA;EAStB,WAAA,CAAY,KAAA,EAAO,KAAA,GAAQ,OAAA,CAAQ,SAAA;EAMlC,UAAA,CAAW,MAAA,YAAkB,aAAA,CAAc,KAAA;EAiB5C,KAAA,CAAA,GAAS,OAAA;AAAA;;;;;;iBAsCK,iBAAA,CACpB,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,KAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;UA2GiB,kBAAA;;EAEf,MAAA;EAFiC;EAKjC,SAAA;EALiC;EAQjC,WAAA;EAHA;EAMA,cAAA;AAAA;AAAA,cAOW,WAAA,YAAuB,KAAA;EAAA;cAOtB,OAAA,EAAS,kBAAA;EAuCf,MAAA,CAAO,KAAA,EAAO,KAAA,EAAO,MAAA,EAAQ,WAAA,GAAc,OAAA;EAqC1C,OAAA,CAAQ,KAAA,EAAO,KAAA,GAAQ,aAAA,CAAc,WAAA;EAetC,OAAA,CAAQ,KAAA,EAAO,KAAA,EAAO,OAAA,EAAS,WAAA,KAAgB,OAAA;EAmC/C,MAAA,CAAO,KAAA,EAAO,KAAA,GAAQ,OAAA;EAStB,WAAA,CAAY,KAAA,EAAO,KAAA,GAAQ,OAAA,CAAQ,SAAA;EAMlC,UAAA,CAAW,MAAA,YAAkB,aAAA,CAAc,KAAA;EAiB5C,KAAA,CAAA,GAAS,OAAA;EAlF6B;EAAA,OAiI/B,IAAA,CAAK,OAAA,EAAS,kBAAA,GAAqB,OAAA,CAAQ,KAAA;AAAA;;;;;;;iBAyCpC,iBAAA,CACpB,OAAA,EAAS,kBAAA,GACR,OAAA,CAAQ,KAAA"}
package/dist/index.js CHANGED
@@ -1,19 +1,24 @@
1
- import { SeqNoTracker } from "@kyneta/exchange";
2
- import { fromRow, planAppend, planReplace } 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 } from "@kyneta/sql-store-core";
3
3
  //#region src/index.ts
4
- var PrismaStore = class {
4
+ var PrismaStore = class PrismaStore {
5
5
  #client;
6
6
  #seqNos = new SeqNoTracker();
7
7
  #metaModelName;
8
8
  #recordModelName;
9
+ #storeMetaModelName;
9
10
  constructor(options) {
10
11
  this.#client = options.client;
11
- this.#metaModelName = options.metaModel ?? "kynetaMeta";
12
+ this.#metaModelName = options.metaModel ?? "kynetaDocMeta";
12
13
  this.#recordModelName = options.recordModel ?? "kynetaRecord";
14
+ this.#storeMetaModelName = options.storeMetaModel ?? "kynetaStoreMeta";
13
15
  }
14
16
  get #meta() {
15
17
  return this.#client[this.#metaModelName];
16
18
  }
19
+ get #storeMeta() {
20
+ return this.#client[this.#storeMetaModelName];
21
+ }
17
22
  get #records() {
18
23
  return this.#client[this.#recordModelName];
19
24
  }
@@ -118,6 +123,41 @@ var PrismaStore = class {
118
123
  for (const r of rows) yield r.docId;
119
124
  }
120
125
  async close() {}
126
+ async #assertFormat() {
127
+ const row = await this.#storeMeta.findUnique({ where: { key: STORE_META_FORMAT_KEY } });
128
+ const parsed = row === null ? null : parseStoreFormat(parseMetaData(row.value));
129
+ if (parsed === "malformed") throw new StoreFormatVersionError({
130
+ reason: "malformed-version",
131
+ backend: "prisma",
132
+ stored: null,
133
+ current: STORE_FORMAT_VERSION
134
+ });
135
+ const decision = decideStoreFormat({
136
+ current: STORE_FORMAT_VERSION,
137
+ stored: parsed,
138
+ storeHasData: await this.#meta.count() > 0
139
+ });
140
+ if (decision.action === "refuse") throw new StoreFormatVersionError({
141
+ reason: decision.reason,
142
+ backend: "prisma",
143
+ stored: parsed,
144
+ current: STORE_FORMAT_VERSION
145
+ });
146
+ if (decision.action === "stamp") await this.#storeMeta.upsert({
147
+ where: { key: STORE_META_FORMAT_KEY },
148
+ create: {
149
+ key: STORE_META_FORMAT_KEY,
150
+ value: decision.value
151
+ },
152
+ update: { value: decision.value }
153
+ });
154
+ }
155
+ /** Construct + run the store-format gate. Used by `createPrismaStore`. */
156
+ static async open(options) {
157
+ const store = new PrismaStore(options);
158
+ await store.#assertFormat();
159
+ return store;
160
+ }
121
161
  };
122
162
  /**
123
163
  * Prisma's `Json` field arrives parsed on Postgres/MySQL but as a raw
@@ -141,12 +181,13 @@ function prefixUpperBound(prefix) {
141
181
  return null;
142
182
  }
143
183
  /**
144
- * Async only for ergonomic parity with `createPostgresStore` does
145
- * no schema validation. Prisma's typed accessors enforce model presence
146
- * at compile time; runtime failures surface on first call.
184
+ * Does no schema validation (Prisma's typed accessors enforce model
185
+ * presence at compile time; runtime failures surface on first call), but
186
+ * does run the store-format gate on open: it stamps a brand-new store,
187
+ * accepts a compatible one, or throws `StoreFormatVersionError`.
147
188
  */
148
189
  async function createPrismaStore(options) {
149
- return new PrismaStore(options);
190
+ return PrismaStore.open(options);
150
191
  }
151
192
  //#endregion
152
193
  export { PrismaStore, createPrismaStore };
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["#client","#seqNos","#metaModelName","#recordModelName","#meta","#records","#txModels"],"sources":["../src/index.ts"],"sourcesContent":["// Prisma-based Store backend.\n//\n// Why `unknown`-typed client: capturing Prisma's generic typed\n// accessors without a hard dep on `@prisma/client` types is brittle,\n// and depending on them pins this package to one Prisma major. The\n// trade is less compile-time safety inside this package (one cast to\n// the structural interfaces below) for version portability across\n// Prisma releases. Caller's call site stays fully typed.\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} from \"@kyneta/sql-store-core\"\n\n// ---------------------------------------------------------------------------\n// Internal structural types — narrow shapes for the Prisma methods we use\n// ---------------------------------------------------------------------------\n\ninterface MetaRow {\n docId: string\n data: unknown\n}\n\ninterface RecordRow {\n docId: string\n seq: number\n kind: string\n payload: string | null\n blob: Uint8Array | null\n}\n\ninterface MetaModel {\n findUnique(args: { where: { docId: string } }): Promise<MetaRow | null>\n findMany(args: {\n where?: { docId?: { gte?: string; lt?: string } }\n select: { docId: true }\n }): Promise<Array<{ docId: string }>>\n upsert(args: {\n where: { docId: string }\n create: { docId: string; data: unknown }\n update: { data: unknown }\n }): Promise<MetaRow>\n delete(args: { where: { docId: string } }): Promise<unknown>\n deleteMany(args: { where: { docId: string } }): Promise<unknown>\n}\n\ninterface RecordModel {\n findMany(args: {\n where: { docId: string }\n orderBy: { seq: \"asc\" }\n }): Promise<RecordRow[]>\n create(args: { data: RecordRow }): Promise<unknown>\n deleteMany(args: { where: { docId: string } }): Promise<unknown>\n aggregate(args: {\n where: { docId: string }\n _max: { seq: true }\n }): Promise<{ _max: { seq: number | null } }>\n}\n\ninterface PrismaClientLike {\n $transaction<R>(fn: (tx: PrismaTransactionLike) => Promise<R>): Promise<R>\n}\n\n/**\n * Real Prisma's `tx` exposes the same model accessors as the client.\n * Indexed by string so caller-chosen model names (via `metaModel` /\n * `recordModel` options) resolve through the same lookup path.\n */\ninterface PrismaTransactionLike {\n readonly [k: string]: unknown\n}\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\nexport interface PrismaStoreOptions {\n /** The PrismaClient. Pass `prisma` directly. */\n client: unknown\n\n /** Property name on the client. Default matches `model KynetaMeta`. */\n metaModel?: string\n\n /** Property name on the client. Default matches `model KynetaRecord`. */\n recordModel?: string\n}\n\n// ---------------------------------------------------------------------------\n// PrismaStore\n// ---------------------------------------------------------------------------\n\nexport class PrismaStore implements Store {\n readonly #client: PrismaClientLike\n readonly #seqNos = new SeqNoTracker()\n readonly #metaModelName: string\n readonly #recordModelName: string\n\n constructor(options: PrismaStoreOptions) {\n this.#client = options.client as PrismaClientLike\n this.#metaModelName = options.metaModel ?? \"kynetaMeta\"\n this.#recordModelName = options.recordModel ?? \"kynetaRecord\"\n }\n\n get #meta(): MetaModel {\n return (this.#client as unknown as Record<string, unknown>)[\n this.#metaModelName\n ] as MetaModel\n }\n\n get #records(): RecordModel {\n return (this.#client as unknown as Record<string, unknown>)[\n this.#recordModelName\n ] as RecordModel\n }\n\n #txModels(tx: PrismaTransactionLike): {\n meta: MetaModel\n records: RecordModel\n } {\n return {\n meta: tx[this.#metaModelName] as MetaModel,\n records: tx[this.#recordModelName] as RecordModel,\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 result = await this.#records.aggregate({\n where: { docId },\n _max: { seq: true },\n })\n return result._max.seq ?? null\n })\n\n const plan = planAppend(docId, record, existingMeta, seq)\n\n await this.#client.$transaction(async tx => {\n const { meta, records } = this.#txModels(tx)\n\n if (plan.upsertMeta !== null) {\n const dataValue = JSON.parse(plan.upsertMeta.data) as unknown\n await meta.upsert({\n where: { docId },\n create: { docId, data: dataValue },\n update: { data: dataValue },\n })\n }\n\n const { row } = plan.insertRecord\n await records.create({\n data: {\n docId,\n seq: plan.insertRecord.seq,\n kind: row.kind,\n payload: row.payload,\n blob: row.blob,\n },\n })\n })\n }\n\n async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {\n const rows = await this.#records.findMany({\n where: { docId },\n orderBy: { seq: \"asc\" },\n })\n for (const r of rows) {\n const row: RowShape = {\n kind: r.kind === \"meta\" ? \"meta\" : \"entry\",\n payload: r.payload as string,\n blob: r.blob ?? null,\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 await this.#client.$transaction(async tx => {\n const { meta, records: recordsModel } = this.#txModels(tx)\n\n await recordsModel.deleteMany({ where: { docId } })\n\n for (const { seq, row } of plan.records) {\n await recordsModel.create({\n data: {\n docId,\n seq,\n kind: row.kind,\n payload: row.payload,\n blob: row.blob,\n },\n })\n }\n\n const dataValue = JSON.parse(plan.upsertMeta.data) as unknown\n await meta.upsert({\n where: { docId },\n create: { docId, data: dataValue },\n update: { data: dataValue },\n })\n })\n\n // Must run after commit. A `$transaction` rejection (failed COMMIT\n // or callback throw) propagates past this line; cache stays\n // unmutated. Inside the callback would corrupt it on rollback.\n this.#seqNos.reset(docId, records.length - 1)\n }\n\n async delete(docId: DocId): Promise<void> {\n await this.#client.$transaction(async tx => {\n const { meta, records } = this.#txModels(tx)\n await records.deleteMany({ where: { docId } })\n await meta.deleteMany({ where: { docId } })\n })\n this.#seqNos.remove(docId)\n }\n\n async currentMeta(docId: DocId): Promise<StoreMeta | null> {\n const row = await this.#meta.findUnique({ where: { docId } })\n if (row === null) return null\n return parseMetaData(row.data) as StoreMeta\n }\n\n async *listDocIds(prefix?: string): AsyncIterable<DocId> {\n if (prefix === undefined) {\n const rows = await this.#meta.findMany({ select: { docId: true } })\n for (const r of rows) yield r.docId\n return\n }\n\n const upper = prefixUpperBound(prefix)\n const rows = await this.#meta.findMany({\n where: {\n docId: upper === null ? { gte: prefix } : { gte: prefix, lt: upper },\n },\n select: { docId: true },\n })\n for (const r of rows) yield r.docId\n }\n\n async close(): Promise<void> {\n // Caller owns the lifecycle (`prisma.$disconnect()`).\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Prisma's `Json` field arrives parsed on Postgres/MySQL but as a raw\n * string on SQLite — the only place where the underlying database\n * type leaks through Prisma's abstraction.\n */\nfunction parseMetaData(value: unknown): unknown {\n if (typeof value === \"string\") return JSON.parse(value)\n return value\n}\n\nfunction prefixUpperBound(prefix: string): string | null {\n if (prefix.length === 0) return null\n const codes = Array.from(prefix)\n for (let i = codes.length - 1; i >= 0; i--) {\n const ch = codes[i] as string\n const code = ch.codePointAt(0) as number\n if (code < 0x10ffff) {\n const next = String.fromCodePoint(code + 1)\n return codes.slice(0, i).join(\"\") + next\n }\n }\n return null\n}\n\n/**\n * Async only for ergonomic parity with `createPostgresStore` — does\n * no schema validation. Prisma's typed accessors enforce model presence\n * at compile time; runtime failures surface on first call.\n */\nexport async function createPrismaStore(\n options: PrismaStoreOptions,\n): Promise<Store> {\n return new PrismaStore(options)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["#client","#seqNos","#metaModelName","#recordModelName","#storeMetaModelName","#meta","#storeMeta","#records","#txModels","#assertFormat"],"sources":["../src/index.ts"],"sourcesContent":["// Prisma-based Store backend.\n//\n// Why `unknown`-typed client: capturing Prisma's generic typed\n// accessors without a hard dep on `@prisma/client` types is brittle,\n// and depending on them pins this package to one Prisma major. The\n// trade is less compile-time safety inside this package (one cast to\n// the structural interfaces below) for version portability across\n// Prisma releases. Caller's call site stays fully typed.\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 STORE_FORMAT_VERSION,\n} from \"@kyneta/sql-store-core\"\n\n// ---------------------------------------------------------------------------\n// Internal structural types — narrow shapes for the Prisma methods we use\n// ---------------------------------------------------------------------------\n\ninterface MetaRow {\n docId: string\n data: unknown\n}\n\ninterface RecordRow {\n docId: string\n seq: number\n kind: string\n payload: string | null\n blob: Uint8Array | null\n}\n\ninterface MetaModel {\n findUnique(args: { where: { docId: string } }): Promise<MetaRow | null>\n findMany(args: {\n where?: { docId?: { gte?: string; lt?: string } }\n select: { docId: true }\n }): Promise<Array<{ docId: string }>>\n upsert(args: {\n where: { docId: string }\n create: { docId: string; data: unknown }\n update: { data: unknown }\n }): Promise<MetaRow>\n delete(args: { where: { docId: string } }): Promise<unknown>\n deleteMany(args: { where: { docId: string } }): Promise<unknown>\n // Empty-store probe for the store-format gate (does any document exist).\n count(): Promise<number>\n}\n\ninterface StoreMetaRow {\n key: string\n value: unknown\n}\n\n/** Store-global metadata model — keyed by an opaque `key`, not a `docId`. */\ninterface StoreMetaModel {\n findUnique(args: { where: { key: string } }): Promise<StoreMetaRow | null>\n upsert(args: {\n where: { key: string }\n create: { key: string; value: unknown }\n update: { value: unknown }\n }): Promise<StoreMetaRow>\n}\n\ninterface RecordModel {\n findMany(args: {\n where: { docId: string }\n orderBy: { seq: \"asc\" }\n }): Promise<RecordRow[]>\n create(args: { data: RecordRow }): Promise<unknown>\n deleteMany(args: { where: { docId: string } }): Promise<unknown>\n aggregate(args: {\n where: { docId: string }\n _max: { seq: true }\n }): Promise<{ _max: { seq: number | null } }>\n}\n\ninterface PrismaClientLike {\n $transaction<R>(fn: (tx: PrismaTransactionLike) => Promise<R>): Promise<R>\n}\n\n/**\n * Real Prisma's `tx` exposes the same model accessors as the client.\n * Indexed by string so caller-chosen model names (via `metaModel` /\n * `recordModel` options) resolve through the same lookup path.\n */\ninterface PrismaTransactionLike {\n readonly [k: string]: unknown\n}\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\nexport interface PrismaStoreOptions {\n /** The PrismaClient. Pass `prisma` directly. */\n client: unknown\n\n /** Property name on the client. Default matches `model KynetaDocMeta`. */\n metaModel?: string\n\n /** Property name on the client. Default matches `model KynetaRecord`. */\n recordModel?: string\n\n /** Property name on the client. Default matches `model KynetaStoreMeta`. */\n storeMetaModel?: string\n}\n\n// ---------------------------------------------------------------------------\n// PrismaStore\n// ---------------------------------------------------------------------------\n\nexport class PrismaStore implements Store {\n readonly #client: PrismaClientLike\n readonly #seqNos = new SeqNoTracker()\n readonly #metaModelName: string\n readonly #recordModelName: string\n readonly #storeMetaModelName: string\n\n constructor(options: PrismaStoreOptions) {\n this.#client = options.client as PrismaClientLike\n this.#metaModelName = options.metaModel ?? \"kynetaDocMeta\"\n this.#recordModelName = options.recordModel ?? \"kynetaRecord\"\n this.#storeMetaModelName = options.storeMetaModel ?? \"kynetaStoreMeta\"\n }\n\n get #meta(): MetaModel {\n return (this.#client as unknown as Record<string, unknown>)[\n this.#metaModelName\n ] as MetaModel\n }\n\n get #storeMeta(): StoreMetaModel {\n return (this.#client as unknown as Record<string, unknown>)[\n this.#storeMetaModelName\n ] as StoreMetaModel\n }\n\n get #records(): RecordModel {\n return (this.#client as unknown as Record<string, unknown>)[\n this.#recordModelName\n ] as RecordModel\n }\n\n #txModels(tx: PrismaTransactionLike): {\n meta: MetaModel\n records: RecordModel\n } {\n return {\n meta: tx[this.#metaModelName] as MetaModel,\n records: tx[this.#recordModelName] as RecordModel,\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 result = await this.#records.aggregate({\n where: { docId },\n _max: { seq: true },\n })\n return result._max.seq ?? null\n })\n\n const plan = planAppend(docId, record, existingMeta, seq)\n\n await this.#client.$transaction(async tx => {\n const { meta, records } = this.#txModels(tx)\n\n if (plan.upsertMeta !== null) {\n const dataValue = JSON.parse(plan.upsertMeta.data) as unknown\n await meta.upsert({\n where: { docId },\n create: { docId, data: dataValue },\n update: { data: dataValue },\n })\n }\n\n const { row } = plan.insertRecord\n await records.create({\n data: {\n docId,\n seq: plan.insertRecord.seq,\n kind: row.kind,\n payload: row.payload,\n blob: row.blob,\n },\n })\n })\n }\n\n async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {\n const rows = await this.#records.findMany({\n where: { docId },\n orderBy: { seq: \"asc\" },\n })\n for (const r of rows) {\n const row: RowShape = {\n kind: r.kind === \"meta\" ? \"meta\" : \"entry\",\n payload: r.payload as string,\n blob: r.blob ?? null,\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 await this.#client.$transaction(async tx => {\n const { meta, records: recordsModel } = this.#txModels(tx)\n\n await recordsModel.deleteMany({ where: { docId } })\n\n for (const { seq, row } of plan.records) {\n await recordsModel.create({\n data: {\n docId,\n seq,\n kind: row.kind,\n payload: row.payload,\n blob: row.blob,\n },\n })\n }\n\n const dataValue = JSON.parse(plan.upsertMeta.data) as unknown\n await meta.upsert({\n where: { docId },\n create: { docId, data: dataValue },\n update: { data: dataValue },\n })\n })\n\n // Must run after commit. A `$transaction` rejection (failed COMMIT\n // or callback throw) propagates past this line; cache stays\n // unmutated. Inside the callback would corrupt it on rollback.\n this.#seqNos.reset(docId, records.length - 1)\n }\n\n async delete(docId: DocId): Promise<void> {\n await this.#client.$transaction(async tx => {\n const { meta, records } = this.#txModels(tx)\n await records.deleteMany({ where: { docId } })\n await meta.deleteMany({ where: { docId } })\n })\n this.#seqNos.remove(docId)\n }\n\n async currentMeta(docId: DocId): Promise<StoreMeta | null> {\n const row = await this.#meta.findUnique({ where: { docId } })\n if (row === null) return null\n return parseMetaData(row.data) as StoreMeta\n }\n\n async *listDocIds(prefix?: string): AsyncIterable<DocId> {\n if (prefix === undefined) {\n const rows = await this.#meta.findMany({ select: { docId: true } })\n for (const r of rows) yield r.docId\n return\n }\n\n const upper = prefixUpperBound(prefix)\n const rows = await this.#meta.findMany({\n where: {\n docId: upper === null ? { gte: prefix } : { gte: prefix, lt: upper },\n },\n select: { docId: true },\n })\n for (const r of rows) yield r.docId\n }\n\n async close(): Promise<void> {\n // Caller owns the lifecycle (`prisma.$disconnect()`).\n }\n\n // Bootstrap reader: stamp/accept/refuse the store-format marker on open.\n // A `static open` reaches this private method so the gate stays internal.\n async #assertFormat(): Promise<void> {\n const row = await this.#storeMeta.findUnique({\n where: { key: STORE_META_FORMAT_KEY },\n })\n const parsed =\n row === null ? null : parseStoreFormat(parseMetaData(row.value))\n if (parsed === \"malformed\") {\n throw new StoreFormatVersionError({\n reason: \"malformed-version\",\n backend: \"prisma\",\n stored: null,\n current: STORE_FORMAT_VERSION,\n })\n }\n\n const docCount = await this.#meta.count()\n\n const decision = decideStoreFormat({\n current: STORE_FORMAT_VERSION,\n stored: parsed,\n storeHasData: docCount > 0,\n })\n\n if (decision.action === \"refuse\") {\n throw new StoreFormatVersionError({\n reason: decision.reason,\n backend: \"prisma\",\n stored: parsed,\n current: STORE_FORMAT_VERSION,\n })\n }\n if (decision.action === \"stamp\") {\n await this.#storeMeta.upsert({\n where: { key: STORE_META_FORMAT_KEY },\n create: { key: STORE_META_FORMAT_KEY, value: decision.value },\n update: { value: decision.value },\n })\n }\n }\n\n /** Construct + run the store-format gate. Used by `createPrismaStore`. */\n static async open(options: PrismaStoreOptions): Promise<Store> {\n const store = new PrismaStore(options)\n await store.#assertFormat()\n return store\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Prisma's `Json` field arrives parsed on Postgres/MySQL but as a raw\n * string on SQLite — the only place where the underlying database\n * type leaks through Prisma's abstraction.\n */\nfunction parseMetaData(value: unknown): unknown {\n if (typeof value === \"string\") return JSON.parse(value)\n return value\n}\n\nfunction prefixUpperBound(prefix: string): string | null {\n if (prefix.length === 0) return null\n const codes = Array.from(prefix)\n for (let i = codes.length - 1; i >= 0; i--) {\n const ch = codes[i] as string\n const code = ch.codePointAt(0) as number\n if (code < 0x10ffff) {\n const next = String.fromCodePoint(code + 1)\n return codes.slice(0, i).join(\"\") + next\n }\n }\n return null\n}\n\n/**\n * Does no schema validation (Prisma's typed accessors enforce model\n * presence at compile time; runtime failures surface on first call), but\n * does run the store-format gate on open: it stamps a brand-new store,\n * accepts a compatible one, or throws `StoreFormatVersionError`.\n */\nexport async function createPrismaStore(\n options: PrismaStoreOptions,\n): Promise<Store> {\n return PrismaStore.open(options)\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyneta/prisma-store",
3
- "version": "1.8.0",
3
+ "version": "2.0.0",
4
4
  "description": "Prisma-based storage backend for @kyneta/exchange",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -31,18 +31,18 @@
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@prisma/client": "^5.0.0 || ^6.0.0",
34
- "@kyneta/exchange": "^1.8.0",
35
- "@kyneta/schema": "^1.8.0",
36
- "@kyneta/sql-store-core": "^1.8.0"
34
+ "@kyneta/schema": "^2.0.0",
35
+ "@kyneta/exchange": "^2.0.0",
36
+ "@kyneta/sql-store-core": "^2.0.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^22",
40
40
  "tsdown": "^0.22.0",
41
41
  "typescript": "^5.9.2",
42
42
  "vitest": "^4.0.17",
43
- "@kyneta/sql-store-core": "^1.8.0",
44
- "@kyneta/exchange": "^1.8.0",
45
- "@kyneta/schema": "^1.8.0"
43
+ "@kyneta/exchange": "^2.0.0",
44
+ "@kyneta/sql-store-core": "^2.0.0",
45
+ "@kyneta/schema": "^2.0.0"
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsdown",
@@ -1,18 +1,20 @@
1
1
  // @kyneta/prisma-store — canonical Prisma schema fragment.
2
2
  //
3
3
  // Copy these models into your existing schema.prisma. The model
4
- // accessors (e.g. `prisma.kynetaMeta`, `prisma.kynetaRecord`) are
5
- // what you pass into the PrismaStore constructor.
4
+ // accessors (e.g. `prisma.kynetaDocMeta`, `prisma.kynetaRecord`,
5
+ // `prisma.kynetaStoreMeta`) are what you pass into the PrismaStore
6
+ // constructor.
6
7
  //
7
- // Both models work on Postgres (Json → JSONB, Bytes → BYTEA),
8
+ // All models work on Postgres (Json → JSONB, Bytes → BYTEA),
8
9
  // SQLite (Json → TEXT, Bytes → BLOB), and MySQL (Json → JSON,
9
10
  // Bytes → LONGBLOB). Round-trip through `loadAll` is portable across
10
11
  // these targets.
11
12
 
12
- model KynetaMeta {
13
+ // Per-document metadata, keyed by doc_id.
14
+ model KynetaDocMeta {
13
15
  docId String @id @map("doc_id")
14
16
  data Json
15
- @@map("kyneta_meta")
17
+ @@map("kyneta_doc_meta")
16
18
  }
17
19
 
18
20
  model KynetaRecord {
@@ -24,3 +26,11 @@ model KynetaRecord {
24
26
  @@id([docId, seq])
25
27
  @@map("kyneta_records")
26
28
  }
29
+
30
+ // Store-global metadata, keyed by an opaque key (not a doc_id). Holds the
31
+ // on-disk format version under key 'format'; stamped and gated on open.
32
+ model KynetaStoreMeta {
33
+ key String @id
34
+ value Json
35
+ @@map("kyneta_store_meta")
36
+ }
@@ -7,8 +7,8 @@
7
7
  // integration tests in tests/integration. Here we only verify:
8
8
  //
9
9
  // 1. PrismaStore accepts a structurally-typed accessor object.
10
- // 2. The model names default to `kynetaMeta` / `kynetaRecord`,
11
- // overridable via options.
10
+ // 2. The model names default to `kynetaDocMeta` / `kynetaRecord` /
11
+ // `kynetaStoreMeta`, overridable via options.
12
12
  // 3. Append, currentMeta, loadAll, listDocIds, delete, replace each
13
13
  // call the expected mock methods with the expected args.
14
14
  //
@@ -19,16 +19,17 @@
19
19
  import type { StoreMeta } from "@kyneta/exchange"
20
20
  import { SYNC_AUTHORITATIVE } from "@kyneta/schema"
21
21
  import { describe, expect, it } from "vitest"
22
- import { PrismaStore } from "../index.js"
22
+ import { createPrismaStore, PrismaStore } from "../index.js"
23
23
 
24
24
  const baseMeta: StoreMeta = {
25
25
  replicaType: ["plain", 1, 0] as const,
26
- syncProtocol: SYNC_AUTHORITATIVE,
26
+ syncMode: SYNC_AUTHORITATIVE,
27
27
  schemaHash: "00test",
28
28
  }
29
29
 
30
30
  interface MockState {
31
31
  metas: Map<string, unknown>
32
+ storeMetas: Map<string, unknown>
32
33
  records: Array<{
33
34
  docId: string
34
35
  seq: number
@@ -79,6 +80,25 @@ function makeMockClient(state: MockState): unknown {
79
80
  state.metas.delete(args.where.docId)
80
81
  return null
81
82
  },
83
+ async count() {
84
+ return state.metas.size
85
+ },
86
+ }
87
+
88
+ const storeMetaModel = {
89
+ async findUnique(args: { where: { key: string } }) {
90
+ const value = state.storeMetas.get(args.where.key)
91
+ if (value === undefined) return null
92
+ return { key: args.where.key, value }
93
+ },
94
+ async upsert(args: {
95
+ where: { key: string }
96
+ create: { key: string; value: unknown }
97
+ update: { value: unknown }
98
+ }) {
99
+ state.storeMetas.set(args.where.key, args.update.value)
100
+ return { key: args.where.key, value: args.update.value }
101
+ },
82
102
  }
83
103
 
84
104
  const recordModel = {
@@ -120,8 +140,9 @@ function makeMockClient(state: MockState): unknown {
120
140
  // those wrappings inside the transaction too, mirroring real Prisma's
121
141
  // behavior where `tx` exposes the same model accessors as the client.
122
142
  const client: Record<string, unknown> = {
123
- kynetaMeta: metaModel,
143
+ kynetaDocMeta: metaModel,
124
144
  kynetaRecord: recordModel,
145
+ kynetaStoreMeta: storeMetaModel,
125
146
  }
126
147
  client.$transaction = async <R>(
127
148
  fn: (tx: unknown) => Promise<R>,
@@ -144,7 +165,7 @@ function makeMockClient(state: MockState): unknown {
144
165
  }
145
166
 
146
167
  function freshState(): MockState {
147
- return { metas: new Map(), records: [], txCalls: 0 }
168
+ return { metas: new Map(), storeMetas: new Map(), records: [], txCalls: 0 }
148
169
  }
149
170
 
150
171
  describe("PrismaStore — structural mock", () => {
@@ -250,7 +271,7 @@ describe("PrismaStore — structural mock", () => {
250
271
  // visible both at the top level and inside transactions.
251
272
  const base = makeMockClient(state) as Record<string, unknown>
252
273
  const renamed: Record<string, unknown> = {
253
- app_meta: base.kynetaMeta,
274
+ app_meta: base.kynetaDocMeta,
254
275
  app_record: base.kynetaRecord,
255
276
  }
256
277
  renamed.$transaction = async (fn: (tx: unknown) => Promise<unknown>) =>
@@ -304,3 +325,29 @@ describe("PrismaStore — structural mock", () => {
304
325
  expect(state.records).toHaveLength(1)
305
326
  })
306
327
  })
328
+
329
+ describe("PrismaStore — store-format gate", () => {
330
+ it("createPrismaStore stamps a fresh store, then accepts it on reopen", async () => {
331
+ const state = freshState()
332
+ await createPrismaStore({ client: makeMockClient(state) })
333
+ expect(state.storeMetas.get("format")).toEqual({ major: 1, minor: 0 })
334
+
335
+ // Reopen against the same state: the marker round-trips, no throw.
336
+ await expect(
337
+ createPrismaStore({ client: makeMockClient(state) }),
338
+ ).resolves.toBeDefined()
339
+ })
340
+
341
+ it("refuses a store whose stamped major is incompatible", async () => {
342
+ const state = freshState()
343
+ state.storeMetas.set("format", { major: 99, minor: 0 })
344
+ state.metas.set("doc-1", {}) // store already holds a document
345
+
346
+ await expect(
347
+ createPrismaStore({ client: makeMockClient(state) }),
348
+ ).rejects.toMatchObject({
349
+ name: "StoreFormatVersionError",
350
+ reason: "incompatible-major",
351
+ })
352
+ })
353
+ })
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"
@@ -19,6 +23,7 @@ import {
19
23
  planAppend,
20
24
  planReplace,
21
25
  type RowShape,
26
+ STORE_FORMAT_VERSION,
22
27
  } from "@kyneta/sql-store-core"
23
28
 
24
29
  // ---------------------------------------------------------------------------
@@ -51,6 +56,23 @@ interface MetaModel {
51
56
  }): Promise<MetaRow>
52
57
  delete(args: { where: { docId: string } }): Promise<unknown>
53
58
  deleteMany(args: { where: { docId: string } }): Promise<unknown>
59
+ // Empty-store probe for the store-format gate (does any document exist).
60
+ count(): Promise<number>
61
+ }
62
+
63
+ interface StoreMetaRow {
64
+ key: string
65
+ value: unknown
66
+ }
67
+
68
+ /** Store-global metadata model — keyed by an opaque `key`, not a `docId`. */
69
+ interface StoreMetaModel {
70
+ findUnique(args: { where: { key: string } }): Promise<StoreMetaRow | null>
71
+ upsert(args: {
72
+ where: { key: string }
73
+ create: { key: string; value: unknown }
74
+ update: { value: unknown }
75
+ }): Promise<StoreMetaRow>
54
76
  }
55
77
 
56
78
  interface RecordModel {
@@ -87,11 +109,14 @@ export interface PrismaStoreOptions {
87
109
  /** The PrismaClient. Pass `prisma` directly. */
88
110
  client: unknown
89
111
 
90
- /** Property name on the client. Default matches `model KynetaMeta`. */
112
+ /** Property name on the client. Default matches `model KynetaDocMeta`. */
91
113
  metaModel?: string
92
114
 
93
115
  /** Property name on the client. Default matches `model KynetaRecord`. */
94
116
  recordModel?: string
117
+
118
+ /** Property name on the client. Default matches `model KynetaStoreMeta`. */
119
+ storeMetaModel?: string
95
120
  }
96
121
 
97
122
  // ---------------------------------------------------------------------------
@@ -103,11 +128,13 @@ export class PrismaStore implements Store {
103
128
  readonly #seqNos = new SeqNoTracker()
104
129
  readonly #metaModelName: string
105
130
  readonly #recordModelName: string
131
+ readonly #storeMetaModelName: string
106
132
 
107
133
  constructor(options: PrismaStoreOptions) {
108
134
  this.#client = options.client as PrismaClientLike
109
- this.#metaModelName = options.metaModel ?? "kynetaMeta"
135
+ this.#metaModelName = options.metaModel ?? "kynetaDocMeta"
110
136
  this.#recordModelName = options.recordModel ?? "kynetaRecord"
137
+ this.#storeMetaModelName = options.storeMetaModel ?? "kynetaStoreMeta"
111
138
  }
112
139
 
113
140
  get #meta(): MetaModel {
@@ -116,6 +143,12 @@ export class PrismaStore implements Store {
116
143
  ] as MetaModel
117
144
  }
118
145
 
146
+ get #storeMeta(): StoreMetaModel {
147
+ return (this.#client as unknown as Record<string, unknown>)[
148
+ this.#storeMetaModelName
149
+ ] as StoreMetaModel
150
+ }
151
+
119
152
  get #records(): RecordModel {
120
153
  return (this.#client as unknown as Record<string, unknown>)[
121
154
  this.#recordModelName
@@ -258,6 +291,55 @@ export class PrismaStore implements Store {
258
291
  async close(): Promise<void> {
259
292
  // Caller owns the lifecycle (`prisma.$disconnect()`).
260
293
  }
294
+
295
+ // Bootstrap reader: stamp/accept/refuse the store-format marker on open.
296
+ // A `static open` reaches this private method so the gate stays internal.
297
+ async #assertFormat(): Promise<void> {
298
+ const row = await this.#storeMeta.findUnique({
299
+ where: { key: STORE_META_FORMAT_KEY },
300
+ })
301
+ const parsed =
302
+ row === null ? null : parseStoreFormat(parseMetaData(row.value))
303
+ if (parsed === "malformed") {
304
+ throw new StoreFormatVersionError({
305
+ reason: "malformed-version",
306
+ backend: "prisma",
307
+ stored: null,
308
+ current: STORE_FORMAT_VERSION,
309
+ })
310
+ }
311
+
312
+ const docCount = await this.#meta.count()
313
+
314
+ const decision = decideStoreFormat({
315
+ current: STORE_FORMAT_VERSION,
316
+ stored: parsed,
317
+ storeHasData: docCount > 0,
318
+ })
319
+
320
+ if (decision.action === "refuse") {
321
+ throw new StoreFormatVersionError({
322
+ reason: decision.reason,
323
+ backend: "prisma",
324
+ stored: parsed,
325
+ current: STORE_FORMAT_VERSION,
326
+ })
327
+ }
328
+ if (decision.action === "stamp") {
329
+ await this.#storeMeta.upsert({
330
+ where: { key: STORE_META_FORMAT_KEY },
331
+ create: { key: STORE_META_FORMAT_KEY, value: decision.value },
332
+ update: { value: decision.value },
333
+ })
334
+ }
335
+ }
336
+
337
+ /** Construct + run the store-format gate. Used by `createPrismaStore`. */
338
+ static async open(options: PrismaStoreOptions): Promise<Store> {
339
+ const store = new PrismaStore(options)
340
+ await store.#assertFormat()
341
+ return store
342
+ }
261
343
  }
262
344
 
263
345
  // ---------------------------------------------------------------------------
@@ -289,12 +371,13 @@ function prefixUpperBound(prefix: string): string | null {
289
371
  }
290
372
 
291
373
  /**
292
- * Async only for ergonomic parity with `createPostgresStore` does
293
- * no schema validation. Prisma's typed accessors enforce model presence
294
- * at compile time; runtime failures surface on first call.
374
+ * Does no schema validation (Prisma's typed accessors enforce model
375
+ * presence at compile time; runtime failures surface on first call), but
376
+ * does run the store-format gate on open: it stamps a brand-new store,
377
+ * accepts a compatible one, or throws `StoreFormatVersionError`.
295
378
  */
296
379
  export async function createPrismaStore(
297
380
  options: PrismaStoreOptions,
298
381
  ): Promise<Store> {
299
- return new PrismaStore(options)
382
+ return PrismaStore.open(options)
300
383
  }