Fix/197 restrict scan publishing #267

Merged
bermudalamb merged 9 commits from fix/197-restrict-scan-publishing into main 2026-09-02 09:33:13 -05:00
8 changed files with 582 additions and 157 deletions
Showing only changes of commit 8e6f11111b - Show all commits
+9
View File
@@ -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/
+17 -1
View File
@@ -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 ?? ''
}
+14 -1
View File
@@ -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
+50
View File
@@ -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.
+171
View File
@@ -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
View File
@@ -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"}),
]);
+23
View File
@@ -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());
}