@lobomfz/db 0.2.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,11 +1,11 @@
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
 
7
7
  ```bash
8
- bun add @lobomfz/db
8
+ bun add @lobomfz/db arktype kysely
9
9
  ```
10
10
 
11
11
  ## Usage
@@ -14,88 +14,67 @@ bun add @lobomfz/db
14
14
  import { Database, generated, type } from "@lobomfz/db";
15
15
 
16
16
  const db = new Database({
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
- },
17
+ path: "data.db",
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",
45
+ },
37
46
  });
38
47
 
39
- // Fully typed Kysely client - fields with defaults are optional on insert
48
+ // Fully typed Kysely client generated/default fields are optional on insert
40
49
  await db.kysely.insertInto("users").values({ name: "John", email: "john@example.com" }).execute();
41
50
 
42
51
  const users = await db.kysely.selectFrom("users").selectAll().execute();
52
+ // users[0].active → true
53
+ // users[0].created_at → Date
43
54
  ```
44
55
 
45
- ## Features
46
-
47
- - Tables auto-created from Arktype schemas
48
- - Full TypeScript inference (insert vs select types)
49
- - JSON columns with validation
50
- - Foreign keys, unique constraints, defaults
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
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.
63
57
 
64
- Use Arktype's `.default()` for JS defaults (also creates SQL DEFAULT):
58
+ ## API
65
59
 
66
60
  ```typescript
67
- type("string").default("pending");
68
- type("number").default(0);
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
69
66
  ```
70
67
 
71
- ## Column Configuration
68
+ JSON columns are validated against the schema on write by default. To also validate on read, or to disable write validation:
72
69
 
73
70
  ```typescript
74
- type("string").configure({ unique: true });
75
- type("number.integer").configure({ references: "users.id", onDelete: "cascade" });
76
- ```
77
-
78
- `onDelete` options: `"cascade"`, `"set null"`, `"restrict"`
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
- },
71
+ new Database({
72
+ // ...
73
+ validation: { onRead: true }, // default: { onRead: false, onWrite: true }
91
74
  });
92
75
  ```
93
76
 
94
- ## Errors
95
-
96
- ```typescript
97
- import { JsonParseError, JsonValidationError } from "@lobomfz/db";
98
- ```
77
+ > **Note:** Migrations are not supported yet. Tables are created with `CREATE TABLE IF NOT EXISTS`.
99
78
 
100
79
  ## License
