diff --git a/apps/cloud/drizzle/0008_stiff_radioactive_man.sql b/apps/cloud/drizzle/0008_stiff_radioactive_man.sql new file mode 100644 index 000000000..596f9eaee --- /dev/null +++ b/apps/cloud/drizzle/0008_stiff_radioactive_man.sql @@ -0,0 +1,52 @@ +CREATE TABLE "workspaces" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "workspaces_organization_slug_unique" ON "workspaces" USING btree ("organization_id","slug");--> statement-breakpoint +CREATE INDEX "workspaces_organization_id_idx" ON "workspaces" USING btree ("organization_id");--> statement-breakpoint +-- --------------------------------------------------------------------------- +-- organizations.handle: add nullable, backfill from name with collision +-- suffixes, then enforce NOT NULL + UNIQUE. Cloud has few users so a single +-- migration backfill is acceptable; matches `slugifyHandle` in +-- apps/cloud/src/services/ids.ts (kept simple — diacritic folding is best +-- effort for ASCII names). +-- --------------------------------------------------------------------------- +ALTER TABLE "organizations" ADD COLUMN "handle" text;--> statement-breakpoint +WITH normalized AS ( + SELECT + "id", + "created_at", + COALESCE( + NULLIF( + regexp_replace( + regexp_replace( + regexp_replace(lower("name"), '[^a-z0-9]+', '-', 'g'), + '-+', '-', 'g' + ), + '^-|-$', '', 'g' + ), + '' + ), + 'org' + ) AS base + FROM "organizations" +), ranked AS ( + SELECT + "id", + "base", + row_number() OVER (PARTITION BY "base" ORDER BY "created_at", "id") AS rn + FROM normalized +) +UPDATE "organizations" o +SET "handle" = CASE WHEN r.rn = 1 THEN r.base ELSE r.base || '-' || (r.rn - 1) END +FROM ranked r +WHERE r."id" = o."id"; +--> statement-breakpoint +ALTER TABLE "organizations" ALTER COLUMN "handle" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "organizations" ADD CONSTRAINT "organizations_handle_unique" UNIQUE("handle"); diff --git a/apps/cloud/drizzle/meta/0008_snapshot.json b/apps/cloud/drizzle/meta/0008_snapshot.json new file mode 100644 index 000000000..b7df1765b --- /dev/null +++ b/apps/cloud/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1755 @@ +{ + "id": "29c4960b-a264-4c03-b6dd-d279aba5e310", + "prevId": "f521e4d8-1eb4-4f84-8110-38fb5157aaca", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": [ + "account_id", + "organization_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_handle_unique": { + "name": "organizations_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_organization_slug_unique": { + "name": "workspaces_organization_slug_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_organization_id_idx": { + "name": "workspaces_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_organization_id_organizations_id_fk": { + "name": "workspaces_organization_id_organizations_id_fk", + "tableFrom": "workspaces", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "blob_namespace_key_pk": { + "name": "blob_namespace_key_pk", + "columns": [ + "namespace", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_secret_id": { + "name": "access_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_secret_id": { + "name": "refresh_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_scope_id_idx": { + "name": "connection_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_provider_idx": { + "name": "connection_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connection_scope_id_id_pk": { + "name": "connection_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_scope_id_idx": { + "name": "definition_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "definition_source_id_idx": { + "name": "definition_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "definition_plugin_id_idx": { + "name": "definition_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "definition_scope_id_id_pk": { + "name": "definition_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.graphql_operation": { + "name": "graphql_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "graphql_operation_scope_id_idx": { + "name": "graphql_operation_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "graphql_operation_source_id_idx": { + "name": "graphql_operation_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_operation_scope_id_id_pk": { + "name": "graphql_operation_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.graphql_source": { + "name": "graphql_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "query_params": { + "name": "query_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "auth": { + "name": "auth", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "graphql_source_scope_id_idx": { + "name": "graphql_source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_source_scope_id_id_pk": { + "name": "graphql_source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_binding": { + "name": "mcp_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_binding_scope_id_idx": { + "name": "mcp_binding_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_binding_source_id_idx": { + "name": "mcp_binding_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_binding_scope_id_id_pk": { + "name": "mcp_binding_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_source": { + "name": "mcp_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_source_scope_id_idx": { + "name": "mcp_source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_source_scope_id_id_pk": { + "name": "mcp_source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth2_session": { + "name": "oauth2_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_scope": { + "name": "token_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth2_session_scope_id_idx": { + "name": "oauth2_session_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth2_session_plugin_id_idx": { + "name": "oauth2_session_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth2_session_connection_id_idx": { + "name": "oauth2_session_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "oauth2_session_scope_id_id_pk": { + "name": "oauth2_session_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_operation": { + "name": "openapi_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_operation_scope_id_idx": { + "name": "openapi_operation_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_operation_source_id_idx": { + "name": "openapi_operation_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_operation_scope_id_id_pk": { + "name": "openapi_operation_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_source": { + "name": "openapi_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "query_params": { + "name": "query_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oauth2": { + "name": "oauth2", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "invocation_config": { + "name": "invocation_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_source_scope_id_idx": { + "name": "openapi_source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_source_scope_id_id_pk": { + "name": "openapi_source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_source_binding": { + "name": "openapi_source_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_scope_id": { + "name": "source_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_scope_id": { + "name": "target_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_source_binding_source_id_idx": { + "name": "openapi_source_binding_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_source_binding_source_scope_id_idx": { + "name": "openapi_source_binding_source_scope_id_idx", + "columns": [ + { + "expression": "source_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_source_binding_target_scope_id_idx": { + "name": "openapi_source_binding_target_scope_id_idx", + "columns": [ + { + "expression": "target_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_source_binding_slot_idx": { + "name": "openapi_source_binding_slot_idx", + "columns": [ + { + "expression": "slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret": { + "name": "secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owned_by_connection_id": { + "name": "owned_by_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "secret_scope_id_idx": { + "name": "secret_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_provider_idx": { + "name": "secret_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_owned_by_connection_id_idx": { + "name": "secret_owned_by_connection_id_idx", + "columns": [ + { + "expression": "owned_by_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "secret_scope_id_id_pk": { + "name": "secret_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source": { + "name": "source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_edit": { + "name": "can_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "source_scope_id_idx": { + "name": "source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_plugin_id_idx": { + "name": "source_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "source_scope_id_id_pk": { + "name": "source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_scope_id_idx": { + "name": "tool_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_source_id_idx": { + "name": "tool_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_plugin_id_idx": { + "name": "tool_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tool_scope_id_id_pk": { + "name": "tool_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_scope_id_position_idx": { + "name": "tool_policy_scope_id_position_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tool_policy_scope_id_id_pk": { + "name": "tool_policy_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_vault_metadata": { + "name": "workos_vault_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workos_vault_metadata_scope_id_idx": { + "name": "workos_vault_metadata_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workos_vault_metadata_scope_id_id_pk": { + "name": "workos_vault_metadata_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 81da0fef7..f3b5b7c4f 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1777567556847, "tag": "0007_military_young_avengers", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1777874366489, + "tag": "0008_stiff_radioactive_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/cloud/src/services/schema.ts b/apps/cloud/src/services/schema.ts index 1038c67f3..7b2536176 100644 --- a/apps/cloud/src/services/schema.ts +++ b/apps/cloud/src/services/schema.ts @@ -7,11 +7,19 @@ // - `accounts` — login identity (foreign key anchor for created_by, etc.) // - `organizations` — billing entity, scoping root for all domain data // - `memberships` — which accounts belong to which organizations +// - `workspaces` — optional project context inside an org // // We do NOT mirror invitations or user profile data — those stay in WorkOS // and are queried via API when needed. -import { pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"; +import { + index, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; /** Login identity. The `id` is the WorkOS user ID. */ export const accounts = pgTable("accounts", { @@ -19,9 +27,15 @@ export const accounts = pgTable("accounts", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); -/** Organization (billing entity, scoping root). The `id` is the WorkOS organization ID. */ +/** + * Organization (billing entity, scoping root). The `id` is the WorkOS + * organization ID. `handle` is a local URL handle, generated from `name` on + * create with collision suffixes; we keep it editable later without changing + * the underlying WorkOS id. + */ export const organizations = pgTable("organizations", { id: text("id").primaryKey(), + handle: text("handle").notNull().unique(), name: text("name").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -46,3 +60,34 @@ export const memberships = pgTable( pk: primaryKey({ columns: [t.accountId, t.organizationId] }), }), ); + +/** + * Workspace — narrower project context inside an organization. Org members + * have access to every workspace in v1; per-workspace membership/roles are + * out of scope. `slug` is unique within the org and used as the URL segment; + * `id` is the immutable primary key (`workspace_`). + */ +export const workspaces = pgTable( + "workspaces", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + slug: text("slug").notNull(), + name: text("name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => ({ + orgSlugUnique: uniqueIndex("workspaces_organization_slug_unique").on( + t.organizationId, + t.slug, + ), + orgIdx: index("workspaces_organization_id_idx").on(t.organizationId), + }), +); diff --git a/apps/cloud/src/services/user-store.node.test.ts b/apps/cloud/src/services/user-store.node.test.ts new file mode 100644 index 000000000..6cc6197a0 --- /dev/null +++ b/apps/cloud/src/services/user-store.node.test.ts @@ -0,0 +1,90 @@ +// --------------------------------------------------------------------------- +// user-store handle picking + upsert behavior +// --------------------------------------------------------------------------- +// +// Validates the runtime org handle generator: stable across re-upserts (we +// don't change the handle on a name change), and resolves collisions by +// numeric suffix. + +import { describe, expect, it } from "@effect/vitest"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import { combinedSchema } from "./db"; +import { organizations } from "./schema"; +import { makeUserStore, pickFreeOrgHandle } from "./user-store"; + +const url = + process.env.DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:5434/postgres"; + +const withDb = async (fn: (db: ReturnType) => Promise) => { + const sql = postgres(url, { max: 1, idle_timeout: 0, max_lifetime: 30 }); + try { + const db = drizzle(sql, { schema: combinedSchema }); + return await fn(db); + } finally { + await sql.end({ timeout: 0 }).catch(() => undefined); + } +}; + +describe("user-store handles", () => { + it("upsertOrganization assigns a slugified handle on first insert", async () => { + const id = `org_${crypto.randomUUID()}`; + const org = await withDb((db) => + makeUserStore(db).upsertOrganization({ id, name: "Acme Corp" }), + ); + expect(org.handle.startsWith("acme-corp")).toBe(true); + }); + + it("upsertOrganization keeps the handle stable across name changes", async () => { + const id = `org_${crypto.randomUUID()}`; + const first = await withDb((db) => + makeUserStore(db).upsertOrganization({ id, name: "Stable Name" }), + ); + const second = await withDb((db) => + makeUserStore(db).upsertOrganization({ id, name: "Renamed Org" }), + ); + expect(second.handle).toBe(first.handle); + expect(second.name).toBe("Renamed Org"); + }); + + it("pickFreeOrgHandle resolves collisions with -2, -3, …", async () => { + const base = `coll-${crypto.randomUUID().slice(0, 8)}`; + await withDb(async (db) => { + await db.insert(organizations).values({ + id: `org_${crypto.randomUUID()}`, + name: "x", + handle: base, + }); + const next = await pickFreeOrgHandle(db, base); + expect(next).toBe(`${base}-2`); + await db.insert(organizations).values({ + id: `org_${crypto.randomUUID()}`, + name: "y", + handle: `${base}-2`, + }); + const after = await pickFreeOrgHandle(db, base); + expect(after).toBe(`${base}-3`); + }); + }); + + it("getOrganizationByHandle round-trips", async () => { + const id = `org_${crypto.randomUUID()}`; + const handle = `lookup-${crypto.randomUUID().slice(0, 8)}`; + await withDb(async (db) => { + await db + .insert(organizations) + .values({ id, name: "Lookup", handle }); + const fetched = await makeUserStore(db).getOrganizationByHandle(handle); + expect(fetched?.id).toBe(id); + const missing = await makeUserStore(db).getOrganizationByHandle( + `nope-${crypto.randomUUID()}`, + ); + expect(missing).toBeNull(); + // cleanup so other tests don't see leaked rows + await db.delete(organizations).where(eq(organizations.id, id)); + }); + }); +}); diff --git a/apps/cloud/src/services/user-store.ts b/apps/cloud/src/services/user-store.ts index d5a4781f8..73068a0c7 100644 --- a/apps/cloud/src/services/user-store.ts +++ b/apps/cloud/src/services/user-store.ts @@ -7,14 +7,40 @@ // so domain tables can foreign-key against them and so we can resolve org // metadata without an API call on every request. -import { eq } from "drizzle-orm"; +import { eq, like } from "drizzle-orm"; +import { slugifyHandle, withHandleSuffix } from "./ids"; import { accounts, organizations } from "./schema"; import type { DrizzleDb } from "./db"; export type Account = typeof accounts.$inferSelect; export type Organization = typeof organizations.$inferSelect; +/** + * Pick a unique org handle starting from `base`. Prefers `base`, then + * `base-2`, `base-3`, … This races safely enough for cloud's volume — + * the unique constraint catches truly concurrent collisions and the + * caller should surface that as a retryable error. + */ +export const pickFreeOrgHandle = async ( + db: DrizzleDb, + base: string, +): Promise => { + const existing = await db + .select({ handle: organizations.handle }) + .from(organizations) + .where(like(organizations.handle, `${base}%`)); + const taken = new Set(existing.map((r) => r.handle)); + if (!taken.has(base)) return base; + for (let n = 2; n < taken.size + 2; n++) { + const candidate = withHandleSuffix(base, n); + if (!taken.has(candidate)) return candidate; + } + // Defensive — in practice unreachable because the loop scans n up to + // taken.size + 2, which dominates any prefix-collision count. + throw new Error(`could not allocate free handle for base "${base}"`); +}; + export const makeUserStore = (db: DrizzleDb) => ({ // --- Accounts --- @@ -31,19 +57,36 @@ export const makeUserStore = (db: DrizzleDb) => ({ // --- Organizations --- upsertOrganization: async (org: { id: string; name: string }) => { - const [result] = await db + const existing = await db + .select() + .from(organizations) + .where(eq(organizations.id, org.id)); + if (existing[0]) { + const [updated] = await db + .update(organizations) + .set({ name: org.name }) + .where(eq(organizations.id, org.id)) + .returning(); + return updated!; + } + const handle = await pickFreeOrgHandle(db, slugifyHandle(org.name)); + const [inserted] = await db .insert(organizations) - .values(org) - .onConflictDoUpdate({ - target: organizations.id, - set: { name: org.name }, - }) + .values({ id: org.id, name: org.name, handle }) .returning(); - return result!; + return inserted!; }, getOrganization: async (id: string) => { const rows = await db.select().from(organizations).where(eq(organizations.id, id)); return rows[0] ?? null; }, + + getOrganizationByHandle: async (handle: string) => { + const rows = await db + .select() + .from(organizations) + .where(eq(organizations.handle, handle)); + return rows[0] ?? null; + }, }); diff --git a/apps/cloud/src/test-worker.ts b/apps/cloud/src/test-worker.ts index 37dbe55a4..5cfaf8091 100644 --- a/apps/cloud/src/test-worker.ts +++ b/apps/cloud/src/test-worker.ts @@ -29,7 +29,9 @@ import { mcpUnauthorized, } from "./mcp"; import { McpJwtVerificationError } from "./mcp-auth"; +import { slugifyHandle } from "./services/ids"; import { organizations } from "./services/schema"; +import { pickFreeOrgHandle } from "./services/user-store"; import { parseTestBearer } from "./test-bearer"; import { DoTelemetryLive } from "./services/telemetry"; @@ -90,9 +92,11 @@ const handleSeedOrg = async ( onnotice: () => undefined, }); try { - await drizzle(sql, { schema: { organizations } }) + const db = drizzle(sql, { schema: { organizations } }); + const handle = await pickFreeOrgHandle(db, slugifyHandle(body.name)); + await db .insert(organizations) - .values({ id: body.id, name: body.name }) + .values({ id: body.id, name: body.name, handle }) .onConflictDoUpdate({ target: organizations.id, set: { name: body.name },