@pikku/kysely-bun-sqlite 0.12.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/CHANGELOG.md +9 -0
- package/dist/src/bun-sqlite-adapter.d.ts +8 -0
- package/dist/src/bun-sqlite-adapter.js +47 -0
- package/dist/src/coercion-plugin.d.ts +24 -0
- package/dist/src/coercion-plugin.js +92 -0
- package/dist/src/create-bun-sqlite-kysely.d.ts +10 -0
- package/dist/src/create-bun-sqlite-kysely.js +15 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +29 -0
- package/run-tests.sh +54 -0
- package/src/bun-sqlite-adapter.test.ts +176 -0
- package/src/bun-sqlite-adapter.ts +51 -0
- package/src/coercion-plugin.test.ts +99 -0
- package/src/coercion-plugin.ts +106 -0
- package/src/create-bun-sqlite-kysely.test.ts +45 -0
- package/src/create-bun-sqlite-kysely.ts +31 -0
- package/src/index.ts +11 -0
- package/tsconfig.json +13 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @pikku/kysely-bun-sqlite
|
|
2
|
+
|
|
3
|
+
## 0.12.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d5c3c85: feat: bun first-class support — new `@pikku/bun-server` runtime and `@pikku/kysely-bun-sqlite` dialect, bun template, CI matrix with `package-manager: [yarn, bun]`, and bun verifier.
|
|
8
|
+
- Updated dependencies [92cd5b1]
|
|
9
|
+
- @pikku/kysely@0.12.17
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import type { SqliteDatabase, SqliteStatement } from 'kysely';
|
|
3
|
+
export declare class BunSqliteDatabase implements SqliteDatabase {
|
|
4
|
+
private readonly db;
|
|
5
|
+
constructor(db: Database);
|
|
6
|
+
prepare(sql: string): SqliteStatement;
|
|
7
|
+
close(): void;
|
|
8
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
function coerce(v) {
|
|
2
|
+
if (v === null || v === undefined)
|
|
3
|
+
return null;
|
|
4
|
+
if (typeof v === 'boolean')
|
|
5
|
+
return v ? 1 : 0;
|
|
6
|
+
if (v instanceof Date)
|
|
7
|
+
return v.toISOString();
|
|
8
|
+
if (typeof v === 'object')
|
|
9
|
+
return JSON.stringify(v);
|
|
10
|
+
return v;
|
|
11
|
+
}
|
|
12
|
+
class BunSqliteStatement {
|
|
13
|
+
stmt;
|
|
14
|
+
constructor(stmt) {
|
|
15
|
+
this.stmt = stmt;
|
|
16
|
+
}
|
|
17
|
+
get reader() {
|
|
18
|
+
return this.stmt.columnNames.length > 0;
|
|
19
|
+
}
|
|
20
|
+
all(parameters) {
|
|
21
|
+
return this.stmt.all(...parameters.map(coerce));
|
|
22
|
+
}
|
|
23
|
+
*iterate(parameters) {
|
|
24
|
+
for (const row of this.stmt.iterate(...parameters.map(coerce))) {
|
|
25
|
+
yield row;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
run(parameters) {
|
|
29
|
+
const result = this.stmt.run(...parameters.map(coerce));
|
|
30
|
+
return {
|
|
31
|
+
changes: result.changes,
|
|
32
|
+
lastInsertRowid: result.lastInsertRowid,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class BunSqliteDatabase {
|
|
37
|
+
db;
|
|
38
|
+
constructor(db) {
|
|
39
|
+
this.db = db;
|
|
40
|
+
}
|
|
41
|
+
prepare(sql) {
|
|
42
|
+
return new BunSqliteStatement(this.db.prepare(sql));
|
|
43
|
+
}
|
|
44
|
+
close() {
|
|
45
|
+
this.db.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { KyselyPlugin } from 'kysely';
|
|
2
|
+
export type ColumnKind = 'date' | 'bool' | 'json';
|
|
3
|
+
/**
|
|
4
|
+
* Per-table column kind map. Keys are snake_case table names matching the
|
|
5
|
+
* physical SQLite tables; inner keys are snake_case column names.
|
|
6
|
+
* Generated by `pikku db migrate` from `-- @date|@bool|@json` annotations
|
|
7
|
+
* and naming conventions (col ends in _at/_on → date; starts with is_/has_/can_ → bool).
|
|
8
|
+
*/
|
|
9
|
+
export type CoercionMap = Record<string, Record<string, ColumnKind>>;
|
|
10
|
+
export interface CreateCoercionPluginOptions {
|
|
11
|
+
map: CoercionMap;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Convert SQLite-stored values (TEXT/INTEGER) back into the logical types
|
|
15
|
+
* app code expects (Date / boolean / parsed JSON).
|
|
16
|
+
*
|
|
17
|
+
* Write-side coercion (Date → ISO string, boolean → 0/1, object → JSON) is
|
|
18
|
+
* handled in the NodeSqliteDatabase adapter (always-on), so this plugin is
|
|
19
|
+
* read-only.
|
|
20
|
+
*
|
|
21
|
+
* Place AFTER CamelCasePlugin in the plugin array (or it handles both
|
|
22
|
+
* orderings via the dual-keyed global map).
|
|
23
|
+
*/
|
|
24
|
+
export declare function createCoercionPlugin(options: CreateCoercionPluginOptions): KyselyPlugin;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
function fromDb(value, kind) {
|
|
2
|
+
if (value == null)
|
|
3
|
+
return value;
|
|
4
|
+
switch (kind) {
|
|
5
|
+
case 'date':
|
|
6
|
+
if (typeof value === 'string') {
|
|
7
|
+
const d = new Date(value);
|
|
8
|
+
return Number.isNaN(d.getTime()) ? value : d;
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
case 'bool':
|
|
12
|
+
if (typeof value === 'number')
|
|
13
|
+
return value !== 0;
|
|
14
|
+
if (typeof value === 'bigint')
|
|
15
|
+
return value !== 0n;
|
|
16
|
+
return value;
|
|
17
|
+
case 'json':
|
|
18
|
+
if (typeof value !== 'string')
|
|
19
|
+
return value;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(value);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function snakeToCamel(name) {
|
|
29
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Flatten the per-table map into a single column-name → kind lookup.
|
|
33
|
+
* Indexed by BOTH snake_case and camelCase so it works regardless of
|
|
34
|
+
* CamelCasePlugin ordering in the plugin array.
|
|
35
|
+
*
|
|
36
|
+
* When two tables disagree on the kind for the same column name, the column
|
|
37
|
+
* is ambiguous (a joined/aliased query could carry either) and is left
|
|
38
|
+
* uncoerced rather than letting whichever table was processed last win.
|
|
39
|
+
*/
|
|
40
|
+
function buildGlobalMap(map) {
|
|
41
|
+
const out = {};
|
|
42
|
+
const ambiguous = new Set();
|
|
43
|
+
const assign = (key, kind) => {
|
|
44
|
+
const existing = out[key];
|
|
45
|
+
if (existing !== undefined && existing !== kind) {
|
|
46
|
+
ambiguous.add(key);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
out[key] = kind;
|
|
50
|
+
};
|
|
51
|
+
for (const tbl of Object.values(map)) {
|
|
52
|
+
for (const [col, kind] of Object.entries(tbl)) {
|
|
53
|
+
assign(col, kind);
|
|
54
|
+
assign(snakeToCamel(col), kind);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const key of ambiguous)
|
|
58
|
+
delete out[key];
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Convert SQLite-stored values (TEXT/INTEGER) back into the logical types
|
|
63
|
+
* app code expects (Date / boolean / parsed JSON).
|
|
64
|
+
*
|
|
65
|
+
* Write-side coercion (Date → ISO string, boolean → 0/1, object → JSON) is
|
|
66
|
+
* handled in the NodeSqliteDatabase adapter (always-on), so this plugin is
|
|
67
|
+
* read-only.
|
|
68
|
+
*
|
|
69
|
+
* Place AFTER CamelCasePlugin in the plugin array (or it handles both
|
|
70
|
+
* orderings via the dual-keyed global map).
|
|
71
|
+
*/
|
|
72
|
+
export function createCoercionPlugin(options) {
|
|
73
|
+
const globalMap = buildGlobalMap(options.map);
|
|
74
|
+
return {
|
|
75
|
+
transformQuery(args) {
|
|
76
|
+
return args.node;
|
|
77
|
+
},
|
|
78
|
+
async transformResult(args) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const row of args.result.rows) {
|
|
81
|
+
const next = { ...row };
|
|
82
|
+
for (const [col, val] of Object.entries(row)) {
|
|
83
|
+
const kind = globalMap[col];
|
|
84
|
+
if (kind)
|
|
85
|
+
next[col] = fromDb(val, kind);
|
|
86
|
+
}
|
|
87
|
+
out.push(next);
|
|
88
|
+
}
|
|
89
|
+
return { ...args.result, rows: out };
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Kysely, type KyselyPlugin } from 'kysely';
|
|
2
|
+
export interface CreateBunSqliteKyselyOptions {
|
|
3
|
+
/** Path to the SQLite file. Use ':memory:' for an in-memory DB. */
|
|
4
|
+
filename: string;
|
|
5
|
+
/** Apply CamelCasePlugin so DB columns map to camelCase TS fields. Default true. */
|
|
6
|
+
camelCase?: boolean;
|
|
7
|
+
/** Extra plugins to layer on top. */
|
|
8
|
+
plugins?: KyselyPlugin[];
|
|
9
|
+
}
|
|
10
|
+
export declare function createBunSqliteKysely<DB>(options: CreateBunSqliteKyselyOptions): Kysely<DB>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { Kysely, SqliteDialect, CamelCasePlugin, } from 'kysely';
|
|
3
|
+
import { BunSqliteDatabase } from './bun-sqlite-adapter.js';
|
|
4
|
+
export function createBunSqliteKysely(options) {
|
|
5
|
+
const db = new Database(options.filename);
|
|
6
|
+
const plugins = [];
|
|
7
|
+
if (options.camelCase ?? true)
|
|
8
|
+
plugins.push(new CamelCasePlugin());
|
|
9
|
+
if (options.plugins)
|
|
10
|
+
plugins.push(...options.plugins);
|
|
11
|
+
return new Kysely({
|
|
12
|
+
dialect: new SqliteDialect({ database: new BunSqliteDatabase(db) }),
|
|
13
|
+
plugins,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { BunSqliteDatabase } from './bun-sqlite-adapter.js';
|
|
2
|
+
export { createBunSqliteKysely, type CreateBunSqliteKyselyOptions, } from './create-bun-sqlite-kysely.js';
|
|
3
|
+
export { createCoercionPlugin, type CoercionMap, type ColumnKind, type CreateCoercionPluginOptions, } from './coercion-plugin.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["../src/bun-sqlite-adapter.ts","../src/coercion-plugin.ts","../src/create-bun-sqlite-kysely.ts","../src/index.ts"],"version":"5.9.3"}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pikku/kysely-bun-sqlite",
|
|
3
|
+
"version": "0.12.1",
|
|
4
|
+
"author": "yasser.fadl@gmail.com",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"module": "dist/src/index.js",
|
|
7
|
+
"main": "dist/src/index.js",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"tsc": "tsc",
|
|
11
|
+
"ncu": "npx npm-check-updates",
|
|
12
|
+
"build": "tsc -b",
|
|
13
|
+
"test": "bash run-tests.sh",
|
|
14
|
+
"test:coverage": "bash run-tests.sh --coverage",
|
|
15
|
+
"prepublishOnly": "bun run build"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"bun": ">=1.0.0"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@pikku/kysely": "^0.12.17",
|
|
22
|
+
"@pikku/kysely-sqlite": "^0.12.0",
|
|
23
|
+
"kysely": "^0.29.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/bun": "latest",
|
|
27
|
+
"typescript": "^5.9"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/run-tests.sh
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
shopt -s nullglob
|
|
4
|
+
|
|
5
|
+
watch_mode=false
|
|
6
|
+
coverage_mode=false
|
|
7
|
+
|
|
8
|
+
while [[ $# -gt 0 ]]; do
|
|
9
|
+
case $1 in
|
|
10
|
+
--watch)
|
|
11
|
+
watch_mode=true
|
|
12
|
+
shift
|
|
13
|
+
;;
|
|
14
|
+
--coverage)
|
|
15
|
+
coverage_mode=true
|
|
16
|
+
shift
|
|
17
|
+
;;
|
|
18
|
+
*)
|
|
19
|
+
echo "Unknown option: $1"
|
|
20
|
+
exit 1
|
|
21
|
+
;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
|
|
25
|
+
files=()
|
|
26
|
+
while IFS= read -r -d '' file; do
|
|
27
|
+
files+=("$file")
|
|
28
|
+
done < <(find src -type f -name "*.test.ts" -print0)
|
|
29
|
+
|
|
30
|
+
if [ ${#files[@]} -eq 0 ]; then
|
|
31
|
+
echo "No test files found"
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
if [ "$coverage_mode" = true ]; then
|
|
36
|
+
# Bun writes coverage/lcov.info and instruments imported dist files too.
|
|
37
|
+
# Re-emit a package-root lcov.info containing only src/ records so the
|
|
38
|
+
# repo-wide unit-coverage merge — which expects <pkg>/lcov.info and prefixes
|
|
39
|
+
# its SF paths — maps them correctly, exactly like the node packages'
|
|
40
|
+
# --test-reporter=lcov output.
|
|
41
|
+
bun test --coverage --coverage-reporter=lcov "${files[@]}"
|
|
42
|
+
status=$?
|
|
43
|
+
awk '/^SF:/{keep=/^SF:src\//} keep' coverage/lcov.info > lcov.info 2>/dev/null || true
|
|
44
|
+
rm -rf coverage
|
|
45
|
+
exit $status
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
bun_cmd="bun test"
|
|
49
|
+
|
|
50
|
+
if [ "$watch_mode" = true ]; then
|
|
51
|
+
bun_cmd="$bun_cmd --watch"
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
$bun_cmd "${files[@]}"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, test, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Database } from 'bun:sqlite'
|
|
4
|
+
import { Kysely, SqliteDialect } from 'kysely'
|
|
5
|
+
import { BunSqliteDatabase } from './bun-sqlite-adapter.js'
|
|
6
|
+
|
|
7
|
+
interface TestDB {
|
|
8
|
+
items: { id: number; name: string; active: number; score: number | null }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('BunSqliteDatabase', () => {
|
|
12
|
+
let db: Database
|
|
13
|
+
let kysely: Kysely<TestDB>
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
db = new Database(':memory:')
|
|
17
|
+
kysely = new Kysely<TestDB>({
|
|
18
|
+
dialect: new SqliteDialect({ database: new BunSqliteDatabase(db) }),
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await kysely.destroy()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('create table and insert a row', async () => {
|
|
27
|
+
await kysely.schema
|
|
28
|
+
.createTable('items')
|
|
29
|
+
.addColumn('id', 'integer', (c) => c.primaryKey().autoIncrement())
|
|
30
|
+
.addColumn('name', 'text', (c) => c.notNull())
|
|
31
|
+
.addColumn('active', 'integer', (c) => c.notNull())
|
|
32
|
+
.addColumn('score', 'real')
|
|
33
|
+
.execute()
|
|
34
|
+
|
|
35
|
+
await kysely
|
|
36
|
+
.insertInto('items')
|
|
37
|
+
.values({ name: 'alpha', active: 1, score: 3.14 })
|
|
38
|
+
.execute()
|
|
39
|
+
|
|
40
|
+
const rows = await kysely.selectFrom('items').selectAll().execute()
|
|
41
|
+
assert.equal(rows.length, 1)
|
|
42
|
+
assert.equal(rows[0].name, 'alpha')
|
|
43
|
+
assert.equal(rows[0].active, 1)
|
|
44
|
+
assert.equal(rows[0].score, 3.14)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('update and delete', async () => {
|
|
48
|
+
await kysely.schema
|
|
49
|
+
.createTable('items')
|
|
50
|
+
.addColumn('id', 'integer', (c) => c.primaryKey().autoIncrement())
|
|
51
|
+
.addColumn('name', 'text', (c) => c.notNull())
|
|
52
|
+
.addColumn('active', 'integer', (c) => c.notNull())
|
|
53
|
+
.addColumn('score', 'real')
|
|
54
|
+
.execute()
|
|
55
|
+
|
|
56
|
+
await kysely
|
|
57
|
+
.insertInto('items')
|
|
58
|
+
.values([
|
|
59
|
+
{ name: 'a', active: 1, score: null },
|
|
60
|
+
{ name: 'b', active: 0, score: null },
|
|
61
|
+
])
|
|
62
|
+
.execute()
|
|
63
|
+
|
|
64
|
+
await kysely
|
|
65
|
+
.updateTable('items')
|
|
66
|
+
.set({ active: 1 })
|
|
67
|
+
.where('name', '=', 'b')
|
|
68
|
+
.execute()
|
|
69
|
+
const updated = await kysely
|
|
70
|
+
.selectFrom('items')
|
|
71
|
+
.where('name', '=', 'b')
|
|
72
|
+
.selectAll()
|
|
73
|
+
.executeTakeFirstOrThrow()
|
|
74
|
+
assert.equal(updated.active, 1)
|
|
75
|
+
|
|
76
|
+
await kysely.deleteFrom('items').where('name', '=', 'a').execute()
|
|
77
|
+
const remaining = await kysely.selectFrom('items').selectAll().execute()
|
|
78
|
+
assert.equal(remaining.length, 1)
|
|
79
|
+
assert.equal(remaining[0].name, 'b')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('boolean coercion via coerce function', async () => {
|
|
83
|
+
await kysely.schema
|
|
84
|
+
.createTable('items')
|
|
85
|
+
.addColumn('id', 'integer', (c) => c.primaryKey().autoIncrement())
|
|
86
|
+
.addColumn('name', 'text', (c) => c.notNull())
|
|
87
|
+
.addColumn('active', 'integer', (c) => c.notNull())
|
|
88
|
+
.addColumn('score', 'real')
|
|
89
|
+
.execute()
|
|
90
|
+
|
|
91
|
+
await kysely
|
|
92
|
+
.insertInto('items')
|
|
93
|
+
.values({
|
|
94
|
+
name: 'bool-test',
|
|
95
|
+
active: true as unknown as number,
|
|
96
|
+
score: null,
|
|
97
|
+
})
|
|
98
|
+
.execute()
|
|
99
|
+
const row = await kysely
|
|
100
|
+
.selectFrom('items')
|
|
101
|
+
.selectAll()
|
|
102
|
+
.executeTakeFirstOrThrow()
|
|
103
|
+
assert.equal(row.active, 1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('null values round-trip', async () => {
|
|
107
|
+
await kysely.schema
|
|
108
|
+
.createTable('items')
|
|
109
|
+
.addColumn('id', 'integer', (c) => c.primaryKey().autoIncrement())
|
|
110
|
+
.addColumn('name', 'text', (c) => c.notNull())
|
|
111
|
+
.addColumn('active', 'integer', (c) => c.notNull())
|
|
112
|
+
.addColumn('score', 'real')
|
|
113
|
+
.execute()
|
|
114
|
+
|
|
115
|
+
await kysely
|
|
116
|
+
.insertInto('items')
|
|
117
|
+
.values({ name: 'nullable', active: 0, score: null })
|
|
118
|
+
.execute()
|
|
119
|
+
const row = await kysely
|
|
120
|
+
.selectFrom('items')
|
|
121
|
+
.selectAll()
|
|
122
|
+
.executeTakeFirstOrThrow()
|
|
123
|
+
assert.equal(row.score, null)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('iterate returns rows lazily', async () => {
|
|
127
|
+
await kysely.schema
|
|
128
|
+
.createTable('items')
|
|
129
|
+
.addColumn('id', 'integer', (c) => c.primaryKey().autoIncrement())
|
|
130
|
+
.addColumn('name', 'text', (c) => c.notNull())
|
|
131
|
+
.addColumn('active', 'integer', (c) => c.notNull())
|
|
132
|
+
.addColumn('score', 'real')
|
|
133
|
+
.execute()
|
|
134
|
+
|
|
135
|
+
await kysely
|
|
136
|
+
.insertInto('items')
|
|
137
|
+
.values([
|
|
138
|
+
{ name: 'x', active: 1, score: null },
|
|
139
|
+
{ name: 'y', active: 1, score: null },
|
|
140
|
+
{ name: 'z', active: 1, score: null },
|
|
141
|
+
])
|
|
142
|
+
.execute()
|
|
143
|
+
|
|
144
|
+
const collected: string[] = []
|
|
145
|
+
const stmt = new BunSqliteDatabase(db).prepare(
|
|
146
|
+
'SELECT name FROM items ORDER BY id'
|
|
147
|
+
)
|
|
148
|
+
for (const row of stmt.iterate([])) {
|
|
149
|
+
collected.push((row as any).name)
|
|
150
|
+
}
|
|
151
|
+
assert.deepEqual(collected, ['x', 'y', 'z'])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('reader is false for writes so run() returns mutation metadata', async () => {
|
|
155
|
+
await kysely.schema
|
|
156
|
+
.createTable('items')
|
|
157
|
+
.addColumn('id', 'integer', (c) => c.primaryKey().autoIncrement())
|
|
158
|
+
.addColumn('name', 'text', (c) => c.notNull())
|
|
159
|
+
.addColumn('active', 'integer', (c) => c.notNull())
|
|
160
|
+
.addColumn('score', 'real')
|
|
161
|
+
.execute()
|
|
162
|
+
|
|
163
|
+
const inserted = await kysely
|
|
164
|
+
.insertInto('items')
|
|
165
|
+
.values({ name: 'meta', active: 1, score: null })
|
|
166
|
+
.executeTakeFirstOrThrow()
|
|
167
|
+
assert.equal(inserted.insertId, 1n)
|
|
168
|
+
|
|
169
|
+
const updated = await kysely
|
|
170
|
+
.updateTable('items')
|
|
171
|
+
.set({ active: 0 })
|
|
172
|
+
.where('name', '=', 'meta')
|
|
173
|
+
.executeTakeFirstOrThrow()
|
|
174
|
+
assert.equal(updated.numUpdatedRows, 1n)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Database, Statement } from 'bun:sqlite'
|
|
2
|
+
import type { SqliteDatabase, SqliteStatement } from 'kysely'
|
|
3
|
+
|
|
4
|
+
function coerce(v: unknown): unknown {
|
|
5
|
+
if (v === null || v === undefined) return null
|
|
6
|
+
if (typeof v === 'boolean') return v ? 1 : 0
|
|
7
|
+
if (v instanceof Date) return v.toISOString()
|
|
8
|
+
if (typeof v === 'object') return JSON.stringify(v)
|
|
9
|
+
return v
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class BunSqliteStatement implements SqliteStatement {
|
|
13
|
+
constructor(private readonly stmt: Statement) {}
|
|
14
|
+
|
|
15
|
+
get reader(): boolean {
|
|
16
|
+
return this.stmt.columnNames.length > 0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
all(parameters: ReadonlyArray<unknown>): unknown[] {
|
|
20
|
+
return this.stmt.all(...(parameters.map(coerce) as any))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
*iterate(parameters: ReadonlyArray<unknown>): IterableIterator<unknown> {
|
|
24
|
+
for (const row of this.stmt.iterate(...(parameters.map(coerce) as any))) {
|
|
25
|
+
yield row
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
run(parameters: ReadonlyArray<unknown>): {
|
|
30
|
+
changes: number | bigint
|
|
31
|
+
lastInsertRowid: number | bigint
|
|
32
|
+
} {
|
|
33
|
+
const result = this.stmt.run(...(parameters.map(coerce) as any))
|
|
34
|
+
return {
|
|
35
|
+
changes: result.changes,
|
|
36
|
+
lastInsertRowid: result.lastInsertRowid,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class BunSqliteDatabase implements SqliteDatabase {
|
|
42
|
+
constructor(private readonly db: Database) {}
|
|
43
|
+
|
|
44
|
+
prepare(sql: string): SqliteStatement {
|
|
45
|
+
return new BunSqliteStatement(this.db.prepare(sql))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
close(): void {
|
|
49
|
+
this.db.close()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import type { QueryResult, UnknownRow } from 'kysely'
|
|
4
|
+
import { createCoercionPlugin } from './coercion-plugin.js'
|
|
5
|
+
|
|
6
|
+
const runTransform = async (
|
|
7
|
+
map: Parameters<typeof createCoercionPlugin>[0]['map'],
|
|
8
|
+
rows: UnknownRow[]
|
|
9
|
+
): Promise<UnknownRow[]> => {
|
|
10
|
+
const plugin = createCoercionPlugin({ map })
|
|
11
|
+
const result = { rows } as QueryResult<UnknownRow>
|
|
12
|
+
const out = await plugin.transformResult({
|
|
13
|
+
result,
|
|
14
|
+
queryId: { queryId: 'test' },
|
|
15
|
+
} as Parameters<NonNullable<typeof plugin.transformResult>>[0])
|
|
16
|
+
return out.rows as UnknownRow[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('createCoercionPlugin', () => {
|
|
20
|
+
test('transformQuery passes the node through untouched', () => {
|
|
21
|
+
const plugin = createCoercionPlugin({ map: {} })
|
|
22
|
+
const node = { kind: 'SelectQueryNode' } as any
|
|
23
|
+
assert.equal(
|
|
24
|
+
plugin.transformQuery({ node, queryId: { queryId: 'x' } } as any),
|
|
25
|
+
node
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('coerces date columns from ISO strings to Date', async () => {
|
|
30
|
+
const [row] = await runTransform(
|
|
31
|
+
{ users: { created_at: 'date' } },
|
|
32
|
+
[{ created_at: '2026-06-26T00:00:00.000Z' }]
|
|
33
|
+
)
|
|
34
|
+
assert.ok(row.created_at instanceof Date)
|
|
35
|
+
assert.equal(
|
|
36
|
+
(row.created_at as Date).toISOString(),
|
|
37
|
+
'2026-06-26T00:00:00.000Z'
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('leaves unparseable date strings untouched', async () => {
|
|
42
|
+
const [row] = await runTransform(
|
|
43
|
+
{ users: { created_at: 'date' } },
|
|
44
|
+
[{ created_at: 'not-a-date' }]
|
|
45
|
+
)
|
|
46
|
+
assert.equal(row.created_at, 'not-a-date')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('coerces bool columns from 0/1 numbers and bigints', async () => {
|
|
50
|
+
const rows = await runTransform(
|
|
51
|
+
{ users: { is_active: 'bool', has_pets: 'bool' } },
|
|
52
|
+
[{ is_active: 1, has_pets: 0n }]
|
|
53
|
+
)
|
|
54
|
+
assert.equal(rows[0].is_active, true)
|
|
55
|
+
assert.equal(rows[0].has_pets, false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('parses json columns and tolerates invalid json', async () => {
|
|
59
|
+
const rows = await runTransform(
|
|
60
|
+
{ users: { meta: 'json', broken: 'json' } },
|
|
61
|
+
[{ meta: '{"a":1}', broken: '{bad' }]
|
|
62
|
+
)
|
|
63
|
+
assert.deepEqual(rows[0].meta, { a: 1 })
|
|
64
|
+
assert.equal(rows[0].broken, '{bad')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('passes through null values and unmapped columns', async () => {
|
|
68
|
+
const rows = await runTransform(
|
|
69
|
+
{ users: { created_at: 'date' } },
|
|
70
|
+
[{ created_at: null, name: 'leave me' }]
|
|
71
|
+
)
|
|
72
|
+
assert.equal(rows[0].created_at, null)
|
|
73
|
+
assert.equal(rows[0].name, 'leave me')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('matches both snake_case and camelCase column names', async () => {
|
|
77
|
+
const rows = await runTransform(
|
|
78
|
+
{ users: { created_at: 'date' } },
|
|
79
|
+
[{ createdAt: '2026-06-26T00:00:00.000Z' }]
|
|
80
|
+
)
|
|
81
|
+
assert.ok(rows[0].createdAt instanceof Date)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('leaves columns ambiguous across tables uncoerced', async () => {
|
|
85
|
+
const rows = await runTransform(
|
|
86
|
+
{ users: { value: 'json' }, events: { value: 'bool' } },
|
|
87
|
+
[{ value: '{"a":1}' }]
|
|
88
|
+
)
|
|
89
|
+
assert.equal(rows[0].value, '{"a":1}')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('still coerces columns that agree across tables', async () => {
|
|
93
|
+
const rows = await runTransform(
|
|
94
|
+
{ users: { active: 'bool' }, events: { active: 'bool' } },
|
|
95
|
+
[{ active: 1 }]
|
|
96
|
+
)
|
|
97
|
+
assert.equal(rows[0].active, true)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { KyselyPlugin, UnknownRow } from 'kysely'
|
|
2
|
+
|
|
3
|
+
export type ColumnKind = 'date' | 'bool' | 'json'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-table column kind map. Keys are snake_case table names matching the
|
|
7
|
+
* physical SQLite tables; inner keys are snake_case column names.
|
|
8
|
+
* Generated by `pikku db migrate` from `-- @date|@bool|@json` annotations
|
|
9
|
+
* and naming conventions (col ends in _at/_on → date; starts with is_/has_/can_ → bool).
|
|
10
|
+
*/
|
|
11
|
+
export type CoercionMap = Record<string, Record<string, ColumnKind>>
|
|
12
|
+
|
|
13
|
+
export interface CreateCoercionPluginOptions {
|
|
14
|
+
map: CoercionMap
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fromDb(value: unknown, kind: ColumnKind): unknown {
|
|
18
|
+
if (value == null) return value
|
|
19
|
+
switch (kind) {
|
|
20
|
+
case 'date':
|
|
21
|
+
if (typeof value === 'string') {
|
|
22
|
+
const d = new Date(value)
|
|
23
|
+
return Number.isNaN(d.getTime()) ? value : d
|
|
24
|
+
}
|
|
25
|
+
return value
|
|
26
|
+
case 'bool':
|
|
27
|
+
if (typeof value === 'number') return value !== 0
|
|
28
|
+
if (typeof value === 'bigint') return value !== 0n
|
|
29
|
+
return value
|
|
30
|
+
case 'json':
|
|
31
|
+
if (typeof value !== 'string') return value
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(value)
|
|
34
|
+
} catch {
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function snakeToCamel(name: string): string {
|
|
41
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Flatten the per-table map into a single column-name → kind lookup.
|
|
46
|
+
* Indexed by BOTH snake_case and camelCase so it works regardless of
|
|
47
|
+
* CamelCasePlugin ordering in the plugin array.
|
|
48
|
+
*
|
|
49
|
+
* When two tables disagree on the kind for the same column name, the column
|
|
50
|
+
* is ambiguous (a joined/aliased query could carry either) and is left
|
|
51
|
+
* uncoerced rather than letting whichever table was processed last win.
|
|
52
|
+
*/
|
|
53
|
+
function buildGlobalMap(map: CoercionMap): Record<string, ColumnKind> {
|
|
54
|
+
const out: Record<string, ColumnKind> = {}
|
|
55
|
+
const ambiguous = new Set<string>()
|
|
56
|
+
const assign = (key: string, kind: ColumnKind) => {
|
|
57
|
+
const existing = out[key]
|
|
58
|
+
if (existing !== undefined && existing !== kind) {
|
|
59
|
+
ambiguous.add(key)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
out[key] = kind
|
|
63
|
+
}
|
|
64
|
+
for (const tbl of Object.values(map)) {
|
|
65
|
+
for (const [col, kind] of Object.entries(tbl)) {
|
|
66
|
+
assign(col, kind)
|
|
67
|
+
assign(snakeToCamel(col), kind)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const key of ambiguous) delete out[key]
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convert SQLite-stored values (TEXT/INTEGER) back into the logical types
|
|
76
|
+
* app code expects (Date / boolean / parsed JSON).
|
|
77
|
+
*
|
|
78
|
+
* Write-side coercion (Date → ISO string, boolean → 0/1, object → JSON) is
|
|
79
|
+
* handled in the NodeSqliteDatabase adapter (always-on), so this plugin is
|
|
80
|
+
* read-only.
|
|
81
|
+
*
|
|
82
|
+
* Place AFTER CamelCasePlugin in the plugin array (or it handles both
|
|
83
|
+
* orderings via the dual-keyed global map).
|
|
84
|
+
*/
|
|
85
|
+
export function createCoercionPlugin(
|
|
86
|
+
options: CreateCoercionPluginOptions
|
|
87
|
+
): KyselyPlugin {
|
|
88
|
+
const globalMap = buildGlobalMap(options.map)
|
|
89
|
+
return {
|
|
90
|
+
transformQuery(args) {
|
|
91
|
+
return args.node
|
|
92
|
+
},
|
|
93
|
+
async transformResult(args) {
|
|
94
|
+
const out: UnknownRow[] = []
|
|
95
|
+
for (const row of args.result.rows as UnknownRow[]) {
|
|
96
|
+
const next: UnknownRow = { ...row }
|
|
97
|
+
for (const [col, val] of Object.entries(row)) {
|
|
98
|
+
const kind = globalMap[col]
|
|
99
|
+
if (kind) next[col] = fromDb(val, kind)
|
|
100
|
+
}
|
|
101
|
+
out.push(next)
|
|
102
|
+
}
|
|
103
|
+
return { ...args.result, rows: out }
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { sql } from 'kysely'
|
|
4
|
+
import { createBunSqliteKysely } from './create-bun-sqlite-kysely.js'
|
|
5
|
+
|
|
6
|
+
interface TestDB {
|
|
7
|
+
widgets: { id: number; displayName: string }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('createBunSqliteKysely', () => {
|
|
11
|
+
test('creates a working in-memory Kysely with camelCase mapping by default', async () => {
|
|
12
|
+
const db = createBunSqliteKysely<TestDB>({ filename: ':memory:' })
|
|
13
|
+
|
|
14
|
+
await sql`CREATE TABLE widgets (id INTEGER PRIMARY KEY, display_name TEXT)`.execute(
|
|
15
|
+
db
|
|
16
|
+
)
|
|
17
|
+
await db
|
|
18
|
+
.insertInto('widgets')
|
|
19
|
+
.values({ id: 1, displayName: 'hello' })
|
|
20
|
+
.execute()
|
|
21
|
+
|
|
22
|
+
const row = await db
|
|
23
|
+
.selectFrom('widgets')
|
|
24
|
+
.selectAll()
|
|
25
|
+
.executeTakeFirstOrThrow()
|
|
26
|
+
|
|
27
|
+
assert.equal(row.displayName, 'hello')
|
|
28
|
+
await db.destroy()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('can disable camelCase and layer extra plugins', async () => {
|
|
32
|
+
const db = createBunSqliteKysely<{ t: { snake_col: string } }>({
|
|
33
|
+
filename: ':memory:',
|
|
34
|
+
camelCase: false,
|
|
35
|
+
plugins: [],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await sql`CREATE TABLE t (snake_col TEXT)`.execute(db)
|
|
39
|
+
await db.insertInto('t').values({ snake_col: 'raw' }).execute()
|
|
40
|
+
const row = await db.selectFrom('t').selectAll().executeTakeFirstOrThrow()
|
|
41
|
+
|
|
42
|
+
assert.equal(row.snake_col, 'raw')
|
|
43
|
+
await db.destroy()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite'
|
|
2
|
+
import {
|
|
3
|
+
Kysely,
|
|
4
|
+
SqliteDialect,
|
|
5
|
+
CamelCasePlugin,
|
|
6
|
+
type KyselyPlugin,
|
|
7
|
+
} from 'kysely'
|
|
8
|
+
import { BunSqliteDatabase } from './bun-sqlite-adapter.js'
|
|
9
|
+
|
|
10
|
+
export interface CreateBunSqliteKyselyOptions {
|
|
11
|
+
/** Path to the SQLite file. Use ':memory:' for an in-memory DB. */
|
|
12
|
+
filename: string
|
|
13
|
+
/** Apply CamelCasePlugin so DB columns map to camelCase TS fields. Default true. */
|
|
14
|
+
camelCase?: boolean
|
|
15
|
+
/** Extra plugins to layer on top. */
|
|
16
|
+
plugins?: KyselyPlugin[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createBunSqliteKysely<DB>(
|
|
20
|
+
options: CreateBunSqliteKyselyOptions
|
|
21
|
+
): Kysely<DB> {
|
|
22
|
+
const db = new Database(options.filename)
|
|
23
|
+
const plugins: KyselyPlugin[] = []
|
|
24
|
+
if (options.camelCase ?? true) plugins.push(new CamelCasePlugin())
|
|
25
|
+
if (options.plugins) plugins.push(...options.plugins)
|
|
26
|
+
|
|
27
|
+
return new Kysely<DB>({
|
|
28
|
+
dialect: new SqliteDialect({ database: new BunSqliteDatabase(db) }),
|
|
29
|
+
plugins,
|
|
30
|
+
})
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { BunSqliteDatabase } from './bun-sqlite-adapter.js'
|
|
2
|
+
export {
|
|
3
|
+
createBunSqliteKysely,
|
|
4
|
+
type CreateBunSqliteKyselyOptions,
|
|
5
|
+
} from './create-bun-sqlite-kysely.js'
|
|
6
|
+
export {
|
|
7
|
+
createCoercionPlugin,
|
|
8
|
+
type CoercionMap,
|
|
9
|
+
type ColumnKind,
|
|
10
|
+
type CreateCoercionPluginOptions,
|
|
11
|
+
} from './coercion-plugin.js'
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": ".",
|
|
5
|
+
"module": "Node18",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"target": "esnext",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"types": ["bun"]
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.ts"],
|
|
12
|
+
"exclude": ["**/*.test.ts", "node_modules"]
|
|
13
|
+
}
|