@lobomfz/db 0.1.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 +68 -0
- package/package.json +50 -0
- package/src/autoincrement.ts +14 -0
- package/src/database.ts +235 -0
- package/src/env.ts +13 -0
- package/src/errors.ts +14 -0
- package/src/index.ts +6 -0
- package/src/plugin.ts +82 -0
- package/src/serializer.ts +19 -0
- package/src/types.ts +34 -0
- package/src/validation-error.ts +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 lobomfz
|
|
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,68 @@
|
|
|
1
|
+
# @lobomfz/db
|
|
2
|
+
|
|
3
|
+
SQLite database with Arktype schemas and typed Kysely client.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @lobomfz/db arktype kysely
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { type } from "arktype";
|
|
15
|
+
import { Database, autoIncrement } from "@lobomfz/db";
|
|
16
|
+
|
|
17
|
+
const db = new Database({
|
|
18
|
+
path: "data.db",
|
|
19
|
+
tables: {
|
|
20
|
+
users: type({
|
|
21
|
+
id: autoIncrement(),
|
|
22
|
+
name: "string",
|
|
23
|
+
email: type("string").configure({ unique: true }),
|
|
24
|
+
"bio?": "string",
|
|
25
|
+
}),
|
|
26
|
+
posts: type({
|
|
27
|
+
id: autoIncrement(),
|
|
28
|
+
user_id: type("number.integer").configure({ references: "users.id", onDelete: "cascade" }),
|
|
29
|
+
title: "string",
|
|
30
|
+
tags: "string[]",
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Fully typed Kysely client
|
|
36
|
+
await db.kysely.insertInto("users").values({ name: "John", email: "john@example.com" }).execute();
|
|
37
|
+
|
|
38
|
+
const users = await db.kysely.selectFrom("users").selectAll().execute();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Tables auto-created from Arktype schemas
|
|
44
|
+
- Full TypeScript inference
|
|
45
|
+
- JSON columns with validation
|
|
46
|
+
- Foreign keys, unique constraints, defaults
|
|
47
|
+
- `autoIncrement()` for primary keys
|
|
48
|
+
|
|
49
|
+
## Column Configuration
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
type("string").configure({ unique: true })
|
|
53
|
+
type("string").configure({ default: "pending" })
|
|
54
|
+
type("number.integer").configure({ default: "now" }) // Unix timestamp
|
|
55
|
+
type("number.integer").configure({ references: "users.id", onDelete: "cascade" })
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`onDelete` options: `"cascade"`, `"set null"`, `"restrict"`
|
|
59
|
+
|
|
60
|
+
## Errors
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { JsonParseError, JsonValidationError } from "@lobomfz/db";
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lobomfz/db",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SQLite database with Arktype schemas and typed Kysely client",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "lobomfz",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/lobomfz/db.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"sqlite",
|
|
13
|
+
"database",
|
|
14
|
+
"arktype",
|
|
15
|
+
"kysely",
|
|
16
|
+
"typescript",
|
|
17
|
+
"bun",
|
|
18
|
+
"schema",
|
|
19
|
+
"validation"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "src/index.ts",
|
|
26
|
+
"types": "src/index.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./src/index.ts",
|
|
30
|
+
"import": "./src/index.ts"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"check": "tsc"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@lobomfz/kysely-bun-sqlite": "^0.4.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
"arktype": "^2.1.29",
|
|
42
|
+
"oxfmt": "^0.26.0",
|
|
43
|
+
"oxlint": "^1.41.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"arktype": "^2.1.0",
|
|
47
|
+
"kysely": "^0.27.0",
|
|
48
|
+
"typescript": "^5"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type, Type } from "arktype";
|
|
2
|
+
|
|
3
|
+
declare const AutoIncrementBrand: unique symbol;
|
|
4
|
+
|
|
5
|
+
export type AutoIncrementNumber = number & { [AutoIncrementBrand]: true };
|
|
6
|
+
|
|
7
|
+
export type AutoIncrementType = Type<AutoIncrementNumber>;
|
|
8
|
+
|
|
9
|
+
export const autoIncrement = (): AutoIncrementType => {
|
|
10
|
+
return type("number.integer").configure({
|
|
11
|
+
primaryKey: true,
|
|
12
|
+
_autoincrement: true,
|
|
13
|
+
} as any) as unknown as AutoIncrementType;
|
|
14
|
+
};
|
package/src/database.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { Database as BunDatabase } from "bun:sqlite";
|
|
2
|
+
import { Kysely } from "kysely";
|
|
3
|
+
import { BunSqliteDialect } from "@lobomfz/kysely-bun-sqlite";
|
|
4
|
+
import type { Type } from "arktype";
|
|
5
|
+
import type { DbFieldMeta } from "./env";
|
|
6
|
+
import { JsonPlugin, type JsonColumnsMap } from "./plugin";
|
|
7
|
+
import type { DatabaseOptions, SchemaRecord, TablesFromSchemas } from "./types";
|
|
8
|
+
|
|
9
|
+
type PropValue =
|
|
10
|
+
| string
|
|
11
|
+
| { unit: unknown }[]
|
|
12
|
+
| {
|
|
13
|
+
domain?: string | { domain: string };
|
|
14
|
+
divisor?: { rule: number };
|
|
15
|
+
meta?: DbFieldMeta;
|
|
16
|
+
proto?: string;
|
|
17
|
+
sequence?: unknown;
|
|
18
|
+
required?: RawProp[];
|
|
19
|
+
optional?: RawProp[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type RawProp = { key: string; value: PropValue };
|
|
23
|
+
type InnerJson = { required?: RawProp[]; optional?: RawProp[] };
|
|
24
|
+
|
|
25
|
+
type Prop = {
|
|
26
|
+
key: string;
|
|
27
|
+
kind: "required" | "optional";
|
|
28
|
+
domain?: string;
|
|
29
|
+
isBoolean?: boolean;
|
|
30
|
+
isInteger?: boolean;
|
|
31
|
+
isJson?: boolean;
|
|
32
|
+
jsonSchema?: Type;
|
|
33
|
+
meta?: DbFieldMeta;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const typeMap: Record<string, string> = {
|
|
37
|
+
string: "TEXT",
|
|
38
|
+
number: "REAL",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export class Database<T extends SchemaRecord> {
|
|
42
|
+
private sqlite: BunDatabase;
|
|
43
|
+
|
|
44
|
+
private jsonColumns: JsonColumnsMap = new Map();
|
|
45
|
+
|
|
46
|
+
readonly infer: TablesFromSchemas<T> = undefined as any;
|
|
47
|
+
|
|
48
|
+
readonly kysely: Kysely<TablesFromSchemas<T>>;
|
|
49
|
+
|
|
50
|
+
constructor(private options: DatabaseOptions<T>) {
|
|
51
|
+
this.sqlite = new BunDatabase(options.path);
|
|
52
|
+
|
|
53
|
+
this.sqlite.run("PRAGMA foreign_keys = ON");
|
|
54
|
+
|
|
55
|
+
this.createTables();
|
|
56
|
+
|
|
57
|
+
this.kysely = new Kysely<TablesFromSchemas<T>>({
|
|
58
|
+
dialect: new BunSqliteDialect({ database: this.sqlite }),
|
|
59
|
+
plugins: [new JsonPlugin(this.jsonColumns)],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private isJsonValue(value: PropValue): boolean {
|
|
64
|
+
if (typeof value === "string" || Array.isArray(value)) return false;
|
|
65
|
+
|
|
66
|
+
if (value.proto === "Array") return true;
|
|
67
|
+
if (value.domain === "object" && (value.required || value.optional)) return true;
|
|
68
|
+
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private normalizeProp(raw: RawProp, kind: "required" | "optional", parentSchema: Type): Prop {
|
|
73
|
+
const { key, value } = raw;
|
|
74
|
+
|
|
75
|
+
if (typeof value === "string") {
|
|
76
|
+
return { key, kind, domain: value };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (Array.isArray(value)) {
|
|
80
|
+
let hasTrue = false;
|
|
81
|
+
let hasFalse = false;
|
|
82
|
+
|
|
83
|
+
for (const u of value) {
|
|
84
|
+
if (u.unit === true) hasTrue = true;
|
|
85
|
+
if (u.unit === false) hasFalse = true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isBool = value.length === 2 && hasTrue && hasFalse;
|
|
89
|
+
|
|
90
|
+
return { key, kind, isBoolean: isBool };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.isJsonValue(value)) {
|
|
94
|
+
return {
|
|
95
|
+
key,
|
|
96
|
+
kind,
|
|
97
|
+
isJson: true,
|
|
98
|
+
jsonSchema: (parentSchema as any).get(key) as Type,
|
|
99
|
+
meta: value.meta,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const domain = typeof value.domain === "string" ? value.domain : value.domain?.domain;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
key,
|
|
107
|
+
kind,
|
|
108
|
+
domain,
|
|
109
|
+
isInteger: value.divisor?.rule === 1,
|
|
110
|
+
meta: value.meta,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private sqlType(prop: Prop) {
|
|
115
|
+
if (prop.isJson) return "TEXT";
|
|
116
|
+
if (prop.isBoolean || prop.isInteger) return "INTEGER";
|
|
117
|
+
if (prop.domain && typeMap[prop.domain]) return typeMap[prop.domain];
|
|
118
|
+
return "TEXT";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private columnDef(prop: Prop) {
|
|
122
|
+
const parts = [`"${prop.key}"`, this.sqlType(prop)];
|
|
123
|
+
const meta = prop.meta;
|
|
124
|
+
|
|
125
|
+
if (meta?.primaryKey) {
|
|
126
|
+
parts.push("PRIMARY KEY");
|
|
127
|
+
if ((meta as any)._autoincrement) parts.push("AUTOINCREMENT");
|
|
128
|
+
} else if (prop.kind === "required") {
|
|
129
|
+
parts.push("NOT NULL");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (meta?.unique) parts.push("UNIQUE");
|
|
133
|
+
|
|
134
|
+
if (meta?.default !== undefined) {
|
|
135
|
+
let val: string | number | boolean;
|
|
136
|
+
|
|
137
|
+
if (meta.default === "now") {
|
|
138
|
+
val = "(unixepoch())";
|
|
139
|
+
} else if (typeof meta.default === "string") {
|
|
140
|
+
val = `'${meta.default}'`;
|
|
141
|
+
} else {
|
|
142
|
+
val = meta.default;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
parts.push(`DEFAULT ${val}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return parts.join(" ");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private foreignKey(prop: Prop) {
|
|
152
|
+
const ref = prop.meta?.references;
|
|
153
|
+
if (!ref) return null;
|
|
154
|
+
|
|
155
|
+
const [table, column] = ref.split(".");
|
|
156
|
+
let fk = `FOREIGN KEY ("${prop.key}") REFERENCES "${table}"("${column}")`;
|
|
157
|
+
|
|
158
|
+
switch (prop.meta?.onDelete) {
|
|
159
|
+
case "cascade":
|
|
160
|
+
fk += " ON DELETE CASCADE";
|
|
161
|
+
break;
|
|
162
|
+
case "set null":
|
|
163
|
+
fk += " ON DELETE SET NULL";
|
|
164
|
+
break;
|
|
165
|
+
case "restrict":
|
|
166
|
+
fk += " ON DELETE RESTRICT";
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return fk;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private parseSchemaProps(schema: Type) {
|
|
174
|
+
const { required = [], optional = [] } = (schema as any).innerJson as InnerJson;
|
|
175
|
+
const props: Prop[] = [];
|
|
176
|
+
|
|
177
|
+
for (const r of required) {
|
|
178
|
+
props.push(this.normalizeProp(r, "required", schema));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const r of optional) {
|
|
182
|
+
props.push(this.normalizeProp(r, "optional", schema));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return props;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private registerJsonColumns(tableName: string, props: Prop[]) {
|
|
189
|
+
const tableJsonCols = new Map<string, { schema: Type }>();
|
|
190
|
+
|
|
191
|
+
for (const prop of props) {
|
|
192
|
+
if (!prop.isJson || !prop.jsonSchema) continue;
|
|
193
|
+
|
|
194
|
+
tableJsonCols.set(prop.key, { schema: prop.jsonSchema });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (tableJsonCols.size === 0) return;
|
|
198
|
+
|
|
199
|
+
this.jsonColumns.set(tableName, tableJsonCols);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private generateCreateTableSQL(tableName: string, props: Prop[]) {
|
|
203
|
+
const columns: string[] = [];
|
|
204
|
+
const fks: string[] = [];
|
|
205
|
+
|
|
206
|
+
for (const prop of props) {
|
|
207
|
+
columns.push(this.columnDef(prop));
|
|
208
|
+
|
|
209
|
+
const fk = this.foreignKey(prop);
|
|
210
|
+
if (fk) fks.push(fk);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return `CREATE TABLE IF NOT EXISTS "${tableName}" (${columns.concat(fks).join(", ")})`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private createTables() {
|
|
217
|
+
for (const [name, schema] of Object.entries(this.options.tables)) {
|
|
218
|
+
const props = this.parseSchemaProps(schema);
|
|
219
|
+
|
|
220
|
+
this.registerJsonColumns(name, props);
|
|
221
|
+
this.sqlite.run(this.generateCreateTableSQL(name, props));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
reset(table?: keyof T & string) {
|
|
226
|
+
const tables = table ? [table] : Object.keys(this.options.tables);
|
|
227
|
+
for (const t of tables) {
|
|
228
|
+
this.sqlite.run(`DELETE FROM "${t}"`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
close() {
|
|
233
|
+
this.sqlite.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type DbFieldMeta = {
|
|
2
|
+
primaryKey?: boolean;
|
|
3
|
+
unique?: boolean;
|
|
4
|
+
references?: `${string}.${string}`;
|
|
5
|
+
onDelete?: "cascade" | "set null" | "restrict";
|
|
6
|
+
default?: "now" | number | string | boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface ArkEnv {
|
|
11
|
+
meta(): DbFieldMeta;
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class JsonParseError extends Error {
|
|
2
|
+
readonly table: string;
|
|
3
|
+
readonly column: string;
|
|
4
|
+
readonly value: string;
|
|
5
|
+
|
|
6
|
+
constructor(table: string, column: string, value: string, cause: unknown) {
|
|
7
|
+
super(`Failed to parse JSON in ${table}.${column}`);
|
|
8
|
+
this.name = "JsonParseError";
|
|
9
|
+
this.table = table;
|
|
10
|
+
this.column = column;
|
|
11
|
+
this.value = value;
|
|
12
|
+
this.cause = cause;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Database } from "./database.ts";
|
|
2
|
+
export { autoIncrement } from "./autoincrement.ts";
|
|
3
|
+
export { JsonParseError } from "./errors.ts";
|
|
4
|
+
export { JsonValidationError } from "./validation-error.ts";
|
|
5
|
+
export type { DbFieldMeta } from "./env.ts";
|
|
6
|
+
export type { DatabaseOptions, SchemaRecord, TablesFromSchemas, InferTableType } from "./types.ts";
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type KyselyPlugin,
|
|
3
|
+
type PluginTransformQueryArgs,
|
|
4
|
+
type PluginTransformResultArgs,
|
|
5
|
+
type QueryResult,
|
|
6
|
+
type RootOperationNode,
|
|
7
|
+
type UnknownRow,
|
|
8
|
+
} from "kysely";
|
|
9
|
+
import { type } from "arktype";
|
|
10
|
+
import type { Type } from "arktype";
|
|
11
|
+
import { JsonSerializer } from "./serializer";
|
|
12
|
+
import { JsonParseError } from "./errors";
|
|
13
|
+
import { JsonValidationError } from "./validation-error";
|
|
14
|
+
|
|
15
|
+
export type JsonColumnInfo = { schema: Type };
|
|
16
|
+
export type JsonColumnsMap = Map<string, Map<string, JsonColumnInfo>>;
|
|
17
|
+
|
|
18
|
+
export class JsonPlugin implements KyselyPlugin {
|
|
19
|
+
private serializer = new JsonSerializer();
|
|
20
|
+
private queryNodes = new Map<unknown, RootOperationNode>();
|
|
21
|
+
|
|
22
|
+
constructor(private jsonColumns: JsonColumnsMap) {}
|
|
23
|
+
|
|
24
|
+
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
|
25
|
+
this.queryNodes.set(args.queryId, args.node);
|
|
26
|
+
return this.serializer.transformNode(args.node);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private getTableFromNode(node: RootOperationNode): string | null {
|
|
30
|
+
switch (node.kind) {
|
|
31
|
+
case "InsertQueryNode":
|
|
32
|
+
case "UpdateQueryNode":
|
|
33
|
+
return (node as any).table?.table?.identifier?.name ?? null;
|
|
34
|
+
|
|
35
|
+
case "SelectQueryNode":
|
|
36
|
+
case "DeleteQueryNode":
|
|
37
|
+
return (node as any).from?.froms?.[0]?.table?.identifier?.name ?? null;
|
|
38
|
+
|
|
39
|
+
default:
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// oxlint-disable-next-line require-await
|
|
45
|
+
async transformResult(args: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
|
|
46
|
+
const node = this.queryNodes.get(args.queryId);
|
|
47
|
+
this.queryNodes.delete(args.queryId);
|
|
48
|
+
|
|
49
|
+
if (!node) return args.result;
|
|
50
|
+
|
|
51
|
+
const table = this.getTableFromNode(node);
|
|
52
|
+
|
|
53
|
+
if (!table) return args.result;
|
|
54
|
+
|
|
55
|
+
const columns = this.jsonColumns.get(table);
|
|
56
|
+
|
|
57
|
+
if (!columns) return args.result;
|
|
58
|
+
|
|
59
|
+
for (const row of args.result.rows) {
|
|
60
|
+
for (const [col, info] of columns.entries()) {
|
|
61
|
+
if (!(col in row) || typeof row[col] !== "string") continue;
|
|
62
|
+
|
|
63
|
+
const value = row[col] as string;
|
|
64
|
+
|
|
65
|
+
let parsed: unknown;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(value);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
throw new JsonParseError(table, col, value, e);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const validated = info.schema(parsed);
|
|
73
|
+
if (validated instanceof type.errors) {
|
|
74
|
+
throw new JsonValidationError(table, col, validated.summary);
|
|
75
|
+
}
|
|
76
|
+
row[col] = validated;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { ...args.result };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { OperationNodeTransformer, type PrimitiveValueListNode } from "kysely";
|
|
2
|
+
|
|
3
|
+
export class JsonSerializer extends OperationNodeTransformer {
|
|
4
|
+
protected override transformPrimitiveValueList(
|
|
5
|
+
node: PrimitiveValueListNode,
|
|
6
|
+
): PrimitiveValueListNode {
|
|
7
|
+
const values: unknown[] = [];
|
|
8
|
+
|
|
9
|
+
for (const v of node.values) {
|
|
10
|
+
if (typeof v === "object" && v !== null && !ArrayBuffer.isView(v)) {
|
|
11
|
+
values.push(JSON.stringify(v));
|
|
12
|
+
} else {
|
|
13
|
+
values.push(v);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return super.transformPrimitiveValueList({ ...node, values });
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Generated } from "kysely";
|
|
2
|
+
import type { Type } from "arktype";
|
|
3
|
+
import type { AutoIncrementNumber } from "./autoincrement";
|
|
4
|
+
|
|
5
|
+
type NullableIf<T, Condition> = Condition extends true ? T | null : T;
|
|
6
|
+
|
|
7
|
+
type IsOptional<T, K extends keyof T> = undefined extends T[K] ? true : false;
|
|
8
|
+
|
|
9
|
+
type TransformColumn<T> = T extends AutoIncrementNumber
|
|
10
|
+
? Generated<number>
|
|
11
|
+
: T extends boolean
|
|
12
|
+
? number
|
|
13
|
+
: T extends (infer U)[]
|
|
14
|
+
? U[]
|
|
15
|
+
: T extends object
|
|
16
|
+
? T
|
|
17
|
+
: T;
|
|
18
|
+
|
|
19
|
+
type TransformTable<T> = {
|
|
20
|
+
[K in keyof T]: NullableIf<TransformColumn<NonNullable<T[K]>>, IsOptional<T, K>>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SchemaRecord = Record<string, Type>;
|
|
24
|
+
|
|
25
|
+
export type InferTableType<T> = T extends Type<infer Output> ? TransformTable<Output> : never;
|
|
26
|
+
|
|
27
|
+
export type TablesFromSchemas<T extends SchemaRecord> = {
|
|
28
|
+
[K in keyof T]: InferTableType<T[K]>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type DatabaseOptions<T extends SchemaRecord> = {
|
|
32
|
+
path: string;
|
|
33
|
+
tables: T;
|
|
34
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class JsonValidationError extends Error {
|
|
2
|
+
readonly table: string;
|
|
3
|
+
readonly column: string;
|
|
4
|
+
readonly summary: string;
|
|
5
|
+
|
|
6
|
+
constructor(table: string, column: string, summary: string) {
|
|
7
|
+
super(`JSON validation failed for ${table}.${column}: ${summary}`);
|
|
8
|
+
this.name = "JsonValidationError";
|
|
9
|
+
this.table = table;
|
|
10
|
+
this.column = column;
|
|
11
|
+
this.summary = summary;
|
|
12
|
+
}
|
|
13
|
+
}
|