@lobomfz/db 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @lobomfz/db
2
2
 
3
- SQLite database with Arktype schemas and typed Kysely client.
3
+ SQLite database with Arktype schemas and typed Kysely client for Bun.
4
4
 
5
5
  ## Install
6
6
 
@@ -11,58 +11,71 @@ bun add @lobomfz/db arktype kysely
11
11
  ## Usage
12
12
 
13
13
  ```typescript
14
- import { type } from "arktype";
15
- import { Database, autoIncrement } from "@lobomfz/db";
14
+ import { Database, generated, type } from "@lobomfz/db";
16
15
 
17
16
  const db = new Database({
18
17
  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
- }),
18
+ schema: {
19
+ tables: {
20
+ users: type({
21
+ id: generated("autoincrement"),
22
+ name: "string",
23
+ email: type("string").configure({ unique: true }),
24
+ "bio?": "string", // optional → nullable in SQLite
25
+ active: type("boolean").default(true),
26
+ created_at: generated("now"), // defaults to current time
27
+ }),
28
+ posts: type({
29
+ id: generated("autoincrement"),
30
+ user_id: type("number.integer").configure({ references: "users.id", onDelete: "cascade" }),
31
+ title: "string",
32
+ published_at: "Date", // native Date support
33
+ tags: "string[]", // JSON columns just work
34
+ metadata: type({ source: "string", "priority?": "number" }), // validated on write by default
35
+ status: type.enumerated("draft", "published").default("draft"),
36
+ }),
37
+ },
38
+ indexes: {
39
+ posts: [{ columns: ["user_id", "status"] }, { columns: ["title"], unique: true }],
40
+ },
41
+ },
42
+ pragmas: {
43
+ journal_mode: "wal",
44
+ synchronous: "normal",
32
45
  },
33
46
  });
34
47
 
35
- // Fully typed Kysely client
48
+ // Fully typed Kysely client — generated/default fields are optional on insert
36
49
  await db.kysely.insertInto("users").values({ name: "John", email: "john@example.com" }).execute();
37
50
 
38
51
  const users = await db.kysely.selectFrom("users").selectAll().execute();
52
+ // users[0].active → true
53
+ // users[0].created_at → Date
39
54
  ```
40
55
 
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
56
+ Booleans, dates, objects, arrays — everything round-trips as the type you declared. The schema is the source of truth for table creation, TypeScript types, and runtime coercion.
48
57
 
49
- ## Column Configuration
58
+ ## API
50
59
 
51
60
  ```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" })
61
+ generated("autoincrement"); // auto-incrementing primary key
62
+ generated("now"); // defaults to current timestamp, returned as Date
63
+ type("string").default("pending"); // SQL DEFAULT
64
+ type("string").configure({ unique: true }); // UNIQUE
65
+ type("number.integer").configure({ references: "users.id", onDelete: "cascade" }); // FK
56
66
  ```
57
67
 
58
- `onDelete` options: `"cascade"`, `"set null"`, `"restrict"`
59
-
60
- ## Errors
68
+ JSON columns are validated against the schema on write by default. To also validate on read, or to disable write validation:
61
69
 
62
70
  ```typescript
63
- import { JsonParseError, JsonValidationError } from "@lobomfz/db";
71
+ new Database({
72
+ // ...
73
+ validation: { onRead: true }, // default: { onRead: false, onWrite: true }
74
+ });
64
75
  ```
65
76
 
77
+ > **Note:** Migrations are not supported yet. Tables are created with `CREATE TABLE IF NOT EXISTS`.
78
+
66
79
  ## License
67
80
 
68
81
  MIT
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
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
- },
3
+ "version": "0.2.1",
4
+ "description": "Bun SQLite database with Arktype schemas and typed Kysely client",
11
5
  "keywords": [
12
- "sqlite",
13
- "database",
14
6
  "arktype",
15
- "kysely",
16
- "typescript",
17
7
  "bun",
8
+ "database",
9
+ "kysely",
18
10
  "schema",
11
+ "sqlite",
12
+ "typescript",
19
13
  "validation"
20
14
  ],
15
+ "license": "MIT",
16
+ "author": "lobomfz",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/lobomfz/db.git"
20
+ },
21
21
  "files": [
22
22
  "src"
23
23
  ],
@@ -25,26 +25,20 @@
25
25
  "main": "src/index.ts",
26
26
  "types": "src/index.ts",
27
27
  "exports": {
28
- ".": {
29
- "types": "./src/index.ts",
30
- "import": "./src/index.ts"
31
- }
28
+ ".": "./src/index.ts"
32
29
  },
33
30
  "scripts": {
34
- "check": "tsc"
35
- },
36
- "dependencies": {
37
- "@lobomfz/kysely-bun-sqlite": "^0.4.1"
31
+ "check": "tsgo && oxlint"
38
32
  },
33
+ "dependencies": {},
39
34
  "devDependencies": {
40
- "@types/bun": "latest",
41
- "arktype": "^2.1.29",
42
- "oxfmt": "^0.26.0",
43
- "oxlint": "^1.41.0"
35
+ "@types/bun": "^1.3.11",
36
+ "@typescript/native-preview": "^7.0.0-dev.20260324.1",
37
+ "oxfmt": "^0.42.0",
38
+ "oxlint": "^1.57.0"
44
39
  },
