@kyneta/prisma-store 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Duane Johnson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @kyneta/prisma-store
2
+
3
+ `@kyneta/exchange` storage backend that takes a caller-supplied `PrismaClient` and uses Prisma's typed query API natively.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @kyneta/prisma-store
9
+ ```
10
+
11
+ Peer dependencies: `@kyneta/exchange`, `@kyneta/schema`, `@kyneta/sql-store-core`, `@prisma/client`.
12
+
13
+ ## Schema
14
+
15
+ Copy [`schema.prisma.example`](./schema.prisma.example) into your existing `schema.prisma` and run `prisma generate` and `prisma migrate dev`:
16
+
17
+ ```prisma
18
+ model KynetaMeta {
19
+ docId String @id @map("doc_id")
20
+ data Json
21
+ @@map("kyneta_meta")
22
+ }
23
+
24
+ model KynetaRecord {
25
+ docId String @map("doc_id")
26
+ seq Int
27
+ kind String
28
+ payload String?
29
+ blob Bytes?
30
+ @@id([docId, seq])
31
+ @@map("kyneta_records")
32
+ }
33
+ ```
34
+
35
+ Both models work on Postgres (`Json` → JSONB, `Bytes` → BYTEA), SQLite (`Json` → TEXT, `Bytes` → BLOB), and MySQL (`Json` → JSON, `Bytes` → LONGBLOB).
36
+
37
+ ## Usage
38
+
39
+ ```ts
40
+ import { PrismaClient } from "@prisma/client"
41
+ import { Exchange } from "@kyneta/exchange"
42
+ import { PrismaStore } from "@kyneta/prisma-store"
43
+
44
+ const prisma = new PrismaClient()
45
+ const store = new PrismaStore({ client: prisma })
46
+
47
+ const exchange = new Exchange({
48
+ stores: [store],
49
+ // ...
50
+ })
51
+
52
+ // On shutdown:
53
+ // await exchange.shutdown()
54
+ // await prisma.$disconnect()
55
+ ```
56
+
57
+ The model accessors default to `prisma.kynetaMeta` and `prisma.kynetaRecord` (matching `model KynetaMeta` / `model KynetaRecord`). To use different model names:
58
+
59
+ ```ts
60
+ const store = new PrismaStore({
61
+ client: prisma,
62
+ metaModel: "appMeta", // matches `model AppMeta`
63
+ recordModel: "appRecord", // matches `model AppRecord`
64
+ })
65
+ ```
66
+
67
+ ## Why `unknown` typing?
68
+
69
+ `PrismaStoreOptions.client` is typed as `unknown`. Capturing Prisma's generic `findUnique<Args>` / `upsert<Args>` types without depending on `@prisma/client`'s types directly is genuinely hard, and depending on them pins this package to a specific Prisma major version. The `unknown`-with-internal-cast approach trades compile-time safety inside `@kyneta/prisma-store` for version-portability across Prisma releases.
70
+
71
+ The user-facing call site retains full type safety: the caller passes their own typed `PrismaClient` in. Internally, the store casts once to a minimal structural interface for the methods it calls (`findUnique`, `findMany`, `upsert`, `create`, `deleteMany`, `aggregate`, `$transaction`).
72
+
73
+ ## Lifecycle
74
+
75
+ The caller owns the connection lifecycle. `PrismaStore.close()` is a no-op; the caller calls `prisma.$disconnect()` on shutdown.
76
+
77
+ ## See also
78
+
79
+ - [`@kyneta/sql-store-core`](../sql-core/) — pure helpers shared with `sqlite-store` and `postgres-store`.
80
+ - [`@kyneta/sqlite-store`](../sqlite/) — universal SQLite backend (no Prisma).
81
+ - [`@kyneta/postgres-store`](../postgres/) — async-native Postgres backend (no Prisma, uses `pg` directly).
@@ -0,0 +1,31 @@
1
+ import { DocId, Store, StoreMeta, StoreRecord } from "@kyneta/exchange";
2
+
3
+ //#region src/index.d.ts
4
+ interface PrismaStoreOptions {
5
+ /** The PrismaClient. Pass `prisma` directly. */
6
+ client: unknown;
7
+ /** Property name on the client. Default matches `model KynetaMeta`. */
8
+ metaModel?: string;
9
+ /** Property name on the client. Default matches `model KynetaRecord`. */
10
+ recordModel?: string;
11
+ }
12
+ declare class PrismaStore implements Store {
13
+ #private;
14
+ constructor(options: PrismaStoreOptions);
15
+ append(docId: DocId, record: StoreRecord): Promise<void>;
16
+ loadAll(docId: DocId): AsyncIterable<StoreRecord>;
17
+ replace(docId: DocId, records: StoreRecord[]): Promise<void>;
18
+ delete(docId: DocId): Promise<void>;
19
+ currentMeta(docId: DocId): Promise<StoreMeta | null>;
20
+ listDocIds(prefix?: string): AsyncIterable<DocId>;
21
+ close(): Promise<void>;
22
+ }
23
+ /**
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.
27
+ */
28
+ declare function createPrismaStore(options: PrismaStoreOptions): Promise<Store>;
29
+ //#endregion
30
+ export { PrismaStore, PrismaStoreOptions, createPrismaStore };
31
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,154 @@
1
+ import { SeqNoTracker } from "@kyneta/exchange";
2
+ import { fromRow, planAppend, planReplace } from "@kyneta/sql-store-core";
3
+ //#region src/index.ts
4
+ var PrismaStore = class {
5
+ #client;
6
+ #seqNos = new SeqNoTracker();
7
+ #metaModelName;
8
+ #recordModelName;
9
+ constructor(options) {
10
+ this.#client = options.client;
11
+ this.#metaModelName = options.metaModel ?? "kynetaMeta";
12
+ this.#recordModelName = options.recordModel ?? "kynetaRecord";
13
+ }
14
+ get #meta() {
15
+ return this.#client[this.#metaModelName];
16
+ }
17
+ get #records() {
18
+ return this.#client[this.#recordModelName];
19
+ }
20
+ #txModels(tx) {
21
+ return {
22
+ meta: tx[this.#metaModelName],
23
+ records: tx[this.#recordModelName]
24
+ };
25
+ }
26
+ async append(docId, record) {
27
+ const plan = planAppend(docId, record, await this.currentMeta(docId), await this.#seqNos.next(docId, async () => {
28
+ return (await this.#records.aggregate({
29
+ where: { docId },
30
+ _max: { seq: true }
31
+ }))._max.seq ?? null;
32
+ }));
33
+ await this.#client.$transaction(async (tx) => {
34
+ const { meta, records } = this.#txModels(tx);
35
+ if (plan.upsertMeta !== null) {
36
+ const dataValue = JSON.parse(plan.upsertMeta.data);
37
+ await meta.upsert({
38
+ where: { docId },
39
+ create: {
40
+ docId,
41
+ data: dataValue
42
+ },
43
+ update: { data: dataValue }
44
+ });
45
+ }
46
+ const { row } = plan.insertRecord;
47
+ await records.create({ data: {
48
+ docId,
49
+ seq: plan.insertRecord.seq,
50
+ kind: row.kind,
51
+ payload: row.payload,
52
+ blob: row.blob
53
+ } });
54
+ });
55
+ }
56
+ async *loadAll(docId) {
57
+ const rows = await this.#records.findMany({
58
+ where: { docId },
59
+ orderBy: { seq: "asc" }
60
+ });
61
+ for (const r of rows) yield fromRow({
62
+ kind: r.kind === "meta" ? "meta" : "entry",
63
+ payload: r.payload,
64
+ blob: r.blob ?? null
65
+ });
66
+ }
67
+ async replace(docId, records) {
68
+ const plan = planReplace(records, await this.currentMeta(docId));
69
+ await this.#client.$transaction(async (tx) => {
70
+ const { meta, records: recordsModel } = this.#txModels(tx);
71
+ await recordsModel.deleteMany({ where: { docId } });
72
+ for (const { seq, row } of plan.records) await recordsModel.create({ data: {
73
+ docId,
74
+ seq,
75
+ kind: row.kind,
76
+ payload: row.payload,
77
+ blob: row.blob
78
+ } });
79
+ const dataValue = JSON.parse(plan.upsertMeta.data);
80
+ await meta.upsert({
81
+ where: { docId },
82
+ create: {
83
+ docId,
84
+ data: dataValue
85
+ },
86
+ update: { data: dataValue }
87
+ });
88
+ });
89
+ this.#seqNos.reset(docId, records.length - 1);
90
+ }
91
+ async delete(docId) {
92
+ await this.#client.$transaction(async (tx) => {
93
+ const { meta, records } = this.#txModels(tx);
94
+ await records.deleteMany({ where: { docId } });
95
+ await meta.deleteMany({ where: { docId } });
96
+ });
97
+ this.#seqNos.remove(docId);
98
+ }
99
+ async currentMeta(docId) {
100
+ const row = await this.#meta.findUnique({ where: { docId } });
101
+ if (row === null) return null;
102
+ return parseMetaData(row.data);
103
+ }
104
+ async *listDocIds(prefix) {
105
+ if (prefix === void 0) {
106
+ const rows = await this.#meta.findMany({ select: { docId: true } });
107
+ for (const r of rows) yield r.docId;
108
+ return;
109
+ }
110
+ const upper = prefixUpperBound(prefix);
111
+ const rows = await this.#meta.findMany({
112
+ where: { docId: upper === null ? { gte: prefix } : {
113
+ gte: prefix,
114
+ lt: upper
115
+ } },
116
+ select: { docId: true }
117
+ });
118
+ for (const r of rows) yield r.docId;
119
+ }
120
+ async close() {}
121
+ };
122
+ /**
123
+ * Prisma's `Json` field arrives parsed on Postgres/MySQL but as a raw
124
+ * string on SQLite — the only place where the underlying database
125
+ * type leaks through Prisma's abstraction.
126
+ */
127
+ function parseMetaData(value) {
128
+ if (typeof value === "string") return JSON.parse(value);
129
+ return value;
130
+ }
131
+ function prefixUpperBound(prefix) {
132
+ if (prefix.length === 0) return null;
133
+ const codes = Array.from(prefix);
134
+ for (let i = codes.length - 1; i >= 0; i--) {
135
+ const code = codes[i].codePointAt(0);
136
+ if (code < 1114111) {
137
+ const next = String.fromCodePoint(code + 1);
138
+ return codes.slice(0, i).join("") + next;
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+ /**
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.
147
+ */
148
+ async function createPrismaStore(options) {
149
+ return new PrismaStore(options);
150
+ }
151
+ //#endregion
152
+ export { PrismaStore, createPrismaStore };
153
+
154
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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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"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@kyneta/prisma-store",
3
+ "version": "1.5.0",
4
+ "description": "Prisma-based storage backend for @kyneta/exchange",
5
+ "author": "Duane Johnson",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/halecraft/kyneta",
10
+ "directory": "packages/exchange/stores/prisma"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "schema.prisma.example"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./src": "./src/index.ts",
31
+ "./src/*": "./src/*",
32
+ "./schema.prisma.example": "./schema.prisma.example"
33
+ },
34
+ "peerDependencies": {
35
+ "@kyneta/exchange": "^1.5.0",
36
+ "@kyneta/schema": "^1.5.0",
37
+ "@kyneta/sql-store-core": "^1.5.0",
38
+ "@prisma/client": "^5.0.0 || ^6.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22",
42
+ "tsdown": "^0.21.9",
43
+ "typescript": "^5.9.2",
44
+ "vitest": "^4.0.17",
45
+ "@kyneta/exchange": "^1.5.0",
46
+ "@kyneta/sql-store-core": "^1.5.0",
47
+ "@kyneta/schema": "^1.5.0"
48
+ },
49
+ "scripts": {
50
+ "build": "tsdown",
51
+ "test": "verify logic",
52
+ "verify": "verify"
53
+ }
54
+ }
@@ -0,0 +1,26 @@
1
+ // @kyneta/prisma-store — canonical Prisma schema fragment.
2
+ //
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.
6
+ //
7
+ // Both models work on Postgres (Json → JSONB, Bytes → BYTEA),
8
+ // SQLite (Json → TEXT, Bytes → BLOB), and MySQL (Json → JSON,
9
+ // Bytes → LONGBLOB). Round-trip through `loadAll` is portable across
10
+ // these targets.
11
+
12
+ model KynetaMeta {
13
+ docId String @id @map("doc_id")
14
+ data Json
15
+ @@map("kyneta_meta")
16
+ }
17
+
18
+ model KynetaRecord {
19
+ docId String @map("doc_id")
20
+ seq Int
21
+ kind String
22
+ payload String?
23
+ blob Bytes?
24
+ @@id([docId, seq])
25
+ @@map("kyneta_records")
26
+ }
@@ -0,0 +1,306 @@
1
+ // prisma-store — unit tests over a structural Prisma mock.
2
+ //
3
+ // These tests exercise the PrismaStore's translation of Store calls
4
+ // into Prisma model-accessor calls without depending on @prisma/client
5
+ // at runtime (which would force a real schema generation step). The
6
+ // full conformance suite runs against Prisma+SQLite as part of the
7
+ // integration tests in tests/integration. Here we only verify:
8
+ //
9
+ // 1. PrismaStore accepts a structurally-typed accessor object.
10
+ // 2. The model names default to `kynetaMeta` / `kynetaRecord`,
11
+ // overridable via options.
12
+ // 3. Append, currentMeta, loadAll, listDocIds, delete, replace each
13
+ // call the expected mock methods with the expected args.
14
+ //
15
+ // The structural-typing test is the load-bearing claim of the
16
+ // `unknown`-with-internal-cast approach: any caller-supplied
17
+ // PrismaClient with the right method signatures must work.
18
+
19
+ import type { StoreMeta } from "@kyneta/exchange"
20
+ import { SYNC_AUTHORITATIVE } from "@kyneta/schema"
21
+ import { describe, expect, it } from "vitest"
22
+ import { PrismaStore } from "../index.js"
23
+
24
+ const baseMeta: StoreMeta = {
25
+ replicaType: ["plain", 1, 0] as const,
26
+ syncProtocol: SYNC_AUTHORITATIVE,
27
+ schemaHash: "00test",
28
+ }
29
+
30
+ interface MockState {
31
+ metas: Map<string, unknown>
32
+ records: Array<{
33
+ docId: string
34
+ seq: number
35
+ kind: string
36
+ payload: string | null
37
+ blob: Uint8Array | null
38
+ }>
39
+ txCalls: number
40
+ }
41
+
42
+ function makeMockClient(state: MockState): unknown {
43
+ const metaModel = {
44
+ async findUnique(args: { where: { docId: string } }) {
45
+ const data = state.metas.get(args.where.docId)
46
+ if (data === undefined) return null
47
+ return { docId: args.where.docId, data }
48
+ },
49
+ async findMany(args: {
50
+ where?: { docId?: { gte?: string; lt?: string } }
51
+ select: { docId: true }
52
+ }) {
53
+ const ids = Array.from(state.metas.keys())
54
+ const docIdFilter = args.where?.docId
55
+ const filtered =
56
+ docIdFilter === undefined
57
+ ? ids
58
+ : ids.filter(id => {
59
+ const { gte, lt } = docIdFilter
60
+ if (gte !== undefined && id < gte) return false
61
+ if (lt !== undefined && id >= lt) return false
62
+ return true
63
+ })
64
+ return filtered.map(docId => ({ docId }))
65
+ },
66
+ async upsert(args: {
67
+ where: { docId: string }
68
+ create: { docId: string; data: unknown }
69
+ update: { data: unknown }
70
+ }) {
71
+ state.metas.set(args.where.docId, args.update.data)
72
+ return { docId: args.where.docId, data: args.update.data }
73
+ },
74
+ async delete(args: { where: { docId: string } }) {
75
+ state.metas.delete(args.where.docId)
76
+ return null
77
+ },
78
+ async deleteMany(args: { where: { docId: string } }) {
79
+ state.metas.delete(args.where.docId)
80
+ return null
81
+ },
82
+ }
83
+
84
+ const recordModel = {
85
+ async findMany(args: {
86
+ where: { docId: string }
87
+ orderBy: { seq: "asc" }
88
+ }) {
89
+ return state.records
90
+ .filter(r => r.docId === args.where.docId)
91
+ .sort((a, b) => a.seq - b.seq)
92
+ },
93
+ async create(args: {
94
+ data: {
95
+ docId: string
96
+ seq: number
97
+ kind: string
98
+ payload: string | null
99
+ blob: Uint8Array | null
100
+ }
101
+ }) {
102
+ state.records.push(args.data)
103
+ return null
104
+ },
105
+ async deleteMany(args: { where: { docId: string } }) {
106
+ state.records = state.records.filter(r => r.docId !== args.where.docId)
107
+ return null
108
+ },
109
+ async aggregate(args: { where: { docId: string }; _max: { seq: true } }) {
110
+ const seqs = state.records
111
+ .filter(r => r.docId === args.where.docId)
112
+ .map(r => r.seq)
113
+ return { _max: { seq: seqs.length === 0 ? null : Math.max(...seqs) } }
114
+ },
115
+ }
116
+
117
+ // The client's `$transaction` passes the same client object back as
118
+ // its `tx` argument. This means callers who wrap or rename outer
119
+ // model accessors (renamed model names; fault-injected methods) see
120
+ // those wrappings inside the transaction too, mirroring real Prisma's
121
+ // behavior where `tx` exposes the same model accessors as the client.
122
+ const client: Record<string, unknown> = {
123
+ kynetaMeta: metaModel,
124
+ kynetaRecord: recordModel,
125
+ }
126
+ client.$transaction = async <R>(
127
+ fn: (tx: unknown) => Promise<R>,
128
+ ): Promise<R> => {
129
+ state.txCalls += 1
130
+ // Snapshot for rollback.
131
+ const snapshot = {
132
+ metas: new Map(state.metas),
133
+ records: state.records.slice(),
134
+ }
135
+ try {
136
+ return await fn(client)
137
+ } catch (e) {
138
+ state.metas = snapshot.metas
139
+ state.records = snapshot.records
140
+ throw e
141
+ }
142
+ }
143
+ return client
144
+ }
145
+
146
+ function freshState(): MockState {
147
+ return { metas: new Map(), records: [], txCalls: 0 }
148
+ }
149
+
150
+ describe("PrismaStore — structural mock", () => {
151
+ it("append + loadAll round-trips a meta and an entry", async () => {
152
+ const state = freshState()
153
+ const store = new PrismaStore({ client: makeMockClient(state) })
154
+
155
+ await store.append("doc-1", { kind: "meta", meta: baseMeta })
156
+ await store.append("doc-1", {
157
+ kind: "entry",
158
+ payload: { kind: "entirety", encoding: "json", data: '{"x":1}' },
159
+ version: "v1",
160
+ })
161
+
162
+ expect(state.txCalls).toBe(2)
163
+ expect(state.metas.size).toBe(1)
164
+ expect(state.records).toHaveLength(2)
165
+
166
+ const out: unknown[] = []
167
+ for await (const r of store.loadAll("doc-1")) out.push(r)
168
+ expect(out).toHaveLength(2)
169
+ })
170
+
171
+ it("currentMeta returns null for nonexistent doc", async () => {
172
+ const store = new PrismaStore({ client: makeMockClient(freshState()) })
173
+ expect(await store.currentMeta("none")).toBeNull()
174
+ })
175
+
176
+ it("currentMeta returns a parsed StoreMeta after append", async () => {
177
+ const state = freshState()
178
+ const store = new PrismaStore({ client: makeMockClient(state) })
179
+ await store.append("doc-1", { kind: "meta", meta: baseMeta })
180
+
181
+ const meta = await store.currentMeta("doc-1")
182
+ expect(meta).toEqual(baseMeta)
183
+ })
184
+
185
+ it("delete clears both meta and records", async () => {
186
+ const state = freshState()
187
+ const store = new PrismaStore({ client: makeMockClient(state) })
188
+ await store.append("doc-1", { kind: "meta", meta: baseMeta })
189
+ await store.append("doc-1", {
190
+ kind: "entry",
191
+ payload: { kind: "entirety", encoding: "json", data: "{}" },
192
+ version: "v1",
193
+ })
194
+
195
+ await store.delete("doc-1")
196
+
197
+ expect(state.metas.size).toBe(0)
198
+ expect(state.records).toHaveLength(0)
199
+ })
200
+
201
+ it("replace swaps the record stream and updates meta", async () => {
202
+ const state = freshState()
203
+ const store = new PrismaStore({ client: makeMockClient(state) })
204
+ await store.append("doc-1", { kind: "meta", meta: baseMeta })
205
+ await store.append("doc-1", {
206
+ kind: "entry",
207
+ payload: { kind: "since", encoding: "json", data: "{}" },
208
+ version: "v1",
209
+ })
210
+ await store.append("doc-1", {
211
+ kind: "entry",
212
+ payload: { kind: "since", encoding: "json", data: "{}" },
213
+ version: "v2",
214
+ })
215
+
216
+ await store.replace("doc-1", [
217
+ { kind: "meta", meta: baseMeta },
218
+ {
219
+ kind: "entry",
220
+ payload: { kind: "entirety", encoding: "json", data: "{}" },
221
+ version: "v3",
222
+ },
223
+ ])
224
+
225
+ const records = state.records
226
+ .filter(r => r.docId === "doc-1")
227
+ .sort((a, b) => a.seq - b.seq)
228
+ expect(records).toHaveLength(2)
229
+ })
230
+
231
+ it("listDocIds(prefix) range-scans, no LIKE-pattern surface", async () => {
232
+ const state = freshState()
233
+ const store = new PrismaStore({ client: makeMockClient(state) })
234
+
235
+ await store.append("100%_done", { kind: "meta", meta: baseMeta })
236
+ await store.append("100_other", { kind: "meta", meta: baseMeta })
237
+ await store.append("100xyz", { kind: "meta", meta: baseMeta })
238
+ await store.append("other", { kind: "meta", meta: baseMeta })
239
+
240
+ const matched: string[] = []
241
+ for await (const id of store.listDocIds("100%")) matched.push(id)
242
+ expect(matched).toEqual(["100%_done"])
243
+ })
244
+
245
+ it("custom model names override defaults", async () => {
246
+ const state = freshState()
247
+ // Build a mock with non-default model names by aliasing the same
248
+ // model objects under the requested keys. The mock's $transaction
249
+ // passes the client back as `tx`, so the renamed accessors are
250
+ // visible both at the top level and inside transactions.
251
+ const base = makeMockClient(state) as Record<string, unknown>
252
+ const renamed: Record<string, unknown> = {
253
+ app_meta: base.kynetaMeta,
254
+ app_record: base.kynetaRecord,
255
+ }
256
+ renamed.$transaction = async (fn: (tx: unknown) => Promise<unknown>) =>
257
+ fn(renamed)
258
+
259
+ const store = new PrismaStore({
260
+ client: renamed,
261
+ metaModel: "app_meta",
262
+ recordModel: "app_record",
263
+ })
264
+
265
+ await store.append("doc-1", { kind: "meta", meta: baseMeta })
266
+ expect(state.metas.size).toBe(1)
267
+ })
268
+
269
+ it("transaction rejection leaves observable state unchanged", async () => {
270
+ const state = freshState()
271
+ const base = makeMockClient(state) as Record<string, unknown>
272
+
273
+ // Wrap $transaction so the SECOND call throws before its callback runs.
274
+ // Models a real Prisma `$transaction` that rejects (e.g. failed COMMIT).
275
+ let txCount = 0
276
+ const baseTx = base.$transaction as <R>(
277
+ fn: (tx: unknown) => Promise<R>,
278
+ ) => Promise<R>
279
+ base.$transaction = async <R>(fn: (tx: unknown) => Promise<R>) => {
280
+ txCount += 1
281
+ if (txCount === 2) throw new Error("fault")
282
+ return baseTx(fn)
283
+ }
284
+
285
+ const store = new PrismaStore({ client: base })
286
+
287
+ // First append: tx #1 — succeeds, schemaHash="primer" persists.
288
+ await store.append("doc-1", {
289
+ kind: "meta",
290
+ meta: { ...baseMeta, schemaHash: "primer" },
291
+ })
292
+
293
+ // Second append: tx #2 — rejects.
294
+ await expect(
295
+ store.append("doc-1", {
296
+ kind: "meta",
297
+ meta: { ...baseMeta, schemaHash: "injected" },
298
+ }),
299
+ ).rejects.toThrow("fault")
300
+
301
+ // No write happened in tx #2 → state still has the primer's meta.
302
+ const meta = await store.currentMeta("doc-1")
303
+ expect(meta?.schemaHash).toBe("primer")
304
+ expect(state.records).toHaveLength(1)
305
+ })
306
+ })
package/src/index.ts ADDED
@@ -0,0 +1,300 @@
1
+ // Prisma-based Store backend.
2
+ //
3
+ // Why `unknown`-typed client: capturing Prisma's generic typed
4
+ // accessors without a hard dep on `@prisma/client` types is brittle,
5
+ // and depending on them pins this package to one Prisma major. The
6
+ // trade is less compile-time safety inside this package (one cast to
7
+ // the structural interfaces below) for version portability across
8
+ // Prisma releases. Caller's call site stays fully typed.
9
+
10
+ import {
11
+ type DocId,
12
+ SeqNoTracker,
13
+ type Store,
14
+ type StoreMeta,
15
+ type StoreRecord,
16
+ } from "@kyneta/exchange"
17
+ import {
18
+ fromRow,
19
+ planAppend,
20
+ planReplace,
21
+ type RowShape,
22
+ } from "@kyneta/sql-store-core"
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Internal structural types — narrow shapes for the Prisma methods we use
26
+ // ---------------------------------------------------------------------------
27
+
28
+ interface MetaRow {
29
+ docId: string
30
+ data: unknown
31
+ }
32
+
33
+ interface RecordRow {
34
+ docId: string
35
+ seq: number
36
+ kind: string
37
+ payload: string | null
38
+ blob: Uint8Array | null
39
+ }
40
+
41
+ interface MetaModel {
42
+ findUnique(args: { where: { docId: string } }): Promise<MetaRow | null>
43
+ findMany(args: {
44
+ where?: { docId?: { gte?: string; lt?: string } }
45
+ select: { docId: true }
46
+ }): Promise<Array<{ docId: string }>>
47
+ upsert(args: {
48
+ where: { docId: string }
49
+ create: { docId: string; data: unknown }
50
+ update: { data: unknown }
51
+ }): Promise<MetaRow>
52
+ delete(args: { where: { docId: string } }): Promise<unknown>
53
+ deleteMany(args: { where: { docId: string } }): Promise<unknown>
54
+ }
55
+
56
+ interface RecordModel {
57
+ findMany(args: {
58
+ where: { docId: string }
59
+ orderBy: { seq: "asc" }
60
+ }): Promise<RecordRow[]>
61
+ create(args: { data: RecordRow }): Promise<unknown>
62
+ deleteMany(args: { where: { docId: string } }): Promise<unknown>
63
+ aggregate(args: {
64
+ where: { docId: string }
65
+ _max: { seq: true }
66
+ }): Promise<{ _max: { seq: number | null } }>
67
+ }
68
+
69
+ interface PrismaClientLike {
70
+ $transaction<R>(fn: (tx: PrismaTransactionLike) => Promise<R>): Promise<R>
71
+ }
72
+
73
+ /**
74
+ * Real Prisma's `tx` exposes the same model accessors as the client.
75
+ * Indexed by string so caller-chosen model names (via `metaModel` /
76
+ * `recordModel` options) resolve through the same lookup path.
77
+ */
78
+ interface PrismaTransactionLike {
79
+ readonly [k: string]: unknown
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Options
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface PrismaStoreOptions {
87
+ /** The PrismaClient. Pass `prisma` directly. */
88
+ client: unknown
89
+
90
+ /** Property name on the client. Default matches `model KynetaMeta`. */
91
+ metaModel?: string
92
+
93
+ /** Property name on the client. Default matches `model KynetaRecord`. */
94
+ recordModel?: string
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // PrismaStore
99
+ // ---------------------------------------------------------------------------
100
+
101
+ export class PrismaStore implements Store {
102
+ readonly #client: PrismaClientLike
103
+ readonly #seqNos = new SeqNoTracker()
104
+ readonly #metaModelName: string
105
+ readonly #recordModelName: string
106
+
107
+ constructor(options: PrismaStoreOptions) {
108
+ this.#client = options.client as PrismaClientLike
109
+ this.#metaModelName = options.metaModel ?? "kynetaMeta"
110
+ this.#recordModelName = options.recordModel ?? "kynetaRecord"
111
+ }
112
+
113
+ get #meta(): MetaModel {
114
+ return (this.#client as unknown as Record<string, unknown>)[
115
+ this.#metaModelName
116
+ ] as MetaModel
117
+ }
118
+
119
+ get #records(): RecordModel {
120
+ return (this.#client as unknown as Record<string, unknown>)[
121
+ this.#recordModelName
122
+ ] as RecordModel
123
+ }
124
+
125
+ #txModels(tx: PrismaTransactionLike): {
126
+ meta: MetaModel
127
+ records: RecordModel
128
+ } {
129
+ return {
130
+ meta: tx[this.#metaModelName] as MetaModel,
131
+ records: tx[this.#recordModelName] as RecordModel,
132
+ }
133
+ }
134
+
135
+ // -------------------------------------------------------------------------
136
+ // Store interface
137
+ // -------------------------------------------------------------------------
138
+
139
+ async append(docId: DocId, record: StoreRecord): Promise<void> {
140
+ const existingMeta = await this.currentMeta(docId)
141
+ const seq = await this.#seqNos.next(docId, async () => {
142
+ const result = await this.#records.aggregate({
143
+ where: { docId },
144
+ _max: { seq: true },
145
+ })
146
+ return result._max.seq ?? null
147
+ })
148
+
149
+ const plan = planAppend(docId, record, existingMeta, seq)
150
+
151
+ await this.#client.$transaction(async tx => {
152
+ const { meta, records } = this.#txModels(tx)
153
+
154
+ if (plan.upsertMeta !== null) {
155
+ const dataValue = JSON.parse(plan.upsertMeta.data) as unknown
156
+ await meta.upsert({
157
+ where: { docId },
158
+ create: { docId, data: dataValue },
159
+ update: { data: dataValue },
160
+ })
161
+ }
162
+
163
+ const { row } = plan.insertRecord
164
+ await records.create({
165
+ data: {
166
+ docId,
167
+ seq: plan.insertRecord.seq,
168
+ kind: row.kind,
169
+ payload: row.payload,
170
+ blob: row.blob,
171
+ },
172
+ })
173
+ })
174
+ }
175
+
176
+ async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {
177
+ const rows = await this.#records.findMany({
178
+ where: { docId },
179
+ orderBy: { seq: "asc" },
180
+ })
181
+ for (const r of rows) {
182
+ const row: RowShape = {
183
+ kind: r.kind === "meta" ? "meta" : "entry",
184
+ payload: r.payload as string,
185
+ blob: r.blob ?? null,
186
+ }
187
+ yield fromRow(row)
188
+ }
189
+ }
190
+
191
+ async replace(docId: DocId, records: StoreRecord[]): Promise<void> {
192
+ const existingMeta = await this.currentMeta(docId)
193
+ const plan = planReplace(records, existingMeta)
194
+
195
+ await this.#client.$transaction(async tx => {
196
+ const { meta, records: recordsModel } = this.#txModels(tx)
197
+
198
+ await recordsModel.deleteMany({ where: { docId } })
199
+
200
+ for (const { seq, row } of plan.records) {
201
+ await recordsModel.create({
202
+ data: {
203
+ docId,
204
+ seq,
205
+ kind: row.kind,
206
+ payload: row.payload,
207
+ blob: row.blob,
208
+ },
209
+ })
210
+ }
211
+
212
+ const dataValue = JSON.parse(plan.upsertMeta.data) as unknown
213
+ await meta.upsert({
214
+ where: { docId },
215
+ create: { docId, data: dataValue },
216
+ update: { data: dataValue },
217
+ })
218
+ })
219
+
220
+ // Must run after commit. A `$transaction` rejection (failed COMMIT
221
+ // or callback throw) propagates past this line; cache stays
222
+ // unmutated. Inside the callback would corrupt it on rollback.
223
+ this.#seqNos.reset(docId, records.length - 1)
224
+ }
225
+
226
+ async delete(docId: DocId): Promise<void> {
227
+ await this.#client.$transaction(async tx => {
228
+ const { meta, records } = this.#txModels(tx)
229
+ await records.deleteMany({ where: { docId } })
230
+ await meta.deleteMany({ where: { docId } })
231
+ })
232
+ this.#seqNos.remove(docId)
233
+ }
234
+
235
+ async currentMeta(docId: DocId): Promise<StoreMeta | null> {
236
+ const row = await this.#meta.findUnique({ where: { docId } })
237
+ if (row === null) return null
238
+ return parseMetaData(row.data) as StoreMeta
239
+ }
240
+
241
+ async *listDocIds(prefix?: string): AsyncIterable<DocId> {
242
+ if (prefix === undefined) {
243
+ const rows = await this.#meta.findMany({ select: { docId: true } })
244
+ for (const r of rows) yield r.docId
245
+ return
246
+ }
247
+
248
+ const upper = prefixUpperBound(prefix)
249
+ const rows = await this.#meta.findMany({
250
+ where: {
251
+ docId: upper === null ? { gte: prefix } : { gte: prefix, lt: upper },
252
+ },
253
+ select: { docId: true },
254
+ })
255
+ for (const r of rows) yield r.docId
256
+ }
257
+
258
+ async close(): Promise<void> {
259
+ // Caller owns the lifecycle (`prisma.$disconnect()`).
260
+ }
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Helpers
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /**
268
+ * Prisma's `Json` field arrives parsed on Postgres/MySQL but as a raw
269
+ * string on SQLite — the only place where the underlying database
270
+ * type leaks through Prisma's abstraction.
271
+ */
272
+ function parseMetaData(value: unknown): unknown {
273
+ if (typeof value === "string") return JSON.parse(value)
274
+ return value
275
+ }
276
+
277
+ function prefixUpperBound(prefix: string): string | null {
278
+ if (prefix.length === 0) return null
279
+ const codes = Array.from(prefix)
280
+ for (let i = codes.length - 1; i >= 0; i--) {
281
+ const ch = codes[i] as string
282
+ const code = ch.codePointAt(0) as number
283
+ if (code < 0x10ffff) {
284
+ const next = String.fromCodePoint(code + 1)
285
+ return codes.slice(0, i).join("") + next
286
+ }
287
+ }
288
+ return null
289
+ }
290
+
291
+ /**
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.
295
+ */
296
+ export async function createPrismaStore(
297
+ options: PrismaStoreOptions,
298
+ ): Promise<Store> {
299
+ return new PrismaStore(options)
300
+ }