feat(db): land the Drizzle schema, config and conventions (#217)
Infrastructure only. No route is converted, nothing changes at run time. The mirror had already drifted, which settles how it should be maintained. schema.ts was missing item_drafts and upload_links from the moment #222 landed, because the spike pulled into ./drizzle and copied the file into src/ by hand, and nobody had reason to look at the copy for a week. So `out` now points at src/db-drizzle and pull refreshes in place — the copy step that made the drift possible is gone — and tablesFilter excludes pgmigrations, which is node-pg-migrate's bookkeeping and has no business in a model of the application's schema. A stale mirror is worse than no mirror, because Drizzle infers row types from it: a converted query would type-check against a schema the database does not have and fail at run time on a column that does not exist. drizzleSchema.integration.test.ts fails when the two disagree, on tables and on columns. It was checked by removing item_drafts from the mirror and confirming the test fails naming it, rather than trusting a green run on a file that already matched. pull also emits 0000_*.sql and meta/ into `out`, because that directory serves both purposes. Both are gitignored: this project's migration history is backend/migrations, hand-written and mostly prose, and #219 has not chosen otherwise — a stray SQL file in src/ is at best noise and at worst mistaken for real history. db is exported beside pool and shares its connections. Both must work at once, since conversion is file by file across 187 sites; separate pools would make a transaction on one invisible to the other and silently double the configured limits. The generated files are excluded from linting. #261 hand-fixed an unused-parameter warning in schema.ts and this re-pull put it straight back, which is the argument in one line: linting generated code buys a fix the next regeneration undoes. itemFilters.drizzle.ts, which is hand-written, is still linted. CONVENTIONS.md records the sql.param() array trap before anyone hits it — the wrong form type-checks, reads correctly and fails at run time as invalid Postgres — and the reason the adoption is worth doing at all, which is that ${value} emits a bind parameter and there is no way to spell "interpolate this as SQL" by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,3 +20,12 @@ test-results/
|
||||
backend/unit-results.json
|
||||
backend/integration-results.json
|
||||
frontend/playwright-results.json
|
||||
|
||||
# drizzle-kit pull writes the schema mirror into backend/src/db-drizzle (see
|
||||
# backend/drizzle.config.ts), but `out` is also where it would put generated
|
||||
# migrations and their journal. This project's migration history is
|
||||
# backend/migrations — hand-written, and mostly prose. #219 has not chosen
|
||||
# otherwise, so a stray 0000_*.sql in src/ is at best noise and at worst
|
||||
# mistaken for real migration history. Keep the mirror, drop the rest.
|
||||
backend/src/db-drizzle/*.sql
|
||||
backend/src/db-drizzle/meta/
|
||||
|
||||
@@ -8,7 +8,23 @@ import { defineConfig } from 'drizzle-kit';
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db-drizzle/schema.ts',
|
||||
out: './drizzle',
|
||||
|
||||
// `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 ?? ''
|
||||
}
|
||||
|
||||
@@ -30,7 +30,20 @@ const advisory = (config) => ({
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/**', 'coverage/**', 'eslint.config.mjs'] },
|
||||
// src/db-drizzle/schema.ts and relations.ts are `drizzle-kit pull` output, not
|
||||
// written by anyone here. #261 hand-fixed an unused-parameter warning in the
|
||||
// schema and #217's re-pull put it straight back, which is the whole argument:
|
||||
// linting generated code buys a fix that the next regeneration undoes. The
|
||||
// hand-written files in that directory are still linted.
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'coverage/**',
|
||||
'eslint.config.mjs',
|
||||
'src/db-drizzle/schema.ts',
|
||||
'src/db-drizzle/relations.ts'
|
||||
]
|
||||
},
|
||||
|
||||
...[js.configs.recommended, ...tseslint.configs.recommended, sonarjs.configs.recommended].map(
|
||||
advisory
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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: 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
|
||||
|
||||
`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.
|
||||
|
||||
## Not settled
|
||||
|
||||
Whether generated migrations replace `node-pg-migrate` is **#219**, and nothing here depends on it. The first generated migration after a pull also emitted drops and recreations of the three expression indexes, which needs hand-editing and takes real locks on a large table; and data migrations cannot be generated at all. Do not start generating migrations as a side effect of converting a query.
|
||||
@@ -0,0 +1,171 @@
|
||||
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]
|
||||
}),
|
||||
}));
|
||||
+203
-155
@@ -1,19 +1,28 @@
|
||||
import { pgTable, serial, varchar, timestamp, text, foreignKey, integer, boolean, jsonb, index, uniqueIndex, unique, primaryKey } from "drizzle-orm/pg-core"
|
||||
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 pgmigrations = pgTable("pgmigrations", {
|
||||
export const items = pgTable("items", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: varchar({ length: 255 }).notNull(),
|
||||
runOn: timestamp("run_on", { mode: 'string' }).notNull(),
|
||||
});
|
||||
|
||||
export const adminSettings = pgTable("admin_settings", {
|
||||
key: text().primaryKey().notNull(),
|
||||
value: text().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().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(),
|
||||
@@ -29,6 +38,96 @@ export const itemImages = pgTable("item_images", {
|
||||
}).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(),
|
||||
@@ -51,51 +150,27 @@ export const shippingAddresses = pgTable("shipping_addresses", {
|
||||
}).onDelete("cascade"),
|
||||
]);
|
||||
|
||||
export const items = pgTable("items", {
|
||||
export const checkouts = pgTable("checkouts", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
priceCents: integer("price_cents").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(),
|
||||
reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }),
|
||||
soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }),
|
||||
paypalOrderId: text("paypal_order_id"),
|
||||
rawEvent: jsonb("raw_event"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||
categoryId: integer("category_id"),
|
||||
conditionNote: text("condition_note"),
|
||||
}, (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"
|
||||
columns: [table.customerId],
|
||||
foreignColumns: [customers.id],
|
||||
name: "checkouts_customer_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
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(),
|
||||
}, () => [
|
||||
uniqueIndex("tags_name_uniq").using("btree", sql`lower(name)`),
|
||||
]);
|
||||
|
||||
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"),
|
||||
columns: [table.shippingAddressId],
|
||||
foreignColumns: [shippingAddresses.id],
|
||||
name: "checkouts_shipping_address_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const orders = pgTable("orders", {
|
||||
@@ -127,111 +202,84 @@ export const orders = pgTable("orders", {
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const cartItems = pgTable("cart_items", {
|
||||
export const categories = pgTable("categories", {
|
||||
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' }),
|
||||
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.cartId],
|
||||
foreignColumns: [carts.id],
|
||||
name: "cart_items_cart_id_fkey"
|
||||
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"),
|
||||
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: "cart_items_item_id_fkey"
|
||||
name: "item_drafts_item_id_fkey"
|
||||
}).onDelete("cascade"),
|
||||
unique("cart_items_item_id_key").on(table.itemId),
|
||||
]);
|
||||
|
||||
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 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 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"
|
||||
columns: [table.uploadLinkId],
|
||||
foreignColumns: [uploadLinks.id],
|
||||
name: "item_drafts_upload_link_id_fkey"
|
||||
}).onDelete("set null"),
|
||||
foreignKey({
|
||||
columns: [table.shippingAddressId],
|
||||
foreignColumns: [shippingAddresses.id],
|
||||
name: "checkouts_shipping_address_id_fkey"
|
||||
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 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 customers = pgTable("customers", {
|
||||
export const uploadLinks = pgTable("upload_links", {
|
||||
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(),
|
||||
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(),
|
||||
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),
|
||||
unique("upload_links_token_hash_key").on(table.tokenHash),
|
||||
]);
|
||||
|
||||
export const itemTags = pgTable("item_tags", {
|
||||
@@ -252,6 +300,24 @@ export const itemTags = pgTable("item_tags", {
|
||||
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(),
|
||||
@@ -270,21 +336,3 @@ export const favorites = pgTable("favorites", {
|
||||
}).onDelete("cascade"),
|
||||
primaryKey({ columns: [table.customerId, table.itemId], name: "favorites_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"}),
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from './db-drizzle/schema';
|
||||
|
||||
export const pool = new Pool({
|
||||
host: process.env.PGHOST,
|
||||
@@ -8,6 +10,27 @@ export const pool = new Pool({
|
||||
database: process.env.PGDATABASE
|
||||
});
|
||||
|
||||
/**
|
||||
* Drizzle 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
|
||||
* 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 trap that goes with it is arrays. See db-drizzle/CONVENTIONS.md.
|
||||
*/
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
/**
|
||||
* The single row a query is guaranteed to have returned.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { pool } from '../../src/db';
|
||||
import { closeDb } from './setup/testDb';
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closeDb();
|
||||
});
|
||||
|
||||
const SCHEMA = readFileSync(
|
||||
path.join(__dirname, '..', '..', 'src', 'db-drizzle', 'schema.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/** Every table name the generated mirror declares. */
|
||||
function mirroredTables(): string[] {
|
||||
return [...SCHEMA.matchAll(/pgTable\("([a-z_]+)"/g)].map((m) => m[1]!).sort();
|
||||
}
|
||||
|
||||
async function liveTables(): Promise<string[]> {
|
||||
const { rows } = await pool.query<{ table_name: string }>(
|
||||
`SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
|
||||
AND table_name <> 'pgmigrations'
|
||||
ORDER BY table_name`
|
||||
);
|
||||
return rows.map((r) => r.table_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The guard for #217.
|
||||
*
|
||||
* `src/db-drizzle/schema.ts` is generated by `drizzle-kit pull` and is a
|
||||
* read-only mirror of the real schema, which `backend/migrations` owns. Nothing
|
||||
* makes anyone re-pull after writing a migration, and that is not hypothetical:
|
||||
* the mirror sat missing `item_drafts` and `upload_links` from the moment #222
|
||||
* landed until #217, because it had been copied into src/ by hand and nobody
|
||||
* had reason to look at it.
|
||||
*
|
||||
* A stale mirror is worse than no mirror. Drizzle infers row types from it, so
|
||||
* a converted query would type-check against a schema the database does not
|
||||
* 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', () => {
|
||||
it('declares every table the migrations create', async () => {
|
||||
const live = await liveTables();
|
||||
const mirrored = mirroredTables();
|
||||
|
||||
const missing = live.filter((name) => !mirrored.includes(name));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('declares no table the database does not have', async () => {
|
||||
const live = await liveTables();
|
||||
const mirrored = mirroredTables();
|
||||
|
||||
const extra = mirrored.filter((name) => !live.includes(name));
|
||||
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.
|
||||
it('excludes node-pg-migrate bookkeeping', () => {
|
||||
expect(mirroredTables()).not.toContain('pgmigrations');
|
||||
});
|
||||
|
||||
// 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.
|
||||
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
|
||||
WHERE table_schema = 'public' AND table_name <> 'pgmigrations'
|
||||
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 missing = rows
|
||||
.filter((row) => !SCHEMA.includes(`"${row.column_name}"`))
|
||||
.filter((row) => !SCHEMA.includes(snakeToCamel(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