Skip to content

Matriz — Database Migration Guide

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.


Path Purpose
workers/src/db/schema.ts Authoritative schema (Drizzle ORM)
workers/drizzle.config.ts Drizzle-kit config (dialect, schema path, output dir)
workers/drizzle/0000_medical_stryfe.sql Baseline migration — full schema from schema.ts (journaled idx 0)
workers/drizzle/0001_capture_217_218_224.sql Journaled idx 1 — real drizzle migration capturing schema drift merged AFTER the 0000 baseline was cut: #217 (dom_nodes.component_instance/instance_status), #218 (site_pages table + content_items workflow columns), #224 (yjs_snapshots reshaped to the R2-pointer model). Generated 2026-06-26; applied to prod 2026-06-26 (MCP).
workers/drizzle/0002_capture_233_token_meta.sql Journaled idx 2 — captures #233’s 4 additive design_tokens meta cols (reference_path, tailwind_class, source_preset, is_inherited). Generated + applied to prod (Skyfall-V2) 2026-06-26 (MCP).
workers/drizzle/0003_sites_default_language_notnull.sql Journaled idx 3 — encodes decision D4 / requirement #4: sites.default_languageDROP 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.
workers/drizzle/0004_sites_supported_languages_text_array.sql Journaled idx 4 — reconciles a pre-existing drift: sites.supported_languages was declared text[] in schema.ts but the column was still jsonb. DROP DEFAULTSET 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).
workers/drizzle/0006_dom_nodes_position_double.sql Journaled idx 6 — Phase D: dom_nodes.position integer → 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.
workers/drizzle/0007_phase6_constraints_last_author.sql 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.
workers/drizzle/0013_target_domains_verification.sql 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 targetsbreakpoints 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/schema PageFormat). 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.
workers/drizzle/0021_visitor_auth_members.sql Journaled idx 21 — Visitor Auth (D73, VA-2): site_visitors (per-site identity, unique (target_id,email)), member_login_tokens (hashed single-use magic-link tokens, PK = sha256, atomic burn), site_members (membership + profile + session_generation). Purely additive. ✅ Applied to prod + recorded 2026-07-13 (MCP). RLS sidecar: 20260713_visitor_auth_rls.sql.
workers/drizzle/0022_site_pages_member_gate.sql 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 index content_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 SELECT is_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).
workers/drizzle/0032_overconfident_fantastic_four.sql Journaled idx 32 — Repeater dead-surface (D105, D:repeater-dead-surface, PR #375): drops the always-null dom_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).
workers/drizzle/meta/_journal.json Drizzle migration journal (do not edit manually)
db/migrations/20260626_catchup_token_model_sidecar.sql 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-V2 round-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 (0025 e33e3dfc…/1784031066406, 0026 bf96a69a…/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-V2 round-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-V2 round-dust-84750148: rows for 00080010 (already applied) plus 00110014 (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 (00000014 + 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-V2 round-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 (00190022) 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_2240002. A fresh DB just runs drizzle-kit migrate (000000010002).

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:driftdb:generate must produce nothing, else schema.ts changed without a committed migration). See workers/package.json db: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) |


Day-to-day workflow (generate → review → apply)

Section titled “Day-to-day workflow (generate → review → apply)”

Make your schema change in TypeScript. This is the only file you should edit.

Terminal window
cd workers
pnpm db:generate
# or: pnpm --filter matriz-api db:generate (from repo root)

Drizzle-kit diffs the current schema against the last snapshot and emits a new .sql file in workers/drizzle/. Review that file before proceeding.

Open the new file and verify:

  • Only the intended columns/indexes/constraints changed.
  • No accidental DROP TABLE or DROP COLUMN statements.
  • Any destructive change (column rename, type change) is intentional and safe.
Terminal window
export DATABASE_URL="postgres://<user>:<pass>@<host>/<db>"
cd workers
pnpm db:migrate

5. Test, then apply to production (human step)

Section titled “5. Test, then apply to production (human step)”

Production applies must be done manually via the Neon MCP tool or Neon console, by a human who has reviewed the SQL. Never automate prod migrations.


Baselining drizzle against an already-migrated production database

Section titled “Baselining drizzle against an already-migrated production database”

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.

Prerequisites: you are connected to the Neon prod database via the Neon MCP tool and the __drizzle_migrations table does not yet exist.

Step 1 — Create the drizzle migrations tracking table (if absent)

CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
id serial PRIMARY KEY,
hash text NOT NULL,
created_at bigint
);

Step 2 — Mark the baseline migration as already applied

The hash is the SHA256 of the migration file content as computed by drizzle-kit. Rather than computing it manually, run the following from workers/:

Terminal window
node -e "
const fs = require('fs');
const crypto = require('crypto');
const sql = fs.readFileSync('drizzle/0000_medical_stryfe.sql', 'utf8');
console.log(crypto.createHash('sha256').update(sql).digest('hex'));
"

Then insert that hash (and the Unix-ms timestamp of when the baseline was generated — use Date.now() or any recent value):

INSERT INTO "__drizzle_migrations" (hash, created_at)
VALUES ('<hash-from-above>', <timestamp-ms>)
ON CONFLICT DO NOTHING;

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:

Terminal window
# Via psql:
psql "$DATABASE_URL" -f db/migrations/20260626_catchup_token_model_sidecar.sql
# Or paste the file contents into the Neon MCP execute_sql tool.

After applying, mark it in the tracking table:

Terminal window
node -e "
const fs = require('fs');
const crypto = require('crypto');
const sql = fs.readFileSync('db/migrations/20260626_catchup_token_model_sidecar.sql', 'utf8');
console.log(crypto.createHash('sha256').update(sql).digest('hex'));
"
INSERT INTO "__drizzle_migrations" (hash, created_at)
VALUES ('<hash-from-above>', <timestamp-ms>)
ON CONFLICT DO NOTHING;

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.


For a brand-new database:

Terminal window
# 1. Bootstrap the full schema
psql "$DATABASE_URL" -f db/schema-v4-neon.sql
# 2. Run the drizzle-kit baseline migration (creates tables already created above — skip)
# Instead, baseline the tracking table as described above.
# 3. Seed design tokens (requires is_base column from v4.1 bootstrap)
psql "$DATABASE_URL" -f db/seed-design-tokens-phase-4.2.sql

Alternatively, use only the drizzle-kit migration:

Terminal window
export DATABASE_URL="..."
# Prereq: the migrations declare `vector(1536)` embedding columns but do NOT
# create the extension — enable it first (Neon/pgvector image both support it):
psql "$DATABASE_URL" -c 'CREATE EXTENSION IF NOT EXISTS vector;'
cd workers
pnpm db:migrate
# Then seed:
psql "$DATABASE_URL" -f db/seed-design-tokens-phase-4.2.sql

Schema drift notes (observed during migration setup, 2026-06-26)

Section titled “Schema drift notes (observed during migration setup, 2026-06-26)”

The following gaps exist between db/schema-v4-neon.sql (pre-v4.1) and the live prod schema / schema.ts:

Table Column/Index Status
design_systems is_base boolean Was missing from SQL; now added in v4.1
design_systems base_system_id uuid Was missing from SQL; now added in v4.1
design_systems site_id uuid Was missing from SQL; now added in v4.1
design_systems idx_design_systems_site Was missing from SQL; now added in v4.1
design_systems idx_design_systems_base Was missing from SQL; now added in v4.1
design_tokens aliases text[] Was missing from SQL; now added in v4.1
design_tokens source_component_id uuid Was missing from SQL; now added in v4.1
design_tokens idx_design_tokens_aliases_gin Was missing from SQL; now added in v4.1
components required_tokens jsonb Was missing from SQL; now added in v4.1

Columns in schema-v4-neon.sql not in schema.ts

Section titled “Columns in schema-v4-neon.sql not in schema.ts”

The SQL bootstrap contains tables that Drizzle’s schema.ts does not manage (they are legacy or handled outside Workers):

  • site_design_overrides, property_categories, control_types, property_registry, component_variants, reusable_components, dom_nodes_history, draft_sessions, data_sources, runtime_variables, deployed_builds, site_deployments, bondlayer_migrations, bondlayer_collections_raw, bondlayer_records_raw, site_integrations, site_states, site_breakpoints, editor_config, site_custom_code, site_image_preset, skyfall_config, scheduled_publishes
  • Materialized view mv_style_value_frequency + its indexes
  • Trigger functions and triggers (update_updated_at_column, etc.)
  • All seed data

These are intentionally omitted from schema.ts — drizzle-kit will not touch them. They remain managed by db/schema-v4-neon.sql and manual migrations.

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.sqlJournaled 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.sqlJournaled 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.sqlJournaled 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.sqlNOT 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:

DELETE FROM assets
WHERE target_id IS NULL
AND project_id = 'org_3GVE1A4LDgOu2RIH3cRXV3bXaqR'
AND metadata->>'pendingMaterialization' = 'true'
AND cdn_url ~ '^https?://'
AND cdn_url NOT LIKE '%/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)

Section titled “Ledger drift — __drizzle_migrations was behind the journal (RECONCILED 2026-07-26)”

Correction: this document was checking the wrong table

Section titled “Correction: this document was checking the wrong table”

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 tablespublic.__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.

What was actually wrong, and what was fixed

Section titled “What was actually wrong, and what was fixed”

Querying drizzle.__drizzle_migrations directly (Neon MCP, round-dust-84750148, production branch) showed 31 rows already present, covering idx 00000029 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 00110030’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 00120029’s rows already on file implied.

Fix applied (2026-07-26, MCP, approved):

INSERT INTO drizzle."__drizzle_migrations" (hash, created_at)
VALUES ('1421cef147cdbe8ee9c170bfda0405cf353dda8c9609c0b859e4ef7b7a5926ec', 1784999614729);
-- 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)

