refactor(db): swap the query builder from Drizzle to Kysely (#305)
One commit, because a main that carries both builders is one where the next person converting a query has to guess which to reach for, and where two generated mirrors of one database can disagree. There was nothing to stage anyway: one file used the builder. The safety property that motivated adopting a builder at all is untouched, and was never the thing being traded. A value interpolated into a sql template becomes a bind parameter in either library, so #202's invariant stays a property of the type system and #180's hotspots retire either way. What changes is the three ways the old library made it easy to be quietly wrong, each verified in #297 against the SQL actually emitted: an array interpolating as a placeholder list unless every site remembered sql.param(), a column reference inside a raw fragment silently losing its table so a correlated subquery correlated with itself, and a camelCase mirror that had to be mapped back at every select or the JSON contract changed with no test noticing. CATEGORY_COLUMNS stops being a translation layer and becomes what it looks like — four column names four selects share. The generated types carry parent_id and sort_order because kysely-codegen emits the database's own names, so there is nothing left to map and nothing left to get wrong by forgetting to. The drift guard survives the swap rather than being rewritten, and loses its library name in the process: it is schemaMirror.integration.test.ts now, so the next such change renames nothing. It also got stricter for free. The Drizzle version had to match each column two ways and its own comment called that deliberately loose; a generated Kysely interface spells the database's name verbatim as a bare key, so one exact match is the whole rule and snakeToCamel is gone. isUniqueViolation keeps accepting both error shapes and now has a test behind it. Kysely uses the pg driver directly and should leave the SQLSTATE on err.code, but "should" is the word that turned two 409s into 500s when the last conversion moved it to err.cause.code with nothing failing to compile. Migrations are untouched. #219 stands, they remain hand-written node-pg-migrate files, and Kysely has no generator to refuse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
// Spike configuration (#216). Credentials come from the environment rather than
|
||||
// this file — the same rule the rest of the repo follows, and this one points at
|
||||
// a developer's local database, not a deployed one.
|
||||
//
|
||||
// DRIZZLE_DATABASE_URL=postgres://redefined_local:redefined_local@localhost:55500/redefined_local
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db-drizzle/schema.ts',
|
||||
|
||||
// `drizzle-kit pull` writes its output here, so this points at the directory
|
||||
// the application actually imports from. The spike pulled into ./drizzle and
|
||||
// copied the file into src/ by hand, and that copy drifted exactly as
|
||||
// predicted — not because anyone re-pulled, but because #222 added
|
||||
// item_drafts and upload_links and the mirror was never refreshed. Nothing
|
||||
// noticed for a week. Pulling in place removes the copy step that made that
|
||||
// possible. See #217.
|
||||
//
|
||||
// If #219 ever chooses generated migrations, they also land in `out`, and
|
||||
// this will need splitting then. It is a queries-only mirror today.
|
||||
out: './src/db-drizzle',
|
||||
|
||||
// pgmigrations is node-pg-migrate's own bookkeeping. It is not part of the
|
||||
// application's schema and has no business in a generated model of it.
|
||||
tablesFilter: ['!pgmigrations'],
|
||||
|
||||
dbCredentials: {
|
||||
url: process.env.DRIZZLE_DATABASE_URL ?? ''
|
||||
}
|
||||
});
|
||||
Generated
+198
-1094
File diff suppressed because it is too large
Load Diff
@@ -26,16 +26,17 @@
|
||||
"db:test:down": "docker compose -f docker-compose.test.yml down -v",
|
||||
"migrate:up": "node migrate.js up",
|
||||
"migrate:down": "node migrate.js down",
|
||||
"migrate:create": "node-pg-migrate create --migration-file-language js"
|
||||
"migrate:create": "node-pg-migrate create --migration-file-language js",
|
||||
"db:types": "kysely-codegen --dialect postgres --exclude-pattern pgmigrations --url \"env(KYSELY_DATABASE_URL)\" --out-file src/db-kysely/schema.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.122.0",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"kysely": "^0.28.17",
|
||||
"markdown-it": "^15.0.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^3.0.3",
|
||||
@@ -57,11 +58,11 @@
|
||||
"@types/nodemailer": "^6.4.15",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-sonarjs": "^4.2.0",
|
||||
"globals": "^17.11.0",
|
||||
"jest": "^29.7.0",
|
||||
"kysely-codegen": "^0.20.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.4",
|
||||
"tsx": "^4.16.5",
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# Drizzle conventions
|
||||
|
||||
Decided in #216, landed in #217. Read this before converting a query.
|
||||
|
||||
## What is in this directory
|
||||
|
||||
| File | Owner |
|
||||
|---|---|
|
||||
| `schema.ts` | **Generated.** `drizzle-kit pull` output. Do not hand-edit. |
|
||||
| `relations.ts` | **Generated.** Same. |
|
||||
| `itemFilters.drizzle.ts` | Hand-written. The #216 spike's conversion of `buildItemFilterSql`, kept as the worked example. |
|
||||
| `CONVENTIONS.md` | This file. |
|
||||
|
||||
`backend/migrations` owns the schema. `schema.ts` is a read-only mirror of it, and refreshing that mirror is a manual step:
|
||||
|
||||
```bash
|
||||
DRIZZLE_DATABASE_URL=postgres://user:pass@localhost:PORT/db npx drizzle-kit pull
|
||||
```
|
||||
|
||||
Run it against a database with every migration applied, after writing a migration. `drizzle-kit pull` also emits `0000_*.sql` and `meta/` into this directory because `out` serves both purposes; both are gitignored, because this project's migration history is `backend/migrations` and a stray SQL file here would at best be noise and at worst be mistaken for real history. Whether that stays true is #219.
|
||||
|
||||
`drizzleSchema.integration.test.ts` fails when the mirror and the database disagree, on tables or on columns. That test exists because the drift is silent and already happened: the mirror sat missing `item_drafts` and `upload_links` from the moment #222 landed until #217, because the spike had copied it into `src/` by hand and nobody had reason to look. A stale mirror is worse than none — Drizzle infers row types from it, so a converted query type-checks against a schema the database does not have and fails at run time on a column that does not exist.
|
||||
|
||||
## The rule that will bite you hardest: columns in a `sql` template are unqualified
|
||||
|
||||
Drizzle renders a column reference inside a `sql` template **without its table**.
|
||||
|
||||
```ts
|
||||
// WRONG. Generates: (SELECT COUNT(*)::int FROM "items" WHERE "category_id" = "id")
|
||||
// Postgres resolves both sides against items, so the subquery correlates with
|
||||
// itself and returns a plausible wrong number.
|
||||
sql`(SELECT COUNT(*)::int FROM ${items} WHERE ${items.categoryId} = ${categories.id})`
|
||||
|
||||
// RIGHT. Literal text, which is honest here because the fragment binds no values.
|
||||
sql`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`
|
||||
```
|
||||
|
||||
This is worse than the array trap below, because the array trap produces invalid SQL and fails loudly. This produces **valid SQL and quietly wrong data** — it type-checks, reads correctly, and executes without error. It was found in #218 only because an integration test asserted the count was 2 and got 1.
|
||||
|
||||
So: any converted query containing a correlated subquery or a self-join needs a test asserting **values**, not just a status code. Write that test before converting.
|
||||
|
||||
## The other one: a driver error code moves
|
||||
|
||||
Drizzle wraps driver errors. A Postgres SQLSTATE that sat on `err.code` sits on `err.cause.code` after conversion, so a `catch` keyed on it still compiles, never matches, and turns a handled 409 into a 500. `adminCategories.ts` has `isUniqueViolation`, which accepts both shapes; reuse that pattern. Revisit every SQLSTATE-keyed catch when converting a file.
|
||||
|
||||
## The rule that will bite you: arrays
|
||||
|
||||
In a Drizzle `sql` template, an array interpolates as a **placeholder list**, not as one array parameter.
|
||||
|
||||
```ts
|
||||
// WRONG. Emits ANY(($1, $2)::int[]), which is invalid Postgres.
|
||||
sql`... WHERE id = ANY(${filters.categoryIds}::int[])`
|
||||
|
||||
// RIGHT. Emits ANY($1::int[]).
|
||||
sql`... WHERE id = ANY(${sql.param(filters.categoryIds)}::int[])`
|
||||
```
|
||||
|
||||
The wrong form type-checks, reads correctly, and fails at run time. Nothing warns. Across 187 call sites this is exactly the shape of defect that passes review and breaks in production, so `sql.param()` is required for every array and any converted query taking one needs a test that actually executes it.
|
||||
|
||||
## The reason this is worth doing
|
||||
|
||||
`${value}` in a Drizzle `sql` template emits a bind parameter, never text. There is no way to spell "interpolate this value as SQL" by accident: the escape hatch that looks like a plain template literal does not behave like one. Passing `"1); DROP TABLE items; --"` as a status value puts it in the parameters and not in the SQL.
|
||||
|
||||
That makes the #202 invariant — only placeholder indices may be interpolated into a clause — a property of the type system rather than a comment guarded by two mutation tests, and it retires #180's three S2077 hotspots rather than leaving them reviewed and watched. It is the strongest argument for the adoption, and the spike confirmed it is real rather than relocated.
|
||||
|
||||
## Both drivers run at once
|
||||
|
||||
Column names differ, and the difference is load-bearing. The mirror is camelCase (`parentId`, `sortOrder`); these APIs answer in snake_case, which the admin frontend reads. So a select must map explicitly — `{ parent_id: categories.parentId }` — rather than selecting the table. Selecting the table directly changes the JSON contract silently, and no test asserting status codes notices. `adminCategories.ts` writes that mapping once as `CATEGORY_COLUMNS` and infers the row type from it, which is also how the hand-declared row interfaces are retired.
|
||||
|
||||
`db` and `pool` are exported from `src/db.ts` and share one pool, deliberately. Conversion is file by file across 187 sites, so most queries will be raw `pg` for a long time and the two must not open separate connection pools — a transaction on one would be invisible to the other, and the configured limits would silently double.
|
||||
|
||||
## Migrations stay hand-written
|
||||
|
||||
Decided in **#219**: `node-pg-migrate` keeps the schema, Drizzle is for queries only. Do not start generating migrations as a side effect of converting a query.
|
||||
|
||||
Three reasons, all measured rather than assumed. `drizzle-kit generate` cannot diff expression indexes, so adding one nullable column emitted six statements — three `DROP INDEX` and three `CREATE UNIQUE INDEX` alongside the `ALTER` — and those rebuilds take real locks on a large table. Our migrations are mostly prose, and generated SQL carries none of it: a rule that every generated migration is annotated before merge is a rule that holds for three migrations and then quietly stops, with the failure invisible because the migration still works. And data migrations cannot be generated at all, so anything touching existing rows stays hand-written regardless.
|
||||
|
||||
The workflow: write the migration by hand, then run `drizzle-kit pull` to refresh the mirror. `drizzleSchema.integration.test.ts` fails if you forget.
|
||||
@@ -1,74 +0,0 @@
|
||||
// Spike (#216): buildItemFilterSql expressed with Drizzle.
|
||||
//
|
||||
// Deliberately the hardest thing in the codebase — 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. If this cannot be said
|
||||
// cleanly, nothing else in the conversion matters.
|
||||
|
||||
import { SQL, and, gte, lte, sql, inArray, exists } from 'drizzle-orm';
|
||||
import { items, itemTags, favorites, categories } from './schema';
|
||||
|
||||
export interface SpikeFilters {
|
||||
categoryIds: number[];
|
||||
tagIds: number[];
|
||||
minPriceCents: number | null;
|
||||
maxPriceCents: number | null;
|
||||
status: string[] | null;
|
||||
favoritesOnly: boolean;
|
||||
}
|
||||
|
||||
export function buildItemFilterDrizzle(
|
||||
filters: SpikeFilters,
|
||||
favoritesCustomerId: number | null
|
||||
): SQL[] {
|
||||
const clauses: SQL[] = [];
|
||||
|
||||
// The recursive CTE. Drizzle's $with() builds statement-level CTEs; this one
|
||||
// has to sit inside an IN (...) subquery, so it stays a sql`` template.
|
||||
//
|
||||
// Note what that template does with ${filters.categoryIds}: it emits a BIND
|
||||
// PARAMETER, not text. That is the difference from a plain JS template
|
||||
// literal, and it is the whole of the #202 invariant expressed by the type
|
||||
// system rather than by a comment — there is no way to spell "interpolate
|
||||
// this value as SQL text" by accident.
|
||||
if (filters.categoryIds.length) {
|
||||
clauses.push(sql`${items.categoryId} IN (
|
||||
WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM ${categories} WHERE id = ANY(${sql.param(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, so the count of
|
||||
// matched rows has to equal the number requested.
|
||||
if (filters.tagIds.length) {
|
||||
clauses.push(sql`(
|
||||
SELECT COUNT(*) FROM ${itemTags} it
|
||||
WHERE it.item_id = ${items.id} AND it.tag_id = ANY(${sql.param(filters.tagIds)}::int[])
|
||||
) = ${filters.tagIds.length}`);
|
||||
}
|
||||
|
||||
if (filters.minPriceCents !== null) clauses.push(gte(items.priceCents, filters.minPriceCents));
|
||||
if (filters.maxPriceCents !== null) clauses.push(lte(items.priceCents, filters.maxPriceCents));
|
||||
|
||||
// inArray replaces `= ANY($n::text[])`. Drizzle emits an IN list of binds.
|
||||
if (filters.status !== null) clauses.push(inArray(items.status, filters.status));
|
||||
|
||||
if (filters.favoritesOnly) {
|
||||
if (favoritesCustomerId === null) throw new Error('favorites filter requires a customer id');
|
||||
clauses.push(
|
||||
exists(
|
||||
sql`(SELECT 1 FROM ${favorites} f WHERE f.item_id = ${items.id} AND f.customer_id = ${favoritesCustomerId})`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return clauses;
|
||||
}
|
||||
|
||||
export function combine(clauses: SQL[]): SQL | undefined {
|
||||
return clauses.length ? and(...clauses) : undefined;
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { categories, items, itemImages, customers, customerSessions, customerTokens, carts, cartItems, shippingAddresses, checkouts, orders, itemDrafts, uploadLinks, itemTags, tags, checkoutItems, favorites } from "./schema";
|
||||
|
||||
export const itemsRelations = relations(items, ({one, many}) => ({
|
||||
category: one(categories, {
|
||||
fields: [items.categoryId],
|
||||
references: [categories.id]
|
||||
}),
|
||||
itemImages: many(itemImages),
|
||||
cartItems: many(cartItems),
|
||||
orders: many(orders),
|
||||
itemDrafts: many(itemDrafts),
|
||||
itemTags: many(itemTags),
|
||||
checkoutItems: many(checkoutItems),
|
||||
favorites: many(favorites),
|
||||
}));
|
||||
|
||||
export const categoriesRelations = relations(categories, ({one, many}) => ({
|
||||
items: many(items),
|
||||
category: one(categories, {
|
||||
fields: [categories.parentId],
|
||||
references: [categories.id],
|
||||
relationName: "categories_parentId_categories_id"
|
||||
}),
|
||||
categories: many(categories, {
|
||||
relationName: "categories_parentId_categories_id"
|
||||
}),
|
||||
itemDrafts: many(itemDrafts),
|
||||
}));
|
||||
|
||||
export const itemImagesRelations = relations(itemImages, ({one}) => ({
|
||||
item: one(items, {
|
||||
fields: [itemImages.itemId],
|
||||
references: [items.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const customerSessionsRelations = relations(customerSessions, ({one}) => ({
|
||||
customer: one(customers, {
|
||||
fields: [customerSessions.customerId],
|
||||
references: [customers.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const customersRelations = relations(customers, ({many}) => ({
|
||||
customerSessions: many(customerSessions),
|
||||
customerTokens: many(customerTokens),
|
||||
carts: many(carts),
|
||||
shippingAddresses: many(shippingAddresses),
|
||||
checkouts: many(checkouts),
|
||||
orders: many(orders),
|
||||
favorites: many(favorites),
|
||||
}));
|
||||
|
||||
export const customerTokensRelations = relations(customerTokens, ({one}) => ({
|
||||
customer: one(customers, {
|
||||
fields: [customerTokens.customerId],
|
||||
references: [customers.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const cartsRelations = relations(carts, ({one, many}) => ({
|
||||
customer: one(customers, {
|
||||
fields: [carts.customerId],
|
||||
references: [customers.id]
|
||||
}),
|
||||
cartItems: many(cartItems),
|
||||
}));
|
||||
|
||||
export const cartItemsRelations = relations(cartItems, ({one}) => ({
|
||||
cart: one(carts, {
|
||||
fields: [cartItems.cartId],
|
||||
references: [carts.id]
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [cartItems.itemId],
|
||||
references: [items.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const shippingAddressesRelations = relations(shippingAddresses, ({one, many}) => ({
|
||||
customer: one(customers, {
|
||||
fields: [shippingAddresses.customerId],
|
||||
references: [customers.id]
|
||||
}),
|
||||
checkouts: many(checkouts),
|
||||
}));
|
||||
|
||||
export const checkoutsRelations = relations(checkouts, ({one, many}) => ({
|
||||
customer: one(customers, {
|
||||
fields: [checkouts.customerId],
|
||||
references: [customers.id]
|
||||
}),
|
||||
shippingAddress: one(shippingAddresses, {
|
||||
fields: [checkouts.shippingAddressId],
|
||||
references: [shippingAddresses.id]
|
||||
}),
|
||||
orders: many(orders),
|
||||
checkoutItems: many(checkoutItems),
|
||||
}));
|
||||
|
||||
export const ordersRelations = relations(orders, ({one}) => ({
|
||||
item: one(items, {
|
||||
fields: [orders.itemId],
|
||||
references: [items.id]
|
||||
}),
|
||||
customer: one(customers, {
|
||||
fields: [orders.customerId],
|
||||
references: [customers.id]
|
||||
}),
|
||||
checkout: one(checkouts, {
|
||||
fields: [orders.checkoutId],
|
||||
references: [checkouts.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const itemDraftsRelations = relations(itemDrafts, ({one}) => ({
|
||||
item: one(items, {
|
||||
fields: [itemDrafts.itemId],
|
||||
references: [items.id]
|
||||
}),
|
||||
uploadLink: one(uploadLinks, {
|
||||
fields: [itemDrafts.uploadLinkId],
|
||||
references: [uploadLinks.id]
|
||||
}),
|
||||
category: one(categories, {
|
||||
fields: [itemDrafts.aiCategoryId],
|
||||
references: [categories.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const uploadLinksRelations = relations(uploadLinks, ({many}) => ({
|
||||
itemDrafts: many(itemDrafts),
|
||||
}));
|
||||
|
||||
export const itemTagsRelations = relations(itemTags, ({one}) => ({
|
||||
item: one(items, {
|
||||
fields: [itemTags.itemId],
|
||||
references: [items.id]
|
||||
}),
|
||||
tag: one(tags, {
|
||||
fields: [itemTags.tagId],
|
||||
references: [tags.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const tagsRelations = relations(tags, ({many}) => ({
|
||||
itemTags: many(itemTags),
|
||||
}));
|
||||
|
||||
export const checkoutItemsRelations = relations(checkoutItems, ({one}) => ({
|
||||
checkout: one(checkouts, {
|
||||
fields: [checkoutItems.checkoutId],
|
||||
references: [checkouts.id]
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [checkoutItems.itemId],
|
||||
references: [items.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const favoritesRelations = relations(favorites, ({one}) => ({
|
||||
customer: one(customers, {
|
||||
fields: [favorites.customerId],
|
||||
references: [customers.id]
|
||||
}),
|
||||
item: one(items, {
|
||||
fields: [favorites.itemId],
|
||||
references: [items.id]
|
||||
}),
|
||||
}));
|
||||
@@ -1,341 +0,0 @@
|
||||
import { pgTable, index, foreignKey, serial, text, integer, timestamp, unique, boolean, jsonb, uniqueIndex, primaryKey, pgSequence } from "drizzle-orm/pg-core"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
|
||||
export const pgmigrationsIdSeq = pgSequence("pgmigrations_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false })
|
||||
|
||||
export const items = pgTable("items", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
priceCents: integer("price_cents").default(8000).notNull(),
|
||||
status: text().default('pending').notNull(),
|
||||
reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }),
|
||||
soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }),
|
||||
paypalOrderId: text("paypal_order_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
categoryId: integer("category_id"),
|
||||
}, (table) => [
|
||||
index("items_category_id_idx").using("btree", table.categoryId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.categoryId],
|
||||
foreignColumns: [categories.id],
|
||||
name: "items_category_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const itemImages = pgTable("item_images", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
itemId: integer("item_id").notNull(),
|
||||
imagePath: text("image_path").notNull(),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
originalImagePath: text("original_image_path"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "item_images_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const customerSessions = pgTable("customer_sessions", {
|
||||
token: text().primaryKey().notNull(),
|
||||
customerId: integer("customer_id").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "customer_sessions_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const customerTokens = pgTable("customer_tokens", {
|
||||
token: text().primaryKey().notNull(),
|
||||
customerId: integer("customer_id").notNull(),
|
||||
kind: text().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "customer_tokens_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const adminSettings = pgTable("admin_settings", {
|
||||
key: text().primaryKey().notNull(),
|
||||
value: text().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const customers = pgTable("customers", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
email: text().notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
marketingConsent: boolean("marketing_consent").default(false).notNull(),
|
||||
marketingConsentAt: timestamp("marketing_consent_at", { withTimezone: true, mode: 'string' }),
|
||||
marketingConsentText: text("marketing_consent_text"),
|
||||
unsubscribeToken: text("unsubscribe_token").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
disabledAt: timestamp("disabled_at", { withTimezone: true, mode: 'string' }),
|
||||
favoriteAlerts: boolean("favorite_alerts").default(false).notNull(),
|
||||
favoriteAlertsAt: timestamp("favorite_alerts_at", { withTimezone: true, mode: 'string' }),
|
||||
favoriteAlertsText: text("favorite_alerts_text"),
|
||||
firstName: text("first_name"),
|
||||
lastName: text("last_name"),
|
||||
}, (table) => [
|
||||
index("customers_disabled_at_idx").using("btree", table.disabledAt.asc().nullsLast().op("timestamptz_ops")).where(sql`(disabled_at IS NOT NULL)`),
|
||||
unique("customers_email_key").on(table.email),
|
||||
unique("customers_unsubscribe_token_key").on(table.unsubscribeToken),
|
||||
]);
|
||||
|
||||
export const carts = pgTable("carts", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
customerId: integer("customer_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "carts_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
unique("carts_customer_id_key").on(table.customerId),
|
||||
]);
|
||||
|
||||
export const cartItems = pgTable("cart_items", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
cartId: integer("cart_id").notNull(),
|
||||
itemId: integer("item_id").notNull(),
|
||||
addedAt: timestamp("added_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
lastReminderSentAt: timestamp("last_reminder_sent_at", { withTimezone: true, mode: 'string' }),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.cartId],
|
||||
foreignColumns: [carts.id],
|
||||
name: "cart_items_cart_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "cart_items_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
unique("cart_items_item_id_key").on(table.itemId),
|
||||
]);
|
||||
|
||||
export const shippingAddresses = pgTable("shipping_addresses", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
customerId: integer("customer_id").notNull(),
|
||||
fullName: text("full_name").notNull(),
|
||||
addressLine1: text("address_line1").notNull(),
|
||||
addressLine2: text("address_line2"),
|
||||
city: text().notNull(),
|
||||
state: text().notNull(),
|
||||
postalCode: text("postal_code").notNull(),
|
||||
country: text().default('US').notNull(),
|
||||
isDefault: boolean("is_default").default(false).notNull(),
|
||||
uspsValidated: boolean("usps_validated").default(false).notNull(),
|
||||
uspsStandardized: jsonb("usps_standardized"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "shipping_addresses_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const checkouts = pgTable("checkouts", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
customerId: integer("customer_id"),
|
||||
shippingAddressId: integer("shipping_address_id"),
|
||||
processor: text().notNull(),
|
||||
processorOrderId: text("processor_order_id"),
|
||||
amountCents: integer("amount_cents"),
|
||||
status: text().default('pending').notNull(),
|
||||
rawEvent: jsonb("raw_event"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "checkouts_customer_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
foreignKey({
|
||||
columns: [table.shippingAddressId],
|
||||
foreignColumns: [shippingAddresses.id],
|
||||
name: "checkouts_shipping_address_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const orders = pgTable("orders", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
itemId: integer("item_id"),
|
||||
customerId: integer("customer_id"),
|
||||
checkoutId: integer("checkout_id"),
|
||||
processor: text().notNull(),
|
||||
processorOrderId: text("processor_order_id"),
|
||||
amountCents: integer("amount_cents"),
|
||||
status: text(),
|
||||
rawEvent: jsonb("raw_event"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "orders_item_id_fkey"
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "orders_customer_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
foreignKey({
|
||||
columns: [table.checkoutId],
|
||||
foreignColumns: [checkouts.id],
|
||||
name: "orders_checkout_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const categories = pgTable("categories", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
parentId: integer("parent_id"),
|
||||
sortOrder: integer("sort_order").default(0).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex("categories_child_name_uniq").using("btree", sql`parent_id`, sql`lower(name)`).where(sql`(parent_id IS NOT NULL)`),
|
||||
index("categories_parent_id_idx").using("btree", table.parentId.asc().nullsLast().op("int4_ops")),
|
||||
uniqueIndex("categories_root_name_uniq").using("btree", sql`lower(name)`).where(sql`(parent_id IS NULL)`),
|
||||
foreignKey({
|
||||
columns: [table.parentId],
|
||||
foreignColumns: [table.id],
|
||||
name: "categories_parent_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const tags = pgTable("tags", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
color: text().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex("tags_name_uniq").using("btree", sql`lower(name)`),
|
||||
]);
|
||||
|
||||
export const itemDrafts = pgTable("item_drafts", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
itemId: integer("item_id").notNull(),
|
||||
uploadLinkId: integer("upload_link_id"),
|
||||
submitterNote: text("submitter_note"),
|
||||
removeBackground: boolean("remove_background").default(true).notNull(),
|
||||
state: text().default('queued').notNull(),
|
||||
attempts: integer().default(0).notNull(),
|
||||
model: text(),
|
||||
aiName: text("ai_name"),
|
||||
aiDescription: text("ai_description"),
|
||||
aiCategoryId: integer("ai_category_id"),
|
||||
aiTagNames: text("ai_tag_names").array(),
|
||||
aiSuggestedPriceCents: integer("ai_suggested_price_cents"),
|
||||
priceSource: text("price_source").default('default').notNull(),
|
||||
aiError: text("ai_error"),
|
||||
inputTokens: integer("input_tokens"),
|
||||
outputTokens: integer("output_tokens"),
|
||||
costMicros: integer("cost_micros"),
|
||||
draftedAt: timestamp("drafted_at", { withTimezone: true, mode: 'string' }),
|
||||
reviewedAt: timestamp("reviewed_at", { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index("item_drafts_state_idx").using("btree", table.state.asc().nullsLast().op("text_ops")),
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "item_drafts_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.uploadLinkId],
|
||||
foreignColumns: [uploadLinks.id],
|
||||
name: "item_drafts_upload_link_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
foreignKey({
|
||||
columns: [table.aiCategoryId],
|
||||
foreignColumns: [categories.id],
|
||||
name: "item_drafts_ai_category_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
unique("item_drafts_item_id_key").on(table.itemId),
|
||||
]);
|
||||
|
||||
export const uploadLinks = pgTable("upload_links", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
label: text().notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true, mode: 'string' }),
|
||||
submissionCount: integer("submission_count").default(0).notNull(),
|
||||
maxSubmissions: integer("max_submissions"),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: 'string' }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
contactEmail: text("contact_email"),
|
||||
}, (table) => [
|
||||
unique("upload_links_token_hash_key").on(table.tokenHash),
|
||||
]);
|
||||
|
||||
export const itemTags = pgTable("item_tags", {
|
||||
itemId: integer("item_id").notNull(),
|
||||
tagId: integer("tag_id").notNull(),
|
||||
}, (table) => [
|
||||
index("item_tags_tag_id_idx").using("btree", table.tagId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "item_tags_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.tagId],
|
||||
foreignColumns: [tags.id],
|
||||
name: "item_tags_tag_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
primaryKey({ columns: [table.itemId, table.tagId], name: "item_tags_pkey"}),
|
||||
]);
|
||||
|
||||
export const checkoutItems = pgTable("checkout_items", {
|
||||
checkoutId: integer("checkout_id").notNull(),
|
||||
itemId: integer("item_id").notNull(),
|
||||
priceCents: integer("price_cents").notNull(),
|
||||
}, (table) => [
|
||||
foreignKey({
|
||||
columns: [table.checkoutId],
|
||||
foreignColumns: [checkouts.id],
|
||||
name: "checkout_items_checkout_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "checkout_items_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
primaryKey({ columns: [table.checkoutId, table.itemId], name: "checkout_items_pkey"}),
|
||||
]);
|
||||
|
||||
export const favorites = pgTable("favorites", {
|
||||
customerId: integer("customer_id").notNull(),
|
||||
itemId: integer("item_id").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index("favorites_item_id_idx").using("btree", table.itemId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "favorites_customer_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
foreignKey({
|
||||
columns: [table.itemId],
|
||||
foreignColumns: [items.id],
|
||||
name: "favorites_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
primaryKey({ columns: [table.customerId, table.itemId], name: "favorites_pkey"}),
|
||||
]);
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* This file was generated by kysely-codegen.
|
||||
* Please do not edit it manually.
|
||||
*/
|
||||
|
||||
import type { ColumnType } from "kysely";
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
export type Json = JsonValue;
|
||||
|
||||
export type JsonArray = JsonValue[];
|
||||
|
||||
export type JsonObject = {
|
||||
[x: string]: JsonValue | undefined;
|
||||
};
|
||||
|
||||
export type JsonPrimitive = boolean | number | string | null;
|
||||
|
||||
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
|
||||
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
export interface AdminSettings {
|
||||
key: string;
|
||||
updated_at: Generated<Timestamp>;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface CartItems {
|
||||
added_at: Generated<Timestamp>;
|
||||
cart_id: number;
|
||||
expires_at: Timestamp;
|
||||
id: Generated<number>;
|
||||
item_id: number;
|
||||
last_reminder_sent_at: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface Carts {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
id: Generated<number>;
|
||||
updated_at: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Categories {
|
||||
created_at: Generated<Timestamp>;
|
||||
id: Generated<number>;
|
||||
name: string;
|
||||
parent_id: number | null;
|
||||
sort_order: Generated<number>;
|
||||
}
|
||||
|
||||
export interface CheckoutItems {
|
||||
checkout_id: number;
|
||||
item_id: number;
|
||||
price_cents: number;
|
||||
}
|
||||
|
||||
export interface Checkouts {
|
||||
amount_cents: number | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number | null;
|
||||
id: Generated<number>;
|
||||
processor: string;
|
||||
processor_order_id: string | null;
|
||||
raw_event: Json | null;
|
||||
shipping_address_id: number | null;
|
||||
status: Generated<string>;
|
||||
}
|
||||
|
||||
export interface Customers {
|
||||
created_at: Generated<Timestamp>;
|
||||
disabled_at: Timestamp | null;
|
||||
email: string;
|
||||
email_verified: Generated<boolean>;
|
||||
favorite_alerts: Generated<boolean>;
|
||||
favorite_alerts_at: Timestamp | null;
|
||||
favorite_alerts_text: string | null;
|
||||
first_name: string | null;
|
||||
id: Generated<number>;
|
||||
last_name: string | null;
|
||||
marketing_consent: Generated<boolean>;
|
||||
marketing_consent_at: Timestamp | null;
|
||||
marketing_consent_text: string | null;
|
||||
password_hash: string;
|
||||
unsubscribe_token: string;
|
||||
}
|
||||
|
||||
export interface CustomerSessions {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
expires_at: Timestamp;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CustomerTokens {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
expires_at: Timestamp;
|
||||
kind: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface Favorites {
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
item_id: number;
|
||||
}
|
||||
|
||||
export interface ItemDrafts {
|
||||
ai_category_id: number | null;
|
||||
ai_description: string | null;
|
||||
ai_error: string | null;
|
||||
ai_name: string | null;
|
||||
ai_suggested_price_cents: number | null;
|
||||
ai_tag_names: string[] | null;
|
||||
attempts: Generated<number>;
|
||||
cost_micros: number | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
drafted_at: Timestamp | null;
|
||||
id: Generated<number>;
|
||||
input_tokens: number | null;
|
||||
item_id: number;
|
||||
model: string | null;
|
||||
output_tokens: number | null;
|
||||
price_source: Generated<string>;
|
||||
remove_background: Generated<boolean>;
|
||||
reviewed_at: Timestamp | null;
|
||||
state: Generated<string>;
|
||||
submitter_note: string | null;
|
||||
upload_link_id: number | null;
|
||||
}
|
||||
|
||||
export interface ItemImages {
|
||||
created_at: Generated<Timestamp>;
|
||||
id: Generated<number>;
|
||||
image_path: string;
|
||||
item_id: number;
|
||||
original_image_path: string | null;
|
||||
sort_order: Generated<number>;
|
||||
}
|
||||
|
||||
export interface Items {
|
||||
category_id: number | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
description: string | null;
|
||||
id: Generated<number>;
|
||||
name: string;
|
||||
paypal_order_id: string | null;
|
||||
price_cents: Generated<number>;
|
||||
reserved_until: Timestamp | null;
|
||||
sold_at: Timestamp | null;
|
||||
status: Generated<string>;
|
||||
}
|
||||
|
||||
export interface ItemTags {
|
||||
item_id: number;
|
||||
tag_id: number;
|
||||
}
|
||||
|
||||
export interface Orders {
|
||||
amount_cents: number | null;
|
||||
checkout_id: number | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number | null;
|
||||
id: Generated<number>;
|
||||
item_id: number | null;
|
||||
processor: string;
|
||||
processor_order_id: string | null;
|
||||
raw_event: Json | null;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
export interface ShippingAddresses {
|
||||
address_line1: string;
|
||||
address_line2: string | null;
|
||||
city: string;
|
||||
country: Generated<string>;
|
||||
created_at: Generated<Timestamp>;
|
||||
customer_id: number;
|
||||
full_name: string;
|
||||
id: Generated<number>;
|
||||
is_default: Generated<boolean>;
|
||||
postal_code: string;
|
||||
state: string;
|
||||
usps_standardized: Json | null;
|
||||
usps_validated: Generated<boolean>;
|
||||
}
|
||||
|
||||
export interface Tags {
|
||||
color: string;
|
||||
created_at: Generated<Timestamp>;
|
||||
id: Generated<number>;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UploadLinks {
|
||||
contact_email: string | null;
|
||||
created_at: Generated<Timestamp>;
|
||||
id: Generated<number>;
|
||||
label: string;
|
||||
last_used_at: Timestamp | null;
|
||||
max_submissions: number | null;
|
||||
revoked_at: Timestamp | null;
|
||||
submission_count: Generated<number>;
|
||||
token_hash: string;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
admin_settings: AdminSettings;
|
||||
cart_items: CartItems;
|
||||
carts: Carts;
|
||||
categories: Categories;
|
||||
checkout_items: CheckoutItems;
|
||||
checkouts: Checkouts;
|
||||
customer_sessions: CustomerSessions;
|
||||
customer_tokens: CustomerTokens;
|
||||
customers: Customers;
|
||||
favorites: Favorites;
|
||||
item_drafts: ItemDrafts;
|
||||
item_images: ItemImages;
|
||||
item_tags: ItemTags;
|
||||
items: Items;
|
||||
orders: Orders;
|
||||
shipping_addresses: ShippingAddresses;
|
||||
tags: Tags;
|
||||
upload_links: UploadLinks;
|
||||
}
|
||||
+20
-16
@@ -1,6 +1,6 @@
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from './db-drizzle/schema';
|
||||
import { Kysely, PostgresDialect } from 'kysely';
|
||||
import type { DB } from './db-kysely/schema';
|
||||
|
||||
export const pool = new Pool({
|
||||
host: process.env.PGHOST,
|
||||
@@ -11,25 +11,29 @@ export const pool = new Pool({
|
||||
});
|
||||
|
||||
/**
|
||||
* Drizzle over the same pool, alongside `pool` rather than instead of it.
|
||||
* Kysely over the same pool, alongside `pool` rather than instead of it.
|
||||
*
|
||||
* Both have to work at once: the conversion decided in #216 is file by file
|
||||
* across 187 call sites, so for a long time most queries will still be raw `pg`
|
||||
* and the two must share one set of connections. Handing drizzle the existing
|
||||
* pool rather than letting it open its own is what makes that true — otherwise
|
||||
* a transaction started on one would be invisible to the other, and the pool
|
||||
* Both have to work at once: the conversion is file by file across 238 call
|
||||
* sites, so for a long time most queries will still be raw `pg` and the two
|
||||
* must share one set of connections. Handing Kysely the existing pool rather
|
||||
* than letting it open its own is what makes that true — otherwise a
|
||||
* transaction started on one would be invisible to the other, and the pool
|
||||
* limits would silently double.
|
||||
*
|
||||
* The value of this over raw `pg` is not brevity. In a Drizzle `sql` template
|
||||
* `${value}` emits a **bind parameter**, never text, so there is no way to
|
||||
* spell "interpolate this as SQL" by accident — the escape hatch that looks
|
||||
* like a plain template literal does not behave like one. That makes the #202
|
||||
* invariant structural instead of a comment plus two mutation tests, and it is
|
||||
* the main reason this adoption is worth doing.
|
||||
* The value of this over raw `pg` is not brevity. In a Kysely `sql` template
|
||||
* `${value}` emits a bind parameter, never text, so there is no way to spell
|
||||
* "interpolate this as SQL" by accident. That makes the #202 invariant
|
||||
* structural instead of a comment plus two mutation tests, and it is the main
|
||||
* reason a builder is here at all.
|
||||
*
|
||||
* The trap that goes with it is arrays. See db-drizzle/CONVENTIONS.md.
|
||||
* Kysely rather than Drizzle since #305. The safety property above was true of
|
||||
* both; what decided it is that three of the four hazards in the old
|
||||
* CONVENTIONS.md — an array needing sql.param(), a column reference silently
|
||||
* losing its table inside a raw fragment, and a camelCase mirror that had to be
|
||||
* mapped back at every select — were properties of Drizzle rather than of
|
||||
* type-safe query building. See #297 for the SQL each one actually emitted.
|
||||
*/
|
||||
export const db = drizzle(pool, { schema });
|
||||
export const db = new Kysely<DB>({ dialect: new PostgresDialect({ pool }) });
|
||||
|
||||
/**
|
||||
* The single row a query is guaranteed to have returned.
|
||||
|
||||
@@ -1,38 +1,28 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { eq, inArray, sql } from 'drizzle-orm';
|
||||
import { sql } from 'kysely';
|
||||
import { db, requireRow } from '../db';
|
||||
import { categories, items } from '../db-drizzle/schema';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
|
||||
/**
|
||||
* The first file converted to Drizzle (#218), chosen because it is awkward
|
||||
* rather than because it is easy — nine sites including a recursive CTE and an
|
||||
* array match. See src/db-drizzle/CONVENTIONS.md.
|
||||
* The one file using the builder (#218, reconverted for Kysely in #305), chosen
|
||||
* because it is awkward rather than because it is easy — a recursive CTE, a
|
||||
* correlated count, and an array match.
|
||||
*
|
||||
* The pool is still available and most of the application still uses it. This
|
||||
* is one file moving, not a cutover.
|
||||
* is one file converted, not a cutover. See src/db-kysely/CONVENTIONS.md.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The response shape, written once.
|
||||
* The four columns this API answers with, named once.
|
||||
*
|
||||
* The generated mirror names columns in camelCase — `parentId`, `sortOrder` —
|
||||
* and this API answers in snake_case, which the admin frontend reads. So the
|
||||
* mapping is explicit here rather than implicit anywhere: selecting the table
|
||||
* directly would silently change the JSON contract, and no test that checks
|
||||
* status codes would catch it.
|
||||
*
|
||||
* It also answers the question #218 asked. The row type is inferred from this
|
||||
* object rather than hand-declared beside the query, so the interfaces that used
|
||||
* to sit at the top of this file are gone and cannot drift from what is
|
||||
* selected.
|
||||
* Under Drizzle this was a mapping — `{ parent_id: categories.parentId }` — and
|
||||
* it existed because the generated mirror was camelCase while this API answers
|
||||
* snake_case, so selecting the table directly changed the JSON contract with no
|
||||
* test noticing. The generated types now carry the database's own names, so
|
||||
* there is nothing left to translate and this is just a list of columns four
|
||||
* selects happen to share.
|
||||
*/
|
||||
const CATEGORY_COLUMNS = {
|
||||
id: categories.id,
|
||||
name: categories.name,
|
||||
parent_id: categories.parentId,
|
||||
sort_order: categories.sortOrder
|
||||
};
|
||||
const CATEGORY_COLUMNS = ['id', 'name', 'parent_id', 'sort_order'] as const;
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -43,11 +33,13 @@ const UNIQUE_VIOLATION = '23505';
|
||||
/**
|
||||
* Whether a thrown error is that unique violation.
|
||||
*
|
||||
* Drizzle wraps driver errors, so the SQLSTATE that used to sit on `err.code`
|
||||
* now sits on `err.cause.code`. The old check still compiled and simply never
|
||||
* matched, turning two 409s into 500s — a conversion hazard with no type error
|
||||
* and no failing build behind it, only two integration tests. Both shapes are
|
||||
* accepted so this keeps working either side of a conversion. See #218.
|
||||
* Both shapes are accepted deliberately. Drizzle wrapped driver errors, moving
|
||||
* this SQLSTATE from `err.code` to `err.cause.code`, and the check that only
|
||||
* looked at `err.code` still compiled, never matched, and turned two 409s into
|
||||
* 500s — a conversion hazard with no type error behind it. Kysely uses the `pg`
|
||||
* driver directly and is expected to leave it on `err.code`, but "expected" is
|
||||
* the word that caused the bug last time, so the tolerant check stays and an
|
||||
* integration test proves the 409 rather than assuming it. See #218, #305.
|
||||
*/
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const direct = (err as { code?: string }).code;
|
||||
@@ -59,12 +51,11 @@ function isUniqueViolation(err: unknown): boolean {
|
||||
* Walks down from a node, collecting it and every descendant. Used both for
|
||||
* cycle detection on reparent and for reporting the blast radius of a delete.
|
||||
*
|
||||
* Still a `sql` template. Drizzle has `$with()` for CTEs, but this one is
|
||||
* recursive and is consumed in two different shapes, and expressing it through
|
||||
* the builder bought nothing over the SQL that is already correct and reviewed.
|
||||
* The important part is that `${id}` here is a bind parameter, not text — there
|
||||
* is no way to spell string interpolation in this template by accident, which is
|
||||
* the property the whole adoption is for.
|
||||
* Still a `sql` template: the CTE is recursive and is consumed in two different
|
||||
* shapes, and expressing it through the builder buys nothing over SQL that is
|
||||
* already correct and reviewed. The important part is that `${id}` is a bind
|
||||
* parameter, not text — there is no way to spell string interpolation in this
|
||||
* template by accident, which is the property the whole adoption is for.
|
||||
*/
|
||||
const subtreeOf = (id: number) => sql`
|
||||
WITH RECURSIVE subtree AS (
|
||||
@@ -89,28 +80,32 @@ function readParentId(value: unknown): number | null | undefined {
|
||||
}
|
||||
|
||||
async function parentExists(id: number): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
const row = await db
|
||||
.selectFrom('categories')
|
||||
.select('id')
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
...CATEGORY_COLUMNS,
|
||||
// Written as literal SQL, NOT with ${items.categoryId} and
|
||||
// ${categories.id}. Drizzle renders a column reference inside a sql
|
||||
// template UNQUALIFIED — those two produced `WHERE "category_id" = "id"`,
|
||||
// which Postgres resolved against items for both sides and answered with
|
||||
// a plausible wrong number rather than an error. There are no values to
|
||||
// bind in this fragment, so literal text is the honest form. See #218.
|
||||
item_count: sql<number>`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`
|
||||
})
|
||||
.from(categories)
|
||||
.orderBy(categories.sortOrder, sql`lower(categories.name)`);
|
||||
.selectFrom('categories')
|
||||
.select(CATEGORY_COLUMNS)
|
||||
// Literal text rather than interpolated column references, and here that is
|
||||
// a free choice rather than a workaround: the fragment binds no values, so
|
||||
// there is nothing to parameterize. Under Drizzle this had to be literal,
|
||||
// because interpolating the columns rendered them unqualified and Postgres
|
||||
// resolved both sides against items, answering with a plausible wrong
|
||||
// number rather than an error (#218).
|
||||
.select(
|
||||
sql<number>`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)`.as(
|
||||
'item_count'
|
||||
)
|
||||
)
|
||||
.orderBy('sort_order')
|
||||
.orderBy(sql`lower(categories.name)`)
|
||||
.execute();
|
||||
|
||||
res.json(rows);
|
||||
}));
|
||||
@@ -134,9 +129,10 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.insert(categories)
|
||||
.values({ name, parentId: parent, sortOrder })
|
||||
.returning(CATEGORY_COLUMNS);
|
||||
.insertInto('categories')
|
||||
.values({ name, parent_id: parent, sort_order: sortOrder })
|
||||
.returning(CATEGORY_COLUMNS)
|
||||
.execute();
|
||||
|
||||
res.status(201).json({ ...requireRow(rows, 'the category INSERT'), item_count: 0 });
|
||||
} catch (err) {
|
||||
@@ -175,9 +171,9 @@ async function resolveParentId(
|
||||
|
||||
// Moving a node beneath itself or one of its own descendants would detach
|
||||
// that whole branch from the tree into an unreachable cycle.
|
||||
const cycle = await db.execute(
|
||||
sql`${subtreeOf(id)} SELECT 1 FROM subtree WHERE id = ${parsed}`
|
||||
);
|
||||
const cycle = await sql<{ found: number }>`
|
||||
${subtreeOf(id)} SELECT 1 AS found FROM subtree WHERE id = ${parsed}
|
||||
`.execute(db);
|
||||
if (cycle.rows.length) {
|
||||
return { error: 'a category cannot be moved beneath itself' };
|
||||
}
|
||||
@@ -187,13 +183,12 @@ async function resolveParentId(
|
||||
|
||||
router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = await db
|
||||
const current = await db
|
||||
.selectFrom('categories')
|
||||
.select(CATEGORY_COLUMNS)
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.limit(1);
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
|
||||
const current = existing[0];
|
||||
if (!current) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
@@ -219,10 +214,11 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.update(categories)
|
||||
.set({ name, parentId: parent, sortOrder })
|
||||
.where(eq(categories.id, id))
|
||||
.returning(CATEGORY_COLUMNS);
|
||||
.updateTable('categories')
|
||||
.set({ name, parent_id: parent, sort_order: sortOrder })
|
||||
.where('id', '=', id)
|
||||
.returning(CATEGORY_COLUMNS)
|
||||
.execute();
|
||||
|
||||
res.json(requireRow(rows, 'the category UPDATE'));
|
||||
} catch (err) {
|
||||
@@ -236,27 +232,28 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
|
||||
const subtree = await db.execute<{ id: number }>(
|
||||
sql`${subtreeOf(id)} SELECT id FROM subtree`
|
||||
);
|
||||
const subtree = await sql<{ id: number }>`
|
||||
${subtreeOf(id)} SELECT id FROM subtree
|
||||
`.execute(db);
|
||||
if (!subtree.rows.length) {
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
const ids = subtree.rows.map((row) => row.id);
|
||||
|
||||
// inArray rather than the ANY(...::int[]) this replaced, which sidesteps the
|
||||
// array trap in CONVENTIONS.md entirely: there is no template to forget
|
||||
// sql.param() in. The builder emits the placeholder list itself and it is
|
||||
// correct by construction.
|
||||
// `in` rather than the ANY(...::int[]) this replaced. Kysely emits the
|
||||
// placeholder list itself, so it is correct by construction and there is no
|
||||
// template to forget anything in. `ids` is never empty — the length check
|
||||
// above returned already if it were.
|
||||
const affected = await db
|
||||
.select({ n: sql<number>`COUNT(*)::int` })
|
||||
.from(items)
|
||||
.where(inArray(items.categoryId, ids));
|
||||
.selectFrom('items')
|
||||
.select(sql<number>`COUNT(*)::int`.as('n'))
|
||||
.where('category_id', 'in', ids)
|
||||
.execute();
|
||||
|
||||
// The FK cascade takes the descendants; items fall back to NULL rather than
|
||||
// being deleted along with their category.
|
||||
await db.delete(categories).where(eq(categories.id, id));
|
||||
await db.deleteFrom('categories').where('id', '=', id).execute();
|
||||
|
||||
res.json({
|
||||
deleted_categories: ids.length,
|
||||
|
||||
@@ -158,6 +158,20 @@ describe('admin categories', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.category_id).toBeNull();
|
||||
});
|
||||
|
||||
// The conversion hazard from #218, asserted rather than assumed. Drizzle
|
||||
// wrapped driver errors and moved the unique-violation SQLSTATE from
|
||||
// err.code to err.cause.code, which turned this 409 into a 500 with nothing
|
||||
// failing to compile. #305 changed builder again, so this proves where the
|
||||
// code actually lands rather than trusting that Kysely leaves it alone.
|
||||
it('refuses a duplicate sibling name with 409, not 500', async () => {
|
||||
await createCategory('Duplicate me');
|
||||
|
||||
const res = await request(app).post('/api/admin/categories').send({ name: 'Duplicate me' });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toMatch(/already exists/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin tags', () => {
|
||||
|
||||
+25
-17
@@ -9,13 +9,22 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
const SCHEMA = readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'db-drizzle', 'schema.ts'),
|
||||
path.join(__dirname, '..', '..', 'src', 'db-kysely', 'schema.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/** Every table name the generated mirror declares. */
|
||||
/**
|
||||
* Every table name the generated mirror declares.
|
||||
*
|
||||
* Read from the `DB` interface, which is the one place kysely-codegen lists
|
||||
* them all, mapping the database's own snake_case name to the interface for
|
||||
* that table. Parsing the file as text rather than importing it is deliberate
|
||||
* and unchanged from the Drizzle version: these are TypeScript types, erased at
|
||||
* run time, so there is nothing to import and inspect.
|
||||
*/
|
||||
function mirroredTables(): string[] {
|
||||
return [...SCHEMA.matchAll(/pgTable\("([a-z_]+)"/g)].map((m) => m[1]!).sort();
|
||||
const block = /export interface DB \{([^}]*)\}/.exec(SCHEMA)?.[1] ?? '';
|
||||
return [...block.matchAll(/^\s*([a-z_]+):/gm)].map((m) => m[1]!).sort();
|
||||
}
|
||||
|
||||
async function liveTables(): Promise<string[]> {
|
||||
@@ -43,7 +52,7 @@ async function liveTables(): Promise<string[]> {
|
||||
* have and fail at run time with a column that does not exist — the exact class
|
||||
* of drift the adoption was meant to close.
|
||||
*/
|
||||
describe('the Drizzle schema mirror', () => {
|
||||
describe('the generated schema mirror', () => {
|
||||
it('declares every table the migrations create', async () => {
|
||||
const live = await liveTables();
|
||||
const mirrored = mirroredTables();
|
||||
@@ -60,8 +69,9 @@ describe('the Drizzle schema mirror', () => {
|
||||
expect(extra).toEqual([]);
|
||||
});
|
||||
|
||||
// pgmigrations is node-pg-migrate's bookkeeping, excluded by tablesFilter in
|
||||
// drizzle.config.ts. A re-pull without that filter would quietly put it back.
|
||||
// pgmigrations is node-pg-migrate's bookkeeping, excluded by
|
||||
// --exclude-pattern in the db:types script. A regeneration without that flag
|
||||
// would quietly put it back.
|
||||
it('excludes node-pg-migrate bookkeeping', () => {
|
||||
expect(mirroredTables()).not.toContain('pgmigrations');
|
||||
});
|
||||
@@ -69,6 +79,11 @@ describe('the Drizzle schema mirror', () => {
|
||||
// Columns, not just tables: a migration that adds a column to a table the
|
||||
// mirror already knows about is the likelier drift, and the one a table-level
|
||||
// check would wave through.
|
||||
//
|
||||
// One exact match, where the Drizzle version needed two and called itself
|
||||
// "deliberately loose" for it. kysely-codegen emits the database's own name
|
||||
// as a bare interface key, so ` column_name:` at the start of a line is the
|
||||
// whole of the naming rule and there is nothing to re-implement.
|
||||
it('declares every column of every table it mirrors', async () => {
|
||||
const { rows } = await pool.query<{ table_name: string; column_name: string }>(
|
||||
`SELECT table_name, column_name FROM information_schema.columns
|
||||
@@ -76,20 +91,13 @@ describe('the Drizzle schema mirror', () => {
|
||||
ORDER BY table_name, column_name`
|
||||
);
|
||||
|
||||
// The mirror names a column either as a bare camelCase key or, when the
|
||||
// database name differs, as an explicit string argument. Checking the
|
||||
// snake_case name appears anywhere in the file is deliberately loose: it
|
||||
// catches a column the mirror has never heard of, which is the failure that
|
||||
// matters, without re-implementing drizzle-kit's naming rules.
|
||||
const declared = new Set(
|
||||
[...SCHEMA.matchAll(/^\s{2}([a-z0-9_]+):/gm)].map((m) => m[1]!)
|
||||
);
|
||||
const missing = rows
|
||||
.filter((row) => !SCHEMA.includes(`"${row.column_name}"`))
|
||||
.filter((row) => !SCHEMA.includes(snakeToCamel(row.column_name)))
|
||||
.filter((row) => !declared.has(row.column_name))
|
||||
.map((row) => `${row.table_name}.${row.column_name}`);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function snakeToCamel(name: string): string {
|
||||
return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
Reference in New Issue
Block a user