@kyneta/prisma-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 +18 -9
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -8
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/schema.prisma.example +15 -5
- package/src/__tests__/prisma-store.test.ts +54 -7
- package/src/index.ts +89 -6
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
|
|
18
|
+
model KynetaDocMeta {
|
|
19
19
|
docId String @id @map("doc_id")
|
|
20
20
|
data Json
|
|
21
|
-
@@map("
|
|
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
|
-
|
|
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 {
|
|
48
|
+
import { createPrismaStore } from "@kyneta/prisma-store"
|
|
43
49
|
|
|
44
50
|
const prisma = new PrismaClient()
|
|
45
|
-
const store =
|
|
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
|
-
|
|
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 =
|
|
68
|
+
const store = await createPrismaStore({
|
|
61
69
|
client: prisma,
|
|
62
|
-
metaModel: "
|
|
63
|
-
recordModel: "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
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
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":";;;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 ?? "
|
|
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
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
|
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": "
|
|
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/
|
|
35
|
-
"@kyneta/
|
|
36
|
-
"@kyneta/sql-store-core": "^
|
|
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/exchange": "^
|
|
44
|
-
"@kyneta/sql-store-core": "^
|
|
45
|
-
"@kyneta/schema": "^
|
|
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",
|
package/schema.prisma.example
CHANGED
|
@@ -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.
|
|
5
|
-
// what you pass into the PrismaStore
|
|
4
|
+
// accessors (e.g. `prisma.kynetaDocMeta`, `prisma.kynetaRecord`,
|
|
5
|
+
// `prisma.kynetaStoreMeta`) are what you pass into the PrismaStore
|
|
6
|
+
// constructor.
|
|
6
7
|
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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("
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 ?? "
|
|
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
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
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
|
|
382
|
+
return PrismaStore.open(options)
|
|
300
383
|
}
|