diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts new file mode 100644 index 0000000000..87c9333cb9 --- /dev/null +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -0,0 +1,651 @@ +import type { ProjectConfig } from "@supabase/config"; + +/** + * The disposition of one project-config leaf in the next local-stack flow. + * + * `presence` tells the future launch resolver how to determine whether a field + * affects the local runtime. Most schema defaults erase the distinction between + * an omitted field and an explicitly configured default value. Generated + * disabled provider stubs and secrets inside disabled subtrees do not affect the + * runtime; unresolved `env(...)` placeholders do not provide concrete secrets. + */ +type LocalStackConfigParityPresence = + | "decoded-value" + | "effective-global-secret" + | "effective-secret" + | "enabled-subtree" + | "non-default-value" + | "raw-document"; + +type LocalStackConfigParityDecision = + | { + readonly _tag: "mapped"; + readonly presence: LocalStackConfigParityPresence; + readonly mappedBy: "start" | "functions-dev" | "stack-functions-runtime"; + readonly rationale: string; + } + | { + readonly _tag: "not-applicable"; + readonly presence: LocalStackConfigParityPresence; + readonly rationale: string; + } + | { + readonly _tag: "unsupported-blocking"; + readonly presence: LocalStackConfigParityPresence; + readonly rationale: string; + } + | { + readonly _tag: "unsupported-warning"; + readonly presence: LocalStackConfigParityPresence; + readonly rationale: string; + }; + +export interface LocalStackConfigParitySection { + readonly [field: string]: Node; +} + +interface LocalStackConfigParityBranch { + readonly decision: LocalStackConfigParityDecision; + readonly children: LocalStackConfigParitySection; +} + +type Node = + | LocalStackConfigParityDecision + | LocalStackConfigParityBranch + | LocalStackConfigParitySection; + +const unsupportedRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "raw-document", + rationale: + "An explicit value changes local runtime behavior but the next stack launch Adapter does not translate it yet.", +}; + +const unsupportedOptionalRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "decoded-value", + rationale: + "An explicitly present optional value changes local runtime behavior but the next stack launch Adapter does not translate it yet.", +}; + +const unsupportedSecretRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "effective-secret", + rationale: + "A concrete resolved secret in an enabled runtime subtree changes local credentials but the next stack launch Adapter does not translate it yet; unresolved generated env placeholders do not count.", +}; + +const unsupportedGlobalSecretRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "effective-global-secret", + rationale: + "A concrete resolved global credential changes local runtime behavior even when the Auth service is disabled; unresolved generated env placeholders do not count.", +}; + +const unsupportedNonDefaultRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "non-default-value", + rationale: + "Only a value that differs from the generated project-config default changes local runtime behavior.", +}; + +const unsupportedEnabledSubtreeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "enabled-subtree", + rationale: + "This setting changes local runtime behavior only when its enclosing feature is effectively enabled; generated disabled stubs do not count.", +}; + +const mappedAutoExposeNewTables: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The start command resolves the tri-state value, emits its deprecation warning, and passes it to PostgreSQL initialization.", +}; + +const mappedFunctionManifest: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "stack-functions-runtime", + rationale: + "The current stack functions runtime resolves every configured function entry, including enablement, JWT verification, paths, and static files.", +}; + +const unsupportedPerFunctionEnv: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "raw-document", + rationale: + "The current stack functions runtime merges every function environment into one global record, so per-function overrides are not preserved.", +}; + +const functionConfigParity = { + enabled: mappedFunctionManifest, + verify_jwt: mappedFunctionManifest, + import_map: mappedFunctionManifest, + entrypoint: mappedFunctionManifest, + static_files: mappedFunctionManifest, + env: unsupportedPerFunctionEnv, +} satisfies Record; + +const mappedFunctionsDevEdgeRuntime: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "functions-dev", + rationale: + "The functions-dev Adapter resolves this field and passes it to the stack edge-runtime configuration.", +}; + +const commandOnlyDatabaseField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "This field configures database tooling outside local stack startup and does not belong in StackConfig.", +}; + +const hostedConfigurationField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "decoded-value", + rationale: + "This hosted-service limit is used by configuration management but does not change the local stack runtime.", +}; + +const legacyIgnoredLocalRuntimeField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "The legacy local start runtime ignores this field, so it cannot change local stack behavior.", +}; + +const projectMetadataField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "The project identifier distinguishes local project directories and is not local stack runtime configuration.", +}; + +const remoteOverlayField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "Remote overlays are selected and merged by project configuration resolution before the local stack launch Adapter runs.", +}; + +const unsupportedFutureRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-warning", + presence: "raw-document", + rationale: + "This experimental field has no stable local stack contract yet; an explicit value must be surfaced rather than silently ignored.", +}; + +const authExternalProviderParity = { + enabled: unsupportedEnabledSubtreeField, + client_id: unsupportedEnabledSubtreeField, + secret: unsupportedSecretRuntimeField, + url: unsupportedEnabledSubtreeField, + redirect_uri: unsupportedEnabledSubtreeField, + skip_nonce_check: unsupportedEnabledSubtreeField, + email_optional: unsupportedEnabledSubtreeField, +} satisfies Record; + +type AuthExternalParity = { + readonly [Provider in keyof ProjectConfig["auth"]["external"]]: Record< + keyof ProjectConfig["auth"]["external"][Provider], + Node + >; +}; + +const authHookParity = { + enabled: unsupportedEnabledSubtreeField, + uri: unsupportedEnabledSubtreeField, + secrets: unsupportedSecretRuntimeField, +} satisfies Record; + +const authRateLimitParity = { + email_sent: legacyIgnoredLocalRuntimeField, + sms_sent: unsupportedRuntimeField, + anonymous_users: unsupportedRuntimeField, + token_refresh: unsupportedRuntimeField, + sign_in_sign_ups: unsupportedRuntimeField, + token_verifications: unsupportedRuntimeField, + web3: unsupportedRuntimeField, +} satisfies Record; + +const authExternalParity = { + apple: authExternalProviderParity, + azure: authExternalProviderParity, + bitbucket: authExternalProviderParity, + discord: authExternalProviderParity, + facebook: authExternalProviderParity, + github: authExternalProviderParity, + gitlab: authExternalProviderParity, + google: authExternalProviderParity, + kakao: authExternalProviderParity, + keycloak: authExternalProviderParity, + linkedin_oidc: authExternalProviderParity, + notion: authExternalProviderParity, + twitch: authExternalProviderParity, + twitter: authExternalProviderParity, + x: authExternalProviderParity, + slack_oidc: authExternalProviderParity, + spotify: authExternalProviderParity, + workos: authExternalProviderParity, + zoom: authExternalProviderParity, +} satisfies AuthExternalParity; + +const authExternalWithCustomParity = { + ...authExternalParity, + "*": { + decision: unsupportedEnabledSubtreeField, + children: authExternalProviderParity, + }, +} satisfies LocalStackConfigParitySection; + +const authHooksParity = { + mfa_verification_attempt: authHookParity, + password_verification_attempt: authHookParity, + custom_access_token: authHookParity, + send_sms: authHookParity, + send_email: authHookParity, + before_user_created: authHookParity, +} satisfies Record; + +const authSmsParity = { + enable_signup: unsupportedEnabledSubtreeField, + enable_confirmations: unsupportedEnabledSubtreeField, + template: unsupportedEnabledSubtreeField, + max_frequency: unsupportedEnabledSubtreeField, + twilio: { + enabled: unsupportedEnabledSubtreeField, + account_sid: unsupportedEnabledSubtreeField, + message_service_sid: unsupportedEnabledSubtreeField, + auth_token: unsupportedSecretRuntimeField, + } satisfies Record, + twilio_verify: { + enabled: unsupportedEnabledSubtreeField, + account_sid: unsupportedEnabledSubtreeField, + message_service_sid: unsupportedEnabledSubtreeField, + auth_token: unsupportedSecretRuntimeField, + } satisfies Record, + messagebird: { + enabled: unsupportedEnabledSubtreeField, + originator: unsupportedEnabledSubtreeField, + access_key: unsupportedSecretRuntimeField, + } satisfies Record, + textlocal: { + enabled: unsupportedEnabledSubtreeField, + sender: unsupportedEnabledSubtreeField, + api_key: unsupportedSecretRuntimeField, + } satisfies Record, + vonage: { + enabled: unsupportedEnabledSubtreeField, + from: unsupportedEnabledSubtreeField, + api_key: unsupportedEnabledSubtreeField, + api_secret: unsupportedSecretRuntimeField, + } satisfies Record, + test_otp: unsupportedOptionalRuntimeField, +} satisfies Record; + +const authParity = { + enabled: unsupportedNonDefaultRuntimeField, + external_url: unsupportedRuntimeField, + passkey: { + enabled: unsupportedEnabledSubtreeField, + }, + webauthn: { + rp_display_name: unsupportedRuntimeField, + rp_id: unsupportedRuntimeField, + rp_origins: unsupportedRuntimeField, + }, + site_url: unsupportedRuntimeField, + additional_redirect_urls: unsupportedRuntimeField, + jwt_expiry: unsupportedNonDefaultRuntimeField, + jwt_issuer: unsupportedOptionalRuntimeField, + signing_keys_path: unsupportedOptionalRuntimeField, + enable_refresh_token_rotation: unsupportedRuntimeField, + refresh_token_reuse_interval: unsupportedRuntimeField, + enable_manual_linking: unsupportedRuntimeField, + enable_signup: unsupportedNonDefaultRuntimeField, + enable_anonymous_sign_ins: unsupportedRuntimeField, + minimum_password_length: unsupportedRuntimeField, + password_requirements: unsupportedRuntimeField, + publishable_key: unsupportedGlobalSecretRuntimeField, + secret_key: unsupportedGlobalSecretRuntimeField, + jwt_secret: unsupportedGlobalSecretRuntimeField, + anon_key: unsupportedGlobalSecretRuntimeField, + service_role_key: unsupportedGlobalSecretRuntimeField, + rate_limit: authRateLimitParity, + captcha: { + enabled: unsupportedRuntimeField, + provider: unsupportedOptionalRuntimeField, + secret: unsupportedSecretRuntimeField, + } satisfies Record, Node>, + hook: authHooksParity, + mfa: { + totp: { + enroll_enabled: unsupportedNonDefaultRuntimeField, + verify_enabled: unsupportedNonDefaultRuntimeField, + } satisfies Record, + phone: { + enroll_enabled: unsupportedNonDefaultRuntimeField, + verify_enabled: unsupportedNonDefaultRuntimeField, + otp_length: unsupportedEnabledSubtreeField, + template: unsupportedEnabledSubtreeField, + max_frequency: unsupportedEnabledSubtreeField, + } satisfies Record, + web_authn: { + enroll_enabled: unsupportedNonDefaultRuntimeField, + verify_enabled: unsupportedNonDefaultRuntimeField, + } satisfies Record, + max_enrolled_factors: unsupportedNonDefaultRuntimeField, + } satisfies Record, + sessions: { + timebox: unsupportedOptionalRuntimeField, + inactivity_timeout: unsupportedOptionalRuntimeField, + } satisfies Record, Node>, + email: { + enable_signup: unsupportedNonDefaultRuntimeField, + double_confirm_changes: unsupportedNonDefaultRuntimeField, + enable_confirmations: unsupportedNonDefaultRuntimeField, + secure_password_change: unsupportedNonDefaultRuntimeField, + max_frequency: unsupportedNonDefaultRuntimeField, + otp_length: unsupportedNonDefaultRuntimeField, + otp_expiry: unsupportedNonDefaultRuntimeField, + smtp: { + enabled: unsupportedEnabledSubtreeField, + host: unsupportedEnabledSubtreeField, + port: unsupportedEnabledSubtreeField, + user: unsupportedEnabledSubtreeField, + pass: unsupportedSecretRuntimeField, + admin_email: unsupportedEnabledSubtreeField, + sender_name: unsupportedEnabledSubtreeField, + } satisfies Record, Node>, + template: { + "*": { + decision: unsupportedRuntimeField, + children: { + subject: unsupportedRuntimeField, + content_path: unsupportedRuntimeField, + } satisfies Record, + }, + }, + notification: { + "*": { + decision: unsupportedRuntimeField, + children: { + enabled: unsupportedRuntimeField, + subject: unsupportedRuntimeField, + content_path: unsupportedRuntimeField, + } satisfies Record, + }, + }, + } satisfies Record, + sms: authSmsParity, + external: authExternalWithCustomParity, + web3: { + solana: { + enabled: unsupportedNonDefaultRuntimeField, + } satisfies Record, + ethereum: { + enabled: unsupportedNonDefaultRuntimeField, + } satisfies Record, + } satisfies Record, + oauth_server: { + enabled: unsupportedEnabledSubtreeField, + authorization_url_path: unsupportedEnabledSubtreeField, + allow_dynamic_registration: unsupportedEnabledSubtreeField, + } satisfies Record, + third_party: { + firebase: { + enabled: unsupportedEnabledSubtreeField, + project_id: unsupportedEnabledSubtreeField, + } satisfies Record, + auth0: { + enabled: unsupportedEnabledSubtreeField, + tenant: unsupportedEnabledSubtreeField, + tenant_region: unsupportedEnabledSubtreeField, + } satisfies Record, + aws_cognito: { + enabled: unsupportedEnabledSubtreeField, + user_pool_id: unsupportedEnabledSubtreeField, + user_pool_region: unsupportedEnabledSubtreeField, + } satisfies Record, + clerk: { + enabled: unsupportedEnabledSubtreeField, + domain: unsupportedEnabledSubtreeField, + } satisfies Record, + workos: { + enabled: unsupportedEnabledSubtreeField, + issuer_url: unsupportedEnabledSubtreeField, + } satisfies Record, + } satisfies Record, +} satisfies Record & LocalStackConfigParitySection; + +const dbSettingsParity = { + effective_cache_size: unsupportedOptionalRuntimeField, + logical_decoding_work_mem: unsupportedOptionalRuntimeField, + maintenance_work_mem: unsupportedOptionalRuntimeField, + max_connections: unsupportedOptionalRuntimeField, + max_locks_per_transaction: unsupportedOptionalRuntimeField, + max_parallel_maintenance_workers: unsupportedOptionalRuntimeField, + max_parallel_workers: unsupportedOptionalRuntimeField, + max_parallel_workers_per_gather: unsupportedOptionalRuntimeField, + max_replication_slots: unsupportedOptionalRuntimeField, + max_slot_wal_keep_size: unsupportedOptionalRuntimeField, + max_standby_archive_delay: unsupportedOptionalRuntimeField, + max_standby_streaming_delay: unsupportedOptionalRuntimeField, + max_wal_size: unsupportedOptionalRuntimeField, + max_wal_senders: unsupportedOptionalRuntimeField, + max_worker_processes: unsupportedOptionalRuntimeField, + session_replication_role: unsupportedOptionalRuntimeField, + shared_buffers: unsupportedOptionalRuntimeField, + statement_timeout: unsupportedOptionalRuntimeField, + track_activity_query_size: unsupportedOptionalRuntimeField, + track_commit_timestamp: unsupportedOptionalRuntimeField, + wal_keep_size: unsupportedOptionalRuntimeField, + wal_sender_timeout: unsupportedOptionalRuntimeField, + work_mem: unsupportedOptionalRuntimeField, +} satisfies Record, Node>; + +/** + * Executable inventory for the current next local-stack implementation. + * + * Every fixed project-config object is checked against its schema-derived + * `keyof` type. Adding or removing a field in `@supabase/config` therefore + * requires an explicit parity decision here before the CLI type-check passes. + * Dynamic records use `*` for user-provided keys while their fixed value shape + * is checked exhaustively. Scalar-valued records such as vault and secrets are + * classified at the record field itself. + */ +const localStackConfigParity = { + project_id: projectMetadataField, + analytics: { + enabled: unsupportedNonDefaultRuntimeField, + port: unsupportedNonDefaultRuntimeField, + backend: unsupportedNonDefaultRuntimeField, + vector_port: unsupportedOptionalRuntimeField, + gcp_project_id: unsupportedOptionalRuntimeField, + gcp_project_number: unsupportedOptionalRuntimeField, + gcp_jwt_path: unsupportedOptionalRuntimeField, + } satisfies Record, + api: { + enabled: unsupportedNonDefaultRuntimeField, + port: unsupportedNonDefaultRuntimeField, + schemas: unsupportedNonDefaultRuntimeField, + extra_search_path: unsupportedNonDefaultRuntimeField, + max_rows: unsupportedNonDefaultRuntimeField, + auto_expose_new_tables: mappedAutoExposeNewTables, + tls: { + enabled: unsupportedEnabledSubtreeField, + cert_path: unsupportedEnabledSubtreeField, + key_path: unsupportedEnabledSubtreeField, + } satisfies Record, + external_url: unsupportedOptionalRuntimeField, + } satisfies Record, + auth: authParity, + db: { + port: unsupportedNonDefaultRuntimeField, + shadow_port: commandOnlyDatabaseField, + health_timeout: unsupportedRuntimeField, + major_version: unsupportedNonDefaultRuntimeField, + pooler: { + enabled: unsupportedRuntimeField, + port: unsupportedRuntimeField, + pool_mode: unsupportedRuntimeField, + default_pool_size: unsupportedRuntimeField, + max_client_conn: unsupportedRuntimeField, + } satisfies Record, + migrations: { + enabled: unsupportedRuntimeField, + schema_paths: unsupportedRuntimeField, + } satisfies Record, + seed: { + enabled: unsupportedRuntimeField, + sql_paths: unsupportedRuntimeField, + } satisfies Record, + settings: dbSettingsParity, + network_restrictions: { + enabled: commandOnlyDatabaseField, + allowed_cidrs: commandOnlyDatabaseField, + allowed_cidrs_v6: commandOnlyDatabaseField, + } satisfies Record, + ssl_enforcement: { + enabled: unsupportedRuntimeField, + } satisfies Record, Node>, + vault: unsupportedSecretRuntimeField, + } satisfies Record, + edge_runtime: { + enabled: mappedFunctionsDevEdgeRuntime, + policy: mappedFunctionsDevEdgeRuntime, + inspector_port: mappedFunctionsDevEdgeRuntime, + deno_version: unsupportedNonDefaultRuntimeField, + secrets: mappedFunctionsDevEdgeRuntime, + } satisfies Record, + functions: { + "*": { + decision: mappedFunctionManifest, + children: functionConfigParity, + }, + }, + local_smtp: { + enabled: unsupportedNonDefaultRuntimeField, + port: unsupportedNonDefaultRuntimeField, + smtp_port: unsupportedOptionalRuntimeField, + pop3_port: unsupportedOptionalRuntimeField, + admin_email: unsupportedOptionalRuntimeField, + sender_name: unsupportedOptionalRuntimeField, + } satisfies Record, + realtime: { + enabled: unsupportedNonDefaultRuntimeField, + ip_version: unsupportedNonDefaultRuntimeField, + max_header_length: unsupportedNonDefaultRuntimeField, + } satisfies Record, + storage: { + enabled: unsupportedNonDefaultRuntimeField, + file_size_limit: unsupportedNonDefaultRuntimeField, + image_transformation: { + enabled: unsupportedRuntimeField, + } satisfies Record, Node>, + buckets: { + "*": { + decision: unsupportedRuntimeField, + children: { + public: unsupportedRuntimeField, + file_size_limit: unsupportedRuntimeField, + allowed_mime_types: unsupportedRuntimeField, + objects_path: unsupportedRuntimeField, + } satisfies Record[string], Node>, + }, + }, + s3_protocol: { + enabled: unsupportedNonDefaultRuntimeField, + } satisfies Record, + analytics: { + enabled: unsupportedEnabledSubtreeField, + max_namespaces: unsupportedEnabledSubtreeField, + max_tables: unsupportedEnabledSubtreeField, + max_catalogs: unsupportedEnabledSubtreeField, + buckets: { + "*": { + decision: unsupportedEnabledSubtreeField, + children: {} satisfies Record< + keyof ProjectConfig["storage"]["analytics"]["buckets"][string], + Node + >, + }, + }, + } satisfies Record, + vector: { + enabled: unsupportedEnabledSubtreeField, + max_buckets: hostedConfigurationField, + max_indexes: hostedConfigurationField, + buckets: { + "*": { + decision: unsupportedEnabledSubtreeField, + children: {} satisfies Record< + keyof ProjectConfig["storage"]["vector"]["buckets"][string], + Node + >, + }, + }, + } satisfies Record, + } satisfies Record, + studio: { + enabled: unsupportedNonDefaultRuntimeField, + port: unsupportedNonDefaultRuntimeField, + api_url: unsupportedNonDefaultRuntimeField, + openai_api_key: unsupportedSecretRuntimeField, + } satisfies Record, + experimental: { + orioledb_version: unsupportedFutureRuntimeField, + s3_host: unsupportedFutureRuntimeField, + s3_region: unsupportedFutureRuntimeField, + s3_access_key: unsupportedFutureRuntimeField, + s3_secret_key: unsupportedFutureRuntimeField, + webhooks: { + enabled: unsupportedFutureRuntimeField, + } satisfies Record, Node>, + pgdelta: { + enabled: commandOnlyDatabaseField, + declarative_schema_path: commandOnlyDatabaseField, + format_options: commandOnlyDatabaseField, + } satisfies Record, Node>, + inspect: { + rules: commandOnlyDatabaseField, + } satisfies Record, Node>, + } satisfies Record, + remotes: remoteOverlayField, +} satisfies Record; + +export interface LocalStackConfigParityEntry { + readonly path: string; + readonly decision: LocalStackConfigParityDecision; +} + +function isDecision(node: Node): node is LocalStackConfigParityDecision { + return "_tag" in node; +} + +function isBranch(node: Node): node is LocalStackConfigParityBranch { + return "decision" in node && "children" in node; +} + +/** Flattens the nested, compile-checked ledger for diagnostics and tests. */ +export function flattenLocalStackConfigParity( + section: LocalStackConfigParitySection = localStackConfigParity, + prefix = "", +): ReadonlyArray { + return Object.entries(section).flatMap(([field, node]) => { + const path = prefix === "" ? field : `${prefix}.${field}`; + if (isDecision(node)) { + return [{ path, decision: node }]; + } + if (isBranch(node)) { + return [ + { path, decision: node.decision }, + ...flattenLocalStackConfigParity(node.children, path), + ]; + } + return flattenLocalStackConfigParity(node, path); + }); +} diff --git a/apps/cli/src/next/config/local-stack-config-parity.unit.test.ts b/apps/cli/src/next/config/local-stack-config-parity.unit.test.ts new file mode 100644 index 0000000000..e3ba1a31dc --- /dev/null +++ b/apps/cli/src/next/config/local-stack-config-parity.unit.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { flattenLocalStackConfigParity } from "./local-stack-config-parity.ts"; + +describe("localStackConfigParity", () => { + const entries = flattenLocalStackConfigParity(); + + it("classifies every fixed project-config leaf exactly once", () => { + const paths = entries.map(({ path }) => path); + + expect(paths).toHaveLength(378); + expect(new Set(paths).size).toBe(paths.length); + expect( + Object.fromEntries( + ["mapped", "not-applicable", "unsupported-blocking", "unsupported-warning"].map((tag) => [ + tag, + entries.filter(({ decision }) => decision._tag === tag).length, + ]), + ), + ).toEqual({ + mapped: 11, + "not-applicable": 13, + "unsupported-blocking": 348, + "unsupported-warning": 6, + }); + }); + + it("claims only behavior implemented by a current next local-runtime flow as mapped", () => { + expect( + entries + .filter(({ decision }) => decision._tag === "mapped") + .map(({ path }) => path) + .sort(), + ).toEqual([ + "api.auto_expose_new_tables", + "edge_runtime.enabled", + "edge_runtime.inspector_port", + "edge_runtime.policy", + "edge_runtime.secrets", + "functions.*", + "functions.*.enabled", + "functions.*.entrypoint", + "functions.*.import_map", + "functions.*.static_files", + "functions.*.verify_jwt", + ]); + + expect( + entries.flatMap(({ path, decision }) => + decision._tag === "mapped" ? [`${decision.mappedBy}:${path}`] : [], + ), + ).toEqual([ + "start:api.auto_expose_new_tables", + "functions-dev:edge_runtime.enabled", + "functions-dev:edge_runtime.policy", + "functions-dev:edge_runtime.inspector_port", + "functions-dev:edge_runtime.secrets", + "stack-functions-runtime:functions.*", + "stack-functions-runtime:functions.*.enabled", + "stack-functions-runtime:functions.*.verify_jwt", + "stack-functions-runtime:functions.*.import_map", + "stack-functions-runtime:functions.*.entrypoint", + "stack-functions-runtime:functions.*.static_files", + ]); + }); + + it("preserves presence requirements for presence-sensitive sections", () => { + const byPath = new Map(entries.map(({ path, decision }) => [path, decision])); + + expect(byPath.get("api.auto_expose_new_tables")?.presence).toBe("raw-document"); + expect(byPath.get("auth.external.github.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.external.github.client_id")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.hook.send_email.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.hook.send_email.uri")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.sms.twilio.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.oauth_server.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.third_party.firebase.project_id")?.presence).toBe("enabled-subtree"); + expect(byPath.get("api.tls.cert_path")?.presence).toBe("enabled-subtree"); + expect(byPath.get("storage.analytics.max_tables")?.presence).toBe("enabled-subtree"); + expect(byPath.get("edge_runtime.deno_version")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.jwt_expiry")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.enable_signup")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.email.enable_signup")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.email.enable_confirmations")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.email.smtp.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.email.smtp.host")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.email.smtp.pass")?.presence).toBe("effective-secret"); + expect(byPath.get("auth.web3.solana.enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.mfa.totp.enroll_enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.mfa.phone.otp_length")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.mfa.max_enrolled_factors")?.presence).toBe("non-default-value"); + expect(byPath.get("db.major_version")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.external_url")?.presence).toBe("raw-document"); + expect(byPath.get("auth.passkey.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.webauthn.rp_id")?.presence).toBe("raw-document"); + expect(byPath.get("db.port")?.presence).toBe("non-default-value"); + expect(byPath.get("storage.s3_protocol.enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("studio.enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("studio.port")?.presence).toBe("non-default-value"); + expect(byPath.get("studio.api_url")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.jwt_secret")?.presence).toBe("effective-global-secret"); + expect(byPath.get("auth.external.*")?.presence).toBe("enabled-subtree"); + expect(byPath.get("storage.image_transformation.enabled")?.presence).toBe("raw-document"); + expect(byPath.get("storage.buckets.*")?.presence).toBe("raw-document"); + expect(byPath.get("storage.analytics.buckets.*")?.presence).toBe("enabled-subtree"); + expect(byPath.get("storage.vector.buckets.*")?.presence).toBe("enabled-subtree"); + expect(byPath.get("experimental.webhooks.enabled")?.presence).toBe("raw-document"); + expect(byPath.get("auth.external.apple.secret")?.presence).toBe("effective-secret"); + expect(byPath.get("studio.openai_api_key")?.presence).toBe("effective-secret"); + }); + + it("keeps non-runtime project configuration out of StackConfig", () => { + expect( + entries + .filter(({ decision }) => decision._tag === "not-applicable") + .map(({ path }) => path) + .sort(), + ).toEqual([ + "auth.rate_limit.email_sent", + "db.network_restrictions.allowed_cidrs", + "db.network_restrictions.allowed_cidrs_v6", + "db.network_restrictions.enabled", + "db.shadow_port", + "experimental.inspect.rules", + "experimental.pgdelta.declarative_schema_path", + "experimental.pgdelta.enabled", + "experimental.pgdelta.format_options", + "project_id", + "remotes", + "storage.vector.max_buckets", + "storage.vector.max_indexes", + ]); + }); +}); diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 7a959e37bc..390e6fbf13 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -745,8 +745,9 @@ describe("Stack", () => { return Effect.gen(function* () { const coordinator = yield* StackLifecycleCoordinator; yield* coordinator.start(); - const stoppedState = yield* coordinator.allStateChanges().pipe( - Stream.filter((state) => state.name === "auth" && state.status === "Stopped"), + const stateChanges = yield* coordinator.stateChanges("auth"); + const stoppedState = yield* stateChanges.pipe( + Stream.filter((state) => state.status === "Stopped"), Stream.runHead, Effect.forkChild({ startImmediately: true }), );