Files
redefined-designs/docs/superpowers/specs/2026-09-04-kysely-spike.ts
T
bermudalambandClaude Opus 5 7ca55eaa1a
Linting / lint (pull_request) Successful in 2m19s
SonarQube Analysis / sonarqube (pull_request) Successful in 29m38s
docs(db): weigh Kysely against the Drizzle decision (#297)
The question is not whether Kysely is good, it is whether it is enough better for this codebase to reverse a decision already made in #216 and partly built in #217. That is a higher bar than being the nicer library, so this answers it against the same target #216 used: buildItemFilterSql, with six optional clauses composed at run time, a recursive CTE, an ANY(...::int[]) tag match with a count equality, and array parameters. Kysely compiles without a connection, so the document quotes the SQL it actually emitted rather than a reading of its documentation.

Three of the four hazards that src/db-drizzle/CONVENTIONS.md exists to warn about turn out to be properties of Drizzle rather than of type-safe query building, and two of them are the silent kind. An array interpolates as one bind parameter with no sql.param() ceremony, so the trap that document calls "the rule that will bite you" does not exist. A column reference inside a raw fragment is the text you wrote, so the correlated-subquery rewrite that returned a quietly wrong count in #218 cannot happen. And the generated types carry the database's own snake_case names, so the explicit column mapping that exists to stop a select silently changing the JSON contract is not needed at all. The property that motivated the whole exercise is unchanged: a hostile value lands in the parameters either way, so #202's invariant becomes a type-system property and #180's hotspots retire either way.

What decides it is how little is actually built. One file is converted — adminCategories.ts, three calls — against 238 raw query sites, and the generated mirror and its drift test are things any builder needs an equivalent of. The recommendation is to switch now, while the cost is reconverting one file and rewriting a conventions document that gets substantially shorter.

The counter-argument is recorded rather than hidden: Drizzle is more widely used, and #219's migration reasoning was measured against drizzle-kit specifically. That reasoning survives, because losing the prose and being unable to express data migrations are true of any generator, and Kysely simply has nothing to refuse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:23:22 -05:00

133 lines
4.5 KiB
TypeScript

// Kysely spike for #297. Deliberately the same target #216 set for Drizzle:
// buildItemFilterSql — six optional clauses composed at run time, a recursive
// CTE for the category subtree, an ANY(...::int[]) tag match with a count
// equality, and array parameters.
//
// Nothing here connects to a database. Kysely compiles to { sql, parameters }
// without a connection, which is the whole point: the questions being asked are
// about what SQL comes out and where the values end up.
import { Kysely, PostgresDialect, sql, SqlBool, expressionBuilder } from 'kysely';
// What kysely-codegen would generate, hand-written for the four tables this
// query touches. Note the names: snake_case, exactly as the database spells
// them and exactly as these APIs answer.
interface DB {
items: {
id: number;
category_id: number | null;
price_cents: number;
status: string;
};
item_tags: { item_id: number; tag_id: number };
categories: { id: number; parent_id: number | null };
favorites: { customer_id: number; item_id: number };
}
const db = new Kysely<DB>({
dialect: new PostgresDialect({ pool: {} as never })
});
interface SpikeFilters {
categoryIds: number[];
tagIds: number[];
minPriceCents: number | null;
maxPriceCents: number | null;
status: string[] | null;
favoritesOnly: boolean;
}
function buildItemFilterKysely(filters: SpikeFilters, favoritesCustomerId: number | null) {
const eb = expressionBuilder<DB, 'items'>();
const clauses = [];
// The recursive CTE, inside an IN (...) subquery.
//
// ${filters.categoryIds} emits a BIND PARAMETER, and — the question this
// spike exists to answer — it emits ONE parameter for the whole array, not a
// placeholder list. No sql.param() equivalent is needed.
if (filters.categoryIds.length) {
clauses.push(sql<SqlBool>`items.category_id IN (
WITH RECURSIVE subtree AS (
SELECT id FROM categories WHERE id = ANY(${filters.categoryIds}::int[])
UNION ALL
SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id
)
SELECT id FROM subtree
)`);
}
// AND, not OR: the item must carry every selected tag.
if (filters.tagIds.length) {
clauses.push(sql<SqlBool>`(
SELECT COUNT(*) FROM item_tags it
WHERE it.item_id = items.id AND it.tag_id = ANY(${filters.tagIds}::int[])
) = ${filters.tagIds.length}`);
}
if (filters.minPriceCents !== null) clauses.push(eb('items.price_cents', '>=', filters.minPriceCents));
if (filters.maxPriceCents !== null) clauses.push(eb('items.price_cents', '<=', filters.maxPriceCents));
if (filters.status !== null) clauses.push(eb('items.status', 'in', filters.status));
if (filters.favoritesOnly && favoritesCustomerId !== null) {
clauses.push(
eb.exists(
eb
.selectFrom('favorites')
.select('favorites.item_id')
.whereRef('favorites.item_id', '=', 'items.id')
.where('favorites.customer_id', '=', favoritesCustomerId)
)
);
}
return clauses;
}
function show(label: string, compiled: { sql: string; parameters: readonly unknown[] }) {
console.log(`\n=== ${label} ===`);
console.log(compiled.sql.replace(/\s+/g, ' ').trim());
console.log('parameters:', JSON.stringify(compiled.parameters));
}
const filters: SpikeFilters = {
categoryIds: [3, 7],
tagIds: [11, 12],
minPriceCents: 1000,
maxPriceCents: 50000,
status: ['available', 'reserved'],
favoritesOnly: true
};
const query = db
.selectFrom('items')
.select(['items.id', 'items.status', 'items.price_cents'])
.where((eb) => eb.and(buildItemFilterKysely(filters, 42)));
show('every clause at once', query.compile());
// Question 2: what does a hostile value do? #202's invariant is that only
// placeholder indices may reach the SQL text.
const hostile = db
.selectFrom('items')
.select('items.id')
.where('items.status', '=', "1); DROP TABLE items; --");
show('hostile value in a status filter', hostile.compile());
// Question 3: the trap that cost #218 a silently wrong count in Drizzle — a
// correlated subquery referencing a column of the outer table.
const correlated = db
.selectFrom('categories')
.select([
'categories.id',
sql<number>`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`.as('item_count')
]);
show('correlated subquery', correlated.compile());
// Question 4: does an empty array still produce one parameter?
const emptyish = db
.selectFrom('items')
.select('items.id')
.where(sql<SqlBool>`items.id = ANY(${[] as number[]}::int[])`);
show('empty array', emptyish.compile());