Deployment plan: dato_tour → tour rename (PR #1418)
PR #1418 renames dato_tour → tour tables, switches 97 files, and establishes the foundation for admin-first tour creation. Existing tour rows keep DatoCMS IDs as Tour.id; new Tour.datoId column (nullable) links to DatoCMS content. New tours get cuid2 IDs.
Migration strategy: Expand-Contract (zero-downtime reads)
- Expand (this PR): New tables/columns created alongside old ones, data backfilled, old constraints relaxed
- Deploy: New code uses new tables+columns. Old code still works against old tables
- Contract (follow-up PR): Drop legacy tables, drop old columns, add NOT NULL
| Target | Method | Trigger |
|---|---|---|
| Next.js | Vercel GitHub integration | Auto on merge to main |
| Workers | CI auto-deploy | Auto on merge to main |
Both deploy in parallel after merge. Both reference the new tour/tour_id tables and columns.
tour— copy ofdato_tour+dato_idcolumn, all data backfilledtour_inventory— copy ofdato_tour_inventorywithtour_id, all data backfilled
bokun_line_item,dato_tour_locale,tour_plan_override,linktivity_line_item,trip_template_day_item- All backfilled:
tour_id = dato_tour_id
DROP NOT NULLon all olddato_tour_idcolumns- Drops composite PKs on
dato_tour_localeandtour_plan_override(replaced with unique constraints) — PKs implicitly enforce NOT NULL - Old indexes, FKs, and tables remain untouched
- All indexes from the Drizzle schema on new
tour/tour_inventorytables - Indexes on new
tour_idcolumns in child tables - FKs from
tour_id→tour.id - Unique constraints on new columns
git checkout feat/tour-rename-expand
# Run migration against local Docker Postgres
cd packages/db && pnpm push
# Verify both old and new tables coexist
psql $LOCAL_DB_URL <<'SQL'
SELECT count(*) as dato_tours FROM dato_tour;
SELECT count(*) as tours FROM tour;
SELECT count(*) as old_inventory FROM dato_tour_inventory;
SELECT count(*) as new_inventory FROM tour_inventory;
SELECT tour_id, dato_tour_id FROM bokun_line_item LIMIT 3;
SQLcd packages/db
dotenv -e .env.production drizzle-kit push --verbose --strictNo error window — old code still reads/writes dato_tour/dato_tour_id which are untouched.
-- Both old and new tables exist with matching counts
SELECT
(SELECT count(*) FROM dato_tour) as old_tours,
(SELECT count(*) FROM tour) as new_tours,
(SELECT count(*) FROM dato_tour_inventory) as old_inventory,
(SELECT count(*) FROM tour_inventory) as new_inventory;
-- Backfill complete
SELECT count(*) FROM tour WHERE dato_id IS NULL; -- expect 0
-- Child table dual columns
SELECT tour_id, dato_tour_id FROM bokun_line_item LIMIT 3;
-- expect both columns populated with same value
-- New constraints exist
SELECT conname FROM pg_constraint
WHERE conrelid = 'tour'::regclass AND contype = 'u';
-- Expected: tour_slug_unique, tour_dato_id_uniqueMerge on GitHub → triggers both Vercel and Workers deploy (~2-5 min).
Reads: zero downtime. Old code reads old tables, new code reads new tables — both have the data.
Writes: brief gap during rollout (~seconds). Old instances write to dato_tour/dato_tour_id, new instances write to tour/tour_id. No sync between them. Inventory sync and webhook handlers re-converge on next run.
| # | Test | Action | Expected |
|---|---|---|---|
| 1 | Tour page | /en/tours/[slug]/[existing-id] |
Renders with CMS content |
| 2 | Admin tours | /admin/tours |
Table loads, all tours |
| 3 | Tour search | Trip builder experiences tab | Results with type: "tour" |
| 4 | Cart add | Add tour from detail page | Cart updates |
| 5 | Cart remove | Remove tour from cart | Cart updates |
| 6 | Inventory | /en/tours listing |
Available tours show |
| 7 | Webhook sync | Edit tour in DatoCMS | modified_at updates in DB |
| 8 | Webhook skip | Create tour in DatoCMS | Logged as skipped |
| 9 | ISR revalidation | Edit tour in DatoCMS | Page revalidates |
| 10 | Sitemap | /sitemaps/tours.xml |
Correct IDs in URLs |
| 11 | Orders | /orders with tour bookings |
Tour line items render |
| 12 | Receipt | /orders/[id]/receipt |
Tour names display |
| 13 | Checkout | Full checkout with tour | Payment succeeds |
- Sentry: filter for
dato_tourortour_idSQL errors - Workers logs:
reindexTourscron completes - Inventory writes to
tour_inventory - DatoCMS edits trigger webhook sync (not creation)
Since old tables/columns are preserved, rollback is just reverting the code — no SQL needed.
Rollback sequence:
- Revert PR on GitHub (both Vercel + Workers auto-redeploy old code)
- Old code uses
dato_tour/dato_tour_idwhich still exist untouched - No database rollback needed
If you also want to clean up the expand artifacts:
-- Drop new tables
DROP TABLE IF EXISTS "tour_inventory";
DROP TABLE IF EXISTS "tour" CASCADE;
-- Drop new tour_id columns
ALTER TABLE "bokun_line_item" DROP COLUMN IF EXISTS "tour_id";
ALTER TABLE "dato_tour_locale" DROP COLUMN IF EXISTS "tour_id";
ALTER TABLE "tour_plan_override" DROP COLUMN IF EXISTS "tour_id";
ALTER TABLE "linktivity_line_item" DROP COLUMN IF EXISTS "tour_id";
ALTER TABLE "trip_template_day_item" DROP COLUMN IF EXISTS "tour_id";
-- Restore old constraints
ALTER TABLE "bokun_line_item" ALTER COLUMN "dato_tour_id" SET NOT NULL;
ALTER TABLE "linktivity_line_item" ALTER COLUMN "dato_tour_id" SET NOT NULL;
ALTER TABLE "dato_tour_locale" ALTER COLUMN "dato_tour_id" SET NOT NULL;
ALTER TABLE "tour_plan_override" ALTER COLUMN "dato_tour_id" SET NOT NULL;
ALTER TABLE "dato_tour_inventory" ALTER COLUMN "dato_tour_id" SET NOT NULL;
-- Restore old PKs
ALTER TABLE "dato_tour_locale" ADD CONSTRAINT "dato_tour_locale_pkey"
PRIMARY KEY("dato_tour_id","locale");
ALTER TABLE "tour_plan_override" ADD CONSTRAINT "tour_plan_override_dato_tour_id_plan_id_pk"
PRIMARY KEY("dato_tour_id","plan_id");
-- Then: drizzle-kit push from main to reconcile snapshotAfter this PR is deployed and stable:
-- Drop old tables
DROP TABLE "dato_tour_inventory";
DROP TABLE "dato_tour";
-- Backfill any stragglers
UPDATE "bokun_line_item" SET "tour_id" = "dato_tour_id" WHERE "tour_id" IS NULL;
UPDATE "dato_tour_locale" SET "tour_id" = "dato_tour_id" WHERE "tour_id" IS NULL;
UPDATE "tour_plan_override" SET "tour_id" = "dato_tour_id" WHERE "tour_id" IS NULL;
UPDATE "linktivity_line_item" SET "tour_id" = "dato_tour_id" WHERE "tour_id" IS NULL;
UPDATE "trip_template_day_item" SET "tour_id" = "dato_tour_id" WHERE "tour_id" IS NULL;
-- Drop old columns
ALTER TABLE "bokun_line_item" DROP COLUMN "dato_tour_id";
ALTER TABLE "dato_tour_locale" DROP COLUMN "dato_tour_id";
ALTER TABLE "tour_plan_override" DROP COLUMN "dato_tour_id";
ALTER TABLE "linktivity_line_item" DROP COLUMN "dato_tour_id";
ALTER TABLE "trip_template_day_item" DROP COLUMN "dato_tour_id";
-- Add NOT NULL on tour_id
ALTER TABLE "bokun_line_item" ALTER COLUMN "tour_id" SET NOT NULL;
ALTER TABLE "dato_tour_locale" ALTER COLUMN "tour_id" SET NOT NULL;
ALTER TABLE "tour_plan_override" ALTER COLUMN "tour_id" SET NOT NULL;
ALTER TABLE "linktivity_line_item" ALTER COLUMN "tour_id" SET NOT NULL;
-- Promote unique constraints to PKs
ALTER TABLE "dato_tour_locale" ADD CONSTRAINT "dato_tour_locale_pkey"
PRIMARY KEY("tour_id","locale");
ALTER TABLE "tour_plan_override" ADD CONSTRAINT "tour_plan_override_tour_id_plan_id_pk"
PRIMARY KEY("tour_id","plan_id");- Table/column renames + dato_id backfill (expand-contract)
- Full code cutover (97 files)
- Webhook: admin-only creation
-
insertTourFromCms→ cuid2 for new tours -
createTouradmin procedure - Clean up datoId ?? id fallback
- Contract migration (drop old tables/columns)
- Audit remaining legacy references
- Optional: rename
dato_tour_locale→tour_locale - Publishing eligibility gates
- Admin UI for full tour lifecycle