From a4daac4f15d8b09fab638ae941f7d91e6b8ec707 Mon Sep 17 00:00:00 2001 From: risuncode Date: Thu, 8 Oct 2026 15:44:17 +0000 Subject: [PATCH] fix(migrations): normalize retired share-link kinds and reconcile legacy schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Databases created from a pre-rewrite baseline cannot complete the migration chain. 0006 rebuilds `share_links_kind_check` over rows that still hold the retired `monitor`/`setup` kinds, so the check fails, the transaction rolls back, and every later migration is left unapplied (35 of them as of 0040). Rename those rows first (`monitor`→`enroll`, `setup`→`handoff`) and retire them, matching the rule the backup importer already applies to the same legacy kinds ("import them as inactive enrollment links so their old tokens never regain a usable public endpoint", `src/console/backup/validate.ts`): the rows were written for the old page contract, and an old token must not start answering the new one. Objects introduced by editing `0000_baseline.sql` itself reach no existing database — the snapshot is already in the ledger, and no later file adds them. A pre-rewrite database therefore finished the chain while still missing `telemetry_usage_totals` and the share-key columns on `api_keys` (`key_mode`, `parent_key_id`, `issued_client_ip`, `issued_client_ip_key`), then failed its first key lookup with `column "key_mode" does not exist`. `0041_reconcile_legacy_schema.sql` closes that gap idempotently. Unused legacy objects (`backup_status`, `console_users.last_login_at`, `telemetry_payloads.redaction_applied`, four superseded indexes) are left in place: dropping them is destructive and not required to unblock the upgrade. Verified against a production dump: chain completes (42 migrations), the resulting schema matches a fresh database column-for-column, a live completion succeeds, and data is preserved. --- .../0006_allow_personal_handoff_links.sql | 18 +++ migrations/0041_reconcile_legacy_schema.sql | 153 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 migrations/0041_reconcile_legacy_schema.sql diff --git a/migrations/0006_allow_personal_handoff_links.sql b/migrations/0006_allow_personal_handoff_links.sql index 58c96e5b..94de9356 100644 --- a/migrations/0006_allow_personal_handoff_links.sql +++ b/migrations/0006_allow_personal_handoff_links.sql @@ -1,4 +1,22 @@ -- Existing databases may still have the enroll-only check; personal keys need handoff links. +-- +-- Databases written by the previous share vocabulary carry `monitor` (the +-- durable public page) and `setup` (the one-time credential handoff) rows. The +-- check below only admits the current names, so those rows must be renamed +-- first or the constraint fails and the whole upgrade stops mid-chain. +-- +-- `monitor` renames to `enroll` (both serve the public policy page) and `setup` +-- to `handoff` (both reveal the key once). Every renamed row is also retired, +-- matching the rule the backup importer already applies to the same two legacy +-- kinds ("import them as inactive enrollment links so their old tokens never +-- regain a usable public endpoint", `src/console/backup/validate.ts`): the old +-- rows were written for the old page contract, and an old token must not start +-- answering the new one. The rows stay for audit; only their tokens stop +-- resolving. +UPDATE "public"."share_links" + SET "kind" = 'enroll', "active" = false WHERE "kind" = 'monitor'; +UPDATE "public"."share_links" + SET "kind" = 'handoff', "active" = false WHERE "kind" = 'setup'; ALTER TABLE "public"."share_links" DROP CONSTRAINT IF EXISTS "share_links_kind_check"; ALTER TABLE "public"."share_links" diff --git a/migrations/0041_reconcile_legacy_schema.sql b/migrations/0041_reconcile_legacy_schema.sql new file mode 100644 index 00000000..3d0d9937 --- /dev/null +++ b/migrations/0041_reconcile_legacy_schema.sql @@ -0,0 +1,153 @@ +-- Reconcile databases built from the pre-rewrite baseline snapshot. +-- +-- `0000_baseline.sql` is a snapshot of the schema, and the numbered files after +-- it carry only the delta *between* snapshots. Objects that were introduced by +-- editing the baseline itself therefore reach no existing database: it already +-- has `0000_baseline.sql` in its ledger, so the snapshot never runs again, and +-- no later file adds them. A database created before the rewrite finishes the +-- whole chain while still missing `telemetry_usage_totals` and the share-key +-- columns on `api_keys`, and the gateway then fails on its first key lookup +-- (`column "key_mode" does not exist`). +-- +-- This migration closes that gap. Every statement is idempotent, so it is a +-- no-op on databases created from the current baseline. +-- +-- Legacy objects that the current baseline no longer declares (`backup_status`, +-- `console_users.last_login_at`, `telemetry_payloads.redaction_applied`, and +-- four superseded indexes) are deliberately left in place: they are unused by +-- the current code and therefore harmless, and dropping them is a destructive +-- change that does not belong in the migration that unblocks the upgrade. + +-- 1. telemetry_usage_totals — absent entirely from pre-rewrite databases. +CREATE TABLE IF NOT EXISTS "public"."telemetry_usage_totals" ( + "tenant_id" uuid NOT NULL, + "identity_type" text NOT NULL, + "entity_id" uuid NOT NULL, + "requests" bigint DEFAULT 0 NOT NULL, + "errors" bigint DEFAULT 0 NOT NULL, + "input_tokens" bigint DEFAULT 0 NOT NULL, + "output_tokens" bigint DEFAULT 0 NOT NULL, + "last_used_at" timestamptz NOT NULL, + "updated_at" timestamptz DEFAULT now() NOT NULL, + CONSTRAINT "telemetry_usage_totals_identity_type_check" + CHECK ("identity_type" IN ('account', 'api_key')) +); +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'telemetry_usage_totals_identity_pk' + AND conrelid = 'public.telemetry_usage_totals'::regclass + ) THEN + ALTER TABLE "public"."telemetry_usage_totals" + ADD CONSTRAINT "telemetry_usage_totals_identity_pk" + PRIMARY KEY ("tenant_id", "identity_type", "entity_id"); + END IF; +END $$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'telemetry_usage_totals_tenant_id_tenants_id_fk' + AND conrelid = 'public.telemetry_usage_totals'::regclass + ) THEN + ALTER TABLE "public"."telemetry_usage_totals" + ADD CONSTRAINT "telemetry_usage_totals_tenant_id_tenants_id_fk" + FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE; + END IF; +END $$; +--> statement-breakpoint + +-- 2. api_keys — the share-key shape (`key_mode`, parent/issued columns) exists +-- only in the rewritten baseline. `key_hash` becomes nullable because a share +-- template key carries no credential of its own; existing rows all hold a hash, +-- so they satisfy the `personal` branch of the constraint added below. +ALTER TABLE "public"."api_keys" + ADD COLUMN IF NOT EXISTS "key_mode" text DEFAULT 'personal' NOT NULL; +--> statement-breakpoint +ALTER TABLE "public"."api_keys" + ADD COLUMN IF NOT EXISTS "parent_key_id" uuid; +--> statement-breakpoint +ALTER TABLE "public"."api_keys" + ADD COLUMN IF NOT EXISTS "issued_client_ip" text; +--> statement-breakpoint +ALTER TABLE "public"."api_keys" + ADD COLUMN IF NOT EXISTS "issued_client_ip_key" text; +--> statement-breakpoint +ALTER TABLE "public"."api_keys" + ALTER COLUMN "key_hash" DROP NOT NULL; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'api_keys_mode_shape_check' + AND conrelid = 'public.api_keys'::regclass + ) THEN + ALTER TABLE "public"."api_keys" + ADD CONSTRAINT "api_keys_mode_shape_check" CHECK ( + ( + ("key_mode" = 'personal') + AND ("key_hash" IS NOT NULL) + AND ("parent_key_id" IS NULL) + AND ("issued_client_ip" IS NULL) + AND ("issued_client_ip_key" IS NULL) + ) + OR ( + ("key_mode" = 'share') + AND ( + ( + ("parent_key_id" IS NULL) + AND ("key_hash" IS NULL) + AND ("key_encrypted" IS NULL) + AND ("issued_client_ip" IS NULL) + AND ("issued_client_ip_key" IS NULL) + ) + OR ( + ("parent_key_id" IS NOT NULL) + AND ("key_hash" IS NOT NULL) + AND ("key_encrypted" IS NULL) + AND ("issued_client_ip" IS NOT NULL) + AND ("issued_client_ip_key" IS NOT NULL) + ) + ) + ) + ); + END IF; +END $$; +--> statement-breakpoint +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'api_keys_parent_key_id_api_keys_id_fk' + AND conrelid = 'public.api_keys'::regclass + ) THEN + ALTER TABLE "public"."api_keys" + ADD CONSTRAINT "api_keys_parent_key_id_api_keys_id_fk" + FOREIGN KEY ("parent_key_id") REFERENCES "public"."api_keys"("id") ON DELETE CASCADE; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "api_keys_parent_key_id_idx" + ON "public"."api_keys" USING btree ("parent_key_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "api_keys_active_shared_ip_uidx" + ON "public"."api_keys" USING btree ("issued_client_ip_key") + WHERE ("parent_key_id" IS NOT NULL AND "revoked_at" IS NULL); +--> statement-breakpoint + +-- 3. share_links — `token_encrypted` and the current kind default. Existing +-- kind values were already normalized by 0006, which runs before this file. +ALTER TABLE "public"."share_links" + ADD COLUMN IF NOT EXISTS "token_encrypted" bytea; +--> statement-breakpoint +ALTER TABLE "public"."share_links" + ALTER COLUMN "kind" SET DEFAULT 'enroll'; +--> statement-breakpoint + +-- 4. telemetry_events — the tenant/account listing index from the baseline. +CREATE INDEX IF NOT EXISTS "telemetry_events_tenant_account_created_idx" + ON "public"."telemetry_events" USING btree ("tenant_id", "account_id", "created_at");