Goose vs Prisma Migrate, and the soft-delete-vs-audit decision
Your two migration systems compared — plus how each project answers "what happened to this data?"
Know your own system
Two migration philosophies, owned side by side. The comparison is crisp because both serve the same purpose in different runtimes.
Goose (zoth)
- Migrations are SQL files (
migrations/postgres/001–007_*.sql) embedded into the binary (embed.FS) — the artifact carries its own schema history; no migration tool needs installing on the cluster, the container, or CI. - Applied at API startup (
cmd/api/main.go→internal/repositories/postgres/migrate.go): takespg_advisory_lock(72183921)— concurrent migrators (two replicas rolling) serialize — thengoose.Up(db, "."). Failure is fatal: the process exits beforeListenAndServe, so a broken migration can never serve traffic. - Versioning discipline: files are immutable once applied; new changes = new numbered files. The one soft spot:
sqlc.yamlreads migrations 001–005 only, so SQLC is blind to outbox/vector tables — the generated layer and the live schema have a known, bounded divergence (rawdatabase/sqlcovers the gap). Saying this unprompted shows you understand generated-artifact drift as a class of problem. - The 007 vector migration is a gem:
ALTER COLUMN embedding TYPE vector(768) USING NULL::vector— it resets embeddings because dimension changes invalidate them (a 1536-dim vector cannot be truncated to 768); re-ingestion follows. Migration-as-reindex: model changes that alter embedding space require regeneration, and the schema tool should encode that honestly (NULL, not garbage).
Prisma Migrate (traverse)
- Schema-first:
schema.prismais the source of truth;prisma migrate devdiffs it into SQL migrations;migrate deployapplies them (container start,exit 1on failure). - sha256-per-migration: editing an applied migration file makes deploy fail — immutability enforced by the tool, not convention. (Zoth achieves the same by convention + goose's own versioning table.)
- CI replays migrations (
scripts/ci-setup-db.sh) becausedb pushcan't express CHECK constraints and partial unique indexes the social-graph tests assert — CI runs the real migration SQL, not a schema-push approximation. Test-what-you-ship applied to DDL. - The gotcha you lived: a
@db.Datecolumn serializes a JSDateby its UTC calendar date — the timezone library normalizes to UTC midnight explicitly, andzonedStartOfDayresolves in two passes (DST-safe) whilezonedEndOfDaysteps +26 h instead of adding 24 (a DST day is 23 or 25 h). Schema details leaking into date semantics is a real class of bug worth citing.
The soft-delete-vs-audit decision
Same question, two correct answers, by domain:
- Zoth: hard delete + append-only hash-chained audit. "What happened to this request?" is answered by the audit chain (who, what, when, with what prev_hash) — immutable history is the product. Deletes are real deletes (cascades clean the tenant); the record of the deletion lives in
audit_logsforever. No WHERE-deleted_at tax, no ghost unique rows. - Traverse: soft delete where undo matters.
User.deletedAt+ 7-day grace +POST /auth/recover(password or authenticated) — a product feature: accidental deletion shouldn't nuke streak history. Notifications and social rows hard-delete or expire (requests older than 30 days → EXPIRED, terminal but retained as history — note that's a status, not a soft delete: the row remains queryable as history, deliberately).
The interview formulation: "Deleted is a business question. If the business answer is 'we must prove what existed' → append-only log + hard deletes (zoth, compliance domain). If it's 'users make mistakes' → soft delete with grace + purge job (traverse, consumer domain). If it's 'this is history' → terminal status, not deletion (friend requests, broadcast states)."
Bulletproof your resume
Say these answers aloud before moving on
These are the follow-ups an interviewer will actually ask about your projects.
Why embed migrations into the Go binary?
So the deployment artifact is self-contained: the binary that serves traffic is the binary that knows how to bring the schema up to date — no 'run migrations from a CI job with DB access' step, no version skew between migrator and server, and the advisory lock makes it safe when two replicas boot simultaneously during a rolling deploy. The tradeoff: migrations run in-process at boot (stop-the-world-ish), which pairs with my fail-fast stance: I'd rather a pod refuse to serve than serve the wrong schema.
A model change altered your embedding dimensions. What happened?
The schema moved from vector(1536) to vector(768) when I switched embedding models — text-embedding-004 outputs 768. A dimension change can't transform old vectors (they live in a different space), so migration 007 does USING NULL::vector: the column resets and documents must be re-ingested. The design lesson baked in: store the model identity with the vectors (I select embedding::text and re-embed on ingest), because embedding-space changes are re-ingestion events, not ALTERs you can paper over.
Your friend requests use an EXPIRED status instead of deleting. Why?
Because 'expired' is meaningful history: the pair's interaction timeline matters (rate limits — 30 requests/hour, 50 pending — and reconciliation queries read it), and a hard delete would make 'what happened between these users' unanswerable. It's the third option people forget: not soft-deleted, not gone — just terminal. Status-as-lifecycle keeps the row queryable without any deleted_at filtering tax.