docs/plans/saas/02.database-migration.md
Create the workspace table, add workspace to root tables, and update indexes. This step depends on Step 01 (principal split, bigserial migration) being completed first.
workspace TableCREATE TABLE workspace (
id text PRIMARY KEY, -- 'ws-xxxxxx'
resource_id text UNIQUE NOT NULL, -- user-chosen slug: 'acme-corp'
name text NOT NULL, -- display name
created_at timestamptz NOT NULL DEFAULT now(),
deleted boolean NOT NULL DEFAULT FALSE
);
-- For existing single-workspace installations, create the default workspace.
-- The workspace ID is derived from the existing setting.
INSERT INTO workspace (id, resource_id, name)
SELECT
'ws-' || (value->>'workspaceId'),
COALESCE(NULLIF(value->>'workspaceId', ''), 'default'),
'Default Workspace'
FROM setting
WHERE name = 'WORKSPACE_PROFILE';
No plan column -- workspace plan/license info is already stored in the setting table (name = 'SYSTEM', value contains license). Once setting gets workspace, plan info is naturally workspace-scoped.
workspace to Root TablesAdd workspace to the 10 existing root-level tables. (service_account and workload_identity will get workspace added in this step too, since Step 01 creates them without it.)
For each table, the pattern is:
-- Helper: get the default workspace ID
-- All statements below use this subquery inline.
-- project
ALTER TABLE project ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE project SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE project ALTER COLUMN workspace SET NOT NULL;
-- instance
ALTER TABLE instance ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE instance SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE instance ALTER COLUMN workspace SET NOT NULL;
-- setting
ALTER TABLE setting ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE setting SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE setting ALTER COLUMN workspace SET NOT NULL;
-- policy
ALTER TABLE policy ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE policy SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE policy ALTER COLUMN workspace SET NOT NULL;
-- role
ALTER TABLE role ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE role SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE role ALTER COLUMN workspace SET NOT NULL;
-- idp
ALTER TABLE idp ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE idp SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE idp ALTER COLUMN workspace SET NOT NULL;
-- review_config
ALTER TABLE review_config ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE review_config SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE review_config ALTER COLUMN workspace SET NOT NULL;
-- user_group
ALTER TABLE user_group ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE user_group SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE user_group ALTER COLUMN workspace SET NOT NULL;
-- export_archive
ALTER TABLE export_archive ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE export_archive SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE export_archive ALTER COLUMN workspace SET NOT NULL;
-- audit_log
ALTER TABLE audit_log ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE audit_log SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE audit_log ALTER COLUMN workspace SET NOT NULL;
-- service_account (created in Step 01 without workspace)
ALTER TABLE service_account ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE service_account SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE service_account ALTER COLUMN workspace SET NOT NULL;
-- workload_identity (created in Step 01 without workspace)
ALTER TABLE workload_identity ADD COLUMN workspace text REFERENCES workspace(resource_id);
UPDATE workload_identity SET workspace = (SELECT id FROM workspace LIMIT 1);
ALTER TABLE workload_identity ALTER COLUMN workspace SET NOT NULL;
Project-child tables (plan, issue, db, project_webhook, worksheet, db_group, release, access_grant, query_history) and grandchild tables do NOT get a workspace column -- they are scoped through their existing project FK chain.
Only root tables need index changes to include workspace:
-- project
DROP INDEX idx_project_unique_resource_id;
CREATE UNIQUE INDEX idx_project_unique_resource_id ON project(workspace, resource_id);
-- instance
DROP INDEX idx_instance_unique_resource_id;
CREATE UNIQUE INDEX idx_instance_unique_resource_id ON instance(workspace, resource_id);
-- principal: no change needed (global identity, email unique globally)
-- setting
DROP INDEX idx_setting_unique_name;
CREATE UNIQUE INDEX idx_setting_unique_name ON setting(workspace, name);
-- role
DROP INDEX idx_role_unique_resource_id;
CREATE UNIQUE INDEX idx_role_unique_resource_id ON role(workspace, resource_id);
-- idp
DROP INDEX idx_idp_unique_resource_id;
CREATE UNIQUE INDEX idx_idp_unique_resource_id ON idp(workspace, resource_id);
-- user_group
DROP INDEX idx_user_group_unique_email;
CREATE UNIQUE INDEX idx_user_group_unique_email ON user_group(workspace, email) WHERE email IS NOT NULL;
-- service_account
CREATE UNIQUE INDEX idx_service_account_unique_workspace_email ON service_account(workspace, email);
-- workload_identity
CREATE UNIQUE INDEX idx_workload_identity_unique_workspace_email ON workload_identity(workspace, email);
Child table unique indexes remain unchanged -- their uniqueness is naturally scoped because their parent references (e.g., plan.project, db.instance) are already workspace-unique:
-- These DO NOT change (already scoped through parent FKs):
-- idx_db_unique_instance_name ON db(instance, name) -- instance is workspace-unique
-- idx_plan_check_run_unique_plan_id ON plan_check_run(plan_id) -- plan_id is globally unique (bigserial)
-- idx_issue_unique_plan_id ON issue(plan_id) -- plan_id is globally unique (bigserial)
-- uk_task_run_task_id_attempt ON task_run(task_id, attempt) -- task_id is globally unique (bigserial)
-- idx_db_group_unique_project_resource_id ON db_group(project, resource_id) -- project is workspace-unique
-- idx_release_project_train_iteration ON release(project, train, iteration) -- project is workspace-unique
CREATE INDEX idx_project_workspace ON project(workspace);
CREATE INDEX idx_instance_workspace ON instance(workspace);
CREATE INDEX idx_setting_workspace ON setting(workspace);
CREATE INDEX idx_policy_workspace ON policy(workspace);
CREATE INDEX idx_audit_log_workspace ON audit_log(workspace);
CREATE INDEX idx_service_account_workspace ON service_account(workspace);
CREATE INDEX idx_workload_identity_workspace ON workload_identity(workspace);
Update TestLatestVersion in backend/migrator/migrator_test.go after adding new migration files.
Update backend/migrator/migration/LATEST.sql to reflect the final schema state with the workspace table and workspace columns.