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:
null 2026-07-06 16:23:50 -05:00
parent 0cad1626b1
commit 35a0e4262e
5 changed files with 186 additions and 4 deletions

64
db/kysely.cts Normal file
View File

@ -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 };

View File

@ -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 (`npm run test:e2e`, probe subset `test:e2e:probe`). Add coverage for new handlers; prove
money invariants with property tests where practical. 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 `db/kysely.cts` provides Kysely as a **type-safe query builder executed synchronously**.
config · `node:sqlite` · ts-rest + OpenAPI · Vite 6/7. Adopt where each makes the code more The app is sync (better-sqlite3) and Kysely's `.execute()` is async, so we never execute
uniform; see the standardization plan. 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.

10
package-lock.json generated
View File

@ -34,6 +34,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"kysely": "^0.29.3",
"lucide-react": "^0.456.0", "lucide-react": "^0.456.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^8.0.9", "nodemailer": "^8.0.9",
@ -10803,6 +10804,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lefthook": {
"version": "2.1.9", "version": "2.1.9",
"resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.9.tgz", "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.9.tgz",

View File

@ -57,6 +57,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"kysely": "^0.29.3",
"lucide-react": "^0.456.0", "lucide-react": "^0.456.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^8.0.9", "nodemailer": "^8.0.9",

86
tests/kyselyPoc.test.js Normal file
View File

@ -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]);
});