Close stale convoy frontmatter for merged scanner, lint, and hygiene work; record P1 #11.5 and queued follow-ups as RESOLVED with PR references. Co-authored-by: Cursor <cursoragent@cursor.com>
24 KiB
| name | classification | success_metric | skip | status | created | parent | addresses | depends_on | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| single-sql-client | quality | `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. |
|
shipped | 2026-05-26 | ship-readiness | P1 |
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/serverlessis wrapped bylib/database.js'sDatabaseAdapter, which exposes adb.query(sqlString, params)API that manually interpolates$1, $2, ...placeholders into the SQL string and then callssql.unsafe(query)on the result.@vercel/postgresis used directly by ~30pages/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'sdb.query(string, params)API for new code — it interpolates params into a string and then callssql.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:
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:
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/serverlessis imported by 11 files outsidelib/database.js:scripts/setup-neon-db.js(the DDL bootstrap)scripts/migrations/2026-05-24-rename-admin-email.js(post-pick-a-nameadmin rename)scripts/reset-db.js(dev reset)- 8 other one-off
scripts/add-*.js/scripts/fix-*.js/scripts/seed-*.jshistorical jobs
- None of those go through
lib/database.js; they all useneon(POSTGRES_URL)directly with tagged-template SQL (which is the safe shape thatlib/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)—userIdis sourced from a verified JWT payload (decoded.userIdafterverifyToken(token)succeeds inpages/api/admin/index.js). It's a number fromusers.id(SERIAL). Thelib/database.jsinterpolation path for numbers isresult = 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 (GrepforgetUserByIdreturns only the definition site + the public-API comment in AGENTS.md). SameuserIdshape 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):
import jwt from 'jsonwebtoken';
import { db } from '../../lib/database.js';
import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js';
Imports (after):
import jwt from 'jsonwebtoken';
import { sql } from '@vercel/postgres';
import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js';
isAdmin body (before):
const result = await db.query(`
SELECT role FROM users WHERE id = $1
`, [userId]);
isAdmin body (after):
const result = await sql`
SELECT role FROM users WHERE id = ${userId}
`;
getUserById body (before):
const result = await db.query(`
SELECT id, email, role, created_at FROM users WHERE id = $1
`, [userId]);
getUserById body (after):
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):
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):
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
npm run lint→ 128 problems (baseline preserved, no regression)npm run test:run→ 21/21 passGrep "lib/database" --type js -l→ 0 hits anywhere (nopages/**, nolib/**, noscripts/**, notest/**)Grep "@neondatabase/serverless" --type js -l→ still matchesscripts/setup-neon-db.js,scripts/migrations/2026-05-24-rename-admin-email.js,scripts/reset-db.js, and the 8 otherscripts/add-*/fix-*/seed-*historical helpers (all out of scope per § D3)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
userstable in prod Neon. Running a live curl smoke against a localnpm run devwould require seeding the admin user with a known password (whichsetup-neon-db.jsnow requiresADMIN_INITIAL_PASSWORDto 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 sametry/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.jshas hidden behavior. Architect re-read all 47 lines oflib/database.js(it's a small file). The only behavior beyond "interpolate, run SQL, return{ rows, rowCount }" is theescapedParams.mapquoting for string params — and both current callers pass numericuserId, so the quoting path isn't exercised. Theraw(sqlString, params)method is just an alias forquery(sqlString, params); no caller invokesraw(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
Grepsweep in the verification plan (step 3) — if any file still importslib/database, the file no longer exists and the import will throw at module load, failing CI's lint or test job. TheGrepsweep also catchesrequire('../../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.jsor anothersql.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 ESLintno-restricted-importsrule againstlib/databaseor ano-restricted-syntaxrule againstsql.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:
lib/database.jsdeleted. 47-line file; no replacement. TheDatabaseAdapterabstraction is gone. The two callers go straight to@vercel/postgrestagged-template SQL, matching the canonical pattern used by ~30 otherpages/api/**handlers already in the tree.pages/api/auth-utils.jsswept (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 sqlSELECT role FROM users WHERE id = ${userId}` ``.getUserById(userId):await db.query(\SELECT id, email, role, created_at FROM users WHERE id = $1`, [userId])→ `` await sqlSELECT id, email, role, created_at FROM users WHERE id = ${userId}` ``.- The
try/catchshape, theresult.rows[0]access, the?.role === 'admin'check, and theconsole.error+return false/return nullerror paths are all preserved verbatim. - SQL is byte-equivalent (single-parameter numeric
userId); result shape is identical ({ rows, rowCount }from@vercel/postgresmatches whatDatabaseAdapter.querywas returning).
- Import:
test/api/auth-utils.test.jsmock cleanup. Droppedvi.mock('../../lib/database.js', () => ({ db: { query: vi.fn() } }))(no longer needed becauseauth-utils.jsno longer imports fromlib/database.js) and the unusedviimport. The 5 test bodies (2generateToken+ 3verifyToken) 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 0npm run lint→ 125 problems (post-PR-#31 baseline preserved; zero regression). Thelib/database.jsdeletion did not change lint count because the file was already lint-clean.npm run test:run→ 21/21 passrg "lib/database" --type js -l→ 0 hits anywhere (nopages/**, nolib/**, noscripts/**, notest/**) — confirms no missed importer (R2 mitigation)rg "@neondatabase/serverless" --type js -l→ still matchesscripts/setup-neon-db.js,scripts/migrations/2026-05-24-rename-admin-email.js,scripts/reset-db.js, and the 8 otherscripts/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 ispages/api/**+lib/**+test/**+.convoys/**— the post-PR-#26!pages/api/**exclusion correctly held. This PR is the first empirical confirmation that thetighten-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 diffdid 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 ESLintno-restricted-importsrule against'../**/lib/database'(or any path resolving to alib/database.jsfile), so a future PR that re-introduces the unsafe wrapper fails at lint time rather than at runtime. Pairs naturally with the queuedlint-against-cjs-in-esm-scriptsfollow-up (both are static-source guards added toeslint.config.mjs). Small surface (one entry in the config); could fold into aharden-eslint-static-guardsconvoy if more such guards accumulate. -
purge-neondatabase-serverless-fully(priority: P3 polish; blocked onmigration-tool). Once themigration-toolconvoy (P1 #11) lands and replaces the ad-hocscripts/add-*/scripts/fix-*/scripts/seed-*shape with a real migration framework, revisit whether the remaining 11@neondatabase/serverlessimport sites can be collapsed onto@vercel/postgres. Caveat:@vercel/postgresis 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 theneon()(returns[rows]) vs@vercel/postgres({ rows: [...] }) return-shape difference as a.cursor/rules/db-and-schema.mdccallout. Partially satisfied by this convoy because the dual-client shape is now collapsed forpages/api/**— only the 11scripts/**callers still useneon()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-scriptprecedent ("Why no architect" / "Why no implementer" — single-file proven-pattern work).