Use for any knex command, migration, raw SQL or backfill, prod DB query from a local machine, or timestamp WHERE clause — the DB traps that have each caused real incidents.
Install
npx skillscat add wavespestcontrolfl/waves-customer-portal/waves-db Install via the SkillsCat registry.
Waves DB rules
1. Always pass the knexfile
Every knex CLI invocation MUST name the knexfile explicitly, with the path
relative to your current working directory:
# from the repo root:
npx knex <command> --knexfile server/knexfile.js
# from server/:
npx knex <command> --knexfile knexfile.jsWithout --knexfile, knex's default discovery picks the wrong config (or
none) depending on cwd, and has silently mis-targeted prod before. The flag
is resolved against cwd too — --knexfile server/knexfile.js run from
inside server/ fails looking for server/server/knexfile.js. This
applies to migrate:latest, migrate:make, seed:run — all of it.
2. The timestamptz window leak
The portal's behavior layer is all Eastern Time, but storage istimestamptz (UTC). The trap: interpolating a naive ISO string (no
offset) into a WHERE clause — Postgres reads it as UTC and your "today in ET"
window silently shifts 4–5 hours. That moves boundary rows into the wrong
day/week (reminders, digests, KPI windows).
Rules:
- Build ET boundaries with
server/utils/datetime-et.js
(parseETDateTime,etDateString) and pass realDateobjects to knex —
never hand-built'YYYY-MM-DDT00:00:00'strings. - Crons are scheduled with
{ timezone: 'America/New_York' }— keep their
query windows in the same frame. - When reviewing a diff, any string literal that looks like a timestamp
inside.where(...)is a finding until proven offset-aware. - The trap runs the other way too: a
timestamptzcolumn takes a SINGLEAT TIME ZONE— reviewers have falsely flagged correct timestamptz SQL
as naive. Verify the column'sudt_namein the target environment before
accepting the finding; production access still requires §3 authorization.
3. Local DB access — Codex uses dev/preview only
Default for agent sessions (per the AGENTS.md database policy): use a
Railway dev or preview Postgres branch as DATABASE_URL. Do not point a
session at production for routine work, and never run migrations or other
writes against prod from a local machine.
Codex sessions never connect to production databases, including for
read-only verification. The AGENTS.md prohibition applies throughout this
skill. Codex must not obtain or use production database credentials; when
production evidence is needed, request it from an authorized operator and
continue independent work against dev/preview.
For other operators whose applicable policy permits it, prod access is
break-glass: owner-authorized for the specific task,
read-only (SELECT), and through a restricted role when one exists for the
domain (e.g. newsletter_verifier) instead of the full-write URL. This
skill deliberately ships no copy/paste prod connection command — get the
credential for the restricted role from the owner at authorization time.
When authorized, three operational facts (not a recipe):
- The private
DATABASE_URLis unreachable from outside Railway; the
public endpoint lives in the Postgres service's env, not the app
service's (the app service's value fails with SSL errors). - Export the credential under a task-specific name (e.g.
PROD_RO_URL)
and have the script read that name explicitly — never setDATABASE_URL
in your shell, so stray knex/node tooling that defaults toDATABASE_URLcannot silently target prod. - Each Bash tool call is a fresh subshell — inline the env var in the same
command; anexportin a previous call does not persist.
Never print the URL or paste it into logs/PRs. Prod writes outside a
deployed migration additionally need Adam's explicit authorization, a stated
expected row count, and a dry-run first. Remember: direct prod DB writes
fire ZERO customer communications — that's sometimes the point (bulk
schedule inserts) and sometimes the bug (you expected a reminder to send).
4. Migrations
- Filename convention:
YYYYMMDD0000NN_short_name.jsinserver/models/migrations/. - Never edit an already-run migration — and a migration file that is
already on the PR's remote branch HAS run (the Railway preview deploys
every push). Knex tracks by filename — an edit is a silent no-op in
every environment that already ran it, review-round fixes included. Ship
a NEW migration instead. Enforced: the pre-push hook's applied-migration
guard (scripts/hooks/pre-push) blocks a push that modifies, renames, or
deletes any file underserver/models/migrations/that exists at the
remote branch's sha (or at the merge base withorigin/mainfor a new
branch). Content that already equalsorigin/main's passes: merging
main after main repaired a migration in place is not your edit. The
guard sees pushes, not deploys:SKIP_MIGRATION_GUARD=1is
for exactly two VERIFIED cases — the file never ran (its deploy failed
before migrating; noknex_migrationsrow on the preview/prod — a failed
first migration cannot be repaired by a second file, knex re-runs the
first) or the preview database was cleaned by hand (below). - Never delete, rename, or re-stamp a migration file once ANY deploy ran
it — Railway PR previews included. Every push to an open PR deploys a
preview environment with its own Postgres that runs the branch's
migrations; if a later push removes or renames that file, knex fails
every subsequent deploy of that environment with "The migration
directory is corrupt, the following files are missing" (the merge state
shows UNSTABLE from the Railway check). Superseding a migration mid-PR
means a NEW file with a fresh stamp whoseupreverses the old one; the
old file stays. If the damage is already done, prove prod never ran the
file (knex_migrationsread-only), then clean the PREVIEW database
(delete itsknex_migrationsrow and undo the DDL by hand) so the
preview deploys again (#3869, 2026-09-05). - House style:
exports.up = async function up(knex)with a symmetricdown; guard withhasTableon the first line and per-columnhasColumnbeforealterTable— idempotent in both directions. - Migrations that touch seeded/admin-editable rows must preserve admin edits
(read-modify-write the row, don't overwrite wholesale) and write an audit
row when an audit table exists. Exemplar:server/models/migrations/20260611000003_pest_footprint_1750_bracket.js. - pg-function splice migrations (string-editing a function body): wrap the
original IF condition in parentheses before AND-ing onto it; fixtures must
be the verbatimpg_get_functiondeftext from prod; simulate the splice
against that prod text before merge. - Migration writes get a dry-run inside
BEGIN; … ROLLBACK;against a
dev/preview Postgres branch that mirrors prod schema before merge —
mocked tests miss type/constraint mismatches, and one failed migration
blocks EVERY Railway deploy (migrations run pre-deploy). Never execute
migration DDL/DML against prod from local, even wrapped in a rollback
(it takes locks and can fire side effects, and the read-only role can't
run writes anyway) — verify the schema/type assumptions the migration
relies on via read-only SELECTs instead (§3, §5). - After merging a PR that ships a migration, verify the deploy actually ran
it (deploy log orknex_migrations) before relying on the new schema. - A migration that keeps an ownership record derives its key from its own
stamp (migration.<stamp>.stateinsystem_settings, themigration:<stamp>audit tag). Stamps are NOT unique across files, so
after mergingorigin/maincheck no other migration derives the same
literal — a shared key means the laterup()overwrites the earlier
file's rollback record and the laterdown()deletes it. Enforced byserver/tests/migration-state-key-uniqueness.test.js(also fails on a
stale stamp after a rename); fix = take a free stamp, never share one. - The 000018 pending-count illusion.
20260415000018_pest_service_cost_truing.jsis a no-op placeholder
(source lost, recreated empty), and…000019deleted itsknex_migrationsrow ONCE when 000019 itself ran — so in an environment
that has run 000019, 000018 reads as pending again and the nextmigrate:latest(or amigrate:up, instead of your migration) re-runs
the harmless no-op and chain counts come out one higher than expected
for that run. Do NOT concludemigrate:downis broken from any of this
(that wrong call shipped once). To test a specific migration's up/down,require()the module and callup(knex)/down(knex)directly against
a throwaway DB. - Seed/data-correction migrations whose
up()preserves admin edits must
makedown()a DOCUMENTED NO-OP — a blanket revert erases exactly the
admin editsup()preserved. Seed rollbacks are never destructive. - SMS-template-seeding migrations dated before
20260706000010must seed
bodies as fixed points of itsnormalizeTemplateBody(trailing
"Reply STOP to opt out.", "Hello {first_name}" greeting, no phone
numbers) — the normalizer already ran on prod, so late-seeded rows are
never normalized (compliance gap + prod/fresh drift). audit_logis append-only: rows are NEVER deleted, and all writes go
throughservices/audit-log.js(recordAuditEvent— passtrxor the
write silently uses its own handle). A migrationdown()APPENDS a
rollback event, never deletes.
5. Raw SQL verification
- Execute new query shapes and column references against a verified
dev/preview PostgreSQL database with the relevant schema before claiming
database verification. Mock builders do not verify SQL identifiers,
types, constraints, or transaction behavior. - For a task whose acceptance criteria concern the deployed schema, obtain
deployment evidence or production evidence supplied by an authorized
operator. Codex must not connect to production to collect it. Other
operators may use restricted read-only access only under §3 and their
applicable policy. Dev verification alone does not establish production state. - Without the required safe database or authorization, report verification
blocked and continue independent implementation or static checks.
Never substitute production credentials for missing development access. db.raw()with request-derived input must use?placeholders; string
interpolation is a SQL-injection P0 (AGENTS.md).
5b. A try/catch inside a transaction is NOT fail-open
Postgres aborts the entire transaction after any failed statement. Catching
the JS error and returning a default does not restore it — every later
statement on that connection fails with current transaction is aborted, so a
"best-effort" read takes down the whole operation hosting it. This has now bitten
twice: the inspection-credit booking marker (#3178 r4 P1) and the accept-path
catalog lookup (#3328 r1 P1), where a lookup meant to degrade gracefully during
deploy skew would instead have failed the estimate accept itself.
Any optional/best-effort query that runs on a caller's trx MUST be wrapped in
a SAVEPOINT — await trx.transaction(async (sp) => { ... }) — so a failure rolls
back only that statement. Exemplar:markBookingForInspectionCredit in server/services/inspection-credit.js.
Corollary for tests: a hand-rolled fake connection cannot reproduce this —
it has no transaction semantics, so the broken version passes. Prove it against
a real Postgres transaction (rename the column away inside a transaction you
then roll back, and assert the transaction is still usable).
5c. An array column cannot carry a scalar unique index
When a lookup column is a jsonb/array (one row owning several keys), there is no
unique index to stop two rows claiming the same key — and an unordered .first()
then resolves NONDETERMINISTICALLY. Fetch up to two and resolve only on exactly
one match (fail closed); a CI/contract test catches existing drift but cannot
prevent the runtime write (#3328 r3 P1).
5d. A JS array into a jsonb column must be JSON.stringify'd
pg serialises a JS ARRAY as a Postgres ARRAY literal ({...}), not JSON — soinsert({ overridden_floors: [{...}] }) into a jsonb column fails withinvalid input syntax for type json in prod while an in-memory test store
accepts it happily. A plain object is fine (pg JSON-encodes objects); an array
needs JSON.stringify(arr). Read side: pg hands jsonb back parsed, so a reader
that must also work over a fake store parses strings defensively. Caught only
by the real-DB ui-verify walkthrough (the step 4 PR 2b "Acquire anyway" 500).
6. Schema truth traps (wrong-answer generators)
customers.activeis TRUE for leads. A "real customer" =pipeline_stage IN (active_customer, won, at_risk)— use thewhereRealCustomerhelper.customers.statusdoes not exist.member_sinceis canonical;customer_sinceis dead.referrals.idis a UUID even though migration 000054 readsincrements
(a dead hasTable-guarded no-op). Migration text lies; prod schema doesn't.services(the catalog) names its display columnname, NOTservice_name— when downstream code readsrow.service_nameoff a
join, selectsv.name as service_name. (service_keyIS the real key
column.)scheduled_services.statusis gated by a CHECK constraint — a new status
string without a migration extending the CHECK throws at runtime and CI
won't catch it (AGENTS.md P0).service_requestsbacks TWO flows; its partial-unique dedup index only
releases on terminal status — never remove the admin resolve PATCH.- Verifying a reschedule: check
scheduled_date+ the HTTP 200 in Railway
logs.updated_atandreschedule_logare dead signals (false
negatives). - Staging has its OWN database and vault key — never copy encrypted
ciphertext between DBs, and failed staging twins are not prod failures. estimates.notesandscheduled_services.notesare CUSTOMER/TECH
VISIBLE (estimate-public.jsservesestimate.notes;
scheduled-service notes render into tech Property Alerts). Internal,
calibration, or ops-audit text about a visit goes inscheduled_services.internal_notes, NEVERnotes. Theestimates
table has NOinternal_notescolumn — internal text about an estimate
must live outside the estimates row entirely (admin-only/audit tables),
never inestimates.notes. This has bitten twice.notification_prefs.updated_atis published as the marketing-SMS consentcapturedAt(marketingSmsConsentBasisForContract) — a system backfill
must NEVER restamp it (falsifies consent provenance); it's also the only
reliable "unchanged since our write" marker for a migrationdown().lead_source='square'means legacy import, not a marketing channel
(lead_source_channelis NULL on those rows).- Completion truth lives in
scheduled_services— many recurring customers
have ZEROservice_recordsrows ever, so "last completed service" read
fromservice_records(or the app) is a false-lapse signal. - Churn source of truth is CURRENT
pipeline_stage='churned'— neverchurned_at(sparse AND sticky; survives reactivation). Churn time =pipeline_stage_changed_at.customer_ltvis populated only by the
admin pricing-strategy recalculation action
(admin-pricing-strategy.js→pricing-intelligence.js) — verify row
counts before relying on it; it is empty unless that has been run. - Admin in-app notifications = the
notificationstable withrecipient_type='admin'; the type column iscategory, nottype. customers.property_sqft= treated LAWN area per the source-arbitration
contract — NEVER a building-footprint/home-sqft source.paymentshas NOinvoice_idcolumn — link via the PI id + metadata
variants (mirrorfindInvoiceForPayment);stripe_surcharge_statusis
never written.- The uuid-vs-integer split is PER-TABLE:
referrals.idis UUID butreferral_invites.promoter_id/referral_promoters.idARE integer —
verify each table in the target environment under §5. scheduled_services.scheduled_dateis a plain DATE (::textto
inspect; no timestamptz comparisons), and any direct SQL date move must
syncappointment_reminderstoo.- One-shot guard stamps need DEDICATED columns —
estimate_data(jsonb)
full-blob writers can erase embedded stamps and un-burn caps. - Customer-merge FK discovery must stay DYNAMIC from
information_schema
(110+ tables referencecustomers.id; 19 have undeclared FKs, incl.leads) — never hardcode the table list. The retired-loser phone
sentinelmerged-<id8>is load-bearing for intake lookups. message_draftshas a prod insert-guard trigger
(message_drafts_disabled_guard) that blocks non-allowlisted intents —
a new pending-queue intent needs a pg-function splice migration (§4) or
its drafts die silently.sms_templates: a MISSING row is ACTIVE (isTemplateActive) — deleting
a disabled template row silently ENABLES the blocked send. Theai_assistant/ai_approved/ai_revisedrows are ops kill switches —
never delete them. Template-inactive ≠ feature-off (confirmation/72h/24h
reminders fall back to EMAIL), never disable rows to change scheduling
(kills theoriginal_message_typemapping), and strip hardcoded fallback
copy when retiring an SMS action (it sends even with templates deleted).
7. Verification before "done"
Confirm data changes by SELECTing the affected rows (or matched/updated
counts for backfills), not by absence of errors. Migration merged ≠
migration ran. If something could not be verified (no dev DB in the
sandbox, no authorized prod access), say so explicitly instead of implying
verification happened.