From 25661ea620439f70cc2fae488fafc238e864424e Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 2 Sep 2026 08:37:29 -0500 Subject: [PATCH 1/8] fix(ci): stop pull request scans overwriting the dashboard's picture of main (#197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 each pull request, and each push to one, overwrote the dashboard's analysis of main with the branch. The new-code period, the gate result, the coverage percentages and the hotspot list then all described whatever was scanned last, with nothing on the dashboard saying which revision that was. A gate that went green on a feature branch read exactly like a gate that went green on main. It was caught only by luck: #180's hotspots reported line numbers that landed on a comment and a blank line in main, which is the kind of nonsense a person notices. Everything else it misreported would have looked fine. scripts/scan-local.sh has always refused to do this, defaulting to a scratch key, and its header says why in as many words. CI walked into the hazard that script guards against. Now the two tell the same story. The suites still run on pull requests, which is where their value is — only publishing is restricted. The measures report is skipped alongside the scan, because with nothing published it would print main's numbers into a pull request's log, which is noise at best and misread as the branch's own at worst. A test asserts both steps carry the restriction and that the three suites do not, because the failure leaves no trace and the `if:` is one line for somebody to drop. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/sonarqube.yml | 22 +++++++++++++- backend/tests/unit/workflowGate.test.ts | 38 ++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) 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/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'); + }); +}); -- 2.54.0 From be6a2fe5bd1514f874fb48670d00c6680a04c877 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 2 Sep 2026 08:44:13 -0500 Subject: [PATCH 2/8] fix(admin): answer 404 for an item id that does not exist (#207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three routes in admin.ts answered a miss with a success. PUT /items/:id ran an UPDATE that matched nothing, committed happily, selected nothing back and replied 200 with an empty body — a success the admin client could do nothing with, and no record anywhere that the item was not found. mark-sold and mark-available did the same. The create route beside them has always used requireRow for exactly this, which is why this reads as an oversight rather than a decision. A garbage id was worse in a different direction. Number('abc') is NaN, the driver sends it to Postgres as the text "NaN", Postgres raises 22P02 for an integer column, and the catch turned that into a 500 — so a caller asking for an item that cannot exist was told the server broke. Both now answer 404, because from the caller's side "/items/abc" identifies no item in exactly the way "/items/999999" does. readId is shared rather than repeated, and rejects zero, negatives and fractions as well as text: every id in this schema is a positive serial, so anything else identifies nothing. mark-sold now notifies favouriters only after the row is known to exist, so nobody is told about a sale that did not happen. The issue asked for the same shape to be checked across the other admin routes. It was: unpublish already looks the item up and 404s, and the tags and categories PUT routes both do an existence check before their UPDATE, so their rows[0] is guaranteed. items.ts already guards the public read. These three were the only ones lying about a miss. Co-Authored-By: Claude Opus 5 --- backend/src/routes/admin.ts | 41 +++++-- backend/src/utils.ts | 18 +++ .../adminItemNotFound.integration.test.ts | 110 ++++++++++++++++++ backend/tests/unit/readId.test.ts | 21 ++++ 4 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 backend/tests/integration/adminItemNotFound.integration.test.ts create mode 100644 backend/tests/unit/readId.test.ts 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/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/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(); + }); +}); -- 2.54.0 From cf2f5dd4a30059579ba1631055ca8858af7a985e Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 2 Sep 2026 08:48:54 -0500 Subject: [PATCH 3/8] fix(scripts): make the alias check fail closed, and correct three stale docs (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alias check was a negative match on an allowlist of English error strings, which returned True for empty output, for $null, and for "exit status 1: Access is denied." — so an alias switch producing nothing, or failing 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, and it also broke whenever nvm reworded an error. It is now a positive match on "Now using node v". The floor check moves ahead of the switch and reads the constant rather than the result. Where it sat, $major was always whatever NODE_VERSION says, so it validated the switch it had just made instead of the pin it exists to guard, and could never fire. Use-NodeLatest is now Use-PinnedNode. In a change whose whole subject is that "latest" means something people do not expect, the name was an avoidable trap. The restore default moves beside NODE_VERSION. It deliberately is not a param default: a param block runs before the dot-source, so $script:DEFAULT_NODE_VERSION is still $null there and the restore would have quietly restored nothing — leaving the machine on the pinned version, which is the exact failure the restore exists to prevent. It is resolved after the dot-source instead, and an explicit -DefaultNodeVersion still wins. Three documents described behaviour the code no longer has: README's "both scripts run nvm use latest", run-tests.ps1's .DESCRIPTION, and project-context.md's instruction to agents. All corrected, and project-context.md now also says not to run these scripts from an agent shell, which is how this machine once ended up with no Node at all. Part 4 of the issue is partly stale: backend/package.json already declares engines >=20.9.0. frontend now matches it. The larger question — whether local should be pinned to the Node 20 that CI and the production image actually run — is a decision rather than an oversight and is left open on the issue. Verified by parsing all three scripts with the PowerShell AST parser, which does not execute them. They are deliberately never run from an agent shell. Co-Authored-By: Claude Opus 5 --- .claude/project-context.md | 2 +- README.md | 14 +++++++---- frontend/package.json | 3 +++ scripts/NodeVersion.ps1 | 50 +++++++++++++++++++++++++++++--------- scripts/run-tests.ps1 | 16 +++++++++--- scripts/start-local.ps1 | 14 +++++++++-- 6 files changed, 77 insertions(+), 22 deletions(-) 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/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/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 -- 2.54.0 From 8e6f11111ba59280118ddebdcf8c356b1c6ecf7b Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 2 Sep 2026 08:56:20 -0500 Subject: [PATCH 4/8] feat(db): land the Drizzle schema, config and conventions (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infrastructure only. No route is converted, nothing changes at run time. The mirror had already drifted, which settles how it should be maintained. schema.ts was missing item_drafts and upload_links from the moment #222 landed, because the spike pulled into ./drizzle and copied the file into src/ by hand, and nobody had reason to look at the copy for a week. So `out` now points at src/db-drizzle and pull refreshes in place — the copy step that made the drift possible is gone — and tablesFilter excludes pgmigrations, which is node-pg-migrate's bookkeeping and has no business in a model of the application's schema. A stale mirror is worse than no mirror, because Drizzle infers row types from it: a converted query would type-check against a schema the database does not have and fail at run time on a column that does not exist. drizzleSchema.integration.test.ts fails when the two disagree, on tables and on columns. It was checked by removing item_drafts from the mirror and confirming the test fails naming it, rather than trusting a green run on a file that already matched. pull also emits 0000_*.sql and meta/ into `out`, because that directory serves both purposes. Both are gitignored: this project's migration history is backend/migrations, hand-written and mostly prose, and #219 has not chosen otherwise — a stray SQL file in src/ is at best noise and at worst mistaken for real history. db is exported beside pool and shares its connections. Both must work at once, since conversion is file by file across 187 sites; separate pools would make a transaction on one invisible to the other and silently double the configured limits. The generated files are excluded from linting. #261 hand-fixed an unused-parameter warning in schema.ts and this re-pull put it straight back, which is the argument in one line: linting generated code buys a fix the next regeneration undoes. itemFilters.drizzle.ts, which is hand-written, is still linted. CONVENTIONS.md records the sql.param() array trap before anyone hits it — the wrong form type-checks, reads correctly and fails at run time as invalid Postgres — and the reason the adoption is worth doing at all, which is that ${value} emits a bind parameter and there is no way to spell "interpolate this as SQL" by accident. Co-Authored-By: Claude Opus 5 --- .gitignore | 9 + backend/drizzle.config.ts | 18 +- backend/eslint.config.mjs | 15 +- backend/src/db-drizzle/CONVENTIONS.md | 50 +++ backend/src/db-drizzle/relations.ts | 171 +++++++++ backend/src/db-drizzle/schema.ts | 358 ++++++++++-------- backend/src/db.ts | 23 ++ .../drizzleSchema.integration.test.ts | 95 +++++ 8 files changed, 582 insertions(+), 157 deletions(-) create mode 100644 backend/src/db-drizzle/CONVENTIONS.md create mode 100644 backend/src/db-drizzle/relations.ts create mode 100644 backend/tests/integration/drizzleSchema.integration.test.ts diff --git a/.gitignore b/.gitignore index 3b6eac3..f8c6afa 100755 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,12 @@ test-results/ backend/unit-results.json backend/integration-results.json frontend/playwright-results.json + +# drizzle-kit pull writes the schema mirror into backend/src/db-drizzle (see +# backend/drizzle.config.ts), but `out` is also where it would put generated +# migrations and their journal. This project's migration history is +# backend/migrations — hand-written, and mostly prose. #219 has not chosen +# otherwise, so a stray 0000_*.sql in src/ is at best noise and at worst +# mistaken for real migration history. Keep the mirror, drop the rest. +backend/src/db-drizzle/*.sql +backend/src/db-drizzle/meta/ diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts index b3cde78..d637073 100644 --- a/backend/drizzle.config.ts +++ b/backend/drizzle.config.ts @@ -8,7 +8,23 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ dialect: 'postgresql', schema: './src/db-drizzle/schema.ts', - out: './drizzle', + + // `drizzle-kit pull` writes its output here, so this points at the directory + // the application actually imports from. The spike pulled into ./drizzle and + // copied the file into src/ by hand, and that copy drifted exactly as + // predicted — not because anyone re-pulled, but because #222 added + // item_drafts and upload_links and the mirror was never refreshed. Nothing + // noticed for a week. Pulling in place removes the copy step that made that + // possible. See #217. + // + // If #219 ever chooses generated migrations, they also land in `out`, and + // this will need splitting then. It is a queries-only mirror today. + out: './src/db-drizzle', + + // pgmigrations is node-pg-migrate's own bookkeeping. It is not part of the + // application's schema and has no business in a generated model of it. + tablesFilter: ['!pgmigrations'], + dbCredentials: { url: process.env.DRIZZLE_DATABASE_URL ?? '' } diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index ce564d5..51cc30f 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -30,7 +30,20 @@ const advisory = (config) => ({ }); export default tseslint.config( - { ignores: ['dist/**', 'coverage/**', 'eslint.config.mjs'] }, + // src/db-drizzle/schema.ts and relations.ts are `drizzle-kit pull` output, not + // written by anyone here. #261 hand-fixed an unused-parameter warning in the + // schema and #217's re-pull put it straight back, which is the whole argument: + // linting generated code buys a fix that the next regeneration undoes. The + // hand-written files in that directory are still linted. + { + ignores: [ + 'dist/**', + 'coverage/**', + 'eslint.config.mjs', + 'src/db-drizzle/schema.ts', + 'src/db-drizzle/relations.ts' + ] + }, ...[js.configs.recommended, ...tseslint.configs.recommended, sonarjs.configs.recommended].map( advisory diff --git a/backend/src/db-drizzle/CONVENTIONS.md b/backend/src/db-drizzle/CONVENTIONS.md new file mode 100644 index 0000000..ee444eb --- /dev/null +++ b/backend/src/db-drizzle/CONVENTIONS.md @@ -0,0 +1,50 @@ +# Drizzle conventions + +Decided in #216, landed in #217. Read this before converting a query. + +## What is in this directory + +| File | Owner | +|---|---| +| `schema.ts` | **Generated.** `drizzle-kit pull` output. Do not hand-edit. | +| `relations.ts` | **Generated.** Same. | +| `itemFilters.drizzle.ts` | Hand-written. The #216 spike's conversion of `buildItemFilterSql`, kept as the worked example. | +| `CONVENTIONS.md` | This file. | + +`backend/migrations` owns the schema. `schema.ts` is a read-only mirror of it, and refreshing that mirror is a manual step: + +```bash +DRIZZLE_DATABASE_URL=postgres://user:pass@localhost:PORT/db npx drizzle-kit pull +``` + +Run it against a database with every migration applied, after writing a migration. `drizzle-kit pull` also emits `0000_*.sql` and `meta/` into this directory because `out` serves both purposes; both are gitignored, because this project's migration history is `backend/migrations` and a stray SQL file here would at best be noise and at worst be mistaken for real history. Whether that stays true is #219. + +`drizzleSchema.integration.test.ts` fails when the mirror and the database disagree, on tables or on columns. That test exists because the drift is silent and already happened: the mirror sat missing `item_drafts` and `upload_links` from the moment #222 landed until #217, because the spike had copied it into `src/` by hand and nobody had reason to look. A stale mirror is worse than none — Drizzle infers row types from it, so a converted query type-checks against a schema the database does not have and fails at run time on a column that does not exist. + +## The rule that will bite you: arrays + +In a Drizzle `sql` template, an array interpolates as a **placeholder list**, not as one array parameter. + +```ts +// WRONG. Emits ANY(($1, $2)::int[]), which is invalid Postgres. +sql`... WHERE id = ANY(${filters.categoryIds}::int[])` + +// RIGHT. Emits ANY($1::int[]). +sql`... WHERE id = ANY(${sql.param(filters.categoryIds)}::int[])` +``` + +The wrong form type-checks, reads correctly, and fails at run time. Nothing warns. Across 187 call sites this is exactly the shape of defect that passes review and breaks in production, so `sql.param()` is required for every array and any converted query taking one needs a test that actually executes it. + +## The reason this is worth doing + +`${value}` in a Drizzle `sql` template emits a bind parameter, never text. There is no way to spell "interpolate this value as SQL" by accident: the escape hatch that looks like a plain template literal does not behave like one. Passing `"1); DROP TABLE items; --"` as a status value puts it in the parameters and not in the SQL. + +That makes the #202 invariant — only placeholder indices may be interpolated into a clause — a property of the type system rather than a comment guarded by two mutation tests, and it retires #180's three S2077 hotspots rather than leaving them reviewed and watched. It is the strongest argument for the adoption, and the spike confirmed it is real rather than relocated. + +## Both drivers run at once + +`db` and `pool` are exported from `src/db.ts` and share one pool, deliberately. Conversion is file by file across 187 sites, so most queries will be raw `pg` for a long time and the two must not open separate connection pools — a transaction on one would be invisible to the other, and the configured limits would silently double. + +## Not settled + +Whether generated migrations replace `node-pg-migrate` is **#219**, and nothing here depends on it. The first generated migration after a pull also emitted drops and recreations of the three expression indexes, which needs hand-editing and takes real locks on a large table; and data migrations cannot be generated at all. Do not start generating migrations as a side effect of converting a query. diff --git a/backend/src/db-drizzle/relations.ts b/backend/src/db-drizzle/relations.ts new file mode 100644 index 0000000..171897b --- /dev/null +++ b/backend/src/db-drizzle/relations.ts @@ -0,0 +1,171 @@ +import { relations } from "drizzle-orm/relations"; +import { categories, items, itemImages, customers, customerSessions, customerTokens, carts, cartItems, shippingAddresses, checkouts, orders, itemDrafts, uploadLinks, itemTags, tags, checkoutItems, favorites } from "./schema"; + +export const itemsRelations = relations(items, ({one, many}) => ({ + category: one(categories, { + fields: [items.categoryId], + references: [categories.id] + }), + itemImages: many(itemImages), + cartItems: many(cartItems), + orders: many(orders), + itemDrafts: many(itemDrafts), + itemTags: many(itemTags), + checkoutItems: many(checkoutItems), + favorites: many(favorites), +})); + +export const categoriesRelations = relations(categories, ({one, many}) => ({ + items: many(items), + category: one(categories, { + fields: [categories.parentId], + references: [categories.id], + relationName: "categories_parentId_categories_id" + }), + categories: many(categories, { + relationName: "categories_parentId_categories_id" + }), + itemDrafts: many(itemDrafts), +})); + +export const itemImagesRelations = relations(itemImages, ({one}) => ({ + item: one(items, { + fields: [itemImages.itemId], + references: [items.id] + }), +})); + +export const customerSessionsRelations = relations(customerSessions, ({one}) => ({ + customer: one(customers, { + fields: [customerSessions.customerId], + references: [customers.id] + }), +})); + +export const customersRelations = relations(customers, ({many}) => ({ + customerSessions: many(customerSessions), + customerTokens: many(customerTokens), + carts: many(carts), + shippingAddresses: many(shippingAddresses), + checkouts: many(checkouts), + orders: many(orders), + favorites: many(favorites), +})); + +export const customerTokensRelations = relations(customerTokens, ({one}) => ({ + customer: one(customers, { + fields: [customerTokens.customerId], + references: [customers.id] + }), +})); + +export const cartsRelations = relations(carts, ({one, many}) => ({ + customer: one(customers, { + fields: [carts.customerId], + references: [customers.id] + }), + cartItems: many(cartItems), +})); + +export const cartItemsRelations = relations(cartItems, ({one}) => ({ + cart: one(carts, { + fields: [cartItems.cartId], + references: [carts.id] + }), + item: one(items, { + fields: [cartItems.itemId], + references: [items.id] + }), +})); + +export const shippingAddressesRelations = relations(shippingAddresses, ({one, many}) => ({ + customer: one(customers, { + fields: [shippingAddresses.customerId], + references: [customers.id] + }), + checkouts: many(checkouts), +})); + +export const checkoutsRelations = relations(checkouts, ({one, many}) => ({ + customer: one(customers, { + fields: [checkouts.customerId], + references: [customers.id] + }), + shippingAddress: one(shippingAddresses, { + fields: [checkouts.shippingAddressId], + references: [shippingAddresses.id] + }), + orders: many(orders), + checkoutItems: many(checkoutItems), +})); + +export const ordersRelations = relations(orders, ({one}) => ({ + item: one(items, { + fields: [orders.itemId], + references: [items.id] + }), + customer: one(customers, { + fields: [orders.customerId], + references: [customers.id] + }), + checkout: one(checkouts, { + fields: [orders.checkoutId], + references: [checkouts.id] + }), +})); + +export const itemDraftsRelations = relations(itemDrafts, ({one}) => ({ + item: one(items, { + fields: [itemDrafts.itemId], + references: [items.id] + }), + uploadLink: one(uploadLinks, { + fields: [itemDrafts.uploadLinkId], + references: [uploadLinks.id] + }), + category: one(categories, { + fields: [itemDrafts.aiCategoryId], + references: [categories.id] + }), +})); + +export const uploadLinksRelations = relations(uploadLinks, ({many}) => ({ + itemDrafts: many(itemDrafts), +})); + +export const itemTagsRelations = relations(itemTags, ({one}) => ({ + item: one(items, { + fields: [itemTags.itemId], + references: [items.id] + }), + tag: one(tags, { + fields: [itemTags.tagId], + references: [tags.id] + }), +})); + +export const tagsRelations = relations(tags, ({many}) => ({ + itemTags: many(itemTags), +})); + +export const checkoutItemsRelations = relations(checkoutItems, ({one}) => ({ + checkout: one(checkouts, { + fields: [checkoutItems.checkoutId], + references: [checkouts.id] + }), + item: one(items, { + fields: [checkoutItems.itemId], + references: [items.id] + }), +})); + +export const favoritesRelations = relations(favorites, ({one}) => ({ + customer: one(customers, { + fields: [favorites.customerId], + references: [customers.id] + }), + item: one(items, { + fields: [favorites.itemId], + references: [items.id] + }), +})); \ No newline at end of file diff --git a/backend/src/db-drizzle/schema.ts b/backend/src/db-drizzle/schema.ts index b769a0e..729744c 100644 --- a/backend/src/db-drizzle/schema.ts +++ b/backend/src/db-drizzle/schema.ts @@ -1,19 +1,28 @@ -import { pgTable, serial, varchar, timestamp, text, foreignKey, integer, boolean, jsonb, index, uniqueIndex, unique, primaryKey } from "drizzle-orm/pg-core" +import { pgTable, index, foreignKey, serial, text, integer, timestamp, unique, boolean, jsonb, uniqueIndex, primaryKey, pgSequence } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" +export const pgmigrationsIdSeq = pgSequence("pgmigrations_id_seq", { startWith: "1", increment: "1", minValue: "1", maxValue: "2147483647", cache: "1", cycle: false }) -export const pgmigrations = pgTable("pgmigrations", { +export const items = pgTable("items", { id: serial().primaryKey().notNull(), - name: varchar({ length: 255 }).notNull(), - runOn: timestamp("run_on", { mode: 'string' }).notNull(), -}); - -export const adminSettings = pgTable("admin_settings", { - key: text().primaryKey().notNull(), - value: text().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}); + name: text().notNull(), + description: text(), + priceCents: integer("price_cents").default(8000).notNull(), + status: text().default('pending').notNull(), + reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }), + soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }), + paypalOrderId: text("paypal_order_id"), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + categoryId: integer("category_id"), +}, (table) => [ + index("items_category_id_idx").using("btree", table.categoryId.asc().nullsLast().op("int4_ops")), + foreignKey({ + columns: [table.categoryId], + foreignColumns: [categories.id], + name: "items_category_id_fkey" + }).onDelete("set null"), +]); export const itemImages = pgTable("item_images", { id: serial().primaryKey().notNull(), @@ -29,6 +38,96 @@ export const itemImages = pgTable("item_images", { }).onDelete("cascade"), ]); +export const customerSessions = pgTable("customer_sessions", { + token: text().primaryKey().notNull(), + customerId: integer("customer_id").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + foreignKey({ + columns: [table.customerId], + foreignColumns: [customers.id], + name: "customer_sessions_customer_id_fkey" + }).onDelete("cascade"), +]); + +export const customerTokens = pgTable("customer_tokens", { + token: text().primaryKey().notNull(), + customerId: integer("customer_id").notNull(), + kind: text().notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + foreignKey({ + columns: [table.customerId], + foreignColumns: [customers.id], + name: "customer_tokens_customer_id_fkey" + }).onDelete("cascade"), +]); + +export const adminSettings = pgTable("admin_settings", { + key: text().primaryKey().notNull(), + value: text().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}); + +export const customers = pgTable("customers", { + id: serial().primaryKey().notNull(), + email: text().notNull(), + passwordHash: text("password_hash").notNull(), + emailVerified: boolean("email_verified").default(false).notNull(), + marketingConsent: boolean("marketing_consent").default(false).notNull(), + marketingConsentAt: timestamp("marketing_consent_at", { withTimezone: true, mode: 'string' }), + marketingConsentText: text("marketing_consent_text"), + unsubscribeToken: text("unsubscribe_token").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + disabledAt: timestamp("disabled_at", { withTimezone: true, mode: 'string' }), + favoriteAlerts: boolean("favorite_alerts").default(false).notNull(), + favoriteAlertsAt: timestamp("favorite_alerts_at", { withTimezone: true, mode: 'string' }), + favoriteAlertsText: text("favorite_alerts_text"), + firstName: text("first_name"), + lastName: text("last_name"), +}, (table) => [ + index("customers_disabled_at_idx").using("btree", table.disabledAt.asc().nullsLast().op("timestamptz_ops")).where(sql`(disabled_at IS NOT NULL)`), + unique("customers_email_key").on(table.email), + unique("customers_unsubscribe_token_key").on(table.unsubscribeToken), +]); + +export const carts = pgTable("carts", { + id: serial().primaryKey().notNull(), + customerId: integer("customer_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + foreignKey({ + columns: [table.customerId], + foreignColumns: [customers.id], + name: "carts_customer_id_fkey" + }).onDelete("cascade"), + unique("carts_customer_id_key").on(table.customerId), +]); + +export const cartItems = pgTable("cart_items", { + id: serial().primaryKey().notNull(), + cartId: integer("cart_id").notNull(), + itemId: integer("item_id").notNull(), + addedAt: timestamp("added_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), + lastReminderSentAt: timestamp("last_reminder_sent_at", { withTimezone: true, mode: 'string' }), +}, (table) => [ + foreignKey({ + columns: [table.cartId], + foreignColumns: [carts.id], + name: "cart_items_cart_id_fkey" + }).onDelete("cascade"), + foreignKey({ + columns: [table.itemId], + foreignColumns: [items.id], + name: "cart_items_item_id_fkey" + }).onDelete("cascade"), + unique("cart_items_item_id_key").on(table.itemId), +]); + export const shippingAddresses = pgTable("shipping_addresses", { id: serial().primaryKey().notNull(), customerId: integer("customer_id").notNull(), @@ -51,51 +150,27 @@ export const shippingAddresses = pgTable("shipping_addresses", { }).onDelete("cascade"), ]); -export const items = pgTable("items", { +export const checkouts = pgTable("checkouts", { id: serial().primaryKey().notNull(), - name: text().notNull(), - description: text(), - priceCents: integer("price_cents").notNull(), + customerId: integer("customer_id"), + shippingAddressId: integer("shipping_address_id"), + processor: text().notNull(), + processorOrderId: text("processor_order_id"), + amountCents: integer("amount_cents"), status: text().default('pending').notNull(), - reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }), - soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }), - paypalOrderId: text("paypal_order_id"), + rawEvent: jsonb("raw_event"), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - categoryId: integer("category_id"), - conditionNote: text("condition_note"), }, (table) => [ - index("items_category_id_idx").using("btree", table.categoryId.asc().nullsLast().op("int4_ops")), foreignKey({ - columns: [table.categoryId], - foreignColumns: [categories.id], - name: "items_category_id_fkey" + columns: [table.customerId], + foreignColumns: [customers.id], + name: "checkouts_customer_id_fkey" }).onDelete("set null"), -]); - -export const tags = pgTable("tags", { - id: serial().primaryKey().notNull(), - name: text().notNull(), - color: text().notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, () => [ - uniqueIndex("tags_name_uniq").using("btree", sql`lower(name)`), -]); - -export const categories = pgTable("categories", { - id: serial().primaryKey().notNull(), - name: text().notNull(), - parentId: integer("parent_id"), - sortOrder: integer("sort_order").default(0).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - uniqueIndex("categories_child_name_uniq").using("btree", sql`parent_id`, sql`lower(name)`).where(sql`(parent_id IS NOT NULL)`), - index("categories_parent_id_idx").using("btree", table.parentId.asc().nullsLast().op("int4_ops")), - uniqueIndex("categories_root_name_uniq").using("btree", sql`lower(name)`).where(sql`(parent_id IS NULL)`), foreignKey({ - columns: [table.parentId], - foreignColumns: [table.id], - name: "categories_parent_id_fkey" - }).onDelete("cascade"), + columns: [table.shippingAddressId], + foreignColumns: [shippingAddresses.id], + name: "checkouts_shipping_address_id_fkey" + }).onDelete("set null"), ]); export const orders = pgTable("orders", { @@ -127,111 +202,84 @@ export const orders = pgTable("orders", { }).onDelete("set null"), ]); -export const cartItems = pgTable("cart_items", { +export const categories = pgTable("categories", { id: serial().primaryKey().notNull(), - cartId: integer("cart_id").notNull(), - itemId: integer("item_id").notNull(), - addedAt: timestamp("added_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), - lastReminderSentAt: timestamp("last_reminder_sent_at", { withTimezone: true, mode: 'string' }), + name: text().notNull(), + parentId: integer("parent_id"), + sortOrder: integer("sort_order").default(0).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), }, (table) => [ + uniqueIndex("categories_child_name_uniq").using("btree", sql`parent_id`, sql`lower(name)`).where(sql`(parent_id IS NOT NULL)`), + index("categories_parent_id_idx").using("btree", table.parentId.asc().nullsLast().op("int4_ops")), + uniqueIndex("categories_root_name_uniq").using("btree", sql`lower(name)`).where(sql`(parent_id IS NULL)`), foreignKey({ - columns: [table.cartId], - foreignColumns: [carts.id], - name: "cart_items_cart_id_fkey" + columns: [table.parentId], + foreignColumns: [table.id], + name: "categories_parent_id_fkey" }).onDelete("cascade"), +]); + +export const tags = pgTable("tags", { + id: serial().primaryKey().notNull(), + name: text().notNull(), + color: text().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + uniqueIndex("tags_name_uniq").using("btree", sql`lower(name)`), +]); + +export const itemDrafts = pgTable("item_drafts", { + id: serial().primaryKey().notNull(), + itemId: integer("item_id").notNull(), + uploadLinkId: integer("upload_link_id"), + submitterNote: text("submitter_note"), + state: text().default('queued').notNull(), + attempts: integer().default(0).notNull(), + model: text(), + aiName: text("ai_name"), + aiDescription: text("ai_description"), + aiCategoryId: integer("ai_category_id"), + aiTagNames: text("ai_tag_names").array(), + aiSuggestedPriceCents: integer("ai_suggested_price_cents"), + priceSource: text("price_source").default('default').notNull(), + aiError: text("ai_error"), + inputTokens: integer("input_tokens"), + outputTokens: integer("output_tokens"), + costMicros: integer("cost_micros"), + draftedAt: timestamp("drafted_at", { withTimezone: true, mode: 'string' }), + reviewedAt: timestamp("reviewed_at", { withTimezone: true, mode: 'string' }), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + index("item_drafts_state_idx").using("btree", table.state.asc().nullsLast().op("text_ops")), foreignKey({ columns: [table.itemId], foreignColumns: [items.id], - name: "cart_items_item_id_fkey" + name: "item_drafts_item_id_fkey" }).onDelete("cascade"), - unique("cart_items_item_id_key").on(table.itemId), -]); - -export const carts = pgTable("carts", { - id: serial().primaryKey().notNull(), - customerId: integer("customer_id").notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "carts_customer_id_fkey" - }).onDelete("cascade"), - unique("carts_customer_id_key").on(table.customerId), -]); - -export const customerTokens = pgTable("customer_tokens", { - token: text().primaryKey().notNull(), - customerId: integer("customer_id").notNull(), - kind: text().notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "customer_tokens_customer_id_fkey" - }).onDelete("cascade"), -]); - -export const checkouts = pgTable("checkouts", { - id: serial().primaryKey().notNull(), - customerId: integer("customer_id"), - shippingAddressId: integer("shipping_address_id"), - processor: text().notNull(), - processorOrderId: text("processor_order_id"), - amountCents: integer("amount_cents"), - status: text().default('pending').notNull(), - rawEvent: jsonb("raw_event"), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "checkouts_customer_id_fkey" + columns: [table.uploadLinkId], + foreignColumns: [uploadLinks.id], + name: "item_drafts_upload_link_id_fkey" }).onDelete("set null"), foreignKey({ - columns: [table.shippingAddressId], - foreignColumns: [shippingAddresses.id], - name: "checkouts_shipping_address_id_fkey" + columns: [table.aiCategoryId], + foreignColumns: [categories.id], + name: "item_drafts_ai_category_id_fkey" }).onDelete("set null"), + unique("item_drafts_item_id_key").on(table.itemId), ]); -export const customerSessions = pgTable("customer_sessions", { - token: text().primaryKey().notNull(), - customerId: integer("customer_id").notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.customerId], - foreignColumns: [customers.id], - name: "customer_sessions_customer_id_fkey" - }).onDelete("cascade"), -]); - -export const customers = pgTable("customers", { +export const uploadLinks = pgTable("upload_links", { id: serial().primaryKey().notNull(), - email: text().notNull(), - passwordHash: text("password_hash").notNull(), - emailVerified: boolean("email_verified").default(false).notNull(), - marketingConsent: boolean("marketing_consent").default(false).notNull(), - marketingConsentAt: timestamp("marketing_consent_at", { withTimezone: true, mode: 'string' }), - marketingConsentText: text("marketing_consent_text"), - unsubscribeToken: text("unsubscribe_token").notNull(), + label: text().notNull(), + tokenHash: text("token_hash").notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true, mode: 'string' }), + submissionCount: integer("submission_count").default(0).notNull(), + maxSubmissions: integer("max_submissions"), + lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: 'string' }), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - disabledAt: timestamp("disabled_at", { withTimezone: true, mode: 'string' }), - favoriteAlerts: boolean("favorite_alerts").default(false).notNull(), - favoriteAlertsAt: timestamp("favorite_alerts_at", { withTimezone: true, mode: 'string' }), - favoriteAlertsText: text("favorite_alerts_text"), - firstName: text("first_name"), - lastName: text("last_name"), }, (table) => [ - index("customers_disabled_at_idx").using("btree", table.disabledAt.asc().nullsLast().op("timestamptz_ops")).where(sql`(disabled_at IS NOT NULL)`), - unique("customers_email_key").on(table.email), - unique("customers_unsubscribe_token_key").on(table.unsubscribeToken), + unique("upload_links_token_hash_key").on(table.tokenHash), ]); export const itemTags = pgTable("item_tags", { @@ -252,6 +300,24 @@ export const itemTags = pgTable("item_tags", { primaryKey({ columns: [table.itemId, table.tagId], name: "item_tags_pkey"}), ]); +export const checkoutItems = pgTable("checkout_items", { + checkoutId: integer("checkout_id").notNull(), + itemId: integer("item_id").notNull(), + priceCents: integer("price_cents").notNull(), +}, (table) => [ + foreignKey({ + columns: [table.checkoutId], + foreignColumns: [checkouts.id], + name: "checkout_items_checkout_id_fkey" + }).onDelete("cascade"), + foreignKey({ + columns: [table.itemId], + foreignColumns: [items.id], + name: "checkout_items_item_id_fkey" + }).onDelete("cascade"), + primaryKey({ columns: [table.checkoutId, table.itemId], name: "checkout_items_pkey"}), +]); + export const favorites = pgTable("favorites", { customerId: integer("customer_id").notNull(), itemId: integer("item_id").notNull(), @@ -270,21 +336,3 @@ export const favorites = pgTable("favorites", { }).onDelete("cascade"), primaryKey({ columns: [table.customerId, table.itemId], name: "favorites_pkey"}), ]); - -export const checkoutItems = pgTable("checkout_items", { - checkoutId: integer("checkout_id").notNull(), - itemId: integer("item_id").notNull(), - priceCents: integer("price_cents").notNull(), -}, (table) => [ - foreignKey({ - columns: [table.checkoutId], - foreignColumns: [checkouts.id], - name: "checkout_items_checkout_id_fkey" - }).onDelete("cascade"), - foreignKey({ - columns: [table.itemId], - foreignColumns: [items.id], - name: "checkout_items_item_id_fkey" - }).onDelete("cascade"), - primaryKey({ columns: [table.checkoutId, table.itemId], name: "checkout_items_pkey"}), -]); diff --git a/backend/src/db.ts b/backend/src/db.ts index 1bf095b..446197a 100755 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -1,4 +1,6 @@ import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import * as schema from './db-drizzle/schema'; export const pool = new Pool({ host: process.env.PGHOST, @@ -8,6 +10,27 @@ export const pool = new Pool({ database: process.env.PGDATABASE }); +/** + * Drizzle over the same pool, alongside `pool` rather than instead of it. + * + * Both have to work at once: the conversion decided in #216 is file by file + * across 187 call sites, so for a long time most queries will still be raw `pg` + * and the two must share one set of connections. Handing drizzle the existing + * pool rather than letting it open its own is what makes that true — otherwise + * a transaction started on one would be invisible to the other, and the pool + * limits would silently double. + * + * The value of this over raw `pg` is not brevity. In a Drizzle `sql` template + * `${value}` emits a **bind parameter**, never text, so there is no way to + * spell "interpolate this as SQL" by accident — the escape hatch that looks + * like a plain template literal does not behave like one. That makes the #202 + * invariant structural instead of a comment plus two mutation tests, and it is + * the main reason this adoption is worth doing. + * + * The trap that goes with it is arrays. See db-drizzle/CONVENTIONS.md. + */ +export const db = drizzle(pool, { schema }); + /** * The single row a query is guaranteed to have returned. * diff --git a/backend/tests/integration/drizzleSchema.integration.test.ts b/backend/tests/integration/drizzleSchema.integration.test.ts new file mode 100644 index 0000000..2f681a3 --- /dev/null +++ b/backend/tests/integration/drizzleSchema.integration.test.ts @@ -0,0 +1,95 @@ +import { readFileSync } from 'fs'; +import path from 'path'; +import { pool } from '../../src/db'; +import { closeDb } from './setup/testDb'; + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +const SCHEMA = readFileSync( + path.join(__dirname, '..', '..', 'src', 'db-drizzle', 'schema.ts'), + 'utf8' +); + +/** Every table name the generated mirror declares. */ +function mirroredTables(): string[] { + return [...SCHEMA.matchAll(/pgTable\("([a-z_]+)"/g)].map((m) => m[1]!).sort(); +} + +async function liveTables(): Promise { + const { rows } = await pool.query<{ table_name: string }>( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + AND table_name <> 'pgmigrations' + ORDER BY table_name` + ); + return rows.map((r) => r.table_name); +} + +/** + * The guard for #217. + * + * `src/db-drizzle/schema.ts` is generated by `drizzle-kit pull` and is a + * read-only mirror of the real schema, which `backend/migrations` owns. Nothing + * makes anyone re-pull after writing a migration, and that is not hypothetical: + * the mirror sat missing `item_drafts` and `upload_links` from the moment #222 + * landed until #217, because it had been copied into src/ by hand and nobody + * had reason to look at it. + * + * A stale mirror is worse than no mirror. Drizzle infers row types from it, so + * a converted query would type-check against a schema the database does not + * have and fail at run time with a column that does not exist — the exact class + * of drift the adoption was meant to close. + */ +describe('the Drizzle schema mirror', () => { + it('declares every table the migrations create', async () => { + const live = await liveTables(); + const mirrored = mirroredTables(); + + const missing = live.filter((name) => !mirrored.includes(name)); + expect(missing).toEqual([]); + }); + + it('declares no table the database does not have', async () => { + const live = await liveTables(); + const mirrored = mirroredTables(); + + const extra = mirrored.filter((name) => !live.includes(name)); + expect(extra).toEqual([]); + }); + + // pgmigrations is node-pg-migrate's bookkeeping, excluded by tablesFilter in + // drizzle.config.ts. A re-pull without that filter would quietly put it back. + it('excludes node-pg-migrate bookkeeping', () => { + expect(mirroredTables()).not.toContain('pgmigrations'); + }); + + // Columns, not just tables: a migration that adds a column to a table the + // mirror already knows about is the likelier drift, and the one a table-level + // check would wave through. + it('declares every column of every table it mirrors', async () => { + const { rows } = await pool.query<{ table_name: string; column_name: string }>( + `SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name <> 'pgmigrations' + ORDER BY table_name, column_name` + ); + + // The mirror names a column either as a bare camelCase key or, when the + // database name differs, as an explicit string argument. Checking the + // snake_case name appears anywhere in the file is deliberately loose: it + // catches a column the mirror has never heard of, which is the failure that + // matters, without re-implementing drizzle-kit's naming rules. + const missing = rows + .filter((row) => !SCHEMA.includes(`"${row.column_name}"`)) + .filter((row) => !SCHEMA.includes(snakeToCamel(row.column_name))) + .map((row) => `${row.table_name}.${row.column_name}`); + + expect(missing).toEqual([]); + }); +}); + +function snakeToCamel(name: string): string { + return name.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()); +} -- 2.54.0 From 667aeb415531c49b2c8a2efc8521cf15cb545c63 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 2 Sep 2026 08:57:05 -0500 Subject: [PATCH 5/8] chore(db): remove the spike's drizzle output directory (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend/drizzle/ was `drizzle-kit`'s output from the #216 spike, superseded now that pull writes into src/db-drizzle. Removing it also removes something that should never have been on main. #216's closing comment said the spike branch carried "an experimental condition_note column that must not reach main". Checking backend/migrations for it found nothing, which is where I stopped looking last time — but it was here, as backend/drizzle/0001_add_condition_note.sql. It reached main in the same merge that brought the Tinqer probe #261 removed. Nothing ran it. node-pg-migrate only executes backend/migrations, so this SQL was inert and no database has the column. It was a loaded gun rather than a fired one, which is the only reason this is a cleanup rather than an incident. That file is also the evidence #219 needs, so it is quoted in that issue before being deleted: adding one nullable column emitted three DROP INDEX statements and three CREATE UNIQUE INDEX statements alongside it, for the expression indexes drizzle-kit could not diff. On a large table those recreations take real locks, and a generated migration nobody read would have taken them. Co-Authored-By: Claude Opus 5 --- .../drizzle/0000_sleepy_franklin_richards.sql | 194 --- backend/drizzle/0001_add_condition_note.sql | 7 - backend/drizzle/meta/0000_snapshot.json | 1384 ----------------- backend/drizzle/meta/0001_snapshot.json | 1363 ---------------- backend/drizzle/meta/_journal.json | 20 - backend/drizzle/relations.ts | 150 -- backend/drizzle/schema.ts | 289 ---- 7 files changed, 3407 deletions(-) delete mode 100644 backend/drizzle/0000_sleepy_franklin_richards.sql delete mode 100644 backend/drizzle/0001_add_condition_note.sql delete mode 100644 backend/drizzle/meta/0000_snapshot.json delete mode 100644 backend/drizzle/meta/0001_snapshot.json delete mode 100644 backend/drizzle/meta/_journal.json delete mode 100644 backend/drizzle/relations.ts delete mode 100644 backend/drizzle/schema.ts 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/relations.ts b/backend/drizzle/relations.ts deleted file mode 100644 index d2f3dcf..0000000 --- a/backend/drizzle/relations.ts +++ /dev/null @@ -1,150 +0,0 @@ -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] - }), -})); - -export const itemsRelations = relations(items, ({one, many}) => ({ - itemImages: many(itemImages), - category: one(categories, { - fields: [items.categoryId], - references: [categories.id] - }), - orders: many(orders), - 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), - favorites: many(favorites), -})); - -export const categoriesRelations = relations(categories, ({one, many}) => ({ - items: many(items), - category: one(categories, { - fields: [categories.parentId], - references: [categories.id], - relationName: "categories_parentId_categories_id" - }), - categories: many(categories, { - relationName: "categories_parentId_categories_id" - }), -})); - -export const ordersRelations = relations(orders, ({one}) => ({ - item: one(items, { - fields: [orders.itemId], - references: [items.id] - }), - customer: one(customers, { - fields: [orders.customerId], - references: [customers.id] - }), - checkout: one(checkouts, { - fields: [orders.checkoutId], - references: [checkouts.id] - }), -})); - -export const 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] - }), - item: one(items, { - fields: [cartItems.itemId], - references: [items.id] - }), -})); - -export const cartsRelations = relations(carts, ({one, many}) => ({ - cartItems: many(cartItems), - customer: one(customers, { - fields: [carts.customerId], - references: [customers.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 itemTagsRelations = relations(itemTags, ({one}) => ({ - item: one(items, { - fields: [itemTags.itemId], - references: [items.id] - }), - tag: one(tags, { - fields: [itemTags.tagId], - references: [tags.id] - }), -})); - -export const tagsRelations = relations(tags, ({many}) => ({ - itemTags: many(itemTags), -})); - -export const 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], - references: [checkouts.id] - }), - item: one(items, { - fields: [checkoutItems.itemId], - references: [items.id] - }), -})); \ 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"}), -]); -- 2.54.0 From 3476afcd702f6c351f272bbcb6c2214cf210db89 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 2 Sep 2026 09:03:11 -0500 Subject: [PATCH 6/8] feat(db): convert routes/adminCategories.ts to Drizzle (#218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All nine sites, chosen because the file is awkward rather than easy: a recursive CTE consumed two ways, a correlated subquery, an array match, and two error paths keyed on a Postgres SQLSTATE. A file of plain CRUD would have produced a flattering number that does not generalise. The hand-declared row interfaces are gone. CATEGORY_COLUMNS is written once and the row type is inferred from it, which closes the drift itemSelect.ts documents as "KEPT IN STEP BY HAND". That mapping has to be explicit rather than selecting the table: the mirror names columns in camelCase and this API answers in snake_case, so selecting the table directly would have silently changed the JSON contract the admin frontend reads, and no test asserting status codes would have caught it. strict and noUncheckedIndexedAccess hold with no non-null assertions added. requireRow covers the RETURNING rows and the existing lookup destructures and branches, exactly as before. Two bugs were introduced and caught by the integration suite, and both are worth recording because neither produced a type error. Drizzle renders a column reference inside a `sql` template UNQUALIFIED. `${items.categoryId} = ${categories.id}` became `WHERE "category_id" = "id"`, which Postgres resolved against items on both sides — so the item count came back plausible and wrong rather than failing. That is worse than the documented array trap, which at least produces invalid SQL. The fragment is now literal text, which is honest since it binds no values. And the driver's error code moved. Drizzle wraps errors, so the SQLSTATE that sat on err.code now sits on err.cause.code; the old check compiled, never matched, and turned two 409s into 500s. isUniqueViolation accepts both shapes. inArray replaced the ANY(...::int[]) match and sidesteps the sql.param trap entirely — there is no template to forget it in, and the builder emits the placeholder list correctly by construction. Co-Authored-By: Claude Opus 5 --- backend/src/routes/adminCategories.ts | 194 +++++++++++++++++--------- 1 file changed, 125 insertions(+), 69 deletions(-) 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; -- 2.54.0 From e3a70b6dc0fe690a12a6421cbb00dfdf0816eb4b Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 2 Sep 2026 09:04:02 -0500 Subject: [PATCH 7/8] docs(db): record the two traps the first conversion found (#218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns in a sql template render unqualified, so a correlated subquery correlates with itself and returns a plausible wrong number rather than failing. That is worse than the array trap already recorded here, which at least produces invalid SQL — this produces valid SQL and quietly wrong data, and only an integration test asserting a value caught it. And a driver error code moves when Drizzle wraps it, so a catch keyed on a SQLSTATE still compiles and silently stops matching. Also records that the camelCase mirror and the snake_case API mean every select must map columns explicitly, because selecting the table changes the JSON contract with nothing to notice. Co-Authored-By: Claude Opus 5 --- backend/src/db-drizzle/CONVENTIONS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/src/db-drizzle/CONVENTIONS.md b/backend/src/db-drizzle/CONVENTIONS.md index ee444eb..5f0412f 100644 --- a/backend/src/db-drizzle/CONVENTIONS.md +++ b/backend/src/db-drizzle/CONVENTIONS.md @@ -21,6 +21,28 @@ Run it against a database with every migration applied, after writing a migratio `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. @@ -43,6 +65,8 @@ That makes the #202 invariant — only placeholder indices may be interpolated i ## 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. ## Not settled -- 2.54.0 From f515584c9a52d9f53b4f74deeadf2e2088841f50 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Wed, 2 Sep 2026 09:04:52 -0500 Subject: [PATCH 8/8] docs(db): record the migrations decision (#219) node-pg-migrate keeps the schema; Drizzle is for queries only. The conventions doc said this was unsettled and now says what was settled and why: drizzle-kit cannot diff expression indexes and emitted six statements for one column, our migrations are mostly prose that generated SQL does not carry, and data migrations cannot be generated at all. The reasoning in full is on the issue. This is the version a reader converting a query will actually find. Co-Authored-By: Claude Opus 5 --- backend/src/db-drizzle/CONVENTIONS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/db-drizzle/CONVENTIONS.md b/backend/src/db-drizzle/CONVENTIONS.md index 5f0412f..294d86f 100644 --- a/backend/src/db-drizzle/CONVENTIONS.md +++ b/backend/src/db-drizzle/CONVENTIONS.md @@ -69,6 +69,10 @@ Column names differ, and the difference is load-bearing. The mirror is camelCase `db` and `pool` are exported from `src/db.ts` and share one pool, deliberately. Conversion is file by file across 187 sites, so most queries will be raw `pg` for a long time and the two must not open separate connection pools — a transaction on one would be invisible to the other, and the configured limits would silently double. -## Not settled +## Migrations stay hand-written -Whether generated migrations replace `node-pg-migrate` is **#219**, and nothing here depends on it. The first generated migration after a pull also emitted drops and recreations of the three expression indexes, which needs hand-editing and takes real locks on a large table; and data migrations cannot be generated at all. Do not start generating migrations as a side effect of converting a query. +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. -- 2.54.0