101
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobomfz/db",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Bun SQLite database with Arktype schemas and typed Kysely client",
5
5
  "keywords": [
6
6
  "arktype",
package/src/database.ts CHANGED
@@ -3,6 +3,7 @@ import { Kysely } from "kysely";
3
3
  import { BunSqliteDialect } from "./dialect/dialect";
4
4
  import type { Type } from "arktype";
5
5
  import type { GeneratedPreset } from "./generated";
6
+ import type { DbFieldMeta } from "./env";
6
7
  import { DeserializePlugin, type ColumnCoercion, type ColumnsMap } from "./plugin";
7
8
  import type {
8
9
  DatabaseOptions,
@@ -26,7 +27,7 @@ type StructureProp = {
26
27
  value: Type & {
27
28
  branches: ArkBranch[];
28
29
  proto?: unknown;
29
- meta: Record<string, unknown>;
30
+ meta: DbFieldMeta & { _generated?: GeneratedPreset };
30
31
  };
31
32
  inner: { default?: unknown };
32
33
  };
@@ -41,7 +42,7 @@ type Prop = {
41
42
  isDate?: boolean;
42
43
  isJson?: boolean;
43
44
  jsonSchema?: Type;
44
- meta?: Record<string, unknown>;
45
+ meta?: DbFieldMeta;
45
46
  generated?: GeneratedPreset;
46
47
  defaultValue?: unknown;
47
48
  };
@@ -71,9 +72,14 @@ export class Database<T extends SchemaRecord> {
71
72
 
72
73
  this.createTables();
73
74
 
75
+ const validation = {
76
+ onRead: options.validation?.onRead ?? false,
77
+ onWrite: options.validation?.onWrite ?? true,
78
+ };
79
+
74
80
  this.kysely = new Kysely<TablesFromSchemas<T>>({
75
81
  dialect: new BunSqliteDialect({ database: this.sqlite }),
76
- plugins: [new DeserializePlugin(this.columns)],
82
+ plugins: [new DeserializePlugin(this.columns, validation)],
77
83
  });
78
84
  }
79
85
 
@@ -95,10 +101,10 @@ export class Database<T extends SchemaRecord> {
95
101
  }
96
102
  }
97
103
 
98
- private normalizeProp(structureProp: StructureProp, parentSchema: Type): Prop {
104
+ private normalizeProp(structureProp: StructureProp, parentSchema: Type) {
99
105
  const { key, value: v, inner } = structureProp;
100
- const kind = structureProp.required ? "required" : "optional";
101
- const generated = v.meta._generated as GeneratedPreset | undefined;
106
+ const kind: Prop["kind"] = structureProp.required ? "required" : "optional";
107
+ const generated = v.meta._generated;
102
108
  const defaultValue = inner.default;
103
109
 
104
110
  const nonNull = v.branches.filter((b) => b.unit !== null);
@@ -155,7 +161,7 @@ export class Database<T extends SchemaRecord> {
155
161
  return "TEXT";
156
162
  }
157
163
 
158
- private columnConstraint(prop: Prop): string | null {
164
+ private columnConstraint(prop: Prop) {
159
165
  if (prop.generated === "autoincrement") {
160
166
  return "PRIMARY KEY AUTOINCREMENT";
161
167
  }
@@ -171,7 +177,7 @@ export class Database<T extends SchemaRecord> {
171
177
  return null;
172
178
  }
173
179
 
174
- private defaultClause(prop: Prop): string | null {
180
+ private defaultClause(prop: Prop) {
175
181
  if (prop.generated === "now") {
176
182
  return "DEFAULT (unixepoch())";
177
183
  }
@@ -192,7 +198,7 @@ export class Database<T extends SchemaRecord> {
192
198
  return `DEFAULT ${prop.defaultValue}`;
193
199
  }
194
200
 
195
- return `DEFAULT '${String(prop.defaultValue)}'`;
201
+ throw new Error(`Unsupported default value type: ${typeof prop.defaultValue}`);
196
202
  }
197
203
 
198
204
  private columnDef(prop: Prop) {
@@ -208,7 +214,7 @@ export class Database<T extends SchemaRecord> {
208
214
  }
209
215
 
210
216
  private foreignKey(prop: Prop) {
211
- const ref = prop.meta?.references as string | undefined;
217
+ const ref = prop.meta?.references;
212
218
 
213
219
  if (!ref) {
214
220
  return null;
@@ -218,7 +224,7 @@ export class Database<T extends SchemaRecord> {
218
224
 
219
225
  let fk = `FOREIGN KEY ("${prop.key}") REFERENCES "${table}"("${column}")`;
220
226
 
221
- const onDelete = prop.meta?.onDelete as string | undefined;
227
+ const onDelete = prop.meta?.onDelete;
222
228
 
223
229
  if (onDelete) {
224
230
  fk += ` ON DELETE ${onDelete.toUpperCase()}`;
@@ -1,4 +1,4 @@
1
- import { Database } from "bun:sqlite";
1
+ import type { Database } from "bun:sqlite";
2
2
  import type { CompiledQuery, DatabaseConnection, QueryResult } from "kysely";
3
3
  import { serializeParam } from "./serialize";
4
4
 
@@ -9,30 +9,30 @@ export class BunSqliteConnection implements DatabaseConnection {
9
9
  this.#db = db;
10
10
  }
11
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);
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
16
 
17
17
  if (stmt.columnNames.length > 0) {
18
- return Promise.resolve({
18
+ return {
19
19
  rows: stmt.all(serializedParams as any) as O[],
20
- });
20
+ };
21
21
  }
22
22
 
23
23
  const results = stmt.run(serializedParams as any);
24
24
 
25
- return Promise.resolve({
25
+ return {
26
26
  insertId: BigInt(results.lastInsertRowid),
27
27
  numAffectedRows: BigInt(results.changes),
28
28
  rows: [],
29
- });
29
+ };
30
30
  }
31
31
 
32
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);
33
+ const serializedParams = compiledQuery.parameters.map(serializeParam);
34
+
35
+ const stmt = this.#db.prepare(compiledQuery.sql);
36
36
 
37
37
  for await (const row of stmt.iterate(serializedParams as any)) {
38
38
  yield { rows: [row as R] };
@@ -1,4 +1,4 @@
1
- import { Database } from "bun:sqlite";
1
+ import type { Database } from "bun:sqlite";
2
2
  import { CompiledQuery, type DatabaseConnection, type Driver } from "kysely";
3
3
  import type { BunSqliteDialectConfig } from "./config";
4
4
  import { BunSqliteConnection } from "./connection";
@@ -38,12 +38,10 @@ export class BunSqliteDriver implements Driver {
38
38
  await connection.executeQuery(CompiledQuery.raw("rollback"));
39
39
  }
40
40
 
41
- // oxlint-disable-next-line require-await
42
41
  async releaseConnection(): Promise<void> {
43
42
  this.#connectionMutex.unlock();
44
43
  }
45
44
 
46
- // oxlint-disable-next-line require-await
47
45
  async destroy(): Promise<void> {
48
46
  this.#db?.close();
49
47
  }
package/src/index.ts CHANGED
@@ -14,5 +14,6 @@ export type {
14
14
  IndexesConfig,
15
15
  DatabasePragmas,
16
16
  DatabaseSchema,
17
+ JsonValidation,
17
18
  SqliteMasterRow,
18
19
  } from "./types";
package/src/plugin.ts CHANGED
@@ -1,40 +1,71 @@
1
- import { type KyselyPlugin, type RootOperationNode, type UnknownRow } from "kysely";
1
+ import {
2
+ type KyselyPlugin,
3
+ type RootOperationNode,
4
+ type UnknownRow,
5
+ type QueryId,
6
+ TableNode,
7
+ AliasNode,
8
+ ValuesNode,
9
+ ValueNode,
10
+ ColumnNode,
11
+ } from "kysely";
2
12
  import { type } from "arktype";
3
13
  import type { Type } from "arktype";
4
14
  import { JsonParseError } from "./errors";
5
15
  import { JsonValidationError } from "./validation-error";
16
+ import type { JsonValidation } from "./types";
6
17
 
7
18
  export type ColumnCoercion = "boolean" | "date" | { type: "json"; schema: Type };
8
19
  export type ColumnsMap = Map<string, Map<string, ColumnCoercion>>;
9
20
 
10
21
  export class DeserializePlugin implements KyselyPlugin {
11
- private queryNodes = new Map<unknown, RootOperationNode>();
22
+ private queryNodes = new WeakMap<QueryId, RootOperationNode>();
12
23
 
13
- constructor(private columns: ColumnsMap) {}
24
+ constructor(
25
+ private columns: ColumnsMap,
26
+ private validation: Required<JsonValidation>,
27
+ ) {}
14
28
 
15
29
  transformQuery: KyselyPlugin["transformQuery"] = (args) => {
16
30
  this.queryNodes.set(args.queryId, args.node);
17
31
 
32
+ if (this.validation.onWrite) {
33
+ this.validateWriteNode(args.node);
34
+ }
35
+
18
36
  return args.node;
19
37
  };
20
38
 
21
- private getTableFromNode(node: RootOperationNode): string | null {
39
+ private getTableFromNode(node: RootOperationNode) {
22
40
  switch (node.kind) {
23
41
  case "InsertQueryNode":
24
- return (node as any).into?.table?.identifier?.name ?? null;
42
+ return node.into?.table.identifier.name ?? null;
25
43
 
26
- case "UpdateQueryNode":
27
- return (node as any).table?.table?.identifier?.name ?? null;
44
+ case "UpdateQueryNode": {
45
+ if (node.table && TableNode.is(node.table)) {
46
+ return node.table.table.identifier.name;
47
+ }
48
+
49
+ return null;
50
+ }
28
51
 
29
52
  case "SelectQueryNode":
30
53
  case "DeleteQueryNode": {
31
- const fromNode = (node as any).from?.froms?.[0];
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
+ }
32
63
 
33
- if (fromNode?.kind === "AliasNode") {
34
- return fromNode.node?.table?.identifier?.name ?? null;
64
+ if (TableNode.is(fromNode)) {
65
+ return fromNode.table.identifier.name;
35
66
  }
36
67
 
37
- return fromNode?.table?.identifier?.name ?? null;
68
+ return null;
38
69
  }
39
70
 
40
71
  default:
@@ -42,6 +73,82 @@ export class DeserializePlugin implements KyselyPlugin {
42
73
  }
43
74
  }
44
75
 
76
+ private validateJsonValue(table: string, col: string, value: unknown, schema: Type) {
77
+ if (value === null || value === undefined) {
78
+ return;
79
+ }
80
+
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
+ }
92
+
93
+ const table = this.getTableFromNode(node);
94
+
95
+ if (!table) {
96
+ return;
97
+ }
98
+
99
+ const cols = this.columns.get(table);
100
+
101
+ if (!cols) {
102
+ return;
103
+ }
104
+
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
+ }
151
+
45
152
  private coerceRow(table: string, row: UnknownRow, cols: Map<string, ColumnCoercion>) {
46
153
  for (const [col, coercion] of cols) {
47
154
  if (!(col in row)) {
@@ -78,17 +185,14 @@ export class DeserializePlugin implements KyselyPlugin {
78
185
  throw new JsonParseError(table, col, value, e);
79
186
  }
80
187
 
81
- const validated = coercion.schema(parsed);
82
-
83
- if (validated instanceof type.errors) {
84
- throw new JsonValidationError(table, col, validated.summary);
188
+ if (this.validation.onRead) {
189
+ this.validateJsonValue(table, col, parsed, coercion.schema);
85
190
  }
86
191
 
87
- row[col] = validated;
192
+ row[col] = parsed;
88
193
  }
89
194
  }
90
195
 
91
- // oxlint-disable-next-line require-await
92
196
  transformResult: KyselyPlugin["transformResult"] = async (args) => {
93
197
  const node = this.queryNodes.get(args.queryId);
94
198
 
package/src/types.ts CHANGED
@@ -58,8 +58,14 @@ export type DatabaseSchema<T extends SchemaRecord> = {
58
58
  indexes?: IndexesConfig<T>;
59
59
  };
60
60
 
61
+ export type JsonValidation = {
62
+ onRead?: boolean;
63
+ onWrite?: boolean;
64
+ };
65
+
61
66
  export type DatabaseOptions<T extends SchemaRecord> = {
62
67
  path: string;
63
68
  schema: DatabaseSchema<T>;
64
69
  pragmas?: DatabasePragmas;
70
+ validation?: JsonValidation;
65
71
  };