Fix/197 restrict scan publishing #267
@@ -20,3 +20,12 @@ test-results/
|
|||||||
backend/unit-results.json
|
backend/unit-results.json
|
||||||
backend/integration-results.json
|
backend/integration-results.json
|
||||||
frontend/playwright-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({
|
export default defineConfig({
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
schema: './src/db-drizzle/schema.ts',
|
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: {
|
dbCredentials: {
|
||||||
url: process.env.DRIZZLE_DATABASE_URL ?? ''
|
url: process.env.DRIZZLE_DATABASE_URL ?? ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,20 @@ const advisory = (config) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default tseslint.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(
|
...[js.configs.recommended, ...tseslint.configs.recommended, sonarjs.configs.recommended].map(
|
||||||
advisory
|
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"
|
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(),
|
id: serial().primaryKey().notNull(),
|
||||||
name: varchar({ length: 255 }).notNull(),
|
name: text().notNull(),
|
||||||
runOn: timestamp("run_on", { mode: 'string' }).notNull(),
|
description: text(),
|
||||||
});
|
priceCents: integer("price_cents").default(8000).notNull(),
|
||||||
|
status: text().default('pending').notNull(),
|
||||||
export const adminSettings = pgTable("admin_settings", {
|
reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }),
|
||||||
key: text().primaryKey().notNull(),
|
soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }),
|
||||||
value: text().notNull(),
|
paypalOrderId: text("paypal_order_id"),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
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", {
|
export const itemImages = pgTable("item_images", {
|
||||||
id: serial().primaryKey().notNull(),
|
id: serial().primaryKey().notNull(),
|
||||||
@@ -29,6 +38,96 @@ export const itemImages = pgTable("item_images", {
|
|||||||
}).onDelete("cascade"),
|
}).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", {
|
export const shippingAddresses = pgTable("shipping_addresses", {
|
||||||
id: serial().primaryKey().notNull(),
|
id: serial().primaryKey().notNull(),
|
||||||
customerId: integer("customer_id").notNull(),
|
customerId: integer("customer_id").notNull(),
|
||||||
@@ -51,51 +150,27 @@ export const shippingAddresses = pgTable("shipping_addresses", {
|
|||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const items = pgTable("items", {
|
export const checkouts = pgTable("checkouts", {
|
||||||
id: serial().primaryKey().notNull(),
|
id: serial().primaryKey().notNull(),
|
||||||
name: text().notNull(),
|
customerId: integer("customer_id"),
|
||||||
description: text(),
|
shippingAddressId: integer("shipping_address_id"),
|
||||||
priceCents: integer("price_cents").notNull(),
|
processor: text().notNull(),
|
||||||
|
processorOrderId: text("processor_order_id"),
|
||||||
|
amountCents: integer("amount_cents"),
|
||||||
status: text().default('pending').notNull(),
|
status: text().default('pending').notNull(),
|
||||||
reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }),
|
rawEvent: jsonb("raw_event"),
|
||||||
soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }),
|
|
||||||
paypalOrderId: text("paypal_order_id"),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
categoryId: integer("category_id"),
|
|
||||||
conditionNote: text("condition_note"),
|
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index("items_category_id_idx").using("btree", table.categoryId.asc().nullsLast().op("int4_ops")),
|
|
||||||
foreignKey({
|
foreignKey({
|
||||||
columns: [table.categoryId],
|
columns: [table.customerId],
|
||||||
foreignColumns: [categories.id],
|
foreignColumns: [customers.id],
|
||||||
name: "items_category_id_fkey"
|
name: "checkouts_customer_id_fkey"
|
||||||
}).onDelete("set null"),
|
}).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({
|
foreignKey({
|
||||||
columns: [table.parentId],
|
columns: [table.shippingAddressId],
|
||||||
foreignColumns: [table.id],
|
foreignColumns: [shippingAddresses.id],
|
||||||
name: "categories_parent_id_fkey"
|
name: "checkouts_shipping_address_id_fkey"
|
||||||
}).onDelete("cascade"),
|
}).onDelete("set null"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const orders = pgTable("orders", {
|
export const orders = pgTable("orders", {
|
||||||
@@ -127,111 +202,84 @@ export const orders = pgTable("orders", {
|
|||||||
}).onDelete("set null"),
|
}).onDelete("set null"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const cartItems = pgTable("cart_items", {
|
export const categories = pgTable("categories", {
|
||||||
id: serial().primaryKey().notNull(),
|
id: serial().primaryKey().notNull(),
|
||||||
cartId: integer("cart_id").notNull(),
|
name: text().notNull(),
|
||||||
itemId: integer("item_id").notNull(),
|
parentId: integer("parent_id"),
|
||||||
addedAt: timestamp("added_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
lastReminderSentAt: timestamp("last_reminder_sent_at", { withTimezone: true, mode: 'string' }),
|
|
||||||
}, (table) => [
|
}, (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({
|
foreignKey({
|
||||||
columns: [table.cartId],
|
columns: [table.parentId],
|
||||||
foreignColumns: [carts.id],
|
foreignColumns: [table.id],
|
||||||
name: "cart_items_cart_id_fkey"
|
name: "categories_parent_id_fkey"
|
||||||
}).onDelete("cascade"),
|
}).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({
|
foreignKey({
|
||||||
columns: [table.itemId],
|
columns: [table.itemId],
|
||||||
foreignColumns: [items.id],
|
foreignColumns: [items.id],
|
||||||
name: "cart_items_item_id_fkey"
|
name: "item_drafts_item_id_fkey"
|
||||||
}).onDelete("cascade"),
|
}).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({
|
foreignKey({
|
||||||
columns: [table.customerId],
|
columns: [table.uploadLinkId],
|
||||||
foreignColumns: [customers.id],
|
foreignColumns: [uploadLinks.id],
|
||||||
name: "carts_customer_id_fkey"
|
name: "item_drafts_upload_link_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"
|
|
||||||
}).onDelete("set null"),
|
}).onDelete("set null"),
|
||||||
foreignKey({
|
foreignKey({
|
||||||
columns: [table.shippingAddressId],
|
columns: [table.aiCategoryId],
|
||||||
foreignColumns: [shippingAddresses.id],
|
foreignColumns: [categories.id],
|
||||||
name: "checkouts_shipping_address_id_fkey"
|
name: "item_drafts_ai_category_id_fkey"
|
||||||
}).onDelete("set null"),
|
}).onDelete("set null"),
|
||||||
|
unique("item_drafts_item_id_key").on(table.itemId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const customerSessions = pgTable("customer_sessions", {
|
export const uploadLinks = pgTable("upload_links", {
|
||||||
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", {
|
|
||||||
id: serial().primaryKey().notNull(),
|
id: serial().primaryKey().notNull(),
|
||||||
email: text().notNull(),
|
label: text().notNull(),
|
||||||
passwordHash: text("password_hash").notNull(),
|
tokenHash: text("token_hash").notNull(),
|
||||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
revokedAt: timestamp("revoked_at", { withTimezone: true, mode: 'string' }),
|
||||||
marketingConsent: boolean("marketing_consent").default(false).notNull(),
|
submissionCount: integer("submission_count").default(0).notNull(),
|
||||||
marketingConsentAt: timestamp("marketing_consent_at", { withTimezone: true, mode: 'string' }),
|
maxSubmissions: integer("max_submissions"),
|
||||||
marketingConsentText: text("marketing_consent_text"),
|
lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: 'string' }),
|
||||||
unsubscribeToken: text("unsubscribe_token").notNull(),
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().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) => [
|
}, (table) => [
|
||||||
index("customers_disabled_at_idx").using("btree", table.disabledAt.asc().nullsLast().op("timestamptz_ops")).where(sql`(disabled_at IS NOT NULL)`),
|
unique("upload_links_token_hash_key").on(table.tokenHash),
|
||||||
unique("customers_email_key").on(table.email),
|
|
||||||
unique("customers_unsubscribe_token_key").on(table.unsubscribeToken),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const itemTags = pgTable("item_tags", {
|
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"}),
|
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", {
|
export const favorites = pgTable("favorites", {
|
||||||
customerId: integer("customer_id").notNull(),
|
customerId: integer("customer_id").notNull(),
|
||||||
itemId: integer("item_id").notNull(),
|
itemId: integer("item_id").notNull(),
|
||||||
@@ -270,21 +336,3 @@ export const favorites = pgTable("favorites", {
|
|||||||
}).onDelete("cascade"),
|
}).onDelete("cascade"),
|
||||||
primaryKey({ columns: [table.customerId, table.itemId], name: "favorites_pkey"}),
|
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 { Pool } from 'pg';
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import * as schema from './db-drizzle/schema';
|
||||||
|
|
||||||
export const pool = new Pool({
|
export const pool = new Pool({
|
||||||
host: process.env.PGHOST,
|
host: process.env.PGHOST,
|
||||||
@@ -8,6 +10,27 @@ export const pool = new Pool({
|
|||||||
database: process.env.PGDATABASE
|
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.
|
* 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