@lobomfz/db 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,34 +5,38 @@ SQLite database with Arktype schemas and typed Kysely client.
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- bun add @lobomfz/db arktype kysely
8
+ bun add @lobomfz/db
9
9
  ```
10
10
 
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
- 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
- },
17
+ path: "data.db",
18
+ tables: {
19
+ users: type({
20
+ id: generated("autoincrement"),
21
+ name: "string",
22
+ email: type("string").configure({ unique: true }),
23
+ "bio?": "string",
24
+ created_at: generated("now"),
25
+ }),
26
+ posts: type({
27
+ id: generated("autoincrement"),
28
+ user_id: type("number.integer").configure({ references: "users.id", onDelete: "cascade" }),
29
+ title: "string",
30
+ tags: "string[]",
31
+ status: type("string").default("draft"),
32
+ }),
33
+ },
34
+ indexes: {
35
+ posts: [{ columns: ["user_id", "status"] }, { columns: ["title"], unique: true }],
36
+ },
33
37
  });
34
38
 
35
- // Fully typed Kysely client
39
+ // Fully typed Kysely client - fields with defaults are optional on insert
36
40
  await db.kysely.insertInto("users").values({ name: "John", email: "john@example.com" }).execute();
37
41
 
38
42
  const users = await db.kysely.selectFrom("users").selectAll().execute();
@@ -41,22 +45,52 @@ const users = await db.kysely.selectFrom("users").selectAll().execute();
41
45
  ## Features
42
46
 
43
47
  - Tables auto-created from Arktype schemas
44
- - Full TypeScript inference
48
+ - Full TypeScript inference (insert vs select types)
45
49
  - JSON columns with validation
46
50
  - Foreign keys, unique constraints, defaults
47
- - `autoIncrement()` for primary keys
51
+ - Composite indexes
52
+
53
+ ## Generated Fields
54
+
55
+ Use `generated()` for SQL-generated values:
56
+
57
+ ```typescript
58
+ generated("autoincrement"); // INTEGER PRIMARY KEY AUTOINCREMENT
59
+ generated("now"); // DEFAULT (unixepoch()) - Unix timestamp
60
+ ```
61
+
62
+ ## Default Values
63
+
64
+ Use Arktype's `.default()` for JS defaults (also creates SQL DEFAULT):
65
+
66
+ ```typescript
67
+ type("string").default("pending");
68
+ type("number").default(0);
69
+ ```
48
70
 
49
71
  ## Column Configuration
50
72
 
51
73
  ```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" })
74
+ type("string").configure({ unique: true });
75
+ type("number.integer").configure({ references: "users.id", onDelete: "cascade" });
56
76
  ```
57
77
 
58
78
  `onDelete` options: `"cascade"`, `"set null"`, `"restrict"`
59
79
 
80
+ ## Composite Indexes
81
+
82
+ ```typescript
83
+ const db = new Database({
84
+ tables: { ... },
85
+ indexes: {
86
+ posts: [
87
+ { columns: ["user_id", "category_id"], unique: true },
88
+ { columns: ["created_at"] },
89
+ ],
90
+ },
91
+ });
92
+ ```
93
+
60
94
  ## Errors
61
95
 
62
96
  ```typescript
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.0",
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,49 @@
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 { 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[] };
5
+ import type { GeneratedPreset } from "./generated";
6
+ import { DeserializePlugin, type ColumnCoercion, type ColumnsMap } from "./plugin";
7
+ import type {
8
+ DatabaseOptions,
9
+ IndexDefinition,
10
+ SchemaRecord,
11
+ TablesFromSchemas,
12
+ DatabasePragmas,
13
+ } from "./types";
14
+
15
+ type ArkBranch = {
16
+ domain?: string;
17
+ proto?: unknown;
18
+ unit?: unknown;
19
+ structure?: unknown;
20
+ inner?: { divisor?: unknown };
21
+ };
22
+
23
+ type StructureProp = {
24
+ key: string;
25
+ required: boolean;
26
+ value: Type & {
27
+ branches: ArkBranch[];
28
+ proto?: unknown;
29
+ meta: Record<string, unknown>;
30
+ };
31
+ inner: { default?: unknown };
32
+ };
24
33
 
