@lobomfz/db 0.1.0

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