@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 +21 -0
- package/README.md +81 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/schema.prisma.example +26 -0
- package/src/__tests__/prisma-store.test.ts +306 -0
- package/src/index.ts +300 -0
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).
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|