-- ============================================================================ -- 0003 — Promote workspaces to a top-level table. -- ============================================================================ -- Block A of the EchoDo commercial plan. We: -- 1. Create the new `workspaces` table. -- 2. Copy every existing `objects` row of type='workspace' INTO `workspaces`, -- preserving the same UUID so existing FKs (which all point at -- objects.id today) remain valid mid-migration. -- 3. Mint a `slug` for each workspace from its title (or falling back to a -- short UUID prefix if the slug would collide / be empty). -- 4. Drop the old objects→objects FK on every anchor table and re-add a new -- FK pointing at workspaces.id. -- 5. Delete the now-redundant type='workspace' rows from `objects` and add -- NOT NULL on objects.workspace_id (it was nullable for self-references). -- ============================================================================ -- 1. New workspaces table CREATE TABLE "workspaces" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "slug" varchar(60) NOT NULL, "name" varchar(200) NOT NULL, "owner_user_id" uuid NOT NULL, "plan_tier" varchar(20) DEFAULT 'free' NOT NULL, "archived_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint -- 2. Copy existing workspaces (objects WHERE type='workspace') into the new -- table. Owner is best-effort: prefer created_by, otherwise the first -- workspace_member that exists, otherwise the first user in the system -- (single-tenant homelab fallback). INSERT INTO "workspaces" ("id", "slug", "name", "owner_user_id", "plan_tier", "archived_at", "created_at", "updated_at") SELECT o.id, -- slug: lowercased, alphanum+dash, fallback to first 8 chars of UUID. COALESCE( NULLIF( regexp_replace(lower(trim(o.title)), '[^a-z0-9]+', '-', 'g'), '' ), substring(o.id::text from 1 for 8) ), COALESCE(NULLIF(trim(o.title), ''), 'Workspace'), COALESCE( o.created_by, (SELECT wm.user_id FROM "workspace_members" wm WHERE wm.workspace_id = o.id ORDER BY wm.created_at ASC LIMIT 1), (SELECT u.id FROM "users" u ORDER BY u.created_at ASC LIMIT 1) ), 'free', o.archived_at, o.created_at, o.updated_at FROM "objects" o WHERE o.type = 'workspace'; --> statement-breakpoint -- 2b. Disambiguate any duplicate slugs (e.g. two workspaces both titled "Tasks") -- by suffixing with the short UUID. Only touches collisions. UPDATE "workspaces" w SET "slug" = w.slug || '-' || substring(w.id::text from 1 for 6) WHERE w.id IN ( SELECT id FROM ( SELECT id, row_number() OVER (PARTITION BY slug ORDER BY created_at) AS rn FROM "workspaces" ) ranked WHERE ranked.rn > 1 ); --> statement-breakpoint -- 2c. Safety net: if there is at least one user but NO workspace exists yet -- (fresh DB on a brand-new Coolify deploy), seed a default one so the -- NOT NULL FK swap below cannot fail at runtime. INSERT INTO "workspaces" ("slug", "name", "owner_user_id") SELECT 'default', 'Default Workspace', u.id FROM "users" u WHERE NOT EXISTS (SELECT 1 FROM "workspaces") ORDER BY u.created_at ASC LIMIT 1; --> statement-breakpoint -- 3. Drop old objects.id-based FKs on every anchor table. ALTER TABLE "objects" DROP CONSTRAINT "objects_workspace_id_objects_id_fk";--> statement-breakpoint ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_workspace_id_objects_id_fk";--> statement-breakpoint ALTER TABLE "object_type_defs" DROP CONSTRAINT "object_type_defs_workspace_id_objects_id_fk";--> statement-breakpoint ALTER TABLE "property_definitions" DROP CONSTRAINT "property_definitions_workspace_id_objects_id_fk";--> statement-breakpoint ALTER TABLE "templates" DROP CONSTRAINT "templates_workspace_id_objects_id_fk";--> statement-breakpoint ALTER TABLE "forms" DROP CONSTRAINT "forms_workspace_id_objects_id_fk";--> statement-breakpoint ALTER TABLE "markdown_backlog_items" DROP CONSTRAINT "markdown_backlog_items_workspace_id_objects_id_fk";--> statement-breakpoint ALTER TABLE "cursor_sync_mappings" DROP CONSTRAINT "cursor_sync_mappings_workspace_id_objects_id_fk";--> statement-breakpoint -- 4. Backfill any orphan `workspace_id` values (rows whose old workspace -- object was deleted before this migration ran). Reassign to the first -- available workspace so the NOT NULL constraint below holds. UPDATE "objects" SET "workspace_id" = (SELECT id FROM "workspaces" ORDER BY created_at ASC LIMIT 1) WHERE "workspace_id" IS NULL AND EXISTS (SELECT 1 FROM "workspaces"); --> statement-breakpoint -- 5. Now objects.workspace_id is fully populated → flip to NOT NULL. ALTER TABLE "objects" ALTER COLUMN "workspace_id" SET NOT NULL;--> statement-breakpoint -- 6. Remove the obsolete type='workspace' rows from `objects` (they live in -- `workspaces` now). Use a guard so this is a no-op on a fresh DB. DELETE FROM "objects" WHERE "type" = 'workspace';--> statement-breakpoint -- 7. New FK constraints + indexes. ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE UNIQUE INDEX "workspaces_slug_unique" ON "workspaces" USING btree ("slug");--> statement-breakpoint CREATE INDEX "workspaces_owner_user_id_idx" ON "workspaces" USING btree ("owner_user_id");--> statement-breakpoint ALTER TABLE "objects" ADD CONSTRAINT "objects_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "object_type_defs" ADD CONSTRAINT "object_type_defs_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "property_definitions" ADD CONSTRAINT "property_definitions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "templates" ADD CONSTRAINT "templates_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "forms" ADD CONSTRAINT "forms_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "markdown_backlog_items" ADD CONSTRAINT "markdown_backlog_items_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "cursor_sync_mappings" ADD CONSTRAINT "cursor_sync_mappings_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;