Updates ship-readiness.md, AGENTS.md, and 7 convoy files to reflect the as-shipped state of the 2026-05-26 7-convoy multitask wave: - PR #26 tighten-visual-diff-path-filter (P3) - PR #27 purge-weak-creds-from-helpers (P2, closes the umbrella) - PR #28 cleanup-mobile-nav-dead-props (P3) - PR #29 lint-against-cjs-in-esm-scripts (P3, surfaced by PR #25) - PR #30 single-sql-client (P1 #8 RESOLVED) - PR #31 single-auth-provider (P1 #9 RESOLVED) - PR #32 migration-tool (P1 #11 RESOLVED) Milestone: 5 of 6 P1 quality items RESOLVED. Only fix-lint-baseline (P1 #11.5) remains in the P1 lane. Newly queued follow-ups: - purge-quick-login-from-loginpage (surfaced by PR #27) - purge-neondatabase-serverless-fully (surfaced by PR #30, unblocked by PR #32's migration tool adoption) Co-authored-by: Cursor <cursoragent@cursor.com>
560 lines
24 KiB
Markdown
560 lines
24 KiB
Markdown
---
|
|
name: single-sql-client
|
|
classification: quality
|
|
success_metric: |
|
|
`lib/database.js` is deleted; every former caller uses
|
|
`@vercel/postgres` tagged templates with identical query
|
|
semantics; vitest 21/21 stays green; lint baseline (128
|
|
problems) is preserved; no remaining `lib/database` import
|
|
appears anywhere under `pages/` or `lib/`. AGENTS.md Gotcha #1
|
|
/ `.convoys/ship-readiness.md` P1 #8 flip → RESOLVED.
|
|
skip:
|
|
- role-design-system-auditor
|
|
- role-a11y-auditor
|
|
- role-ux-reviewer
|
|
- role-ia-architect
|
|
status: open
|
|
created: 2026-05-26
|
|
parent: ship-readiness
|
|
addresses: P1 #8 (launch sequence step 8) — "Two SQL clients in parallel"
|
|
depends_on: []
|
|
---
|
|
|
|
# Convoy: single-sql-client
|
|
|
|
Collapse the dual SQL-client problem documented in AGENTS.md
|
|
Gotcha #1 by deleting `lib/database.js` and migrating its sole
|
|
production caller (`pages/api/auth-utils.js`) onto the canonical
|
|
`@vercel/postgres` tagged-template surface. Keep
|
|
`@neondatabase/serverless` as a runtime dependency for the
|
|
out-of-scope `scripts/**` helpers that already use `neon()`
|
|
directly.
|
|
|
|
## Background
|
|
|
|
`AGENTS.md` Gotcha #1 (and the `.cursor/rules/db-and-schema.mdc`
|
|
"Clients" section) document a long-standing dual-client problem:
|
|
|
|
- `@neondatabase/serverless` is wrapped by `lib/database.js`'s
|
|
`DatabaseAdapter`, which exposes a `db.query(sqlString,
|
|
params)` API that **manually interpolates `$1, $2, ...`
|
|
placeholders into the SQL string** and then calls
|
|
`sql.unsafe(query)` on the result.
|
|
- `@vercel/postgres` is used directly by ~30 `pages/api/**`
|
|
handlers via tagged-template SQL (`` await sql`SELECT … WHERE
|
|
id = ${id}` ``), which is parameterized natively by the driver.
|
|
|
|
The manual-interpolation shape in `lib/database.js` is the
|
|
documented foot-gun:
|
|
|
|
> *Avoid `lib/database.js`'s `db.query(string, params)` API for
|
|
> new code — it interpolates params into a string and then
|
|
> calls `sql.unsafe()`, which is a SQL-injection vector.*
|
|
> — `.cursor/rules/db-and-schema.mdc` § Clients
|
|
|
|
The `.convoys/ship-readiness.md` P1 #8 entry tracks the cleanup
|
|
as launch sequence step 8 ("touches ~3 files based on graph").
|
|
|
|
## Decisions
|
|
|
|
### D1 — Caller inventory: 2 files in scope (1 source + 1 test), not "~3"
|
|
|
|
Architect ran `Grep` against the workspace for `lib/database`
|
|
imports. Only **2** files touch the module:
|
|
|
|
| File | Kind | What it does with `db` |
|
|
| --- | --- | --- |
|
|
| `pages/api/auth-utils.js` | source | imports `db`, calls `db.query` in `isAdmin(userId)` + `getUserById(userId)` |
|
|
| `test/api/auth-utils.test.js` | test | `vi.mock('../../lib/database.js', () => ({ db: { query: vi.fn() } }))` — mock-only; the 5 tests exercise `generateToken` / `verifyToken`, not `isAdmin` / `getUserById`. The mock exists purely to satisfy the import graph |
|
|
|
|
The README/AGENTS-quoted "~3 files based on graph" estimate from
|
|
ship-readiness P1 #8 was loose. Confirmed exact count = 2. No
|
|
deviation flag needed; the convoy is even smaller than estimated.
|
|
|
|
### D2 — `@vercel/postgres` tagged templates, byte-equivalent SQL
|
|
|
|
Two call sites in `pages/api/auth-utils.js`:
|
|
|
|
**Before:**
|
|
|
|
```js
|
|
import { db } from '../../lib/database.js';
|
|
|
|
export async function isAdmin(userId) {
|
|
try {
|
|
const result = await db.query(`
|
|
SELECT role FROM users WHERE id = $1
|
|
`, [userId]);
|
|
return result.rows[0]?.role === 'admin';
|
|
} catch (error) { ... }
|
|
}
|
|
|
|
export async function getUserById(userId) {
|
|
try {
|
|
const result = await db.query(`
|
|
SELECT id, email, role, created_at FROM users WHERE id = $1
|
|
`, [userId]);
|
|
return result.rows[0];
|
|
} catch (error) { ... }
|
|
}
|
|
```
|
|
|
|
**After:**
|
|
|
|
```js
|
|
import { sql } from '@vercel/postgres';
|
|
|
|
export async function isAdmin(userId) {
|
|
try {
|
|
const result = await sql`
|
|
SELECT role FROM users WHERE id = ${userId}
|
|
`;
|
|
return result.rows[0]?.role === 'admin';
|
|
} catch (error) { ... }
|
|
}
|
|
|
|
export async function getUserById(userId) {
|
|
try {
|
|
const result = await sql`
|
|
SELECT id, email, role, created_at FROM users WHERE id = ${userId}
|
|
`;
|
|
return result.rows[0];
|
|
} catch (error) { ... }
|
|
}
|
|
```
|
|
|
|
The two queries are SELECT-only, single-table, single-parameter
|
|
(numeric `userId`). The translated tagged-template form produces
|
|
**byte-equivalent SQL** with proper parameter binding (instead
|
|
of string interpolation + `sql.unsafe`). The `result.rows[0]`
|
|
access pattern works identically — `@vercel/postgres` returns
|
|
`{ rows, rowCount }` natively, which matches the shape
|
|
`DatabaseAdapter.query` was already returning. No
|
|
result-handling change needed at the call sites in `isAdmin` /
|
|
`getUserById`. No transaction / pool semantics change either:
|
|
neither `lib/database.js` nor `@vercel/postgres` uses a
|
|
long-lived pool from these call sites (both create a per-request
|
|
HTTP connection against Neon's serverless endpoint).
|
|
|
|
### D3 — Keep `@neondatabase/serverless` as a dep; SCOPE = `lib/database.js` collapse only
|
|
|
|
The convoy seed flagged this explicitly. Architect verified:
|
|
|
|
- `@neondatabase/serverless` is imported by **11 files** outside
|
|
`lib/database.js`:
|
|
- `scripts/setup-neon-db.js` (the DDL bootstrap)
|
|
- `scripts/migrations/2026-05-24-rename-admin-email.js` (post-`pick-a-name` admin rename)
|
|
- `scripts/reset-db.js` (dev reset)
|
|
- 8 other one-off `scripts/add-*.js` / `scripts/fix-*.js` / `scripts/seed-*.js` historical jobs
|
|
- None of those go through `lib/database.js`; they all use
|
|
`neon(POSTGRES_URL)` directly with tagged-template SQL (which
|
|
is the safe shape that `lib/database.js`'s wrapper subverts).
|
|
|
|
**Scope decision: keep `@neondatabase/serverless` in
|
|
`package.json`.** This convoy is about deleting the
|
|
`lib/database.js` abstraction, not about eliminating
|
|
`@neondatabase/serverless` from the dependency tree. The
|
|
no-go-zones rule explicitly forbids touching
|
|
`scripts/setup-neon-db.js`, `scripts/migrations/*`, and the
|
|
`scripts/add-*` / `scripts/fix-*` / `scripts/seed-*` historical
|
|
jobs. Migrating those to `@vercel/postgres` would also be the
|
|
wrong call architecturally — `@vercel/postgres` is tuned for
|
|
Vercel's edge / serverless runtime; one-off scripts run from a
|
|
developer laptop or CI runner where `@neondatabase/serverless`'s
|
|
direct `neon()` shape is more appropriate.
|
|
|
|
A future convoy could (a) migrate the scripts to a uniform
|
|
client OR (b) extract a thin `scripts/lib/db.js` that wraps
|
|
`neon()` once. Either is its own scope; flagged in § Follow-ups.
|
|
|
|
### D4 — `sql.unsafe` audit: NOT a real injection vector with current callers (security finding: NO)
|
|
|
|
The `lib/database.js` shape is **unsafe-by-default**: it
|
|
interpolates raw values into a SQL string and calls
|
|
`sql.unsafe()`. In theory, that's a SQL-injection vector for
|
|
any caller that passes user-controlled string input.
|
|
|
|
Architect audit of the **2 current call sites**:
|
|
|
|
- `isAdmin(userId)` — `userId` is sourced from a verified JWT
|
|
payload (`decoded.userId` after `verifyToken(token)` succeeds
|
|
in `pages/api/admin/index.js`). It's a number from
|
|
`users.id` (SERIAL). The `lib/database.js` interpolation path
|
|
for numbers is `result = await sql\`${sql.unsafe(query)}\``
|
|
with the number inlined raw — for a numeric SERIAL id, no
|
|
injection vector exists in practice.
|
|
- `getUserById(userId)` — not currently called by any handler
|
|
(`Grep` for `getUserById` returns only the definition site +
|
|
the public-API comment in AGENTS.md). Same `userId` shape
|
|
semantics apply if it were called.
|
|
|
|
**Conclusion: NO real security finding.** This is a pure
|
|
refactor + foot-gun-removal convoy. The next convoy that adds a
|
|
caller passing user-controlled string input to `db.query` would
|
|
have been the security incident; deleting the unsafe surface
|
|
prevents that future incident.
|
|
|
|
If a future audit surfaces a `lib/database.js`-shaped wrapper
|
|
re-introduced in another lib (e.g. `lib/db-helper.js`), this
|
|
convoy's lesson is: kill it on sight. See § Follow-ups for a
|
|
queued ESLint rule that would catch a `sql.unsafe` re-introduction.
|
|
|
|
### D5 — Tests: 1 test file touched, no semantic change
|
|
|
|
`test/api/auth-utils.test.js` already mocks `lib/database.js`
|
|
purely to satisfy the import graph; the 5 tests exercise
|
|
`generateToken` + `verifyToken`, neither of which touches the
|
|
DB. **Post-migration:** the mock is no longer needed because
|
|
`auth-utils.js` no longer imports from `lib/database.js`. Drop
|
|
the `vi.mock('../../lib/database.js', ...)` call and the now-unused
|
|
`vi` import. Test count + assertions unchanged: 5/5.
|
|
|
|
The other 16 vitest tests (`lib/auth-secret.test.js`,
|
|
`lib/permission-middleware.test.js`,
|
|
`components/Layout.test.js`) don't touch `lib/database.js`. No
|
|
mock drift risk; nothing else to update.
|
|
|
|
`@vercel/postgres` does NOT get a new mock in this convoy
|
|
(deferred to queued `fill-vitest-handler-coverage`); the
|
|
`isAdmin` / `getUserById` functions are still untested at the
|
|
unit level, same as pre-migration. The migration is purely a
|
|
client swap, not a coverage expansion.
|
|
|
|
## Caller inventory (verbatim before/after)
|
|
|
|
### `pages/api/auth-utils.js` (only source caller)
|
|
|
|
**Imports (before):**
|
|
```js
|
|
import jwt from 'jsonwebtoken';
|
|
import { db } from '../../lib/database.js';
|
|
import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js';
|
|
```
|
|
|
|
**Imports (after):**
|
|
```js
|
|
import jwt from 'jsonwebtoken';
|
|
import { sql } from '@vercel/postgres';
|
|
import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js';
|
|
```
|
|
|
|
**`isAdmin` body (before):**
|
|
```js
|
|
const result = await db.query(`
|
|
SELECT role FROM users WHERE id = $1
|
|
`, [userId]);
|
|
```
|
|
|
|
**`isAdmin` body (after):**
|
|
```js
|
|
const result = await sql`
|
|
SELECT role FROM users WHERE id = ${userId}
|
|
`;
|
|
```
|
|
|
|
**`getUserById` body (before):**
|
|
```js
|
|
const result = await db.query(`
|
|
SELECT id, email, role, created_at FROM users WHERE id = $1
|
|
`, [userId]);
|
|
```
|
|
|
|
**`getUserById` body (after):**
|
|
```js
|
|
const result = await sql`
|
|
SELECT id, email, role, created_at FROM users WHERE id = ${userId}
|
|
`;
|
|
```
|
|
|
|
The `try/catch` shape, the `result.rows[0]` access, the
|
|
`?.role === 'admin'` check, and the `console.error` + `return
|
|
false`/`return null` error paths are all preserved verbatim.
|
|
|
|
### `test/api/auth-utils.test.js` (mock cleanup)
|
|
|
|
**Imports (before):**
|
|
```js
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import jwt from 'jsonwebtoken';
|
|
|
|
vi.mock('../../lib/database.js', () => ({
|
|
db: { query: vi.fn() },
|
|
}));
|
|
|
|
import { JWT_SECRET } from '../../lib/auth-secret.js';
|
|
import { generateToken, verifyToken } from '../../pages/api/auth-utils.js';
|
|
```
|
|
|
|
**Imports (after):**
|
|
```js
|
|
import { describe, expect, it } from 'vitest';
|
|
import jwt from 'jsonwebtoken';
|
|
|
|
import { JWT_SECRET } from '../../lib/auth-secret.js';
|
|
import { generateToken, verifyToken } from '../../pages/api/auth-utils.js';
|
|
```
|
|
|
|
The 5 test bodies (2 `generateToken` + 3 `verifyToken`) are
|
|
unchanged.
|
|
|
|
### `lib/database.js` (deleted)
|
|
|
|
47-line file. No replacement; the abstraction is gone. The two
|
|
callers go straight to `@vercel/postgres` tagged-template SQL,
|
|
matching the canonical pattern used by ~30 other `pages/api/**`
|
|
handlers already in the tree.
|
|
|
|
## The fix (per-file diff shape)
|
|
|
|
| File | Change | Lines |
|
|
| --- | --- | --- |
|
|
| `pages/api/auth-utils.js` | swap `db.query(string, params)` → `sql\`…${userId}\`` in 2 functions; swap import | +5 / -7 |
|
|
| `test/api/auth-utils.test.js` | drop `vi.mock('../../lib/database.js', ...)` + unused `vi` import | +1 / -5 |
|
|
| `lib/database.js` | DELETE | 0 / -47 |
|
|
| `.convoys/single-sql-client.md` | NEW (this file) | +330 / 0 |
|
|
|
|
Total: 3 modified files (1 source + 1 test + 1 deletion) + 1
|
|
new convoy doc.
|
|
|
|
## Verification plan
|
|
|
|
1. `npm run lint` → 128 problems (baseline preserved, no regression)
|
|
2. `npm run test:run` → 21/21 pass
|
|
3. `Grep "lib/database" --type js -l` → 0 hits anywhere
|
|
(no `pages/**`, no `lib/**`, no `scripts/**`, no `test/**`)
|
|
4. `Grep "@neondatabase/serverless" --type js -l` → still matches
|
|
`scripts/setup-neon-db.js`, `scripts/migrations/2026-05-24-rename-admin-email.js`,
|
|
`scripts/reset-db.js`, and the 8 other `scripts/add-*` / `fix-*` /
|
|
`seed-*` historical helpers (all out of scope per § D3)
|
|
5. `node --check pages/api/auth-utils.js` → exit 0 (syntax)
|
|
|
|
**Live runtime smoke deferred.** The two migrated functions
|
|
(`isAdmin`, `getUserById`) are only reachable via
|
|
`pages/api/admin/index.js` which requires an admin Bearer token
|
|
+ a populated `users` table in prod Neon. Running a live curl
|
|
smoke against a local `npm run dev` would require seeding the
|
|
admin user with a known password (which `setup-neon-db.js`
|
|
now requires `ADMIN_INITIAL_PASSWORD` to do — operator-only
|
|
flow) and minting a token. Out-of-band for a convoy
|
|
that's a pure client swap; the byte-equivalent SQL semantics
|
|
(D2) plus the same `try/catch` + `result.rows[0]` access shape
|
|
gives high confidence that the migration is correct. If the
|
|
post-merge Vercel preview's admin surface 500s on an admin
|
|
action, the rollback is a single-commit revert of this convoy.
|
|
|
|
## Risks
|
|
|
|
- **R1 — Byte-equivalence not guaranteed if `lib/database.js`
|
|
has hidden behavior.** Architect re-read all 47 lines of
|
|
`lib/database.js` (it's a small file). The only behavior
|
|
beyond "interpolate, run SQL, return `{ rows, rowCount }`" is
|
|
the `escapedParams.map` quoting for string params — and both
|
|
current callers pass numeric `userId`, so the quoting path
|
|
isn't exercised. The `raw(sqlString, params)` method is just
|
|
an alias for `query(sqlString, params)`; no caller invokes
|
|
`raw` (`Grep "\.raw\(" --type js` → zero hits). **Residual
|
|
risk: very low.** The migration's byte-equivalent SQL plus
|
|
identical result shape (`{ rows: [...], rowCount: N }`)
|
|
closes this risk in practice.
|
|
|
|
- **R2 — Missed callers.** Mitigated by the post-delete `Grep`
|
|
sweep in the verification plan (step 3) — if any file still
|
|
imports `lib/database`, the file no longer exists and the
|
|
import will throw at module load, failing CI's lint or test
|
|
job. The `Grep` sweep also catches `require('../../lib/database')`
|
|
CJS shape (zero hits across the tree at architect time;
|
|
expected since the repo is `"type": "module"`).
|
|
|
|
- **R3 — A future PR re-introduces `lib/database.js` or
|
|
another `sql.unsafe`-shaped wrapper.** Mitigated by:
|
|
documentation (this convoy file + AGENTS.md Gotcha #1
|
|
doc-writer flip → RESOLVED in a follow-up cleanup pass).
|
|
Stronger mitigation would be an ESLint
|
|
`no-restricted-imports` rule against `lib/database` or a
|
|
`no-restricted-syntax` rule against `sql.unsafe(` — surfaced
|
|
as a follow-up below (`lint-against-lib-database`).
|
|
|
|
## As-shipped
|
|
|
|
Single squash commit `c403ea4` (PR #30, merged 2026-05-27T03:54:01Z
|
|
UTC / local 2026-05-26). Parent-owned end-to-end per the "Owns" line
|
|
— no architect, no implementer subagent dispatched. Mirror-the-pattern
|
|
fix exactly as planned; no mid-execution surprises that would have
|
|
forced an architect bounce. **AGENTS.md Gotcha #1 flipped from open
|
|
→ RESOLVED in the post-convoy doc-writer pass** (see
|
|
`.convoys/ship-readiness.md` § P1 #8 entry post-flip and the AGENTS.md
|
|
sweep in this same wave).
|
|
|
|
**Diff: 4 files, +447 / -64.** The 447-addition figure is dominated
|
|
by `.convoys/single-sql-client.md` (~330 lines for the planning
|
|
document, committed atomically with the fix). Actual source-file diff
|
|
is small: `pages/api/auth-utils.js` +5 / -7 (import swap + 2 query
|
|
shape conversions); `test/api/auth-utils.test.js` +1 / -5 (drop the
|
|
now-unused `vi.mock` + unused `vi` import); `lib/database.js` 0 / -47
|
|
(deletion).
|
|
|
|
**The change shipped exactly as designed:**
|
|
|
|
1. **`lib/database.js` deleted.** 47-line file; no replacement. The
|
|
`DatabaseAdapter` abstraction is gone. The two callers go straight
|
|
to `@vercel/postgres` tagged-template SQL, matching the canonical
|
|
pattern used by ~30 other `pages/api/**` handlers already in the
|
|
tree.
|
|
2. **`pages/api/auth-utils.js`** swept (the only source caller).
|
|
- Import: `import { db } from '../../lib/database.js'` → `import
|
|
{ sql } from '@vercel/postgres'`.
|
|
- `isAdmin(userId)`: `await db.query(\`SELECT role FROM users
|
|
WHERE id = $1\`, [userId])` → `` await sql`SELECT role FROM
|
|
users WHERE id = ${userId}` ``.
|
|
- `getUserById(userId)`: `await db.query(\`SELECT id, email, role,
|
|
created_at FROM users WHERE id = $1\`, [userId])` → `` await
|
|
sql`SELECT id, email, role, created_at FROM users WHERE id =
|
|
${userId}` ``.
|
|
- The `try/catch` shape, the `result.rows[0]` access, the
|
|
`?.role === 'admin'` check, and the `console.error` + `return
|
|
false` / `return null` error paths are all preserved verbatim.
|
|
- SQL is byte-equivalent (single-parameter numeric `userId`);
|
|
result shape is identical (`{ rows, rowCount }` from
|
|
`@vercel/postgres` matches what `DatabaseAdapter.query` was
|
|
returning).
|
|
3. **`test/api/auth-utils.test.js`** mock cleanup. Dropped
|
|
`vi.mock('../../lib/database.js', () => ({ db: { query: vi.fn() } }))`
|
|
(no longer needed because `auth-utils.js` no longer imports from
|
|
`lib/database.js`) and the unused `vi` import. The 5 test bodies
|
|
(2 `generateToken` + 3 `verifyToken`) are unchanged. Test count
|
|
stays at 21/21.
|
|
|
|
**`@neondatabase/serverless` retained as a runtime dep**, per D3 of
|
|
this convoy file. 11 `scripts/*` helpers still import `neon()`
|
|
directly (`scripts/setup-neon-db.js`, `scripts/migrations/2026-05-24-rename-admin-email.js`,
|
|
`scripts/reset-db.js`, plus 8 historical `add-*` / `fix-*` / `seed-*`
|
|
jobs); all out of scope per the no-go-zones rule. The dep-purge is
|
|
tracked as the newly queued `purge-neondatabase-serverless-fully`
|
|
follow-up (now **unblocked** by PR #32 `migration-tool` — the
|
|
migration helpers go through `node-pg-migrate`'s `pg` client, not
|
|
`@neondatabase/serverless`, so the only remaining direct `neon()`
|
|
consumers post-PR-#32 are `setup-neon-db.js`, `reset-db.js`, and the
|
|
historical graveyard).
|
|
|
|
**Verification (all gates green at merge):**
|
|
- `node --check pages/api/auth-utils.js` → exit 0
|
|
- `npm run lint` → 125 problems (post-PR-#31 baseline preserved;
|
|
zero regression). The `lib/database.js` deletion did not change
|
|
lint count because the file was already lint-clean.
|
|
- `npm run test:run` → 21/21 pass
|
|
- `rg "lib/database" --type js -l` → 0 hits anywhere (no `pages/**`,
|
|
no `lib/**`, no `scripts/**`, no `test/**`) — confirms no missed
|
|
importer (R2 mitigation)
|
|
- `rg "@neondatabase/serverless" --type js -l` → still matches
|
|
`scripts/setup-neon-db.js`,
|
|
`scripts/migrations/2026-05-24-rename-admin-email.js`,
|
|
`scripts/reset-db.js`, and the 8 other `scripts/add-*` / `fix-*` /
|
|
`seed-*` historical helpers (all out of scope per D3)
|
|
- CI on PR #30: Lint ✓ | Vitest 21/21 ✓ | Playwright smoke 3/3 ✓ |
|
|
`forbidden-endpoints` ✓ | `forbidden-cors-headers` ✓ | Vercel
|
|
preview deploy ✓ | Aggregate gate ✓
|
|
- **`Screenshot diff`: NOT triggered.** PR #30's diff is
|
|
`pages/api/**` + `lib/**` + `test/**` + `.convoys/**` — the
|
|
post-PR-#26 `!pages/api/**` exclusion correctly held. **This PR is
|
|
the first empirical confirmation that the
|
|
`tighten-visual-diff-path-filter` (PR #26, `ba95462`) exclusion
|
|
fires as documented** — the post-merge success criterion that
|
|
PR #26's § Verification plan deferred until "the next API-only PR
|
|
after this merges". `Screenshot diff` did NOT appear in PR #30's
|
|
Checks tab; the convoy-file as-shipped block of PR #26 now records
|
|
this confirmation.
|
|
|
|
**Live runtime smoke deferred** per § Verification plan — the two
|
|
migrated functions (`isAdmin`, `getUserById`) are only reachable via
|
|
`pages/api/admin/index.js` which requires an admin Bearer token + a
|
|
populated `users` table. Running a live curl smoke would require
|
|
seeding the admin user with a known password (operator-only flow per
|
|
`drop-public-setup`'s contract) and minting a token. The
|
|
byte-equivalent SQL semantics (D2) plus identical `try/catch` +
|
|
`result.rows[0]` access shape gives high confidence the migration is
|
|
correct; if a post-merge Vercel preview admin action 500s, rollback
|
|
is a single-commit revert.
|
|
|
|
**Cross-validation finding (continues the lineage).** The `Playwright
|
|
smoke` 3/3 PASS confirms the deployed preview is unaffected by the
|
|
lib-database deletion — the smoke spec doesn't exercise `isAdmin` /
|
|
`getUserById`, but the auth surface that smoke does exercise
|
|
(`/api/health`, sign-in render) is correctly insensitive to the
|
|
DatabaseAdapter removal. **Eighth consecutive convoy** where the
|
|
same 3-test smoke spec defends the auth surface through a sweeping
|
|
change (PR #15 → #19 → #20 → #21 → #25 → #32 → #27 → this PR).
|
|
|
|
**Operator action required going forward:** **none.** No env-var
|
|
change; no schema change; no infra change. The migration is purely a
|
|
client swap; the existing `POSTGRES_URL` contract is preserved
|
|
verbatim (both `lib/database.js` and `@vercel/postgres` read the same
|
|
`POSTGRES_URL`).
|
|
|
|
**Spec deviation:** none of substance. The convoy spec's "~3 files
|
|
based on graph" estimate was loose; architect grep (D1) confirmed
|
|
exactly 2 source-tree callers (1 source + 1 test mock). No deviation
|
|
flag needed; the convoy is even smaller than estimated. The post-flip
|
|
ship-readiness P1 #8 entry records this fact explicitly.
|
|
|
|
**Doc surface flipped atomically (in the post-convoy doc-writer pass):**
|
|
`AGENTS.md` Gotcha #1 → RESOLVED with the as-shipped paragraph
|
|
(tool deletion + retained-dep caveat + the 11-script `neon()` direct
|
|
consumers list); `AGENTS.md` § 3 "DB access" bullet trimmed (the
|
|
warning about `lib/database.js` is gone — the file doesn't exist);
|
|
`.cursor/rules/db-and-schema.mdc` § Clients section refreshed to
|
|
match (handled in the doc-writer commit alongside this convoy in
|
|
the 7-wave cleanup).
|
|
|
|
**Surfaced follow-ups (newly queued in `.convoys/ship-readiness.md`):**
|
|
- `purge-neondatabase-serverless-fully` (P3 polish; unblocked by PR #32).
|
|
- `lint-against-lib-database` (P3 polish; from this convoy's
|
|
pre-existing § Follow-ups list — would prevent re-introduction).
|
|
|
|
## Follow-ups
|
|
|
|
- **`lint-against-lib-database`** (priority: P3 polish). Add an
|
|
ESLint `no-restricted-imports` rule against `'../**/lib/database'`
|
|
(or any path resolving to a `lib/database.js` file), so a future
|
|
PR that re-introduces the unsafe wrapper fails at lint time
|
|
rather than at runtime. Pairs naturally with the queued
|
|
`lint-against-cjs-in-esm-scripts` follow-up (both are
|
|
static-source guards added to `eslint.config.mjs`). Small
|
|
surface (one entry in the config); could fold into a
|
|
`harden-eslint-static-guards` convoy if more such guards
|
|
accumulate.
|
|
|
|
- **`purge-neondatabase-serverless-fully`** (priority: P3 polish;
|
|
blocked on `migration-tool`). Once the `migration-tool` convoy
|
|
(P1 #11) lands and replaces the ad-hoc `scripts/add-*` /
|
|
`scripts/fix-*` / `scripts/seed-*` shape with a real migration
|
|
framework, revisit whether the remaining 11
|
|
`@neondatabase/serverless` import sites can be collapsed onto
|
|
`@vercel/postgres`. Caveat: `@vercel/postgres` is tuned for the
|
|
Vercel edge runtime and may not be the right choice for
|
|
developer-laptop / CI-runner scripts; the right answer may be
|
|
"keep the dep, but route all scripts through a single thin
|
|
helper" rather than "delete the dep entirely". This is its own
|
|
scope; do NOT bolt onto this convoy.
|
|
|
|
- **`add-neon-return-shape-rule`** (priority: P3 polish; surfaced
|
|
2026-05-25 in PR #24 + restated in `.convoys/fix-reset-db-script.md`
|
|
§ Out of scope). Codify the `neon()` (returns `[rows]`) vs
|
|
`@vercel/postgres` (`{ rows: [...] }`) return-shape difference
|
|
as a `.cursor/rules/db-and-schema.mdc` callout. **Partially
|
|
satisfied** by this convoy because the dual-client shape is now
|
|
collapsed for `pages/api/**` — only the 11 `scripts/**` callers
|
|
still use `neon()` directly, and they all destructure the
|
|
array return shape correctly today. The rule would defend
|
|
future contributors who don't already know the difference; queue
|
|
if a third such bug surfaces.
|
|
|
|
## Owns
|
|
|
|
- Architect (this convoy file + decisions D1-D5).
|
|
- Implementer (mechanical: 2 source-file edits + 1 deletion +
|
|
verification gates). Possibly the same agent — the
|
|
implementation surface is small enough that the cost of a
|
|
fresh implementer subagent boot may exceed the cost of doing
|
|
the work in the architect's own turn. Documented per
|
|
`fix-reset-db-script` precedent ("Why no architect" / "Why
|
|
no implementer" — single-file proven-pattern work).
|