ubiquitous-invention/packages/database/migrations/0003_damp_green_goblin.sql

121 lines
7.2 KiB
MySQL
Raw Normal View History

multi-tenancy: promote workspaces to top-level table Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`, which made it impossible to put a real RLS-friendly tenant boundary on the schema or to give each workspace a stable URL slug. This commit: - Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook). - Migrates the 8 anchor tables (objects, workspace_members, object_type_defs, property_definitions, templates, forms, markdown_backlog_items, cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`, with a hand-augmented data-copy migration that preserves IDs and slug-collision- proofs on backfill. - Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All tenant-scoped routers (objects, types, properties, templates, forms, search, ai, relations, favorites) now flow through it. - Updates the web app to pass `workspace` slugs from the URL (or store) instead of the old `workspaceId`, including a workspace-sync layer that rewrites /<UUID>/... links to /<slug>/... - Updates the MCP tools (list_objects, create_object, search_objects) and the workspace://{handle}/tree resource to accept either a slug or UUID so existing agents keep working. - Adds a Create Workspace dialog and a Workspace Settings page (rename + slug rename with redirect, owner-only archive). Verified locally against a fresh Postgres: migration applies cleanly, slug uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution works in both directions, and ON DELETE CASCADE cleans up child rows in the correct workspace only. Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 00:02:55 -04:00
-- ============================================================================
-- 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;