diff --git a/.claude/project-context.md b/.claude/project-context.md index 0859bd3..3c012b2 100644 --- a/.claude/project-context.md +++ b/.claude/project-context.md @@ -270,7 +270,7 @@ Neither failure mentions the Node version as the cause, and the first one reads export PATH="/c/Users/tlamb/AppData/Local/nvm/v24.13.1:$PATH" ``` -Thom is fine with switching the active version for a test run — `nvm use latest`, then **`nvm use 18.16.1` when finished**, which is not optional since the app's own tooling expects 18. +Thom is fine with the scripts switching the active version for a test run: they use the pinned `NODE_VERSION` (26.7.0) in `scripts/NodeVersion.ps1` and restore 18.16.1 when finished, including on failure. Do not run those scripts from an agent shell — they prompt for elevation and can leave the machine with no Node at all. Unit tests and `tsc` run fine on 18, so a green `npm test` says nothing about whether the other two suites can even start. diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index d2c7310..252637f 100755 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -200,8 +200,25 @@ jobs: # sonar.qualitygate.wait, so a degraded run marks the dashboard and is # overwritten by the next good one rather than blocking anything; and the # job fails regardless, so no run in this state reads as clean. + # Not on pull requests. SonarQube Community has no branch analysis: every + # scan published under a project key replaces that project's single + # analysis, whatever revision it came from. So a pull request scan + # overwrote the dashboard's picture of main with the branch, silently, and + # the new-code period, gate result, coverage and hotspot list then all + # described whatever was scanned last with nothing saying which revision + # that was. #197 caught it in the act — the dashboard describing a feature + # branch while reporting hotspot line numbers that landed on a blank line + # in main. + # + # scripts/scan-local.sh already refuses to do this, defaulting to a + # scratch key for exactly this reason. CI walked into the hazard that + # script guards against; now the two tell the same story. + # + # The suites above still run on pull requests, which is where their value + # is. Only publishing is restricted. - name: SonarQube Scan id: scan + if: github.event_name != 'pull_request' continue-on-error: true uses: sonarsource/sonarqube-scan-action@v4 env: @@ -238,8 +255,11 @@ jobs: # every path, so it cannot fail the job anyway, and guarding it would # oblige it to appear in the gate below — which exists to fail the job, # the opposite of what a report should do. See #261. + # Skipped alongside the scan on pull requests. With nothing published, this + # would report main's numbers under a pull request's log, which is noise + # at best and misread as the branch's own at worst. - name: Report SonarQube measures - if: always() + if: always() && github.event_name != 'pull_request' env: SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 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/README.md b/README.md index f1b308b..116e7c2 100755 --- a/README.md +++ b/README.md @@ -33,13 +33,17 @@ Commands below are shown for **PowerShell** (Windows). A bash equivalent is note .\scripts\start-local.ps1 -Fresh # ...from an empty database .\scripts\start-local.ps1 -Stop # stop everything -.\scripts un-tests.ps1 -Suite unit -.\scripts un-tests.ps1 -Suite integration -.\scripts un-tests.ps1 -Suite e2e -.\scripts un-tests.ps1 -Suite all +.\scripts +un-tests.ps1 -Suite unit +.\scripts +un-tests.ps1 -Suite integration +.\scripts +un-tests.ps1 -Suite e2e +.\scripts +un-tests.ps1 -Suite all ``` -Both scripts run `nvm use latest` first and verify the result is Node 20 or newer, then put the machine back to 18.16.1 when they finish — including when they fail partway, so an interrupted run does not leave the version switched. **`nvm use` rewrites a machine-global symlink, so this changes the Node version for every terminal on the machine while a script is running, not only the one you ran it in.** Both scripts say so as they do it. +Both scripts switch to the pinned Node 26.7.0 (`NODE_VERSION` in `scripts/NodeVersion.ps1`) and verify that is what actually ends up running, then put the machine back to 18.16.1 when they finish — including when they fail partway, so an interrupted run does not leave the version switched. **`nvm use` rewrites a machine-global symlink, so this changes the Node version for every terminal on the machine while a script is running, not only the one you ran it in.** Both scripts say so as they do it. The Node 20 floor is not arbitrary: `node-pg-migrate` pulls in an `lru-cache` that calls `diagnostics_channel.tracingChannel()`, which does not exist before Node 19.9. On Node 18 migrations die inside minified library code with `(0 , U.tracingChannel) is not a function`, which says nothing about versions. 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/drizzle/0000_sleepy_franklin_richards.sql b/backend/drizzle/0000_sleepy_franklin_richards.sql deleted file mode 100644 index 4f8730f..0000000 --- a/backend/drizzle/0000_sleepy_franklin_richards.sql +++ /dev/null @@ -1,194 +0,0 @@ --- Current sql file was generated after introspecting the database --- If you want to run this migration please uncomment this code before executing migrations -/* -CREATE TABLE "pgmigrations" ( - "id" serial PRIMARY KEY NOT NULL, - "name" varchar(255) NOT NULL, - "run_on" timestamp NOT NULL -); ---> statement-breakpoint -CREATE TABLE "admin_settings" ( - "key" text PRIMARY KEY NOT NULL, - "value" text NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "item_images" ( - "id" serial PRIMARY KEY NOT NULL, - "item_id" integer NOT NULL, - "image_path" text NOT NULL, - "sort_order" integer DEFAULT 0 NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "shipping_addresses" ( - "id" serial PRIMARY KEY NOT NULL, - "customer_id" integer NOT NULL, - "full_name" text NOT NULL, - "address_line1" text NOT NULL, - "address_line2" text, - "city" text NOT NULL, - "state" text NOT NULL, - "postal_code" text NOT NULL, - "country" text DEFAULT 'US' NOT NULL, - "is_default" boolean DEFAULT false NOT NULL, - "usps_validated" boolean DEFAULT false NOT NULL, - "usps_standardized" jsonb, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "items" ( - "id" serial PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "description" text, - "price_cents" integer NOT NULL, - "status" text DEFAULT 'pending' NOT NULL, - "reserved_until" timestamp with time zone, - "sold_at" timestamp with time zone, - "paypal_order_id" text, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "category_id" integer -); ---> statement-breakpoint -CREATE TABLE "tags" ( - "id" serial PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "color" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "categories" ( - "id" serial PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "parent_id" integer, - "sort_order" integer DEFAULT 0 NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "orders" ( - "id" serial PRIMARY KEY NOT NULL, - "item_id" integer, - "customer_id" integer, - "checkout_id" integer, - "processor" text NOT NULL, - "processor_order_id" text, - "amount_cents" integer, - "status" text, - "raw_event" jsonb, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "cart_items" ( - "id" serial PRIMARY KEY NOT NULL, - "cart_id" integer NOT NULL, - "item_id" integer NOT NULL, - "added_at" timestamp with time zone DEFAULT now() NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "last_reminder_sent_at" timestamp with time zone, - CONSTRAINT "cart_items_item_id_key" UNIQUE("item_id") -); ---> statement-breakpoint -CREATE TABLE "carts" ( - "id" serial PRIMARY KEY NOT NULL, - "customer_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "carts_customer_id_key" UNIQUE("customer_id") -); ---> statement-breakpoint -CREATE TABLE "customer_tokens" ( - "token" text PRIMARY KEY NOT NULL, - "customer_id" integer NOT NULL, - "kind" text NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "checkouts" ( - "id" serial PRIMARY KEY NOT NULL, - "customer_id" integer, - "shipping_address_id" integer, - "processor" text NOT NULL, - "processor_order_id" text, - "amount_cents" integer, - "status" text DEFAULT 'pending' NOT NULL, - "raw_event" jsonb, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "customer_sessions" ( - "token" text PRIMARY KEY NOT NULL, - "customer_id" integer NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "customers" ( - "id" serial PRIMARY KEY NOT NULL, - "email" text NOT NULL, - "password_hash" text NOT NULL, - "email_verified" boolean DEFAULT false NOT NULL, - "marketing_consent" boolean DEFAULT false NOT NULL, - "marketing_consent_at" timestamp with time zone, - "marketing_consent_text" text, - "unsubscribe_token" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "disabled_at" timestamp with time zone, - "favorite_alerts" boolean DEFAULT false NOT NULL, - "favorite_alerts_at" timestamp with time zone, - "favorite_alerts_text" text, - "first_name" text, - "last_name" text, - CONSTRAINT "customers_email_key" UNIQUE("email"), - CONSTRAINT "customers_unsubscribe_token_key" UNIQUE("unsubscribe_token") -); ---> statement-breakpoint -CREATE TABLE "item_tags" ( - "item_id" integer NOT NULL, - "tag_id" integer NOT NULL, - CONSTRAINT "item_tags_pkey" PRIMARY KEY("item_id","tag_id") -); ---> statement-breakpoint -CREATE TABLE "favorites" ( - "customer_id" integer NOT NULL, - "item_id" integer NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "favorites_pkey" PRIMARY KEY("customer_id","item_id") -); ---> statement-breakpoint -CREATE TABLE "checkout_items" ( - "checkout_id" integer NOT NULL, - "item_id" integer NOT NULL, - "price_cents" integer NOT NULL, - CONSTRAINT "checkout_items_pkey" PRIMARY KEY("checkout_id","item_id") -); ---> statement-breakpoint -ALTER TABLE "item_images" ADD CONSTRAINT "item_images_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "shipping_addresses" ADD CONSTRAINT "shipping_addresses_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "items" ADD CONSTRAINT "items_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "public"."categories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "orders" ADD CONSTRAINT "orders_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "orders" ADD CONSTRAINT "orders_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "orders" ADD CONSTRAINT "orders_checkout_id_fkey" FOREIGN KEY ("checkout_id") REFERENCES "public"."checkouts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_cart_id_fkey" FOREIGN KEY ("cart_id") REFERENCES "public"."carts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "carts" ADD CONSTRAINT "carts_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "customer_tokens" ADD CONSTRAINT "customer_tokens_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "checkouts" ADD CONSTRAINT "checkouts_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "checkouts" ADD CONSTRAINT "checkouts_shipping_address_id_fkey" FOREIGN KEY ("shipping_address_id") REFERENCES "public"."shipping_addresses"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "customer_sessions" ADD CONSTRAINT "customer_sessions_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "item_tags" ADD CONSTRAINT "item_tags_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "item_tags" ADD CONSTRAINT "item_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "favorites" ADD CONSTRAINT "favorites_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "favorites" ADD CONSTRAINT "favorites_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "checkout_items" ADD CONSTRAINT "checkout_items_checkout_id_fkey" FOREIGN KEY ("checkout_id") REFERENCES "public"."checkouts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "checkout_items" ADD CONSTRAINT "checkout_items_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "items_category_id_idx" ON "items" USING btree ("category_id" int4_ops);--> statement-breakpoint -CREATE UNIQUE INDEX "tags_name_uniq" ON "tags" USING btree (lower(name) text_ops);--> statement-breakpoint -CREATE UNIQUE INDEX "categories_child_name_uniq" ON "categories" USING btree (parent_id text_ops,lower(name) int4_ops) WHERE (parent_id IS NOT NULL);--> statement-breakpoint -CREATE INDEX "categories_parent_id_idx" ON "categories" USING btree ("parent_id" int4_ops);--> statement-breakpoint -CREATE UNIQUE INDEX "categories_root_name_uniq" ON "categories" USING btree (lower(name) text_ops) WHERE (parent_id IS NULL);--> statement-breakpoint -CREATE INDEX "customers_disabled_at_idx" ON "customers" USING btree ("disabled_at" timestamptz_ops) WHERE (disabled_at IS NOT NULL);--> statement-breakpoint -CREATE INDEX "item_tags_tag_id_idx" ON "item_tags" USING btree ("tag_id" int4_ops);--> statement-breakpoint -CREATE INDEX "favorites_item_id_idx" ON "favorites" USING btree ("item_id" int4_ops); -*/ \ No newline at end of file diff --git a/backend/drizzle/0001_add_condition_note.sql b/backend/drizzle/0001_add_condition_note.sql deleted file mode 100644 index 20dccbb..0000000 --- a/backend/drizzle/0001_add_condition_note.sql +++ /dev/null @@ -1,7 +0,0 @@ -DROP INDEX "tags_name_uniq";--> statement-breakpoint -DROP INDEX "categories_child_name_uniq";--> statement-breakpoint -DROP INDEX "categories_root_name_uniq";--> statement-breakpoint -ALTER TABLE "items" ADD COLUMN "condition_note" text;--> statement-breakpoint -CREATE UNIQUE INDEX "tags_name_uniq" ON "tags" USING btree (lower(name));--> statement-breakpoint -CREATE UNIQUE INDEX "categories_child_name_uniq" ON "categories" USING btree (parent_id,lower(name)) WHERE (parent_id IS NOT NULL);--> statement-breakpoint -CREATE UNIQUE INDEX "categories_root_name_uniq" ON "categories" USING btree (lower(name)) WHERE (parent_id IS NULL); \ No newline at end of file diff --git a/backend/drizzle/meta/0000_snapshot.json b/backend/drizzle/meta/0000_snapshot.json deleted file mode 100644 index 60ef76c..0000000 --- a/backend/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,1384 +0,0 @@ -{ - "id": "00000000-0000-0000-0000-000000000000", - "prevId": "", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.pgmigrations": { - "name": "pgmigrations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "run_on": { - "name": "run_on", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.admin_settings": { - "name": "admin_settings", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.item_images": { - "name": "item_images", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "image_path": { - "name": "image_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "item_images_item_id_fkey": { - "name": "item_images_item_id_fkey", - "tableFrom": "item_images", - "tableTo": "items", - "schemaTo": "public", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.shipping_addresses": { - "name": "shipping_addresses", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "full_name": { - "name": "full_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "address_line1": { - "name": "address_line1", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "address_line2": { - "name": "address_line2", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "city": { - "name": "city", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "postal_code": { - "name": "postal_code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country": { - "name": "country", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'US'" - }, - "is_default": { - "name": "is_default", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "usps_validated": { - "name": "usps_validated", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "usps_standardized": { - "name": "usps_standardized", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "shipping_addresses_customer_id_fkey": { - "name": "shipping_addresses_customer_id_fkey", - "tableFrom": "shipping_addresses", - "tableTo": "customers", - "schemaTo": "public", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.items": { - "name": "items", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "price_cents": { - "name": "price_cents", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "reserved_until": { - "name": "reserved_until", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "sold_at": { - "name": "sold_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "paypal_order_id": { - "name": "paypal_order_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "category_id": { - "name": "category_id", - "type": "integer", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "items_category_id_idx": { - "name": "items_category_id_idx", - "columns": [ - { - "expression": "category_id", - "asc": true, - "nulls": "last", - "opclass": "int4_ops", - "isExpression": false - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "items_category_id_fkey": { - "name": "items_category_id_fkey", - "tableFrom": "items", - "tableTo": "categories", - "schemaTo": "public", - "columnsFrom": [ - "category_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.tags": { - "name": "tags", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "color": { - "name": "color", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "tags_name_uniq": { - "name": "tags_name_uniq", - "columns": [ - { - "expression": "lower(name)", - "asc": true, - "nulls": "last", - "opclass": "text_ops", - "isExpression": true - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.categories": { - "name": "categories", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "parent_id": { - "name": "parent_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "categories_child_name_uniq": { - "name": "categories_child_name_uniq", - "columns": [ - { - "expression": "parent_id", - "asc": true, - "nulls": "last", - "opclass": "text_ops", - "isExpression": true - }, - { - "expression": "lower(name)", - "asc": true, - "nulls": "last", - "opclass": "int4_ops", - "isExpression": true - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "where": "(parent_id IS NOT NULL)", - "with": {} - }, - "categories_parent_id_idx": { - "name": "categories_parent_id_idx", - "columns": [ - { - "expression": "parent_id", - "asc": true, - "nulls": "last", - "opclass": "int4_ops", - "isExpression": false - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "categories_root_name_uniq": { - "name": "categories_root_name_uniq", - "columns": [ - { - "expression": "lower(name)", - "asc": true, - "nulls": "last", - "opclass": "text_ops", - "isExpression": true - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "where": "(parent_id IS NULL)", - "with": {} - } - }, - "foreignKeys": { - "categories_parent_id_fkey": { - "name": "categories_parent_id_fkey", - "tableFrom": "categories", - "tableTo": "categories", - "schemaTo": "public", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.orders": { - "name": "orders", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "checkout_id": { - "name": "checkout_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "processor": { - "name": "processor", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "processor_order_id": { - "name": "processor_order_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "amount_cents": { - "name": "amount_cents", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "raw_event": { - "name": "raw_event", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "orders_item_id_fkey": { - "name": "orders_item_id_fkey", - "tableFrom": "orders", - "tableTo": "items", - "schemaTo": "public", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "orders_customer_id_fkey": { - "name": "orders_customer_id_fkey", - "tableFrom": "orders", - "tableTo": "customers", - "schemaTo": "public", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "orders_checkout_id_fkey": { - "name": "orders_checkout_id_fkey", - "tableFrom": "orders", - "tableTo": "checkouts", - "schemaTo": "public", - "columnsFrom": [ - "checkout_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.cart_items": { - "name": "cart_items", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "cart_id": { - "name": "cart_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "added_at": { - "name": "added_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "last_reminder_sent_at": { - "name": "last_reminder_sent_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "cart_items_cart_id_fkey": { - "name": "cart_items_cart_id_fkey", - "tableFrom": "cart_items", - "tableTo": "carts", - "schemaTo": "public", - "columnsFrom": [ - "cart_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "cart_items_item_id_fkey": { - "name": "cart_items_item_id_fkey", - "tableFrom": "cart_items", - "tableTo": "items", - "schemaTo": "public", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "cart_items_item_id_key": { - "columns": [ - "item_id" - ], - "nullsNotDistinct": false, - "name": "cart_items_item_id_key" - } - }, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.carts": { - "name": "carts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "carts_customer_id_fkey": { - "name": "carts_customer_id_fkey", - "tableFrom": "carts", - "tableTo": "customers", - "schemaTo": "public", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "carts_customer_id_key": { - "columns": [ - "customer_id" - ], - "nullsNotDistinct": false, - "name": "carts_customer_id_key" - } - }, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.customer_tokens": { - "name": "customer_tokens", - "schema": "", - "columns": { - "token": { - "name": "token", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "customer_tokens_customer_id_fkey": { - "name": "customer_tokens_customer_id_fkey", - "tableFrom": "customer_tokens", - "tableTo": "customers", - "schemaTo": "public", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.checkouts": { - "name": "checkouts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "shipping_address_id": { - "name": "shipping_address_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "processor": { - "name": "processor", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "processor_order_id": { - "name": "processor_order_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "amount_cents": { - "name": "amount_cents", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "raw_event": { - "name": "raw_event", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "checkouts_customer_id_fkey": { - "name": "checkouts_customer_id_fkey", - "tableFrom": "checkouts", - "tableTo": "customers", - "schemaTo": "public", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "checkouts_shipping_address_id_fkey": { - "name": "checkouts_shipping_address_id_fkey", - "tableFrom": "checkouts", - "tableTo": "shipping_addresses", - "schemaTo": "public", - "columnsFrom": [ - "shipping_address_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.customer_sessions": { - "name": "customer_sessions", - "schema": "", - "columns": { - "token": { - "name": "token", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "customer_sessions_customer_id_fkey": { - "name": "customer_sessions_customer_id_fkey", - "tableFrom": "customer_sessions", - "tableTo": "customers", - "schemaTo": "public", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.customers": { - "name": "customers", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "password_hash": { - "name": "password_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "marketing_consent": { - "name": "marketing_consent", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "marketing_consent_at": { - "name": "marketing_consent_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "marketing_consent_text": { - "name": "marketing_consent_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "unsubscribe_token": { - "name": "unsubscribe_token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "disabled_at": { - "name": "disabled_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "favorite_alerts": { - "name": "favorite_alerts", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "favorite_alerts_at": { - "name": "favorite_alerts_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "favorite_alerts_text": { - "name": "favorite_alerts_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "first_name": { - "name": "first_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "last_name": { - "name": "last_name", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "customers_disabled_at_idx": { - "name": "customers_disabled_at_idx", - "columns": [ - { - "expression": "disabled_at", - "asc": true, - "nulls": "last", - "opclass": "timestamptz_ops", - "isExpression": false - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "where": "(disabled_at IS NOT NULL)", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "customers_email_key": { - "columns": [ - "email" - ], - "nullsNotDistinct": false, - "name": "customers_email_key" - }, - "customers_unsubscribe_token_key": { - "columns": [ - "unsubscribe_token" - ], - "nullsNotDistinct": false, - "name": "customers_unsubscribe_token_key" - } - }, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.item_tags": { - "name": "item_tags", - "schema": "", - "columns": { - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "tag_id": { - "name": "tag_id", - "type": "integer", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "item_tags_tag_id_idx": { - "name": "item_tags_tag_id_idx", - "columns": [ - { - "expression": "tag_id", - "asc": true, - "nulls": "last", - "opclass": "int4_ops", - "isExpression": false - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "item_tags_item_id_fkey": { - "name": "item_tags_item_id_fkey", - "tableFrom": "item_tags", - "tableTo": "items", - "schemaTo": "public", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "item_tags_tag_id_fkey": { - "name": "item_tags_tag_id_fkey", - "tableFrom": "item_tags", - "tableTo": "tags", - "schemaTo": "public", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "item_tags_pkey": { - "name": "item_tags_pkey", - "columns": [ - "item_id", - "tag_id" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.favorites": { - "name": "favorites", - "schema": "", - "columns": { - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "favorites_item_id_idx": { - "name": "favorites_item_id_idx", - "columns": [ - { - "expression": "item_id", - "asc": true, - "nulls": "last", - "opclass": "int4_ops", - "isExpression": false - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "favorites_customer_id_fkey": { - "name": "favorites_customer_id_fkey", - "tableFrom": "favorites", - "tableTo": "customers", - "schemaTo": "public", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "favorites_item_id_fkey": { - "name": "favorites_item_id_fkey", - "tableFrom": "favorites", - "tableTo": "items", - "schemaTo": "public", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "favorites_pkey": { - "name": "favorites_pkey", - "columns": [ - "customer_id", - "item_id" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - }, - "public.checkout_items": { - "name": "checkout_items", - "schema": "", - "columns": { - "checkout_id": { - "name": "checkout_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "price_cents": { - "name": "price_cents", - "type": "integer", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "checkout_items_checkout_id_fkey": { - "name": "checkout_items_checkout_id_fkey", - "tableFrom": "checkout_items", - "tableTo": "checkouts", - "schemaTo": "public", - "columnsFrom": [ - "checkout_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "checkout_items_item_id_fkey": { - "name": "checkout_items_item_id_fkey", - "tableFrom": "checkout_items", - "tableTo": "items", - "schemaTo": "public", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "checkout_items_pkey": { - "name": "checkout_items_pkey", - "columns": [ - "checkout_id", - "item_id" - ] - } - }, - "uniqueConstraints": {}, - "checkConstraints": {}, - "policies": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "tables": {} - } -} \ No newline at end of file diff --git a/backend/drizzle/meta/0001_snapshot.json b/backend/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 86fc8a5..0000000 --- a/backend/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,1363 +0,0 @@ -{ - "id": "8023ec5b-dd3f-4cdb-bf4c-f8f0b81f0a33", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.admin_settings": { - "name": "admin_settings", - "schema": "", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.cart_items": { - "name": "cart_items", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "cart_id": { - "name": "cart_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "added_at": { - "name": "added_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "last_reminder_sent_at": { - "name": "last_reminder_sent_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "cart_items_cart_id_fkey": { - "name": "cart_items_cart_id_fkey", - "tableFrom": "cart_items", - "tableTo": "carts", - "columnsFrom": [ - "cart_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "cart_items_item_id_fkey": { - "name": "cart_items_item_id_fkey", - "tableFrom": "cart_items", - "tableTo": "items", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "cart_items_item_id_key": { - "name": "cart_items_item_id_key", - "nullsNotDistinct": false, - "columns": [ - "item_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.carts": { - "name": "carts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "carts_customer_id_fkey": { - "name": "carts_customer_id_fkey", - "tableFrom": "carts", - "tableTo": "customers", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "carts_customer_id_key": { - "name": "carts_customer_id_key", - "nullsNotDistinct": false, - "columns": [ - "customer_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.categories": { - "name": "categories", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "parent_id": { - "name": "parent_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "categories_child_name_uniq": { - "name": "categories_child_name_uniq", - "columns": [ - { - "expression": "parent_id", - "asc": true, - "isExpression": true, - "nulls": "last" - }, - { - "expression": "lower(name)", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "(parent_id IS NOT NULL)", - "concurrently": false, - "method": "btree", - "with": {} - }, - "categories_parent_id_idx": { - "name": "categories_parent_id_idx", - "columns": [ - { - "expression": "parent_id", - "isExpression": false, - "asc": true, - "nulls": "last", - "opclass": "int4_ops" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "categories_root_name_uniq": { - "name": "categories_root_name_uniq", - "columns": [ - { - "expression": "lower(name)", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "(parent_id IS NULL)", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "categories_parent_id_fkey": { - "name": "categories_parent_id_fkey", - "tableFrom": "categories", - "tableTo": "categories", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.checkout_items": { - "name": "checkout_items", - "schema": "", - "columns": { - "checkout_id": { - "name": "checkout_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "price_cents": { - "name": "price_cents", - "type": "integer", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "checkout_items_checkout_id_fkey": { - "name": "checkout_items_checkout_id_fkey", - "tableFrom": "checkout_items", - "tableTo": "checkouts", - "columnsFrom": [ - "checkout_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "checkout_items_item_id_fkey": { - "name": "checkout_items_item_id_fkey", - "tableFrom": "checkout_items", - "tableTo": "items", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "checkout_items_pkey": { - "name": "checkout_items_pkey", - "columns": [ - "checkout_id", - "item_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.checkouts": { - "name": "checkouts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "shipping_address_id": { - "name": "shipping_address_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "processor": { - "name": "processor", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "processor_order_id": { - "name": "processor_order_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "amount_cents": { - "name": "amount_cents", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "raw_event": { - "name": "raw_event", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "checkouts_customer_id_fkey": { - "name": "checkouts_customer_id_fkey", - "tableFrom": "checkouts", - "tableTo": "customers", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "checkouts_shipping_address_id_fkey": { - "name": "checkouts_shipping_address_id_fkey", - "tableFrom": "checkouts", - "tableTo": "shipping_addresses", - "columnsFrom": [ - "shipping_address_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.customer_sessions": { - "name": "customer_sessions", - "schema": "", - "columns": { - "token": { - "name": "token", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "customer_sessions_customer_id_fkey": { - "name": "customer_sessions_customer_id_fkey", - "tableFrom": "customer_sessions", - "tableTo": "customers", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.customer_tokens": { - "name": "customer_tokens", - "schema": "", - "columns": { - "token": { - "name": "token", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "customer_tokens_customer_id_fkey": { - "name": "customer_tokens_customer_id_fkey", - "tableFrom": "customer_tokens", - "tableTo": "customers", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.customers": { - "name": "customers", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "password_hash": { - "name": "password_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "marketing_consent": { - "name": "marketing_consent", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "marketing_consent_at": { - "name": "marketing_consent_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "marketing_consent_text": { - "name": "marketing_consent_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "unsubscribe_token": { - "name": "unsubscribe_token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "disabled_at": { - "name": "disabled_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "favorite_alerts": { - "name": "favorite_alerts", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "favorite_alerts_at": { - "name": "favorite_alerts_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "favorite_alerts_text": { - "name": "favorite_alerts_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "first_name": { - "name": "first_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "last_name": { - "name": "last_name", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "customers_disabled_at_idx": { - "name": "customers_disabled_at_idx", - "columns": [ - { - "expression": "disabled_at", - "isExpression": false, - "asc": true, - "nulls": "last", - "opclass": "timestamptz_ops" - } - ], - "isUnique": false, - "where": "(disabled_at IS NOT NULL)", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "customers_email_key": { - "name": "customers_email_key", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - }, - "customers_unsubscribe_token_key": { - "name": "customers_unsubscribe_token_key", - "nullsNotDistinct": false, - "columns": [ - "unsubscribe_token" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.favorites": { - "name": "favorites", - "schema": "", - "columns": { - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "favorites_item_id_idx": { - "name": "favorites_item_id_idx", - "columns": [ - { - "expression": "item_id", - "isExpression": false, - "asc": true, - "nulls": "last", - "opclass": "int4_ops" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "favorites_customer_id_fkey": { - "name": "favorites_customer_id_fkey", - "tableFrom": "favorites", - "tableTo": "customers", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "favorites_item_id_fkey": { - "name": "favorites_item_id_fkey", - "tableFrom": "favorites", - "tableTo": "items", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "favorites_pkey": { - "name": "favorites_pkey", - "columns": [ - "customer_id", - "item_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.item_images": { - "name": "item_images", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "image_path": { - "name": "image_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "item_images_item_id_fkey": { - "name": "item_images_item_id_fkey", - "tableFrom": "item_images", - "tableTo": "items", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.item_tags": { - "name": "item_tags", - "schema": "", - "columns": { - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "tag_id": { - "name": "tag_id", - "type": "integer", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "item_tags_tag_id_idx": { - "name": "item_tags_tag_id_idx", - "columns": [ - { - "expression": "tag_id", - "isExpression": false, - "asc": true, - "nulls": "last", - "opclass": "int4_ops" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "item_tags_item_id_fkey": { - "name": "item_tags_item_id_fkey", - "tableFrom": "item_tags", - "tableTo": "items", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "item_tags_tag_id_fkey": { - "name": "item_tags_tag_id_fkey", - "tableFrom": "item_tags", - "tableTo": "tags", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "item_tags_pkey": { - "name": "item_tags_pkey", - "columns": [ - "item_id", - "tag_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.items": { - "name": "items", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "price_cents": { - "name": "price_cents", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "reserved_until": { - "name": "reserved_until", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "sold_at": { - "name": "sold_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "paypal_order_id": { - "name": "paypal_order_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "category_id": { - "name": "category_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "condition_note": { - "name": "condition_note", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "items_category_id_idx": { - "name": "items_category_id_idx", - "columns": [ - { - "expression": "category_id", - "isExpression": false, - "asc": true, - "nulls": "last", - "opclass": "int4_ops" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "items_category_id_fkey": { - "name": "items_category_id_fkey", - "tableFrom": "items", - "tableTo": "categories", - "columnsFrom": [ - "category_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.orders": { - "name": "orders", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "item_id": { - "name": "item_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "checkout_id": { - "name": "checkout_id", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "processor": { - "name": "processor", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "processor_order_id": { - "name": "processor_order_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "amount_cents": { - "name": "amount_cents", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "raw_event": { - "name": "raw_event", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "orders_item_id_fkey": { - "name": "orders_item_id_fkey", - "tableFrom": "orders", - "tableTo": "items", - "columnsFrom": [ - "item_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "orders_customer_id_fkey": { - "name": "orders_customer_id_fkey", - "tableFrom": "orders", - "tableTo": "customers", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "orders_checkout_id_fkey": { - "name": "orders_checkout_id_fkey", - "tableFrom": "orders", - "tableTo": "checkouts", - "columnsFrom": [ - "checkout_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.pgmigrations": { - "name": "pgmigrations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "run_on": { - "name": "run_on", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.shipping_addresses": { - "name": "shipping_addresses", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "full_name": { - "name": "full_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "address_line1": { - "name": "address_line1", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "address_line2": { - "name": "address_line2", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "city": { - "name": "city", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "postal_code": { - "name": "postal_code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "country": { - "name": "country", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'US'" - }, - "is_default": { - "name": "is_default", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "usps_validated": { - "name": "usps_validated", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "usps_standardized": { - "name": "usps_standardized", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "shipping_addresses_customer_id_fkey": { - "name": "shipping_addresses_customer_id_fkey", - "tableFrom": "shipping_addresses", - "tableTo": "customers", - "columnsFrom": [ - "customer_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tags": { - "name": "tags", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "serial", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "color": { - "name": "color", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "tags_name_uniq": { - "name": "tags_name_uniq", - "columns": [ - { - "expression": "lower(name)", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json deleted file mode 100644 index 7813ec8..0000000 --- a/backend/drizzle/meta/_journal.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1787946791717, - "tag": "0000_sleepy_franklin_richards", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1787946975565, - "tag": "0001_add_condition_note", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/backend/drizzle/schema.ts b/backend/drizzle/schema.ts deleted file mode 100644 index 5985a9b..0000000 --- a/backend/drizzle/schema.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { pgTable, serial, varchar, timestamp, text, foreignKey, integer, boolean, jsonb, index, uniqueIndex, unique, primaryKey } from "drizzle-orm/pg-core" -import { sql } from "drizzle-orm" - - - -export const pgmigrations = pgTable("pgmigrations", { - 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(), -}); - -export const itemImages = pgTable("item_images", { - id: serial().primaryKey().notNull(), - itemId: integer("item_id").notNull(), - imagePath: text("image_path").notNull(), - sortOrder: integer("sort_order").default(0).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "item_images_item_id_fkey" - }).onDelete("cascade"), -]); - -export const shippingAddresses = pgTable("shipping_addresses", { - id: serial().primaryKey().notNull(), - customerId: integer("customer_id").notNull(), - fullName: text("full_name").notNull(), - addressLine1: text("address_line1").notNull(), - addressLine2: text("address_line2"), - city: text().notNull(), - state: text().notNull(), - postalCode: text("postal_code").notNull(), - country: text().default('US').notNull(), - isDefault: boolean("is_default").default(false).notNull(), - uspsValidated: boolean("usps_validated").default(false).notNull(), - uspsStandardized: jsonb("usps_standardized"), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "shipping_addresses_customer_id_fkey" - }).onDelete("cascade"), -]); - -export const items = pgTable("items", { - id: serial().primaryKey().notNull(), - name: text().notNull(), - description: text(), - priceCents: integer("price_cents").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 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 categories = pgTable("categories", { - id: serial().primaryKey().notNull(), - name: text().notNull(), - parentId: integer("parent_id"), - sortOrder: integer("sort_order").default(0).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - uniqueIndex("categories_child_name_uniq").using("btree", sql`parent_id`, sql`lower(name)`).where(sql`(parent_id IS NOT NULL)`), - index("categories_parent_id_idx").using("btree", table.parentId.asc().nullsLast().op("int4_ops")), - uniqueIndex("categories_root_name_uniq").using("btree", sql`lower(name)`).where(sql`(parent_id IS NULL)`), - foreignKey({ - columns: [table.parentId], - foreignColumns: [table.id], - name: "categories_parent_id_fkey" - }).onDelete("cascade"), -]); - -export const orders = pgTable("orders", { - id: serial().primaryKey().notNull(), - itemId: integer("item_id"), - customerId: integer("customer_id"), - checkoutId: integer("checkout_id"), - processor: text().notNull(), - processorOrderId: text("processor_order_id"), - amountCents: integer("amount_cents"), - status: text(), - rawEvent: jsonb("raw_event"), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "orders_item_id_fkey" - }), - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "orders_customer_id_fkey" - }).onDelete("set null"), - foreignKey({ - columns: [table.checkoutId], - foreignColumns: [checkouts.id], - name: "orders_checkout_id_fkey" - }).onDelete("set null"), -]); - -export const 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 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" - }).onDelete("set null"), - foreignKey({ - columns: [table.shippingAddressId], - foreignColumns: [shippingAddresses.id], - name: "checkouts_shipping_address_id_fkey" - }).onDelete("set null"), -]); - -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", { - 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 itemTags = pgTable("item_tags", { - itemId: integer("item_id").notNull(), - tagId: integer("tag_id").notNull(), -}, (table) => [ - index("item_tags_tag_id_idx").using("btree", table.tagId.asc().nullsLast().op("int4_ops")), - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "item_tags_item_id_fkey" - }).onDelete("cascade"), - foreignKey({ - columns: [table.tagId], - foreignColumns: [tags.id], - name: "item_tags_tag_id_fkey" - }).onDelete("cascade"), - primaryKey({ columns: [table.itemId, table.tagId], name: "item_tags_pkey"}), -]); - -export const favorites = pgTable("favorites", { - customerId: integer("customer_id").notNull(), - itemId: integer("item_id").notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - index("favorites_item_id_idx").using("btree", table.itemId.asc().nullsLast().op("int4_ops")), - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "favorites_customer_id_fkey" - }).onDelete("cascade"), - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "favorites_item_id_fkey" - }).onDelete("cascade"), - primaryKey({ columns: [table.customerId, table.itemId], name: "favorites_pkey"}), -]); - -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/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..294d86f --- /dev/null +++ b/backend/src/db-drizzle/CONVENTIONS.md @@ -0,0 +1,78 @@ +# Drizzle conventions + +Decided in #216, landed in #217. Read this before converting a query. + +## What is in this directory + +| File | Owner | +|---|---| +| `schema.ts` | **Generated.** `drizzle-kit pull` output. Do not hand-edit. | +| `relations.ts` | **Generated.** Same. | +| `itemFilters.drizzle.ts` | Hand-written. The #216 spike's conversion of `buildItemFilterSql`, kept as the worked example. | +| `CONVENTIONS.md` | This file. | + +`backend/migrations` owns the schema. `schema.ts` is a read-only mirror of it, and refreshing that mirror is a manual step: + +```bash +DRIZZLE_DATABASE_URL=postgres://user:pass@localhost:PORT/db npx drizzle-kit pull +``` + +Run it against a database with every migration applied, after writing a migration. `drizzle-kit pull` also emits `0000_*.sql` and `meta/` into this directory because `out` serves both purposes; both are gitignored, because this project's migration history is `backend/migrations` and a stray SQL file here would at best be noise and at worst be mistaken for real history. Whether that stays true is #219. + +`drizzleSchema.integration.test.ts` fails when the mirror and the database disagree, on tables or on columns. That test exists because the drift is silent and already happened: the mirror sat missing `item_drafts` and `upload_links` from the moment #222 landed until #217, because the spike had copied it into `src/` by hand and nobody had reason to look. A stale mirror is worse than none — Drizzle infers row types from it, so a converted query type-checks against a schema the database does not have and fails at run time on a column that does not exist. + +## The rule that will bite you hardest: columns in a `sql` template are unqualified + +Drizzle renders a column reference inside a `sql` template **without its table**. + +```ts +// WRONG. Generates: (SELECT COUNT(*)::int FROM "items" WHERE "category_id" = "id") +// Postgres resolves both sides against items, so the subquery correlates with +// itself and returns a plausible wrong number. +sql`(SELECT COUNT(*)::int FROM ${items} WHERE ${items.categoryId} = ${categories.id})` + +// RIGHT. Literal text, which is honest here because the fragment binds no values. +sql`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)` +``` + +This is worse than the array trap below, because the array trap produces invalid SQL and fails loudly. This produces **valid SQL and quietly wrong data** — it type-checks, reads correctly, and executes without error. It was found in #218 only because an integration test asserted the count was 2 and got 1. + +So: any converted query containing a correlated subquery or a self-join needs a test asserting **values**, not just a status code. Write that test before converting. + +## The other one: a driver error code moves + +Drizzle wraps driver errors. A Postgres SQLSTATE that sat on `err.code` sits on `err.cause.code` after conversion, so a `catch` keyed on it still compiles, never matches, and turns a handled 409 into a 500. `adminCategories.ts` has `isUniqueViolation`, which accepts both shapes; reuse that pattern. Revisit every SQLSTATE-keyed catch when converting a file. + +## The rule that will bite you: arrays + +In a Drizzle `sql` template, an array interpolates as a **placeholder list**, not as one array parameter. + +```ts +// WRONG. Emits ANY(($1, $2)::int[]), which is invalid Postgres. +sql`... WHERE id = ANY(${filters.categoryIds}::int[])` + +// RIGHT. Emits ANY($1::int[]). +sql`... WHERE id = ANY(${sql.param(filters.categoryIds)}::int[])` +``` + +The wrong form type-checks, reads correctly, and fails at run time. Nothing warns. Across 187 call sites this is exactly the shape of defect that passes review and breaks in production, so `sql.param()` is required for every array and any converted query taking one needs a test that actually executes it. + +## The reason this is worth doing + +`${value}` in a Drizzle `sql` template emits a bind parameter, never text. There is no way to spell "interpolate this value as SQL" by accident: the escape hatch that looks like a plain template literal does not behave like one. Passing `"1); DROP TABLE items; --"` as a status value puts it in the parameters and not in the SQL. + +That makes the #202 invariant — only placeholder indices may be interpolated into a clause — a property of the type system rather than a comment guarded by two mutation tests, and it retires #180's three S2077 hotspots rather than leaving them reviewed and watched. It is the strongest argument for the adoption, and the spike confirmed it is real rather than relocated. + +## Both drivers run at once + +Column names differ, and the difference is load-bearing. The mirror is camelCase (`parentId`, `sortOrder`); these APIs answer in snake_case, which the admin frontend reads. So a select must map explicitly — `{ parent_id: categories.parentId }` — rather than selecting the table. Selecting the table directly changes the JSON contract silently, and no test asserting status codes notices. `adminCategories.ts` writes that mapping once as `CATEGORY_COLUMNS` and infers the row type from it, which is also how the hand-declared row interfaces are retired. + +`db` and `pool` are exported from `src/db.ts` and share one pool, deliberately. Conversion is file by file across 187 sites, so most queries will be raw `pg` for a long time and the two must not open separate connection pools — a transaction on one would be invisible to the other, and the configured limits would silently double. + +## Migrations stay hand-written + +Decided in **#219**: `node-pg-migrate` keeps the schema, Drizzle is for queries only. Do not start generating migrations as a side effect of converting a query. + +Three reasons, all measured rather than assumed. `drizzle-kit generate` cannot diff expression indexes, so adding one nullable column emitted six statements — three `DROP INDEX` and three `CREATE UNIQUE INDEX` alongside the `ALTER` — and those rebuilds take real locks on a large table. Our migrations are mostly prose, and generated SQL carries none of it: a rule that every generated migration is annotated before merge is a rule that holds for three migrations and then quietly stops, with the failure invisible because the migration still works. And data migrations cannot be generated at all, so anything touching existing rows stays hand-written regardless. + +The workflow: write the migration by hand, then run `drizzle-kit pull` to refresh the mirror. `drizzleSchema.integration.test.ts` fails if you forget. diff --git a/backend/drizzle/relations.ts b/backend/src/db-drizzle/relations.ts similarity index 83% rename from backend/drizzle/relations.ts rename to backend/src/db-drizzle/relations.ts index d2f3dcf..171897b 100644 --- a/backend/drizzle/relations.ts +++ b/backend/src/db-drizzle/relations.ts @@ -1,41 +1,17 @@ import { relations } from "drizzle-orm/relations"; -import { items, itemImages, customers, shippingAddresses, categories, orders, checkouts, carts, cartItems, customerTokens, customerSessions, itemTags, tags, favorites, checkoutItems } from "./schema"; - -export const itemImagesRelations = relations(itemImages, ({one}) => ({ - item: one(items, { - fields: [itemImages.itemId], - references: [items.id] - }), -})); +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}) => ({ - itemImages: many(itemImages), category: one(categories, { fields: [items.categoryId], references: [categories.id] }), - orders: many(orders), + itemImages: many(itemImages), cartItems: many(cartItems), - itemTags: many(itemTags), - favorites: many(favorites), - checkoutItems: many(checkoutItems), -})); - -export const shippingAddressesRelations = relations(shippingAddresses, ({one, many}) => ({ - customer: one(customers, { - fields: [shippingAddresses.customerId], - references: [customers.id] - }), - checkouts: many(checkouts), -})); - -export const customersRelations = relations(customers, ({many}) => ({ - shippingAddresses: many(shippingAddresses), orders: many(orders), - carts: many(carts), - customerTokens: many(customerTokens), - checkouts: many(checkouts), - customerSessions: many(customerSessions), + itemDrafts: many(itemDrafts), + itemTags: many(itemTags), + checkoutItems: many(checkoutItems), favorites: many(favorites), })); @@ -49,6 +25,78 @@ export const categoriesRelations = relations(categories, ({one, many}) => ({ 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}) => ({ @@ -66,50 +114,23 @@ export const ordersRelations = relations(orders, ({one}) => ({ }), })); -export const checkoutsRelations = relations(checkouts, ({one, many}) => ({ - orders: many(orders), - customer: one(customers, { - fields: [checkouts.customerId], - references: [customers.id] - }), - shippingAddress: one(shippingAddresses, { - fields: [checkouts.shippingAddressId], - references: [shippingAddresses.id] - }), - checkoutItems: many(checkoutItems), -})); - -export const cartItemsRelations = relations(cartItems, ({one}) => ({ - cart: one(carts, { - fields: [cartItems.cartId], - references: [carts.id] - }), +export const itemDraftsRelations = relations(itemDrafts, ({one}) => ({ item: one(items, { - fields: [cartItems.itemId], + fields: [itemDrafts.itemId], references: [items.id] }), -})); - -export const cartsRelations = relations(carts, ({one, many}) => ({ - cartItems: many(cartItems), - customer: one(customers, { - fields: [carts.customerId], - references: [customers.id] + uploadLink: one(uploadLinks, { + fields: [itemDrafts.uploadLinkId], + references: [uploadLinks.id] + }), + category: one(categories, { + fields: [itemDrafts.aiCategoryId], + references: [categories.id] }), })); -export const customerTokensRelations = relations(customerTokens, ({one}) => ({ - customer: one(customers, { - fields: [customerTokens.customerId], - references: [customers.id] - }), -})); - -export const customerSessionsRelations = relations(customerSessions, ({one}) => ({ - customer: one(customers, { - fields: [customerSessions.customerId], - references: [customers.id] - }), +export const uploadLinksRelations = relations(uploadLinks, ({many}) => ({ + itemDrafts: many(itemDrafts), })); export const itemTagsRelations = relations(itemTags, ({one}) => ({ @@ -127,17 +148,6 @@ export const tagsRelations = relations(tags, ({many}) => ({ itemTags: many(itemTags), })); -export const favoritesRelations = relations(favorites, ({one}) => ({ - customer: one(customers, { - fields: [favorites.customerId], - references: [customers.id] - }), - item: one(items, { - fields: [favorites.itemId], - references: [items.id] - }), -})); - export const checkoutItemsRelations = relations(checkoutItems, ({one}) => ({ checkout: one(checkouts, { fields: [checkoutItems.checkoutId], @@ -147,4 +157,15 @@ export const checkoutItemsRelations = relations(checkoutItems, ({one}) => ({ 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/src/routes/admin.ts b/backend/src/routes/admin.ts index 0ad0f70..4797d7a 100755 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { PoolClient } from 'pg'; import { pool, requireRow } from '../db'; +import { readId } from '../utils'; import { ADMIN_ITEM_SELECT, AdminItemRow, ItemRecord } from '../itemSelect'; import { ItemStatus } from '../types'; import { asyncRoute } from '../asyncRoute'; @@ -185,6 +186,9 @@ router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Respons })); router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Response) => { + const itemId = readId(req.params.id); + if (itemId === null) return res.status(404).json({ error: 'not found' }); + const { name, description, price } = req.body; const parsed = readOptionalItemFields(req.body); @@ -197,7 +201,7 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp await client.query('BEGIN'); await client.query( `UPDATE items SET name=$1, description=$2, price_cents=$3 WHERE id=$4`, - [name, description, Math.round(parseFloat(price) * 100), req.params.id] + [name, description, Math.round(parseFloat(price) * 100), itemId] ); // Only touch the category when the field was actually submitted, so a // caller that omits it doesn't silently uncategorize the item. @@ -224,8 +228,15 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp // S2077, the same constant-plus-$1 shape as the create route above. // req.params.id is caller-controlled and goes through the driver as a bound // parameter; it never reaches the query text. - const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]); - res.json(full[0]); + const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [itemId]); + // The create route beside this one has always used requireRow here. This + // one did not, so an UPDATE matching nothing committed happily, the SELECT + // returned nothing, and the caller got 200 with an empty body — a success + // it could do nothing with, and no record anywhere that the item was + // missing. See #207. + const updated = full[0]; + if (!updated) return res.status(404).json({ error: 'not found' }); + res.json(updated); } catch (err) { await client.query('ROLLBACK'); console.error(err); @@ -257,14 +268,21 @@ router.delete('/items/:id/images/:imageId', asyncRoute(async (req: Request, res: })); router.post('/items/:id/mark-sold', asyncRoute(async (req: Request, res: Response) => { + const itemId = readId(req.params.id); + if (itemId === null) return res.status(404).json({ error: 'not found' }); + const { rows } = await pool.query( `UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`, - [req.params.id] + [itemId] ); + const sold = rows[0]; + if (!sold) return res.status(404).json({ error: 'not found' }); + // No buyer to exclude: an admin marking an item sold has no associated - // customer, so everyone watching it hears about it. - await notifyFavoritersOfSale([Number(req.params.id)], null); - res.json(rows[0]); + // customer, so everyone watching it hears about it. Sent only after the row + // is known to exist, so nobody is told about a sale that did not happen. + await notifyFavoritersOfSale([sold.id], null); + res.json(sold); })); // Publishing is the existing mark-available: it already sets status='available' @@ -302,12 +320,17 @@ router.post('/items/:id/unpublish', asyncRoute(async (req: Request, res: Respons })); router.post('/items/:id/mark-available', asyncRoute(async (req: Request, res: Response) => { + const itemId = readId(req.params.id); + if (itemId === null) return res.status(404).json({ error: 'not found' }); + const { rows } = await pool.query( `UPDATE items SET status='available', sold_at=NULL, reserved_until=NULL, paypal_order_id=NULL WHERE id=$1 RETURNING *`, - [req.params.id] + [itemId] ); - res.json(rows[0]); + const available = rows[0]; + if (!available) return res.status(404).json({ error: 'not found' }); + res.json(available); })); export default router; diff --git a/backend/src/routes/adminCategories.ts b/backend/src/routes/adminCategories.ts index a8d66da..bb392d0 100644 --- a/backend/src/routes/adminCategories.ts +++ b/backend/src/routes/adminCategories.ts @@ -1,35 +1,38 @@ import { Router, Request, Response } from 'express'; -import { pool, requireRow } from '../db'; +import { eq, inArray, sql } from 'drizzle-orm'; +import { db, requireRow } from '../db'; +import { categories, items } from '../db-drizzle/schema'; import { asyncRoute } from '../asyncRoute'; -interface CategoryRow { - id: number; - name: string; - parent_id: number | null; - sort_order: number; -} - -/** The tree adds a usage count, cast to int so it arrives as a number. */ -interface CategoryListRow extends CategoryRow { - item_count: number; -} - -interface IdRow { - id: number; -} - -interface CountRow { - n: number; -} +/** + * The first file converted to Drizzle (#218), chosen because it is awkward + * rather than because it is easy — nine sites including a recursive CTE and an + * array match. See src/db-drizzle/CONVENTIONS.md. + * + * The pool is still available and most of the application still uses it. This + * is one file moving, not a cutover. + */ /** - * `SELECT 1 ...`, used only for `.length`. The column has no name of its own — - * Postgres calls it `?column?` — so the shape is an index signature rather than - * a field, and nothing reads a value out of it. + * The response shape, written once. + * + * The generated mirror names columns in camelCase — `parentId`, `sortOrder` — + * and this API answers in snake_case, which the admin frontend reads. So the + * mapping is explicit here rather than implicit anywhere: selecting the table + * directly would silently change the JSON contract, and no test that checks + * status codes would catch it. + * + * It also answers the question #218 asked. The row type is inferred from this + * object rather than hand-declared beside the query, so the interfaces that used + * to sit at the top of this file are gone and cannot drift from what is + * selected. */ -interface ExistsProbe { - [column: string]: number; -} +const CATEGORY_COLUMNS = { + id: categories.id, + name: categories.name, + parent_id: categories.parentId, + sort_order: categories.sortOrder +}; const router = Router(); @@ -37,11 +40,35 @@ const router = Router(); // stop siblings sharing a name. const UNIQUE_VIOLATION = '23505'; -// Walks down from a node, collecting it and every descendant. Used both for -// cycle detection on reparent and for reporting the blast radius of a delete. -const SUBTREE_CTE = ` +/** + * Whether a thrown error is that unique violation. + * + * Drizzle wraps driver errors, so the SQLSTATE that used to sit on `err.code` + * now sits on `err.cause.code`. The old check still compiled and simply never + * matched, turning two 409s into 500s — a conversion hazard with no type error + * and no failing build behind it, only two integration tests. Both shapes are + * accepted so this keeps working either side of a conversion. See #218. + */ +function isUniqueViolation(err: unknown): boolean { + const direct = (err as { code?: string }).code; + const wrapped = (err as { cause?: { code?: string } }).cause?.code; + return direct === UNIQUE_VIOLATION || wrapped === UNIQUE_VIOLATION; +} + +/** + * Walks down from a node, collecting it and every descendant. Used both for + * cycle detection on reparent and for reporting the blast radius of a delete. + * + * Still a `sql` template. Drizzle has `$with()` for CTEs, but this one is + * recursive and is consumed in two different shapes, and expressing it through + * the builder bought nothing over the SQL that is already correct and reviewed. + * The important part is that `${id}` here is a bind parameter, not text — there + * is no way to spell string interpolation in this template by accident, which is + * the property the whole adoption is for. + */ +const subtreeOf = (id: number) => sql` WITH RECURSIVE subtree AS ( - SELECT id FROM categories WHERE id = $1 + SELECT id FROM categories WHERE id = ${id} UNION ALL SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id )`; @@ -62,17 +89,29 @@ function readParentId(value: unknown): number | null | undefined { } async function parentExists(id: number): Promise { - const { rows } = await pool.query(`SELECT 1 FROM categories WHERE id = $1`, [id]); + const rows = await db + .select({ id: categories.id }) + .from(categories) + .where(eq(categories.id, id)) + .limit(1); return rows.length > 0; } router.get('/', asyncRoute(async (_req: Request, res: Response) => { - const { rows } = await pool.query( - `SELECT c.id, c.name, c.parent_id, c.sort_order, - (SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count - FROM categories c - ORDER BY c.sort_order, lower(c.name)` - ); + const rows = await db + .select({ + ...CATEGORY_COLUMNS, + // Written as literal SQL, NOT with ${items.categoryId} and + // ${categories.id}. Drizzle renders a column reference inside a sql + // template UNQUALIFIED — those two produced `WHERE "category_id" = "id"`, + // which Postgres resolved against items for both sides and answered with + // a plausible wrong number rather than an error. There are no values to + // bind in this fragment, so literal text is the honest form. See #218. + item_count: sql`(SELECT COUNT(*)::int FROM items WHERE items.category_id = categories.id)` + }) + .from(categories) + .orderBy(categories.sortOrder, sql`lower(categories.name)`); + res.json(rows); })); @@ -94,14 +133,14 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => { const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order : 0; try { - const { rows } = await pool.query( - `INSERT INTO categories (name, parent_id, sort_order) VALUES ($1, $2, $3) - RETURNING id, name, parent_id, sort_order`, - [name, parent, sortOrder] - ); - res.status(201).json({ ...rows[0], item_count: 0 }); + const rows = await db + .insert(categories) + .values({ name, parentId: parent, sortOrder }) + .returning(CATEGORY_COLUMNS); + + res.status(201).json({ ...requireRow(rows, 'the category INSERT'), item_count: 0 }); } catch (err) { - if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + if (isUniqueViolation(err)) { return res.status(409).json({ error: 'a category with that name already exists here' }); } throw err; @@ -136,11 +175,10 @@ async function resolveParentId( // Moving a node beneath itself or one of its own descendants would detach // that whole branch from the tree into an unreachable cycle. - const { rows: cycle } = await pool.query( - `${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`, - [id, parsed] + const cycle = await db.execute( + sql`${subtreeOf(id)} SELECT 1 FROM subtree WHERE id = ${parsed}` ); - if (cycle.length) { + if (cycle.rows.length) { return { error: 'a category cannot be moved beneath itself' }; } @@ -149,12 +187,18 @@ async function resolveParentId( router.put('/:id', asyncRoute(async (req: Request, res: Response) => { const id = Number(req.params.id); - const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]); - if (!existing.rows.length) { + const existing = await db + .select(CATEGORY_COLUMNS) + .from(categories) + .where(eq(categories.id, id)) + .limit(1); + + const current = existing[0]; + if (!current) { return res.status(404).json({ error: 'not found' }); } - let name = existing.rows[0].name; + let name = current.name; if (req.body.name !== undefined) { const parsed = readName(req.body.name); if (!parsed) { @@ -163,7 +207,7 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => { name = parsed; } - const resolved = await resolveParentId(req.body.parent_id, id, existing.rows[0].parent_id); + const resolved = await resolveParentId(req.body.parent_id, id, current.parent_id); if ('error' in resolved) { return res.status(400).json({ error: resolved.error }); } @@ -171,17 +215,18 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => { const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order - : existing.rows[0].sort_order; + : current.sort_order; try { - const { rows } = await pool.query( - `UPDATE categories SET name = $1, parent_id = $2, sort_order = $3 WHERE id = $4 - RETURNING id, name, parent_id, sort_order`, - [name, parent, sortOrder, id] - ); - res.json(rows[0]); + const rows = await db + .update(categories) + .set({ name, parentId: parent, sortOrder }) + .where(eq(categories.id, id)) + .returning(CATEGORY_COLUMNS); + + res.json(requireRow(rows, 'the category UPDATE')); } catch (err) { - if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + if (isUniqueViolation(err)) { return res.status(409).json({ error: 'a category with that name already exists here' }); } throw err; @@ -190,22 +235,33 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => { router.delete('/:id', asyncRoute(async (req: Request, res: Response) => { const id = Number(req.params.id); - const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]); - if (!subtree.length) { + + const subtree = await db.execute<{ id: number }>( + sql`${subtreeOf(id)} SELECT id FROM subtree` + ); + if (!subtree.rows.length) { return res.status(404).json({ error: 'not found' }); } - const ids = subtree.map((row: { id: number }) => row.id); - const { rows: affected } = await pool.query( - `SELECT COUNT(*)::int AS n FROM items WHERE category_id = ANY($1::int[])`, - [ids] - ); + const ids = subtree.rows.map((row) => row.id); + + // inArray rather than the ANY(...::int[]) this replaced, which sidesteps the + // array trap in CONVENTIONS.md entirely: there is no template to forget + // sql.param() in. The builder emits the placeholder list itself and it is + // correct by construction. + const affected = await db + .select({ n: sql`COUNT(*)::int` }) + .from(items) + .where(inArray(items.categoryId, ids)); // The FK cascade takes the descendants; items fall back to NULL rather than // being deleted along with their category. - await pool.query(`DELETE FROM categories WHERE id = $1`, [id]); + await db.delete(categories).where(eq(categories.id, id)); - res.json({ deleted_categories: ids.length, uncategorized_items: requireRow(affected, 'the affected-items COUNT').n }); + res.json({ + deleted_categories: ids.length, + uncategorized_items: requireRow(affected, 'the affected-items COUNT').n + }); })); export default router; diff --git a/backend/src/utils.ts b/backend/src/utils.ts index c1425f9..9c64e76 100755 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -91,3 +91,21 @@ export function trimTrailingSlashes(value: string): string { while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1); return trimmed; } + +/** + * A route's `:id` as a positive integer, or null when it is not one. + * + * Guarding this is not cosmetic. `Number('abc')` is NaN, which the driver sends + * to Postgres as the text "NaN"; Postgres raises 22P02 for an integer column, + * the route's catch turns that into a 500, and a caller asking for an item that + * cannot exist is told the server broke. Returning null lets the route answer + * 404, which is what "/items/abc" actually means. See #207. + * + * Rejects 0 and negatives as well as fractions: every id in this schema is a + * positive serial, so anything else identifies nothing. + */ +export function readId(value: string | undefined): number | null { + if (value === undefined || value.trim() === '') return null; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} diff --git a/backend/tests/integration/adminItemNotFound.integration.test.ts b/backend/tests/integration/adminItemNotFound.integration.test.ts new file mode 100644 index 0000000..eeb5278 --- /dev/null +++ b/backend/tests/integration/adminItemNotFound.integration.test.ts @@ -0,0 +1,110 @@ +import request from 'supertest'; +import app from '../../src/app'; +import { pool } from '../../src/db'; +import { resetDb, closeDb } from './setup/testDb'; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +); + +/** + * Nothing exists, so every id below is absent. 999999 is well-formed and + * missing; 'abc' is not a number at all. Before #207 the first answered 200 + * with an empty body and the second answered 500, because the raw string + * reached Postgres and raised 22P02. + */ +const ABSENT = 999999; + +describe('admin item routes for an id that does not exist', () => { + it('PUT answers 404 rather than 200 with an empty body', async () => { + const res = await request(app) + .put(`/api/admin/items/${ABSENT}`) + .field('name', 'renamed') + .field('price', '10.00'); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'not found' }); + }); + + it('mark-sold answers 404', async () => { + const res = await request(app).post(`/api/admin/items/${ABSENT}/mark-sold`); + expect(res.status).toBe(404); + }); + + it('mark-available answers 404', async () => { + const res = await request(app).post(`/api/admin/items/${ABSENT}/mark-available`); + expect(res.status).toBe(404); + }); + + // A garbage id used to reach Postgres and raise 22P02, which the catch turned + // into a 500. From the caller's side "/items/abc" identifies no item, exactly + // like "/items/999999" does. + it.each(['abc', '1.5', '-1', ''])('PUT answers 404 for the id %p', async (id) => { + const res = await request(app) + .put(`/api/admin/items/${id}`) + .field('name', 'renamed') + .field('price', '10.00'); + + expect(res.status).toBe(404); + }); + + it('mark-available answers 404 for a non-numeric id', async () => { + const res = await request(app).post('/api/admin/items/abc/mark-available'); + expect(res.status).toBe(404); + }); +}); + +describe('admin item routes for an id that does exist', () => { + async function makeItem(): Promise { + const res = await request(app) + .post('/api/admin/items') + .field('name', 'a real item') + .field('price', '12.00') + .attach('images', PNG, 'a.png'); + return res.body.id; + } + + // The point of the change is to stop lying about misses, not to start + // refusing hits. + it('PUT still updates and answers with the item', async () => { + const id = await makeItem(); + + const res = await request(app) + .put(`/api/admin/items/${id}`) + .field('name', 'renamed') + .field('price', '15.00'); + + expect(res.status).toBe(200); + expect(res.body.name).toBe('renamed'); + expect(res.body.price_cents).toBe(1500); + }); + + it('mark-available still publishes', async () => { + const id = await makeItem(); + + const res = await request(app).post(`/api/admin/items/${id}/mark-available`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('available'); + }); +}); + +describe('a non-numeric id on every admin item route', () => { + // Each of these used to reach Postgres, raise 22P02 and surface as a 500. + it.each([ + ['mark-sold', '/api/admin/items/abc/mark-sold'], + ['mark-available', '/api/admin/items/abc/mark-available'] + ])('%s answers 404', async (_name, path) => { + expect((await request(app).post(path)).status).toBe(404); + }); +}); 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()); +} diff --git a/backend/tests/unit/readId.test.ts b/backend/tests/unit/readId.test.ts new file mode 100644 index 0000000..8b498b5 --- /dev/null +++ b/backend/tests/unit/readId.test.ts @@ -0,0 +1,21 @@ +import { readId } from '../../src/utils'; + +describe('readId', () => { + it('reads a positive integer', () => { + expect(readId('7')).toBe(7); + expect(readId('999999')).toBe(999999); + }); + + // Each of these used to be sent to Postgres as text, raising 22P02 for an + // integer column and surfacing to the caller as a 500 (#207). + it.each(['abc', '', ' ', '1.5', '-1', '0', 'NaN', '1e5abc'])( + 'refuses %p', + (value) => { + expect(readId(value)).toBeNull(); + } + ); + + it('refuses a missing param', () => { + expect(readId(undefined)).toBeNull(); + }); +}); diff --git a/backend/tests/unit/workflowGate.test.ts b/backend/tests/unit/workflowGate.test.ts index f8062d5..531960c 100644 --- a/backend/tests/unit/workflowGate.test.ts +++ b/backend/tests/unit/workflowGate.test.ts @@ -29,6 +29,7 @@ interface Step { name: string; id: string | null; guarded: boolean; + condition: string | null; } /** @@ -51,7 +52,7 @@ function parseSteps(source: string): Step[] { for (const line of source.split(/\r?\n/)) { const named = /^ {6}- name: (.+?)\s*$/.exec(line); if (named?.[1] !== undefined) { - current = { name: named[1], id: null, guarded: false }; + current = { name: named[1], id: null, guarded: false, condition: null }; steps.push(current); continue; } @@ -61,6 +62,9 @@ function parseSteps(source: string): Step[] { if (id?.[1] !== undefined) current.id = id[1]; if (/^ {8}continue-on-error: true\s*$/.test(line)) current.guarded = true; + + const condition = /^ {8}if: (.+?)\s*$/.exec(line); + if (condition?.[1] !== undefined) current.condition = condition[1]; } return steps; @@ -181,3 +185,35 @@ describe('sonarqube.yml fails at the end rather than part way through', () => { expect(unguarded.map((step) => step.name)).toEqual([]); }); }); + +/** + * The guard for #197. + * + * SonarQube Community has no branch analysis: every scan published under a + * project key replaces that project's single analysis, whatever revision it came + * from. Publishing from a pull request therefore overwrites the dashboard's + * picture of main with the branch — silently, because nothing on the dashboard + * says which revision it describes. It was caught only because hotspot line + * numbers landed on a blank line. + * + * scripts/scan-local.sh has always refused to do this, defaulting to a scratch + * key. This asserts CI refuses too, because the failure leaves no trace and the + * `if:` is one line for somebody to drop. + */ +describe('publishing is restricted to non-pull-request runs', () => { + it.each(['SonarQube Scan', 'Report SonarQube measures'])('%s does not run on a pull request', (name) => { + const step = steps.find((candidate) => candidate.name === name); + + expect(step).toBeDefined(); + expect(step?.condition).toContain("github.event_name != 'pull_request'"); + }); + + // The suites are the reason pull requests run this workflow at all. Gating + // them would turn a fix for a reporting problem into a loss of every check. + it.each(['unit', 'integration', 'e2e'])('the %s suite still runs on pull requests', (id) => { + const step = steps.find((candidate) => candidate.id === id); + + expect(step).toBeDefined(); + expect(step?.condition ?? '').not.toContain('pull_request'); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 9dcebea..0ab671a 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,6 +2,9 @@ "name": "redefined-designs-frontend", "version": "1.0.0", "private": true, + "engines": { + "node": ">=20.9.0" + }, "scripts": { "dev": "vite", "build": "tsc && tsc -p tsconfig.test.json --noEmit && vite build", diff --git a/scripts/NodeVersion.ps1 b/scripts/NodeVersion.ps1 index 0f76294..1b72782 100644 --- a/scripts/NodeVersion.ps1 +++ b/scripts/NodeVersion.ps1 @@ -69,8 +69,17 @@ function Use-Node { # nvm wanted is not installed" into a claim that the newest install # was too old, which sent the reader to `nvm install` holding a list # that already had newer versions on it. + # A POSITIVE match on what success looks like, not a negative + # one on an allowlist of English error strings. The negative form + # returned True for empty output, for $null, and for + # 'exit status 1: Access is denied.' — so an alias switch that + # produced nothing, or failed on the symlink permission error this + # file's own header warns about, was reported as success while the + # old version kept running. That is the bug #198 was filed about, + # narrowed rather than removed. It also broke whenever nvm reworded + # an error. See #208. $switched = if ($Version -in @('latest', 'lts', 'newest')) { - $output -notmatch 'activation error|not installed' + $output -match ('Now using node v' + [regex]::Escape($raw.TrimStart('v'))) } else { $raw.TrimStart('v') -eq $Version.TrimStart('v') @@ -100,9 +109,10 @@ nvm said: $output nvm-windows rewrites a symlink at $env:NVM_SYMLINK, and can report success -without having rewritten it. Check the version is installed, and that this shell -can write that link: +without having rewritten it. If that version is not installed, install it; if it +is, check this shell can write that link: + nvm install $Version nvm list "@ } @@ -114,6 +124,20 @@ can write that link: # the specification. $script:NODE_VERSION = '26.7.0' +# What the machine is put back to afterwards. Beside NODE_VERSION rather than +# duplicated as a parameter default in each script, because two copies of a +# version drift and the half that drifts is the half nobody runs. Both scripts +# still take -DefaultNodeVersion to override it. +$script:DEFAULT_NODE_VERSION = '18.16.1' + +# Local runs are three major lines ahead of CI (Node 20 in every workflow) and of +# the production image (node:20-bookworm-slim). That divergence is deliberate but +# not free: post-20 syntax and node: APIs pass here and fail in the pipeline, and +# Node 26 ships an npm that can touch the lockfile in ways CI's npm reads +# differently. The `engines` field in both package.json files records the floor +# machine-readably; nothing yet catches "too new for where this ships". See #208 +# part 4, which is an open decision rather than an oversight. + <# Switches to the pinned version and insists it clears the floor. @@ -130,20 +154,24 @@ $script:NODE_VERSION = '26.7.0' A version that is not installed is Use-Node's error to report, and it now reports nvm's own reason, so there is nothing to say about it here. #> -function Use-NodeLatest { +function Use-PinnedNode { param([scriptblock]$Step, [scriptblock]$Note) - Use-Node -Version $script:NODE_VERSION -Why 'this project needs Node 20 or newer' -Step $Step -Note $Note | Out-Null - - $major = Get-NodeMajor - if ($major -lt 20) { + # Checked BEFORE the switch, against the constant rather than against what + # ends up running. After the switch $major is whatever NODE_VERSION says, + # so the old placement could never fire — it validated the switch it had + # just made instead of the pin it exists to guard. See #208. + $pinnedMajor = [int](($script:NODE_VERSION -replace '^v', '') -split '\.')[0] + if ($pinnedMajor -lt 20) { throw @" -Node is v$major after switching to $($script:NODE_VERSION), which is below the 20 this project needs. +NODE_VERSION in scripts/NodeVersion.ps1 is $($script:NODE_VERSION), which is below the 20 this project needs. -NODE_VERSION in scripts/NodeVersion.ps1 is pinned to a version that is too old. -node-pg-migrate, ts-jest and Playwright all need 20 or newer. +node-pg-migrate, ts-jest and Playwright all need 20 or newer. Nothing was +switched. "@ } + + Use-Node -Version $script:NODE_VERSION -Why 'this project needs Node 20 or newer' -Step $Step -Note $Note | Out-Null } <# diff --git a/scripts/run-tests.ps1 b/scripts/run-tests.ps1 index 1b735a9..1e49f8e 100644 --- a/scripts/run-tests.ps1 +++ b/scripts/run-tests.ps1 @@ -8,7 +8,7 @@ database bring-up and the TEST_PGPORT handling are shared by more than one suite and would otherwise be copied around and drift apart. - Switches Node to the latest installed version for the run and puts the + Switches Node to the pinned version in scripts/NodeVersion.ps1 for the run and puts the machine default back afterwards, the same way start-local.ps1 does — and for the same reason, since ts-jest and Playwright are subject to the same Node 20 floor as the migrations. @@ -47,7 +47,12 @@ param( [int]$TestDbPort = 55432, [switch]$KeepTestDb, [string]$Filter, - [string]$DefaultNodeVersion = '18.16.1' + # Resolved after NodeVersion.ps1 is dot-sourced below, not here. A param + # block runs before anything else in the script, so $script:DEFAULT_NODE_VERSION + # is still $null at this point and using it as the default would silently + # restore nothing — leaving the machine on the pinned version, which is the + # exact failure the restore exists to prevent. See #208. + [string]$DefaultNodeVersion = '' ) $ErrorActionPreference = 'Stop' @@ -74,6 +79,11 @@ function Invoke-Checked { . (Join-Path $PSScriptRoot 'NodeVersion.ps1') +# The one home for this value is NodeVersion.ps1, beside NODE_VERSION. It cannot +# be a param default (see the note there), so it is filled in here instead, and +# an explicit -DefaultNodeVersion still wins. +if (-not $DefaultNodeVersion) { $DefaultNodeVersion = $script:DEFAULT_NODE_VERSION } + # Bound once so the shared switcher reports in this script's output style. $NodeOut = @{ Step = ${function:Write-Step}; Note = ${function:Write-Note} } @@ -160,7 +170,7 @@ running stack. Start it first: finally { Pop-Location } } -Use-NodeLatest @NodeOut +Use-PinnedNode @NodeOut try { switch ($Suite) { diff --git a/scripts/start-local.ps1 b/scripts/start-local.ps1 index 738dc98..9d94a73 100644 --- a/scripts/start-local.ps1 +++ b/scripts/start-local.ps1 @@ -39,7 +39,12 @@ param( # What -Stop puts the machine back to. nvm's default here is 18.16.1, which # is too old to run this project's tooling but is what everything else on # the machine expects. - [string]$DefaultNodeVersion = '18.16.1' + # Resolved after NodeVersion.ps1 is dot-sourced below, not here. A param + # block runs before anything else in the script, so $script:DEFAULT_NODE_VERSION + # is still $null at this point and using it as the default would silently + # restore nothing — leaving the machine on the pinned version, which is the + # exact failure the restore exists to prevent. See #208. + [string]$DefaultNodeVersion = '' ) $ErrorActionPreference = 'Stop' @@ -78,6 +83,11 @@ function Assert-Docker { . (Join-Path $PSScriptRoot 'NodeVersion.ps1') +# The one home for this value is NodeVersion.ps1, beside NODE_VERSION. It cannot +# be a param default (see the note there), so it is filled in here instead, and +# an explicit -DefaultNodeVersion still wins. +if (-not $DefaultNodeVersion) { $DefaultNodeVersion = $script:DEFAULT_NODE_VERSION } + # Bound once so the shared switcher reports through this script's own output # style rather than printing in a voice of its own. $NodeOut = @{ Step = ${function:Write-Step}; Note = ${function:Write-Note} } @@ -270,7 +280,7 @@ if ($Stop) { return } -Use-NodeLatest @NodeOut +Use-PinnedNode @NodeOut # Anything after the switch reverts on the way out of a failure. Without this a # run that dies in migrations leaves the machine on the new version with nothing