diff --git a/.gitignore b/.gitignore index 3b6eac3..f8c6afa 100755 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts index b3cde78..d637073 100644 --- a/backend/drizzle.config.ts +++ b/backend/drizzle.config.ts @@ -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 ?? '' } diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index ce564d5..51cc30f 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -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 diff --git a/backend/src/db-drizzle/CONVENTIONS.md b/backend/src/db-drizzle/CONVENTIONS.md new file mode 100644 index 0000000..ee444eb --- /dev/null +++ b/backend/src/db-drizzle/CONVENTIONS.md @@ -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. diff --git a/backend/src/db-drizzle/relations.ts b/backend/src/db-drizzle/relations.ts new file mode 100644 index 0000000..171897b --- /dev/null +++ b/backend/src/db-drizzle/relations.ts @@ -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] + }), +})); \ No newline at end of file diff --git a/backend/src/db-drizzle/schema.ts b/backend/src/db-drizzle/schema.ts index b769a0e..729744c 100644 --- a/backend/src/db-drizzle/schema.ts +++ b/backend/src/db-drizzle/schema.ts @@ -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"}), -]); diff --git a/backend/src/db.ts b/backend/src/db.ts index 1bf095b..446197a 100755 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -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. * diff --git a/backend/tests/integration/drizzleSchema.integration.test.ts b/backend/tests/integration/drizzleSchema.integration.test.ts new file mode 100644 index 0000000..2f681a3 --- /dev/null +++ b/backend/tests/integration/drizzleSchema.integration.test.ts @@ -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 { + 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()); +}