DO NOT AUTO-PUSH TO PROD. Every SQL file in workers/drizzle/ must be
reviewed by a human before being applied to any live Neon database. The
db:migrate script must never be wired into CI/CD pipelines or automated
deploy hooks.
workers/src/db/schema.ts is the single source of truth for the Postgres
schema. Drizzle-kit generates versioned SQL migration files from it; those files
live in workers/drizzle/ and form the auditable history of every schema change.
The hand-written bootstrap db/schema-v4-neon.sql is kept in sync for
documentation and disaster-recovery purposes, but it is not the primary
migration vehicle for existing databases.
Journaled idx 3 — encodes decision D4 / requirement #4: sites.default_language → DROP DEFAULT + SET NOT NULL (mandatory at provisioning, no silent 'en' default). Generated 2026-06-26 (offline). ✅ Applied to prod 2026-06-30 (MCP). Safe to apply as-is because there are 0 sites in Skyfall-V2; if ever applied against a DB that has site rows with a NULL default_language, backfill them first (e.g. UPDATE sites SET default_language = 'en' WHERE default_language IS NULL) before SET NOT NULL. See docs/architecture/LOCALIZATION-AND-PROVISIONING.md.
Journaled idx 4 — reconciles a pre-existing drift: sites.supported_languages was declared text[] in schema.ts but the column was still jsonb. DROP DEFAULT → SET DATA TYPE text[] USING translate(col::text,'[]','{}')::text[] (the USING transform may not contain a subquery; values are BCP-47 language codes so the bracket→brace rewrite is safe; NULL passes through) → restore the {"en"} default. Generated 2026-06-29 (offline). ✅ Applied to prod 2026-06-30 (MCP). Safe at 0 sites; the cast is exercised on real multi-element + NULL rows in migrations-cascade.test.ts.
workers/drizzle/0005_dom_nodes_parent_cascade.sql
Journaled idx 5 — Phase C: adds the dom_nodes.parent_id → dom_nodes.id ON DELETE CASCADE self-FK so deleting a node deletes its subtree at the DB level. Prepended orphan pre-clean (NULLs parent_ids that point at a missing row, breaks parent_id = id self-loops) so the FK is satisfiable on existing data — non-destructive (rows kept, only dangling links nulled). Generated 2026-06-29 (offline). ✅ Applied to prod 2026-06-30 (MCP). Safe at 0 dom_nodes. Prerequisite before applying: the batch-create path must order creates parent-before-child across the sink’s chunk boundary — within one multi-row INSERT order is fine (immediate FK sees sibling rows), but a child in an earlier/batch request than its parent would now be rejected (lands with the Phase C2 route work).
Journaled idx 6 — Phase D: dom_nodes.positioninteger → double precision so sibling order is a FRACTIONAL rank. A move now sets the moved node’s position to a midpoint between its neighbours and touches no other row (deletes the renumber-every-sibling / RP-1 surface). int→double is an implicit cast (no USING, no data loss; existing integer ranks stay valid). Generated 2026-06-30 (offline). ✅ Applied to prod 2026-06-30 (MCP). Safe at 0 dom_nodes.
Journaled idx 7 — Phase 6 data-model: new constraints table (site-scoped guideline store) + last_author text NOT NULL DEFAULT 'human' provenance stamp on components/component_styles/design_tokens/dom_nodes. Additive/non-breaking. Renumbered from 0004 on merge (2026-06-30): master had independently taken idx 4/5/6 for the supported_languages/cascade/position migrations, so this Phase-6 migration was regenerated as idx 7 against master’s 0006 snapshot (drizzle-kit check clean). ✅ Applied to prod 2026-06-30 (MCP). ⚠️ Index is relative to master@14ba1af — if another branch lands a journaled migration on master before this branch merges, regenerate (bump to the next free index).
db/migrations/20260629_rls_neon_authorize.sql
Non-journaled sidecar — Phase 6 hardening: tenant RLS via Neon RLS Authorize. Creates membership helpers (current_app_user_id/is_member_of_site/…), ENABLE ROW LEVEL SECURITY + auth.user_id()-based policies on all tenant tables, and GRANTs to the authenticated role. Idempotent and INERT under the BYPASSRLS service role (applying it does not change current runtime behaviour). ⚠️ PREREQUISITES (live/owner): Neon RLS Authorize enabled on the project (the authenticated role + pg_session_jwt must exist for the GRANTs/auth.user_id()) and the Neon Auth JWKS registered as the auth provider. Enforcement activates only when the Worker routes through the tenant connection — set RLS_ENFORCED=true (and optionally NEON_AUTHENTICATED_DATABASE_URL) AFTER verifying the policies live (see the migration’s §4 verification block).
workers/drizzle/0010_targets_import_source.sql
Journaled idx 10 — adds nullable targets.import_source text (currently used value: 'bondlayer'). Import provenance stamp so the editor can gate Bondlayer-specific UI (reviewing/editing imported triggers) to only the targets that actually came from a Bondlayer import, instead of showing it on every site. Purely additive (ADD COLUMN, no default, no backfill needed — existing rows read as NULL). Generated 2026-07-06 (offline, pnpm db:generate). ✅ Applied to prod 2026-07-07 (MCP) — the live-Neon CI integration suite (test/integration.test.ts) runs against this same Skyfall-V2 database and started failing with column "import_source" of relation "targets" does not exist once this PR’s schema.ts change landed; applying the migration (and marking it in __drizzle_migrations) fixed it.
workers/drizzle/0011_target_domains.sql
Journaled idx 11 — creates the target_domains table (custom hostname → target map for host-based published serving; B2B2C punch-list §1). target_id FK ON DELETE cascade, globally-unique hostname (one host → one target), cloudflare_hostname_id/status/is_primary for the CF-for-SaaS cert lifecycle, + a unique index on hostname and an index on target_id. Purely additive (CREATE TABLE, no backfill). Generated 2026-07-09 (offline, pnpm db:generate; renamed from the drizzle default). ✅ Applied to prod + recorded in __drizzle_migrations (2026-07-10, with the RLS sidecar, in one transaction). Companion RLS: db/migrations/20260709_target_domains_rls.sql.
db/migrations/20260709_target_domains_rls.sql
Non-journaled sidecar — enables RLS on target_domains + a target-scoped policy (reuses the Clerk-baseline can_access_target() helper) + authenticated grant. ✅ Applied to prod 2026-07-10 alongside 0011. Idempotent; inert under the BYPASSRLS service role. Public host resolution runs on the service connection, so RLS never blocks serving.
workers/drizzle/0012_target_domains_kind.sql
Journaled idx 12 — adds target_domains.kind text NOT NULL DEFAULT 'custom' ('subdomain' | 'custom') so the free auto-provisioned platform subdomain (<slug>.<PLATFORM_ROOT_DOMAIN>) is distinguishable from a user-brought vanity domain (Publish & Custom Domains spec, increment 1). Purely additive (ADD COLUMN with default — existing rows read as 'custom'). Generated 2026-07-09 (offline). ✅ Applied to prod + recorded 2026-07-10.
Journaled idx 13 — adds verification_token, dns_target, last_checked_at, last_check jsonb to target_domains for the custom-domain verification subsystem (spec §8, increment 2): ownership TXT challenge, the CNAME target shown in setup, and the persisted per-check result. Purely additive (all nullable ADD COLUMN). Generated 2026-07-09 (offline). ✅ Applied to prod + recorded 2026-07-10.
workers/drizzle/0014_real_big_bertha.sql
Journaled idx 14 — N8 Phase 3 / N4: two additive columns on targets — breakpoints jsonb (nullable → default breakpoint registry; per-target responsive descriptors [{id,label,bound,px}]) and token_overrides jsonb DEFAULT '{}' (sparse per-target overrides over the SHARED design system’s tokens: { [designTokenId]: { base?, byBreakpoint? } } — the layered-DS model that avoids per-target copies). Purely additive (ADD COLUMN; existing rows read NULL/{}). Renumbered from a pre-merge 0011 after master’s 0011–0013 custom-domain migrations landed; made idempotent (ADD COLUMN IF NOT EXISTS) since the columns already existed on prod from an out-of-band apply. ✅ Columns present on prod + recorded in __drizzle_migrations (2026-07-10).
workers/drizzle/0015_good_hawkeye.sql
Journaled idx 15 — Forms & Email (D72/D72a, FE-1): new forms table (form config: write targets, input bindings, actions, spam, on-success), content_types.kind ('collection'|'form'|'audience', default 'collection'), content_items.review_state (nullable submission-inbox state) + idx_content_items_review. Purely additive. Generated 2026-07-10 (offline). ✅ Applied to prod + recorded 2026-07-11 (MCP), in one transaction with 0016 + the RLS sidecar below.
workers/drizzle/0016_nosy_amphibian.sql
Journaled idx 16 — Forms & Email (D72a, FE-7): project_email_settings (managed/BYO sending config; provider_credential_enc holds ONLY the AES-256-GCM sealed BYO key — never plaintext), email_suppressions (compliance list, unique (project_id,email)), email_events (provider event log). Purely additive. Generated 2026-07-10 (offline). ✅ Applied to prod + recorded 2026-07-11 (MCP).
workers/drizzle/0017_white_thunderbolt.sql
Journaled idx 17 — Print Mode (D74): adds nullable site_pages.page_format jsonb (the fixed-format page box for page_type print/raster pages: preset, trim W/H as CSS absolute lengths, orientation, mirrored margins, bleed, cropmarks, output intent — contract @matriz/schemaPageFormat). Purely additive (ADD COLUMN, nullable, no backfill — web pages read NULL). Renumbered from a pre-merge 0015 after master’s 0015/0016 Forms & Email migrations landed (regenerated against the 0016 snapshot; db:check clean). ✅ Applied to prod + recorded 2026-07-12 (MCP) — ledger row 19 inserted with the file’s sha256 + journal when; verified live (column present as jsonb, ledger contiguous), so pnpm db:migrate is a no-op again. No RLS sidecar needed: a new column on site_pages is covered by its existing row-level policies.
db/migrations/20260710_forms_email_rls.sql
Non-journaled sidecar — RLS for the four Forms & Email tables: enable RLS + is_project_member(project_id) policies + authenticated grants (SELECT-only on email_events). Mirrors the target_domains sidecar. Public ingest/mailer/webhook paths run on the BYPASSRLS service connection, so RLS never blocks a visitor submission or a bounce-driven suppression. Idempotent. ✅ Applied to prod 2026-07-11 alongside 0015/0016.
workers/drizzle/0019_illegal_union_jack.sql
Journaled idx 19 — Marketing (FE-10, D:email-render): email_templates (designer doc: constrained tree jsonb) + email_campaigns (one-off broadcast to an audience). Purely additive. ✅ Applied to prod + recorded 2026-07-13 (MCP) — was UNAPPLIED after the marketing PR merged (deployed code referenced missing tables); caught by the visitor-auth prod-apply audit. RLS sidecar: 20260713_marketing_rls.sql.
workers/drizzle/0020_classy_big_bertha.sql
Journaled idx 20 — Marketing (FE-11, D:email-flows): email_flows (definition) + email_flow_runs (authoritative per-enrollment runtime state). Purely additive. ✅ Applied to prod + recorded 2026-07-13 (MCP) — same audit as 0019. RLS sidecar: 20260713_marketing_rls.sql.
db/migrations/20260713_marketing_rls.sql
Non-journaled sidecar — BACKFILL: the marketing PR shipped 0019/0020 with NO RLS companion, but their builder-CRUD routes use the TENANT (getScopedDB) connection, so under RLS_ENFORCED=true they need policies. Enables RLS + is_project_member(project_id) + authenticated grants on all four marketing tables (they all carry project_id). Mirrors 20260710_forms_email_rls.sql. Send/flow-runner side effects run on the BYPASSRLS service connection. Idempotent. ✅ Applied to prod 2026-07-13 alongside 0019/0020.
Journaled idx 22 — Visitor Auth (D73, VA-4): site_pages.members_only boolean NOT NULL DEFAULT false + site_pages.required_role text (drives the publish-time memberGate R2 customMetadata). Purely additive. ✅ Applied to prod + recorded 2026-07-13 (MCP). No RLS sidecar: new columns on site_pages ride its existing policies.
workers/drizzle/0023_dry_mauler.sql
Journaled idx 23 — Backups (PR #316, editor-feature-gaps): new backups table (per-target snapshot metadata → R2 storage_path; backup_type/label/size_bytes/expires_at, FKs to targets/projects/users, two target_id indexes). Purely additive. ✅ Applied to prod + recorded 2026-07-14 (MCP) — was the “owner: apply 0023 to prod” action flagged in STATUS; applied during the component-review-user-areas merge reconciliation (ledger hash=2b1557af…, when=1783986048503).
workers/drizzle/0024_moaning_trish_tilby.sql
Journaled idx 24 — Member user areas (D:member-user-areas): the ATOMIC submission-quota backstop. Adds nullable content_items.quota_key text + a partial unique indexcontent_items_quota_unique (content_type_id, quota_key) WHERE quota_key IS NOT NULL. The forms ingest stamps `quota_key = ’
db/migrations/20260713_visitor_auth_rls.sql
Non-journaled sidecar — RLS for the visitor-auth tables: site_visitors (glass shape, can_access_target), site_members (data shape, is_project_member) + authenticated grants; member_login_tokens RLS-enabled but deny-closed (service-only, no policy). Public magic-link flow runs on the BYPASSRLS service connection. Idempotent. ✅ Applied to prod 2026-07-13 alongside 0021/0022.
db/migrations/20260720_symbols_rls.sql
Non-journaled sidecar — BACKFILL: the MISSING companion to journaled 0018_demonic_wind_dancer.sql, which added the symbols table (D:symbols-v1) but shipped no RLS sidecar — the one tenant-reachable table added after the Clerk baseline that slipped through. symbols had RLS disabled + no policy + no authenticated grant, so under RLS_ENFORCED=true every tenant write failed with permission denied for table symbols (table-GRANT error, not an RLS-policy violation) — first seen on Bondlayer import (POST /api/import/site/:targetId batches an INSERT into symbols), but the interactive Symbols API (workers/src/routes/symbols.ts, also getScopedDB) hit the same wall. Enables RLS + glass-shape can_access_target(target_id) policy (target-scoped, like dom_nodes/site_font) + full DML grant. Import/publish run on the BYPASSRLS service connection, so RLS never blocks them. Idempotent; inert under the service role. ✅ Applied to prod 2026-07-20 (MCP) — verified live (RLS enabled, tenant_symbols_all policy, authenticated grants SELECT/INSERT/UPDATE/DELETE). No __drizzle_migrations row (non-journaled sidecar).
db/migrations/20260720_backups_rls.sql
Non-journaled sidecar — BACKFILL: the MISSING companion to journaled 0023_dry_mauler.sql, which added the backups table (D:backups-r2) but shipped no RLS sidecar — same defect class as the symbols gap above, found during that audit. workers/src/routes/backup.ts runs create/list/get/delete + the pre-restore auto-backup on the TENANT connection (getScopedDB), so under RLS_ENFORCED=true every backup action failed with permission denied for table backups. backups carries both target_id and project_id but all routes filter by target_id, so it takes the glass-shape can_access_target(target_id) policy (like dom_nodes/site_font/symbols) + full DML grant. The R2 snapshot store/restore runs unaffected. Idempotent; inert under the service role. ✅ Applied to prod 2026-07-20 (MCP) — verified live (RLS enabled, tenant_backups_all policy, authenticated grants SELECT/INSERT/UPDATE/DELETE). No __drizzle_migrations row (non-journaled sidecar).
workers/drizzle/0025_cms_scale_perf_indexes.sql
Journaled idx 25 — CMS scale/perf: a GIN index on content_items.data (jsonb containment queries) + a composite (content_type_id, created_at) btree. Purely additive (two CREATE INDEX). Landed on master but was left unapplied to prod until 2026-07-21 (see reconciliation note below). ✅ Applied to prod + recorded 2026-07-21 (MCP) alongside 0026 (ledger hash=e33e3dfc…, when=1784031066406). No RLS sidecar: rides content_items’ existing project-member policies.
workers/drizzle/0026_zippy_tombstone.sql
Journaled idx 26 — Audit log (EP-12, D:audit-log): new append-only audit_events table (project cascade + target SET NULL so an event outlives its target; FK-less denormalized actor_user_id + actor_label; summary jsonb; three (project|target, created_at)/(project, action) indexes). Purely additive (CREATE TABLE + 2 FKs + 3 indexes). Generated 2026-07-21 (offline, pnpm db:generate). ✅ Applied to prod + recorded 2026-07-21 (MCP) (ledger hash=bf96a69a…, when=1784634586558). Companion RLS: db/migrations/20260721_audit_events_rls.sql.
db/migrations/20260721_audit_events_rls.sql
Non-journaled sidecar — RLS for audit_events (EP-12). Enables RLS + a FOR SELECTis_project_member(project_id) policy + a SELECT-only grant to authenticated. This is the append-only enforcement: the tenant role can READ its project’s events but has no INSERT/UPDATE/DELETE grant, so events can only be written on the BYPASSRLS service connection (recordAudit) and never forged/edited/erased by a viewer. Idempotent; inert under the service role. ✅ Applied to prod 2026-07-21 (MCP) alongside 0026 — verified live (RLS enabled, tenant_audit_events_select SELECT policy, authenticated granted SELECT only; INSERT/UPDATE/DELETE all denied). No __drizzle_migrations row (non-journaled sidecar).
Journaled idx 32 — Repeater dead-surface (D105, D:repeater-dead-surface, PR #375): drops the always-nulldom_nodes.filter_config + sort_config columns (threaded through ~10 files, written by nothing since D29; every threading removed in the same PR). Destructive but content-free: both columns verified all-NULL by the Phase 3b adversarial review before the drop. Generated 2026-07-28 (offline). ✅ Applied to prod + recorded 2026-07-28 (MCP, coordinator session) — one transaction: the two DROPs + the ledger row (hash=92d1df80…, created_at=1785193356630 = the journal when); verified live (0 matching columns, ledger contiguous, head = 0032). No RLS sidecar: dropping columns on dom_nodes rides its existing policies.
workers/drizzle/0033_detail_template_variants.sql
Journaled idx 33 — Detail-template variants (D108, D:detail-variants, PR #378): adds nullable site_pages.detail_collection_id (FK → content_types ON DELETE SET NULL) + site_pages.detail_template_key + content_items.detail_template_key, and idx_site_pages_detail_collection (target_id, detail_collection_id). Purely additive — every existing row reads NULL; derivation stays as the resolution fallback so no behaviour changes until a link is stored. Renumbered from a pre-merge 0032 after D105’s column-drop took that index (regenerated against the 0032 snapshot; db:check clean). ✅ Applied to prod + recorded 2026-07-28 (MCP, coordinator session, owner-instructed) — one transaction: 5 DDL statements + the ledger row (hash=ba0284ce…, created_at=1785211464990 = the journal when); verified live (3 columns + index present, ledger contiguous, head = 0033, 35 rows). No RLS sidecar: new columns on site_pages/content_items ride their existing policies (verified by the Phase 9 adversarial review — no column enumeration in any policy).
Non-journaled, legacy-prod-only sidecar (moved out of workers/drizzle/ 2026-06-26 so that dir holds ONLY journaled migrations). Idempotent ADD COLUMN IF NOT EXISTS ALTERs that bring an old prod (bootstrapped from schema-v4-neon.sql before v4.1) up to the 0000 baseline. A fresh drizzle bootstrap does NOT need it (the token cols are inside the 0000 snapshot). Already applied to the live Skyfall-V2 DB; kept for disaster-recovery of legacy DBs only.
✅ LEDGER RECONCILED 2026-07-21 (MCP session). Applying EP-12 (audit log) surfaced a one-migration
gap: the drizzle.__drizzle_migrations ledger sat at high-water 0024 (id-27, when=1784028787197)
while journaled 0025_cms_scale_perf_indexes had landed on master but was never applied to prod
(its two content_items indexes were physically absent — verified before touching anything). Both
0025 and the new 0026_zippy_tombstone (audit_events) were applied to Skyfall-V2round-dust-84750148 in ONE transaction via Neon MCP (db:migrate can’t run from the agent sandbox —
outbound TCP :5432 is blocked; MCP runs over HTTPS, the documented prod path). Dry-run first: an
ephemeral branch (ep12-migrate-test, a copy of prod) received the identical transaction and was
verified (table shape, RLS/policy/grant, indexes, ledger) before the prod apply, then deleted. The
transaction: 0025’s 2 indexes → 0026’s audit_events (table + 2 FKs + 3 indexes) → two
drizzle.__drizzle_migrations rows (0025e33e3dfc…/1784031066406, 0026bf96a69a…/1784634586558)
→ the 20260721_audit_events_rls.sql sidecar. __drizzle_migrations now holds 28 rows, high-water
1784634586558 (0026); RLS verified (SELECT policy, authenticated SELECT-only — INSERT/UPDATE/DELETE
denied = append-only); pnpm db:migrate is a no-op again. No app data touched (both migrations additive,
audit_events starts empty).
✅ PROD BASELINED 2026-06-30 (MCP session). Neon project Skyfall-V2round-dust-84750148 now has
__drizzle_migrations with 9 rows: 0000 + catchup-sidecar + 0001_capture_217_218_224 + 0002_capture_233_token_meta
0003, 0004, 0005, 0006, and 0007, all applied. The legacy yjs_snapshots
(old state text shape) was dropped and recreated to the #224 R2-pointer shape. pnpm db:migrate against
this DB is now a no-op until a genuinely new journaled migration is generated.
✅ LEDGER RECONCILED 2026-07-10 (MCP session). After 2026-06-30 the ledger drifted (later
migrations were applied out-of-band without recording), leaving the last real row at 0007. It was
reconciled to reality on Skyfall-V2round-dust-84750148: rows for 0008–0010 (already applied)
plus 0011–0014 (target_domains + RLS sidecar + breakpoints/token_overrides, applied 2026-07-10)
were inserted with each file’s exact sha256 hash + journal when timestamp. __drizzle_migrations
now holds 16 rows (0000–0014 + the legacy pre-0000 catchup row at id 2), so pnpm db:migrate
sees a contiguous, fully-applied history and is a no-op. 0014 was made idempotent
(ADD COLUMN IF NOT EXISTS) since its columns pre-existed from the out-of-band apply.
Also applied 2026-06-26: db/migrations/20260626_token_category_taxonomy.sql (non-journaled sidecar) —
folded the seed’s legacy animation category into transition and installed a CHECK pinning
design_tokens.category to the 12 canonical categories (pivot §21a #6b, Strategy A).
✅ LEDGER RECONCILED 2026-07-13 (MCP session). Applying visitor auth surfaced a gap: the ledger
sat at 0018 (24→20 rows) while 0019/0020 (the merged marketing PR) were never applied to
prod — deployed marketing code was referencing missing tables. Reconciled on Skyfall-V2round-dust-84750148 in three transactions: (1) schema DDL for 0019+0020 (marketing) and
0021+0022 (visitor auth), all additive; (2) the RLS sidecars 20260713_marketing_rls.sql
(backfill — the marketing PR shipped none) and 20260713_visitor_auth_rls.sql; (3) four
__drizzle_migrations rows (0019–0022) inserted with each file’s exact sha256 hash + journal
when. __drizzle_migrations now holds 24 rows, high-water 0022; RLS verified (7 tables
enabled, 6 tenant policies, member_login_tokens deny-closed); pnpm db:migrate is a no-op again.
Worker secret MEMBER_SESSION_SIGNING_KEY set on skyfall-api the same session (256-bit, generated
server-side).
ℹ️ Separation of concerns (2026-06-26):workers/drizzle/ now contains ONLY journaled drizzle
migrations (0000, 0001_capture_217_218_224, 0002_capture_233_token_meta) + meta/. All hand-authored,
non-journaled SQL sidecars live in db/migrations/ (the catchup, the taxonomy CHECK, the slots backfill).
drizzle-kit only ever applies the journaled set; the sidecars are applied manually (psql / MCP) when needed.
Legacy-prod apply order (only for an old schema-v4-neon-bootstrapped DB):0000 → catchup sidecar →
0001_capture_217_218_224 → 0002. A fresh DB just runs drizzle-kit migrate (0000→0001→0002).
✅ CI guards (2026-06-26):.github/workflows/db-guard.yml enforces this convergence on every PR/push
touching the schema — two zero-dependency gates: db:check (drizzle-kit check — migration history is
consistent) and a drift gate (db:drift → db:generate must produce nothing, else schema.ts changed
without a committed migration). See workers/package.jsondb:check / db:drift. A from-scratch
apply-verify is documented as an opt-in job in the workflow (needs a NEON_API_KEY secret — drizzle-kit migrate uses the websocket-only Neon driver, so it can’t target a plain Postgres container).
| db/schema-v4-neon.sql | Hand-maintained bootstrap for fresh databases |
| db/seed-design-tokens-phase-4.2.sql | 152-token seed data (requires is_base column) |
The Neon prod instance was bootstrapped from db/schema-v4-neon.sql and then
had several ALTER TABLE statements applied live (before this migration system
existed). The baseline migration 0000_medical_stryfe.sql encodes the full
current schema — running it on prod would try to create tables that already
exist and fail.
To tell drizzle-kit “this database is already up to date with migration N”, you
must insert a row into the __drizzle_migrations table for each migration file
you want to mark as already applied.
Step 3 — Apply the catch-up migration to prod (if not already applied)
db/migrations/20260626_catchup_token_model_sidecar.sql contains only
ADD COLUMN IF NOT EXISTS and CREATE INDEX IF NOT EXISTS statements. It is
safe to run on the live prod database:
After both rows are inserted, pnpm db:migrate will correctly skip the baseline
on future runs and apply genuinely new journaled migrations.
Step 4 — Apply the captured-drift migration (0001_capture_217_218_224)
This is the journaled drizzle idx-1 migration (#217 + #218 + #224 columns/tables).
It is NOT idempotent (plain ADD COLUMN / CREATE TABLE), so it must run exactly
once, against a prod that is at the 0000 baseline + the catchup sidecar but does
not yet have these columns. Since prod was last touched live before #217/#218/#224,
that should hold — but verify each target column/table is absent first (e.g.
SELECT column_name FROM information_schema.columns WHERE table_name='dom_nodes' AND column_name='component_instance';).
Once 0000 is marked applied in __drizzle_migrations (Step 2) and the catchup
ran (Step 3, non-journaled), pnpm db:migrate will apply 0001_capture_217_218_224
as the next journaled migration. Review the SQL, confirm the absence checks, then apply.
The catch-up migration assumes source_component_id (on design_tokens) was
added as a bare nullable uuid with no FK constraint, matching what schema.ts
declares. If prod actually has a FK constraint on that column pointing to
components(id), the ADD COLUMN IF NOT EXISTS will be a no-op (column
exists) and no harm is done — but the FK would not be created. Verify with:
SELECT conname, contype FROM pg_constraint
WHERE conrelid ='design_tokens'::regclass AND contype ='f';
workers/drizzle/0018_demonic_wind_dancer.sql — Journaled idx 18, two additive
changes from claude/editor-ui-review-179yrg:
(a) site_font.axes jsonb — variable-font axes parsed from the uploaded file’s fvar
table at upload ([{tag,label,min,max,def,step?}]; NULL → static/unknown);
(b) new symbols table (D:symbols-v1) — user-created reusable components; master = a
real dom_nodes subtree (master_node_id), instance roots carry
reusable_component_id + instance_status; CREATE TABLE + 2 FKs + target index.
Merge history: originally shipped as 0017_parched_nekra + 0018_workable_chameleon
and applied to prod 2026-07-12 (MCP; Workers CI runs against the live DB, so the additive
column had to land before merge — the two 500s in deferred-features/integration were
Drizzle selecting the not-yet-existing axes column). Print Mode independently claimed
idx 17 (0017_white_thunderbolt), so on the master merge both files were dropped and
REGENERATED as this single idx-18 migration against the print-mode 0017 snapshot
(identical DDL, clean snapshot chain). Prod ledger reconciled the same day (MCP): the two
superseded rows deleted, one row inserted with this file’s sha256 + journal when —
verified contiguous, pnpm db:migrate is a no-op.
workers/drizzle/0030_orange_justin_hammer.sql — Journaled idx 30, one
additive change from claude/bondlayer-importer-binding-review-8apy4y (D:assets-target-id):
assets.target_id uuid (nullable, FK → targets(id)ON DELETE SET NULL) plus its
index. Lets GET /api/import/status/:targetId, POST /api/import/retry/:targetId and the
Render finalize-assets callback count and re-drive one site’s assets instead of the whole
project’s. SET NULL rather than CASCADE is deliberate — deleting a target must not
delete bytes a sibling site still renders; the row reverts to a project-level library
asset. Applied to prod (Neon Skyfall-V2 / round-dust-84750148) on 2026-07-26 via
the Neon MCP session that shipped PR #356.
db/migrations/20260726_assets_target_id_backfill.sql — non-journaled sidecar for the
above. Every asset row predating idx 30 has target_id IS NULL, so the new target-scoped
queries matched ZERO of them: a site’s import read 0/0 and its unmaterialized assets could
never be re-driven. Attributes an asset to its project’s target only when that project has
exactly ONE target (the only inference that is always correct); multi-target projects are
left NULL rather than guessed. Idempotent (touches only NULLs), additive.
Applied to prod 2026-07-26, same session, and verified: 1134/1134 attributed, 0
unattributed, 1 distinct target.
sha256 eef59094a26de4911f50f1652d839c605220ff5fa92e0d1172665c17d8e2af9a.
⚠️ Neither of the two rows above was recorded in __drizzle_migrations at the time — and,
as it turned out, the doc’s own reconciliation for that predates a bigger correction; see
Ledger drift below.
⚠️ Update 2026-07-26 (verification session): the target these 1134 rows were attributed
to was deleted before a re-import landed. assets.target_id is ON DELETE SET NULL, so all
1134 rows reverted to target_id IS NULL — orphaned again, independent of this backfill.
See the resolution below.
workers/drizzle/0031_font_metrics.sql — Journaled idx 31 (D:text-trim,
custom-font extension): global content-addressed font_metrics table (PK = lowercase
sha256 hex of the font file bytes; family, metrics jsonb, source, created_at) +
site_font.metrics_hash text (nullable, indexed, deliberately NOT an FK — a dangling
hash renders as “no metrics”, and an FK would turn the benign PATCH-before-PUT ordering
race into a hard failure). Purely additive, no backfill. Rows are immutable by
construction: the route upserts ON CONFLICT DO NOTHING (first write wins) and the
tenant role holds no UPDATE/DELETE grant. ✅ Applied to prod + recorded in
__drizzle_migrations (Neon Skyfall-V2 / round-dust-84750148) 2026-07-27 (MCP),
in one transaction with the RLS sidecar below; ledger row inserted with the file’s
sha256 (2896cc7d…) + journal when (1785150255418); verified live (table + column +
index present, ledger contiguous at id 35, authenticated grants exactly SELECT,INSERT).
db/migrations/20260727_font_metrics_rls.sql — non-journaled RLS sidecar for the
above: ENABLE RLS + world-read SELECT policy (global non-sensitive data, the
components-catalog shape) + INSERT gated on clerk_user_id() IS NOT NULL; grants
SELECT+INSERT only to authenticated (no UPDATE/DELETE = deny-closed immutability, the
audit_events precedent). Idempotent; inert under the BYPASSRLS service role.
✅ Applied to prod 2026-07-27 alongside 0031, same transaction.
db/migrations/20260726_assets_source_url_backfill.sql — NOT APPLIED. Resolved as
inert / superseded, 2026-07-26 (verification session, approved). Written to reconstruct
assets.metadata.sourceUrl from cdn_url so POST /api/import/retry/:targetId could
reach the 1134 rows above. Before it was ever applied, the target those rows belonged to
was deleted (see the update note above), setting their target_id to NULL — and
retry is target-scoped, so restoring sourceUrl on an orphaned row would not have made
it reachable anyway. This is item (c) from the file’s own decision tree (“orphaned rows
exist → that is a separate cleanup with its own tradeoff”), not (a).
Live-DB check confirmed all 1134 rows matched the sidecar’s WHERE clause exactly
(pending = orphaned = origin_cdn = 1134, have_source = materialized = 0) — same
pre-#356 stranded shape as always — and confirmed none were referenced by any dom_nodes
or content_items in the owning project (its only surviving target is an unrelated, empty
test target) and none had ever been materialized into R2 (materialized = 0, so no R2
objects to reconcile). Deleted rather than patched:
DELETEFROM assets
WHERE target_id ISNULL
AND project_id ='org_3GVE1A4LDgOu2RIH3cRXV3bXaqR'
AND metadata->>'pendingMaterialization'='true'
AND cdn_url ~ '^https?://'
AND cdn_url NOTLIKE'%/api/assets/serve/%';
Verified: assets table is now empty (0 rows) — those 1134 were the only rows in it.
The sidecar file matches 0 rows on prod as of 2026-07-26 and is not deleted — kept as
protection in case the same stranded-row shape recurs for a different project (it’s
idempotent and additive, so an empty match is a safe no-op). If a future retry/import run
ever needs it, re-run the before-state query in the file header first — do not assume
it still matches nothing.
Ledger drift — __drizzle_migrations was behind the journal (RECONCILED 2026-07-26)
Every prior write-up here (including the “12 rows / 31 journal entries” premise reported
when PR #356 was reviewed) assumed __drizzle_migrations lives in the public schema,
because that’s where earlier hand-run MCP reconciliation sessions had been writing it. It
doesn’t. drizzle-orm’s migrator (drizzle-orm/pg-core/dialect.{ts,cjs}, the code
drizzle-kit migrate actually calls) defaults migrationsSchema to drizzle, and this
project’s drizzle.config.ts never overrides it. So prod has carried two independent
__drizzle_migrations tables — public.__drizzle_migrations (a phantom ledger nothing
reads) and drizzle.__drizzle_migrations (the real one) — and this doc had been reconciling
the phantom one.
The real migrator’s “already applied?” check is also not a per-hash lookup. From
PgDialect.migrate:
const dbMigrations = await session.all(
sql`select id, hash, created_at from ${schema}.${table} order by created_at desc limit 1`
);
const lastDbMigration = dbMigrations[0];
// ...
if (!lastDbMigration||Number(lastDbMigration.created_at) <migration.folderMillis) {
// run this migration's SQL, then insert a row for it
}
It fetches only the single latest row and re-runs every journal entry whose when is
greater than that row’s created_at. Individual hashes are written for audit purposes but
never re-checked — the row with the maximum created_at is the entire cursor.
Querying drizzle.__drizzle_migrations directly (Neon MCP, round-dust-84750148,
production branch) showed 31 rows already present, covering idx 0000–0029 by
created_at high-water mark. The only journal entry newer than the latest row (0029,
created_at 1784903723416) was 0030_orange_justin_hammer (when 1784999614729) — so
pnpm db:migrate tried to re-run it and failed on column "target_id" of relation "assets" already exists (that DDL had genuinely already run against prod, per the D:assets-target-id
entry above — it was just never given a ledger row in the schema that matters).
Verified via information_schema that idx 0011–0030’s objects (the target_domains
table, targets.breakpoints/token_overrides, every table/column added through 0030) all
already exist on prod — so this was a pure ledger gap, not a missing-DDL situation, exactly
as 0012–0029’s rows already on file implied.
Fix applied (2026-07-26, MCP, approved):
INSERT INTO drizzle."__drizzle_migrations" (hash, created_at)
-- idx 30, 0030_orange_justin_hammer.sql, hash generated from the file (never typed by hand)
Verified:pnpm --filter ./workers db:migrate against prod now reports “migrations
applied successfully” with zero new rows inserted (drizzle.__drizzle_migrations count
unchanged at 32 across two consecutive runs) — a true no-op. db:check and db:drift both
clean.
Loose ends from this reconciliation (harmless, not re-touched)
public.__drizzle_migrations now holds 32 stray rows (12 pre-existing + 20 inserted
during this session before the schema mistake was caught, covering idx 0011–0030 by
the old, wrong procedure below). Nothing reads this table, so it’s inert clutter, not a
bug — left as-is rather than deleted without a separate ask. A future session could drop
it entirely; flagging here so nobody mistakes it for the live ledger again.
drizzle.__drizzle_migrations has three garbled historical rows at created_at1784644005837, 1784644005838, 1784903723416 (nominally idx 0027–0029): the first
carries idx 0028’s hash under idx 0027’s timestamp, the second has a hash matching no
current journal file, and the third has idx 0025’s hash with its last hex digit flipped
(...415e → ...415f) — a plausible hand-typed reconciliation typo from before this
session, i.e. exactly the mistake “generate, never type a hash by hand” exists to prevent.
Harmless to db:migrate (only the max created_at matters, not per-row hash correctness),
so left unfixed rather than issuing speculative UPDATEs; worth a cleanup pass if the
ledger’s audit trail ever needs to be trustworthy row-by-row.
The two non-journaled sidecars in this session (20260726_assets_target_id_backfill.sql,
20260726_assets_source_url_backfill.sql) are unaffected by any of the above — drizzle-kit
only manages the journaled set; sidecars are tracked in this document, not in either ledger.
Bondlayer importer live-DB verification checklist (blocked on deploy — see STATUS.md)
PR #360 (D89–D94) fixed the asset-pipeline bugs from #356, with a real-Postgres/real-R2 e2e
test (workers/test/node/import-materialization-e2e.test.ts) — but that test uses a
filesystem-backed R2 and PGlite, not the actual deployed Worker + Cloudflare R2 + Neon. This
session tried to close that gap by triggering a live Bondlayer re-import against prod
(Skyfall-V2/round-dust-84750148) and running the checks below, but the live API turned out
to be running pre-#356 code — see the 2026-07-26 entry at the top of STATUS.md for why
(GitHub Actions billing outage; Deploy API to Cloudflare Workers hasn’t run since PR #347).
The 124 asset rows that import produced were deleted (stuck pendingMaterialization, no
target_id, no sourceUrl, unreachable by retry — the pre-#360 bug reproducing itself on the
currently-live code). None of the checks below have been run against genuinely post-#360
code yet. Once billing is fixed and a real deploy goes out, re-run the Bondlayer import and
run these against the new target (<newTargetId>):
Original-name R2 keys.storage_path should be ${projectId}/<source path>/<name>.<ext>
— no sha256 prefix, no UID, always inside the ${projectId}/ prefix:
Both should be 0. Also check site_font.source_url/font_face_css.
Re-import is idempotent — the single most important check, and the originally reported
bug. Record count(*) of assets, dom_nodes, content_items for the target, re-run the
SAME import, confirm counts are unchanged and no new R2 objects appeared.
SELECTfilename, mime_type, storage_path FROM assets
WHERE target_id ='<newTargetId>'ANDfilenameLIKE'%.json';
Collection bindings resolve — no content_type_id pointing at nothing:
SELECTcount(*) FROM dom_nodes WHERE target_id ='<newTargetId>'
AND data_binding->'data_source'->>'content_type_id'IS NOT NULL
AND data_binding->'data_source'->>'content_type_id'NOTIN
(SELECT id::textFROM content_types WHERE target_id ='<newTargetId>');
Expect 0.
If any of these fail against genuinely post-#360 code, that’s a real regression — report it
with the query output rather than reshaping the check to pass.
workers/drizzle/0033_detail_template_variants.sql — Journaled idx 33
(D:detail-variants, Phase 9.1/9.2; renumbered from 0032 on merge — master
independently took idx 32 for the Phase 3b constraint-model migration, so this was
regenerated against master’s 0032 snapshot, db:check clean): three additive
columns, one FK, one index.
site_pages.detail_collection_id uuid NULL REFERENCES content_types(id) ON DELETE SET NULL
site_pages.detail_template_key text NULL store the page↔collection detail-template
link that was previously only DERIVED (/:slug path segment + a content_item root
binding); content_items.detail_template_key text NULL is the per-item template choice.
idx_site_pages_detail_collection (target_id, detail_collection_id) backs the
per-publish/per-session template-index build. Purely additive — every existing row reads
NULL, so derivation remains the fallback and publish output is byte-identical until an
author (or the importer) stores a link. No RLS sidecar needed: new columns on
site_pages/content_items ride those tables’ existing policies. ⏳ Not yet applied to
prod — apply with pnpm --filter ./workers db:migrate (human review first, per the
banner at the top of this file).
workers/drizzle/0034_atier_fts_content_items.sql — Journaled idx 34
(D190, A-tier full-text search): adds content_items.fts tsvector as a STORED
generated column (jsonb_to_tsvector('simple', data, '["string"]')) plus GIN index
idx_content_items_fts. Purely additive; no RLS sidecar (rides content_items’
existing policies). ⚠️ This entry was backfilled 2026-08-22 during the doc reset —
the migration shipped without its ledger row (the gap this file exists to prevent).
Prod-apply status unverified: confirm against Neon before relying on it, and
apply with pnpm --filter ./workers db:migrate after human review per the banner
at the top of this file.