diff --git a/apps/cloud/drizzle/0012_talented_surge.sql b/apps/cloud/drizzle/0012_talented_surge.sql new file mode 100644 index 000000000..022df47b9 --- /dev/null +++ b/apps/cloud/drizzle/0012_talented_surge.sql @@ -0,0 +1,15 @@ +CREATE TABLE "artifact" ( + "id" varchar(255) NOT NULL, + "title" text NOT NULL, + "description" text, + "code" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "definition" ALTER COLUMN "name" SET DATA TYPE varchar(255);--> statement-breakpoint +CREATE UNIQUE INDEX "artifact_uidx" ON "artifact" USING btree ("tenant","owner","subject","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0012_snapshot.json b/apps/cloud/drizzle/meta/0012_snapshot.json new file mode 100644 index 000000000..7d5a4e513 --- /dev/null +++ b/apps/cloud/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1403 @@ +{ + "id": "c0757240-8619-468a-adf6-39c86a38d641", + "prevId": "e285be7d-2427-4b1b-8b86-50a80ada25af", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "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 + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "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 + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "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 + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "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 + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "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 + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "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 + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 72f9cbb97..b67e2ced0 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1784581232732, "tag": "0011_tense_caretaker", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1785215550990, + "tag": "0012_talented_surge", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 350b0c18b..f74f0d738 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -210,6 +210,26 @@ export const tool_policy = pgTable( ], ); +export const artifact = pgTable( + "artifact", + { + id: varchar("id", { length: 255 }).notNull(), + title: text("title").notNull(), + description: text("description"), + code: text("code").notNull(), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull(), + row_id: varchar("row_id", { length: 255 }) + .primaryKey() + .notNull() + .$defaultFn(() => createId()), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), + }, + (table) => [uniqueIndex("artifact_uidx").on(table.tenant, table.owner, table.subject, table.id)], +); + export const plugin_storage = pgTable( "plugin_storage", { diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index f05f5234e..dc2025e8f 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -21,6 +21,7 @@ import type { DrizzleDb } from "./db"; import { makeUserStore } from "../auth/user-store"; import { memberships, accounts } from "./schema"; import { + artifact, blob, connection, definition, @@ -131,6 +132,17 @@ const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { subject: "s", }); + await db.insert(artifact).values({ + id: `art-${tag}`, + title: "Dashboard", + code: "export default function App() { return null; }", + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + const orgNs = `o:${tenant}/plugin`; const userNs = `u:${tenant}:subject/plugin`; await db.insert(blob).values({ @@ -156,6 +168,7 @@ const TENANT_TABLES = [ definition, tool_policy, plugin_storage, + artifact, ] as const; const countTenantRows = async (db: DrizzleDb, tenant: string): Promise => { diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index c86afd63e..f35e18cd4 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -16,6 +16,7 @@ import { eq, or, sql } from "drizzle-orm"; import type { DrizzleDb } from "./db"; import { organizations } from "./schema"; import { + artifact, blob, connection, definition, @@ -48,6 +49,7 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); + await tx.delete(artifact).where(eq(artifact.tenant, organizationId)); // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by // owner: `o:/` (org scope) and `u::/` diff --git a/packages/core/api/src/api.ts b/packages/core/api/src/api.ts index 22d531bc8..4bbe145e2 100644 --- a/packages/core/api/src/api.ts +++ b/packages/core/api/src/api.ts @@ -8,6 +8,7 @@ import { ProvidersApi } from "./providers/api"; import { ExecutionsApi } from "./executions/api"; import { OAuthApi } from "./oauth/api"; import { PoliciesApi } from "./policies/api"; +import { ArtifactsApi } from "./artifacts/api"; export const CoreExecutorApi = HttpApi.make("executor") .add(ToolsApi) @@ -17,6 +18,7 @@ export const CoreExecutorApi = HttpApi.make("executor") .add(ExecutionsApi) .add(OAuthApi) .add(PoliciesApi) + .add(ArtifactsApi) .annotateMerge( OpenApi.annotations({ title: "Executor API", diff --git a/packages/core/api/src/artifacts/api.ts b/packages/core/api/src/artifacts/api.ts new file mode 100644 index 000000000..9234f9018 --- /dev/null +++ b/packages/core/api/src/artifacts/api.ts @@ -0,0 +1,95 @@ +// --------------------------------------------------------------------------- +// Artifacts HTTP API — saved generative-UI components. +// +// An artifact is the JSX source a model produced plus the title/description an +// agent matches against. Owner-scoped: every endpoint reads and writes only +// what the bound owner scope may see, so no owner travels on the wire. +// --------------------------------------------------------------------------- + +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { ArtifactId, ArtifactNotFoundError, InternalError, Owner } from "@executor-js/sdk/shared"; + +// --------------------------------------------------------------------------- +// Params +// --------------------------------------------------------------------------- + +const ArtifactParams = { artifactId: ArtifactId }; + +// --------------------------------------------------------------------------- +// Response / payload schemas +// --------------------------------------------------------------------------- + +/** What a list returns — no `code`, so a long list stays cheap. */ +const ArtifactSummaryResponse = Schema.Struct({ + id: ArtifactId, + owner: Owner, + title: Schema.String, + description: Schema.NullOr(Schema.String), + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); + +const ArtifactResponse = Schema.Struct({ + ...ArtifactSummaryResponse.fields, + code: Schema.String, +}); + +/** Omit `id` to create; pass one to overwrite that artifact in place. */ +const SaveArtifactPayload = Schema.Struct({ + id: Schema.optional(ArtifactId), + title: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + code: Schema.String, +}); + +const RenameArtifactPayload = Schema.Struct({ + title: Schema.String, +}); + +// --------------------------------------------------------------------------- +// Error schemas with HTTP status annotations +// --------------------------------------------------------------------------- + +const ArtifactNotFound = ArtifactNotFoundError.annotate({ httpApiStatus: 404 }); + +// --------------------------------------------------------------------------- +// Group +// --------------------------------------------------------------------------- + +export const ArtifactsApi = HttpApiGroup.make("artifacts") + .add( + HttpApiEndpoint.get("list", "/artifacts", { + success: Schema.Array(ArtifactSummaryResponse), + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.get("get", "/artifacts/:artifactId", { + params: ArtifactParams, + success: ArtifactResponse, + error: [InternalError, ArtifactNotFound], + }), + ) + .add( + HttpApiEndpoint.post("save", "/artifacts", { + payload: SaveArtifactPayload, + success: ArtifactResponse, + error: [InternalError, ArtifactNotFound], + }), + ) + .add( + HttpApiEndpoint.patch("rename", "/artifacts/:artifactId", { + params: ArtifactParams, + payload: RenameArtifactPayload, + success: ArtifactResponse, + error: [InternalError, ArtifactNotFound], + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/artifacts/:artifactId", { + params: ArtifactParams, + success: Schema.Struct({ removed: Schema.Boolean }), + error: InternalError, + }), + ); diff --git a/packages/core/api/src/handlers/artifacts.ts b/packages/core/api/src/handlers/artifacts.ts new file mode 100644 index 000000000..983b15dfc --- /dev/null +++ b/packages/core/api/src/handlers/artifacts.ts @@ -0,0 +1,78 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; +import type { Artifact, ArtifactSummary } from "@executor-js/sdk"; + +import { ExecutorApi } from "../api"; +import { ExecutorService } from "../services"; +import { capture } from "@executor-js/api"; + +const summaryToResponse = (a: ArtifactSummary) => ({ + id: a.id, + owner: a.owner, + title: a.title, + description: a.description, + createdAt: a.createdAt.getTime(), + updatedAt: a.updatedAt.getTime(), +}); + +const artifactToResponse = (a: Artifact) => ({ + ...summaryToResponse(a), + code: a.code, +}); + +export const ArtifactsHandlers = HttpApiBuilder.group(ExecutorApi, "artifacts", (handlers) => + handlers + .handle("list", () => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const artifacts = yield* executor.artifacts.list(); + return artifacts.map(summaryToResponse); + }), + ), + ) + .handle("get", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const artifact = yield* executor.artifacts.get(path.artifactId); + return artifactToResponse(artifact); + }), + ), + ) + .handle("save", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const saved = yield* executor.artifacts.save({ + id: payload.id, + title: payload.title, + description: payload.description, + code: payload.code, + }); + return artifactToResponse(saved); + }), + ), + ) + .handle("rename", ({ params: path, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const renamed = yield* executor.artifacts.rename({ + id: path.artifactId, + title: payload.title, + }); + return artifactToResponse(renamed); + }), + ), + ) + .handle("remove", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.artifacts.remove({ id: path.artifactId }); + return { removed: true }; + }), + ), + ), +); diff --git a/packages/core/api/src/handlers/index.ts b/packages/core/api/src/handlers/index.ts index 6a21313bf..360952bd7 100644 --- a/packages/core/api/src/handlers/index.ts +++ b/packages/core/api/src/handlers/index.ts @@ -7,6 +7,7 @@ import { ProvidersHandlers } from "./providers"; import { ExecutionsHandlers } from "./executions"; import { OAuthHandlers } from "./oauth"; import { PoliciesHandlers } from "./policies"; +import { ArtifactsHandlers } from "./artifacts"; export { ToolsHandlers } from "./tools"; export { IntegrationsHandlers } from "./integrations"; @@ -15,6 +16,7 @@ export { ProvidersHandlers } from "./providers"; export { ExecutionsHandlers } from "./executions"; export { OAuthHandlers } from "./oauth"; export { PoliciesHandlers } from "./policies"; +export { ArtifactsHandlers } from "./artifacts"; export const CoreHandlers = Layer.mergeAll( ToolsHandlers, @@ -24,4 +26,5 @@ export const CoreHandlers = Layer.mergeAll( ExecutionsHandlers, OAuthHandlers, PoliciesHandlers, + ArtifactsHandlers, ); diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 344be92f3..652a9d503 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -34,6 +34,7 @@ export { type RunOAuthCallbackInput, } from "./oauth-popup"; export { PoliciesApi } from "./policies/api"; +export { ArtifactsApi } from "./artifacts/api"; export { AccountApi, AccountHttpApi, diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index a909a8130..c1e8c9a41 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -7,6 +7,7 @@ export { ProvidersHandlers, OAuthHandlers, PoliciesHandlers, + ArtifactsHandlers, ExecutionsHandlers, } from "./handlers"; export { diff --git a/packages/core/sdk/src/artifact-table.test.ts b/packages/core/sdk/src/artifact-table.test.ts new file mode 100644 index 000000000..0e4f52520 --- /dev/null +++ b/packages/core/sdk/src/artifact-table.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { withQueryContext } from "@executor-js/fumadb/query"; + +import { collectTables } from "./executor"; +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; + +// The artifact table is OWNER-scoped: a saved artifact belongs to the subject +// who generated it, and nobody else in the tenant may read it (org-tier +// sharing is a later change that reuses the `owner` column already here). +// Written against the real SQLite bring-up (`createSqliteTestFumaDb` runs the +// same statements the local/self-host/D1 hosts boot with) instead of asserting +// on the table definition object. + +const TENANT = "t1"; +const SUBJECT = "user_a"; +const OTHER_SUBJECT = "user_b"; + +const withDb = (body: (db: SqliteTestFumaDb) => Promise): Promise => + Effect.runPromise( + Effect.acquireUseRelease( + Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), + (db) => Effect.promise(() => body(db)), + (db) => Effect.promise(() => db.close()), + ), + ); + +const insertArtifact = ( + db: SqliteTestFumaDb, + row: { + readonly rowId: string; + readonly tenant: string; + readonly subject: string; + readonly id: string; + readonly title?: string; + }, +): Promise => + db.client.execute({ + sql: `INSERT INTO artifact (row_id, tenant, owner, subject, id, title, description, code, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + row.rowId, + row.tenant, + "user", + row.subject, + row.id, + row.title ?? "Dashboard", + null, + "export default function App() { return null; }", + Date.now(), + Date.now(), + ], + }); + +describe("artifact table", () => { + it.effect("is created by the runtime schema bring-up", () => + Effect.promise(() => + withDb(async (db) => { + const info = await db.client.execute("PRAGMA table_info('artifact')"); + const columns = info.rows.map((row) => String(row["name"])); + expect(columns).toEqual( + expect.arrayContaining([ + "row_id", + "tenant", + "owner", + "subject", + "id", + "title", + "description", + "code", + "created_at", + "updated_at", + ]), + ); + }), + ), + ); + + it.effect("keys one row per (tenant, owner, subject, id)", () => + Effect.promise(() => + withDb(async (db) => { + await insertArtifact(db, { rowId: "a1", tenant: TENANT, subject: SUBJECT, id: "art_1" }); + + // The same artifact id under a DIFFERENT subject is a different row — + // ids are only unique within one owner partition. + await insertArtifact(db, { + rowId: "a2", + tenant: TENANT, + subject: OTHER_SUBJECT, + id: "art_1", + }); + + await expect( + insertArtifact(db, { rowId: "a3", tenant: TENANT, subject: SUBJECT, id: "art_1" }), + ).rejects.toThrow(); + + const rows = await db.client.execute("SELECT row_id FROM artifact ORDER BY row_id"); + expect(rows.rows.map((row) => String(row["row_id"]))).toEqual(["a1", "a2"]); + }), + ), + ); + + it.effect("hides another subject's artifacts from a bound executor", () => + Effect.promise(() => + withDb(async (db) => { + await insertArtifact(db, { + rowId: "a1", + tenant: TENANT, + subject: SUBJECT, + id: "art_mine", + title: "Mine", + }); + await insertArtifact(db, { + rowId: "a2", + tenant: TENANT, + subject: OTHER_SUBJECT, + id: "art_theirs", + title: "Theirs", + }); + + const scoped = withQueryContext(db.db, { tenant: TENANT, subject: SUBJECT }); + const visible = await scoped.findMany("artifact", {}); + expect(visible.map((row) => row.id)).toEqual(["art_mine"]); + + const otherScoped = withQueryContext(db.db, { tenant: TENANT, subject: OTHER_SUBJECT }); + const otherVisible = await otherScoped.findMany("artifact", {}); + expect(otherVisible.map((row) => row.id)).toEqual(["art_theirs"]); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/artifact.ts b/packages/core/sdk/src/artifact.ts new file mode 100644 index 000000000..981d9837b --- /dev/null +++ b/packages/core/sdk/src/artifact.ts @@ -0,0 +1,63 @@ +// --------------------------------------------------------------------------- +// Artifacts — saved generative-UI components. Row → public projection plus the +// operation inputs; the executor stitches these into `executor.artifacts` and +// the core `artifacts` HTTP group. +// +// An artifact IS the stored JSX source plus the title/description an agent +// matches against ("show me my active users dashboard"). Owner-scoped like a +// connection: created at the `user` tier, readable through the same owner +// policy, so org-tier sharing later needs no new machinery. +// --------------------------------------------------------------------------- + +import type { ArtifactRow, ArtifactSummaryRow } from "./core-schema"; +import { ArtifactId, type Owner } from "./ids"; + +export interface Artifact { + readonly id: ArtifactId; + readonly owner: Owner; + readonly title: string; + /** Model-supplied prose used for agent matching. Null when none was given. */ + readonly description: string | null; + /** The JSX source. Only the full read carries it — lists stay light. */ + readonly code: string; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +/** What a list returns: everything except the (potentially large) source. */ +export type ArtifactSummary = Omit; + +/** Create a new artifact, or overwrite an existing one in place when `id` names + * one. v1 has no version history: a save replaces the stored source. */ +export interface SaveArtifactInput { + readonly id?: string; + readonly title: string; + readonly description?: string | null; + readonly code: string; +} + +export interface RenameArtifactInput { + readonly id: string; + readonly title: string; +} + +export interface RemoveArtifactInput { + readonly id: string; +} + +const asDate = (value: Date | number | string): Date => + value instanceof Date ? value : new Date(value); + +export const rowToArtifactSummary = (row: ArtifactSummaryRow): ArtifactSummary => ({ + id: ArtifactId.make(row.id), + owner: row.owner as Owner, + title: row.title, + description: row.description ?? null, + createdAt: asDate(row.created_at), + updatedAt: asDate(row.updated_at), +}); + +export const rowToArtifact = (row: ArtifactRow): Artifact => ({ + ...rowToArtifactSummary(row), + code: row.code, +}); diff --git a/packages/core/sdk/src/artifacts.test.ts b/packages/core/sdk/src/artifacts.test.ts new file mode 100644 index 000000000..11208fc04 --- /dev/null +++ b/packages/core/sdk/src/artifacts.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate, Result } from "effect"; + +import { makeTestExecutor } from "./testing"; + +// The `executor.artifacts` surface against the real SQLite test db. Artifacts +// are owner-scoped rows with no plugin involvement, so the default test +// executor (one tenant + one bound subject) is the whole fixture. + +const CODE = "export default function App() { return
hi
; }"; + +describe("executor.artifacts", () => { + it.effect("list is empty when nothing is saved", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + expect(yield* executor.artifacts.list()).toEqual([]); + }), + ); + + it.effect("save mints an id and stores the source", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ + title: "Active users", + description: "Daily active users over time", + code: CODE, + }); + expect(saved.id).not.toBe(""); + expect(saved.owner).toBe("user"); + expect(saved.title).toBe("Active users"); + expect(saved.description).toBe("Daily active users over time"); + expect(saved.code).toBe(CODE); + + const fetched = yield* executor.artifacts.get(saved.id); + expect(fetched).toEqual(saved); + }), + ); + + it.effect("save defaults a missing description to null", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ title: "Untitled", code: CODE }); + expect(saved.description).toBe(null); + }), + ); + + it.effect("list omits the source", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ + title: "Active users", + description: "described", + code: CODE, + }); + + const listed = yield* executor.artifacts.list(); + // The list projection is deliberately code-free: a saved component can be + // large and a picker only needs title/description to match on. + expect(listed).toEqual([ + { + id: saved.id, + owner: "user", + title: "Active users", + description: "described", + createdAt: saved.createdAt, + updatedAt: saved.updatedAt, + }, + ]); + }), + ); + + // `it.live` (real clock) with a full-second gap: `dateColumn` lands in SQLite + // as a SECOND-resolution integer, so two artifacts saved inside one second + // carry the same `updated_at` and only the id tiebreak separates them. This + // pins the ordering the picker actually depends on, at the resolution the + // storage layer really has. + it.live("list returns the most recently updated artifact first", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const first = yield* executor.artifacts.save({ title: "First", code: CODE }); + yield* Effect.sleep("1100 millis"); + const second = yield* executor.artifacts.save({ title: "Second", code: CODE }); + expect(second.updatedAt.getTime()).toBeGreaterThan(first.updatedAt.getTime()); + + expect((yield* executor.artifacts.list()).map((a) => a.id)).toEqual([second.id, first.id]); + + // Touching the OLDER artifact floats it back to the top — the list is + // keyed on `updated_at`, not on creation order. + yield* Effect.sleep("1100 millis"); + yield* executor.artifacts.rename({ id: first.id, title: "First, touched" }); + expect((yield* executor.artifacts.list()).map((a) => a.id)).toEqual([first.id, second.id]); + }), + ); + + it.effect("save with an id overwrites in place", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ title: "Draft", code: CODE }); + const updated = yield* executor.artifacts.save({ + id: saved.id, + title: "Final", + description: "now described", + code: "export default function App() { return null; }", + }); + + expect(updated.id).toBe(saved.id); + expect(updated.title).toBe("Final"); + expect(updated.description).toBe("now described"); + expect(updated.code).toBe("export default function App() { return null; }"); + expect(updated.createdAt.getTime()).toBe(saved.createdAt.getTime()); + + // Overwrite, not append: still exactly one row. + const listed = yield* executor.artifacts.list(); + expect(listed.map((a) => a.id)).toEqual([saved.id]); + }), + ); + + it.effect("save fails for an id that names no visible artifact", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const result = yield* Effect.result( + executor.artifacts.save({ id: "art_missing", title: "Ghost", code: CODE }), + ); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ArtifactNotFoundError")(result.failure)).toBe(true); + }), + ); + + it.effect("get fails for an unknown id", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const result = yield* Effect.result(executor.artifacts.get("art_missing")); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ArtifactNotFoundError")(result.failure)).toBe(true); + }), + ); + + it.effect("rename changes only the title", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ + title: "Old name", + description: "kept", + code: CODE, + }); + const renamed = yield* executor.artifacts.rename({ id: saved.id, title: "New name" }); + expect(renamed.title).toBe("New name"); + expect(renamed.description).toBe("kept"); + expect(renamed.code).toBe(CODE); + }), + ); + + it.effect("rename fails for an unknown id", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const result = yield* Effect.result( + executor.artifacts.rename({ id: "art_missing", title: "Nope" }), + ); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ArtifactNotFoundError")(result.failure)).toBe(true); + }), + ); + + it.effect("remove hard-deletes the artifact", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + const saved = yield* executor.artifacts.save({ title: "Temp", code: CODE }); + yield* executor.artifacts.remove({ id: saved.id }); + + expect(yield* executor.artifacts.list()).toEqual([]); + const result = yield* Effect.result(executor.artifacts.get(saved.id)); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ArtifactNotFoundError")(result.failure)).toBe(true); + }), + ); + + it.effect("remove is idempotent", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor(); + yield* executor.artifacts.remove({ id: "art_missing" }); + }), + ); + + it.effect("an executor with no subject cannot save a user artifact", () => + Effect.gen(function* () { + // Pure-org executors (host request paths with no member bound) have no + // user partition to file a personal artifact into. + const executor = yield* makeTestExecutor({ subject: null }); + const result = yield* Effect.result(executor.artifacts.save({ title: "Orphan", code: CODE })); + expect(Result.isFailure(result)).toBe(true); + }), + ); +}); diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index e472ed9bb..98123eae8 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -311,6 +311,27 @@ export const coreTables = defineTables({ ["tenant", "owner", "subject", "id"], ), + // A saved generative-UI artifact — the JSX source a model produced, kept so + // it can be re-rendered later and matched by title/description from any MCP + // client. Owner-scoped like every other personal row: artifacts are created + // at the `user` tier, and the `org` tier the table already carries is what + // sharing will use later without new machinery. + artifact: ownedExecutorTable( + "artifact", + { + id: keyColumn("id"), + title: textColumn("title"), + // Model-supplied prose the agent matches against ("my active users + // dashboard"). Nullable: the title alone is enough to save one. + description: nullableTextColumn("description"), + // The JSX source. Free-form TEXT, never indexed. + code: textColumn("code"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "owner", "subject", "id"], + ), + // Host-owned plugin storage (shared `plugin_storage` table, owner-scoped). plugin_storage: ownedExecutorTable( "plugin_storage", @@ -363,6 +384,19 @@ export const TOOL_INVOCATION_COLUMNS = [ ] as const satisfies readonly (keyof ToolRow)[]; export type DefinitionRow = FumaRow; export type ToolPolicyRow = FumaRow; +export type ArtifactRow = FumaRow; +/** The columns a list projects — everything except the JSX source, which only + * a full read needs. */ +export const ARTIFACT_SUMMARY_COLUMNS = [ + "owner", + "id", + "title", + "description", + "created_at", + "updated_at", +] as const satisfies readonly (keyof ArtifactRow)[]; +/** The artifact-row projection {@link ARTIFACT_SUMMARY_COLUMNS} selects. */ +export type ArtifactSummaryRow = Pick; export type PluginStorageRow = FumaRow; export type BlobRow = FumaRow; diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 7c5223cc2..8136ae4cf 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -2,7 +2,14 @@ import { Schema } from "effect"; import { ElicitationDeclinedError } from "./elicitation"; import type { StorageFailure } from "./fuma-runtime"; -import { ConnectionName, IntegrationSlug, Owner, ProviderKey, ToolAddress } from "./ids"; +import { + ArtifactId, + ConnectionName, + IntegrationSlug, + Owner, + ProviderKey, + ToolAddress, +} from "./ids"; export interface UserActionableError { readonly __executorUserActionable: true; @@ -184,6 +191,21 @@ export class CredentialResolutionError extends Schema.TaggedErrorClass()( + "ArtifactNotFoundError", + { id: ArtifactId }, +) { + override get message(): string { + return `Artifact not found: ${this.id}`; + } +} + // --------------------------------------------------------------------------- // Union — the failure channel of `execute`. // --------------------------------------------------------------------------- @@ -204,4 +226,5 @@ export type ExecuteError = export type ExecutorError = | ExecuteError | IntegrationNotFoundError - | IntegrationRemovalNotAllowedError; + | IntegrationRemovalNotAllowedError + | ArtifactNotFoundError; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 42dee8cd1..a493a5c6b 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -28,6 +28,7 @@ import type { import { HealthCheckResult, HealthCheckSpec } from "./health-check"; import type { HealthCheckCandidate } from "./health-check"; import { + ARTIFACT_SUMMARY_COLUMNS, coreSchema, isToolPolicyAction, TOOL_INVOCATION_COLUMNS, @@ -51,6 +52,16 @@ import { export type { OnElicitation, InvokeOptions } from "./elicitation"; import { + rowToArtifact, + rowToArtifactSummary, + type Artifact, + type ArtifactSummary, + type RemoveArtifactInput, + type RenameArtifactInput, + type SaveArtifactInput, +} from "./artifact"; +import { + ArtifactNotFoundError, ConnectionNotFoundError, CredentialProviderNotRegisteredError, CredentialResolutionError, @@ -65,6 +76,7 @@ import { type ExecuteError, } from "./errors"; import { + ArtifactId, AuthTemplateSlug, ConnectionAddress, ConnectionName, @@ -367,6 +379,21 @@ export type Executor = { readonly resolve: (address: ToolAddress) => Effect.Effect; }; + /** Saved generative-UI artifacts, visible to the bound owner scope. */ + readonly artifacts: { + /** Newest first, without the JSX source — lists stay light. */ + readonly list: () => Effect.Effect; + readonly get: (id: string) => Effect.Effect; + /** Create, or overwrite an existing artifact in place when `id` is given. */ + readonly save: ( + input: SaveArtifactInput, + ) => Effect.Effect; + readonly rename: ( + input: RenameArtifactInput, + ) => Effect.Effect; + readonly remove: (input: RemoveArtifactInput) => Effect.Effect; + }; + readonly execute: ( address: ToolAddress, args: unknown, @@ -3519,6 +3546,105 @@ export const createExecutor = + (b: AnyCb) => + b("id", "=", id); + + const artifactsList = (): Effect.Effect => + core + .findMany("artifact", { + // Newest first. `id` breaks ties so two artifacts sharing an + // `updated_at` millisecond list in a repeatable order rather than + // whatever the storage engine happens to return; which of the two + // comes first is arbitrary, only its stability is guaranteed. + orderBy: [ + ["updated_at", "desc"], + ["id", "desc"], + ], + select: ARTIFACT_SUMMARY_COLUMNS, + }) + .pipe(Effect.map((rows) => rows.map(rowToArtifactSummary))); + + const artifactsGet = ( + id: string, + ): Effect.Effect => + Effect.gen(function* () { + const row = yield* core.findFirst("artifact", { where: artifactById(id) }); + if (!row) return yield* new ArtifactNotFoundError({ id: ArtifactId.make(id) }); + return rowToArtifact(row); + }); + + const artifactsSave = ( + input: SaveArtifactInput, + ): Effect.Effect => + Effect.gen(function* () { + const now = new Date(); + const description = input.description ?? null; + // An explicit id overwrites in place (v1 keeps no version history). It + // must already resolve to a visible row: minting a caller-chosen id + // would let a stale client resurrect a deleted artifact silently. + if (input.id !== undefined) { + const where = artifactById(input.id); + const existing = yield* core.findFirst("artifact", { where }); + if (!existing) { + return yield* new ArtifactNotFoundError({ id: ArtifactId.make(input.id) }); + } + const set = { + title: input.title, + description, + code: input.code, + updated_at: now, + }; + yield* core.updateMany("artifact", { where, set }); + return rowToArtifact({ ...existing, ...set }); + } + yield* requireUserSubject("user"); + const keys = yield* Effect.try({ + try: () => ownedKeys("user"), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), + }); + const created = yield* core.create("artifact", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: `art_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, + title: input.title, + description, + code: input.code, + created_at: now, + updated_at: now, + }); + return rowToArtifact(created); + }); + + const artifactsRename = ( + input: RenameArtifactInput, + ): Effect.Effect => + Effect.gen(function* () { + const where = artifactById(input.id); + const existing = yield* core.findFirst("artifact", { where }); + if (!existing) { + return yield* new ArtifactNotFoundError({ id: ArtifactId.make(input.id) }); + } + const set = { title: input.title, updated_at: new Date() }; + yield* core.updateMany("artifact", { where, set }); + return rowToArtifact({ ...existing, ...set }); + }); + + // Hard delete: an artifact is not a connection, so there is no disabled or + // archived state to fall back to. + const artifactsRemove = (input: RemoveArtifactInput): Effect.Effect => + core.deleteMany("artifact", { where: artifactById(input.id) }); + // ------------------------------------------------------------------ // Elicitation // ------------------------------------------------------------------ @@ -4116,6 +4242,13 @@ export const createExecutor =