Section titled “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 00110030 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_at 1784644005837, 1784644005838, 1784903723416 (nominally idx 00270029): 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)

Section titled “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>):

  1. Original-name R2 keys. storage_path should be ${projectId}/<source path>/<name>.<ext> — no sha256 prefix, no UID, always inside the ${projectId}/ prefix:
    SELECT storage_path, cdn_url, hash, file_size, mime_type
    FROM assets WHERE target_id = '<newTargetId>' LIMIT 30;
    SELECT count(*) FROM assets
    WHERE target_id = '<newTargetId>' AND storage_path ~ '/[0-9a-f]{64}-'; -- expect 0
    Spot-check the objects exist in R2 under those exact keys.
  2. Materialization actually completed (the bug that shipped):
    SELECT count(*) FILTER (WHERE metadata->>'pendingMaterialization' = 'true') AS pending,
    count(*) FILTER (WHERE metadata->>'reImportPending' = 'true') AS failed,
    count(*) FILTER (WHERE width IS NOT NULL) AS with_width,
    count(*) AS total
    FROM assets WHERE target_id = '<newTargetId>';
    pending must be 0. If not, POST /api/import/retry/<newTargetId> should drain it.
  3. No bndlyr.com left anywhere:
    SELECT count(*) FROM dom_nodes
    WHERE target_id = '<newTargetId>' AND to_jsonb(dom_nodes)::text LIKE '%bndlyr.com%';
    SELECT count(*) FROM content_items ci JOIN content_types ct ON ct.id = ci.content_type_id
    WHERE ct.target_id = '<newTargetId>' AND ci.data::text LIKE '%bndlyr.com%';
    Both should be 0. Also check site_font.source_url/font_face_css.
  4. 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.
  5. Lottie .json assets cataloged (_recover_lottie_src):
    SELECT filename, mime_type, storage_path FROM assets
    WHERE target_id = '<newTargetId>' AND filename LIKE '%.json';
  6. Collection bindings resolve — no content_type_id pointing at nothing:
    SELECT count(*) 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' NOT IN
    (SELECT id::text FROM 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.sqlJournaled 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.sqlJournaled 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.