diff --git a/.oxfmtrc.json b/.oxfmtrc.json index a1b79de7d..5ae9d84b2 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -7,9 +7,11 @@ "dist", "integrationsdotsh", "node_modules", + "packages/core/fumadb", "bun.lock", "*.tsbuildinfo", "executor-*.tgz", - "**/routeTree.gen.ts" + "**/routeTree.gen.ts", + "apps/cloud/src/services/executor-schema.ts" ] } diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 800603b09..a8864f6af 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -118,6 +118,7 @@ "dist/", "integrationsdotsh/", "node_modules/", + "packages/core/fumadb/", "bun.lock", "*.tsbuildinfo", "executor-*.tgz", diff --git a/apps/cloud/drizzle.config.ts b/apps/cloud/drizzle.config.ts index a6e463121..6b8a2463b 100644 --- a/apps/cloud/drizzle.config.ts +++ b/apps/cloud/drizzle.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from "drizzle-kit"; // migrate) ignores it. Default to the local PGlite socket started by // `bun run dev:db`; override via `DATABASE_URL` for prod studio sessions. // drizzle-kit uses node-postgres (`pg`) for studio and the `ssl` option in -// dbCredentials doesn't reliably reach the pool — append `sslmode=require` +// dbCredentials doesn't reliably reach the pool - append `sslmode=require` // directly to the URL instead, which `pg` honours. const DEFAULT_DEV_URL = "postgresql://postgres:postgres@127.0.0.1:5433/postgres"; diff --git a/apps/cloud/drizzle/0016_fumadb_cutover.sql b/apps/cloud/drizzle/0016_fumadb_cutover.sql new file mode 100644 index 000000000..ce2d089b0 --- /dev/null +++ b/apps/cloud/drizzle/0016_fumadb_cutover.sql @@ -0,0 +1,114 @@ +CREATE TABLE IF NOT EXISTS "private_executor_cloud_settings" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "version" varchar(255) DEFAULT '1.0.0' NOT NULL +); +--> statement-breakpoint +INSERT INTO "private_executor_cloud_settings" ("id", "version") +VALUES ('default', '1.0.0') +ON CONFLICT ("id") DO UPDATE SET "version" = excluded."version"; +--> statement-breakpoint +ALTER TABLE "credential_binding" ADD COLUMN IF NOT EXISTS "secret_scope_id" text; +--> statement-breakpoint +ALTER TABLE "blob" ADD COLUMN IF NOT EXISTS "row_id" varchar(255); +--> statement-breakpoint +ALTER TABLE "blob" ADD COLUMN IF NOT EXISTS "id" varchar(255); +--> statement-breakpoint +UPDATE "blob" +SET + "id" = COALESCE("id", '[' || to_json("namespace")::text || ',' || to_json("key")::text || ']'), + "row_id" = COALESCE("row_id", 'legacy_' || md5("namespace" || chr(31) || "key")) +WHERE "id" IS NULL OR "row_id" IS NULL; +--> statement-breakpoint +ALTER TABLE "blob" ALTER COLUMN "id" SET NOT NULL; +--> statement-breakpoint +ALTER TABLE "blob" ALTER COLUMN "row_id" SET NOT NULL; +--> statement-breakpoint +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'public.blob'::regclass + AND conname = 'blob_namespace_key_pk' + ) THEN + ALTER TABLE "blob" DROP CONSTRAINT "blob_namespace_key_pk"; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'public.blob'::regclass + AND conname = 'blob_pkey' + ) THEN + ALTER TABLE "blob" ADD CONSTRAINT "blob_pkey" PRIMARY KEY ("row_id"); + END IF; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "blob_id_uidx" ON "blob" USING btree ("id"); +--> statement-breakpoint +DO $$ +DECLARE + table_name text; + legacy_pk_name text; + new_pk_name text; + new_unique_name text; +BEGIN + FOREACH table_name IN ARRAY ARRAY[ + 'connection', + 'credential_binding', + 'definition', + 'graphql_operation', + 'graphql_source', + 'graphql_source_header', + 'graphql_source_query_param', + 'mcp_binding', + 'mcp_source', + 'mcp_source_header', + 'mcp_source_query_param', + 'oauth2_session', + 'openapi_operation', + 'openapi_source', + 'openapi_source_header', + 'openapi_source_query_param', + 'openapi_source_spec_fetch_header', + 'openapi_source_spec_fetch_query_param', + 'secret', + 'source', + 'tool', + 'tool_policy', + 'workos_vault_metadata' + ] + LOOP + legacy_pk_name := table_name || '_scope_id_id_pk'; + new_pk_name := table_name || '_pkey'; + new_unique_name := table_name || '_scope_id_id_uidx'; + + EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS "row_id" varchar(255)', table_name); + EXECUTE format( + 'UPDATE %I SET "row_id" = COALESCE("row_id", %L || md5("scope_id" || chr(31) || "id")) WHERE "row_id" IS NULL', + table_name, + 'legacy_' + ); + EXECUTE format('ALTER TABLE %I ALTER COLUMN "row_id" SET NOT NULL', table_name); + + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = format('public.%I', table_name)::regclass + AND conname = legacy_pk_name + ) THEN + EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', table_name, legacy_pk_name); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = format('public.%I', table_name)::regclass + AND conname = new_pk_name + ) THEN + EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I PRIMARY KEY ("row_id")', table_name, new_pk_name); + END IF; + + EXECUTE format( + 'CREATE UNIQUE INDEX IF NOT EXISTS %I ON %I USING btree ("scope_id", "id")', + new_unique_name, + table_name + ); + END LOOP; +END $$; diff --git a/apps/cloud/drizzle/meta/0016_snapshot.json b/apps/cloud/drizzle/meta/0016_snapshot.json new file mode 100644 index 000000000..9642ba3bf --- /dev/null +++ b/apps/cloud/drizzle/meta/0016_snapshot.json @@ -0,0 +1,2258 @@ +{ + "id": "77c36b81-283e-4a48-989b-a12cce8d0651", + "prevId": "3b53ed57-f0b1-40a4-9929-dd399180a17a", + "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 + }, + "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.blob": { + "name": "blob", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "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": { + "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": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "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": "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 + } + }, + "indexes": { + "connection_scope_id_id_uidx": { + "name": "connection_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.credential_binding": { + "name": "credential_binding", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "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 + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_id": { + "name": "secret_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope_id": { + "name": "secret_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "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": { + "credential_binding_scope_id_id_uidx": { + "name": "credential_binding_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.definition": { + "name": "definition", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "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": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_scope_id_id_uidx": { + "name": "definition_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.graphql_operation": { + "name": "graphql_operation", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "json", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "graphql_operation_scope_id_id_uidx": { + "name": "graphql_operation_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.graphql_source": { + "name": "graphql_source", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "auth_connection_slot": { + "name": "auth_connection_slot", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "graphql_source_scope_id_id_uidx": { + "name": "graphql_source_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.graphql_source_header": { + "name": "graphql_source_header", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "graphql_source_header_scope_id_id_uidx": { + "name": "graphql_source_header_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.graphql_source_query_param": { + "name": "graphql_source_query_param", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "graphql_source_query_param_scope_id_id_uidx": { + "name": "graphql_source_query_param_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.mcp_binding": { + "name": "mcp_binding", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_binding_scope_id_id_uidx": { + "name": "mcp_binding_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.mcp_source": { + "name": "mcp_source", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "auth_header_name": { + "name": "auth_header_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_header_slot": { + "name": "auth_header_slot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_header_prefix": { + "name": "auth_header_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_connection_slot": { + "name": "auth_connection_slot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_client_id_slot": { + "name": "auth_client_id_slot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_client_secret_slot": { + "name": "auth_client_secret_slot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_source_scope_id_id_uidx": { + "name": "mcp_source_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.mcp_source_header": { + "name": "mcp_source_header", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcp_source_header_scope_id_id_uidx": { + "name": "mcp_source_header_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.mcp_source_query_param": { + "name": "mcp_source_query_param", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcp_source_query_param_scope_id_id_uidx": { + "name": "mcp_source_query_param_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.oauth2_session": { + "name": "oauth2_session", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "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": "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 + } + }, + "indexes": { + "oauth2_session_scope_id_id_uidx": { + "name": "oauth2_session_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.openapi_operation": { + "name": "openapi_operation", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "json", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_operation_scope_id_id_uidx": { + "name": "openapi_operation_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.openapi_source": { + "name": "openapi_source", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "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 + }, + "oauth2": { + "name": "oauth2", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "openapi_source_scope_id_id_uidx": { + "name": "openapi_source_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.openapi_source_header": { + "name": "openapi_source_header", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "openapi_source_header_scope_id_id_uidx": { + "name": "openapi_source_header_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.openapi_source_query_param": { + "name": "openapi_source_query_param", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "openapi_source_query_param_scope_id_id_uidx": { + "name": "openapi_source_query_param_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.openapi_source_spec_fetch_header": { + "name": "openapi_source_spec_fetch_header", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "openapi_source_spec_fetch_header_scope_id_id_uidx": { + "name": "openapi_source_spec_fetch_header_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.openapi_source_spec_fetch_query_param": { + "name": "openapi_source_spec_fetch_query_param", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slot_key": { + "name": "slot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "openapi_source_spec_fetch_query_param_scope_id_id_uidx": { + "name": "openapi_source_spec_fetch_query_param_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.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.secret": { + "name": "secret", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "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_id_uidx": { + "name": "secret_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.source": { + "name": "source", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "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_id_uidx": { + "name": "source_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.tool": { + "name": "tool", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "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": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "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 + } + }, + "indexes": { + "tool_scope_id_id_uidx": { + "name": "tool_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_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 + } + }, + "indexes": { + "tool_policy_scope_id_id_uidx": { + "name": "tool_policy_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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.workos_vault_metadata": { + "name": "workos_vault_metadata", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(255)", + "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_id_uidx": { + "name": "workos_vault_metadata_scope_id_id_uidx", + "columns": [ + { + "expression": "scope_id", + "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 1653f238f..2a55c3b59 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1778192434063, "tag": "0015_add_credential_binding_secret_scope", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1778781460169, + "tag": "0016_fumadb_cutover", + "breakpoints": true } ] } diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 0f04149d9..21d903cba 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -8,14 +8,14 @@ import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-w // Single source of truth for the cloud app's plugin list. // // Consumed by: -// - the schema-gen CLI (reads `plugin.schema` only; calls `plugins({})`) +// - FumaDB schema wiring (calls `plugins({})`) // - the host runtime (calls `plugins({ workosCredentials })` per request) // - the test harness (calls `plugins({ workosVaultClient })` per test) // // `TDeps` is inferred directly from the factory parameter annotation — // no global `declare module "@executor-js/sdk"` augmentation. Each -// caller (runtime / CLI / tests) passes whatever subset of the deps it -// has; all fields are optional so the CLI's `plugins({})` keeps working. +// caller (runtime / schema wiring / tests) passes whatever subset of the deps +// it has; all fields are optional so `plugins({})` keeps working. // // Cloud only ships plugins safe to run in a multi-tenant setting — no // stdio MCP, no keychain/file-secrets/1password/google-discovery. @@ -36,7 +36,6 @@ interface CloudPluginDeps { } export default defineExecutorConfig({ - dialect: "pg", plugins: ({ workosCredentials, workosVaultClient }: CloudPluginDeps = {}) => [ openApiHttpPlugin(), diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 939a96cff..f67e6ac21 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -8,7 +8,7 @@ "dev:proxy": "portless proxy start --multiplex --shared-port --port 5394 || (portless proxy stop -p 5394 && portless proxy start --multiplex --shared-port --port 5394)", "dev:db": "bun run scripts/dev-db.ts", "dev:vite": "EXECUTOR_DIRECT_DATABASE_URL=true CLOUDFLARE_INCLUDE_PROCESS_ENV=true op run --env-file=.env.op -- portless --name executor-cloud vite dev", - "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts generate --config ./executor.config.ts --output ./src/services/executor-schema.ts", + "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts schema generate --config ./executor.config.ts --output ./src/services/executor-schema.ts --namespace executor_cloud --adapter drizzle --provider postgresql", "db:generate": "drizzle-kit generate", "db:studio": "drizzle-kit studio", "db:studio:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs studio", @@ -39,8 +39,6 @@ "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", - "@executor-js/storage-postgres": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "~1.9.0", @@ -60,6 +58,7 @@ "autumn-js": "^1.2.8", "drizzle-orm": "catalog:", "effect": "catalog:", + "fumadb": "workspace:*", "jose": "^5.6.3", "postgres": "^3.4.9", "posthog-js": "^1.372.5", @@ -72,8 +71,8 @@ "@cloudflare/workers-types": "^4.20250620.0", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", - "@electric-sql/pglite": "^0.4.3", - "@electric-sql/pglite-socket": "^0.1.3", + "@electric-sql/pglite": "^0.4.4", + "@electric-sql/pglite-socket": "^0.1.4", "@executor-js/cli": "workspace:*", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", diff --git a/apps/cloud/scripts/dev-db.ts b/apps/cloud/scripts/dev-db.ts index bd4622cf6..5da4cea86 100644 --- a/apps/cloud/scripts/dev-db.ts +++ b/apps/cloud/scripts/dev-db.ts @@ -4,16 +4,17 @@ // // Exposes an in-process PGlite instance over a TCP socket so Hyperdrive's // localConnectionString can connect to it like a real Postgres server. -// Runs drizzle migrations on startup so the schema is ready. +// Runs Drizzle migrations on startup so the schema matches cloud production. -import { PGlite } from "@electric-sql/pglite"; -import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; import { execSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; import { setTimeout as sleep } from "node:timers/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; import { drizzle } from "drizzle-orm/pglite"; import { migrate } from "drizzle-orm/pglite/migrator"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = 5433; @@ -50,6 +51,27 @@ if (reapStaleDevDb()) { await sleep(200); } +async function hasDrizzleMigrationHistory(path: string): Promise { + if (!existsSync(path)) return true; + + const db = await PGlite.create(path); + const result = await db.query<{ exists: boolean }>(` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'drizzle' + AND table_name = '__drizzle_migrations' + ) AS "exists" + `); + await db.close(); + return result.rows[0]?.exists === true; +} + +if (!(await hasDrizzleMigrationHistory(DB_PATH))) { + console.log("[dev-db] Resetting dev database without Drizzle migration history"); + rmSync(DB_PATH, { recursive: true, force: true }); +} + console.log(`[dev-db] Starting PGlite at ${DB_PATH}`); const db = await PGlite.create(DB_PATH); diff --git a/apps/cloud/scripts/test-globalsetup.ts b/apps/cloud/scripts/test-globalsetup.ts index ddcbb2703..1568e0f88 100644 --- a/apps/cloud/scripts/test-globalsetup.ts +++ b/apps/cloud/scripts/test-globalsetup.ts @@ -8,10 +8,11 @@ import { PGlite } from "@electric-sql/pglite"; import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; import { drizzle } from "drizzle-orm/pglite"; import { migrate } from "drizzle-orm/pglite/migrator"; -import { resolve, dirname } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); + const PORT = 5434; const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); @@ -24,6 +25,7 @@ export default async function setup() { server = new PGLiteSocketServer({ db, port: PORT, host: "127.0.0.1" }); await server.start(); + // eslint-disable-next-line no-console console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`); diff --git a/apps/cloud/src/api.request-scope.node.test.ts b/apps/cloud/src/api.request-scope.node.test.ts index d87a8e336..55f6ddba5 100644 --- a/apps/cloud/src/api.request-scope.node.test.ts +++ b/apps/cloud/src/api.request-scope.node.test.ts @@ -8,7 +8,7 @@ // `Writable` I/O object) is opened in request 1's context and reused by // request 2, which the runtime forbids: // -// StorageError: [storage-drizzle] findMany select failed: +// StorageError: FumaDB findMany select failed: // Cannot perform I/O on behalf of a different request. (I/O type: Writable) // // The only primitive that actually rebuilds per request is a custom diff --git a/apps/cloud/src/mcp-session.e2e.node.test.ts b/apps/cloud/src/mcp-session.e2e.node.test.ts index f6a9c52ff..c3552d318 100644 --- a/apps/cloud/src/mcp-session.e2e.node.test.ts +++ b/apps/cloud/src/mcp-session.e2e.node.test.ts @@ -2,7 +2,7 @@ // // The `McpSessionDO` in mcp-session.ts wires several things that previously // had zero integration coverage: -// - `createScopedExecutor` against a real drizzle adapter (the 2026-04-16 +// - `createScopedExecutor` against a real FumaDB/Drizzle handle (the 2026-04-16 // prod outage was a schema spread bug here; see services/db.schema.test.ts) // - `createExecutionEngine` with an in-process code executor // - `createExecutorMcpServer` for the MCP request surface @@ -29,15 +29,15 @@ import { FormElicitation, Scope, ScopeId, - collectSchemas, + collectTables, createExecutor, definePlugin, } from "@executor-js/sdk"; import { FetchHttpClient } from "effect/unstable/http"; -import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres"; import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; import executorConfig from "../executor.config"; import { DbService } from "./services/db"; +import { createDrizzleFumaDb } from "./services/fuma"; // --------------------------------------------------------------------------- // Test-only plugin: exposes one in-memory tool that elicits once. Lets the @@ -103,9 +103,12 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO const plugins = options.withElicitingPlugin ? ([...basePlugins, elicitingTestPlugin()] as const) : basePlugins; - const schema = collectSchemas(plugins); - const adapter = makePostgresAdapter({ db, schema }); - const blobs = makePostgresBlobStore({ db }); + const fuma = createDrizzleFumaDb({ + db, + tables: collectTables(plugins), + namespace: "executor_cloud", + provider: "postgresql", + }); const scope = Scope.make({ id: ScopeId.make(scopeId), name: scopeName, @@ -113,8 +116,7 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO }); return yield* createExecutor({ scopes: [scope], - adapter, - blobs, + db: fuma.db, plugins, httpClientLayer: FetchHttpClient.layer, onElicitation: "accept-all", diff --git a/apps/cloud/src/mcp-session.ts b/apps/cloud/src/mcp-session.ts index 44d2837d8..2759e39d8 100644 --- a/apps/cloud/src/mcp-session.ts +++ b/apps/cloud/src/mcp-session.ts @@ -10,7 +10,7 @@ import type * as Tracer from "effect/Tracer"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { TransportState } from "agents/mcp"; import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; +import postgres, { type Sql } from "postgres"; import { createExecutorMcpServer } from "@executor-js/host-mcp"; import { buildExecuteDescription } from "@executor-js/execution"; @@ -114,7 +114,7 @@ const withIncomingParent = ( return parsed ? OtelTracer.withSpanContext(effect, parsed) : effect; }; -type DbHandle = DbServiceShape & { end: () => Promise }; +type DbHandle = DbServiceShape & { readonly sql: Sql; end: () => Promise }; type SessionMeta = { readonly organizationId: string; readonly organizationName: string; diff --git a/apps/cloud/src/secrets-isolation.e2e.node.test.ts b/apps/cloud/src/secrets-isolation.e2e.node.test.ts index bf1169b5f..8bb48326e 100644 --- a/apps/cloud/src/secrets-isolation.e2e.node.test.ts +++ b/apps/cloud/src/secrets-isolation.e2e.node.test.ts @@ -5,7 +5,7 @@ // the cloud app actually ships: `[userOrgScope, orgScope]`. The harness // builds the same shape `apps/cloud/src/services/executor.ts#createScopedExecutor` // builds in production, and every request goes through `HttpApiClient` → -// `fetch` → the real `ProtectedCloudApi` → real postgres adapter. +// `fetch` → the real `ProtectedCloudApi` → the real Drizzle/FumaDB path. // // Invariants the product is staking on: // diff --git a/apps/cloud/src/services/__test-harness__/api-harness.ts b/apps/cloud/src/services/__test-harness__/api-harness.ts index 9d0a47c46..213d6abf5 100644 --- a/apps/cloud/src/services/__test-harness__/api-harness.ts +++ b/apps/cloud/src/services/__test-harness__/api-harness.ts @@ -26,8 +26,7 @@ import { } from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; -import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk"; -import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres"; +import { Scope, ScopeId, collectTables, createExecutor } from "@executor-js/sdk"; import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; import executorConfig from "../../../executor.config"; @@ -38,6 +37,7 @@ import { RouterConfig, } from "../../api/protected-layers"; import { DbService } from "../db"; +import { createDrizzleFumaDb } from "../fuma"; export const TEST_BASE_URL = "http://test.local"; export const TEST_ORG_HEADER = "x-test-org-id"; @@ -70,9 +70,12 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string Effect.gen(function* () { const { db } = yield* DbService; const plugins = testPlugins; - const schema = collectSchemas(plugins); - const adapter = makePostgresAdapter({ db, schema }); - const blobs = makePostgresBlobStore({ db }); + const fuma = createDrizzleFumaDb({ + db, + tables: collectTables(plugins), + namespace: "executor_cloud", + provider: "postgresql", + }); const orgScope = Scope.make({ id: ScopeId.make(orgId), name: orgName, @@ -85,8 +88,7 @@ const createTestScopedExecutor = (userId: string, orgId: string, orgName: string }); return yield* createExecutor({ scopes: [userOrgScope, orgScope], - adapter, - blobs, + db: fuma.db, plugins, httpClientLayer: testHttpClientLayer, onElicitation: "accept-all", diff --git a/apps/cloud/src/services/db.ts b/apps/cloud/src/services/db.ts index ba7c35c10..75026bd5c 100644 --- a/apps/cloud/src/services/db.ts +++ b/apps/cloud/src/services/db.ts @@ -1,5 +1,5 @@ // --------------------------------------------------------------------------- -// Database service — Postgres via postgres.js (porsager) +// Database service — Postgres through Drizzle // --------------------------------------------------------------------------- // // We use `postgres` (not `pg`) because Cloudflare Workers forbids sharing @@ -8,6 +8,9 @@ // fresh TCP socket per Effect scope, which aligns with Workers' per-request // I/O model. See personal-notes/pg-cloudflare-sockets-dev.md. // +// Tests point DATABASE_URL at a PGlite Postgres-compatible socket, so they use +// the same postgres.js path as production. +// // Migrations are run out-of-band (e.g. via a separate script or CI step), // not at request time — Cloudflare Workers cannot read the filesystem. @@ -29,10 +32,14 @@ export const combinedSchema = { ...cloudSchema, ...executorSchema }; export type DrizzleDb = PgDatabase; export type DbServiceShape = { - readonly sql: Sql; + readonly sql?: Sql; readonly db: DrizzleDb; }; +type DbResource = DbServiceShape & { + readonly close: () => Effect.Effect; +}; + export const resolveConnectionString = () => { // Production should always use Hyperdrive when the binding exists. Keeping // DATABASE_URL as a higher-priority fallback made it too easy for a deployed @@ -48,9 +55,9 @@ const makeSql = (): Sql => // max=1 is correct for Hyperdrive: one request, one connection. The // earlier deadlock under ctx.transaction (outer sql.begin holding the // only connection while nested writes pulled fresh ones) is fixed in - // @executor-js/sdk — nested writes now thread through the active tx - // handle via a FiberRef in buildAdapterRouter, so they reuse the same - // connection and never contend with the outer sql.begin. + // @executor-js/sdk — nested writes now thread through the active FumaDB tx + // handle, so they reuse the same connection and never contend with the + // outer sql.begin. max: 1, idle_timeout: 0, max_lifetime: 60, @@ -60,28 +67,29 @@ const makeSql = (): Sql => onnotice: () => undefined, }); +const makePostgresResource = (): DbResource => { + const sql = makeSql(); + return { + sql, + db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb, + close: () => + Effect.sync(() => { + void Effect.runFork( + Effect.ignore( + Effect.tryPromise({ + try: () => sql.end({ timeout: 0 }), + catch: (cause) => cause, + }), + ), + ); + }), + }; +}; + export class DbService extends Context.Service()( "@executor-js/cloud/DbService", ) { static Live = Layer.effect(this)( - Effect.acquireRelease( - Effect.sync((): DbServiceShape => { - const sql = makeSql(); - return { sql, db: drizzle(sql, { schema: combinedSchema }) as DrizzleDb }; - }), - ({ sql }) => - // Fire-and-forget: the Terminate round-trip sometimes hangs, and - // we don't need to block scope close waiting for it. - Effect.sync(() => { - void Effect.runFork( - Effect.ignore( - Effect.tryPromise({ - try: () => sql.end({ timeout: 0 }), - catch: (cause) => cause, - }), - ), - ); - }), - ), + Effect.acquireRelease(Effect.sync(makePostgresResource), (resource) => resource.close()), ); } diff --git a/apps/cloud/src/services/executor-schema.ts b/apps/cloud/src/services/executor-schema.ts index 4e8d73199..d6f1f381b 100644 --- a/apps/cloud/src/services/executor-schema.ts +++ b/apps/cloud/src/services/executor-schema.ts @@ -1,457 +1,337 @@ -import { - pgTable, - text, - boolean, - timestamp, - bigint, - jsonb, - index, - primaryKey, -} from "drizzle-orm/pg-core"; +import { pgTable, varchar, text, boolean, timestamp, uniqueIndex, json, bigint } from "drizzle-orm/pg-core" +import { createId } from "fumadb/cuid" -export { blobTable as blob } from "@executor-js/storage-postgres"; +export const source = pgTable("source", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + plugin_id: text("plugin_id").notNull(), + kind: text("kind").notNull(), + name: text("name").notNull(), + url: text("url"), + can_remove: boolean("can_remove").notNull().default(true), + can_refresh: boolean("can_refresh").notNull().default(false), + can_edit: boolean("can_edit").notNull().default(false), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull() +}, (table) => [ + uniqueIndex("source_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const source = pgTable( - "source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - plugin_id: text("plugin_id").notNull(), - kind: text("kind").notNull(), - name: text("name").notNull(), - url: text("url"), - can_remove: boolean("can_remove").default(true).notNull(), - can_refresh: boolean("can_refresh").default(false).notNull(), - can_edit: boolean("can_edit").default(false).notNull(), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("source_scope_id_idx").on(table.scope_id), - index("source_plugin_id_idx").on(table.plugin_id), - ], -); +export const tool = pgTable("tool", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + plugin_id: text("plugin_id").notNull(), + name: text("name").notNull(), + description: text("description").notNull(), + input_schema: json("input_schema"), + output_schema: json("output_schema"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull() +}, (table) => [ + uniqueIndex("tool_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const tool = pgTable( - "tool", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - plugin_id: text("plugin_id").notNull(), - name: text("name").notNull(), - description: text("description").notNull(), - input_schema: jsonb("input_schema"), - output_schema: jsonb("output_schema"), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("tool_scope_id_idx").on(table.scope_id), - index("tool_source_id_idx").on(table.source_id), - index("tool_plugin_id_idx").on(table.plugin_id), - ], -); +export const definition = pgTable("definition", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + plugin_id: text("plugin_id").notNull(), + name: text("name").notNull(), + schema: json("schema").notNull(), + created_at: timestamp("created_at").notNull() +}, (table) => [ + uniqueIndex("definition_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const definition = pgTable( - "definition", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - plugin_id: text("plugin_id").notNull(), - name: text("name").notNull(), - schema: jsonb("schema").notNull(), - created_at: timestamp("created_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("definition_scope_id_idx").on(table.scope_id), - index("definition_source_id_idx").on(table.source_id), - index("definition_plugin_id_idx").on(table.plugin_id), - ], -); +export const secret = pgTable("secret", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + name: text("name").notNull(), + provider: text("provider").notNull(), + owned_by_connection_id: text("owned_by_connection_id"), + created_at: timestamp("created_at").notNull() +}, (table) => [ + uniqueIndex("secret_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const secret = pgTable( - "secret", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - provider: text("provider").notNull(), - owned_by_connection_id: text("owned_by_connection_id"), - created_at: timestamp("created_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("secret_scope_id_idx").on(table.scope_id), - index("secret_provider_idx").on(table.provider), - index("secret_owned_by_connection_id_idx").on(table.owned_by_connection_id), - ], -); +export const connection = pgTable("connection", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + provider: text("provider").notNull(), + identity_label: text("identity_label"), + access_token_secret_id: text("access_token_secret_id").notNull(), + refresh_token_secret_id: text("refresh_token_secret_id"), + expires_at: bigint("expires_at", { mode: "bigint" }), + scope: text("scope"), + provider_state: json("provider_state"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull() +}, (table) => [ + uniqueIndex("connection_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const connection = pgTable( - "connection", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - provider: text("provider").notNull(), - identity_label: text("identity_label"), - access_token_secret_id: text("access_token_secret_id").notNull(), - refresh_token_secret_id: text("refresh_token_secret_id"), - expires_at: bigint("expires_at", { mode: "number" }), - scope: text("scope"), - provider_state: jsonb("provider_state"), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("connection_scope_id_idx").on(table.scope_id), - index("connection_provider_idx").on(table.provider), - ], -); +export const oauth2_session = pgTable("oauth2_session", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + plugin_id: text("plugin_id").notNull(), + strategy: text("strategy").notNull(), + connection_id: text("connection_id").notNull(), + token_scope: text("token_scope").notNull(), + redirect_url: text("redirect_url").notNull(), + payload: json("payload").notNull(), + expires_at: bigint("expires_at", { mode: "bigint" }).notNull(), + created_at: timestamp("created_at").notNull() +}, (table) => [ + uniqueIndex("oauth2_session_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const oauth2_session = pgTable( - "oauth2_session", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - plugin_id: text("plugin_id").notNull(), - strategy: text("strategy").notNull(), - connection_id: text("connection_id").notNull(), - token_scope: text("token_scope").notNull(), - redirect_url: text("redirect_url").notNull(), - payload: jsonb("payload").notNull(), - expires_at: bigint("expires_at", { mode: "number" }).notNull(), - created_at: timestamp("created_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("oauth2_session_scope_id_idx").on(table.scope_id), - index("oauth2_session_plugin_id_idx").on(table.plugin_id), - index("oauth2_session_connection_id_idx").on(table.connection_id), - ], -); +export const credential_binding = pgTable("credential_binding", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + plugin_id: text("plugin_id").notNull(), + source_id: text("source_id").notNull(), + source_scope_id: text("source_scope_id").notNull(), + slot_key: text("slot_key").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + secret_id: text("secret_id"), + secret_scope_id: text("secret_scope_id"), + connection_id: text("connection_id"), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull() +}, (table) => [ + uniqueIndex("credential_binding_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const credential_binding = pgTable( - "credential_binding", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - plugin_id: text("plugin_id").notNull(), - source_id: text("source_id").notNull(), - source_scope_id: text("source_scope_id").notNull(), - slot_key: text("slot_key").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - secret_id: text("secret_id"), - secret_scope_id: text("secret_scope_id"), - connection_id: text("connection_id"), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("credential_binding_scope_id_idx").on(table.scope_id), - index("credential_binding_plugin_id_idx").on(table.plugin_id), - index("credential_binding_source_id_idx").on(table.source_id), - index("credential_binding_source_scope_id_idx").on(table.source_scope_id), - index("credential_binding_slot_key_idx").on(table.slot_key), - index("credential_binding_kind_idx").on(table.kind), - index("credential_binding_secret_id_idx").on(table.secret_id), - index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id), - index("credential_binding_connection_id_idx").on(table.connection_id), - ], -); +export const tool_policy = pgTable("tool_policy", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + pattern: text("pattern").notNull(), + action: text("action").notNull(), + position: text("position").notNull(), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull() +}, (table) => [ + uniqueIndex("tool_policy_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const tool_policy = pgTable( - "tool_policy", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - pattern: text("pattern").notNull(), - action: text("action").notNull(), - position: text("position").notNull(), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position), - ], -); +export const blob = pgTable("blob", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + namespace: text("namespace").notNull(), + key: text("key").notNull(), + value: text("value").notNull() +}, (table) => [ + uniqueIndex("blob_id_uidx").on(table.id) +]) -export const openapi_source = pgTable( - "openapi_source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - spec: text("spec").notNull(), - source_url: text("source_url"), - base_url: text("base_url"), - oauth2: jsonb("oauth2"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_scope_id_idx").on(table.scope_id), - ], -); +export const openapi_source = pgTable("openapi_source", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + name: text("name").notNull(), + spec: text("spec").notNull(), + source_url: text("source_url"), + base_url: text("base_url"), + oauth2: json("oauth2") +}, (table) => [ + uniqueIndex("openapi_source_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const openapi_operation = pgTable( - "openapi_operation", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: jsonb("binding").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_operation_scope_id_idx").on(table.scope_id), - index("openapi_operation_source_id_idx").on(table.source_id), - ], -); +export const openapi_operation = pgTable("openapi_operation", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + binding: json("binding").notNull() +}, (table) => [ + uniqueIndex("openapi_operation_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const openapi_source_header = pgTable( - "openapi_source_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind", { enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_header_scope_id_idx").on(table.scope_id), - index("openapi_source_header_source_id_idx").on(table.source_id), - ], -); +export const openapi_source_header = pgTable("openapi_source_header", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + name: text("name").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + slot_key: text("slot_key"), + prefix: text("prefix") +}, (table) => [ + uniqueIndex("openapi_source_header_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const openapi_source_query_param = pgTable( - "openapi_source_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind", { enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_query_param_scope_id_idx").on(table.scope_id), - index("openapi_source_query_param_source_id_idx").on(table.source_id), - ], -); +export const openapi_source_query_param = pgTable("openapi_source_query_param", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + name: text("name").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + slot_key: text("slot_key"), + prefix: text("prefix") +}, (table) => [ + uniqueIndex("openapi_source_query_param_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const openapi_source_spec_fetch_header = pgTable( - "openapi_source_spec_fetch_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind", { enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_spec_fetch_header_scope_id_idx").on(table.scope_id), - index("openapi_source_spec_fetch_header_source_id_idx").on(table.source_id), - ], -); +export const openapi_source_spec_fetch_header = pgTable("openapi_source_spec_fetch_header", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + name: text("name").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + slot_key: text("slot_key"), + prefix: text("prefix") +}, (table) => [ + uniqueIndex("openapi_source_spec_fetch_header_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const openapi_source_spec_fetch_query_param = pgTable( - "openapi_source_spec_fetch_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind", { enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_spec_fetch_query_param_scope_id_idx").on(table.scope_id), - index("openapi_source_spec_fetch_query_param_source_id_idx").on(table.source_id), - ], -); +export const openapi_source_spec_fetch_query_param = pgTable("openapi_source_spec_fetch_query_param", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + name: text("name").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + slot_key: text("slot_key"), + prefix: text("prefix") +}, (table) => [ + uniqueIndex("openapi_source_spec_fetch_query_param_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const mcp_source = pgTable( - "mcp_source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - config: jsonb("config").notNull(), - auth_kind: text("auth_kind", { enum: ["none", "header", "oauth2"] }) - .default("none") - .notNull(), - auth_header_name: text("auth_header_name"), - auth_header_slot: text("auth_header_slot"), - auth_header_prefix: text("auth_header_prefix"), - auth_connection_slot: text("auth_connection_slot"), - auth_client_id_slot: text("auth_client_id_slot"), - auth_client_secret_slot: text("auth_client_secret_slot"), - created_at: timestamp("created_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_source_scope_id_idx").on(table.scope_id), - ], -); +export const mcp_source = pgTable("mcp_source", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + name: text("name").notNull(), + config: json("config").notNull(), + auth_kind: text("auth_kind").notNull().default("none"), + auth_header_name: text("auth_header_name"), + auth_header_slot: text("auth_header_slot"), + auth_header_prefix: text("auth_header_prefix"), + auth_connection_slot: text("auth_connection_slot"), + auth_client_id_slot: text("auth_client_id_slot"), + auth_client_secret_slot: text("auth_client_secret_slot"), + created_at: timestamp("created_at").notNull() +}, (table) => [ + uniqueIndex("mcp_source_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const mcp_source_header = pgTable( - "mcp_source_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind", { enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_source_header_scope_id_idx").on(table.scope_id), - index("mcp_source_header_source_id_idx").on(table.source_id), - ], -); +export const mcp_source_header = pgTable("mcp_source_header", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + name: text("name").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + slot_key: text("slot_key"), + prefix: text("prefix") +}, (table) => [ + uniqueIndex("mcp_source_header_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const mcp_source_query_param = pgTable( - "mcp_source_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind", { enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_source_query_param_scope_id_idx").on(table.scope_id), - index("mcp_source_query_param_source_id_idx").on(table.source_id), - ], -); +export const mcp_source_query_param = pgTable("mcp_source_query_param", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + name: text("name").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + slot_key: text("slot_key"), + prefix: text("prefix") +}, (table) => [ + uniqueIndex("mcp_source_query_param_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const mcp_binding = pgTable( - "mcp_binding", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: jsonb("binding").notNull(), - created_at: timestamp("created_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_binding_scope_id_idx").on(table.scope_id), - index("mcp_binding_source_id_idx").on(table.source_id), - ], -); +export const mcp_binding = pgTable("mcp_binding", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + binding: json("binding").notNull(), + created_at: timestamp("created_at").notNull() +}, (table) => [ + uniqueIndex("mcp_binding_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const graphql_source = pgTable( - "graphql_source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - endpoint: text("endpoint").notNull(), - auth_kind: text("auth_kind", { enum: ["none", "oauth2"] }) - .default("none") - .notNull(), - auth_connection_slot: text("auth_connection_slot"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_source_scope_id_idx").on(table.scope_id), - ], -); +export const graphql_source = pgTable("graphql_source", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + name: text("name").notNull(), + endpoint: text("endpoint").notNull(), + auth_kind: text("auth_kind").notNull().default("none"), + auth_connection_slot: text("auth_connection_slot") +}, (table) => [ + uniqueIndex("graphql_source_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const graphql_source_header = pgTable( - "graphql_source_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind", { enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_source_header_scope_id_idx").on(table.scope_id), - index("graphql_source_header_source_id_idx").on(table.source_id), - ], -); +export const graphql_source_header = pgTable("graphql_source_header", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + name: text("name").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + slot_key: text("slot_key"), + prefix: text("prefix") +}, (table) => [ + uniqueIndex("graphql_source_header_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const graphql_source_query_param = pgTable( - "graphql_source_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind", { enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_source_query_param_scope_id_idx").on(table.scope_id), - index("graphql_source_query_param_source_id_idx").on(table.source_id), - ], -); +export const graphql_source_query_param = pgTable("graphql_source_query_param", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + name: text("name").notNull(), + kind: text("kind").notNull(), + text_value: text("text_value"), + slot_key: text("slot_key"), + prefix: text("prefix") +}, (table) => [ + uniqueIndex("graphql_source_query_param_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const graphql_operation = pgTable( - "graphql_operation", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: jsonb("binding").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_operation_scope_id_idx").on(table.scope_id), - index("graphql_operation_source_id_idx").on(table.source_id), - ], -); +export const graphql_operation = pgTable("graphql_operation", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + source_id: text("source_id").notNull(), + binding: json("binding").notNull() +}, (table) => [ + uniqueIndex("graphql_operation_scope_id_id_uidx").on(table.scope_id, table.id) +]) -export const workos_vault_metadata = pgTable( - "workos_vault_metadata", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - purpose: text("purpose"), - created_at: timestamp("created_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("workos_vault_metadata_scope_id_idx").on(table.scope_id), - ], -); +export const workos_vault_metadata = pgTable("workos_vault_metadata", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + name: text("name").notNull(), + purpose: text("purpose"), + created_at: timestamp("created_at").notNull() +}, (table) => [ + uniqueIndex("workos_vault_metadata_scope_id_id_uidx").on(table.scope_id, table.id) +]) + +export const private_executor_cloud_settings = pgTable("private_executor_cloud_settings", { + id: varchar("id", { length: 255 }).primaryKey().notNull(), + version: varchar("version", { length: 255 }).notNull().default("1.0.0") +}) \ No newline at end of file diff --git a/apps/cloud/src/services/executor.ts b/apps/cloud/src/services/executor.ts index d909bc0aa..fa15ca222 100644 --- a/apps/cloud/src/services/executor.ts +++ b/apps/cloud/src/services/executor.ts @@ -13,21 +13,21 @@ import { Effect } from "effect"; import { Scope, ScopeId, - collectSchemas, + collectTables, createExecutor, makeHostedHttpClientLayer, } from "@executor-js/sdk"; -import { makePostgresAdapter, makePostgresBlobStore } from "@executor-js/storage-postgres"; import { env } from "cloudflare:workers"; import executorConfig from "../../executor.config"; import { DbService } from "./db"; +import { createDrizzleFumaDb } from "./fuma"; // --------------------------------------------------------------------------- -// Plugin list lives in `executor.config.ts` — that file is the single -// source of truth, also consumed by the schema-gen CLI and the test -// harness. Per-request runtime values (WorkOS credentials from the -// Worker env) are passed through the factory's `deps` parameter. +// Plugin list lives in `executor.config.ts` — that file is the single source +// of truth for runtime, schema wiring, and the test harness. Per-request +// runtime values (WorkOS credentials from the Worker env) are passed through +// the factory's `deps` parameter. // --------------------------------------------------------------------------- export type CloudPlugins = ReturnType; @@ -67,9 +67,12 @@ export const createScopedExecutor = ( const httpClientLayer = makeHostedHttpClientLayer({ allowLocalNetwork: env.NODE_ENV === "test", }); - const schema = collectSchemas(plugins); - const adapter = makePostgresAdapter({ db, schema }); - const blobs = makePostgresBlobStore({ db }); + const fuma = createDrizzleFumaDb({ + db, + tables: collectTables(plugins), + namespace: "executor_cloud", + provider: "postgresql", + }); const orgScope = Scope.make({ id: ScopeId.make(organizationId), @@ -88,8 +91,7 @@ export const createScopedExecutor = ( // where `ErrorCaptureLive` (Sentry) gets wired in. return yield* createExecutor({ scopes: [userOrgScope, orgScope], - adapter, - blobs, + db: fuma.db, plugins, httpClientLayer, onElicitation: "accept-all", diff --git a/apps/cloud/src/services/fuma.ts b/apps/cloud/src/services/fuma.ts new file mode 100644 index 000000000..8753dcfbd --- /dev/null +++ b/apps/cloud/src/services/fuma.ts @@ -0,0 +1,47 @@ +import { fumadb, type FumaDB } from "fumadb"; +import { drizzleAdapter, type DrizzleConfig } from "fumadb/adapters/drizzle"; +import { schema as fumaSchema, type RelationsMap } from "fumadb/schema"; + +import type { FumaDb, FumaTables } from "@executor-js/sdk"; + +type DrizzleFumaSchema = ReturnType< + typeof fumaSchema> +>; + +export interface DrizzleFumaDb { + readonly db: FumaDb>; + readonly fuma: FumaDB[]>; +} + +export interface CreateDrizzleFumaDbOptions { + readonly db: DrizzleConfig["db"]; + readonly tables: TTables; + readonly namespace: string; + readonly version?: string; + readonly provider: DrizzleConfig["provider"]; +} + +export const createDrizzleFumaDb = ( + options: CreateDrizzleFumaDbOptions, +): DrizzleFumaDb => { + const version = options.version ?? "1.0.0"; + const latestSchema = fumaSchema({ + version, + tables: options.tables, + }); + const factory = fumadb({ + namespace: options.namespace, + schemas: [latestSchema], + }); + const fuma = factory.client( + drizzleAdapter({ + db: options.db, + provider: options.provider, + }), + ); + + return { + db: fuma.orm(version), + fuma, + }; +}; diff --git a/apps/cloud/src/services/fumadb-cutover-migration.node.test.ts b/apps/cloud/src/services/fumadb-cutover-migration.node.test.ts new file mode 100644 index 000000000..440934fc6 --- /dev/null +++ b/apps/cloud/src/services/fumadb-cutover-migration.node.test.ts @@ -0,0 +1,126 @@ +import { readFileSync } from "node:fs"; +import { PGlite } from "@electric-sql/pglite"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +const scopedTables = [ + "connection", + "credential_binding", + "definition", + "graphql_operation", + "graphql_source", + "graphql_source_header", + "graphql_source_query_param", + "mcp_binding", + "mcp_source", + "mcp_source_header", + "mcp_source_query_param", + "oauth2_session", + "openapi_operation", + "openapi_source", + "openapi_source_header", + "openapi_source_query_param", + "openapi_source_spec_fetch_header", + "openapi_source_spec_fetch_query_param", + "secret", + "source", + "tool", + "tool_policy", + "workos_vault_metadata", +] as const; + +const migrationPath = new URL("../../drizzle/0016_fumadb_cutover.sql", import.meta.url); + +const statements = readFileSync(migrationPath, "utf8") + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter((statement) => statement.length > 0); + +const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; + +const createLegacySchema = async (db: PGlite) => { + await db.exec(` + CREATE TABLE "blob" ( + "namespace" text NOT NULL, + "key" text NOT NULL, + "value" text NOT NULL, + CONSTRAINT "blob_namespace_key_pk" PRIMARY KEY ("namespace", "key") + ); + INSERT INTO "blob" ("namespace", "key", "value") VALUES ('scope/plugin', 'spec', '{}'); + `); + + for (const tableName of scopedTables) { + await db.exec(` + CREATE TABLE ${quoteIdent(tableName)} ( + "scope_id" text NOT NULL, + "id" text NOT NULL, + CONSTRAINT ${quoteIdent(`${tableName}_scope_id_id_pk`)} PRIMARY KEY ("scope_id", "id") + ); + INSERT INTO ${quoteIdent(tableName)} ("scope_id", "id") VALUES ('scope-a', 'row-a'); + `); + } +}; + +const applyCutoverMigration = async (db: PGlite) => { + for (const statement of statements) { + await db.exec(statement); + } +}; + +describe("FumaDB cutover migration", () => { + it.effect( + "converts legacy primary keys to row_id primary keys while preserving scoped uniqueness", + () => + Effect.acquireUseRelease( + Effect.promise(() => PGlite.create("memory://")), + (db) => + Effect.promise(async () => { + await createLegacySchema(db); + await applyCutoverMigration(db); + + const blobRows = await db.query<{ + id: string; + row_id: string; + }>(`SELECT "id", "row_id" FROM "blob"`); + expect(blobRows.rows).toEqual([ + { + id: '["scope/plugin","spec"]', + row_id: expect.stringMatching(/^legacy_/), + }, + ]); + + const blobConstraints = await db.query<{ conname: string }>( + `SELECT conname FROM pg_constraint WHERE conrelid = 'public.blob'::regclass ORDER BY conname`, + ); + expect(blobConstraints.rows.map((row) => row.conname)).toContain("blob_pkey"); + expect(blobConstraints.rows.map((row) => row.conname)).not.toContain( + "blob_namespace_key_pk", + ); + + for (const tableName of scopedTables) { + const rows = await db.query<{ row_id: string }>( + `SELECT "row_id" FROM ${quoteIdent(tableName)}`, + ); + expect(rows.rows).toEqual([{ row_id: expect.stringMatching(/^legacy_/) }]); + + const constraints = await db.query<{ conname: string }>( + `SELECT conname FROM pg_constraint WHERE conrelid = ${`'public.${tableName}'`}::regclass ORDER BY conname`, + ); + expect(constraints.rows.map((row) => row.conname)).toContain(`${tableName}_pkey`); + expect(constraints.rows.map((row) => row.conname)).not.toContain( + `${tableName}_scope_id_id_pk`, + ); + + const indexes = await db.query<{ indexname: string }>( + `SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND tablename = '${tableName}' ORDER BY indexname`, + ); + expect(indexes.rows.map((row) => row.indexname)).toContain( + `${tableName}_scope_id_id_uidx`, + ); + } + }), + (db) => Effect.promise(() => db.close()), + ), + 15_000, + ); +}); diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index 1920aac69..b5c218f93 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -288,10 +288,10 @@ const startMcpServer = () => { // The Cloudflare OpenAPI spec is the biggest real spec we care about: // 16MB, 2700+ operations, thousands of shared schemas. Exercising -// addSpec end-to-end on it through the real postgres adapter is the -// load-bearing check that any adapter regression (per-row `createMany`, -// accidental N+1 reads, transaction snapshots that copy too much) will -// show up as a test failure instead of a prod incident. +// addSpec end-to-end on it through the real Drizzle/FumaDB path is the +// load-bearing check that any storage regression (per-row `createMany`, +// accidental N+1 reads, transaction snapshots that copy too much) will show up +// as a test failure instead of a prod incident. const CLOUDFLARE_SPEC_PATH = resolve( __dirname, "../../../../packages/plugins/openapi/fixtures/cloudflare.json", @@ -880,7 +880,7 @@ describe("sources api (HTTP)", () => { ); it.effect( - "addSpec persists the full Cloudflare spec through the real adapter", + "addSpec persists the full Cloudflare spec through the real Drizzle/FumaDB path", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; diff --git a/apps/cloud/vitest.node.config.ts b/apps/cloud/vitest.node.config.ts index 6200f9e53..69ee563e8 100644 --- a/apps/cloud/vitest.node.config.ts +++ b/apps/cloud/vitest.node.config.ts @@ -1,7 +1,8 @@ -// Vitest config for node-pool integration tests. These run close to -// production (real postgres, real DbService, real plugins, HttpApiClient -// through an in-process handler) but outside workerd. The workerd/ -// miniflare path for the rest of the suite lives in vitest.config.ts. +// Vitest config for node-pool integration tests. These run real DbService, +// real plugins, and HttpApiClient through an in-process handler, but outside +// workerd. Node can use Drizzle's PGlite driver directly; the workerd suite +// keeps the PGlite socket path because Workers code reaches Postgres through +// postgres.js. import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; @@ -15,9 +16,7 @@ export default defineConfig({ test: { include: ["src/**/*.node.test.ts"], globalSetup: ["./scripts/test-globalsetup.ts"], - // PGlite is a single in-process WASM instance — running multiple - // test files in parallel against the same socket leaks connections - // and triggers ECONNRESET. Serialize file execution instead. + // Keep files serialized so tests share one deterministic PGlite state. fileParallelism: false, env: { DATABASE_URL: "postgresql://postgres:postgres@127.0.0.1:5434/postgres", diff --git a/apps/desktop/scripts/build-sidecar.ts b/apps/desktop/scripts/build-sidecar.ts index bf3011e02..48956985b 100644 --- a/apps/desktop/scripts/build-sidecar.ts +++ b/apps/desktop/scripts/build-sidecar.ts @@ -3,19 +3,14 @@ * * Produces a fully self-contained executable that includes the Bun runtime * plus the entire @executor-js/local server graph (including bun:sqlite, - * drizzle, MCP, etc.). The Electron main process exec's this binary at + * FumaDB, MCP, etc.). The Electron main process exec's this binary at * runtime instead of relying on a `bun` install on the user's machine. * * Also stages the apps/local Vite build output as `resources/web-ui/` so * electron-builder picks it up via extraResources. * - * Like apps/cli/src/build.ts, this script generates - * apps/local/src/server/embedded-migrations.gen.ts with drizzle migration - * files inlined via `with { type: "text" }` before compiling, then restores - * the stub afterwards. The compiled binary unpacks migrations to a tmpdir - * at boot so drizzle's `migrate()` (which only accepts folder paths) works. */ -import { mkdir, rm, cp, writeFile } from "node:fs/promises"; +import { mkdir, rm, cp } from "node:fs/promises"; import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; @@ -29,9 +24,6 @@ const SIDECAR_OUT_DIR = resolve(ROOT, "resources/sidecar"); const WEB_UI_OUT_DIR = resolve(ROOT, "resources/web-ui"); const APPS_LOCAL_DIST = resolve(APPS_LOCAL, "dist"); -const EMBEDDED_MIGRATIONS_PATH = join(APPS_LOCAL, "src/server/embedded-migrations.gen.ts"); -const EMBEDDED_MIGRATIONS_STUB = `const migrations: Record | null = null;\n\nexport default migrations;\n`; - /** * Cross-compile target for `bun build --compile`. When unset we use Bun's * default `bun` target (the runner's own platform). CI passes a specific @@ -60,28 +52,6 @@ const resolveQuickJsWasmPath = (): string => { return wasmPath; }; -const createEmbeddedMigrationsSource = async (): Promise => { - const migrationsDir = resolve(APPS_LOCAL, "drizzle"); - const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: migrationsDir }))) - .map((f) => f.replaceAll("\\", "/")) - .sort(); - - const imports = files.map((file, i) => { - const spec = join(migrationsDir, file).replaceAll("\\", "/"); - return `import file_${i} from ${JSON.stringify(spec)} with { type: "text" };`; - }); - - const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`); - - return [ - "// Auto-generated — maps migration paths to inlined file contents", - ...imports, - "export default {", - ...entries, - "} as Record;", - ].join("\n"); -}; - if (!existsSync(APPS_LOCAL_DIST)) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal throw new Error( @@ -94,21 +64,12 @@ await rm(WEB_UI_OUT_DIR, { recursive: true, force: true }); await mkdir(SIDECAR_OUT_DIR, { recursive: true }); await mkdir(WEB_UI_OUT_DIR, { recursive: true }); -console.log("[build-sidecar] inlining drizzle migrations..."); -const embeddedMigrations = await createEmbeddedMigrationsSource(); -await writeFile(EMBEDDED_MIGRATIONS_PATH, `${embeddedMigrations}\n`); - -// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: ensure the gen stub is restored even if compile fails -try { - console.log( - `[build-sidecar] bun build --compile --target=${BUN_TARGET} ${SIDECAR_ENTRY} → ${sidecarBinary}`, - ); - await $`bun build --compile --minify --sourcemap --target=${BUN_TARGET} --outfile ${sidecarBinary} ${SIDECAR_ENTRY}`.cwd( - REPO_ROOT, - ); -} finally { - await writeFile(EMBEDDED_MIGRATIONS_PATH, EMBEDDED_MIGRATIONS_STUB); -} +console.log( + `[build-sidecar] bun build --compile --target=${BUN_TARGET} ${SIDECAR_ENTRY} → ${sidecarBinary}`, +); +await $`bun build --compile --minify --sourcemap --target=${BUN_TARGET} --outfile ${sidecarBinary} ${SIDECAR_ENTRY}`.cwd( + REPO_ROOT, +); console.log(`[build-sidecar] staging QuickJS WASM → ${SIDECAR_OUT_DIR}`); await cp(resolveQuickJsWasmPath(), join(SIDECAR_OUT_DIR, "emscripten-module.wasm")); diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/apps/desktop/scripts/smoke-sidecar.ts index 9b125883d..759a813c3 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/apps/desktop/scripts/smoke-sidecar.ts @@ -2,7 +2,7 @@ * End-to-end smoke test for the compiled sidecar binary. * * Catches "works in dev, breaks in --compile" regressions: bunfs asset - * loading (QuickJS WASM, embedded migrations, embedded web UI), native + * loading (QuickJS WASM, staged web UI), native * .node loaders (keychain), and the MCP → engine → QuickJS → tool path. * * Flow: diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index eadacaf6f..04b0772bd 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -11,15 +11,13 @@ import { desktopSettingsPlugin } from "@executor-js/plugin-desktop-settings/serv // --------------------------------------------------------------------------- // Single source of truth for the local app's plugin list. // -// Consumed by: -// - the schema-gen CLI (reads `plugin.schema` only; calls `plugins({})`) -// - the host runtime +// Consumed by the host runtime. The runtime passes the merged plugin tables +// to FumaDB directly; there is no separate Executor schema-generation step. // // First-party and third-party plugins use the same import-and-call flow. // --------------------------------------------------------------------------- export default defineExecutorConfig({ - dialect: "sqlite", plugins: () => [ openApiHttpPlugin(), diff --git a/apps/local/package.json b/apps/local/package.json index 8e44597d1..69cd85936 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -12,7 +12,6 @@ "dev:vite": "portless --name executor-local bunx --bun vite dev", "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build", "start": "bun run src/serve.ts", - "db:schema": "node --import jiti/register ../../packages/core/cli/src/index.ts generate --config ./executor.config.ts --output ./src/server/executor-schema.ts", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", "test": "bunx --bun vitest run", @@ -40,12 +39,12 @@ "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", - "@executor-js/storage-file": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@modelcontextprotocol/sdk": "^1.12.1", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", + "fumadb": "workspace:*", "jsonc-parser": "^3.3.1", "react": "catalog:", "react-dom": "catalog:" diff --git a/apps/local/src/server/db-upgrade.test.ts b/apps/local/src/server/db-upgrade.test.ts index 4ffb2d352..0a4c0aa58 100644 --- a/apps/local/src/server/db-upgrade.test.ts +++ b/apps/local/src/server/db-upgrade.test.ts @@ -1,18 +1,16 @@ // Upgrade path for local DBs written by pre-scope executor versions. // -// These tests exercise both halves: -// 1. The detector correctly identifies DBs missing the `scope_id` -// column on `source`. -// 2. The move-aside helper renames the file (plus WAL/SHM siblings) -// so a subsequent fresh `migrate()` can create the new shape. +// These helpers still run before the one-shot FumaDB import. They detect +// SQLite files whose core tables predate `scope_id`, move the file set aside, +// and preserve legacy secret routing rows for the fresh scoped database. import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; import { Database } from "bun:sqlite"; -import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { importLegacySecrets, @@ -136,13 +134,11 @@ describe("moveAsidePreScopeDb", () => { expect(existsSync(path)).toBe(true); }); - it("is a no-op when the DB doesn't exist yet (fresh install)", () => { + it("is a no-op when the DB doesn't exist yet", () => { expect(moveAsidePreScopeDb(join(workDir, "missing.db"))).toBeNull(); }); }); -// Integration: the whole reason this helper exists — a pre-scope DB -// must be recoverable via fresh drizzle migrations after the move. describe("move-aside + fresh migrate end-to-end", () => { it("lets migrations run cleanly after an old DB is moved aside", () => { const path = join(workDir, "data.db"); @@ -153,14 +149,13 @@ describe("move-aside + fresh migrate end-to-end", () => { const db = new Database(path); migrate(drizzle(db), { - migrationsFolder: join(__dirname, "../../drizzle"), + migrationsFolder: join(import.meta.dirname, "../../drizzle"), }); - // migrate() should have produced the new schema — source now has scope_id. const cols = db.prepare("PRAGMA table_info('source')").all() as ReadonlyArray<{ readonly name: string; }>; - expect(cols.some((c) => c.name === "scope_id")).toBe(true); db.close(); + expect(cols.some((c) => c.name === "scope_id")).toBe(true); }); }); @@ -173,13 +168,13 @@ describe("readLegacySecrets", () => { "sec_1", "GitHub Token", "onepassword", - 1700000000, + 1_700_000_000, ); db.prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)").run( "sec_2", "Stripe", "keychain", - 1700000001, + 1_700_000_001, ); db.close(); @@ -189,7 +184,7 @@ describe("readLegacySecrets", () => { id: "sec_1", name: "GitHub Token", provider: "onepassword", - createdAt: 1700000000, + createdAt: 1_700_000_000, }); }); @@ -205,7 +200,6 @@ describe("readLegacySecrets", () => { }); describe("importLegacySecrets", () => { - // Set up a fresh DB with the new (scoped) `secret` shape to import into. const createScopedDb = (path: string): Database => { const db = new Database(path); db.exec(` @@ -256,8 +250,6 @@ describe("importLegacySecrets", () => { const db = createScopedDb(path); const rows = [{ id: "sec_1", name: "GH", provider: "onepassword", createdAt: 1 }]; importLegacySecrets(db, "scope_a", rows); - // If the user's already re-registered the secret via a different - // provider, the legacy row must NOT clobber it. db.prepare( "UPDATE secret SET provider = 'file' WHERE id = 'sec_1' AND scope_id = 'scope_a'", ).run(); diff --git a/apps/local/src/server/executor-schema-compat.test.ts b/apps/local/src/server/executor-schema-compat.test.ts deleted file mode 100644 index e3dcd9696..000000000 --- a/apps/local/src/server/executor-schema-compat.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Database } from "bun:sqlite"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; - -import { - LocalDatabaseMigrationHistoryMismatch, - LocalDatabaseSchemaTooNew, - checkDrizzleMigrationCompatibility, - readAppliedDrizzleMigrationHashes, - readBundledDrizzleMigrationHashes, -} from "./executor"; - -const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); - -const workDirs: string[] = []; -const openDbs: Database[] = []; - -const tempDb = (): { db: Database; path: string; dataDir: string } => { - const dir = mkdtempSync(join(tmpdir(), "executor-schema-compat-")); - workDirs.push(dir); - const path = join(dir, "data.db"); - const db = new Database(path); - openDbs.push(db); - return { db, path, dataDir: dir }; -}; - -const createMigrationTable = (db: Database): void => { - db.exec(` - CREATE TABLE __drizzle_migrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - hash TEXT NOT NULL, - created_at NUMERIC - ) - `); -}; - -const insertMigrationHashes = (db: Database, hashes: ReadonlyArray): void => { - const stmt = db.prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)"); - for (const [index, hash] of hashes.entries()) { - stmt.run(hash, index + 1); - } -}; - -afterEach(() => { - for (const db of openDbs.splice(0)) { - db.close(); - } - for (const dir of workDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("Drizzle migration compatibility preflight", () => { - it.effect("allows a fresh DB without __drizzle_migrations", () => - Effect.gen(function* () { - const { db, path, dataDir } = tempDb(); - - yield* checkDrizzleMigrationCompatibility({ - sqlite: db, - dbPath: path, - dataDir, - migrationsFolder: MIGRATIONS_FOLDER, - }); - }), - ); - - it.effect("allows an existing but empty __drizzle_migrations table", () => - Effect.gen(function* () { - const { db, path, dataDir } = tempDb(); - createMigrationTable(db); - - yield* checkDrizzleMigrationCompatibility({ - sqlite: db, - dbPath: path, - dataDir, - migrationsFolder: MIGRATIONS_FOLDER, - }); - }), - ); - - it("computes bundled hashes that exactly match hashes written by Drizzle", () => { - const { db } = tempDb(); - migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); - - expect(readAppliedDrizzleMigrationHashes(db)).toEqual( - readBundledDrizzleMigrationHashes(MIGRATIONS_FOLDER), - ); - }); - - it.effect("fails with LocalDatabaseSchemaTooNew when the DB has more migrations", () => - Effect.gen(function* () { - const { db, path, dataDir } = tempDb(); - const bundled = readBundledDrizzleMigrationHashes(MIGRATIONS_FOLDER); - createMigrationTable(db); - insertMigrationHashes(db, [...bundled, "future-migration-hash"]); - - const error = yield* checkDrizzleMigrationCompatibility({ - sqlite: db, - dbPath: path, - dataDir, - migrationsFolder: MIGRATIONS_FOLDER, - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(LocalDatabaseSchemaTooNew); - expect(error).toMatchObject({ - message: expect.stringContaining("This Executor binary is older than the schema"), - }); - expect(error).toMatchObject({ - message: expect.stringContaining("Use a newer Executor binary"), - }); - }), - ); - - it.effect("fails with LocalDatabaseMigrationHistoryMismatch when hashes diverge", () => - Effect.gen(function* () { - const { db, path, dataDir } = tempDb(); - const bundled = readBundledDrizzleMigrationHashes(MIGRATIONS_FOLDER); - createMigrationTable(db); - insertMigrationHashes(db, ["different-migration-hash", ...bundled.slice(1)]); - - const error = yield* checkDrizzleMigrationCompatibility({ - sqlite: db, - dbPath: path, - dataDir, - migrationsFolder: MIGRATIONS_FOLDER, - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(LocalDatabaseMigrationHistoryMismatch); - expect(error).toMatchObject({ - message: expect.stringContaining("does not match this Executor build"), - }); - expect(error).toMatchObject({ message: expect.stringContaining("restore a backup") }); - }), - ); - - it.effect("allows an older DB whose migration history is a bundled prefix", () => - Effect.gen(function* () { - const { db, path, dataDir } = tempDb(); - const bundled = readBundledDrizzleMigrationHashes(MIGRATIONS_FOLDER); - createMigrationTable(db); - insertMigrationHashes(db, bundled.slice(0, 1)); - - yield* checkDrizzleMigrationCompatibility({ - sqlite: db, - dbPath: path, - dataDir, - migrationsFolder: MIGRATIONS_FOLDER, - }); - }), - ); -}); diff --git a/apps/local/src/server/executor.ts b/apps/local/src/server/executor.ts index 87b8829ce..363404ebc 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -1,12 +1,25 @@ +import { Context, Data, Effect, Layer, ManagedRuntime, Schema } from "effect"; import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { Context, Data, Effect, Layer, ManagedRuntime, Schema } from "effect"; -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import * as fs from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; +import { + Scope, + ScopeId, + collectTables, + createExecutor, + type AnyPlugin, + type Executor, + type FumaTables, +} from "@executor-js/sdk"; +import { withQueryContext } from "fumadb/query"; +import { loadPluginsFromJsonc } from "@executor-js/config"; + +import executorConfig from "../../executor.config"; import embeddedMigrations from "./embedded-migrations.gen"; import { importLegacySecrets, @@ -14,22 +27,30 @@ import { readLegacySecrets, type LegacySecret, } from "./db-upgrade"; +import * as legacyExecutorSchema from "./executor-schema"; +import { + importSqliteDataToFuma, + readLegacySqliteScopeIds, + type LocalSqliteImportResult, +} from "./sqlite-import"; +import { createSqliteFumaDb } from "./sqlite-fumadb"; -import { Scope, ScopeId, type AnyPlugin, collectSchemas, createExecutor } from "@executor-js/sdk"; -import { makeSqliteAdapter, makeSqliteBlobStore } from "@executor-js/storage-file"; -import { loadPluginsFromJsonc } from "@executor-js/config"; -import * as executorSchema from "./executor-schema"; +interface ResolvedStorage { + readonly dataDir: string; + readonly sqlitePath: string; + readonly importMarkerPath: string; +} -import executorConfig from "../../executor.config"; +const localNamespace = "executor_local"; // In dev mode the drizzle folder sits next to the source tree. In a compiled -// binary the files are inlined via the build-time gen module below, and we -// extract them to a tmpdir at boot so drizzle's `migrate()` — which only -// accepts a folder path — can read them. +// binary the files are inlined by apps/cli/src/build.ts and extracted to a +// temp folder because drizzle's migrator accepts a folder path. const resolveMigrationsFolder = (): string => { if (!embeddedMigrations) { return join(import.meta.dirname, "../../drizzle"); } + const dir = fs.mkdtempSync(join(tmpdir(), "executor-migrations-")); for (const [rel, content] of Object.entries(embeddedMigrations)) { const target = join(dir, rel); @@ -41,52 +62,62 @@ const resolveMigrationsFolder = (): string => { const MIGRATIONS_FOLDER = resolveMigrationsFolder(); -interface ResolvedDb { - readonly path: string; - readonly dataDir: string; - readonly legacySecrets: readonly LegacySecret[]; -} - -const resolveDbPath = (): ResolvedDb => { +const resolveStorage = (): ResolvedStorage => { const dataDir = process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor"); fs.mkdirSync(dataDir, { recursive: true }); - const dbPath = `${dataDir}/data.db`; - // DBs written by pre-scope-refactor versions of the CLI have a schema - // the current drizzle migration can't be applied on top of. Before we - // move it aside, pull the `secret` routing rows so non-enumerating - // providers (keychain) stay reachable after the fresh DB is created. - const legacySecrets = readLegacySecrets(dbPath); - const backup = moveAsidePreScopeDb(dbPath); - if (backup) { - console.warn( - `[executor] Pre-scope database detected; moved to ${backup}. ` + - `Sources and tool catalogs will need to be re-added` + - (legacySecrets.length > 0 - ? ` (${legacySecrets.length} secret routing row(s) preserved).` - : "."), - ); - } - return { path: dbPath, dataDir, legacySecrets }; + return { + dataDir, + sqlitePath: join(dataDir, "data.db"), + importMarkerPath: join(dataDir, "fumadb-sqlite-imported"), + }; }; // Hash suffix disambiguates same-basename folders so two projects with -// identical directory names can't collide on the same scope id. +// identical directory names cannot collide on the same scope id. const makeScopeId = (cwd: string): string => { const folder = basename(cwd) || cwd; const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 8); return `${folder}-${hash}`; }; +const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); + // Plugins reach the host through two doors that compose: -// - `executor.config.ts`'s static tuple (typed at TS compile time) -// - `executor.jsonc#plugins` (loaded via jiti at boot) -// We concatenate the two and widen the result to `readonly AnyPlugin[]`. -// The frontend's typed atom client still resolves correctly because -// each plugin imports its own group from `${pkg}/shared`. +// - `executor.config.ts`'s static tuple +// - `executor.jsonc#plugins` loaded at boot +// Static config wins on conflict, matching the Vite plugin. type LocalPlugins = readonly AnyPlugin[]; +const loadLocalPlugins = Effect.gen(function* () { + const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); + const staticPlugins = executorConfig.plugins(); + const dynamicPlugins = + (yield* Effect.promise(() => loadPluginsFromJsonc({ path: resolvePluginConfigPath(cwd) }))) ?? + []; + + const staticPackageNames = new Set( + staticPlugins.map((plugin) => plugin.packageName).filter((name): name is string => !!name), + ); + const dedupedDynamic = dynamicPlugins.filter((plugin) => { + if (plugin.packageName && staticPackageNames.has(plugin.packageName)) { + console.warn( + `[executor] plugin "${plugin.packageName}" appears in both ` + + `executor.config.ts and executor.jsonc#plugins. The static ` + + `entry wins; the jsonc entry is ignored.`, + ); + return false; + } + return true; + }); + + return { + cwd, + plugins: [...staticPlugins, ...dedupedDynamic] as LocalPlugins, + }; +}); + interface LocalExecutorBundle { - readonly executor: Effect.Success>>; + readonly executor: Executor; readonly plugins: LocalPlugins; } @@ -96,21 +127,9 @@ class LocalExecutorTag extends Context.Service {} - -export class LocalDatabaseMigrationHistoryMismatch extends Data.TaggedError( - "LocalDatabaseMigrationHistoryMismatch", -)<{ - readonly message: string; - readonly dbPath: string; - readonly migrationIndex: number; - readonly appliedHash: string | undefined; - readonly knownHash: string | undefined; +class LocalExecutorCreateError extends Data.TaggedError("LocalExecutorCreateError")<{ + readonly operation: "createSqlite" | "importSqlite"; + readonly cause: unknown; }> {} class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeError")<{ @@ -143,6 +162,12 @@ const handleOrNull = (promise: ReturnType) => ), ); +const sqliteTableHasColumn = (db: Database, table: string, column: string): boolean => + db + .query<{ name: string }, []>(`PRAGMA table_info('${table.replaceAll("'", "''")}')`) + .all() + .some((row) => row.name === column); + export const drizzleMigrationsTableExists = (sqlite: Database): boolean => { const row = sqlite .query<{ name: string }, [string]>( @@ -156,8 +181,6 @@ export const drizzleMigrationsTableExists = (sqlite: Database): boolean => { export const readAppliedDrizzleMigrationHashes = (sqlite: Database): ReadonlyArray => { if (!drizzleMigrationsTableExists(sqlite)) return []; - // Drizzle inserts one row per applied migration. `id` is the stable - // application order; `created_at` comes from migration metadata and can tie. return sqlite .query<{ hash: string }, []>("SELECT hash FROM __drizzle_migrations ORDER BY id ASC") .all() @@ -178,8 +201,6 @@ const decodeDrizzleJournal = Schema.decodeUnknownSync(Schema.fromJsonString(Driz export const readBundledDrizzleMigrationHashes = ( migrationsFolder: string, ): ReadonlyArray => { - // Keep this in sync with drizzle-orm/src/migrator.ts: Drizzle hashes the raw - // migration file contents before splitting on statement breakpoints. const journal = decodeDrizzleJournal( fs.readFileSync(join(migrationsFolder, "meta", "_journal.json")).toString(), ); @@ -192,113 +213,433 @@ export const readBundledDrizzleMigrationHashes = ( }); }; -const schemaTooNewMessage = (dataDir: string): string => - [ - `This Executor binary is older than the schema in ${dataDir}.`, - "The database was likely opened by a newer Executor build.", - "Use a newer Executor binary or set EXECUTOR_DATA_DIR to a different data directory.", - ].join("\n"); +const hasBundledDrizzleMigrationPrefix = (input: { + readonly sqlite: Database; + readonly migrationsFolder: string; +}): boolean => { + if (!drizzleMigrationsTableExists(input.sqlite)) return true; -const migrationHistoryMismatchMessage = (dataDir: string): string => - [ - `The migration history in ${dataDir} does not match this Executor build.`, - "The database may have been created by a different development branch, manually modified, or corrupted.", - "Use the matching Executor build, set EXECUTOR_DATA_DIR to a different data directory, or restore a backup.", - ].join("\n"); + const applied = readAppliedDrizzleMigrationHashes(input.sqlite); + const bundled = readBundledDrizzleMigrationHashes(input.migrationsFolder); + return ( + applied.length <= bundled.length && applied.every((hash, index) => hash === bundled[index]) + ); +}; -const resolvePluginConfigPath = (scopeDir: string): string => join(scopeDir, "executor.jsonc"); +const isFumaSqliteDatabase = (path: string): boolean => { + if (!fs.existsSync(path)) return false; + + let db: Database | null = null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: native SQLite probe treats unreadable legacy files as non-FumaDB databases + try { + db = new Database(path, { readonly: true }); + const settings = db + .query<{ name: string }, [string]>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .get(`private_${localNamespace}_settings`); + return settings !== null || sqliteTableHasColumn(db, "source", "row_id"); + } catch { + return false; + } finally { + db?.close(); + } +}; -export const checkDrizzleMigrationCompatibility = (input: { - readonly sqlite: Database; - readonly dbPath: string; - readonly dataDir: string; - readonly migrationsFolder: string; -}): Effect.Effect => - Effect.gen(function* () { - // Before running migrations, ensure the DB history is a prefix of the - // migrations bundled with this binary. This catches newer or divergent schemas - // before startup reaches arbitrary schema-dependent queries. - if (!drizzleMigrationsTableExists(input.sqlite)) return; - - const applied = readAppliedDrizzleMigrationHashes(input.sqlite); - const bundled = readBundledDrizzleMigrationHashes(input.migrationsFolder); - - if (applied.length > bundled.length) { - return yield* new LocalDatabaseSchemaTooNew({ - message: schemaTooNewMessage(input.dataDir), - dbPath: input.dbPath, - appliedMigrationCount: applied.length, - knownMigrationCount: bundled.length, +const removeSqliteFileSet = (path: string) => { + for (const suffix of ["", "-wal", "-shm"]) { + fs.rmSync(`${path}${suffix}`, { force: true }); + } +}; + +const moveSqliteFileSet = (source: string, target: string) => { + fs.renameSync(source, target); + for (const suffix of ["-wal", "-shm"]) { + if (fs.existsSync(`${source}${suffix}`)) { + fs.renameSync(`${source}${suffix}`, `${target}${suffix}`); + } + } +}; + +const moveSqliteFileSetToBackup = (path: string): string => { + const backupPath = `${path}.imported-${Date.now()}-${randomBytes(4).toString("hex")}`; + moveSqliteFileSet(path, backupPath); + return backupPath; +}; + +const writeSqliteImportMarker = ( + markerPath: string, + input: { + readonly importedRows: number; + readonly importedTables: readonly string[]; + readonly backupPath?: string; + readonly recovered?: boolean; + }, +) => { + fs.mkdirSync(dirname(markerPath), { recursive: true }); + fs.writeFileSync( + markerPath, + `${JSON.stringify({ + importedAt: new Date().toISOString(), + importedRows: input.importedRows, + importedTables: input.importedTables, + backupPath: input.backupPath, + recovered: input.recovered === true ? true : undefined, + })}\n`, + { flag: "w" }, + ); +}; + +const SqliteImportMarkerSchema = Schema.Struct({ + importedTables: Schema.optional(Schema.Array(Schema.String)), + importedRows: Schema.optional(Schema.Number), + backupPath: Schema.optional(Schema.String), + recovered: Schema.optional(Schema.Boolean), +}); + +const decodeSqliteImportMarker = Schema.decodeUnknownSync( + Schema.fromJsonString(SqliteImportMarkerSchema), +); + +const normalizeSqliteImportMarker = (decoded: typeof SqliteImportMarkerSchema.Type) => ({ + importedRows: decoded.importedRows ?? 0, + importedTables: decoded.importedTables ?? [], + backupPath: decoded.backupPath, + recovered: decoded.recovered, +}); + +type SqliteImportMarker = ReturnType; + +const readSqliteImportMarker = (markerPath: string): SqliteImportMarker | null => { + if (!fs.existsSync(markerPath)) return null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: malformed import markers are treated as incomplete so startup can re-check the database + try { + return normalizeSqliteImportMarker( + decodeSqliteImportMarker(fs.readFileSync(markerPath).toString()), + ); + } catch { + return null; + } +}; + +const pickFumaTables = (tables: FumaTables, names: ReadonlySet): FumaTables => { + const picked: FumaTables = {}; + for (const [name, table] of Object.entries(tables)) { + if (names.has(name)) picked[name] = table; + } + return picked; +}; + +const replaceSqliteFileSetWithRollback = (input: { + readonly sourcePath: string; + readonly targetPath: string; +}): string => { + const backupPath = moveSqliteFileSetToBackup(input.sourcePath); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local DB replacement must restore the original file set if the swap fails halfway + try { + moveSqliteFileSet(input.targetPath, input.sourcePath); + return backupPath; + } catch (cause) { + removeSqliteFileSet(input.sourcePath); + if (fs.existsSync(backupPath)) { + moveSqliteFileSet(backupPath, input.sourcePath); + } + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve the original replacement failure after rollback + throw cause; + } +}; + +const createLegacySecretRows = (scopeId: string, secrets: readonly LegacySecret[]) => + secrets.map((secret) => ({ + id: secret.id, + scope_id: scopeId, + name: secret.name, + provider: secret.provider, + owned_by_connection_id: null, + created_at: new Date(secret.createdAt), + })); + +interface PreparedLegacySqlite { + readonly legacySecrets: readonly LegacySecret[]; + readonly preScopeBackup?: string; +} + +const prepareLegacySqliteForFumaImport = (input: { + readonly storage: ResolvedStorage; + readonly scopeId: string; +}): PreparedLegacySqlite => { + if (!fs.existsSync(input.storage.sqlitePath) || isFumaSqliteDatabase(input.storage.sqlitePath)) { + return { legacySecrets: [] }; + } + + const legacySecrets = readLegacySecrets(input.storage.sqlitePath); + const preScopeBackup = moveAsidePreScopeDb(input.storage.sqlitePath); + if (preScopeBackup) { + console.warn( + `[executor] Pre-scope database detected; moved to ${preScopeBackup}. ` + + `Sources and tool catalogs will need to be re-added` + + (legacySecrets.length > 0 + ? ` (${legacySecrets.length} secret routing row(s) preserved).` + : "."), + ); + return { legacySecrets, preScopeBackup }; + } + + const sqlite = new Database(input.storage.sqlitePath); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: legacy migration preflight must close SQLite before the FumaDB import re-opens the file + try { + if (hasBundledDrizzleMigrationPrefix({ sqlite, migrationsFolder: MIGRATIONS_FOLDER })) { + sqlite.exec("PRAGMA journal_mode = WAL"); + migrate(drizzle(sqlite, { schema: legacyExecutorSchema }), { + migrationsFolder: MIGRATIONS_FOLDER, + }); + importLegacySecrets(sqlite, input.scopeId, legacySecrets); + } else { + console.warn( + `[executor] Local SQLite migration history in ${input.storage.dataDir} ` + + `does not match this build's bundled legacy migrations. ` + + `Skipping legacy Drizzle replay and importing the existing schema as-is.`, + ); + } + return { legacySecrets: [] }; + } finally { + sqlite.close(); + } +}; + +const importMissingMarkedTables = async (input: { + readonly storage: ResolvedStorage; + readonly marker: SqliteImportMarker; + readonly tables: FumaTables; + readonly scopeId: string; +}): Promise => { + const alreadyImported = new Set(input.marker.importedTables); + const missingTables = Object.keys(input.tables).filter((table) => !alreadyImported.has(table)); + if ( + !input.marker.backupPath || + missingTables.length === 0 || + !fs.existsSync(input.marker.backupPath) + ) { + return { imported: false, importedRows: 0, importedTables: [] }; + } + + const missingTableSet = new Set(missingTables); + const target = await createSqliteFumaDb({ + tables: input.tables, + namespace: localNamespace, + path: input.storage.sqlitePath, + }); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: late plugin-table imports must close the active SQLite handle on failure + try { + const pickedTables = pickFumaTables(input.tables, missingTableSet); + const legacyScopeIds = readLegacySqliteScopeIds({ + sqlitePath: input.marker.backupPath, + tables: pickedTables, + scopeId: input.scopeId, + }); + const result = await importSqliteDataToFuma({ + sqlitePath: input.marker.backupPath, + target: withQueryContext(target.db, { + allowedScopeIds: legacyScopeIds, + }), + tables: pickedTables, + scopeId: input.scopeId, + }); + target.sqlite.exec("PRAGMA wal_checkpoint(FULL)"); + await target.close(); + + if (result.imported) { + const importedTables = [ + ...new Set([...input.marker.importedTables, ...result.importedTables]), + ]; + writeSqliteImportMarker(input.storage.importMarkerPath, { + importedRows: input.marker.importedRows + result.importedRows, + importedTables, + backupPath: input.marker.backupPath, + recovered: input.marker.recovered, }); } - for (let index = 0; index < applied.length; index += 1) { - if (applied[index] !== bundled[index]) { - return yield* new LocalDatabaseMigrationHistoryMismatch({ - message: migrationHistoryMismatchMessage(input.dataDir), - dbPath: input.dbPath, - migrationIndex: index, - appliedHash: applied[index], - knownHash: bundled[index], + return result; + } catch (cause) { + await target.close(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve late plugin-table import failure after closing SQLite + throw cause; + } +}; + +export const importLegacySqliteIfNeeded = async (options: { + readonly storage: ResolvedStorage; + readonly tables: ReturnType; + readonly scopeId: string; +}) => { + const { storage, tables, scopeId } = options; + const targetPath = `${storage.sqlitePath}.fumadb-next`; + const marker = readSqliteImportMarker(storage.importMarkerPath); + + if (marker) { + return importMissingMarkedTables({ + storage, + marker, + tables, + scopeId, + }); + } + if (fs.existsSync(storage.importMarkerPath)) { + fs.rmSync(storage.importMarkerPath, { force: true }); + } + + if (!fs.existsSync(storage.importMarkerPath) && fs.existsSync(storage.sqlitePath)) { + if (isFumaSqliteDatabase(storage.sqlitePath)) { + writeSqliteImportMarker(storage.importMarkerPath, { + importedRows: 0, + importedTables: [], + recovered: true, + }); + } else { + const prepared = prepareLegacySqliteForFumaImport({ storage, scopeId }); + if (prepared.preScopeBackup) { + if (prepared.legacySecrets.length > 0) { + const target = await createSqliteFumaDb({ + tables, + namespace: localNamespace, + path: storage.sqlitePath, + }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: pre-scope secret import must close the fresh FumaDB handle on failure + try { + await withQueryContext(target.db, { + allowedScopeIds: new Set([scopeId]), + }).createMany("secret", createLegacySecretRows(scopeId, prepared.legacySecrets)); + target.sqlite.exec("PRAGMA wal_checkpoint(FULL)"); + } finally { + await target.close(); + } + } + writeSqliteImportMarker(storage.importMarkerPath, { + importedRows: prepared.legacySecrets.length, + importedTables: prepared.legacySecrets.length > 0 ? ["secret"] : [], + backupPath: prepared.preScopeBackup, }); + return { + imported: prepared.legacySecrets.length > 0, + importedRows: prepared.legacySecrets.length, + importedTables: prepared.legacySecrets.length > 0 ? ["secret"] : [], + backupPath: prepared.preScopeBackup, + }; } } + } + + if ( + !fs.existsSync(storage.importMarkerPath) && + !fs.existsSync(storage.sqlitePath) && + fs.existsSync(targetPath) && + isFumaSqliteDatabase(targetPath) + ) { + moveSqliteFileSet(targetPath, storage.sqlitePath); + writeSqliteImportMarker(storage.importMarkerPath, { + importedRows: 0, + importedTables: [], + recovered: true, + }); + } + + if ( + !fs.existsSync(storage.sqlitePath) || + fs.existsSync(storage.importMarkerPath) || + isFumaSqliteDatabase(storage.sqlitePath) + ) { + return { imported: false, importedRows: 0, importedTables: [] }; + } + + removeSqliteFileSet(targetPath); + + const target = await createSqliteFumaDb({ + tables, + namespace: localNamespace, + path: targetPath, }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local SQLite cutover must close and remove the temporary target database on import failure + try { + const legacyScopeIds = readLegacySqliteScopeIds({ + sqlitePath: storage.sqlitePath, + tables, + scopeId, + }); + const result = await importSqliteDataToFuma({ + sqlitePath: storage.sqlitePath, + target: withQueryContext(target.db, { + allowedScopeIds: legacyScopeIds, + }), + tables, + scopeId, + }); + target.sqlite.exec("PRAGMA wal_checkpoint(FULL)"); + await target.close(); + + if (result.imported) { + const backupPath = replaceSqliteFileSetWithRollback({ + sourcePath: storage.sqlitePath, + targetPath, + }); + writeSqliteImportMarker(storage.importMarkerPath, { + importedRows: result.importedRows, + importedTables: result.importedTables, + backupPath, + }); + return { ...result, backupPath }; + } else { + removeSqliteFileSet(targetPath); + } + return result; + } catch (cause) { + await target.close(); + removeSqliteFileSet(targetPath); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve the original import failure after temp-file cleanup + throw cause; + } +}; + const createLocalExecutorLayer = () => { - const { path: dbPath, dataDir, legacySecrets } = resolveDbPath(); + const storage = resolveStorage(); return Layer.effect(LocalExecutorTag)( Effect.gen(function* () { - const sqlite = yield* Effect.acquireRelease( - Effect.sync(() => new Database(dbPath)), - (conn) => Effect.sync(() => conn.close()), - ); - yield* checkDrizzleMigrationCompatibility({ - sqlite, - dbPath, - dataDir, - migrationsFolder: MIGRATIONS_FOLDER, + const { cwd, plugins } = yield* loadLocalPlugins; + const scopeId = makeScopeId(cwd); + const tables = collectTables(plugins); + + const importResult = yield* Effect.tryPromise({ + try: () => + importLegacySqliteIfNeeded({ + storage, + tables, + scopeId, + }), + catch: (cause) => new LocalExecutorCreateError({ operation: "importSqlite", cause }), }); - sqlite.exec("PRAGMA journal_mode = WAL"); - const db = drizzle(sqlite, { schema: executorSchema }); - migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + const sqlite = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => + createSqliteFumaDb({ + tables, + namespace: localNamespace, + path: storage.sqlitePath, + }), + catch: (cause) => new LocalExecutorCreateError({ operation: "createSqlite", cause }), + }), + (db) => Effect.promise(() => db.close()).pipe(Effect.ignore), + ); - const cwd = process.env.EXECUTOR_SCOPE_DIR || process.cwd(); - const scopeId = makeScopeId(cwd); - // Reinstate pre-scope secret routing rows once migrations have - // created the new `secret` table. INSERT OR IGNORE makes this - // safe across reboots and on fresh installs (no-op when there's - // nothing to import). - if (legacySecrets.length > 0) { - importLegacySecrets(sqlite, scopeId, legacySecrets); + if (importResult.imported) { + console.warn( + `[executor] Imported ${importResult.importedRows} row(s) into FumaDB SQLite storage` + + (importResult.backupPath ? `; moved old DB to ${importResult.backupPath}.` : "."), + ); } - const configPath = resolvePluginConfigPath(cwd); - const staticPlugins = executorConfig.plugins(); - const dynamicPlugins = - (yield* Effect.promise(() => loadPluginsFromJsonc({ path: configPath }))) ?? []; - // Static config wins on conflict — mirrors @executor-js/vite-plugin's - // ordering. Without this, a package listed in both surfaces would boot - // twice (double routes, double in-memory storage). - const staticPackageNames = new Set( - staticPlugins.map((p) => p.packageName).filter((n): n is string => !!n), - ); - const dedupedDynamic = dynamicPlugins.filter((p) => { - if (p.packageName && staticPackageNames.has(p.packageName)) { - console.warn( - `[executor] plugin "${p.packageName}" appears in both ` + - `executor.config.ts and executor.jsonc#plugins. The static ` + - `entry wins; the jsonc entry is ignored.`, - ); - return false; - } - return true; - }); - const plugins: LocalPlugins = [...staticPlugins, ...dedupedDynamic]; - const schema = collectSchemas(plugins); - const adapter = makeSqliteAdapter({ db, schema }); - const blobs = makeSqliteBlobStore({ db }); const scope = Scope.make({ id: ScopeId.make(scopeId), @@ -308,8 +649,7 @@ const createLocalExecutorLayer = () => { const executor = yield* createExecutor({ scopes: [scope], - adapter, - blobs, + db: sqlite.db, plugins, onElicitation: "accept-all", oauthEndpointUrlPolicy: { allowHttp: true }, diff --git a/apps/local/src/server/mcp-oauth.test.ts b/apps/local/src/server/mcp-oauth.test.ts index ddd8fde30..5f1b5e710 100644 --- a/apps/local/src/server/mcp-oauth.test.ts +++ b/apps/local/src/server/mcp-oauth.test.ts @@ -25,9 +25,6 @@ import { createHash, randomBytes } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { HttpApi, HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; @@ -37,14 +34,13 @@ import { addGroup, observabilityMiddleware } from "@executor-js/api"; import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; -import { Scope, ScopeId, collectSchemas, createExecutor } from "@executor-js/sdk"; -import { makeSqliteAdapter, makeSqliteBlobStore } from "@executor-js/storage-file"; +import { Scope, ScopeId, collectTables, createExecutor } from "@executor-js/sdk"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { mcpPlugin } from "@executor-js/plugin-mcp"; import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; -import * as executorSchema from "./executor-schema"; import { ErrorCaptureLive } from "./observability"; +import { createSqliteFumaDb } from "./sqlite-fumadb"; // Shape of the test API: core + mcp group, with InternalError surfaced at // the top level so `observabilityMiddleware` can land its typed-error @@ -219,11 +215,10 @@ const startFakeServer = async (): Promise => { }; // --------------------------------------------------------------------------- -// In-process local API harness — tmpdir sqlite + minimal plugin set. +// In-process local API harness — tmpdir SQLite + minimal plugin set. // --------------------------------------------------------------------------- const TEST_BASE_URL = "http://local.test"; -const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); interface Harness { readonly fetch: typeof globalThis.fetch; @@ -232,19 +227,16 @@ interface Harness { } const startHarness = async (tmpDir: string): Promise => { - const sqlite = new Database(join(tmpDir, "data.db")); - sqlite.exec("PRAGMA journal_mode = WAL"); - const db = drizzle(sqlite, { schema: executorSchema }); - migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); - const scopeId = `test-${randomBytes(4).toString("hex")}`; const plugins = [ mcpPlugin({ dangerouslyAllowStdioMCP: false }), fileSecretsPlugin({ directory: tmpDir }), ] as const; - const schema = collectSchemas(plugins); - const adapter = makeSqliteAdapter({ db, schema }); - const blobs = makeSqliteBlobStore({ db }); + const sqlite = await createSqliteFumaDb({ + tables: collectTables(plugins), + namespace: "executor_local_test", + path: join(tmpDir, "data.db"), + }); const scope = Scope.make({ id: ScopeId.make(scopeId), @@ -255,8 +247,7 @@ const startHarness = async (tmpDir: string): Promise => { const executor = await Effect.runPromise( createExecutor({ scopes: [scope], - adapter, - blobs, + db: sqlite.db, plugins, onElicitation: "accept-all", oauthEndpointUrlPolicy: { allowHttp: true }, @@ -300,7 +291,7 @@ const startHarness = async (tmpDir: string): Promise => { await Effect.runPromise( Effect.ignore(Effect.tryPromise(() => Effect.runPromise(executor.close()))), ); - sqlite.close(); + await sqlite.close(); }, }; }; diff --git a/apps/local/src/server/migrate-connections.test.ts b/apps/local/src/server/migrate-connections.test.ts deleted file mode 100644 index 55896fdbb..000000000 --- a/apps/local/src/server/migrate-connections.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Database } from "bun:sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { Schema } from "effect"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { migrateLegacyConnections } from "./migrate-connections"; - -let workDir: string; -let databases: Array; - -beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), "executor-migrate-connections-")); - databases = []; -}); - -afterEach(() => { - for (const db of databases) { - db.close(); - } - rmSync(workDir, { recursive: true, force: true }); -}); - -const openDatabase = (): Database => { - const db = new Database(join(workDir, "data.db")); - databases.push(db); - return db; -}; - -const columnNames = (db: Database, table: string): ReadonlyArray => - ( - db.prepare(`PRAGMA table_info('${table}')`).all() as ReadonlyArray<{ - readonly name: string; - }> - ).map((column) => column.name); - -const MigratedMcpConfig = Schema.Struct({ - auth: Schema.optional(Schema.Unknown), -}); -const decodeMigratedMcpConfig = Schema.decodeUnknownSync(Schema.fromJsonString(MigratedMcpConfig)); - -const MigratedOpenApiOAuth2 = Schema.Struct({ - kind: Schema.Literal("oauth2"), - clientIdSlot: Schema.String, - clientSecretSlot: Schema.NullOr(Schema.String), - connectionSlot: Schema.String, -}); -const decodeMigratedOpenApiOAuth2 = Schema.decodeUnknownSync( - Schema.fromJsonString(MigratedOpenApiOAuth2), -); - -const CredentialBindingRow = Schema.Struct({ - slot_key: Schema.String, - kind: Schema.String, - secret_id: Schema.NullOr(Schema.String), - connection_id: Schema.NullOr(Schema.String), -}); -const decodeCredentialBindingRows = Schema.decodeUnknownSync(Schema.Array(CredentialBindingRow)); - -const ConnectionProviderStateRow = Schema.Struct({ - provider_state: Schema.String, -}); -const decodeConnectionProviderStateRow = Schema.decodeUnknownSync(ConnectionProviderStateRow); -const decodeJsonRecord = Schema.decodeUnknownSync( - Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), -); - -describe("migrateLegacyConnections", () => { - it("backfills legacy MCP OAuth rows after connection.kind has been dropped", async () => { - const db = openDatabase(); - migrate(drizzle(db), { - migrationsFolder: join(import.meta.dirname, "../../drizzle"), - }); - - expect(columnNames(db, "connection")).not.toContain("kind"); - - const now = Date.now(); - db.prepare( - "INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)", - ).run("scope-1", "access-token", "Access token", "keychain", now); - db.prepare( - "INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)", - ).run("scope-1", "refresh-token", "Refresh token", "keychain", now); - db.prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ).run( - "scope-1", - "remote-mcp", - "Remote MCP", - JSON.stringify({ - transport: "remote", - endpoint: "https://example.com/mcp", - auth: { - kind: "oauth2", - accessTokenSecretId: "access-token", - refreshTokenSecretId: "refresh-token", - tokenType: "Bearer", - expiresAt: null, - scope: "read", - clientInformation: { - client_id: "legacy-client", - token_endpoint_auth_method: "none", - }, - tokenEndpoint: "https://auth.example.com/token", - authorizationServerUrl: null, - authorizationServerMetadata: null, - resourceMetadataUrl: null, - }, - }), - now, - ); - - await migrateLegacyConnections(db); - - const connection = db - .prepare( - "SELECT id, provider, access_token_secret_id, refresh_token_secret_id FROM connection WHERE scope_id = ?", - ) - .get("scope-1") as - | { - readonly id: string; - readonly provider: string; - readonly access_token_secret_id: string; - readonly refresh_token_secret_id: string | null; - } - | undefined; - expect(connection).toEqual({ - id: "mcp-oauth2-remote-mcp", - provider: "oauth2", - access_token_secret_id: "access-token", - refresh_token_secret_id: "refresh-token", - }); - const providerState = decodeJsonRecord( - decodeConnectionProviderStateRow( - db.prepare("SELECT provider_state FROM connection WHERE scope_id = ?").get("scope-1"), - ).provider_state, - ); - expect(providerState).toMatchObject({ - kind: "dynamic-dcr", - tokenEndpoint: "https://auth.example.com/token", - clientId: "legacy-client", - resource: "https://example.com/mcp", - }); - - // The canonical auth lives in a source-owned slot. The migrator - // strips config.auth, stamps the slot on mcp_source, and writes the - // concrete connection id to credential_binding. - const source = db - .prepare( - "SELECT config, auth_kind, auth_connection_slot FROM mcp_source WHERE scope_id = ? AND id = ?", - ) - .get("scope-1", "remote-mcp") as { - readonly config: string; - readonly auth_kind: string; - readonly auth_connection_slot: string; - }; - expect(decodeMigratedMcpConfig(source.config).auth).toBeUndefined(); - expect(source.auth_kind).toBe("oauth2"); - expect(source.auth_connection_slot).toBe("auth:oauth2:connection"); - - const connectionBindings = decodeCredentialBindingRows( - db - .prepare( - "SELECT slot_key, kind, secret_id, connection_id FROM credential_binding WHERE plugin_id = ? AND source_id = ?", - ) - .all("mcp", "remote-mcp"), - ); - expect(connectionBindings).toEqual([ - { - slot_key: "auth:oauth2:connection", - kind: "connection", - secret_id: null, - connection_id: "mcp-oauth2-remote-mcp", - }, - ]); - - const ownedSecrets = db - .prepare("SELECT id, owned_by_connection_id FROM secret WHERE scope_id = ? ORDER BY id") - .all("scope-1"); - expect(ownedSecrets).toEqual([ - { - id: "access-token", - owned_by_connection_id: "mcp-oauth2-remote-mcp", - }, - { - id: "refresh-token", - owned_by_connection_id: "mcp-oauth2-remote-mcp", - }, - ]); - }); - - it("fails and rolls back legacy MCP OAuth rows when token secrets are already owned", async () => { - const db = openDatabase(); - migrate(drizzle(db), { - migrationsFolder: join(import.meta.dirname, "../../drizzle"), - }); - - const now = Date.now(); - db.prepare( - `INSERT INTO connection ( - scope_id, id, provider, identity_label, - access_token_secret_id, refresh_token_secret_id, - expires_at, scope, provider_state, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - "scope-1", - "existing-connection", - "oauth2", - "Existing", - "access-token", - null, - null, - null, - "{}", - now, - now, - ); - db.prepare( - `INSERT INTO secret ( - scope_id, id, name, provider, created_at, owned_by_connection_id - ) VALUES (?, ?, ?, ?, ?, ?)`, - ).run("scope-1", "access-token", "Access token", "keychain", now, "existing-connection"); - db.prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ).run( - "scope-1", - "remote-mcp", - "Remote MCP", - JSON.stringify({ - transport: "remote", - endpoint: "https://example.com/mcp", - auth: { - kind: "oauth2", - accessTokenSecretId: "access-token", - refreshTokenSecretId: null, - tokenType: "Bearer", - expiresAt: null, - scope: "read", - tokenEndpoint: "https://auth.example.com/token", - }, - }), - now, - ); - - await expect(migrateLegacyConnections(db)).rejects.toThrow("secret(s) already owned"); - - expect( - db - .prepare("SELECT id FROM connection WHERE scope_id = ? AND id = ?") - .get("scope-1", "mcp-oauth2-remote-mcp"), - ).toBeNull(); - expect( - db - .prepare( - "SELECT connection_id FROM credential_binding WHERE scope_id = ? AND source_id = ?", - ) - .all("scope-1", "remote-mcp"), - ).toEqual([]); - - const source = db - .prepare("SELECT config FROM mcp_source WHERE scope_id = ? AND id = ?") - .get("scope-1", "remote-mcp") as { readonly config: string }; - expect(decodeMigratedMcpConfig(source.config).auth).toBeDefined(); - }); - - it("backfills legacy OpenAPI OAuth from oauth2 column after invocation_config has been dropped", async () => { - const db = openDatabase(); - migrate(drizzle(db), { - migrationsFolder: join(import.meta.dirname, "../../drizzle"), - }); - - expect(columnNames(db, "openapi_source")).not.toContain("invocation_config"); - - const now = Date.now(); - db.prepare( - "INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)", - ).run("scope-1", "client-id", "Client ID", "keychain", now); - db.prepare( - "INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)", - ).run("scope-1", "client-secret", "Client secret", "keychain", now); - db.prepare( - "INSERT INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)", - ).run("scope-1", "access-token", "Access token", "keychain", now); - - db.prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, oauth2) VALUES (?, ?, ?, ?, ?)", - ).run( - "scope-1", - "legacy-openapi", - "Legacy OpenAPI", - JSON.stringify({ - openapi: "3.0.0", - info: { title: "Legacy", version: "1" }, - paths: {}, - components: { - securitySchemes: { - oauth2: { - type: "oauth2", - flows: { - clientCredentials: { - tokenUrl: "https://example.com/oauth/token", - scopes: { read: "read" }, - }, - }, - }, - }, - }, - }), - JSON.stringify({ - kind: "oauth2", - securitySchemeName: "oauth2", - flow: "clientCredentials", - tokenUrl: "https://example.com/oauth/token", - clientIdSecretId: "client-id", - clientSecretSecretId: "client-secret", - accessTokenSecretId: "access-token", - refreshTokenSecretId: null, - tokenType: "Bearer", - expiresAt: null, - scope: "read", - scopes: ["read"], - }), - ); - - await migrateLegacyConnections(db); - - const connection = db - .prepare("SELECT id, provider, access_token_secret_id FROM connection WHERE scope_id = ?") - .get("scope-1") as - | { readonly id: string; readonly provider: string; readonly access_token_secret_id: string } - | undefined; - expect(connection?.provider).toBe("oauth2"); - expect(connection?.access_token_secret_id).toBe("access-token"); - const providerState = decodeJsonRecord( - decodeConnectionProviderStateRow( - db.prepare("SELECT provider_state FROM connection WHERE scope_id = ?").get("scope-1"), - ).provider_state, - ); - expect(providerState).toMatchObject({ - kind: "client-credentials", - tokenEndpoint: "https://example.com/oauth/token", - clientIdSecretId: "client-id", - clientSecretSecretId: "client-secret", - scopes: ["read"], - scope: "read", - }); - - // The oauth2 column should now hold source-owned slot structure. Concrete - // secrets and the live connection id live in core credential_binding rows. - const source = db - .prepare("SELECT oauth2 FROM openapi_source WHERE scope_id = ? AND id = ?") - .get("scope-1", "legacy-openapi") as { readonly oauth2: string }; - const oauth2 = decodeMigratedOpenApiOAuth2(source.oauth2); - expect(oauth2.kind).toBe("oauth2"); - expect(oauth2.clientIdSlot).toBe("oauth2:oauth2:client-id"); - expect(oauth2.clientSecretSlot).toBe("oauth2:oauth2:client-secret"); - expect(oauth2.connectionSlot).toBe("oauth2:oauth2:connection"); - - const bindings = decodeCredentialBindingRows( - db - .prepare( - "SELECT slot_key, kind, secret_id, connection_id FROM credential_binding WHERE scope_id = ? AND source_id = ? ORDER BY slot_key", - ) - .all("scope-1", "legacy-openapi"), - ); - expect( - bindings.map((row) => [row.slot_key, row.kind, row.secret_id, row.connection_id]), - ).toEqual([ - ["oauth2:oauth2:client-id", "secret", "client-id", null], - ["oauth2:oauth2:client-secret", "secret", "client-secret", null], - ["oauth2:oauth2:connection", "connection", null, connection?.id], - ]); - }); - - it("fails legacy OpenAPI auth-code rows that cannot produce an authorization endpoint", async () => { - const db = openDatabase(); - migrate(drizzle(db), { - migrationsFolder: join(import.meta.dirname, "../../drizzle"), - }); - - db.prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, oauth2) VALUES (?, ?, ?, ?, ?)", - ).run( - "scope-1", - "broken-openapi", - "Broken OpenAPI", - JSON.stringify({ - openapi: "3.0.0", - info: { title: "Broken", version: "1" }, - paths: {}, - }), - JSON.stringify({ - kind: "oauth2", - securitySchemeName: "oauth2", - flow: "authorizationCode", - tokenUrl: "https://example.com/oauth/token", - clientIdSecretId: "client-id", - clientSecretSecretId: null, - accessTokenSecretId: "access-token", - refreshTokenSecretId: "refresh-token", - tokenType: "Bearer", - expiresAt: null, - scope: "read", - scopes: ["read"], - }), - ); - - await expect(migrateLegacyConnections(db)).rejects.toThrow("authorizationUrl unavailable"); - }); - - it("fails legacy MCP OAuth rows that cannot produce a token endpoint", async () => { - const db = openDatabase(); - migrate(drizzle(db), { - migrationsFolder: join(import.meta.dirname, "../../drizzle"), - }); - - db.prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ).run( - "scope-1", - "broken-mcp", - "Broken MCP", - JSON.stringify({ - transport: "remote", - endpoint: "https://example.com/mcp", - auth: { - kind: "oauth2", - accessTokenSecretId: "access-token", - refreshTokenSecretId: null, - tokenType: "Bearer", - expiresAt: null, - scope: "read", - }, - }), - Date.now(), - ); - - await expect(migrateLegacyConnections(db)).rejects.toThrow("token endpoint unavailable"); - }); -}); diff --git a/apps/local/src/server/migrate-connections.ts b/apps/local/src/server/migrate-connections.ts deleted file mode 100644 index 7a8b26b78..000000000 --- a/apps/local/src/server/migrate-connections.ts +++ /dev/null @@ -1,782 +0,0 @@ -// --------------------------------------------------------------------------- -// OAuth legacy → Connection backfill (local) -// --------------------------------------------------------------------------- -// -// Explicit one-shot helper for rows still on the pre-refactor inline-OAuth -// shape (openapi_source, mcp_source, google_discovery_source). It mints a -// Connection row, re-parents the referenced secret(s), and rewrites the -// source's stored auth to the new pointer shape. Normal runtime startup must -// not call this helper; runtime code assumes Drizzle migrations have already -// produced the final model. -// -// Self-contained: the only plugin imports are current-shape parsing -// helpers. Each legacy shape is defined inline — this file is the last -// place in the codebase that still needs to know about them. - -import { Database } from "bun:sqlite"; -import { randomUUID } from "node:crypto"; -import { Effect, Option, Result, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { - parse as parseOpenApi, - resolveSpecText, - OAuth2SourceConfig, -} from "@executor-js/plugin-openapi"; -import { McpConnectionAuth } from "@executor-js/plugin-mcp"; -import { discoverAuthorizationServerMetadata, OAUTH2_PROVIDER_KEY } from "@executor-js/sdk"; - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -const isRecord = (v: unknown): v is Record => - typeof v === "object" && v !== null && !Array.isArray(v); -const isString = (v: unknown): v is string => typeof v === "string"; -const stringArray = (value: unknown): readonly string[] => - Array.isArray(value) ? value.filter((scope): scope is string => typeof scope === "string") : []; - -const originOrNull = (value: string | null): string | null => { - if (!value || !URL.canParse(value)) return null; - return new URL(value).origin; -}; - -const slotPart = (value: string): string => - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "default"; - -const openApiOauth2ClientIdSlot = (securitySchemeName: string): string => - `oauth2:${slotPart(securitySchemeName)}:client-id`; -const openApiOauth2ClientSecretSlot = (securitySchemeName: string): string => - `oauth2:${slotPart(securitySchemeName)}:client-secret`; -const openApiOauth2ConnectionSlot = (securitySchemeName: string): string => - `oauth2:${slotPart(securitySchemeName)}:connection`; - -const JsonObject = Schema.Record(Schema.String, Schema.Unknown); -const JsonObjectFromString = Schema.fromJsonString(JsonObject); - -const decodeUnknownOptionAs = (schema: Schema.Decoder) => { - // oxlint-disable-next-line executor/no-inline-schema-compile -- schema bound by parameter; compiler hoisted into closure - const decode = Schema.decodeUnknownOption(schema); - return (input: unknown): Option.Option => decode(input); -}; - -const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString); - -const failUnmigratableConnection = (message: string): never => { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: one-shot Promise migration must abort when legacy OAuth cannot be represented - throw new Error(message); -}; - -/** Pre-flight: bail unless the drizzle migration that added the Connection - * table + `secret.owned_by_connection_id` has completed. */ -const connectionsReady = (sqlite: Database): boolean => { - const secretColumns = sqlite.prepare("PRAGMA table_info('secret')").all() as ReadonlyArray<{ - readonly name: string; - }>; - if (!secretColumns.some((c) => c.name === "owned_by_connection_id")) return false; - const connectionTable = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='connection'") - .get(); - return connectionTable !== null && connectionTable !== undefined; -}; - -const tableExists = (sqlite: Database, name: string): boolean => { - const row = sqlite - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?") - .get(name); - return row !== null && row !== undefined; -}; - -const columnExists = (sqlite: Database, table: string, column: string): boolean => { - const columns = sqlite - .prepare(`PRAGMA table_info('${table.replaceAll("'", "''")}')`) - .all() as ReadonlyArray<{ readonly name: string }>; - return columns.some((c) => c.name === column); -}; - -type SecretRow = { id: string; owned_by_connection_id: string | null }; - -/** Shared: re-parent the pointed-to secret ids to the new connection, - * backfilling any missing routing rows. Returns `null` on success, an - * error message string on skip. */ -const rewireSecrets = ( - sqlite: Database, - scopeId: string, - connectionId: string, - secretIds: ReadonlyArray, - namesByIndex: ReadonlyArray, -): string | null => { - const selectSecret = sqlite.prepare( - "SELECT id, owned_by_connection_id FROM secret WHERE scope_id = ? AND id = ?", - ); - const selectAnySecretProvider = sqlite.prepare( - "SELECT provider FROM secret WHERE scope_id = ? LIMIT 1", - ); - const updateSecretOwner = sqlite.prepare( - "UPDATE secret SET owned_by_connection_id = ? WHERE scope_id = ? AND id = ?", - ); - const insertSecret = sqlite.prepare( - `INSERT INTO secret ( - id, scope_id, provider, name, - owned_by_connection_id, created_at - ) VALUES (?, ?, ?, ?, ?, ?)`, - ); - - const rows = secretIds.map((sid) => selectSecret.get(scopeId, sid) as SecretRow | undefined); - const alreadyOwned = rows - .filter((r): r is SecretRow => !!r) - .filter((r) => r.owned_by_connection_id !== null && r.owned_by_connection_id !== connectionId); - if (alreadyOwned.length > 0) return "secret(s) already owned"; - - // Early-onboarded rows never got a `secret` routing row — pre-refactor - // `secretsGet` resolved them via provider enumeration. Pick the - // provider already in use at this scope (or fall back to keychain) so - // the new id-indexed fast path resolves. If we guess wrong the SDK's - // enumerate-fallback still works. - const missingCount = rows.filter((r) => r === undefined).length; - let fallbackProvider: string | null = null; - if (missingCount > 0) { - const existing = selectAnySecretProvider.get(scopeId) as { provider: string } | undefined; - fallbackProvider = existing?.provider ?? "keychain"; - } - - const now = Date.now(); - for (let i = 0; i < secretIds.length; i++) { - const sid = secretIds[i]!; - if (rows[i] === undefined) { - insertSecret.run(sid, scopeId, fallbackProvider!, namesByIndex[i]!, connectionId, now); - } else { - updateSecretOwner.run(connectionId, scopeId, sid); - } - } - return null; -}; - -const insertConnectionRow = ( - sqlite: Database, - params: { - id: string; - scopeId: string; - provider: string; - identityLabel: string; - accessTokenSecretId: string; - refreshTokenSecretId: string | null; - expiresAt: number | null; - scope: string | null; - providerState: unknown; - }, -): void => { - const hasKind = columnExists(sqlite, "connection", "kind"); - const stmt = hasKind - ? sqlite.prepare( - `INSERT INTO connection ( - id, scope_id, provider, kind, identity_label, - access_token_secret_id, refresh_token_secret_id, - expires_at, scope, provider_state, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - : sqlite.prepare( - `INSERT INTO connection ( - id, scope_id, provider, identity_label, - access_token_secret_id, refresh_token_secret_id, - expires_at, scope, provider_state, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - const now = Date.now(); - const values = [ - params.id, - params.scopeId, - params.provider, - ...(hasKind ? ["user"] : []), - params.identityLabel, - params.accessTokenSecretId, - params.refreshTokenSecretId, - params.expiresAt, - params.scope, - JSON.stringify(params.providerState), - now, - now, - ]; - stmt.run(...values); -}; - -const insertCredentialBinding = ( - sqlite: Database, - params: { - pluginId: string; - scopeId: string; - sourceId: string; - slotKey: string; - kind: "secret" | "connection"; - secretId?: string; - connectionId?: string; - }, -): void => { - if (!tableExists(sqlite, "credential_binding")) return; - const now = Date.now(); - sqlite - .prepare( - `INSERT OR REPLACE INTO credential_binding ( - id, scope_id, plugin_id, source_id, source_scope_id, slot_key, - kind, text_value, secret_id, connection_id, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)`, - ) - .run( - JSON.stringify([params.pluginId, params.scopeId, params.sourceId, params.slotKey]), - params.scopeId, - params.pluginId, - params.sourceId, - params.scopeId, - params.slotKey, - params.kind, - params.secretId ?? null, - params.connectionId ?? null, - now, - now, - ); -}; - -const insertOpenApiCredentialBinding = ( - sqlite: Database, - params: Omit[1], "pluginId">, -): void => insertCredentialBinding(sqlite, { pluginId: "openapi", ...params }); - -// --------------------------------------------------------------------------- -// OpenAPI — legacy shape -// --------------------------------------------------------------------------- - -const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentials"]); - -const LegacyOpenApiOAuth2 = Schema.Struct({ - kind: Schema.Literal("oauth2"), - securitySchemeName: Schema.String, - flow: OAuth2Flow, - tokenUrl: Schema.String, - clientIdSecretId: Schema.String, - clientSecretSecretId: Schema.NullOr(Schema.String), - accessTokenSecretId: Schema.String, - refreshTokenSecretId: Schema.NullOr(Schema.String), - tokenType: Schema.String, - expiresAt: Schema.NullOr(Schema.Number), - scope: Schema.NullOr(Schema.String), - scopes: Schema.Array(Schema.String), -}); -type LegacyOpenApiOAuth2 = typeof LegacyOpenApiOAuth2.Type; - -const decodeOpenApiCurrent = Schema.decodeUnknownOption(OAuth2SourceConfig); -const decodeOpenApiLegacy = decodeUnknownOptionAs(LegacyOpenApiOAuth2); - -const extractAuthorizationUrl = async ( - rawSpec: string, - securitySchemeName: string, - flow: "authorizationCode" | "clientCredentials", -): Promise => { - if (flow === "clientCredentials") return null; - const parsed = await Effect.runPromise( - resolveSpecText(rawSpec).pipe( - Effect.flatMap((text) => parseOpenApi(text)), - Effect.provide(FetchHttpClient.layer), - Effect.result, - ), - ); - if (Result.isFailure(parsed)) return null; - const spec = parsed.success as unknown; - if (!isRecord(spec)) return null; - const components = isRecord(spec.components) ? spec.components : null; - const schemes = - components && isRecord(components.securitySchemes) ? components.securitySchemes : null; - const scheme = - schemes && isRecord(schemes[securitySchemeName]) - ? (schemes[securitySchemeName] as Record) - : null; - const flows = scheme && isRecord(scheme.flows) ? scheme.flows : null; - const flowObj = - flows && isRecord(flows.authorizationCode) - ? (flows.authorizationCode as Record) - : null; - return flowObj && isString(flowObj.authorizationUrl) ? flowObj.authorizationUrl : null; -}; - -type OpenApiRow = { - scope_id: string; - id: string; - name: string; - spec: string; - invocation_config: string | null; - oauth2: string | null; -}; - -const migrateOpenApi = async (sqlite: Database): Promise => { - if (!tableExists(sqlite, "openapi_source")) return; - // After the plugin normalization migration, `invocation_config` is - // gone (specFetchCredentials moved to child tables). The `oauth2` - // column stays JSON — that's the canonical source for the OAuth - // pointer. Pre-migration, both columns mirror each other; post- - // migration, only `oauth2` is left. We read both when available and - // fall back to `oauth2` so legacy data isn't silently skipped. - const hasInvocationConfig = columnExists(sqlite, "openapi_source", "invocation_config"); - const selectCols = hasInvocationConfig - ? "scope_id, id, name, spec, invocation_config, oauth2" - : "scope_id, id, name, spec, NULL AS invocation_config, oauth2"; - const rows = sqlite - .prepare(`SELECT ${selectCols} FROM openapi_source`) - .all() as ReadonlyArray; - if (rows.length === 0) return; - - const updateSource = hasInvocationConfig - ? sqlite.prepare( - "UPDATE openapi_source SET oauth2 = ?, invocation_config = ? WHERE scope_id = ? AND id = ?", - ) - : sqlite.prepare("UPDATE openapi_source SET oauth2 = ? WHERE scope_id = ? AND id = ?"); - - for (const row of rows) { - let invocation: Record = {}; - if (row.invocation_config) { - const parsed = decodeJsonObjectString(row.invocation_config); - if (Option.isNone(parsed)) continue; - invocation = parsed.value; - } - let oauth2Col: unknown = null; - if (row.oauth2) { - const parsed = decodeJsonObjectString(row.oauth2); - if (Option.isSome(parsed)) oauth2Col = parsed.value; - } - const primary = invocation.oauth2 ?? oauth2Col; - if (primary == null) continue; - if (Option.isSome(decodeOpenApiCurrent(primary))) continue; - - const legacyOption = decodeOpenApiLegacy(primary); - if (Option.isNone(legacyOption)) continue; - const legacy = legacyOption.value; - - const authorizationUrl = await extractAuthorizationUrl( - row.spec, - legacy.securitySchemeName, - legacy.flow, - ); - if (legacy.flow === "authorizationCode" && authorizationUrl === null) { - failUnmigratableConnection( - `[migrate-connections] openapi ${row.scope_id}/${row.id}: authorizationCode flow but authorizationUrl unavailable`, - ); - } - if (legacy.flow === "clientCredentials" && legacy.clientSecretSecretId === null) { - failUnmigratableConnection( - `[migrate-connections] openapi ${row.scope_id}/${row.id}: clientCredentials flow without client secret`, - ); - } - - const connectionId = `openapi-oauth2-${randomUUID()}`; - const providerState = - legacy.flow === "authorizationCode" - ? { - kind: "authorization-code" as const, - tokenEndpoint: legacy.tokenUrl, - issuerUrl: originOrNull(authorizationUrl), - clientIdSecretId: legacy.clientIdSecretId, - clientSecretSecretId: legacy.clientSecretSecretId, - clientAuth: "body" as const, - scopes: legacy.scopes, - scope: legacy.scope, - } - : { - kind: "client-credentials" as const, - tokenEndpoint: legacy.tokenUrl, - clientIdSecretId: legacy.clientIdSecretId, - clientSecretSecretId: legacy.clientSecretSecretId, - scopes: legacy.scopes, - clientAuth: "body" as const, - scope: legacy.scope, - }; - const clientIdSlot = openApiOauth2ClientIdSlot(legacy.securitySchemeName); - const clientSecretSlot = - legacy.clientSecretSecretId === null - ? null - : openApiOauth2ClientSecretSlot(legacy.securitySchemeName); - const connectionSlot = openApiOauth2ConnectionSlot(legacy.securitySchemeName); - const oauth2Pointer = { - kind: "oauth2" as const, - securitySchemeName: legacy.securitySchemeName, - flow: legacy.flow, - tokenUrl: legacy.tokenUrl, - authorizationUrl, - clientIdSlot, - clientSecretSlot, - connectionSlot, - scopes: legacy.scopes, - }; - - const secretIds = [legacy.accessTokenSecretId]; - const secretNames = [`Connection ${connectionId} access token`]; - if (legacy.refreshTokenSecretId) { - secretIds.push(legacy.refreshTokenSecretId); - secretNames.push(`Connection ${connectionId} refresh token`); - } - - const txn = sqlite.transaction(() => { - insertConnectionRow(sqlite, { - id: connectionId, - scopeId: row.scope_id, - provider: OAUTH2_PROVIDER_KEY, - identityLabel: row.name, - accessTokenSecretId: legacy.accessTokenSecretId, - refreshTokenSecretId: legacy.refreshTokenSecretId, - expiresAt: legacy.expiresAt, - scope: legacy.scope, - providerState, - }); - const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames); - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: bun:sqlite transaction callback must throw to roll back - if (err) throw new Error(err); - insertOpenApiCredentialBinding(sqlite, { - scopeId: row.scope_id, - sourceId: row.id, - slotKey: clientIdSlot, - kind: "secret", - secretId: legacy.clientIdSecretId, - }); - if (legacy.clientSecretSecretId !== null && clientSecretSlot !== null) { - insertOpenApiCredentialBinding(sqlite, { - scopeId: row.scope_id, - sourceId: row.id, - slotKey: clientSecretSlot, - kind: "secret", - secretId: legacy.clientSecretSecretId, - }); - } - insertOpenApiCredentialBinding(sqlite, { - scopeId: row.scope_id, - sourceId: row.id, - slotKey: connectionSlot, - kind: "connection", - connectionId, - }); - if (hasInvocationConfig) { - const nextInvocation = { ...invocation, oauth2: oauth2Pointer }; - updateSource.run( - JSON.stringify(oauth2Pointer), - JSON.stringify(nextInvocation), - row.scope_id, - row.id, - ); - } else { - updateSource.run(JSON.stringify(oauth2Pointer), row.scope_id, row.id); - } - }); - txn(); - console.log(`[migrate-connections] openapi ${row.scope_id}/${row.id} -> ${connectionId}`); - } -}; - -// --------------------------------------------------------------------------- -// MCP — legacy shape -// --------------------------------------------------------------------------- - -const LegacyMcpOAuth2 = Schema.Struct({ - kind: Schema.Literal("oauth2"), - accessTokenSecretId: Schema.String, - refreshTokenSecretId: Schema.NullOr(Schema.String), - tokenType: Schema.String.pipe( - Schema.optional, - Schema.withDecodingDefaultType(Effect.succeed("Bearer")), - ), - expiresAt: Schema.NullOr(Schema.Number), - scope: Schema.NullOr(Schema.String), - clientInformation: Schema.NullOr(JsonObject).pipe( - Schema.optional, - Schema.withDecodingDefaultType(Effect.succeed(null)), - ), - tokenEndpoint: Schema.NullOr(Schema.String).pipe( - Schema.optional, - Schema.withDecodingDefaultType(Effect.succeed(null)), - ), - authorizationServerUrl: Schema.NullOr(Schema.String).pipe( - Schema.optional, - Schema.withDecodingDefaultType(Effect.succeed(null)), - ), - authorizationServerMetadata: Schema.NullOr(JsonObject).pipe( - Schema.optional, - Schema.withDecodingDefaultType(Effect.succeed(null)), - ), - resourceMetadataUrl: Schema.NullOr(Schema.String).pipe( - Schema.optional, - Schema.withDecodingDefaultType(Effect.succeed(null)), - ), -}); - -const decodeMcpCurrent = Schema.decodeUnknownOption(McpConnectionAuth); -type LegacyMcpOAuth2Type = typeof LegacyMcpOAuth2.Type; -const decodeMcpLegacy = decodeUnknownOptionAs(LegacyMcpOAuth2); - -const resolveMcpTokenEndpoint = async (legacy: LegacyMcpOAuth2Type): Promise => { - if (legacy.tokenEndpoint) return legacy.tokenEndpoint; - const metadata = legacy.authorizationServerMetadata; - if (metadata && isString(metadata.token_endpoint)) return metadata.token_endpoint; - if (!legacy.authorizationServerUrl) return null; - const discovered = await Effect.runPromise( - discoverAuthorizationServerMetadata(legacy.authorizationServerUrl).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.result, - ), - ); - if (Result.isFailure(discovered)) return null; - return discovered.success?.metadata.token_endpoint ?? null; -}; - -type McpRow = { - scope_id: string; - id: string; - name: string; - config: string; -}; - -const migrateMcp = async (sqlite: Database): Promise => { - if (!tableExists(sqlite, "mcp_source")) return; - const rows = sqlite - .prepare("SELECT scope_id, id, name, config FROM mcp_source") - .all() as ReadonlyArray; - if (rows.length === 0) return; - - // Drizzle migrations normalize current MCP auth first, then this - // one-shot backfill handles older inline OAuth rows that still have - // accessTokenSecretId in config.auth. The final model is auth slots - // on mcp_source plus a core credential_binding row for the connection. - const hasAuthSlotColumns = columnExists(sqlite, "mcp_source", "auth_connection_slot"); - const updateConfig = sqlite.prepare( - "UPDATE mcp_source SET config = ? WHERE scope_id = ? AND id = ?", - ); - const updateConfigAndAuth = hasAuthSlotColumns - ? sqlite.prepare( - "UPDATE mcp_source SET config = ?, auth_kind = 'oauth2', auth_connection_slot = 'auth:oauth2:connection' WHERE scope_id = ? AND id = ?", - ) - : null; - - for (const row of rows) { - const parsedConfig = decodeJsonObjectString(row.config); - if (Option.isNone(parsedConfig)) continue; - const config = parsedConfig.value; - if (config.transport !== "remote") continue; - const auth = config.auth; - if (!isRecord(auth) || auth.kind !== "oauth2") continue; - - if (Option.isSome(decodeMcpCurrent(auth))) continue; - - const legacyOption = decodeMcpLegacy(auth); - if (Option.isNone(legacyOption)) continue; - const legacy = legacyOption.value; - - const endpoint = typeof config.endpoint === "string" ? config.endpoint : null; - if (!endpoint) { - failUnmigratableConnection( - `[migrate-connections] mcp ${row.scope_id}/${row.id}: endpoint missing`, - ); - } - const tokenEndpoint = await resolveMcpTokenEndpoint(legacy); - if (!tokenEndpoint) { - failUnmigratableConnection( - `[migrate-connections] mcp ${row.scope_id}/${row.id}: token endpoint unavailable`, - ); - } - const clientInformation = legacy.clientInformation ?? {}; - const metadata = legacy.authorizationServerMetadata ?? {}; - const connectionId = `mcp-oauth2-${row.id}`; - const providerState = { - kind: "dynamic-dcr" as const, - tokenEndpoint, - issuerUrl: isString(metadata.issuer) ? metadata.issuer : null, - authorizationServerUrl: legacy.authorizationServerUrl, - authorizationServerMetadataUrl: null, - idTokenSigningAlgValuesSupported: stringArray(metadata.id_token_signing_alg_values_supported), - clientId: isString(clientInformation.client_id) ? clientInformation.client_id : "", - clientSecretSecretId: null, - clientAuth: - clientInformation.token_endpoint_auth_method === "client_secret_basic" ? "basic" : "body", - scopes: [], - scope: legacy.scope, - resource: endpoint, - }; - // Strip auth from config. The canonical home is mcp_source's - // auth slot columns plus credential_binding. - const { auth: _unused, ...configWithoutAuth } = config; - void _unused; - const nextConfig = hasAuthSlotColumns - ? configWithoutAuth - : { ...config, auth: { kind: "oauth2" as const, connectionId } }; - - const secretIds = [legacy.accessTokenSecretId]; - const secretNames = [`Connection ${connectionId} access token`]; - if (legacy.refreshTokenSecretId) { - secretIds.push(legacy.refreshTokenSecretId); - secretNames.push(`Connection ${connectionId} refresh token`); - } - - const txn = sqlite.transaction(() => { - insertConnectionRow(sqlite, { - id: connectionId, - scopeId: row.scope_id, - provider: OAUTH2_PROVIDER_KEY, - identityLabel: row.name, - accessTokenSecretId: legacy.accessTokenSecretId, - refreshTokenSecretId: legacy.refreshTokenSecretId, - expiresAt: legacy.expiresAt, - scope: legacy.scope, - providerState, - }); - const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames); - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: bun:sqlite transaction callback must throw to roll back - if (err) throw new Error(err); - if (updateConfigAndAuth) { - updateConfigAndAuth.run(JSON.stringify(nextConfig), row.scope_id, row.id); - insertCredentialBinding(sqlite, { - pluginId: "mcp", - scopeId: row.scope_id, - sourceId: row.id, - slotKey: "auth:oauth2:connection", - kind: "connection", - connectionId, - }); - } else { - updateConfig.run(JSON.stringify(nextConfig), row.scope_id, row.id); - } - }); - txn(); - console.log(`[migrate-connections] mcp ${row.scope_id}/${row.id} -> ${connectionId}`); - } -}; - -// --------------------------------------------------------------------------- -// google-discovery — legacy shape -// --------------------------------------------------------------------------- - -const LegacyGoogleDiscoveryOAuth2 = Schema.Struct({ - kind: Schema.Literal("oauth2"), - clientIdSecretId: Schema.String, - clientSecretSecretId: Schema.NullOr(Schema.String), - accessTokenSecretId: Schema.String, - refreshTokenSecretId: Schema.NullOr(Schema.String), - tokenType: Schema.String.pipe( - Schema.optional, - Schema.withDecodingDefaultType(Effect.succeed("Bearer")), - ), - expiresAt: Schema.NullOr(Schema.Number), - scope: Schema.NullOr(Schema.String), - scopes: Schema.Array(Schema.String), -}); - -const CurrentGoogleDiscoveryOAuth2 = Schema.Struct({ - kind: Schema.Literal("oauth2"), - connectionId: Schema.String, - clientIdSecretId: Schema.String, - clientSecretSecretId: Schema.NullOr(Schema.String), - scopes: Schema.Array(Schema.String), -}); - -const decodeGoogleCurrent = Schema.decodeUnknownOption(CurrentGoogleDiscoveryOAuth2); -type LegacyGoogleDiscoveryOAuth2Type = typeof LegacyGoogleDiscoveryOAuth2.Type; -const decodeGoogleLegacy = decodeUnknownOptionAs( - LegacyGoogleDiscoveryOAuth2, -); - -type GoogleRow = { - scope_id: string; - id: string; - name: string; - config: string; -}; - -const migrateGoogleDiscovery = (sqlite: Database): void => { - if (!tableExists(sqlite, "google_discovery_source")) return; - const rows = sqlite - .prepare("SELECT scope_id, id, name, config FROM google_discovery_source") - .all() as ReadonlyArray; - if (rows.length === 0) return; - - const updateSource = sqlite.prepare( - "UPDATE google_discovery_source SET config = ?, updated_at = ? WHERE scope_id = ? AND id = ?", - ); - - for (const row of rows) { - const parsedConfig = decodeJsonObjectString(row.config); - if (Option.isNone(parsedConfig)) continue; - const config = parsedConfig.value; - const auth = config.auth; - if (!isRecord(auth) || auth.kind !== "oauth2") continue; - - if (Option.isSome(decodeGoogleCurrent(auth))) continue; - - const legacyOption = decodeGoogleLegacy(auth); - if (Option.isNone(legacyOption)) continue; - const legacy = legacyOption.value; - - const connectionId = `google-discovery-oauth2-${randomUUID()}`; - const providerState = { - kind: "authorization-code" as const, - tokenEndpoint: "https://oauth2.googleapis.com/token", - issuerUrl: "https://accounts.google.com", - clientIdSecretId: legacy.clientIdSecretId, - clientSecretSecretId: legacy.clientSecretSecretId, - clientAuth: "body" as const, - scopes: legacy.scopes, - scope: legacy.scope, - }; - const authPointer = { - kind: "oauth2" as const, - connectionId, - clientIdSecretId: legacy.clientIdSecretId, - clientSecretSecretId: legacy.clientSecretSecretId, - scopes: legacy.scopes, - }; - const nextConfig = { ...config, auth: authPointer }; - - const secretIds = [legacy.accessTokenSecretId]; - const secretNames = [`Connection ${connectionId} access token`]; - if (legacy.refreshTokenSecretId) { - secretIds.push(legacy.refreshTokenSecretId); - secretNames.push(`Connection ${connectionId} refresh token`); - } - - const txn = sqlite.transaction(() => { - insertConnectionRow(sqlite, { - id: connectionId, - scopeId: row.scope_id, - provider: OAUTH2_PROVIDER_KEY, - identityLabel: row.name, - accessTokenSecretId: legacy.accessTokenSecretId, - refreshTokenSecretId: legacy.refreshTokenSecretId, - expiresAt: legacy.expiresAt, - scope: legacy.scope, - providerState, - }); - const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames); - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: bun:sqlite transaction callback must throw to roll back - if (err) throw new Error(err); - updateSource.run(JSON.stringify(nextConfig), Date.now(), row.scope_id, row.id); - }); - txn(); - console.log( - `[migrate-connections] google-discovery ${row.scope_id}/${row.id} -> ${connectionId}`, - ); - } -}; - -// --------------------------------------------------------------------------- -// Umbrella -// --------------------------------------------------------------------------- - -/** - * Scan openapi_source, mcp_source, and google_discovery_source; migrate - * any row still on its plugin's legacy inline-OAuth shape to a fresh - * Connection row + pointer. Idempotent — rows already on the current - * shape are skipped. Legacy rows that cannot be represented in the new - * model fail the migration instead of being silently left behind. - */ -export const migrateLegacyConnections = async (sqlite: Database): Promise => { - if (!connectionsReady(sqlite)) return; - await migrateOpenApi(sqlite); - await migrateMcp(sqlite); - migrateGoogleDiscovery(sqlite); -}; diff --git a/apps/local/src/server/sqlite-fumadb.ts b/apps/local/src/server/sqlite-fumadb.ts new file mode 100644 index 000000000..57fb99d1b --- /dev/null +++ b/apps/local/src/server/sqlite-fumadb.ts @@ -0,0 +1,81 @@ +import { Database } from "bun:sqlite"; +import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; +import { fumadb, type FumaDB } from "fumadb"; +import { + createDrizzleRuntimeSchemaFromTables, + createDrizzleRuntimeSchemaSqlFromTables, + drizzleAdapter, +} from "fumadb/adapters/drizzle"; +import { schema as fumaSchema, type RelationsMap } from "fumadb/schema"; + +import type { FumaDb, FumaTables } from "@executor-js/sdk"; + +type SqliteFumaSchema = ReturnType< + typeof fumaSchema> +>; + +export interface SqliteFumaDb { + readonly db: FumaDb>; + readonly fuma: FumaDB[]>; + readonly drizzle: BunSQLiteDatabase>; + readonly sqlite: Database; + readonly close: () => Promise; +} + +export interface CreateSqliteFumaDbOptions { + readonly tables: TTables; + readonly namespace: string; + readonly version?: string; + readonly path: string; +} + +export const createSqliteFumaDb = async ( + options: CreateSqliteFumaDbOptions, +): Promise> => { + const version = options.version ?? "1.0.0"; + const sqlite = new Database(options.path, { create: true }); + sqlite.exec("PRAGMA foreign_keys = ON"); + sqlite.exec("PRAGMA journal_mode = WAL"); + + const schema = createDrizzleRuntimeSchemaFromTables({ + tables: options.tables, + namespace: options.namespace, + version, + provider: "sqlite", + }); + const drizzleDb = drizzle(sqlite, { schema }); + + for (const statement of createDrizzleRuntimeSchemaSqlFromTables({ + tables: options.tables, + namespace: options.namespace, + version, + provider: "sqlite", + })) { + sqlite.exec(statement); + } + + const latestSchema = fumaSchema({ + version, + tables: options.tables, + }); + const factory = fumadb({ + namespace: options.namespace, + schemas: [latestSchema], + }); + const fuma = factory.client( + drizzleAdapter({ + db: drizzleDb, + provider: "sqlite", + }), + ); + + return { + db: fuma.orm(version), + fuma, + drizzle: drizzleDb, + sqlite, + close: async () => { + sqlite.close(); + }, + }; +}; diff --git a/apps/local/src/server/sqlite-import.test.ts b/apps/local/src/server/sqlite-import.test.ts new file mode 100644 index 000000000..449d99841 --- /dev/null +++ b/apps/local/src/server/sqlite-import.test.ts @@ -0,0 +1,500 @@ +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + boolColumn, + collectTables, + dateColumn, + definePlugin, + jsonColumn, + nullableBigintColumn, + nullableTextColumn, + scopedExecutorTable, + textColumn, +} from "@executor-js/sdk"; +import { withQueryContext } from "fumadb/query"; + +import { importLegacySqliteIfNeeded, readBundledDrizzleMigrationHashes } from "./executor"; +import { importSqliteDataToFuma, readLegacySqliteScopeIds } from "./sqlite-import"; +import { createSqliteFumaDb, type SqliteFumaDb } from "./sqlite-fumadb"; + +let workDir: string; +let sqlite: SqliteFumaDb | null; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "executor-sqlite-import-")); + sqlite = null; +}); + +afterEach(async () => { + await sqlite?.close(); + rmSync(workDir, { recursive: true, force: true }); +}); + +const seedSqlite = (path: string) => { + const db = new Database(path); + db.exec(` + CREATE TABLE source ( + id TEXT PRIMARY KEY NOT NULL, + plugin_id TEXT NOT NULL, + kind TEXT NOT NULL, + name TEXT NOT NULL, + url TEXT, + can_remove INTEGER NOT NULL, + can_refresh INTEGER NOT NULL, + can_edit INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE blob ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (namespace, key) + ); + `); + db.prepare( + `INSERT INTO source ( + id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "src_1", + "plugin", + "remote", + "Imported", + null, + 1, + 0, + 1, + 1_700_000_000_000, + 1_700_000_001_000, + ); + db.prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)").run( + "scope_a/plugin", + "spec", + "{}", + ); + db.close(); +}; + +const seedDrizzleMigrationHistory = ( + db: Database, + hashes: ReadonlyArray = readBundledDrizzleMigrationHashes( + join(import.meta.dirname, "../../drizzle"), + ), +) => { + db.exec(` + CREATE TABLE "__drizzle_migrations" ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + hash text NOT NULL, + created_at numeric + ); + `); + const insert = db.prepare(`INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)`); + for (const hash of hashes) { + insert.run(hash, Date.now()); + } +}; + +const seedMigratedSqlite = ( + path: string, + options?: { + readonly migrationHashes?: ReadonlyArray; + }, +) => { + const db = new Database(path); + db.exec(` + CREATE TABLE source ( + scope_id TEXT NOT NULL, + id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + kind TEXT NOT NULL, + name TEXT NOT NULL, + url TEXT, + can_remove INTEGER NOT NULL, + can_refresh INTEGER NOT NULL, + can_edit INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (scope_id, id) + ); + CREATE TABLE blob ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (namespace, key) + ); + `); + seedDrizzleMigrationHistory(db, options?.migrationHashes); + db.prepare( + `INSERT INTO source ( + scope_id, id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + "scope_a", + "src_1", + "plugin", + "remote", + "Imported", + null, + 1, + 0, + 1, + 1_700_000_000_000, + 1_700_000_001_000, + ); + db.prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)").run( + "scope_a/plugin", + "spec", + "{}", + ); + db.close(); +}; + +const lateSchema = { + late_item: scopedExecutorTable("late_item", { + value: textColumn("value"), + }), +}; + +const latePlugin = definePlugin(() => ({ + id: "late" as const, + schema: lateSchema, + storage: () => ({}), +}))(); + +const legacyShapeSchema = { + legacy_shape: scopedExecutorTable("legacy_shape", { + payload: jsonColumn("payload"), + enabled: boolColumn("enabled", false), + retry_after_ms: nullableBigintColumn("retry_after_ms"), + discovered_at: dateColumn("discovered_at"), + note: nullableTextColumn("note"), + }), +}; + +const legacyShapePlugin = definePlugin(() => ({ + id: "legacy-shape" as const, + schema: legacyShapeSchema, + storage: () => ({}), +}))(); + +describe("importSqliteDataToFuma", () => { + it("imports current SQLite rows into FumaDB SQLite without replacing source files", async () => { + const sqlitePath = join(workDir, "data.db"); + const markerPath = join(workDir, "fumadb-sqlite-imported"); + seedSqlite(sqlitePath); + + const tables = collectTables([]); + sqlite = await createSqliteFumaDb({ + tables, + namespace: "executor_local_test", + path: join(workDir, "target.db"), + }); + + const scopedDb = withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }); + const result = await importSqliteDataToFuma({ + sqlitePath, + target: scopedDb, + tables, + scopeId: "scope_a", + }); + + expect(result.imported).toBe(true); + expect(result.importedRows).toBe(2); + expect(result.importedTables).toEqual(["source", "blob"]); + expect(existsSync(markerPath)).toBe(false); + expect(existsSync(sqlitePath)).toBe(true); + expect(result.backupPath).toBeUndefined(); + + const source = (await scopedDb.findFirst("source", { + where: (b) => b("id", "=", "src_1"), + })) as Record; + expect(source.scope_id).toBe("scope_a"); + expect(source.can_remove).toBe(true); + expect(source.can_refresh).toBe(false); + expect(source.can_edit).toBe(true); + expect(source.created_at).toBeInstanceOf(Date); + + const blob = (await scopedDb.findFirst("blob", { + where: (b) => b("id", "=", JSON.stringify(["scope_a/plugin", "spec"])), + })) as Record; + expect(blob.value).toBe("{}"); + }); + + it("imports every existing legacy scope from the global local database", async () => { + const sqlitePath = join(workDir, "data.db"); + const db = new Database(sqlitePath); + db.exec(` + CREATE TABLE source ( + scope_id TEXT NOT NULL, + id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + kind TEXT NOT NULL, + name TEXT NOT NULL, + url TEXT, + can_remove INTEGER NOT NULL, + can_refresh INTEGER NOT NULL, + can_edit INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (scope_id, id) + ); + `); + const insert = db.prepare( + `INSERT INTO source ( + scope_id, id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + insert.run( + "scope_a", + "src_a", + "plugin", + "remote", + "Scope A", + null, + 1, + 0, + 1, + 1_700_000_000_000, + 1_700_000_001_000, + ); + insert.run( + "scope_b", + "src_b", + "plugin", + "remote", + "Scope B", + null, + 1, + 0, + 1, + 1_700_000_000_000, + 1_700_000_001_000, + ); + db.close(); + + const tables = collectTables([]); + const legacyScopeIds = readLegacySqliteScopeIds({ + sqlitePath, + tables, + scopeId: "scope_a", + }); + expect([...legacyScopeIds].sort()).toEqual(["scope_a", "scope_b"]); + + sqlite = await createSqliteFumaDb({ + tables, + namespace: "executor_local_test", + path: join(workDir, "target.db"), + }); + await importSqliteDataToFuma({ + sqlitePath, + target: withQueryContext(sqlite.db, { allowedScopeIds: legacyScopeIds }), + tables, + scopeId: "scope_a", + }); + + await expect( + withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }).findMany("source", { + select: ["id", "scope_id", "name"], + orderBy: ["id", "asc"], + }), + ).resolves.toEqual([{ id: "src_a", scope_id: "scope_a", name: "Scope A" }]); + await expect( + withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_b"]) }).findMany("source", { + select: ["id", "scope_id", "name"], + orderBy: ["id", "asc"], + }), + ).resolves.toEqual([{ id: "src_b", scope_id: "scope_b", name: "Scope B" }]); + }); + + it("normalizes plugin table values when importing legacy SQLite rows", async () => { + const sqlitePath = join(workDir, "data.db"); + const db = new Database(sqlitePath); + db.exec(` + CREATE TABLE legacy_shape ( + scope_id TEXT NOT NULL, + id TEXT NOT NULL, + payload TEXT NOT NULL, + enabled INTEGER NOT NULL, + retry_after_ms TEXT, + discovered_at INTEGER NOT NULL, + note TEXT, + PRIMARY KEY (scope_id, id) + ); + `); + db.prepare( + `INSERT INTO legacy_shape ( + scope_id, id, payload, enabled, retry_after_ms, discovered_at, note + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + "scope_a", + "shape_1", + JSON.stringify({ auth: { type: "oauth2" }, paths: ["/v1/items"] }), + 1, + "9007199254740993", + 1_700_000_000_000, + null, + ); + db.close(); + + const tables = collectTables([legacyShapePlugin]); + sqlite = await createSqliteFumaDb({ + tables, + namespace: "executor_local_test", + path: join(workDir, "target.db"), + }); + + const scopedDb = withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }); + const result = await importSqliteDataToFuma({ + sqlitePath, + target: scopedDb, + tables, + scopeId: "scope_a", + }); + + expect(result.importedTables).toEqual(["legacy_shape"]); + expect(result.importedRows).toBe(1); + + const row = await scopedDb.findFirst("legacy_shape", { + where: (b) => b("id", "=", "shape_1"), + }); + expect(row).toMatchObject({ + id: "shape_1", + scope_id: "scope_a", + payload: { auth: { type: "oauth2" }, paths: ["/v1/items"] }, + enabled: true, + note: null, + }); + expect(row?.retry_after_ms).toBe(9_007_199_254_740_993n); + expect(row?.discovered_at).toEqual(new Date(1_700_000_000_000)); + }); + + it("writes the import marker only after the replacement database is in place", async () => { + const sqlitePath = join(workDir, "data.db"); + const markerPath = join(workDir, "fumadb-sqlite-imported"); + seedMigratedSqlite(sqlitePath); + + const tables = collectTables([]); + const result = await importLegacySqliteIfNeeded({ + storage: { + dataDir: workDir, + sqlitePath, + importMarkerPath: markerPath, + }, + tables, + scopeId: "scope_a", + }); + + expect(result.imported).toBe(true); + expect(existsSync(markerPath)).toBe(true); + expect(existsSync(sqlitePath)).toBe(true); + expect(result.backupPath && existsSync(result.backupPath)).toBe(true); + + sqlite = await createSqliteFumaDb({ + tables, + namespace: "executor_local", + path: sqlitePath, + }); + await expect( + withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }).findFirst("source", { + where: (b) => b("id", "=", "src_1"), + }), + ).resolves.toMatchObject({ id: "src_1", scope_id: "scope_a" }); + }); + + it("imports an existing legacy schema with divergent Drizzle migration history", async () => { + const sqlitePath = join(workDir, "data.db"); + const markerPath = join(workDir, "fumadb-sqlite-imported"); + seedMigratedSqlite(sqlitePath, { + migrationHashes: ["different-branch-migration", "newer-branch-migration"], + }); + + const tables = collectTables([]); + const result = await importLegacySqliteIfNeeded({ + storage: { + dataDir: workDir, + sqlitePath, + importMarkerPath: markerPath, + }, + tables, + scopeId: "scope_a", + }); + + expect(result.imported).toBe(true); + expect(result.importedRows).toBe(2); + expect(result.importedTables).toEqual(["source", "blob"]); + expect(existsSync(markerPath)).toBe(true); + + sqlite = await createSqliteFumaDb({ + tables, + namespace: "executor_local", + path: sqlitePath, + }); + await expect( + withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }).findFirst("source", { + where: (b) => b("id", "=", "src_1"), + }), + ).resolves.toMatchObject({ id: "src_1", scope_id: "scope_a" }); + }); + + it("imports newly-loaded plugin tables from the original backup after the first cutover", async () => { + const sqlitePath = join(workDir, "data.db"); + const markerPath = join(workDir, "fumadb-sqlite-imported"); + seedMigratedSqlite(sqlitePath); + + const legacy = new Database(sqlitePath); + legacy.exec(` + CREATE TABLE late_item ( + scope_id TEXT NOT NULL, + id TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (scope_id, id) + ); + `); + legacy + .prepare("INSERT INTO late_item (scope_id, id, value) VALUES (?, ?, ?)") + .run("scope_a", "late_1", "from-backup"); + legacy.close(); + + const firstTables = collectTables([]); + const firstResult = await importLegacySqliteIfNeeded({ + storage: { + dataDir: workDir, + sqlitePath, + importMarkerPath: markerPath, + }, + tables: firstTables, + scopeId: "scope_a", + }); + expect(firstResult.importedTables).not.toContain("late_item"); + + const allTables = collectTables([latePlugin]); + const secondResult = await importLegacySqliteIfNeeded({ + storage: { + dataDir: workDir, + sqlitePath, + importMarkerPath: markerPath, + }, + tables: allTables, + scopeId: "scope_a", + }); + + expect(secondResult.imported).toBe(true); + expect(secondResult.importedTables).toEqual(["late_item"]); + + sqlite = await createSqliteFumaDb({ + tables: allTables, + namespace: "executor_local", + path: sqlitePath, + }); + await expect( + withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }).findMany("late_item", { + select: ["id", "value"], + }), + ).resolves.toEqual([{ id: "late_1", value: "from-backup" }]); + }); +}); diff --git a/apps/local/src/server/sqlite-import.ts b/apps/local/src/server/sqlite-import.ts new file mode 100644 index 000000000..71a071d0f --- /dev/null +++ b/apps/local/src/server/sqlite-import.ts @@ -0,0 +1,245 @@ +import { Database } from "bun:sqlite"; +import { Data } from "effect"; +import { existsSync } from "node:fs"; + +/* oxlint-disable executor/no-json-parse, executor/no-switch-statement, executor/no-try-catch-or-throw -- boundary: one-shot legacy SQLite importer normalizes unknown rows and wraps native sqlite failures */ + +import { type AnyColumn, type AnyTable, type FumaTables } from "@executor-js/sdk"; + +type SqliteRow = Record; + +type ImportFumaDb = Readonly<{ + createMany: (table: string, rows: SqliteRow[]) => Promise; + transaction: (run: (db: ImportFumaDb) => Promise) => Promise; +}>; + +export class LocalSqliteImportError extends Data.TaggedError("LocalSqliteImportError")<{ + readonly message: string; + readonly sqlitePath: string; + readonly table?: string; + readonly cause: unknown; +}> {} + +export interface LocalSqliteImportOptions { + readonly sqlitePath: string; + readonly target: ImportFumaDb; + readonly tables: FumaTables; + readonly scopeId: string; +} + +export interface LocalSqliteImportResult { + readonly imported: boolean; + readonly importedRows: number; + readonly importedTables: readonly string[]; + readonly backupPath?: string; +} + +const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; +const sqliteStringLiteral = (value: string): string => `'${value.replaceAll("'", "''")}'`; + +const tableExists = (sqlite: Database, tableName: string): boolean => { + const row = sqlite + .query<{ name: string }, [string]>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .get(tableName); + return row !== null; +}; + +const sqliteColumnNames = (sqlite: Database, tableName: string): ReadonlySet => { + const rows = sqlite + .query<{ name: string }, []>(`PRAGMA table_info(${sqliteStringLiteral(tableName)})`) + .all(); + return new Set(rows.map((row) => row.name)); +}; + +const readRows = (sqlite: Database, tableName: string): readonly SqliteRow[] => + sqlite.query(`SELECT * FROM ${quoteIdent(tableName)}`).all(); + +const readScopeIds = (sqlite: Database, tableName: string): readonly string[] => + sqlite + .query<{ scope_id: unknown }, []>( + `SELECT DISTINCT "scope_id" AS scope_id FROM ${quoteIdent(tableName)} WHERE "scope_id" IS NOT NULL`, + ) + .all() + .flatMap((row) => (typeof row.scope_id === "string" ? [row.scope_id] : [])); + +const parseJson = (value: string): unknown => { + try { + return JSON.parse(value); + } catch { + return value; + } +}; + +const toBigInt = (value: unknown): unknown => { + if (typeof value === "bigint") return value; + if (typeof value === "number" && Number.isFinite(value)) return BigInt(value); + if (typeof value === "string" && value.trim().length > 0) return BigInt(value); + return value; +}; + +const toDate = (value: unknown): unknown => { + if (value instanceof Date) return value; + if (typeof value === "number") return new Date(value); + if (typeof value === "string") { + const trimmed = value.trim(); + if (/^-?\d+$/.test(trimmed)) return new Date(Number(trimmed)); + return new Date(trimmed); + } + return value; +}; + +const toBool = (value: unknown): unknown => { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") return value === "1" || value.toLowerCase() === "true"; + return value; +}; + +const defaultColumnValue = (input: { + readonly tableKey: string; + readonly columnKey: string; + readonly row: SqliteRow; + readonly scopeId: string; +}): unknown => { + if (input.columnKey === "scope_id") return input.scopeId; + if (input.tableKey === "blob" && input.columnKey === "id") { + const namespace = input.row.namespace; + const key = input.row.key; + if (typeof namespace === "string" && typeof key === "string") { + return JSON.stringify([namespace, key]); + } + } + return undefined; +}; + +const normalizeColumnValue = (value: unknown, column: AnyColumn): unknown => { + if (value === undefined || value === null) return value; + switch (column.type) { + case "bool": + return toBool(value); + case "bigint": + return toBigInt(value); + case "date": + case "timestamp": + return toDate(value); + case "json": + return typeof value === "string" ? parseJson(value) : value; + default: + return value; + } +}; + +const toFumaRow = (input: { + readonly tableKey: string; + readonly table: AnyTable; + readonly sqliteColumns: ReadonlySet; + readonly row: SqliteRow; + readonly scopeId: string; +}): SqliteRow => { + const out: SqliteRow = {}; + + for (const [columnKey, column] of Object.entries(input.table.columns)) { + if (columnKey === "row_id") continue; + + const sqlName = column.names.sql; + const rawValue = input.sqliteColumns.has(sqlName) + ? input.row[sqlName] + : defaultColumnValue({ + tableKey: input.tableKey, + columnKey, + row: input.row, + scopeId: input.scopeId, + }); + + const value = normalizeColumnValue(rawValue, column); + if (value !== undefined) out[columnKey] = value; + } + + return out; +}; + +export const readLegacySqliteScopeIds = (options: { + readonly sqlitePath: string; + readonly tables: FumaTables; + readonly scopeId: string; +}): ReadonlySet => { + const scopeIds = new Set([options.scopeId]); + if (!existsSync(options.sqlitePath)) return scopeIds; + + let sqlite: Database | null = null; + try { + sqlite = new Database(options.sqlitePath, { readonly: true }); + for (const table of Object.values(options.tables)) { + const tableName = table.names.sql; + if (!tableExists(sqlite, tableName)) continue; + const columns = sqliteColumnNames(sqlite, tableName); + if (!columns.has("scope_id")) continue; + for (const scopeId of readScopeIds(sqlite, tableName)) { + scopeIds.add(scopeId); + } + } + return scopeIds; + } catch (cause) { + throw new LocalSqliteImportError({ + message: `Failed to inspect local SQLite scope ids from ${options.sqlitePath}`, + sqlitePath: options.sqlitePath, + cause, + }); + } finally { + sqlite?.close(); + } +}; + +export const importSqliteDataToFuma = async ( + options: LocalSqliteImportOptions, +): Promise => { + if (!existsSync(options.sqlitePath)) { + return { imported: false, importedRows: 0, importedTables: [] }; + } + + let sqlite: Database | null = null; + + try { + sqlite = new Database(options.sqlitePath, { readonly: true }); + const importedTables: string[] = []; + let importedRows = 0; + + await options.target.transaction(async (db) => { + for (const [tableKey, table] of Object.entries(options.tables)) { + const tableName = table.names.sql; + if (!tableExists(sqlite!, tableName)) continue; + + const sqliteColumns = sqliteColumnNames(sqlite!, tableName); + const rows = readRows(sqlite!, tableName).map((row) => + toFumaRow({ + tableKey, + table, + sqliteColumns, + row, + scopeId: options.scopeId, + }), + ); + + if (rows.length === 0) continue; + await db.createMany(tableKey, rows); + importedTables.push(tableKey); + importedRows += rows.length; + } + }); + + sqlite.close(); + sqlite = null; + + return { imported: true, importedRows, importedTables }; + } catch (cause) { + throw new LocalSqliteImportError({ + message: `Failed to import local SQLite data from ${options.sqlitePath}`, + sqlitePath: options.sqlitePath, + cause, + }); + } finally { + sqlite?.close(); + } +}; diff --git a/apps/marketing/src/pages/api/detect.ts b/apps/marketing/src/pages/api/detect.ts index ec4be04a9..7f6b53019 100644 --- a/apps/marketing/src/pages/api/detect.ts +++ b/apps/marketing/src/pages/api/detect.ts @@ -1,6 +1,7 @@ import type { APIRoute } from "astro"; import { Effect } from "effect"; -import { createExecutor, makeTestConfig, type Tool } from "@executor-js/sdk"; +import { createExecutor, type Tool } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { googleDiscoveryHttpPlugin } from "@executor-js/plugin-google-discovery/api"; diff --git a/bun.lock b/bun.lock index a96ed2348..da3d2eae3 100644 --- a/bun.lock +++ b/bun.lock @@ -66,8 +66,6 @@ "@executor-js/runtime-dynamic-worker": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", - "@executor-js/storage-postgres": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "~1.9.0", @@ -87,6 +85,7 @@ "autumn-js": "^1.2.8", "drizzle-orm": "catalog:", "effect": "catalog:", + "fumadb": "workspace:*", "jose": "^5.6.3", "postgres": "^3.4.9", "posthog-js": "^1.372.5", @@ -99,8 +98,8 @@ "@cloudflare/workers-types": "^4.20250620.0", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", - "@electric-sql/pglite": "^0.4.3", - "@electric-sql/pglite-socket": "^0.1.3", + "@electric-sql/pglite": "^0.4.4", + "@electric-sql/pglite-socket": "^0.1.4", "@executor-js/cli": "workspace:*", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", @@ -173,12 +172,12 @@ "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", - "@executor-js/storage-file": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@modelcontextprotocol/sdk": "^1.12.1", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", + "fumadb": "workspace:*", "jsonc-parser": "^3.3.1", "react": "catalog:", "react-dom": "catalog:", @@ -243,7 +242,6 @@ "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-workos-vault": "workspace:*", "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", "effect": "catalog:", }, "devDependencies": { @@ -295,7 +293,6 @@ "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", "effect": "catalog:", }, "devDependencies": { @@ -310,16 +307,17 @@ "name": "@executor-js/cli", "version": "0.2.1", "bin": { - "executor": "./dist/index.js", + "executor-sdk": "./dist/index.js", }, "dependencies": { + "@executor-js/sdk": "workspace:*", "commander": "^12.1.0", "effect": "catalog:", + "fumadb": "workspace:*", "jiti": "^2.6.1", }, "devDependencies": { - "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", + "@effect/vitest": "catalog:", "@types/node": "catalog:", "tsup": "catalog:", "typescript": "catalog:", @@ -362,6 +360,36 @@ "vitest": "catalog:", }, }, + "packages/core/fumadb": { + "name": "fumadb", + "version": "0.3.0", + "dependencies": { + "@clack/prompts": "^1.3.0", + "@paralleldrive/cuid2": "^3.3.0", + "commander": "^14.0.3", + "kysely": "^0.28.16", + "kysely-typeorm": "^0.3.0", + "semver": "^7.7.4", + "zod": "4.3.6", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "catalog:", + "@types/pg": "^8.20.0", + "@types/semver": "^7.7.1", + "better-sqlite3": "^12.9.0", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "drizzle-orm": "^0.44.0 || ^0.45.0", + }, + "optionalPeers": [ + "drizzle-orm", + ], + }, "packages/core/integrations-registry": { "name": "@executor-js/integrations-registry", "version": "0.1.0", @@ -380,18 +408,21 @@ "name": "@executor-js/sdk", "version": "1.4.29", "dependencies": { - "@executor-js/storage-core": "workspace:*", "@standard-schema/spec": "^1.1.0", "effect": "catalog:", "fractional-indexing": "^3.2.0", + "fumadb": "workspace:*", "oauth4webapi": "^3.8.5", }, "devDependencies": { "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@types/better-sqlite3": "^7.6.13", "@types/node": "catalog:", "@types/react": "catalog:", + "better-sqlite3": "^12.9.0", + "drizzle-orm": "catalog:", "react": "catalog:", "tsup": "catalog:", "typescript": "catalog:", @@ -408,82 +439,6 @@ "react", ], }, - "packages/core/storage-core": { - "name": "@executor-js/storage-core", - "version": "1.4.29", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "effect": "catalog:", - }, - "devDependencies": { - "@effect/vitest": "catalog:", - "tsup": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:", - }, - "peerDependencies": { - "@effect/vitest": "catalog:", - "vitest": "catalog:", - }, - "optionalPeers": [ - "@effect/vitest", - "vitest", - ], - }, - "packages/core/storage-drizzle": { - "name": "@executor-js/storage-drizzle", - "version": "0.0.14", - "dependencies": { - "@executor-js/storage-core": "workspace:*", - "drizzle-orm": "catalog:", - "effect": "catalog:", - }, - "devDependencies": { - "@effect/vitest": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:", - }, - }, - "packages/core/storage-file": { - "name": "@executor-js/storage-file", - "version": "1.4.4", - "dependencies": { - "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", - "@executor-js/storage-drizzle": "workspace:*", - "drizzle-orm": "catalog:", - "effect": "catalog:", - }, - "devDependencies": { - "@effect/vitest": "catalog:", - "@types/better-sqlite3": "^7.6.13", - "@types/node": "catalog:", - "better-sqlite3": "^11.8.1", - "bun-types": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:", - }, - }, - "packages/core/storage-postgres": { - "name": "@executor-js/storage-postgres", - "version": "1.4.2", - "dependencies": { - "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", - "@executor-js/storage-drizzle": "workspace:*", - "drizzle-orm": "catalog:", - "effect": "catalog:", - }, - "devDependencies": { - "@effect/vitest": "catalog:", - "@electric-sql/pglite": "^0.4.4", - "@electric-sql/pglite-socket": "^0.1.4", - "@types/node": "^25.6.0", - "postgres": "^3.4.9", - "typescript": "catalog:", - "vitest": "catalog:", - }, - }, "packages/core/vite-plugin": { "name": "@executor-js/vite-plugin", "version": "0.0.14", @@ -576,13 +531,18 @@ "effect": "catalog:", }, "devDependencies": { - "@cloudflare/vitest-pool-workers": "^0.14.2", + "@cloudflare/vitest-pool-workers": "^0.15.0", "@cloudflare/workers-types": "^4.20250620.0", "@effect/vitest": "catalog:", + "@electric-sql/pglite": "^0.4.4", + "@electric-sql/pglite-socket": "^0.1.4", "@executor-js/execution": "workspace:*", "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", "@types/node": "catalog:", + "drizzle-orm": "catalog:", + "fumadb": "workspace:*", + "postgres": "^3.4.9", "vitest": "catalog:", "wrangler": "^4.81.0", }, @@ -759,7 +719,6 @@ "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", - "@executor-js/storage-core": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", @@ -832,7 +791,6 @@ "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", - "@executor-js/storage-core": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", @@ -864,7 +822,7 @@ "effect": "catalog:", }, "devDependencies": { - "@executor-js/storage-core": "workspace:*", + "@effect/vitest": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "react": "catalog:", @@ -1155,9 +1113,9 @@ "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], - "@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="], + "@clack/core": ["@clack/core@1.3.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA=="], - "@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="], + "@clack/prompts": ["@clack/prompts@1.3.0", "", { "dependencies": { "@clack/core": "1.3.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], @@ -1405,14 +1363,6 @@ "@executor-js/sdk": ["@executor-js/sdk@workspace:packages/core/sdk"], - "@executor-js/storage-core": ["@executor-js/storage-core@workspace:packages/core/storage-core"], - - "@executor-js/storage-drizzle": ["@executor-js/storage-drizzle@workspace:packages/core/storage-drizzle"], - - "@executor-js/storage-file": ["@executor-js/storage-file@workspace:packages/core/storage-file"], - - "@executor-js/storage-postgres": ["@executor-js/storage-postgres@workspace:packages/core/storage-postgres"], - "@executor-js/vite-plugin": ["@executor-js/vite-plugin@workspace:packages/core/vite-plugin"], "@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="], @@ -1537,6 +1487,8 @@ "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.31.0", "", {}, "sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw=="], @@ -1631,6 +1583,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -1839,10 +1793,14 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ=="], + "@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.3.0", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw=="], + "@pierre/diffs": ["@pierre/diffs@1.1.15", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-Gj863E+aSpc0H3C4cH0fQTaF/tP9yYfhnilR7/dS72qq8thqNpR3fo3jURHRtRKz6KJJ10anxcurHP7b3ZUQkw=="], "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -2219,6 +2177,8 @@ "@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="], + "@sqltools/formatter": ["@sqltools/formatter@1.2.5", "", {}, "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -2429,6 +2389,8 @@ "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], "@types/prettier": ["@types/prettier@3.0.0", "", { "dependencies": { "prettier": "*" } }, "sha512-mFMBfMOz8QxhYVbuINtswBp9VL2b4Y0QqYHwqLz3YbgtfAcat2Dl6Y1o4e22S/OVE6Ebl9m7wWiMT2lSbAs1wA=="], @@ -2439,6 +2401,8 @@ "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -2555,6 +2519,8 @@ "app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], + "app-root-path": ["app-root-path@3.1.0", "", {}, "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA=="], + "arctic": ["arctic@3.7.0", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -2597,6 +2563,8 @@ "autumn-js": ["autumn-js@1.2.8", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "express": "^5.2.1", "hono": "^4.0.0", "next": "^14.0.0 || ^15.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["better-auth", "better-call", "express", "hono", "next", "react"] }, "sha512-N1DIlfC2CCxQN//po6I5+wULkw28PhNxW4tzLGWn48H3r6M1bP+DkuFtMeeD24vZZ3J8ajqUjPEJE6L6JoLiRw=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -2615,7 +2583,9 @@ "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], - "better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="], + "better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -2639,7 +2609,7 @@ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], @@ -2665,6 +2635,8 @@ "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -2911,6 +2883,8 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -2989,6 +2963,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -3047,6 +3023,8 @@ "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], @@ -3139,13 +3117,13 @@ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - "fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="], + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], - "fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="], + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -3187,8 +3165,12 @@ "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], @@ -3209,6 +3191,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fumadb": ["fumadb@workspace:packages/core/fumadb"], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -3235,7 +3219,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3399,6 +3383,8 @@ "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "is-data-descriptor": ["is-data-descriptor@1.0.1", "", { "dependencies": { "hasown": "^2.0.0" } }, "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw=="], @@ -3439,6 +3425,8 @@ "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], "is-wayland": ["is-wayland@0.1.0", "", {}, "sha512-QkbMsWkIfkrzOPxenwye0h56iAXirZYHG9eHVPb22fO9y+wPbaX/CHacOWBa/I++4ohTcByimhM1/nyCsH8KNA=="], @@ -3449,6 +3437,8 @@ "is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="], + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], "isbot": ["isbot@5.1.38", "", {}, "sha512-Cus2702JamTNMEY4zTP+TShgq/3qzjvGcBC4XMOV45BLaxD4iUFENkqu7ZhFeSzwNsCSZLjnGlihDQznnpnEEA=="], @@ -3457,6 +3447,8 @@ "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -3517,6 +3509,10 @@ "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], + + "kysely-typeorm": ["kysely-typeorm@0.3.0", "", { "peerDependencies": { "kysely": ">= 0.24.0 < 1", "typeorm": ">= 0.3.0 < 0.4.0" } }, "sha512-xQDx6+HagVmtXeuYd3Sz2CPYc/jOo7JO1ALldYPdwRrbiwSs9GFyNPp5JbElkfZB+T4DRb+A53pKWKYjlgSFeA=="], + "langium": ["langium@4.2.2", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ=="], "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], @@ -3907,6 +3903,8 @@ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -3943,6 +3941,8 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], @@ -3953,6 +3953,22 @@ "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], + + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], + + "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], + + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -3975,12 +3991,22 @@ "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "posthog-js": ["posthog-js@1.372.5", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.27.9", "@posthog/types": "1.372.5", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-0Wq4yRTX8rg2/SOTo3T/0tt2EIE0usBDJKxWPY6eRTGxWAajNmPWZwK4vREn2ANZGdPhUHQ+hg4kLEUdQnzs/Q=="], "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], @@ -4149,6 +4175,8 @@ "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -4285,10 +4313,14 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + "set-value": ["set-value@2.0.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", "is-plain-object": "^2.0.3", "split-string": "^3.0.1" } }, "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" } }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -4345,8 +4377,12 @@ "split-string": ["split-string@3.1.0", "", { "dependencies": { "extend-shallow": "^3.0.0" } }, "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "sql-highlight": ["sql-highlight@6.1.0", "", {}, "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA=="], + "srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], @@ -4369,12 +4405,16 @@ "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], @@ -4465,6 +4505,8 @@ "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="], @@ -4505,6 +4547,10 @@ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typeorm": ["typeorm@0.3.29", "", { "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", "app-root-path": "^3.1.0", "buffer": "^6.0.3", "dayjs": "^1.11.20", "debug": "^4.4.3", "dedent": "^1.7.2", "dotenv": "^16.6.1", "glob": "^10.5.0", "reflect-metadata": "^0.2.2", "sha.js": "^2.4.12", "sql-highlight": "^6.1.0", "tslib": "^2.8.1", "uuid": "^11.1.1", "yargs": "^17.7.2" }, "peerDependencies": { "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@sap/hana-client": "^2.14.22", "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", "ioredis": "^5.0.4", "mongodb": "^5.8.0 || ^6.0.0", "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", "mysql2": "^2.2.5 || ^3.0.1", "oracledb": "^6.3.0", "pg": "^8.5.1", "pg-native": "^3.0.0", "pg-query-stream": "^4.0.0", "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", "sql.js": "^1.4.0", "sqlite3": "^5.0.3", "ts-node": "^10.7.0", "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" }, "optionalPeers": ["@google-cloud/spanner", "@sap/hana-client", "better-sqlite3", "ioredis", "mongodb", "mssql", "mysql2", "oracledb", "pg", "pg-native", "pg-query-stream", "redis", "sql.js", "sqlite3", "ts-node", "typeorm-aurora-data-api-driver"], "bin": { "typeorm": "cli.js", "typeorm-ts-node-esm": "cli-ts-node-esm.js", "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js" } }, "sha512-wwPEX/df4l72gCmOsrs0otJZYLGA9lLQkUZCkukbsymEycV4zXv2KM7wU7v2r8L01TaCgY9ApSSqHQWBOUhEoQ=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], @@ -4635,6 +4681,8 @@ "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], @@ -4647,6 +4695,8 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], @@ -4657,6 +4707,8 @@ "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -4737,6 +4789,8 @@ "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "@electron/fuses/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -4785,10 +4839,6 @@ "@executor-js/example-promise-sdk/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.14.7", "", { "dependencies": { "cjs-module-lexer": "^1.2.3", "esbuild": "0.27.3", "miniflare": "4.20260415.0", "wrangler": "4.83.0", "zod": "^3.25.76" }, "peerDependencies": { "@vitest/runner": "^4.1.0", "@vitest/snapshot": "^4.1.0", "vitest": "^4.1.0" } }, "sha512-6LooO25358uPn/7U3LUg5f5pLULncDTShGuOf00TyEn3VIBW2otq92dTNf8ScIR3gV9QDztXNfChc4mqPCSBEA=="], - - "@executor-js/storage-postgres/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], - "@graphql-tools/executor/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], "@graphql-tools/merge/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], @@ -4797,6 +4847,12 @@ "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@lobehub/fluent-emoji/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], @@ -4967,6 +5023,8 @@ "@types/keyv/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/pg/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/plist/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], "@types/responselike/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], @@ -4993,12 +5051,16 @@ "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "astro/@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="], + "astro/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "astro/vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "atmn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "builder-util/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -5017,6 +5079,8 @@ "cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + "crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "crypto-random-string/type-fest": ["type-fest@1.4.0", "", {}, "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -5075,7 +5139,9 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "fumadb/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -5171,6 +5237,8 @@ "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], @@ -5193,6 +5261,8 @@ "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "subsume/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], @@ -5211,6 +5281,10 @@ "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "typeorm/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "typeorm/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "unstorage/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], @@ -5223,6 +5297,10 @@ "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -5365,20 +5443,14 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20260415.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260415.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.83.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.16.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260415.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260415.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260415.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-gw5g3LCiuAqVWxaoKY6+quE0HzAUEFb/FV3oAlNkE1ttd4XP3FiV91XDkkzUCcdqxS4WjhQvPhIDBNdhEi8P0A=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@executor-js/storage-postgres/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "@lobehub/ui/@base-ui/react/@base-ui/utils": ["@base-ui/utils@0.2.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-/CguQ2PDaOzeVOkllQR8nocJ0FFIDqsWIcURsVmm53QGo8NhFNpePjNlyPIB41luxfOqnG7PU0xicMEw3ls7XQ=="], "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -5409,6 +5481,8 @@ "@types/keyv/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@types/pg/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "@types/plist/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "@types/responselike/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], @@ -5435,6 +5509,12 @@ "app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + "astro/@clack/prompts/@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="], + + "astro/@clack/prompts/fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="], + + "astro/@clack/prompts/fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="], + "builder-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -5601,7 +5681,7 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -5635,6 +5715,8 @@ "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "run-jxa/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "run-jxa/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -5711,6 +5793,8 @@ "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -5793,66 +5877,6 @@ "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/miniflare/workerd": ["workerd@1.20260415.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260415.1", "@cloudflare/workerd-darwin-arm64": "1.20260415.1", "@cloudflare/workerd-linux-64": "1.20260415.1", "@cloudflare/workerd-linux-arm64": "1.20260415.1", "@cloudflare/workerd-windows-64": "1.20260415.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260415.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260415.1", "@cloudflare/workerd-darwin-arm64": "1.20260415.1", "@cloudflare/workerd-linux-64": "1.20260415.1", "@cloudflare/workerd-linux-arm64": "1.20260415.1", "@cloudflare/workerd-windows-64": "1.20260415.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ=="], - "@inquirer/core/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "agents/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -5861,6 +5885,8 @@ "agents/yargs/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "astro/@clack/prompts/fast-string-width/fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="], + "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -5887,28 +5913,12 @@ "read-yaml-file/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260415.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260415.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260415.1", "", { "os": "linux", "cpu": "x64" }, "sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260415.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260415.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260415.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260415.1", "", { "os": "linux", "cpu": "x64" }, "sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260415.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog=="], - - "@executor-js/runtime-dynamic-worker/@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260415.1", "", { "os": "win32", "cpu": "x64" }, "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "agents/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "agents/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/examples/all-plugins/package.json b/examples/all-plugins/package.json index 5ead75628..0df347365 100644 --- a/examples/all-plugins/package.json +++ b/examples/all-plugins/package.json @@ -18,7 +18,6 @@ "@executor-js/plugin-openapi": "workspace:*", "@executor-js/plugin-workos-vault": "workspace:*", "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/examples/all-plugins/src/main.ts b/examples/all-plugins/src/main.ts index d52b38baa..ad04186f2 100644 --- a/examples/all-plugins/src/main.ts +++ b/examples/all-plugins/src/main.ts @@ -9,7 +9,7 @@ // the new SDK shape — minus the HTTP API layer, runtime lifecycle, and // scope persistence that real apps add on top. // -// Runs against an in-memory adapter + in-memory blob store so you can +// Runs against the SDK's ephemeral in-memory FumaDB backend so you can // `bun run src/main.ts` and watch the whole surface exercise itself. // Plugins that need external infra (keychain prompts, 1Password unlock, // MCP transport, WorkOS Vault, Google OAuth) are wired so their secret @@ -19,16 +19,7 @@ import { Cause, Effect } from "effect"; -import { - SecretId, - Scope, - ScopeId, - SetSecretInput, - collectSchemas, - createExecutor, - makeInMemoryBlobStore, -} from "@executor-js/sdk"; -import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; +import { SecretId, Scope, ScopeId, SetSecretInput, createExecutor } from "@executor-js/sdk"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { googleDiscoveryPlugin } from "@executor-js/plugin-google-discovery"; @@ -42,7 +33,7 @@ import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; // --------------------------------------------------------------------------- // 1. Build the ExecutorConfig. // -// Three pieces only: scope, storage seam (adapter + blobs), plugins. +// Three pieces only: scope, FumaDB, plugins. // Compare to the old SDK, where you'd pass pre-built ToolRegistry, // SourceRegistry, SecretStore, and PolicyEngine service instances. // --------------------------------------------------------------------------- @@ -82,14 +73,6 @@ const plugins = [ // }), ] as const; -const config = { - scopes: [scope], - adapter: makeMemoryAdapter({ schema: collectSchemas(plugins) }), - blobs: makeInMemoryBlobStore(), - plugins, - onElicitation: "accept-all" as const, -}; - // Silence the unused-import warning for workos-vault (kept in scope as // documentation; uncomment the plugin entry above to use it). void workosVaultPlugin; @@ -175,7 +158,11 @@ const program = Effect.gen(function* () { console.log("Building executor with every ported plugin"); console.log("=".repeat(72)); - const executor = yield* createExecutor(config); + const executor = yield* createExecutor({ + scopes: [scope], + plugins, + onElicitation: "accept-all" as const, + }); // Every plugin's extension is accessible as `executor[pluginId]`. // TypeScript knows about each one — hovering over `executor` in your diff --git a/examples/promise-sdk/src/main.ts b/examples/promise-sdk/src/main.ts index ee46c7fed..44a0aebac 100644 --- a/examples/promise-sdk/src/main.ts +++ b/examples/promise-sdk/src/main.ts @@ -1,8 +1,9 @@ /** * Example: Promise-based executor SDK with MCP, OpenAPI, and GraphQL - * — no Effect knowledge needed. In-memory stores, runs anywhere. + * — no Effect knowledge or database setup needed. Uses the SDK's + * ephemeral in-memory FumaDB backend by default. */ -import { createExecutor, SecretId, SetSecretInput } from "@executor-js/sdk/promise"; +import { createExecutor } from "@executor-js/sdk/promise"; import { mcpPlugin } from "@executor-js/plugin-mcp/promise"; import { openApiPlugin } from "@executor-js/plugin-openapi/promise"; import { graphqlPlugin } from "@executor-js/plugin-graphql/promise"; @@ -11,9 +12,11 @@ import { graphqlPlugin } from "@executor-js/plugin-graphql/promise"; // 1. Create the executor with all plugins // --------------------------------------------------------------------------- +const plugins = [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const; + const executor = await createExecutor({ scopes: [{ id: "my-app", name: "my-app" }], - plugins: [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const, + plugins, onElicitation: "accept-all", }); @@ -98,14 +101,12 @@ if (anilistTool) { // 7. Secrets — shared across all plugins // --------------------------------------------------------------------------- -await executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make("api-key"), - scope: "my-app" as SetSecretInput["scope"], - name: "Shared API Key", - value: "sk_...", - }), -); +await executor.secrets.set({ + id: "api-key", + scope: "my-app", + name: "Shared API Key", + value: "sk_...", +}); const resolved = await executor.secrets.get("api-key"); console.log("Secret:", resolved); diff --git a/package.json b/package.json index de1efc7b5..12cf236d8 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "dev:cli": "bun run apps/cli/src/main.ts", "test": "turbo run test", "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts", - "build:packages": "bun run --filter='@executor-js/storage-core' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build", + "build:packages": "bun run --filter='fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build", "typecheck": "turbo run typecheck", "typecheck:slow": "turbo run typecheck:slow", "ci": "bun run lint && bun run typecheck && bun run test", diff --git a/packages/core/api/package.json b/packages/core/api/package.json index 12f66f557..d338365d9 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -15,7 +15,6 @@ "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/core/api/src/observability.test.ts b/packages/core/api/src/observability.test.ts index a99ef6e9c..df85881e4 100644 --- a/packages/core/api/src/observability.test.ts +++ b/packages/core/api/src/observability.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Ref, Result } from "effect"; -import { StorageError, UniqueViolationError } from "@executor-js/storage-core"; +import { StorageError, UniqueViolationError } from "@executor-js/sdk/core"; import { capture, ErrorCapture, InternalError } from "./observability"; diff --git a/packages/core/api/src/observability.ts b/packages/core/api/src/observability.ts index e49eb10ca..1bda74d5a 100644 --- a/packages/core/api/src/observability.ts +++ b/packages/core/api/src/observability.ts @@ -33,7 +33,7 @@ import { Cause, Context, Effect, Layer, Option, Result, Schema } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; import { HttpApiMiddleware, type HttpApi, type HttpApiGroup } from "effect/unstable/httpapi"; -import type { StorageFailure } from "@executor-js/storage-core"; +import type { StorageFailure } from "@executor-js/sdk/core"; import { InternalError } from "@executor-js/sdk/core"; // Re-export so existing `@executor-js/api` consumers keep working. diff --git a/packages/core/api/src/scoped-targets.test.ts b/packages/core/api/src/scoped-targets.test.ts index 3bd4cde16..638495c35 100644 --- a/packages/core/api/src/scoped-targets.test.ts +++ b/packages/core/api/src/scoped-targets.test.ts @@ -13,9 +13,9 @@ import { TokenMaterial, createExecutor, definePlugin, - makeTestConfig, type Executor, } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; import { memorySecretsPlugin } from "@executor-js/sdk/testing"; import { ExecutorApi } from "./api"; @@ -49,6 +49,9 @@ const handlerContextFor = (executor: Executor) => const scope = (id: ScopeId, name: string) => Scope.make({ id, name, createdAt: new Date() }); +const toScopeRows = (rows: unknown): ReadonlyArray<{ readonly scope_id: string }> => + rows as ReadonlyArray<{ readonly scope_id: string }>; + const connectionProviderPlugin = definePlugin(() => ({ id: "test-connection-provider" as const, storage: () => ({}), @@ -170,10 +173,13 @@ describe("core API explicit target scopes", () => { ); expect(response.status).toBe(200); - const rows = (yield* config.adapter.findMany({ - model: "connection", - where: [{ field: "id", value: connectionId }], - })) as ReadonlyArray<{ readonly scope_id: string }>; + const rows = toScopeRows( + yield* Effect.promise(() => + config.db.findMany("connection", { + where: (b) => b("id", "=", connectionId), + }), + ), + ); expect(rows.map((row) => row.scope_id).sort()).toEqual([String(userScope)]); }), ); @@ -216,7 +222,7 @@ describe("core API explicit target scopes", () => { ); expect(response.status).toBe(400); - const sessions = yield* config.adapter.findMany({ model: "oauth2_session" }); + const sessions = yield* Effect.promise(() => config.db.findMany("oauth2_session")); expect(sessions).toEqual([]); }), ); diff --git a/packages/core/cli/README.md b/packages/core/cli/README.md index 17bcc30f5..a1754c34a 100644 --- a/packages/core/cli/README.md +++ b/packages/core/cli/README.md @@ -1,57 +1,7 @@ # @executor-js/cli -Command-line tool for `@executor-js/sdk` projects. Generates Drizzle schema files from the plugins registered in your `executor.config.ts` so database migrations stay in sync with the executor you actually run. +Minimal command-line entrypoint for Executor projects. -## Install - -```sh -bun add -d @executor-js/cli -# or -npm install --save-dev @executor-js/cli -``` - -The binary is installed as `executor`. - -## Quick start - -Create an `executor.config.ts` alongside your app code: - -```ts -import { defineExecutorConfig } from "@executor-js/sdk"; -import { mcpPlugin } from "@executor-js/plugin-mcp"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; - -export default defineExecutorConfig({ - dialect: "pg", - plugins: [mcpPlugin(), openApiPlugin()], -}); -``` - -Then generate a Drizzle schema from it: - -```sh -bunx executor generate --output ./src/db/executor-schema.ts -# or -npx executor generate --output ./src/db/executor-schema.ts -``` - -The generator walks every plugin in the config, collects their schema contributions, and emits a single Drizzle schema file ready to hand to `drizzle-kit`. - -## Commands - -``` -executor generate [options] - --cwd Project directory (default: cwd) - --config Path to executor.config.ts (default: auto-discover) - --output Output file for the generated schema -``` - -Run `executor --help` to see the current command list. - -## Status - -Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). - -## License - -MIT +Schema generation and migrations are owned by FumaDB now. Hosts should build a +FumaDB client from `collectTables(plugins)` and use FumaDB's adapter/migrator +APIs directly instead of generating Executor-specific storage adapters. diff --git a/packages/core/cli/package.json b/packages/core/cli/package.json index 333e4cf5f..d67362680 100644 --- a/packages/core/cli/package.json +++ b/packages/core/cli/package.json @@ -13,7 +13,7 @@ "directory": "packages/core/cli" }, "bin": { - "executor": "./dist/index.js" + "executor-sdk": "./dist/index.js" }, "files": [ "dist" @@ -40,13 +40,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@executor-js/sdk": "workspace:*", "commander": "^12.1.0", "effect": "catalog:", + "fumadb": "workspace:*", "jiti": "^2.6.1" }, "devDependencies": { - "@executor-js/sdk": "workspace:*", - "@executor-js/storage-core": "workspace:*", + "@effect/vitest": "catalog:", "@types/node": "catalog:", "tsup": "catalog:", "typescript": "catalog:", diff --git a/packages/core/cli/src/commands/generate.ts b/packages/core/cli/src/commands/generate.ts deleted file mode 100644 index c73fe22fa..000000000 --- a/packages/core/cli/src/commands/generate.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { existsSync } from "node:fs"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { Command } from "commander"; -import { collectSchemas } from "@executor-js/sdk/core"; -import { getConfig } from "../utils/get-config.js"; -import { generateDrizzleSchema } from "../generators/drizzle.js"; - -async function generateAction(opts: { cwd: string; config?: string; output?: string }) { - const cwd = path.resolve(opts.cwd); - if (!existsSync(cwd)) { - console.error(`The directory "${cwd}" does not exist.`); - process.exit(1); - } - - const config = await getConfig({ cwd, configPath: opts.config }); - if (!config) { - console.error( - "No configuration file found. Add an `executor.config.ts` file to " + - "your project or pass the path using the `--config` flag.", - ); - process.exit(1); - } - - // The CLI never reaches plugin runtime — `plugins()` is called with - // no host deps and only `plugin.schema` is read. Each plugin's - // factory tolerates missing host deps for this introspection-only - // path; runtime callers (apps) pass real deps. - const schema = collectSchemas(config.plugins()); - - const result = await generateDrizzleSchema({ - schema, - dialect: config.dialect, - file: opts.output, - }); - - if (!result.code) { - console.log("Schema is already up to date."); - process.exit(0); - } - - const outPath = path.resolve(cwd, result.fileName); - const outDir = path.dirname(outPath); - if (!existsSync(outDir)) { - await fs.mkdir(outDir, { recursive: true }); - } - - await fs.writeFile(outPath, result.code); - console.log(`Schema generated: ${path.relative(cwd, outPath)}`); -} - -export const generate = new Command("generate") - .description("Generate a drizzle schema file from the executor config") - .option("-c, --cwd ", "the working directory", process.cwd()) - .option("--config ", "path to the executor config file") - .option("--output ", "output file path for the generated schema") - .action(generateAction); diff --git a/packages/core/cli/src/commands/schema.test.ts b/packages/core/cli/src/commands/schema.test.ts new file mode 100644 index 000000000..94e119303 --- /dev/null +++ b/packages/core/cli/src/commands/schema.test.ts @@ -0,0 +1,44 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { schema } from "./schema"; + +describe("schema generate", () => { + it.effect("generates a FumaDB-backed Drizzle schema from executor config", () => + Effect.acquireUseRelease( + Effect.promise(() => mkdtemp(join(tmpdir(), "executor-cli-schema-"))), + (cwd) => + Effect.promise(async () => { + await writeFile( + join(cwd, "executor.config.js"), + "export default { plugins: () => [] };\n", + ); + + await schema.parseAsync( + [ + "node", + "test", + "generate", + "--cwd", + cwd, + "--output", + "generated/executor-schema.ts", + "--namespace", + "executor_cli_test", + "--provider", + "sqlite", + ], + { from: "node" }, + ); + + const generated = await readFile(join(cwd, "generated/executor-schema.ts"), "utf8"); + expect(generated).toContain("executor_cli_test"); + expect(generated).toContain("source"); + expect(generated).toContain("credential_binding"); + }), + (cwd) => Effect.promise(() => rm(cwd, { recursive: true, force: true })), + ), + ); +}); diff --git a/packages/core/cli/src/commands/schema.ts b/packages/core/cli/src/commands/schema.ts new file mode 100644 index 000000000..fdc15a2c8 --- /dev/null +++ b/packages/core/cli/src/commands/schema.ts @@ -0,0 +1,87 @@ +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Command } from "commander"; +import { collectTables } from "@executor-js/sdk/core"; +import { getConfig } from "../utils/get-config.js"; + +type SchemaGenerateOptions = { + readonly cwd: string; + readonly config?: string; + readonly output?: string; + readonly namespace: string; + readonly adapter: string; + readonly provider: string; + readonly version: string; +}; + +const schemaGenerateAction = async (opts: SchemaGenerateOptions) => { + const cwd = path.resolve(opts.cwd); + if (!existsSync(cwd)) { + console.error(`The directory "${cwd}" does not exist.`); + process.exit(1); + } + + const config = await getConfig({ cwd, configPath: opts.config }); + if (!config) { + console.error( + "No configuration file found. Add an `executor.config.ts` file to " + + "your project or pass the path using the `--config` flag.", + ); + process.exit(1); + } + if (opts.adapter !== "drizzle") { + console.error(`Unsupported schema adapter "${opts.adapter}". Supported adapters: drizzle.`); + process.exit(1); + } + if (opts.provider !== "mysql" && opts.provider !== "postgresql" && opts.provider !== "sqlite") { + console.error( + `Unsupported drizzle provider "${opts.provider}". Supported providers: mysql, postgresql, sqlite.`, + ); + process.exit(1); + } + + const [{ fumadb }, { drizzleAdapter }, { schema: fumaSchema }] = await Promise.all([ + import("fumadb"), + import("fumadb/adapters/drizzle"), + import("fumadb/schema"), + ]); + + const schema = fumaSchema({ + version: opts.version, + tables: collectTables(config.plugins()), + }); + const factory = fumadb({ + namespace: opts.namespace, + schemas: [schema], + }); + const generated = factory + .client( + drizzleAdapter({ + db: {}, + provider: opts.provider, + }), + ) + .generateSchema("latest", opts.namespace); + + const output = opts.output ?? generated.path; + const outPath = path.resolve(cwd, output); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, generated.code); + console.log(`Schema generated: ${path.relative(cwd, outPath)}`); +}; + +export const schema = new Command("schema") + .description("Database schema utilities") + .addCommand( + new Command("generate") + .description("Generate an ORM schema file from the executor config") + .option("-c, --cwd ", "the working directory", process.cwd()) + .option("--config ", "path to the executor config file") + .option("--output ", "output file path for the generated schema") + .option("--namespace ", "FumaDB namespace", "executor") + .option("--adapter ", "FumaDB adapter", "drizzle") + .option("--provider ", "database provider", "postgresql") + .option("--version ", "FumaDB schema version", "1.0.0") + .action(schemaGenerateAction), + ); diff --git a/packages/core/cli/src/generators/drizzle.test.ts b/packages/core/cli/src/generators/drizzle.test.ts deleted file mode 100644 index 1b91038c9..000000000 --- a/packages/core/cli/src/generators/drizzle.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -// Drizzle generator snapshot tests. Pin the emitted code so changes -// to the generator surface as visible diffs on the snapshots instead -// of silently rippling into every downstream `executor-schema.ts`. -// -// Categories covered: -// - unscoped tables (single-column PK, blob-store-like shape) -// - scoped tables (composite PK on (scope_id, id)) -// - indexes + unique indexes -// - FK references + relations -// - default values (literal + now()) -// - all three dialects (pg, sqlite, mysql) on a shared fixture -// -// Run the suite once to populate inline snapshots, then commit them — -// subsequent generator changes that would alter the output fail -// loudly until the snapshot is reviewed. - -import { describe, expect, it } from "@effect/vitest"; - -import type { DBSchema } from "@executor-js/storage-core"; - -import { generateDrizzleSchema } from "./drizzle"; - -const emit = (schema: DBSchema, dialect: "pg" | "sqlite" | "mysql") => - generateDrizzleSchema({ schema, dialect }).then((r) => r.code); - -// --------------------------------------------------------------------------- -// Scoped vs unscoped PK shape -// --------------------------------------------------------------------------- - -describe("drizzle generator: primary key shape", () => { - it("unscoped table gets single-column PK on `id`", async () => { - const schema: DBSchema = { - unscoped_thing: { - fields: { - id: { type: "string", required: true }, - name: { type: "string", required: true }, - }, - }, - }; - expect(await emit(schema, "pg")).toMatchInlineSnapshot(` - "import { pgTable, text } from "drizzle-orm/pg-core"; - - export const unscoped_thing = pgTable("unscoped_thing", { - id: text('id').primaryKey(), - name: text('name').notNull() - }); - - " - `); - }); - - it("scoped table gets composite (scope_id, id) PK", async () => { - const schema: DBSchema = { - scoped_thing: { - fields: { - id: { type: "string", required: true }, - scope_id: { type: "string", required: true, index: true }, - name: { type: "string", required: true }, - }, - }, - }; - expect(await emit(schema, "pg")).toMatchInlineSnapshot(` - "import { pgTable, text, index, primaryKey } from "drizzle-orm/pg-core"; - - export const scoped_thing = pgTable("scoped_thing", { - id: text('id').notNull(), - scope_id: text('scope_id').notNull(), - name: text('name').notNull() - }, (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("scoped_thing_scope_id_idx").on(table.scope_id), - ]); - - " - `); - }); - - it("scoped sqlite table uses composite PK too", async () => { - const schema: DBSchema = { - scoped_thing: { - fields: { - id: { type: "string", required: true }, - scope_id: { type: "string", required: true, index: true }, - name: { type: "string", required: true }, - }, - }, - }; - expect(await emit(schema, "sqlite")).toMatchInlineSnapshot(` - "import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"; - - export const scoped_thing = sqliteTable("scoped_thing", { - id: text('id').notNull(), - scope_id: text('scope_id').notNull(), - name: text('name').notNull() - }, (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("scoped_thing_scope_id_idx").on(table.scope_id), - ]); - - " - `); - }); -}); - -// --------------------------------------------------------------------------- -// Indexes + unique indexes -// --------------------------------------------------------------------------- - -describe("drizzle generator: indexes", () => { - it("emits index() for field.index, uniqueIndex() for field.unique+index", async () => { - const schema: DBSchema = { - t: { - fields: { - id: { type: "string", required: true }, - label: { type: "string", required: true, index: true }, - slug: { type: "string", required: true, index: true, unique: true }, - notes: { type: "string" }, - }, - }, - }; - expect(await emit(schema, "pg")).toMatchInlineSnapshot(` - "import { pgTable, text, index, uniqueIndex } from "drizzle-orm/pg-core"; - - export const t = pgTable("t", { - id: text('id').primaryKey(), - label: text('label').notNull(), - slug: text('slug').notNull().unique(), - notes: text('notes').notNull() - }, (table) => [ - index("t_label_idx").on(table.label), - uniqueIndex("t_slug_uidx").on(table.slug), - ]); - - " - `); - }); -}); - -// --------------------------------------------------------------------------- -// Column types — dates, json, boolean, number -// --------------------------------------------------------------------------- - -describe("drizzle generator: column types", () => { - const mixed: DBSchema = { - row: { - fields: { - id: { type: "string", required: true }, - count: { type: "number" }, - active: { type: "boolean", defaultValue: true }, - when: { type: "date", required: true, defaultValue: () => new Date() }, - blob: { type: "json" }, - }, - }, - }; - - it("emits pg-specific column types (jsonb, timestamp, boolean, integer)", async () => { - expect(await emit(mixed, "pg")).toMatchInlineSnapshot(` - "import { pgTable, text, boolean, timestamp, integer, jsonb } from "drizzle-orm/pg-core"; - - export const row = pgTable("row", { - id: text('id').primaryKey(), - count: integer('count').notNull(), - active: boolean('active').default(true).notNull(), - when: timestamp('when').defaultNow().notNull(), - blob: jsonb('blob').notNull() - }); - - " - `); - }); - - it("emits sqlite-specific column types (integer for bool/date, text for json, real for number)", async () => { - expect(await emit(mixed, "sqlite")).toMatchInlineSnapshot(` - "import { sql } from "drizzle-orm"; - import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; - - export const row = sqliteTable("row", { - id: text('id').primaryKey(), - count: integer('count').notNull(), - active: integer('active', { mode: 'boolean' }).default(true).notNull(), - when: integer('when', { mode: 'timestamp_ms' }).default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`).notNull(), - blob: text('blob', { mode: "json" }).notNull() - }); - - " - `); - }); -}); - -// --------------------------------------------------------------------------- -// Foreign keys + relations -// --------------------------------------------------------------------------- - -describe("drizzle generator: references + relations", () => { - it("emits .references() for fields with `references` and relations() block for the parent/child pair", async () => { - const schema: DBSchema = { - parent: { - fields: { - id: { type: "string", required: true }, - name: { type: "string", required: true }, - }, - }, - child: { - fields: { - id: { type: "string", required: true }, - parent_id: { - type: "string", - required: true, - index: true, - references: { model: "parent", field: "id", onDelete: "cascade" }, - }, - label: { type: "string" }, - }, - }, - }; - expect(await emit(schema, "pg")).toMatchInlineSnapshot(` - "import { relations } from "drizzle-orm"; - import { pgTable, text, index } from "drizzle-orm/pg-core"; - - export const parent = pgTable("parent", { - id: text('id').primaryKey(), - name: text('name').notNull() - }); - - export const child = pgTable("child", { - id: text('id').primaryKey(), - parent_id: text('parent_id').notNull().references(()=> parent.id, { onDelete: 'cascade' }), - label: text('label').notNull() - }, (table) => [ - index("child_parent_id_idx").on(table.parent_id), - ]); - - - export const parentRelations = relations(parent, ({ many }) => ({ - childs: many(child) - })) - - export const childRelations = relations(child, ({ one }) => ({ - parent: one(parent, { - fields: [child.parent_id], - references: [parent.id], - }) - })) - " - `); - }); -}); - -// --------------------------------------------------------------------------- -// Regression: no `relations` import when there are no references -// --------------------------------------------------------------------------- - -describe("drizzle generator: imports", () => { - it("does not import `relations` when the schema has no references", async () => { - const schema: DBSchema = { - t: { fields: { id: { type: "string", required: true } } }, - }; - const code = await emit(schema, "pg"); - expect(code).not.toContain(`import { relations }`); - expect(code).not.toContain(`, relations`); - }); - - it("imports `primaryKey` only when there is a composite PK", async () => { - const unscoped: DBSchema = { - t: { fields: { id: { type: "string", required: true } } }, - }; - const scoped: DBSchema = { - t: { - fields: { - id: { type: "string", required: true }, - scope_id: { type: "string", required: true }, - }, - }, - }; - // Unscoped uses `.primaryKey()` on the column itself; no need for - // the `primaryKey` import. - const unscopedCode = await emit(unscoped, "pg"); - expect(unscopedCode).not.toMatch(/import .*\bprimaryKey\b/); - expect(unscopedCode).not.toContain("primaryKey({ columns:"); - - const scopedCode = await emit(scoped, "pg"); - expect(scopedCode).toMatch(/import .*\bprimaryKey\b/); - expect(scopedCode).toContain("primaryKey({ columns:"); - }); -}); diff --git a/packages/core/cli/src/generators/drizzle.ts b/packages/core/cli/src/generators/drizzle.ts deleted file mode 100644 index 81d26d3a0..000000000 --- a/packages/core/cli/src/generators/drizzle.ts +++ /dev/null @@ -1,452 +0,0 @@ -// --------------------------------------------------------------------------- -// Drizzle schema generator — DBSchema → drizzle-orm TS source. -// -// Ported from better-auth (packages/cli/src/generators/drizzle.ts) under -// MIT. Adapted for executor: -// - Reads our DBSchema shape (modelName optional, key = default) -// - No auth-specific logic (uuid/serial id modes, usePlural, camelCase) -// - Always emits text primary keys -// - Dialect from ExecutorCliConfig, not from adapter.options.provider -// --------------------------------------------------------------------------- - -import { existsSync } from "node:fs"; -import type { DBSchema, DBFieldAttribute } from "@executor-js/storage-core"; -import type { SchemaGenerator } from "./types.js"; - -type Dialect = "pg" | "sqlite" | "mysql"; - -const getModelName = (key: string, def: DBSchema[string]): string => def.modelName ?? key; - -const getType = (name: string, field: DBFieldAttribute, dialect: Dialect): string => { - if (field.references?.field === "id") { - return `text('${name}')`; - } - - const type = field.type; - - if (typeof type !== "string") { - // Enum array — e.g. ["active", "inactive"] - if (Array.isArray(type) && type.every((x) => typeof x === "string")) { - return { - sqlite: `text({ enum: [${type.map((x) => `'${x}'`).join(", ")}] })`, - pg: `text('${name}', { enum: [${type.map((x) => `'${x}'`).join(", ")}] })`, - mysql: `mysqlEnum([${type.map((x) => `'${x}'`).join(", ")}])`, - }[dialect]; - } - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: generator rejects invalid schema input through the SchemaGenerator promise API - throw new TypeError(`Invalid field type for field ${name}`); - } - - const typeMap: Record> = { - string: { - sqlite: `text('${name}')`, - pg: `text('${name}')`, - mysql: field.unique - ? `varchar('${name}', { length: 255 })` - : field.references - ? `varchar('${name}', { length: 36 })` - : field.sortable - ? `varchar('${name}', { length: 255 })` - : field.index - ? `varchar('${name}', { length: 255 })` - : `text('${name}')`, - }, - boolean: { - sqlite: `integer('${name}', { mode: 'boolean' })`, - pg: `boolean('${name}')`, - mysql: `boolean('${name}')`, - }, - number: { - sqlite: `integer('${name}')`, - pg: field.bigint ? `bigint('${name}', { mode: 'number' })` : `integer('${name}')`, - mysql: field.bigint ? `bigint('${name}', { mode: 'number' })` : `int('${name}')`, - }, - date: { - sqlite: `integer('${name}', { mode: 'timestamp_ms' })`, - pg: `timestamp('${name}')`, - mysql: `timestamp('${name}', { fsp: 3 })`, - }, - "number[]": { - sqlite: `text('${name}', { mode: "json" })`, - pg: field.bigint - ? `bigint('${name}', { mode: 'number' }).array()` - : `integer('${name}').array()`, - mysql: `text('${name}', { mode: 'json' })`, - }, - "string[]": { - sqlite: `text('${name}', { mode: "json" })`, - pg: `text('${name}').array()`, - mysql: `text('${name}', { mode: "json" })`, - }, - json: { - sqlite: `text('${name}', { mode: "json" })`, - pg: `jsonb('${name}')`, - mysql: `json('${name}', { mode: "json" })`, - }, - }; - - const dbTypeMap = typeMap[type]; - if (!dbTypeMap) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: generator rejects unsupported schema input through the SchemaGenerator promise API - throw new Error(`Unsupported field type '${field.type}' for field '${name}'.`); - } - return dbTypeMap[dialect]; -}; - -// --------------------------------------------------------------------------- -// Generator -// --------------------------------------------------------------------------- - -export const generateDrizzleSchema: SchemaGenerator = async ({ schema, dialect, file }) => { - const filePath = file || "./executor-schema.ts"; - const fileExist = existsSync(filePath); - - let code = generateImport({ dialect, schema }); - - for (const [tableKey, tableDef] of Object.entries(schema)) { - const modelName = getModelName(tableKey, tableDef); - const fields = tableDef.fields; - - // Scoped tables get a composite `(scope_id, id)` primary key so two - // tenants can register rows with the same user-facing id without - // colliding on a globally-unique PK. Single-column PK stays for - // unscoped tables (conformance fixtures, the blob store, etc.). - const hasScopeId = Object.prototype.hasOwnProperty.call(fields, "scope_id"); - const id = hasScopeId ? `text('id').notNull()` : `text('id').primaryKey()`; - - type TableExtra = - | { kind: "uniqueIndex" | "index"; name: string; on: string | readonly string[] } - | { kind: "primaryKey"; columns: readonly string[] }; - const extras: TableExtra[] = []; - - const assignExtras = (items: TableExtra[]): string => { - if (!items.length) return ""; - const lines: string[] = [`, (table) => [`]; - for (const item of items) { - if (item.kind === "primaryKey") { - const cols = item.columns.map((c) => `table.${c}`).join(", "); - lines.push(` primaryKey({ columns: [${cols}] }),`); - } else { - const cols = Array.isArray(item.on) - ? item.on.map((c) => `table.${c}`).join(", ") - : `table.${item.on}`; - lines.push(` ${item.kind}("${item.name}").on(${cols}),`); - } - } - lines.push(`]`); - return lines.join("\n"); - }; - - if (hasScopeId) { - extras.push({ kind: "primaryKey", columns: ["scope_id", "id"] }); - } - - const fieldLines = Object.entries(fields) - .filter(([fieldName]) => fieldName !== "id") - .map(([fieldName, attr]) => { - const physical = attr.fieldName ?? fieldName; - - const isToolPolicyCompositeField = - tableKey === "tool_policy" && (physical === "scope_id" || physical === "position"); - - if (attr.index && !attr.unique && !isToolPolicyCompositeField) { - extras.push({ - kind: "index", - name: `${tableKey}_${physical}_idx`, - on: physical, - }); - } else if (attr.index && attr.unique) { - extras.push({ - kind: "uniqueIndex", - name: `${tableKey}_${physical}_uidx`, - on: physical, - }); - } - - let col = getType(physical, attr, dialect); - - if (attr.defaultValue !== null && typeof attr.defaultValue !== "undefined") { - if (typeof attr.defaultValue === "function") { - if (attr.type === "date" && attr.defaultValue.toString().includes("new Date()")) { - if (dialect === "sqlite") { - col += `.default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)`; - } else { - col += `.defaultNow()`; - } - } - } else if (typeof attr.defaultValue === "string") { - col += `.default("${attr.defaultValue}")`; - } else { - col += `.default(${attr.defaultValue})`; - } - } - - if (attr.onUpdate && attr.type === "date") { - if (typeof attr.onUpdate === "function") { - col += `.$onUpdate(${attr.onUpdate})`; - } - } - - return `${physical}: ${col}${attr.required !== false ? ".notNull()" : ""}${ - attr.unique ? ".unique()" : "" - }${ - attr.references - ? `.references(()=> ${attr.references.model}.${attr.references.field ?? "id"}, { onDelete: '${ - attr.references.onDelete || "cascade" - }' })` - : "" - }`; - }) - .join(",\n "); - - if (tableKey === "tool_policy") { - extras.push({ - kind: "index", - name: "tool_policy_scope_id_position_idx", - on: ["scope_id", "position"], - }); - } - - const tableSchema = `export const ${tableKey} = ${dialect}Table("${modelName}", { - id: ${id}, - ${fieldLines} -}${assignExtras(extras)});`; - - code += `\n${tableSchema}\n`; - } - - // --------------------------------------------------------------------------- - // Relations — scan FKs in both directions - // --------------------------------------------------------------------------- - - let relationsString = ""; - for (const [tableKey, tableDef] of Object.entries(schema)) { - const modelName = tableKey; - - type Relation = { - key: string; - model: string; - type: "one" | "many"; - reference?: { - field: string; - references: string; - fieldName: string; - }; - }; - - const oneRelations: Relation[] = []; - const manyRelations: Relation[] = []; - const manyRelationsSet = new Set(); - - // Find all FKs in THIS table → "one" relations - for (const [fieldName, field] of Object.entries(tableDef.fields)) { - if (!field.references) continue; - const referencedModel = field.references.model; - const physical = field.fieldName ?? fieldName; - const fieldRef = `${tableKey}.${physical}`; - const referenceRef = `${referencedModel}.${field.references.field || "id"}`; - - oneRelations.push({ - key: referencedModel, - model: referencedModel, - type: "one", - reference: { - field: fieldRef, - references: referenceRef, - fieldName, - }, - }); - } - - // Find all OTHER tables that reference THIS table → "many" relations - for (const [otherKey, otherDef] of Object.entries(schema)) { - if (otherKey === tableKey) continue; - const hasFK = Object.values(otherDef.fields).some( - (field) => field.references?.model === tableKey, - ); - if (!hasFK) continue; - - const relationKey = `${otherKey}s`; - if (!manyRelationsSet.has(relationKey)) { - manyRelationsSet.add(relationKey); - manyRelations.push({ - key: relationKey, - model: otherKey, - type: "many", - }); - } - } - - // Detect duplicates - const relationsByModel = new Map(); - for (const rel of oneRelations) { - if (!rel.reference) continue; - const arr = relationsByModel.get(rel.key) ?? []; - arr.push(rel); - relationsByModel.set(rel.key, arr); - } - - const duplicateRelations: Relation[] = []; - const singleRelations: Relation[] = []; - - for (const [, rels] of relationsByModel.entries()) { - if (rels.length > 1) { - duplicateRelations.push(...rels); - } else { - singleRelations.push(rels[0]!); - } - } - - // Duplicate relations get field-specific exports - for (const rel of duplicateRelations) { - if (!rel.reference) continue; - const relExportName = `${modelName}${rel.reference.fieldName.charAt(0).toUpperCase() + rel.reference.fieldName.slice(1)}Relations`; - const block = `export const ${relExportName} = relations(${modelName}, ({ one }) => ({ - ${rel.key}: one(${rel.model}, { - fields: [${rel.reference.field}], - references: [${rel.reference.references}], - }) -}))`; - relationsString += `\n${block}\n`; - } - - // Combined single relations - const hasOne = singleRelations.length > 0; - const hasMany = manyRelations.length > 0; - - if (hasOne || hasMany) { - const destructured = [hasOne ? "one" : "", hasMany ? "many" : ""].filter(Boolean).join(", "); - - const body = [ - ...singleRelations - .filter((r) => r.reference) - .map( - (r) => - ` ${r.key}: one(${r.model}, {\n fields: [${r.reference!.field}],\n references: [${r.reference!.references}],\n })`, - ), - ...manyRelations.map(({ key, model }) => ` ${key}: many(${model})`), - ].join(",\n"); - - const block = `export const ${modelName}Relations = relations(${modelName}, ({ ${destructured} }) => ({ -${body} -}))`; - relationsString += `\n${block}\n`; - } - } - - code += `\n${relationsString}`; - - return { - code, - fileName: filePath, - overwrite: fileExist, - }; -}; - -// --------------------------------------------------------------------------- -// Import generation — only emit what's actually used -// --------------------------------------------------------------------------- - -function generateImport({ dialect, schema }: { dialect: Dialect; schema: DBSchema }) { - const rootImports: string[] = []; - const coreImports: string[] = []; - - let hasBigint = false; - let hasJson = false; - let hasBoolean = false; - let hasNumber = false; - let hasDate = false; - let hasIndex = false; - let hasUniqueIndex = false; - let hasReferences = false; - let hasCompositePrimaryKey = false; - - for (const [tableKey, table] of Object.entries(schema)) { - for (const field of Object.values(table.fields)) { - if (field.bigint) hasBigint = true; - if (field.type === "json") hasJson = true; - if (field.type === "boolean") hasBoolean = true; - if ((field.type === "number" && !field.bigint) || field.type === "number[]") { - hasNumber = true; - } - if (field.type === "date") hasDate = true; - if (field.index && !field.unique) hasIndex = true; - if (field.index && field.unique) hasUniqueIndex = true; - if (field.references) hasReferences = true; - } - // Scoped tables get a composite (scope_id, id) PK — see generator - // body where `primaryKey({ columns: [...] })` is emitted. - if (Object.prototype.hasOwnProperty.call(table.fields, "scope_id")) { - hasCompositePrimaryKey = true; - } - // Keep the generator silent about tableKey in this pass — we only - // need the existence check above. Referenced here to satisfy lint. - void tableKey; - } - - coreImports.push(`${dialect}Table`); - coreImports.push("text"); - - if (hasBoolean && dialect !== "sqlite") coreImports.push("boolean"); - if (hasDate) { - if (dialect === "pg") coreImports.push("timestamp"); - // sqlite uses integer for timestamps, pg uses timestamp - } - if (hasNumber || dialect === "sqlite") { - if (dialect === "pg") coreImports.push("integer"); - else if (dialect === "mysql") coreImports.push("int"); - else coreImports.push("integer"); - } - if (hasBigint && dialect !== "sqlite") coreImports.push("bigint"); - if (hasJson) { - if (dialect === "pg") coreImports.push("jsonb"); - else if (dialect === "mysql") coreImports.push("json"); - // sqlite uses text for JSON - } - if (hasIndex) coreImports.push("index"); - if (hasUniqueIndex) coreImports.push("uniqueIndex"); - if (hasCompositePrimaryKey) coreImports.push("primaryKey"); - - // sqlite needs integer for boolean + date - if (dialect === "sqlite" && (hasBoolean || hasDate)) { - if (!coreImports.includes("integer")) coreImports.push("integer"); - } - // sqlite needs real for number - if (dialect === "sqlite" && hasNumber) { - // better-auth uses integer for numbers on sqlite; we use real() - // for floating-point fidelity. - } - - // Has any timestamp with defaultNow function? - const hasSqliteTimestamp = - dialect === "sqlite" && - Object.values(schema).some((table) => - Object.values(table.fields).some( - (field) => - field.type === "date" && - field.defaultValue && - typeof field.defaultValue === "function" && - field.defaultValue.toString().includes("new Date()"), - ), - ); - - if (hasSqliteTimestamp) { - rootImports.push("sql"); - } - - if (hasReferences || dialect === "mysql") { - // mysql might need varchar for FK fields - } - - // `relations` is only imported when the schema has any references that - // produce relation blocks (see relationsString generation). - if (hasReferences) rootImports.push("relations"); - - const filteredCore = coreImports.map((x) => x.trim()).filter((x) => x !== ""); - - // Deduplicate - const uniqueCore = [...new Set(filteredCore)]; - const uniqueRoot = [...new Set(rootImports)]; - - return `${uniqueRoot.length > 0 ? `import { ${uniqueRoot.join(", ")} } from "drizzle-orm";\n` : ""}import { ${uniqueCore.join(", ")} } from "drizzle-orm/${dialect}-core";\n`; -} diff --git a/packages/core/cli/src/generators/index.ts b/packages/core/cli/src/generators/index.ts deleted file mode 100644 index 6e844f00d..000000000 --- a/packages/core/cli/src/generators/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { generateDrizzleSchema } from "./drizzle.js"; -import type { SchemaGenerator } from "./types.js"; - -export type { SchemaGenerator, SchemaGeneratorResult } from "./types.js"; - -const generators: Record = { - drizzle: generateDrizzleSchema, -}; - -export const generateSchema = (adapter: string, ...args: Parameters) => { - const generator = generators[adapter]; - if (!generator) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: synchronous CLI generator registry rejects unsupported adapter names - throw new Error( - `Generator "${adapter}" is not supported. Available: ${Object.keys(generators).join(", ")}`, - ); - } - return generator(...args); -}; - -export { generateDrizzleSchema }; diff --git a/packages/core/cli/src/generators/types.ts b/packages/core/cli/src/generators/types.ts deleted file mode 100644 index 2cb85914b..000000000 --- a/packages/core/cli/src/generators/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { DBSchema } from "@executor-js/storage-core"; -import type { ExecutorDialect } from "@executor-js/sdk/core"; - -export interface SchemaGeneratorResult { - code?: string; - fileName: string; - overwrite?: boolean; -} - -export interface SchemaGeneratorOptions { - schema: DBSchema; - dialect: ExecutorDialect; - file?: string; -} - -export interface SchemaGenerator { - (opts: SchemaGeneratorOptions): Promise; -} diff --git a/packages/core/cli/src/index.ts b/packages/core/cli/src/index.ts index 953194c7f..472558dc5 100644 --- a/packages/core/cli/src/index.ts +++ b/packages/core/cli/src/index.ts @@ -1,15 +1,15 @@ #!/usr/bin/env node import { Command } from "commander"; -import { generate } from "./commands/generate.js"; +import { schema } from "./commands/schema.js"; process.on("SIGINT", () => process.exit(0)); process.on("SIGTERM", () => process.exit(0)); -const program = new Command("executor") +const program = new Command("executor-sdk") .version("0.0.1") - .description("Executor CLI") - .addCommand(generate) + .description("Executor SDK CLI") + .addCommand(schema) .action(() => program.help()); -program.parse(); +await program.parseAsync(); diff --git a/packages/core/cli/vitest.config.ts b/packages/core/cli/vitest.config.ts index ae847ff6d..5bfa2d586 100644 --- a/packages/core/cli/vitest.config.ts +++ b/packages/core/cli/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["src/**/*.test.ts"], + passWithNoTests: true, }, }); diff --git a/packages/core/execution/src/description.test.ts b/packages/core/execution/src/description.test.ts index 1b79cdef1..94a39111b 100644 --- a/packages/core/execution/src/description.test.ts +++ b/packages/core/execution/src/description.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; -import { createExecutor, definePlugin, makeTestConfig } from "@executor-js/sdk"; +import { createExecutor, definePlugin } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; import { buildExecuteDescription } from "./description"; diff --git a/packages/core/execution/src/engine.test.ts b/packages/core/execution/src/engine.test.ts index d73d513b6..89f13e367 100644 --- a/packages/core/execution/src/engine.test.ts +++ b/packages/core/execution/src/engine.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Exit } from "effect"; -import { createExecutor, definePlugin, makeTestConfig } from "@executor-js/sdk"; +import { createExecutor, definePlugin } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; import type { CodeExecutor, ExecuteResult } from "@executor-js/codemode-core"; import { createExecutionEngine } from "./engine"; diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index 5755b3ecb..453ed1a9f 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -67,10 +67,12 @@ const toPromiseInvokeOptions = (options: EffectInvokeOptions): PromiseInvokeOpti return { onElicitation: (ctx) => - onElicitation({ - ...ctx, - toolId: ToolId.make(ctx.toolId), - }), + Effect.runPromise( + onElicitation({ + ...ctx, + toolId: ToolId.make(ctx.toolId), + }), + ), }; }; diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index c874f75a4..e130a5f8e 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -6,8 +6,8 @@ import { FormElicitation, createExecutor, definePlugin, - makeTestConfig, } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { createExecutionEngine } from "./engine"; import { describeTool, makeExecutorToolInvoker, searchTools } from "./tool-invoker"; diff --git a/packages/core/fumadb/.gitignore b/packages/core/fumadb/.gitignore new file mode 100644 index 000000000..9f18fda5f --- /dev/null +++ b/packages/core/fumadb/.gitignore @@ -0,0 +1,3 @@ + +.env.local +convex/_generated \ No newline at end of file diff --git a/packages/core/fumadb/CHANGELOG.md b/packages/core/fumadb/CHANGELOG.md new file mode 100644 index 000000000..9548ea8b5 --- /dev/null +++ b/packages/core/fumadb/CHANGELOG.md @@ -0,0 +1,140 @@ +# fumadb + +## 0.3.0 + +### Minor Changes + +- d87478f: Support Prisma 7 + +## 0.2.2 + +### Patch Changes + +- a262b62: fix: properly serialize JSON fields on insert + +## 0.2.1 + +### Patch Changes + +- bbd0ac4: fix: prismaAdapter to handle unique constraint violations gracefully + +## 0.2.0 + +### Minor Changes + +- 03ec630: feat: supports uuid column + +## 0.1.2 + +### Patch Changes + +- 51bd4a2: adapter expose name field + +## 0.1.1 + +### Patch Changes + +- f35742b: Simplify semver imports + +## 0.1.0 + +### Minor Changes + +- 155c48b: [breaking] Change syntax for column builder to simplify types + + ```ts + import { table, column, idColumn } from "fumadb/schema"; + + const users = table("users", { + // `defaultTo# fumadb for generated default value + id: idColumn("id", "varchar(255)").defaultTo$("auto"), + timestamp: column("timestamp", "date").defaultTo$("now"), + name: column("name", "string").defaultTo$(() => myFn()), + + // or database-level default value + image: column("image", "string").defaultTo("haha"), + + // nullable + email: column("email", "string").nullable(), + }); + ``` + +### Patch Changes + +- a681f98: Support composite unique constraints +- d8acc31: Improve `from-database` migration to introspect varchar length + +## 0.0.9 + +### Patch Changes + +- a1dc58c: disallow disabling tables to avoid breaking relations +- 94a6168: Support internal version control on all adapters +- 009d838: Support backward compatible `orm()` API, deprecate `abstract` +- 65d9e96: Migrate SQLite specific transformations to dedicated transformer +- a0b2a88: Default to drop unused tables to avoid conflicts with custom `up`/`down` +- 8525880: Support name variants migration on consumer-side without history. +- 6158b45: Fix condition builder types +- 65d9e96: Support migration transformer API + +## 0.0.8 + +### Patch Changes + +- e681b1a: Fix default value auto migration +- 5c702a1: [breaking] Require string table name instead of table object in relation builder +- 41336be: Improve CLI experience +- b217b3c: Introduce schema variants + +## 0.0.7 + +### Patch Changes + +- 691e0f9: Remove parameters from output migration SQL +- 849273e: MongoDB [breaking]: Use the missing field instead of using NULL +- 849273e: Drop SQL only `<>` operator +- 51f6494: Implement MongoDB migration engine +- 142cb38: Support `createAdapter()` API +- 51f6494: Make `createMigrator` sync + +## 0.0.6 + +### Patch Changes + +- a19ff3c: [Breaking] Remove abstract table/column API, use string instead +- 736c28c: Breaking: Redesign API to support adapters with `fumadb().client()` function, drop the old `configure()` +- aaf30ae: Support name variants API +- 5e675ee: Implement application-level foreign key layer for MongoDB + +## 0.0.5 + +### Patch Changes + +- cfbe836: Implement soft transaction + return ids on `createMany` +- 9c86db9: support duplicated null values for MongoDB +- 9c86db9: Support relation disambiguation + +## 0.0.4 + +### Patch Changes + +- 3eadb6d: Implement Binary type +- 115fe92: Use new migration strategy that compares with schema + +## 0.0.3 + +### Patch Changes + +- 537670c: reduce unnecessary size + +## 0.0.2 + +### Patch Changes + +- ca9bb6f: fix release + +## 0.0.1 + +### Patch Changes + +- 2f492a9: Initial release (Not ready for production use yet). diff --git a/packages/core/fumadb/convex/convex.config.ts b/packages/core/fumadb/convex/convex.config.ts new file mode 100644 index 000000000..e777743de --- /dev/null +++ b/packages/core/fumadb/convex/convex.config.ts @@ -0,0 +1,7 @@ +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import aggregate from "@convex-dev/aggregate/convex.config"; + +const app = defineApp(); +app.use(aggregate); +export default app; diff --git a/packages/core/fumadb/convex/test.ts b/packages/core/fumadb/convex/test.ts new file mode 100644 index 000000000..72afb737d --- /dev/null +++ b/packages/core/fumadb/convex/test.ts @@ -0,0 +1,7 @@ +import { createHandler } from "../src/convex"; +import { v1 } from "../test/query/query.schema"; + +export const { mutationHandler, queryHandler } = createHandler({ + secret: "test", + schema: v1, +}); diff --git a/packages/core/fumadb/package.json b/packages/core/fumadb/package.json new file mode 100644 index 000000000..fecbe92b0 --- /dev/null +++ b/packages/core/fumadb/package.json @@ -0,0 +1,122 @@ +{ + "name": "fumadb", + "description": "A library for interacting with different databases for your package.", + "version": "0.3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/RhysSullivan/executor.git", + "directory": "packages/core/fumadb" + }, + "author": "Fuma Nama", + "license": "MIT", + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": "./src/index.ts", + "./adapters": "./src/adapters/index.ts", + "./adapters/drizzle": "./src/adapters/drizzle/index.ts", + "./adapters/kysely": "./src/adapters/kysely/index.ts", + "./adapters/memory": "./src/adapters/memory/index.ts", + "./cli": "./src/cli/index.ts", + "./cuid": "./src/cuid.ts", + "./query": "./src/query/index.ts", + "./schema": "./src/schema/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./adapters": { + "import": { + "types": "./dist/adapters/index.d.ts", + "default": "./dist/adapters/index.js" + } + }, + "./adapters/drizzle": { + "import": { + "types": "./dist/adapters/drizzle/index.d.ts", + "default": "./dist/adapters/drizzle/index.js" + } + }, + "./adapters/kysely": { + "import": { + "types": "./dist/adapters/kysely/index.d.ts", + "default": "./dist/adapters/kysely/index.js" + } + }, + "./adapters/memory": { + "import": { + "types": "./dist/adapters/memory/index.d.ts", + "default": "./dist/adapters/memory/index.js" + } + }, + "./cli": { + "import": { + "types": "./dist/cli/index.d.ts", + "default": "./dist/cli/index.js" + } + }, + "./cuid": { + "import": { + "types": "./dist/cuid.d.ts", + "default": "./dist/cuid.js" + } + }, + "./query": { + "import": { + "types": "./dist/query/index.d.ts", + "default": "./dist/query/index.js" + } + }, + "./schema": { + "import": { + "types": "./dist/schema/index.d.ts", + "default": "./dist/schema/index.js" + } + } + } + }, + "scripts": { + "build": "tsup && tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src", + "typecheck": "tsgo --noEmit", + "typecheck:slow": "tsc --noEmit", + "test": "vitest run src/**/*.test.ts test/generate.test.ts test/uuid.test.ts --passWithNoTests", + "test:integration": "vitest run test/**/*.test.ts --passWithNoTests" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "catalog:", + "@types/pg": "^8.20.0", + "@types/semver": "^7.7.1", + "better-sqlite3": "^12.9.0", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "drizzle-orm": "^0.44.0 || ^0.45.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + }, + "dependencies": { + "@clack/prompts": "^1.3.0", + "@paralleldrive/cuid2": "^3.3.0", + "commander": "^14.0.3", + "kysely": "^0.28.16", + "kysely-typeorm": "^0.3.0", + "semver": "^7.7.4", + "zod": "4.3.6" + } +} diff --git a/packages/core/fumadb/prisma.config.ts b/packages/core/fumadb/prisma.config.ts new file mode 100644 index 000000000..cd96557ef --- /dev/null +++ b/packages/core/fumadb/prisma.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + datasource: { + url: process.env.DATABASE_URL!, + }, + schema: process.env.PRISMA_SCHEMA!, +}); diff --git a/packages/core/fumadb/src/adapters/drizzle/generate.ts b/packages/core/fumadb/src/adapters/drizzle/generate.ts new file mode 100644 index 000000000..eb86ab198 --- /dev/null +++ b/packages/core/fumadb/src/adapters/drizzle/generate.ts @@ -0,0 +1,278 @@ +import { + type AnyColumn, + type AnySchema, + type AnyTable, + IdColumn, +} from "../../schema/create"; +import { schemaToDBType } from "../../schema/serialize"; +import type { SQLProvider } from "../../shared/providers"; +import { importGenerator } from "../../utils/import-generator"; +import { ident, parseVarchar } from "../../utils/parse"; + +export function generateSchema( + schema: AnySchema, + provider: Exclude, +): string { + const imports = importGenerator(); + const importSource = { + mysql: "drizzle-orm/mysql-core", + postgresql: "drizzle-orm/pg-core", + sqlite: "drizzle-orm/sqlite-core", + }[provider]; + + const tableFn = { + mysql: "mysqlTable", + postgresql: "pgTable", + sqlite: "sqliteTable", + }[provider]; + + const generatedCustomTypes = new Set(); + function generateCustomType( + name: string, + options: { + dataType: string; + driverDataType: string; + databaseDataType: string; + + fromDriverCode: string; + toDriverCode: string; + }, + ) { + if (generatedCustomTypes.has(name)) return; + + imports.addImport("customType", importSource); + generatedCustomTypes.add(name); + return `const ${name} = customType< + { + data: ${options.dataType}; + driverData: ${options.driverDataType}; + } +>({ + dataType() { + return "${options.databaseDataType}"; + }, + fromDriver(value) { + ${options.fromDriverCode} + }, + toDriver(value) { + ${options.toDriverCode} + } +});`; + } + + function generateBinary() { + const name = "customBinary"; + // most Node.js based drivers return Buffer for binary data, make sure to convert them + const code = generateCustomType(name, { + dataType: "Uint8Array", + driverDataType: "Buffer", + databaseDataType: schemaToDBType({ type: "binary" }, provider), + fromDriverCode: + "return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)", + toDriverCode: `return value instanceof Buffer? value : Buffer.from(value)`, + }); + + if (code) lines.push(code); + return name; + } + + function getColumnTypeFunction(column: AnyColumn): { + name: string; + isCustomType?: boolean; + params?: string[]; + } { + if (provider === "sqlite") { + switch (column.type) { + case "uuid": + return { name: "text" }; + case "bigint": + return { + name: "blob", + params: [`{ mode: "bigint" }`], + }; + case "bool": + return { + name: "integer", + params: [`{ mode: "boolean" }`], + }; + case "json": + return { name: "blob", params: [`{ mode: "json" }`] }; + // for sqlite, generate dates as a timestamp + case "timestamp": + case "date": + return { name: "integer", params: [`{ mode: "timestamp" }`] }; + case "decimal": + return { name: "real" }; + } + } + + switch (column.type) { + case "uuid": + if (provider === "postgresql") { + return { name: "uuid" }; + } else if (provider === "mysql") { + return { + name: "char", + params: [`{ length: 36 }`], + }; + } + return { name: "text" }; + case "string": + return { name: "text" }; + case "binary": + return { + name: generateBinary(), + isCustomType: true, + }; + case "bool": + return { name: "boolean" }; + case "bigint": + return { name: "bigint", params: [`{ mode: "bigint" }`] }; + default: + if (column.type.startsWith("varchar")) { + return { + name: provider === "sqlite" ? "text" : "varchar", + params: [`{ length: ${parseVarchar(column.type)} }`], + }; + } + + return { name: column.type }; + } + } + + function generateTable(table: AnyTable) { + const cols: string[] = []; + + for (const column of Object.values(table.columns)) { + const col: string[] = []; + const typeFn = getColumnTypeFunction(column); + // Handle column type + const params: string[] = [ + `"${column.names.sql}"`, + ...(typeFn.params ?? []), + ]; + + if (!typeFn.isCustomType) imports.addImport(typeFn.name, importSource); + col.push(`${typeFn.name}(${params.join(", ")})`); + + if (column instanceof IdColumn || (column as { id?: unknown }).id === true) { + col.push("primaryKey()"); + } + + if (column.isUnique) { + col.push("unique()"); + } + + if (!column.isNullable) { + col.push("notNull()"); + } + + // Handle default values + if (column.default) { + if ("value" in column.default) { + const value = JSON.stringify(column.default.value); + col.push(`default(${value})`); + } else if (column.default.runtime === "auto") { + imports.addImport("createId", "fumadb/cuid"); + col.push("$defaultFn(() => createId())"); + } else if (column.default.runtime === "now") { + col.push("defaultNow()"); + } + } + + cols.push(` ${column.names.drizzle}: ${col.join(".")}`); + } + + const args: string[] = [`"${table.names.sql}"`]; + args.push(`{\n${cols.join(",\n")}\n}`); + + const keys: string[] = []; + for (const key of table.foreignKeys) { + const referencedTable = key.referencedTable; + + const columns = key.columns.map((col) => `table.${col.names.drizzle}`); + const foreignColumns = key.referencedColumns.map( + (col) => `${referencedTable.names.drizzle}.${col.names.drizzle}`, + ); + + imports.addImport("foreignKey", importSource); + let code = `foreignKey({ + columns: [${columns.join(", ")}], + foreignColumns: [${foreignColumns.join(", ")}], + name: "${key.name}" +})`; + if (key?.onUpdate) code += `.onUpdate("${key.onUpdate.toLowerCase()}")`; + + if (key?.onDelete) code += `.onDelete("${key.onDelete.toLowerCase()}")`; + + keys.push(code); + } + + for (const con of table.getUniqueConstraints("table")) { + imports.addImport("uniqueIndex", importSource); + keys.push( + `uniqueIndex("${con.name}").on(${con.columns.map((col) => `table.${col.names.drizzle}`).join(", ")})`, + ); + } + + if (keys.length > 0) + args.push(`(table) => [\n${ident(keys.join(",\n"))}\n]`); + + return `export const ${table.names.drizzle} = ${tableFn}(${args.join(", ")})`; + } + + function generateRelation(table: AnyTable) { + const cols: string[] = []; + + for (const relation of Object.values(table.relations)) { + const options: string[] = [`relationName: "${relation.id}"`]; + + // only `many` doesn't require fields, references + if (!relation.implied || relation.type === "one") { + const fields: string[] = []; + const references: string[] = []; + + for (const [left, right] of relation.on) { + fields.push( + `${table.names.drizzle}.${table.columns[left].names.drizzle}`, + ); + references.push( + `${relation.table.names.drizzle}.${relation.table.columns[right].names.drizzle}`, + ); + } + + options.push( + `fields: [${fields.join(", ")}]`, + `references: [${references.join(", ")}]`, + ); + } + + const args: string[] = []; + args.push(relation.table.names.drizzle); + if (options.length > 0) args.push(`{\n${ident(options.join(",\n"))}\n}`); + + cols.push( + ident(`${relation.name}: ${relation.type}(${args.join(", ")})`), + ); + } + + if (cols.length === 0) return; + imports.addImport("relations", "drizzle-orm"); + return `export const ${table.names.drizzle}Relations = relations(${ + table.names.drizzle + }, ({ one, many }) => ({ +${cols.join(",\n")} +}));`; + } + + imports.addImport(tableFn, importSource); + const lines: string[] = []; + for (const table of Object.values(schema.tables)) { + lines.push(generateTable(table)); + const relation = generateRelation(table); + if (relation) lines.push(relation); + } + + lines.unshift(imports.format()); + return lines.join("\n\n"); +} diff --git a/packages/core/fumadb/src/adapters/drizzle/index.ts b/packages/core/fumadb/src/adapters/drizzle/index.ts new file mode 100644 index 000000000..3a096793b --- /dev/null +++ b/packages/core/fumadb/src/adapters/drizzle/index.ts @@ -0,0 +1,73 @@ +import { column, idColumn, table } from "../../schema"; +import type { Provider } from "../../shared/providers"; +import type { FumaDBAdapter } from "../"; +import { generateSchema } from "./generate"; +import { fromDrizzle } from "./query"; +import { parseDrizzle } from "./shared"; + +export { + createDrizzleRuntimeSchema, + createDrizzleRuntimeSchemaFromTables, + createDrizzleRuntimeSchemaSql, + createDrizzleRuntimeSchemaSqlFromTables, + ensureDrizzleRuntimeSchema, + ensureDrizzleRuntimeSchemaFromTables, + type DrizzleRuntimeProvider, + type DrizzleRuntimeSchemaOptions, + type DrizzleRuntimeTablesOptions, + type ExecutableDrizzleDb, +} from "./runtime"; + +export interface DrizzleConfig { + /** + * Drizzle instance, must have query mode configured: https://orm.drizzle.team/docs/rqb. + */ + db: unknown; + provider: Exclude; +} + +export function drizzleAdapter(options: DrizzleConfig): FumaDBAdapter { + const settingsTableName = (namespace: string) => + `private_${namespace}_settings`; + + return { + name: "drizzle", + createORM(schema) { + return fromDrizzle(schema, options.db, options.provider); + }, + // assume the database is sync with Drizzle schema + async getSchemaVersion() { + const [_db, tables] = parseDrizzle(options.db); + const table = tables[settingsTableName(this.namespace)]; + if (!table) return; + const col = table["version"]; + if (!col) return; + + return col.default as string; + }, + generateSchema(schema, schemaName) { + const settings = settingsTableName(this.namespace); + + const internalTable = table(settings, { + id: idColumn("id", "varchar(255)"), + // use default value to save schema version + version: column("version", "varchar(255)").defaultTo(schema.version), + }); + internalTable.ormName = settings; + + return { + code: generateSchema( + { + ...schema, + tables: { + ...schema.tables, + [settings]: internalTable, + }, + }, + options.provider + ), + path: `./db/${schemaName}.ts`, + }; + }, + }; +} diff --git a/packages/core/fumadb/src/adapters/drizzle/query.ts b/packages/core/fumadb/src/adapters/drizzle/query.ts new file mode 100644 index 000000000..34abb3b95 --- /dev/null +++ b/packages/core/fumadb/src/adapters/drizzle/query.ts @@ -0,0 +1,410 @@ +import * as Drizzle from "drizzle-orm"; +import type * as PostgreSQL from "drizzle-orm/pg-core"; +import type { AbstractQuery, FindManyOptions } from "../../query"; +import { type Condition, ConditionType } from "../../query/condition-builder"; +import { type SimplifyFindOptions, toORM } from "../../query/orm"; +import { + type AnyColumn, + type AnySchema, + type AnyTable, + Column, +} from "../../schema"; +import type { SQLProvider } from "../../shared/providers"; +import { type ColumnType, parseDrizzle, type TableType } from "./shared"; + +type P_TableType = PostgreSQL.PgTableWithColumns; +type P_ColumnType = PostgreSQL.AnyPgColumn; +type P_DBType = PostgreSQL.PgDatabase< + PostgreSQL.PgQueryResultHKT, + Record, + Drizzle.TablesRelationalConfig +>; + +const CREATE_MANY_BATCH_SIZE = 500; + +function buildWhere( + toDrizzle: (col: AnyColumn) => ColumnType, + condition: Condition +): Drizzle.SQL | undefined { + if (condition.type === ConditionType.Compare) { + const left = toDrizzle(condition.a); + const op = condition.operator; + let right = condition.b; + if (right instanceof Column) right = toDrizzle(right); + let inverse = false; + + switch (op) { + case "=": + return Drizzle.eq(left, right); + case "!=": + return Drizzle.ne(left, right); + case ">": + return Drizzle.gt(left, right); + case ">=": + return Drizzle.gte(left, right); + case "<": + return Drizzle.lt(left, right); + case "<=": + return Drizzle.lte(left, right); + case "in": { + // @ts-expect-error -- skip type check + return Drizzle.inArray(left, right); + } + case "not in": + // @ts-expect-error -- skip type check + return Drizzle.notInArray(left, right); + case "is": + return right === null ? Drizzle.isNull(left) : Drizzle.eq(left, right); + case "is not": + return right === null + ? Drizzle.isNotNull(left) + : Drizzle.ne(left, right); + case "not contains": + inverse = true; + case "contains": + right = + typeof right === "string" + ? `%${right}%` + : Drizzle.sql`concat('%', ${right}, '%')`; + + return inverse + ? // @ts-expect-error -- skip type check + Drizzle.notLike(left, right) + : // @ts-expect-error -- skip type check + Drizzle.like(left, right); + case "not ends with": + inverse = true; + case "ends with": + right = + typeof right === "string" + ? `%${right}` + : Drizzle.sql`concat('%', ${right})`; + + return inverse + ? // @ts-expect-error -- skip type check + Drizzle.notLike(left, right) + : // @ts-expect-error -- skip type check + Drizzle.like(left, right); + case "not starts with": + inverse = true; + case "starts with": + right = + typeof right === "string" + ? `${right}%` + : Drizzle.sql`concat(${right}, '%')`; + + return inverse + ? // @ts-expect-error -- skip type check + Drizzle.notLike(left, right) + : // @ts-expect-error -- skip type check + Drizzle.like(left, right); + + default: + throw new Error(`Unsupported operator: ${op}`); + } + } + + if (condition.type === ConditionType.And) + return Drizzle.and( + ...condition.items.map((item) => buildWhere(toDrizzle, item)) + ); + + if (condition.type === ConditionType.Not) { + const result = buildWhere(toDrizzle, condition.item); + if (!result) return; + + return Drizzle.not(result); + } + + return Drizzle.or( + ...condition.items.map((item) => buildWhere(toDrizzle, item)) + ); +} + +function mapValues( + values: Record, + table: AnyTable +): Record { + const out: Record = {}; + + for (const column of Object.values(table.columns)) { + out[column.names.drizzle] = values[column.ormName]; + } + + return out; +} + +function mapQueryResult(table: AnyTable, result: Record) { + const out: Record = {}; + + for (const k in result) { + const value = result[k]; + + if (k in table.relations) { + const relation = table.relations[k]; + + if (relation.type === "many") { + out[k] = (value as Record[]).map((v) => + mapQueryResult(relation.table, v) + ); + continue; + } + + out[k] = value ? mapQueryResult(relation.table, value as any) : null; + continue; + } + + const col = table.getColumnByName(k, "drizzle"); + if (!col) continue; + out[col.ormName] = value; + } + + return out; +} + +// TODO: Support binary data in relation queries, because Drizzle doesn't support it: https://github.com/drizzle-team/drizzle-orm/issues/3497 +/** + * Require drizzle query mode, make sure to configure it first. (including the `schema` option) + */ +export function fromDrizzle( + schema: AnySchema, + _db: unknown, + provider: SQLProvider +): AbstractQuery { + const [db, drizzleTables] = parseDrizzle(_db); + + async function executeRaw(statement: string) { + const target = db as unknown as { + run?: (query: Drizzle.SQL) => unknown; + execute?: (query: Drizzle.SQL) => Promise; + }; + const query = Drizzle.sql.raw(statement); + + if (target.run) { + await target.run(query); + return; + } + + if (target.execute) { + await target.execute(query); + return; + } + + throw new Error("[FumaDB Drizzle] Database cannot execute raw transaction statements."); + } + + function toDrizzle(v: AnyTable): TableType { + const out = drizzleTables[v.names.drizzle]; + if (out) return out; + + throw new Error( + `[FumaDB Drizzle] Unknown table name ${v.names.drizzle}, is it included in your Drizzle schema?` + ); + } + + function toDrizzleColumn(v: AnyColumn): ColumnType { + const table = toDrizzle(v.table!); + const out = table[v.names.drizzle]; + if (out) return out; + + throw new Error( + `[FumaDB Drizzle] Unknown column name ${v.names.drizzle} in ${v.table.names.drizzle}.` + ); + } + + // Drizzle Queries doesn't support renaming fields with `mapWith` because https://github.com/drizzle-team/drizzle-orm/issues/1157 + // we need to map the result on JS instead of relying on Drizzle + function buildQueryConfig( + table: AnyTable, + options: SimplifyFindOptions + ) { + const columns: Record = {}; + const select = options.select; + + if (select === true) { + for (const col of Object.values(table.columns)) { + columns[col.names.drizzle] = true; + } + } else { + for (const k of select) { + columns[table.columns[k].names.drizzle] = true; + } + } + + const out: Drizzle.DBQueryConfig<"many" | "one", boolean> = { + columns, + limit: options.limit, + offset: options.offset, + where: options.where + ? buildWhere(toDrizzleColumn, options.where) + : undefined, + orderBy: options.orderBy?.map(([item, mode]) => + mode === "asc" + ? Drizzle.asc(toDrizzleColumn(item)) + : Drizzle.desc(toDrizzleColumn(item)) + ), + }; + + if (options.join) { + out.with = {}; + + for (const join of options.join) { + if (join.options === false) continue; + + out.with[join.relation.name] = buildQueryConfig( + join.relation.table, + join.options + ); + } + } + + return out; + } + + return toORM({ + tables: schema.tables, + async count(table, v) { + return await db.$count( + toDrizzle(table), + v.where ? buildWhere(toDrizzleColumn, v.where) : undefined + ); + }, + async findFirst(table, v) { + const results = await this.findMany(table, { + ...v, + limit: 1, + }); + + return results[0] ?? null; + }, + + async upsert(table, v) { + const idField = table.getIdColumn().names.drizzle; + const drizzleTable = toDrizzle(table); + let query = db + .select({ id: drizzleTable[idField] }) + .from(drizzleTable) + .limit(1); + + if (v.where) { + query = query.where(buildWhere(toDrizzleColumn, v.where)) as any; + } + + const targetIds = await query.execute(); + + if (targetIds.length > 0) { + await db + .update(drizzleTable) + .set(mapValues(v.update, table)) + .where(Drizzle.eq(drizzleTable[idField], targetIds[0].id)); + } else { + await this.createMany(table, [v.create]); + } + }, + async findMany(table, v) { + return ( + await db.query[table.names.drizzle].findMany(buildQueryConfig(table, v)) + ).map((v) => mapQueryResult(table, v)); + }, + + async updateMany(table, v) { + const drizzleTable = toDrizzle(table); + + let query = db.update(drizzleTable).set(mapValues(v.set, table)); + + if (v.where) { + query = query.where(buildWhere(toDrizzleColumn, v.where)) as any; + } + + await query; + }, + + async create(table, values) { + const idField = table.getIdColumn().names.drizzle; + const drizzleTable = toDrizzle(table); + values = mapValues(values, table); + + const returning: Record = {}; + for (const column of Object.values(table.columns)) { + returning[column.ormName] = drizzleTable[column.names.drizzle]; + } + + if (provider === "sqlite" || provider === "postgresql") { + const result = await (db as unknown as P_DBType) + .insert(drizzleTable as unknown as P_TableType) + .values(values) + .returning(returning as unknown as Record); + return result[0]; + } + + const obj = ( + await db.insert(drizzleTable).values(values).$returningId() + )[0] as Record; + + return ( + await db + .select(returning) + .from(drizzleTable) + .where(Drizzle.eq(drizzleTable[idField], obj[idField])) + .limit(1) + )[0]; + }, + + async createMany(table, values) { + const idField = table.getIdColumn().names.drizzle; + const drizzleTable = toDrizzle(table); + values = values.map((v) => mapValues(v, table)); + const batches: (typeof values)[] = []; + for (let i = 0; i < values.length; i += CREATE_MANY_BATCH_SIZE) { + batches.push(values.slice(i, i + CREATE_MANY_BATCH_SIZE)); + } + + if (provider === "sqlite" || provider === "postgresql") { + const out: { _id: unknown }[] = []; + for (const batch of batches) { + out.push( + ...(await (db as unknown as P_DBType) + .insert(drizzleTable as unknown as P_TableType) + .values(batch) + .returning({ + _id: (drizzleTable as unknown as P_TableType)[idField], + })), + ); + } + return out; + } + + const results: Record[] = []; + for (const batch of batches) { + results.push(...(await db.insert(drizzleTable).values(batch).$returningId())); + } + return results.map((result) => ({ _id: result[idField] })); + }, + + async deleteMany(table, v) { + const drizzleTable = toDrizzle(table); + let query = db.delete(drizzleTable); + + if (v.where) { + query = query.where(buildWhere(toDrizzleColumn, v.where)) as any; + } + + await query; + }, + async transaction(run) { + if (provider === "sqlite") { + await executeRaw("BEGIN"); + try { + const result = await run(fromDrizzle(schema, _db, provider)); + await executeRaw("COMMIT"); + return result; + } catch (e) { + await executeRaw("ROLLBACK"); + throw e; + } + } + + return db.transaction((tx) => run(fromDrizzle(schema, tx, provider))); + }, + }); +} diff --git a/packages/core/fumadb/src/adapters/drizzle/runtime.ts b/packages/core/fumadb/src/adapters/drizzle/runtime.ts new file mode 100644 index 000000000..ad33f9d77 --- /dev/null +++ b/packages/core/fumadb/src/adapters/drizzle/runtime.ts @@ -0,0 +1,386 @@ +import { relations, sql } from "drizzle-orm"; +import * as pg from "drizzle-orm/pg-core"; +import * as sqlite from "drizzle-orm/sqlite-core"; +import { createId } from "../../cuid"; +import { IdColumn, schema as fumaSchema, type AnyColumn, type AnySchema, type AnyTable } from "../../schema"; +import { schemaToDBType } from "../../schema/serialize"; +import type { SQLProvider } from "../../shared/providers"; + +export type DrizzleRuntimeProvider = Extract; + +export interface DrizzleRuntimeSchemaOptions { + readonly schema: AnySchema; + readonly namespace: string; + readonly provider: DrizzleRuntimeProvider; +} + +export interface DrizzleRuntimeTablesOptions { + readonly tables: Record; + readonly namespace: string; + readonly version: string; + readonly provider: DrizzleRuntimeProvider; +} + +export interface ExecutableDrizzleDb { + readonly execute?: (query: ReturnType) => Promise; + readonly run?: (query: ReturnType) => unknown; + readonly transaction?: (run: (tx: ExecutableDrizzleDb) => Promise) => Promise; +} + +const parseVarcharLength = (type: string): number | undefined => { + const match = /^varchar\((\d+)\)$/.exec(type); + return match ? Number(match[1]) : undefined; +}; + +const mapForeignKeyAction = (action: string): "cascade" | "restrict" | "set null" => { + if (action === "CASCADE") return "cascade"; + if (action === "SET NULL") return "set null"; + return "restrict"; +}; + +const pgBinary = pg.customType<{ data: Uint8Array; driverData: Uint8Array }>({ + dataType: () => "bytea", + fromDriver: (value) => new Uint8Array(value.buffer, value.byteOffset, value.byteLength), + toDriver: (value) => value, +}); + +const pgColumnBuilder = (column: AnyColumn) => { + let builder: any = + column.type === "uuid" + ? pg.uuid(column.names.sql) + : column.type === "string" + ? pg.text(column.names.sql) + : column.type === "binary" + ? pgBinary(column.names.sql) + : column.type === "bool" + ? pg.boolean(column.names.sql) + : column.type === "bigint" + ? pg.bigint(column.names.sql, { mode: "bigint" }) + : column.type === "integer" + ? pg.integer(column.names.sql) + : column.type === "decimal" + ? pg.numeric(column.names.sql, { mode: "number" }) + : column.type === "json" + ? pg.json(column.names.sql) + : column.type === "date" + ? pg.date(column.names.sql) + : column.type === "timestamp" + ? pg.timestamp(column.names.sql) + : undefined; + + if (!builder) { + const length = parseVarcharLength(column.type); + if (length === undefined) throw new Error(`Unsupported FumaDB column type for Postgres Drizzle: ${column.type}`); + builder = pg.varchar(column.names.sql, { length }); + } + + return applyColumnModifiers(builder, column, "postgresql"); +}; + +const sqliteColumnBuilder = (column: AnyColumn) => { + let builder: any = + column.type === "uuid" || column.type === "string" || column.type.startsWith("varchar(") + ? sqlite.text(column.names.sql) + : column.type === "binary" + ? sqlite.blob(column.names.sql) + : column.type === "bool" + ? sqlite.integer(column.names.sql, { mode: "boolean" }) + : column.type === "bigint" + ? sqlite.blob(column.names.sql, { mode: "bigint" }) + : column.type === "integer" + ? sqlite.integer(column.names.sql) + : column.type === "decimal" + ? sqlite.real(column.names.sql) + : column.type === "json" + ? sqlite.blob(column.names.sql, { mode: "json" }) + : column.type === "date" || column.type === "timestamp" + ? sqlite.integer(column.names.sql, { mode: "timestamp" }) + : undefined; + + if (!builder) throw new Error(`Unsupported FumaDB column type for SQLite Drizzle: ${column.type}`); + return applyColumnModifiers(builder, column, "sqlite"); +}; + +const applyColumnModifiers = (builder: any, column: AnyColumn, provider: DrizzleRuntimeProvider) => { + if (column instanceof IdColumn) builder = builder.primaryKey(); + if (column.isUnique) builder = builder.unique(column.getUniqueConstraintName()); + if (!column.isNullable) builder = builder.notNull(); + + if (column.default) { + if ("value" in column.default) { + builder = builder.default(column.default.value); + } else if (column.default.runtime === "auto") { + builder = builder.$defaultFn(() => createId()); + } else if (column.default.runtime === "now") { + builder = provider === "sqlite" ? builder.$defaultFn(() => new Date()) : builder.defaultNow(); + } else { + builder = builder.$defaultFn(column.default.runtime); + } + } + + return builder; +}; + +const makeTable = ( + provider: DrizzleRuntimeProvider, + table: AnyTable, + columns: Record, + tableMap: Record, +) => { + const constraints = (self: any) => [ + ...table + .getUniqueConstraints("table") + .map((constraint) => + (provider === "sqlite" ? sqlite.uniqueIndex(constraint.name) : pg.uniqueIndex(constraint.name)).on( + ...constraint.columns.map((column) => self[column.names.drizzle]), + ), + ), + ...table.foreignKeys.map((key) => { + const foreignKey = (provider === "sqlite" ? sqlite.foreignKey : pg.foreignKey) as any; + return foreignKey({ + columns: key.columns.map((column) => self[column.names.drizzle]), + foreignColumns: key.referencedColumns.map( + (column) => tableMap[key.referencedTable.names.drizzle][column.names.drizzle], + ), + name: key.name, + }) + .onUpdate(mapForeignKeyAction(key.onUpdate)) + .onDelete(mapForeignKeyAction(key.onDelete)); + }), + ]; + + return provider === "sqlite" + ? sqlite.sqliteTable(table.names.sql, columns as Record, constraints) + : pg.pgTable(table.names.sql, columns as Record, constraints); +}; + +const settingsTableName = (namespace: string) => `private_${namespace}_settings`; + +export const createDrizzleRuntimeSchema = ( + options: DrizzleRuntimeSchemaOptions, +): Record => { + const schema: Record = {}; + const tableMap: Record = {}; + + for (const table of Object.values(options.schema.tables)) { + const columns: Record = {}; + for (const [columnKey, column] of Object.entries(table.columns)) { + columns[columnKey] = + options.provider === "sqlite" ? sqliteColumnBuilder(column) : pgColumnBuilder(column); + } + + const drizzleTable = makeTable(options.provider, table, columns, tableMap); + schema[table.names.drizzle] = drizzleTable; + tableMap[table.names.drizzle] = drizzleTable; + } + + for (const table of Object.values(options.schema.tables)) { + const relationEntries = Object.values(table.relations); + if (relationEntries.length === 0) continue; + + schema[`${table.names.drizzle}Relations`] = (relations as any)( + tableMap[table.names.drizzle], + ({ one, many }: any) => { + const out: Record = {}; + for (const relation of relationEntries) { + const targetTable = tableMap[relation.table.names.drizzle]; + const relationOptions: any = { + relationName: relation.id, + }; + + if (!relation.implied || relation.type === "one") { + relationOptions.fields = relation.on.map( + ([left]) => tableMap[table.names.drizzle][table.columns[left].names.drizzle], + ); + relationOptions.references = relation.on.map( + ([, right]) => targetTable[relation.table.columns[right].names.drizzle], + ); + } + + out[relation.name] = + relation.type === "one" + ? one(targetTable, relationOptions) + : many(targetTable, relationOptions); + } + return out; + }, + ); + } + + const settings = settingsTableName(options.namespace); + schema[settings] = + options.provider === "sqlite" + ? sqlite.sqliteTable(settings, { + id: sqlite.text("id").primaryKey().notNull(), + version: sqlite.text("version").notNull().default(options.schema.version), + }) + : pg.pgTable(settings, { + id: pg.varchar("id", { length: 255 }).primaryKey().notNull(), + version: pg.varchar("version", { length: 255 }).notNull().default(options.schema.version), + }); + + return schema; +}; + +export const createDrizzleRuntimeSchemaFromTables = ( + options: DrizzleRuntimeTablesOptions, +): Record => + createDrizzleRuntimeSchema({ + schema: fumaSchema({ + version: options.version, + tables: options.tables, + }), + namespace: options.namespace, + provider: options.provider, + }); + +const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; +const quoteLiteral = (value: string): string => `'${value.replaceAll("'", "''")}'`; +const bytesToHex = (bytes: Uint8Array): string => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +const defaultSql = (column: AnyColumn, provider: DrizzleRuntimeProvider): string | undefined => { + if (!column.default) return undefined; + if ("runtime" in column.default) return undefined; + + const value = column.default.value; + if (value === null) return "NULL"; + if (typeof value === "boolean") return provider === "sqlite" ? (value ? "1" : "0") : value ? "TRUE" : "FALSE"; + if (typeof value === "number") return String(value); + if (typeof value === "bigint") return String(value); + if (value instanceof Date) return provider === "sqlite" ? String(value.getTime()) : quoteLiteral(value.toISOString()); + if (column.type === "json") { + const encoded = quoteLiteral(JSON.stringify(value)); + return provider === "sqlite" ? encoded : `${encoded}::json`; + } + if (value instanceof Uint8Array) { + const hex = bytesToHex(value); + return provider === "sqlite" ? `x'${hex}'` : `decode(${quoteLiteral(hex)}, 'hex')`; + } + return quoteLiteral(String(value)); +}; + +const columnDefinitionSql = (column: AnyColumn, provider: DrizzleRuntimeProvider): string => { + const parts = [quoteIdent(column.names.sql), schemaToDBType(column, provider)]; + if (column instanceof IdColumn) parts.push("PRIMARY KEY"); + if (!column.isNullable) parts.push("NOT NULL"); + const defaultValue = defaultSql(column, provider); + if (defaultValue) parts.push("DEFAULT", defaultValue); + return parts.join(" "); +}; + +const createTableSql = (table: AnyTable, provider: DrizzleRuntimeProvider): string => { + const constraints = table.foreignKeys.map((key) => { + const columns = key.columns.map((column) => quoteIdent(column.names.sql)).join(", "); + const referencedColumns = key.referencedColumns + .map((column) => quoteIdent(column.names.sql)) + .join(", "); + return [ + "CONSTRAINT", + quoteIdent(key.name), + "FOREIGN KEY", + `(${columns})`, + "REFERENCES", + quoteIdent(key.referencedTable.names.sql), + `(${referencedColumns})`, + "ON UPDATE", + key.onUpdate, + "ON DELETE", + key.onDelete, + ].join(" "); + }); + + return [ + "CREATE TABLE IF NOT EXISTS", + quoteIdent(table.names.sql), + `(${[...Object.values(table.columns).map((column) => columnDefinitionSql(column, provider)), ...constraints].join(", ")})`, + ].join(" "); +}; + +const createUniqueIndexSql = ( + table: AnyTable, + constraint: { name: string; columns: AnyColumn[] }, +) => + [ + "CREATE UNIQUE INDEX IF NOT EXISTS", + quoteIdent(constraint.name), + "ON", + quoteIdent(table.names.sql), + `(${constraint.columns.map((column) => quoteIdent(column.names.sql)).join(", ")})`, + ].join(" "); + +const createSettingsTableSql = ( + namespace: string, + version: string, + provider: DrizzleRuntimeProvider, +) => + [ + "CREATE TABLE IF NOT EXISTS", + quoteIdent(settingsTableName(namespace)), + `(${quoteIdent("id")} ${provider === "sqlite" ? "text" : "varchar(255)"} PRIMARY KEY NOT NULL, ${quoteIdent("version")} ${provider === "sqlite" ? "text" : "varchar(255)"} NOT NULL DEFAULT ${quoteLiteral(version)})`, + ].join(" "); + +export const createDrizzleRuntimeSchemaSql = ( + options: DrizzleRuntimeSchemaOptions, +): readonly string[] => [ + ...Object.values(options.schema.tables).map((table) => createTableSql(table, options.provider)), + ...Object.values(options.schema.tables).flatMap((table) => + table.getUniqueConstraints().map((constraint) => createUniqueIndexSql(table, constraint)), + ), + createSettingsTableSql(options.namespace, options.schema.version, options.provider), +]; + +export const createDrizzleRuntimeSchemaSqlFromTables = ( + options: DrizzleRuntimeTablesOptions, +): readonly string[] => + createDrizzleRuntimeSchemaSql({ + schema: fumaSchema({ + version: options.version, + tables: options.tables, + }), + namespace: options.namespace, + provider: options.provider, + }); + +const runStatement = async (db: ExecutableDrizzleDb, statement: string): Promise => { + if (db.execute) { + await db.execute(sql.raw(statement)); + return; + } + if (db.run) { + await db.run(sql.raw(statement)); + return; + } + throw new Error("Drizzle database cannot execute raw schema statements"); +}; + +export const ensureDrizzleRuntimeSchema = async ( + db: ExecutableDrizzleDb, + options: DrizzleRuntimeSchemaOptions, +): Promise => { + const statements = createDrizzleRuntimeSchemaSql(options); + const run = async (target: ExecutableDrizzleDb) => { + for (const statement of statements) { + await runStatement(target, statement); + } + }; + + if (db.transaction) { + await db.transaction(run); + } else { + await run(db); + } +}; + +export const ensureDrizzleRuntimeSchemaFromTables = async ( + db: ExecutableDrizzleDb, + options: DrizzleRuntimeTablesOptions, +): Promise => + ensureDrizzleRuntimeSchema(db, { + schema: fumaSchema({ + version: options.version, + tables: options.tables, + }), + namespace: options.namespace, + provider: options.provider, + }); diff --git a/packages/core/fumadb/src/adapters/drizzle/shared.ts b/packages/core/fumadb/src/adapters/drizzle/shared.ts new file mode 100644 index 000000000..5acd27efe --- /dev/null +++ b/packages/core/fumadb/src/adapters/drizzle/shared.ts @@ -0,0 +1,22 @@ +import type * as Drizzle from "drizzle-orm"; +import type * as MySQL from "drizzle-orm/mysql-core"; + +export type TableType = MySQL.MySqlTableWithColumns; +export type ColumnType = MySQL.AnyMySqlColumn; +export type DBType = MySQL.MySqlDatabase< + MySQL.MySqlQueryResultHKT, + MySQL.PreparedQueryHKTBase, + Record, + Drizzle.TablesRelationalConfig +>; + +export function parseDrizzle(drizzle: unknown) { + const db = drizzle as DBType; + const drizzleTables = db._.fullSchema as Record; + if (!drizzleTables || Object.keys(drizzleTables).length === 0) + throw new Error( + "[fumadb] Drizzle adapter requires query mode, make sure to configure it following their guide: https://orm.drizzle.team/docs/rqb." + ); + + return [db, drizzleTables] as const; +} diff --git a/packages/core/fumadb/src/adapters/index.ts b/packages/core/fumadb/src/adapters/index.ts new file mode 100644 index 000000000..bfac1ccd3 --- /dev/null +++ b/packages/core/fumadb/src/adapters/index.ts @@ -0,0 +1,57 @@ +import type { Migrator } from "../migration-engine/create"; +import type { AbstractQuery } from "../query"; +import type { AnySchema } from "../schema"; +import type { LibraryConfig } from "../shared/config"; + +export interface SettingsManagerConfig { + models: { + /** + * unique table & collection name for library settings (binded to database) + */ + settings: string; + }; +} + +export interface FumaDBAdapterContext extends LibraryConfig {} + +export interface FumaDBAdapter { + /** + * Name of the adapter + */ + name: string; + + /** + * Generate ORM schema based on FumaDB Schema + */ + generateSchema?: ( + this: FumaDBAdapterContext, + schema: AnySchema, + schemaName: string + ) => { + code: string; + path: string; + }; + + createORM( + this: FumaDBAdapterContext, + schema: AnySchema + ): AbstractQuery; + + /** + * Get current schema version, undefined if not initialized. + */ + getSchemaVersion(this: FumaDBAdapterContext): Promise; + + createMigrationEngine?: (this: FumaDBAdapterContext) => Migrator; +} + +export type FumaDBAdapterOptionsV1 = FumaDBAdapter; + +export function createAdapter( + _version: "v1", + options: FumaDBAdapterOptionsV1 +): FumaDBAdapter { + return options; +} + +export { memoryAdapter, type MemoryAdapterOptions, type MemoryDatabase } from "./memory"; diff --git a/packages/core/fumadb/src/adapters/kysely/index.ts b/packages/core/fumadb/src/adapters/kysely/index.ts new file mode 100644 index 000000000..b23dfbb87 --- /dev/null +++ b/packages/core/fumadb/src/adapters/kysely/index.ts @@ -0,0 +1,228 @@ +import { type Kysely, sql } from "kysely"; +import { createMigrator, type Migrator } from "../../migration-engine/create"; +import type { + CustomOperation, + MigrationOperation, +} from "../../migration-engine/shared"; +import { exportNameVariants } from "../../schema/export"; +import { schemaToDBType } from "../../schema/serialize"; +import type { KyselyConfig, LibraryConfig } from "../../shared/config"; +import type { SQLProvider } from "../../shared/providers"; +import type { FumaDBAdapter } from "../"; +import { generateMigration } from "./migration/auto-from-database"; +import { execute } from "./migration/execute"; +import { transformerSQLite } from "./migration/transformer-sqlite"; +import { fromKysely } from "./query"; + +interface ModelNames { + settings: string; +} + +export function kyselyAdapter(config: KyselyConfig): FumaDBAdapter { + return { + name: "kysely", + createORM(schema) { + return fromKysely(schema, config); + }, + getSchemaVersion() { + const manager = createSettingsManager(config.db, config.provider, { + settings: `private_${this.namespace}_settings`, + }); + + return manager.get("version"); + }, + createMigrationEngine() { + return createSQLMigrator(this, config, { + settings: `private_${this.namespace}_settings`, + }); + }, + }; +} + +function createSQLMigrator( + lib: LibraryConfig, + config: KyselyConfig, + modelNames: ModelNames +): Migrator { + const manager = createSettingsManager(config.db, config.provider, modelNames); + + function onCustomNode(node: CustomOperation, db: Kysely) { + const statement = sql.raw(node.sql as string); + + return { + compile() { + return statement.compile(db); + }, + execute() { + return statement.execute(db); + }, + }; + } + + async function getNameVariants() { + const currentVariants = await manager.get("name-variants"); + if (!currentVariants) return; + + try { + return JSON.parse(currentVariants); + } catch (e) { + console.warn("failed to parse stored name variants, skipping for now", e); + } + } + + function preprocess(operations: MigrationOperation[], db: Kysely) { + if (config.provider === "mysql") { + operations.unshift({ type: "custom", sql: "SET FOREIGN_KEY_CHECKS = 0" }); + operations.push({ type: "custom", sql: "SET FOREIGN_KEY_CHECKS = 1" }); + } else if (config.provider === "sqlite") { + operations.unshift({ + type: "custom", + sql: "PRAGMA defer_foreign_keys = ON", + }); + } + + const tsConfig = { + ...config, + db, + }; + + return operations.flatMap((op) => + execute(op, tsConfig, (node) => onCustomNode(node, db)) + ); + } + + return createMigrator({ + libConfig: lib, + userConfig: config, + async generateMigrationFromDatabase(options) { + return generateMigration(options.target, config, { + nameVariants: await getNameVariants(), + internalTables: Object.values(modelNames), + dropUnusedColumns: options.dropUnusedColumns, + }); + }, + + async executor(operations) { + await config.db.transaction().execute(async (tx) => { + for (const node of preprocess(operations, tx)) { + try { + await node.execute(); + } catch (e) { + console.error("failed at", node.compile(), e); + throw e; + } + } + }); + }, + settings: { + getVersion: () => manager.get("version"), + getNameVariants, + async updateSettingsInMigration(schema) { + const settings = { + version: schema.version, + "name-variants": JSON.stringify(exportNameVariants(schema)), + }; + + const init = await manager.initIfNeeded(); + const statements: string[] = []; + if (init) statements.push(init); + + for (const [k, v] of Object.entries(settings)) { + if (init || !(await manager.get(k))) { + statements.push(manager.insert(k, v)); + continue; + } + + statements.push(manager.update(k, v)); + } + + return statements.map((statement) => ({ + type: "custom", + sql: statement, + })); + }, + }, + sql: { + toSql(operations) { + const compiled = preprocess(operations, config.db).map( + (m) => `${m.compile().sql};` + ); + + return compiled.join("\n\n"); + }, + }, + transformers: config.provider === "sqlite" ? [transformerSQLite] : [], + }); +} + +function createSettingsManager( + db: Kysely, + provider: SQLProvider, + modelNames: ModelNames +) { + const { settings } = modelNames; + + function initTable() { + return db.schema + .createTable(settings) + .addColumn( + "key", + provider === "sqlite" ? "text" : "varchar(255)", + (col) => col.primaryKey() + ) + .addColumn( + "value", + sql.raw(schemaToDBType({ type: "string" }, provider)), + (col) => col.notNull() + ); + } + + let initPromise: Promise | undefined; + + async function ensureSettingsTable() { + initPromise ??= initTable() + .ifNotExists() + .execute() + .then(() => undefined); + await initPromise; + } + + return { + async get(key: string): Promise { + await ensureSettingsTable(); + const result = await db + .selectFrom(settings) + .where("key", "=", key) + .select(["value"]) + .executeTakeFirst(); + return result?.value as string | undefined; + }, + + async initIfNeeded() { + const tables = await db.introspection.getTables(); + if (tables.some((table) => table.name === settings)) return; + + return initTable().compile().sql; + }, + + insert(key: string, value: string) { + return db + .insertInto(settings) + .values({ + key: sql.lit(key), + value: sql.lit(value), + }) + .compile().sql; + }, + + update(key: string, value: string) { + return db + .updateTable(settings) + .set({ + value: sql.lit(value), + }) + .where("key", "=", sql.lit(key)) + .compile().sql; + }, + }; +} diff --git a/packages/core/fumadb/src/adapters/kysely/migration/auto-from-database.ts b/packages/core/fumadb/src/adapters/kysely/migration/auto-from-database.ts new file mode 100644 index 000000000..aab8d1e1c --- /dev/null +++ b/packages/core/fumadb/src/adapters/kysely/migration/auto-from-database.ts @@ -0,0 +1,86 @@ +import { generateMigrationFromSchema } from "../../../migration-engine/auto-from-schema"; +import type { MigrationOperation } from "../../../migration-engine/shared"; +import type { AnySchema } from "../../../schema/create"; +import { + applyNameVariants, + type NameVariantsConfig, +} from "../../../schema/name-variants-builder"; +import { dbToSchemaType } from "../../../schema/serialize"; +import type { KyselyConfig } from "../../../shared/config"; +import { introspectSchema } from "./introspect"; + +export async function generateMigration( + schema: AnySchema, + config: KyselyConfig, + options: { + nameVariants?: NameVariantsConfig; + dropUnusedColumns?: boolean; + internalTables: string[]; + } +): Promise { + const { db, provider } = config; + const { dropUnusedColumns = true, internalTables, nameVariants } = options; + const schemaWithVariant = nameVariants + ? applyNameVariants(schema, nameVariants) + : schema; + + const tables = Object.values(schemaWithVariant.tables); + const tableNameMapping = new Map(); + for (const t of tables) { + tableNameMapping.set(t.names.sql, t.ormName); + } + + const introspected = await introspectSchema({ + db, + provider, + columnNameMapping(tableName, columnName) { + const name = tableNameMapping.get(tableName); + if (!name) return columnName; + + const col = schemaWithVariant.tables[name].getColumnByName(columnName); + if (!col) return columnName; + + return col.ormName; + }, + columnTypeMapping(dataType, options) { + const predicted = dbToSchemaType(dataType, provider, options.metadata); + + function fallback() { + for (let item of predicted) { + if (item === "varchar(n)") item = "varchar(255)"; + + if (!options.isPrimaryKey) return item; + + if (item.startsWith("varchar")) return item; + } + + throw new Error("failed to predict"); + } + + const col = schemaWithVariant.tables[ + tableNameMapping.get(options.tableMetadata.name) ?? + options.tableMetadata.name + ]?.getColumnByName(options.metadata.name); + + if (!col) return fallback(); + + for (const item of predicted) { + if (item === col.type) return item; + if (item === "varchar(n)" && col.type.startsWith("varchar")) + return col.type; + } + + return fallback(); + }, + tableNameMapping(tableName) { + return tableNameMapping.get(tableName) ?? tableName; + }, + internalTables, + }); + + return generateMigrationFromSchema(introspected.schema, schema, { + ...config, + dropUnusedColumns, + dropUnusedTables: false, + }); +} diff --git a/packages/core/fumadb/src/adapters/kysely/migration/cockroach-inspector.ts b/packages/core/fumadb/src/adapters/kysely/migration/cockroach-inspector.ts new file mode 100644 index 000000000..a44f1e4ff --- /dev/null +++ b/packages/core/fumadb/src/adapters/kysely/migration/cockroach-inspector.ts @@ -0,0 +1,149 @@ +import { + type DatabaseIntrospector, + type DatabaseMetadata, + type DatabaseMetadataOptions, + DEFAULT_MIGRATION_LOCK_TABLE, + DEFAULT_MIGRATION_TABLE, + type Kysely, + type SchemaMetadata, + sql, + type TableMetadata, +} from "kysely"; + +export class CockroachIntrospector implements DatabaseIntrospector { + readonly #db: Kysely; + + constructor(db: Kysely) { + this.#db = db; + } + + async getSchemas(): Promise { + const rawSchemas = await this.#db + .selectFrom("pg_catalog.pg_namespace") + .select("nspname") + .$castTo() + .execute(); + + return rawSchemas.map((it) => ({ name: it.nspname })); + } + + async getTables( + options: DatabaseMetadataOptions = { withInternalKyselyTables: false }, + ): Promise { + let query = this.#db + // column + .selectFrom("pg_catalog.pg_attribute as a") + // table + .innerJoin("pg_catalog.pg_class as c", "a.attrelid", "c.oid") + // table schema + .innerJoin("pg_catalog.pg_namespace as ns", "c.relnamespace", "ns.oid") + // column data type + .innerJoin("pg_catalog.pg_type as typ", "a.atttypid", "typ.oid") + // column data type schema + .innerJoin( + "pg_catalog.pg_namespace as dtns", + "typ.typnamespace", + "dtns.oid", + ) + .select([ + "a.attname as column", + "a.attnotnull as not_null", + "a.atthasdef as has_default", + "c.relname as table", + "c.relkind as table_type", + "ns.nspname as schema", + "typ.typname as type", + "dtns.nspname as type_schema", + sql`col_description(a.attrelid, a.attnum)`.as( + "column_description", + ), + sql< + string | null + >`pg_get_serial_sequence(quote_ident(ns.nspname) || '.' || quote_ident(c.relname), a.attname)`.as( + "auto_incrementing", + ), + ]) + .where("c.relkind", "in", [ + "r" /*regular table*/, + "v" /*view*/, + "p" /*partitioned table*/, + ]) + .where("ns.nspname", "!~", "^pg_") + .where("ns.nspname", "!=", "information_schema") + // Filter out internal cockroachdb schema + .where("ns.nspname", "!=", "crdb_internal") + // No system columns + .where("a.attnum", ">=", 0) + .where("a.attisdropped", "!=", true) + .orderBy("ns.nspname") + .orderBy("c.relname") + .orderBy("a.attnum") + .$castTo(); + + if (!options.withInternalKyselyTables) { + query = query + .where("c.relname", "!=", DEFAULT_MIGRATION_TABLE) + .where("c.relname", "!=", DEFAULT_MIGRATION_LOCK_TABLE); + } + + const rawColumns = await query.execute(); + + return this.#parseTableMetadata(rawColumns); + } + + async getMetadata( + options?: DatabaseMetadataOptions, + ): Promise { + return { + tables: await this.getTables(options), + }; + } + + #parseTableMetadata(columns: RawColumnMetadata[]): TableMetadata[] { + return columns.reduce((tables, it) => { + let table = tables.find( + (tbl) => tbl.name === it.table && tbl.schema === it.schema, + ); + + if (!table) { + table = { + name: it.table, + isView: it.table_type === "v", + schema: it.schema, + columns: [], + }; + + tables.push(table); + } + + table.columns.push({ + name: it.column, + dataType: it.type, + dataTypeSchema: it.type_schema, + isNullable: !it.not_null, + isAutoIncrementing: it.auto_incrementing !== null, + hasDefaultValue: it.has_default, + comment: it.column_description ?? undefined, + }); + + return tables; + }, []); + } +} + +interface RawSchemaMetadata { + nspname: string; +} + +interface RawColumnMetadata { + column: string; + table: string; + table_type: string; + schema: string; + not_null: boolean; + has_default: boolean; + type: string; + type_schema: string; + auto_incrementing: string | null; + column_description: string | null; +} diff --git a/packages/core/fumadb/src/adapters/kysely/migration/execute.md b/packages/core/fumadb/src/adapters/kysely/migration/execute.md new file mode 100644 index 000000000..628a4829a --- /dev/null +++ b/packages/core/fumadb/src/adapters/kysely/migration/execute.md @@ -0,0 +1,17 @@ +For SQLite, we use unique index instead of constraint because: + +1. They behave the same for foreign keys. +2. Only unique index can be added and dropped after table creation. + +For MSSQL, we use unique index instead of constraint because: + +1. Unique constraints include NULL values by default, to disable, we need a filtered unique index. + +Also, MSSQL has many limitations on foreign key: + +1. Cannot use filtered unique index (which is necessary for us). +2. Cannot define foreign key actions on self-referencing keys (which other databases support). + +Hence, MSSQL will use our own virtual foreign key system instead. + +Otherwise, we need unique constraint because most SQL databases require unique constraint for foreign keys to work. diff --git a/packages/core/fumadb/src/adapters/kysely/migration/execute.ts b/packages/core/fumadb/src/adapters/kysely/migration/execute.ts new file mode 100644 index 000000000..d341e5fa7 --- /dev/null +++ b/packages/core/fumadb/src/adapters/kysely/migration/execute.ts @@ -0,0 +1,417 @@ +import { + type ColumnBuilderCallback, + type Compilable, + type CreateTableBuilder, + type Kysely, + type OnModifyForeignAction, + type RawBuilder, + sql, +} from "kysely"; +import { + type ColumnOperation, + type CustomOperation, + isUpdated, + type MigrationOperation, +} from "../../../migration-engine/shared"; +import { + type AnyColumn, + type AnyTable, + compileForeignKey, + type ForeignKeyAction, + IdColumn, +} from "../../../schema/create"; +import { schemaToDBType } from "../../../schema/serialize"; +import type { KyselyConfig } from "../../../shared/config"; +import type { SQLProvider } from "../../../shared/providers"; + +export type ExecuteNode = Compilable & { + execute(): Promise; +}; + +function getColumnBuilderCallback( + col: AnyColumn, + provider: SQLProvider +): ColumnBuilderCallback { + return (build) => { + if (!col.isNullable) { + build = build.notNull(); + } + if (col instanceof IdColumn) build = build.primaryKey(); + + const defaultValue = defaultValueToDB(col, provider); + if (defaultValue) build = build.defaultTo(defaultValue); + return build; + }; +} + +const errors = { + IdColumnUpdate: + "ID columns must not be updated, not every database supports updating primary keys and often requires workarounds.", + SQLiteUpdateForeignKeys: + "In SQLite, you cannot modify foreign keys directly, use `recreate-table` instead.", +}; + +function createUniqueIndex( + db: Kysely, + name: string, + tableName: string, + cols: string[], + provider: SQLProvider +) { + const query = db.schema + .createIndex(name) + .on(tableName) + .columns(cols) + .unique(); + + if (provider === "mssql") { + // ignore null by default + return query.where((b) => { + return b.and(cols.map((col) => b(col, "is not", null))); + }); + } + + return query; +} + +function createUniqueIndexOrConstraint( + db: Kysely, + name: string, + tableName: string, + cols: string[], + provider: SQLProvider +) { + if (provider === "sqlite" || provider === "mssql") { + return createUniqueIndex(db, name, tableName, cols, provider); + } + + return db.schema.alterTable(tableName).addUniqueConstraint(name, cols); +} + +function dropUniqueIndexOrConstraint( + db: Kysely, + name: string, + tableName: string, + provider: SQLProvider +) { + // Cockroach DB needs to drop the index instead + if ( + provider === "cockroachdb" || + provider === "sqlite" || + provider === "mssql" + ) { + let query = db.schema.dropIndex(name).ifExists(); + if (provider === "cockroachdb") query = query.cascade(); + if (provider === "mssql") query = query.on(tableName); + + return query; + } + + return db.schema.alterTable(tableName).dropConstraint(name); +} + +function executeColumn( + tableName: string, + operation: ColumnOperation, + config: KyselyConfig +): ExecuteNode[] { + const { db, provider } = config; + const next = () => db.schema.alterTable(tableName); + const results: ExecuteNode[] = []; + + switch (operation.type) { + case "rename-column": + results.push(next().renameColumn(operation.from, operation.to)); + return results; + + case "drop-column": + results.push(next().dropColumn(operation.name)); + + return results; + case "create-column": { + const col = operation.value; + + results.push( + next().addColumn( + col.names.sql, + sql.raw(schemaToDBType(col, provider)), + getColumnBuilderCallback(col, provider) + ) + ); + + return results; + } + case "update-column": { + const col = operation.value; + + if (col instanceof IdColumn) throw new Error(errors.IdColumnUpdate); + if (provider === "sqlite") { + throw new Error( + "SQLite doesn't support updating column, recreate the table instead." + ); + } + + if (!isUpdated(operation)) return results; + + if (provider === "mysql") { + results.push( + next().modifyColumn( + operation.name, + sql.raw(schemaToDBType(col, provider)), + getColumnBuilderCallback(col, provider) + ) + ); + return results; + } + + const mssqlRecreateDefaultConstraint = + operation.updateDataType || operation.updateDefault; + + if (provider === "mssql" && mssqlRecreateDefaultConstraint) { + results.push( + rawToNode(db, mssqlDropDefaultConstraint(tableName, col.names.sql)) + ); + } + + if (operation.updateDataType) { + const dbType = sql.raw(schemaToDBType(col, provider)); + + results.push( + provider === "postgresql" || provider === "cockroachdb" + ? rawToNode( + db, + sql`ALTER TABLE ${sql.ref(tableName)} ALTER COLUMN ${sql.ref(operation.name)} TYPE ${dbType} USING (${sql.ref(operation.name)}::${dbType})` + ) + : next().alterColumn(operation.name, (b) => b.setDataType(dbType)) + ); + } + + if (operation.updateNullable) { + results.push( + next().alterColumn(operation.name, (build) => + col.isNullable ? build.dropNotNull() : build.setNotNull() + ) + ); + } + + if (provider === "mssql" && mssqlRecreateDefaultConstraint) { + const defaultValue = defaultValueToDB(col, provider); + + if (defaultValue) { + const name = `DF_${tableName}_${col.names.sql}`; + + results.push( + rawToNode( + db, + sql`ALTER TABLE ${sql.ref(tableName)} ADD CONSTRAINT ${sql.ref(name)} DEFAULT ${defaultValue} FOR ${sql.ref(col.names.sql)}` + ) + ); + } + } else if (provider !== "mssql" && operation.updateDefault) { + const defaultValue = defaultValueToDB(col, provider); + + results.push( + next().alterColumn(operation.name, (build) => { + if (!defaultValue) return build.dropDefault(); + return build.setDefault(defaultValue); + }) + ); + } + + return results; + } + } +} + +export function execute( + operation: MigrationOperation, + config: KyselyConfig, + onCustomNode: (op: CustomOperation) => ExecuteNode | ExecuteNode[] +): ExecuteNode | ExecuteNode[] { + const { + db, + provider, + relationMode = provider === "mssql" ? "fumadb" : "foreign-keys", + } = config; + + function createTable( + table: AnyTable, + tableName = table.names.sql, + sqliteDeferChecks = false + ) { + const results: ExecuteNode[] = []; + let builder = db.schema.createTable(tableName) as CreateTableBuilder< + string, + string + >; + + for (const col of Object.values(table.columns)) { + builder = builder.addColumn( + col.names.sql, + sql.raw(schemaToDBType(col, provider)), + getColumnBuilderCallback(col, provider) + ); + } + + for (const foreignKey of table.foreignKeys) { + if (relationMode === "fumadb") break; + const compiled = compileForeignKey(foreignKey, "sql"); + + builder = builder.addForeignKeyConstraint( + compiled.name, + compiled.columns, + compiled.referencedTable, + compiled.referencedColumns, + (b) => { + const builder = b + .onUpdate(mapForeignKeyAction(compiled.onUpdate, provider)) + .onDelete(mapForeignKeyAction(compiled.onDelete, provider)); + + if (sqliteDeferChecks) + return builder.deferrable().initiallyDeferred(); + return builder; + } + ); + } + + for (const con of table.getUniqueConstraints()) { + results.push( + createUniqueIndexOrConstraint( + db, + con.name, + table.names.sql, + con.columns.map((col) => col.names.sql), + provider + ) + ); + } + + results.unshift(builder); + return results; + } + + switch (operation.type) { + case "create-table": + return createTable(operation.value); + case "rename-table": + if (provider === "mssql") { + return rawToNode( + db, + sql.raw(`EXEC sp_rename ${operation.from}, ${operation.to}`) + ); + } + + return db.schema.alterTable(operation.from).renameTo(operation.to); + case "update-table": { + const results: ExecuteNode[] = []; + + for (const op of operation.value) { + results.push(...executeColumn(operation.name, op, config)); + } + + return results; + } + case "drop-table": + return db.schema.dropTable(operation.name); + case "custom": + return onCustomNode(operation); + case "add-foreign-key": { + if (provider === "sqlite") + throw new Error(errors.SQLiteUpdateForeignKeys); + const { table, value } = operation; + + return db.schema + .alterTable(table) + .addForeignKeyConstraint( + value.name, + value.columns, + value.referencedTable, + value.referencedColumns, + (b) => + b + .onUpdate(mapForeignKeyAction(value.onUpdate, provider)) + .onDelete(mapForeignKeyAction(value.onDelete, provider)) + ); + } + case "drop-foreign-key": { + if (provider === "sqlite") + throw new Error(errors.SQLiteUpdateForeignKeys); + const { table, name } = operation; + let query = db.schema.alterTable(table).dropConstraint(name); + if (provider !== "mysql") query = query.ifExists(); + + return query; + } + case "add-unique-constraint": + return createUniqueIndexOrConstraint( + db, + operation.name, + operation.table, + operation.columns, + provider + ); + case "drop-unique-constraint": + return dropUniqueIndexOrConstraint( + db, + operation.name, + operation.table, + provider + ); + } +} + +function mapForeignKeyAction( + action: ForeignKeyAction, + provider: SQLProvider +): OnModifyForeignAction { + switch (action) { + case "CASCADE": + return "cascade"; + case "RESTRICT": + return provider === "mssql" ? "no action" : "restrict"; + case "SET NULL": + return "set null"; + } +} + +function rawToNode(db: Kysely, raw: RawBuilder): ExecuteNode { + return { + compile() { + return raw.compile(db); + }, + execute() { + return raw.execute(db); + }, + }; +} + +function mssqlDropDefaultConstraint(tableName: string, columnName: string) { + const alter = sql.lit(`ALTER TABLE "dbo"."${tableName}" DROP CONSTRAINT `); + + return sql`DECLARE @ConstraintName NVARCHAR(200); + +SELECT @ConstraintName = dc.name +FROM sys.default_constraints dc +JOIN sys.columns c ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id +JOIN sys.tables t ON t.object_id = c.object_id +JOIN sys.schemas s ON t.schema_id = s.schema_id +WHERE s.name = 'dbo' AND t.name = ${sql.lit(tableName)} AND c.name = ${sql.lit(columnName)}; + +IF @ConstraintName IS NOT NULL +BEGIN + EXEC(${alter} + @ConstraintName); +END`; +} + +function defaultValueToDB(column: AnyColumn, provider: SQLProvider) { + const value = column.default; + if (!value) return; + // mysql doesn't support default value for text + if (provider === "mysql" && column.type === "string") return; + + if ("runtime" in value && value.runtime === "now") { + return sql`CURRENT_TIMESTAMP`; + } + + if ("value" in value) return sql.lit(value.value); +} diff --git a/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts b/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts new file mode 100644 index 000000000..212a7258f --- /dev/null +++ b/packages/core/fumadb/src/adapters/kysely/migration/introspect.ts @@ -0,0 +1,1066 @@ +import { + type ColumnMetadata, + type Kysely, + sql, + type TableMetadata, +} from "kysely"; +import type { ForeignKeyInfo } from "../../../migration-engine/shared"; +import { + type AnyColumn, + type AnySchema, + type AnyTable, + column, + idColumn, + type RelationBuilder, + type RelationsMap, + schema, + type TypeMap, + table, +} from "../../../schema/create"; +import type { NameVariantsConfig } from "../../../schema/name-variants-builder"; +import { dbToSchemaType } from "../../../schema/serialize"; +import type { SQLProvider } from "../../../shared/providers"; +import { CockroachIntrospector } from "./cockroach-inspector"; + +export interface AdditionalColumnMetadata { + length?: number; + precision?: number; + scale?: number; +} + +export interface IntrospectOptions { + /** + * Database connection + */ + db: Kysely; + + /** + * Database provider + */ + provider: SQLProvider; + + nameVariants?: NameVariantsConfig; + + /** + * Schema version to generate + * @default "1.0.0" + */ + version?: string; + + /** + * Internal tables to exclude from introspection + * @default [] + */ + internalTables?: string[]; + + /** + * Custom table name mapping (database name -> schema name) + */ + tableNameMapping?: (tableName: string) => string; + + /** + * Custom column name mapping (database name -> schema name) + */ + columnNameMapping?: (tableName: string, columnName: string) => string; + + columnTypeMapping?: ( + dataType: string, + options: { + tableMetadata: TableMetadata; + metadata: ColumnMetadata & AdditionalColumnMetadata; + isPrimaryKey: boolean; + } + ) => keyof TypeMap; + + /** + * Whether to include relations in the generated schema + * @default true + */ + includeRelations?: boolean; +} + +export interface IntrospectResult { + /** + * Generated FumaDB schema + */ + schema: AnySchema; +} + +/** + * Get user tables from database (copied from auto.ts) + */ +async function getUserTables( + db: Kysely, + internalTables: string[], + provider: SQLProvider +): Promise { + const allTables = + provider === "cockroachdb" + ? await new CockroachIntrospector(db).getTables() + : await db.introspection.getTables(); + + // MySQL, PostgreSQL, SQLite, etc. + const excludedSchemas = [ + "mysql", + "information_schema", + "performance_schema", + "sys", + "pg_catalog", + "pg_toast", + "sqlite_master", + "sqlite_temp_master", + ]; + + // Filter out tables that belong to internal schemas or are views + return allTables.filter( + (table) => + !table.isView && + (!table.schema || !excludedSchemas.includes(table.schema)) && + !internalTables.includes(table.name) + ); +} + +/** + * Introspect a database and generate a FumaDB schema + */ +export async function introspectSchema( + options: IntrospectOptions +): Promise { + const { + db, + provider, + version = "1.0.0", + internalTables = [], + tableNameMapping = (t) => t, + columnNameMapping = (_, c) => c, + columnTypeMapping = (type, options) => + dbToSchemaType(type, provider, options.metadata)[0] as keyof TypeMap, + includeRelations = true, + } = options; + + const dbTables = await getUserTables(db, internalTables, provider); + + const tables: Record = {}; + const relations: RelationsMap> = {}; + + async function buildColumn( + dbTable: TableMetadata, + dbColumn: ColumnMetadata, + isPrimaryKey: boolean + ): Promise { + const metadata = await getColumnMetadata( + db, + provider, + dbTable.name, + dbColumn.name + ); + + const columnType = columnTypeMapping(dbColumn.dataType, { + isPrimaryKey, + metadata: { ...dbColumn, ...metadata }, + tableMetadata: dbTable, + }); + + if (!columnType) + throw new Error( + `Failed to detect data type of ${dbColumn.dataType}, note that FumaDB doesn't support advanced data types in schema.` + ); + + let rawDefault: unknown; + + try { + rawDefault = await getColumnDefaultValue( + db, + provider, + dbTable.name, + dbColumn.name + ); + } catch { + // ignore + } + + let col: AnyColumn; + if (isPrimaryKey) { + if (!columnType.startsWith("varchar") && columnType !== "uuid") + throw new Error( + `ID column only supports varchar and uuid at the moment, found ${columnType}.` + ); + + if (columnType === "uuid") { + col = idColumn(dbColumn.name, "uuid"); + } else { + col = idColumn(dbColumn.name, columnType as `varchar(${number})`); + } + } else { + col = column(dbColumn.name, columnType).nullable(dbColumn.isNullable); + } + + const addDefault = normalizeColumnDefault(rawDefault, columnType); + if (addDefault) col = addDefault(col); + return col; + } + + async function buildRelation(table: AnyTable) { + const foreignKeys = await introspectTableForeignKeys( + db, + provider, + table.names.sql + ); + + return (b: RelationBuilder) => { + const output: Record< + string, + ReturnType + > = {}; + + for (const key of foreignKeys) { + let relationName = key.name; + const RemoveSuffix = "_fk"; + + if (relationName.endsWith(RemoveSuffix)) + relationName = relationName.slice(0, -RemoveSuffix.length); + + output[relationName] = buildRelationDefinition(b, table, key, (name) => + Object.values(tables).find((t) => t.names.sql === name) + ); + } + + return output; + }; + } + + for (const dbTable of dbTables) { + const columns: Record = {}; + const primaryKeys = await introspectPrimaryKeys(db, dbTable.name, provider); + const uniqueConsts = await introspectUniqueConstraints( + db, + dbTable.name, + provider + ); + + for (const index of await introspectUniqueIndexes( + db, + dbTable.name, + provider + )) { + if (uniqueConsts.some((con) => con.name === index.name)) continue; + + uniqueConsts.push(index); + } + + if (primaryKeys.length !== 1) + throw new Error( + `FumaDB only supports 1 primary key (ID column), received: ${primaryKeys.length}.` + ); + + for (const dbColumn of dbTable.columns) { + const isPrimaryKey = primaryKeys.includes(dbColumn.name); + + columns[columnNameMapping(dbTable.name, dbColumn.name)] = + await buildColumn(dbTable, dbColumn, isPrimaryKey); + } + + // all unique constraints are treated as table-level ones to support custom names + const t = table(dbTable.name, columns); + for (const con of uniqueConsts) { + t.unique( + con.name, + con.columns.map((col) => columnNameMapping(dbTable.name, col)) + ); + } + + tables[tableNameMapping(dbTable.name)] = t; + } + + // Build relations + if (includeRelations) { + for (const k in tables) { + const table = tables[k]; + + relations[k] = await buildRelation(table); + } + } + + const generatedSchema = schema({ + version, + tables, + relations, + }); + + return { + schema: generatedSchema, + }; +} + +/** + * Get column metadata including length, precision, and scale + */ +async function getColumnMetadata( + db: Kysely, + provider: SQLProvider, + tableName: string, + columnName: string +): Promise { + function num(v?: string | number | null): number | undefined { + if (v == null) return; + const converted = Number(v); + + if (Number.isNaN(converted) || converted === -1) return; + return converted; + } + + switch (provider) { + case "cockroachdb": + case "postgresql": { + const result = await db + .selectFrom("information_schema.columns") + .select([ + "character_maximum_length as length", + "numeric_precision as precision", + "numeric_scale as scale", + ]) + .where("table_name", "=", tableName) + .where("column_name", "=", columnName) + .executeTakeFirst(); + + return { + length: num(result?.length), + precision: num(result?.precision), + scale: num(result?.scale), + }; + } + case "mysql": { + const result = await db + .selectFrom("information_schema.columns") + .select([ + "CHARACTER_MAXIMUM_LENGTH as length", + "NUMERIC_PRECISION as precision", + "NUMERIC_SCALE as scale", + ]) + .where("table_name", "=", tableName) + .where("column_name", "=", columnName) + .executeTakeFirst(); + + return { + length: num(result?.length), + precision: num(result?.precision), + scale: num(result?.scale), + }; + } + // SQLite doesn't have length/precision/scale + case "sqlite": { + return {}; + } + case "mssql": { + const result = await db + .selectFrom("sys.columns as c") + .innerJoin("sys.tables as t", "c.object_id", "t.object_id") + .select([ + "c.max_length as length", + "c.precision as precision", + "c.scale as scale", + ]) + .where("t.name", "=", tableName) + .where("c.name", "=", columnName) + .executeTakeFirst(); + + return { + length: num(result?.length), + precision: num(result?.precision), + scale: num(result?.scale), + }; + } + } +} + +/** + * Get column default value from database + */ +async function getColumnDefaultValue( + db: Kysely, + provider: SQLProvider, + tableName: string, + columnName: string +): Promise { + switch (provider) { + case "cockroachdb": + case "postgresql": + return await db + .selectFrom("information_schema.columns") + .select("column_default") + .where("table_name", "=", tableName) + .where("column_name", "=", columnName) + .executeTakeFirst() + .then((result) => result?.column_default ?? null); + case "mysql": { + const result = await db + .selectFrom("information_schema.columns") + .select("COLUMN_DEFAULT as column_default") + .where("table_name", "=", tableName) + .where("column_name", "=", columnName) + .executeTakeFirst(); + return result?.column_default ?? null; + } + case "sqlite": { + const { sql } = await import("kysely"); + const pragmaRows = await sql + .raw(`PRAGMA table_info(${tableName})`) + .execute(db); + + const row = Array.isArray(pragmaRows) + ? pragmaRows.find((r: any) => r.name === columnName) + : undefined; + return row?.dflt_value ?? null; + } + case "mssql": { + const result = await db + .selectFrom("sys.columns as c") + .innerJoin("sys.tables as t", "c.object_id", "t.object_id") + .leftJoin("sys.default_constraints as d", (join) => + join.on("c.default_object_id", "=", "d.object_id") + ) + .select("d.definition as column_default") + .where("t.name", "=", tableName) + .where("c.name", "=", columnName) + .executeTakeFirst(); + return result?.column_default ?? null; + } + default: + throw new Error( + `Provider ${provider} not supported for default value introspection` + ); + } +} + +/** + * Normalize column default value + */ +function normalizeColumnDefault( + raw: unknown | null, + type: string +): ((col: AnyColumn) => AnyColumn) | undefined { + if (raw == null) return; + let str = String(raw).trim(); + + if ( + /^(CURRENT_TIMESTAMP|now\(\)|datetime\('now'\)|getdate\(\))/i.test(str) && + (type === "date" || type === "timestamp") + ) { + return (col) => col.defaultTo$("now" as any); + } + + // Remove type casts and quotes + str = str.replace(/::[\w\s[\]."]+$/, ""); + if (str.startsWith("E'") || str.startsWith("N'")) { + str = str.slice(2, -1); + } else if ( + (str.startsWith("'") && str.endsWith("'")) || + (str.startsWith('"') && str.endsWith('"')) + ) { + str = str.slice(1, -1); + } + + if (type === "bool") { + if (str === "true" || str === "1") + return (col) => col.defaultTo(true as any); + if (str === "false" || str === "0") + return (col) => col.defaultTo(false as any); + } + + if ((type === "integer" || type === "decimal") && str.length > 0) { + const parsed = Number(str); + if (Number.isNaN(parsed)) + throw new Error( + `Failed to parse number from database default column value: ${str}` + ); + + return (col) => col.defaultTo(parsed as any); + } + + if (type === "json") { + return (col) => col.defaultTo(JSON.parse(str)); + } + + if (type === "bigint" && str.length > 0) { + return (col) => col.defaultTo(BigInt(str) as any); + } + + if (type === "timestamp" || type === "date") { + return (col) => col.defaultTo(new Date(str) as any); + } + + if (str.toLowerCase() === "null") return; + + if (type === "string" || type.startsWith("varchar")) + return (col) => col.defaultTo(str); +} + +function buildRelationDefinition( + builder: RelationBuilder, + table: AnyTable, + fk: ForeignKeyInfo, + dbNameToTable: (name: string) => AnyTable | undefined +) { + const targetTable = dbNameToTable(fk.referencedTable); + if (!targetTable) + throw new Error( + `Failed to resolve referenced table in a foreign key: ${fk.referencedTable}` + ); + + const on: [string, string][] = []; + for (let i = 0; i < fk.columns.length; i++) { + const col = fk.columns[i]!; + const refCol = fk.referencedColumns[i]!; + + on.push([ + table.getColumnByName(col)!.ormName, + targetTable.getColumnByName(refCol)!.ormName, + ]); + } + + return builder.one(targetTable.ormName, ...on).foreignKey({ + name: fk.name, + onDelete: fk.onDelete, + onUpdate: fk.onUpdate, + }); +} + +async function introspectPrimaryKeys( + db: Kysely, + tableName: string, + provider: SQLProvider +): Promise { + if (provider === "sqlite") { + const columns = await db + .selectFrom(sql.raw(`pragma_table_info('${tableName}')`).as("t")) + .select(["name", "pk"]) + .execute(); + + return columns.filter((col) => col.pk).map((col) => col.name as string); + } + + if (provider === "postgresql" || provider === "cockroachdb") { + const pkRows = await db + .selectFrom("pg_constraint") + .innerJoin("pg_class", "pg_constraint.conrelid", "pg_class.oid") + .innerJoin("pg_namespace", "pg_class.relnamespace", "pg_namespace.oid") + .where("pg_class.relname", "=", tableName) + .where("pg_constraint.contype", "=", "p") + .select(["pg_constraint.conname", "pg_constraint.conkey"]) + .execute(); + + const attnumToName = await postgresqlIntrospectAttnumToName(db, tableName); + + const primaryKeys: string[] = []; + for (const pk of pkRows) { + const attnums = postgresqlParseConName(pk.conkey); + + for (const attnum of attnums) { + const colName = attnumToName.get(attnum); + + if (colName !== undefined) { + primaryKeys.push(colName); + } + } + } + return primaryKeys; + } + + if (provider === "mysql") { + const keyRows = await db + .selectFrom("information_schema.KEY_COLUMN_USAGE") + .where("TABLE_NAME", "=", tableName) + .select(["CONSTRAINT_NAME", "COLUMN_NAME"]) + .execute(); + + const constraints: Record = {}; + for (const row of keyRows) { + if (row.CONSTRAINT_NAME && row.COLUMN_NAME) { + constraints[row.CONSTRAINT_NAME] ??= []; + constraints[row.CONSTRAINT_NAME]?.push(row.COLUMN_NAME); + } + } + + const pkRow = await db + .selectFrom("information_schema.TABLE_CONSTRAINTS") + .where("TABLE_NAME", "=", tableName) + .where("CONSTRAINT_TYPE", "=", "PRIMARY KEY") + .select(["CONSTRAINT_NAME"]) + // a table should have at least one primary key + .executeTakeFirstOrThrow(); + + const pkName = pkRow.CONSTRAINT_NAME; + return constraints[pkName] ?? []; + } + + if (provider === "mssql") { + const result = await db + .selectFrom("sys.key_constraints as kc") + .select("c.name as column_name") + .innerJoin("sys.index_columns as ic", (v) => + v + .onRef("kc.parent_object_id", "=", "ic.object_id") + .onRef("kc.unique_index_id", "=", "ic.index_id") + ) + .innerJoin("sys.columns as c", (v) => + v + .onRef("ic.object_id", "=", "c.object_id") + .onRef("ic.column_id", "=", "c.column_id") + ) + .innerJoin("sys.tables as t", "kc.parent_object_id", "t.object_id") + .innerJoin("sys.schemas as s", "t.schema_id", "s.schema_id") + .where("kc.type", "=", "PK") + .where("s.name", "=", "dbo") + .where("t.name", "=", tableName) + .orderBy("ic.key_ordinal") + .execute(); + + return result.map((row) => row.column_name); + } + + // Fallback: return empty + return []; +} + +async function postgresqlIntrospectAttnumToName( + db: Kysely, + tableName: string +) { + const colRows = await db + .selectFrom("pg_attribute") + .innerJoin("pg_class", "pg_attribute.attrelid", "pg_class.oid") + .where("pg_class.relname", "=", tableName) + .where("pg_attribute.attnum", ">", 0) + .select(["pg_attribute.attnum", "pg_attribute.attname"]) + .execute(); + + const attnumToName = new Map(); + for (const row of colRows) { + attnumToName.set(Number(row.attnum), row.attname); + } + return attnumToName; +} + +/** + * @param conName usually in the format of {1,2,4} or returned as an array of numbers (depending on the driver) + */ +function postgresqlParseConName(conName: unknown): number[] { + if (Array.isArray(conName)) return conName.map(Number); + if (typeof conName === "string") { + return conName + .substring(1, conName.length - 1) + .split(",") + .map(Number); + } + + return []; +} + +interface UniqueConstraint { + name: string; + columns: string[]; +} + +function mapToUniqueConstraints( + from: { column_name: string; constraint_name: string }[] +): UniqueConstraint[] { + const map = new Map(); + + for (const item of from) { + const value = map.get(item.constraint_name) ?? { + name: item.constraint_name, + columns: [], + }; + + value.columns.push(item.column_name); + map.set(item.constraint_name, value); + } + + return Array.from(map.values()); +} + +async function introspectUniqueIndexes( + db: Kysely, + tableName: string, + provider: SQLProvider +): Promise { + if (provider === "mssql") { + const indexes = await db + .selectFrom("sys.indexes as i") + .innerJoin("sys.index_columns as ic", (join) => + join + .onRef("i.object_id", "=", "ic.object_id") + .onRef("i.index_id", "=", "ic.index_id") + ) + .innerJoin("sys.columns as c", (join) => + join + .onRef("ic.object_id", "=", "c.object_id") + .onRef("ic.column_id", "=", "c.column_id") + ) + .innerJoin("sys.tables as t", "i.object_id", "t.object_id") + .where("i.is_unique", "=", 1) + // Exclude indexes backing unique constraints or primary keys + .where( + "i.index_id", + "not in", + db + .selectFrom("sys.key_constraints") + .select("unique_index_id") + .whereRef("parent_object_id", "=", "t.object_id") + ) + .where("t.name", "=", tableName) + .select([ + "i.name as constraint_name", + "c.name as column_name", + "ic.key_ordinal", + ]) + .orderBy("constraint_name") + .orderBy("ic.key_ordinal") + .execute(); + + return mapToUniqueConstraints(indexes); + } + + if (provider === "sqlite") { + const indexes = await db + .selectFrom(sql.raw(`pragma_index_list('${tableName}')`).as("i")) + .select(["name", "unique"]) + .execute(); + + const uniqueConstraints: UniqueConstraint[] = []; + for (const idx of indexes) { + if (!idx.unique) continue; + + const idxCols = await db + .selectFrom(sql.raw(`pragma_index_info('${idx.name}')`).as("ii")) + .select(["name"]) + .execute(); + + uniqueConstraints.push({ + name: idx.name, + columns: idxCols.map((c) => c.name as string), + }); + } + + return uniqueConstraints; + } + + return []; +} + +async function introspectUniqueConstraints( + db: Kysely, + tableName: string, + provider: SQLProvider +): Promise { + if (provider === "postgresql" || provider === "cockroachdb") { + const uniqueRows = await db + .selectFrom("pg_constraint") + .innerJoin("pg_class", "pg_constraint.conrelid", "pg_class.oid") + .innerJoin("pg_namespace", "pg_class.relnamespace", "pg_namespace.oid") + .where("pg_class.relname", "=", tableName) + .where("pg_constraint.contype", "=", "u") + .select(["pg_constraint.conname", "pg_constraint.conkey"]) + .execute(); + + const attnumToName = await postgresqlIntrospectAttnumToName(db, tableName); + const uniqueConstraints: UniqueConstraint[] = []; + for (const uq of uniqueRows) { + const attnums = postgresqlParseConName(uq.conkey); + uniqueConstraints.push({ + name: uq.conname, + columns: attnums.flatMap((a: number) => attnumToName.get(a) ?? []), + }); + } + + return uniqueConstraints; + } + + if (provider === "mysql") { + const keyRows = await db + .selectFrom("information_schema.KEY_COLUMN_USAGE") + .where("TABLE_NAME", "=", tableName) + .select(["CONSTRAINT_NAME", "COLUMN_NAME"]) + .execute(); + + const constraints: Record = {}; + for (const row of keyRows) { + if (row.CONSTRAINT_NAME && row.COLUMN_NAME) { + constraints[row.CONSTRAINT_NAME] ??= []; + constraints[row.CONSTRAINT_NAME]?.push(row.COLUMN_NAME); + } + } + + const uniqueRows = await db + .selectFrom("information_schema.TABLE_CONSTRAINTS") + .where("TABLE_NAME", "=", tableName) + .where("CONSTRAINT_TYPE", "=", "UNIQUE") + .select(["CONSTRAINT_NAME"]) + .execute(); + + const uniqueConstraints: UniqueConstraint[] = []; + for (const uq of uniqueRows) { + uniqueConstraints.push({ + name: uq.CONSTRAINT_NAME, + columns: constraints[uq.CONSTRAINT_NAME] ?? [], + }); + } + + return uniqueConstraints; + } + + if (provider === "mssql") { + const constraints = await db + .selectFrom("sys.key_constraints as kc") + .innerJoin("sys.index_columns as ic", (join) => + join + .onRef("kc.parent_object_id", "=", "ic.object_id") + .onRef("kc.unique_index_id", "=", "ic.index_id") + ) + .innerJoin("sys.columns as c", (join) => + join + .onRef("ic.object_id", "=", "c.object_id") + .onRef("ic.column_id", "=", "c.column_id") + ) + .innerJoin("sys.tables as t", "kc.parent_object_id", "t.object_id") + .where("kc.type", "=", "UQ") + .where("t.name", "=", tableName) + .select([ + "kc.name as constraint_name", + "c.name as column_name", + "ic.key_ordinal", + ]) + .orderBy("constraint_name") + .orderBy("ic.key_ordinal") + .execute(); + + return mapToUniqueConstraints(constraints); + } + + return []; +} + +async function introspectTableForeignKeys( + db: Kysely, + provider: SQLProvider, + tableName: string +): Promise { + if (provider === "postgresql" || provider === "cockroachdb") { + // Get all foreign keys for the table (columns, referenced table, and actions) + const constraints = await db + .selectFrom("information_schema.table_constraints as tc") + .innerJoin("information_schema.key_column_usage as kcu", (join) => + join + .onRef("tc.constraint_name", "=", "kcu.constraint_name") + .onRef("tc.table_name", "=", "kcu.table_name") + ) + .innerJoin("information_schema.referential_constraints as rc", (join) => + join.onRef("tc.constraint_name", "=", "rc.constraint_name") + ) + .innerJoin("information_schema.table_constraints as tc_ref", (join) => + join + .onRef("rc.unique_constraint_name", "=", "tc_ref.constraint_name") + .onRef("rc.unique_constraint_schema", "=", "tc_ref.constraint_schema") + ) + .select([ + "tc.constraint_name as name", + "kcu.column_name as column_name", + "kcu.ordinal_position as ordinal_position", + "tc_ref.table_name as referenced_table", + "rc.unique_constraint_name as referenced_constraint_name", + "rc.update_rule as on_update", + "rc.delete_rule as on_delete", + ]) + .where("tc.table_name", "=", tableName) + .where("tc.constraint_type", "=", "FOREIGN KEY") + .orderBy("name", "asc") + .orderBy("ordinal_position", "asc") + .execute(); + + const map = new Map< + string, + ForeignKeyInfo & { + referencedConstraintName: string; + referencedTable: string; + } + >(); + for (const row of constraints) { + let fk = map.get(row.name); + if (!fk) { + fk = { + name: row.name, + columns: [], + referencedTable: row.referenced_table, + referencedColumns: [], + onUpdate: mapAction(row.on_update), + onDelete: mapAction(row.on_delete), + referencedConstraintName: row.referenced_constraint_name, + }; + map.set(row.name, fk); + } + fk.columns.push(row.column_name); + } + + // referenced columns + for (const fk of map.values()) { + const refCols = await db + .selectFrom("information_schema.key_column_usage") + .select(["column_name"]) + .where("constraint_name", "=", fk.referencedConstraintName) + .where("table_name", "=", fk.referencedTable) + .orderBy("ordinal_position", "asc") + .execute(); + fk.referencedColumns = refCols.map((r) => r.column_name); + // Remove helper fields + delete (fk as any).referencedConstraintName; + } + + return Array.from(map.values()); + } + + if (provider === "mysql") { + // Query information_schema.key_column_usage and referential_constraints + const constraints = await db + .selectFrom("information_schema.key_column_usage as kcu") + .innerJoin("information_schema.referential_constraints as rc", (join) => + join + .onRef("kcu.constraint_name", "=", "rc.constraint_name") + .onRef("kcu.table_name", "=", "rc.table_name") + ) + .select([ + "kcu.constraint_name as name", + "kcu.column_name as column_name", + "kcu.ordinal_position as ordinal_position", + "kcu.referenced_table_name as referenced_table", + "kcu.referenced_column_name as referenced_column", + "rc.update_rule as on_update", + "rc.delete_rule as on_delete", + ]) + .where("kcu.table_name", "=", tableName) + .where("kcu.referenced_table_name", "is not", null) + .orderBy("name", "asc") + .orderBy("ordinal_position", "asc") + .execute(); + + const map = new Map(); + for (const row of constraints) { + let fk = map.get(row.name); + if (!fk) { + fk = { + name: row.name, + columns: [], + referencedTable: row.referenced_table, + referencedColumns: [], + onUpdate: mapAction(row.on_update), + onDelete: mapAction(row.on_delete), + }; + map.set(row.name, fk); + } + fk.columns.push(row.column_name); + fk.referencedColumns.push(row.referenced_column); + } + return Array.from(map.values()); + } + + if (provider === "sqlite") { + // Use PRAGMA foreign_key_list + const pragmaRows = await sql + .raw(`PRAGMA foreign_key_list(${tableName})`) + .execute(db); + // Each row: id, seq, table, from, to, on_update, on_delete, match + const map = new Map(); + for (const row of pragmaRows.rows as any[]) { + let fk = map.get(row.id); + + if (!fk) { + fk = { + name: `fk_${tableName}_${row.id}`, + columns: [], + referencedTable: row.table, + referencedColumns: [], + onUpdate: mapAction(row.on_update), + onDelete: mapAction(row.on_delete), + }; + map.set(row.id, fk); + } + fk.columns.push(row.from); + fk.referencedColumns.push(row.to); + } + return Array.from(map.values()); + } + + if (provider === "mssql") { + // Query sys.foreign_keys, sys.foreign_key_columns, sys.columns, sys.tables + const constraints = await db + .selectFrom("sys.foreign_keys as fk") + .innerJoin( + "sys.foreign_key_columns as fkc", + "fk.object_id", + "fkc.constraint_object_id" + ) + .innerJoin("sys.tables as t", "fk.parent_object_id", "t.object_id") + .innerJoin("sys.columns as c", (join) => + join + .onRef("fkc.parent_object_id", "=", "c.object_id") + .onRef("fkc.parent_column_id", "=", "c.column_id") + ) + .innerJoin("sys.tables as rt", "fk.referenced_object_id", "rt.object_id") + .innerJoin("sys.columns as rc", (join) => + join + .onRef("fkc.referenced_object_id", "=", "rc.object_id") + .onRef("fkc.referenced_column_id", "=", "rc.column_id") + ) + .select([ + "fk.name as name", + "c.name as column_name", + "rc.name as referenced_column", + "rt.name as referenced_table", + "fkc.constraint_column_id as ordinal_position", + "fk.delete_referential_action_desc as on_delete", + "fk.update_referential_action_desc as on_update", + ]) + .where("t.name", "=", tableName) + .orderBy("name", "asc") + .orderBy("ordinal_position", "asc") + .execute(); + const map = new Map(); + for (const row of constraints) { + let fk = map.get(row.name); + if (!fk) { + fk = { + name: row.name, + columns: [], + referencedTable: row.referenced_table, + referencedColumns: [], + onUpdate: mapAction(row.on_update), + onDelete: mapAction(row.on_delete), + }; + map.set(row.name, fk); + } + fk.columns.push(row.column_name); + fk.referencedColumns.push(row.referenced_column); + } + return Array.from(map.values()); + } + + throw new Error( + `Provider ${provider} not supported for foreign key introspection` + ); +} + +function mapAction( + action: string | undefined +): "RESTRICT" | "CASCADE" | "SET NULL" { + switch (action?.toUpperCase()) { + case "CASCADE": + return "CASCADE"; + case "SET NULL": + return "SET NULL"; + case "RESTRICT": + case "NO ACTION": + case "NONE": + return "RESTRICT"; + default: + return "RESTRICT"; + } +} diff --git a/packages/core/fumadb/src/adapters/kysely/migration/transformer-sqlite.ts b/packages/core/fumadb/src/adapters/kysely/migration/transformer-sqlite.ts new file mode 100644 index 000000000..ffcc9d1c1 --- /dev/null +++ b/packages/core/fumadb/src/adapters/kysely/migration/transformer-sqlite.ts @@ -0,0 +1,156 @@ +import type { MigrationTransformer } from "../../../migration-engine/create"; +import type { + ColumnOperation, + MigrationOperation, +} from "../../../migration-engine/shared"; +import type { AnyTable } from "../../../schema"; + +const SupportedColumnOperations: ColumnOperation["type"][] = [ + "create-column", + "rename-column", +]; + +export const transformerSQLite: MigrationTransformer = { + afterAuto(operations, { prev, next }) { + const operationTables: (AnyTable | null)[] = []; + const nameToTable = new Map(); + const recreate = new Set(); + + for (const table of Object.values(prev.tables)) { + nameToTable.set(table.names.sql, table); + } + + for (const op of operations) { + let table: AnyTable | undefined ; + + switch (op.type) { + case "create-table": { + table = op.value; + nameToTable.set(op.value.names.sql, table); + break; + } + case "rename-table": { + table = nameToTable.get(op.from); + if (!table) break; + + nameToTable.set(op.to, table); + nameToTable.delete(op.from); + break; + } + case "add-unique-constraint": + case "drop-unique-constraint": { + table = nameToTable.get(op.table); + break; + } + case "add-foreign-key": + case "drop-foreign-key": { + table = nameToTable.get(op.table); + if (!table) break; + + recreate.add(table); + break; + } + case "update-table": { + table = nameToTable.get(op.name); + if ( + !table || + op.value.every((action) => + SupportedColumnOperations.includes(action.type) + ) + ) + break; + + recreate.add(table); + break; + } + case "drop-table": { + table = nameToTable.get(op.name); + if (!table) break; + + nameToTable.delete(op.name); + recreate.delete(table); + } + } + + operationTables.push(table ?? null); + } + + // remove all operations on the recreating tables, as the recreated one will be 100% consistent with target schema + operations = operations.filter((_, i) => { + const table = operationTables[i]; + + return !table || !recreate.has(table); + }); + + const post: (() => void)[] = []; + for (const prevTable of recreate) { + const nextTable = next.tables[prevTable.ormName]; + if (!nextTable) continue; + + for (const con of prevTable.getUniqueConstraints()) { + operations.push({ + type: "drop-unique-constraint", + table: prevTable.names.sql, + name: con.name, + }); + } + + const tempTable = + nextTable.names.sql === prevTable.names.sql + ? { + ...nextTable, + names: { + ...nextTable.names, + sql: `_temp_${nextTable.names.sql}`, + }, + } + : nextTable; + + operations.push({ + type: "create-table", + value: tempTable, + }); + + post.push(() => { + operations.push(...transferTable(prevTable, tempTable)); + + if (tempTable !== nextTable) + operations.push({ + type: "rename-table", + from: tempTable.names.sql, + to: nextTable.names.sql, + }); + }); + } + + for (const item of post) item(); + + return operations; + }, +}; + +function transferTable(from: AnyTable, to: AnyTable): MigrationOperation[] { + const tempName = + to.names.sql === from.names.sql ? `_temp_${to.names.sql}` : to.names.sql; + + const colNames: string[] = []; + const values: string[] = []; + for (const prevCol of Object.values(from.columns)) { + const nextCol = to.columns[prevCol.ormName]; + if (!nextCol) continue; + + colNames.push(`"${nextCol.names.sql}"`); + values.push(`"${prevCol.names.sql}" as "${nextCol.names.sql}"`); + } + + return [ + { + type: "custom", + sql: `INSERT INTO "${tempName}" (${colNames.join(", ")}) SELECT ${values.join(", ")} FROM "${from.names.sql}"`, + }, + { + type: "drop-table", + name: from.names.sql, + }, + ]; +} diff --git a/packages/core/fumadb/src/adapters/kysely/query.ts b/packages/core/fumadb/src/adapters/kysely/query.ts new file mode 100644 index 000000000..a1038cd52 --- /dev/null +++ b/packages/core/fumadb/src/adapters/kysely/query.ts @@ -0,0 +1,544 @@ +import type { SqlBool } from "kysely"; +import { + type BinaryOperator, + type ExpressionBuilder, + type ExpressionWrapper, + sql, +} from "kysely"; +import type { + AbstractQuery, + AnySelectClause, + FindManyOptions, +} from "../../query"; +import { type Condition, ConditionType } from "../../query/condition-builder"; +import { + type CompiledJoin, + type ORMAdapter, + type SimplifyFindOptions, + toORM, +} from "../../query/orm"; +import { createSoftForeignKey } from "../../query/polyfills/foreign-key"; +import { + type AnyColumn, + type AnySchema, + type AnyTable, + Column, +} from "../../schema"; +import { deserialize, serialize } from "../../schema/serialize"; +import type { KyselyConfig } from "../../shared/config"; +import type { SQLProvider } from "../../shared/providers"; + +function fullSQLName(column: AnyColumn) { + return `${column.table.names.sql}.${column.names.sql}`; +} + +export function buildWhere( + condition: Condition, + eb: ExpressionBuilder, + provider: SQLProvider +): ExpressionWrapper { + if (condition.type === ConditionType.Compare) { + const left = condition.a; + const op = condition.operator; + let val = condition.b; + + if (!(val instanceof Column)) { + val = serialize(val, left, provider); + } + + let v: BinaryOperator; + let rhs: unknown; + + switch (op) { + case "contains": + v = "like"; + case "not contains": + v ??= "not like"; + rhs = + val instanceof Column + ? sql`concat('%', ${eb.ref(fullSQLName(val))}, '%')` + : `%${val}%`; + + break; + case "starts with": + v = "like"; + case "not starts with": + v ??= "not like"; + rhs = + val instanceof Column + ? sql`concat(${eb.ref(fullSQLName(val))}, '%')` + : `${val}%`; + + break; + case "ends with": + v = "like"; + case "not ends with": + v ??= "not like"; + rhs = + val instanceof Column + ? sql`concat('%', ${eb.ref(fullSQLName(val))})` + : `%${val}`; + break; + default: + v = op; + rhs = val instanceof Column ? eb.ref(fullSQLName(val)) : val; + } + + return eb(fullSQLName(left), v, rhs); + } + + // Nested conditions + if (condition.type === ConditionType.And) { + return eb.and(condition.items.map((v) => buildWhere(v, eb, provider))); + } + + if (condition.type === ConditionType.Not) { + return eb.not(buildWhere(condition.item, eb, provider)); + } + + return eb.or(condition.items.map((v) => buildWhere(v, eb, provider))); +} + +function mapSelect( + select: AnySelectClause, + table: AnyTable, + options: { + relation?: string; + tableName?: string; + } = {} +): string[] { + const { relation, tableName = table.names.sql } = options; + const out: string[] = []; + const keys = Array.isArray(select) ? select : Object.keys(table.columns); + + for (const key of keys) { + const name = relation ? `${relation}:${key}` : key; + + out.push(`${tableName}.${table.columns[key].names.sql} as ${name}`); + } + + return out; +} + +function extendSelect(original: AnySelectClause): { + extend: (key: string) => void; + compile: () => { + result: AnySelectClause; + extendedKeys: string[]; + /** + * It doesn't create new object + */ + removeExtendedKeys: ( + record: Record + ) => Record; + }; +} { + const select = Array.isArray(original) ? new Set(original) : true; + const extendedKeys: string[] = []; + + return { + extend(key) { + if (select === true || select.has(key)) return; + + select.add(key); + extendedKeys.push(key); + }, + compile() { + return { + result: select instanceof Set ? Array.from(select) : true, + extendedKeys, + removeExtendedKeys(record) { + for (const key of extendedKeys) { + delete record[key]; + } + return record; + }, + }; + }, + }; +} + +// always use raw SQL names since Kysely is a query builder +export function fromKysely( + schema: AnySchema, + config: KyselyConfig +): AbstractQuery { + const { + db: kysely, + provider, + relationMode = provider === "mssql" ? "fumadb" : "foreign-keys", + } = config; + + /** + * Transform object keys and encode values (e.g. for SQLite, date -> number) + */ + function encodeValues( + values: Record, + table: AnyTable, + generateDefault: boolean + ) { + const result: Record = {}; + + for (const k in table.columns) { + const col = table.columns[k]; + let value = values[k]; + + if (generateDefault && value === undefined) { + // prefer generating them on runtime to avoid SQLite's problem with column default value being ignored when insert + value = col.generateDefaultValue(); + } + + if (value !== undefined) { + result[col.names.sql] = serialize(value, col, provider); + } + } + + return result; + } + + /** + * Transform object keys and decode values + */ + function decodeResult(result: Record, table: AnyTable) { + const output: Record = {}; + + for (const k in result) { + const segs = k.split(":", 2); + const value = result[k]; + + if (segs.length === 1) { + output[k] = deserialize(value, table.columns[k]!, provider); + } + + if (segs.length === 2) { + const [relationName, colName] = segs as [string, string]; + const relation = table.relations[relationName]; + if (relation === undefined) continue; + const col = relation.table.columns[colName]; + if (col === undefined) continue; + + output[relationName] ??= {}; + const obj = output[relationName] as Record; + obj[colName] = deserialize(value, col, provider); + } + } + + return output; + } + + async function runSubQueryJoin( + records: Record[], + join: CompiledJoin + ) { + const { relation, options: joinOptions } = join; + if (joinOptions === false) return; + + const selectBuilder = extendSelect(joinOptions.select); + const root: Condition = { + type: ConditionType.Or, + items: [], + }; + + for (const record of records) { + const condition: Condition = { + type: ConditionType.And, + items: [], + }; + + for (const [left, right] of relation.on) { + selectBuilder.extend(right); + + condition.items.push({ + type: ConditionType.Compare, + a: relation.table.columns[right], + operator: "=", + b: record[left], + }); + } + + root.items.push(condition); + } + + const compiledSelect = selectBuilder.compile(); + const subRecords = await findMany(relation.table, { + ...joinOptions, + select: compiledSelect.result, + where: joinOptions.where + ? { + type: ConditionType.And, + items: [root, joinOptions.where], + } + : root, + }); + + for (const record of records) { + const filtered = subRecords.filter((subRecord) => { + for (const [left, right] of relation.on) { + if (record[left] !== subRecord[right]) return false; + } + + compiledSelect.removeExtendedKeys(subRecord); + return true; + }); + + record[relation.name] = + relation.type === "one" ? (filtered[0] ?? null) : filtered; + } + } + + async function findMany( + table: AnyTable, + v: SimplifyFindOptions + ) { + let query = kysely.selectFrom(table.names.sql); + + const where = v.where; + if (where) { + query = query.where((eb) => buildWhere(where, eb, provider)); + } + + if (v.offset !== undefined) { + query = query.offset(v.offset); + } + + if (v.limit !== undefined) { + query = provider === "mssql" ? query.top(v.limit) : query.limit(v.limit); + } + + if (v.orderBy) { + for (const [col, mode] of v.orderBy) { + query = query.orderBy(fullSQLName(col), mode); + } + } + + const selectBuilder = extendSelect(v.select); + const mappedSelect: string[] = []; + const subqueryJoins: CompiledJoin[] = []; + + for (const join of v.join ?? []) { + const { options: joinOptions, relation } = join; + if (joinOptions === false) continue; + + if (relation.type === "many" || joinOptions.join) { + subqueryJoins.push(join); + for (const [left] of relation.on) { + selectBuilder.extend(left); + } + + continue; + } + + const targetTable = relation.table; + const joinName = relation.name; + // update select + mappedSelect.push( + ...mapSelect(joinOptions.select, targetTable, { + relation: relation.name, + tableName: joinName, + }) + ); + + query = query.leftJoin(`${targetTable.names.sql} as ${joinName}`, (b) => + b.on((eb) => { + const conditions = []; + for (const [left, right] of relation.on) { + conditions.push( + eb( + `${table.names.sql}.${table.columns[left].names.sql}`, + "=", + eb.ref(`${joinName}.${targetTable.columns[right].names.sql}`) + ) + ); + } + + if (joinOptions.where) { + conditions.push(buildWhere(joinOptions.where, eb, provider)); + } + + return eb.and(conditions); + }) + ); + } + + const compiledSelect = selectBuilder.compile(); + mappedSelect.push(...mapSelect(compiledSelect.result, table)); + + const records = (await query.select(mappedSelect).execute()).map((v) => + decodeResult(v, table) + ); + + await Promise.all( + subqueryJoins.map((join) => runSubQueryJoin(records, join)) + ); + for (const record of records) { + compiledSelect.removeExtendedKeys(record); + } + + return records; + } + + let adapter: ORMAdapter = { + tables: schema.tables, + async count(table, { where }) { + let query = await kysely + .selectFrom(table.names.sql) + .select(kysely.fn.countAll().as("count")); + if (where) query = query.where((b) => buildWhere(where, b, provider)); + + const result = await query.executeTakeFirstOrThrow(); + + const count = Number(result.count); + if (Number.isNaN(count)) + throw new Error(`Unexpected result for count, received: ${count}`); + + return count; + }, + async create(table, values) { + const rawTable = table; + const insertValues = encodeValues(values, rawTable, true); + const insert = kysely.insertInto(rawTable.names.sql).values(insertValues); + + if (provider === "mssql") { + return decodeResult( + await insert + .output( + mapSelect(true, rawTable, { tableName: "inserted" }) as any[] + ) + .executeTakeFirstOrThrow(), + rawTable + ); + } + + if (provider === "postgresql" || provider === "sqlite") { + return decodeResult( + await insert + .returning(mapSelect(true, rawTable)) + .executeTakeFirstOrThrow(), + rawTable + ); + } + + const idColumn = rawTable.getIdColumn(); + const idValue = insertValues[idColumn.names.sql]; + + if (idValue == null) + throw new Error( + "cannot find value of id column, which is required for `create()`." + ); + + await insert.execute(); + return decodeResult( + await kysely + .selectFrom(rawTable.names.sql) + .select(mapSelect(true, rawTable)) + .where(idColumn.names.sql, "=", idValue) + .limit(1) + .executeTakeFirstOrThrow(), + rawTable + ); + }, + async findFirst(table, v) { + const records = await this.findMany(table, { + ...v, + limit: 1, + }); + + if (records.length === 0) return null; + return records[0]!; + }, + + async findMany(table, v) { + return findMany(table, v); + }, + + async updateMany(table, v) { + let query = kysely + .updateTable(table.names.sql) + .set(encodeValues(v.set, table, false)); + if (v.where) { + query = query.where((eb) => buildWhere(v.where!, eb, provider)); + } + await query.execute(); + }, + async upsert(table, { where, update, create }) { + if (provider === "mssql") { + let query = kysely + .updateTable(table.names.sql) + .top(1) + .set(encodeValues(update, table, false)); + + if (where) query = query.where((b) => buildWhere(where, b, provider)); + const result = await query.executeTakeFirstOrThrow(); + + if (result.numUpdatedRows === 0n) + await this.createMany(table, [create]); + return; + } + + const idColumn = table.getIdColumn(); + let query = kysely + .selectFrom(table.names.sql) + .select([`${idColumn.names.sql} as id`]); + if (where) query = query.where((b) => buildWhere(where, b, provider)); + const result = await query.limit(1).executeTakeFirst(); + + if (result) { + await kysely + .updateTable(table.names.sql) + .set(encodeValues(update, table, false)) + .where(idColumn.names.sql, "=", result.id) + .execute(); + } else { + await this.createMany(table, [create]); + } + }, + + async createMany(table, values) { + const encodedValues = values.map((v) => encodeValues(v, table, true)); + await kysely.insertInto(table.names.sql).values(encodedValues).execute(); + + return encodedValues.map((value) => ({ + _id: value[table.getIdColumn().names.sql], + })); + }, + async deleteMany(table, { where }) { + let query = kysely.deleteFrom(table.names.sql); + if (where) { + query = query.where((eb) => buildWhere(where, eb, provider)); + } + await query.execute(); + }, + transaction(run) { + return kysely.transaction().execute((ctx) => { + const tx = fromKysely(schema, { + ...config, + db: ctx, + }); + + return run(tx); + }); + }, + }; + + if (relationMode === "fumadb") + adapter = createSoftForeignKey(schema, { + ...adapter, + generateInsertValuesDefault(table, values) { + const result: Record = {}; + + for (const k in table.columns) { + const col = table.columns[k]; + + if (values[k] === undefined) { + result[k] = col.generateDefaultValue(); + } else { + result[k] = values[k]; + } + } + + return result; + }, + }); + + return toORM(adapter); +} diff --git a/packages/core/fumadb/src/adapters/memory/index.ts b/packages/core/fumadb/src/adapters/memory/index.ts new file mode 100644 index 000000000..a596373f5 --- /dev/null +++ b/packages/core/fumadb/src/adapters/memory/index.ts @@ -0,0 +1,209 @@ +import type { FumaDBAdapter } from "../"; +import type { AbstractQuery } from "../../query"; +import { ConditionType, type Condition } from "../../query/condition-builder"; +import { toORM, type SimplifyFindOptions } from "../../query/orm"; +import type { AnyColumn, AnySchema, AnyTable } from "../../schema"; +import { Column } from "../../schema"; +import type { FindManyOptions } from "../../query"; + +export type MemoryDatabase = Record[]>; + +export interface MemoryAdapterOptions { + readonly db?: MemoryDatabase; +} + +const cloneValue = (value: T): T => { + if (value instanceof Date) return new Date(value.getTime()) as T; + if (value instanceof Uint8Array) return new Uint8Array(value) as T; + if (Array.isArray(value)) return value.map((item) => cloneValue(item)) as T; + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneValue(item)]) + ) as T; + } + return value; +}; + +const comparable = (value: unknown): unknown => { + if (value instanceof Date) return value.getTime(); + return value; +}; + +const columnValue = (row: Record, column: AnyColumn): unknown => + row[column.ormName]; + +const matchesCondition = ( + row: Record, + condition: Condition | undefined, +): boolean => { + if (!condition) return true; + + switch (condition.type) { + case ConditionType.And: + return condition.items.every((item) => matchesCondition(row, item)); + case ConditionType.Or: + return condition.items.some((item) => matchesCondition(row, item)); + case ConditionType.Not: + return !matchesCondition(row, condition.item); + case ConditionType.Compare: + break; + default: + return false; + } + + const left = comparable(columnValue(row, condition.a)); + const right = + condition.b instanceof Column + ? comparable(columnValue(row, condition.b)) + : comparable(condition.b); + + switch (condition.operator) { + case "=": + return left === right; + case "!=": + return left !== right; + case ">": + return left != null && right != null && left > right; + case ">=": + return left != null && right != null && left >= right; + case "<": + return left != null && right != null && left < right; + case "<=": + return left != null && right != null && left <= right; + case "is": + return right === null ? left == null : left === right; + case "is not": + return right === null ? left != null : left !== right; + case "in": + return Array.isArray(right) && right.includes(left); + case "not in": + return Array.isArray(right) && !right.includes(left); + case "contains": + return typeof left === "string" && typeof right === "string" && left.includes(right); + case "not contains": + return !(typeof left === "string" && typeof right === "string" && left.includes(right)); + case "starts with": + return typeof left === "string" && typeof right === "string" && left.startsWith(right); + case "not starts with": + return !(typeof left === "string" && typeof right === "string" && left.startsWith(right)); + case "ends with": + return typeof left === "string" && typeof right === "string" && left.endsWith(right); + case "not ends with": + return !(typeof left === "string" && typeof right === "string" && left.endsWith(right)); + } +}; + +const tableRows = (db: MemoryDatabase, table: AnyTable): Record[] => { + db[table.ormName] ??= []; + return db[table.ormName]!; +}; + +const applyDefaults = ( + table: AnyTable, + values: Record, +): Record => { + const row: Record = {}; + for (const column of Object.values(table.columns)) { + if (Object.hasOwn(values, column.ormName) && values[column.ormName] !== undefined) { + row[column.ormName] = cloneValue(values[column.ormName]); + continue; + } + const defaultValue = column.generateDefaultValue(); + if (defaultValue !== undefined) row[column.ormName] = cloneValue(defaultValue); + else if (column.isNullable) row[column.ormName] = null; + } + return row; +}; + +const selectRow = ( + table: AnyTable, + row: Record, + select: SimplifyFindOptions["select"], +): Record => { + if (select === true) return cloneValue(row); + return Object.fromEntries(select.map((key) => [key, cloneValue(row[key as string])])); +}; + +export function memoryAdapter(options: MemoryAdapterOptions = {}): FumaDBAdapter { + const db = options.db ?? {}; + + return { + name: "memory", + createORM(schema): AbstractQuery { + let orm: AbstractQuery; + orm = toORM({ + tables: schema.tables, + async count(table, v) { + return tableRows(db, table).filter((row) => matchesCondition(row, v.where)).length; + }, + async findFirst(table, v) { + return (await this.findMany(table, { ...v, limit: 1 }))[0] ?? null; + }, + async findMany(table, v) { + if (v.join?.length) throw new Error("[FumaDB Memory] Joins are not supported."); + let rows = tableRows(db, table).filter((row) => matchesCondition(row, v.where)); + + for (const [column, direction] of [...(v.orderBy ?? [])].reverse()) { + rows = [...rows].sort((a, b) => { + const left = comparable(columnValue(a, column)); + const right = comparable(columnValue(b, column)); + if (left == null && right == null) return 0; + if (left == null) return direction === "asc" ? -1 : 1; + if (right == null) return direction === "asc" ? 1 : -1; + if (left < right) return direction === "asc" ? -1 : 1; + if (left > right) return direction === "asc" ? 1 : -1; + return 0; + }); + } + + const offset = v.offset ?? 0; + const limited = rows.slice(offset, v.limit === undefined ? undefined : offset + v.limit); + return limited.map((row) => selectRow(table, row, v.select)); + }, + async updateMany(table, v) { + for (const row of tableRows(db, table)) { + if (!matchesCondition(row, v.where)) continue; + Object.assign(row, cloneValue(v.set)); + } + }, + async upsert(table, v) { + const existing = tableRows(db, table).find((row) => matchesCondition(row, v.where)); + if (existing) { + Object.assign(existing, cloneValue(v.update)); + return; + } + await this.create(table, v.create); + }, + async create(table, values) { + const row = applyDefaults(table, values); + tableRows(db, table).push(row); + return cloneValue(row); + }, + async createMany(table, values) { + const idColumn = table.getIdColumn(); + return Promise.all(values.map((value) => this.create(table, value))).then((rows) => + rows.map((row) => ({ _id: row[idColumn.ormName] })) + ); + }, + async deleteMany(table, v) { + const rows = tableRows(db, table); + db[table.ormName] = rows.filter((row) => !matchesCondition(row, v.where)); + }, + async transaction(run: (transactionInstance: AbstractQuery) => Promise) { + const snapshot = cloneValue(db); + try { + return await run(orm); + } catch (error) { + for (const key of Object.keys(db)) delete db[key]; + Object.assign(db, snapshot); + throw error; + } + }, + }); + return orm; + }, + async getSchemaVersion() { + return undefined; + }, + }; +} diff --git a/packages/core/fumadb/src/adapters/mongodb/index.ts b/packages/core/fumadb/src/adapters/mongodb/index.ts new file mode 100644 index 000000000..995ce71a0 --- /dev/null +++ b/packages/core/fumadb/src/adapters/mongodb/index.ts @@ -0,0 +1,113 @@ +import type { MongoClient } from "mongodb"; +import { createMigrator, type Migrator } from "../../migration-engine/create"; +import type { NameVariants } from "../../schema"; +import { exportNameVariants } from "../../schema/export"; +import type { LibraryConfig } from "../../shared/config"; +import type { FumaDBAdapter } from "../"; +import { execute } from "./migration/execute"; +import { fromMongoDB } from "./query"; + +export interface MongoDBConfig { + client: MongoClient; +} + +export function mongoAdapter(options: MongoDBConfig): FumaDBAdapter { + return { + name: "mongodb", + createORM(schema) { + return fromMongoDB(schema, options.client); + }, + createMigrationEngine() { + return createMongoDBMigrator(this, options.client); + }, + async getSchemaVersion() { + const manager = createSettingsManager(this, options.client); + return (await manager.get("version")) as string; + }, + }; +} + +function createMongoDBMigrator( + lib: LibraryConfig, + client: MongoClient +): Migrator { + const manager = createSettingsManager(lib, client); + + return createMigrator({ + ...lib, + libConfig: lib, + userConfig: { + provider: "mongodb", + }, + settings: { + async getVersion() { + const result = await manager.get("version"); + if (typeof result === "string") return result; + }, + async getNameVariants() { + const result = await manager.get("name-variants"); + if (result) return result as Record; + }, + updateSettingsInMigration(schema) { + return [ + { + type: "custom", + key: "version", + value: schema.version, + }, + { + type: "custom", + key: "name-variants", + value: exportNameVariants(schema), + }, + ]; + }, + }, + async executor(operations) { + const session = client.startSession(); + + try { + for (const op of operations) { + await execute(op, { client, session }, (node) => + manager.set(node.key as string, node.value) + ).catch((e) => { + console.error("failed at", op, e); + throw e; + }); + } + } finally { + await session.endSession(); + } + }, + }); +} + +function createSettingsManager(lib: LibraryConfig, client: MongoClient) { + const db = client.db(); + const collection = db.collection<{ + key: string; + value: unknown; + }>(`private_${lib.namespace}_settings`); + + return { + async get(key: string) { + const result = await collection.findOne({ + key, + }); + + return result?.value; + }, + async set(key: string, value: unknown) { + const result = await collection.updateOne( + { + key, + }, + { $set: { value } } + ); + + if (result.matchedCount === 0) { + await collection.insertOne({ key, value }); + } + }, + }; +} diff --git a/packages/core/fumadb/src/adapters/mongodb/migration/execute.ts b/packages/core/fumadb/src/adapters/mongodb/migration/execute.ts new file mode 100644 index 000000000..70a3dbaf8 --- /dev/null +++ b/packages/core/fumadb/src/adapters/mongodb/migration/execute.ts @@ -0,0 +1,319 @@ +import { + Binary, + type ClientSession, + type Collection, + type Document, + type MongoClient, + ObjectId, +} from "mongodb"; +import type { + ColumnOperation, + CustomOperation, + MigrationOperation, +} from "../../../migration-engine/shared"; +import { + type AnyColumn, + type AnyTable, + IdColumn, + type TypeMap, +} from "../../../schema/create"; +import { + bigintToUint8Array, + booleanToUint8Array, + numberToUint8Array, + stringToUint8Array, + uint8ArrayToBigInt, + uint8ArrayToBoolean, + uint8ArrayToNumber, + uint8ArrayToString, +} from "../../../utils/binary"; + +interface MongoDBConfig { + client: MongoClient; + session?: ClientSession; +} + +const errors = { + IdColumnUpdate: + "ID columns must not be updated, not every database supports updating primary keys and often requires workarounds.", +}; + +async function createUniqueIndex( + collection: Collection, + name: string, + columns: string[] +) { + const idx: Record = {}; + for (const col of columns) { + idx[col] = 1; + } + + await collection.createIndex(idx, { + name, + unique: true, + sparse: true, + }); +} + +async function executeColumn( + collection: Collection, + operation: ColumnOperation, + config: MongoDBConfig +) { + const { session } = config; + + switch (operation.type) { + case "rename-column": + await collection.updateMany( + {}, + { $rename: { [operation.from]: operation.to } }, + { session } + ); + return; + + case "drop-column": { + if (operation.name === "_id") + throw new Error("You cannot drop `_id` column"); + const indexes = await collection.indexes(); + + // drop unique index on it + for (const index of indexes) { + if (!index.name || !index.unique || index.key[operation.name] !== 1) + continue; + + await collection.dropIndex(index.name); + break; + } + + await collection.updateMany( + {}, + { $unset: { [operation.name]: "" } }, + { session } + ); + return; + } + case "create-column": { + const col = operation.value; + const defaultValue = col.generateDefaultValue() ?? null; + + if (defaultValue) { + await collection.updateMany( + { [col.names.mongodb]: { $exists: false } }, + { $set: { [col.names.mongodb]: defaultValue } }, + { session } + ); + } + return; + } + + // do not handle nullable & default update as they're handled at application level + case "update-column": { + const col = operation.value; + + if (col instanceof IdColumn) { + throw new Error(errors.IdColumnUpdate); + } + + if (operation.updateDataType) { + const field = operation.name; + const bulk = collection.initializeUnorderedBulkOp(); + + for await (const doc of collection.find()) { + bulk.find({ _id: doc._id }).updateOne({ + $set: { [field]: migrateDataType(doc[field], col.type) }, + }); + } + + if (bulk.batches.length > 0) await bulk.execute(); + } + } + } +} + +export async function execute( + operation: MigrationOperation, + config: MongoDBConfig, + handleCustomNode: (op: CustomOperation) => Promise +): Promise { + const { client, session } = config; + const db = client.db(); + + async function createCollection(table: AnyTable) { + const collection = await db.createCollection(table.names.mongodb); + + // init unique index, columns are created on insert + for (const col of Object.values(table.columns)) { + if (!col.isUnique) continue; + + await createUniqueIndex(collection, col.getUniqueConstraintName(), [ + col.names.sql, + ]); + } + } + + switch (operation.type) { + case "create-table": + await createCollection(operation.value); + return true; + + case "rename-table": + await db.collection(operation.from).rename(operation.to, { session }); + return true; + + case "update-table": { + const collection = db.collection(operation.name); + + for (const op of operation.value) { + await executeColumn(collection, op, config); + } + + return true; + } + case "add-unique-constraint": { + const collection = db.collection(operation.table); + + await createUniqueIndex(collection, operation.name, operation.columns); + return true; + } + case "drop-table": + await db.collection(operation.name).drop({ session }); + return true; + + case "custom": + await handleCustomNode(operation); + return true; + + case "drop-unique-constraint": { + const collection = db.collection(operation.table); + + await collection.dropIndex(operation.name); + return true; + } + case "add-foreign-key": + case "drop-foreign-key": + // MongoDB doesn't have foreign key constraints + // This would be handled at the application level + return false; + } +} + +function migrateDataType(originalValue: unknown, toType: keyof TypeMap) { + // ignore string constraint + if (toType.startsWith("varchar(")) toType = "string"; + if (toType === "uuid") toType = "string"; + + // just for safe, generally you can't migrate the data type of id column + if (originalValue instanceof ObjectId) + originalValue = originalValue.toHexString(); + + if (originalValue == null) return originalValue; + + if (toType === "bigint") { + if (originalValue instanceof Binary) { + return uint8ArrayToBigInt(originalValue.buffer); + } + + if (originalValue instanceof Date) return BigInt(originalValue.getTime()); + + switch (typeof originalValue) { + case "bigint": + return originalValue; + case "boolean": + return originalValue ? 1n : 0n; + case "number": + case "string": + return BigInt(originalValue); + default: + throw new Error(`Failed to convert ${originalValue} to ${toType}.`); + } + } + + if (toType === "bool") { + if (originalValue instanceof Binary) { + return uint8ArrayToBoolean(originalValue.buffer); + } + + switch (typeof originalValue) { + case "boolean": + return originalValue; + case "bigint": + return originalValue !== 0n; + case "number": + return originalValue !== 0; + case "string": + return originalValue.toLowerCase() === "true"; + default: + throw new Error(`Failed to convert ${originalValue} to ${toType}.`); + } + } + + if (toType === "binary") { + if (originalValue instanceof Binary) return originalValue; + if (originalValue instanceof Date) originalValue = originalValue.getTime(); + + switch (typeof originalValue) { + case "bigint": + return new Binary(bigintToUint8Array(originalValue)); + case "string": + return new Binary(stringToUint8Array(originalValue)); + case "number": + return new Binary(numberToUint8Array(originalValue)); + case "boolean": + return new Binary(booleanToUint8Array(originalValue)); + default: + throw new Error(`Failed to convert ${originalValue} to ${toType}.`); + } + } + + if (toType === "date" || toType === "timestamp") { + if (originalValue instanceof Binary) + return new Date(uint8ArrayToNumber(originalValue.buffer)); + if (originalValue instanceof Date) return originalValue; + + switch (typeof originalValue) { + case "bigint": + // ignore precision loss, we assume bigint when used as time, won't exceed the safe integer range. + return new Date(Number(originalValue)); + case "string": + case "number": + return new Date(originalValue); + default: + throw new Error(`Failed to convert ${originalValue} to ${toType}.`); + } + } + + if (toType === "decimal" || toType === "integer") { + if (originalValue instanceof Binary) + return uint8ArrayToNumber(originalValue.buffer); + if (originalValue instanceof Date) return originalValue.getTime(); + + switch (typeof originalValue) { + case "bigint": + case "string": + case "number": + return Number(originalValue); + case "boolean": + return originalValue ? 1 : 0; + default: + throw new Error(`Failed to convert ${originalValue} to ${toType}.`); + } + } + + // MongoDB can just store JSON-compatible values, not conversion needed + if (toType === "json") return originalValue; + + if (toType === "string") { + if (originalValue instanceof Binary) + return uint8ArrayToString(originalValue.buffer); + + switch (typeof originalValue) { + case "bigint": + case "boolean": + case "number": + case "string": + return String(originalValue); + default: + return JSON.stringify(originalValue); + } + } +} diff --git a/packages/core/fumadb/src/adapters/mongodb/query.ts b/packages/core/fumadb/src/adapters/mongodb/query.ts new file mode 100644 index 000000000..5e029d75a --- /dev/null +++ b/packages/core/fumadb/src/adapters/mongodb/query.ts @@ -0,0 +1,467 @@ +import { + Binary, + type ClientSession, + type Document, + type Filter, + type MongoClient, + ObjectId, +} from "mongodb"; +import type { + AbstractQuery, + AnySelectClause, + FindManyOptions, +} from "../../query"; +import { + type Condition, + ConditionType, + type Operator, +} from "../../query/condition-builder"; +import { type SimplifyFindOptions, toORM } from "../../query/orm"; +import { createSoftForeignKey } from "../../query/polyfills/foreign-key"; +import { + type AnyColumn, + type AnySchema, + type AnyTable, + Column, +} from "../../schema"; + +function buildWhere(condition: Condition): Filter { + function doc(name: string, op: Operator, value: unknown): Filter { + switch (op) { + case "=": + case "is": + if (value == null) return { [name]: { $exists: false } }; + + return { [name]: value }; + case "!=": + case "is not": + if (value == null) return { [name]: { $exists: true } }; + + return { [name]: { $ne: value } }; + case ">": + return { [name]: { $gt: value } }; + case ">=": + return { [name]: { $gte: value } }; + case "<": + return { [name]: { $lt: value } }; + case "<=": + return { [name]: { $lte: value } }; + case "in": + return { [name]: { $in: value } }; + case "not in": + return { [name]: { $nin: value } }; + case "starts with": + return { [name]: { $regex: `^${value}`, $options: "i" } }; + case "not starts with": + return { [name]: { $not: { $regex: `^${value}`, $options: "i" } } }; + case "contains": + return { [name]: { $regex: value, $options: "i" } }; + case "not contains": + return { [name]: { $not: { $regex: value, $options: "i" } } }; + case "ends with": + return { [name]: { $regex: `${value}$`, $options: "i" } }; + case "not ends with": + return { [name]: { $not: { $regex: `${value}$`, $options: "i" } } }; + default: + throw new Error(`Unsupported operator: ${op}`); + } + } + + function expr(exp1: string, op: Operator, exp2: string): Filter { + switch (op) { + case "=": + case "is": + return { $eq: [exp1, exp2] }; + case "!=": + case "is not": + return { $ne: [exp1, exp2] }; + case ">": + return { $gt: [exp1, exp2] }; + case ">=": + return { $gte: [exp1, exp2] }; + case "<": + return { $lt: [exp1, exp2] }; + case "<=": + return { $lte: [exp1, exp2] }; + case "in": + return { $in: [exp1, exp2] }; + case "not in": + return { $nin: [exp1, exp2] }; + case "starts with": + return { + $regexMatch: { + input: exp1, + regex: `^${exp2}`, + options: "i", + }, + }; + case "not starts with": + return { + $not: [expr(exp1, "starts with", exp2)], + }; + case "contains": + return { + $regexMatch: { + input: exp1, + regex: exp2, + options: "i", + }, + }; + case "not contains": + return { + $not: [expr(exp1, "contains", exp2)], + }; + case "ends with": + return { + $regexMatch: { + input: exp1, + regex: `${exp2}$`, + options: "i", + }, + }; + case "not ends with": + return { + $not: [expr(exp1, "ends with", exp2)], + }; + default: + throw new Error(`Unsupported operator: ${op}`); + } + } + + if (condition.type === ConditionType.Compare) { + const column = condition.a; + const value = condition.b; + + const name = column.names.mongodb; + if (value instanceof Column) { + return { + $match: expr( + `$${name}`, + condition.operator, + column.table === value.table + ? `$${value.names.mongodb}` + : `$$${value.table?.ormName}_${value.ormName}` + ), + }; + } + + return doc(name, condition.operator, serialize(value)); + } + + if (condition.type === ConditionType.And) { + return { + $and: condition.items.map(buildWhere), + }; + } + + if (condition.type === ConditionType.Not) { + return { + $not: buildWhere(condition), + }; + } + + return { + $or: condition.items.map(buildWhere), + }; +} + +function mapProjection(select: AnySelectClause, table: AnyTable): Document { + const out: Document = { + _id: 0, + }; + + function item(col: AnyColumn) { + out[col.ormName] = { $ifNull: [`$${col.names.mongodb}`, null] }; + } + + if (select === true) { + for (const col of Object.values(table.columns)) item(col); + } else { + for (const k of select) { + const col = table.columns[k]; + if (!col) continue; + + item(col); + } + } + + return out; +} + +function mapSort(orderBy: [column: AnyColumn, "asc" | "desc"][]) { + const out: Record = {}; + + for (const [col, mode] of orderBy) { + out[col.names.mongodb] = mode === "asc" ? 1 : -1; + } + + return out; +} + +function serialize(value: unknown) { + if (value instanceof Uint8Array) { + value = new Binary(value); + } + + return value; +} + +function mapInsertValues(values: Record, table: AnyTable) { + const out: Record = {}; + + for (const k in table.columns) { + const col = table.columns[k]; + const value = serialize(values[k]); + + if (value != null) out[col.names.mongodb] = value; + } + + return out; +} + +function mapResult( + result: Record, + table: AnyTable +): Record { + const out: Record = {}; + + for (const k in result) { + let value = result[k]; + + if (k in table.relations) { + const relation = table.relations[k]; + + if (Array.isArray(value)) { + value = value.map((v) => mapResult(v, relation.table)); + } else if (value) { + value = mapResult(value as any, relation.table); + } + + out[k] = value; + continue; + } + + if (value instanceof ObjectId) { + value = value.toString("hex"); + } else if (value instanceof Binary) { + const buffer = value.buffer; + value = + buffer instanceof Buffer + ? new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) + : buffer; + } + + out[k] = value; + } + + return out; +} + +/** + * This adapter uses string ids instead of object id, which is better suited for the API design of FumaDB. + */ +export function fromMongoDB( + schema: AnySchema, + client: MongoClient, + session?: ClientSession +): AbstractQuery { + const db = client.db(); + + function buildFindPipeline( + table: AnyTable, + v: SimplifyFindOptions + ) { + const pipeline: Document[] = []; + const where = v.where ? buildWhere(v.where) : undefined; + + if (where) pipeline.push({ $match: where }); + if (v.limit !== undefined) + pipeline.push({ + $limit: v.limit, + }); + if (v.offset !== undefined) + pipeline.push({ + $skip: v.offset, + }); + if (v.orderBy) { + pipeline.push({ $sort: mapSort(v.orderBy) }); + } + const project = mapProjection(v.select, table); + + if (v.join) { + for (const { relation, options: joinOptions } of v.join) { + project[relation.name] = 1; + + if (joinOptions === false) continue; + const vars: Record = {}; + + for (const column of Object.values(table.columns)) { + vars[`${table.ormName}_${column.ormName}`] = + `$${column.names.mongodb}`; + } + + const targetTable = relation.table; + pipeline.push({ + $lookup: { + from: targetTable.names.mongodb, + let: vars, + pipeline: [ + ...relation.on.map(([left, right]) => { + return { + $match: { + $expr: { + $eq: [ + `$${targetTable.columns[right].names.mongodb}`, + `$$${table.ormName}_${left}`, + ], + }, + }, + }; + }), + ...buildFindPipeline(targetTable, { + ...joinOptions, + limit: relation.type === "many" ? joinOptions.limit : 1, + }), + ], + as: relation.name, + }, + }); + + if (relation.type === "one") { + pipeline.push({ + $set: { + [relation.name]: { + $ifNull: [{ $first: `$${relation.name}` }, null], + }, + }, + }); + } + } + } + + pipeline.push({ + $project: project, + }); + + return pipeline; + } + + const orm = createSoftForeignKey(schema, { + generateInsertValuesDefault(table, values) { + const out: Record = {}; + + for (const k in table.columns) { + if (values[k] === undefined) { + out[k] = table.columns[k].generateDefaultValue(); + } else { + out[k] = values[k]; + } + } + + return out; + }, + tables: schema.tables, + async count(table, { where }) { + return await db + .collection(table.names.mongodb) + .countDocuments(where ? buildWhere(where) : undefined, { session }); + }, + async findFirst(table, v) { + const result = await orm.findMany(table, { + ...v, + limit: 1, + }); + + return result[0] ?? null; + }, + async findMany(table, v) { + const query = db + .collection(table.names.mongodb) + .aggregate(buildFindPipeline(table, v), { session }); + + const result = await query.toArray(); + return result.map((v) => mapResult(v, table)); + }, + async updateMany(table, v) { + const where = v.where ? buildWhere(v.where) : {}; + const set: Record = {}; + const unset: Record = {}; + + for (const k in v.set) { + const col = table.columns[k]; + const value = v.set[k]; + if (!col || value === undefined) continue; + + const name = col.names.mongodb; + + if (value === null) { + unset[name] = ""; + } else { + set[name] = serialize(value); + } + } + + await db.collection(table.names.mongodb).updateMany( + where, + { + $set: set, + $unset: unset, + }, + { + session, + } + ); + }, + async create(table, values) { + const collection = db.collection(table.names.mongodb); + const { insertedId } = await collection.insertOne( + mapInsertValues(values, table), + { session } + ); + + const result = await collection.findOne( + { + _id: insertedId, + }, + { + session, + projection: mapProjection(true, table), + } + ); + + if (result === null) + throw new Error( + "Failed to insert document: cannot find inserted coument." + ); + return mapResult(result, table); + }, + async createMany(table, values) { + const idField = table.getIdColumn().names.mongodb; + values = values.map((v) => mapInsertValues(v, table)); + + await db.collection(table.names.mongodb).insertMany(values, { session }); + return values.map((value) => ({ _id: value[idField] })); + }, + async deleteMany(table, v) { + const where = v.where ? buildWhere(v.where) : undefined; + + await db.collection(table.names.mongodb).deleteMany(where, { session }); + }, + async transaction(run) { + const child = client.startSession(); + + try { + return await child.withTransaction( + () => run(fromMongoDB(schema, client, child)), + { + session, + } + ); + } finally { + await child.endSession(); + } + }, + }); + + return toORM(orm); +} diff --git a/packages/core/fumadb/src/adapters/prisma/generate.ts b/packages/core/fumadb/src/adapters/prisma/generate.ts new file mode 100644 index 000000000..619d27a79 --- /dev/null +++ b/packages/core/fumadb/src/adapters/prisma/generate.ts @@ -0,0 +1,172 @@ +import { + type AnySchema, + type AnyTable, + type ForeignKeyAction, + IdColumn, +} from "../../schema/create"; +import type { Provider } from "../../shared/providers"; +import { parseVarchar } from "../../utils/parse"; + +const foreignKeyActionMap: Record = { + "SET NULL": "SetNull", + CASCADE: "Cascade", + RESTRICT: "Restrict", +}; + +export function generateSchema(schema: AnySchema, provider: Provider): string { + function generateTable(table: AnyTable) { + const code: string[] = [`model ${table.names.prisma} {`]; + + for (const column of Object.values(table.columns)) { + let type: string; + const attributes: string[] = []; + + function map(name: string) { + if (column.names.prisma === name) return; + attributes.push(`@map("${name}")`); + } + + map(provider === "mongodb" ? column.names.mongodb : column.names.sql); + + switch (column.type) { + case "uuid": + type = "String"; + switch (provider) { + case "postgresql": + case "cockroachdb": + attributes.push("@db.Uuid"); + break; + case "mssql": + attributes.push("@db.UniqueIdentifier"); + break; + // MySQL, SQLite, MongoDB use String without db attribute + } + break; + case "integer": + type = "Int"; + break; + case "bigint": + type = "BigInt"; + break; + case "bool": + type = "Boolean"; + break; + case "json": + type = "Json"; + break; + case "timestamp": + case "date": + type = "DateTime"; + break; + case "decimal": + type = "Decimal"; + break; + case "binary": + type = "Bytes"; + break; + default: + type = "String"; + + if (column.type.startsWith("varchar")) { + const parsed = parseVarchar(column.type); + + switch (provider) { + case "cockroachdb": + attributes.push(`@db.String(${parsed})`); + break; + case "mysql": + case "postgresql": + case "mssql": + attributes.push(`@db.VarChar(${parsed})`); + break; + } + } + } + + if (column instanceof IdColumn) { + attributes.push("@id"); + } + + if (column.isUnique) { + attributes.push("@unique"); + } + + if (column.default) { + if ("value" in column.default) { + attributes.push(`@default(${JSON.stringify(column.default.value)})`); + } else if (column.default.runtime === "auto") { + attributes.push("@default(cuid())"); + } else if (column.default.runtime === "now") { + attributes.push("@default(now())"); + } + } + + // Add nullable modifier if needed + if (column.isNullable) { + type += "?"; + } + + code.push(` ${[column.names.prisma, type, ...attributes].join(" ")}`); + } + + for (const relation of Object.values(table.relations)) { + let type = relation.table.names.prisma; + + if (relation.implied) { + if (relation.type === "many") type += "[]"; + else type += "?"; + + code.push(` ${relation.name} ${type} @relation("${relation.id}")`); + continue; + } + + const fields: string[] = []; + const references: string[] = []; + let isOptional = false; + + for (const [left, right] of relation.on) { + const col = table.columns[left]; + const refCol = relation.table.columns[right]; + + if (col.isNullable) isOptional = true; + fields.push(col.names.prisma); + references.push(refCol.names.prisma); + } + + if (isOptional) type += "?"; + const config = relation.foreignKey!; + code.push( + ` ${relation.name} ${type} @relation(${[ + `"${relation.id}"`, + `fields: [${fields.join(", ")}]`, + `references: [${references.join(", ")}]`, + `onUpdate: ${foreignKeyActionMap[config.onUpdate]}`, + `onDelete: ${foreignKeyActionMap[config.onDelete]}`, + ].join(", ")})` + ); + } + + for (const con of table.getUniqueConstraints("table")) { + code.push( + `@@unique([${con.columns.map((col) => col.names.prisma).join(", ")}])` + ); + } + + function mapTable(name: string) { + if (table.names.prisma === name) return; + code.push(`@@map("${name}")`); + } + + mapTable(provider === "mongodb" ? table.names.mongodb : table.names.sql); + + code.push("}"); + return code.join("\n"); + } + + const lines: string[] = []; + for (const t of Object.values(schema.tables)) { + lines.push(generateTable(t)); + } + + return lines.join("\n\n"); +} diff --git a/packages/core/fumadb/src/adapters/prisma/index.ts b/packages/core/fumadb/src/adapters/prisma/index.ts new file mode 100644 index 000000000..b57bc3b98 --- /dev/null +++ b/packages/core/fumadb/src/adapters/prisma/index.ts @@ -0,0 +1,90 @@ +import type { MongoClient } from "mongodb"; +import { column, idColumn, table } from "../../schema"; +import type { PrismaClient } from "../../shared/prisma"; +import type { Provider } from "../../shared/providers"; +import type { FumaDBAdapter } from ".."; +import { generateSchema } from "./generate"; +import { fromPrisma } from "./query"; + +export interface PrismaConfig { + provider: Provider; + prisma: PrismaClient; + + /** + * The relation mode you're using, see https://prisma.io/docs/orm/prisma-schema/data-model/relations/relation-mode. + * + * Default to foreign keys on SQL databases, and `prisma` on MongoDB. + */ + relationMode?: "prisma" | "foreign-keys"; + + /** + * Underlying database instance, highly recommended to provide so FumaDB can optimize some operations & indexes. + * + * supported: MongoDB + */ + db?: MongoClient; +} + +export function prismaAdapter( + options: Omit & { + prisma: unknown; + } +): FumaDBAdapter { + const config = options as PrismaConfig; + const settingsModel = (namespace: string) => `private_${namespace}_settings`; + + return { + name: "prisma", + createORM(schema) { + return fromPrisma(schema, config); + }, + async getSchemaVersion() { + const prisma = config.prisma; + const settings = settingsModel(this.namespace); + if (!(settings in prisma)) return; + + // Try to find existing record first + let result = await prisma[settings].findFirst({ + where: { key: "version" }, + }); + + if (!result) { + // If not found, try to create it (handles race conditions gracefully) + try { + result = await prisma[settings].create({ + data: { key: "version" }, + }); + } catch { + // If create fails (unique constraint), another concurrent call created it + result = await prisma[settings].findFirst({ + where: { key: "version" }, + }); + } + } + + return result?.value as string | undefined; + }, + generateSchema(schema, name) { + const settings = settingsModel(this.namespace); + const internalTable = table(settings, { + key: idColumn("key", "varchar(255)"), + value: column("value", "string").defaultTo(schema.version), + }); + internalTable.ormName = settings; + + return { + code: generateSchema( + { + ...schema, + tables: { + ...schema.tables, + [settings]: internalTable, + }, + }, + config.provider + ), + path: `./prisma/schema/${name}.prisma`, + }; + }, + }; +} diff --git a/packages/core/fumadb/src/adapters/prisma/query.ts b/packages/core/fumadb/src/adapters/prisma/query.ts new file mode 100644 index 000000000..fccc469e4 --- /dev/null +++ b/packages/core/fumadb/src/adapters/prisma/query.ts @@ -0,0 +1,348 @@ +import type { + AbstractQuery, + AnySelectClause, + FindManyOptions, +} from "../../query"; +import { type Condition, ConditionType } from "../../query/condition-builder"; +import { type SimplifyFindOptions, toORM } from "../../query/orm"; +import { checkForeignKeyOnInsert } from "../../query/polyfills/foreign-key"; +import { + type AnyColumn, + type AnySchema, + type AnyTable, + Column, +} from "../../schema"; +import type * as Prisma from "../../shared/prisma"; +import type { PrismaConfig } from "."; + +// TODO: implement comparing values with another table's columns +function buildWhere(condition: Condition): object { + if (condition.type === ConditionType.Compare) { + const column = condition.a; + const value = condition.b; + const name = column.names.prisma; + + if (value instanceof Column) { + throw new Error( + "Prisma adapter does not support comparing against another column at the moment.", + ); + } + + switch (condition.operator) { + case "=": + case "is": + return { [name]: value }; + case "!=": + case "is not": + return { [name]: { not: value } }; + case ">": + return { [name]: { gt: value } }; + case ">=": + return { [name]: { gte: value } }; + case "<": + return { [name]: { lt: value } }; + case "<=": + return { [name]: { lte: value } }; + case "in": + return { [name]: { in: value } }; + case "not in": + return { [name]: { notIn: value } }; + case "starts with": + return { [name]: { startsWith: value } }; + case "not starts with": + return { NOT: { [name]: { startsWith: value } } }; + case "contains": + return { [name]: { contains: value } }; + case "not contains": + return { NOT: { [name]: { contains: value } } }; + case "ends with": + return { [name]: { endsWith: value } }; + case "not ends with": + return { NOT: { [name]: { endsWith: value } } }; + default: + throw new Error(`Unsupported operator: ${condition.operator}`); + } + } + + if (condition.type === ConditionType.And) { + return { + AND: condition.items.map(buildWhere), + }; + } + + if (condition.type === ConditionType.Not) { + return { + NOT: condition, + }; + } + + return { + OR: condition.items.map(buildWhere), + }; +} + +function mapSelect(select: AnySelectClause, table: AnyTable) { + const out: Record = {}; + + if (select === true) { + for (const col of Object.values(table.columns)) { + out[col.names.prisma] = true; + } + } else { + for (const col of select) { + out[table.columns[col].names.prisma] = true; + } + } + + return out; +} + +function mapOrderBy(orderBy: [column: AnyColumn, mode: "asc" | "desc"][]) { + const out: Prisma.OrderBy = {}; + + for (const [col, mode] of orderBy) { + out[col.names.prisma] = mode; + } + + return out; +} + +function mapResult(result: Record, table: AnyTable) { + const out: Record = {}; + + for (const k in result) { + const value = result[k]; + + if (k in table.relations) { + const relation = table.relations[k]; + if (relation.type === "many") { + out[k] = (value as Record[]).map((v) => + mapResult(v, relation.table), + ); + } else { + out[k] = value ? mapResult(value as any, relation.table) : null; + } + + continue; + } + + const col = table.getColumnByName(k, "prisma"); + if (col) out[col.ormName] = value; + } + + return out; +} + +export function fromPrisma( + schema: AnySchema, + config: PrismaConfig & { + isTransaction?: boolean; + }, +): AbstractQuery { + const { + provider, + prisma, + relationMode = provider === "mongodb" ? "prisma" : "foreign-keys", + db: internalClient, + isTransaction = false, + } = config; + + // replace index with partial index to ignore null values + // see https://github.com/prisma/prisma/issues/3387 + async function initMongoDB() { + if (!internalClient || isTransaction) return; + const db = internalClient.db(); + + async function initCollection(table: AnyTable) { + const collection = db.collection(table.names.mongodb); + const indexes = await collection.indexes(); + + for (const index of indexes) { + if (!index.unique || !index.name || index.sparse) continue; + + await collection.dropIndex(index.name); + await collection.createIndex(index.key, { + name: index.name, + unique: true, + sparse: true, + }); + } + } + + await Promise.all(Object.values(schema.tables).map(initCollection)); + } + + let mapped: Map | undefined; + + function getPrismaModel(table: AnyTable) { + if (!mapped) { + mapped = new Map(); + for (const key in prisma) { + mapped.set(key.toLowerCase(), key); + } + } + + const modelName = mapped.get(table.names.prisma.toLowerCase()); + + if (!modelName) { + throw new Error( + `Prisma client is missing model delegate "${table.names.prisma}" for table "${table.ormName}".`, + ); + } + + return prisma[modelName]!; + } + + const init = initMongoDB(); + + function createFindOptions( + table: AnyTable, + v: SimplifyFindOptions, + ) { + const where = v.where ? buildWhere(v.where) : undefined; + const select: Record = mapSelect(v.select, table); + + if (v.join) { + for (const { relation, options: joinOptions } of v.join) { + if (joinOptions === false) continue; + + select[relation.name] = createFindOptions(relation.table, joinOptions); + } + } + + return { + where, + select, + skip: v.offset, + take: v.limit, + orderBy: v.orderBy ? mapOrderBy(v.orderBy) : undefined, + }; + } + + function mapValues( + table: AnyTable, + values: Record, + generateDefault = false, + ) { + const out: Record = {}; + + for (const col of Object.values(table.columns)) { + let value = values[col.ormName]; + if (value === undefined && generateDefault) + value = col.generateDefaultValue(); + + out[col.names.prisma] = value; + } + + return out; + } + + return toORM({ + tables: schema.tables, + async count(table, v) { + await init; + const model = getPrismaModel(table); + + return ( + await model.count({ + select: { + _all: true, + }, + where: v.where ? buildWhere(v.where) : undefined, + }) + )._all; + }, + async findFirst(table, v) { + await init; + const model = getPrismaModel(table); + const options = createFindOptions(table, v); + delete options.take; + + const result = await model.findFirst({ + ...options, + where: options.where!, + }); + if (result) return mapResult(result, table); + + return null; + }, + async findMany(table, v) { + await init; + const model = getPrismaModel(table); + + return (await model.findMany(createFindOptions(table, v))).map((v) => + mapResult(v, table), + ); + }, + async updateMany(table, v) { + await init; + const model = getPrismaModel(table); + const where = v.where ? buildWhere(v.where) : undefined; + + await model.updateMany({ where, data: v.set }); + }, + async create(table, values) { + await init; + if (relationMode === "prisma") { + await Promise.all( + table.foreignKeys.map((key) => + checkForeignKeyOnInsert(this, key, [values]), + ), + ); + } + + values = mapValues(table, values, true); + const model = getPrismaModel(table); + return mapResult( + await model.create({ + data: values, + }), + table, + ); + }, + async createMany(table, values) { + await init; + const idField = table.getIdColumn().names.prisma; + if (relationMode === "prisma") { + await Promise.all( + table.foreignKeys.map((key) => + checkForeignKeyOnInsert(this, key, values), + ), + ); + } + + values = values.map((value) => mapValues(table, value, true)); + await getPrismaModel(table).createMany({ data: values }); + return values.map((value) => ({ _id: value[idField] })); + }, + async deleteMany(table, v) { + await init; + const model = getPrismaModel(table); + const where = v.where ? buildWhere(v.where) : undefined; + + await model.deleteMany({ where }); + }, + async upsert(table, { where, ...v }) { + await init; + + await getPrismaModel(table).upsert({ + where: where ? buildWhere(where) : {}, + create: mapValues(table, v.create, true), + update: mapValues(table, v.update), + }); + }, + async transaction(run) { + await init; + + return prisma.$transaction((tx) => + run( + fromPrisma(schema, { + ...config, + isTransaction: true, + prisma: tx, + }), + ), + ); + }, + }); +} diff --git a/packages/core/fumadb/src/adapters/typeorm/generate.ts b/packages/core/fumadb/src/adapters/typeorm/generate.ts new file mode 100644 index 000000000..5c42515b9 --- /dev/null +++ b/packages/core/fumadb/src/adapters/typeorm/generate.ts @@ -0,0 +1,179 @@ +import { type AnySchema, type AnyTable, IdColumn } from "../../schema/create"; +import { schemaToDBType } from "../../schema/serialize"; +import type { SQLProvider } from "../../shared/providers"; +import { importGenerator } from "../../utils/import-generator"; +import { ident, parseVarchar } from "../../utils/parse"; + +function toPascalCase(str: string): string { + return str + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); +} + +export function generateSchema( + schema: AnySchema, + provider: SQLProvider +): string { + const code: string[] = []; + const imports = importGenerator(); + imports.addImport("Entity", "typeorm"); + + function generateTable(table: AnyTable) { + const lines: string[] = []; + const className = toPascalCase(table.names.sql); + + // Add entity decorator + lines.push(`@Entity("${table.names.sql}")`); + lines.push(`export class ${className} {`); + + // Generate columns + for (const [key, column] of Object.entries(table.columns)) { + const options: string[] = []; + let type: string; + + // Handle column type + switch (column.type) { + case "uuid": + type = "string"; + options.push(`type: "uuid"`); + break; + case "integer": + type = "number"; + break; + case "bigint": + type = "bigint"; + break; + case "bool": + type = "boolean"; + break; + case "json": + type = "object"; + break; + case "timestamp": + case "date": + type = "Date"; + break; + case "decimal": + type = "number"; + break; + case "binary": + type = "Uint8Array"; + options.push( + `type: "${schemaToDBType({ type: "binary" }, provider)}"` + ); + break; + default: + type = "string"; + if (column.type.startsWith("varchar")) { + const length = parseVarchar(column.type); + + if (length) { + options.push(`length: ${length}`); + } + } + } + + let decorator = "Column"; + // Add column decorator + if (column instanceof IdColumn) { + decorator = + column.default && + "runtime" in column.default && + column.default.runtime === "auto" + ? "PrimaryGeneratedColumn" + : "PrimaryColumn"; + } + + if (key !== column.names.sql) { + options.push(`name: "${column.names.sql}"`); + } + + if (column.isNullable) { + type += " | null"; + options.push(`nullable: true`); + } + + if (column.isUnique) { + options.push(`unique: true`); + } + + if (column.default) { + if ("value" in column.default) { + options.push(`default: ${JSON.stringify(column.default.value)}`); + } else if (column.default.runtime === "now") { + options.push("default: () => 'CURRENT_TIMESTAMP'"); + } + } + + let arg = ""; + if (options.length > 0) { + arg = `{\n${ident(options.join(",\n"))}\n}`; + } + + imports.addImport(decorator, "typeorm"); + lines.push(ident(`@${decorator}(${arg})`)); + lines.push(` ${key}: ${type};`); + lines.push(""); + } + + for (const k in table.relations) { + const relation = table.relations[k]; + if (!relation) continue; + + function buildJoinColumn() { + imports.addImport("JoinColumn", "typeorm"); + const args: string[] = []; + + for (const [left, right] of relation.on) { + args.push(`{ name: "${left}", referencedColumnName: "${right}" }`); + } + + return ` @JoinColumn([${args.join(", ")}])`; + } + let decorator: string; + const className = toPascalCase(relation.table.ormName); + const args: string[] = [`() => ${className}`]; + let type = className; + + if (relation.implied) { + if (relation.type === "many") { + decorator = "OneToMany"; + type += "[]"; + } else decorator = "OneToOne"; + + args.push(`v => v.${relation.impliedBy?.name}`); + } else { + if (relation.implying?.type === "many") decorator = "ManyToOne"; + else decorator = "OneToOne"; + + args.push(`v => v.${relation.implying?.name}`); + lines.push(buildJoinColumn()); + const config = relation.foreignKey; + + if (config) { + args.push( + `{ onUpdate: "${config.onUpdate}", onDelete: "${config.onDelete}" }` + ); + } + } + + imports.addImport(decorator, "typeorm"); + lines.push(` @${decorator}(${args.join(", ")})`); + lines.push(` ${relation.name}: ${type}`); + lines.push(""); + } + + lines.pop(); + lines.push("}"); + return lines.join("\n"); + } + + // Generate all tables + for (const table of Object.values(schema.tables)) { + code.push(generateTable(table)); + } + + code.unshift(imports.format()); + return code.join("\n\n"); +} diff --git a/packages/core/fumadb/src/adapters/typeorm/index.ts b/packages/core/fumadb/src/adapters/typeorm/index.ts new file mode 100644 index 000000000..c81b4ea89 --- /dev/null +++ b/packages/core/fumadb/src/adapters/typeorm/index.ts @@ -0,0 +1,86 @@ +import { + Kysely, + MssqlAdapter, + MssqlIntrospector, + MssqlQueryCompiler, + MysqlAdapter, + MysqlIntrospector, + MysqlQueryCompiler, + PostgresAdapter, + PostgresIntrospector, + PostgresQueryCompiler, + SqliteAdapter, + SqliteIntrospector, + SqliteQueryCompiler, +} from "kysely"; +import { type KyselySubDialect, KyselyTypeORMDialect } from "kysely-typeorm"; +import type { DataSource } from "typeorm"; +import type { SQLProvider } from "../../shared/providers"; +import type { FumaDBAdapter } from ".."; +import { kyselyAdapter } from "../kysely"; +import { generateSchema } from "./generate"; + +export interface TypeORMConfig { + source: DataSource; + provider: Exclude; +} + +export function typeormAdapter(options: TypeORMConfig): FumaDBAdapter { + const kysely = getKysely(options.source, options.provider); + + return { + ...kyselyAdapter({ + db: kysely, + provider: options.provider, + }), + name: "typeorm", + generateSchema(schema, name) { + return { + code: generateSchema(schema, options.provider), + path: `./models/${name}.ts`, + }; + }, + }; +} + +/** + * Create TypeORM query interface based on Kysely, because TypeORM returns class instances, it's more performant to use Kysely directly. + * + * This doesn't support MongoDB. + */ +function getKysely(source: DataSource, provider: SQLProvider) { + let subDialect: KyselySubDialect; + + if (provider === "postgresql") { + subDialect = { + createAdapter: () => new PostgresAdapter(), + createIntrospector: (db) => new PostgresIntrospector(db), + createQueryCompiler: () => new PostgresQueryCompiler(), + }; + } else if (provider === "mysql") { + subDialect = { + createAdapter: () => new MysqlAdapter(), + createIntrospector: (db) => new MysqlIntrospector(db), + createQueryCompiler: () => new MysqlQueryCompiler(), + }; + } else if (provider === "mssql") { + subDialect = { + createAdapter: () => new MssqlAdapter(), + createIntrospector: (db) => new MssqlIntrospector(db), + createQueryCompiler: () => new MssqlQueryCompiler(), + }; + } else { + subDialect = { + createAdapter: () => new SqliteAdapter(), + createIntrospector: (db) => new SqliteIntrospector(db), + createQueryCompiler: () => new SqliteQueryCompiler(), + }; + } + + return new Kysely({ + dialect: new KyselyTypeORMDialect({ + kyselySubDialect: subDialect, + typeORMDataSource: source, + }), + }); +} diff --git a/packages/core/fumadb/src/cli/index.ts b/packages/core/fumadb/src/cli/index.ts new file mode 100644 index 000000000..d3896df6e --- /dev/null +++ b/packages/core/fumadb/src/cli/index.ts @@ -0,0 +1,187 @@ +import * as fs from "node:fs/promises"; +import path from "node:path"; +import { cancel, isCancel, select, text } from "@clack/prompts"; +import { Command } from "commander"; +import type { FumaDB } from ".."; + +export function createCli(options: { + db: FumaDB; + + /** + * CLI command name, must be lowercase without whitespaces. + */ + command: string; + description?: string; + + /** + * CLI Version + */ + version: string; +}) { + const db = options.db as FumaDB; + + async function selectVersion(defaultValue?: string) { + const schemas = db.schemas; + const selected = await select({ + message: "Select target schema version:", + options: schemas.map((s, i) => { + let hint: string | undefined; + if (s.version === defaultValue) { + hint = "current"; + } else if (i === schemas.length - 1) { + hint = "latest"; + } + + return { + value: s.version, + label: s.version, + hint, + }; + }), + initialValue: defaultValue, + }); + + if (isCancel(selected)) { + cancel("Migration cancelled."); + process.exit(0); + } + + return selected; + } + + async function inputOutputPath(type: "sql" | "orm", suggestion: string) { + const result = await text({ + message: + type === "sql" + ? "Where to output the SQL migration file?" + : "Where to output the generated schema? (it will override the destination)", + defaultValue: suggestion, + placeholder: suggestion, + }); + + if (isCancel(result)) { + cancel("Migration cancelled."); + process.exit(0); + } + + return result; + } + + return { + async main() { + const program = new Command(); + program + .name(options.command) + .description( + options.description ?? + "FumaDB CLI for migrations and schema generation" + ) + .version(options.version); + + program + .command("migrate:up") + .description("Migrate to the next schema version") + .action(async () => { + const migrator = db.createMigrator(); + const next = await migrator.next(); + if (!next) { + console.log("Already up to date."); + process.exit(1); + } + + const result = await migrator.migrateTo(next.version); + await result.execute(); + console.log(`Migration to ${next.version} executed.`); + }); + + program + .command("migrate:down") + .description("Rollback to the previous schema version") + .action(async () => { + const migrator = db.createMigrator(); + const prev = await migrator.previous(); + if (!prev) { + console.log("Cannot downgrade."); + process.exit(1); + } + + const result = await migrator.migrateTo(prev.version); + await result.execute(); + console.log(`Migration to ${prev.version} executed.`); + }); + + program + .command("migrate:to [version]") + .alias("migrate") + .description( + "Migrate to a specific schema version (interactive if not provided)" + ) + .action(async (version: string | undefined) => { + const migrator = db.createMigrator(); + version ??= await selectVersion(await migrator.getVersion()); + const result = + version === "latest" + ? await migrator.migrateToLatest() + : await migrator.migrateTo(version); + + await result.execute(); + console.log(`Migrated to version ${version}.`); + }); + + program + .command("generate [version]") + .description( + "Output SQL (for Kysely) or database schema (for ORMs) for the migration." + ) + .option( + "-o, --output ", + "the output path of generated SQL/schema file" + ) + .action( + async ( + version: string | undefined, + { output }: { output?: string } + ) => { + let generated: string; + + if (db.adapter.createMigrationEngine) { + const migrator = db.createMigrator(); + version ??= await selectVersion(await migrator.getVersion()); + + const result = + version === "latest" + ? await migrator.migrateToLatest() + : await migrator.migrateTo(version); + + if (!result.getSQL) + throw new Error( + "The adapter doesn't support migration file generation." + ); + + generated = result.getSQL(); + output ??= await inputOutputPath( + "sql", + `./migrations/${Date.now()}.sql` + ); + } else if (db.adapter.generateSchema) { + version ??= await selectVersion(); + const result = db.generateSchema(version); + + generated = result.code; + output ??= await inputOutputPath("orm", result.path); + } else { + throw new Error( + "The adapter doesn't support migration generation." + ); + } + + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.writeFile(output, generated); + console.log("Successful."); + } + ); + + await program.parseAsync(process.argv); + }, + }; +} diff --git a/packages/core/fumadb/src/convex/deserialize.ts b/packages/core/fumadb/src/convex/deserialize.ts new file mode 100644 index 000000000..fdf237d3d --- /dev/null +++ b/packages/core/fumadb/src/convex/deserialize.ts @@ -0,0 +1,64 @@ +import type { AnySelectClause } from "../query"; +import { type Condition, ConditionType } from "../query/condition-builder"; +import type { AnyColumn, AnySchema } from "../schema/create"; +import { + type SerializedColumn, + type SerializedSelect, + type SerializedWhere, + serializedColumn, +} from "./serialize"; + +interface Context { + schema: AnySchema; +} + +// Helper to resolve column from serialized form +function deserializeColumn( + serialized: SerializedColumn, + { schema }: Context, +): AnyColumn { + const table = schema.tables[serialized.$table]; + if (!table) throw new Error(`Unknown table: ${serialized.$table}`); + const column = table.columns[serialized.$column]; + if (!column) throw new Error(`Unknown Column: ${serialized.$column}`); + + return column; +} + +export function deserializeSelect(select: SerializedSelect): AnySelectClause { + return select; +} + +export function deserializeWhere( + where: SerializedWhere, + context: Context, +): Condition { + function run(where: SerializedWhere): Condition { + if (where.type === "Compare") { + const parsedB = serializedColumn.safeParse(where.b); + + return { + type: ConditionType.Compare, + a: deserializeColumn(where.a, context), + operator: where.operator, + b: parsedB.success ? deserializeColumn(parsedB.data, context) : where.b, + }; + } + if (where.type === "And" || where.type === "Or") { + return { + type: where.type === "And" ? ConditionType.And : ConditionType.Or, + items: where.items.map(run), + }; + } + if (where.type === "Not") { + return { + type: ConditionType.Not, + item: run(where.item), + }; + } + + throw new Error("Unknown serialized condition type"); + } + + return run(where); +} diff --git a/packages/core/fumadb/src/convex/generate.ts b/packages/core/fumadb/src/convex/generate.ts new file mode 100644 index 000000000..361e0a751 --- /dev/null +++ b/packages/core/fumadb/src/convex/generate.ts @@ -0,0 +1,104 @@ +import { type AnyColumn, type AnySchema, IdColumn } from "../schema/create"; + +export interface ConvexConfig { + type: "convex"; +} + +function mapColumnToValidator(column: AnyColumn, tableName: string): string { + // Map FumaDB types to Convex validators + let validator: string; + if (column instanceof IdColumn) { + // Convex id type: v.id("tableName") + validator = `v.id("${tableName}")`; + } else if ( + typeof column.type === "string" && + column.type.startsWith("varchar") + ) { + validator = "v.string()"; + } else { + switch (column.type) { + case "uuid": + case "string": + validator = "v.string()"; + break; + case "integer": + case "decimal": + validator = "v.number()"; + break; + case "bigint": + validator = "v.int64()"; + break; + case "bool": + validator = "v.boolean()"; + break; + case "json": + validator = "v.any()"; + break; + case "binary": + validator = "v.bytes()"; + break; + case "date": + case "timestamp": + validator = "v.number()"; // Convex stores timestamps as numbers + break; + default: + validator = "v.any()"; + } + } + if (column.isNullable) { + validator = `v.optional(${validator})`; + } + return validator; +} + +export function generateSchema( + schema: AnySchema, + _config: ConvexConfig +): string { + // Header imports + const lines: string[] = [ + 'import { defineSchema, defineTable } from "convex/server";', + 'import { v } from "convex/values";', + "", + ]; + + // Table definitions + const tableDefs: string[] = []; + const tableNames: string[] = []; + + for (const table of Object.values(schema.tables)) { + tableNames.push(table.names.convex); + const fields: string[] = []; + for (const column of Object.values(table.columns)) { + const validator = mapColumnToValidator(column, table.names.convex); + fields.push(` ${column.names.convex}: ${validator},`); + } + tableDefs.push( + `const ${table.names.convex}Table = defineTable({\n${fields.join("\n")}\n})` // indexes will be chained below + ); + } + + // Indexes + let tableIdx = 0; + for (const table of Object.values(schema.tables)) { + let indexLines = ""; + for (const column of Object.values(table.columns)) { + if (column instanceof IdColumn) continue; + + indexLines += `\n .index("by_${column.names.convex}", ["${column.names.convex}"])`; + } + tableDefs[tableIdx] += `${indexLines};\n`; + tableIdx++; + } + + // Schema export + lines.push(...tableDefs); + lines.push(""); + lines.push( + `export default defineSchema({\n${tableNames + .map((t) => ` ${t}: ${t}Table,`) + .join("\n")}\n});` + ); + + return lines.join("\n"); +} diff --git a/packages/core/fumadb/src/convex/index.ts b/packages/core/fumadb/src/convex/index.ts new file mode 100644 index 000000000..4c7aed692 --- /dev/null +++ b/packages/core/fumadb/src/convex/index.ts @@ -0,0 +1,427 @@ +import { + type Expression, + type FilterBuilder, + type GenericTableInfo, + mutationGeneric, + queryGeneric, +} from "convex/server"; +import { ConvexError, type GenericId, v } from "convex/values"; +import { type Condition, ConditionType } from "../query/condition-builder"; +import { + type AnyColumn, + type AnySchema, + type AnyTable, + Column, +} from "../schema"; +import { deserializeSelect, deserializeWhere } from "./deserialize"; +import { serializedSelect, serializedWhere } from "./serialize"; + +const mutationArgs = v.object({ + tableName: v.string(), + secret: v.string(), + action: v.union( + v.object({ + type: v.literal("create"), + data: v.array(v.record(v.string(), v.any())), + returning: v.boolean(), + }), + v.object({ + type: v.literal("update"), + where: v.optional(v.any()), + set: v.record(v.string(), v.any()), + }), + v.object({ + type: v.literal("delete"), + where: v.optional(v.any()), + }), + v.object({ + type: v.literal("upsert"), + where: v.any(), + create: v.record(v.string(), v.any()), + update: v.record(v.string(), v.any()), + }) + ), +}); + +const queryArgs = v.object({ + tableName: v.string(), + secret: v.string(), + query: v.union( + v.object({ + type: v.literal("find"), + where: v.optional(v.any()), + select: v.any(), + limit: v.optional(v.number()), + offset: v.optional(v.number()), + }), + v.object({ + type: v.literal("count"), + where: v.optional(v.any()), + }) + ), +}); + +enum ValuesMode { + Insert, + Update, +} + +function mapValues( + mode: ValuesMode, + values: Record, + table: AnyTable +): Record { + const out: Record = {}; + for (const k in table.columns) { + if (mode === ValuesMode.Update && values[k] === undefined) continue; + + out[k] = values[k] ?? null; + } + return out; +} + +class DeferredFilter { + filter: (record: Record) => boolean; + + constructor(filter: (record: Record) => boolean) { + this.filter = filter; + } + + static onField(colName: string, filter: (v: unknown) => boolean) { + return new DeferredFilter((record) => filter(record[colName])); + } + + inverse() { + const filter = this.filter; + return new DeferredFilter((record) => !filter(record)); + } +} + +type Filter = (builder: FilterBuilder) => Expression; + +function buildFilter(where: Condition, defer = false): Filter | DeferredFilter { + function autoField( + builder: FilterBuilder, + v: AnyColumn | unknown + ) { + if (v instanceof Column) return builder.field(v.ormName); + return v; + } + if (where.type === ConditionType.Compare) { + const left = where.a; + const right = where.b; + const fieldName = left.ormName; + + let inverse = false; + switch (where.operator) { + case "=": + case "is": + if (defer) return DeferredFilter.onField(fieldName, (v) => v === right); + + return (b) => b.eq(autoField(b, left), autoField(b, right)); + case "!=": + case "is not": + if (defer) return DeferredFilter.onField(fieldName, (v) => v !== right); + + return (b) => b.neq(autoField(b, left), autoField(b, right)); + case "<": + if (defer) + return DeferredFilter.onField( + fieldName, + (v) => + typeof v === "number" && typeof right === "number" && v < right + ); + return (b) => b.lt(autoField(b, left), autoField(b, right)); + case "<=": + if (defer) + return DeferredFilter.onField( + fieldName, + (v) => + typeof v === "number" && typeof right === "number" && v <= right + ); + return (b) => b.lte(autoField(b, left), autoField(b, right)); + case ">": + if (defer) + return DeferredFilter.onField( + fieldName, + (v) => + typeof v === "number" && typeof right === "number" && v > right + ); + return (b) => b.gt(autoField(b, left), autoField(b, right)); + case ">=": + if (defer) + return DeferredFilter.onField( + fieldName, + (v) => + typeof v === "number" && typeof right === "number" && v >= right + ); + return (b) => b.gte(autoField(b, left), autoField(b, right)); + case "not in": + inverse = true; + case "in": + if (!Array.isArray(right)) + throw new ConvexError( + "FumaDB doesn't support using `in` operator against non-literal values" + ); + + if (defer) + return DeferredFilter.onField(fieldName, (v) => + inverse ? !right.includes(v) : right.includes(v) + ); + + return (b) => { + const leftField = autoField(b, left); + const filter = b.or(...right.map((item) => b.eq(leftField, item))); + + return inverse ? b.not(filter) : filter; + }; + + case "not contains": + inverse = true; + case "contains": + return DeferredFilter.onField(fieldName, (v) => { + if (typeof v !== "string" || typeof right !== "string") return false; + const result = v.includes(right); + return inverse ? !result : result; + }); + case "not ends with": + inverse = true; + case "ends with": + return DeferredFilter.onField(fieldName, (v) => { + if (typeof v !== "string" || typeof right !== "string") return false; + const result = v.endsWith(right); + return inverse ? !result : result; + }); + case "not starts with": + inverse = true; + case "starts with": + return DeferredFilter.onField(fieldName, (v) => { + if (typeof v !== "string" || typeof right !== "string") return false; + const result = v.startsWith(right); + return inverse ? !result : result; + }); + } + } + + if (where.type === ConditionType.Not) { + const filter = buildFilter(where.item, defer); + if (filter instanceof DeferredFilter) return filter.inverse(); + return (b) => b.not(filter(b)); + } + + const filters: Filter[] = []; + const deferredFilters: DeferredFilter[] = []; + let deferItem = defer; + + for (const item of where.items) { + const filter = buildFilter(item, deferItem); + + if (filter instanceof DeferredFilter) { + if (!deferItem) { + deferItem = true; + + // add previous items back + for (const prev of where.items) { + if (prev === item) break; + deferredFilters.push(buildFilter(prev, true) as DeferredFilter); + } + } + + deferredFilters.push(filter); + } else { + filters.push(filter); + } + } + + if (deferItem) + return where.type === ConditionType.And + ? new DeferredFilter((record) => { + for (const item of deferredFilters) { + if (!item.filter(record)) return false; + } + + return true; + }) + : new DeferredFilter((record) => { + for (const item of deferredFilters) { + if (item.filter(record)) return true; + } + + return false; + }); + + return (b) => + where.type === ConditionType.And + ? b.and(...filters.map((f) => f(b))) + : b.or(...filters.map((f) => f(b))); +} + +export function createHandler(options: { + schema: AnySchema; + /** + * A secret key to ensure this action is only accessible for your backend server. + * + * **Please be careful, anyone with the secret may access your database**. + */ + secret: string; +}) { + const { schema, secret } = options; + if (!secret) throw new ConvexError("`secret` must be provided."); + + return { + mutationHandler: mutationGeneric({ + args: mutationArgs, + handler: async (ctx, { tableName, action, ...args }) => { + if (args.secret !== secret) throw new ConvexError("Invalid secret"); + + const table = schema.tables[tableName]; + if (!table) throw new ConvexError(`Unknown table: ${tableName}`); + if (action.type === "create") { + const ids = await Promise.all( + action.data.map((values) => ctx.db.insert(tableName, values)) + ); + + if (action.returning) { + return await Promise.all(ids.map((id) => ctx.db.get(id))); + } + + return; + } + + if (action.type === "update") { + const query = ctx.db.query(tableName); + const filter = action.where + ? buildFilter(deserializeWhere(action.where, { schema })) + : undefined; + let targets: Record[]; + + if (filter instanceof DeferredFilter) { + targets = (await query.collect()).filter((v) => filter.filter(v)); + } else if (filter) { + targets = await query.filter(filter).collect(); + } else { + targets = await query.collect(); + } + + const mappedValues = mapValues(ValuesMode.Update, action.set, table); + await Promise.all( + targets.map((target) => ctx.db.patch(target._id, mappedValues)) + ); + return; + } + + if (action.type === "upsert") { + const query = ctx.db.query(tableName); + const filter = buildFilter( + deserializeWhere(action.where, { schema }) + ); + let target: Record; + if (filter instanceof DeferredFilter) { + target = (await query.collect()).filter((v) => filter.filter(v))[0]; + } else { + target = await query.filter(filter).first(); + } + + if (target) { + await ctx.db.patch( + target._id as GenericId, + mapValues(ValuesMode.Update, action.update, table) + ); + } else { + await ctx.db.insert( + tableName, + mapValues(ValuesMode.Insert, action.create, table) + ); + } + return; + } + + if (action.type === "delete") { + const query = ctx.db.query(tableName); + const filter = action.where + ? buildFilter(deserializeWhere(action.where, { schema })) + : undefined; + let targets: Record[]; + + if (filter instanceof DeferredFilter) { + targets = (await query.collect()).filter((v) => filter.filter(v)); + } else if (filter) { + targets = await query.filter(filter).collect(); + } else { + targets = await query.collect(); + } + + await Promise.all( + targets.map((target) => + ctx.db.delete(target._id as GenericId) + ) + ); + return; + } + + throw new ConvexError("Unhandled action type"); + }, + }), + queryHandler: queryGeneric({ + args: queryArgs, + handler: async (ctx, { tableName, query: options, ...args }) => { + if (args.secret !== secret) throw new ConvexError("Invalid secret"); + + const table = schema.tables[tableName]; + if (!table) throw new ConvexError(`Unknown table: ${tableName}`); + + if (options.type === "find") { + const { where, offset = 0, limit } = options; + const _select = deserializeSelect( + serializedSelect.parse(options.select) + ); + + const filter = where + ? buildFilter( + deserializeWhere(serializedWhere.parse(where), { schema }) + ) + : undefined; + let query = ctx.db.query(tableName); + let records: unknown[]; + + if (filter instanceof DeferredFilter) { + records = (await query.collect()).filter((v) => filter.filter(v)); + if (limit !== undefined) records = records.slice(offset, limit); + else if (offset > 0) records = records.slice(offset); + } else { + if (filter) query = query.filter(filter); + + if (limit !== undefined) { + records = await query.take(limit + offset); + } else { + records = await query.collect(); + } + if (offset > 0) records = records.slice(offset); + } + + return records; + } + + const { where } = options; + const filter = where + ? buildFilter( + deserializeWhere(serializedWhere.parse(where), { schema }) + ) + : undefined; + let query = ctx.db.query(tableName); + + if (filter instanceof DeferredFilter) { + let count = 0; + for (const v of await query.collect()) { + if (filter.filter(v)) count++; + } + return count; + } else { + if (filter) query = query.filter(filter); + // TODO: consider aggregate, it currently needs some extra configure for user to enable it and with too much complexity + return (await query.collect()).length; + } + }, + }), + }; +} diff --git a/packages/core/fumadb/src/convex/query.ts b/packages/core/fumadb/src/convex/query.ts new file mode 100644 index 000000000..4bae33628 --- /dev/null +++ b/packages/core/fumadb/src/convex/query.ts @@ -0,0 +1,129 @@ +import type { ConvexClient, ConvexHttpClient } from "convex/browser"; +import type * as GeneratedAPI from "../../convex/_generated/api"; +import { toORM } from "../query/orm"; +import { createTransaction } from "../query/polyfills/transaction"; +import type { AnySchema } from "../schema"; +import { serializeSelect, serializeWhere } from "./serialize"; + +interface ConvexOptions { + secret: string; + client: ConvexClient | ConvexHttpClient; + generatedAPI: Record; +} + +// TODO: join, sort +export function fromConvex(schema: AnySchema, options: ConvexOptions) { + const { secret, client, generatedAPI } = options; + const api = generatedAPI as (typeof GeneratedAPI.fullApi)["test"]; + + const orm = createTransaction({ + tables: schema.tables, + async count(table, v) { + return (await client.query(api.queryHandler, { + tableName: table.ormName, + query: { + type: "count", + where: v.where ? serializeWhere(v.where) : undefined, + }, + secret, + })) as number; + }, + async findFirst(table, v) { + const result = await client.query(api.queryHandler, { + tableName: table.ormName, + query: { + type: "find", + select: serializeSelect(table, v.select), + where: v.where ? serializeWhere(v.where) : undefined, + limit: 1, + }, + secret, + }); + + if (Array.isArray(result) && result.length > 0) + return result[0] as Record; + return null; + }, + async findMany(table, v) { + const result = await client.query(api.queryHandler, { + tableName: table.ormName, + query: { + type: "find", + select: serializeSelect(table, v.select), + where: v.where ? serializeWhere(v.where) : undefined, + limit: v.limit, + offset: v.offset, + }, + secret, + }); + + if (Array.isArray(result)) return result as Record[]; + return []; + }, + async updateMany(table, v) { + await client.mutation(api.mutationHandler, { + tableName: table.ormName, + action: { + type: "update", + set: v.set, + where: v.where ? serializeWhere(v.where) : undefined, + }, + secret, + }); + }, + async create(table, values) { + const result = await client.mutation(api.mutationHandler, { + tableName: table.ormName, + action: { + type: "create", + data: [values], + returning: true, + }, + secret, + }); + + return result?.[0]; + }, + async createMany(table, values) { + const results = await client.mutation(api.mutationHandler, { + tableName: table.ormName, + action: { + type: "create", + data: values, + returning: true, + }, + secret, + }); + + if (!results) throw new Error("Failed to create records."); + const idColumn = table.getIdColumn(); + return results.map((result: Record) => ({ + _id: result[idColumn.ormName], + })); + }, + async deleteMany(table, v) { + await client.mutation(api.mutationHandler, { + tableName: table.names.sql, + action: { + type: "delete", + where: v.where ? serializeWhere(v.where) : undefined, + }, + secret, + }); + }, + async upsert(table, v) { + await client.mutation(api.mutationHandler, { + tableName: table.names.sql, + action: { + type: "upsert", + create: v.create, + update: v.update, + where: v.where ? serializeWhere(v.where) : undefined, + }, + secret, + }); + }, + }); + + return toORM(orm); +} diff --git a/packages/core/fumadb/src/convex/serialize.ts b/packages/core/fumadb/src/convex/serialize.ts new file mode 100644 index 000000000..cd5bcdeb0 --- /dev/null +++ b/packages/core/fumadb/src/convex/serialize.ts @@ -0,0 +1,87 @@ +import z from "zod"; +import type { AnySelectClause } from "../query"; +import { + type Condition, + ConditionType, + operators, +} from "../query/condition-builder"; +import { type AnyColumn, type AnyTable, Column } from "../schema/create"; + +export const serializedSelect = z.array(z.string()); + +export const serializedColumn = z.looseObject({ + $table: z.string(), + $column: z.string(), +}); + +export const serializedWhere = z.union([ + z.object({ + type: z.literal("Compare"), + a: serializedColumn, + operator: z.literal(operators), + b: z.union([serializedColumn, z.unknown()]), + }), + z.object({ + type: z.literal(["And", "Or"]), + get items() { + return z.array(serializedWhere); + }, + }), + z.object({ + type: z.literal("Not"), + get item() { + return serializedWhere; + }, + }), +]); + +/** + * column names + */ +export type SerializedSelect = z.infer; + +/** + * Serialized Condition + */ +export type SerializedWhere = z.infer; + +export type SerializedColumn = z.infer; + +function serializeColumn(col: AnyColumn) { + return { + $table: col.table!.ormName, + $column: col.ormName, + }; +} + +export function serializeSelect( + table: AnyTable, + select: AnySelectClause +): SerializedSelect { + if (select === true) return Object.keys(table.columns); + return select; +} + +export function serializeWhere(where: Condition): SerializedWhere { + if (where.type === ConditionType.Compare) { + return { + type: "Compare", + a: serializeColumn(where.a), + operator: where.operator, + b: where.b instanceof Column ? serializeColumn(where.b) : where.b, + }; + } + if (where.type === ConditionType.And || where.type === ConditionType.Or) { + return { + type: where.type === ConditionType.And ? "And" : "Or", + items: where.items.map(serializeWhere), + }; + } + if (where.type === ConditionType.Not) { + return { + type: "Not", + item: serializeWhere(where.item), + }; + } + throw new Error("Unknown condition type"); +} diff --git a/packages/core/fumadb/src/cuid.ts b/packages/core/fumadb/src/cuid.ts new file mode 100644 index 000000000..1ae0bb3fa --- /dev/null +++ b/packages/core/fumadb/src/cuid.ts @@ -0,0 +1,3 @@ +import { createId } from "@paralleldrive/cuid2"; + +export { createId }; diff --git a/packages/core/fumadb/src/index.ts b/packages/core/fumadb/src/index.ts new file mode 100644 index 000000000..f5816cb0b --- /dev/null +++ b/packages/core/fumadb/src/index.ts @@ -0,0 +1,155 @@ +import { compare } from "semver"; +import type { FumaDBAdapter, FumaDBAdapterContext } from "./adapters"; +import type { Migrator } from "./migration-engine/create"; +import type { AbstractQuery } from "./query"; +import type { AnySchema } from "./schema"; +import { + createNameVariantsBuilder, + type NameVariantsBuilder, +} from "./schema/name-variants-builder"; +import type { LibraryConfig } from "./shared/config"; + +export * from "./shared/config"; +export * from "./shared/providers"; + +type Last = T extends [...infer _, infer L] + ? L + : T[number]; + +export interface FumaDB { + schemas: Schemas; + adapter: FumaDBAdapter; + + /** + * Shorthand for `orm()` latest schema version + * @deprecated use `orm()` directly + */ + readonly abstract: AbstractQuery>; + + /** + * Get current schema version + */ + version: () => Promise; + + orm: ( + version: V + ) => AbstractQuery>; + + /** + * Kysely & MongoDB only + */ + createMigrator: () => Migrator; + + /** + * ORM only + */ + generateSchema: ( + version: Schemas[number]["version"] | "latest", + name?: string + ) => { + code: string; + path: string; + }; +} + +export interface FumaDBFactory { + /** + * A static type-checker + */ + version: (target: T) => T; + + /** + * Configure consumer-side integration + */ + client: (adapter: FumaDBAdapter) => FumaDB; + + /** + * Set name variants + */ + names: NameVariantsBuilder>; +} + +export type InferFumaDB> = + Factory extends FumaDBFactory ? FumaDB : never; + +export type InferAbstractQuery< + Factory extends FumaDB, + Version extends string, +> = + Factory extends FumaDB + ? AbstractQuery> & { + version: Version; + } + : never; + +export function fumadb( + config: LibraryConfig +): FumaDBFactory { + const schemas = config.schemas.sort((a, b) => compare(a.version, b.version)); + return { + names: createNameVariantsBuilder(config.namespace, schemas, (schemas) => { + return fumadb({ + ...config, + schemas, + }); + }), + version(targetVersion) { + return targetVersion; + }, + + client(adapter) { + const orms = new Map>(); + const adapterContext: FumaDBAdapterContext = { + ...config, + }; + + return { + adapter, + schemas, + orm(version) { + let orm = orms.get(version); + if (orm) return orm; + + const schema = schemas.find((schema) => schema.version === version); + if (!schema) throw new Error(`unknown schema version ${version}`); + + orm = adapter.createORM.call(adapterContext, schema); + orms.set(version, orm); + return orm as any; + }, + async version() { + const version = await adapter.getSchemaVersion.call(adapterContext); + if (!version) + throw new Error(`FumaDB ${config.namespace} is not initialized.`); + + return version; + }, + generateSchema(version, name = config.namespace) { + if (!adapter.generateSchema) + throw new Error("The adapter doesn't support schema API."); + let schema: AnySchema; + + if (version === "latest") { + schema = schemas.at(-1)!; + } else { + schema = schemas.find((schema) => schema.version === version)!; + if (!schema) throw new Error(`Invalid version: ${version}`); + } + + return adapter.generateSchema.call(adapterContext, schema, name); + }, + + createMigrator() { + if (!adapter.createMigrationEngine) + throw new Error("The adapter doesn't support migration engine."); + + return adapter.createMigrationEngine.call(adapterContext); + }, + + get abstract() { + return this.orm(schemas.at(-1)!.version) as any; + }, + }; + }, + }; +} diff --git a/packages/core/fumadb/src/migration-engine/auto-from-schema.ts b/packages/core/fumadb/src/migration-engine/auto-from-schema.ts new file mode 100644 index 000000000..9a3c33665 --- /dev/null +++ b/packages/core/fumadb/src/migration-engine/auto-from-schema.ts @@ -0,0 +1,365 @@ +import { + type AnyColumn, + type AnySchema, + type AnyTable, + compileForeignKey, + type NameVariants, +} from "../schema/create"; +import type { RelationMode } from "../shared/config"; +import type { Provider } from "../shared/providers"; +import { deepEqual } from "../utils/deep-equal"; +import { + type ColumnOperation, + isUpdated, + type MigrationOperation, +} from "./shared"; + +type Operation = MigrationOperation & { enforce?: "pre" | "post" }; + +/** + * Generate migration by comparing two schemas + */ +export function generateMigrationFromSchema( + old: AnySchema, + schema: AnySchema, + options: { + provider: Provider; + relationMode?: RelationMode; + + /** + * Drop tables if no longer exist in latest schema. + * + * This only detects tables from schema, user tables won't be affected. + */ + dropUnusedTables?: boolean; + dropUnusedColumns?: boolean; + } +): MigrationOperation[] { + const { + provider, + relationMode = provider === "mssql" || provider === "mongodb" + ? "fumadb" + : "foreign-keys", + dropUnusedTables = true, + dropUnusedColumns = true, + } = options; + + function getName(names: NameVariants) { + return provider === "mongodb" ? names.mongodb : names.sql; + } + + function columnActionToOperation( + tableName: string, + actions: ColumnOperation[] + ): MigrationOperation[] { + if (actions.length === 0) return []; + + if ( + provider === "mysql" || + provider === "postgresql" || + provider === "mongodb" + ) { + return [ + { + type: "update-table", + name: tableName, + value: actions, + }, + ]; + } + + return actions.map((action) => ({ + type: "update-table", + name: tableName, + value: [action], + })); + } + + function onUniqueConstraintCheck(prev: AnyTable, next: AnyTable) { + const operations: Operation[] = []; + const newConstraints = next.getUniqueConstraints(); + const oldConstraints = prev.getUniqueConstraints(); + + for (const con of newConstraints) { + const oldCon = oldConstraints.find((item) => item.name === con.name); + const columnNames = con.columns.map((col) => getName(col.names)); + + if (!oldCon) { + operations.push({ + type: "add-unique-constraint", + name: con.name, + table: getName(next.names), + columns: columnNames, + }); + continue; + } + + if ( + deepEqual( + columnNames, + oldCon.columns.map((col) => getName(col.names)) + ) + ) + continue; + + operations.push( + { + type: "drop-unique-constraint", + table: getName(next.names), + name: con.name, + }, + { + type: "add-unique-constraint", + table: getName(next.names), + name: con.name, + columns: columnNames, + } + ); + } + + for (const con of oldConstraints) { + const isUnused = newConstraints.every((item) => item.name !== con.name); + + if (isUnused) + operations.push({ + type: "drop-unique-constraint", + table: getName(next.names), + name: con.name, + }); + } + + return operations; + } + + function onTableRenameCheck(oldTable: AnyTable, newTable: AnyTable) { + const operations: Operation[] = []; + + if (getName(newTable.names) !== getName(oldTable.names)) { + operations.push({ + type: "rename-table", + from: getName(oldTable.names), + to: getName(newTable.names), + enforce: "pre", + }); + } + + return operations; + } + + function onTableColumnsCheck( + oldTable: AnyTable, + newTable: AnyTable + ): Operation[] { + const colActions: ColumnOperation[] = []; + + for (const column of Object.values(newTable.columns)) { + const oldColumn = oldTable.columns[column.ormName]; + + if (!oldColumn) { + colActions.push({ + type: "create-column", + value: column, + }); + continue; + } + + if (getName(column.names) !== getName(oldColumn.names)) { + colActions.push({ + type: "rename-column", + from: getName(oldColumn.names), + to: getName(column.names), + }); + } + + /** + * Generate hash to compare default values + */ + function hashDefaultValue(col: AnyColumn) { + if (!col.default || "runtime" in col.default) return; + if (col.type === "string" && provider === "mysql") return; + + return col.default.value; + } + + const action: ColumnOperation = { + type: "update-column", + name: getName(column.names), + updateDataType: column.type !== oldColumn.type, + updateDefault: !deepEqual( + hashDefaultValue(column), + hashDefaultValue(oldColumn) + ), + updateNullable: column.isNullable !== oldColumn.isNullable, + value: column, + }; + + if (isUpdated(action)) colActions.push(action); + } + + return columnActionToOperation(getName(newTable.names), colActions); + } + + function onTableForeignKeyCheck( + oldTable: AnyTable, + newTable: AnyTable + ): Operation[] { + const tableName = getName(newTable.names); + const operations: Operation[] = []; + if (relationMode === "fumadb") return operations; + + for (const foreignKey of newTable.foreignKeys) { + const compiled = compileForeignKey(foreignKey, "sql"); + const oldKey = oldTable.foreignKeys.find( + (key) => key.name === foreignKey.name + ); + + if (!oldKey) { + operations.push({ + type: "add-foreign-key", + table: tableName, + value: compiled, + enforce: "post", + }); + continue; + } + + const isUpdated = !deepEqual(compiled, compileForeignKey(oldKey, "sql")); + if (isUpdated) { + operations.push( + { + type: "drop-foreign-key", + name: oldKey.name, + table: tableName, + enforce: "post", + }, + { + type: "add-foreign-key", + table: tableName, + value: compiled, + enforce: "post", + } + ); + } + } + + return operations; + } + + function onTableUnusedForeignKeyCheck( + oldTable: AnyTable, + newTable: AnyTable + ) { + const operations: Operation[] = []; + + for (const oldKey of oldTable.foreignKeys) { + const isUnused = newTable.foreignKeys.every( + (key) => key.name !== oldKey.name + ); + + if (!isUnused) continue; + operations.push({ + type: "drop-foreign-key", + name: oldKey.name, + table: getName(oldTable.names), + enforce: "pre", + }); + } + + return operations; + } + + function onTableUnusedColumnsCheck( + oldTable: AnyTable, + newTable: AnyTable + ): Operation[] { + // this check happens after unique constraint check + const constraints = newTable.getUniqueConstraints(); + const operations: Operation[] = []; + + for (const oldColumn of Object.values(oldTable.columns)) { + const isUnused = !newTable.columns[oldColumn.ormName]; + const isRequired = !oldColumn.isNullable && !oldColumn.default; + const shouldDrop = isUnused && (dropUnusedColumns || isRequired); + + if (!shouldDrop) continue; + + // mssql doesn't auto drop unique index/constraint + if (provider === "mssql" && oldColumn.isUnique) { + for (const con of constraints) { + if (con.columns.every((col) => col.ormName !== oldColumn.ormName)) + continue; + + operations.push({ + type: "drop-unique-constraint", + name: con.name, + table: getName(newTable.names), + }); + } + } + + operations.push({ + type: "update-table", + name: getName(newTable.names), + value: [{ type: "drop-column", name: getName(oldColumn.names) }], + enforce: "post", + }); + } + + return operations; + } + + function reorder(operations: Operation[]) { + const out: MigrationOperation[] = []; + for (const item of operations) { + if (item.enforce === "pre") out.push(item); + } + + for (const item of operations) { + if (!item.enforce) out.push(item); + } + + for (const item of operations) { + if (item.enforce === "post") out.push(item); + } + + return out; + } + + function generate() { + const operations: Operation[] = []; + + for (const table of Object.values(schema.tables)) { + const oldTable = old.tables[table.ormName]; + if (!oldTable) { + operations.push({ + type: "create-table", + value: table, + }); + continue; + } + + operations.push( + ...onTableUnusedForeignKeyCheck(oldTable, table), + ...onTableRenameCheck(oldTable, table), + ...onTableColumnsCheck(oldTable, table), + ...onUniqueConstraintCheck(oldTable, table), + ...onTableForeignKeyCheck(oldTable, table), + ...onTableUnusedColumnsCheck(oldTable, table) + ); + } + + for (const oldTable of Object.values(old.tables)) { + if (!schema.tables[oldTable.ormName] && dropUnusedTables) { + operations.push({ + type: "drop-table", + name: getName(oldTable.names), + enforce: "post", + }); + } + } + + return reorder(operations); + } + + return generate(); +} diff --git a/packages/core/fumadb/src/migration-engine/create.ts b/packages/core/fumadb/src/migration-engine/create.ts new file mode 100644 index 000000000..53dab2066 --- /dev/null +++ b/packages/core/fumadb/src/migration-engine/create.ts @@ -0,0 +1,316 @@ +import { parse } from "semver"; +import { type AnySchema, type NameVariants, schema } from "../schema/create"; +import { + applyNameVariants, + type NameVariantsConfig, +} from "../schema/name-variants-builder"; +import type { LibraryConfig, RelationMode } from "../shared/config"; +import type { Provider } from "../shared/providers"; +import { deepEqual } from "../utils/deep-equal"; +import { generateMigrationFromSchema as defaultGenerateMigrationFromSchema } from "./auto-from-schema"; +import type { MigrationOperation } from "./shared"; + +type Awaitable = T | Promise; + +interface MigrationContext { + auto: () => Promise; +} + +export type CustomMigrationFn = ( + context: MigrationContext +) => Awaitable; + +export interface MigrateOptions { + /** + * Manage how migrations are generated. + * + * - `from-schema` (default): compare fumadb schemas + * - `from-database`: introspect & compare the database with schema + */ + mode?: "from-schema" | "from-database"; + + /** + * Update internal settings, it's true by default. + * We don't recommend to disable it other than testing purposes. + */ + updateSettings?: boolean; + + /** + * Enable unsafe operations when auto-generating migration. + */ + unsafe?: boolean; +} + +export interface MigrationResult { + operations: MigrationOperation[]; + getSQL?: () => string; + execute: () => Promise; +} + +export interface Migrator { + /** + * Get current version, undefined if not initialized + */ + getVersion: () => Promise; + getNameVariants: () => Promise; + + next: () => Promise; + previous: () => Promise; + up: (options?: MigrateOptions) => Promise; + down: (options?: MigrateOptions) => Promise; + migrateTo: ( + version: string, + options?: MigrateOptions + ) => Promise; + migrateToLatest: (options?: MigrateOptions) => Promise; +} + +export interface MigrationEngineOptions { + libConfig: LibraryConfig; + userConfig: { + provider: Provider; + relationMode?: RelationMode; + }; + + executor: (operations: MigrationOperation[]) => Promise; + + generateMigrationFromSchema?: typeof defaultGenerateMigrationFromSchema; + + generateMigrationFromDatabase?: (options: { + target: AnySchema; + dropUnusedColumns: boolean; + }) => Awaitable; + + settings: { + getVersion: () => Promise; + + /** + * get name variants for every table and column in schema. + * + * this is necessary for migrating database when name variants are changed by consumer, + * consumer's code doesn't have history like library's schema do, we need to detect it from previous settings. + */ + getNameVariants(): Promise | undefined>; + + updateSettingsInMigration: ( + schema: AnySchema + ) => Awaitable; + }; + + sql?: { + toSql: (operations: MigrationOperation[]) => string; + }; + + transformers?: MigrationTransformer[]; +} + +export interface MigrationTransformer { + /** + * Run after auto-generating migration operations + */ + afterAuto?: ( + operations: MigrationOperation[], + context: { + options: MigrateOptions; + prev: AnySchema; + next: AnySchema; + } + ) => MigrationOperation[]; + + /** + * Run on all migration operations + */ + afterAll?: ( + operations: MigrationOperation[], + context: { + prev: AnySchema; + next: AnySchema; + } + ) => MigrationOperation[]; +} + +export function createMigrator({ + settings, + generateMigrationFromDatabase, + generateMigrationFromSchema = defaultGenerateMigrationFromSchema, + libConfig: { schemas, initialVersion = "0.0.0" }, + userConfig, + executor, + sql: sqlConfig, + transformers = [], +}: MigrationEngineOptions): Migrator { + const indexedSchemas = new Map(); + + indexedSchemas.set( + initialVersion, + schema({ + version: initialVersion, + tables: {}, + }) + ); + + for (const schema of schemas) { + if (indexedSchemas.has(schema.version)) + throw new Error(`Duplicated version: ${schema.version}`); + + indexedSchemas.set(schema.version, schema); + } + + function getSchemaByVersion(version: string) { + const schema = indexedSchemas.get(version); + if (!schema) throw new Error(`Invalid version ${version}`); + return schema; + } + + async function getCurrentSchema() { + const version = (await settings.getVersion()) ?? initialVersion; + const nameVariants = await settings.getNameVariants(); + let schema = getSchemaByVersion(version); + + if (nameVariants) schema = applyNameVariants(schema, nameVariants); + + return schema; + } + + function getSchemasOfVariant( + variant: readonly (string | number)[] + ): AnySchema[] { + return schemas.filter((schema) => + deepEqual(parse(schema.version)!.prerelease, variant) + ); + } + + const instance: Migrator = { + getVersion() { + return settings.getVersion(); + }, + getNameVariants() { + return settings.getNameVariants(); + }, + async next() { + const version = (await settings.getVersion()) ?? initialVersion; + const list = getSchemasOfVariant(parse(version)!.prerelease); + const index = list.findIndex((schema) => schema.version === version); + + return list[index + 1]; + }, + async previous() { + const version = await settings.getVersion(); + if (!version) return; + + const list = getSchemasOfVariant(parse(version)!.prerelease); + const index = list.findIndex((schema) => schema.version === version); + return list[index - 1]; + }, + async up(options = {}) { + const next = await this.next(); + if (!next) throw new Error("Already up to date."); + + return this.migrateTo(next.version, options); + }, + async down(options = {}) { + const prev = await this.previous(); + if (!prev) throw new Error("No previous schema to migrate to."); + + return this.migrateTo(prev.version, options); + }, + async migrateTo(version, options = {}) { + const { + updateSettings: updateVersion = true, + unsafe = false, + mode = "from-schema", + } = options; + const targetSchema = getSchemaByVersion(version); + const currentSchema = await getCurrentSchema(); + + let run: + | ((context: MigrationContext) => Awaitable) + | undefined; + + // same variant + const prevVariant = parse(targetSchema.version)!.prerelease; + const variant = parse(currentSchema.version)!.prerelease; + + if (deepEqual(prevVariant, variant)) { + const list = getSchemasOfVariant(variant); + const targetIdx = list.indexOf(targetSchema); + + switch (currentSchema.version) { + case list[targetIdx - 1]?.version: + run = targetSchema.up; + break; + case list[targetIdx + 1]?.version: + run = targetSchema.down; + break; + } + } + + run ??= (context) => context.auto(); + + const context: MigrationContext = { + async auto() { + let generated: MigrationOperation[]; + + if (mode === "from-schema") { + generated = generateMigrationFromSchema( + currentSchema, + targetSchema, + userConfig + ); + } else { + if (!generateMigrationFromDatabase) + throw new Error(`${mode} is not supported for this adapter.`); + + generated = await generateMigrationFromDatabase({ + target: targetSchema, + dropUnusedColumns: unsafe, + }); + } + + for (const transformer of transformers) { + if (!transformer.afterAuto) continue; + + generated = transformer.afterAuto(generated, { + prev: currentSchema, + next: targetSchema, + options, + }); + } + + return generated; + }, + }; + + let operations = await run(context); + + if (updateVersion) { + operations.push( + ...(await settings.updateSettingsInMigration(targetSchema)) + ); + } + + for (const transformer of transformers) { + if (!transformer.afterAll) continue; + operations = transformer.afterAll(operations, { + prev: currentSchema, + next: targetSchema, + }); + } + + return { + operations, + getSQL: sqlConfig ? () => sqlConfig.toSql(operations) : undefined, + execute: () => executor(operations), + }; + }, + async migrateToLatest(options) { + const version = (await settings.getVersion()) ?? initialVersion; + const last = getSchemasOfVariant(parse(version)!.prerelease).at(-1); + if (!last) throw new Error(`Cannot find other schemas`); + + return this.migrateTo(last.version, options); + }, + }; + + return instance; +} diff --git a/packages/core/fumadb/src/migration-engine/shared.ts b/packages/core/fumadb/src/migration-engine/shared.ts new file mode 100644 index 000000000..e2494374c --- /dev/null +++ b/packages/core/fumadb/src/migration-engine/shared.ts @@ -0,0 +1,109 @@ +import type { AnyColumn, AnyTable } from "../schema/create"; + +export interface ForeignKeyInfo { + name: string; + columns: string[]; + referencedTable: string; + referencedColumns: string[]; + onUpdate: "RESTRICT" | "CASCADE" | "SET NULL"; + onDelete: "RESTRICT" | "CASCADE" | "SET NULL"; +} + +export type MigrationOperation = + | TableOperation + | { + // warning: not supported by SQLite + type: "add-foreign-key"; + table: string; + value: ForeignKeyInfo; + } + | { + // warning: not supported by SQLite + type: "drop-foreign-key"; + table: string; + name: string; + } + | { + type: "drop-unique-constraint"; + table: string; + name: string; + } + | { + type: "add-unique-constraint"; + table: string; + columns: string[]; + name: string; + } + | CustomOperation; + +export type CustomOperation = { + type: "custom"; +} & Record; + +export type TableOperation = + | { + type: "create-table"; + value: AnyTable; + } + | { + type: "drop-table"; + name: string; + } + | { + /** + * Not supported by FumaDB + * - update table's primary key + */ + type: "update-table"; + name: string; + value: ColumnOperation[]; + } + | { + type: "rename-table"; + from: string; + to: string; + }; + +export type ColumnOperation = + | { + type: "rename-column"; + from: string; + to: string; + } + | { + type: "drop-column"; + name: string; + } + | { + /** + * Note: unique constraints are not created, please use dedicated operations like `add-unique-constraint` instead + */ + type: "create-column"; + value: AnyColumn; + } + | { + /** + * warning: Not supported by SQLite + */ + type: "update-column"; + name: string; + /** + * For databases like MySQL, it requires the full definition for any modify column statement. + * Hence, you need to specify the full information of your column here. + * + * Then, opt-in for in-detail modification for other databases that supports changing data type/nullable/default separately, such as PostgreSQL. + * + * Note: unique constraints are not updated, please use dedicated operations like `add-unique-constraint` instead + */ + value: AnyColumn; + + updateNullable: boolean; + updateDefault: boolean; + updateDataType: boolean; + }; + +export function isUpdated( + op: Extract +): boolean { + return op.updateDataType || op.updateDefault || op.updateNullable; +} diff --git a/packages/core/fumadb/src/query/condition-builder.ts b/packages/core/fumadb/src/query/condition-builder.ts new file mode 100644 index 000000000..fe6d8de8b --- /dev/null +++ b/packages/core/fumadb/src/query/condition-builder.ts @@ -0,0 +1,180 @@ +import type { AnyColumn } from "../schema/create"; + +export enum ConditionType { + And, + Or, + Compare, + Not, +} + +export type Condition = + | { + type: ConditionType.Compare; + a: AnyColumn; + operator: Operator; + b: AnyColumn | unknown | null; + } + | { + type: ConditionType.Or | ConditionType.And; + items: Condition[]; + } + | { + type: ConditionType.Not; + item: Condition; + }; + +// TODO: we temporarily dropped support for comparing against another column, because Prisma ORM still have problems with it. + +export type ConditionBuilder> = { + ( + a: ColName, + operator: + | (typeof valueOperators)[number] + | (typeof stringOperators)[number], + b: Columns[ColName]["$in"] | null + ): Condition; + + ( + a: ColName, + operator: (typeof arrayOperators)[number], + b: Columns[ColName]["$in"][] + ): Condition; + + /** + * Boolean values + */ + (a: ColName): Condition; + + and: (...v: (Condition | boolean)[]) => Condition | boolean; + or: (...v: (Condition | boolean)[]) => Condition | boolean; + not: (v: Condition | boolean) => Condition | boolean; + + isNull: (a: keyof Columns) => Condition; + isNotNull: (a: keyof Columns) => Condition; +}; + +// replacement for `like` (Prisma doesn't support `like`) +const stringOperators = [ + "contains", + "starts with", + "ends with", + + "not contains", + "not starts with", + "not ends with", + // excluded `regexp` since MSSQL doesn't support it, may re-consider +] as const; + +const arrayOperators = ["in", "not in"] as const; + +const valueOperators = [ + "=", + "!=", + ">", + ">=", + "<", + "<=", + "is", + "is not", +] as const; + +// JSON specific operators are not included, some databases don't support them +// `match` requires additional extensions & configurations on SQLite and PostgreSQL +// MySQL & SQLite requires workarounds to support `ilike` +export const operators = [ + ...valueOperators, + ...arrayOperators, + ...stringOperators, +] as const; + +export type Operator = (typeof operators)[number]; + +export function createBuilder>( + columns: Columns +): ConditionBuilder { + function col(name: keyof Columns) { + const out = columns[name]; + if (!out) throw new Error(`[FumaDB] Invalid column name ${String(name)}`); + + return out; + } + + const builder: ConditionBuilder = ( + ...args: [string, Operator, unknown] | [string] + ) => { + if (args.length === 3) { + const [a, operator, b] = args; + + if (!operators.includes(operator)) + throw new Error(`Unsupported operator: ${operator}`); + + return { + type: ConditionType.Compare, + a: col(a), + b, + operator, + }; + } + + return { + type: ConditionType.Compare, + a: col(args[0]), + operator: "=", + b: true, + }; + }; + + builder.isNull = (a) => builder(a, "is", null); + builder.isNotNull = (a) => builder(a, "is not", null); + builder.not = (condition) => { + if (typeof condition === "boolean") return !condition; + + return { + type: ConditionType.Not, + item: condition, + }; + }; + + builder.or = (...conditions) => { + const out = { + type: ConditionType.Or, + items: [] as Condition[], + } as const; + + for (const item of conditions) { + if (item === true) return true; + if (item === false) continue; + + out.items.push(item); + } + + if (out.items.length === 0) return false; + return out; + }; + + builder.and = (...conditions) => { + const out = { + type: ConditionType.And, + items: [] as Condition[], + } as const; + + for (const item of conditions) { + if (item === true) continue; + if (item === false) return false; + + out.items.push(item); + } + + if (out.items.length === 0) return true; + return out; + }; + + return builder; +} + +export function buildCondition>( + columns: Columns, + input: (builder: ConditionBuilder) => T +): T { + return input(createBuilder(columns)); +} diff --git a/packages/core/fumadb/src/query/index.ts b/packages/core/fumadb/src/query/index.ts new file mode 100644 index 000000000..2128adb0e --- /dev/null +++ b/packages/core/fumadb/src/query/index.ts @@ -0,0 +1,211 @@ +import type { + AnySchema, + AnyTable, + Relation, + TableColumnValues, + TableInsertValues, + TableUpdateValues, +} from "../schema/create"; +import type { Condition, ConditionBuilder } from "./condition-builder"; +import type { ORMAdapter } from "./orm"; + +export type { Condition, ConditionBuilder } from "./condition-builder"; +export { ConditionType } from "./condition-builder"; +export { withQueryContext } from "./orm"; +export type { + CompiledJoin, + ORMAdapter, + SimplifiedCountOptions, + SimplifyFindOptions, +} from "./orm"; + +export type AnySelectClause = SelectClause; + +export type SelectClause = true | (keyof T["columns"])[]; + +export type TableToColumnValues = TableColumnValues; + +export type TableToInsertValues = TableInsertValues; + +export type TableToUpdateValues = TableUpdateValues; + +export type MainSelectResult< + S extends SelectClause, + T extends AnyTable, +> = S extends true + ? TableToColumnValues + : S extends (keyof T["columns"])[] + ? Pick, S[number]> + : never; + +export type JoinBuilder = { + [K in keyof T["relations"]]: T["relations"][K] extends Relation< + infer Type, + infer Target + > + ?