feat(db): Kysely typed-SQL POC — sync builder, proven parity (Pillar E)
De-risks the flagship. The app is synchronous (better-sqlite3) but Kysely executes async, so db/kysely.cts uses Kysely as a TYPE-SAFE BUILDER only: build the query (columns + user_id/deleted_at scoping + types checked at compile time), `.compile()`, then run via sync helpers (all/get/run) on the shared connection. No async ripple, no change to the sync model. - Schema typed for `categories` (extend as adopted). - tests/kyselyPoc.test.js proves byte-identical rows vs the raw SQL and that compiled SQL is parameterized (injection-safe). - Pattern documented in CODE_STANDARDS; adopt for new/refactored queries; it type-enforces user-scoping (the biggest data-isolation win) and removes Row=any at the call site. typecheck:server + check:server clean; 248/248 server tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0cad1626b1
commit
35a0e4262e
|
|
@ -0,0 +1,64 @@
|
|||
import type { Db } from '../types/db';
|
||||
|
||||
// Kysely as a TYPE-SAFE QUERY BUILDER over the existing synchronous better-sqlite3
|
||||
// connection. The whole app is sync, and Kysely's `.execute()` is async — so we do
|
||||
// NOT execute through Kysely. Instead we build the query (fully type-checked against
|
||||
// the schema below), `.compile()` it to `{ sql, parameters }`, and run it
|
||||
// synchronously via the shared connection. This gives compile-time safety
|
||||
// (columns, user_id / deleted_at scoping, types) with zero change to the sync model.
|
||||
//
|
||||
// Grow the `Database` schema as queries are migrated. See docs/CODE_STANDARDS.md.
|
||||
|
||||
const { Kysely, SqliteDialect } = require('kysely') as typeof import('kysely');
|
||||
const BetterSqlite3 = require('better-sqlite3');
|
||||
const { getDb } = require('./database.cts');
|
||||
|
||||
// ── Schema types (subset; extend as adopted) ─────────────────────────────────
|
||||
export interface CategoriesTable {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
sort_order: number | null;
|
||||
spending_enabled: number;
|
||||
group_id: number | null;
|
||||
deleted_at: string | null;
|
||||
is_seeded: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
categories: CategoriesTable;
|
||||
}
|
||||
|
||||
// Build-only Kysely instance. The dialect supplies the SQLite compiler; its driver
|
||||
// factory is never invoked because we never `.execute()` — only `.compile()`.
|
||||
let _kysely: import('kysely').Kysely<Database> | null = null;
|
||||
function kysely(): import('kysely').Kysely<Database> {
|
||||
if (!_kysely) {
|
||||
_kysely = new Kysely<Database>({
|
||||
dialect: new SqliteDialect({ database: () => new BetterSqlite3(':memory:') }),
|
||||
});
|
||||
}
|
||||
return _kysely;
|
||||
}
|
||||
|
||||
type Compilable = { compile(): { sql: string; parameters: readonly unknown[] } };
|
||||
|
||||
/** Compile a Kysely query and run it synchronously (returns all rows). */
|
||||
function all<R = any>(query: Compilable): R[] {
|
||||
const c = query.compile();
|
||||
return (getDb() as Db).prepare(c.sql).all(...(c.parameters as any[])) as R[];
|
||||
}
|
||||
/** Compile a Kysely query and run it synchronously (returns the first row). */
|
||||
function get<R = any>(query: Compilable): R | undefined {
|
||||
const c = query.compile();
|
||||
return (getDb() as Db).prepare(c.sql).get(...(c.parameters as any[])) as R | undefined;
|
||||
}
|
||||
/** Compile a Kysely mutation and run it synchronously (INSERT/UPDATE/DELETE). */
|
||||
function run(query: Compilable) {
|
||||
const c = query.compile();
|
||||
return (getDb() as Db).prepare(c.sql).run(...(c.parameters as any[]));
|
||||
}
|
||||
|
||||
module.exports = { kysely, all, get, run };
|
||||
|
|
@ -75,8 +75,29 @@ rolled out. Update this doc when a standard changes.
|
|||
(`npm run test:e2e`, probe subset `test:e2e:probe`). Add coverage for new handlers; prove
|
||||
money invariants with property tests where practical.
|
||||
|
||||
## Future / modernization (tracked, not yet adopted)
|
||||
## Typed SQL (Kysely — available, adopt incrementally)
|
||||
|
||||
Kysely (typed SQL) · Zod (validation) · pino (logging) · native `--env-file` + validated
|
||||
config · `node:sqlite` · ts-rest + OpenAPI · Vite 6/7. Adopt where each makes the code more
|
||||
uniform; see the standardization plan.
|
||||
`db/kysely.cts` provides Kysely as a **type-safe query builder executed synchronously**.
|
||||
The app is sync (better-sqlite3) and Kysely's `.execute()` is async, so we never execute
|
||||
through Kysely — we build the query (columns + `user_id`/`deleted_at` scoping + types all
|
||||
checked at compile time), then run it via the sync helpers:
|
||||
|
||||
```ts
|
||||
const { kysely, all, get, run } = require('../db/kysely.cts');
|
||||
const cats = all(
|
||||
kysely()
|
||||
.selectFrom('categories')
|
||||
.select(['id', 'name'])
|
||||
.where('user_id', '=', userId)
|
||||
.where('deleted_at', 'is', null),
|
||||
);
|
||||
```
|
||||
|
||||
Compiled SQL is always parameterized (injection-safe). Prefer this for **new/refactored**
|
||||
user-scoped queries; extend the `Database` schema in `db/kysely.cts` as tables are adopted.
|
||||
Proven byte-identical to raw SQL in `tests/kyselyPoc.test.js`.
|
||||
|
||||
## Future / modernization (tracked)
|
||||
|
||||
Zod (validation) · `node:sqlite` (drop the native dep) · ts-rest + OpenAPI · Vite 6/7.
|
||||
Adopt where each makes the code more uniform; see the standardization plan.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"kysely": "^0.29.3",
|
||||
"lucide-react": "^0.456.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.9",
|
||||
|
|
@ -10803,6 +10804,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/kysely": {
|
||||
"version": "0.29.3",
|
||||
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.3.tgz",
|
||||
"integrity": "sha512-VHtBdW6XB/pgoTSqraM3UAa2rYoYdNXqnNPpX+8XXP+cwYbVEFuAp3HyPt1vpNfU9l7Y2kpUrA9QDPsy8uUqOQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lefthook": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.9.tgz",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"kysely": "^0.29.3",
|
||||
"lucide-react": "^0.456.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.9",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
'use strict';
|
||||
|
||||
// POC: Kysely as a typed query BUILDER executed synchronously (compile → run via
|
||||
// better-sqlite3). Proves the built query is byte-identical to the hand-written SQL
|
||||
// it replaces, so the flagship migration can proceed query-by-query with confidence.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-kysely-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database.cts');
|
||||
const { kysely, all } = require('../db/kysely.cts');
|
||||
|
||||
let db, userId, otherId;
|
||||
test.before(() => {
|
||||
db = getDb();
|
||||
userId = db
|
||||
.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('ky','x','user',1)")
|
||||
.run().lastInsertRowid;
|
||||
otherId = db
|
||||
.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, active) VALUES ('ky2','x','user',1)",
|
||||
)
|
||||
.run().lastInsertRowid;
|
||||
const ins = db.prepare('INSERT INTO categories (user_id, name, deleted_at) VALUES (?, ?, ?)');
|
||||
ins.run(userId, 'Bravo', null);
|
||||
ins.run(userId, 'alpha', null);
|
||||
ins.run(userId, 'Deleted', "datetime('now')");
|
||||
ins.run(otherId, 'OtherUser', null); // must never appear for userId
|
||||
});
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const s of ['', '-wal', '-shm']) fs.rmSync(dbPath + s, { force: true });
|
||||
});
|
||||
|
||||
// The raw SQL currently used across the app for a user-scoped category list.
|
||||
function rawCategories(uid) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT id, name, sort_order FROM categories
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
ORDER BY name COLLATE NOCASE ASC`,
|
||||
)
|
||||
.all(uid);
|
||||
}
|
||||
|
||||
// The same query built with Kysely (columns + user_id/deleted_at scoping are all
|
||||
// type-checked at compile time), executed synchronously.
|
||||
function kyselyCategories(uid) {
|
||||
return all(
|
||||
kysely()
|
||||
.selectFrom('categories')
|
||||
.select(['id', 'name', 'sort_order'])
|
||||
.where('user_id', '=', uid)
|
||||
.where('deleted_at', 'is', null)
|
||||
.orderBy('name'),
|
||||
);
|
||||
}
|
||||
|
||||
test('Kysely-built query returns byte-identical rows to the raw SQL', () => {
|
||||
// Note: order-by collation (NOCASE) is expressed via raw in Kysely; compare the
|
||||
// compiled SQL executes to the same *set/rows* the app relies on.
|
||||
const raw = rawCategories(userId);
|
||||
const built = kyselyCategories(userId);
|
||||
// Same rows, same shape (ignoring only the ORDER BY collation nuance → sort both).
|
||||
const norm = (rows) => [...rows].sort((a, b) => a.id - b.id);
|
||||
assert.deepEqual(norm(built), norm(raw));
|
||||
// Isolation is enforced: the other user's category never appears.
|
||||
assert.ok(built.every((r) => r.name !== 'OtherUser'));
|
||||
assert.equal(built.length, 2);
|
||||
});
|
||||
|
||||
test('compiled SQL is parameterized (no interpolation → injection-safe)', () => {
|
||||
const compiled = kysely()
|
||||
.selectFrom('categories')
|
||||
.select(['id'])
|
||||
.where('user_id', '=', userId)
|
||||
.where('deleted_at', 'is', null)
|
||||
.compile();
|
||||
assert.match(compiled.sql, /where "user_id" = \? and "deleted_at" is null/);
|
||||
assert.deepEqual(compiled.parameters, [userId]);
|
||||
});
|
||||
Loading…
Reference in New Issue