45
40
  "peerDependencies": {
46
- "arktype": "^2.1.0",
47
- "kysely": "^0.27.0",
48
- "typescript": "^5"
41
+ "arktype": "^2.1.29",
42
+ "kysely": "^0.28.14"
49
43
  }
50
44
  }
package/src/database.ts CHANGED
@@ -1,36 +1,50 @@
1
1
  import { Database as BunDatabase } from "bun:sqlite";
2
2
  import { Kysely } from "kysely";
3
- import { BunSqliteDialect } from "@lobomfz/kysely-bun-sqlite";
3
+ import { BunSqliteDialect } from "./dialect/dialect";
4
4
  import type { Type } from "arktype";
5
+ import type { GeneratedPreset } from "./generated";
5
6
  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[] };
7
+ import { DeserializePlugin, type ColumnCoercion, type ColumnsMap } from "./plugin";
8
+ import type {
9
+ DatabaseOptions,
10
+ IndexDefinition,
11
+ SchemaRecord,
12
+ TablesFromSchemas,
13
+ DatabasePragmas,
14
+ } from "./types";
15
+
16
+ type ArkBranch = {
17
+ domain?: string;
18
+ proto?: unknown;
19
+ unit?: unknown;
20
+ structure?: unknown;
21
+ inner?: { divisor?: unknown };
22
+ };
23
+
24
+ type StructureProp = {
25
+ key: string;
26
+ required: boolean;
27
+ value: Type & {
28
+ branches: ArkBranch[];
29
+ proto?: unknown;
30
+ meta: DbFieldMeta & { _generated?: GeneratedPreset };
31
+ };
32
+ inner: { default?: unknown };
33
+ };
24
34
 
25
35
  type Prop = {
26
36
  key: string;
27
37
  kind: "required" | "optional";
28
38
  domain?: string;
39
+ nullable?: boolean;
29
40
  isBoolean?: boolean;
30
41
  isInteger?: boolean;
42
+ isDate?: boolean;
31
43
  isJson?: boolean;
32
44
  jsonSchema?: Type;
33
45
  meta?: DbFieldMeta;
46
+ generated?: GeneratedPreset;
47
+ defaultValue?: unknown;
34
48
  };
35
49
 