25
34
  type Prop = {
26
35
  key: string;
27
36
  kind: "required" | "optional";
28
37
  domain?: string;
38
+ nullable?: boolean;
29
39
  isBoolean?: boolean;
30
40
  isInteger?: boolean;
41
+ isDate?: boolean;
31
42
  isJson?: boolean;
32
43
  jsonSchema?: Type;
33
- meta?: DbFieldMeta;
44
+ meta?: Record<string, unknown>;
45
+ generated?: GeneratedPreset;
46
+ defaultValue?: unknown;
34
47
  };
35
48
 
36
49
  const typeMap: Record<string, string> = {
@@ -38,10 +51,14 @@ const typeMap: Record<string, string> = {
38
51
  number: "REAL",
39
52
  };
40
53
 
54
+ const defaultPragmas: DatabasePragmas = {
55
+ foreign_keys: true,
56
+ };
57
+
41
58
  export class Database<T extends SchemaRecord> {
42
59
  private sqlite: BunDatabase;
43
60
 
44
- private jsonColumns: JsonColumnsMap = new Map();
61
+ private columns: ColumnsMap = new Map();
45
62
 
46
63
  readonly infer: TablesFromSchemas<T> = undefined as any;
47
64
 
@@ -50,153 +67,198 @@ export class Database<T extends SchemaRecord> {
50
67
  constructor(private options: DatabaseOptions<T>) {
51
68
  this.sqlite = new BunDatabase(options.path);
52
69
 
53
- this.sqlite.run("PRAGMA foreign_keys = ON");
70
+ this.applyPragmas();
54
71
 
55
72
  this.createTables();
56
73
 
57
74
  this.kysely = new Kysely<TablesFromSchemas<T>>({
58
75
  dialect: new BunSqliteDialect({ database: this.sqlite }),
59
- plugins: [new JsonPlugin(this.jsonColumns)],
76
+ plugins: [new DeserializePlugin(this.columns)],
60
77
  });
61
78
  }
62
79
 
63
- private isJsonValue(value: PropValue): boolean {
64
- if (typeof value === "string" || Array.isArray(value)) return false;
80
+ private applyPragmas() {
81
+ const pragmas = { ...defaultPragmas, ...this.options.pragmas };
65
82
 
66
- if (value.proto === "Array") return true;
67
- if (value.domain === "object" && (value.required || value.optional)) return true;
83
+ if (pragmas.journal_mode) {
84
+ this.sqlite.run(`PRAGMA journal_mode = ${pragmas.journal_mode.toUpperCase()}`);
85
+ }
68
86
 
69
- return false;
70
- }
87
+ if (pragmas.synchronous) {
88
+ this.sqlite.run(`PRAGMA synchronous = ${pragmas.synchronous.toUpperCase()}`);
89
+ }
71
90
 
72
- private normalizeProp(raw: RawProp, kind: "required" | "optional", parentSchema: Type): Prop {
73
- const { key, value } = raw;
91
+ this.sqlite.run(`PRAGMA foreign_keys = ${pragmas.foreign_keys ? "ON" : "OFF"}`);
74
92
 
75
- if (typeof value === "string") {
76
- return { key, kind, domain: value };
93
+ if (pragmas.busy_timeout_ms !== undefined) {
94
+ this.sqlite.run(`PRAGMA busy_timeout = ${pragmas.busy_timeout_ms}`);
77
95
  }
96
+ }
78
97
 
79
- if (Array.isArray(value)) {
80
- let hasTrue = false;
81
- let hasFalse = false;
98
+ private normalizeProp(structureProp: StructureProp, parentSchema: Type): Prop {
99
+ const { key, value: v, inner } = structureProp;
100
+ const kind = structureProp.required ? "required" : "optional";
101
+ const generated = v.meta._generated as GeneratedPreset | undefined;
102
+ const defaultValue = inner.default;
82
103
 
83
- for (const u of value) {
84
- if (u.unit === true) hasTrue = true;
85
- if (u.unit === false) hasFalse = true;
86
- }
104
+ const nonNull = v.branches.filter((b) => b.unit !== null);
105
+ const nullable = nonNull.length < v.branches.length;
87
106
 
88
- const isBool = value.length === 2 && hasTrue && hasFalse;
107
+ if (v.proto === Date || nonNull.some((b) => b.proto === Date)) {
108
+ return { key, kind, nullable, isDate: true, generated, defaultValue };
109
+ }
89
110
 
90
- return { key, kind, isBoolean: isBool };
111
+ if (nonNull.length > 0 && nonNull.every((b) => b.domain === "boolean")) {
112
+ return { key, kind, nullable, isBoolean: true, generated, defaultValue };
91
113
  }
92
114
 
93
- if (this.isJsonValue(value)) {
115
+ if (nonNull.some((b) => !!b.structure)) {
94
116
  return {
95
117
  key,
96
118
  kind,
119
+ nullable,
97
120
  isJson: true,
98
121
  jsonSchema: (parentSchema as any).get(key) as Type,
99
- meta: value.meta,
122
+ meta: v.meta,
123
+ generated,
124
+ defaultValue,
100
125
  };
101
126
  }
102
127
 
103
- const domain = typeof value.domain === "string" ? value.domain : value.domain?.domain;
128
+ const branch = nonNull[0];
104
129
 
105
130
  return {
106
131
  key,
107
132
  kind,
108
- domain,
109
- isInteger: value.divisor?.rule === 1,
110
- meta: value.meta,
133
+ nullable,
134
+ domain: branch?.domain,
135
+ isInteger: !!branch?.inner?.divisor,
136
+ meta: v.meta,
137
+ generated,
138
+ defaultValue,
111
139
  };
112
140
  }
113
141
 
114
142
  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];
143
+ if (prop.isJson) {
144
+ return "TEXT";
145
+ }
146
+
147
+ if (prop.isDate || prop.isBoolean || prop.isInteger) {
148
+ return "INTEGER";
149
+ }
150
+
151
+ if (prop.domain) {
152
+ return typeMap[prop.domain] ?? "TEXT";
153
+ }
154
+
118
155
  return "TEXT";
119
156
  }
120
157
 
121
- private columnDef(prop: Prop) {
122
- const parts = [`"${prop.key}"`, this.sqlType(prop)];
123
- const meta = prop.meta;
158
+ private columnConstraint(prop: Prop): string | null {
159
+ if (prop.generated === "autoincrement") {
160
+ return "PRIMARY KEY AUTOINCREMENT";
161
+ }
124
162
 
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");
163
+ if (prop.meta?.primaryKey) {
164
+ return "PRIMARY KEY";
130
165
  }
131
166
 
132
- if (meta?.unique) parts.push("UNIQUE");
167
+ if (prop.kind === "required" && !prop.nullable) {
168
+ return "NOT NULL";
169
+ }
133
170
 
134
- if (meta?.default !== undefined) {
135
- let val: string | number | boolean;
171
+ return null;
172
+ }
136
173
 
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
- }
174
+ private defaultClause(prop: Prop): string | null {
175
+ if (prop.generated === "now") {
176
+ return "DEFAULT (unixepoch())";
177
+ }
144
178
 
145
- parts.push(`DEFAULT ${val}`);
179
+ if (prop.defaultValue === undefined || prop.generated === "autoincrement") {
180
+ return null;
146
181
  }
147
182
 
148
- return parts.join(" ");
183
+ if (prop.defaultValue === null) {
184
+ return "DEFAULT NULL";
185
+ }
186
+
187
+ if (typeof prop.defaultValue === "string") {
188
+ return `DEFAULT '${prop.defaultValue}'`;
189
+ }
190
+
191
+ if (typeof prop.defaultValue === "number" || typeof prop.defaultValue === "boolean") {
192
+ return `DEFAULT ${prop.defaultValue}`;
193
+ }
194
+
195
+ return `DEFAULT '${String(prop.defaultValue)}'`;
196
+ }
197
+
198
+ private columnDef(prop: Prop) {
199
+ return [
200
+ `"${prop.key}"`,
201
+ this.sqlType(prop),
202
+ this.columnConstraint(prop),
203
+ prop.meta?.unique ? "UNIQUE" : null,
204
+ this.defaultClause(prop),
205
+ ]
206
+ .filter(Boolean)
207
+ .join(" ");
149
208
  }
150
209
 
151
210
  private foreignKey(prop: Prop) {
152
- const ref = prop.meta?.references;
153
- if (!ref) return null;
211
+ const ref = prop.meta?.references as string | undefined;
212
+
213
+ if (!ref) {
214
+ return null;
215
+ }
154
216
 
155
217
  const [table, column] = ref.split(".");
218
+
156
219
  let fk = `FOREIGN KEY ("${prop.key}") REFERENCES "${table}"("${column}")`;
157
220
 
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;
221
+ const onDelete = prop.meta?.onDelete as string | undefined;
222
+
223
+ if (onDelete) {
224
+ fk += ` ON DELETE ${onDelete.toUpperCase()}`;
168
225
  }
169
226
 
170
227
  return fk;
171
228
  }
172
229
 
173
230
  private parseSchemaProps(schema: Type) {
174
- const { required = [], optional = [] } = (schema as any).innerJson as InnerJson;
175
- const props: Prop[] = [];
231
+ const structureProps = (schema as any).structure?.props as StructureProp[] | undefined;
176
232
 
177
- for (const r of required) {
178
- props.push(this.normalizeProp(r, "required", schema));
233
+ if (!structureProps) {
234
+ return [];
179
235
  }
180
236
 
181
- for (const r of optional) {
182
- props.push(this.normalizeProp(r, "optional", schema));
183
- }
184
-
185
- return props;
237
+ return structureProps.map((p) => this.normalizeProp(p, schema));
186
238
  }
187
239
 
188
- private registerJsonColumns(tableName: string, props: Prop[]) {
189
- const tableJsonCols = new Map<string, { schema: Type }>();
240
+ private registerColumns(tableName: string, props: Prop[]) {
241
+ const colMap = new Map<string, ColumnCoercion>();
190
242
 
191
243
  for (const prop of props) {
192
- if (!prop.isJson || !prop.jsonSchema) continue;
244
+ if (prop.isBoolean) {
245
+ colMap.set(prop.key, "boolean");
246
+ continue;
247
+ }
193
248
 
194
- tableJsonCols.set(prop.key, { schema: prop.jsonSchema });
195
- }
249
+ if (prop.isDate) {
250
+ colMap.set(prop.key, "date");
251
+ continue;
252
+ }
196
253
 
197
- if (tableJsonCols.size === 0) return;
254
+ if (prop.isJson && prop.jsonSchema) {
255
+ colMap.set(prop.key, { type: "json", schema: prop.jsonSchema });
256
+ }
257
+ }
198
258
 
199
- this.jsonColumns.set(tableName, tableJsonCols);
259
+ if (colMap.size > 0) {
260
+ this.columns.set(tableName, colMap);
261
+ }
200
262
  }
201
263
 
202
264
  private generateCreateTableSQL(tableName: string, props: Prop[]) {
@@ -207,29 +269,64 @@ export class Database<T extends SchemaRecord> {
207
269
  columns.push(this.columnDef(prop));
208
270
 
209
271
  const fk = this.foreignKey(prop);
210
- if (fk) fks.push(fk);
272
+
273
+ if (fk) {
274
+ fks.push(fk);
275
+ }
211
276
  }
212
277
 
213
278
  return `CREATE TABLE IF NOT EXISTS "${tableName}" (${columns.concat(fks).join(", ")})`;
214
279
  }
215
280
 
216
281
  private createTables() {
217
- for (const [name, schema] of Object.entries(this.options.tables)) {
282
+ for (const [name, schema] of Object.entries(this.options.schema.tables)) {
218
283
  const props = this.parseSchemaProps(schema);
219
284
 
220
- this.registerJsonColumns(name, props);
285
+ this.registerColumns(name, props);
221
286
  this.sqlite.run(this.generateCreateTableSQL(name, props));
222
287
  }
288
+
289
+ this.createIndexes();
223
290
  }
224
291
 
225
- reset(table?: keyof T & string) {
226
- const tables = table ? [table] : Object.keys(this.options.tables);
292
+ private generateIndexName(tableName: string, columns: string[], unique: boolean) {
293
+ const prefix = unique ? "ux" : "ix";
294
+
295
+ return `${prefix}_${tableName}_${columns.join("_")}`;
296
+ }
297
+
298
+ private generateCreateIndexSQL(tableName: string, indexDef: IndexDefinition) {
299
+ const indexName = this.generateIndexName(tableName, indexDef.columns, indexDef.unique ?? false);
300
+ const unique = indexDef.unique ? "UNIQUE " : "";
301
+ const columns = indexDef.columns.map((c) => `"${c}"`).join(", ");
302
+
303
+ return `CREATE ${unique}INDEX IF NOT EXISTS "${indexName}" ON "${tableName}" (${columns})`;
304
+ }
305
+
306
+ private createIndexes() {
307
+ const indexes = this.options.schema.indexes;
308
+
309
+ if (!indexes) {
310
+ return;
311
+ }
312
+
313
+ for (const [tableName, tableIndexes] of Object.entries(indexes)) {
314
+ if (!tableIndexes) {
315
+ continue;
316
+ }
317
+
318
+ for (const indexDef of tableIndexes) {
319
+ this.sqlite.run(this.generateCreateIndexSQL(tableName, indexDef));
320
+ }
321
+ }
322
+ }
323
+
324
+ reset(table?: keyof T & string): void {
325
+ const tables = table ? [table] : Object.keys(this.options.schema.tables);
326
+
227
327
  for (const t of tables) {
228
328
  this.sqlite.run(`DELETE FROM "${t}"`);
229
329
  }
230
330
  }
231
331
 
232
- close() {
233
- this.sqlite.close();
234
- }
235
332
  }
@@ -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 { 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
+ executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> {
13
+ const { sql, parameters } = compiledQuery;
14
+ const serializedParams = parameters.map(serializeParam);
15
+ const stmt = this.#db.query(sql);
16
+
17
+ if (stmt.columnNames.length > 0) {
18
+ return Promise.resolve({
19
+ rows: stmt.all(serializedParams as any) as O[],
20
+ });
21
+ }
22
+
23
+ const results = stmt.run(serializedParams as any);
24
+
25
+ return Promise.resolve({
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 { sql, parameters } = compiledQuery;
34
+ const serializedParams = parameters.map(serializeParam);
35
+ const stmt = this.#db.prepare(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,50 @@
1
+ import { 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
+ // oxlint-disable-next-line require-await
42
+ async releaseConnection(): Promise<void> {
43
+ this.#connectionMutex.unlock();
44
+ }
45
+
46
+ // oxlint-disable-next-line require-await
47
+ async destroy(): Promise<void> {
48
+ this.#db?.close();
49
+ }
50
+ }
@@ -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,18 @@
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
+ SqliteMasterRow,
18
+ } from "./types";
package/src/plugin.ts CHANGED
@@ -1,82 +1,117 @@
1
- import {
2
- type KyselyPlugin,
3
- type PluginTransformQueryArgs,
4
- type PluginTransformResultArgs,
5
- type QueryResult,
6
- type RootOperationNode,
7
- type UnknownRow,
8
- } from "kysely";
1
+ import { type KyselyPlugin, type RootOperationNode, type UnknownRow } from "kysely";
9
2
  import { type } from "arktype";
10
3
  import type { Type } from "arktype";
11
- import { JsonSerializer } from "./serializer";
12
4
  import { JsonParseError } from "./errors";
13
5
  import { JsonValidationError } from "./validation-error";
14
6
 
15
- export type JsonColumnInfo = { schema: Type };
16
- export type JsonColumnsMap = Map<string, Map<string, JsonColumnInfo>>;
7
+ export type ColumnCoercion = "boolean" | "date" | { type: "json"; schema: Type };
8
+ export type ColumnsMap = Map<string, Map<string, ColumnCoercion>>;
17
9
 
18
- export class JsonPlugin implements KyselyPlugin {
19
- private serializer = new JsonSerializer();
10
+ export class DeserializePlugin implements KyselyPlugin {
20
11
  private queryNodes = new Map<unknown, RootOperationNode>();
21
12
 
22
- constructor(private jsonColumns: JsonColumnsMap) {}
13
+ constructor(private columns: ColumnsMap) {}
23
14
 
24
- transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
15
+ transformQuery: KyselyPlugin["transformQuery"] = (args) => {
25
16
  this.queryNodes.set(args.queryId, args.node);
26
- return this.serializer.transformNode(args.node);
27
- }
17
+
18
+ return args.node;
19
+ };
28
20
 
29
21
  private getTableFromNode(node: RootOperationNode): string | null {
30
22
  switch (node.kind) {
31
23
  case "InsertQueryNode":
24
+ return (node as any).into?.table?.identifier?.name ?? null;
25
+
32
26
  case "UpdateQueryNode":
33
27
  return (node as any).table?.table?.identifier?.name ?? null;
34
28
 
35
29
  case "SelectQueryNode":
36
- case "DeleteQueryNode":
37
- return (node as any).from?.froms?.[0]?.table?.identifier?.name ?? null;
30
+ case "DeleteQueryNode": {
31
+ const fromNode = (node as any).from?.froms?.[0];
32
+
33
+ if (fromNode?.kind === "AliasNode") {
34
+ return fromNode.node?.table?.identifier?.name ?? null;
35
+ }
36
+
37
+ return fromNode?.table?.identifier?.name ?? null;
38
+ }
38
39
 
39
40
  default:
40
41
  return null;
41
42
  }
42
43
  }
43
44
 
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);
45
+ private coerceRow(table: string, row: UnknownRow, cols: Map<string, ColumnCoercion>) {
46
+ for (const [col, coercion] of cols) {
47
+ if (!(col in row)) {
48
+ continue;
49
+ }
48
50
 
49
- if (!node) return args.result;
51
+ if (coercion === "boolean") {
52
+ if (typeof row[col] === "number") {
53
+ row[col] = row[col] === 1;
54
+ }
50
55
 
51
- const table = this.getTableFromNode(node);
56
+ continue;
57
+ }
52
58
 
53
- if (!table) return args.result;
59
+ if (coercion === "date") {
60
+ if (typeof row[col] === "number") {
61
+ row[col] = new Date(row[col] * 1000);
62
+ }
54
63
 
55
- const columns = this.jsonColumns.get(table);
64
+ continue;
65
+ }
56
66
 
57
- if (!columns) return args.result;
67
+ if (typeof row[col] !== "string") {
68
+ continue;
69
+ }
58
70
 
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;
71
+ const value = row[col];
62
72
 
63
- const value = row[col] as string;
73
+ let parsed: unknown;
64
74
 
65
- let parsed: unknown;
66
- try {
67
- parsed = JSON.parse(value);
68
- } catch (e) {
69
- throw new JsonParseError(table, col, value, e);
70
- }
75
+ try {
76
+ parsed = JSON.parse(value);
77
+ } catch (e) {
78
+ throw new JsonParseError(table, col, value, e);
79
+ }
71
80
 
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;
81
+ const validated = coercion.schema(parsed);
82
+
83
+ if (validated instanceof type.errors) {
84
+ throw new JsonValidationError(table, col, validated.summary);
77
85
  }
86
+
87
+ row[col] = validated;
88
+ }
89
+ }
90
+
91
+ // oxlint-disable-next-line require-await
92
+ transformResult: KyselyPlugin["transformResult"] = async (args) => {
93
+ const node = this.queryNodes.get(args.queryId);
94
+
95
+ if (!node) {
96
+ return args.result;
97
+ }
98
+
99
+ const table = this.getTableFromNode(node);
100
+
101
+ if (!table) {
102
+ return args.result;
103
+ }
104
+
105
+ const cols = this.columns.get(table);
106
+
107
+ if (!cols) {
108
+ return args.result;
109
+ }
110
+
111
+ for (const row of args.result.rows) {
112
+ this.coerceRow(table, row, cols);
78
113
  }
79
114
 
80
115
  return { ...args.result };
81
- }
116
+ };
82
117
  }
package/src/types.ts CHANGED
@@ -1,34 +1,65 @@
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;
18
-
19
- type TransformTable<T> = {
20
- [K in keyof T]: NullableIf<TransformColumn<NonNullable<T[K]>>, IsOptional<T, K>>;
9
+ type TransformColumn<T> = T extends (infer U)[] ? U[] : T;
10
+
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>;
29
59
  };
30
60
 
31
61
  export type DatabaseOptions<T extends SchemaRecord> = {
32
62
  path: string;
33
- tables: T;
63
+ schema: DatabaseSchema<T>;
64
+ pragmas?: DatabasePragmas;
34
65
  };
@@ -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
- }