36
50
  const typeMap: Record<string, string> = {
@@ -38,10 +52,14 @@ const typeMap: Record<string, string> = {
38
52
  number: "REAL",
39
53
  };
40
54
 
55
+ const defaultPragmas: DatabasePragmas = {
56
+ foreign_keys: true,
57
+ };
58
+
41
59
  export class Database<T extends SchemaRecord> {
42
60
  private sqlite: BunDatabase;
43
61
 
44
- private jsonColumns: JsonColumnsMap = new Map();
62
+ private columns: ColumnsMap = new Map();
45
63
 
46
64
  readonly infer: TablesFromSchemas<T> = undefined as any;
47
65
 
@@ -50,153 +68,203 @@ export class Database<T extends SchemaRecord> {
50
68
  constructor(private options: DatabaseOptions<T>) {
51
69
  this.sqlite = new BunDatabase(options.path);
52
70
 
53
- this.sqlite.run("PRAGMA foreign_keys = ON");
71
+ this.applyPragmas();
54
72
 
55
73
  this.createTables();
56
74
 
75
+ const validation = {
76
+ onRead: options.validation?.onRead ?? false,
77
+ onWrite: options.validation?.onWrite ?? true,
78
+ };
79
+
57
80
  this.kysely = new Kysely<TablesFromSchemas<T>>({
58
81
  dialect: new BunSqliteDialect({ database: this.sqlite }),
59
- plugins: [new JsonPlugin(this.jsonColumns)],
82
+ plugins: [new DeserializePlugin(this.columns, validation)],
60
83
  });
61
84
  }
62
85
 
63
- private isJsonValue(value: PropValue): boolean {
64
- if (typeof value === "string" || Array.isArray(value)) return false;
86
+ private applyPragmas() {
87
+ const pragmas = { ...defaultPragmas, ...this.options.pragmas };
65
88
 
66
- if (value.proto === "Array") return true;
67
- if (value.domain === "object" && (value.required || value.optional)) return true;
89
+ if (pragmas.journal_mode) {
90
+ this.sqlite.run(`PRAGMA journal_mode = ${pragmas.journal_mode.toUpperCase()}`);
91
+ }
68
92
 
69
- return false;
70
- }
93
+ if (pragmas.synchronous) {
94
+ this.sqlite.run(`PRAGMA synchronous = ${pragmas.synchronous.toUpperCase()}`);
95
+ }
71
96
 
72
- private normalizeProp(raw: RawProp, kind: "required" | "optional", parentSchema: Type): Prop {
73
- const { key, value } = raw;
97
+ this.sqlite.run(`PRAGMA foreign_keys = ${pragmas.foreign_keys ? "ON" : "OFF"}`);
74
98
 
75
- if (typeof value === "string") {
76
- return { key, kind, domain: value };
99
+ if (pragmas.busy_timeout_ms !== undefined) {
100
+ this.sqlite.run(`PRAGMA busy_timeout = ${pragmas.busy_timeout_ms}`);
77
101
  }
102
+ }
78
103
 
79
- if (Array.isArray(value)) {
80
- let hasTrue = false;
81
- let hasFalse = false;
104
+ private normalizeProp(structureProp: StructureProp, parentSchema: Type) {
105
+ const { key, value: v, inner } = structureProp;
106
+ const kind: Prop["kind"] = structureProp.required ? "required" : "optional";
107
+ const generated = v.meta._generated;
108
+ const defaultValue = inner.default;
82
109
 
83
- for (const u of value) {
84
- if (u.unit === true) hasTrue = true;
85
- if (u.unit === false) hasFalse = true;
86
- }
110
+ const nonNull = v.branches.filter((b) => b.unit !== null);
111
+ const nullable = nonNull.length < v.branches.length;
87
112
 
88
- const isBool = value.length === 2 && hasTrue && hasFalse;
113
+ if (v.proto === Date || nonNull.some((b) => b.proto === Date)) {
114
+ return { key, kind, nullable, isDate: true, generated, defaultValue };
115
+ }
89
116
 
90
- return { key, kind, isBoolean: isBool };
117
+ if (nonNull.length > 0 && nonNull.every((b) => b.domain === "boolean")) {
118
+ return { key, kind, nullable, isBoolean: true, generated, defaultValue };
91
119
  }
92
120
 
93
- if (this.isJsonValue(value)) {
121
+ if (nonNull.some((b) => !!b.structure)) {
94
122
  return {
95
123
  key,
96
124
  kind,
125
+ nullable,
97
126
  isJson: true,
98
127
  jsonSchema: (parentSchema as any).get(key) as Type,
99
- meta: value.meta,
128
+ meta: v.meta,
129
+ generated,
130
+ defaultValue,
100
131
  };
101
132
  }
102
133
 
103
- const domain = typeof value.domain === "string" ? value.domain : value.domain?.domain;
134
+ const branch = nonNull[0];
104
135
 
105
136
  return {
106
137
  key,
107
138
  kind,
108
- domain,
109
- isInteger: value.divisor?.rule === 1,
110
- meta: value.meta,
139
+ nullable,
140
+ domain: branch?.domain,
141
+ isInteger: !!branch?.inner?.divisor,
142
+ meta: v.meta,
143
+ generated,
144
+ defaultValue,
111
145
  };
112
146
  }
113
147
 
114
148
  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];
149
+ if (prop.isJson) {
150
+ return "TEXT";
151
+ }
152
+
153
+ if (prop.isDate || prop.isBoolean || prop.isInteger) {
154
+ return "INTEGER";
155
+ }
156
+
157
+ if (prop.domain) {
158
+ return typeMap[prop.domain] ?? "TEXT";
159
+ }
160
+
118
161
  return "TEXT";
119
162
  }
120
163
 
121
- private columnDef(prop: Prop) {
122
- const parts = [`"${prop.key}"`, this.sqlType(prop)];
123
- const meta = prop.meta;
164
+ private columnConstraint(prop: Prop) {
165
+ if (prop.generated === "autoincrement") {
166
+ return "PRIMARY KEY AUTOINCREMENT";
167
+ }
124
168
 
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");
169
+ if (prop.meta?.primaryKey) {
170
+ return "PRIMARY KEY";
130
171
  }
131
172
 
132
- if (meta?.unique) parts.push("UNIQUE");
173
+ if (prop.kind === "required" && !prop.nullable) {
174
+ return "NOT NULL";
175
+ }
133
176
 
134
- if (meta?.default !== undefined) {
135
- let val: string | number | boolean;
177
+ return null;
178
+ }
136
179
 
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
- }
180
+ private defaultClause(prop: Prop) {
181
+ if (prop.generated === "now") {
182
+ return "DEFAULT (unixepoch())";
183
+ }
184
+
185
+ if (prop.defaultValue === undefined || prop.generated === "autoincrement") {
186
+ return null;
187
+ }
188
+
189
+ if (prop.defaultValue === null) {
190
+ return "DEFAULT NULL";
191
+ }
192
+
193
+ if (typeof prop.defaultValue === "string") {
194
+ return `DEFAULT '${prop.defaultValue}'`;
195
+ }
144
196
 
145
- parts.push(`DEFAULT ${val}`);
197
+ if (typeof prop.defaultValue === "number" || typeof prop.defaultValue === "boolean") {
198
+ return `DEFAULT ${prop.defaultValue}`;
146
199
  }
147
200
 
148
- return parts.join(" ");
201
+ throw new Error(`Unsupported default value type: ${typeof prop.defaultValue}`);
202
+ }
203
+
204
+ private columnDef(prop: Prop) {
205
+ return [
206
+ `"${prop.key}"`,
207
+ this.sqlType(prop),
208
+ this.columnConstraint(prop),
209
+ prop.meta?.unique ? "UNIQUE" : null,
210
+ this.defaultClause(prop),
211
+ ]
212
+ .filter(Boolean)
213
+ .join(" ");
149
214
  }
150
215
 
151
216
  private foreignKey(prop: Prop) {
152
217
  const ref = prop.meta?.references;
153
- if (!ref) return null;
218
+
219
+ if (!ref) {
220
+ return null;
221
+ }
154
222
 
155
223
  const [table, column] = ref.split(".");
224
+
156
225
  let fk = `FOREIGN KEY ("${prop.key}") REFERENCES "${table}"("${column}")`;
157
226
 
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;
227
+ const onDelete = prop.meta?.onDelete;
228
+
229
+ if (onDelete) {
230
+ fk += ` ON DELETE ${onDelete.toUpperCase()}`;
168
231
  }
169
232
 
170
233
  return fk;
171
234
  }
172
235
 
173
236
  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
- }
237
+ const structureProps = (schema as any).structure?.props as StructureProp[] | undefined;
180
238
 
181
- for (const r of optional) {
182
- props.push(this.normalizeProp(r, "optional", schema));
239
+ if (!structureProps) {
240
+ return [];
183
241
  }
184
242
 
185
- return props;
243
+ return structureProps.map((p) => this.normalizeProp(p, schema));
186
244
  }
187
245
 
188
- private registerJsonColumns(tableName: string, props: Prop[]) {
189
- const tableJsonCols = new Map<string, { schema: Type }>();
246
+ private registerColumns(tableName: string, props: Prop[]) {
247
+ const colMap = new Map<string, ColumnCoercion>();
190
248
 
191
249
  for (const prop of props) {
192
- if (!prop.isJson || !prop.jsonSchema) continue;
250
+ if (prop.isBoolean) {
251
+ colMap.set(prop.key, "boolean");
252
+ continue;
253
+ }
193
254
 
194
- tableJsonCols.set(prop.key, { schema: prop.jsonSchema });
195
- }
255
+ if (prop.isDate) {
256
+ colMap.set(prop.key, "date");
257
+ continue;
258
+ }
196
259
 
197
- if (tableJsonCols.size === 0) return;
260
+ if (prop.isJson && prop.jsonSchema) {
261
+ colMap.set(prop.key, { type: "json", schema: prop.jsonSchema });
262
+ }
263
+ }
198
264
 
199
- this.jsonColumns.set(tableName, tableJsonCols);
265
+ if (colMap.size > 0) {
266
+ this.columns.set(tableName, colMap);
267
+ }
200
268
  }
201
269
 
202
270
  private generateCreateTableSQL(tableName: string, props: Prop[]) {
@@ -207,29 +275,64 @@ export class Database<T extends SchemaRecord> {
207
275
  columns.push(this.columnDef(prop));
208
276
 
209
277
  const fk = this.foreignKey(prop);
210
- if (fk) fks.push(fk);
278
+
279
+ if (fk) {
280
+ fks.push(fk);
281
+ }
211
282
  }
212
283
 
213
284
  return `CREATE TABLE IF NOT EXISTS "${tableName}" (${columns.concat(fks).join(", ")})`;
214
285
  }
215
286
 
216
287
  private createTables() {
217
- for (const [name, schema] of Object.entries(this.options.tables)) {
288
+ for (const [name, schema] of Object.entries(this.options.schema.tables)) {
218
289
  const props = this.parseSchemaProps(schema);
219
290
 
220
- this.registerJsonColumns(name, props);
291
+ this.registerColumns(name, props);
221
292
  this.sqlite.run(this.generateCreateTableSQL(name, props));
222
293
  }
294
+
295
+ this.createIndexes();
296
+ }
297
+
298
+ private generateIndexName(tableName: string, columns: string[], unique: boolean) {
299
+ const prefix = unique ? "ux" : "ix";
300
+
301
+ return `${prefix}_${tableName}_${columns.join("_")}`;
302
+ }
303
+
304
+ private generateCreateIndexSQL(tableName: string, indexDef: IndexDefinition) {
305
+ const indexName = this.generateIndexName(tableName, indexDef.columns, indexDef.unique ?? false);
306
+ const unique = indexDef.unique ? "UNIQUE " : "";
307
+ const columns = indexDef.columns.map((c) => `"${c}"`).join(", ");
308
+
309
+ return `CREATE ${unique}INDEX IF NOT EXISTS "${indexName}" ON "${tableName}" (${columns})`;
310
+ }
311
+
312
+ private createIndexes() {
313
+ const indexes = this.options.schema.indexes;
314
+
315
+ if (!indexes) {
316
+ return;
317
+ }
318
+
319
+ for (const [tableName, tableIndexes] of Object.entries(indexes)) {
320
+ if (!tableIndexes) {
321
+ continue;
322
+ }
323
+
324
+ for (const indexDef of tableIndexes) {
325
+ this.sqlite.run(this.generateCreateIndexSQL(tableName, indexDef));
326
+ }
327
+ }
223
328
  }
224
329
 
225
- reset(table?: keyof T & string) {
226
- const tables = table ? [table] : Object.keys(this.options.tables);
330
+ reset(table?: keyof T & string): void {
331
+ const tables = table ? [table] : Object.keys(this.options.schema.tables);
332
+
227
333
  for (const t of tables) {
228
334
  this.sqlite.run(`DELETE FROM "${t}"`);
229
335
  }
230
336
  }
231
337
 
232
- close() {
233
- this.sqlite.close();
234
- }
235
338
  }
@@ -0,0 +1,7 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { DatabaseConnection } from "kysely";
3
+
4
+ export interface BunSqliteDialectConfig {
5
+ database: Database;
6
+ onCreateConnection?: (connection: DatabaseConnection) => Promise<void>;
7
+ }
@@ -0,0 +1,41 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { CompiledQuery, DatabaseConnection, QueryResult } from "kysely";
3
+ import { serializeParam } from "./serialize";
4
+
5
+ export class BunSqliteConnection implements DatabaseConnection {
6
+ readonly #db: Database;
7
+
8
+ constructor(db: Database) {
9
+ this.#db = db;
10
+ }
11
+
12
+ async executeQuery<O>(compiled: CompiledQuery): Promise<QueryResult<O>> {
13
+ const serializedParams = compiled.parameters.map(serializeParam);
14
+
15
+ const stmt = this.#db.query(compiled.sql);
16
+
17
+ if (stmt.columnNames.length > 0) {
18
+ return {
19
+ rows: stmt.all(serializedParams as any) as O[],
20
+ };
21
+ }
22
+
23
+ const results = stmt.run(serializedParams as any);
24
+
25
+ return {
26
+ insertId: BigInt(results.lastInsertRowid),
27
+ numAffectedRows: BigInt(results.changes),
28
+ rows: [],
29
+ };
30
+ }
31
+
32
+ async *streamQuery<R>(compiledQuery: CompiledQuery): AsyncIterableIterator<QueryResult<R>> {
33
+ const serializedParams = compiledQuery.parameters.map(serializeParam);
34
+
35
+ const stmt = this.#db.prepare(compiledQuery.sql);
36
+
37
+ for await (const row of stmt.iterate(serializedParams as any)) {
38
+ yield { rows: [row as R] };
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,37 @@
1
+ import {
2
+ SqliteAdapter,
3
+ SqliteIntrospector,
4
+ SqliteQueryCompiler,
5
+ type DatabaseIntrospector,
6
+ type Dialect,
7
+ type DialectAdapter,
8
+ type Driver,
9
+ type Kysely,
10
+ type QueryCompiler,
11
+ } from "kysely";
12
+ import type { BunSqliteDialectConfig } from "./config";
13
+ import { BunSqliteDriver } from "./driver";
14
+
15
+ export class BunSqliteDialect implements Dialect {
16
+ readonly #config: BunSqliteDialectConfig;
17
+
18
+ constructor(config: BunSqliteDialectConfig) {
19
+ this.#config = { ...config };
20
+ }
21
+
22
+ createDriver(): Driver {
23
+ return new BunSqliteDriver(this.#config);
24
+ }
25
+
26
+ createQueryCompiler(): QueryCompiler {
27
+ return new SqliteQueryCompiler();
28
+ }
29
+
30
+ createAdapter(): DialectAdapter {
31
+ return new SqliteAdapter();
32
+ }
33
+
34
+ createIntrospector(db: Kysely<any>): DatabaseIntrospector {
35
+ return new SqliteIntrospector(db);
36
+ }
37
+ }
@@ -0,0 +1,48 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { CompiledQuery, type DatabaseConnection, type Driver } from "kysely";
3
+ import type { BunSqliteDialectConfig } from "./config";
4
+ import { BunSqliteConnection } from "./connection";
5
+ import { ConnectionMutex } from "./mutex";
6
+
7
+ export class BunSqliteDriver implements Driver {
8
+ readonly #config: BunSqliteDialectConfig;
9
+ readonly #connectionMutex = new ConnectionMutex();
10
+
11
+ #db?: Database;
12
+ #connection?: DatabaseConnection;
13
+
14
+ constructor(config: BunSqliteDialectConfig) {
15
+ this.#config = { ...config };
16
+ }
17
+
18
+ async init(): Promise<void> {
19
+ this.#db = this.#config.database;
20
+ this.#connection = new BunSqliteConnection(this.#db);
21
+ await this.#config.onCreateConnection?.(this.#connection);
22
+ }
23
+
24
+ async acquireConnection(): Promise<DatabaseConnection> {
25
+ await this.#connectionMutex.lock();
26
+ return this.#connection!;
27
+ }
28
+
29
+ async beginTransaction(connection: DatabaseConnection): Promise<void> {
30
+ await connection.executeQuery(CompiledQuery.raw("begin"));
31
+ }
32
+
33
+ async commitTransaction(connection: DatabaseConnection): Promise<void> {
34
+ await connection.executeQuery(CompiledQuery.raw("commit"));
35
+ }
36
+
37
+ async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
38
+ await connection.executeQuery(CompiledQuery.raw("rollback"));
39
+ }
40
+
41
+ async releaseConnection(): Promise<void> {
42
+ this.#connectionMutex.unlock();
43
+ }
44
+
45
+ async destroy(): Promise<void> {
46
+ this.#db?.close();
47
+ }
48
+ }
@@ -0,0 +1,23 @@
1
+ export class ConnectionMutex {
2
+ #promise?: Promise<void>;
3
+ #resolve?: () => void;
4
+
5
+ async lock(): Promise<void> {
6
+ while (this.#promise) {
7
+ await this.#promise;
8
+ }
9
+
10
+ this.#promise = new Promise((resolve) => {
11
+ this.#resolve = resolve;
12
+ });
13
+ }
14
+
15
+ unlock(): void {
16
+ const resolve = this.#resolve;
17
+
18
+ this.#promise = undefined;
19
+ this.#resolve = undefined;
20
+
21
+ resolve?.();
22
+ }
23
+ }
@@ -0,0 +1,11 @@
1
+ export function serializeParam(p: unknown): unknown {
2
+ if (p instanceof Date) {
3
+ return Math.floor(p.getTime() / 1000);
4
+ }
5
+
6
+ if (typeof p === "object" && p !== null && !ArrayBuffer.isView(p)) {
7
+ return JSON.stringify(p);
8
+ }
9
+
10
+ return p;
11
+ }
package/src/env.ts CHANGED
@@ -3,7 +3,6 @@ export type DbFieldMeta = {
3
3
  unique?: boolean;
4
4
  references?: `${string}.${string}`;
5
5
  onDelete?: "cascade" | "set null" | "restrict";
6
- default?: "now" | number | string | boolean;
7
6
  };
8
7
 
9
8
  declare global {
@@ -0,0 +1,18 @@
1
+ import { type } from "arktype";
2
+
3
+ export type GeneratedPreset = "autoincrement" | "now";
4
+
5
+ const generatedTypes = {
6
+ autoincrement: () =>
7
+ type("number.integer")
8
+ .configure({ _generated: "autoincrement" } as any)
9
+ .default(0),
10
+ now: () =>
11
+ type("Date")
12
+ .configure({ _generated: "now" } as any)
13
+ .default(() => new Date(0)),
14
+ };
15
+
16
+ export function generated<P extends GeneratedPreset>(preset: P): ReturnType<(typeof generatedTypes)[P]> {
17
+ return generatedTypes[preset]() as ReturnType<(typeof generatedTypes)[P]>;
18
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,19 @@
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";
1
+ export { Database } from "./database";
2
+ export { generated, type GeneratedPreset } from "./generated";
3
+ export { JsonParseError } from "./errors";
4
+ export { sql, type Selectable, type Insertable, type Updateable } from "kysely";
5
+ export { type } from "arktype";
6
+ export { JsonValidationError } from "./validation-error";
7
+ export type { DbFieldMeta } from "./env";
8
+ export type {
9
+ DatabaseOptions,
10
+ SchemaRecord,
11
+ TablesFromSchemas,
12
+ InferTableType,
13
+ IndexDefinition,
14
+ IndexesConfig,
15
+ DatabasePragmas,
16
+ DatabaseSchema,
17
+ JsonValidation,
18
+ SqliteMasterRow,
19
+ } from "./types";
package/src/plugin.ts CHANGED
@@ -1,82 +1,221 @@
1
1
  import {
2
2
  type KyselyPlugin,
3
- type PluginTransformQueryArgs,
4
- type PluginTransformResultArgs,
5
- type QueryResult,
6
3
  type RootOperationNode,
7
4
  type UnknownRow,
5
+ type QueryId,
6
+ TableNode,
7
+ AliasNode,
8
+ ValuesNode,
9
+ ValueNode,
10
+ ColumnNode,
8
11
  } from "kysely";
9
12
  import { type } from "arktype";
10
13
  import type { Type } from "arktype";
11
- import { JsonSerializer } from "./serializer";
12
14
  import { JsonParseError } from "./errors";
13
15
  import { JsonValidationError } from "./validation-error";
16
+ import type { JsonValidation } from "./types";
14
17
 
15
- export type JsonColumnInfo = { schema: Type };
16
- export type JsonColumnsMap = Map<string, Map<string, JsonColumnInfo>>;
18
+ export type ColumnCoercion = "boolean" | "date" | { type: "json"; schema: Type };
19
+ export type ColumnsMap = Map<string, Map<string, ColumnCoercion>>;
17
20
 
18
- export class JsonPlugin implements KyselyPlugin {
19
- private serializer = new JsonSerializer();
20
- private queryNodes = new Map<unknown, RootOperationNode>();
21
+ export class DeserializePlugin implements KyselyPlugin {
22
+ private queryNodes = new WeakMap<QueryId, RootOperationNode>();
21
23
 
22
- constructor(private jsonColumns: JsonColumnsMap) {}
24
+ constructor(
25
+ private columns: ColumnsMap,
26
+ private validation: Required<JsonValidation>,
27
+ ) {}
23
28
 
24
- transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
29
+ transformQuery: KyselyPlugin["transformQuery"] = (args) => {
25
30
  this.queryNodes.set(args.queryId, args.node);
26
- return this.serializer.transformNode(args.node);
27
- }
28
31
 
29
- private getTableFromNode(node: RootOperationNode): string | null {
32
+ if (this.validation.onWrite) {
33
+ this.validateWriteNode(args.node);
34
+ }
35
+
36
+ return args.node;
37
+ };
38
+
39
+ private getTableFromNode(node: RootOperationNode) {
30
40
  switch (node.kind) {
31
41
  case "InsertQueryNode":
32
- case "UpdateQueryNode":
33
- return (node as any).table?.table?.identifier?.name ?? null;
42
+ return node.into?.table.identifier.name ?? null;
43
+
44
+ case "UpdateQueryNode": {
45
+ if (node.table && TableNode.is(node.table)) {
46
+ return node.table.table.identifier.name;
47
+ }
48
+
49
+ return null;
50
+ }
34
51
 
35
52
  case "SelectQueryNode":
36
- case "DeleteQueryNode":
37
- return (node as any).from?.froms?.[0]?.table?.identifier?.name ?? null;
53
+ case "DeleteQueryNode": {
54
+ const fromNode = node.from?.froms[0];
55
+
56
+ if (!fromNode) {
57
+ return null;
58
+ }
59
+
60
+ if (AliasNode.is(fromNode) && TableNode.is(fromNode.node)) {
61
+ return fromNode.node.table.identifier.name;
62
+ }
63
+
64
+ if (TableNode.is(fromNode)) {
65
+ return fromNode.table.identifier.name;
66
+ }
67
+
68
+ return null;
69
+ }
38
70
 
39
71
  default:
40
72
  return null;
41
73
  }
42
74
  }
43
75
 
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);
76
+ private validateJsonValue(table: string, col: string, value: unknown, schema: Type) {
77
+ if (value === null || value === undefined) {
78
+ return;
79
+ }
48
80
 
49
- if (!node) return args.result;
81
+ const result = schema(value);
82
+
83
+ if (result instanceof type.errors) {
84
+ throw new JsonValidationError(table, col, result.summary);
85
+ }
86
+ }
87
+
88
+ private validateWriteNode(node: RootOperationNode) {
89
+ if (node.kind !== "InsertQueryNode" && node.kind !== "UpdateQueryNode") {
90
+ return;
91
+ }
50
92
 
51
93
  const table = this.getTableFromNode(node);
52
94
 
53
- if (!table) return args.result;
95
+ if (!table) {
96
+ return;
97
+ }
54
98
 
55
- const columns = this.jsonColumns.get(table);
99
+ const cols = this.columns.get(table);
56
100
 
57
- if (!columns) return args.result;
101
+ if (!cols) {
102
+ return;
103
+ }
58
104
 
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;
105
+ for (const [col, value] of this.writeValues(node)) {
106
+ const coercion = cols.get(col);
107
+
108
+ if (!coercion || typeof coercion === "string") {
109
+ continue;
110
+ }
111
+
112
+ this.validateJsonValue(table, col, value, coercion.schema);
113
+ }
114
+ }
115
+
116
+ private *writeValues(node: RootOperationNode) {
117
+ if (node.kind === "InsertQueryNode") {
118
+ const columns = node.columns?.map((c) => c.column.name);
119
+
120
+ if (!columns || !node.values || !ValuesNode.is(node.values)) {
121
+ return;
122
+ }
123
+
124
+ for (const valueList of node.values.values) {
125
+ for (let i = 0; i < columns.length; i++) {
126
+ const col = columns[i]!;
127
+
128
+ if (valueList.kind === "PrimitiveValueListNode") {
129
+ yield [col, valueList.values[i]] as [string, unknown];
130
+ continue;
131
+ }
132
+
133
+ const raw = valueList.values[i];
134
+ yield [col, raw && ValueNode.is(raw) ? raw.value : raw] as [string, unknown];
135
+ }
136
+ }
137
+
138
+ return;
139
+ }
140
+
141
+ if (node.kind !== "UpdateQueryNode" || !node.updates) {
142
+ return;
143
+ }
144
+
145
+ for (const update of node.updates) {
146
+ if (ColumnNode.is(update.column) && ValueNode.is(update.value)) {
147
+ yield [update.column.column.name, update.value.value] as [string, unknown];
148
+ }
149
+ }
150
+ }
62
151
 
63
- const value = row[col] as string;
152
+ private coerceRow(table: string, row: UnknownRow, cols: Map<string, ColumnCoercion>) {
153
+ for (const [col, coercion] of cols) {
154
+ if (!(col in row)) {
155
+ continue;
156
+ }
64
157
 
65
- let parsed: unknown;
66
- try {
67
- parsed = JSON.parse(value);
68
- } catch (e) {
69
- throw new JsonParseError(table, col, value, e);
158
+ if (coercion === "boolean") {
159
+ if (typeof row[col] === "number") {
160
+ row[col] = row[col] === 1;
70
161
  }
71
162
 
72
- const validated = info.schema(parsed);
73
- if (validated instanceof type.errors) {
74
- throw new JsonValidationError(table, col, validated.summary);
163
+ continue;
164
+ }
165
+
166
+ if (coercion === "date") {
167
+ if (typeof row[col] === "number") {
168
+ row[col] = new Date(row[col] * 1000);
75
169
  }
76
- row[col] = validated;
170
+
171
+ continue;
172
+ }
173
+
174
+ if (typeof row[col] !== "string") {
175
+ continue;
176
+ }
177
+
178
+ const value = row[col];
179
+
180
+ let parsed: unknown;
181
+
182
+ try {
183
+ parsed = JSON.parse(value);
184
+ } catch (e) {
185
+ throw new JsonParseError(table, col, value, e);
77
186
  }
187
+
188
+ if (this.validation.onRead) {
189
+ this.validateJsonValue(table, col, parsed, coercion.schema);
190
+ }
191
+
192
+ row[col] = parsed;
193
+ }
194
+ }
195
+
196
+ transformResult: KyselyPlugin["transformResult"] = async (args) => {
197
+ const node = this.queryNodes.get(args.queryId);
198
+
199
+ if (!node) {
200
+ return args.result;
201
+ }
202
+
203
+ const table = this.getTableFromNode(node);
204
+
205
+ if (!table) {
206
+ return args.result;
207
+ }
208
+
209
+ const cols = this.columns.get(table);
210
+
211
+ if (!cols) {
212
+ return args.result;
213
+ }
214
+
215
+ for (const row of args.result.rows) {
216
+ this.coerceRow(table, row, cols);
78
217
  }
79
218
 
80
219
  return { ...args.result };
81
- }
220
+ };
82
221
  }
package/src/types.ts CHANGED
@@ -1,34 +1,71 @@
1
1
  import type { Generated } from "kysely";
2
2
  import type { Type } from "arktype";
3
- import type { AutoIncrementNumber } from "./autoincrement";
4
3
 
5
- type NullableIf<T, Condition> = Condition extends true ? T | null : T;
4
+ type ExtractInput<T> = T extends { inferIn: infer I } ? I : never;
5
+ type ExtractOutput<T> = T extends { infer: infer O } ? O : never;
6
6
 
7
7
  type IsOptional<T, K extends keyof T> = undefined extends T[K] ? true : false;
8
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;
9
+ type TransformColumn<T> = T extends (infer U)[] ? U[] : T;
18
10
 
19
- type TransformTable<T> = {
20
- [K in keyof T]: NullableIf<TransformColumn<NonNullable<T[K]>>, IsOptional<T, K>>;
11
+ type TransformField<TOutput, TInput, K extends keyof TOutput & keyof TInput> =
12
+ IsOptional<TInput, K> extends true
13
+ ? IsOptional<TOutput, K> extends true
14
+ ? TransformColumn<NonNullable<TOutput[K]>> | null
15
+ : Generated<TransformColumn<NonNullable<TOutput[K]>>>
16
+ : TransformColumn<TOutput[K]>;
17
+
18
+ type TransformTable<TOutput, TInput> = {
19
+ [K in keyof TOutput & keyof TInput]: TransformField<TOutput, TInput, K>;
21
20
  };
22
21
 
23
22
  export type SchemaRecord = Record<string, Type>;
24
23
 
25
- export type InferTableType<T> = T extends Type<infer Output> ? TransformTable<Output> : never;
24
+ export type InferTableType<T> = TransformTable<ExtractOutput<T>, ExtractInput<T>>;
25
+
26
+ export type SqliteMasterRow = {
27
+ type: "table" | "index" | "view" | "trigger";
28
+ name: string;
29
+ tbl_name: string;
30
+ rootpage: number;
31
+ sql: string | null;
32
+ };
26
33
 
27
34
  export type TablesFromSchemas<T extends SchemaRecord> = {
28
35
  [K in keyof T]: InferTableType<T[K]>;
36
+ } & { sqlite_master: SqliteMasterRow };
37
+
38
+ type TableColumns<T extends SchemaRecord, K extends keyof T> = keyof ExtractOutput<T[K]> & string;
39
+
40
+ export type IndexDefinition<Columns extends string = string> = {
41
+ columns: Columns[];
42
+ unique?: boolean;
43
+ };
44
+
45
+ export type IndexesConfig<T extends SchemaRecord> = {
46
+ [K in keyof T]?: IndexDefinition<TableColumns<T, K>>[];
47
+ };
48
+
49
+ export type DatabasePragmas = {
50
+ journal_mode?: "delete" | "truncate" | "persist" | "memory" | "wal" | "off";
51
+ synchronous?: "off" | "normal" | "full" | "extra";
52
+ foreign_keys?: boolean;
53
+ busy_timeout_ms?: number;
54
+ };
55
+
56
+ export type DatabaseSchema<T extends SchemaRecord> = {
57
+ tables: T;
58
+ indexes?: IndexesConfig<T>;
59
+ };
60
+
61
+ export type JsonValidation = {
62
+ onRead?: boolean;
63
+ onWrite?: boolean;
29
64
  };
30
65
 
31
66
  export type DatabaseOptions<T extends SchemaRecord> = {
32
67
  path: string;
33
- tables: T;
68
+ schema: DatabaseSchema<T>;
69
+ pragmas?: DatabasePragmas;
70
+ validation?: JsonValidation;
34
71
  };
@@ -1,13 +1,11 @@
1
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) {
2
+ constructor(
3
+ readonly table: string,
4
+ readonly column: string,
5
+ readonly summary: string,
6
+ ) {
7
7
  super(`JSON validation failed for ${table}.${column}: ${summary}`);
8
+
8
9
  this.name = "JsonValidationError";
9
- this.table = table;
10
- this.column = column;
11
- this.summary = summary;
12
10
  }
13
11
  }
@@ -1,14 +0,0 @@
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/serializer.ts DELETED
@@ -1,19 +0,0 @@
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
- }