From f3529dfa9d6ccb9525e9de067309108d07bc7a96 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:37:59 +0000 Subject: [PATCH 1/6] chore(stack): bump storage to v1.81.4-r0 (#7132) This bumps storage from v1.81.3-r0 to v1.81.4-r0. https://github.com/supabase/slim-services/releases/tag/storage-v1.81.4-r0 Planned after supabase/slim-services published v1.81.4-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 22517b7130..6dd6765091 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.3 AS realtime -FROM supabase/storage-api:v1.81.3 AS storage +FROM supabase/storage-api:v1.81.4 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 22517b7130..6dd6765091 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.3 AS realtime -FROM supabase/storage-api:v1.81.3 AS storage +FROM supabase/storage-api:v1.81.4 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 82f29353ac..32775ea88b 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -267,23 +267,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.81.3", + upstreamVersion: "v1.81.4", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.81.3-r0@sha256:37e8de163d36f7f32d6a76a4cbf5ea65f5864423107a7de2c6900d5e0683b911", - upstreamImage: "supabase/storage-api:v1.81.3", + "ghcr.io/supabase/cli/storage:v1.81.4-r0@sha256:6c89420505b7c7ba7cd7837607757509ce45fdec5310a5e1f22ee0da689c6e0b", + upstreamImage: "supabase/storage-api:v1.81.4", natives: { "darwin-arm64": { - archive: "d1cc1b4fcab0db84cfff209c66476c5029d7b1c945b16016e5cc9b6e2636b11c", - manifest: "65efde19f716cb53535104dc8c8af4cdf9ebc125897ac75003e28376ac9f3695", + archive: "5036c1cbb0e95d6861321ab8d3daf0f3cbf784220b9524084a829116390d3289", + manifest: "9e1e23082e3129a41e87ba3d8fd4a7e8d4e7f5a4e0a14ddc0264f1ca543c8ec6", }, "linux-amd64": { - archive: "bac99f84f23a092603c89e8f401f9039513d7b74e6c85047d6536cb04ab67096", - manifest: "fc70193ad8a6efd792c98f44aa2022d0fd0879d572616dca5b542387caa93304", + archive: "868d09fd9bb8721cbf3dd2bc77e05e31d88d7c47475a918d6c5e9e0b8b6e2694", + manifest: "880e5b911deaaf511160c18873a8fe91321525512d8b055526dbfc4dada0b683", }, "linux-arm64": { - archive: "6b52c2467e377f979e1a2c9d4da32dad25f6009eac8155082438de99bbef3548", - manifest: "2c5147c6fb0a54fb03c541e6738fee5071d235365fc2acd7ea1faf1a1a24b503", + archive: "b2cc9f0ac7185f49b12f7a634f0a14ba4fed965c4f07b210974967682958cb58", + manifest: "cd94e99ca5b18d690060fba36c9aaf3cd3b55f8fd4d4b38b5f8b998d99da5dc4", }, }, }, From 95e196130afa6cef8d696aa788fe5841b43c9198 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:51:33 +0000 Subject: [PATCH 2/6] chore(stack): bump realtime to v2.143.4-r0 (#7133) This bumps realtime from v2.143.3-r0 to v2.143.4-r0. https://github.com/supabase/slim-services/releases/tag/realtime-v2.143.4-r0 Planned after supabase/slim-services published v2.143.4-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 6dd6765091..5c7491ac52 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -33,7 +33,7 @@ FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.143.3 AS realtime +FROM supabase/realtime:v2.143.4 AS realtime FROM supabase/storage-api:v1.81.4 AS storage diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 6dd6765091..5c7491ac52 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -33,7 +33,7 @@ FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.143.3 AS realtime +FROM supabase/realtime:v2.143.4 AS realtime FROM supabase/storage-api:v1.81.4 AS storage diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 32775ea88b..b7259cd672 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -241,23 +241,23 @@ const definitions: Readonly> = { realtime: definition( "realtime", { - upstreamVersion: "v2.143.3", + upstreamVersion: "v2.143.4", revision: 0, image: - "ghcr.io/supabase/cli/realtime:v2.143.3-r0@sha256:5e08a38846f94cdbcae74901dc90d8187b0528021954aad912492199e2700e6b", - upstreamImage: "supabase/realtime:v2.143.3", + "ghcr.io/supabase/cli/realtime:v2.143.4-r0@sha256:902db0b55daecaf2f0c9fa50f4c73606be267d4f1eb004185be1bc00549ca2e6", + upstreamImage: "supabase/realtime:v2.143.4", natives: { "darwin-arm64": { - archive: "f3bafcaed2f3983c74634d786d5caf20d5353c879233f3bc19bb3c87eff8cdf3", - manifest: "95d6bb98a40847add0d528bf8c727deedf5b78104d6b4d2575eab455695d340e", + archive: "930c58118a6146bf6836935ff54cb9ca49f6cc0dfa2949e9a16c62d572a26a74", + manifest: "4183263200339bd30e241df1fa31235481d4a0d85560d541360a900511c46597", }, "linux-amd64": { - archive: "1391eba6adf34c203436a2ba999daf157b91c61c8d54c94f7c51879b6a5ae6f4", - manifest: "c069ebe0ec16c50c91c76bb9563ad6cfe34adcd68ffff142f86e3858bfa77646", + archive: "dfb1308c03a789228afdf2fd92b533efdccb08eec4c2e5801e70333ab7d78b75", + manifest: "caa2c5defee9dea6464fd3b818559b77a64dfec296418f33b37dc46c56682f58", }, "linux-arm64": { - archive: "e60f215cebdb15ed6e666d56dc7b1ee841ff5660eb7b009833a88412c954c922", - manifest: "60668e00a3331c1627d527cd52f922896062635ac6eb87b47db929cb4a256551", + archive: "0518d38ba40e844f3a1ae07ee1adf20b06f7f5cdcfe085825a4be6ec50eb9c17", + manifest: "dcf4883e6b868240b2129f2ee7b0e8525518769a671881d3a097a6803d94e2b5", }, }, }, From 7428f5ecc563ae354f86a347a49a0ee58a43feb1 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:10:04 +0000 Subject: [PATCH 3/6] chore(stack): bump storage to v1.81.5-r0 (#7134) This bumps storage from v1.81.4-r0 to v1.81.5-r0. https://github.com/supabase/slim-services/releases/tag/storage-v1.81.5-r0 Planned after supabase/slim-services published v1.81.5-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 5c7491ac52..c851417adc 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.4 AS realtime -FROM supabase/storage-api:v1.81.4 AS storage +FROM supabase/storage-api:v1.81.5 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 5c7491ac52..c851417adc 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.4 AS realtime -FROM supabase/storage-api:v1.81.4 AS storage +FROM supabase/storage-api:v1.81.5 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index b7259cd672..bd4ef400e6 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -267,23 +267,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.81.4", + upstreamVersion: "v1.81.5", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.81.4-r0@sha256:6c89420505b7c7ba7cd7837607757509ce45fdec5310a5e1f22ee0da689c6e0b", - upstreamImage: "supabase/storage-api:v1.81.4", + "ghcr.io/supabase/cli/storage:v1.81.5-r0@sha256:0d200e4c0b47f5dfbede9b850429543a5363c0d1798e9c5ca670015766dc99b6", + upstreamImage: "supabase/storage-api:v1.81.5", natives: { "darwin-arm64": { - archive: "5036c1cbb0e95d6861321ab8d3daf0f3cbf784220b9524084a829116390d3289", - manifest: "9e1e23082e3129a41e87ba3d8fd4a7e8d4e7f5a4e0a14ddc0264f1ca543c8ec6", + archive: "d7a7d4ea3743460b2cb117ff4de968ac6e6d66f44cffeaca7f3683412767b6d9", + manifest: "da0629092a687b83b47b9e44351a624a500839442505515b0e04ba7384fa92b3", }, "linux-amd64": { - archive: "868d09fd9bb8721cbf3dd2bc77e05e31d88d7c47475a918d6c5e9e0b8b6e2694", - manifest: "880e5b911deaaf511160c18873a8fe91321525512d8b055526dbfc4dada0b683", + archive: "6cab4b6b93e123c4e8e9d4808d81c68658a1563a93874c64d506f740c8acf3a4", + manifest: "4eb224328de931085948c0c769b5c95cf52e9e1292418672f49199e967623c50", }, "linux-arm64": { - archive: "b2cc9f0ac7185f49b12f7a634f0a14ba4fed965c4f07b210974967682958cb58", - manifest: "cd94e99ca5b18d690060fba36c9aaf3cd3b55f8fd4d4b38b5f8b998d99da5dc4", + archive: "8b69c856197413f2ebba455f7ad711568be9d9600ede90240eb6fc9ce8e47c6e", + manifest: "d6650aaf8c78fab72187171e996444b6e58c0a47f24e02dd17549dcdf0262482", }, }, }, From 9af6852163a091448c49580570a2c424f250b550 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:35:03 +0000 Subject: [PATCH 4/6] chore(cli): bring the remaining commands under the effect skill (CLI-2693) (#7122) ## TL;DR moves the remaining commands that predate the effect skill onto it, with no change in behaviour ## whats introduced?
commands covered `start` `stop` `status` `services` `init` `link` `unlink` `bootstrap` `db` `migration` `inspect` `seed` `test` & `stack`
- error recovery narrows to `catchTag`, `catchTags` and `catchReason` instead of broad catches and `_tag` checks - casts and non null assertions give way to `Predicate` narrowing, exhaustive `Match` and schema decoding - mutable state read by finalizers moves into `Ref`, and the shadow port retry goes through `Schedule` - the pg-delta services use `Effect.fn` under their existing span names, adding `PgAdmin.diff` and two `stack start` spans - tests run on `it.effect` and wait on `Deferred` instead of polling, staying on `it.live` only where a real clock, process, server or backoff is involved ## notes - no user facing behaviour change.. - output, exit codes, errors and telemetry match develop, and the new spans only appear in trace output and the `SUPABASE_DEBUG=1` span console - the shadow port probe now also closes its server when interrupted instead of leaking it ## ref - closes: CLI-2693 - extends: https://github.com/supabase/cli/pull/7092 --- .../src/commands/bootstrap/SIDE_EFFECTS.md | 2 +- .../commands/bootstrap/bootstrap.handler.ts | 91 +- .../bootstrap/bootstrap.integration.test.ts | 6 +- .../bootstrap/bootstrap.layers.unit.test.ts | 9 +- .../src/commands/bootstrap/bootstrap.retry.ts | 15 +- .../bootstrap/bootstrap.retry.unit.test.ts | 9 +- .../bootstrap/bootstrap.suggest.unit.test.ts | 10 +- .../bootstrap.templates.integration.test.ts | 12 +- .../commands/bootstrap/bootstrap.templates.ts | 183 ++-- .../commands/db/advisors/advisors.format.ts | 36 +- .../db/advisors/advisors.format.unit.test.ts | 17 +- .../db/advisors/advisors.integration.test.ts | 179 ++-- .../db/diff/diff.declarative.e2e.test.ts | 2 +- .../cli/src/commands/db/diff/diff.explicit.ts | 4 +- apps/cli/src/commands/db/diff/diff.handler.ts | 23 +- .../commands/db/diff/diff.integration.test.ts | 148 +-- apps/cli/src/commands/db/diff/pgadmin-diff.ts | 140 +-- apps/cli/src/commands/db/dump/dump.handler.ts | 20 +- .../commands/db/dump/dump.integration.test.ts | 131 +-- apps/cli/src/commands/db/lint/lint.format.ts | 30 +- .../commands/db/lint/lint.integration.test.ts | 104 +- .../commands/db/lint/lint.layers.unit.test.ts | 4 +- .../commands/db/pull/pull.integration.test.ts | 109 +- .../db/pull/pull.sync.integration.test.ts | 24 +- apps/cli/src/commands/db/push/push.handler.ts | 12 +- .../commands/db/push/push.integration.test.ts | 277 ++--- .../cli/src/commands/db/query/query.format.ts | 33 +- .../src/commands/db/query/query.handler.ts | 37 +- .../db/query/query.integration.test.ts | 102 +- .../src/commands/db/reset/reset.handler.ts | 26 +- .../db/reset/reset.integration.test.ts | 396 +++---- .../schema/declarative/declarative.errors.ts | 2 +- .../declarative/declarative.former-default.ts | 4 +- .../declarative/declarative.gate.unit.test.ts | 22 +- ...eclarative.orchestrate.integration.test.ts | 26 +- .../declarative/generate/generate.handler.ts | 29 +- .../generate/generate.integration.test.ts | 111 +- .../schema/declarative/sync/sync.handler.ts | 139 ++- .../declarative/sync/sync.integration.test.ts | 105 +- .../src/commands/db/shared/debug-bundle.ts | 2 +- .../src/commands/db/shared/drop-schemas.ts | 26 +- ...delta-declarative-shadow-prep.unit.test.ts | 24 +- ...elta-engine.next.layer.integration.test.ts | 6 +- .../db/shared/pgdelta-engine.next.layer.ts | 326 +++--- .../src/commands/db/shared/pgdelta-files.ts | 24 +- .../db/shared/pgdelta-migrations.write.ts | 210 ++-- .../db/shared/pgdelta-next-adapter.layer.ts | 23 +- .../shared/pgdelta-next-adapter.unit.test.ts | 28 +- .../db/shared/pgdelta-next-shadow.layer.ts | 42 +- .../db/shared/pgdelta-next-shadow.plan.ts | 53 +- .../pgdelta-next-shadow.plan.unit.test.ts | 6 +- .../shared/pgdelta.seam.integration.test.ts | 42 +- .../commands/db/shared/pgdelta.seam.layer.ts | 28 +- .../src/commands/db/shared/pgdelta.write.ts | 56 +- .../src/commands/db/shared/shadow-source.ts | 206 ++-- .../db/shared/shadow-source.unit.test.ts | 50 +- .../db/start/start.integration.test.ts | 152 ++- .../commands/db/test/test.integration.test.ts | 31 +- .../stack/destroy/destroy.integration.test.ts | 20 +- ...oy.runtime-unavailable.integration.test.ts | 2 +- .../stack/list/list.integration.test.ts | 6 +- .../stack/logs/logs.integration.test.ts | 30 +- .../stack/prepare/prepare.integration.test.ts | 58 +- .../stack/restart/restart.integration.test.ts | 4 +- ...tack-command-telemetry.integration.test.ts | 12 +- ...ack-config-environment.integration.test.ts | 14 +- .../stack/stack-config.integration.test.ts | 26 +- .../stack-forwarding.integration.test.ts | 10 +- .../stack/stack.shared.integration.test.ts | 26 +- .../start-export-pointer.integration.test.ts | 4 +- .../experimental/stack/start/start.handler.ts | 77 +- .../stack/start/start.integration.test.ts | 438 ++++---- .../start/start.native.integration.test.ts | 22 +- .../stack/status/status.integration.test.ts | 76 +- .../stack/stop/stop.integration.test.ts | 6 +- .../commands/init/init.integration.test.ts | 74 +- .../db/inspect-deprecated.integration.test.ts | 2 +- .../db/inspect-query.integration.test.ts | 114 +-- .../db/inspect-specs.integration.test.ts | 2 +- .../inspect/inspect.layers.unit.test.ts | 2 +- .../commands/inspect/report/report.config.ts | 70 +- .../inspect/report/report.config.unit.test.ts | 2 +- .../commands/inspect/report/report.csvq.ts | 111 +- .../inspect/report/report.csvq.unit.test.ts | 5 +- .../inspect/report/report.e2e.test.ts | 2 +- .../inspect/report/report.integration.test.ts | 128 ++- .../commands/inspect/report/report.queries.ts | 5 +- .../inspect/report/report.rules.unit.test.ts | 5 +- apps/cli/src/commands/link/link.handler.ts | 10 +- .../commands/link/link.integration.test.ts | 209 ++-- .../commands/migration/down/down.handler.ts | 4 +- .../migration/down/down.integration.test.ts | 108 +- .../migration/fetch/fetch.integration.test.ts | 137 +-- .../commands/migration/list/list.format.ts | 22 +- .../migration/list/list.integration.test.ts | 67 +- .../migration/migration.integration.test.ts | 2 +- .../migration/new/new.integration.test.ts | 78 +- .../repair/repair.integration.test.ts | 214 ++-- .../commands/migration/squash/squash.diff.ts | 2 +- .../migration/squash/squash.diff.unit.test.ts | 2 +- .../migration/squash/squash.handler.ts | 23 +- .../squash/squash.integration.test.ts | 294 +++--- .../src/commands/migration/up/up.handler.ts | 64 +- .../migration/up/up.integration.test.ts | 100 +- .../seed/buckets/buckets.flags.unit.test.ts | 63 +- .../commands/seed/buckets/buckets.handler.ts | 10 +- .../seed/buckets/buckets.integration.test.ts | 490 ++++----- .../src/commands/services/services.handler.ts | 25 +- .../services/services.integration.test.ts | 46 +- .../services/services.layers.unit.test.ts | 2 +- .../edge-runtime.service.integration.test.ts | 48 +- .../services/gotrue.service.unit.test.ts | 12 +- .../services/studio.service.unit.test.ts | 5 +- .../commands/start/services/vector.service.ts | 14 +- .../services/vector.service.unit.test.ts | 23 +- apps/cli/src/commands/start/start.gates.ts | 5 +- apps/cli/src/commands/start/start.handler.ts | 138 ++- .../commands/start/start.integration.test.ts | 969 +++++++++--------- .../start/start.services.unit.test.ts | 27 +- ...art.string-slice-flags.integration.test.ts | 2 +- .../status/status.command.unit.test.ts | 18 +- .../cli/src/commands/status/status.handler.ts | 16 +- .../status/status.integration.test.ts | 427 ++++---- ...tus.string-slice-flags.integration.test.ts | 2 +- .../commands/stop/stop.integration.test.ts | 282 ++--- .../commands/test/new/new.integration.test.ts | 60 +- .../unlink/unlink.integration.test.ts | 20 +- 127 files changed, 4547 insertions(+), 4810 deletions(-) diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 26e46f18c5..cc35f8e1bf 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -156,7 +156,7 @@ suppressed; a single structured result is emitted for the whole command: channel — it always reuses the create-resolved password (`created.dbPassword`). There is no flag-vs-env distinction to preserve once the call is in-process (unlike the former subprocess delegation, CLI-1617, which had to route the resolved password across process boundaries). -- **Retry**: the native push step is wrapped in the same `bootstrapRetryNotify()` + +- **Retry**: the native push step is wrapped in the same `bootstrapRetryNotify` + `Effect.retry(retry)` policy as the api-keys and health-poll steps. A retried attempt re-runs the whole push (connect, list pending migrations/seeds/roles, prompt, apply). - The api-keys, health, and push retries use the same backoff policy: diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 403fb02bd3..3a535824a6 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -1,5 +1,5 @@ -import { Effect, FileSystem, Option, Path, Redacted, Schedule } from "effect"; -import * as HttpClientError from "effect/http/HttpClientError"; +import { Effect, FileSystem, Option, Path, Redacted, Ref, Schedule } from "effect"; +import type * as HttpClientError from "effect/http/HttpClientError"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; @@ -88,10 +88,10 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( // process mutation; every step reads its own `workdir` var, never `process.cwd()`, so nothing // else depends on the chdir staying in effect. const originalCwd = process.cwd(); - let createdRef: string | undefined; + const createdRef = yield* Ref.make(undefined); // Hoisted so the linked-project-cache finalizer writes beside the other `supabase/.temp/` // files instead of `cliSettings.workdir`. - let resolvedWorkdir: string | undefined; + const resolvedWorkdir = yield* Ref.make(undefined); yield* Effect.gen(function* () { // Reads the prefixed `SUPABASE_WORKDIR` only (never plain `WORKDIR`). @@ -108,7 +108,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const workdir = path.isAbsolute(workdirInput) ? workdirInput : path.join(runtimeInfo.cwd, workdirInput); - resolvedWorkdir = workdir; + yield* Ref.set(resolvedWorkdir, workdir); const samples = yield* templateService.listSamples; const allTemplates = [...samples, SCRATCH_TEMPLATE]; @@ -205,12 +205,12 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( emitStructuredResult: false, }); const projectRef = created.ref; - createdRef = projectRef.length > 0 ? projectRef : undefined; + yield* Ref.set(createdRef, projectRef.length > 0 ? projectRef : undefined); yield* Effect.annotateCurrentSpan("project.ref", projectRef); // A fresh notifier per retry block keeps this step's "Retry (n/8)" counter independent of // the health-poll and push retries below. - const apiKeysNotify = bootstrapRetryNotify(); + const apiKeysNotify = yield* bootstrapRetryNotify; const keys = yield* Effect.gen(function* () { if (isText) yield* output.raw("Linking project...\n", "stderr"); return yield* getProjectApiKeys(projectRef); @@ -240,12 +240,20 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( yield* fs.makeDirectory(path.dirname(paths.projectRef), { recursive: true }); yield* fs.writeFileString(paths.projectRef, projectRef); - const healthNotify = bootstrapRetryNotify(); + const healthNotify = yield* bootstrapRetryNotify; yield* Effect.gen(function* () { if (isText) yield* output.raw("Checking project health...\n", "stderr"); - const services = yield* api.v1 - .getServicesHealth({ ref: projectRef, services: ["db"] }) - .pipe(Effect.catch(mapHealthError)); + const services = yield* api.v1.getServicesHealth({ ref: projectRef, services: ["db"] }).pipe( + Effect.catchTags({ + HttpClientError: mapHealthHttpError, + SchemaError: (cause) => + Effect.fail( + new BootstrapHealthError({ message: `Error status 0: ${cause}`, decode: true }), + ), + HttpBodyError: healthTransportError, + SupabaseApiInputError: healthTransportError, + }), + ); for (const service of services) { if (!service.healthy) { return yield* new BootstrapHealthError({ @@ -265,8 +273,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const dbConfig = deriveDbConfig(projectRef, created.dbPassword, cliSettings.projectHost); const supabaseUrl = `https://${projectRef}.${cliSettings.projectHost}`; const envFilePath = path.join(workdir, ".env"); - let envFileWritten = true; - yield* Effect.gen(function* () { + const envFileWritten = yield* Effect.gen(function* () { const examplePath = path.join(workdir, ".env.example"); const hasExample = yield* fs.exists(examplePath); let example: Record | undefined; @@ -283,14 +290,11 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const env = buildDotEnv(keys, dbConfig, supabaseUrl, example); yield* fs.writeFileString(envFilePath, marshalDotEnv(env)); }).pipe( + Effect.as(true), Effect.catch((cause) => - Effect.gen(function* () { - envFileWritten = false; - yield* output.raw( - `Failed to create .env file: ${cause instanceof Error ? cause.message : String(cause)}\n`, - "stderr", - ); - }), + output + .raw(`Failed to create .env file: ${cause.message}\n`, "stderr") + .pipe(Effect.as(false)), ), Effect.withSpan("bootstrap.writeDotEnv"), ); @@ -319,7 +323,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( // Passes workdir/projectRef/toml through directly rather than calling the full `dbPush` // command, since its CommandSettings-based resolvers would be stale after this handler's // own chdir above. - const pushNotify = bootstrapRetryNotify(); + const pushNotify = yield* bootstrapRetryNotify; yield* dbPushCore({ workdir, projectRef, @@ -361,41 +365,30 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( }), ), Effect.ensuring( - Effect.suspend(() => - createdRef === undefined - ? Effect.void - : linkedProjectCache.cache(createdRef, resolvedWorkdir), + Effect.flatMap( + Effect.all([Ref.get(createdRef), Ref.get(resolvedWorkdir)]), + ([ref, workdir]) => + ref === undefined ? Effect.void : linkedProjectCache.cache(ref, workdir), ), ), Effect.ensuring(telemetryState.flush), ); }); -// True when `cause` is the generated client's undecodable `SchemaError`, not a transport failure. -function isDecodeFailureCause(cause: unknown): boolean { - if (typeof cause !== "object" || cause === null || !("_tag" in cause)) { - return false; - } - return cause._tag === "SchemaError"; -} +const healthTransportError = (cause: unknown) => + Effect.fail(new BootstrapHealthError({ message: `Error status 0: ${cause}`, transport: true })); // Non-200 branch: `Error status : `. -const mapHealthError = (cause: unknown): Effect.Effect => { - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - const status = cause.response.status; - return cause.response.text.pipe( - Effect.orElseSucceed(() => ""), - Effect.map(sanitizeErrorBody), - Effect.flatMap((body) => - Effect.fail( - new BootstrapHealthError({ message: `Error status ${status}: ${body}`, status }), - ), - ), - ); - } - return Effect.fail( - isDecodeFailureCause(cause) - ? new BootstrapHealthError({ message: `Error status 0: ${cause}`, decode: true }) - : new BootstrapHealthError({ message: `Error status 0: ${cause}`, transport: true }), +const mapHealthHttpError = ( + cause: HttpClientError.HttpClientError, +): Effect.Effect => { + if (cause.response === undefined) return healthTransportError(cause); + const status = cause.response.status; + return cause.response.text.pipe( + Effect.orElseSucceed(() => ""), + Effect.map(sanitizeErrorBody), + Effect.flatMap((body) => + Effect.fail(new BootstrapHealthError({ message: `Error status ${status}: ${body}`, status })), + ), ); }; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index dea9a754ee..c53920506f 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -320,7 +320,7 @@ describe("bootstrap integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "scratch scaffolding fails closed on an invalid SUPABASE_EXPERIMENTAL_STACK before writing config", () => Effect.gen(function* () { @@ -373,7 +373,7 @@ describe("bootstrap integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects an unknown template argument", () => + it.effect("rejects an unknown template argument", () => Effect.gen(function* () { const path = yield* Path.Path; const s = setup(path, { samples: [NEXTJS_TEMPLATE] }); @@ -435,7 +435,7 @@ describe("bootstrap integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("aborts when the user declines to overwrite a non-empty workdir", () => + it.effect("aborts when the user declines to overwrite a non-empty workdir", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts index 27a69c0d85..703485d137 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts @@ -16,6 +16,7 @@ import { import { VALID_TOKEN, isolatedHomeLayer, + mockCommandPlatformApiService, mockCommandSettings, mockCommandCredentialsLayer, mockLinkedProjectCacheLayer, @@ -36,7 +37,6 @@ import { } from "../../command-internal/global-flags.ts"; import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; -import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; import { ProjectRefResolver } from "../../config/project-ref.service.ts"; import { IdentityStitch } from "../../command-internal/identity-stitch.ts"; import { TemplateService } from "./bootstrap.templates.ts"; @@ -65,10 +65,7 @@ function ambientStubs() { // CommandPlatformApiFactory, and ProjectRefResolver as satisfiable in the outer context; the // runtime layer's own provisions take precedence at runtime. const heavyServiceStubs = Layer.mergeAll( - Layer.succeed(CommandPlatformApi, { - v1: new Proxy({}, { get: () => () => Effect.die("not needed for layer-exposure test") }), - executeRaw: () => Effect.die("not needed for layer-exposure test"), - } as unknown as import("@supabase/api/effect").ApiClient), + mockCommandPlatformApiService().layer, Layer.succeed(CommandPlatformApiFactory, { make: Effect.die("platform-api-factory not needed for layer-exposure test"), }), @@ -110,7 +107,7 @@ function ambientStubs() { } describe("bootstrapRuntimeLayer — IdentityStitch exposure", () => { - it.live( + it.effect( "exposes IdentityStitch at top level so withCommandTelemetry can read stitchedDistinctId()", () => { return Effect.gen(function* () { diff --git a/apps/cli/src/commands/bootstrap/bootstrap.retry.ts b/apps/cli/src/commands/bootstrap/bootstrap.retry.ts index 13e65cf74b..a391154304 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.retry.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.retry.ts @@ -1,4 +1,4 @@ -import { Duration, Effect, Random, Schedule } from "effect"; +import { Duration, Effect, Random, Ref, Schedule } from "effect"; import { DebugFlag } from "../../command-internal/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; @@ -31,15 +31,15 @@ export const bootstrapBackoff = Schedule.exponential("3 seconds", 1.5).pipe( * `--debug`) for the first two failures, then to stderr from the third on. Never fires for the * final, exhausted attempt. * - * Returns a fresh wrapper with its own failure counter per call. + * Yields a fresh wrapper with its own failure counter per evaluation. */ -export const bootstrapRetryNotify = () => { - let failureCount = 0; +export const bootstrapRetryNotify = Effect.gen(function* () { + const failures = yield* Ref.make(0); return (operation: Effect.Effect) => operation.pipe( Effect.tapError((error) => Effect.gen(function* () { - failureCount += 1; + const failureCount = yield* Ref.updateAndGet(failures, (count) => count + 1); yield* Effect.annotateCurrentSpan("retry.failure_count", failureCount); // No notify on the final, exhausted attempt. if (failureCount > BOOTSTRAP_MAX_RETRIES) return; @@ -56,12 +56,11 @@ export const bootstrapRetryNotify = () => { }), ), ); -}; +}); function stringifyError(error: unknown): string { if (typeof error === "object" && error !== null && "message" in error) { - const message = (error as { message: unknown }).message; - if (typeof message === "string") return message; + if (typeof error.message === "string") return error.message; } return String(error); } diff --git a/apps/cli/src/commands/bootstrap/bootstrap.retry.unit.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.retry.unit.test.ts index 5a53397450..983cea4eef 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.retry.unit.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.retry.unit.test.ts @@ -63,14 +63,9 @@ const BOOM = new Error("boom"); // Always-failing effect retried 8 times (9 attempts) to exercise the notify routing. const runNotify = (opts: { debug: boolean; error?: unknown }) => { const out = mockOutput({ format: "text" }); - const notify = bootstrapRetryNotify(); - const program = Effect.fail(opts.error ?? BOOM).pipe( - notify, - Effect.retry({ times: 8 }), - Effect.exit, - ); return Effect.gen(function* () { - yield* program; + const notify = yield* bootstrapRetryNotify; + yield* Effect.fail(opts.error ?? BOOM).pipe(notify, Effect.retry({ times: 8 }), Effect.exit); return out; }).pipe(Effect.provide(Layer.mergeAll(out.layer, Layer.succeed(DebugFlag, opts.debug)))); }; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.suggest.unit.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.suggest.unit.test.ts index f8caa6b0ec..2ee0a53124 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.suggest.unit.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.suggest.unit.test.ts @@ -7,7 +7,7 @@ import { suggestAppStart } from "./bootstrap.suggest.ts"; // Colour is identity here so the assertions match the established non-TTY // (uncoloured) output byte-for-byte. describe("suggestAppStart", () => { - it.live("suggests the start command when the workdir is the current directory", () => + it.effect("suggests the start command when the workdir is the current directory", () => Effect.gen(function* () { const path = yield* Path.Path; expect(suggestAppStart(path, "/home/me/app", "/home/me/app", "npm ci && npm run dev")).toBe( @@ -16,7 +16,7 @@ describe("suggestAppStart", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("prefixes a cd line when the workdir is nested", () => + it.effect("prefixes a cd line when the workdir is nested", () => Effect.gen(function* () { const path = yield* Path.Path; expect(suggestAppStart(path, "/home/me", "/home/me/app", "npm ci && npm run dev")).toBe( @@ -25,7 +25,7 @@ describe("suggestAppStart", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("omits the cd line for a '.' relative path", () => + it.effect("omits the cd line for a '.' relative path", () => Effect.gen(function* () { const path = yield* Path.Path; expect(suggestAppStart(path, "/home/me/app", "/home/me/app", "supabase start")).toBe( @@ -34,7 +34,7 @@ describe("suggestAppStart", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("omits the command line when the start command is empty", () => + it.effect("omits the command line when the start command is empty", () => Effect.gen(function* () { const path = yield* Path.Path; expect(suggestAppStart(path, "/home/me", "/home/me/app", "")).toBe( @@ -43,7 +43,7 @@ describe("suggestAppStart", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("applies the colorize callback to each command line", () => + it.effect("applies the colorize callback to each command line", () => Effect.gen(function* () { const path = yield* Path.Path; const aqua = (line: string) => `<${line}>`; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.templates.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.templates.integration.test.ts index cea75451f1..508ec3561e 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.templates.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.templates.integration.test.ts @@ -103,7 +103,7 @@ function setupDownload(listings: Readonly { - it.live("lists samples with a bearer header when settings carry GITHUB_TOKEN", () => { + it.effect("lists samples with a bearer header when settings carry GITHUB_TOKEN", () => { const s = setup({ githubToken: "gh-tok" }); return Effect.gen(function* () { const service = yield* TemplateService; @@ -118,7 +118,7 @@ describe("bootstrap template service", () => { }).pipe(Effect.provide(s.layer)); }); - it.live("lists samples anonymously when no GITHUB_TOKEN is captured", () => { + it.effect("lists samples anonymously when no GITHUB_TOKEN is captured", () => { const s = setup(); return Effect.gen(function* () { const service = yield* TemplateService; @@ -128,7 +128,7 @@ describe("bootstrap template service", () => { }).pipe(Effect.provide(s.layer)); }); - it.live("fails with the unmarshal error when samples.json is not json", () => { + it.effect("fails with the unmarshal error when samples.json is not json", () => { const s = setup({ content: "not json" }); return Effect.gen(function* () { const service = yield* TemplateService; @@ -138,7 +138,7 @@ describe("bootstrap template service", () => { }).pipe(Effect.provide(s.layer)); }); - it.live("downloads a template tree below its root into the target directory", () => { + it.effect("downloads a template tree below its root into the target directory", () => { const s = setupDownload({ "examples/app": [ { type: "file", path: "examples/app/a.txt", download_url: "https://files.test/a.txt" }, @@ -161,7 +161,7 @@ describe("bootstrap template service", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(s.layer, BunServices.layer))); }); - it.live("rejects a listing entry that escapes the target directory", () => { + it.effect("rejects a listing entry that escapes the target directory", () => { const s = setupDownload({ "examples/app": [ { type: "file", path: "../escape.txt", download_url: "https://files.test/x" }, @@ -181,7 +181,7 @@ describe("bootstrap template service", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(s.layer, BunServices.layer))); }); - it.live("rejects a file entry with no download url", () => { + it.effect("rejects a file entry with no download url", () => { const s = setupDownload({ "examples/app": [{ type: "file", path: "examples/app/big.bin", download_url: null }], }); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.templates.ts b/apps/cli/src/commands/bootstrap/bootstrap.templates.ts index e096362184..3abeb97477 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.templates.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.templates.ts @@ -47,9 +47,7 @@ interface GithubContentEntry { function isStarterTemplate(value: unknown): value is StarterTemplate { return ( - typeof value === "object" && - value !== null && - typeof (value as StarterTemplate).name === "string" + typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" ); } @@ -57,13 +55,11 @@ const jsonValue = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); // Preserve an explicit non-200 / parse failure (already a tagged error); wrap any // transport / filesystem cause in the same tagged error so the channel stays narrow. -const mapDownloadError = (cause: unknown): Effect.Effect => +const wrapDownloadCause = (cause: unknown) => Effect.fail( - cause instanceof BootstrapTemplateDownloadError - ? cause - : new BootstrapTemplateDownloadError({ - message: `failed to download template: ${cause}`, - }), + new BootstrapTemplateDownloadError({ + message: `failed to download template: ${cause}`, + }), ); export const templateServiceLayer = Layer.effect( @@ -97,10 +93,7 @@ export const templateServiceLayer = Layer.effect( return request; }; - const listSamples: Effect.Effect< - ReadonlyArray, - BootstrapTemplateListError - > = Effect.gen(function* () { + const listSamples = Effect.gen(function* () { const response = yield* httpClient .execute(contentsRequest(SAMPLES_OWNER, SAMPLES_REPO, "samples.json", "main")) .pipe( @@ -141,8 +134,8 @@ export const templateServiceLayer = Layer.effect( return templates; }).pipe(Effect.withSpan("TemplateService.listSamples")); - const downloadFile = (localPath: string, remoteUrl: string) => - Effect.gen(function* () { + const downloadFile = Effect.fnUntraced( + function* (localPath: string, remoteUrl: string) { const response = yield* httpClient.execute(HttpClientRequest.get(remoteUrl)); if (response.status !== 200) { return yield* new BootstrapTemplateDownloadError({ @@ -152,87 +145,93 @@ export const templateServiceLayer = Layer.effect( const bytes = new Uint8Array(yield* response.arrayBuffer); yield* fs.makeDirectory(path.dirname(localPath), { recursive: true }); yield* fs.writeFile(localPath, bytes); - }).pipe(Effect.catch(mapDownloadError)); - - const download = Effect.fn("TemplateService.download")(function* ( - templateUrl: string, - targetDir: string, - ) { - // e.g. https://github.com/supabase/supabase/tree/master/examples/user-management - const parsed = new URL(templateUrl); - const parts = parsed.pathname.split("/"); - const owner = parts[1] ?? ""; - const repo = parts[2] ?? ""; - const ref = parts[4] ?? ""; - const root = parts.slice(5).join("/"); - - const downloads: Array<{ readonly localPath: string; readonly remoteUrl: string }> = []; - const queue: Array = [root]; - while (queue.length > 0) { - const contentPath = queue.shift() ?? ""; - const response = yield* httpClient.execute(contentsRequest(owner, repo, contentPath, ref)); - if (response.status !== 200) { - const body = sanitizeErrorBody(yield* response.text.pipe(Effect.orElseSucceed(() => ""))); - return yield* new BootstrapTemplateDownloadError({ - message: `failed to download template: status ${response.status}: ${body}`, - }); - } - const payload = yield* response.json; - if (!Array.isArray(payload)) { - return yield* new BootstrapTemplateDownloadError({ - message: `failed to download template: expected a directory listing for ${contentPath}`, - }); - } - const listing = payload as ReadonlyArray; - for (const entry of listing) { - const entryPath = entry.path ?? ""; - if (entry.type === "file") { - // Strip `` on a path-segment boundary so a sibling directory that merely - // shares the prefix (e.g. `examples/app-2` under `root="examples/app"`) is never - // mis-sliced. - const relative = - root === "" - ? entryPath - : entryPath === root - ? "" - : entryPath.startsWith(`${root}/`) - ? entryPath.slice(root.length + 1) - : entryPath; - const localPath = path.join(targetDir, ...relative.split("/").filter(Boolean)); - // Defence-in-depth: reject a malicious `path` (e.g. `../../etc/x`) - // that would escape the target directory. - const resolvedTarget = path.resolve(targetDir); - const resolvedLocal = path.resolve(localPath); - if ( - resolvedLocal !== resolvedTarget && - !resolvedLocal.startsWith(resolvedTarget + path.sep) - ) { - return yield* new BootstrapTemplateDownloadError({ - message: `failed to download template: entry escapes target directory: ${entryPath}`, - }); - } - // GitHub returns a null `download_url` for files over 1 MB and submodules; without - // this guard, the `?? ""` fallback would issue `GET ""` with a confusing error. - if (entry.download_url == null || entry.download_url.length === 0) { - return yield* new BootstrapTemplateDownloadError({ - message: `failed to download template: unsupported entry (no download URL): ${entryPath}`, - }); + }, + Effect.catchTags({ HttpClientError: wrapDownloadCause, PlatformError: wrapDownloadCause }), + ); + + const download = Effect.fn("TemplateService.download")( + function* (templateUrl: string, targetDir: string) { + // e.g. https://github.com/supabase/supabase/tree/master/examples/user-management + const parsed = new URL(templateUrl); + const parts = parsed.pathname.split("/"); + const owner = parts[1] ?? ""; + const repo = parts[2] ?? ""; + const ref = parts[4] ?? ""; + const root = parts.slice(5).join("/"); + + const downloads: Array<{ readonly localPath: string; readonly remoteUrl: string }> = []; + const queue: Array = [root]; + while (queue.length > 0) { + const contentPath = queue.shift() ?? ""; + const response = yield* httpClient.execute( + contentsRequest(owner, repo, contentPath, ref), + ); + if (response.status !== 200) { + const body = sanitizeErrorBody( + yield* response.text.pipe(Effect.orElseSucceed(() => "")), + ); + return yield* new BootstrapTemplateDownloadError({ + message: `failed to download template: status ${response.status}: ${body}`, + }); + } + const payload = yield* response.json; + if (!Array.isArray(payload)) { + return yield* new BootstrapTemplateDownloadError({ + message: `failed to download template: expected a directory listing for ${contentPath}`, + }); + } + const listing = payload as ReadonlyArray; + for (const entry of listing) { + const entryPath = entry.path ?? ""; + if (entry.type === "file") { + // Strip `` on a path-segment boundary so a sibling directory that merely + // shares the prefix (e.g. `examples/app-2` under `root="examples/app"`) is never + // mis-sliced. + const relative = + root === "" + ? entryPath + : entryPath === root + ? "" + : entryPath.startsWith(`${root}/`) + ? entryPath.slice(root.length + 1) + : entryPath; + const localPath = path.join(targetDir, ...relative.split("/").filter(Boolean)); + // Defence-in-depth: reject a malicious `path` (e.g. `../../etc/x`) + // that would escape the target directory. + const resolvedTarget = path.resolve(targetDir); + const resolvedLocal = path.resolve(localPath); + if ( + resolvedLocal !== resolvedTarget && + !resolvedLocal.startsWith(resolvedTarget + path.sep) + ) { + return yield* new BootstrapTemplateDownloadError({ + message: `failed to download template: entry escapes target directory: ${entryPath}`, + }); + } + // GitHub returns a null `download_url` for files over 1 MB and submodules; without + // this guard, the `?? ""` fallback would issue `GET ""` with a confusing error. + if (entry.download_url == null || entry.download_url.length === 0) { + return yield* new BootstrapTemplateDownloadError({ + message: `failed to download template: unsupported entry (no download URL): ${entryPath}`, + }); + } + downloads.push({ localPath, remoteUrl: entry.download_url }); + } else if (entry.type === "dir") { + queue.push(entryPath); + } else { + yield* output.raw(`Ignoring ${entry.type}: ${entryPath}\n`, "stderr"); } - downloads.push({ localPath, remoteUrl: entry.download_url }); - } else if (entry.type === "dir") { - queue.push(entryPath); - } else { - yield* output.raw(`Ignoring ${entry.type}: ${entryPath}\n`, "stderr"); } } - } - yield* Effect.annotateCurrentSpan("file.count", downloads.length); - yield* Effect.forEach(downloads, (job) => downloadFile(job.localPath, job.remoteUrl), { - concurrency: DOWNLOAD_CONCURRENCY, - }); - }, Effect.catch(mapDownloadError)); + yield* Effect.annotateCurrentSpan("file.count", downloads.length); + yield* Effect.forEach(downloads, (job) => downloadFile(job.localPath, job.remoteUrl), { + concurrency: DOWNLOAD_CONCURRENCY, + }); + }, + Effect.catchTag("HttpClientError", wrapDownloadCause), + ); - return { listSamples, download }; + return TemplateService.of({ listSamples, download }); }), ); diff --git a/apps/cli/src/commands/db/advisors/advisors.format.ts b/apps/cli/src/commands/db/advisors/advisors.format.ts index e67f380dbf..96c0b3fb58 100644 --- a/apps/cli/src/commands/db/advisors/advisors.format.ts +++ b/apps/cli/src/commands/db/advisors/advisors.format.ts @@ -6,6 +6,8 @@ * byte-for-byte. The only `omitempty` field is `metadata`. */ +import { Predicate } from "effect"; + import { encodeGoJsonIndented } from "../../../command-internal/go-json.ts"; import { makeLevelEnum } from "../../../command-internal/fail-on.ts"; @@ -132,16 +134,15 @@ export function scanAdvisorLintRow(row: Record): AdvisorLint { */ function projectApiMetadata(value: unknown): Record | undefined { if (value === undefined || value === null) return undefined; - if (typeof value !== "object" || Array.isArray(value)) { + if (!Predicate.isObject(value)) { throw new TypeError("cannot unmarshal advisor metadata"); } - const record = value as Record; const out: Record = {}; // Each string subfield decodes absent/null to omitted and a present // non-string throws. Add in the established output-contract order: entity, // fkey_columns, fkey_name, name, schema, type. const optString = (key: string) => { - const field = record[key]; + const field = value[key]; if (field === undefined || field === null) return; if (typeof field !== "string") { throw new TypeError(`cannot unmarshal advisor metadata.${key} into string`); @@ -150,7 +151,7 @@ function projectApiMetadata(value: unknown): Record | undefined }; optString("entity"); - const fkeyColumns = record["fkey_columns"]; + const fkeyColumns = value["fkey_columns"]; if (fkeyColumns !== undefined && fkeyColumns !== null) { if (!Array.isArray(fkeyColumns)) { throw new TypeError("cannot unmarshal advisor metadata.fkey_columns into []float32"); @@ -190,10 +191,10 @@ function projectApiMetadata(value: unknown): Record | undefined */ export function apiResponseToAdvisorLints(parsed: unknown): ReadonlyArray { if (parsed === null) return []; - if (typeof parsed !== "object" || Array.isArray(parsed)) { + if (!Predicate.isObject(parsed)) { throw new TypeError("cannot unmarshal advisors response"); } - const lintsRaw = (parsed as { lints?: unknown }).lints; + const lintsRaw = parsed["lints"]; if (lintsRaw === undefined || lintsRaw === null) return []; if (!Array.isArray(lintsRaw)) { throw new TypeError("cannot unmarshal lints into []Lint"); @@ -216,22 +217,21 @@ export function apiResponseToAdvisorLints(parsed: unknown): ReadonlyArray; - const metadata = projectApiMetadata(record["metadata"]); + const metadata = projectApiMetadata(entry["metadata"]); lints.push({ - name: requireApiString(record["name"], "name"), - title: requireApiString(record["title"], "title"), - level: requireApiString(record["level"], "level"), - facing: requireApiString(record["facing"], "facing"), - categories: requireApiStringArray(record["categories"]), - description: requireApiString(record["description"], "description"), - detail: requireApiString(record["detail"], "detail"), - remediation: requireApiString(record["remediation"], "remediation"), + name: requireApiString(entry["name"], "name"), + title: requireApiString(entry["title"], "title"), + level: requireApiString(entry["level"], "level"), + facing: requireApiString(entry["facing"], "facing"), + categories: requireApiStringArray(entry["categories"]), + description: requireApiString(entry["description"], "description"), + detail: requireApiString(entry["detail"], "detail"), + remediation: requireApiString(entry["remediation"], "remediation"), ...(metadata !== undefined ? { metadata } : {}), - cacheKey: requireApiString(record["cache_key"], "cache_key"), + cacheKey: requireApiString(entry["cache_key"], "cache_key"), }); } return lints; diff --git a/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts b/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts index 50719871fe..181bf7619d 100644 --- a/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { Predicate } from "effect"; +import { assert, describe, expect, it } from "vitest"; import { apiResponseToAdvisorLints, @@ -129,11 +130,9 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { expect(lints[0]?.name).toBe("rls_disabled_in_public"); expect(lints[0]?.level).toBe("ERROR"); expect(lints[0]?.categories).toEqual(["SECURITY"]); - expect(Object.keys(lints[0]?.metadata as Record)).toEqual([ - "entity", - "schema", - "type", - ]); + const metadata = lints[0]?.metadata; + assert(Predicate.isObject(metadata)); + expect(Object.keys(metadata)).toEqual(["entity", "schema", "type"]); }); it("accepts an unknown advisor name (closed-enum divergence guard)", () => { @@ -245,16 +244,14 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { const result = apiResponseToAdvisorLints({ lints: [{ name: "x", metadata: { fkey_columns: [null, 2] } }], }); - const meta = result[0]?.metadata as Record; - expect(meta?.["fkey_columns"]).toEqual([0, 2]); + expect(result[0]?.metadata).toMatchObject({ fkey_columns: [0, 2] }); }); it("normalizes a fully-null fkey_columns array to all zeros", () => { const result = apiResponseToAdvisorLints({ lints: [{ name: "x", metadata: { fkey_columns: [null, null] } }], }); - const meta = result[0]?.metadata as Record; - expect(meta?.["fkey_columns"]).toEqual([0, 0]); + expect(result[0]?.metadata).toMatchObject({ fkey_columns: [0, 0] }); }); it("still throws on non-number, non-null fkey_columns elements (Go UnmarshalTypeError)", () => { diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 407ece3ec3..44ac73dafc 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -334,7 +334,7 @@ const flags = (over: Partial = {}): DbAdvisorsFlags => ({ }); describe("db advisors — local", () => { - it.live("queries the local database and prints the Go pretty JSON array", () => { + it.effect("queries the local database and prints the Go pretty JSON array", () => { const { layer, out, connection } = setup({ rows: [lintRow()] }); return Effect.gen(function* () { yield* dbAdvisors(flags()); @@ -345,7 +345,7 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("prints 'No issues found' to stderr and nothing to stdout when empty", () => { + it.effect("prints 'No issues found' to stderr and nothing to stdout when empty", () => { const { layer, out } = setup({ rows: [] }); return Effect.gen(function* () { yield* dbAdvisors(flags()); @@ -354,7 +354,7 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("filters by --type security locally", () => { + it.effect("filters by --type security locally", () => { const { layer, out } = setup({ rows: [ lintRow({ name: "sec", categories: ["SECURITY"] }), @@ -368,7 +368,7 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with 'failed to prepare lint session' on a setup error", () => { + it.effect("fails with 'failed to prepare lint session' on a setup error", () => { const { layer } = setup({ setupFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags())); @@ -380,7 +380,7 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with 'failed to query lints' on a query error", () => { + it.effect("fails with 'failed to query lints' on a query error", () => { const { layer } = setup({ queryFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags())); @@ -392,46 +392,30 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("exits non-zero when --fail-on error and an error-level lint exists", () => { + it.effect("exits non-zero when --fail-on error and an error-level lint exists", () => { const { layer } = setup({ rows: [lintRow({ level: "ERROR" })] }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbAdvisors(flags({ failOn: Option.some("error"), level: Option.some("info") })), ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(DbAdvisorsFailOnError); - expect((failure.value as DbAdvisorsFailOnError).message).toBe( - "fail-on is set to error, non-zero exit", - ); - } - } + expect(error).toBeInstanceOf(DbAdvisorsFailOnError); + expect(error.message).toBe("fail-on is set to error, non-zero exit"); }).pipe(Effect.provide(layer)); }); - it.live("echoes the raw --fail-on value in the message (warn, not warning)", () => { + it.effect("echoes the raw --fail-on value in the message (warn, not warning)", () => { // advisors uses the raw flag value, unlike lint which uses the canonical // level name — guard that asymmetry. const { layer } = setup({ rows: [lintRow({ level: "WARN" })] }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbAdvisors(flags({ failOn: Option.some("warn"), level: Option.some("info") })), ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - if (Option.isSome(failure)) { - expect((failure.value as DbAdvisorsFailOnError).message).toBe( - "fail-on is set to warn, non-zero exit", - ); - } - } + expect(error.message).toBe("fail-on is set to warn, non-zero exit"); }).pipe(Effect.provide(layer)); }); - it.live("rejects --db-url together with --local (via args Changed detection)", () => { + it.effect("rejects --db-url together with --local (via args Changed detection)", () => { const { layer } = setup({ args: ["--db-url=postgres://x", "--local"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags({ dbUrl: Option.some("postgres://x") }))); @@ -445,7 +429,7 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a success envelope in json mode and writes nothing raw to stdout", () => { + it.effect("emits a success envelope in json mode and writes nothing raw to stdout", () => { const { layer, out } = setup({ format: "json", rows: [lintRow()] }); return Effect.gen(function* () { yield* dbAdvisors(flags()); @@ -456,7 +440,7 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("sets exit code 1 without failing the effect on fail-on in json mode", () => { + it.effect("sets exit code 1 without failing the effect on fail-on in json mode", () => { const { layer, processControl } = setup({ format: "json", rows: [lintRow({ level: "ERROR" })], @@ -470,7 +454,7 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry on completion", () => { + it.effect("flushes telemetry on completion", () => { const { layer, telemetry } = setup({ rows: [] }); return Effect.gen(function* () { yield* dbAdvisors(flags()); @@ -478,7 +462,7 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("--linked=false routes to the linked branch (Changed, not value)", () => { + it.effect("--linked=false routes to the linked branch (Changed, not value)", () => { // "Changed" fires when the flag appears on the command line regardless of its value. const { layer, projectRef, cache } = setup({ args: ["--linked=false"], @@ -492,7 +476,7 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("--no-linked routes to the linked branch (boolean negation is still Changed)", () => { + it.effect("--no-linked routes to the linked branch (boolean negation is still Changed)", () => { const { layer, projectRef, cache } = setup({ args: ["--no-linked"], securityLints: [], @@ -505,28 +489,34 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - it.live("--local=false --linked fails with mutual-exclusion (sorted set [linked local])", () => { - const { layer } = setup({ args: ["--local=false", "--linked"] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } - }).pipe(Effect.provide(layer)); - }); + it.effect( + "--local=false --linked fails with mutual-exclusion (sorted set [linked local])", + () => { + const { layer } = setup({ args: ["--local=false", "--linked"] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(dbAdvisors(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain( + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); - it.live("--local=false alone routes to the local branch (Changed local, connType=local)", () => { - const { layer, out, cache } = setup({ rows: [], args: ["--local=false"] }); - return Effect.gen(function* () { - yield* dbAdvisors(flags()); - expect(out.stderrText).toContain("Connecting to local database..."); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "--local=false alone routes to the local branch (Changed local, connType=local)", + () => { + const { layer, out, cache } = setup({ rows: [], args: ["--local=false"] }); + return Effect.gen(function* () { + yield* dbAdvisors(flags()); + expect(out.stderrText).toContain("Connecting to local database..."); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); }); describe("db advisors — linked", () => { @@ -547,7 +537,7 @@ describe("db advisors — linked", () => { cache_key: "perf", }; - it.live("fetches both security and performance advisors for --type all", () => { + it.effect("fetches both security and performance advisors for --type all", () => { const { layer, out, api, cache } = setup({ securityLints: [securityLint], performanceLints: [performanceLint], @@ -564,7 +554,7 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "fetches advisors for the project given via --project-ref, overriding the workdir's own ref", () => { // The fake resolver's own fallback (VALID_REF) represents whatever the workdir would @@ -588,7 +578,7 @@ describe("db advisors — linked", () => { }, ); - it.live("rejects --project-ref on the default local target", () => { + it.effect("rejects --project-ref on the default local target", () => { // The guard fires from the flag alone; no explicit --local/--db-url is needed. const FLAG_REF = "flagflagflagflagflag"; const { layer, connection, api, cache } = setup({ rows: [] }); @@ -607,7 +597,7 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "resolves the linked DB config before fetching advisors (Go root PersistentPreRunE)", () => { // Resolved even though the linked lint-gathering path discards the connection. @@ -623,7 +613,7 @@ describe("db advisors — linked", () => { }, ); - it.live("fails on the linked DB-config error before any advisor API call", () => { + it.effect("fails on the linked DB-config error before any advisor API call", () => { // The DB-config resolve fails before the linked lint-gathering path runs, so the advisors // API is never reached — but the ref was already loaded and cached unconditionally on the // error path. @@ -640,7 +630,7 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live("runs the identity stitch on each advisor response (Go identityTransport)", () => { + it.effect("runs the identity stitch on each advisor response (Go identityTransport)", () => { // Every Management API response is wrapped in identity stitching; the raw-HTTP advisor path // must run the same stitch, once per response. const { layer, identityStitch } = setup({ @@ -654,7 +644,7 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live("resolves the linked ref via the non-prompting load (Go LoadProjectRef)", () => { + it.effect("resolves the linked ref via the non-prompting load (Go LoadProjectRef)", () => { // `resolve` opens an interactive project picker on a TTY; `--linked` must avoid it. const { layer, projectRef } = setup({ securityLints: [securityLint], @@ -667,7 +657,7 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live("fetches only the security endpoint for --type security", () => { + it.effect("fetches only the security endpoint for --type security", () => { const { layer, api } = setup({ securityLints: [securityLint], args: ["--linked"] }); return Effect.gen(function* () { yield* dbAdvisors(flags({ type: Option.some("security") })); @@ -677,7 +667,7 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live("fetches only the performance endpoint for --type performance", () => { + it.effect("fetches only the performance endpoint for --type performance", () => { const { layer, api } = setup({ performanceLints: [performanceLint], args: ["--linked"] }); return Effect.gen(function* () { yield* dbAdvisors(flags({ type: Option.some("performance") })); @@ -687,43 +677,36 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with a login suggestion when no access token is available", () => { + it.effect("fails with a login suggestion when no access token is available", () => { const { layer } = setup({ loggedIn: false, args: ["--linked"] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(DbAdvisorsNotLoggedInError); - const error = failure.value as DbAdvisorsNotLoggedInError; - expect(error.message).toContain("Access token not provided"); - expect(error.suggestion).toContain("supabase login"); - } - } + const error = yield* Effect.flip(dbAdvisors(flags())); + expect(error).toBeInstanceOf(DbAdvisorsNotLoggedInError); + expect(error).toMatchObject({ + message: expect.stringContaining("Access token not provided"), + suggestion: expect.stringContaining("supabase login"), + }); }).pipe(Effect.provide(layer)); }); - it.live("fails with the invalid-token message before any API call (Go LoadAccessTokenFS)", () => { - const { layer, api } = setup({ invalidToken: true, args: ["--linked"] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags())); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(DbAdvisorsInvalidTokenError); - const error = failure.value as DbAdvisorsInvalidTokenError; - expect(error.message).toContain("Invalid access token format"); - expect(error.suggestion).toContain("supabase login"); - } - } - // The token gate fails before any advisors request is made. - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "fails with the invalid-token message before any API call (Go LoadAccessTokenFS)", + () => { + const { layer, api } = setup({ invalidToken: true, args: ["--linked"] }); + return Effect.gen(function* () { + const error = yield* Effect.flip(dbAdvisors(flags())); + expect(error).toBeInstanceOf(DbAdvisorsInvalidTokenError); + expect(error).toMatchObject({ + message: expect.stringContaining("Invalid access token format"), + suggestion: expect.stringContaining("supabase login"), + }); + // The token gate fails before any advisors request is made. + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); - it.live("fails on a 200 with a non-JSON content type (Go requires json header)", () => { + it.effect("fails on a 200 with a non-JSON content type (Go requires json header)", () => { // The body is only decoded when Content-Type contains "json"; otherwise this fails as a // status-200 error. const { layer } = setup({ securityNonJson: true, args: ["--linked"] }); @@ -737,7 +720,7 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with the JSON parse error when a JSON advisors body is malformed", () => { + it.effect("fails with the JSON parse error when a JSON advisors body is malformed", () => { const { layer } = setup({ securityJsonBody: "{ not json", args: ["--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); @@ -751,7 +734,7 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when the advisors API returns a non-200 status", () => { + it.effect("fails when the advisors API returns a non-200 status", () => { const { layer } = setup({ securityStatus: 500, args: ["--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); @@ -763,7 +746,7 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a result event in stream-json mode", () => { + it.effect("emits a result event in stream-json mode", () => { const { layer, out } = setup({ format: "stream-json", securityLints: [securityLint], diff --git a/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts index 53e5e91c11..9fbd74df3d 100644 --- a/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts @@ -128,7 +128,7 @@ as $$ select 1; $$;`, f.endsWith("_revoke_public_execute.sql"), ); expect(written, `no migration written; stderr:\n${diff.stderr}`).toBeTruthy(); - const sql = yield* fs.readFileString(path.join(migrationsDir, written as string)); + const sql = yield* fs.readFileString(path.join(migrationsDir, written || "")); // Anchored to the function's own REVOKE statement, up to its terminating `;`, so this // cannot pass on an unrelated PUBLIC mention elsewhere in the file. diff --git a/apps/cli/src/commands/db/diff/diff.explicit.ts b/apps/cli/src/commands/db/diff/diff.explicit.ts index 2849643523..876be4f039 100644 --- a/apps/cli/src/commands/db/diff/diff.explicit.ts +++ b/apps/cli/src/commands/db/diff/diff.explicit.ts @@ -3,15 +3,13 @@ import { isPostgresURL } from "../../../command-internal/pgdelta.ts"; /** The kinds an explicit `--from`/`--to` ref resolves to. */ export type ExplicitRefKind = "local" | "linked" | "migrations" | "url" | "unknown"; -const VALID_TARGETS = new Set(["local", "linked", "migrations"]); - /** * Classifies an explicit `--from`/`--to` ref: `local`/`linked`/`migrations` are * the named targets; anything else must be a `postgres://` / `postgresql://` * URL, otherwise it is unknown. */ export function classifyExplicitRef(ref: string): ExplicitRefKind { - if (VALID_TARGETS.has(ref)) return ref as "local" | "linked" | "migrations"; + if (ref === "local" || ref === "linked" || ref === "migrations") return ref; if (isPostgresURL(ref)) return "url"; return "unknown"; } diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 56b154a9ee..f77c4627cd 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -1,4 +1,4 @@ -import { Clock, Effect, FileSystem, Option, Path } from "effect"; +import { Clock, Effect, FileSystem, Option, Path, Ref } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { @@ -154,7 +154,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // Resolved linked ref, captured so the post-run finalizer caches the project // (GET /v1/projects/{ref}). - let linkedRefForCache: string | undefined; + const linkedRefForCache = yield* Ref.make(Option.none()); yield* Effect.gen(function* () { // The engine flags (`use-migra use-pgadmin use-pg-schema use-pg-delta`) and the target flags @@ -254,7 +254,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { if (preflightConnType === "linked") { const preflightRef = Option.getOrUndefined(preflight.ref ?? Option.none()); if (preflightRef !== undefined) { - linkedRefForCache = preflightRef; + yield* Ref.set(linkedRefForCache, Option.some(preflightRef)); mergedLinkedRef = preflightRef; } } @@ -324,7 +324,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }); const ref2 = Option.getOrUndefined(resolved.ref ?? Option.none()); if (ref2 !== undefined) { - linkedRefForCache = ref2; + yield* Ref.set(linkedRefForCache, Option.some(ref2)); mergedLinkedRef = ref2; cfg = yield* readDbToml(fs, path, cliSettings.workdir, ref2); } @@ -351,7 +351,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // by default, so TLS is used. connectOptions: { isLocal: false, dnsResolver }, } satisfies PgDeltaDatabaseEndpoint; - default: + case "unknown": return yield* new DbDiffUnknownTargetError({ message: unknownTargetMessage(ref) }); } }); @@ -477,7 +477,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); // Cached the moment the ref is known, not after `cfg`/`localInputs` below (both // fallible) resolve, so the project cache is still written even if a later step fails. - linkedRefForCache = linkedRef; + yield* Ref.set(linkedRefForCache, Option.some(linkedRef)); } const cfg = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); if (cfg.appliedRemote !== undefined) { @@ -516,7 +516,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { if (linkedRef === undefined) { linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); } - if (linkedRef !== undefined) linkedRefForCache = linkedRef; + if (linkedRef !== undefined) yield* Ref.set(linkedRefForCache, Option.some(linkedRef)); const targetUrl = toPostgresURL(resolved.conn); const ctx: PgDeltaContext = { // `SUPABASE_PROJECT_ID` env override wins, then config.toml's `project_id`, then the @@ -851,8 +851,13 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { } }).pipe( Effect.ensuring( - Effect.suspend(() => - linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, + Ref.get(linkedRefForCache).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (ref) => linkedProjectCache.cache(ref), + }), + ), ), ), Effect.ensuring(telemetryState.flush), diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 120ca9ac83..e4531ecb64 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -5,6 +5,7 @@ import { Cause, Config, ConfigProvider, + Deferred, Effect, Exit, Fiber, @@ -12,9 +13,11 @@ import { Layer, Option, Path, + Schema, } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { @@ -147,6 +150,11 @@ const alwaysReadyHttpClientLayer = Layer.succeed( ), ); +const WrittenMigrations = Schema.Struct({ + file: Schema.String, + files: Schema.Array(Schema.String), +}); + /** `[db] shadow_port`'s schema default — the port every connect to the shadow itself dials. */ const SHADOW_PORT = 54320; @@ -159,11 +167,13 @@ const SHADOW_PORT = 54320; function fakeShadowDbConnection(opts: { readonly neverConnectableShadow?: boolean } = {}) { const connectedDatabases: Array = []; const execCalls: Array = []; + const shadowConnectRefused = Deferred.makeUnsafe(); const layer = Layer.succeed(DbConnection, { connect: (cfg: PgConnInput) => Effect.gen(function* () { connectedDatabases.push(cfg.database); if (opts.neverConnectableShadow === true && cfg.port === SHADOW_PORT) { + yield* Deferred.succeed(shadowConnectRefused, undefined); return yield* new DbConnectError({ message: "connection refused" }); } const session: DbSession = { @@ -180,7 +190,7 @@ function fakeShadowDbConnection(opts: { readonly neverConnectableShadow?: boolea return session; }), }); - return { layer, connectedDatabases, execCalls }; + return { layer, connectedDatabases, execCalls, shadowConnectRefused }; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -195,6 +205,26 @@ function setup(workdir: string, opts: SetupOpts = {}) { dbNotRunning: opts.dbNotRunning ?? false, dbInspectFailsWith: opts.dbInspectFailsWith, }); + const shadowHealthProbed = Deferred.makeUnsafe(); + const shadowSpawnerLayer = Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.map(ChildProcessSpawner.ChildProcessSpawner, (inner) => + ChildProcessSpawner.make((command) => + inner + .spawn(command) + .pipe( + Effect.tap(() => + ChildProcess.isStandardCommand(command) && + command.args[0] === "container" && + command.args[1] === "inspect" && + command.args[2] === FAKE_SHADOW_CONTAINER_ID + ? Deferred.succeed(shadowHealthProbed, undefined) + : Effect.void, + ), + ), + ), + ), + ).pipe(Layer.provide(shadowSpawner.layer)); // Cache tests need the stateful Docker model since `docker cp` needs real container state. const dockerDaemon = opts.statefulDocker === true ? mockDockerDaemonCliSpawner() : undefined; const shadowDbConnection = fakeShadowDbConnection({ @@ -291,7 +321,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { // Tracks the migra OOM bash fallback's own `runCapture` calls; the native shadow's // PG15+ one-shot setup jobs go through `runStream` instead and are tracked // separately in `shadowSetupJobCalls`. - const dockerCalls: unknown[] = []; + const dockerCalls: Array = []; // The pgAdmin differ's own `runCapture` calls, distinguished from `dockerCalls` // by `image` (both share the same `DockerRun.runCapture` seam). const differCalls: Array = []; @@ -422,7 +452,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { edge, docker, shadowDbConnection.layer, - dockerDaemon?.layer ?? shadowSpawner.layer, + dockerDaemon?.layer ?? shadowSpawnerLayer, mockLocalDockerEngineUnavailableLayer, alwaysReadyHttpClientLayer, resolver, @@ -472,9 +502,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { differRegistryEnvAtCall, shadowSetupJobCalls, shadowSpawned: shadowSpawner.spawned, + shadowHealthProbed, dockerDaemon, shadowConnectedDatabases: shadowDbConnection.connectedDatabases, shadowExecCalls: shadowDbConnection.execCalls, + shadowConnectRefused: shadowDbConnection.shadowConnectRefused, }; } @@ -1368,7 +1400,7 @@ describe("db diff", () => { return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") })); const success = s.out.messages.find((m) => m.type === "success"); - const data = success?.data as { file: string; files: ReadonlyArray }; + const data = yield* Schema.decodeUnknownEffect(WrittenMigrations)(success?.data); expect(data.file).toMatch(/\d{14}_pgadmin_diff\.sql$/); expect(data.files).toEqual([data.file]); const fs = yield* FileSystem.FileSystem; @@ -1432,7 +1464,9 @@ describe("db diff", () => { const files = yield* fs.readDirectory(dir); expect(files).toHaveLength(1); expect(files[0]).toMatch(/^\d{14}_my_diff\.sql$/); - expect(yield* readFileText(path.join(dir, files[0]!))).toBe("create table live_only ();\n"); + expect(yield* readFileText(path.join(dir, files[0] ?? ""))).toBe( + "create table live_only ();\n", + ); }).pipe(Effect.provide(s.layer)); }); @@ -1507,11 +1541,11 @@ describe("db diff", () => { expect(files).toHaveLength(2); expect(files[0]).toBe("19700101000000_my_diff_1.sql"); expect(files[1]).toBe("19700101000001_my_diff_2.sql"); - expect(yield* readFileText(path.join(dir, files[0]!))).toBe( + expect(yield* readFileText(path.join(dir, files[0] ?? ""))).toBe( "alter type mood add value 'ok';\n", ); const success = s.out.messages.find((m) => m.type === "success"); - const data = success?.data as { file: string; files: ReadonlyArray }; + const data = yield* Schema.decodeUnknownEffect(WrittenMigrations)(success?.data); expect(data.files).toHaveLength(2); expect(data.file).toBe(data.files[0]); }).pipe(Effect.provide(s.layer)); @@ -1527,7 +1561,9 @@ describe("db diff", () => { const dirs = yield* fs.readDirectory(migrationsRoot); expect(dirs).toHaveLength(1); expect(dirs[0]).toMatch(/^\d{14}_snapshots$/); - expect(yield* fs.readDirectory(path.join(migrationsRoot, dirs[0]!))).toEqual(["remote.sql"]); + expect(yield* fs.readDirectory(path.join(migrationsRoot, dirs[0] ?? ""))).toEqual([ + "remote.sql", + ]); }).pipe(Effect.provide(s.layer)); }); @@ -1985,7 +2021,7 @@ describe("db diff", () => { return Effect.gen(function* () { yield* dbDiff(flags({ schema: ["public"] })); expect(s.dockerCalls).toHaveLength(1); - expect((s.dockerCalls[0] as { network: unknown }).network).toEqual({ + expect(s.dockerCalls[0]?.network).toEqual({ _tag: "named", name: "my-net", }); @@ -2003,14 +2039,14 @@ describe("db diff", () => { }); return Effect.gen(function* () { yield* dbDiff(flags({ schema: ["public"] })); - const env = (s.dockerCalls[0] as { env: Readonly> }).env; - expect(new URL(env["SOURCE"] ?? "").hostname).toBe("host.docker.internal"); - expect(new URL(env["TARGET"] ?? "").host).toBe("host.docker.internal:54322"); + const env = s.dockerCalls[0]?.env; + expect(new URL(env?.["SOURCE"] ?? "").hostname).toBe("host.docker.internal"); + expect(new URL(env?.["TARGET"] ?? "").host).toBe("host.docker.internal:54322"); }).pipe(Effect.provide(s.layer)); }, ); - it.live( + it.effect( "removes the shadow container on a SIGINT-style interruption during the readiness wait, without waiting for the readiness timeout", () => { // `acquire` covers only `createShadowDatabase`; the readiness wait runs in the @@ -2024,11 +2060,9 @@ describe("db diff", () => { ); // Waits until the shadow's readiness gate has refused a connect at least once, // proving the fiber is suspended in `waitForShadowReady`'s retry loop. - while (s.shadowConnectedDatabases.length === 0) { - yield* Effect.sleep("5 millis"); - } - // `Fiber.interrupt` only resolves once finalizers complete; this would hang for - // up to 30s if `acquire` still covered the readiness wait. + yield* Deferred.await(s.shadowConnectRefused); + // `Fiber.interrupt` only resolves once finalizers complete; this would hang (the readiness + // timeout never elapses under the test clock) if `acquire` still covered the readiness wait. yield* Fiber.interrupt(fiber); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); @@ -2083,7 +2117,7 @@ describe("db diff", () => { const files = yield* fs.readDirectory(dir); expect(files).toHaveLength(1); expect(files[0]).toMatch(/^\d{14}_pgadmin_diff\.sql$/); - expect(yield* readFileText(path.join(dir, files[0]!))).toBe(PGADMIN_DIFF_SQL); + expect(yield* readFileText(path.join(dir, files[0] ?? ""))).toBe(PGADMIN_DIFF_SQL); }).pipe(Effect.provide(s.layer)); }); @@ -2099,7 +2133,7 @@ describe("db diff", () => { const dirs = yield* fs.readDirectory(migrationsRoot); expect(dirs).toHaveLength(1); expect(dirs[0]).toMatch(/^\d{14}_snapshots$/); - expect(yield* fs.readDirectory(path.join(migrationsRoot, dirs[0]!))).toEqual([ + expect(yield* fs.readDirectory(path.join(migrationsRoot, dirs[0] ?? ""))).toEqual([ "remote.sql", ]); }).pipe(Effect.provide(s.layer)); @@ -2149,21 +2183,21 @@ describe("db diff", () => { const projectId = path.basename(tmp.current); yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); expect(s.differCalls).toHaveLength(1); - const call = s.differCalls[0] as DockerRunOpts; - expect(call.image).toBe(dockerfileServiceImage("differ", false)); - expect(call.image).toBe("supabase/pgadmin-schema-diff:cli-0.0.5"); - expect(call.cmd).toEqual(["--json-diff", PGADMIN_SOURCE_URL, PGADMIN_TARGET_URL]); - expect(call.env).toEqual({}); - expect(call.binds).toEqual([]); - expect(call.securityOpt).toEqual([]); - expect(call.workingDir).toEqual(Option.none()); - expect(call.entrypoint).toBeUndefined(); - expect(call.network).toEqual({ _tag: "named", name: `supabase_network_${projectId}` }); - expect(call.labels).toEqual({ + const call = s.differCalls[0]; + expect(call?.image).toBe(dockerfileServiceImage("differ", false)); + expect(call?.image).toBe("supabase/pgadmin-schema-diff:cli-0.0.5"); + expect(call?.cmd).toEqual(["--json-diff", PGADMIN_SOURCE_URL, PGADMIN_TARGET_URL]); + expect(call?.env).toEqual({}); + expect(call?.binds).toEqual([]); + expect(call?.securityOpt).toEqual([]); + expect(call?.workingDir).toEqual(Option.none()); + expect(call?.entrypoint).toBeUndefined(); + expect(call?.network).toEqual({ _tag: "named", name: `supabase_network_${projectId}` }); + expect(call?.labels).toEqual({ "com.supabase.cli.project": projectId, "com.docker.compose.project": projectId, }); - expect(call.extraHosts).toEqual(["host.docker.internal:host-gateway"]); + expect(call?.extraHosts).toEqual(["host.docker.internal:host-gateway"]); // The differ's stderr is never teed to the parent terminal. expect(s.differCaptureOpts[0]).toBeUndefined(); }).pipe(Effect.provide(s.layer)); @@ -2177,8 +2211,8 @@ describe("db diff", () => { }); return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); - const call = s.differCalls[0] as DockerRunOpts; - expect(call.network).toEqual({ _tag: "named", name: "custom-net" }); + const call = s.differCalls[0]; + expect(call?.network).toEqual({ _tag: "named", name: "custom-net" }); }).pipe(Effect.provide(s.layer)); }); @@ -2191,8 +2225,8 @@ describe("db diff", () => { }); return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); - const call = s.differCalls[0] as DockerRunOpts; - expect(call.extraHosts).toEqual([]); + const call = s.differCalls[0]; + expect(call?.extraHosts).toEqual([]); }).pipe(Effect.provide(s.layer)); }, ); @@ -2208,9 +2242,9 @@ describe("db diff", () => { }); return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); - const call = s.differCalls[0] as DockerRunOpts; - expect(call.cmd.at(-1)).toBe(PGADMIN_TARGET_URL); - expect(call.cmd.join(" ")).not.toContain("distinctive-pw"); + const call = s.differCalls[0]; + expect(call?.cmd.at(-1)).toBe(PGADMIN_TARGET_URL); + expect(call?.cmd.join(" ")).not.toContain("distinctive-pw"); }).pipe(Effect.provide(s.layer)); }, ); @@ -2286,14 +2320,14 @@ describe("db diff", () => { return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true), schema: ["public", "app"] })); expect(s.differCalls).toHaveLength(2); - expect((s.differCalls[0] as DockerRunOpts).cmd).toEqual([ + expect(s.differCalls[0]?.cmd).toEqual([ "--schema", "public", "--json-diff", PGADMIN_SOURCE_URL, PGADMIN_TARGET_URL, ]); - expect((s.differCalls[1] as DockerRunOpts).cmd).toEqual([ + expect(s.differCalls[1]?.cmd).toEqual([ "--schema", "app", "--json-diff", @@ -2345,9 +2379,7 @@ describe("db diff", () => { _tag: "DbDiffPgAdminError", reason: "invalid_output", }); - expect((error as { message: string }).message).toContain( - "failed to parse schema diff output:", - ); + expect(error.message).toContain("failed to parse schema diff output:"); }).pipe(Effect.provide(s.layer)); }); @@ -2404,7 +2436,7 @@ describe("db diff", () => { message: "error running container: exit 1", }); // The differ's own stderr never reaches the error message. - expect((error as { message: string }).message).not.toContain("some differ crash text"); + expect(error.message).not.toContain("some differ crash text"); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }, @@ -2445,9 +2477,7 @@ describe("db diff", () => { return Effect.gen(function* () { const error = yield* dbDiff(flags({ usePgAdmin: Option.some(true) })).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DbDiffDbNotRunningError" }); - expect(stripAnsi((error as { message: string }).message)).toBe( - "supabase start is not running.", - ); + expect(stripAnsi(error.message)).toBe("supabase start is not running."); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); expect(s.differCalls).toEqual([]); expect(s.resolverCalls.length).toBeGreaterThan(0); @@ -2464,8 +2494,11 @@ describe("db diff", () => { }); return Effect.gen(function* () { const error = yield* dbDiff(flags({ usePgAdmin: Option.some(true) })).pipe(Effect.flip); - expect(error).toMatchObject({ _tag: "DbDiffDbNotRunningError", daemonDown: true }); - expect((error as { suggestion?: string }).suggestion).toContain("Docker Desktop"); + expect(error).toMatchObject({ + _tag: "DbDiffDbNotRunningError", + daemonDown: true, + suggestion: expect.stringContaining("Docker Desktop"), + }); }).pipe(Effect.provide(s.layer)); }, ); @@ -2504,7 +2537,7 @@ describe("db diff", () => { const error = yield* dbDiff( flags({ usePgAdmin: Option.some(true), usePgDelta: Option.some(true) }), ).pipe(Effect.flip); - expect((error as { message: string }).message).toBe( + expect(error.message).toBe( "if any flags in the group [use-migra use-pgadmin use-pg-schema use-pg-delta] are set none of the others can be; [use-pg-delta use-pgadmin] were all set", ); }).pipe(Effect.provide(s.layer)); @@ -2547,7 +2580,7 @@ describe("db diff", () => { }, ); - it.live( + it.effect( "removes the shadow container on interruption during the health wait for --use-pgadmin too", () => { const s = setup(tmp.current, { neverHealthyShadow: true }); @@ -2559,16 +2592,7 @@ describe("db diff", () => { // Waits for the shadow's own health probe (its 64-hex id); the pgadmin path's // separate `supabase_db_test` probe fires first and would satisfy a looser // check immediately. - while ( - !s.shadowSpawned.some( - (c) => - c.args[0] === "container" && - c.args[1] === "inspect" && - c.args[2] === FAKE_SHADOW_CONTAINER_ID, - ) - ) { - yield* Effect.sleep("5 millis"); - } + yield* Deferred.await(s.shadowHealthProbed); yield* Fiber.interrupt(fiber); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); diff --git a/apps/cli/src/commands/db/diff/pgadmin-diff.ts b/apps/cli/src/commands/db/diff/pgadmin-diff.ts index a1c868777a..128d81a429 100644 --- a/apps/cli/src/commands/db/diff/pgadmin-diff.ts +++ b/apps/cli/src/commands/db/diff/pgadmin-diff.ts @@ -212,10 +212,17 @@ function pgAdminDockerReason( reason: "spawn" | "config" | "inspect" | "pull", daemonDown: boolean, ): "docker_daemon" | "config" | "image_inspect" | "registry_pull" { - if (reason === "spawn" || daemonDown) return "docker_daemon"; - if (reason === "pull") return "registry_pull"; - if (reason === "config") return "config"; - return "image_inspect"; + if (daemonDown) return "docker_daemon"; + switch (reason) { + case "spawn": + return "docker_daemon"; + case "pull": + return "registry_pull"; + case "config": + return "config"; + case "inspect": + return "image_inspect"; + } } export interface DiffSchemaPgAdminParams { @@ -245,70 +252,69 @@ export interface DiffSchemaPgAdminParams { * run's container exits, not live. The image is passed unresolved so the registry * resolver can use the project's merged environment. */ -export const diffSchemaPgAdmin = ( +export const diffSchemaPgAdmin = Effect.fn("PgAdmin.diff")(function* ( params: DiffSchemaPgAdminParams, -): Effect.Effect => - Effect.gen(function* () { - const docker = yield* DockerRun; - const labels = { - [CLI_PROJECT_LABEL]: params.projectId, - [COMPOSE_PROJECT_LABEL]: params.projectId, - }; - const network = { _tag: "named" as const, name: params.networkId }; - const runs: ReadonlyArray = - params.schema.length === 0 ? [undefined] : params.schema; +) { + const docker = yield* DockerRun; + const labels = { + [CLI_PROJECT_LABEL]: params.projectId, + [COMPOSE_PROJECT_LABEL]: params.projectId, + }; + const network = { _tag: "named" as const, name: params.networkId }; + const runs: ReadonlyArray = + params.schema.length === 0 ? [undefined] : params.schema; - const ddls: Array = []; - for (const s of runs) { - if (s !== undefined) yield* params.emitStatus(`Diffing schema: ${s}`); - const cmd = - s === undefined - ? ["--json-diff", params.source, params.target] - : ["--schema", s, "--json-diff", params.source, params.target]; - const result = yield* docker - .runCapture({ - image: DIFFER_IMAGE, - cmd, - env: {}, - binds: [], - workingDir: Option.none(), - securityOpt: [], - extraHosts: params.extraHosts, - network, - labels, - projectEnvValues: params.projectEnvValues, - }) - .pipe( - Effect.mapError( - (cause) => - new DbDiffPgAdminError({ - message: cause.message, - reason: pgAdminDockerReason(cause.reason, cause.daemonDown), - }), - ), - ); - // Emitted before the exit-code check so a failed run's status lines still print. - for (const line of processPgAdminDiffProgress(result.stderr)) { - yield* params.emitStatus(line); - } - if (result.exitCode !== 0) { - // Non-progress stderr lines are dropped silently, even under `--debug`. - return yield* new DbDiffPgAdminError({ - message: `error running container: exit ${result.exitCode}`, - reason: "differ", - }); - } - const stdout = new TextDecoder().decode(result.stdout); - // Parsed per run, not concatenated across runs. - const parsed = parsePgAdminDiffEntries(stdout); - if (Result.isFailure(parsed)) { - return yield* new DbDiffPgAdminError({ - message: parsed.failure.message, - reason: "invalid_output", - }); - } - ddls.push(...parsed.success); + const ddls: Array = []; + for (const s of runs) { + if (s !== undefined) yield* params.emitStatus(`Diffing schema: ${s}`); + const cmd = + s === undefined + ? ["--json-diff", params.source, params.target] + : ["--schema", s, "--json-diff", params.source, params.target]; + const result = yield* docker + .runCapture({ + image: DIFFER_IMAGE, + cmd, + env: {}, + binds: [], + workingDir: Option.none(), + securityOpt: [], + extraHosts: params.extraHosts, + network, + labels, + projectEnvValues: params.projectEnvValues, + }) + .pipe( + Effect.mapError( + (cause) => + new DbDiffPgAdminError({ + message: cause.message, + reason: pgAdminDockerReason(cause.reason, cause.daemonDown), + }), + ), + ); + // Emitted before the exit-code check so a failed run's status lines still print. + for (const line of processPgAdminDiffProgress(result.stderr)) { + yield* params.emitStatus(line); + } + if (result.exitCode !== 0) { + // Non-progress stderr lines are dropped silently, even under `--debug`. + return yield* new DbDiffPgAdminError({ + message: `error running container: exit ${result.exitCode}`, + reason: "differ", + }); } + const stdout = new TextDecoder().decode(result.stdout); + // Parsed per run, not concatenated across runs. + const parsed = parsePgAdminDiffEntries(stdout); + if (Result.isFailure(parsed)) { + return yield* new DbDiffPgAdminError({ + message: parsed.failure.message, + reason: "invalid_output", + }); + } + ddls.push(...parsed.success); + } - return renderPgAdminDiff(ddls); - }); + return renderPgAdminDiff(ddls); +}); diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 017673875d..0b39683db8 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -1,4 +1,4 @@ -import { Config, Effect, FileSystem, Option, Path, Predicate } from "effect"; +import { Config, Effect, FileSystem, Option, Path, Predicate, Ref } from "effect"; import type { Stack } from "@supabase/stack/effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; @@ -50,7 +50,6 @@ import { } from "../../../command-internal/stack-local-database.ts"; import { resolveBundledPostgresRuntime } from "../../../command-internal/bundled-postgres-client.ts"; import { viperEnvStringWithProjectFallback } from "../../../command-internal/viper-env.ts"; -import { DockerRunError } from "../../../command-internal/docker-run.errors.ts"; import { runWithPoolerFallback } from "../shared/pooler-fallback.ts"; import { dumpDataScript, @@ -92,7 +91,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // Captured so the post-run finalizer can cache the project after the command's // own API calls. - let linkedRefForCache: string | undefined; + const linkedRefForCache = yield* Ref.make(undefined); yield* Effect.gen(function* () { const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); @@ -114,7 +113,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // 2. Mutually-exclusive flag groups. "Set" means explicitly set: an // Option is set when `Some`, a boolean when explicitly `true`, a // string-slice when non-empty. - const isSet = (name: string): boolean => { + const isSet = (name: (typeof DUMP_EXCLUSIVE_GROUPS)[number][number]): boolean => { switch (name) { case "db-url": return Option.isSome(flags.dbUrl); @@ -130,8 +129,6 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { return Option.isSome(flags.keepComments); case "schema": return flags.schema.length > 0; - default: - return false; } }; for (const group of DUMP_EXCLUSIVE_GROUPS) { @@ -164,7 +161,8 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // unvalidated raw `--project-ref` is never stored for the cache finalizer. if (connType === "linked") { const refResolver = yield* ProjectRefResolver; - linkedRefForCache = yield* refResolver.loadProjectRef(flags.projectRef); + const ref = yield* refResolver.loadProjectRef(flags.projectRef); + yield* Ref.set(linkedRefForCache, ref); } const { conn, @@ -183,7 +181,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { const linkedRef = Option.getOrUndefined(resolvedRef ?? Option.none()); // Guards a `None` from a non-linked path from clobbering the up-front capture. if (linkedRef !== undefined) { - linkedRefForCache = linkedRef; + yield* Ref.set(linkedRefForCache, linkedRef); } // Read before the dry-run print so an invalid merged config fails rather than @@ -441,7 +439,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { ), Effect.withSpan("db.dump.run"), Effect.catchIf( - (error): error is DockerRunError => + (error) => Predicate.isTagged(error, "DockerRunError") && stackRuntime === "native" && runtimeInfo.platform === "win32", @@ -487,8 +485,8 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // command's own API calls, then flush telemetry. The cache layer no-ops // when the file exists / no token / non-200. Effect.ensuring( - Effect.suspend(() => - linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, + Effect.flatMap(Ref.get(linkedRefForCache), (ref) => + ref !== undefined ? linkedProjectCache.cache(ref) : Effect.void, ), ), Effect.ensuring(telemetryState.flush), diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index 8780b1c307..1fb7b1d89b 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -489,7 +489,7 @@ const failSuggestion = ( describe("db dump integration", () => { const tmp = useTempWorkdir(); - it.live("errors when --use-copy is used without --data-only", () => { + it.effect("errors when --use-copy is used without --data-only", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump(flags({ useCopy: true, local: Option.some(true) })).pipe( @@ -500,7 +500,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "allows --use-copy with an explicit --data-only=false (Go required check is presence)", () => { const { layer } = setup({ isLocal: true, stdout: "SELECT 1;\n" }); @@ -513,7 +513,7 @@ describe("db dump integration", () => { }, ); - it.live("errors when --exclude is used without --data-only", () => { + it.effect("errors when --exclude is used without --data-only", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( @@ -524,7 +524,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects combining --data-only and --role-only", () => { + it.effect("rejects combining --data-only and --role-only", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( @@ -537,7 +537,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects combining --keep-comments and --data-only", () => { + it.effect("rejects combining --keep-comments and --data-only", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( @@ -550,7 +550,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects combining --schema and --role-only", () => { + it.effect("rejects combining --schema and --role-only", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump(flags({ schema: ["public"], roleOnly: Option.some(true) })).pipe( @@ -563,7 +563,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects combining --linked and --local", () => { + it.effect("rejects combining --linked and --local", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( @@ -576,7 +576,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects --linked=false --local as a target conflict (Go flag.Changed)", () => { + it.effect("rejects --linked=false --local as a target conflict (Go flag.Changed)", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( @@ -589,7 +589,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects --data-only=false --role-only as a conflict (Go flag.Changed)", () => { + it.effect("rejects --data-only=false --role-only as a conflict (Go flag.Changed)", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( @@ -602,7 +602,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("treats --local=false as an explicit local target (Go ParseDatabaseConfig)", () => { + it.effect("treats --local=false as an explicit local target (Go ParseDatabaseConfig)", () => { const { layer, resolver } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump(flags({ local: Option.some(false), dryRun: true })); @@ -610,7 +610,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("prints the expanded pg_dump script on --dry-run without running a container", () => { + it.effect("prints the expanded pg_dump script on --dry-run without running a container", () => { const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump(flags({ dryRun: true, local: Option.some(true) })); @@ -621,7 +621,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("prints the post-run Dumped-schema message on --dry-run --file without writing", () => { + it.effect("prints the post-run Dumped-schema message on --dry-run --file without writing", () => { const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -636,7 +636,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("treats an explicit --file '' as stdout on --dry-run (Go: len(path) > 0)", () => { + it.effect("treats an explicit --file '' as stdout on --dry-run (Go: len(path) > 0)", () => { const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump(flags({ dryRun: true, local: Option.some(true), file: Option.some("") })); @@ -646,7 +646,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("validates the merged config before the --dry-run print (Go root PreRun order)", () => { + it.effect("validates the merged config before the --dry-run print (Go root PreRun order)", () => { const { layer, out } = setup({ isLocal: true, workdir: tmp.current }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -667,7 +667,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("dumps schema from the local database to stdout", () => { + it.effect("dumps schema from the local database to stdout", () => { const { layer, out, docker } = setup({ isLocal: true, stdout: "CREATE SCHEMA public;\n" }); return Effect.gen(function* () { yield* dbDump(flags({ local: Option.some(true) })); @@ -685,7 +685,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("dumps only data with column inserts", () => { + it.effect("dumps only data with column inserts", () => { const { layer, out, docker } = setup({ isLocal: true, stdout: "INSERT INTO ...;\n" }); return Effect.gen(function* () { yield* dbDump(flags({ dataOnly: Option.some(true), local: Option.some(true) })); @@ -694,7 +694,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("dumps only data without column inserts when --use-copy is set", () => { + it.effect("dumps only data without column inserts when --use-copy is set", () => { const { layer, docker } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump( @@ -704,7 +704,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("dumps only roles", () => { + it.effect("dumps only roles", () => { const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump(flags({ roleOnly: Option.some(true), local: Option.some(true) })); @@ -713,7 +713,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("limits the dump to selected schemas", () => { + it.effect("limits the dump to selected schemas", () => { const { layer, docker } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump(flags({ schema: ["public", "auth"], local: Option.some(true) })); @@ -721,7 +721,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("joins a multi-schema selection into EXTRA_FLAGS with pipes", () => { + it.effect("joins a multi-schema selection into EXTRA_FLAGS with pipes", () => { // The handler receives an already-split array (CSV-split at the flag level by // `parseSchemaFlags`) and the env builder pipe-joins it. const { layer, docker } = setup({ isLocal: true }); @@ -731,7 +731,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("resolves a relative --file against the workdir", () => { + it.effect("resolves a relative --file against the workdir", () => { const { layer } = setup({ isLocal: true, stdout: "CREATE SCHEMA public;\n", @@ -744,7 +744,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("honors --network-id over host networking", () => { + it.effect("honors --network-id over host networking", () => { const { layer, docker } = setup({ isLocal: true, networkId: "custom_net" }); return Effect.gen(function* () { yield* dbDump(flags({ local: Option.some(true) })); @@ -752,7 +752,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", () => { // A `SUPABASE_NETWORK_ID` sourced only from `supabase/.env` still overrides host @@ -772,7 +772,7 @@ describe("db dump integration", () => { }, ); - it.live("defaults to the linked connection when neither --local nor --db-url is set", () => { + it.effect("defaults to the linked connection when neither --local nor --db-url is set", () => { const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { yield* dbDump(flags({})); @@ -780,7 +780,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("keeps an explicit local db-url on the bundled client path", () => { + it.effect("keeps an explicit local db-url on the bundled client path", () => { const { layer, bundled } = setup({ conn: { host: "127.0.0.1", @@ -808,7 +808,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); }); - it.live("points a Windows tool container at the host for a loopback stack db-url", () => { + it.effect("points a Windows tool container at the host for a loopback stack db-url", () => { const conn = { host: "127.0.0.1", port: 55432, @@ -830,25 +830,28 @@ describe("db dump integration", () => { }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); }); - it.live("points a named-network legacy tool container at the host for a loopback db-url", () => { - const { layer, docker } = setup({ - conn: { ...LOCAL_CONN, port: 55432 }, - isLocal: false, - networkId: "custom_net", - }); - return Effect.gen(function* () { - yield* dbDump( - flags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:55432/postgres") }), - ); - expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "custom_net" }); - expect(docker.lastOpts?.env).toMatchObject({ - PGHOST: "host.docker.internal", - PGPORT: "55432", + it.effect( + "points a named-network legacy tool container at the host for a loopback db-url", + () => { + const { layer, docker } = setup({ + conn: { ...LOCAL_CONN, port: 55432 }, + isLocal: false, + networkId: "custom_net", }); - }).pipe(Effect.provide(layer)); - }); + return Effect.gen(function* () { + yield* dbDump( + flags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:55432/postgres") }), + ); + expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "custom_net" }); + expect(docker.lastOpts?.env).toMatchObject({ + PGHOST: "host.docker.internal", + PGPORT: "55432", + }); + }).pipe(Effect.provide(layer)); + }, + ); - it.live("caches the linked project even when connection resolution fails (Go PostRun)", () => { + it.effect("caches the linked project even when connection resolution fails (Go PostRun)", () => { // The project ref is resolved before the connection is built, and the // linked-project cache is refreshed unconditionally afterward. So an // IPv6/pooler/login-role failure during resolution still refreshes the @@ -866,7 +869,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "caches the flag ref, not the workdir's own config ref, when resolution fails (regression)", () => { // `linkedRefForCache` must check `flags.projectRef` before config.toml's @@ -888,7 +891,7 @@ describe("db dump integration", () => { }, ); - it.live("does not cache when the linked ref is unknown and resolution fails", () => { + it.effect("does not cache when the linked ref is unknown and resolution fails", () => { // No config project_id or .temp/project-ref file, so the up-front pre-capture // itself fails "not linked" before `resolve()` is reached; nothing is cached. const { layer, cache } = setup({ resolveFails: true, linkedFails: true }); @@ -899,7 +902,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("caches the linked project from the resolved ref on a successful dump", () => { + it.effect("caches the linked project from the resolved ref on a successful dump", () => { const { layer, cache } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -912,7 +915,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("dumps the project given via --project-ref without a linked workdir", () => { + it.effect("dumps the project given via --project-ref without a linked workdir", () => { // No fixed `opts.ref`; only the flag can resolve a ref here. const FLAG_REF = "flagflagflagflagflag"; const { layer, cache, resolver } = setup({ @@ -928,7 +931,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; // A distinct fixed ref proves the flag, not the workdir's own ref, wins. const { layer, cache } = setup({ @@ -945,7 +948,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "rejects a malformed --project-ref on the linked path before resolving or caching", () => { // `loadProjectRef` validates before `resolver.resolve()` runs, so a malformed @@ -963,7 +966,7 @@ describe("db dump integration", () => { }, ); - it.live("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with an explicit --local target", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver, cache } = setup({ isLocal: true }); return Effect.gen(function* () { @@ -979,7 +982,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes the dump to --file and reports the absolute path on stderr", () => { + it.effect("writes the dump to --file and reports the absolute path on stderr", () => { const { layer, out } = setup({ isLocal: true, stdout: "CREATE SCHEMA public;\n" }); return Effect.gen(function* () { const path = yield* Path.Path; @@ -993,7 +996,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with exit 1 when the container exits non-zero", () => { + it.effect("fails with exit 1 when the container exits non-zero", () => { const { layer } = setup({ isLocal: true, exitCode: 1, stdout: "partial\n" }); return Effect.gen(function* () { const exit = yield* dbDump(flags({ local: Option.some(true) })).pipe(Effect.exit); @@ -1012,7 +1015,7 @@ describe("db dump integration", () => { const IPV6_STDERR = 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname'; - it.live("linked: retries through the IPv4 pooler on a container IPv6 failure", () => { + it.effect("linked: retries through the IPv4 pooler on a container IPv6 failure", () => { const { layer, out, resolver, docker } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -1034,7 +1037,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("linked: preserves the original dump error when the pooler fallback fails", () => { + it.effect("linked: preserves the original dump error when the pooler fallback fails", () => { const { layer, resolver, docker } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -1050,7 +1053,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("linked: does not retry when the failure is not an IPv6 connectivity error", () => { + it.effect("linked: does not retry when the failure is not an IPv6 connectivity error", () => { const { layer, resolver, docker } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -1066,7 +1069,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("linked: keeps the original error when no pooler fallback is available", () => { + it.effect("linked: keeps the original error when no pooler fallback is available", () => { const { layer, resolver, docker } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -1086,7 +1089,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("linked: attaches the IPv6 suggestion when the pooler retry also fails", () => { + it.effect("linked: attaches the IPv6 suggestion when the pooler retry also fails", () => { const { layer, docker } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -1105,7 +1108,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("linked: no IPv6 suggestion on a non-IPv6 container failure", () => { + it.effect("linked: no IPv6 suggestion on a non-IPv6 container failure", () => { const { layer } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -1119,7 +1122,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("json mode: emits the SQL to stdout with no machine envelope", () => { + it.effect("json mode: emits the SQL to stdout with no machine envelope", () => { const { layer, out } = setup({ format: "json", isLocal: true, stdout: "CREATE SCHEMA x;\n" }); return Effect.gen(function* () { yield* dbDump(flags({ local: Option.some(true) })); @@ -1128,7 +1131,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("stream-json mode: emits the SQL to stdout with no machine envelope", () => { + it.effect("stream-json mode: emits the SQL to stdout with no machine envelope", () => { const { layer, out } = setup({ format: "stream-json", isLocal: true, @@ -1202,7 +1205,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("windows: warns when a piped stdout dump contains non-ASCII text", () => { + it.effect("windows: warns when a piped stdout dump contains non-ASCII text", () => { const { layer, out } = setup({ isLocal: true, stdout: UNICODE_SQL, @@ -1218,7 +1221,7 @@ describe("db dump integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("stays silent when a piped Windows dump writes to --file", () => { + it.effect("stays silent when a piped Windows dump writes to --file", () => { const { layer, out } = setup({ isLocal: true, stdout: UNICODE_SQL, @@ -1247,7 +1250,7 @@ describe("db dump integration", () => { ["when the dump is ASCII-only", { ...PIPED_WIN32, stdout: "select 'plain \x7f';\n" }], ]; for (const [scenario, over] of SILENT) { - it.live(`stays silent ${scenario}`, () => { + it.effect(`stays silent ${scenario}`, () => { const { layer, out } = setup({ isLocal: true, stdout: UNICODE_SQL, ...over }); return Effect.gen(function* () { yield* dbDump(flags({ local: Option.some(true) })); @@ -1259,7 +1262,7 @@ describe("db dump integration", () => { describe("managed stack dump", () => { for (const runtime of ["native", "docker"] as const) { - it.live(`streams schema output through the ${runtime} stack tool`, () => { + it.effect(`streams schema output through the ${runtime} stack tool`, () => { const { layer, out } = setup({ isLocal: true, stdout: "" }); return Effect.gen(function* () { yield* dbDump(flags({ local: Option.some(true) })); diff --git a/apps/cli/src/commands/db/lint/lint.format.ts b/apps/cli/src/commands/db/lint/lint.format.ts index ca714772cc..ff954814bd 100644 --- a/apps/cli/src/commands/db/lint/lint.format.ts +++ b/apps/cli/src/commands/db/lint/lint.format.ts @@ -7,6 +7,8 @@ * `level` / `message` have no `omitempty` and are always present. */ +import { Predicate } from "effect"; + import { encodeGoJsonIndented } from "../../../command-internal/go-json.ts"; import { makeLevelEnum } from "../../../command-internal/fail-on.ts"; @@ -63,34 +65,29 @@ function requireLintString(value: unknown, field: string): string { function normalizeStatement(value: unknown): LintStatement | undefined { // absent/null → omitted; present non-object (string/number/array) → throw. if (value === undefined || value === null) return undefined; - if (typeof value !== "object" || Array.isArray(value)) { + if (!Predicate.isObject(value)) { throw new TypeError("cannot unmarshal lint statement into lint.Statement"); } - const record = value as Record; return { - lineNumber: requireLintString(record["lineNumber"], "statement.lineNumber"), - text: requireLintString(record["text"], "statement.text"), + lineNumber: requireLintString(value["lineNumber"], "statement.lineNumber"), + text: requireLintString(value["text"], "statement.text"), }; } function normalizeQuery(value: unknown): LintQuery | undefined { if (value === undefined || value === null) return undefined; - if (typeof value !== "object" || Array.isArray(value)) { + if (!Predicate.isObject(value)) { throw new TypeError("cannot unmarshal lint query into lint.Query"); } - const record = value as Record; return { - position: requireLintString(record["position"], "query.position"), - text: requireLintString(record["text"], "query.text"), + position: requireLintString(value["position"], "query.position"), + text: requireLintString(value["text"], "query.text"), }; } /** Builds an `Issue` in the established output-contract order, dropping empty `omitempty` fields. */ function normalizeIssue(value: unknown): LintIssue { - const record = (typeof value === "object" && value !== null ? value : {}) as Record< - string, - unknown - >; + const record: { readonly [key: string]: unknown } = Predicate.isObject(value) ? value : {}; const issue: { level: string; message: string; @@ -134,11 +131,10 @@ export function parseLintResult(jsonText: string, functionName: string): LintRes if (parsed === null) { return { function: functionName, issues: [] }; } - if (typeof parsed !== "object" || Array.isArray(parsed)) { + if (!Predicate.isObject(parsed)) { throw new TypeError("cannot unmarshal payload into lint.Result"); } - const record = parsed as Record; - const issuesField = record["issues"]; + const issuesField = parsed["issues"]; let issuesRaw: ReadonlyArray; if (issuesField === undefined || issuesField === null) { issuesRaw = []; @@ -150,13 +146,13 @@ export function parseLintResult(jsonText: string, functionName: string): LintRes // A null entry decodes to a zero-value issue (handled by `normalizeIssue`'s // fallback), not skipped. for (const entry of issuesRaw) { - if (entry !== null && (typeof entry !== "object" || Array.isArray(entry))) { + if (entry !== null && !Predicate.isObject(entry)) { throw new TypeError("cannot unmarshal issue into lint.Issue"); } } // Validates `function`'s type (throwing if non-string) before discarding it for // the override below. - requireLintString(record["function"], "function"); + requireLintString(parsed["function"], "function"); return { function: functionName, issues: issuesRaw.map(normalizeIssue) }; } diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index 6035e79a3c..ba55fd2cd2 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -216,7 +216,7 @@ const flags = (over: Partial = {}): DbLintFlags => ({ }); describe("db lint", () => { - it.live("lints the named schema and prints parsed issues to stdout", () => { + it.effect("lints the named schema and prints parsed issues to stdout", () => { const { layer, out, connection } = setup({ schemas: [], checkRows: { public: [checkRow("f1", [ERROR_ISSUE])] }, @@ -238,7 +238,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("lints every user schema when --schema is omitted", () => { + it.effect("lints every user schema when --schema is omitted", () => { const { layer, out, connection } = setup({ schemas: ["public", "private"], checkRows: { public: [checkRow("f1", [ERROR_ISSUE])], private: [] }, @@ -251,7 +251,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when the plpgsql_check extension cannot be enabled", () => { + it.effect("fails when the plpgsql_check extension cannot be enabled", () => { const { layer } = setup({ schemas: [], enableFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbLint(flags({ schema: ["public"] }))); @@ -263,7 +263,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails on malformed plpgsql_check json", () => { + it.effect("fails on malformed plpgsql_check json", () => { const { layer } = setup({ malformed: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbLint(flags({ schema: ["public"] }))); @@ -275,7 +275,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("surfaces a query failure from plpgsql_check", () => { + it.effect("surfaces a query failure from plpgsql_check", () => { const { layer } = setup({ queryFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbLint(flags({ schema: ["public"] }))); @@ -287,7 +287,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("surfaces a list-schemas failure", () => { + it.effect("surfaces a list-schemas failure", () => { const { layer } = setup({ listFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbLint(flags())); @@ -299,7 +299,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("prints 'No schema errors found' to stderr and nothing to stdout when clean", () => { + it.effect("prints 'No schema errors found' to stderr and nothing to stdout when clean", () => { const { layer, out } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public"] })); @@ -308,7 +308,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits nothing on stdout when all issues are below --level (no clean message)", () => { + it.effect("emits nothing on stdout when all issues are below --level (no clean message)", () => { const { layer, out } = setup({ checkRows: { public: [checkRow("f1", [WARNING_ISSUE])] } }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public"], level: Option.some("error") })); @@ -317,7 +317,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("exits non-zero when --fail-on warning and a warning exists", () => { + it.effect("exits non-zero when --fail-on warning and a warning exists", () => { const { layer, out } = setup({ checkRows: { public: [checkRow("f1", [WARNING_ISSUE])] } }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -329,16 +329,14 @@ describe("db lint", () => { expect(Option.isSome(failure)).toBe(true); if (Option.isSome(failure)) { expect(failure.value).toBeInstanceOf(DbLintFailOnError); - expect((failure.value as DbLintFailOnError).message).toBe( - "fail-on is set to warning, non-zero exit", - ); + expect(failure.value.message).toBe("fail-on is set to warning, non-zero exit"); } } expect(out.stdoutText).toContain("never read variable"); }).pipe(Effect.provide(layer)); }); - it.live("exits non-zero when --fail-on error and an error exists", () => { + it.effect("exits non-zero when --fail-on error and an error exists", () => { const { layer } = setup({ checkRows: { public: [checkRow("f1", [ERROR_ISSUE])] } }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -352,7 +350,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("does not exit non-zero when --fail-on is none", () => { + it.effect("does not exit non-zero when --fail-on is none", () => { const { layer } = setup({ checkRows: { public: [checkRow("f1", [ERROR_ISSUE])] } }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbLint(flags({ schema: ["public"] }))); @@ -360,7 +358,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("does not trigger --fail-on warning when --level error filters the warning out", () => { + it.effect("does not trigger --fail-on warning when --level error filters the warning out", () => { const { layer, out } = setup({ checkRows: { public: [checkRow("f1", [WARNING_ISSUE])] } }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -377,7 +375,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects --db-url together with --linked (via args Changed detection)", () => { + it.effect("rejects --db-url together with --linked (via args Changed detection)", () => { const { layer } = setup({ args: ["--db-url=postgres://x", "--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbLint(flags({ dbUrl: Option.some("postgres://x") }))); @@ -391,7 +389,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a standard success envelope in json mode", () => { + it.effect("emits a standard success envelope in json mode", () => { const { layer, out } = setup({ format: "json", checkRows: { public: [checkRow("f1", [ERROR_ISSUE])] }, @@ -409,7 +407,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits an empty result envelope in json mode when clean", () => { + it.effect("emits an empty result envelope in json mode when clean", () => { const { layer, out } = setup({ format: "json", checkRows: { public: [] } }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public"] })); @@ -419,7 +417,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a result event in stream-json mode", () => { + it.effect("emits a result event in stream-json mode", () => { const { layer, out } = setup({ format: "stream-json", checkRows: { public: [checkRow("f1", [ERROR_ISSUE])] }, @@ -432,7 +430,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("sets exit code 1 without failing the effect on fail-on in json mode", () => { + it.effect("sets exit code 1 without failing the effect on fail-on in json mode", () => { const { layer, processControl } = setup({ format: "json", checkRows: { public: [checkRow("f1", [ERROR_ISSUE])] }, @@ -446,7 +444,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("labels the diagnostic 'remote' for a non-local connection", () => { + it.effect("labels the diagnostic 'remote' for a non-local connection", () => { const { layer, out } = setup({ isLocal: false, checkRows: { public: [] }, @@ -458,7 +456,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("lints multiple pre-parsed schemas from a comma-separated --schema value", () => { + it.effect("lints multiple pre-parsed schemas from a comma-separated --schema value", () => { // CSV parsing happens at `Flag.mapTryCatch` parse time; the handler receives the // already-split list directly. const { layer, connection } = setup({ @@ -470,7 +468,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes the linked-project cache for --linked (Go PersistentPostRun)", () => { + it.effect("writes the linked-project cache for --linked (Go PersistentPostRun)", () => { const { layer, projectRef, cache } = setup({ isLocal: false, checkRows: { public: [] }, @@ -483,7 +481,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("lints the project given via --project-ref, overriding the workdir's own ref", () => { + it.effect("lints the project given via --project-ref, overriding the workdir's own ref", () => { // `VALID_REF` stands in for whatever the workdir would resolve to absent the // flag; the flag must win. const FLAG_REF = "flagflagflagflagflag"; @@ -500,7 +498,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("does not write the linked-project cache for a local run", () => { + it.effect("does not write the linked-project cache for a local run", () => { const { layer, cache } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public"] })); @@ -508,7 +506,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects --project-ref on the default local target", () => { + it.effect("rejects --project-ref on the default local target", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, connection, cache } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { @@ -527,7 +525,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry on success and on failure", () => { + it.effect("flushes telemetry on success and on failure", () => { const success = setup({ checkRows: { public: [] } }); const failure = setup({ enableFails: true }); return Effect.gen(function* () { @@ -538,7 +536,7 @@ describe("db lint", () => { }); }); - it.live("--linked=false routes to the linked branch (Changed, not value)", () => { + it.effect("--linked=false routes to the linked branch (Changed, not value)", () => { const { layer, projectRef, cache } = setup({ isLocal: false, checkRows: { public: [] }, @@ -551,7 +549,7 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("--no-linked routes to the linked branch (boolean negation is still Changed)", () => { + it.effect("--no-linked routes to the linked branch (boolean negation is still Changed)", () => { const { layer, projectRef, cache } = setup({ isLocal: false, checkRows: { public: [] }, @@ -564,26 +562,32 @@ describe("db lint", () => { }).pipe(Effect.provide(layer)); }); - it.live("--local=false --linked fails with mutual-exclusion (sorted set [linked local])", () => { - const { layer } = setup({ args: ["--local=false", "--linked"] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(dbLint(flags({ schema: ["public"] }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } - }).pipe(Effect.provide(layer)); - }); + it.effect( + "--local=false --linked fails with mutual-exclusion (sorted set [linked local])", + () => { + const { layer } = setup({ args: ["--local=false", "--linked"] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(dbLint(flags({ schema: ["public"] }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain( + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); - it.live("--local=false alone routes to the local branch (Changed local, connType=local)", () => { - const { layer, out, cache } = setup({ checkRows: { public: [] }, args: ["--local=false"] }); - return Effect.gen(function* () { - yield* dbLint(flags({ schema: ["public"] })); - expect(out.stderrText).toContain("Connecting to local database..."); - expect(cache.cached).toBe(false); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "--local=false alone routes to the local branch (Changed local, connType=local)", + () => { + const { layer, out, cache } = setup({ checkRows: { public: [] }, args: ["--local=false"] }); + return Effect.gen(function* () { + yield* dbLint(flags({ schema: ["public"] })); + expect(out.stderrText).toContain("Connecting to local database..."); + expect(cache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts b/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts index 6c80704857..271ce17692 100644 --- a/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts +++ b/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts @@ -114,7 +114,7 @@ function ambientStubs() { } describe("dbLintRuntimeLayer — IdentityStitch exposure", () => { - it.live( + it.effect( "exposes IdentityStitch at top level so withCommandTelemetry can read stitchedDistinctId()", () => { return Effect.gen(function* () { @@ -126,7 +126,7 @@ describe("dbLintRuntimeLayer — IdentityStitch exposure", () => { }); describe("dbAdvisorsRuntimeLayer — IdentityStitch exposure (regression guard)", () => { - it.live( + it.effect( "exposes IdentityStitch at top level so withCommandTelemetry can read stitchedDistinctId()", () => { return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index 563b6d5add..c7043c7a2c 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -95,6 +95,23 @@ const pgDeltaDiffEnvelope = ( })), }); +const PulledSchemaFiles = Schema.Struct({ + schemaWritten: Schema.String, + schemaFiles: Schema.Array(Schema.String), +}); + +const PgDeltaPlanEnvelope = Schema.fromJsonString( + Schema.Struct({ + files: Schema.Array( + Schema.Struct({ + name: Schema.String, + sql: Schema.String, + transactionMode: Schema.Literals(["transactional", "none"]), + }), + ), + }), +); + interface SetupOpts { readonly nextDebugDirectory?: string; readonly format?: OutputFormat; @@ -205,42 +222,25 @@ function setup(workdir: string, opts: SetupOpts = {}) { : {}), }); } - try { - const parsed: unknown = JSON.parse(stdout); - if (typeof parsed !== "object" || parsed === null) throw new Error("invalid envelope"); - const rawFiles = Reflect.get(parsed, "files"); - if (!Array.isArray(rawFiles)) throw new Error("invalid envelope"); - const files = rawFiles.map((raw, index) => { - if (typeof raw !== "object" || raw === null) throw new Error("invalid file"); - const sql = Reflect.get(raw, "sql"); - const name = Reflect.get(raw, "name"); - const transactionMode = Reflect.get(raw, "transactionMode"); - if (typeof sql !== "string" || typeof name !== "string") { - throw new Error("invalid file"); - } - if (transactionMode !== "transactional" && transactionMode !== "none") { - throw new Error(`unknown transaction mode ${String(transactionMode)}`); - } - return { - sequence: index + 1, - name, - sql, - transactionMode, - }; - }); - return Effect.succeed({ + return Schema.decodeEffect(PgDeltaPlanEnvelope)(stdout).pipe( + Effect.map(({ files }) => ({ changes: files.length > 0, sql: files.map((file) => file.sql).join("\n"), - files, - }); - } catch (cause) { - return Effect.fail( - new PgDeltaEngineError({ - message: "failed to parse pg-delta diff output", - cause, - }), - ); - } + files: files.map((file, index) => ({ + sequence: index + 1, + name: file.name, + sql: file.sql, + transactionMode: file.transactionMode, + })), + })), + Effect.mapError( + (cause) => + new PgDeltaEngineError({ + message: "failed to parse pg-delta diff output", + cause, + }), + ), + ); }, exportDeclarativeSchema: (input) => { engineCalls.push({ @@ -345,7 +345,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { } historyUpserts.push(params); } - return Effect.succeed([] as ReadonlyArray>); + return Effect.succeed>>([]); }; return { exec, @@ -747,14 +747,12 @@ describe("db pull", () => { return Effect.gen(function* () { yield* dbPull(flags({ diffEngine: Option.some("pg-delta") })); const success = s.out.messages.find((m) => m.type === "success"); - const data = success?.data as - | { schemaWritten?: string; schemaFiles?: Array } - | undefined; - expect(data?.schemaFiles).toHaveLength(3); - expect(data?.schemaFiles?.[0]).toMatch(/_remote_schema_schema_changes\.sql$/u); - expect(data?.schemaFiles?.[1]).toMatch(/_remote_schema_after_enum_values\.sql$/u); - expect(data?.schemaFiles?.[2]).toMatch(/_remote_schema_non_transactional\.sql$/u); - expect(data?.schemaWritten).toBe(data?.schemaFiles?.[0]); + const data = yield* Schema.decodeUnknownEffect(PulledSchemaFiles)(success?.data); + expect(data.schemaFiles).toHaveLength(3); + expect(data.schemaFiles[0]).toMatch(/_remote_schema_schema_changes\.sql$/u); + expect(data.schemaFiles[1]).toMatch(/_remote_schema_after_enum_values\.sql$/u); + expect(data.schemaFiles[2]).toMatch(/_remote_schema_non_transactional\.sql$/u); + expect(data.schemaWritten).toBe(data.schemaFiles[0]); }).pipe(Effect.provide(s.layer)); }, ); @@ -1225,13 +1223,11 @@ describe("db pull", () => { remoteHistoryUpdated: true, engine: "migra", }); - const data = success?.data as - | { schemaWritten?: string; schemaFiles?: Array } - | undefined; - expect(data?.schemaWritten).toMatch(/_remote_schema\.sql$/u); + const data = yield* Schema.decodeUnknownEffect(PulledSchemaFiles)(success?.data); + expect(data.schemaWritten).toMatch(/_remote_schema\.sql$/u); // Single-unit case: exactly one path, matching `schemaWritten`. - expect(data?.schemaFiles).toHaveLength(1); - expect(data?.schemaFiles?.[0]).toBe(data?.schemaWritten); + expect(data.schemaFiles).toHaveLength(1); + expect(data.schemaFiles[0]).toBe(data.schemaWritten); }).pipe(Effect.provide(s.layer)); }); @@ -1268,14 +1264,9 @@ describe("db pull", () => { yes: true, }); return Effect.gen(function* () { - const exit = yield* dbPull(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const error = Exit.isFailure(exit) - ? exit.cause.reasons.find((reason) => reason._tag === "Fail")?.error - : undefined; - const message = (error as { message?: string } | undefined)?.message ?? ""; - expect(message).toContain("Cannot read properties of undefined"); - expect(message).not.toContain("No schema changes found"); + const error = yield* dbPull(flags()).pipe(Effect.flip); + expect(error.message).toContain("Cannot read properties of undefined"); + expect(error.message).not.toContain("No schema changes found"); }).pipe(Effect.provide(s.layer)); }, ); @@ -1516,9 +1507,7 @@ describe("db pull", () => { const writtenPath = path.join(dir, file ?? ""); // Confirms the failure happened after the write, not instead of it. expect(yield* fs.exists(writtenPath)).toBe(true); - expect((error as { writtenSoFar?: ReadonlyArray }).writtenSoFar).toEqual([ - writtenPath, - ]); + expect(error).toMatchObject({ writtenSoFar: [writtenPath] }); expect(s.historyUpserts.length).toBe(0); }).pipe(Effect.provide(s.layer)); }, diff --git a/apps/cli/src/commands/db/pull/pull.sync.integration.test.ts b/apps/cli/src/commands/db/pull/pull.sync.integration.test.ts index 2b6f27b8b2..5a527925cb 100644 --- a/apps/cli/src/commands/db/pull/pull.sync.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.sync.integration.test.ts @@ -27,7 +27,7 @@ function mockSession(opts: { readonly failUpsertAt?: number } = {}) { } calls.push("UPSERT"); } - return Effect.succeed([] as ReadonlyArray>); + return Effect.succeed>>([]); }, extensionExists: () => Effect.die("extensionExists unused"), copyToCsv: () => Effect.die("copyToCsv unused"), @@ -39,13 +39,17 @@ function mockSession(opts: { readonly failUpsertAt?: number } = {}) { const writeMigrations = Effect.fnUntraced(function* (dir: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const migrations: ReadonlyArray = [ - { path: path.join(dir, "20240101000000_a.sql"), version: "20240101000000" }, - { path: path.join(dir, "20240101000001_b.sql"), version: "20240101000001" }, - ]; - yield* fs.writeFileString(migrations[0]!.path, "create table a ();"); - yield* fs.writeFileString(migrations[1]!.path, "create table b ();"); - return migrations; + const first: PulledMigration = { + path: path.join(dir, "20240101000000_a.sql"), + version: "20240101000000", + }; + const second: PulledMigration = { + path: path.join(dir, "20240101000001_b.sql"), + version: "20240101000001", + }; + yield* fs.writeFileString(first.path, "create table a ();"); + yield* fs.writeFileString(second.path, "create table b ();"); + return [first, second]; }); describe("updateMigrationHistory", () => { @@ -53,7 +57,7 @@ describe("updateMigrationHistory", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectory({ prefix: "pull-sync-" }); + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pull-sync-" }); const migrations = yield* writeMigrations(dir); const out = mockOutput(); const { session, calls } = mockSession(); @@ -74,7 +78,7 @@ describe("updateMigrationHistory", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectory({ prefix: "pull-sync-" }); + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pull-sync-" }); const migrations = yield* writeMigrations(dir); const out = mockOutput(); const { session, calls } = mockSession({ failUpsertAt: 2 }); diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index 42e93b72e9..86c6059145 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path, Ref } from "effect"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; @@ -39,7 +39,7 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { // with that project env, as `db pull` does. const projectEnv = yield* loadProjectEnv(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); - let linkedRefForCache: string | undefined; + const linkedRefForCache = yield* Ref.make(undefined); const body = Effect.gen(function* () { const target = resolveDbTargetFlags(cliArgs.args); @@ -71,7 +71,7 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { if (connType === "linked") { const refResolver = yield* ProjectRefResolver; projectRef = yield* refResolver.loadProjectRef(flags.projectRef); - linkedRefForCache = projectRef; + yield* Ref.set(linkedRefForCache, projectRef); } // Single config load (except `--skip-vault`, which omits `[db.vault]` secret @@ -124,10 +124,8 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { yield* body.pipe( Effect.ensuring( - Effect.suspend(() => - linkedRefForCache !== undefined && linkedRefForCache !== "" - ? linkedProjectCache.cache(linkedRefForCache) - : Effect.void, + Effect.flatMap(Ref.get(linkedRefForCache), (ref) => + ref !== undefined && ref !== "" ? linkedProjectCache.cache(ref) : Effect.void, ), ), Effect.ensuring(telemetryState.flush), diff --git a/apps/cli/src/commands/db/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 497c144633..708ec3ad82 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -30,6 +30,7 @@ import { } from "../../../command-internal/db-connection.service.ts"; import { dbPush } from "./push.handler.ts"; import type { DbPushFlags } from "./push.command.ts"; +import { DbPushApplyError } from "./push.errors.ts"; const LIST_MIGRATIONS = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; @@ -268,7 +269,7 @@ const migrationFile = (version: string, body = "create table t ();") => ({ describe("db push", () => { const tmp = useTempWorkdir("supabase-db-push-"); - it.live("reports up to date when nothing is pending (text)", () => { + it.effect("reports up to date when nothing is pending (text)", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); @@ -278,21 +279,24 @@ describe("db push", () => { }); }); - it.live("reports up to date when an 8-digit and 14-digit version share a prefix (#6036)", () => { - const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { ...migrationFile("20260420"), ...migrationFile("20260420010000") }, - remoteMigrations: ["20260420", "20260420010000"], - }); - return Effect.gen(function* () { - const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stdoutText).toBe("Local database is up to date.\n"); - expect(conn.execs).not.toContain("BEGIN"); - }); - }); + it.effect( + "reports up to date when an 8-digit and 14-digit version share a prefix (#6036)", + () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20260420"), ...migrationFile("20260420010000") }, + remoteMigrations: ["20260420", "20260420010000"], + }); + return Effect.gen(function* () { + const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }, + ); - it.live("emits a json result for an up-to-date run", () => { + it.effect("emits a json result for an up-to-date run", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', format: "json" }); return Effect.gen(function* () { yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -302,7 +306,7 @@ describe("db push", () => { }); }); - it.live("rejects mutually exclusive target flags", () => { + it.effect("rejects mutually exclusive target flags", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "push", "--local", "--linked"], @@ -313,7 +317,7 @@ describe("db push", () => { }); }); - it.live("applies a pending migration after confirmation", () => { + it.effect("applies a pending migration after confirmation", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -332,7 +336,7 @@ describe("db push", () => { }); }); - it.live("honors pg-delta's no-transaction migration header", () => { + it.effect("honors pg-delta's no-transaction migration header", () => { const set = "SET check_function_bodies = off"; const action = "DROP SUBSCRIPTION app_events"; const { layer, conn } = setup(tmp.current, { @@ -361,7 +365,7 @@ describe("db push", () => { }); }); - it.live("returns context canceled when the migration prompt is declined", () => { + it.effect("returns context canceled when the migration prompt is declined", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -377,7 +381,7 @@ describe("db push", () => { }); }); - it.live.each(["u", "yess", "nope", " ", "n", "no"])( + it.effect.each(["u", "yess", "nope", " ", "n", "no"])( "applies nothing when the piped answer is %j", (answer) => { const { layer, conn } = setup(tmp.current, { @@ -393,7 +397,7 @@ describe("db push", () => { }, ); - it.live.each(["y\n", "yes\n", "\n", ""])( + it.effect.each(["y\n", "yes\n", "\n", ""])( "applies migrations when the piped answer is %j", (piped) => { const { layer, conn } = setup(tmp.current, { @@ -408,7 +412,7 @@ describe("db push", () => { }, ); - it.live("prints the plan without applying in dry-run mode", () => { + it.effect("prints the plan without applying in dry-run mode", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -422,7 +426,7 @@ describe("db push", () => { }); }); - it.live("skips vault decryption in dry-run mode with --skip-vault", () => { + it.effect("skips vault decryption in dry-run mode with --skip-vault", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:not-valid"\n', files: migrationFile("20240101000000"), @@ -437,7 +441,7 @@ describe("db push", () => { }); }); - it.live( + it.effect( "prints the DRY RUN heads-up line after the connection resolves, not before (Go's push.Run order)", () => { // `simulateInitialisingLoginRole` stands in for the real resolver's stderr line @@ -462,7 +466,7 @@ describe("db push", () => { }, ); - it.live("fails with a repair suggestion when remote has versions missing locally", () => { + it.effect("fails with a repair suggestion when remote has versions missing locally", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', remoteMigrations: ["20240101000000"], @@ -481,7 +485,7 @@ describe("db push", () => { }); }); - it.live("keeps repair suggestions targetless for an explicit local database URL", () => { + it.effect("keeps repair suggestions targetless for an explicit local database URL", () => { const dbUrl = "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -501,7 +505,7 @@ describe("db push", () => { }); }); - it.live("fails with an --include-all suggestion for out-of-order local migrations", () => { + it.effect("fails with an --include-all suggestion for out-of-order local migrations", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', // 0101 is local-only and ordered before the already-applied remote 0202. @@ -517,7 +521,7 @@ describe("db push", () => { }); }); - it.live("pushes out-of-order migrations with --include-all", () => { + it.effect("pushes out-of-order migrations with --include-all", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, @@ -530,7 +534,7 @@ describe("db push", () => { }); }); - it.live("skips migrations when disabled in config and reports up to date", () => { + it.effect("skips migrations when disabled in config and reports up to date", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n', files: migrationFile("20240101000000"), @@ -544,7 +548,7 @@ describe("db push", () => { }); }); - it.live("seeds a new file with --include-seed", () => { + it.effect("seeds a new file with --include-seed", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, @@ -559,7 +563,7 @@ describe("db push", () => { }); }); - it.live("expands a directory in [db.seed].sql_paths to its sorted .sql children", () => { + it.effect("expands a directory in [db.seed].sql_paths to its sorted .sql children", () => { // Directories are walked recursively; without expansion the path would reach // `readFileString()` and fail. const { layer, out } = setup(tmp.current, { @@ -579,7 +583,7 @@ describe("db push", () => { }); }); - it.live("reports seed files up to date when hash matches remote", () => { + it.effect("reports seed files up to date when hash matches remote", () => { // sha256 of the seed body must match the remote hash to be skipped. const body = "insert into t values (1);"; const hash = createHash("sha256").update(body).digest("hex"); @@ -594,7 +598,7 @@ describe("db push", () => { }); }); - it.live("hashes a non-UTF-8 seed file by its raw bytes (Go's io.Copy parity)", () => { + it.effect("hashes a non-UTF-8 seed file by its raw bytes (Go's io.Copy parity)", () => { // Writing invalid UTF-8 and pre-seeding the remote with the raw-byte sha256 proves // the push hashes bytes, not a UTF-8 decode (which would replace invalid bytes and // mark the seed dirty). @@ -614,7 +618,7 @@ describe("db push", () => { }).pipe(Effect.provide(BunServices.layer)); }); - it.live("skips seeding when disabled in config", () => { + it.effect("skips seeding when disabled in config", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', files: { "supabase/seed.sql": "insert into t values (1);" }, @@ -627,7 +631,7 @@ describe("db push", () => { }); }); - it.live("creates custom roles with --include-roles", () => { + it.effect("creates custom roles with --include-roles", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/roles.sql": "create role app;" }, @@ -639,7 +643,7 @@ describe("db push", () => { }); }); - it.live("--include-roles without a roles.sql pushes migrations and skips globals", () => { + it.effect("--include-roles without a roles.sql pushes migrations and skips globals", () => { // An absent roles.sql is silently skipped (no error, no "Seeding globals" line). const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -653,7 +657,7 @@ describe("db push", () => { }); }); - it.live("emits the seeded file paths in the json success payload", () => { + it.effect("emits the seeded file paths in the json success payload", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { @@ -671,7 +675,7 @@ describe("db push", () => { }); }); - it.live("reports schema migrations up to date when only roles are pushed", () => { + it.effect("reports schema migrations up to date when only roles are pushed", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/roles.sql": "create role app;" }, @@ -683,7 +687,7 @@ describe("db push", () => { }); }); - it.live("returns context canceled when the roles prompt is declined", () => { + it.effect("returns context canceled when the roles prompt is declined", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/roles.sql": "create role app;" }, @@ -699,7 +703,7 @@ describe("db push", () => { }); }); - it.live("returns context canceled when the seed prompt is declined", () => { + it.effect("returns context canceled when the seed prompt is declined", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, @@ -715,7 +719,7 @@ describe("db push", () => { }); }); - it.live("re-hashes a dirty seed without re-running its statements", () => { + it.effect("re-hashes a dirty seed without re-running its statements", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, @@ -730,7 +734,7 @@ describe("db push", () => { }); }); - it.live("treats every seed as pending when the seed_files table is absent", () => { + it.effect("treats every seed as pending when the seed_files table is absent", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, @@ -743,7 +747,7 @@ describe("db push", () => { }); }); - it.live("warns and reports up to date when no seed files match", () => { + it.effect("warns and reports up to date when no seed files match", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', }); @@ -754,7 +758,7 @@ describe("db push", () => { }); }); - it.live("reports seed files up to date when migrations push but no seeds match", () => { + it.effect("reports seed files up to date when migrations push but no seeds match", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', files: migrationFile("20240101000000"), @@ -766,7 +770,7 @@ describe("db push", () => { }); }); - it.live("upserts vault secrets (update existing, create new) before migrating", () => { + it.effect("upserts vault secrets (update existing, create new) before migrating", () => { const { layer, out, conn, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', files: migrationFile("20240101000000"), @@ -784,7 +788,7 @@ describe("db push", () => { }); }); - it.live("applies migrations without touching vault when --skip-vault is set", () => { + it.effect("applies migrations without touching vault when --skip-vault is set", () => { const { layer, out, conn, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', files: migrationFile("20240101000000"), @@ -802,7 +806,7 @@ describe("db push", () => { }); }); - it.live("does not decrypt vault secrets skipped by --skip-vault", () => { + it.effect("does not decrypt vault secrets skipped by --skip-vault", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:not-valid"\n', files: migrationFile("20240101000000"), @@ -819,7 +823,7 @@ describe("db push", () => { }); }); - it.live("still validates non-vault secrets with --skip-vault", () => { + it.effect("still validates non-vault secrets with --skip-vault", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db]\nroot_key = "encrypted:not-valid"\n', files: migrationFile("20240101000000"), @@ -837,30 +841,33 @@ describe("db push", () => { }); }); - it.live("decrypts an encrypted vault secret keyed by the project .env (not process.env)", () => { - // `DOTENV_PRIVATE_KEY` present only in the project .env must still decrypt — the - // config load merges the project .env into the key set (`checkDbToml`). - const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; - const ENCRYPTED = - "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; - const { layer, out, conn } = setup(tmp.current, { - toml: `project_id = "test"\n\n[db.vault]\nmy_secret = "${ENCRYPTED}"\n`, - files: { - ...migrationFile("20240101000000"), - "supabase/.env": `DOTENV_PRIVATE_KEY=${PRIVATE_KEY}\n`, - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Updating vault secrets..."); - // The decrypted plaintext ("value") is written, proving the project-.env key was used. - const create = conn.queries.find((q) => q.sql === "SELECT vault.create_secret($1, $2)"); - expect(create?.params).toEqual(["value", "my_secret"]); - }); - }); + it.effect( + "decrypts an encrypted vault secret keyed by the project .env (not process.env)", + () => { + // `DOTENV_PRIVATE_KEY` present only in the project .env must still decrypt — the + // config load merges the project .env into the key set (`checkDbToml`). + const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; + const ENCRYPTED = + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; + const { layer, out, conn } = setup(tmp.current, { + toml: `project_id = "test"\n\n[db.vault]\nmy_secret = "${ENCRYPTED}"\n`, + files: { + ...migrationFile("20240101000000"), + "supabase/.env": `DOTENV_PRIVATE_KEY=${PRIVATE_KEY}\n`, + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating vault secrets..."); + // The decrypted plaintext ("value") is written, proving the project-.env key was used. + const create = conn.queries.find((q) => q.sql === "SELECT vault.create_secret($1, $2)"); + expect(create?.params).toEqual(["value", "my_secret"]); + }); + }, + ); - it.live("defaults to the linked target when no target flag is set", () => { + it.effect("defaults to the linked target when no target flag is set", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "push"], @@ -874,7 +881,7 @@ describe("db push", () => { }); }); - it.live("surfaces an apply error with statement context", () => { + it.effect("surfaces an apply error with statement context", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000", "BOOM;"), @@ -884,46 +891,49 @@ describe("db push", () => { return Effect.gen(function* () { const error = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); // A server error without position/detail keeps the plain error layout. - expect(error._tag).toBe("DbPushApplyError"); + expect(error).toBeInstanceOf(DbPushApplyError); expect(error.message).toBe("ERROR: boom (SQLSTATE 42601)\nAt statement: 0\nBOOM"); }); }); - it.live("renders Go's caret, Detail line, and 42704 extension hint on a failed migration", () => { - // Established failure-rendering format: caret under the error position, Detail - // line, and undefined-object extension hint. - const stat = "CREATE TABLE test (path ltree NOT NULL)"; - const { layer } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000", `${stat};`), - failExec: stat, - failExecWith: { - message: 'ERROR: type "ltree" does not exist (SQLSTATE 42704)', - code: "42704", - detail: "Detail from the server.", - position: 25, - }, - confirm: [true], - }); - return Effect.gen(function* () { - const error = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); - expect(error._tag).toBe("DbPushApplyError"); - expect(error.message).toBe( - 'ERROR: type "ltree" does not exist (SQLSTATE 42704)\n' + - "Detail from the server.\n" + - "\n" + - "Hint: This type may be defined in a schema that's not in your search_path.\n" + - " Use schema-qualified type references to avoid this error:\n" + - " CREATE TABLE example (col extensions.ltree);\n" + - " Learn more: supabase migration new --help\n" + - "At statement: 0\n" + - "CREATE TABLE test (path ltree NOT NULL)\n" + - " ^", - ); - }); - }); + it.effect( + "renders Go's caret, Detail line, and 42704 extension hint on a failed migration", + () => { + // Established failure-rendering format: caret under the error position, Detail + // line, and undefined-object extension hint. + const stat = "CREATE TABLE test (path ltree NOT NULL)"; + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000", `${stat};`), + failExec: stat, + failExecWith: { + message: 'ERROR: type "ltree" does not exist (SQLSTATE 42704)', + code: "42704", + detail: "Detail from the server.", + position: 25, + }, + confirm: [true], + }); + return Effect.gen(function* () { + const error = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toBeInstanceOf(DbPushApplyError); + expect(error.message).toBe( + 'ERROR: type "ltree" does not exist (SQLSTATE 42704)\n' + + "Detail from the server.\n" + + "\n" + + "Hint: This type may be defined in a schema that's not in your search_path.\n" + + " Use schema-qualified type references to avoid this error:\n" + + " CREATE TABLE example (col extensions.ltree);\n" + + " Learn more: supabase migration new --help\n" + + "At statement: 0\n" + + "CREATE TABLE test (path ltree NOT NULL)\n" + + " ^", + ); + }); + }, + ); - it.live("dry-run lists roles, migrations and seeds without applying", () => { + it.effect("dry-run lists roles, migrations and seeds without applying", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { @@ -947,7 +957,7 @@ describe("db push", () => { }); }); - it.live("dry-run with only custom roles lists them without a migration section", () => { + it.effect("dry-run with only custom roles lists them without a migration section", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/roles.sql": "create role app;" }, @@ -961,7 +971,7 @@ describe("db push", () => { }); }); - it.live("uses embedded defaults when no config file is present", () => { + it.effect("uses embedded defaults when no config file is present", () => { const { layer, out } = setup(tmp.current, { files: migrationFile("20240101000000"), confirm: [true], @@ -974,22 +984,25 @@ describe("db push", () => { }); }); - it.live("auto-confirms pending migrations via SUPABASE_YES set only in the project .env", () => { - // The project `.env` is applied before the history prompt reads - // `SUPABASE_YES`, so a `SUPABASE_YES` in supabase/.env auto-confirms without - // any interactive answer. - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { ...migrationFile("20240101000000"), "supabase/.env": "SUPABASE_YES=true\n" }, - // No `confirm` responses; the prompt must be auto-confirmed. - }); - return Effect.gen(function* () { - yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - }); - }); + it.effect( + "auto-confirms pending migrations via SUPABASE_YES set only in the project .env", + () => { + // The project `.env` is applied before the history prompt reads + // `SUPABASE_YES`, so a `SUPABASE_YES` in supabase/.env auto-confirms without + // any interactive answer. + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), "supabase/.env": "SUPABASE_YES=true\n" }, + // No `confirm` responses; the prompt must be auto-confirmed. + }); + return Effect.gen(function* () { + yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }, + ); - it.live("fails when config.toml cannot be parsed", () => { + it.effect("fails when config.toml cannot be parsed", () => { const { layer } = setup(tmp.current, { toml: "this is = = not [[[ valid toml" }); return Effect.gen(function* () { const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); @@ -1002,7 +1015,7 @@ describe("db push", () => { }); }); - it.live("loads a Go-style env() boolean in config (no CliConfigParseError)", () => { + it.effect("loads a Go-style env() boolean in config (no CliConfigParseError)", () => { // env-expansion + boolean parsing must resolve `env(VAR)` so the config loads and // the migration proceeds. const { layer, out } = setup(tmp.current, { @@ -1019,7 +1032,7 @@ describe("db push", () => { ); }); - it.live("a matched remote block's migrations.enabled beats the shell env override", () => { + it.effect("a matched remote block's migrations.enabled beats the shell env override", () => { // A matched [remotes.] block overrides the shell env, so // `[remotes.preview.db.migrations] enabled = false` wins over // `SUPABASE_DB_MIGRATIONS_ENABLED=true`. @@ -1041,7 +1054,7 @@ describe("db push", () => { ); }); - it.live("announces a matching [remotes.*] override on the linked path", () => { + it.effect("announces a matching [remotes.*] override on the linked path", () => { const { layer, out } = setup(tmp.current, { toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${VALID_REF}"\n`, args: ["db", "push", "--linked"], @@ -1054,7 +1067,7 @@ describe("db push", () => { }); }); - it.live("pushes to the linked project and caches the project ref (json)", () => { + it.effect("pushes to the linked project and caches the project ref (json)", () => { const { layer, out, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -1074,7 +1087,7 @@ describe("db push", () => { }); }); - it.live("pushes to the project given via --project-ref without a linked workdir", () => { + it.effect("pushes to the project given via --project-ref without a linked workdir", () => { // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. const { layer, out, linkedCache, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -1101,7 +1114,7 @@ describe("db push", () => { }); }); - it.live("--project-ref drives which [remotes.] block merges into config", () => { + it.effect("--project-ref drives which [remotes.] block merges into config", () => { // `[remotes.staging]`'s `project_id` matches the flag ref, not the resolver's own // `VALID_REF` fallback, so the override only announces if the flag resolved it. const { layer, out } = setup(tmp.current, { @@ -1120,7 +1133,7 @@ describe("db push", () => { }); }); - it.live("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--project-ref overrides an already-linked workdir's project ref", () => { const { layer, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -1143,7 +1156,7 @@ describe("db push", () => { }); }); - it.live("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with an explicit --local target", () => { const { layer, conn, resolver, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), diff --git a/apps/cli/src/commands/db/query/query.format.ts b/apps/cli/src/commands/db/query/query.format.ts index 63a41c7b0a..a72ad01467 100644 --- a/apps/cli/src/commands/db/query/query.format.ts +++ b/apps/cli/src/commands/db/query/query.format.ts @@ -1,4 +1,4 @@ -import { DateTime, Option } from "effect"; +import { DateTime, Option, Predicate } from "effect"; import { goFormatFloat } from "../../../command-internal/go-float.ts"; import { stringWidth } from "../../../command-internal/rune-width.ts"; @@ -31,10 +31,9 @@ function goFormatValue(value: unknown): string { // `Buffer` (`Uint8Array`), which would otherwise fall into the object branch below. if (value instanceof Uint8Array) return `[${Array.from(value).join(" ")}]`; if (Array.isArray(value)) return `[${value.map(goFormatValue).join(" ")}]`; - if (typeof value === "object") { - const obj = value as Record; - const keys = Object.keys(obj).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - return `map[${keys.map((k) => `${k}:${goFormatValue(obj[k])}`).join(" ")}]`; + if (Predicate.isObject(value)) { + const keys = Object.keys(value).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + return `map[${keys.map((k) => `${k}:${goFormatValue(value[k])}`).join(" ")}]`; } return String(value); } @@ -269,12 +268,17 @@ export function renderTablewriter( return width; }); - const segment = (i: number) => "─".repeat(widths[i]! + 2); - const top = `┌${widths.map((_, i) => segment(i)).join("┬")}┐`; - const sep = `├${widths.map((_, i) => segment(i)).join("┼")}┤`; - const bottom = `└${widths.map((_, i) => segment(i)).join("┴")}┘`; + const segment = (width: number) => "─".repeat(width + 2); + const top = `┌${widths.map(segment).join("┬")}┐`; + const sep = `├${widths.map(segment).join("┼")}┤`; + const bottom = `└${widths.map(segment).join("┴")}┘`; const renderLine = (cells: ReadonlyArray) => - `│${cells.map((cell, i) => ` ${cell}${" ".repeat(widths[i]! - displayWidth(cell))} `).join("│")}│`; + `│${cells + .map((cell, i) => { + const width = widths[i]; + return ` ${cell}${width === undefined ? "" : " ".repeat(width - displayWidth(cell))} `; + }) + .join("│")}│`; // A multiline cell splits across stacked bordered lines within the same // logical row (other columns blank on continuation lines), no per-row separator. const renderRow = (cells: ReadonlyArray): string => { @@ -296,8 +300,7 @@ function csvFieldNeedsQuotes(field: string): boolean { if (field === "") return false; if (field === "\\.") return true; if (/[\n\r",]/.test(field)) return true; - const first = field[0]!; - return /\s/u.test(first); + return /\s/u.test(field[0] ?? ""); } function csvField(field: string): string { @@ -490,7 +493,7 @@ export function orderedKeys(body: string): ReadonlyArray { } if (!Array.isArray(parsed) || parsed.length === 0) return []; const first = parsed[0]; - if (typeof first !== "object" || first === null || Array.isArray(first)) return []; + if (!Predicate.isObject(first)) return []; const keys: string[] = []; const open = body.indexOf("{"); @@ -498,11 +501,11 @@ export function orderedKeys(body: string): ReadonlyArray { let i = open + 1; let depth = 1; while (i < body.length && depth > 0) { - const ch = body[i]!; + const ch = body[i] ?? ""; if (ch === '"') { const { value, end } = readJsonStringToken(body, i); i = end; - while (i < body.length && /\s/.test(body[i]!)) i++; + while (i < body.length && /\s/.test(body[i] ?? "")) i++; // A string immediately followed by `:` at the first object's top level is a key. if (depth === 1 && body[i] === ":") keys.push(value); continue; diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index dc7c213cc3..03737525f0 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -1,4 +1,14 @@ -import { DateTime, Effect, FileSystem, Option, Path, Redacted, Schema } from "effect"; +import { + DateTime, + Effect, + FileSystem, + Option, + Path, + Predicate, + Redacted, + Ref, + Schema, +} from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -48,6 +58,11 @@ const MISSING_TOKEN_MESSAGE = const BOUNDARY_BYTES = 16; +const decodeJsonBody = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const isRowObject = (element: unknown): element is { readonly [key: string]: unknown } | null => + element === null || Predicate.isObject(element); + export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { const output = yield* Output; const telemetryState = yield* TelemetryState; @@ -55,7 +70,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { const linkedProjectCache = yield* LinkedProjectCache; // Set only when the linked preflight resolves a ref, so the `Effect.ensuring` finalizer at the // end of this handler can refresh the linked-project cache even if a later step fails. - let linkedRefForCache: string | undefined; + const linkedRefForCache = yield* Ref.make(undefined); const stdin = yield* Stdin; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -197,20 +212,14 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // The API returns a JSON array of row objects for SELECT, or a plain command tag for // DDL/DML; anything else is printed verbatim. - const decoded = Schema.decodeOption(Schema.fromJsonString(Schema.Unknown))(body); + const decoded = yield* decodeJsonBody(body).pipe(Effect.option); if (Option.isNone(decoded)) { return yield* output.raw(`${body}\n`); } - const parsed = decoded.value; - const isRowArray = - Array.isArray(parsed) && - parsed.every( - (element) => element === null || (typeof element === "object" && !Array.isArray(element)), - ); - if (!isRowArray) { + const rows = decoded.value; + if (!Array.isArray(rows) || !rows.every(isRowObject)) { return yield* output.raw(`${body}\n`); } - const rows = parsed as ReadonlyArray | null>; yield* Effect.annotateCurrentSpan("query.row_count", rows.length); if (rows.length === 0) { return yield* emit(format, [], [], agentMode, Option.none()); @@ -255,7 +264,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // strict loader: a real ref-file read error surfaces instead of being treated as not-linked. const ref = yield* projectRef.loadProjectRef(flags.projectRef); // Recorded now so the cache finalizer still fires if a later step fails. - linkedRefForCache = ref; + yield* Ref.set(linkedRefForCache, ref); // Loads and validates the remote-merged config and resolves the live DB connection (TCP // probe, pooler fallback, temp login-role mint) purely to surface early failures — the // linked query itself uses the Management API, so this resolved connection is discarded. @@ -364,8 +373,8 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // fails — `linkedRefForCache` is only set on the linked path, so `--local`/`--db-url` never // trigger this. Effect.ensuring( - Effect.suspend(() => - linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, + Effect.flatMap(Ref.get(linkedRefForCache), (ref) => + ref !== undefined ? linkedProjectCache.cache(ref) : Effect.void, ), ), Effect.ensuring(telemetryState.flush), diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index 8477e2f0f9..10fbfb7c7d 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -193,9 +193,9 @@ function mockStdin(opts: { isTTY?: boolean; piped?: string }) { ), readLine: () => Effect.succeed( - opts.piped === undefined || opts.piped.split(/\r?\n/u)[0]!.trim() === "" + opts.piped === undefined || (opts.piped.split(/\r?\n/u)[0] ?? "").trim() === "" ? Option.none() - : Option.some(opts.piped.split(/\r?\n/u)[0]!.trim()), + : Option.some((opts.piped.split(/\r?\n/u)[0] ?? "").trim()), ), }); } @@ -379,7 +379,7 @@ const SELECT_RESULT: QueryResult = { describe("db query integration", () => { const tempRoot = useTempWorkdir("supabase-db-query-int-"); - it.live("runs SQL passed as a positional argument and renders a table for humans", () => { + it.effect("runs SQL passed as a positional argument and renders a table for humans", () => { const { layer, out, cache } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select * from users"), local: Option.some(true) })); @@ -390,7 +390,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("renders a local float8 column with Go's %g, integer columns plain", () => { + it.effect("renders a local float8 column with Go's %g, integer columns plain", () => { // OIDs: int8=20 → plain; float8=701 → %g (select 1000000::int8, 1000000::float8). const { layer, out } = setup({ result: { @@ -406,7 +406,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("reports connecting to the remote database for a --db-url target", () => { + it.effect("reports connecting to the remote database for a --db-url target", () => { const { layer, out } = setup({ result: SELECT_RESULT, isLocal: false }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), dbUrl: Option.some("postgres://x/y") })); @@ -414,7 +414,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("errors when no SQL is provided on a TTY", () => { + it.effect("errors when no SQL is provided on a TTY", () => { const { layer } = setup({ stdinTTY: true }); return Effect.gen(function* () { const exit = yield* dbQuery(flags({ local: Option.some(true) })).pipe(Effect.exit); @@ -424,7 +424,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("reads SQL piped via stdin", () => { + it.effect("reads SQL piped via stdin", () => { const { layer, out } = setup({ result: SELECT_RESULT, stdinTTY: false, piped: "select 1\n" }); return Effect.gen(function* () { yield* dbQuery(flags({ local: Option.some(true) })); @@ -432,7 +432,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("reads SQL from --file", () => { + it.effect("reads SQL from --file", () => { const { layer, out } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -445,7 +445,7 @@ describe("db query integration", () => { }).pipe(Effect.scoped, Effect.provide(layer)); }); - it.live("resolves a relative --file against the workdir", () => + it.effect("resolves a relative --file against the workdir", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -459,7 +459,7 @@ describe("db query integration", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("errors when --file cannot be read", () => { + it.effect("errors when --file cannot be read", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -469,7 +469,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("errors on empty stdin", () => { + it.effect("errors on empty stdin", () => { const { layer } = setup({ stdinTTY: false, piped: " " }); return Effect.gen(function* () { const exit = yield* dbQuery(flags({ local: Option.some(true) })).pipe(Effect.exit); @@ -477,7 +477,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("prints the command tag for DDL with no result columns", () => { + it.effect("prints the command tag for DDL with no result columns", () => { const { layer, out } = setup({ result: { fields: [], rows: [], commandTag: "CREATE TABLE" } }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("create table t()"), local: Option.some(true) })); @@ -485,7 +485,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("renders JSON for agents by default with the untrusted-data envelope", () => { + it.effect("renders JSON for agents by default with the untrusted-data envelope", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); @@ -499,7 +499,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("auto-detects an agent from AiTool and defaults to JSON", () => { + it.effect("auto-detects an agent from AiTool and defaults to JSON", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "auto", aiTool: "cursor" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); @@ -507,7 +507,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("renders plain JSON (no envelope) for a human with -o json", () => { + it.effect("renders plain JSON (no envelope) for a human with -o json", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", goOutput: "json" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); @@ -520,7 +520,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("renders plain JSON for --output-format json with --agent no", () => { + it.effect("renders plain JSON for --output-format json with --agent no", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", format: "json" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); @@ -531,7 +531,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a result event for --output-format stream-json with --agent no", () => { + it.effect("emits a result event for --output-format stream-json with --agent no", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", format: "stream-json" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); @@ -551,7 +551,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("preserves exact bigint tokens in --output-format stream-json", () => { + it.effect("preserves exact bigint tokens in --output-format stream-json", () => { const { layer, out } = setup({ result: { fields: ["n"], @@ -569,7 +569,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("lets --output pretty win over --output-format json", () => { + it.effect("lets --output pretty win over --output-format json", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", @@ -583,7 +583,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails JSON output on a non-finite float (Go's json.Encoder error), no stdout", () => { + it.effect("fails JSON output on a non-finite float (Go's json.Encoder error), no stdout", () => { const { layer, out } = setup({ result: { fields: ["f"], fieldTypeIds: [701], rows: [[Number.NaN]], commandTag: "SELECT 1" }, agent: "no", @@ -599,7 +599,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("records the resolved -o as the telemetry output_format (Go parity)", () => { + it.effect("records the resolved -o as the telemetry output_format (Go parity)", () => { const human = setup({ result: SELECT_RESULT, agent: "no" }); const agent = setup({ result: SELECT_RESULT, agent: "yes" }); const csv = setup({ result: SELECT_RESULT, agent: "no", goOutput: "csv" }); @@ -619,7 +619,7 @@ describe("db query integration", () => { }); }); - it.live("renders CSV with -o csv", () => { + it.effect("renders CSV with -o csv", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", goOutput: "csv" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); @@ -627,7 +627,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("honors an explicit -o table over the agent JSON default", () => { + it.effect("honors an explicit -o table over the agent JSON default", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes", goOutput: "table" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); @@ -636,7 +636,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("honors an explicit -o csv over the agent JSON default", () => { + it.effect("honors an explicit -o csv over the agent JSON default", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes", goOutput: "csv" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); @@ -644,7 +644,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("attaches an RLS advisory in agent JSON mode", () => { + it.effect("attaches an RLS advisory in agent JSON mode", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes", @@ -656,7 +656,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("omits the advisory when the RLS check fails", () => { + it.effect("omits the advisory when the RLS check fails", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes", rlsFails: true }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); @@ -664,7 +664,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("resolves the --db-url/config before reading SQL (Go root PreRun order)", () => { + it.effect("resolves the --db-url/config before reading SQL (Go root PreRun order)", () => { const { layer } = setup({ resolveFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -675,7 +675,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with DbQueryExecError when the query errors", () => { + it.effect("fails with DbQueryExecError when the query errors", () => { const { layer } = setup({ queryFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -685,7 +685,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects conflicting targets (--linked --local) before running any SQL", () => { + it.effect("rejects conflicting targets (--linked --local) before running any SQL", () => { const { layer, cache } = setup(); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -703,7 +703,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects --local=false --linked=false as a target conflict (Go flag.Changed)", () => { + it.effect("rejects --local=false --linked=false as a target conflict (Go flag.Changed)", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -720,7 +720,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails an unlinked --linked query without prompting for a project", () => { + it.effect("fails an unlinked --linked query without prompting for a project", () => { const { layer } = setup({ unlinked: true }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -731,7 +731,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("surfaces a project-ref read failure instead of reporting not-linked", () => { + it.effect("surfaces a project-ref read failure instead of reporting not-linked", () => { const { layer } = setup({ refReadFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -743,7 +743,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("queries the linked project over HTTP and writes the linked-project cache", () => { + it.effect("queries the linked project over HTTP and writes the linked-project cache", () => { const { layer, out, cache, httpClient } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', @@ -845,7 +845,7 @@ describe("db query integration", () => { ); }); - it.live("queries the project given via --project-ref without a linked workdir", () => { + it.effect("queries the project given via --project-ref without a linked workdir", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, out, cache, httpClient } = setup({ linkedStatus: 201, @@ -875,7 +875,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, cache, httpClient } = setup({ linkedStatus: 201, @@ -898,7 +898,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects --project-ref without --linked (query defaults to the local target)", () => { + it.effect("rejects --project-ref without --linked (query defaults to the local target)", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, out, cache, httpClient } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { @@ -915,7 +915,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("treats --linked=false as an explicit linked target (Go gates on flag.Changed)", () => { + it.effect("treats --linked=false as an explicit linked target (Go gates on flag.Changed)", () => { const { layer, out, cache } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', @@ -927,7 +927,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("resolves the linked DB config before the API call (Go root PreRun order)", () => { + it.effect("resolves the linked DB config before the API call (Go root PreRun order)", () => { const { layer, out, cache } = setup({ resolveFails: true, linkedStatus: 201, @@ -944,7 +944,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("caches the linked project even when SQL resolution fails (Go PostRun)", () => { + it.effect("caches the linked project even when SQL resolution fails (Go PostRun)", () => { const { layer, cache } = setup({ stdinTTY: true }); return Effect.gen(function* () { const exit = yield* dbQuery(flags({ linked: Option.some(true) })).pipe(Effect.exit); @@ -954,7 +954,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "errors when the linked API returns a non-201 but still caches the linked project", () => { const { layer, cache } = setup({ @@ -971,7 +971,7 @@ describe("db query integration", () => { }, ); - it.live("handles an empty linked result array", () => { + it.effect("handles an empty linked result array", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: "[]" }); return Effect.gen(function* () { yield* dbQuery( @@ -981,7 +981,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("prints the raw body when the linked response is not a JSON array", () => { + it.effect("prints the raw body when the linked response is not a JSON array", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: '{"command":"INSERT"}' }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("insert ..."), linked: Option.some(true) })); @@ -989,7 +989,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("prints the raw body when the linked response is not valid JSON", () => { + it.effect("prints the raw body when the linked response is not valid JSON", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: "CREATE TABLE" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("create ..."), linked: Option.some(true) })); @@ -997,7 +997,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("renders linked agent JSON with the envelope (no advisory on the linked path)", () => { + it.effect("renders linked agent JSON with the envelope (no advisory on the linked path)", () => { const { layer, out } = setup({ agent: "yes", linkedStatus: 201, @@ -1012,7 +1012,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("falls back to map keys when the first linked row has no orderable keys", () => { + it.effect("falls back to map keys when the first linked row has no orderable keys", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: "[null]" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); @@ -1020,7 +1020,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("renders NULL for a null row object in a linked result", () => { + it.effect("renders NULL for a null row object in a linked result", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: '[{"a":1},null]' }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); @@ -1029,7 +1029,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("maps a linked HTTP transport failure to an exec error", () => { + it.effect("maps a linked HTTP transport failure to an exec error", () => { const { layer } = setup({ networkFail: true }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -1039,7 +1039,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("requires login before querying --linked", () => { + it.effect("requires login before querying --linked", () => { const { layer } = setup({ accessToken: Option.none() }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -1049,7 +1049,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "rejects an invalid env access token before the linked query (Go LoadAccessTokenFS)", () => { const { layer, out } = setup({ accessTokenInvalid: true, linkedStatus: 201 }); @@ -1064,7 +1064,7 @@ describe("db query integration", () => { }, ); - it.live("runs the --linked login preflight before reading --file (Go PreRun order)", () => { + it.effect("runs the --linked login preflight before reading --file (Go PreRun order)", () => { const { layer } = setup({ accessToken: Option.none() }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -1075,7 +1075,7 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("surfaces a linked config/connection failure before the missing-token error", () => { + it.effect("surfaces a linked config/connection failure before the missing-token error", () => { const { layer } = setup({ accessToken: Option.none(), resolveFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index c2ddca7451..0c3c17235e 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path, Ref } from "effect"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; @@ -72,7 +72,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { const projectEnv = yield* loadProjectEnv(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); - let linkedRefForCache: string | undefined; + const linkedRefForCache = yield* Ref.make(Option.none()); const body = Effect.gen(function* () { const target = resolveDbTargetFlags(cliArgs.args); @@ -143,7 +143,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { // so a deprecated first migration that `listLocalMigrations` excludes is still accepted. const entries = yield* fs .readDirectory(migrationsDir) - .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); const found = entries.some((name) => pathMatch(`${v}_*.sql`, path.basename(name)).matched); if (!found) { return yield* new DbResetMigrationFileError({ @@ -159,7 +159,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { }); const total = versions.length; const last = flags.last.value; - resolvedVersion = last < total ? versions[total - last - 1]! : "-"; + resolvedVersion = versions[total - last - 1] ?? "-"; } const connType = target.connType ?? "local"; @@ -177,7 +177,8 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { // even when `resolver.resolve` fails mid-way (merged config, temp-role mint, connection). if (connType === "linked") { const refResolver = yield* ProjectRefResolver; - linkedRefForCache = yield* refResolver.loadProjectRef(flags.projectRef); + const ref = yield* refResolver.loadProjectRef(flags.projectRef); + yield* Ref.set(linkedRefForCache, Option.some(ref)); } const cfg = yield* resolver.resolve({ @@ -213,7 +214,9 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { // Re-confirms `linkedRefForCache` from the now-resolved `cfg.ref`; a `db-url` target leaves // it as whatever the pre-load block set (nothing), since this only fires when linked. const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); - if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; + if (connType === "linked" && linkedRef !== undefined) { + yield* Ref.set(linkedRefForCache, Option.some(linkedRef)); + } // Decodes the whole config with env-expansion and weak-typed boolean parsing, applies // `SUPABASE_*` overrides, merges a matching `[remotes.]` block, and decrypts every @@ -299,10 +302,13 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { yield* body.pipe( Effect.ensuring( - Effect.suspend(() => - linkedRefForCache !== undefined && linkedRefForCache !== "" - ? linkedProjectCache.cache(linkedRefForCache) - : Effect.void, + Ref.get(linkedRefForCache).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (ref) => (ref === "" ? Effect.void : linkedProjectCache.cache(ref)), + }), + ), ), ), Effect.ensuring(telemetryState.flush), diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 49bebfce33..a3193835d3 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -14,7 +14,8 @@ import { Redacted, Schema, } from "effect"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as HttpBody from "effect/http/HttpBody"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -276,10 +277,10 @@ function mockContainerCliSpawner(route: (args: ReadonlyArray) => RouteRe ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { - const args = command._tag === "StandardCommand" ? command.args : []; + const args = ChildProcess.isStandardCommand(command) ? command.args : []; spawned.push({ args }); - if (command._tag !== "StandardCommand") { + if (!ChildProcess.isStandardCommand(command)) { return yield* PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", @@ -412,6 +413,8 @@ function defaultLocalResetRoute(opts: DefaultRouteOpts = {}) { }; } +const JsonFromString = Schema.fromJsonString(Schema.Unknown); + const alwaysReadyHttpClientLayer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -970,14 +973,12 @@ function setup( HttpClient.HttpClient, HttpClient.make((request) => { const reqBody = request.body; - let body: unknown; - if (reqBody._tag === "Uint8Array") { - try { - body = JSON.parse(new TextDecoder().decode(reqBody.body)); - } catch { - body = undefined; - } - } + const body = + reqBody instanceof HttpBody.Uint8Array + ? Option.getOrUndefined( + Schema.decodeOption(JsonFromString)(new TextDecoder().decode(reqBody.body)), + ) + : undefined; requests.push({ method: request.method, url: request.url, body }); const matched = storageRoutes.find( (r) => r.method === request.method && request.url.includes(r.match), @@ -1058,7 +1059,7 @@ describe("db reset", () => { const tmp = useTempWorkdir("supabase-db-reset-"); describe("local reset — PG15+", () => { - it.live("recreates the container, waits healthy, and runs the setup pipeline", () => { + it.effect("recreates the container, waits healthy, and runs the setup pipeline", () => { const { layer, out, child, telemetry } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1096,7 +1097,7 @@ describe("db reset", () => { ); }); - it.live( + it.effect( "passes the resolved --version through to the setup pipeline's seed/migrate step", () => { const { layer, conn } = setup(tmp.current, { @@ -1124,7 +1125,7 @@ describe("db reset", () => { }, ); - it.live("reapplies migrations and seeds after a default local reset (PG15)", () => { + it.effect("reapplies migrations and seeds after a default local reset (PG15)", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { @@ -1143,7 +1144,7 @@ describe("db reset", () => { }); }); - it.live("skips seeding with --no-seed on a local reset", () => { + it.effect("skips seeding with --no-seed on a local reset", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/seed.sql": "insert into t values (1);" }, @@ -1156,7 +1157,7 @@ describe("db reset", () => { }); }); - it.live("seeds from --sql-paths overriding config on a local reset", () => { + it.effect("seeds from --sql-paths overriding config on a local reset", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', files: { "supabase/custom-seed.sql": "insert into t values (2);" }, @@ -1173,7 +1174,7 @@ describe("db reset", () => { }); }); - it.live( + it.effect( "fails a local reset when the database is not running, before any recreate work", () => { const { layer, child } = setup(tmp.current, { @@ -1193,7 +1194,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "refuses a local reset from the direct Engine answer without touching the container CLI", () => { const { layer, child } = setup(tmp.current, { @@ -1223,7 +1224,7 @@ describe("db reset", () => { }, ); - it.live("resets the stack database without Compose volume recreate", () => { + it.effect("resets the stack database without Compose volume recreate", () => { const { layer, child, stackApi, catalogApplied, out } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1246,7 +1247,7 @@ describe("db reset", () => { }); }); - it.live("preserves the existing stack composition while rebuilding the database", () => { + it.effect("preserves the existing stack composition while rebuilding the database", () => { const { layer, catalogApplied } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1259,7 +1260,7 @@ describe("db reset", () => { }); }); - it.live("keeps a service excluded from the saved stack out of schema provisioning", () => { + it.effect("keeps a service excluded from the saved stack out of schema provisioning", () => { const { layer, catalogApplied } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1273,7 +1274,7 @@ describe("db reset", () => { }); }); - it.live("provisions configured service schemas for a postgres-only stack", () => { + it.effect("provisions configured service schemas for a postgres-only stack", () => { const { layer, catalogApplied } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1287,7 +1288,7 @@ describe("db reset", () => { }); }); - it.live("skips schemas for services disabled in config on a postgres-only stack", () => { + it.effect("skips schemas for services disabled in config on a postgres-only stack", () => { const { layer, catalogApplied } = setup(tmp.current, { toml: 'project_id = "test"\n[storage]\nenabled = false\n', args: ["db", "reset", "--local"], @@ -1301,7 +1302,7 @@ describe("db reset", () => { }); }); - it.live("leaves the stopped composition after a migration failure", () => { + it.effect("leaves the stopped composition after a migration failure", () => { const { layer, stackApi } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000", "create table stack_reset_failure ();") }, @@ -1318,7 +1319,7 @@ describe("db reset", () => { }); }); - it.live("seeds stack buckets through the stack API listener with stack JWTs", () => { + it.effect("seeds stack buckets through the stack API listener with stack JWTs", () => { const requests: Array<{ readonly url: string; readonly authorization: string }> = []; const { layer } = setup(tmp.current, { toml: [ @@ -1371,7 +1372,7 @@ describe("db reset", () => { }); }); - it.live("fails --local reset when the stack database is not running", () => { + it.effect("fails --local reset when the stack database is not running", () => { const { layer, stackApi } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1387,7 +1388,7 @@ describe("db reset", () => { }); }); - it.live("reports a stack whose owner is absent as not running", () => { + it.effect("reports a stack whose owner is absent as not running", () => { const { layer, stackApi } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1409,7 +1410,7 @@ describe("db reset", () => { }); }); - it.live("surfaces a release mismatch instead of reporting the stack as not running", () => { + it.effect("surfaces a release mismatch instead of reporting the stack as not running", () => { const message = "Stack test is served by release 1.0.0+old, but this client is release 1.0.0+new; stop or destroy the stack (both work across releases) and retry"; const { layer, stackApi } = setup(tmp.current, { @@ -1430,7 +1431,7 @@ describe("db reset", () => { }); }); - it.live("reports a database that becomes unavailable after the reset as not running", () => { + it.effect("reports a database that becomes unavailable after the reset as not running", () => { const { layer, stackApi } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1452,7 +1453,7 @@ describe("db reset", () => { "\n", ); - it.live("seeds stack buckets through the gateway when storage is dormant", () => { + it.effect("seeds stack buckets through the gateway when storage is dormant", () => { const client = recordingStackStorageHttpClient(); const { layer } = setup(tmp.current, { toml: BUCKET_TOML, @@ -1477,34 +1478,37 @@ describe("db reset", () => { }); }); - it.live("skips seeding storage buckets and issues no requests when storage is disabled", () => { - const client = recordingStackStorageHttpClient(); - const { layer, out } = setup(tmp.current, { - toml: BUCKET_TOML, - args: ["db", "reset", "--local"], - isLocal: true, - stackBackend: true, - stackStorageState: "disabled", - stackApiEndpoint: { url: "http://127.0.0.1:55423", port: 55423 }, - httpClient: client.layer, - }); - return Effect.gen(function* () { - yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain( - "WARNING: skipped seeding storage buckets: Storage is disabled for this stack.", - ); - expect(out.stderrText).toContain( - "Set [storage] enabled = true in supabase/config.toml, run supabase stack stop " + - "followed by supabase stack start without -x storage, then supabase seed buckets --local.", - ); - expect(out.stderrText).not.toContain( - "Run supabase seed buckets --local once Storage is available.", - ); - expect(client.requests).toHaveLength(0); - }); - }); + it.effect( + "skips seeding storage buckets and issues no requests when storage is disabled", + () => { + const client = recordingStackStorageHttpClient(); + const { layer, out } = setup(tmp.current, { + toml: BUCKET_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + stackStorageState: "disabled", + stackApiEndpoint: { url: "http://127.0.0.1:55423", port: 55423 }, + httpClient: client.layer, + }); + return Effect.gen(function* () { + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "WARNING: skipped seeding storage buckets: Storage is disabled for this stack.", + ); + expect(out.stderrText).toContain( + "Set [storage] enabled = true in supabase/config.toml, run supabase stack stop " + + "followed by supabase stack start without -x storage, then supabase seed buckets --local.", + ); + expect(out.stderrText).not.toContain( + "Run supabase seed buckets --local once Storage is available.", + ); + expect(client.requests).toHaveLength(0); + }); + }, + ); - it.live( + it.effect( "skips seeding storage buckets silently when storage is disabled and no buckets are configured", () => { const client = recordingStackStorageHttpClient(); @@ -1525,30 +1529,33 @@ describe("db reset", () => { }, ); - it.live("skips seeding storage buckets and issues no requests when storage is stopped", () => { - const client = recordingStackStorageHttpClient(); - const { layer, out } = setup(tmp.current, { - toml: BUCKET_TOML, - args: ["db", "reset", "--local"], - isLocal: true, - stackBackend: true, - stackStorageState: "stopped", - stackApiEndpoint: { url: "http://127.0.0.1:55424", port: 55424 }, - httpClient: client.layer, - }); - return Effect.gen(function* () { - yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain( - "WARNING: skipped seeding storage buckets: Storage is stopped for this stack.", - ); - expect(out.stderrText).toContain( - "Run supabase seed buckets --local once Storage is available.", - ); - expect(client.requests).toHaveLength(0); - }); - }); + it.effect( + "skips seeding storage buckets and issues no requests when storage is stopped", + () => { + const client = recordingStackStorageHttpClient(); + const { layer, out } = setup(tmp.current, { + toml: BUCKET_TOML, + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + stackStorageState: "stopped", + stackApiEndpoint: { url: "http://127.0.0.1:55424", port: 55424 }, + httpClient: client.layer, + }); + return Effect.gen(function* () { + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "WARNING: skipped seeding storage buckets: Storage is stopped for this stack.", + ); + expect(out.stderrText).toContain( + "Run supabase seed buckets --local once Storage is available.", + ); + expect(client.requests).toHaveLength(0); + }); + }, + ); - it.live( + it.effect( "seeds immediately when storage is starting, without waiting for it to become ready", () => { const client = recordingStackStorageHttpClient(); @@ -1576,7 +1583,7 @@ describe("db reset", () => { }, ); - it.live("seeds immediately when storage is stopping, letting the gateway wake it", () => { + it.effect("seeds immediately when storage is stopping, letting the gateway wake it", () => { const client = recordingStackStorageHttpClient(); const { layer, out } = setup(tmp.current, { toml: BUCKET_TOML, @@ -1602,7 +1609,7 @@ describe("db reset", () => { }); }); - it.live( + it.effect( "warns and completes the reset when the bucket-create gateway request fails (500)", () => { const client = recordingStackStorageHttpClientBucketCreateFails(500); @@ -1628,7 +1635,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "warns and completes the reset when the bucket-list gateway request fails at the transport level", () => { const client = recordingStackStorageHttpClientBucketListTransportFails(); @@ -1654,7 +1661,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "reports the specific failed-storage reason (with underlying error) in the pre-check warning", () => { const { layer, out } = setup(tmp.current, { @@ -1676,7 +1683,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "warns and completes the reset when storage is seedable but the stack has no API gateway endpoint", () => { const { layer, out } = setup(tmp.current, { @@ -1700,7 +1707,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "skips seeding silently when no buckets are configured even without an API gateway endpoint", () => { const client = recordingStackStorageHttpClient(); @@ -1722,7 +1729,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "fails a local reset before the destructive recreate on a malformed config.toml", () => { const { layer, child } = setup(tmp.current, { @@ -1743,7 +1750,7 @@ describe("db reset", () => { }, ); - it.live("seeds buckets after a local reset when storage is ready", () => { + it.effect("seeds buckets after a local reset when storage is ready", () => { const { layer, child } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1761,7 +1768,7 @@ describe("db reset", () => { }); }); - it.live("skips bucket seeding when storage is absent (any inspect error)", () => { + it.effect("skips bucket seeding when storage is absent (any inspect error)", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1774,7 +1781,7 @@ describe("db reset", () => { }); }); - it.live( + it.effect( "confirms overwriting an existing bucket non-interactively and applies the yes default", () => { const { layer, out, requests } = setup(tmp.current, { @@ -1808,7 +1815,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "keeps a vector bucket that is missing from config.toml (prune declines by default)", () => { const { layer, out, requests } = setup(tmp.current, { @@ -1841,7 +1848,7 @@ describe("db reset", () => { }, ); - it.live("prunes a stale vector bucket when SUPABASE_YES is set in the project dotenv", () => { + it.effect("prunes a stale vector bucket when SUPABASE_YES is set in the project dotenv", () => { const { layer, out, requests } = setup(tmp.current, { toml: 'project_id = "test"\n[storage.vector]\nenabled = true\n[storage.vector.buckets.embeddings]\n', files: { "supabase/.env": "SUPABASE_YES=true\n" }, @@ -1870,14 +1877,14 @@ describe("db reset", () => { expect(out.stderrText).toContain("Pruning vector bucket: stale-vec"); const deleteBodies = yield* Effect.forEach( requests.filter((r) => r.url.includes("DeleteVectorBucket")), - (r) => Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(r.body ?? ""), + (r) => Schema.encodeEffect(JsonFromString)(r.body ?? ""), ); expect(deleteBodies.some((body) => body.includes("stale-vec"))).toBe(true); }), ); }); - it.live("prunes a stale vector bucket when a piped y answers the confirmation", () => { + it.effect("prunes a stale vector bucket when a piped y answers the confirmation", () => { const { layer, out, requests } = setup(tmp.current, { toml: 'project_id = "test"\n[storage.vector]\nenabled = true\n[storage.vector.buckets.embeddings]\n', args: ["db", "reset", "--local"], @@ -1909,7 +1916,7 @@ describe("db reset", () => { ); }); - it.live("uses the detected git branch in the Finished line", () => { + it.effect("uses the detected git branch in the Finished line", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1925,7 +1932,7 @@ describe("db reset", () => { ); }); - it.live("emits a json result for a local reset", () => { + it.effect("emits a json result for a local reset", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1939,7 +1946,7 @@ describe("db reset", () => { }); }); - it.live("still flushes telemetry when the recreate itself fails", () => { + it.effect("still flushes telemetry when the recreate itself fails", () => { const { layer, telemetry } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1963,7 +1970,7 @@ describe("db reset", () => { }); describe("local reset — Kong reload", () => { - it.live("fails the whole command with the exact suggestion when Kong reload fails", () => { + it.effect("fails the whole command with the exact suggestion when Kong reload fails", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1974,18 +1981,22 @@ describe("db reset", () => { const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause) as { message: string; suggestion?: string }; + const error = Option.getOrUndefined(Cause.findErrorOption(exit.cause)); // Message text is an established output contract, not the raw exit code. - expect(error.message).toContain("failed to reload kong: error executing command"); - expect(error.suggestion).toContain( - "Local services restarted, but API routes may return 502", - ); - expect(error.suggestion).toContain(`docker restart ${KONG_ID}`); + expect(error).toMatchObject({ + message: expect.stringContaining("failed to reload kong: error executing command"), + suggestion: expect.stringContaining( + "Local services restarted, but API routes may return 502", + ), + }); + expect(error).toMatchObject({ + suggestion: expect.stringContaining(`docker restart ${KONG_ID}`), + }); } }); }); - it.live("skips the reload without failing when Kong is excluded from the stack", () => { + it.effect("skips the reload without failing when Kong is excluded from the stack", () => { const { layer, out, child } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -1999,7 +2010,7 @@ describe("db reset", () => { }); }); - it.live("skips the reload without failing when Kong is present but stopped", () => { + it.effect("skips the reload without failing when Kong is present but stopped", () => { const { layer, out, child } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -2013,7 +2024,7 @@ describe("db reset", () => { }); }); - it.live("fails the command when a satellite restart fails", () => { + it.effect("fails the command when a satellite restart fails", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], @@ -2031,7 +2042,7 @@ describe("db reset", () => { }); describe("local reset — PG14", () => { - it.live( + it.effect( "recreates via the four-statement DROP/CREATE sequence, then initDatabase + RestartDatabase", () => { const { layer, out, child, conn } = setup(tmp.current, { @@ -2073,7 +2084,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "attaches Go's ExecBatch error context to a failed DROP/CREATE DATABASE statement", () => { // Built as a migration file and run through a batch executor, so a failure gets the same @@ -2100,7 +2111,7 @@ describe("db reset", () => { }, ); - it.live("swallows a disconnect-clients failure when the code is invalid_catalog_name", () => { + it.effect("swallows a disconnect-clients failure when the code is invalid_catalog_name", () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, args: ["db", "reset", "--local"], @@ -2119,7 +2130,7 @@ describe("db reset", () => { }); }); - it.live("surfaces a disconnect-clients failure for any other error code", () => { + it.effect("surfaces a disconnect-clients failure for any other error code", () => { const { layer } = setup(tmp.current, { toml: PG14_TOML, args: ["db", "reset", "--local"], @@ -2139,7 +2150,7 @@ describe("db reset", () => { }); }); - it.live("swallows a disconnect-clients failure that is not a PgError at all", () => { + it.effect("swallows a disconnect-clients failure that is not a PgError at all", () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, args: ["db", "reset", "--local"], @@ -2157,7 +2168,7 @@ describe("db reset", () => { }); }); - it.live( + it.effect( "swallows a disconnect-clients failure carrying a node system errno, not a real SQLSTATE", () => { // `extractSqlState` returns any string `code` found in the cause chain, including a bare @@ -2200,7 +2211,7 @@ describe("db reset", () => { 10_000, ); - it.live("fails permanently (no retry) when counting replication slots itself fails", () => { + it.effect("fails permanently (no retry) when counting replication slots itself fails", () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, args: ["db", "reset", "--local"], @@ -2239,7 +2250,7 @@ describe("db reset", () => { 20_000, ); - it.live("passes --no-seed and the resolved version to the final MigrateAndSeed step", () => { + it.effect("passes --no-seed and the resolved version to the final MigrateAndSeed step", () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, files: { "supabase/seed.sql": "insert into t values (9);" }, @@ -2252,7 +2263,7 @@ describe("db reset", () => { }); }); - it.live("reapplies migrations and seeds after a default local reset (PG14)", () => { + it.effect("reapplies migrations and seeds after a default local reset (PG14)", () => { const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, files: { @@ -2271,7 +2282,7 @@ describe("db reset", () => { }); }); - it.live("installs pg_net before replay when Database Webhooks is enabled (PG14)", () => { + it.effect("installs pg_net before replay when Database Webhooks is enabled (PG14)", () => { const { layer, conn } = setup(tmp.current, { toml: `${PG14_TOML}[experimental.webhooks]\nenabled = true\n`, files: migrationFile( @@ -2299,7 +2310,7 @@ describe("db reset", () => { }); }); - it.live("drops the PG14 dump's implicit pg_net when Database Webhooks is disabled", () => { + it.effect("drops the PG14 dump's implicit pg_net when Database Webhooks is disabled", () => { // Without this drop, a PG14 `db reset` would leave pg_net installed and diverge from // `supabase start`, surfacing as drift in the next engine's shadow baseline. const { layer, conn } = setup(tmp.current, { @@ -2320,7 +2331,7 @@ describe("db reset", () => { }); }); - it.live( + it.effect( "passes the resolved --version cutoff through to the final MigrateAndSeed step (PG14)", () => { const { layer, conn } = setup(tmp.current, { @@ -2348,7 +2359,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "does NOT run globals.sql on the PG14 reset path (deliberately different from db start's PG14 path)", () => { const { layer, conn } = setup(tmp.current, { @@ -2364,7 +2375,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "resolves db.migrations.schema_paths against supabase/ before applying it on an experimental PG14 reset", () => { // `recreateLocalDatabase14` must pass the normalized `toml.schemaPaths` (resolved by @@ -2403,7 +2414,7 @@ describe("db reset", () => { }); describe("remote reset", () => { - it.live("fails a remote reset on a malformed config.toml", () => { + it.effect("fails a remote reset on a malformed config.toml", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "unterminated\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( @@ -2417,7 +2428,7 @@ describe("db reset", () => { }); }); - it.live("loads a Go-style env() boolean in config for a remote reset", () => { + it.effect("loads a Go-style env() boolean in config for a remote reset", () => { // Regression: `enabled = "env(VAR)"` must load via env-expansion + boolean // parsing (`checkDbToml`) instead of the strict @supabase/config // loader rejecting it. @@ -2436,7 +2447,7 @@ describe("db reset", () => { ).pipe((body) => withConfigEnv({ MIGRATIONS_ENABLED: "true" }, body)); }); - it.live("rejects mutually exclusive target flags", () => { + it.effect("rejects mutually exclusive target flags", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--linked", "--local"], @@ -2447,7 +2458,7 @@ describe("db reset", () => { }); }); - it.live("rejects --version together with --last", () => { + it.effect("rejects --version together with --last", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ @@ -2461,7 +2472,7 @@ describe("db reset", () => { }); }); - it.live("rejects a non-integer --version", () => { + it.effect("rejects a non-integer --version", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ @@ -2471,14 +2482,15 @@ describe("db reset", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("DbResetInvalidVersionError"); - expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "DbResetInvalidVersionError", + message: "invalid version number", + }); } }); }); - it.live("fails when --version has no matching migration file", () => { + it.effect("fails when --version has no matching migration file", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ @@ -2495,7 +2507,7 @@ describe("db reset", () => { }); }); - it.live("rejects an out-of-int64-range --version", () => { + it.effect("rejects an out-of-int64-range --version", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ @@ -2505,14 +2517,15 @@ describe("db reset", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("DbResetInvalidVersionError"); - expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "DbResetInvalidVersionError", + message: "invalid version number", + }); } }); }); - it.live("treats an empty --version like no version at all", () => { + it.effect("treats an empty --version like no version at all", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', confirm: [true], @@ -2528,7 +2541,7 @@ describe("db reset", () => { }); }); - it.live("returns context canceled when the reset prompt is declined", () => { + it.effect("returns context canceled when the reset prompt is declined", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', confirm: [false], @@ -2544,7 +2557,7 @@ describe("db reset", () => { }); }); - it.live("drops schemas and applies migrations + seed on a confirmed remote reset", () => { + it.effect("drops schemas and applies migrations + seed on a confirmed remote reset", () => { const { layer, out, conn, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { @@ -2564,7 +2577,7 @@ describe("db reset", () => { }); }); - it.live("fails a remote reset before dropping schemas on an undecryptable secret", () => { + it.effect("fails a remote reset before dropping schemas on an undecryptable secret", () => { // Every secret is decrypted while loading config, before the reset runs, so an // undecryptable secret must abort before any destructive work. const { layer, conn } = setup(tmp.current, { @@ -2584,7 +2597,7 @@ describe("db reset", () => { }); }); - it.live("fails a remote reset before dropping schemas on an empty project_id", () => { + it.effect("fails a remote reset before dropping schemas on an empty project_id", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = ""\n', confirm: [true], @@ -2604,7 +2617,7 @@ describe("db reset", () => { }); }); - it.live("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { + it.effect("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/.env": "SUPABASE_YES=true\n" }, @@ -2616,7 +2629,7 @@ describe("db reset", () => { }); }); - it.live("still caches the linked ref when DB-config resolution fails", () => { + it.effect("still caches the linked ref when DB-config resolution fails", () => { const { layer, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', resolveFails: true, @@ -2632,7 +2645,7 @@ describe("db reset", () => { }); }); - it.live("resets the project given via --project-ref without a linked workdir", () => { + it.effect("resets the project given via --project-ref without a linked workdir", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, conn, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -2651,7 +2664,7 @@ describe("db reset", () => { }); }); - it.live("--project-ref overrides an already-linked workdir's project ref", () => { + it.effect("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -2670,7 +2683,7 @@ describe("db reset", () => { }); }); - it.live("rejects --project-ref on the default local target", () => { + it.effect("rejects --project-ref on the default local target", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, conn, resolver, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -2693,7 +2706,7 @@ describe("db reset", () => { }); }); - it.live("resets to a specific version, applying only migrations up to it", () => { + it.effect("resets to a specific version, applying only migrations up to it", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { @@ -2715,7 +2728,7 @@ describe("db reset", () => { }); }); - it.live("resolves --last to a version prefix", () => { + it.effect("resolves --last to a version prefix", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { @@ -2733,7 +2746,7 @@ describe("db reset", () => { }); }); - it.live("reverts all migrations when --last covers the full history", () => { + it.effect("reverts all migrations when --last covers the full history", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, @@ -2748,7 +2761,7 @@ describe("db reset", () => { }); }); - it.live("skips seeding with --no-seed", () => { + it.effect("skips seeding with --no-seed", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { @@ -2765,7 +2778,7 @@ describe("db reset", () => { }); }); - it.live( + it.effect( "applies configured schema files instead of replaying migrations on an experimental remote reset", () => { // `--linked=false` still selects the linked/remote target, exercised here alongside the @@ -2795,7 +2808,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "applies schema files across multiple schema_paths patterns in declaration order, sorted within each pattern", () => { const { layer, conn } = setup(tmp.current, { @@ -2819,7 +2832,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "expands a schema_paths directory entry to its nested .sql files on an experimental remote reset", () => { const { layer, conn } = setup(tmp.current, { @@ -2839,7 +2852,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "silently applies nothing when schema_paths is unset on an experimental remote reset (Go's undocumented default-config behavior)", () => { const { layer, out, conn } = setup(tmp.current, { @@ -2857,7 +2870,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "replays migrations instead of schema files on an experimental remote reset when pg-delta is enabled", () => { const { layer, out, conn } = setup(tmp.current, { @@ -2878,7 +2891,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "replays migrations instead of schema files on an experimental remote reset with a resolved version", () => { const { layer, conn } = setup(tmp.current, { @@ -2902,7 +2915,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "fails an experimental remote reset when no schema_paths pattern matches anything", () => { const { layer, conn } = setup(tmp.current, { @@ -2931,25 +2944,28 @@ describe("db reset", () => { }, ); - it.live("ignores a partial schema_paths glob failure once at least one pattern matches", () => { - const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql", "typo/*.sql"]\n', - files: { - "supabase/schemas/01_users.sql": "create table schema_users ();", - // Present so the seed glob's own "no files matched" warning doesn't show up here too. - "supabase/seed.sql": "insert into t values (1);", - }, - experimental: true, - confirm: [true], - }); - return Effect.gen(function* () { - yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); - expect(out.stderrText).not.toContain("no files matched pattern"); - }); - }); + it.effect( + "ignores a partial schema_paths glob failure once at least one pattern matches", + () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql", "typo/*.sql"]\n', + files: { + "supabase/schemas/01_users.sql": "create table schema_users ();", + // Present so the seed glob's own "no files matched" warning doesn't show up here too. + "supabase/seed.sql": "insert into t values (1);", + }, + experimental: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); + expect(out.stderrText).not.toContain("no files matched pattern"); + }); + }, + ); - it.live( + it.effect( "attaches Go's schema-file suggestion when a schema file fails to apply on an experimental remote reset", () => { const { layer } = setup(tmp.current, { @@ -3021,7 +3037,7 @@ describe("db reset", () => { return overridden; }); - it.live( + it.effect( "does not attach the schema-file suggestion when a schema file cannot be READ on an experimental remote reset", () => { const { layer, conn } = setup(tmp.current, { @@ -3059,7 +3075,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "fails an experimental remote reset (without silently succeeding) when a matched schema_paths directory cannot be walked", () => { const { layer, conn } = setup(tmp.current, { @@ -3101,7 +3117,7 @@ describe("db reset", () => { }, ); - it.live( + it.effect( "takes the native experimental schema-files path via SUPABASE_EXPERIMENTAL in the project .env", () => { const { layer, out, conn } = setup(tmp.current, { @@ -3125,7 +3141,7 @@ describe("db reset", () => { }, ); - it.live("attaches the Go seed-flag conflict suggestion to --no-seed + --sql-paths", () => { + it.effect("attaches the Go seed-flag conflict suggestion to --no-seed + --sql-paths", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ @@ -3143,7 +3159,7 @@ describe("db reset", () => { }); }); - it.live( + it.effect( "applies configured schema files and skips seeding on an experimental remote --db-url reset", () => { const { layer, conn, resolver } = setup(tmp.current, { @@ -3166,7 +3182,7 @@ describe("db reset", () => { }, ); - it.live("recreates to a specific --version on a local db-url reset", () => { + it.effect("recreates to a specific --version on a local db-url reset", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -3184,7 +3200,7 @@ describe("db reset", () => { }); }); - it.live("resets a remote --db-url target without loading a remote config override", () => { + it.effect("resets a remote --db-url target without loading a remote config override", () => { const { layer, out, conn } = setup(tmp.current, { // No config file → embedded defaults (migrations + seed enabled). files: migrationFile("20240101000000"), @@ -3203,7 +3219,7 @@ describe("db reset", () => { }); }); - it.live("announces a matching [remotes.*] override", () => { + it.effect("announces a matching [remotes.*] override", () => { const { layer, out } = setup(tmp.current, { toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${VALID_REF}"\n`, confirm: [true], @@ -3215,7 +3231,7 @@ describe("db reset", () => { }); }); - it.live("skips migrations and seed when both are disabled in config", () => { + it.effect("skips migrations and seed when both are disabled in config", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n\n[db.seed]\nenabled = false\n', files: { @@ -3232,7 +3248,7 @@ describe("db reset", () => { }); }); - it.live("emits a json result for a confirmed remote reset (--yes)", () => { + it.effect("emits a json result for a confirmed remote reset (--yes)", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -3246,7 +3262,7 @@ describe("db reset", () => { }); }); - it.live("emits a json result for a confirmed remote reset", () => { + it.effect("emits a json result for a confirmed remote reset", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -3263,7 +3279,7 @@ describe("db reset", () => { }); }); - it.live("rejects --no-seed together with --sql-paths", () => { + it.effect("rejects --no-seed together with --sql-paths", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ @@ -3279,7 +3295,7 @@ describe("db reset", () => { }); }); - it.live("rejects an empty --sql-paths value", () => { + it.effect("rejects an empty --sql-paths value", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ @@ -3296,7 +3312,7 @@ describe("db reset", () => { }); }); - it.live("rejects a negative --last value", () => { + it.effect("rejects a negative --last value", () => { const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ @@ -3313,7 +3329,7 @@ describe("db reset", () => { }); }); - it.live("seeds an absolute --sql-paths file on a remote reset", () => { + it.effect("seeds an absolute --sql-paths file on a remote reset", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -3336,7 +3352,7 @@ describe("db reset", () => { }).pipe(Effect.provide(BunServices.layer)); }); - it.live("warns and seeds from --sql-paths overriding config on a remote reset", () => { + it.effect("warns and seeds from --sql-paths overriding config on a remote reset", () => { const { layer, out } = setup(tmp.current, { // Seed disabled in config — --sql-paths must force-enable it. toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', @@ -3357,7 +3373,7 @@ describe("db reset", () => { }); }); - it.live( + it.effect( "seeds from --sql-paths on an experimental remote reset, independently of the schema-files apply", () => { const { layer, out, conn } = setup(tmp.current, { diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts index 532fcfcee0..6f0a7cc02a 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts @@ -140,6 +140,6 @@ export class DeclarativeApplyError extends Data.TaggedError("DeclarativeApplyErr */ export function readErrorSuggestion(error: unknown): string | undefined { if (typeof error !== "object" || error === null || !("suggestion" in error)) return undefined; - const { suggestion } = error as { suggestion: unknown }; + const { suggestion } = error; return typeof suggestion === "string" ? suggestion : undefined; } diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.former-default.ts b/apps/cli/src/commands/db/schema/declarative/declarative.former-default.ts index c97ba22492..89d2f56fb0 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.former-default.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.former-default.ts @@ -35,13 +35,13 @@ export const warnFormerDeclarativeDefault = Effect.fnUntraced(function* ( const defaultDirRel = resolveDeclarativeDir(path, pgDelta); const defaultEntries = yield* fs .readDirectory(path.resolve(workdir, defaultDirRel)) - .pipe(Effect.orElseSucceed(() => [] as string[])); + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); if (defaultEntries.length > 0) return; const formerDirRel = path.join(...FORMER_DEFAULT_SEGMENTS); const formerDir = path.resolve(workdir, formerDirRel); const formerSqlFiles = yield* walkSqlFiles(fs, formerDir, "").pipe( - Effect.orElseSucceed(() => [] as string[]), + Effect.orElseSucceed((): ReadonlyArray => []), ); const formerHasManifest = yield* fs .exists(path.join(formerDir, ".pgdelta-export.json")) diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.gate.unit.test.ts b/apps/cli/src/commands/db/schema/declarative/declarative.gate.unit.test.ts index 7c4ddc86ed..ff8185a783 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.gate.unit.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.gate.unit.test.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Exit } from "effect"; +import { Effect, Exit } from "effect"; import { describe, expect, it } from "@effect/vitest"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; @@ -42,22 +42,16 @@ describe("requirePgDelta", () => { it.effect("fails with DeclarativeNotEnabledError when the gate is closed", () => Effect.gen(function* () { - const exit = yield* requirePgDelta({ + const error = yield* requirePgDelta({ experimental: false, pgDeltaEnabled: false, configPath: "supabase/config.toml", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = exit.cause.reasons.find(Cause.isFailReason)?.error; - expect(error).toBeInstanceOf(DeclarativeNotEnabledError); - expect(error?.message).toBe( - "declarative commands require --experimental flag or pg-delta enabled in config", - ); - expect(stripAnsi((error as DeclarativeNotEnabledError).suggestion)).toBe( - EXPECTED_SUGGESTION, - ); - } + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(DeclarativeNotEnabledError); + expect(error.message).toBe( + "declarative commands require --experimental flag or pg-delta enabled in config", + ); + expect(stripAnsi(error.suggestion)).toBe(EXPECTED_SUGGESTION); }), ); }); diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index e0f920fdac..b0330f6ac6 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import type { DbTomlValues } from "../../../../command-internal/db-config.toml-read.ts"; import { @@ -184,14 +184,10 @@ describe("diffDeclarativeToMigrations", () => { "create table public.accounts();", ); yield* fs.writeFileString(path.join(declDir, ".pgdelta-export.json"), "{ not json at all"); - const exit = yield* diffDeclarativeToMigrations(ctx(dir, declDir), toml).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = exit.cause.reasons.find(Cause.isFailReason)?.error; - expect(String((error as { message?: string } | undefined)?.message)).toBe( - `malformed export manifest ${path.join(declDir, ".pgdelta-export.json")}: ${jsonParseErrorMessage("{ not json at all")}`, - ); - } + const error = yield* diffDeclarativeToMigrations(ctx(dir, declDir), toml).pipe(Effect.flip); + expect(error.message).toBe( + `malformed export manifest ${path.join(declDir, ".pgdelta-export.json")}: ${jsonParseErrorMessage("{ not json at all")}`, + ); expect(calls).toEqual([]); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(stubEngine(calls), BunServices.layer))); }); @@ -202,17 +198,11 @@ describe("diffDeclarativeToMigrations", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "decl-orch-" }); - const exit = yield* diffDeclarativeToMigrations( + const error = yield* diffDeclarativeToMigrations( ctx(dir, path.join(dir, "missing")), toml, - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = exit.cause.reasons.find(Cause.isFailReason)?.error; - expect((error as { message: string }).message).toContain( - "No declarative schema directory found", - ); - } + ).pipe(Effect.flip); + expect(error.message).toContain("No declarative schema directory found"); expect(calls).toEqual([]); }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(stubEngine(calls), BunServices.layer))); }); diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts index fbe93b4d58..e173023860 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path, Ref } from "effect"; import { DnsResolverFlag, @@ -69,7 +69,7 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene // The resolved linked ref (explicit `--linked` only), hoisted so the post-run // linked-project cache finalizer can read it after the body resolves it. - let linkedProjectRef: string | undefined; + const linkedProjectRef = yield* Ref.make(undefined); yield* Effect.gen(function* () { const baseToml = yield* readDbToml(fs, path, cliSettings.workdir); @@ -107,7 +107,7 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene ? cliSettings.projectId : yield* readProjectRefFile(fs, path, cliSettings.workdir); if (Option.isSome(linkedRef)) { - linkedProjectRef = linkedRef.value; + yield* Ref.set(linkedProjectRef, linkedRef.value); toml = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef.value); } } @@ -136,6 +136,7 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); const local: LocalConn = { port: toml.port, password: toml.password }; + const resolvedLinkedProjectRef = yield* Ref.get(linkedProjectRef); const run: DeclarativeRunContext = { pgDelta: { // `resolvePgDeltaProjectId` resolves `SUPABASE_PROJECT_ID` env → config.toml's @@ -157,7 +158,9 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene debug: yield* isPgDeltaDebugEnabled, strictCoverage: flags.strictCoverage, dnsResolver, - ...(linkedProjectRef !== undefined ? { linkedProjectRef } : {}), + ...(resolvedLinkedProjectRef !== undefined + ? { linkedProjectRef: resolvedLinkedProjectRef } + : {}), }; const hasExplicitTarget = @@ -220,7 +223,7 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene Effect.orElseSucceed(() => Option.none()), ); if (Option.isSome(linkedRef)) { - linkedProjectRef = linkedRef.value; + yield* Ref.set(linkedProjectRef, linkedRef.value); } } target = yield* resolveSmartTargetEndpoint( @@ -264,8 +267,8 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene // explicit `--linked` resolves a ref here; the cache layer no-ops when the file exists, the // token is missing, or the GET is non-200. Effect.ensuring( - Effect.suspend(() => - linkedProjectRef !== undefined ? linkedProjectCache.cache(linkedProjectRef) : Effect.void, + Effect.flatMap(Ref.get(linkedProjectRef), (ref) => + ref !== undefined ? linkedProjectCache.cache(ref) : Effect.void, ), ), Effect.ensuring(telemetryState.flush), @@ -275,7 +278,9 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene const hasDeclarativeFiles = Effect.fnUntraced(function* (fs: FileSystem.FileSystem, dir: string) { const exists = yield* fs.exists(dir).pipe(Effect.orElseSucceed(() => false)); if (!exists) return false; - const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [] as string[])); + const entries = yield* fs + .readDirectory(dir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); return entries.length > 0; }); @@ -290,10 +295,8 @@ const confirmOverwriteHasFiles = Effect.fnUntraced(function* ( const entries = yield* fs .readDirectory(dir) .pipe( - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.succeed>([]) - : Effect.fail(error), + Effect.catchReason("PlatformError", "NotFound", () => + Effect.succeed>([]), ), ); return entries.length > 0; @@ -308,7 +311,7 @@ const hasMigrationFiles = Effect.fnUntraced(function* ( // path-is-a-file, not-exist, …), so generate continues into the no-migrations local flow. The // real diff path keeps `listLocalMigrations`'s hard error behavior instead. const migrations = yield* listLocalMigrations(fs, path, migrationsDir).pipe( - Effect.orElseSucceed(() => [] as ReadonlyArray), + Effect.orElseSucceed((): ReadonlyArray => []), ); return migrations.length > 0; }); diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 3d12ded7ab..c8a74ec2cf 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -1,17 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Redacted, - Schema, - Stream, -} from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Schema, Stream } from "effect"; import { StackError, type DatabaseInstance, type Stack } from "@supabase/stack/effect"; import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; import { unusedGateway } from "../../../../../../tests/helpers/unused-stack.ts"; @@ -63,6 +52,7 @@ import { import { PgDeltaEngine, PgDeltaEngineError } from "../../../shared/pgdelta-engine.service.ts"; import { DeclarativeShadowDbError } from "../../../shared/pgdelta.errors.ts"; import { DeclarativeSeam } from "../../../shared/pgdelta.seam.service.ts"; +import { DeclarativeNotEnabledError } from "../declarative.errors.ts"; import type { DbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; import { dbSchemaDeclarativeGenerate } from "./generate.handler.ts"; @@ -391,9 +381,6 @@ const flags = ( password: over.password ?? Option.none(), }); -const failError = (exit: Exit.Exit) => - Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; - const seedMigration = (workdir: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -409,22 +396,20 @@ describe("db schema declarative generate integration", () => { it.effect("gate: fails when neither --experimental nor config enables pg-delta", () => { const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("DeclarativeNotEnabledError"); + expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); }); it.effect("--local --linked with --experimental fails with the mutex error", () => { const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeGenerate(flags({ local: Option.some(true), linked: Option.some(true) })), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeMutuallyExclusiveFlagsError", message: "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", @@ -437,13 +422,12 @@ describe("db schema declarative generate integration", () => { () => { const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeGenerate( flags({ local: Option.some(true), linked: Option.some(true) }), ), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("DeclarativeNotEnabledError"); + expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); }, ); @@ -454,16 +438,15 @@ describe("db schema declarative generate integration", () => { const { layer } = setup(tmp.current, { experimental: false }); const ENV = "SUPABASE_EXPERIMENTAL"; return Effect.gen(function* () { - const exit = yield* withConfigEnv( + const error = yield* withConfigEnv( { [ENV]: "1" }, - Effect.exit( + Effect.flip( dbSchemaDeclarativeGenerate( flags({ local: Option.some(true), linked: Option.some(true) }), ), ), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeMutuallyExclusiveFlagsError", message: "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", @@ -481,12 +464,11 @@ describe("db schema declarative generate integration", () => { }); const ENV = "SUPABASE_EXPERIMENTAL"; return Effect.gen(function* () { - const exit = yield* withConfigEnv( + const error = yield* withConfigEnv( { [ENV]: "1" }, - Effect.exit(dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) }))), + Effect.flip(dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) }))), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("DeclarativeNotEnabledError"); + expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); }, ); @@ -504,13 +486,12 @@ describe("db schema declarative generate integration", () => { path.join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n", ); - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeGenerate( flags({ local: Option.some(true), linked: Option.some(true) }), ), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeMutuallyExclusiveFlagsError", message: "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", @@ -547,7 +528,7 @@ describe("db schema declarative generate integration", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - expect(s.engineExportCalls[0]!.targetRef).toContain( + expect(s.engineExportCalls[0]?.targetRef).toContain( "postgresql://postgres:postgres@127.0.0.1:54322", ); const written = yield* fs.readFileString( @@ -571,10 +552,10 @@ describe("db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true, stackBackend: true }); return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - expect(s.engineExportCalls[0]!.targetRef).toContain( + expect(s.engineExportCalls[0]?.targetRef).toContain( "postgresql://postgres:stack-secret@127.0.0.1:54329", ); - expect(s.engineExportCalls[0]!.targetRef).not.toContain(":54322"); + expect(s.engineExportCalls[0]?.targetRef).not.toContain(":54322"); }).pipe(Effect.provide(s.layer)); }, ); @@ -667,11 +648,10 @@ describe("db schema declarative generate integration", () => { const s = setup(projectDir, { experimental: true }); yield* Effect.gen(function* () { for (const output of ["", ".", "..", path.dirname(projectDir)]) { - const exit = yield* dbSchemaDeclarativeGenerate( + const error = yield* dbSchemaDeclarativeGenerate( flags({ local: Option.some(true), outputDir: Option.some(output), overwrite: true }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + ).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "DeclarativeWriteError", message: "declarative output directory must not be empty, resolve to the project directory, or contain the project directory", @@ -703,11 +683,10 @@ describe("db schema declarative generate integration", () => { it.effect("explicit --local checks the local Postgres image before generating", () => { const s = setup(tmp.current, { experimental: true, staleLocalImage: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeShadowDbError", message: "local Postgres container image is stale", }); @@ -764,7 +743,7 @@ describe("db schema declarative generate integration", () => { return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags({ dbUrl: Option.some("postgres://remote/db") })); expect(s.resolverCalls.length).toBe(1); - expect(s.engineExportCalls[0]!.targetRef).toContain("@db.remote:5432"); + expect(s.engineExportCalls[0]?.targetRef).toContain("@db.remote:5432"); expect(s.ensureStartedCalls).toBe(0); }).pipe(Effect.provide(s.layer)); }); @@ -824,7 +803,7 @@ describe("db schema declarative generate integration", () => { path.join(tmp.current, "supabase", "remote_schema", "public", "tables", "players.sql"), ); expect(written).toBe("create table players ();"); - expect(s.engineExportCalls[0]!.projectRef).toBe(ref); + expect(s.engineExportCalls[0]?.projectRef).toBe(ref); }).pipe(Effect.provide(s.layer)); }); @@ -880,11 +859,10 @@ describe("db schema declarative generate integration", () => { "", ].join("\n"), ); - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("DeclarativeNotEnabledError"); + expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(s.layer)); }, ); @@ -892,11 +870,10 @@ describe("db schema declarative generate integration", () => { it.effect("smart mode: non-TTY without --yes fails with the target hint", () => { const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: false }); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags())); - expect(Exit.isFailure(exit)).toBe(true); - expect((failError(exit) as { message: string }).message).toContain( - "in non-interactive mode, specify a target", - ); + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags())); + expect(error).toMatchObject({ + message: expect.stringContaining("in non-interactive mode, specify a target"), + }); }).pipe(Effect.provide(s.layer)); }); @@ -984,11 +961,10 @@ describe("db schema declarative generate integration", () => { it.effect("fails generate when the engine export fails", () => { const s = setup(tmp.current, { experimental: true, exportFails: true }); return Effect.gen(function* () { - const exit = yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })).pipe( - Effect.exit, + const error = yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })).pipe( + Effect.flip, ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "PgDeltaEngineError", message: "declarative export failed", }); @@ -1007,9 +983,8 @@ describe("db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* seedMigration(tmp.current); - const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags({ reset: true }))); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags({ reset: true }))); + expect(error).toMatchObject({ message: "database reset failed: supabase start is not running.", }); expect(localResetRemovedContainers(s.child.spawned)).toEqual([]); @@ -1042,9 +1017,8 @@ describe("db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* seedMigration(tmp.current); - const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags())); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags())); + expect(error).toMatchObject({ _tag: "DeclarativeShadowDbError", message: "local Postgres container image is stale", }); @@ -1205,9 +1179,8 @@ describe("db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* seedMigration(tmp.current); - const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags())); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + const error = yield* Effect.flip(dbSchemaDeclarativeGenerate(flags())); + expect(error).toMatchObject({ _tag: "DeclarativeInvalidDbUrlError", message: "failed to parse connection string: not a url", }); @@ -1224,7 +1197,7 @@ describe("db schema declarative generate integration", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current); yield* dbSchemaDeclarativeGenerate(flags()); - expect(s.engineExportCalls[0]!.targetRef).toContain( + expect(s.engineExportCalls[0]?.targetRef).toContain( "@db.example.com:5432/app?connect_timeout=", ); }).pipe(Effect.provide(s.layer)); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index e4caa00467..4e09fc904e 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -1,4 +1,4 @@ -import { Cause, Clock, DateTime, Effect, Exit, FileSystem, Option, Path, Result } from "effect"; +import { Clock, DateTime, Effect, FileSystem, Option, Path, Ref, Result } from "effect"; import { DnsResolverFlag, @@ -106,7 +106,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f // Set when the bootstrap branch below resolves a linked ref, so the `Effect.ensuring` // finalizer at the end of this handler can refresh the linked-project cache. - let linkedProjectRef: string | undefined; + const linkedProjectRef = yield* Ref.make(undefined); yield* Effect.gen(function* () { const toml = yield* readDbToml(fs, path, cliSettings.workdir); @@ -174,13 +174,11 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f // bundle was saved. const saveApplyDebugBundle = (bundle: DebugBundle) => saveDebugBundle(fs, path, cliSettings.workdir, tempDir, migrationsDir, bundle).pipe( - Effect.matchEffect({ - onFailure: (error) => - output - .raw(`Warning: failed to save debug artifacts: ${error.message}\n`, "stderr") - .pipe(Effect.as("")), - onSuccess: Effect.succeed, - }), + Effect.catch((error) => + output + .raw(`Warning: failed to save debug artifacts: ${error.message}\n`, "stderr") + .pipe(Effect.as("")), + ), ); // Step 1: declarative files must exist; in a TTY, offer to generate them. @@ -204,7 +202,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f // keeps the hard list behavior. const hasMigrations = (yield* listLocalMigrations(fs, path, migrationsDir).pipe( - Effect.orElseSucceed(() => [] as ReadonlyArray), + Effect.orElseSucceed((): ReadonlyArray => []), )).length > 0; // Only when migrations exist, resolve the ref (config `project_id` → `.temp/project-ref`) // and record it for the finalizer, so a linked-workdir bootstrap caches regardless of the @@ -220,7 +218,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f Effect.orElseSucceed(() => Option.none()), ); if (Option.isSome(linkedRef)) { - linkedProjectRef = linkedRef.value; + yield* Ref.set(linkedProjectRef, linkedRef.value); } } // sync has no target flags, so reset stays interactive (the prompt fires under the local @@ -352,14 +350,9 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f const planWithLoadRecovery = Effect.fnUntraced(function* () { while (true) { - const attempt = yield* planDeclarativeSync().pipe( - Effect.match({ - onFailure: (error) => ({ error }), - onSuccess: (result) => ({ result }), - }), - ); - if ("result" in attempt) return Option.some(attempt.result); - const error = attempt.error; + const attempt = yield* planDeclarativeSync().pipe(Effect.result); + if (Result.isSuccess(attempt)) return Option.some(attempt.success); + const error = attempt.failure; if (!(error instanceof DeclarativeCompatibilityError) || error.loadFindings === undefined) { return yield* error; } @@ -593,7 +586,9 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f port: toml.port, password: toml.password, }; - const applyExit = yield* applyMigrationToLocal( + // A Ctrl-C or defect during the apply is not a migration-apply failure — propagate it + // unchanged instead of synthesizing a fake `DeclarativeApplyError`. + const applyResult = yield* applyMigrationToLocal( { host: applyTarget.host, port: applyTarget.port, @@ -601,22 +596,15 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f dnsResolver, }, migrationPaths, - ).pipe(Effect.exit); + ).pipe(Effect.result); - if (Exit.isSuccess(applyExit)) { + if (Result.isSuccess(applyResult)) { yield* output.raw("Migration applied successfully.\n", "stderr"); return; } - // A Ctrl-C or defect during the apply is not a migration-apply failure — propagate it - // unchanged instead of synthesizing a fake `DeclarativeApplyError`. - const applyFailure = Cause.findFail(applyExit.cause); - if (Result.isFailure(applyFailure)) { - return yield* Effect.failCause(applyFailure.failure); - } - // Apply failed: print, save a debug bundle, and (in a TTY) offer reset+reapply. - const applyError = applyFailure.success.error; + const applyError = applyResult.failure; yield* output.raw(`${red(`Migration failed to apply: ${applyError.message}`)}\n`, "stderr"); const ts = formatDebugId(yield* Clock.currentTimeMillis); const migrations = yield* collectMigrationsList(fs, path, migrationsDir); @@ -637,18 +625,14 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f if (shouldReset) { // `resetLocalDatabase` runs in-process, sharing this command's own context: it resolves // `NetworkIdFlag` itself, so no argv-forwarding is needed to stay on a custom network. - const resetExit = yield* resetLocalDatabase().pipe(Effect.exit); - if (Exit.isFailure(resetExit)) { - // A Ctrl-C or defect during the recovery reset must cancel the command, not get - // rewritten into a synthetic "unknown error" apply failure. - const resetFailure = Cause.findFail(resetExit.cause); - if (Result.isFailure(resetFailure)) { - return yield* Effect.failCause(resetFailure.failure); - } + // A Ctrl-C or defect during the recovery reset must cancel the command, not get + // rewritten into a synthetic "unknown error" apply failure. + const resetResult = yield* resetLocalDatabase().pipe(Effect.result); + if (Result.isFailure(resetResult)) { // Surfaces the failure that actually blocked recovery, not the original apply error, // printed exactly once (no extra "database reset failed:" wrapper) — build it from the // real typed failure and reuse that one value for message, suggestion, and bundle. - const rawResetFailure = resetFailure.success.error; + const rawResetFailure = resetResult.failure; const resetError = new DeclarativeApplyError({ message: rawResetFailure.message, suggestion: readErrorSuggestion(rawResetFailure), @@ -689,8 +673,8 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f // sync succeeds or fails; only the linked bootstrap sets `linkedProjectRef`, so non-linked // syncs never trigger this. Effect.ensuring( - Effect.suspend(() => - linkedProjectRef !== undefined ? linkedProjectCache.cache(linkedProjectRef) : Effect.void, + Effect.flatMap(Ref.get(linkedProjectRef), (ref) => + ref !== undefined ? linkedProjectCache.cache(ref) : Effect.void, ), ), Effect.ensuring(telemetryState.flush), @@ -703,47 +687,44 @@ const declarativeDirHasFiles = Effect.fnUntraced(function* ( ) { const exists = yield* fs.exists(dir).pipe(Effect.orElseSucceed(() => false)); if (!exists) return false; - const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [] as string[])); + const entries = yield* fs + .readDirectory(dir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); return entries.length > 0; }); /** Connects once and applies the ordered migration files. */ -const applyMigrationToLocal = ( +const applyMigrationToLocal = Effect.fn("db.schema.declarative.sync.apply")(function* ( local: { host: string; port: number; password: string; dnsResolver: "native" | "https" }, migrationPaths: ReadonlyArray, -) => - Effect.gen(function* () { - const dbConnection = yield* DbConnection; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const session = yield* dbConnection - .connect( - { - host: local.host, - port: local.port, - user: "postgres", - password: local.password, - database: "postgres", - }, - { isLocal: true, dnsResolver: local.dnsResolver }, - ) - .pipe( - Effect.mapError( - (error) => new DeclarativeApplyError({ message: error.message, connect: true }), - ), - ); - for (const migrationPath of migrationPaths) { - yield* applyMigrationFile( - session, - fs, - path, - migrationPath, - (message) => new DeclarativeApplyError({ message }), - ); - } - }).pipe( - Effect.scoped, - Effect.withSpan("db.schema.declarative.sync.apply", { - attributes: { "migration.count": migrationPaths.length }, - }), - ); +) { + yield* Effect.annotateCurrentSpan({ "migration.count": migrationPaths.length }); + const dbConnection = yield* DbConnection; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const session = yield* dbConnection + .connect( + { + host: local.host, + port: local.port, + user: "postgres", + password: local.password, + database: "postgres", + }, + { isLocal: true, dnsResolver: local.dnsResolver }, + ) + .pipe( + Effect.mapError( + (error) => new DeclarativeApplyError({ message: error.message, connect: true }), + ), + ); + for (const migrationPath of migrationPaths) { + yield* applyMigrationFile( + session, + fs, + path, + migrationPath, + (message) => new DeclarativeApplyError({ message }), + ); + } +}, Effect.scoped); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 5def629575..2208ddb7cf 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -60,6 +60,7 @@ import { DbConnection, type PgConnInput, } from "../../../../../command-internal/db-connection.service.ts"; +import { DbExecError } from "../../../../../command-internal/db-connection.errors.ts"; import { PgDeltaEngine, PgDeltaEngineError, @@ -68,6 +69,7 @@ import { } from "../../../shared/pgdelta-engine.service.ts"; import { DeclarativeShadowDbError } from "../../../shared/pgdelta.errors.ts"; import { DeclarativeSeam } from "../../../shared/pgdelta.seam.service.ts"; +import { DeclarativeNotEnabledError } from "../declarative.errors.ts"; import type { DbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; import { dbSchemaDeclarativeSync } from "./sync.handler.ts"; @@ -235,7 +237,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { return Effect.succeed({ exec: (sql: string) => opts.applyFails === true && sql.startsWith("ALTER") - ? Effect.fail({ _tag: "DbExecError", message: "boom" } as never) + ? Effect.fail(new DbExecError({ message: "boom" })) : Effect.sync(() => { if (cfg.port !== SHADOW_PORT) dbExec.push(sql); }), @@ -246,11 +248,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ? sql.findIndex((statement) => statement.startsWith("ALTER")) : -1; return failureIndex >= 0 - ? Effect.fail({ - _tag: "DbExecError", - message: "boom", - statementIndex: failureIndex, - } as never) + ? Effect.fail(new DbExecError({ message: "boom", statementIndex: failureIndex })) : Effect.sync(() => { if (cfg.port !== SHADOW_PORT) { dbBatches.push(sql); @@ -422,9 +420,6 @@ const flags = (over: Partial = {}): DbSchemaDeclar noApply: over.noApply ?? Option.none(), }); -const failError = (exit: Exit.Exit) => - Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; - const seedDeclarative = (workdir: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -492,19 +487,18 @@ describe("db schema declarative sync integration", () => { const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { yield* seedDeclarative(tmp.current); - const exit = yield* Effect.exit(dbSchemaDeclarativeSync(flags())); - expect(failError(exit)?.constructor.name).toBe("DeclarativeNotEnabledError"); + const error = yield* Effect.flip(dbSchemaDeclarativeSync(flags())); + expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); }); it.effect("--apply and --no-apply together with --experimental fail with the mutex error", () => { const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeSync(flags({ apply: Option.some(true), noApply: Option.some(true) })), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeMutuallyExclusiveFlagsError", message: "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", @@ -517,11 +511,10 @@ describe("db schema declarative sync integration", () => { () => { const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeSync(flags({ apply: Option.some(true), noApply: Option.some(true) })), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("DeclarativeNotEnabledError"); + expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); }, ); @@ -532,16 +525,15 @@ describe("db schema declarative sync integration", () => { const { layer } = setup(tmp.current, { experimental: false }); const ENV = "SUPABASE_EXPERIMENTAL"; return Effect.gen(function* () { - const exit = yield* withConfigEnv( + const error = yield* withConfigEnv( { [ENV]: "1" }, - Effect.exit( + Effect.flip( dbSchemaDeclarativeSync( flags({ apply: Option.some(true), noApply: Option.some(true) }), ), ), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeMutuallyExclusiveFlagsError", message: "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", @@ -559,12 +551,11 @@ describe("db schema declarative sync integration", () => { }); const ENV = "SUPABASE_EXPERIMENTAL"; return Effect.gen(function* () { - const exit = yield* withConfigEnv( + const error = yield* withConfigEnv( { [ENV]: "1" }, - Effect.exit(dbSchemaDeclarativeSync(flags())), + Effect.flip(dbSchemaDeclarativeSync(flags())), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("DeclarativeNotEnabledError"); + expect(error).toBeInstanceOf(DeclarativeNotEnabledError); }).pipe(Effect.provide(layer)); }, ); @@ -582,13 +573,12 @@ describe("db schema declarative sync integration", () => { path.join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n", ); - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeSync( flags({ apply: Option.some(true), noApply: Option.some(true) }), ), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeMutuallyExclusiveFlagsError", message: "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", @@ -602,11 +592,10 @@ describe("db schema declarative sync integration", () => { // The gate runs first, so `--experimental` is required here for the mutex error to surface. const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeSync(flags({ apply: Option.some(false), noApply: Option.some(true) })), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeMutuallyExclusiveFlagsError", }); }).pipe(Effect.provide(layer)); @@ -615,11 +604,10 @@ describe("db schema declarative sync integration", () => { it.effect("fails when there are no declarative files", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit(dbSchemaDeclarativeSync(flags())); - expect(Exit.isFailure(exit)).toBe(true); - expect((failError(exit) as { message: string }).message).toContain( - "no declarative schema found", - ); + const error = yield* Effect.flip(dbSchemaDeclarativeSync(flags())); + expect(error).toMatchObject({ + message: expect.stringContaining("no declarative schema found"), + }); expect(stripAnsi(s.out.stderrText)).not.toContain("WARNING: found declarative schema files"); }).pipe(Effect.provide(s.layer)); }); @@ -667,9 +655,10 @@ describe("db schema declarative sync integration", () => { }); return Effect.gen(function* () { yield* seedDeclarative(tmp.current); - const exit = yield* Effect.exit(dbSchemaDeclarativeSync(flags({ apply: Option.some(true) }))); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + const error = yield* Effect.flip( + dbSchemaDeclarativeSync(flags({ apply: Option.some(true) })), + ); + expect(error).toMatchObject({ _tag: "DeclarativeShadowDbError", message: "local Postgres container image is stale", }); @@ -807,7 +796,7 @@ describe("db schema declarative sync integration", () => { }); return Effect.gen(function* () { yield* seedMigration(tmp.current); - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeSync( flags({ noCache: true, @@ -816,8 +805,7 @@ describe("db schema declarative sync integration", () => { }), ), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeShadowDbError", message: "local Postgres container image is stale", }); @@ -836,11 +824,10 @@ describe("db schema declarative sync integration", () => { }); return Effect.gen(function* () { yield* seedMigration(tmp.current); - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), ); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeShadowDbError", message: "local Postgres container image is stale", }); @@ -1030,12 +1017,11 @@ describe("db schema declarative sync integration", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* seedUuidDeclarative(tmp.current); - const exit = yield* dbSchemaDeclarativeSync(flags()).pipe(Effect.exit); - expect(failError(exit)).toMatchObject({ + const error = yield* dbSchemaDeclarativeSync(flags()).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "DeclarativeCompatibilityError", message: expect.stringContaining("schemas/app/tables/members.sql:3"), }); - const error = failError(exit); expect(error).toMatchObject({ message: expect.stringContaining("uuid-ossp"), suggestion: expect.stringContaining( @@ -1169,8 +1155,8 @@ describe("db schema declarative sync integration", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* seedDeclarative(tmp.current); - const exit = yield* dbSchemaDeclarativeSync(flags()).pipe(Effect.exit); - expect(failError(exit)).toMatchObject({ + const error = yield* dbSchemaDeclarativeSync(flags()).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "DeclarativeCompatibilityError", message: expect.stringContaining( "This supabase/schemas tree looks like a legacy pg-delta export.", @@ -1179,7 +1165,7 @@ describe("db schema declarative sync integration", () => { "Upgrade without changing the active supabase/schemas tree:", ), }); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ message: expect.stringContaining(" Extension-managed objects: pg_cron job refresh"), }); expect(yield* fs.exists(path.join(tmp.current, "supabase", "migrations"))).toBe(false); @@ -1222,10 +1208,10 @@ describe("db schema declarative sync integration", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* seedDeclarative(tmp.current); - const exit = yield* dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })).pipe( - Effect.exit, + const error = yield* dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })).pipe( + Effect.flip, ); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeCompatibilityError", message: expect.stringContaining("[experimental.webhooks]\nenabled = true"), }); @@ -1447,9 +1433,10 @@ describe("db schema declarative sync integration", () => { }); return Effect.gen(function* () { yield* seedDeclarative(tmp.current); - const exit = yield* Effect.exit(dbSchemaDeclarativeSync(flags({ apply: Option.some(true) }))); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)).toMatchObject({ + const error = yield* Effect.flip( + dbSchemaDeclarativeSync(flags({ apply: Option.some(true) })), + ); + expect(error).toMatchObject({ message: "supabase start is not running.", }); expect( @@ -1545,10 +1532,10 @@ describe("db schema declarative sync integration", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* seedDeclarative(tmp.current); - const exit = yield* Effect.exit( + const error = yield* Effect.flip( dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), ); - expect(failError(exit)).toMatchObject({ + expect(error).toMatchObject({ _tag: "DeclarativeApplyError", message: 'unknown pg-delta transaction mode "batched"', }); diff --git a/apps/cli/src/commands/db/shared/debug-bundle.ts b/apps/cli/src/commands/db/shared/debug-bundle.ts index d894841427..caac3059b6 100644 --- a/apps/cli/src/commands/db/shared/debug-bundle.ts +++ b/apps/cli/src/commands/db/shared/debug-bundle.ts @@ -96,7 +96,7 @@ export const collectMigrationsList = Effect.fnUntraced(function* ( // unreadable `supabase/migrations` must only omit migration copies, never replace the // actionable original error. The main generate/sync path still fails on an unreadable dir. const migrations = yield* listLocalMigrations(fs, path, migrationsDir).pipe( - Effect.orElseSucceed(() => [] as ReadonlyArray), + Effect.orElseSucceed((): ReadonlyArray => []), ); return migrations.map((p) => path.basename(p)); }); diff --git a/apps/cli/src/commands/db/shared/drop-schemas.ts b/apps/cli/src/commands/db/shared/drop-schemas.ts index eaecc8014d..f0c5db6caa 100644 --- a/apps/cli/src/commands/db/shared/drop-schemas.ts +++ b/apps/cli/src/commands/db/shared/drop-schemas.ts @@ -10,20 +10,18 @@ import { dropObjectsSql } from "../../../command-internal/drop-objects.ts"; * explicit transaction, mapping failures through the caller's error * constructor (no migration-history row). */ -export const dropUserSchemas = ( +export const dropUserSchemas = Effect.fn("DropSchemas.dropUserSchemas")(function* ( session: DbSession, mapError: (message: string) => E, -): Effect.Effect => - Effect.gen(function* () { - // No `RESET ALL` before the drop: resetting would clear caller-supplied DB - // URL runtime params (e.g. `options=-c statement_timeout=…`) on the remote - // `db reset --db-url` path before the destructive statement runs. - yield* session.exec("BEGIN"); - yield* session - .exec(dropObjectsSql) - .pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); - yield* session.exec("COMMIT"); - }).pipe( - Effect.mapError((error: DbExecError) => mapError(error.message)), - Effect.withSpan("DropSchemas.dropUserSchemas"), +): Effect.fn.Return { + const exec = (sql: string) => + session.exec(sql).pipe(Effect.mapError((error: DbExecError) => mapError(error.message))); + // No `RESET ALL` before the drop: resetting would clear caller-supplied DB + // URL runtime params (e.g. `options=-c statement_timeout=…`) on the remote + // `db reset --db-url` path before the destructive statement runs. + yield* exec("BEGIN"); + yield* exec(dropObjectsSql).pipe( + Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), ); + yield* exec("COMMIT"); +}); diff --git a/apps/cli/src/commands/db/shared/pgdelta-declarative-shadow-prep.unit.test.ts b/apps/cli/src/commands/db/shared/pgdelta-declarative-shadow-prep.unit.test.ts index a010a6fb81..bae6cdb378 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-declarative-shadow-prep.unit.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-declarative-shadow-prep.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Effect } from "effect"; import { declaredSqlExtensions, @@ -55,7 +55,7 @@ describe("filesForDeclarativeShadowLoad", () => { }); describe("prepareDeclarativeShadow", () => { - it.live("skips the shadow when declarations omit image-default extensions", () => { + it.effect("skips the shadow when declarations omit image-default extensions", () => { const queries: string[] = []; const client = fakeShadowClient((sql) => { queries.push(sql); @@ -70,7 +70,7 @@ describe("prepareDeclarativeShadow", () => { }); }); - it.live("names the failing prep statement", () => { + it.effect("names the failing prep statement", () => { const client = fakeShadowClient((sql) => { if (sql === "SHOW server_version") { return Promise.resolve({ rows: [{ server_version: "15.8" }] }); @@ -81,21 +81,15 @@ describe("prepareDeclarativeShadow", () => { return Promise.resolve({ rows: [] }); }); return Effect.gen(function* () { - const exit = yield* prepareDeclarativeShadow(client, [ + const error = yield* prepareDeclarativeShadow(client, [ { name: "public/01.sql", sql: "CREATE EXTENSION pgcrypto;" }, - ]).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const error = Exit.isFailure(exit) - ? Option.getOrUndefined(Cause.findErrorOption(exit.cause)) - : undefined; + ]).pipe(Effect.flip); expect(error).toBeInstanceOf(PgDeltaEngineError); - expect(error instanceof PgDeltaEngineError ? error.message : "").toContain( - "DROP EXTENSION IF EXISTS pgcrypto", - ); + expect(error.message).toContain("DROP EXTENSION IF EXISTS pgcrypto"); }); }); - it.live("runs the version-selected prep statements against the shadow", () => { + it.effect("runs the version-selected prep statements against the shadow", () => { const queries: string[] = []; const client = fakeShadowClient((sql) => { queries.push(sql); @@ -115,7 +109,7 @@ describe("prepareDeclarativeShadow", () => { }); }); - it.live("detaches PG14 storage.objects before dropping declared uuid-ossp", () => { + it.effect("detaches PG14 storage.objects before dropping declared uuid-ossp", () => { const queries: string[] = []; const client = fakeShadowClient((sql) => { queries.push(sql); @@ -135,7 +129,7 @@ describe("prepareDeclarativeShadow", () => { }); }); - it.live("restores pgjwt only when the image had it installed", () => { + it.effect("restores pgjwt only when the image had it installed", () => { const queries: string[] = []; const withPgjwt = fakeShadowClient((sql) => { queries.push(sql); diff --git a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts index daa26fe648..75ade774b2 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts @@ -59,7 +59,11 @@ const toml: DbTomlValues = { }; function setup() { - const state = { migrations: 0, plan: 0, planBypassCache: undefined as boolean | undefined }; + const state: { migrations: number; plan: number; planBypassCache: boolean | undefined } = { + migrations: 0, + plan: 0, + planBypassCache: undefined, + }; const shadow = Layer.succeed(PgDeltaNextShadow, { provisionMigrations: () => Effect.sync(() => { diff --git a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts index 329178cfb5..6815739a1f 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts @@ -1,4 +1,4 @@ -import { Clock, Effect, FileSystem, Layer, Path } from "effect"; +import { Clock, Effect, FileSystem, Layer, Path, Ref } from "effect"; import type { Pool } from "pg"; import { Output } from "../../../shared/output/output.service.ts"; @@ -134,7 +134,7 @@ export const pgDeltaNextEngineLayer = Layer.effect( const path = yield* Path.Path; const debugLogger = yield* DebugLogger; const output = yield* Output; - let feedbackInvitationShown = false; + const feedbackInvitationShown = yield* Ref.make(false); const saveDebugArtifacts = ( workdir: string, @@ -177,16 +177,16 @@ export const pgDeltaNextEngineLayer = Layer.effect( Effect.flatMap((connection) => acquirePgPool(connection, endpoint.connectOptions)), ); - const reportDiagnostics = ( + const reportDiagnostics = Effect.fnUntraced(function* ( operation: PgDeltaNextOperation, diagnostics: Parameters[1], strictCoverage: boolean, verboseDiagnostics: boolean, - ) => { + ) { const report = pgDeltaNextDiagnosticReport(diagnostics, strictCoverage); - const showFeedback = !feedbackInvitationShown && report.unmodeledKinds.length > 0; - if (showFeedback) feedbackInvitationShown = true; - return reportPgDeltaNextDiagnostics( + const showFeedback = + report.unmodeledKinds.length > 0 && !(yield* Ref.getAndSet(feedbackInvitationShown, true)); + return yield* reportPgDeltaNextDiagnostics( operation, diagnostics, strictCoverage, @@ -196,7 +196,7 @@ export const pgDeltaNextEngineLayer = Layer.effect( Effect.provideService(Output, output), Effect.provideService(DebugLogger, debugLogger), ); - }; + }); const diffPools = Effect.fn("PgDeltaNextEngine.diff")(function* ( input: { @@ -235,172 +235,164 @@ export const pgDeltaNextEngineLayer = Layer.effect( }); return PgDeltaEngine.of({ - diffExplicit: (input) => - Effect.scoped( - Effect.gen(function* () { - let shadow: { readonly migrationsUrl: string } | undefined; - const migrationsEndpoint = - input.source.kind === "migrations" - ? input.source - : input.desired.kind === "migrations" - ? input.desired - : undefined; - if (migrationsEndpoint !== undefined) { - if (input.toml === undefined) { - return yield* new PgDeltaEngineError({ - message: "pg-delta migrations endpoint requires loaded database config", - cause: "missing database config", - }); - } - shadow = yield* shadowService.provisionMigrations({ - context: input.context, - toml: input.toml, - ...(migrationsEndpoint.projectRef !== undefined - ? { projectRef: migrationsEndpoint.projectRef } - : {}), - }); - } - const endpointPool = (endpoint: PgDeltaEndpoint) => - Effect.gen(function* () { - if (endpoint.kind === "database") { - return yield* acquireDatabase(endpoint, input.context.projectEnv); - } - if (shadow === undefined) { - return yield* Effect.die("missing pg-delta migrations shadow"); - } - const connection = yield* parseConnectionString(shadow.migrationsUrl); - if (connection === undefined) { - return yield* new PgDeltaEngineError({ - message: "failed to parse pg-delta migrations shadow URL", - cause: redactConnectionString(shadow.migrationsUrl), - }); - } - return yield* acquirePgPool(connection, { - isLocal: true, - dnsResolver: "native", - }); - }); - const [sourcePool, desiredPool] = yield* Effect.all( - [endpointPool(input.source), endpointPool(input.desired)], - { concurrency: 2 }, - ); - return yield* diffPools(input, sourcePool, desiredPool); - }), - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.mapError(pgDeltaNextEngineError), - Effect.withSpan("PgDeltaNextEngine.diffExplicit"), - ), - diffDatabase: (input) => - Effect.scoped( - Effect.gen(function* () { - const migrationsPool = yield* acquireDatabase(input.source, input.context.projectEnv); - const desiredPool = yield* acquireDatabase(input.target, input.context.projectEnv); - return yield* diffPools(input, migrationsPool, desiredPool); - }), - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.mapError(pgDeltaNextEngineError), - Effect.withSpan("PgDeltaNextEngine.diffDatabase"), - ), - exportDeclarativeSchema: (input) => - Effect.scoped( - Effect.gen(function* () { - const pool = yield* acquireDatabase(input.target, input.context.projectEnv); - const result = yield* adapter.exportDeclarativeSchema({ - pool, - schema: input.schema, - formatOptions: input.formatOptions, - }); - if (input.debug) { - const capture = yield* adapter - .captureSnapshot({ pool }) - .pipe(Effect.orElseSucceed(() => undefined)); - yield* saveDebugArtifacts(input.context.cwd, "declarativeExport", { - ...(capture !== undefined ? { desiredSnapshot: capture.snapshot } : {}), - diagnostics: - capture === undefined - ? result.diagnostics - : [...result.diagnostics, ...capture.diagnostics], + diffExplicit: Effect.fn("PgDeltaNextEngine.diffExplicit")( + function* (input) { + let shadow: { readonly migrationsUrl: string } | undefined; + const migrationsEndpoint = + input.source.kind === "migrations" + ? input.source + : input.desired.kind === "migrations" + ? input.desired + : undefined; + if (migrationsEndpoint !== undefined) { + if (input.toml === undefined) { + return yield* new PgDeltaEngineError({ + message: "pg-delta migrations endpoint requires loaded database config", + cause: "missing database config", }); } - yield* reportDiagnostics( - "declarativeExport", - result.diagnostics, - input.strictCoverage, - input.debug, - ); - return { files: result.files, manifest: result.manifest }; - }), - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.mapError(pgDeltaNextEngineError), - Effect.withSpan("PgDeltaNextEngine.exportDeclarativeSchema"), - ), - planDeclarativeSchema: (input) => - Effect.scoped( - Effect.gen(function* () { - const shadow = yield* shadowService.provisionPlan({ + shadow = yield* shadowService.provisionMigrations({ context: input.context, toml: input.toml, - ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), - ...(input.noCache ? { bypassCache: true } : {}), + ...(migrationsEndpoint.projectRef !== undefined + ? { projectRef: migrationsEndpoint.projectRef } + : {}), }); - const migrations = yield* parseConnectionString(shadow.migrationsUrl); - const declarative = yield* parseConnectionString(shadow.declarativeUrl); - if (migrations === undefined || declarative === undefined) { - return yield* new PgDeltaEngineError({ - message: "failed to parse pg-delta next shadow database URL", - cause: "invalid password-free shadow output", + } + const endpointPool = (endpoint: PgDeltaEndpoint) => + Effect.gen(function* () { + if (endpoint.kind === "database") { + return yield* acquireDatabase(endpoint, input.context.projectEnv); + } + if (shadow === undefined) { + return yield* Effect.die("missing pg-delta migrations shadow"); + } + const connection = yield* parseConnectionString(shadow.migrationsUrl); + if (connection === undefined) { + return yield* new PgDeltaEngineError({ + message: "failed to parse pg-delta migrations shadow URL", + cause: redactConnectionString(shadow.migrationsUrl), + }); + } + return yield* acquirePgPool(connection, { + isLocal: true, + dnsResolver: "native", }); - } - const [migrationsPool, declarativePool] = yield* Effect.all( - [ - acquirePgPool(migrations, { isLocal: true, dnsResolver: "native" }), - acquirePgPool(declarative, { isLocal: true, dnsResolver: "native" }), - ], - { concurrency: 2 }, - ); - const prep = yield* prepareDeclarativeShadow(declarativePool, input.files); - const result = yield* adapter.planDeclarativeSchema({ - targetPool: migrationsPool, - shadowPool: declarativePool, - files: filesForDeclarativeShadowLoad(input.files, prep.restorePgjwt), - allowDrops: true, - ...(shadow.allowSameDatabaseIdentity ? { allowSameDatabaseIdentity: true } : {}), - debug: input.debug, - schema: input.schema, - formatOptions: input.formatOptions, - ...(input.manifest !== undefined ? { manifest: input.manifest } : {}), }); - const debugDirectory = - result.debug !== undefined - ? yield* saveDebugArtifacts(input.context.cwd, "declarativePlan", { - ...result.debug, - diagnostics: result.diagnostics, - }) - : undefined; - yield* reportDiagnostics( - "declarativePlan", - result.diagnostics, - input.strictCoverage, - input.debug, - ); - return { - ...normalizeNextDiff(result, debugDirectory), - sourceRef: "pg-delta-next:migrations", - targetRef: "pg-delta-next:declarative", - }; - }), - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.mapError(pgDeltaNextEngineError), - Effect.withSpan("PgDeltaNextEngine.planDeclarativeSchema"), - ), + const [sourcePool, desiredPool] = yield* Effect.all( + [endpointPool(input.source), endpointPool(input.desired)], + { concurrency: 2 }, + ); + return yield* diffPools(input, sourcePool, desiredPool); + }, + Effect.scoped, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError(pgDeltaNextEngineError), + ), + diffDatabase: Effect.fn("PgDeltaNextEngine.diffDatabase")( + function* (input) { + const migrationsPool = yield* acquireDatabase(input.source, input.context.projectEnv); + const desiredPool = yield* acquireDatabase(input.target, input.context.projectEnv); + return yield* diffPools(input, migrationsPool, desiredPool); + }, + Effect.scoped, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError(pgDeltaNextEngineError), + ), + exportDeclarativeSchema: Effect.fn("PgDeltaNextEngine.exportDeclarativeSchema")( + function* (input) { + const pool = yield* acquireDatabase(input.target, input.context.projectEnv); + const result = yield* adapter.exportDeclarativeSchema({ + pool, + schema: input.schema, + formatOptions: input.formatOptions, + }); + if (input.debug) { + const capture = yield* adapter + .captureSnapshot({ pool }) + .pipe(Effect.orElseSucceed(() => undefined)); + yield* saveDebugArtifacts(input.context.cwd, "declarativeExport", { + ...(capture !== undefined ? { desiredSnapshot: capture.snapshot } : {}), + diagnostics: + capture === undefined + ? result.diagnostics + : [...result.diagnostics, ...capture.diagnostics], + }); + } + yield* reportDiagnostics( + "declarativeExport", + result.diagnostics, + input.strictCoverage, + input.debug, + ); + return { files: result.files, manifest: result.manifest }; + }, + Effect.scoped, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError(pgDeltaNextEngineError), + ), + planDeclarativeSchema: Effect.fn("PgDeltaNextEngine.planDeclarativeSchema")( + function* (input) { + const shadow = yield* shadowService.provisionPlan({ + context: input.context, + toml: input.toml, + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + ...(input.noCache ? { bypassCache: true } : {}), + }); + const migrations = yield* parseConnectionString(shadow.migrationsUrl); + const declarative = yield* parseConnectionString(shadow.declarativeUrl); + if (migrations === undefined || declarative === undefined) { + return yield* new PgDeltaEngineError({ + message: "failed to parse pg-delta next shadow database URL", + cause: "invalid password-free shadow output", + }); + } + const [migrationsPool, declarativePool] = yield* Effect.all( + [ + acquirePgPool(migrations, { isLocal: true, dnsResolver: "native" }), + acquirePgPool(declarative, { isLocal: true, dnsResolver: "native" }), + ], + { concurrency: 2 }, + ); + const prep = yield* prepareDeclarativeShadow(declarativePool, input.files); + const result = yield* adapter.planDeclarativeSchema({ + targetPool: migrationsPool, + shadowPool: declarativePool, + files: filesForDeclarativeShadowLoad(input.files, prep.restorePgjwt), + allowDrops: true, + ...(shadow.allowSameDatabaseIdentity ? { allowSameDatabaseIdentity: true } : {}), + debug: input.debug, + schema: input.schema, + formatOptions: input.formatOptions, + ...(input.manifest !== undefined ? { manifest: input.manifest } : {}), + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "declarativePlan", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* reportDiagnostics( + "declarativePlan", + result.diagnostics, + input.strictCoverage, + input.debug, + ); + return { + ...normalizeNextDiff(result, debugDirectory), + sourceRef: "pg-delta-next:migrations", + targetRef: "pg-delta-next:declarative", + }; + }, + Effect.scoped, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError(pgDeltaNextEngineError), + ), }); }), ); diff --git a/apps/cli/src/commands/db/shared/pgdelta-files.ts b/apps/cli/src/commands/db/shared/pgdelta-files.ts index 000c95f9e2..3297191730 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-files.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-files.ts @@ -1,4 +1,4 @@ -import { Data, Effect, type FileSystem, type Path } from "effect"; +import { Data, Effect, type FileSystem, type Path, Predicate } from "effect"; import { actionability, @@ -20,10 +20,6 @@ class PgDeltaFilesError extends Data.TaggedError("PgDeltaFilesError")<{ const filesError = (message: string) => new PgDeltaFilesError({ message }); -function readManifestValue(doc: object, key: string): unknown { - return Reflect.get(doc, key); -} - function parseManifestJson(raw: string): unknown { return JSON.parse(raw); } @@ -56,13 +52,13 @@ export const ReadPgDeltaExportManifest = Effect.fn("PgDeltaFiles.readExportManif `malformed export manifest ${manifestPath}: ${cause instanceof Error ? cause.message : String(cause)}`, ), }); - if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) { + if (!Predicate.isObject(decoded)) { return yield* filesError(`malformed export manifest ${manifestPath}`); } - const formatVersion = readManifestValue(decoded, "formatVersion"); - const redactSecrets = readManifestValue(decoded, "redactSecrets"); - const scope = readManifestValue(decoded, "scope"); + const formatVersion = decoded["formatVersion"]; + const redactSecrets = decoded["redactSecrets"]; + const scope = decoded["scope"]; if ( (formatVersion !== undefined && formatVersion !== 1) || typeof redactSecrets !== "boolean" || @@ -71,11 +67,11 @@ export const ReadPgDeltaExportManifest = Effect.fn("PgDeltaFiles.readExportManif return yield* filesError(`export manifest ${manifestPath} is missing required policy metadata`); } - const profile = readManifestValue(decoded, "profile"); - const baselineDigest = readManifestValue(decoded, "baselineDigest"); - const defaultOwner = readManifestValue(decoded, "defaultOwner"); - const files = readManifestValue(decoded, "files"); - const loadOrder = readManifestValue(decoded, "loadOrder"); + const profile = decoded["profile"]; + const baselineDigest = decoded["baselineDigest"]; + const defaultOwner = decoded["defaultOwner"]; + const files = decoded["files"]; + const loadOrder = decoded["loadOrder"]; return { redactSecrets, scope, diff --git a/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts b/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts index 84f6dee4ba..1d74f9381b 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts @@ -1,4 +1,4 @@ -import { Data, Effect, type FileSystem, type Path } from "effect"; +import { Array as Arr, Data, Effect, Predicate, type FileSystem, type Path } from "effect"; import { actionability, @@ -47,7 +47,7 @@ function quoteJson(value: unknown): string { * forward only (never backdated) until unique. Each file opens with the exclusive `"wx"` * flag against overwrite races; a failed write removes every file this call already wrote. */ -export const writePgDeltaMigrations = ( +export const writePgDeltaMigrations = Effect.fn("PgDeltaMigrations.write")(function* ( fs: FileSystem.FileSystem, pathSvc: Path.Path, opts: { @@ -61,120 +61,118 @@ export const writePgDeltaMigrations = ( readonly transactionMode: MigrationTransactionMode; }>; }, -): Effect.Effect, PgDeltaMigrationWriteError> => - Effect.gen(function* () { - const { workdir, name, files } = opts; - yield* Effect.annotateCurrentSpan({ "file.count": files.length }); - for (const file of files) { - if (file.transactionMode !== "transactional" && file.transactionMode !== "none") { - return yield* new PgDeltaMigrationWriteError({ - message: `unknown pg-delta transaction mode ${quoteJson(file.transactionMode)}`, - }); - } +): Effect.fn.Return, PgDeltaMigrationWriteError> { + const { workdir, name, files } = opts; + yield* Effect.annotateCurrentSpan({ "file.count": files.length }); + for (const file of files) { + if (file.transactionMode !== "transactional" && file.transactionMode !== "none") { + return yield* new PgDeltaMigrationWriteError({ + message: `unknown pg-delta transaction mode ${quoteJson(file.transactionMode)}`, + }); } - const single = files.length === 1; - const migrationsDir = pathSvc.join(workdir, "supabase", "migrations"); - const migrationEntries = yield* fs.readDirectory(migrationsDir).pipe( - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.succeed([] as ReadonlyArray) - : Effect.fail( - new PgDeltaMigrationWriteError({ - message: `failed to read migration directory: ${error.message}`, - }), - ), - ), - ); - const usedVersions = new Set(); - for (const entry of migrationEntries) { - const match = /^([0-9]+)_(.+)$/u.exec(entry); - if (match?.[1] === undefined) continue; - if (entry.endsWith(".sql")) { - usedVersions.add(match[1]); - continue; - } - const stat = yield* fs.stat(pathSvc.join(migrationsDir, entry)).pipe( - Effect.mapError( - (cause) => - new PgDeltaMigrationWriteError({ - message: `failed to inspect migration directory entry: ${cause.message}`, - }), + } + const single = files.length === 1; + const migrationsDir = pathSvc.join(workdir, "supabase", "migrations"); + const migrationEntries = yield* fs.readDirectory(migrationsDir).pipe( + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.succeed>([]), + (_, error) => + Effect.fail( + new PgDeltaMigrationWriteError({ + message: `failed to read migration directory: ${error.message}`, + }), ), - ); - if (stat.type === "Directory") { - // Nested names such as `snapshots/remote` are stored under a - // `_snapshots/` directory, whose prefix still owns the version. - usedVersions.add(match[1]); - } + ), + ); + const usedVersions = new Set(); + for (const entry of migrationEntries) { + const match = /^([0-9]+)_(.+)$/u.exec(entry); + if (match?.[1] === undefined) continue; + if (entry.endsWith(".sql")) { + usedVersions.add(match[1]); + continue; } - const buildSet = (baseMillis: number): Array => - files.map((file, i) => { - const version = formatMigrationTimestamp(baseMillis + i * 1000); - const unitName = single - ? name - : file.suffix !== undefined && file.suffix !== null - ? `${name}${file.suffix}` - : `${name}_${file.name}`; - return { path: getMigrationPath(pathSvc, workdir, version, unitName), version }; - }); - - let baseMillis = opts.baseMillis; - let set = buildSet(baseMillis); - for (let attempt = 0; ; attempt++) { - let collision = set.some((w) => usedVersions.has(w.version)); - if (!collision) { - for (const w of set) { - const exists = yield* fs.exists(w.path).pipe( - Effect.mapError( - (cause) => - new PgDeltaMigrationWriteError({ - message: `failed to check migration file: ${cause.message}`, - }), - ), - ); - if (exists) { - collision = true; - break; - } - } - } - if (!collision) break; - if (attempt + 1 >= MAX_VERSION_COLLISION_ATTEMPTS) { - return yield* new PgDeltaMigrationWriteError({ - message: `failed to find a unique migration version after ${MAX_VERSION_COLLISION_ATTEMPTS} attempts`, - }); - } - baseMillis += 1000; - set = buildSet(baseMillis); + const stat = yield* fs.stat(pathSvc.join(migrationsDir, entry)).pipe( + Effect.mapError( + (cause) => + new PgDeltaMigrationWriteError({ + message: `failed to inspect migration directory entry: ${cause.message}`, + }), + ), + ); + if (stat.type === "Directory") { + // Nested names such as `snapshots/remote` are stored under a + // `_snapshots/` directory, whose prefix still owns the version. + usedVersions.add(match[1]); } + } + const buildSet = (baseMillis: number): Array => + files.map((file, i) => { + const version = formatMigrationTimestamp(baseMillis + i * 1000); + const unitName = single + ? name + : file.suffix !== undefined && file.suffix !== null + ? `${name}${file.suffix}` + : `${name}_${file.name}`; + return { path: getMigrationPath(pathSvc, workdir, version, unitName), version }; + }); - const written: Array = []; - const writeAll = Effect.gen(function* () { - for (let i = 0; i < files.length; i++) { - const w = set[i]!; - const file = files[i]!; - yield* makeDir(fs, pathSvc.dirname(w.path)).pipe( - Effect.mapError((cause) => new PgDeltaMigrationWriteError({ message: cause.message })), - ); - yield* fs.writeFileString(w.path, `${file.sql}\n`, { flag: "wx" }).pipe( + let baseMillis = opts.baseMillis; + let set = buildSet(baseMillis); + for (let attempt = 0; ; attempt++) { + let collision = set.some((w) => usedVersions.has(w.version)); + if (!collision) { + for (const w of set) { + const exists = yield* fs.exists(w.path).pipe( Effect.mapError( (cause) => new PgDeltaMigrationWriteError({ - message: - cause.reason._tag === "AlreadyExists" - ? `failed to open migration file: ${cause.message}` - : `failed to write migration file: ${cause.message}`, + message: `failed to check migration file: ${cause.message}`, }), ), ); - written.push(w); + if (exists) { + collision = true; + break; + } } - return written; - }); + } + if (!collision) break; + if (attempt + 1 >= MAX_VERSION_COLLISION_ATTEMPTS) { + return yield* new PgDeltaMigrationWriteError({ + message: `failed to find a unique migration version after ${MAX_VERSION_COLLISION_ATTEMPTS} attempts`, + }); + } + baseMillis += 1000; + set = buildSet(baseMillis); + } - return yield* writeAll.pipe( - Effect.tapError(() => - Effect.forEach(written, (w) => fs.remove(w.path).pipe(Effect.ignore), { discard: true }), - ), - ); - }).pipe(Effect.withSpan("PgDeltaMigrations.write")); + const written: Array = []; + const writeAll = Effect.gen(function* () { + for (const [w, file] of Arr.zip(set, files)) { + yield* makeDir(fs, pathSvc.dirname(w.path)).pipe( + Effect.mapError((cause) => new PgDeltaMigrationWriteError({ message: cause.message })), + ); + yield* fs.writeFileString(w.path, `${file.sql}\n`, { flag: "wx" }).pipe( + Effect.mapError( + (cause) => + new PgDeltaMigrationWriteError({ + message: Predicate.isTagged(cause.reason, "AlreadyExists") + ? `failed to open migration file: ${cause.message}` + : `failed to write migration file: ${cause.message}`, + }), + ), + ); + written.push(w); + } + return written; + }); + + return yield* writeAll.pipe( + Effect.tapError(() => + Effect.forEach(written, (w) => fs.remove(w.path).pipe(Effect.ignore), { discard: true }), + ), + ); +}); diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts index 8816ea41aa..1316cfa4f3 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts @@ -1,4 +1,4 @@ -import { Effect, Layer } from "effect"; +import { Effect, Layer, Predicate } from "effect"; import type { Pool } from "pg"; import { serializeSnapshot, @@ -347,19 +347,18 @@ function pgDeltaNextFormatOptions(raw: string | undefined): SqlFormatOptions | u if (raw === undefined || raw.trim().length === 0) return pgDeltaNextDefaultFormatOptions; const parsed: unknown = JSON.parse(raw); if (parsed === null) return undefined; - if (typeof parsed !== "object" || Array.isArray(parsed)) { + if (!Predicate.isObject(parsed)) { return pgDeltaNextDefaultFormatOptions; } - const value = (key: string): unknown => Reflect.get(parsed, key); - const keywordCase = value("keywordCase"); - const commaStyle = value("commaStyle"); - const indent = value("indent"); - const maxWidth = value("maxWidth"); - const alignColumns = value("alignColumns"); - const alignKeyValues = value("alignKeyValues"); - const preserveRoutineBodies = value("preserveRoutineBodies"); - const preserveViewBodies = value("preserveViewBodies"); - const preserveRuleBodies = value("preserveRuleBodies"); + const keywordCase = parsed["keywordCase"]; + const commaStyle = parsed["commaStyle"]; + const indent = parsed["indent"]; + const maxWidth = parsed["maxWidth"]; + const alignColumns = parsed["alignColumns"]; + const alignKeyValues = parsed["alignKeyValues"]; + const preserveRoutineBodies = parsed["preserveRoutineBodies"]; + const preserveViewBodies = parsed["preserveViewBodies"]; + const preserveRuleBodies = parsed["preserveRuleBodies"]; return { ...pgDeltaNextDefaultFormatOptions, ...(keywordCase === "upper" || keywordCase === "lower" || keywordCase === "preserve" diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-adapter.unit.test.ts b/apps/cli/src/commands/db/shared/pgdelta-next-adapter.unit.test.ts index 7c30ca8759..4e24fda637 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-adapter.unit.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-adapter.unit.test.ts @@ -45,8 +45,8 @@ function fakeDiagnostic(code: string, subject: string) { } function setupLibraries(sourcePool: Pool, desiredPool: Pool) { - const state = { - resolveCalls: [] as Array<{ + const state: { + readonly resolveCalls: Array<{ pool: Pool; options: { restrictToApplier?: boolean; @@ -54,17 +54,25 @@ function setupLibraries(sourcePool: Pool, desiredPool: Pool) { skipBaseline?: boolean; }; schema?: readonly string[]; - }>, - extractCalls: [] as Array<{ pool: Pool; options: object | undefined }>, - planCalls: [] as Array<{ + }>; + readonly extractCalls: Array<{ pool: Pool; options: object | undefined }>; + readonly planCalls: Array<{ source: FakeFactBase; desired: FakeFactBase; options: FakePlanOptions & { redactSecrets: boolean }; - }>, - renderOptions: [] as Array<{ allowDrops: boolean }>, - exportInputs: [] as object[], - declarativeInputs: [] as object[], - snapshotMetadata: [] as object[], + }>; + readonly renderOptions: Array<{ allowDrops: boolean }>; + readonly exportInputs: object[]; + readonly declarativeInputs: object[]; + readonly snapshotMetadata: object[]; + } = { + resolveCalls: [], + extractCalls: [], + planCalls: [], + renderOptions: [], + exportInputs: [], + declarativeInputs: [], + snapshotMetadata: [], }; const extract = ( diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts index 0f5db7067f..f9ac1ef9d4 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts @@ -1,4 +1,4 @@ -import { Crypto, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Crypto, Effect, FileSystem, Layer, Option, Path, Schedule } from "effect"; import * as Net from "node:net"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -60,12 +60,17 @@ import { stackCatalogSetupLayer } from "../../../command-internal/stack-catalog- const allocateFreeHostPort = Effect.callback>((resume) => { const server = Net.createServer(); - server.once("error", () => resume(Effect.succeed(Option.none()))); + const onError = () => resume(Effect.succeed(Option.none())); + server.once("error", onError); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address !== null ? address.port : 0; server.close(() => resume(Effect.succeed(port > 0 ? Option.some(port) : Option.none()))); }); + return Effect.sync(() => { + server.removeListener("error", onError); + if (server.listening) server.close(); + }); }); const nextShadowError = (cause: unknown) => @@ -163,28 +168,30 @@ export const pgDeltaNextShadowLayer = Layer.effect( Layer.succeed(DockerRun, docker), Layer.succeed(DbConnection, dbConnection), Layer.succeed(HttpClient.HttpClient, httpClient), - Layer.succeed(Crypto.Crypto, crypto), Layer.succeed(CommandSettings, cliSettings), Layer.succeed(StackApi, stackApi), Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - return Layer.mergeAll(deps, stackCatalogSetupLayer.pipe(Layer.provide(deps))); + return stackCatalogSetupLayer.pipe(Layer.provideMerge(deps)); }; const runtime = runtimeWith(output); const nextPort = (excluded?: number) => - Effect.gen(function* () { - for (let attempt = 0; attempt < 10; attempt++) { - const candidate = yield* allocateFreeHostPort; - if (Option.isSome(candidate) && candidate.value !== excluded) return candidate.value; - } - return yield* new DeclarativeShadowDbError({ - message: - excluded === undefined - ? "failed to allocate a host port for pg-delta shadow database" - : `failed to allocate a host port distinct from ${excluded}`, - }); - }); + allocateFreeHostPort.pipe( + Effect.flatMap((candidate) => + Option.isSome(candidate) && candidate.value !== excluded + ? Effect.succeed(candidate.value) + : Effect.fail( + new DeclarativeShadowDbError({ + message: + excluded === undefined + ? "failed to allocate a host port for pg-delta shadow database" + : `failed to allocate a host port distinct from ${excluded}`, + }), + ), + ), + Effect.retry(Schedule.recurs(9)), + ); const buildNativeBase = (request: PgDeltaNextShadowInput) => Effect.gen(function* () { @@ -385,7 +392,8 @@ export const pgDeltaNextShadowLayer = Layer.effect( strategy === "parallel" ? withPeek(cacheOpts(opts, "disabled"), declarativePeek) : cacheOpts(opts, "disabled"); - const buffered = strategy === "sequential" ? undefined : bufferedShadowOutput(output); + const buffered = + strategy === "sequential" ? undefined : yield* bufferedShadowOutput(output); const provisions = runPlanShadowProvisions({ strategy, provisionMigrations: (onBaselineSeam) => diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.ts index ccd0bd78e7..c66169b9d3 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.ts @@ -4,7 +4,7 @@ * ({@link runPlanShadowProvisions}), and output buffering ({@link bufferedShadowOutput}). */ -import { Deferred, Effect } from "effect"; +import { Chunk, Deferred, Effect, Ref } from "effect"; import { Output } from "../../../shared/output/output.service.ts"; import type { ShadowBaselinePeek } from "../../../command-internal/db-bootstrap/shadow-cache.ts"; @@ -86,7 +86,9 @@ export interface BufferedShadowOutput { * only holds anomaly-path output (cache warnings, cold-fallback baseline prints); it does not * cover writes that bypass `Output`, such as `SUPABASE_SHADOW_DEBUG` timing lines. */ -export function bufferedShadowOutput(real: typeof Output.Service): BufferedShadowOutput { +export const bufferedShadowOutput = Effect.fnUntraced(function* ( + real: typeof Output.Service, +): Effect.fn.Return { type BufferedWrite = | { readonly kind: "raw"; readonly text: string; readonly stream: "stdout" | "stderr" } | { @@ -94,34 +96,27 @@ export function bufferedShadowOutput(real: typeof Output.Service): BufferedShado readonly bytes: Uint8Array; readonly stream: "stdout" | "stderr"; }; - const buffer: Array = []; - let flushed = false; + const state = yield* Ref.make<{ + readonly flushed: boolean; + readonly buffer: Chunk.Chunk; + }>({ flushed: false, buffer: Chunk.empty() }); + const write = (pending: BufferedWrite) => + pending.kind === "raw" + ? real.raw(pending.text, pending.stream) + : real.rawBytes(pending.bytes, pending.stream); + const bufferOrWrite = (pending: BufferedWrite) => + Ref.modify(state, (current) => + current.flushed + ? [true, current] + : [false, { flushed: false, buffer: Chunk.append(current.buffer, pending) }], + ).pipe(Effect.flatMap((live) => (live ? write(pending) : Effect.void))); const output = Output.of({ ...real, - raw: (text, stream = "stdout") => - Effect.suspend(() => { - if (flushed) return real.raw(text, stream); - buffer.push({ kind: "raw", text, stream }); - return Effect.void; - }), - rawBytes: (bytes, stream = "stdout") => - Effect.suspend(() => { - if (flushed) return real.rawBytes(bytes, stream); - buffer.push({ kind: "rawBytes", bytes, stream }); - return Effect.void; - }), - }); - const flush = Effect.suspend(() => { - flushed = true; - const pending = buffer.splice(0); - return Effect.forEach( - pending, - (write) => - write.kind === "raw" - ? real.raw(write.text, write.stream) - : real.rawBytes(write.bytes, write.stream), - { discard: true }, - ); + raw: (text, stream = "stdout") => bufferOrWrite({ kind: "raw", text, stream }), + rawBytes: (bytes, stream = "stdout") => bufferOrWrite({ kind: "rawBytes", bytes, stream }), }); + const flush = Ref.getAndSet(state, { flushed: true, buffer: Chunk.empty() }).pipe( + Effect.flatMap(({ buffer }) => Effect.forEach(buffer, write, { discard: true })), + ); return { output, flush }; -} +}); diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.unit.test.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.unit.test.ts index 9e87dc4c4c..1167b5da62 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.unit.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.unit.test.ts @@ -190,7 +190,7 @@ describe("bufferedShadowOutput", () => { const out = mockOutput(); return Effect.gen(function* () { const real = yield* Output; - const buffered = bufferedShadowOutput(real); + const buffered = yield* bufferedShadowOutput(real); yield* real.raw("Applying migration a...\n", "stderr"); yield* buffered.output.raw("Initialising schema...\n", "stderr"); yield* real.raw("Applying migration b...\n", "stderr"); @@ -209,7 +209,7 @@ describe("bufferedShadowOutput", () => { const out = mockOutput(); return Effect.gen(function* () { const real = yield* Output; - const buffered = bufferedShadowOutput(real); + const buffered = yield* bufferedShadowOutput(real); yield* buffered.output.raw("buffered\n", "stderr"); yield* buffered.flush; yield* buffered.flush; @@ -222,7 +222,7 @@ describe("bufferedShadowOutput", () => { const out = mockOutput(); return Effect.gen(function* () { const real = yield* Output; - const buffered = bufferedShadowOutput(real); + const buffered = yield* bufferedShadowOutput(real); yield* buffered.output.raw("first\n", "stderr"); yield* buffered.output.rawBytes(new TextEncoder().encode("second\n"), "stderr"); yield* buffered.flush; diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts index e6ffc1d77f..c056b58738 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { afterEach, beforeEach, vi } from "vitest"; @@ -185,9 +185,6 @@ function setup( return { layer, out, shadowSpawned: shadowSpawner.spawned }; } -const failError = (exit: Exit.Exit) => - Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; - describe("declarativeSeamLayer.ensureLocalDatabaseStarted", () => { it.effect( "carries the inspect failure's daemon marker AND recovery suggestion onto the seam error", @@ -205,13 +202,10 @@ describe("declarativeSeamLayer.ensureLocalDatabaseStarted", () => { }); return Effect.gen(function* () { const seam = yield* DeclarativeSeam; - const exit = yield* seam.ensureLocalDatabaseStarted.pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const error = failError(exit); + const error = yield* seam.ensureLocalDatabaseStarted.pipe(Effect.flip); expect(error).toBeInstanceOf(DeclarativeShadowDbError); - const shadowError = error as DeclarativeShadowDbError; - expect(shadowError.docker).toBe("daemon"); - expect(shadowError.suggestion).toBe(SUGGEST_DOCKER_INSTALL); + expect(error.docker).toBe("daemon"); + expect(error.suggestion).toBe(SUGGEST_DOCKER_INSTALL); }).pipe(Effect.provide(layer)); }, ); @@ -233,17 +227,11 @@ describe("declarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { const { layer } = setup(dir, { dbInspectImage: dockerfileServiceImageRaw("pg") }); return Effect.gen(function* () { const seam = yield* DeclarativeSeam; - const exit = yield* seam.ensureLocalPostgresImageCurrent.pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const error = failError(exit); + const error = yield* seam.ensureLocalPostgresImageCurrent.pipe(Effect.flip); expect(error).toBeInstanceOf(DeclarativeShadowDbError); - expect((error as DeclarativeShadowDbError).message).toContain( - "local Postgres container image is stale", - ); - expect((error as DeclarativeShadowDbError).message).toContain( - "same SUPABASE_USE_SLIM_IMAGES setting", - ); - expect((error as DeclarativeShadowDbError).message).not.toContain("--no-backup"); + expect(error.message).toContain("local Postgres container image is stale"); + expect(error.message).toContain("same SUPABASE_USE_SLIM_IMAGES setting"); + expect(error.message).not.toContain("--no-backup"); }).pipe(Effect.provide(layer)); }, ); @@ -259,18 +247,12 @@ describe("declarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { }); return Effect.gen(function* () { const seam = yield* DeclarativeSeam; - const exit = yield* seam.ensureLocalPostgresImageCurrent.pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const error = failError(exit); + const error = yield* seam.ensureLocalPostgresImageCurrent.pipe(Effect.flip); expect(error).toBeInstanceOf(DeclarativeShadowDbError); - expect((error as DeclarativeShadowDbError).message).toContain( - "local Postgres container image is stale", - ); + expect(error.message).toContain("local Postgres container image is stale"); // Same family (slim vs slim): the generic remediation, not the family-mismatch wording. - expect((error as DeclarativeShadowDbError).message).not.toContain( - "same SUPABASE_USE_SLIM_IMAGES setting", - ); - expect((error as DeclarativeShadowDbError).message).toContain("--no-backup"); + expect(error.message).not.toContain("same SUPABASE_USE_SLIM_IMAGES setting"); + expect(error.message).toContain("--no-backup"); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 40af8278da..79ae4290ea 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import { CommandSettings } from "../../../config/command-settings.service.ts"; @@ -182,13 +182,7 @@ export const declarativeSeamLayer = Layer.effect( }), ), ); - const stdoutChunks: Array = []; - const stderrChunks: Array = []; - yield* Stream.runForEach(child.stdout, (chunk) => - Effect.sync(() => { - stdoutChunks.push(chunk); - }), - ).pipe( + const stdoutChunks = yield* Stream.runCollect(child.stdout).pipe( Effect.mapError( () => new DeclarativeShadowDbError({ @@ -197,11 +191,7 @@ export const declarativeSeamLayer = Layer.effect( }), ), ); - yield* Stream.runForEach(child.stderr, (chunk) => - Effect.sync(() => { - stderrChunks.push(chunk); - }), - ).pipe( + const stderrChunks = yield* Stream.runCollect(child.stderr).pipe( Effect.mapError( () => new DeclarativeShadowDbError({ @@ -301,16 +291,14 @@ export function isMissingContainerInspectError(stderr: string): boolean { return stderr.toLowerCase().includes("no such container"); } +const decodeInspectJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + export function resolveContainerInspectImageName(stdout: string): string { const trimmed = stdout.trim(); if (trimmed.length === 0) return ""; - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - return trimmed; - } - const inspect = Array.isArray(parsed) ? parsed[0] : parsed; + const parsed = decodeInspectJson(trimmed); + if (Option.isNone(parsed)) return trimmed; + const inspect = Array.isArray(parsed.value) ? parsed.value[0] : parsed.value; if (!isJsonRecord(inspect)) return ""; const imageName = inspect["ImageName"]; if (typeof imageName === "string" && imageName.trim().length > 0) { diff --git a/apps/cli/src/commands/db/shared/pgdelta.write.ts b/apps/cli/src/commands/db/shared/pgdelta.write.ts index 06da3da61f..d73c932755 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.write.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.write.ts @@ -32,12 +32,14 @@ export interface DeclarativeWriteResult { readonly preservedUnmanagedFiles: ReadonlyArray; } -function safeDeclarativeExportName(path: Path.Path, name: string): string { +function safeDeclarativeExportName( + path: Path.Path, + name: string, +): Effect.Effect { const rel = path.normalize(name.split("\\").join("/")); - if (rel.startsWith("..") || path.isAbsolute(rel)) { - throw declarativeWriteError(`unsafe declarative export path: ${name}`); - } - return rel.split("\\").join("/"); + return rel.startsWith("..") || path.isAbsolute(rel) + ? Effect.fail(declarativeWriteError(`unsafe declarative export path: ${name}`)) + : Effect.succeed(rel.split("\\").join("/")); } function isCustomDeclarativePath(name: string): boolean { @@ -89,19 +91,17 @@ export const writeDeclarativeSchemas = Effect.fn("PgDelta.writeDeclarativeSchema output: PgDeltaDeclarativeExportResult, ) { const proposed = yield* Effect.forEach(output.files, (file) => - Effect.try({ - try: () => { - const name = safeDeclarativeExportName(path, file.name); - if (isCustomDeclarativePath(name)) { - throw declarativeWriteError( - `refusing to write into reserved declarative schema path: ${file.name}`, - ); - } - return { name, sql: file.sql }; - }, - catch: (error) => - error instanceof DeclarativeWriteError ? error : declarativeWriteError(String(error)), - }), + safeDeclarativeExportName(path, file.name).pipe( + Effect.flatMap((name) => + isCustomDeclarativePath(name) + ? Effect.fail( + declarativeWriteError( + `refusing to write into reserved declarative schema path: ${file.name}`, + ), + ) + : Effect.succeed({ name, sql: file.sql }), + ), + ), ); const exists = yield* fs @@ -273,15 +273,17 @@ export const updateDeclarativeSchemaPathsConfig = Effect.fn( const block = `\nschema_paths = [\n "${relative}",\n]\n`; const configPath = path.join(workdir, "supabase", "config.toml"); const existing = yield* fs.readFileString(configPath).pipe( - Effect.catchTag("PlatformError", (error) => - // A missing config file is tolerated; other read errors abort. - error.reason._tag === "NotFound" - ? Effect.succeed("") - : Effect.fail( - new DeclarativeWriteError({ - message: `failed to read config: ${error.message}`, - }), - ), + // A missing config file is tolerated; other read errors abort. + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.succeed(""), + (_, error) => + Effect.fail( + new DeclarativeWriteError({ + message: `failed to read config: ${error.message}`, + }), + ), ), ); // A replacer function, not a string, so a literal "$" in the path/value can't be diff --git a/apps/cli/src/commands/db/shared/shadow-source.ts b/apps/cli/src/commands/db/shared/shadow-source.ts index b5afb92d70..b2eeaa4f09 100644 --- a/apps/cli/src/commands/db/shared/shadow-source.ts +++ b/apps/cli/src/commands/db/shared/shadow-source.ts @@ -72,79 +72,78 @@ export type PrepareShadowSourceError = * `acquireUseRelease` runs `acquire` under an uninterruptible mask, so nesting them there * would swallow a SIGINT until the whole sequence finished. */ -export const prepareShadowSource = ( +export const prepareShadowSource = Effect.fn("ShadowSource.prepare")(function* ( spawner: Spawner, handle: ShadowAcquiredHandle, input: PrepareShadowSourceInput, -): Effect.Effect< +): Effect.fn.Return< ShadowSourceResult, PrepareShadowSourceError | E, Output | DockerRun | RuntimeInfo | HttpClient.HttpClient | DbConnection -> => - Effect.gen(function* () { - const { containerId } = handle; - yield* Effect.annotateCurrentSpan({ - "shadow.migration_mode": input.migrationMode ?? "legacy", - "shadow.target_local": input.targetLocal, - }); +> { + const { containerId } = handle; + yield* Effect.annotateCurrentSpan({ + "shadow.migration_mode": input.migrationMode ?? "legacy", + "shadow.target_local": input.targetLocal, + }); - const connConfig: PgConnInput = { - host: input.hostname, - port: input.shadowPort, - user: "postgres", - password: input.password, - database: "postgres", - }; + const connConfig: PgConnInput = { + host: input.hostname, + port: input.shadowPort, + user: "postgres", + password: input.password, + database: "postgres", + }; - yield* waitForShadowReady(spawner, containerId, connConfig, { - timeoutSeconds: input.healthTimeoutSeconds, - image: input.image, - }); + yield* waitForShadowReady(spawner, containerId, connConfig, { + timeoutSeconds: input.healthTimeoutSeconds, + image: input.image, + }); - // `handle` is the baseline state. A warm hit already has the platform baseline. A - // cache-enabled cold provision snapshots after that baseline. The legacy engine then - // creates `contrib_regression`; pg-delta does not. An uncached acquire is always-cold. - const migrateShadow = - input.migrationMode === "pgdelta-next" ? migrateNextShadowDatabase : migrateShadowDatabase; - yield* migrateShadow( - spawner, - { - fs: input.fs, - path: input.path, - workdir: input.workdir, - projectId: input.projectId, - container: containerId, - networkId: input.networkId, - connConfig, - setup: input.setup, - }, - handle, - ); + // `handle` is the baseline state. A warm hit already has the platform baseline. A + // cache-enabled cold provision snapshots after that baseline. The legacy engine then + // creates `contrib_regression`; pg-delta does not. An uncached acquire is always-cold. + const migrateShadow = + input.migrationMode === "pgdelta-next" ? migrateNextShadowDatabase : migrateShadowDatabase; + yield* migrateShadow( + spawner, + { + fs: input.fs, + path: input.path, + workdir: input.workdir, + projectId: input.projectId, + container: containerId, + networkId: input.networkId, + connConfig, + setup: input.setup, + }, + handle, + ); - const sourceUrl = toPostgresURL(connConfig); + const sourceUrl = toPostgresURL(connConfig); - let targetUrlOverride: string | undefined; - if (input.targetLocal && input.migrationMode !== "pgdelta-next") { - const declared = yield* loadDeclaredSchemas( - input.fs, - input.path, - input.workdir, - input.schemaPaths, - input.pgDelta, - ); - if (declared.length > 0) { - const overrideConn: PgConnInput = { ...connConfig, database: "contrib_regression" }; - yield* migrateBaseDatabase(input.fs, input.path, input.workdir, overrideConn, declared); - targetUrlOverride = toPostgresURL(overrideConn); - } + let targetUrlOverride: string | undefined; + if (input.targetLocal && input.migrationMode !== "pgdelta-next") { + const declared = yield* loadDeclaredSchemas( + input.fs, + input.path, + input.workdir, + input.schemaPaths, + input.pgDelta, + ); + if (declared.length > 0) { + const overrideConn: PgConnInput = { ...connConfig, database: "contrib_regression" }; + yield* migrateBaseDatabase(input.fs, input.path, input.workdir, overrideConn, declared); + targetUrlOverride = toPostgresURL(overrideConn); } + } - return { - container: containerId, - sourceUrl, - targetUrlOverride, - } satisfies ShadowSourceResult; - }).pipe(Effect.withSpan("ShadowSource.prepare")); + return { + container: containerId, + sourceUrl, + targetUrlOverride, + } satisfies ShadowSourceResult; +}); /** Glob metacharacters recognized here: `*?[` only — not `\`. */ function hasConfigGlobMeta(pattern: string): boolean { @@ -286,57 +285,56 @@ function walkSqlFilesSorted( * pg-delta's declarative dir (when `[experimental.pgdelta] enabled` and the dir exists), then * `supabase/schemas` (when it exists), then `[]`. Each source is byte-sorted. */ -export function loadDeclaredSchemas( +export const loadDeclaredSchemas = Effect.fn("ShadowSource.loadDeclaredSchemas")(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, schemaPaths: ReadonlyArray, pgDelta: PgDeltaTomlConfig, -): Effect.Effect, DeclarativeShadowDbError> { - return Effect.gen(function* () { - if (schemaPaths.length > 0) { - return yield* globDeclaredSchemaPaths(fs, path, workdir, schemaPaths); - } - if (pgDelta.enabled) { - const declDirRel = resolveDeclarativeDir(path, pgDelta); - const declDirAbs = resolveUnderWorkdir(path, workdir, declDirRel); - // A path that exists but is a regular file is treated as "not a directory," not an - // error, so it falls through to the `supabase/schemas` source below. - const isDeclDir = yield* fs.stat(declDirAbs).pipe( - Effect.map((info) => info.type === "Directory"), - Effect.orElseSucceed(() => false), +): Effect.fn.Return, DeclarativeShadowDbError> { + if (schemaPaths.length > 0) { + return yield* globDeclaredSchemaPaths(fs, path, workdir, schemaPaths); + } + if (pgDelta.enabled) { + const declDirRel = resolveDeclarativeDir(path, pgDelta); + const declDirAbs = resolveUnderWorkdir(path, workdir, declDirRel); + // A path that exists but is a regular file is treated as "not a directory," not an + // error, so it falls through to the `supabase/schemas` source below. + const isDeclDir = yield* fs.stat(declDirAbs).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + if (isDeclDir) { + return yield* walkSqlFilesSorted( + fs, + path, + workdir, + declDirRel, + "failed to walk declarative dir", ); - if (isDeclDir) { - return yield* walkSqlFilesSorted( - fs, - path, - workdir, - declDirRel, - "failed to walk declarative dir", - ); - } } - const schemasDirRel = "supabase/schemas"; - const schemasDirAbs = resolveUnderWorkdir(path, workdir, schemasDirRel); - // A missing path or a non-directory path both resolve to "no declared schemas" (`[]`), - // not an error — only a genuine stat failure propagates. - const isSchemasDir = yield* fs.stat(schemasDirAbs).pipe( - Effect.matchEffect({ - onFailure: (cause) => - cause.reason._tag === "NotFound" - ? Effect.succeed(false) - : Effect.fail( - new DeclarativeShadowDbError({ - message: `failed to check schemas: ${cause.message}`, - }), - ), - onSuccess: (info) => Effect.succeed(info.type === "Directory"), - }), - ); - if (!isSchemasDir) return []; - return yield* walkSqlFilesSorted(fs, path, workdir, schemasDirRel, "failed to walk dir"); - }).pipe(Effect.withSpan("ShadowSource.loadDeclaredSchemas")); -} + } + const schemasDirRel = "supabase/schemas"; + const schemasDirAbs = resolveUnderWorkdir(path, workdir, schemasDirRel); + // A missing path or a non-directory path both resolve to "no declared schemas" (`[]`), + // not an error — only a genuine stat failure propagates. + const isSchemasDir = yield* fs.stat(schemasDirAbs).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.succeed(false), + (_, cause) => + Effect.fail( + new DeclarativeShadowDbError({ + message: `failed to check schemas: ${cause.message}`, + }), + ), + ), + ); + if (!isSchemasDir) return []; + return yield* walkSqlFilesSorted(fs, path, workdir, schemasDirRel, "failed to walk dir"); +}); /** * Windows-only sibling of {@link cleanSchemaPath}'s segment cleaner: the length of the leading diff --git a/apps/cli/src/commands/db/shared/shadow-source.unit.test.ts b/apps/cli/src/commands/db/shared/shadow-source.unit.test.ts index 5b27c7ebbf..328c98667b 100644 --- a/apps/cli/src/commands/db/shared/shadow-source.unit.test.ts +++ b/apps/cli/src/commands/db/shared/shadow-source.unit.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "effect"; import { cleanSchemaPath, loadDeclaredSchemas } from "./shadow-source.ts"; import type { PgDeltaTomlConfig } from "../../../command-internal/db-config.toml-read.ts"; @@ -498,15 +498,11 @@ describe("loadDeclaredSchemas", () => { const locked = path.join(workdir, "supabase", "locked"); yield* fs.makeDirectory(locked, { recursive: true }); yield* lockDirectory(locked); - const exit = yield* loadDeclaredSchemas(fs, path, workdir, ["locked"], pgDelta()).pipe( - Effect.exit, + const error = yield* loadDeclaredSchemas(fs, path, workdir, ["locked"], pgDelta()).pipe( + Effect.flip, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("failed to walk matched directory:"); - expect(causeText).not.toContain("failed to walk matched directory: locked"); - } + expect(error.message).toContain("failed to walk matched directory:"); + expect(error.message).not.toContain("failed to walk matched directory: locked"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -526,15 +522,11 @@ describe("loadDeclaredSchemas", () => { yield* fs.makeDirectory(byteOrderFirst, { recursive: true }); yield* lockDirectory(utf16First); yield* lockDirectory(byteOrderFirst); - const exit = yield* loadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()).pipe( - Effect.exit, + const error = yield* loadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()).pipe( + Effect.flip, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain(byteOrderFirst); - expect(causeText).not.toContain(utf16First); - } + expect(error.message).toContain(byteOrderFirst); + expect(error.message).not.toContain(utf16First); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -548,7 +540,7 @@ describe("loadDeclaredSchemas", () => { const declDir = path.join(workdir, "supabase", "database"); yield* fs.makeDirectory(declDir, { recursive: true }); yield* lockDirectory(declDir); - const exit = yield* loadDeclaredSchemas( + const error = yield* loadDeclaredSchemas( fs, path, workdir, @@ -557,13 +549,9 @@ describe("loadDeclaredSchemas", () => { enabled: true, declarativeSchemaPath: Option.some("supabase/database"), }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("failed to walk declarative dir:"); - expect(causeText).not.toContain("failed to walk dir:"); - } + ).pipe(Effect.flip); + expect(error.message).toContain("failed to walk declarative dir:"); + expect(error.message).not.toContain("failed to walk dir:"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -577,13 +565,11 @@ describe("loadDeclaredSchemas", () => { const schemasDir = path.join(workdir, "supabase", "schemas"); yield* fs.makeDirectory(schemasDir, { recursive: true }); yield* lockDirectory(schemasDir); - const exit = yield* loadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("failed to walk dir:"); - expect(causeText).not.toContain("failed to walk declarative dir:"); - } + const error = yield* loadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe( + Effect.flip, + ); + expect(error.message).toContain("failed to walk dir:"); + expect(error.message).not.toContain("failed to walk declarative dir:"); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 580a76a9f5..ee9219fc7e 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -13,7 +13,7 @@ import { Stream, Redacted, } from "effect"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { vi } from "vitest"; @@ -90,10 +90,10 @@ function mockContainerCliSpawner(route: (args: ReadonlyArray) => RouteRe ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { - const args = command._tag === "StandardCommand" ? command.args : []; + const args = ChildProcess.isStandardCommand(command) ? command.args : []; spawned.push({ args }); - if (command._tag !== "StandardCommand") { + if (!ChildProcess.isStandardCommand(command)) { return yield* PlatformError.systemError({ _tag: "NotFound", module: "ChildProcess", @@ -435,7 +435,7 @@ describe("db start", () => { vi.unstubAllEnvs(); }); - it.live("reports an already-running database without starting a container", () => { + it.effect("reports an already-running database without starting a container", () => { const { layer, out, telemetry, child } = setup({ running: true }); return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -446,7 +446,7 @@ describe("db start", () => { }); }); - it.live( + it.effect( "reports an already-running database from the direct Engine-API answer without touching the container CLI", () => { const { layer, out, child } = setup({}); @@ -467,7 +467,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "starts the database on a fresh volume: creates the container, runs the SetupLocalDatabase-equivalent pipeline, and writes _current_branch", () => { const { layer, out, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); @@ -498,7 +498,7 @@ describe("db start", () => { 15_000, ); - it.live("fresh volume: a non-dial connect failure is not retried", () => { + it.effect("fresh volume: a non-dial connect failure is not retried", () => { const s = setup({ route: freshVolumeRoute(defaultRoute()), connectFailures: 1, @@ -511,7 +511,7 @@ describe("db start", () => { }); }); - it.live( + it.effect( "PG <= 14 on a fresh volume: execs schema/globals SQL directly instead of the PG15+ one-shot migrate jobs", () => { const { layer, out, child, dbSession } = setup({ @@ -528,7 +528,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "a fresh volume with realtime disabled skips the realtime migrate job AND never attempts JWKS resolution", () => { // globalThis.fetch is stubbed to fail so any JWKS resolution attempt would blow up the @@ -557,7 +557,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "a fresh volume with realtime enabled fails with a typed error when JWKS resolution fails", () => { const previousFetch = globalThis.fetch; @@ -588,7 +588,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "restarts against an existing volume: skips the SetupLocalDatabase-equivalent pipeline but still writes _current_branch", () => { const { layer, out, child, dbSession } = setup(); @@ -611,7 +611,7 @@ describe("db start", () => { }, ); - it.live("installs pg_net on an existing volume from effective Webhooks config", () => { + it.effect("installs pg_net on an existing volume from effective Webhooks config", () => { const { layer, out, child, dbSession } = setup({ configContents: 'project_id = "test"\n[experimental.webhooks]\nenabled = false\n', projectEnvContents: "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED=true\n", @@ -628,7 +628,7 @@ describe("db start", () => { }); }); - it.live( + it.effect( "--from-backup on a fresh volume: uses the restore entrypoint, binds the backup file, and skips the SetupLocalDatabase-equivalent pipeline entirely", () => { const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); @@ -647,26 +647,20 @@ describe("db start", () => { }, ); - it.live( + it.effect( '--from-backup against an existing volume fails with "backup volume already exists" and rolls back without creating a container', () => { const { layer, child } = setup(); return Effect.gen(function* () { - const exit = yield* dbStart(flags("/abs/host/backup.sql")).pipe( + const error = yield* dbStart(flags("/abs/host/backup.sql")).pipe( Effect.provide(layer), - Effect.exit, + Effect.flip, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.squash(exit.cause); - expect(error).toMatchObject({ - _tag: "StartBackupVolumeExistsError", - message: "backup volume already exists", - }); - expect((error as { suggestion?: string }).suggestion).toContain( - "supabase stop --no-backup", - ); - } + expect(error).toMatchObject({ + _tag: "StartBackupVolumeExistsError", + message: "backup volume already exists", + suggestion: expect.stringContaining("supabase stop --no-backup"), + }); expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); // The volume this guard detected must never be pruned, even though the rollback still // removes everything else on failure. @@ -676,7 +670,7 @@ describe("db start", () => { }, ); - it.live("resolves a relative --from-backup against the caller cwd, not the workdir", () => { + it.effect("resolves a relative --from-backup against the caller cwd, not the workdir", () => { const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()), cwd: "/caller/here", @@ -688,7 +682,7 @@ describe("db start", () => { }); }); - it.live("treats an empty --from-backup as a normal no-backup start", () => { + it.effect("treats an empty --from-backup as a normal no-backup start", () => { const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* dbStart(flags("")).pipe(Effect.provide(layer)); @@ -769,7 +763,7 @@ describe("db start", () => { }, ); - it.live("proceeds with no config file (missing config is tolerated)", () => { + it.effect("proceeds with no config file (missing config is tolerated)", () => { const { layer, child } = setup({ skipConfig: true, route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -777,7 +771,7 @@ describe("db start", () => { }); }); - it.live( + it.effect( "fails with a typed error on a malformed supabase/.env file, before any container is created", () => { const { layer, child } = setup({ projectEnvContents: "not a valid env line at all\n" }); @@ -792,7 +786,7 @@ describe("db start", () => { }, ); - it.live("fails fast on a malformed config.toml", () => { + it.effect("fails fast on a malformed config.toml", () => { const { layer, child, telemetry } = setup({ configContents: 'project_id = "unterminated\n' }); return Effect.gen(function* () { const exit = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); @@ -805,7 +799,7 @@ describe("db start", () => { }); }); - it.live("fails fast on an undecryptable secret even when the db is already running", () => { + it.effect("fails fast on an undecryptable secret even when the db is already running", () => { const { layer, out } = setup({ configContents: '[db]\nroot_key = "encrypted:anything"\n', running: true, @@ -820,7 +814,7 @@ describe("db start", () => { }); }); - it.live( + it.effect( "--network-id forces the created network/container onto the override, not the generated network name", () => { const { layer, child } = setup({ @@ -839,7 +833,7 @@ describe("db start", () => { }, ); - it.live("falls back to SUPABASE_NETWORK_ID when --network-id is omitted", () => { + it.effect("falls back to SUPABASE_NETWORK_ID when --network-id is omitted", () => { const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -852,7 +846,7 @@ describe("db start", () => { }).pipe((body) => withConfigEnv({ SUPABASE_NETWORK_ID: "env-network" }, body)); }); - it.live( + it.effect( "an explicitly empty --network-id falls back to the generated network name, not a literal empty override", () => { const { layer, child } = setup({ @@ -873,7 +867,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails with a typed config error on a malformed SUPABASE_DB_HEALTH_TIMEOUT, before any container is created", () => { const { layer, child } = setup({ @@ -892,7 +886,7 @@ describe("db start", () => { // Config loading decodes every duration field unconditionally for every command, including // `db start`, even though it never starts GoTrue itself. - it.live.each([ + it.effect.each([ ["auth.email.max_frequency", '[auth.email]\nmax_frequency = "not-a-duration"\n'], ["auth.sms.max_frequency", '[auth.sms]\nmax_frequency = "not-a-duration"\n'], ["auth.sessions.timebox", '[auth.sessions]\ntimebox = "not-a-duration"\n'], @@ -920,7 +914,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails with a typed config error on a malformed SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT override, before any container is created", () => { // auth.rate_limit has no enabled-gated validation — it's decoded unconditionally @@ -943,7 +937,7 @@ describe("db start", () => { // Config loading decodes the entire config struct unconditionally in one pass, including // every field below, regardless of whether `db start` itself reads it. - it.live.each([ + it.effect.each([ ["edge_runtime.inspector_port", "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", "not-a-port"], ["edge_runtime.policy", "SUPABASE_EDGE_RUNTIME_POLICY", "not-a-policy"], ["api.max_rows", "SUPABASE_API_MAX_ROWS", "not-a-uint"], @@ -974,7 +968,7 @@ describe("db start", () => { }, ); - it.live.each([ + it.effect.each([ ["realtime.ip_version", "SUPABASE_REALTIME_IP_VERSION", "IPv5"], ["realtime.max_header_length", "SUPABASE_REALTIME_MAX_HEADER_LENGTH", "not-a-uint"], ] as const)( @@ -997,7 +991,7 @@ describe("db start", () => { }, ); - it.live.each([ + it.effect.each([ ["db.settings.max_connections", "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", "bogus"], ["db.settings.track_commit_timestamp", "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", "bogus"], ] as const)( @@ -1020,7 +1014,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails with a typed config error on a malformed SUPABASE_STORAGE_ENABLED override even when Postgres is already running", () => { const { layer, child } = setup({ @@ -1040,7 +1034,7 @@ describe("db start", () => { }, ); - it.live.each([ + it.effect.each([ ["edge_runtime.enabled", "SUPABASE_EDGE_RUNTIME_ENABLED", "not-a-bool"], ["db.network_restrictions.enabled", "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", "not-a-bool"], ["studio.enabled", "SUPABASE_STUDIO_ENABLED", "not-a-bool"], @@ -1065,7 +1059,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails with a typed config error on a malformed SUPABASE_STUDIO_API_URL override even when Postgres is already running", () => { const { layer, child } = setup({ @@ -1085,7 +1079,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails with a typed config error when local_smtp is enabled with a zero port even when Postgres is already running", () => { const { layer, child } = setup({ @@ -1105,7 +1099,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails with a typed config error on a malformed SUPABASE_AUTH_JWT_EXPIRY override even when Postgres is already running", () => { const { layer, child } = setup({ @@ -1125,7 +1119,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails with a typed config error on a malformed SUPABASE_API_PORT override even when Postgres is already running", () => { const { layer, child } = setup({ @@ -1145,7 +1139,7 @@ describe("db start", () => { }, ); - it.live.each([ + it.effect.each([ ["auth.enable_signup", "SUPABASE_AUTH_ENABLE_SIGNUP", "not-a-bool"], ["auth.enable_anonymous_sign_ins", "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", "not-a-bool"], [ @@ -1181,7 +1175,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails on an invalid auth.passkey.enabled even when auth is disabled, matching Go's Config.Load", () => { // auth.passkey has no @supabase/config schema, so the malformed value must live in @@ -1204,7 +1198,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails on an invalid auth.external..enabled even when auth is disabled, matching Go's Config.Load", () => { // auth.external is a dynamic provider map; an unmodeled key like "custom" is silently @@ -1227,7 +1221,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails on a malformed SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED override, matching Go's Config.Load", () => { // The [auth.hook.send_email] section must be present for the env override to reach the @@ -1250,7 +1244,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails on a malformed SUPABASE_AUTH_EMAIL_SMTP_PORT override even when auth is disabled, matching Go's Config.Load", () => { // [auth.email.smtp] must be present in config.toml for the env override to reach the @@ -1273,7 +1267,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "ignores SUPABASE_AUTH_EMAIL_SMTP_PORT when [auth.email.smtp] is absent from config.toml", () => { const { layer, child } = setup({ @@ -1288,7 +1282,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails on a malformed SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED override, matching Go's Config.Load", () => { // [storage.image_transformation] must be present in config.toml for the env override to @@ -1310,7 +1304,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "ignores SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED when [storage.image_transformation] is absent from config.toml", () => { const { layer, child } = setup({ @@ -1324,7 +1318,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails on a malformed SUPABASE_DB_SSL_ENFORCEMENT_ENABLED override, matching Go's Config.Load", () => { // [db.ssl_enforcement] must be present in config.toml for the env override to reach the @@ -1346,7 +1340,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails on a malformed SUPABASE_DB_SSL_ENFORCEMENT_ENABLED override even when Postgres is already running", () => { const { layer, child } = setup({ @@ -1367,7 +1361,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "ignores SUPABASE_DB_SSL_ENFORCEMENT_ENABLED when [db.ssl_enforcement] is absent from config.toml", () => { const { layer, child } = setup({ @@ -1381,7 +1375,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "fails with a typed config error when [experimental.webhooks] is present without enabled = true, even when Postgres is already running", () => { // Experimental validation rejects any present [experimental.webhooks] section whose @@ -1405,7 +1399,7 @@ describe("db start", () => { }, ); - it.live("starts normally when [experimental.webhooks] is absent from config.toml", () => { + it.effect("starts normally when [experimental.webhooks] is absent from config.toml", () => { const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -1413,7 +1407,7 @@ describe("db start", () => { }); }); - it.live( + it.effect( "never mentions api.auto_expose_new_tables on stderr, whatever the flag is set to", () => { // Each case's config.toml is written just before its own run — writing all three up @@ -1439,7 +1433,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "prints @supabase/config's deprecated-[inbucket]-section WARN only once on a fresh, not-already-running start", () => { // The deprecation WARN is Console.error-pinned to the real console, not this file's @@ -1465,7 +1459,7 @@ describe("db start", () => { }, ); - it.live("fails on a malformed auth duration field even when the db is already running", () => { + it.effect("fails on a malformed auth duration field even when the db is already running", () => { const { layer, out } = setup({ configContents: 'project_id = "test"\n[auth.email]\nmax_frequency = "not-a-duration"\n', running: true, @@ -1482,7 +1476,7 @@ describe("db start", () => { }); }); - it.live( + it.effect( "warns when auth.sms.enable_signup is true but no SMS provider is enabled, matching Go's (s *sms) validate()", () => { const { layer, out } = setup({ @@ -1496,7 +1490,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "does not warn about SMS when auth is disabled, matching Go's Enabled-gated (s *sms) validate()", () => { const { layer, out } = setup({ @@ -1511,7 +1505,7 @@ describe("db start", () => { }, ); - it.live( + it.effect( "does not add the Linux-only host.docker.internal extra host on a non-Linux platform", () => { const { layer, child } = setup({ @@ -1526,7 +1520,7 @@ describe("db start", () => { }, ); - it.live("propagates a Docker inspect failure", () => { + it.effect("propagates a Docker inspect failure", () => { const { layer } = setup({ runningFails: true }); return Effect.gen(function* () { const exit = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); @@ -1537,7 +1531,7 @@ describe("db start", () => { }); }); - it.live("propagates a container-create failure and rolls back", () => { + it.effect("propagates a container-create failure and rolls back", () => { const base = defaultRoute(); const route = freshVolumeRoute((args) => { if (args[0] === "create") return { exitCode: 1, stderr: ["boom"] }; @@ -1552,7 +1546,7 @@ describe("db start", () => { }); }); - it.live("emits a json result when the database is already running", () => { + it.effect("emits a json result when the database is already running", () => { const { layer, out } = setup({ running: true, format: "json" }); return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -1561,7 +1555,7 @@ describe("db start", () => { }); }); - it.live("emits a json result after starting the database", () => { + it.effect("emits a json result after starting the database", () => { const { layer, out, child } = setup({ format: "json" }); return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -1764,7 +1758,7 @@ describe("db start stack backend", () => { findDeleted: () => Effect.die("unused"), }); - it.live("creates and starts only the primary database", () => { + it.effect("creates and starts only the primary database", () => { const { layer, catalogApplied } = setup({ recordCatalog: true }); const fixture = stackFixture(false); return Effect.gen(function* () { @@ -1782,7 +1776,7 @@ describe("db start stack backend", () => { }); }); - it.live("destroys a newly created database when catalog setup fails", () => { + it.effect("destroys a newly created database when catalog setup fails", () => { const { layer } = setup({ catalogFails: true }); const fixture = stackFixture(false); return Effect.gen(function* () { @@ -1802,7 +1796,7 @@ describe("db start stack backend", () => { }); }); - it.live( + it.effect( "resumes the existing database when its pinned version matches the configured major", () => { const { layer, out, catalogApplied } = setup({ recordCatalog: true }); @@ -1824,7 +1818,7 @@ describe("db start stack backend", () => { }, ); - it.live("rejects a different database major when resuming the saved stack", () => { + it.effect("rejects a different database major when resuming the saved stack", () => { const { layer, catalogApplied } = setup({ recordCatalog: true }); const fixture = stackFixture(true, false, false, "15.13.0.161"); return Effect.gen(function* () { @@ -1844,7 +1838,7 @@ describe("db start stack backend", () => { }); }); - it.live("rejects a standalone database outside the saved composition", () => { + it.effect("rejects a standalone database outside the saved composition", () => { const { layer, catalogApplied } = setup({ recordCatalog: true }); const fixture = stackFixture(true, false, true); return Effect.gen(function* () { @@ -1873,7 +1867,7 @@ describe("db start stack backend", () => { }); }); - it.live("does not launch a stopped stack while resolving local credentials", () => { + it.effect("does not launch a stopped stack while resolving local credentials", () => { const { layer } = setup(); const fixture = stackFixture(true, false); return Effect.gen(function* () { @@ -1892,7 +1886,7 @@ describe("db start stack backend", () => { }); }); - it.live("uses the running primary's observed connection without launching its owner", () => { + it.effect("uses the running primary's observed connection without launching its owner", () => { const { layer, out } = setup(); const fixture = stackFixture(true, true); const provided = Layer.mergeAll( @@ -1912,7 +1906,7 @@ describe("db start stack backend", () => { }); }); - it.live("refuses --from-backup", () => { + it.effect("refuses --from-backup", () => { const { layer } = setup(); const fixture = stackFixture(false); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/test/test.integration.test.ts b/apps/cli/src/commands/db/test/test.integration.test.ts index 1369c4f27b..27f16d3bcd 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -7,7 +7,7 @@ */ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Stdio } from "effect"; +import { Effect, Exit, Layer, Option, Stdio } from "effect"; import { CliOutput, Command } from "effect/cli"; import { @@ -51,7 +51,10 @@ import { BundledPostgresClient } from "../../../command-internal/bundled-postgre import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; import { EdgeRuntimeScript } from "../../../command-internal/edge-runtime-script.service.ts"; import { PgDeltaSslProbe } from "../../../command-internal/pgdelta-ssl-probe.service.ts"; -import { runTestDbCommand } from "../../../command-internal/test-db.command-handler.ts"; +import { + runTestDbCommand, + type TestDbFlags, +} from "../../../command-internal/test-db.command-handler.ts"; import { GoProxy } from "../../../command-internal/go-proxy.service.ts"; import { dbCommand } from "../db.command.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; @@ -190,16 +193,16 @@ function setup(opts: SetupOpts = {}) { return { layer, out, analytics, processControl, connection, docker }; } -const flags = () => ({ - paths: [] as ReadonlyArray, - dbUrl: Option.none(), +const flags = (): TestDbFlags => ({ + paths: [], + dbUrl: Option.none(), linked: false, local: true, - projectRef: Option.none(), + projectRef: Option.none(), }); describe("db test (alias) integration", () => { - it.live("runs pgTAP through the alias exactly like `test db`", () => { + it.effect("runs pgTAP through the alias exactly like `test db`", () => { const { layer, connection, docker } = setup(); return Effect.gen(function* () { yield* runTestDbCommand(flags()); @@ -211,7 +214,7 @@ describe("db test (alias) integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "dispatches through the real `dbTestCommand` and records `db test`, not `test db`, as the telemetry command", () => { // `--local --linked` fail inside `testDb` (mutual exclusivity) before any DB/docker IO, @@ -268,18 +271,18 @@ describe("db test (alias) integration", () => { }, ); - it.live("fails in text mode when pg_prove exits non-zero", () => { + it.effect("fails in text mode when pg_prove exits non-zero", () => { const { layer, processControl } = setup({ exitCode: 1 }); return Effect.gen(function* () { const exit = yield* Effect.exit(runTestDbCommand(flags())); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); // Text mode lets the failed Effect itself drive the process exit code, unlike the // json-mode branch below, which hand-writes exit 1. expect(processControl.exitCode).toBeUndefined(); }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "in json mode, a pg_prove failure writes to stderr and sets exit 1 without failing (Go's stdout-safety)", () => { const { layer, out, processControl } = setup({ format: "json", exitCode: 1 }); @@ -293,20 +296,20 @@ describe("db test (alias) integration", () => { }, ); - it.live("fails in text mode when the run found no tests", () => { + it.effect("fails in text mode when the run found no tests", () => { const { layer, processControl } = setup({ exitCode: 0, stdout: ["Files=0, Tests=0, 0 wallclock secs\nResult: NOTESTS\n"], }); return Effect.gen(function* () { const exit = yield* Effect.exit(runTestDbCommand(flags())); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); // Text mode lets the failed Effect drive the exit code, as for a run failure. expect(processControl.exitCode).toBeUndefined(); }).pipe(Effect.provide(layer)); }); - it.live("in json mode, a run that found no tests takes the same stderr + exit 1 path", () => { + it.effect("in json mode, a run that found no tests takes the same stderr + exit 1 path", () => { const { layer, out, processControl } = setup({ format: "json", exitCode: 0, diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index ea17ea4eb6..be0332c4dd 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -107,7 +107,7 @@ const fixture = Effect.fn("StackDestroyTest.fixture")(function* ( }); describe("stack destroy", () => { - it.live("requires confirmation before starting an owner or changing saved state", () => + it.effect("requires confirmation before starting an owner or changing saved state", () => Effect.gen(function* () { const f = yield* fixture(false); const error = yield* stackDestroy(f.flags).pipe(Effect.provide(f.layer), Effect.flip); @@ -119,7 +119,7 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("asks on an interactive terminal and keeps the stack when declined", () => + it.effect("asks on an interactive terminal and keeps the stack when declined", () => Effect.gen(function* () { const f = yield* fixture(false, { answer: false }); const error = yield* stackDestroy(f.flags).pipe(Effect.provide(f.layer), Effect.flip); @@ -170,7 +170,7 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("refuses a stack with an unknown service kind and names its directory", () => + it.effect("refuses a stack with an unknown service kind and names its directory", () => Effect.gen(function* () { const f = yield* fixture(true); const statePath = f.path.join(f.locations.stateRoot, f.stack.id, "state.json"); @@ -210,7 +210,7 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("dispatches a full ID with no registration to its deleted-stack cleanup", () => + it.effect("dispatches a full ID with no registration to its deleted-stack cleanup", () => Effect.gen(function* () { const f = yield* fixture(true); const id = "d".repeat(64); @@ -245,7 +245,7 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("reports a full ID as not found, with the engine that could not be searched", () => + it.effect("reports a full ID as not found, with the engine that could not be searched", () => Effect.gen(function* () { const f = yield* fixture(true); const id = "d".repeat(64); @@ -382,7 +382,7 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("rejects --stack combined with repeated --stack-id flags", () => + it.effect("rejects --stack combined with repeated --stack-id flags", () => Effect.gen(function* () { const f = yield* fixture(true); @@ -405,7 +405,7 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("asks once for every selected stack and keeps them all when declined", () => + it.effect("asks once for every selected stack and keeps them all when declined", () => Effect.gen(function* () { const f = yield* fixture(false, { answer: false }); const second = yield* f.api.create({ @@ -430,7 +430,7 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("destroys nothing and names every later --stack-id that is not found", () => + it.effect("destroys nothing and names every later --stack-id that is not found", () => Effect.gen(function* () { const f = yield* fixture(true); const missing = ["0000", "1111", "2222"] @@ -455,7 +455,7 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("fails a single stack with its own destroy error", () => + it.effect("fails a single stack with its own destroy error", () => Effect.gen(function* () { const f = yield* fixture(true); @@ -471,7 +471,7 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); - it.live("points a batch at the engine only when it was unreachable for every stack", () => + it.effect("points a batch at the engine only when it was unreachable for every stack", () => Effect.gen(function* () { const f = yield* fixture(true); const second = yield* f.api.create({ diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts index 1062f8bbcb..4ee7d19865 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts @@ -72,7 +72,7 @@ const fixture = Effect.fn("StackDestroyRuntimeUnavailableTest.fixture")(function }); for (const format of ["text", "json"] as const) - it.live( + it.effect( `fails with the exact --yes retry command and keeps the stack registered when its engine is unreachable (${format})`, () => Effect.gen(function* () { diff --git a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts index 36e1c08a0c..5cf1817b0b 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -29,7 +29,7 @@ const registry = Effect.fn("StackListTest.registry")(function* () { const live = Layer.provideMerge(stackApiLayer, BunServices.layer); describe("stack list", () => { - it.live("lists real saved identities without starting owners in text and machine output", () => + it.effect("lists real saved identities without starting owners in text and machine output", () => Effect.gen(function* () { const fixture = yield* registry(); for (const name of ["beta", "alpha"]) { @@ -62,7 +62,7 @@ describe("stack list", () => { }).pipe(Effect.provide(live)), ); - it.live("lists healthy stacks and warns about invalid entries without altering state", () => + it.effect("lists healthy stacks and warns about invalid entries without altering state", () => Effect.gen(function* () { const fixture = yield* registry(); const healthy = yield* fixture.api.create({ @@ -112,7 +112,7 @@ describe("stack list", () => { }).pipe(Effect.provide(live)), ); - it.live("reports an empty registry and rejects the legacy output flag", () => + it.effect("reports an empty registry and rejects the legacy output flag", () => Effect.gen(function* () { const fixture = yield* registry(); const output = mockOutput(); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index 420c3ddfd0..b5e6cff6a7 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -1,5 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; +import { TestClock } from "effect/testing"; import { Clock, DateTime, @@ -194,7 +195,7 @@ const eventLines = (events: ReadonlyArray<{ readonly type: string; readonly line describe("stack logs", () => { const t0 = Date.parse("2026-09-29T10:00:00.000Z"); - it.live("prints the newest lines across services with aligned labels, offline", () => + it.effect("prints the newest lines across services with aligned labels, offline", () => Effect.gen(function* () { const f = yield* fixture({ instances: [mail("mail-a"), database("db-a")], @@ -229,8 +230,9 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("selects history with --tail, --since, and repeated --service", () => + it.effect("selects history with --tail, --since, and repeated --service", () => Effect.gen(function* () { + yield* TestClock.setTime(1_767_225_600_000); const now = yield* Clock.currentTimeMillis; const minutes = (count: number) => now - count * 60_000; const f = yield* fixture({ @@ -281,7 +283,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("starts --since start at each instance's highest launch record", () => + it.effect("starts --since start at each instance's highest launch record", () => Effect.gen(function* () { const f = yield* fixture({ instances: [mail("mail-a"), mail("mail-b")], @@ -311,7 +313,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("keeps the log-entry fields and reports markers in every output mode", () => + it.effect("keeps the log-entry fields and reports markers in every output mode", () => Effect.gen(function* () { const f = yield* fixture({ instances: [mail("mail-a")], members: ["mail-a"] }); yield* f.writeSegment("mail", "mail-a", [ @@ -359,7 +361,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("fails --follow without a running owner before printing anything", () => + it.effect("fails --follow without a running owner before printing anything", () => Effect.gen(function* () { const f = yield* fixture({ instances: [mail("mail-a")], members: ["mail-a"] }); yield* f.writeSegment("mail", "mail-a", [launch(t0), line(t0 + 1, "stdout", "retained")]); @@ -383,7 +385,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("follows each service after its printed history without repeating it", () => + it.effect("follows each service after its printed history without repeating it", () => Effect.gen(function* () { // Serves the history like an owner, from `from` inclusive or none for `tail: 0`, then one new record. const owner = @@ -453,7 +455,7 @@ describe("stack logs", () => { const request = '127.0.0.1 - - [29/Sep/2026:10:00:00 +0000] "GET /rest/v1/ HTTP/1.1" 200 2 "-" "curl/8.7.1" 3ms'; - it.live("includes the gateway's requests by default and selects them with --service", () => + it.effect("includes the gateway's requests by default and selects them with --service", () => Effect.gen(function* () { const f = yield* fixture({ instances: [mail("mail-a"), rest("rest-a")], @@ -481,7 +483,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("rejects --service gateway when the stack has no shared API listener", () => + it.effect("rejects --service gateway when the stack has no shared API listener", () => Effect.gen(function* () { const f = yield* fixture({ instances: [mail("mail-a")], members: ["mail-a"] }); yield* f.writeSegment("mail", "mail-a", [launch(t0), line(t0 + 1, "stdout", "mail up")]); @@ -497,7 +499,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("follows the gateway's new requests through the owner", () => + it.effect("follows the gateway's new requests through the owner", () => Effect.gen(function* () { const f = yield* fixture({ instances: [rest("rest-a")], @@ -522,7 +524,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("keeps only the current owner run's gateway requests with --since start", () => + it.effect("keeps only the current owner run's gateway requests with --since start", () => Effect.gen(function* () { const f = yield* fixture({ instances: [mail("mail-a"), rest("rest-a")], @@ -547,7 +549,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("follows --since start without delayed records of an earlier launch", () => + it.effect("follows --since start without delayed records of an earlier launch", () => Effect.gen(function* () { const record = (offset: number, launchId: number, text: string): LogRecord => ({ kind: "stdout", @@ -582,7 +584,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("follows the services the running stack serves and names the others", () => + it.effect("follows the services the running stack serves and names the others", () => Effect.gen(function* () { const served = (text: string) => Stream.make({ @@ -610,7 +612,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("fails --follow when the running stack serves none of the selected services", () => + it.effect("fails --follow when the running stack serves none of the selected services", () => Effect.gen(function* () { const f = yield* fixture({ instances: [mail("mail-a")], @@ -629,7 +631,7 @@ describe("stack logs", () => { }).pipe(Effect.scoped, Effect.provide(live)), ); - it.live("interrupts a follow and releases its subscriptions", () => + it.effect("interrupts a follow and releases its subscriptions", () => Effect.gen(function* () { const subscribed = yield* Deferred.make(); const released = yield* Ref.make(false); diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index 4421d325b6..4589a2e673 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -245,7 +245,7 @@ const makeFixture = (root: string, options: FixtureOptions = {}) => { const databaseOnlyConfig = `project_id = "prepare-test"\n\n[api]\nenabled = false\n`; describe("stack prepare", () => { - it.live("prepares a temporary Database without changing the composition", () => + it.effect("prepares a temporary Database without changing the composition", () => makeProject(databaseOnlyConfig).pipe( Effect.flatMap((root) => { const fixture = makeFixture(root); @@ -265,7 +265,7 @@ describe("stack prepare", () => { ), ); - it.live("prepares Studio together with its configured Pgmeta companion", () => + it.effect("prepares Studio together with its configured Pgmeta companion", () => makeProject(databaseOnlyConfig).pipe( Effect.flatMap((root) => { const fixture = makeFixture(root); @@ -289,7 +289,7 @@ describe("stack prepare", () => { ), ); - it.live("removes its temporary instance after a preparation failure", () => + it.effect("removes its temporary instance after a preparation failure", () => makeProject(databaseOnlyConfig).pipe( Effect.flatMap((root) => { const fixture = makeFixture(root, { failPreparation: true }); @@ -310,7 +310,7 @@ describe("stack prepare", () => { ), ); - it.live("rejects a disabled requested service before preparing an instance", () => + it.effect("rejects a disabled requested service before preparing an instance", () => makeProject(databaseOnlyConfig).pipe( Effect.flatMap((root) => { const fixture = makeFixture(root); @@ -330,7 +330,7 @@ describe("stack prepare", () => { ), ); - it.live("rejects malformed config before creating a stack", () => + it.effect("rejects malformed config before creating a stack", () => makeProject("project_id = [\n").pipe( Effect.flatMap((root) => { const fixture = makeFixture(root); @@ -365,7 +365,7 @@ describe("stack prepare automatic runtime selection", () => { Effect.provide(BunServices.layer), ); - it.live("creates a Docker stack when the Docker engine answers", () => + it.effect("creates a Docker stack when the Docker engine answers", () => selectRuntime({ engines: { docker: "running", podman: "running" } }).pipe( Effect.tap((fixture) => Effect.sync(() => { @@ -377,7 +377,7 @@ describe("stack prepare automatic runtime selection", () => { ), ); - it.live("creates a Podman stack when Docker is not installed and Podman answers", () => + it.effect("creates a Podman stack when Docker is not installed and Podman answers", () => selectRuntime({ engines: { docker: "missing", podman: "running" }, runtimeInfo: mockRuntimeInfo({ platform: "linux", arch: "x64" }), @@ -394,7 +394,7 @@ describe("stack prepare automatic runtime selection", () => { ), ); - it.live( + it.effect( "creates a native stack on Linux when the Docker daemon is down and Podman is absent", () => selectRuntime({ @@ -416,7 +416,7 @@ describe("stack prepare automatic runtime selection", () => { ["win32", "x64"], ["darwin", "x64"], ] as const) { - it.live(`fails without creating a stack when no engine answers on ${platform}/${arch}`, () => + it.effect(`fails without creating a stack when no engine answers on ${platform}/${arch}`, () => makeProject(databaseOnlyConfig).pipe( Effect.flatMap((root) => { const fixture = makeFixture(root, { @@ -443,7 +443,7 @@ describe("stack prepare automatic runtime selection", () => { ); } - it.live("reuses a saved runtime without probing any engine", () => + it.effect("reuses a saved runtime without probing any engine", () => selectRuntime({ savedRuntime: "podman", engines: { docker: "running", podman: "running" }, @@ -459,23 +459,25 @@ describe("stack prepare automatic runtime selection", () => { ), ); - it.live("honors an explicit runtime without probing any engine and records it on telemetry", () => - makeProject(databaseOnlyConfig).pipe( - Effect.flatMap((root) => { - const fixture = makeFixture(root, { engines: { docker: "running", podman: "running" } }); - return stackPrepare(flags({ runtime: "podman" })).pipe( - Effect.provide(fixture.layer), - Effect.tap(() => - Effect.sync(() => { - expect(fixture.createdRuntimes).toEqual(["podman"]); - expect(fixture.recordedRuntimes).toEqual(["podman"]); - expect(fixture.probes).toEqual([]); - expect(fixture.runtimeNotices).toEqual([]); - }), - ), - ); - }), - Effect.provide(BunServices.layer), - ), + it.effect( + "honors an explicit runtime without probing any engine and records it on telemetry", + () => + makeProject(databaseOnlyConfig).pipe( + Effect.flatMap((root) => { + const fixture = makeFixture(root, { engines: { docker: "running", podman: "running" } }); + return stackPrepare(flags({ runtime: "podman" })).pipe( + Effect.provide(fixture.layer), + Effect.tap(() => + Effect.sync(() => { + expect(fixture.createdRuntimes).toEqual(["podman"]); + expect(fixture.recordedRuntimes).toEqual(["podman"]); + expect(fixture.probes).toEqual([]); + expect(fixture.runtimeNotices).toEqual([]); + }), + ), + ); + }), + Effect.provide(BunServices.layer), + ), ); }); diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts index f63f5dec21..24537493a0 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts @@ -150,7 +150,7 @@ describe("stack restart", () => { 60_000, ); - it.live("rejects an unconfigured namespace without starting an owner", () => + it.effect("rejects an unconfigured namespace without starting an owner", () => Effect.gen(function* () { const f = yield* fixture(); const error = yield* stackRestart(f.flags).pipe(Effect.provide(f.layer), Effect.flip); @@ -160,7 +160,7 @@ describe("stack restart", () => { }).pipe(Effect.provide(live)), ); - it.live("identifies failed members when a composition restart fails", () => + it.effect("identifies failed members when a composition restart fails", () => Effect.gen(function* () { const f = yield* fixture(); const failure = new StackError({ diff --git a/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts index aca726ef17..0aabefa418 100644 --- a/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-command-telemetry.integration.test.ts @@ -69,7 +69,7 @@ const setup = () => }); describe("stack command telemetry", () => { - it.live("records the canonical list command identity", () => { + it.effect("records the canonical list command identity", () => { return setup().pipe( Effect.flatMap((fixture) => { const command = stackCommand.pipe(Command.provide(fixture.layer)); @@ -91,7 +91,7 @@ describe("stack command telemetry", () => { ); }); - it.live("records canonical and top-level stop paths with distinct run ids", () => { + it.effect("records canonical and top-level stop paths with distinct run ids", () => { return setup().pipe( Effect.flatMap((fixture) => { const canonical = stackCommand.pipe(Command.provide(fixture.layer)); @@ -120,7 +120,7 @@ describe("stack command telemetry", () => { ); }); - it.live("records the destroy command identity on invalid target input", () => { + it.effect("records the destroy command identity on invalid target input", () => { return setup().pipe( Effect.flatMap((fixture) => { const command = stackCommand.pipe(Command.provide(fixture.layer)); @@ -140,7 +140,7 @@ describe("stack command telemetry", () => { ); }); - it.live("records the prepare command identity on invalid target input", () => { + it.effect("records the prepare command identity on invalid target input", () => { return setup().pipe( Effect.flatMap((fixture) => { const command = stackCommand.pipe(Command.provide(fixture.layer)); @@ -160,7 +160,7 @@ describe("stack command telemetry", () => { ); }); - it.live("records the logs command identity when no stack exists", () => { + it.effect("records the logs command identity when no stack exists", () => { return setup().pipe( Effect.flatMap((fixture) => { const command = stackCommand.pipe(Command.provide(fixture.layer)); @@ -176,7 +176,7 @@ describe("stack command telemetry", () => { ); }); - it.live("records the restart command identity on invalid target input", () => { + it.effect("records the restart command identity on invalid target input", () => { return setup().pipe( Effect.flatMap((fixture) => { const command = stackCommand.pipe(Command.provide(fixture.layer)); diff --git a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts index fbe22e26eb..cb84a2c98f 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts @@ -33,7 +33,7 @@ const service = (services: ReadonlyArray, name: string) => services.find((entry) => entry.service === name); describe("loadStackConfig environment overrides", () => { - it.live("keeps malformed Functions env out of database and config preparation", () => + it.effect("keeps malformed Functions env out of database and config preparation", () => Effect.gen(function* () { const root = yield* project('project_id = "functions-env-malformed"\n'); const fs = yield* FileSystem.FileSystem; @@ -45,7 +45,7 @@ describe("loadStackConfig environment overrides", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("uses shell > supabase dotenv > project-root dotenv precedence", () => + it.effect("uses shell > supabase dotenv > project-root dotenv precedence", () => Effect.gen(function* () { const root = yield* project( `project_id = "stack-config-env-precedence" @@ -72,7 +72,7 @@ site_url = "from-config" }), ); - it.live("applies env-only listener ports and leaves omitted ports automatic", () => + it.effect("applies env-only listener ports and leaves omitted ports automatic", () => Effect.gen(function* () { const root = yield* project('project_id = "stack-config-env-only-ports"\n'); const config = yield* withEnvironment( @@ -95,7 +95,7 @@ site_url = "from-config" }), ); - it.live("disables and re-enables Auth through the effective environment layer", () => + it.effect("disables and re-enables Auth through the effective environment layer", () => Effect.gen(function* () { const disabledRoot = yield* project('project_id = "stack-config-env-disable"\n', { supabaseEnv: "SUPABASE_AUTH_ENABLED=false\n", @@ -120,7 +120,7 @@ enabled = false }), ); - it.live("reports malformed environment ports as configuration errors", () => + it.effect("reports malformed environment ports as configuration errors", () => Effect.gen(function* () { const root = yield* project('project_id = "stack-config-env-invalid-port"\n'); const exit = yield* withEnvVar("SUPABASE_API_PORT", "not-a-port", load(root)).pipe( @@ -131,7 +131,7 @@ enabled = false }), ); - it.live("applies the OrioleDB environment override before checking the catalog", () => + it.effect("applies the OrioleDB environment override before checking the catalog", () => Effect.gen(function* () { const root = yield* project('project_id = "stack-config-env-orioledb"\n'); const exit = yield* withEnvVar("SUPABASE_DB_ORIOLEDB_VERSION", "17.0.0.000", load(root)).pipe( @@ -145,7 +145,7 @@ enabled = false }), ); - it.live("rejects OrioleDB S3 settings given only through the environment", () => + it.effect("rejects OrioleDB S3 settings given only through the environment", () => Effect.gen(function* () { const [orioledb = ""] = orioledbVersions(); const root = yield* project( diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 79421ca9a2..553b3c03be 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -49,7 +49,7 @@ describe("loadStackConfig", () => { vi.unstubAllEnvs(); }); - it.live("decodes the default recipe and leaves listeners automatic", () => + it.effect("decodes the default recipe and leaves listeners automatic", () => Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-defaults" [edge_runtime] @@ -85,7 +85,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("preserves explicit listener ports while translating every enabled service", () => + it.effect("preserves explicit listener ports while translating every enabled service", () => Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-ports" [api] @@ -129,7 +129,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("hands Studio the API settings it mirrors and its snippets folder", () => + it.effect("hands Studio the API settings it mirrors and its snippets folder", () => Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-studio" [api] @@ -149,7 +149,7 @@ max_rows = 250 }).pipe(Effect.provide(BunServices.layer)), ); - it.live("leaves stack-opt-in init listeners automatic except disabled pooler", () => + it.effect("leaves stack-opt-in init listeners automatic except disabled pooler", () => Effect.gen(function* () { const root = yield* project(renderCliConfigTemplate("stack-config-init", false, true)); const config = yield* load(root); @@ -160,7 +160,7 @@ max_rows = 250 }).pipe(Effect.provide(BunServices.layer)), ); - it.live("keeps the shared API port when REST is disabled", () => + it.effect("keeps the shared API port when REST is disabled", () => Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-auth-port" [api] @@ -175,7 +175,7 @@ port = 55431 }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects Auth email template content paths deferred by the stack", () => + it.effect("rejects Auth email template content paths deferred by the stack", () => Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-auth-template" [auth.email.template.invite] @@ -187,7 +187,7 @@ content_path = "./templates/invite.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("validates enabled Auth third-party providers", () => + it.effect("validates enabled Auth third-party providers", () => Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-auth-third-party" [auth.third_party.firebase] @@ -200,7 +200,7 @@ project_id = "" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("loads an empty signing-key file and skips it when Auth is disabled", () => + it.effect("loads an empty signing-key file and skips it when Auth is disabled", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -300,7 +300,7 @@ issuer_url = "" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects an analytics backend the stack cannot represent", () => + it.effect("rejects an analytics backend the stack cannot represent", () => Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-analytics" [analytics] @@ -314,7 +314,7 @@ backend = "bigquery" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects unsupported database versions before creating recipes", () => + it.effect("rejects unsupported database versions before creating recipes", () => Effect.gen(function* () { const root = yield* project(`project_id = "stack-config-db-version" [db] @@ -327,7 +327,7 @@ major_version = 14 }).pipe(Effect.provide(BunServices.layer)), ); - it.live("ignores unresolved experimental S3 env placeholders", () => { + it.effect("ignores unresolved experimental S3 env placeholders", () => { for (const name of ["s3_host", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"]) vi.stubEnv(name, ""); return Effect.gen(function* () { @@ -344,7 +344,7 @@ s3_secret_key = "env(S3_SECRET_KEY)" }).pipe(Effect.provide(BunServices.layer)); }); - it.live( + it.effect( "fails closed on an unpublished OrioleDB version and rejects S3 settings only for OrioleDB", () => Effect.gen(function* () { @@ -392,7 +392,7 @@ s3_host = "s3.example.test" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("records OrioleDB selection on the command event before rejecting it", () => + it.effect("records OrioleDB selection on the command event before rejecting it", () => Effect.gen(function* () { const loadRecordingOrioleDb = (projectRoot: string) => Effect.gen(function* () { diff --git a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts index d48745aeff..e9cf18c46c 100644 --- a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts @@ -15,7 +15,7 @@ import { createStackConfigProject } from "../../../../tests/helpers/stack-config const layer = Layer.merge(BunServices.layer, runtimeInfoLayer); describe("stack service configuration", () => { - it.live("carries editable TOML settings into native and container service environments", () => + it.effect("carries editable TOML settings into native and container service environments", () => Effect.gen(function* () { const root = yield* createStackConfigProject(`project_id = "forwarding" [auth] @@ -110,7 +110,7 @@ max_indexes = 2 }).pipe(Effect.provide(layer)), ); - it.live( + it.effect( "resolves per-function configuration and shared secrets without enabling the inspector", () => Effect.gen(function* () { @@ -159,7 +159,7 @@ enabled = false expect(functions?.endpoints?.inspector?.port).toBe(59229); }).pipe(Effect.provide(layer)), ); - it.live("decrypts a configured signing secret before forwarding it to the stack", () => + it.effect("decrypts a configured signing secret before forwarding it to the stack", () => Effect.gen(function* () { const plaintext = "test-jwt-secret-with-more-than-32-characters"; const root = yield* createStackConfigProject( @@ -180,7 +180,7 @@ jwt_secret = "encrypted:BOsrXIZY2BNTW43BeRhMbfvlOIUjwI7GCyFHxJD/Ik+UQ4mqkgVl2+61 }).pipe(Effect.provide(layer)), ); - it.live("leaves empty configured secrets for the stack to normalize", () => + it.effect("leaves empty configured secrets for the stack to normalize", () => Effect.gen(function* () { const root = yield* createStackConfigProject(`project_id = "empty-secrets" [auth] @@ -201,7 +201,7 @@ root_key = "" }).pipe(Effect.provide(layer)), ); - it.live("forwards Auth policies, providers and custom SMTP ahead of local mail", () => + it.effect("forwards Auth policies, providers and custom SMTP ahead of local mail", () => Effect.gen(function* () { const root = yield* createStackConfigProject( `project_id = "auth-forwarding" diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts index b307ec32c9..03d7afd658 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts @@ -58,7 +58,7 @@ const workspace = Effect.gen(function* () { }); describe("stack target resolver", () => { - it.live("resolves an explicit id without reading the current project identity", () => + it.effect("resolves an explicit id without reading the current project identity", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -82,7 +82,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects an id prefix that several readable or unreadable saved stacks share", () => + it.effect("rejects an id prefix that several readable or unreadable saved stacks share", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -113,7 +113,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects a stack id shorter than four characters", () => + it.effect("rejects a stack id shorter than four characters", () => Effect.gen(function* () { const { root, register, resolve } = yield* workspace; const id = yield* register({ projectRoot: root }); @@ -129,7 +129,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects an explicit id when its saved runtime differs", () => + it.effect("rejects an explicit id when its saved runtime differs", () => Effect.gen(function* () { const { root, register, resolve } = yield* workspace; const id = yield* register({ projectRoot: root, runtime: "docker" }); @@ -147,7 +147,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("selects the saved stack whose identity the project and stack name derive", () => + it.effect("selects the saved stack whose identity the project and stack name derive", () => Effect.gen(function* () { const { root, register, resolve } = yield* workspace; yield* register({ projectRoot: root }); @@ -160,7 +160,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("records the saved stack's runtime on the command event", () => + it.effect("records the saved stack's runtime on the command event", () => Effect.gen(function* () { const { root, register, resolve } = yield* workspace; yield* register({ projectRoot: root, runtime: "docker" }); @@ -176,7 +176,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("reports no id when the project has no saved stack", () => + it.effect("reports no id when the project has no saved stack", () => Effect.gen(function* () { const { root, resolve } = yield* workspace; @@ -186,7 +186,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("carries an explicit Podman runtime onto a new stack target and records it", () => + it.effect("carries an explicit Podman runtime onto a new stack target and records it", () => Effect.gen(function* () { const { root, resolve } = yield* workspace; const recorded = yield* Ref.make({}); @@ -202,7 +202,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("returns the canonical project root for a new stack reached through a symlink", () => + it.effect("returns the canonical project root for a new stack reached through a symlink", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -217,7 +217,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("surfaces an unreadable saved stack instead of reporting it missing", () => + it.effect("surfaces an unreadable saved stack instead of reporting it missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -233,7 +233,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "tells the user to remove the directory of a saved stack with an unknown service kind", () => Effect.gen(function* () { @@ -265,7 +265,7 @@ describe("stack target resolver", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("surfaces an unreadable saved stack that an id prefix selects", () => + it.effect("surfaces an unreadable saved stack that an id prefix selects", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -286,7 +286,7 @@ describe("stack target resolver", () => { }); describe("stack API layer", () => { - it.live("creates, discovers, and opens state without starting an owner", () => + it.effect("creates, discovers, and opens state without starting an owner", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts index 04c94d1c88..2f0f7f92e2 100644 --- a/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts @@ -250,7 +250,7 @@ const runStatusEnv = (input: { }).pipe(Effect.provide(BunServices.layer)); describe("stack start export pointer", () => { - it.live( + it.effect( "the printed pointer's argv resolves --workdir and the named --stack to that stack, not the caller's cwd or the project's default stack", () => Effect.gen(function* () { @@ -278,7 +278,7 @@ describe("stack start export pointer", () => { }), ); - it.live( + it.effect( "quotes a workdir and stack name that need it, and the same raw values still resolve that stack", () => Effect.gen(function* () { diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index 2b3d903de7..0ffdd6b979 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -24,7 +24,7 @@ import { type StackRuntime, } from "../../../../command-internal/stack-runtime.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { Effect, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect"; +import { Effect, FileSystem, Fiber, Match, Option, Path, Redacted, Ref } from "effect"; import { apiRoute, resolveNativePostgresUser, @@ -198,22 +198,22 @@ const stackAcquireError = ( }); }; -const loadStartConfig = (projectRoot: string, fs: FileSystem.FileSystem, path: Path.Path) => - Effect.gen(function* () { +const loadStartConfig = Effect.fn("experimental.stack.start.loadConfig")( + function* (projectRoot: string, fs: FileSystem.FileSystem, path: Path.Path) { const config = yield* loadStackConfig(projectRoot); const keys = yield* config.keys; const toml = yield* readDbToml(fs, path, projectRoot); return { config, keys, toml }; - }).pipe( - Effect.mapError( - (error) => - new StackCommandStartError({ - reason: "invalid-config", - message: error.message, - cause: error, - }), - ), - ); + }, + Effect.mapError( + (error) => + new StackCommandStartError({ + reason: "invalid-config", + message: error.message, + cause: error, + }), + ), +); const sameKinds = ( left: ReadonlyArray<{ readonly service: string }>, @@ -487,28 +487,27 @@ const endpointKeys = (change: EndpointPortChange, reported: ReadonlyArray) => status.lifecycle === "running" && status.health === "healthy"; -const startReport = ( +const startReport = Effect.fn("experimental.stack.start.report")(function* ( stack: Pick, instances: ReadonlyArray<{ readonly id: string; readonly service: ServiceCreation["service"]; readonly status: Effect.Effect; }>, -) => - Effect.gen(function* () { - const { members } = yield* stack.composition.describe; - const activation = new Map(members.map((member) => [member.id, member.activation])); - const views = yield* Effect.forEach(instances, (instance) => - instance.status.pipe( - Effect.map((observation): StackServiceView => ({ - service: instance.service, - observation, - activation: activation.get(instance.id), - })), - ), - ); - return { views, endpoints: stackEndpoints(views) }; - }).pipe(Effect.mapError(stackError)); +) { + const { members } = yield* stack.composition.describe; + const activation = new Map(members.map((member) => [member.id, member.activation])); + const views = yield* Effect.forEach(instances, (instance) => + instance.status.pipe( + Effect.map((observation): StackServiceView => ({ + service: instance.service, + observation, + activation: activation.get(instance.id), + })), + ), + ); + return { views, endpoints: stackEndpoints(views) }; +}, Effect.mapError(stackError)); /** Starts the selected managed stack and applies the local database overlays. */ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags: StackStartFlags) { @@ -550,16 +549,16 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ), ); const postgresUser = yield* resolveNativePostgresUser(selectedRuntime); - const ensurePostgresUser = - postgresUser._tag === "Unavailable" - ? new StackCommandStartError({ - reason: "lifecycle", - message: postgresUser.message, - suggestion: postgresUser.suggestion, - }) - : postgresUser._tag === "StepDown" - ? output.info(postgresUser.message) - : Effect.void; + const ensurePostgresUser = Match.valueTags(postgresUser, { + Unavailable: (unavailable) => + new StackCommandStartError({ + reason: "lifecycle", + message: unavailable.message, + suggestion: unavailable.suggestion, + }), + StepDown: (stepDown) => output.info(stepDown.message), + NotNeeded: () => Effect.void, + }); const configBeforeCreate = target.id === undefined || !target.hostRunning ? yield* loadStartConfig(target.projectRoot, fs, path) diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index e0b065eadd..c82c6e5df6 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -485,12 +485,14 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { // Reads each member's live creation the same way `plan()` does: a member's own `restart` // can hold a newer creation (e.g. a changed artifact version) in its own closure, which // `memberCreations` never sees again after construction. - const liveCreations = new Map( - members.map((member) => [member.id, Effect.runSync(member.status).config]), - ); + const instances = members.map((member) => ({ + id: member.id, + creation: Effect.runSync(member.status).config, + })); + const liveCreations = new Map(instances.map(({ id, creation }) => [id, creation])); const planned = planSupabaseComposition( { - instances: members.map(({ id }) => ({ id, creation: liveCreations.get(id)! })), + instances, composition: { members: members.map(({ id }) => ({ id, activation: activations.get(id) ?? "eager" })), dependencies: [], @@ -583,7 +585,7 @@ const layers = ( ); }; -const machineEnvelope = Schema.decodeEffect( +const machineEnvelope = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); @@ -655,7 +657,7 @@ const liveIdleOwner = (root: string, fixture: ReturnType) => ); describe("experimental stack start", () => { - it.live("rejects incompatible Functions env before changing composition", () => + it.effect("rejects incompatible Functions env before changing composition", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-functions-env-" }); @@ -678,7 +680,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects malformed configuration before creating a stack", () => + it.effect("rejects malformed configuration before creating a stack", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-invalid-" }); @@ -707,7 +709,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "rejects --runtime native on a platform with no native artifacts before creating a stack", () => Effect.gen(function* () { @@ -750,7 +752,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "creates and reports a Podman stack when automatic selection skips a stopped Docker", () => Effect.gen(function* () { @@ -806,7 +808,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("applies capability selection across repeated starts", () => + it.effect("applies capability selection across repeated starts", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-db-" }); @@ -887,7 +889,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("reports connection details and lazy services once the stack is ready", () => + it.effect("reports connection details and lazy services once the stack is ready", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-summary-" }); @@ -900,9 +902,7 @@ describe("experimental stack start", () => { const fixture = fakeStack(); const json = mockOutput({ format: "json" }); yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); - const data = json.messages.find(({ data }) => data !== undefined)?.data as { - endpoints: Readonly>; - }; + const data = json.messages.find(({ data }) => data !== undefined)?.data; expect(data).toMatchObject({ runtime: "native", endpoints: { @@ -921,7 +921,7 @@ describe("experimental stack start", () => { SERVICE_ROLE_KEY: "service-token", }, }); - expect(data.endpoints).not.toHaveProperty("studio.mcp"); + expect(data?.endpoints).not.toHaveProperty("studio.mcp"); yield* fixture.stack.composition.stop; const text = mockOutput(); @@ -951,7 +951,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("returns the connection env for an already-running stack in JSON", () => + it.effect("returns the connection env for an already-running stack in JSON", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-already-running-" }); @@ -966,10 +966,8 @@ describe("experimental stack start", () => { const json = mockOutput({ format: "json" }); yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); expect(fixture.composed).toBe(1); - const data = json.messages.find(({ data }) => data !== undefined)?.data as { - env: Readonly>; - }; - expect(data.env).toMatchObject({ + const data = json.messages.find(({ data }) => data !== undefined)?.data; + expect(data?.env).toMatchObject({ API_URL: "http://127.0.0.1:23457", MCP_URL: "http://127.0.0.1:23457/mcp", DB_URL: "postgresql://postgres:postgres@127.0.0.1:23456/postgres", @@ -977,7 +975,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("returns the connection env for a resumed stack in JSON", () => + it.effect("returns the connection env for a resumed stack in JSON", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-resumed-env-" }); @@ -998,15 +996,15 @@ describe("experimental stack start", () => { yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); expect(fixture.compositionStarts).toBe(2); expect(fixture.composed).toBe(1); - const data = json.messages.find(({ data }) => data !== undefined)?.data as { - env: Readonly>; - }; - expect(data.env.DB_URL).toBe("postgresql://postgres:postgres@127.0.0.1:23456/postgres"); - expect(data.env.API_URL).toBe("http://127.0.0.1:23457"); + const data = json.messages.find(({ data }) => data !== undefined)?.data; + expect(data?.env).toMatchObject({ + DB_URL: "postgresql://postgres:postgres@127.0.0.1:23456/postgres", + API_URL: "http://127.0.0.1:23457", + }); }).pipe(Effect.provide(BunServices.layer)), ); - it.live("names the services that failed when the composition start fails", () => + it.effect("names the services that failed when the composition start fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-outcomes-" }); @@ -1038,7 +1036,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("loads Functions env only when Functions are selected", () => + it.effect("loads Functions env only when Functions are selected", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-functions-env-" }); @@ -1107,7 +1105,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects Studio without REST before changing the composition", () => + it.effect("rejects Studio without REST before changing the composition", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-studio-" }); @@ -1125,7 +1123,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("applies signing-key file changes only after the stack is stopped", () => + it.effect("applies signing-key file changes only after the stack is stopped", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-key-rotation-" }); @@ -1161,29 +1159,34 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("recomposes a stopped stack with its existing IDs without rerunning catalog setup", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-stopped-restart-" }); - yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "stopped-restart"\n'); - const fixture = fakeStack(); + it.effect( + "recomposes a stopped stack with its existing IDs without rerunning catalog setup", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-stopped-restart-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "stopped-restart"\n', + ); + const fixture = fakeStack(); - yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); - const firstIds = fixture.members.map(({ id }) => id); - expect(fixture.composed).toBe(1); - expect(fixture.catalogApplied).toBe(1); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const firstIds = fixture.members.map(({ id }) => id); + expect(fixture.composed).toBe(1); + expect(fixture.catalogApplied).toBe(1); - yield* fixture.stack.composition.stop; - yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + yield* fixture.stack.composition.stop; + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); - expect(fixture.composed).toBe(2); - expect(fixture.members.map(({ id }) => id)).toEqual(firstIds); - expect(fixture.catalogApplied).toBe(1); - }).pipe(Effect.provide(BunServices.layer)), + expect(fixture.composed).toBe(2); + expect(fixture.members.map(({ id }) => id)).toEqual(firstIds); + expect(fixture.catalogApplied).toBe(1); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("creates the supabase_functions schema once when restarting a stack without it", () => + it.effect("creates the supabase_functions schema once when restarting a stack without it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-webhook-schema-" }); @@ -1211,7 +1214,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("skips invalid config while running and reports it after stop", () => + it.effect("skips invalid config while running and reports it after stop", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ @@ -1244,7 +1247,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects a partially active stack before reading changed config", () => + it.effect("rejects a partially active stack before reading changed config", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-partial-state-" }); @@ -1276,7 +1279,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects a stack whose database alone was started before reading changed config", () => + it.effect("rejects a stack whose database alone was started before reading changed config", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-db-only-" }); @@ -1308,7 +1311,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "reports a resumed stack ready only after its unhealthy and booting members are ready", () => Effect.gen(function* () { @@ -1370,70 +1373,77 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails a start while a woken lazy member is still booting and never becomes ready", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-booting-" }); - yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "booting"\n'); - const fixture = fakeStack(); - yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); - const auth = fixture.members.find(({ service }) => service === "auth"); - if (auth === undefined) return yield* Effect.die("Expected an Auth member"); - fixture.setMemberStatus(auth.id, { lifecycle: "starting", health: "starting" }); - fixture.setMemberReadiness( - auth.id, - Effect.fail( - new StackError({ operation: "service.ready", message: "auth HTTP readiness timed out" }), - ), - ); - const output = mockOutput(); - - const error = yield* stackStart(flags()).pipe( - Effect.provide(layers(root, fixture, output)), - Effect.flip, - ); - - expect(error).toBeInstanceOf(StackCommandStartError); - expect(error).toMatchObject({ message: "auth HTTP readiness timed out" }); - expect(output.messages.map(({ message }) => message)).not.toContain("Stack is ready."); - expect(output.messages.map(({ message }) => message)).not.toContain( - "Stack is already running with its current services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", - ); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.live("rejects changed Postgres root keys and database versions after stopping the stack", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - for (const [name, changed] of [ - ["root-key", '[db]\nroot_key = "a-different-postgres-root-key"\n'], - ["version", "[db]\nmajor_version = 15\n"], - ] as const) { - const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-start-${name}-` }); + it.effect( + "fails a start while a woken lazy member is still booting and never becomes ready", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-booting-" }); yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString(`${root}/supabase/config.toml`, `project_id = "${name}"\n`); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "booting"\n'); const fixture = fakeStack(); yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); - expect(fixture.composed).toBe(1); - - yield* fs.writeFileString( - `${root}/supabase/config.toml`, - `project_id = "${name}"\n${changed}`, + const auth = fixture.members.find(({ service }) => service === "auth"); + if (auth === undefined) return yield* Effect.die("Expected an Auth member"); + fixture.setMemberStatus(auth.id, { lifecycle: "starting", health: "starting" }); + fixture.setMemberReadiness( + auth.id, + Effect.fail( + new StackError({ + operation: "service.ready", + message: "auth HTTP readiness timed out", + }), + ), ); - yield* fixture.stack.composition.stop; - const error = yield* stackStart(flags(["studio"])).pipe( - Effect.provide(layers(root, fixture)), + const output = mockOutput(); + + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture, output)), Effect.flip, ); - expect(error).toMatchObject({ reason: "invalid-config" }); - expect(fixture.stopped).toBe(1); - expect(fixture.composed).toBe(1); - } - }).pipe(Effect.provide(BunServices.layer)), + + expect(error).toBeInstanceOf(StackCommandStartError); + expect(error).toMatchObject({ message: "auth HTTP readiness timed out" }); + expect(output.messages.map(({ message }) => message)).not.toContain("Stack is ready."); + expect(output.messages.map(({ message }) => message)).not.toContain( + "Stack is already running with its current services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "rejects changed Postgres root keys and database versions after stopping the stack", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const [name, changed] of [ + ["root-key", '[db]\nroot_key = "a-different-postgres-root-key"\n'], + ["version", "[db]\nmajor_version = 15\n"], + ] as const) { + const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-start-${name}-` }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, `project_id = "${name}"\n`); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + expect(fixture.composed).toBe(1); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + `project_id = "${name}"\n${changed}`, + ); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags(["studio"])).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + expect(error).toMatchObject({ reason: "invalid-config" }); + expect(fixture.stopped).toBe(1); + expect(fixture.composed).toBe(1); + } + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("applies changed service configuration after the stack is stopped", () => + it.effect("applies changed service configuration after the stack is stopped", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-changed-config-" }); @@ -1458,7 +1468,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "re-plans a changed endpoint on a stopped stack instead of rejecting it, prints the change, and reports none on the next unchanged restart", () => Effect.gen(function* () { @@ -1504,7 +1514,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("passes requested creations to a live owner, which ignores them", () => + it.effect("passes requested creations to a live owner, which ignores them", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-running-no-replan-" }); @@ -1542,7 +1552,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("re-plans when the owner it observed running is gone by the time the stack opens", () => + it.effect("re-plans when the owner it observed running is gone by the time the stack opens", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-owner-gone-" }); @@ -1586,7 +1596,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("adds endpoint_changes to the json and stream-json success payloads", () => + it.effect("adds endpoint_changes to the json and stream-json success payloads", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const onlyRest = [ @@ -1627,12 +1637,12 @@ describe("experimental stack start", () => { const stream = yield* prepare("stack-start-changes-stream-json-"); const { layer, stdio } = jsonErrorLayers(stream.root, stream.fixture, "stream-json"); yield* stackStart(flags(onlyRest)).pipe(Effect.provide(layer)); - const event = yield* machineEnvelope(stdio.stdout.at(-1)!); + const event = yield* machineEnvelope(stdio.stdout.at(-1)); expect(event.data).toMatchObject({ endpoint_changes: expected }); }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "lists every endpoints key the shared API listener serves for a re-planned API port", () => Effect.gen(function* () { @@ -1682,97 +1692,103 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("advises stop then start, not destroy, when a live idle owner meets a changed port", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-idle-owner-" }); - yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "idle-owner"\n'); - const fixture = fakeStack(); - yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); - yield* fs.writeFileString( - `${root}/supabase/config.toml`, - 'project_id = "idle-owner"\n[api]\nport = 54999\n', - ); - yield* fixture.stack.composition.stop; + it.effect( + "advises stop then start, not destroy, when a live idle owner meets a changed port", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-idle-owner-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "idle-owner"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "idle-owner"\n[api]\nport = 54999\n', + ); + yield* fixture.stack.composition.stop; - // A live owner keeps its startup state, so `open` never re-plans the requested creations. - const liveOwner = liveIdleOwner(root, fixture); - const error = yield* stackStart(flags()).pipe( - Effect.provide(Layer.merge(layers(root, fixture), liveOwner)), - Effect.flip, - ); + // A live owner keeps its startup state, so `open` never re-plans the requested creations. + const liveOwner = liveIdleOwner(root, fixture); + const error = yield* stackStart(flags()).pipe( + Effect.provide(Layer.merge(layers(root, fixture), liveOwner)), + Effect.flip, + ); - expect(error).toMatchObject({ - reason: "invalid-config", - message: expect.stringContaining("[api] port: saved"), - suggestion: `Run \`supabase stack stop --stack-id ${fixture.stack.id}\`, then \`supabase stack start --stack-id ${fixture.stack.id}\` to apply the new ports.`, - }); - }).pipe(Effect.provide(BunServices.layer)), + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("[api] port: saved"), + suggestion: `Run \`supabase stack stop --stack-id ${fixture.stack.id}\`, then \`supabase stack start --stack-id ${fixture.stack.id}\` to apply the new ports.`, + }); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("omits recreate_command from the JSON envelope when a live idle owner meets a port", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-idle-json-" }); - yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "idle-json"\n'); - const fixture = fakeStack(); - yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); - yield* fs.writeFileString( - `${root}/supabase/config.toml`, - 'project_id = "idle-json"\n[api]\nport = 54999\n', - ); - yield* fixture.stack.composition.stop; + it.effect( + "omits recreate_command from the JSON envelope when a live idle owner meets a port", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-idle-json-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "idle-json"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "idle-json"\n[api]\nport = 54999\n', + ); + yield* fixture.stack.composition.stop; - const { layer, stdio } = jsonErrorLayers(root, fixture, "json"); - yield* stackStart(flags()).pipe( - withJsonErrorHandling, - Effect.provide(Layer.merge(layer, liveIdleOwner(root, fixture))), - ); + const { layer, stdio } = jsonErrorLayers(root, fixture, "json"); + yield* stackStart(flags()).pipe( + withJsonErrorHandling, + Effect.provide(Layer.merge(layer, liveIdleOwner(root, fixture))), + ); - const envelope = yield* machineEnvelope(stdio.stdout[0]!); - expect(envelope._tag).toBe("Error"); - expect(envelope.stack_changes).toEqual( - expect.arrayContaining([expect.objectContaining({ key: "[api] port" })]), - ); - expect(envelope).not.toHaveProperty("recreate_command"); - }).pipe(Effect.provide(BunServices.layer)), + const envelope = yield* machineEnvelope(stdio.stdout[0]); + expect(envelope._tag).toBe("Error"); + expect(envelope.stack_changes).toEqual( + expect.arrayContaining([expect.objectContaining({ key: "[api] port" })]), + ); + expect(envelope).not.toHaveProperty("recreate_command"); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("advises destroy and keeps recreate_command when a live idle owner meets a version", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-idle-version-" }); - yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "idle-version"\n'); - const fixture = fakeStack(); - yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); - yield* fs.writeFileString( - `${root}/supabase/config.toml`, - 'project_id = "idle-version"\n[db]\nmajor_version = 15\n[api]\nport = 54999\n', - ); - yield* fixture.stack.composition.stop; + it.effect( + "advises destroy and keeps recreate_command when a live idle owner meets a version", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-idle-version-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "idle-version"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "idle-version"\n[db]\nmajor_version = 15\n[api]\nport = 54999\n', + ); + yield* fixture.stack.composition.stop; - const { layer, stdio } = jsonErrorLayers(root, fixture, "json"); - yield* stackStart(flags()).pipe( - withJsonErrorHandling, - Effect.provide(Layer.merge(layer, liveIdleOwner(root, fixture))), - ); + const { layer, stdio } = jsonErrorLayers(root, fixture, "json"); + yield* stackStart(flags()).pipe( + withJsonErrorHandling, + Effect.provide(Layer.merge(layer, liveIdleOwner(root, fixture))), + ); - const envelope = yield* machineEnvelope(stdio.stdout[0]!); - expect(envelope.error).toMatchObject({ - suggestion: expect.stringContaining( + const envelope = yield* machineEnvelope(stdio.stdout[0]); + expect(envelope.error).toMatchObject({ + suggestion: expect.stringContaining( + `supabase stack destroy --stack-id ${fixture.stack.id}`, + ), + }); + expect(envelope.recreate_command).toBe( `supabase stack destroy --stack-id ${fixture.stack.id}`, - ), - }); - expect(envelope.recreate_command).toBe( - `supabase stack destroy --stack-id ${fixture.stack.id}`, - ); - }).pipe(Effect.provide(BunServices.layer)), + ); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "names the config key and the saved port when the owner's startup claim hits a port conflict", () => Effect.gen(function* () { @@ -1817,7 +1833,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "names the config key and both values when a saved port changes alongside a database version", () => Effect.gen(function* () { @@ -1850,7 +1866,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "names a dedicated (non-shared) port's own config key alongside a database version change", () => Effect.gen(function* () { @@ -1887,7 +1903,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "reports a shared API port transitioning from automatic to fixed alongside a database version change", () => Effect.gen(function* () { @@ -1921,7 +1937,7 @@ describe("experimental stack start", () => { // any other changed port, since every sibling sharing that port reports it; this locks down // that it re-plans successfully rather than rejecting the start, the way a single service's own // port change does. - it.live("accepts a shared API port going from fixed back to automatic by re-planning it", () => + it.effect("accepts a shared API port going from fixed back to automatic by re-planning it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-api-to-auto-" }); @@ -1941,7 +1957,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "collects simultaneous database-version and port changes into one error with plural revert wording", () => Effect.gen(function* () { @@ -1977,7 +1993,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "names the env var override when SUPABASE_*_PORT set the saved port alongside a database version change", () => Effect.gen(function* () { @@ -2012,7 +2028,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "names [db] major_version and the destroy command when the saved Postgres version changes", () => Effect.gen(function* () { @@ -2044,7 +2060,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "names the full Postgres build, not major_version, when only the pinned build differs", () => Effect.gen(function* () { @@ -2113,7 +2129,7 @@ describe("experimental stack start", () => { revert: "Revert SUPABASE_DB_ORIOLEDB_VERSION to its saved value", }, ]) - it.live(`names ${target.name}`, () => + it.effect(`names ${target.name}`, () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-orioledb-" }); @@ -2143,7 +2159,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("offers no revert to an OrioleDB build the catalog no longer pins", () => + it.effect("offers no revert to an OrioleDB build the catalog no longer pins", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-retired-orioledb-" }); @@ -2177,7 +2193,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "drops the revert sentence for an artifact-version-only mismatch and explains the fix in plain language", () => Effect.gen(function* () { @@ -2218,7 +2234,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "uses the destroy-only wording when a non-editable change accompanies an editable one", () => Effect.gen(function* () { @@ -2266,7 +2282,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("emits structured stack_changes and recreate_command on the JSON error envelope", () => + it.effect("emits structured stack_changes and recreate_command on the JSON error envelope", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-json-envelope-" }); @@ -2284,7 +2300,7 @@ describe("experimental stack start", () => { yield* stackStart(flags()).pipe(withJsonErrorHandling, Effect.provide(layer)); expect(stdio.stdout).toHaveLength(1); - const envelope = yield* machineEnvelope(stdio.stdout[0]!); + const envelope = yield* machineEnvelope(stdio.stdout[0]); expect(envelope._tag).toBe("Error"); expect(envelope.error).toMatchObject({ code: "ExperimentalStackStartError", @@ -2306,7 +2322,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("emits the same structured error fields on the stream-json terminal event", () => + it.effect("emits the same structured error fields on the stream-json terminal event", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-stream-json-" }); @@ -2323,7 +2339,7 @@ describe("experimental stack start", () => { const { layer, stdio } = jsonErrorLayers(root, fixture, "stream-json"); yield* stackStart(flags()).pipe(withJsonErrorHandling, Effect.provide(layer)); - const event = yield* machineEnvelope(stdio.stdout.at(-1)!); + const event = yield* machineEnvelope(stdio.stdout.at(-1)); expect(event.type).toBe("error"); expect(event.error).toMatchObject({ code: "ExperimentalStackStartError", @@ -2343,7 +2359,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("always suggests --stack-id for a named stack, naming the stack as plain text", () => + it.effect("always suggests --stack-id for a named stack, naming the stack as plain text", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-named-destroy-" }); @@ -2385,7 +2401,7 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("matches a Postgres major alias to the saved pinned database version", () => + it.effect("matches a Postgres major alias to the saved pinned database version", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-postgres-alias-" }); @@ -2504,7 +2520,7 @@ describe("experimental stack start", () => { ); } - it.live( + it.effect( "stops, without destroying, the owner when startup fails after the stack is acquired", () => Effect.gen(function* () { diff --git a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts index 76e1810448..ee6b7fa2c2 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts @@ -103,16 +103,14 @@ const captureOwnerPid = (capture: (pid: number) => void) => ); const ownerHasExited = (pid: number) => - Effect.sync(() => { - try { - process.kill(pid, 0); - return false; - } catch (cause) { - if (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ESRCH") - return true; - throw cause; - } - }); + Effect.try(() => process.kill(pid, 0)).pipe( + Effect.as(false), + Effect.catchIf( + ({ cause }) => + typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ESRCH", + () => Effect.succeed(true), + ), + ); const projectConfig = ` project_id = "stack-start-native-integration" @@ -382,6 +380,10 @@ describe("experimental stack start native lifecycle", () => { server.removeListener("error", onError); resume(Effect.succeed(server)); }); + return Effect.sync(() => { + server.removeListener("error", onError); + if (server.listening) server.close(); + }); }), (listeningServer) => Effect.callback((resume) => { diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index 516dc0d792..a188f02817 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -84,6 +84,11 @@ const storage = (s3ProtocolEnabled: boolean): ServiceCreation => ({ }, endpoints: { http: { port: 54321 } }, }); +const StatusEnvReport = Schema.Struct({ env: Schema.Record(Schema.String, Schema.String) }); +const StatusEndpointsReport = Schema.Struct({ + ...StatusEnvReport.fields, + endpoints: Schema.Record(Schema.String, Schema.Unknown), +}); const flags = (input?: Partial): StackStatusFlags => ({ stack: Option.none(), stackId: Option.none(), @@ -271,7 +276,7 @@ const runStatus = (input: { return { effect, out, root }; }).pipe(Effect.provide(BunServices.layer)); -it.live("renders connections and a services summary without internal IDs", () => +it.effect("renders connections and a services summary without internal IDs", () => Effect.gen(function* () { const databaseCalls = { value: 0 }; const restCalls = { value: 0 }; @@ -333,7 +338,7 @@ it.live("renders connections and a services summary without internal IDs", () => }), ); -it.live("preserves service status failures and marks aggregate readiness unavailable", () => +it.effect("preserves service status failures and marks aggregate readiness unavailable", () => Effect.gen(function* () { const run = yield* runStatus({ services: [ @@ -361,7 +366,7 @@ it.live("preserves service status failures and marks aggregate readiness unavail }), ); -it.live("does not label a requested stop as an unexpected process exit", () => +it.effect("does not label a requested stop as an unexpected process exit", () => Effect.gen(function* () { const stopped = makeObservation("database-id", database, { lifecycle: "stopped", @@ -402,7 +407,7 @@ it.live("does not label a requested stop as an unexpected process exit", () => }), ); -it.live("reports stopped readiness without treating unbound endpoints as drift", () => +it.effect("reports stopped readiness without treating unbound endpoints as drift", () => Effect.gen(function* () { const run = yield* runStatus({ services: [ @@ -429,7 +434,7 @@ it.live("reports stopped readiness without treating unbound endpoints as drift", }), ); -it.live("reports the planned differences of composition members as drift", () => +it.effect("reports the planned differences of composition members as drift", () => Effect.gen(function* () { const drifted = (outputFormat: StatusOutputFormat) => runStatus({ @@ -479,7 +484,7 @@ it.live("reports the planned differences of composition members as drift", () => }), ); -it.live( +it.effect( "reports the gateway-served MCP endpoint at the API URL, with no synthetic endpoint entry", () => Effect.gen(function* () { @@ -514,10 +519,9 @@ it.live( ]; const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); yield* report.effect; - const result = report.out.messages.find((message) => message.type === "success")?.data as { - endpoints: Readonly>; - env: Readonly>; - }; + const result = yield* Schema.decodeUnknownEffect(StatusEndpointsReport)( + report.out.messages.find((message) => message.type === "success")?.data, + ); expect(result).toMatchObject({ endpoints: { "studio.http": { url: "http://127.0.0.1:54323" } }, env: { MCP_URL: "http://127.0.0.1:54321/mcp" }, @@ -531,7 +535,7 @@ it.live( }), ); -it.live("omits MCP_URL when no member exposes the shared API listener", () => +it.effect("omits MCP_URL when no member exposes the shared API listener", () => Effect.gen(function* () { const services = [ makeService({ @@ -556,14 +560,14 @@ it.live("omits MCP_URL when no member exposes the shared API listener", () => ]; const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); yield* report.effect; - const result = report.out.messages.find((message) => message.type === "success")?.data as { - env: Readonly>; - }; + const result = yield* Schema.decodeUnknownEffect(StatusEnvReport)( + report.out.messages.find((message) => message.type === "success")?.data, + ); expect(result.env).not.toHaveProperty("MCP_URL"); }), ); -it.live("omits MCP_URL when Studio is not a composition member", () => +it.effect("omits MCP_URL when Studio is not a composition member", () => Effect.gen(function* () { const services = [ makeService({ @@ -588,9 +592,9 @@ it.live("omits MCP_URL when Studio is not a composition member", () => ]; const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); yield* report.effect; - const result = report.out.messages.find((message) => message.type === "success")?.data as { - env: Readonly>; - }; + const result = yield* Schema.decodeUnknownEffect(StatusEnvReport)( + report.out.messages.find((message) => message.type === "success")?.data, + ); expect(result.env).not.toHaveProperty("MCP_URL"); }), ); @@ -620,7 +624,7 @@ const storageServices = (creation: ServiceCreation) => { ]; }; -it.live("reports the Storage S3 endpoint and access keys through the gateway", () => +it.effect("reports the Storage S3 endpoint and access keys through the gateway", () => Effect.gen(function* () { const text = yield* runStatus({ services: storageServices(storage(true)), reachable: true }); yield* text.effect; @@ -641,7 +645,7 @@ it.live("reports the Storage S3 endpoint and access keys through the gateway", ( }), ); -it.live("omits Storage S3 details when the S3 protocol is disabled", () => +it.effect("omits Storage S3 details when the S3 protocol is disabled", () => Effect.gen(function* () { const text = yield* runStatus({ services: storageServices(storage(false)), reachable: true }); yield* text.effect; @@ -659,7 +663,7 @@ it.live("omits Storage S3 details when the S3 protocol is disabled", () => }), ); -it.live("omits Storage S3 details for a Storage member saved without S3 keys", () => +it.effect("omits Storage S3 details for a Storage member saved without S3 keys", () => Effect.gen(function* () { const env = yield* runStatus({ services: storageServices({ @@ -681,7 +685,7 @@ for (const { functionsMember, status } of [ { functionsMember: false, status: "unchanged" }, { functionsMember: true, status: "unavailable" }, ] as const) - it.live( + it.effect( `reports ${status} drift for a multiline Functions dotenv when Functions is ${functionsMember ? "" : "not "}a member`, () => Effect.gen(function* () { @@ -705,7 +709,7 @@ for (const { functionsMember, status } of [ }), ); -it.live("reports unavailable owner and does not query service status", () => +it.effect("reports unavailable owner and does not query service status", () => Effect.gen(function* () { const statusCalls = { value: 0 }; const services = [ @@ -724,7 +728,7 @@ it.live("reports unavailable owner and does not query service status", () => }), ); -it.live("rejects environment export when the owner is unavailable", () => +it.effect("rejects environment export when the owner is unavailable", () => Effect.gen(function* () { const run = yield* runStatus({ services: [makeService({ id: "database-id", creation: database, statusCalls: { value: 0 } })], @@ -742,7 +746,7 @@ it.live("rejects environment export when the owner is unavailable", () => }), ); -it.live("exports saved credentials only for a running database", () => +it.effect("exports saved credentials only for a running database", () => Effect.gen(function* () { const services = [ makeService({ @@ -787,7 +791,7 @@ it.live("exports saved credentials only for a running database", () => }), ); -it.live("derives DB_URL using the postgres role and a non-default saved password", () => +it.effect("derives DB_URL using the postgres role and a non-default saved password", () => Effect.gen(function* () { const password = "p@ss w/ord!"; const customDatabase: ServiceCreation = { @@ -820,7 +824,7 @@ it.live("derives DB_URL using the postgres role and a non-default saved password }), ); -it.live("the status JSON env map equals the status --env export for the same stack", () => +it.effect("the status JSON env map equals the status --env export for the same stack", () => Effect.gen(function* () { const services = [ makeService({ @@ -846,9 +850,9 @@ it.live("the status JSON env map equals the status --env export for the same sta ]; const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); yield* report.effect; - const result = report.out.messages.find((message) => message.type === "success")?.data as { - env: Readonly>; - }; + const result = yield* Schema.decodeUnknownEffect(StatusEnvReport)( + report.out.messages.find((message) => message.type === "success")?.data, + ); const exported = yield* runStatus({ services, reachable: true, @@ -864,7 +868,7 @@ it.live("the status JSON env map equals the status --env export for the same sta }), ); -it.live("status JSON env degrades to what's available when credentials are unreachable", () => +it.effect("status JSON env degrades to what's available when credentials are unreachable", () => Effect.gen(function* () { const services = [ makeService({ @@ -885,15 +889,15 @@ it.live("status JSON env degrades to what's available when credentials are unrea credentialsUnavailable: true, }); yield* run.effect; - const result = run.out.messages.find((message) => message.type === "success")?.data as { - env: Readonly>; - }; + const result = yield* Schema.decodeUnknownEffect(StatusEnvReport)( + run.out.messages.find((message) => message.type === "success")?.data, + ); expect(result.env).not.toHaveProperty("PUBLISHABLE_KEY"); expect(result.env.DB_URL).toContain("127.0.0.1:54322"); }), ); -it.live("uses only the composition database for environment export", () => +it.effect("uses only the composition database for environment export", () => Effect.gen(function* () { const shadow = makeObservation("shadow-db", database, { lifecycle: "running", @@ -930,7 +934,7 @@ it.live("uses only the composition database for environment export", () => }), ); -it.live("keeps status usable with malformed project configuration", () => +it.effect("keeps status usable with malformed project configuration", () => Effect.gen(function* () { const services = [ makeService({ diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index c8ee078a28..e7b8f7b11c 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -57,7 +57,7 @@ describe("stack stop", () => { }).pipe(Effect.provide(live)), ); - it.live( + it.effect( "stop all reclaims ownerless stacks and keeps their definitions without claiming a stop", () => Effect.gen(function* () { @@ -81,7 +81,7 @@ describe("stack stop", () => { }).pipe(Effect.provide(live)), ); - it.live.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + it.effect.skipIf(process.platform === "win32" || process.getuid?.() === 0)( "stop all continues past siblings whose state cannot be decoded or accessed and warns about them", () => Effect.gen(function* () { @@ -106,7 +106,7 @@ describe("stack stop", () => { }).pipe(Effect.provide(live)), ); - it.live("surfaces a shutdown failure after a successful owner preflight", () => + it.effect("surfaces a shutdown failure after a successful owner preflight", () => Effect.gen(function* () { const f = yield* fixture(); const saved = (yield* f.api.discover(f.locations))[0]; diff --git a/apps/cli/src/commands/init/init.integration.test.ts b/apps/cli/src/commands/init/init.integration.test.ts index 91fde6b4d8..be785f1127 100644 --- a/apps/cli/src/commands/init/init.integration.test.ts +++ b/apps/cli/src/commands/init/init.integration.test.ts @@ -11,6 +11,8 @@ import { Layer, Option, Path, + Predicate, + Schema, Stdio, } from "effect"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; @@ -25,6 +27,12 @@ import { init } from "./init.handler.ts"; const tempRoot = useTempWorkdir("supabase-init-"); +const decodeOrioleDbConfig = Schema.decodeUnknownEffect( + Schema.Struct({ + db: Schema.Struct({ major_version: Schema.Finite, orioledb_version: Schema.String }), + }), +); + const readTextFile = (...segments: Array) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -69,15 +77,15 @@ function setup( }; } -function findFailure(exit: Exit.Exit): Record { +function findFailure(exit: Exit.Exit): unknown { expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { - return {}; + return undefined; } const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure)).toBe(true); - return Option.isSome(failure) ? (failure.value as Record) : {}; + return Option.getOrUndefined(failure); } /** @@ -118,7 +126,7 @@ function renderFailureToStderr(exit: Exit.Exit) { } describe("init", () => { - it.live("creates config.toml natively without the Go proxy", () => { + it.effect("creates config.toml natively without the Go proxy", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -141,7 +149,7 @@ describe("init", () => { }); }); - it.live.each([ + it.effect.each([ { experimental: false, label: "without --experimental" }, { experimental: true, label: "with --experimental" }, ])("writes OrioleDB config $label", ({ experimental }) => { @@ -160,14 +168,14 @@ describe("init", () => { }).pipe(Effect.provide(layer)); const content = yield* readTextFile(tempDir, "supabase", "config.toml"); - const db = (SmolToml.parse(content) as { db?: Record }).db; - expect(db?.["major_version"]).toBe(17); - expect(orioledbVersions()).toContain(db?.["orioledb_version"]); + const { db } = yield* decodeOrioleDbConfig(SmolToml.parse(content)); + expect(db.major_version).toBe(17); + expect(orioledbVersions()).toContain(db.orioledb_version); expect(out.stdoutText).toBe("Finished supabase init.\n"); }); }); - it.live("fails with Go's exact error when config.toml already exists", () => { + it.effect("fails with Go's exact error when config.toml already exists", () => { const tempDir = tempRoot.current; const initFlags = { @@ -186,13 +194,11 @@ describe("init", () => { const exit = yield* init(initFlags).pipe(Effect.provide(layer), Effect.exit); const error = findFailure(exit); - expect(error["_tag"]).toBe("InitConfigExistsError"); - expect(error["message"]).toBe( - "failed to create config file: open supabase/config.toml: file exists", - ); - expect(error["suggestion"]).toBe( - "Run supabase init --force to overwrite existing config file.", - ); + expect(Predicate.isTagged(error, "InitConfigExistsError")).toBe(true); + expect(error).toMatchObject({ + message: "failed to create config file: open supabase/config.toml: file exists", + suggestion: "Run supabase init --force to overwrite existing config file.", + }); expect(yield* renderFailureToStderr(exit)).toEqual([ "failed to create config file: open supabase/config.toml: file exists\n", @@ -201,7 +207,7 @@ describe("init", () => { }); }); - it.live("renders the Windows form of the already-exists error on win32", () => { + it.effect("renders the Windows form of the already-exists error on win32", () => { const tempDir = tempRoot.current; const initFlags = { @@ -220,13 +226,11 @@ describe("init", () => { const exit = yield* init(initFlags).pipe(Effect.provide(layer), Effect.exit); const error = findFailure(exit); - expect(error["_tag"]).toBe("InitConfigExistsError"); - expect(error["message"]).toBe( - "failed to create config file: open supabase\\config.toml: The file exists.", - ); - expect(error["suggestion"]).toBe( - "Run supabase init --force to overwrite existing config file.", - ); + expect(Predicate.isTagged(error, "InitConfigExistsError")).toBe(true); + expect(error).toMatchObject({ + message: "failed to create config file: open supabase\\config.toml: The file exists.", + suggestion: "Run supabase init --force to overwrite existing config file.", + }); expect(yield* renderFailureToStderr(exit)).toEqual([ "failed to create config file: open supabase\\config.toml: The file exists.\n", @@ -235,7 +239,7 @@ describe("init", () => { }); }); - it.live("supports the hidden IDE flags natively", () => { + it.effect("supports the hidden IDE flags natively", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -264,7 +268,7 @@ describe("init", () => { }); }); - it.live("respects the legacy --workdir global flag", () => { + it.effect("respects the legacy --workdir global flag", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -292,7 +296,7 @@ describe("init", () => { withIntellijSettings: false, } as const; - it.live("init -i --yes writes VS Code settings with the Go echo instead of prompting", () => { + it.effect("init -i --yes writes VS Code settings with the Go echo instead of prompting", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -309,7 +313,7 @@ describe("init", () => { }); }); - it.live("init -i with SUPABASE_YES=1 auto-accepts the VS Code prompt like --yes", () => { + it.effect("init -i with SUPABASE_YES=1 auto-accepts the VS Code prompt like --yes", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -329,7 +333,7 @@ describe("init", () => { }); }); - it.live("init -i --yes writes VS Code settings even when stdout is piped (Go parity)", () => { + it.effect("init -i --yes writes VS Code settings even when stdout is piped (Go parity)", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -344,7 +348,7 @@ describe("init", () => { }); }); - it.live("writes the stack-opt-in template when SUPABASE_EXPERIMENTAL_STACK=1", () => { + it.effect("writes the stack-opt-in template when SUPABASE_EXPERIMENTAL_STACK=1", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -361,7 +365,7 @@ describe("init", () => { }); }); - it.live("keeps the established template when SUPABASE_EXPERIMENTAL_STACK=0", () => { + it.effect("keeps the established template when SUPABASE_EXPERIMENTAL_STACK=0", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -375,7 +379,7 @@ describe("init", () => { }); }); - it.live("fails closed when SUPABASE_EXPERIMENTAL_STACK is not 0 or 1", () => { + it.effect("fails closed when SUPABASE_EXPERIMENTAL_STACK is not 0 or 1", () => { const tempDir = tempRoot.current; return Effect.gen(function* () { @@ -387,8 +391,10 @@ describe("init", () => { ); const error = findFailure(exit); - expect(error["_tag"]).toBe("ExperimentalFeatureFlagError"); - expect(error["message"]).toBe("SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set"); + expect(Predicate.isTagged(error, "ExperimentalFeatureFlagError")).toBe(true); + expect(error).toMatchObject({ + message: "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set", + }); expect(yield* renderFailureToStderr(exit)).toEqual([ "SUPABASE_EXPERIMENTAL_STACK must be 0 or 1 when set\n", diff --git a/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts index bfdb402c2f..d3d08c3319 100644 --- a/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-deprecated.integration.test.ts @@ -177,7 +177,7 @@ describe("inspect db deprecated aliases", () => { }); for (const testCase of cases) { - it.live(`${testCase.alias} warns and runs the ${testCase.routedSpec.name} query`, () => { + it.effect(`${testCase.alias} warns and runs the ${testCase.routedSpec.name} query`, () => { const ctx = setup(); return Effect.gen(function* () { yield* testCase.handler(flags); diff --git a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts index 0e61bc8ec0..ac87b12ab7 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts @@ -175,7 +175,7 @@ const flags = (over: Partial = {}): InspectConnectionFla }); describe("inspect db query runner", () => { - it.live("renders a glamour table in text mode (db-stats)", () => { + it.effect("renders a glamour table in text mode (db-stats)", () => { const { layer, out, connection } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); @@ -192,7 +192,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("collapses statement whitespace and formats bool/int cells (locks)", () => { + it.effect("collapses statement whitespace and formats bool/int cells (locks)", () => { const { layer, out } = setup({ rows: [LOCKS_ROW] }); return Effect.gen(function* () { yield* inspectDbLocks(flags()); @@ -202,7 +202,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("renders an empty backtick-wrapped cell as two literal backticks (role-stats)", () => { + it.effect("renders an empty backtick-wrapped cell as two literal backticks (role-stats)", () => { const ROLE_ROW = { role_name: "postgres", active_connections: 3, @@ -219,7 +219,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits raw rows in json mode", () => { + it.effect("emits raw rows in json mode", () => { const { layer, out } = setup({ format: "json", rows: [DB_STATS_ROW] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); @@ -234,7 +234,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a result event in stream-json mode", () => { + it.effect("emits a result event in stream-json mode", () => { const { layer, out } = setup({ format: "stream-json", rows: [DB_STATS_ROW] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); @@ -244,7 +244,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("inspects an explicit database url", () => { + it.effect("inspects an explicit database url", () => { const { layer, resolver, connection } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -253,13 +253,13 @@ describe("inspect db query runner", () => { }); return Effect.gen(function* () { yield* inspectDbDbStats(flags({ dbUrl: Option.some("postgres://x") })); - expect(Option.isSome(resolver.resolveInput?.dbUrl ?? Option.none())).toBe(true); + expect(resolver.resolveInput?.dbUrl).toEqual(Option.some("postgres://x")); expect(resolver.resolveInput?.connType).toBe("db-url"); expect(connection.connectCalls[0]?.isLocal).toBe(false); }).pipe(Effect.provide(layer)); }); - it.live("inspects the local database", () => { + it.effect("inspects the local database", () => { const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--local"] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags({ local: true })); @@ -268,16 +268,16 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("inspects the linked project by default (no connection flag)", () => { + it.effect("inspects the linked project by default (no connection flag)", () => { const { layer, resolver } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); expect(resolver.resolveInput?.connType).toBe("linked"); - expect(Option.isNone(resolver.resolveInput?.dbUrl ?? Option.some("x"))).toBe(true); + expect(resolver.resolveInput?.dbUrl).toEqual(Option.none()); }).pipe(Effect.provide(layer)); }); - it.live("labels the diagnostic 'remote' for a non-local connection", () => { + it.effect("labels the diagnostic 'remote' for a non-local connection", () => { const { layer, out } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -290,28 +290,18 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects conflicting connection flags", () => { + it.effect("rejects conflicting connection flags", () => { const { layer } = setup({ cliArgs: ["--linked", "--local"] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(inspectDbDbStats(flags({ linked: true, local: true }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - const error = failure.value; - expect(error).toBeInstanceOf(InspectMutuallyExclusiveFlagsError); - if (error instanceof InspectMutuallyExclusiveFlagsError) { - expect(error.message).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } - } - } + const error = yield* inspectDbDbStats(flags({ linked: true, local: true })).pipe(Effect.flip); + expect(error).toBeInstanceOf(InspectMutuallyExclusiveFlagsError); + expect(error.message).toBe( + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + ); }).pipe(Effect.provide(layer)); }); - it.live("--local=false is Changed and routes to local (not linked)", () => { + it.effect("--local=false is Changed and routes to local (not linked)", () => { const { layer, resolver } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--local=false"] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags({ local: false })); @@ -319,28 +309,20 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("--linked --local=false raises the mutual-exclusion error", () => { + it.effect("--linked --local=false raises the mutual-exclusion error", () => { const { layer } = setup({ cliArgs: ["--linked", "--local=false"] }); return Effect.gen(function* () { - const exit = yield* Effect.exit(inspectDbDbStats(flags({ linked: true, local: false }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - const error = failure.value; - expect(error).toBeInstanceOf(InspectMutuallyExclusiveFlagsError); - if (error instanceof InspectMutuallyExclusiveFlagsError) { - expect(error.message).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } - } - } + const error = yield* inspectDbDbStats(flags({ linked: true, local: false })).pipe( + Effect.flip, + ); + expect(error).toBeInstanceOf(InspectMutuallyExclusiveFlagsError); + expect(error.message).toBe( + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + ); }).pipe(Effect.provide(layer)); }); - it.live("--linked routes to linked", () => { + it.effect("--linked routes to linked", () => { const { layer, resolver } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--linked"] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags({ linked: true })); @@ -348,7 +330,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("inspects the project given via --project-ref on the default linked path", () => { + it.effect("inspects the project given via --project-ref on the default linked path", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { @@ -358,32 +340,22 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with an explicit --local target", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--local"] }); return Effect.gen(function* () { - const exit = yield* Effect.exit( - inspectDbDbStats(flags({ local: true, projectRef: Option.some(FLAG_REF) })), + const error = yield* inspectDbDbStats( + flags({ local: true, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(InspectMutuallyExclusiveFlagsError); + expect(error.message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - const error = failure.value; - expect(error).toBeInstanceOf(InspectMutuallyExclusiveFlagsError); - if (error instanceof InspectMutuallyExclusiveFlagsError) { - expect(error.message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } - } - } expect(resolver.resolveInput).toBeUndefined(); }).pipe(Effect.provide(layer)); }); - it.live("surfaces a query failure", () => { + it.effect("surfaces a query failure", () => { const { layer } = setup({ queryFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(inspectDbDbStats(flags())); @@ -395,7 +367,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("surfaces a connection failure", () => { + it.effect("surfaces a connection failure", () => { const { layer } = setup({ connectFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(inspectDbDbStats(flags())); @@ -407,7 +379,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("surfaces a resolution failure", () => { + it.effect("surfaces a resolution failure", () => { const { layer } = setup({ resolveFails: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(inspectDbDbStats(flags())); @@ -419,7 +391,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("renders the header and separator for an empty result set", () => { + it.effect("renders the header and separator for an empty result set", () => { const { layer, out } = setup({ rows: [] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); @@ -429,7 +401,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits an empty rows array in json mode for no results", () => { + it.effect("emits an empty rows array in json mode for no results", () => { const { layer, out } = setup({ format: "json", rows: [] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); @@ -439,7 +411,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("forwards the https dns resolver to the connection", () => { + it.effect("forwards the https dns resolver to the connection", () => { const { layer, connection } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -452,7 +424,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry on completion", () => { + it.effect("flushes telemetry on completion", () => { const { layer, telemetry } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); @@ -460,7 +432,7 @@ describe("inspect db query runner", () => { }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry even when the query fails", () => { + it.effect("flushes telemetry even when the query fails", () => { const { layer, telemetry } = setup({ queryFails: true }); return Effect.gen(function* () { yield* Effect.exit(inspectDbDbStats(flags())); diff --git a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts index 7e67fc8ea3..6081127282 100644 --- a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts @@ -256,7 +256,7 @@ describe("inspect db specs (per-subcommand correctness)", () => { }); for (const testCase of cases) { - it.live(`runs the ${testCase.spec.name} query and renders its cells`, () => { + it.effect(`runs the ${testCase.spec.name} query and renders its cells`, () => { const ctx = setup([testCase.row]); return Effect.gen(function* () { yield* runInspectQuery(testCase.spec, localFlags, "native"); diff --git a/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts b/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts index 0a49436872..33c4387341 100644 --- a/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts +++ b/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts @@ -79,7 +79,7 @@ function ambientStubs() { } describe("inspectBaseLayer — IdentityStitch exposure", () => { - it.live( + it.effect( "exposes IdentityStitch at top level so withCommandTelemetry can read stitchedDistinctId()", () => { return Effect.gen(function* () { diff --git a/apps/cli/src/commands/inspect/report/report.config.ts b/apps/cli/src/commands/inspect/report/report.config.ts index d7e068dfbf..f522556f75 100644 --- a/apps/cli/src/commands/inspect/report/report.config.ts +++ b/apps/cli/src/commands/inspect/report/report.config.ts @@ -1,4 +1,4 @@ -import { Effect, type FileSystem, Match, Option, type Path } from "effect"; +import { Effect, type FileSystem, Option, type Path, Predicate } from "effect"; import * as SmolToml from "smol-toml"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; import { @@ -11,10 +11,10 @@ import type { InspectRule } from "./report.rules.ts"; type RawDoc = { readonly [key: string]: unknown }; +const NO_RULES: ReadonlyArray = []; + function asRecord(value: unknown): RawDoc | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as RawDoc) - : undefined; + return Predicate.isObject(value) ? value : undefined; } /** @@ -46,21 +46,20 @@ export const readInspectRules = Effect.fnUntraced(function* ( const content = yield* fs.readFileString(configPath).pipe( Effect.map((text): string | undefined => text), - Effect.catchTag("PlatformError", (error) => - Match.value(error.reason).pipe( - Match.tag("NotFound", () => Effect.void), - Match.orElse(() => - Effect.fail( - new DbConfigLoadError({ - message: `failed to read file config: ${error.message}`, - }), - ), + Effect.catchReason( + "PlatformError", + "NotFound", + () => Effect.void, + (_reason, error) => + Effect.fail( + new DbConfigLoadError({ + message: `failed to read file config: ${error.message}`, + }), ), - ), ), ); - if (content === undefined) return [] as ReadonlyArray; + if (content === undefined) return NO_RULES; const doc = yield* Effect.try({ try: () => asRecord(SmolToml.parse(content)), @@ -77,17 +76,17 @@ export const readInspectRules = Effect.fnUntraced(function* ( // wraps into an entry, which fails the table check below. let entries: ReadonlyArray; if (rawRules === undefined) { - return [] as ReadonlyArray; + return NO_RULES; } else if (Array.isArray(rawRules)) { entries = rawRules; } else { const asMap = asRecord(rawRules); if (asMap !== undefined && Object.keys(asMap).length === 0) { - return [] as ReadonlyArray; + return NO_RULES; } entries = [rawRules]; } - if (entries.length === 0) return [] as ReadonlyArray; + if (entries.length === 0) return NO_RULES; const RULE_FIELDS = ["query", "name", "pass", "fail"] as const; @@ -101,6 +100,19 @@ export const readInspectRules = Effect.fnUntraced(function* ( Option.getOrElse(fromEnv, () => projectEnv[name]), ); }); + const readField = Effect.fnUntraced(function* ( + record: RawDoc, + index: number, + field: (typeof RULE_FIELDS)[number], + ) { + const coerced = coerceRuleField(record[field]); + if (coerced === undefined) { + return yield* new DbConfigLoadError({ + message: `failed to load config: experimental.inspect.rules[${index}].${field} expected a string`, + }); + } + return yield* expandEnv(coerced); + }); const rules: Array = []; for (let index = 0; index < entries.length; index++) { @@ -113,29 +125,19 @@ export const readInspectRules = Effect.fnUntraced(function* ( } // An unknown or misspelled key aborts the whole load instead of being ignored. const unknownKeys = Object.keys(record).filter( - (key) => !(RULE_FIELDS as ReadonlyArray).includes(key), + (key) => !RULE_FIELDS.some((field) => field === key), ); if (unknownKeys.length > 0) { return yield* new DbConfigLoadError({ message: `failed to load config: experimental.inspect.rules[${index}] has invalid keys: ${unknownKeys.join(", ")}`, }); } - const fields: Record = {}; - for (const field of RULE_FIELDS) { - const coerced = coerceRuleField(record[field]); - if (coerced === undefined) { - return yield* new DbConfigLoadError({ - message: `failed to load config: experimental.inspect.rules[${index}].${field} expected a string`, - }); - } - fields[field] = yield* expandEnv(coerced); - } rules.push({ - query: fields["query"]!, - name: fields["name"]!, - pass: fields["pass"]!, - fail: fields["fail"]!, + query: yield* readField(record, index, "query"), + name: yield* readField(record, index, "name"), + pass: yield* readField(record, index, "pass"), + fail: yield* readField(record, index, "fail"), }); } - return rules as ReadonlyArray; + return rules; }); diff --git a/apps/cli/src/commands/inspect/report/report.config.unit.test.ts b/apps/cli/src/commands/inspect/report/report.config.unit.test.ts index e93b73416d..7bc53ce8d1 100644 --- a/apps/cli/src/commands/inspect/report/report.config.unit.test.ts +++ b/apps/cli/src/commands/inspect/report/report.config.unit.test.ts @@ -12,7 +12,7 @@ const makeWorkdir = Effect.fnUntraced(function* ( configToml?: string, dotEnv?: string, ) { - const workdir = yield* fs.makeTempDirectory({ prefix: "supabase-report-config-" }); + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-report-config-" }); if (configToml !== undefined || dotEnv !== undefined) { yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); } diff --git a/apps/cli/src/commands/inspect/report/report.csvq.ts b/apps/cli/src/commands/inspect/report/report.csvq.ts index a6f123ea43..ac0b78f216 100644 --- a/apps/cli/src/commands/inspect/report/report.csvq.ts +++ b/apps/cli/src/commands/inspect/report/report.csvq.ts @@ -51,7 +51,7 @@ function parseCsvRecords(text: string): Array> { started = false; }; for (let i = 0; i < text.length; i++) { - const ch = text[i]!; + const ch = text[i] ?? ""; if (inQuotes) { started = true; if (ch === '"') { @@ -94,10 +94,10 @@ function parseCsvRecords(text: string): Array> { export function parseReportCsv(input: Uint8Array | string): CsvTable { const text = typeof input === "string" ? input : new TextDecoder().decode(input); const records = parseCsvRecords(text); - if (records.length === 0) { + const header = records[0]; + if (header === undefined) { return { columns: new Map(), rows: [] }; } - const header = records[0]!; const columns = new Map(); header.forEach((name, index) => { columns.set(name.toLowerCase(), index); @@ -121,7 +121,7 @@ function tokenize(sql: string): Array { const tokens: Array = []; let i = 0; while (i < sql.length) { - const ch = sql[i]!; + const ch = sql[i] ?? ""; if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { i++; continue; @@ -159,7 +159,7 @@ function tokenize(sql: string): Array { } if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(sql[i + 1] ?? ""))) { let raw = ""; - while (i < sql.length && /[0-9.eE+-]/.test(sql[i]!)) { + while (i < sql.length && /[0-9.eE+-]/.test(sql[i] ?? "")) { // Stop a sign that is not part of an exponent (e.g. `1-2`). if ((sql[i] === "+" || sql[i] === "-") && !/[eE]/.test(sql[i - 1] ?? "")) break; raw += sql[i]; @@ -173,7 +173,7 @@ function tokenize(sql: string): Array { } if (IDENT_START.test(ch)) { let value = ""; - while (i < sql.length && IDENT_PART.test(sql[i]!)) { + while (i < sql.length && IDENT_PART.test(sql[i] ?? "")) { value += sql[i]; i++; } @@ -236,31 +236,54 @@ type CondNode = | { readonly k: "cmp"; readonly op: string; readonly l: ValNode; readonly r: ValNode } | { readonly k: "isnull"; readonly e: ValNode; readonly negated: boolean }; -interface AggNode { - readonly fn: "LISTAGG" | "COUNT" | "SUM" | "MIN" | "MAX" | "AVG"; - readonly col?: string; // undefined for COUNT(*) - readonly star?: boolean; - readonly sep?: string; // LISTAGG separator -} +type AggFn = "LISTAGG" | "COUNT" | "SUM" | "MIN" | "MAX" | "AVG"; + +type AggNode = + | { readonly fn: "COUNT"; readonly star: true } + | { + readonly fn: AggFn; + readonly star?: undefined; + readonly col: string; + readonly sep?: string; // LISTAGG separator + }; + +type SelectExpr = + | { readonly agg: AggNode; readonly expr?: undefined } + | { readonly agg?: undefined; readonly expr: ValNode }; // plain (non-aggregate) scalar expression -interface SelectStmt { - readonly agg?: AggNode; - readonly expr?: ValNode; // plain (non-aggregate) scalar expression +type SelectStmt = SelectExpr & { readonly table: string; readonly where?: CondNode; -} +}; + +const EOF_TOKEN: Token = { t: "eof" }; -const AGG_FNS = new Set(["LISTAGG", "COUNT", "SUM", "MIN", "MAX", "AVG"]); +const AGG_FNS: ReadonlySet = new Set([ + "LISTAGG", + "COUNT", + "SUM", + "MIN", + "MAX", + "AVG", +]); + +function isAggFn(name: string): name is AggFn { + return AGG_FNS.has(name); +} class Parser { private pos = 0; constructor(private readonly tokens: ReadonlyArray) {} private peek(): Token { - return this.tokens[this.pos]!; + return this.tokens[this.pos] ?? EOF_TOKEN; } private next(): Token { - return this.tokens[this.pos++]!; + return this.tokens[this.pos++] ?? EOF_TOKEN; + } + private peekOp(a: string, b?: string): string | undefined { + const tok = this.peek(); + return tok.t === "op" && (tok.v === a || tok.v === b) ? tok.v : undefined; } private isKeyword(word: string): boolean { const tok = this.peek(); @@ -291,7 +314,7 @@ class Parser { parse(): SelectStmt { this.expectKeyword("SELECT"); - const { agg, expr } = this.parseSelectExpr(); + const select = this.parseSelectExpr(); if (this.eatKeyword("AS")) { const tok = this.next(); if (tok.t !== "ident") throw new InspectCsvqError({ message: "expected alias after AS" }); @@ -313,27 +336,25 @@ class Parser { if (this.peek().t !== "eof") { throw new InspectCsvqError({ message: "unexpected trailing tokens" }); } - return { agg, expr, table: tableTok.v, where }; + return { ...select, table: tableTok.v, where }; } - private parseSelectExpr(): { agg?: AggNode; expr?: ValNode } { + private parseSelectExpr(): SelectExpr { const tok = this.peek(); - if ( - tok.t === "ident" && - AGG_FNS.has(tok.v.toUpperCase()) && - this.tokens[this.pos + 1]?.t === "punct" && - (this.tokens[this.pos + 1] as { v: string }).v === "(" - ) { - return { agg: this.parseAgg() }; + const after = this.tokens[this.pos + 1]; + if (tok.t === "ident" && after?.t === "punct" && after.v === "(") { + const fn = tok.v.toUpperCase(); + if (isAggFn(fn)) { + return { agg: this.parseAgg(fn) }; + } } return { expr: this.parseValueExpr() }; } - private parseAgg(): AggNode { - const fnTok = this.next(); - const fn = (fnTok as { v: string }).v.toUpperCase() as AggNode["fn"]; + private parseAgg(fn: AggFn): AggNode { + this.pos++; this.expectPunct("("); - if (fn === "COUNT" && this.peek().t === "op" && (this.peek() as { v: string }).v === "*") { + if (fn === "COUNT" && this.peekOp("*") !== undefined) { this.pos++; this.expectPunct(")"); return { fn, star: true }; @@ -413,30 +434,24 @@ class Parser { } private parseConcat(): ValNode { let left = this.parseArith(); - while (this.peek().t === "op" && (this.peek() as { v: string }).v === "||") { - const op = (this.next() as { v: string }).v; + for (let op = this.peekOp("||"); op !== undefined; op = this.peekOp("||")) { + this.pos++; left = { k: "binop", op, l: left, r: this.parseArith() }; } return left; } private parseArith(): ValNode { let left = this.parseTerm(); - while ( - this.peek().t === "op" && - ((this.peek() as { v: string }).v === "+" || (this.peek() as { v: string }).v === "-") - ) { - const op = (this.next() as { v: string }).v; + for (let op = this.peekOp("+", "-"); op !== undefined; op = this.peekOp("+", "-")) { + this.pos++; left = { k: "binop", op, l: left, r: this.parseTerm() }; } return left; } private parseTerm(): ValNode { let left = this.parseFactor(); - while ( - this.peek().t === "op" && - ((this.peek() as { v: string }).v === "*" || (this.peek() as { v: string }).v === "/") - ) { - const op = (this.next() as { v: string }).v; + for (let op = this.peekOp("*", "/"); op !== undefined; op = this.peekOp("*", "/")) { + this.pos++; left = { k: "binop", op, l: left, r: this.parseFactor() }; } return left; @@ -643,12 +658,12 @@ function evalAggregate( table: CsvTable, rows: Array>, ): Option.Option { - if (agg.fn === "COUNT" && agg.star === true) { + if (agg.star === true) { return Option.some(String(rows.length)); } // Resolves the column before checking any rows, so an unknown column always errors — // matching csvq's schema validation, even when the matched set would otherwise be empty. - const index = columnIndex(table, agg.col!); + const index = columnIndex(table, agg.col); if (agg.fn === "COUNT") { // CSV cells are never NULL, so COUNT(col) == COUNT(*) == the matched-row count. return Option.some(String(rows.length)); @@ -708,7 +723,7 @@ export function evalCsvqScalar(query: string, provider: CsvTableProvider): Optio const first = rows[0]; return first === undefined ? Option.none() - : Option.some(toStringValue(evalVal(stmt.expr!, table, first))); + : Option.some(toStringValue(evalVal(stmt.expr, table, first))); } const DUPLICATE_INDEXES_QUERY = diff --git a/apps/cli/src/commands/inspect/report/report.csvq.unit.test.ts b/apps/cli/src/commands/inspect/report/report.csvq.unit.test.ts index 4683515bb4..0035c695a6 100644 --- a/apps/cli/src/commands/inspect/report/report.csvq.unit.test.ts +++ b/apps/cli/src/commands/inspect/report/report.csvq.unit.test.ts @@ -10,7 +10,10 @@ import { import { DEFAULT_INSPECT_RULES } from "./report.rules.ts"; function provider(tables: Record): CsvTableProvider { - return (name) => (name in tables ? parseReportCsv(tables[name]!) : undefined); + return (name) => { + const csv = tables[name]; + return csv === undefined ? undefined : parseReportCsv(csv); + }; } const rule = (name: string): string => { diff --git a/apps/cli/src/commands/inspect/report/report.e2e.test.ts b/apps/cli/src/commands/inspect/report/report.e2e.test.ts index abc3210346..b411202981 100644 --- a/apps/cli/src/commands/inspect/report/report.e2e.test.ts +++ b/apps/cli/src/commands/inspect/report/report.e2e.test.ts @@ -34,7 +34,7 @@ describe("supabase inspect report", () => { /^\d{4}-\d{2}-\d{2}$/.test(name), ); expect(dated.length).toBe(1); - expect(yield* fs.exists(path.join(outputDir, dated[0]!))).toBe(true); + expect(yield* fs.exists(path.join(outputDir, dated[0] ?? ""))).toBe(true); }), ).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, diff --git a/apps/cli/src/commands/inspect/report/report.integration.test.ts b/apps/cli/src/commands/inspect/report/report.integration.test.ts index 9abdf979c2..922ce48025 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -23,7 +23,7 @@ import { cliConfigProviderLayer } from "../../../shared/config/cli-config-provid import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; -import type { ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; +import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbConnectError, DbCopyError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; import type { InspectReportFlags } from "./report.command.ts"; @@ -47,7 +47,7 @@ for (const { fileName, sql } of REPORT_QUERIES) { const tempDir = Effect.fnUntraced(function* (prefix: string) { const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectory({ prefix }); + return yield* fs.makeTempDirectoryScoped({ prefix }); }); /** A layer whose backing directory is a fresh temp dir, created when the layer is built. */ @@ -59,9 +59,9 @@ function tempDirLayer( } function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean; fails?: boolean } = {}) { - let resolveInput: unknown; + let resolveInput: DbConfigFlags | undefined; const layer = Layer.succeed(DbConfigResolver, { - resolve: (flags) => { + resolve: (flags: DbConfigFlags) => { resolveInput = flags; if (opts.fails === true) { return Effect.fail(new DbConfigLoadError({ message: "cannot load config" })); @@ -203,12 +203,12 @@ const dateFolderContents = Effect.fnUntraced(function* (base: string) { Effect.map(fs.stat(path.join(base, name)), (info) => info.type === "Directory"), ); expect(entries.length).toBe(1); - const dir = path.join(base, entries[0]!); + const dir = path.join(base, entries[0] ?? ""); return { dir, files: yield* fs.readDirectory(dir) }; }); describe("inspect report", () => { - it.live("writes one CSV per inspect query for the linked project", () => { + it.effect("writes one CSV per inspect query for the linked project", () => { const { layer, connection } = setupReport({ csvs: DEFAULT_RULE_CSVS }); const prevUmask = process.umask(0); return Effect.gen(function* () { @@ -232,7 +232,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => process.umask(prevUmask)))); }); - it.live("inspects the local database with --local", () => { + it.effect("inspects the local database with --local", () => { const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS, cliArgs: ["--local"], @@ -240,11 +240,11 @@ describe("inspect report", () => { return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base, local: true })); - expect((resolver.resolveInput as { connType: string }).connType).toBe("local"); + expect(resolver.resolveInput?.connType).toBe("local"); }).pipe(Effect.provide(layer)); }); - it.live("inspects a custom database with --db-url and labels the diagnostic 'remote'", () => { + it.effect("inspects a custom database with --db-url and labels the diagnostic 'remote'", () => { const { layer, resolver, out } = setupReport({ csvs: DEFAULT_RULE_CSVS, isLocal: false, @@ -253,23 +253,21 @@ describe("inspect report", () => { return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base, dbUrl: Option.some("postgres://x") })); - expect(Option.isSome((resolver.resolveInput as { dbUrl: Option.Option }).dbUrl)).toBe( - true, - ); + expect(resolver.resolveInput?.dbUrl).toEqual(Option.some("postgres://x")); expect(out.stderrText).toContain("Connecting to remote database..."); }).pipe(Effect.provide(layer)); }); - it.live("inspects the linked project by default when no connection flag is set", () => { + it.effect("inspects the linked project by default when no connection flag is set", () => { const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base })); - expect((resolver.resolveInput as { connType: string }).connType).toBe("linked"); + expect(resolver.resolveInput?.connType).toBe("linked"); }).pipe(Effect.provide(layer)); }); - it.live("rejects more than one of --db-url/--linked/--local", () => { + it.effect("rejects more than one of --db-url/--linked/--local", () => { const { layer } = setupReport({ cliArgs: ["--linked", "--local"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(inspectReport(flags({ linked: true, local: true }))); @@ -281,7 +279,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live("--local=false is Changed and routes to local (not linked)", () => { + it.effect("--local=false is Changed and routes to local (not linked)", () => { const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS, cliArgs: ["--local=false"], @@ -289,11 +287,11 @@ describe("inspect report", () => { return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base, local: false })); - expect((resolver.resolveInput as { connType: string }).connType).toBe("local"); + expect(resolver.resolveInput?.connType).toBe("local"); }).pipe(Effect.provide(layer)); }); - it.live("--linked --local=false raises the mutual-exclusion error", () => { + it.effect("--linked --local=false raises the mutual-exclusion error", () => { const { layer } = setupReport({ cliArgs: ["--linked", "--local=false"] }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -308,7 +306,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live("--linked routes to linked", () => { + it.effect("--linked routes to linked", () => { const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS, cliArgs: ["--linked"], @@ -316,26 +314,22 @@ describe("inspect report", () => { return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base, linked: true })); - expect((resolver.resolveInput as { connType: string }).connType).toBe("linked"); + expect(resolver.resolveInput?.connType).toBe("linked"); }).pipe(Effect.provide(layer)); }); - it.live("reports on the project given via --project-ref on the default linked path", () => { + it.effect("reports on the project given via --project-ref on the default linked path", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); yield* inspectReport(flags({ outputDir: base, projectRef: Option.some(FLAG_REF) })); - const resolveInput = resolver.resolveInput as { - connType: string; - linkedProjectRef: Option.Option; - }; - expect(resolveInput.connType).toBe("linked"); - expect(resolveInput.linkedProjectRef).toEqual(Option.some(FLAG_REF)); + expect(resolver.resolveInput?.connType).toBe("linked"); + expect(resolver.resolveInput?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); }).pipe(Effect.provide(layer)); }); - it.live("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with an explicit --local target", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS, @@ -356,7 +350,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "prints connect + running + saved progress to stderr and the rules table to stdout", () => { const { layer, out } = setupReport({ csvs: DEFAULT_RULE_CSVS, isLocal: true }); @@ -375,7 +369,7 @@ describe("inspect report", () => { }, ); - it.live("shows a passing rule as ✔/- and a failing rule with its message and matches", () => { + it.effect("shows a passing rule as ✔/- and a failing rule with its message and matches", () => { const { layer, out } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); @@ -387,7 +381,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "custom config.toml rules replace the defaults and suppress 'Loading default rules...'", () => Effect.gen(function* () { @@ -418,7 +412,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "keeps a rule's literal env(VAR) when the shell sets VAR empty, even if supabase/.env defines it", () => Effect.gen(function* () { @@ -457,7 +451,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("surfaces a malformed rule query as the STATUS cell without failing", () => + it.effect("surfaces a malformed rule query as the STATUS cell without failing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -485,7 +479,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("aborts on a malformed config.toml before connecting or writing any CSV", () => + it.effect("aborts on a malformed config.toml before connecting or writing any CSV", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -519,7 +513,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("emits a structured result and writes CSVs but no table in json mode", () => { + it.effect("emits a structured result and writes CSVs but no table in json mode", () => { const { layer, out } = setupReport({ format: "json", csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); @@ -528,19 +522,16 @@ describe("inspect report", () => { expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "inspect report" }), ); - const success = out.messages.find((m) => m.type === "success"); - const data = ( - success as { data?: { files?: Array; outputDir?: string; rules?: Array } } - ).data; - expect(data?.files?.length).toBe(14); + const data = out.messages.find((m) => m.type === "success")?.data; + expect(data?.files).toHaveLength(14); expect(typeof data?.outputDir).toBe("string"); - expect(data?.rules?.length).toBe(13); + expect(data?.rules).toHaveLength(13); expect((yield* dateFolderContents(base)).files.length).toBe(14); expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); - it.live("streams the structured result in stream-json mode", () => { + it.effect("streams the structured result in stream-json mode", () => { const { layer, out } = setupReport({ format: "stream-json", csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); @@ -551,24 +542,27 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live("aborts with a failed-to-mkdir error when the output directory cannot be created", () => { - const { layer } = setupReport(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - // Point --output-dir at a regular file so mkdir of `/` fails. - const fileAsDir = path.join(yield* tempDir("supabase-report-out-"), "afile"); - yield* fs.writeFileString(fileAsDir, "x"); - const exit = yield* Effect.exit(inspectReport(flags({ outputDir: fileAsDir }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("failed to mkdir"); - } - }).pipe(Effect.provide(layer)); - }); + it.effect( + "aborts with a failed-to-mkdir error when the output directory cannot be created", + () => { + const { layer } = setupReport(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Point --output-dir at a regular file so mkdir of `/` fails. + const fileAsDir = path.join(yield* tempDir("supabase-report-out-"), "afile"); + yield* fs.writeFileString(fileAsDir, "x"); + const exit = yield* Effect.exit(inspectReport(flags({ outputDir: fileAsDir }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("failed to mkdir"); + } + }).pipe(Effect.provide(layer)); + }, + ); - it.live("aborts with a copy error when COPY fails", () => { + it.effect("aborts with a copy error when COPY fails", () => { const { layer } = setupReport({ copyFails: true }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); @@ -581,7 +575,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live("aborts with a failed-to-create-output-file error when a CSV cannot be written", () => { + it.effect("aborts with a failed-to-create-output-file error when a CSV cannot be written", () => { const { layer } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -601,7 +595,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live("aborts when the connection fails", () => { + it.effect("aborts when the connection fails", () => { const { layer } = setupReport({ connectFails: true }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); @@ -614,7 +608,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live("aborts when resolution fails", () => { + it.effect("aborts when resolution fails", () => { const { layer } = setupReport({ resolveFails: true }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); @@ -627,7 +621,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live("resolves a relative --output-dir under the process CWD", () => + it.effect("resolves a relative --output-dir under the process CWD", () => Effect.gen(function* () { const path = yield* Path.Path; const cwd = yield* tempDir("supabase-report-cwd-"); @@ -638,7 +632,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("uses an absolute --output-dir as-is", () => + it.effect("uses an absolute --output-dir as-is", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const base = yield* tempDir("supabase-report-out-"); @@ -650,7 +644,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("renders the path in bold when stdout is a TTY", () => { + it.effect("renders the path in bold when stdout is a TTY", () => { const { layer, out } = setupReport({ csvs: DEFAULT_RULE_CSVS, stdoutIsTty: true }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); @@ -659,7 +653,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry on success", () => { + it.effect("flushes telemetry on success", () => { const { layer, telemetry } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); @@ -668,7 +662,7 @@ describe("inspect report", () => { }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry even when the command fails", () => { + it.effect("flushes telemetry even when the command fails", () => { const { layer, telemetry } = setupReport({ resolveFails: true }); return Effect.gen(function* () { const base = yield* tempDir("supabase-report-out-"); diff --git a/apps/cli/src/commands/inspect/report/report.queries.ts b/apps/cli/src/commands/inspect/report/report.queries.ts index f6db51df6d..6e508418c0 100644 --- a/apps/cli/src/commands/inspect/report/report.queries.ts +++ b/apps/cli/src/commands/inspect/report/report.queries.ts @@ -78,9 +78,6 @@ export function reportIgnoreSchemas(): string { * since a placeholder can repeat), then wraps the result in `COPY (...) TO STDOUT WITH CSV HEADER`. */ export function wrapReportQuery(sql: string, ...args: ReadonlyArray): string { - let query = sql; - for (let index = 0; index < args.length; index++) { - query = query.replaceAll(`$${index + 1}`, args[index]!); - } + const query = args.reduce((text, arg, index) => text.replaceAll(`$${index + 1}`, arg), sql); return `COPY (${query}) TO STDOUT WITH CSV HEADER`; } diff --git a/apps/cli/src/commands/inspect/report/report.rules.unit.test.ts b/apps/cli/src/commands/inspect/report/report.rules.unit.test.ts index e64e921c9d..b9218a60d4 100644 --- a/apps/cli/src/commands/inspect/report/report.rules.unit.test.ts +++ b/apps/cli/src/commands/inspect/report/report.rules.unit.test.ts @@ -4,7 +4,10 @@ import { type CsvTableProvider, parseReportCsv } from "./report.csvq.ts"; import { type InspectRule, buildRuleSummaryRows, evaluateInspectRule } from "./report.rules.ts"; function provider(tables: Record): CsvTableProvider { - return (name) => (name in tables ? parseReportCsv(tables[name]!) : undefined); + return (name) => { + const csv = tables[name]; + return csv === undefined ? undefined : parseReportCsv(csv); + }; } const RULE: InspectRule = { diff --git a/apps/cli/src/commands/link/link.handler.ts b/apps/cli/src/commands/link/link.handler.ts index 4fb7143a04..1ab9394bba 100644 --- a/apps/cli/src/commands/link/link.handler.ts +++ b/apps/cli/src/commands/link/link.handler.ts @@ -1,5 +1,5 @@ import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; -import { Duration, Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Duration, Effect, FileSystem, Option, Path, Ref, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; @@ -212,7 +212,7 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { // Captured once ref/branch resolution succeeds; `undefined` means no ref was ever resolved, // so the post-run cache fill below stays a no-op. - let resolvedRef: string | undefined; + const resolvedRef = yield* Ref.make(undefined); // Persists the linked-project cache and telemetry state whether the link succeeds or fails. // `link` itself writes `linked-project.json` on success (below), so `cache` only fires for @@ -244,7 +244,7 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { : requested; const ref = yield* resolver.resolveForLink(resolvedRefOrBranch); - resolvedRef = ref; + yield* Ref.set(resolvedRef, ref); yield* Effect.annotateCurrentSpan({ "project.ref": ref, "link.via_branch": Option.isSome(branchResolution), @@ -432,8 +432,8 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { } }).pipe( Effect.ensuring( - Effect.suspend(() => - resolvedRef === undefined ? Effect.void : linkedProjectCache.cache(resolvedRef), + Effect.flatMap(Ref.get(resolvedRef), (ref) => + ref === undefined ? Effect.void : linkedProjectCache.cache(ref), ), ), Effect.ensuring(telemetryState.flush), diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 3948924d16..c6ab16f356 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -1,7 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; import { BunCrypto } from "@effect/platform-bun"; import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Predicate, + Schema, + Stdio, +} from "effect"; import * as HttpClient from "effect/http/HttpClient"; import type * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequestModule from "effect/http/HttpClientRequest"; @@ -329,7 +340,7 @@ function transportFailureForMock() { describe("link integration", () => { describe("plain project-ref linking", () => { - it.live("links a project, writing the project-ref and version files", () => { + it.effect("links a project, writing the project-ref and version files", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { yield* link(flags()); @@ -346,7 +357,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("skips the storage migration file when the API has no migration version", () => { + it.effect("skips the storage migration file when the API has no migration version", () => { const { layer, workdir } = setup({ storageConfig: { ok: { migrationVersion: null } } }); return Effect.gen(function* () { yield* link(flags()); @@ -354,7 +365,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes linked-project.json with ref/name/org metadata", () => { + it.effect("writes linked-project.json with ref/name/org metadata", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { yield* link(flags()); @@ -370,7 +381,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits cli_project_linked + org/project groupIdentify keyed by org id", () => { + it.effect("emits cli_project_linked + org/project groupIdentify keyed by org id", () => { const { layer, analytics } = setup(); return Effect.gen(function* () { yield* link(flags()); @@ -393,7 +404,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("resolves the ref from SUPABASE_PROJECT_ID when no flag is given", () => { + it.effect("resolves the ref from SUPABASE_PROJECT_ID when no flag is given", () => { const { layer, workdir } = setup({ projectId: Option.some(VALID_REF) }); return Effect.gen(function* () { yield* link(flags({ projectRef: Option.none() })); @@ -401,7 +412,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("positional valid ref beats SUPABASE_PROJECT_ID as the link target", () => { + it.effect("positional valid ref beats SUPABASE_PROJECT_ID as the link target", () => { const { layer, workdir } = setup({ projectId: Option.some(VALID_REF) }); return Effect.gen(function* () { yield* link(flags({ refOrBranch: Option.some(POSITIONAL_REF), projectRef: Option.none() })); @@ -409,7 +420,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails in non-TTY with no --project-ref and no PROJECT_ID", () => { + it.effect("fails in non-TTY with no --project-ref and no PROJECT_ID", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(link(flags({ projectRef: Option.none() }))); @@ -422,7 +433,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "fails with InvalidProjectRefError for a malformed ref from SUPABASE_PROJECT_ID (env stays strict)", () => { const { layer } = setup({ projectId: Option.some("BADREF") }); @@ -436,28 +447,31 @@ describe("link integration", () => { }, ); - it.live("tolerates a 404 project (branch linking): writes project-ref, skips telemetry", () => { - const { layer, workdir, analytics } = setup({ - project: { fail: statusCodeFailure(404) }, - }); - return Effect.gen(function* () { - yield* link(flags()); - expect(yield* readTemp(workdir, "project-ref")).toBe(VALID_REF); - expect(yield* existsTemp(workdir, "postgres-version")).toBe(false); - expect(yield* existsTemp(workdir, "linked-project.json")).toBe(false); - expect(analytics.captured.map((c) => c.event)).not.toContain("cli_project_linked"); - expect(analytics.groupIdentified).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "tolerates a 404 project (branch linking): writes project-ref, skips telemetry", + () => { + const { layer, workdir, analytics } = setup({ + project: { fail: statusCodeFailure(404) }, + }); + return Effect.gen(function* () { + yield* link(flags()); + expect(yield* readTemp(workdir, "project-ref")).toBe(VALID_REF); + expect(yield* existsTemp(workdir, "postgres-version")).toBe(false); + expect(yield* existsTemp(workdir, "linked-project.json")).toBe(false); + expect(analytics.captured.map((c) => c.event)).not.toContain("cli_project_linked"); + expect(analytics.groupIdentified).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); - it.live("fails with project-paused error + dashboard suggestion when INACTIVE", () => { + it.effect("fails with project-paused error + dashboard suggestion when INACTIVE", () => { const { layer } = setup({ project: { ok: { ...HEALTHY_PROJECT, status: "INACTIVE" } }, }); return Effect.gen(function* () { const error = yield* Effect.flip(link(flags())); + expect(Predicate.isTagged(error, "ProjectPausedError")).toBe(true); expect(error).toMatchObject({ - _tag: "ProjectPausedError", message: expect.stringContaining("project is paused"), suggestion: expect.stringContaining( `An admin must unpause it from the Supabase dashboard at https://supabase.com/dashboard/project/${VALID_REF}`, @@ -466,7 +480,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("warns to stderr when status is not ACTIVE_HEALTHY but still links", () => { + it.effect("warns to stderr when status is not ACTIVE_HEALTHY but still links", () => { const { layer, out, workdir } = setup({ project: { ok: { ...HEALTHY_PROJECT, status: "COMING_UP" } }, }); @@ -479,7 +493,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with LinkProjectStatusError on an unexpected status", () => { + it.effect("fails with LinkProjectStatusError on an unexpected status", () => { const { layer } = setup({ project: { fail: statusCodeFailure(500) } }); return Effect.gen(function* () { const exit = yield* Effect.exit(link(flags())); @@ -492,7 +506,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with auth error when api-keys returns non-200", () => { + it.effect("fails with auth error when api-keys returns non-200", () => { const { layer } = setup({ apiKeys: { fail: statusCodeFailure(401) } }); return Effect.gen(function* () { const exit = yield* Effect.exit(link(flags())); @@ -507,7 +521,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with missing-key error when api-keys are empty", () => { + it.effect("fails with missing-key error when api-keys are empty", () => { const { layer } = setup({ apiKeys: { ok: [] } }); return Effect.gen(function* () { const exit = yield* Effect.exit(link(flags())); @@ -520,7 +534,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("resolves keys by legacy name when no type field is present", () => { + it.effect("resolves keys by legacy name when no type field is present", () => { const { layer, out, workdir } = setup({ apiKeys: { ok: [ @@ -536,7 +550,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with missing-key error when the only secret key is not service_role", () => { + it.effect("fails with missing-key error when the only secret key is not service_role", () => { const { layer } = setup({ apiKeys: { ok: [ @@ -558,7 +572,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("ignores best-effort service errors without failing the link", () => { + it.effect("ignores best-effort service errors without failing the link", () => { const { layer, out, workdir } = setup({ storageConfig: { fail: statusCodeFailure(500) }, poolerConfig: { fail: statusCodeFailure(503) }, @@ -573,7 +587,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("removes pooler-url and skips the pooler fetch when --skip-pooler is set", () => { + it.effect("removes pooler-url and skips the pooler fetch when --skip-pooler is set", () => { const { layer, workdir, apiMock } = setup(); return Effect.gen(function* () { yield* writeTempContent(workdir, "pooler-url", "stale-pooler-url"); @@ -583,7 +597,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when writing the project-ref file errors", () => { + it.effect("fails when writing the project-ref file errors", () => { // Makes `/supabase` a file so every temp write fails; with no version in the // project status, project-ref is the first mandatory write to hit the broken path. const out = mockOutput({ format: "text" }); @@ -618,7 +632,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry and runs the linked-project cache via ensuring", () => { + it.effect("flushes telemetry and runs the linked-project cache via ensuring", () => { const { layer, telemetry, linkedCache } = setup(); return Effect.gen(function* () { yield* link(flags()); @@ -627,7 +641,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("json output: emits a structured success and suppresses the Finished line", () => { + it.effect("json output: emits a structured success and suppresses the Finished line", () => { const { layer, out, workdir } = setup({ format: "json" }); return Effect.gen(function* () { yield* link(flags()); @@ -639,7 +653,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("stream-json output: emits a structured success", () => { + it.effect("stream-json output: emits a structured success", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { yield* link(flags()); @@ -650,7 +664,7 @@ describe("link integration", () => { }); describe("ref-or-branch argument conflicts", () => { - it.live( + it.effect( "fails with LinkRefArgConflictError when both the positional and --project-ref are set", () => { const { layer, telemetry, linkedCache } = setup(); @@ -670,7 +684,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "treats an empty-string positional as absent, falling through to the no-value behavior", () => { const { layer } = setup(); @@ -688,7 +702,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "treats an empty-string --project-ref as absent, falling through to the no-value behavior", () => { const { layer } = setup(); @@ -704,7 +718,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "an empty-string positional alongside a real --project-ref links normally (no conflict)", () => { const { layer, workdir } = setup(); @@ -715,7 +729,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "links directly from a positional ref argument without calling the branches endpoint", () => { const { layer, workdir, apiMock } = setup(); @@ -729,7 +743,7 @@ describe("link integration", () => { }); describe("branch-name resolution: parent chain", () => { - it.live( + it.effect( "THE HEADLINE REGRESSION: relinking a different branch resolves via the cached real parent, not the previously-linked branch ref", () => { // Simulates the state left behind by a prior `supabase link feature-branch`: @@ -755,7 +769,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "SUPABASE_PROJECT_ID wins over both the cache file and the project-ref file when ref-shaped", () => { const { layer, apiMock } = setup({ @@ -776,7 +790,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "a garbage SUPABASE_PROJECT_ID hard-fails the branch lookup with LinkParentRefInvalidError, never falling through to the cache (PR #6168 review)", () => { // The first present candidate decides, even if invalid — a typo'd override must not @@ -800,7 +814,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "degrades to the project-ref file when the linked-project.json cache is unreadable or has no usable ref", () => { const { layer, apiMock, workdir } = setup({ branches: { ok: [LINK_BRANCH] } }); @@ -829,7 +843,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "fails with LinkParentRefInvalidError when a parent candidate exists but none is ref-shaped", () => { const { layer, workdir, apiMock } = setup(); @@ -854,26 +868,29 @@ describe("link integration", () => { }, ); - it.live("fails with LinkBranchNotLinkedError when no parent candidate exists anywhere", () => { - const { layer, apiMock } = setup(); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - link(flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() })), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("LinkBranchNotLinkedError"); - expect(causeText).toContain(`Cannot resolve "feature-branch": it is not a project ref`); - expect(causeText).toContain( - "If it is a branch name, link the parent project first: supabase link --project-ref", + it.effect( + "fails with LinkBranchNotLinkedError when no parent candidate exists anywhere", + () => { + const { layer, apiMock } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + link(flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() })), ); - } - expect(apiMock.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)); - }); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("LinkBranchNotLinkedError"); + expect(causeText).toContain(`Cannot resolve "feature-branch": it is not a project ref`); + expect(causeText).toContain( + "If it is a branch name, link the parent project first: supabase link --project-ref", + ); + } + expect(apiMock.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); - it.live( + it.effect( "cache alone (linked-project.json with no project-ref file) is never proof of a link: fails with LinkBranchNotLinkedError, no API call (PR #6168 review)", () => { const { layer, apiMock, workdir } = setup(); @@ -897,7 +914,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "treats an unreadable project-ref path (e.g. a directory) as no candidate rather than failing", () => { const { layer, workdir, apiMock } = setup(); @@ -922,7 +939,7 @@ describe("link integration", () => { }); describe("branch-name resolution: 404-path cache write/invalidation (PR #6168 review)", () => { - it.live( + it.effect( "name-resolved branch link with no cache file (P1 fix): persists {ref: parentRef}, and a follow-up branch resolution proves the parent chain survives", () => { const { layer, workdir, apiMock } = setup({ @@ -949,7 +966,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "name-resolved branch link whose cache already agrees with the parent: cache left byte-identical (richer record not clobbered by the ref-only write)", () => { const { layer, workdir } = setup({ @@ -968,7 +985,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "raw ref-shaped 404 link whose ref IS among the stale cache's branches: keeps the cache, link still succeeds", () => { const { layer, workdir, apiMock } = setup({ @@ -987,7 +1004,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "raw ref-shaped 404 link whose ref is NOT among the stale cache's branches: deletes the cache, link still succeeds", () => { const { layer, workdir } = setup({ @@ -1003,7 +1020,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "raw ref-shaped 404 link where the correlation lookup itself fails: DELETES the unverified cache, link still succeeds (fail-safe, PR #6168 review)", () => { const { layer, workdir } = setup({ @@ -1022,7 +1039,7 @@ describe("link integration", () => { }); describe("branch-name resolution: matching and safety", () => { - it.live( + it.effect( "resolves a positional branch name via the parent linked in the project-ref temp file", () => { const { layer, workdir, apiMock } = setup({ branches: { ok: [LINK_BRANCH] } }); @@ -1038,7 +1055,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "resolves a branch name passed via --project-ref using the same linked-parent lookup", () => { const { layer, workdir } = setup({ branches: { ok: [LINK_BRANCH] } }); @@ -1050,7 +1067,7 @@ describe("link integration", () => { }, ); - it.live("resolves a branch by its UUID", () => { + it.effect("resolves a branch by its UUID", () => { const { layer, workdir } = setup({ branches: { ok: [LINK_BRANCH] } }); return Effect.gen(function* () { yield* writeLinkedParentRef(workdir, PARENT_REF); @@ -1059,7 +1076,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("resolves a branch by an UPPERCASE-hex UUID spelling (PR #6168 review)", () => { + it.effect("resolves a branch by an UPPERCASE-hex UUID spelling (PR #6168 review)", () => { const { layer, workdir } = setup({ branches: { ok: [LINK_BRANCH] } }); return Effect.gen(function* () { yield* writeLinkedParentRef(workdir, PARENT_REF); @@ -1073,7 +1090,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "fails with LinkBranchNotReadyError and never falls through to link the parent, even with SUPABASE_PROJECT_ID set", () => { const { layer, workdir, apiMock } = setup({ @@ -1100,7 +1117,7 @@ describe("link integration", () => { }, ); - it.live("a failed branch lookup leaves an existing project-ref file untouched", () => { + it.effect("a failed branch lookup leaves an existing project-ref file untouched", () => { const { layer, workdir } = setup({ branches: { ok: [LINK_BRANCH] } }); // The project-ref file doubles as the existing link and the parent candidate here. return Effect.gen(function* () { @@ -1115,7 +1132,7 @@ describe("link integration", () => { }); describe("branch-name resolution: message variants", () => { - it.live("caps the available-branches list at 20 names with a remainder count", () => { + it.effect("caps the available-branches list at 20 names with a remainder count", () => { const { layer, workdir } = setup({ branches: { ok: manyBranches(25) } }); return Effect.gen(function* () { yield* writeLinkedParentRef(workdir, PARENT_REF); @@ -1134,7 +1151,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live('suggests a case-insensitive near-miss ("Did you mean")', () => { + it.effect('suggests a case-insensitive near-miss ("Did you mean")', () => { const { layer, workdir } = setup({ branches: { ok: [LINK_BRANCH, LINK_BRANCH_STAGING] }, }); @@ -1152,7 +1169,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "includes a ref-typo hint for an all-lowercase value not found in an empty branch list", () => { const { layer, workdir } = setup({ branches: { ok: [] } }); @@ -1176,7 +1193,7 @@ describe("link integration", () => { }, ); - it.live("omits the ref-typo hint when the value is not purely lowercase letters", () => { + it.effect("omits the ref-typo hint when the value is not purely lowercase letters", () => { const { layer, workdir } = setup({ branches: { ok: [LINK_BRANCH] } }); return Effect.gen(function* () { yield* writeLinkedParentRef(workdir, PARENT_REF); @@ -1193,7 +1210,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("lists sorted available branch names when the branch is not found", () => { + it.effect("lists sorted available branch names when the branch is not found", () => { const { layer, workdir, telemetry, linkedCache } = setup({ branches: { ok: [LINK_BRANCH_ZETA, LINK_BRANCH_ALPHA] }, }); @@ -1215,7 +1232,7 @@ describe("link integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "surfaces a dedicated message when listing branches 404s (parent may itself be a branch)", () => { const { layer, workdir } = setup({ branches: { fail: statusCodeFailure(404) } }); @@ -1239,7 +1256,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "fails with LinkBranchListStatusError when listing branches returns a non-200, non-404 status", () => { const { layer, workdir } = setup({ branches: { fail: statusCodeFailure(500) } }); @@ -1258,7 +1275,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "fails with LinkBranchListNetworkError when listing branches fails at the transport layer", () => { const { layer, workdir } = setup({ branches: { fail: transportFailureForMock() } }); @@ -1279,7 +1296,7 @@ describe("link integration", () => { }); describe("branch-name resolution: output contract", () => { - it.live( + it.effect( "text mode: shows the Resolving branch... spinner, writes the resolved line to stderr, and keeps stdout to the Finished line", () => { const { layer, out, workdir } = setup({ branches: { ok: [LINK_BRANCH] } }); @@ -1300,7 +1317,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "json mode: emits branch + parent_project_ref in the success payload with zero progress events", () => { const { layer, out, workdir } = setup({ @@ -1324,7 +1341,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "json mode: a branch-list failure produces zero progress events (spinner suppressed)", () => { const { layer, out, workdir } = setup({ @@ -1346,7 +1363,7 @@ describe("link integration", () => { }, ); - it.live("stream-json mode: emits branch + parent_project_ref in the success payload", () => { + it.effect("stream-json mode: emits branch + parent_project_ref in the success payload", () => { const { layer, out, workdir } = setup({ format: "stream-json", branches: { ok: [LINK_BRANCH] }, @@ -1367,7 +1384,7 @@ describe("link integration", () => { }); describe("branch-name resolution: telemetry", () => { - it.live( + it.effect( "fires cli_project_linked with linked_via/parent_project_ref and a project group, no groupIdentify, and never the branch name", () => { const analytics = mockContextualAnalytics(); @@ -1384,19 +1401,19 @@ describe("link integration", () => { flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() }), ); const capture = analytics.captured.find((c) => c.event === "cli_project_linked"); - const properties = capture?.properties as { groups?: unknown } | undefined; + const properties = capture?.properties; expect(properties).toMatchObject({ linked_via: "branch", parent_project_ref: PARENT_REF, }); - expect(properties?.groups).toEqual({ project: BRANCH_PROJECT_REF }); + expect(properties?.["groups"]).toEqual({ project: BRANCH_PROJECT_REF }); expect(analytics.groupIdentified).toHaveLength(0); expect(yield* jsonText(analytics.captured)).not.toContain("feature-branch"); }).pipe(Effect.provide(layer)); }, ); - it.live( + it.effect( "default-branch link (getProject(parent) returns 200): the normal 200 arm ALSO carries linked_via/parent_project_ref, plus its usual org/project groupIdentify richness (PR #6168 review)", () => { const analytics = mockContextualAnalytics(); @@ -1411,12 +1428,12 @@ describe("link integration", () => { yield* link(flags({ refOrBranch: Option.some("main"), projectRef: Option.none() })); const captures = analytics.captured.filter((c) => c.event === "cli_project_linked"); expect(captures).toHaveLength(1); - const properties = captures[0]?.properties as { groups?: unknown } | undefined; + const properties = captures[0]?.properties; expect(properties).toMatchObject({ linked_via: "branch", parent_project_ref: PARENT_REF, }); - expect(properties?.groups).toEqual({ organization: "org_123", project: PARENT_REF }); + expect(properties?.["groups"]).toEqual({ organization: "org_123", project: PARENT_REF }); expect(analytics.groupIdentified).toEqual([ { groupType: "organization", @@ -1436,7 +1453,7 @@ describe("link integration", () => { }); describe("telemetry: --project-ref redaction (CLI-2167)", () => { - it.live( + it.effect( "does not redact --project-ref in cli_command_executed when it is ref-shaped (Go parity: cmd/link.go:52)", () => { const out = mockOutput({ format: "text" }); @@ -1473,7 +1490,7 @@ describe("link integration", () => { }, ); - it.live( + it.effect( "redacts --project-ref in cli_command_executed when it is a branch name, not a ref", () => { const out = mockOutput({ format: "text" }); diff --git a/apps/cli/src/commands/migration/down/down.handler.ts b/apps/cli/src/commands/migration/down/down.handler.ts index 312183c4aa..bd1ee18ff1 100644 --- a/apps/cli/src/commands/migration/down/down.handler.ts +++ b/apps/cli/src/commands/migration/down/down.handler.ts @@ -97,7 +97,8 @@ const runDown = Effect.fnUntraced(function* ( const remote = yield* listRemoteMigrations(session); const total = remote.length; - if (total <= flags.last) { + const version = remote[total - flags.last - 1]; + if (total <= flags.last || version === undefined) { return yield* new MigrationLastTooLargeError({ message: `--last must be smaller than total applied migrations: ${total}`, suggestion: `Try ${aqua("supabase db reset")} if you want to revert all migrations.`, @@ -115,7 +116,6 @@ const runDown = Effect.fnUntraced(function* ( return yield* new OperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); } - const version = remote[total - flags.last - 1]!; yield* output.raw(`Resetting database to version: ${version}\n`, "stderr"); yield* Effect.annotateCurrentSpan({ "db.is_local": cfg.isLocal, diff --git a/apps/cli/src/commands/migration/down/down.integration.test.ts b/apps/cli/src/commands/migration/down/down.integration.test.ts index b7363ae29e..aed95b4aa7 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { @@ -24,6 +24,8 @@ import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { type DbSession, DbConnection } from "../../../command-internal/db-connection.service.ts"; import { MigrationDropError } from "../../../command-internal/drop-objects.ts"; import { MigrationSeedError } from "../../../command-internal/seed.ts"; +import { MigrationTargetFlagsError, OperationCanceledError } from "../migration.errors.ts"; +import { MigrationLastTooLargeError, MigrationLastZeroError } from "./down.errors.ts"; import { migrationDown } from "./down.handler.ts"; import type { MigrationDownFlags } from "./down.command.ts"; @@ -175,43 +177,31 @@ const writeProjectFile = Effect.fnUntraced(function* (workdir: string, name: str const tmp = useTempWorkdir(); describe("migration down", () => { - it.live("rejects --last 0", () => { + it.effect("rejects --last 0", () => { const { layer } = setup(tmp.current); return Effect.gen(function* () { - const exit = yield* migrationDown(flags({ last: 0 })).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationLastZeroError"); - } + const error = yield* migrationDown(flags({ last: 0 })).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationLastZeroError); }).pipe(Effect.provide(layer)); }); - it.live("resolves the DB target before rejecting --last 0", () => { + it.effect("resolves the DB target before rejecting --last 0", () => { const { layer } = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { - const exit = yield* migrationDown(flags({ last: 0 })).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("ProjectRefNotLinkedError"); - } + const error = yield* migrationDown(flags({ last: 0 })).pipe(Effect.flip); + expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(layer)); }); - it.live("rejects --last >= total applied migrations", () => { + it.effect("rejects --last >= total applied migrations", () => { const { layer } = setup(tmp.current, { remote: ["20240101000000"] }); return Effect.gen(function* () { - const exit = yield* migrationDown(flags({ last: 1 })).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationLastTooLargeError"); - } + const error = yield* migrationDown(flags({ last: 1 })).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationLastTooLargeError); }).pipe(Effect.provide(layer)); }); - it.live("reverts to the target version on confirm (drop + migrate&seed)", () => { + it.effect("reverts to the target version on confirm (drop + migrate&seed)", () => { const { layer, out, execs, queries } = setup(tmp.current, { confirm: true, remote: ["20240101000000", "20240102000000"], @@ -231,7 +221,7 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "reverts on the project given via --project-ref --linked, overriding the linked ref", () => { // VALID_REF is the fake resolver's fallback, representing whatever the @@ -254,64 +244,52 @@ describe("migration down", () => { }, ); - it.live("rejects --project-ref on the default local target", () => { + it.effect("rejects --project-ref on the default local target", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, execs, queries, cache } = setup(tmp.current, { remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { - const exit = yield* migrationDown(flags({ last: 1, projectRef: Option.some(FLAG_REF) })).pipe( - Effect.exit, + const error = yield* migrationDown( + flags({ last: 1, projectRef: Option.some(FLAG_REF) }), + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error.message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationTargetFlagsError"); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } expect(execs).toEqual([]); expect(queries).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); - it.live("cancels on a declined prompt", () => { + it.effect("cancels on a declined prompt", () => { const { layer, execs } = setup(tmp.current, { confirm: false, remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); - const exit = yield* migrationDown(flags({ last: 1 })).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); - } + const error = yield* migrationDown(flags({ last: 1 })).pipe(Effect.flip); + expect(error).toBeInstanceOf(OperationCanceledError); expect(execs.some((sql) => sql.startsWith("do $$"))).toBe(false); }).pipe(Effect.provide(layer)); }); - it.live("falls back to NO (cancels) without a TTY and no piped answer", () => { + it.effect("falls back to NO (cancels) without a TTY and no piped answer", () => { // isTTY only changes the read timeout; stdin is still read either way. const { layer, out } = setup(tmp.current, { isTTY: false, remote: ["20240101000000", "20240102000000"], }); return Effect.gen(function* () { - const exit = yield* migrationDown(flags({ last: 1 })).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); - } + const error = yield* migrationDown(flags({ last: 1 })).pipe(Effect.flip); + expect(error).toBeInstanceOf(OperationCanceledError); expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); - it.live("emits a structured result in json with --yes", () => { + it.effect("emits a structured result in json with --yes", () => { const { layer, out } = setup(tmp.current, { format: "json", yes: true, @@ -330,7 +308,7 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.live("auto-confirms from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { + it.effect("auto-confirms from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { // SUPABASE_YES lives only in supabase/.env; the project env loads it before the prompt. const { layer, out } = setup(tmp.current, { format: "json", @@ -350,7 +328,7 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.live("reports a drop-schema failure", () => { + it.effect("reports a drop-schema failure", () => { const { layer } = setup(tmp.current, { confirm: true, remote: ["20240101000000", "20240102000000"], @@ -358,16 +336,12 @@ describe("migration down", () => { }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); - const exit = yield* migrationDown(flags({ last: 1 })).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value instanceof MigrationDropError).toBe(true); - } + const error = yield* migrationDown(flags({ last: 1 })).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationDropError); }).pipe(Effect.provide(layer)); }); - it.live("seeds data from a new seed file and records its hash", () => { + it.effect("seeds data from a new seed file and records its hash", () => { const { layer, out, queries } = setup(tmp.current, { confirm: true, remote: ["20240101000000", "20240102000000"], @@ -383,7 +357,7 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.live("reports a seed-apply failure", () => { + it.effect("reports a seed-apply failure", () => { const { layer } = setup(tmp.current, { confirm: true, remote: ["20240101000000", "20240102000000"], @@ -392,16 +366,12 @@ describe("migration down", () => { return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); yield* writeProjectFile(tmp.current, "seed.sql", "insert into a values (1);\n"); - const exit = yield* migrationDown(flags({ last: 1 })).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value instanceof MigrationSeedError).toBe(true); - } + const error = yield* migrationDown(flags({ last: 1 })).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSeedError); }).pipe(Effect.provide(layer)); }); - it.live("skips an unchanged seed file", () => { + it.effect("skips an unchanged seed file", () => { const body = "insert into a values (1);\n"; const hash = createHash("sha256").update(body).digest("hex"); const { layer, out, queries } = setup(tmp.current, { @@ -420,7 +390,7 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.live("updates the recorded hash (without re-running) for a changed seed file", () => { + it.effect("updates the recorded hash (without re-running) for a changed seed file", () => { const { layer, out, execs, queries } = setup(tmp.current, { confirm: true, remote: ["20240101000000", "20240102000000"], @@ -438,7 +408,7 @@ describe("migration down", () => { }).pipe(Effect.provide(layer)); }); - it.live("skips migration apply when db.migrations.enabled = false", () => { + it.effect("skips migration apply when db.migrations.enabled = false", () => { const { layer, queries } = setup(tmp.current, { confirm: true, remote: ["20240101000000", "20240102000000"], diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index a512dece4a..fc9e80f8c7 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { VALID_REF, @@ -18,6 +18,8 @@ import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { MigrationTargetFlagsError, OperationCanceledError } from "../migration.errors.ts"; +import { MigrationFetchWriteError } from "./fetch.errors.ts"; import { migrationFetch } from "./fetch.handler.ts"; import type { MigrationFetchFlags } from "./fetch.command.ts"; @@ -173,7 +175,7 @@ const migrationsDirExists = Effect.fnUntraced(function* (workdir: string) { const tmp = useTempWorkdir(); describe("migration fetch", () => { - it.live("writes migration files joined with the Go separator when the dir is empty", () => { + it.effect("writes migration files joined with the Go separator when the dir is empty", () => { const { layer, out } = setup(tmp.current, { rows: [ { @@ -186,15 +188,14 @@ describe("migration fetch", () => { return Effect.gen(function* () { yield* migrationFetch(flags()); expect(out.stderrText).toContain("Connecting to remote database..."); - const files = yield* listMigrations(tmp.current); - expect(files).toEqual(["20240101000000_init.sql"]); - expect(yield* readMigration(tmp.current, files[0]!)).toBe( + expect(yield* listMigrations(tmp.current)).toEqual(["20240101000000_init.sql"]); + expect(yield* readMigration(tmp.current, "20240101000000_init.sql")).toBe( "create table a;\ncreate index b;\n", ); }).pipe(Effect.provide(layer)); }); - it.live("writes a lone separator for a row with no statements (Go parity)", () => { + it.effect("writes a lone separator for a row with no statements (Go parity)", () => { // A schema_migrations row can have a NULL/empty statements array; joining still // yields exactly ";\n", not an empty file. This locks that byte behavior. const { layer } = setup(tmp.current, { @@ -206,7 +207,7 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.live("prompts before overwriting a non-empty directory and proceeds on yes", () => { + it.effect("prompts before overwriting a non-empty directory and proceeds on yes", () => { const { layer } = setup(tmp.current, { confirm: true, rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], @@ -218,24 +219,20 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.live("cancels with context canceled when the overwrite prompt is declined", () => { + it.effect("cancels with context canceled when the overwrite prompt is declined", () => { const { layer } = setup(tmp.current, { confirm: false, rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], }); return Effect.gen(function* () { yield* seedExistingMigration(tmp.current); - const exit = yield* migrationFetch(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); - } + const error = yield* migrationFetch(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(OperationCanceledError); expect(yield* listMigrations(tmp.current)).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }); - it.live("honors a piped 'n' answer without a TTY (cancels the overwrite)", () => { + it.effect("honors a piped 'n' answer without a TTY (cancels the overwrite)", () => { // The overwrite prompt defaults to YES; piped stdin still overrides it without a TTY. const { layer } = setup(tmp.current, { isTTY: false, @@ -244,17 +241,13 @@ describe("migration fetch", () => { }); return Effect.gen(function* () { yield* seedExistingMigration(tmp.current); - const exit = yield* migrationFetch(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); - } + const error = yield* migrationFetch(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(OperationCanceledError); expect(yield* listMigrations(tmp.current)).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }); - it.live.each( + it.effect.each( ["u", "yess", "nope", " "].flatMap((answer) => [ { answer, isTTY: false }, { answer, isTTY: true }, @@ -269,18 +262,14 @@ describe("migration fetch", () => { }); return Effect.gen(function* () { yield* seedExistingMigration(tmp.current); - const exit = yield* migrationFetch(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); - } + const error = yield* migrationFetch(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(OperationCanceledError); expect(yield* listMigrations(tmp.current)).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }, ); - it.live.each(["\n", "", " YES \n"])("overwrites on the piped answer %j", (pipedInput) => { + it.effect.each(["\n", "", " YES \n"])("overwrites on the piped answer %j", (pipedInput) => { const { layer } = setup(tmp.current, { isTTY: false, pipedInput, @@ -293,7 +282,7 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.live("bypasses the overwrite prompt with --yes (echoes the auto-answer)", () => { + it.effect("bypasses the overwrite prompt with --yes (echoes the auto-answer)", () => { const { layer, out } = setup(tmp.current, { yes: true, rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], @@ -306,7 +295,7 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "auto-confirms the overwrite prompt from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { // SUPABASE_YES lives only in supabase/.env; the project env loads before the @@ -324,7 +313,7 @@ describe("migration fetch", () => { }, ); - it.live("still prompts on stderr in json mode and proceeds on a piped yes", () => { + it.effect("still prompts on stderr in json mode and proceeds on a piped yes", () => { // The overwrite prompt still writes to stderr and reads stdin in json mode; it must // not silently auto-accept. const { layer, out } = setup(tmp.current, { @@ -346,7 +335,7 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.live("honors a piped no in json mode (cancels the overwrite, no auto-accept)", () => { + it.effect("honors a piped no in json mode (cancels the overwrite, no auto-accept)", () => { const { layer } = setup(tmp.current, { format: "json", pipedInput: "n\n", @@ -354,34 +343,26 @@ describe("migration fetch", () => { }); return Effect.gen(function* () { yield* seedExistingMigration(tmp.current); - const exit = yield* migrationFetch(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); - } + const error = yield* migrationFetch(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(OperationCanceledError); expect(yield* listMigrations(tmp.current)).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }); - it.live("rejects a hostile version/name from the history table (path traversal guard)", () => { + it.effect("rejects a hostile version/name from the history table (path traversal guard)", () => { // A tampered remote `schema_migrations` row could use `..`/separators to // escape the migrations dir (CWE-22). The guard rejects it before writing. const { layer } = setup(tmp.current, { rows: [{ version: "20240101000000", name: "../../../etc/passwd", statements: [] }], }); return Effect.gen(function* () { - const exit = yield* migrationFetch(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationFetchWriteError"); - } + const error = yield* migrationFetch(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationFetchWriteError); expect(yield* listMigrations(tmp.current)).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("writes a Go-valid signed version verbatim (no all-digits requirement)", () => { + it.effect("writes a Go-valid signed version verbatim (no all-digits requirement)", () => { // The raw version column writes verbatim into _.sql with no digit // check, so a value like "-1" still fetches instead of aborting the run. const { layer } = setup(tmp.current, { @@ -393,53 +374,41 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects a hostile version from the history table (traversal guard on version)", () => { + it.effect("rejects a hostile version from the history table (traversal guard on version)", () => { // Traversal hardening covers the version field too, not just name. const { layer } = setup(tmp.current, { rows: [{ version: "../../etc", name: "x", statements: [] }], }); return Effect.gen(function* () { - const exit = yield* migrationFetch(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationFetchWriteError"); - } + const error = yield* migrationFetch(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationFetchWriteError); expect(yield* listMigrations(tmp.current)).toEqual([]); }).pipe(Effect.provide(layer)); }); - it.live("reports a write failure", () => { + it.effect("reports a write failure", () => { // A file at .../migrations makes makeDirectory fail; supabase itself must stay a // real directory, since the handler's project-env load reads supabase/.env* before // this mkdir and would hit ENOTDIR first otherwise. const { layer } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { yield* writeProjectFile(tmp.current, "migrations", "not a directory"); - const exit = yield* migrationFetch(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationFetchWriteError"); - } + const error = yield* migrationFetch(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationFetchWriteError); }).pipe(Effect.provide(layer)); }); - it.live("resolves DB config before creating the migrations dir or prompting", () => { + it.effect("resolves DB config before creating the migrations dir or prompting", () => { const { layer, out } = setup(tmp.current, { resolveFails: true }); return Effect.gen(function* () { - const exit = yield* migrationFetch(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("DbConfigLoadError"); - } + const error = yield* migrationFetch(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(DbConfigLoadError); expect(yield* migrationsDirExists(tmp.current)).toBe(false); expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "rejects --db-url combined with --linked before reading the project .env (CLI-1878)", () => { // A flag conflict must surface even when supabase/.env is malformed, which would @@ -449,19 +418,15 @@ describe("migration fetch", () => { }); return Effect.gen(function* () { yield* writeProjectFile(tmp.current, ".env", "!=broken\n"); - const exit = yield* migrationFetch(flags({ dbUrl: Option.some("postgresql://x") })).pipe( - Effect.exit, + const error = yield* migrationFetch(flags({ dbUrl: Option.some("postgresql://x") })).pipe( + Effect.flip, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationTargetFlagsError"); - } + expect(error).toBeInstanceOf(MigrationTargetFlagsError); }).pipe(Effect.provide(layer)); }, ); - it.live( + it.effect( "fetches from the project given via --project-ref, overriding the default linked ref", () => { // VALID_REF is the fake resolver's fallback; the flag must win over it and drive @@ -477,21 +442,17 @@ describe("migration fetch", () => { }, ); - it.live("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with an explicit --local target", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, out, cache } = setup(tmp.current, { cliArgs: ["--local"] }); return Effect.gen(function* () { - const exit = yield* migrationFetch( + const error = yield* migrationFetch( flags({ linked: false, local: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationTargetFlagsError"); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error.message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); expect(yield* migrationsDirExists(tmp.current)).toBe(false); expect(out.promptConfirmCalls.length).toBe(0); expect(cache.cached).toBe(false); diff --git a/apps/cli/src/commands/migration/list/list.format.ts b/apps/cli/src/commands/migration/list/list.format.ts index 40ebdcbb50..0706ea4aae 100644 --- a/apps/cli/src/commands/migration/list/list.format.ts +++ b/apps/cli/src/commands/migration/list/list.format.ts @@ -29,9 +29,11 @@ export function makeMigrationListRows( let i = 0; let j = 0; while (i < remote.length || j < sortedLocal.length) { + const remoteVersion = remote[i]; + const localVersion = sortedLocal[j]; let remoteTs = MIGRATION_VERSION_MAX; - if (i < remote.length) { - const parsed = parseMigrationVersion(remote[i]!); + if (remoteVersion !== undefined) { + const parsed = parseMigrationVersion(remoteVersion); if (parsed === undefined) { i++; continue; @@ -39,23 +41,19 @@ export function makeMigrationListRows( remoteTs = parsed; } let localTs = MIGRATION_VERSION_MAX; - if (j < sortedLocal.length) { - const parsed = parseMigrationVersion(sortedLocal[j]!); + if (localVersion !== undefined) { + const parsed = parseMigrationVersion(localVersion); if (parsed === undefined) { j++; continue; } localTs = parsed; } - if (localTs < remoteTs) { - rows.push({ - local: sortedLocal[j]!, - remote: "", - time: formatTimestampVersion(sortedLocal[j]!), - }); + if (localVersion !== undefined && localTs < remoteTs) { + rows.push({ local: localVersion, remote: "", time: formatTimestampVersion(localVersion) }); j++; - } else if (remoteTs < localTs) { - rows.push({ local: "", remote: remote[i]!, time: formatTimestampVersion(remote[i]!) }); + } else if (remoteVersion !== undefined && remoteTs < localTs) { + rows.push({ local: "", remote: remoteVersion, time: formatTimestampVersion(remoteVersion) }); i++; } else { rows.push({ diff --git a/apps/cli/src/commands/migration/list/list.integration.test.ts b/apps/cli/src/commands/migration/list/list.integration.test.ts index bdf87b9e28..42a8336344 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { @@ -20,6 +20,7 @@ import { MigrationsReadError } from "../../../command-internal/migration.errors. import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { MigrationPasswordFlagsError, MigrationTargetFlagsError } from "../migration.errors.ts"; import { migrationList } from "./list.handler.ts"; import type { MigrationListFlags } from "./list.command.ts"; @@ -129,7 +130,7 @@ const seedMigrations = Effect.fnUntraced(function* (workdir: string, names: Read const tmp = useTempWorkdir(); describe("migration list", () => { - it.live("lists merged local + remote migrations for the linked project by default", () => { + it.effect("lists merged local + remote migrations for the linked project by default", () => { const ctx = setup(tmp.current, { remote: ["20240101000000", "20240102000000"], }); @@ -148,7 +149,7 @@ describe("migration list", () => { }).pipe(Effect.provide(ctx.layer)); }); - it.live("shows an empty Remote column when the history table is absent (42P01)", () => { + it.effect("shows an empty Remote column when the history table is absent (42P01)", () => { const { layer, out } = setup(tmp.current, { remoteError: new DbExecError({ message: 'relation "supabase_migrations.schema_migrations" does not exist', @@ -164,7 +165,7 @@ describe("migration list", () => { }).pipe(Effect.provide(layer)); }); - it.live("skips init-schema and non-migration files when loading local versions", () => { + it.effect("skips init-schema and non-migration files when loading local versions", () => { const { layer, out } = setup(tmp.current, { remote: [] }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, [ @@ -179,7 +180,7 @@ describe("migration list", () => { }).pipe(Effect.provide(layer)); }); - it.live("lists the project given via --project-ref, overriding the default linked ref", () => { + it.effect("lists the project given via --project-ref, overriding the default linked ref", () => { // VALID_REF is the fake resolver's fallback; the flag must win over it and drive // the cached ref. const FLAG_REF = "flagflagflagflagflag"; @@ -192,28 +193,24 @@ describe("migration list", () => { }).pipe(Effect.provide(ctx.layer)); }); - it.live("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with an explicit --local target", () => { const FLAG_REF = "flagflagflagflagflag"; const ctx = setup(tmp.current, { args: ["--local"], isLocal: true, remote: [] }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - const exit = yield* migrationList( + const error = yield* migrationList( flags({ linked: false, local: true, projectRef: Option.some(FLAG_REF) }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationTargetFlagsError"); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error.message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); expect(ctx.resolverCalls).toEqual([]); expect(ctx.cache.cachedRef).toBeUndefined(); }).pipe(Effect.provide(ctx.layer)); }); - it.live("targets the local database with --local and skips the linked cache", () => { + it.effect("targets the local database with --local and skips the linked cache", () => { const ctx = setup(tmp.current, { args: ["--local"], isLocal: true, @@ -227,35 +224,27 @@ describe("migration list", () => { }).pipe(Effect.provide(ctx.layer)); }); - it.live("rejects --db-url combined with --linked", () => { + it.effect("rejects --db-url combined with --linked", () => { const { layer } = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--linked"] }); return Effect.gen(function* () { - const exit = yield* migrationList( + const error = yield* migrationList( flags({ dbUrl: Option.some("postgresql://x"), linked: true }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationTargetFlagsError"); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); }).pipe(Effect.provide(layer)); }); - it.live("rejects --db-url combined with --password", () => { + it.effect("rejects --db-url combined with --password", () => { const { layer } = setup(tmp.current, { args: ["--db-url", "postgresql://x"] }); return Effect.gen(function* () { - const exit = yield* migrationList( + const error = yield* migrationList( flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("pw") }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationPasswordFlagsError"); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationPasswordFlagsError); }).pipe(Effect.provide(layer)); }); - it.live("emits structured migrations in json", () => { + it.effect("emits structured migrations in json", () => { const { layer, out } = setup(tmp.current, { format: "json", remote: ["20240102000000"] }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, ["20240103000000_c.sql"]); @@ -276,7 +265,7 @@ describe("migration list", () => { }).pipe(Effect.provide(layer)); }); - it.live("propagates a non-undefined-table remote read failure", () => { + it.effect("propagates a non-undefined-table remote read failure", () => { const { layer } = setup(tmp.current, { remoteError: new DbExecError({ message: "permission denied for schema", @@ -285,12 +274,8 @@ describe("migration list", () => { }); return Effect.gen(function* () { yield* seedMigrations(tmp.current, ["20240101000000_a.sql"]); - const exit = yield* migrationList(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value instanceof MigrationsReadError).toBe(true); - } + const error = yield* migrationList(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationsReadError); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/commands/migration/migration.integration.test.ts b/apps/cli/src/commands/migration/migration.integration.test.ts index 8ace4998b6..641195109c 100644 --- a/apps/cli/src/commands/migration/migration.integration.test.ts +++ b/apps/cli/src/commands/migration/migration.integration.test.ts @@ -23,7 +23,7 @@ const testRoot = Command.make("supabase").pipe( const tmp = useTempWorkdir("supabase-migration-alias-int-"); describe("migration command integration", () => { - it.live("accepts the Go-compatible plural migrations alias", () => { + it.effect("accepts the Go-compatible plural migrations alias", () => { const layer = Layer.mergeAll( buildTestRuntime({ out: mockOutput({ format: "text" }), diff --git a/apps/cli/src/commands/migration/new/new.integration.test.ts b/apps/cli/src/commands/migration/new/new.integration.test.ts index b60c45aee5..5ab761dd2d 100644 --- a/apps/cli/src/commands/migration/new/new.integration.test.ts +++ b/apps/cli/src/commands/migration/new/new.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Stream } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import { badArgument } from "effect/PlatformError"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; @@ -83,7 +83,7 @@ const listMigrations = Effect.fnUntraced(function* (workdir: string) { const onlyMigration = Effect.fnUntraced(function* (workdir: string) { const files = yield* listMigrations(workdir); expect(files).toHaveLength(1); - return files[0]!; + return yield* Effect.fromNullishOr(files[0]); }); const readMigration = Effect.fnUntraced(function* (workdir: string, file: string) { @@ -102,7 +102,7 @@ const projectPath = Effect.fnUntraced(function* (workdir: string) { }); describe("migration new", () => { - it.live("creates a timestamped migration file and prints its relative path", () => { + it.effect("creates a timestamped migration file and prints its relative path", () => { const { layer, out, telemetry } = setup(tmp.current); return Effect.gen(function* () { yield* migrationNew({ migrationName: "create_widgets" }); @@ -117,7 +117,7 @@ describe("migration new", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes piped stdin into the new migration verbatim", () => { + it.effect("writes piped stdin into the new migration verbatim", () => { const script = "create table pet;\ndrop table pet;\n"; const { layer, out } = setup(tmp.current, { isTTY: false, piped: script }); return Effect.gen(function* () { @@ -129,7 +129,7 @@ describe("migration new", () => { }).pipe(Effect.provide(layer)); }); - it.live("creates an empty migration when stdin is piped but empty", () => { + it.effect("creates an empty migration when stdin is piped but empty", () => { const { layer } = setup(tmp.current, { isTTY: false }); return Effect.gen(function* () { yield* migrationNew({ migrationName: "empty_pipe" }); @@ -138,7 +138,7 @@ describe("migration new", () => { }).pipe(Effect.provide(layer)); }); - it.live("materializes the migration without relying on an open handle", () => { + it.effect("materializes the migration without relying on an open handle", () => { const { layer } = setup(tmp.current, { openDoesNotMaterialize: true }); return Effect.gen(function* () { yield* migrationNew({ migrationName: "windows_open" }); @@ -147,31 +147,22 @@ describe("migration new", () => { }).pipe(Effect.provide(layer)); }); - it.live("does not report success when the migration is not materialized", () => { + it.effect("does not report success when the migration is not materialized", () => { const { layer, out, telemetry } = setup(tmp.current, { openDoesNotMaterialize: true, writeDoesNotMaterialize: true, }); return Effect.gen(function* () { - const exit = yield* migrationNew({ migrationName: "missing" }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(MigrationNewWriteError); - if (failure.value instanceof MigrationNewWriteError) { - expect(failure.value.message).toContain("failed to verify migration file"); - } - } - } + const error = yield* migrationNew({ migrationName: "missing" }).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationNewWriteError); + expect(error.message).toContain("failed to verify migration file"); expect(yield* listMigrations(tmp.current)).toEqual([]); expect(out.stdoutText).toBe(""); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); - it.live("emits a structured result with the absolute path in json", () => { + it.effect("emits a structured result with the absolute path in json", () => { const { layer, out } = setup(tmp.current, { format: "json" }); return Effect.gen(function* () { yield* migrationNew({ migrationName: "as_json" }); @@ -188,7 +179,7 @@ describe("migration new", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a structured result in stream-json", () => { + it.effect("emits a structured result in stream-json", () => { const { layer, out } = setup(tmp.current, { format: "stream-json" }); return Effect.gen(function* () { yield* migrationNew({ migrationName: "as_stream" }); @@ -198,46 +189,30 @@ describe("migration new", () => { }).pipe(Effect.provide(layer)); }); - it.live("reports a write failure and still flushes telemetry", () => { + it.effect("reports a write failure and still flushes telemetry", () => { const { layer, telemetry } = setup(tmp.current); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; // A file at /supabase makes `makeDirectory(supabase/migrations)` fail. yield* fs.writeFileString(yield* projectPath(tmp.current), "not a directory"); - const exit = yield* migrationNew({ migrationName: "doomed" }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(MigrationNewWriteError); - } - } + const error = yield* migrationNew({ migrationName: "doomed" }).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationNewWriteError); expect(yield* pathExists(yield* migrationsDir(tmp.current))).toBe(false); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); - it.live("rejects a name that escapes the migrations directory and writes nothing", () => { + it.effect("rejects a name that escapes the migrations directory and writes nothing", () => { const { layer, telemetry } = setup(tmp.current, { isTTY: false, piped: "DROP DATABASE;\n" }); return Effect.gen(function* () { - const exit = yield* migrationNew({ - migrationName: "../../../escapes", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(MigrationNewWriteError); - } - } + const error = yield* migrationNew({ migrationName: "../../../escapes" }).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationNewWriteError); expect(yield* pathExists(yield* projectPath(tmp.current))).toBe(false); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "fails the command when piped stdin errors mid-copy (Go: failed to copy from stdin)", () => { // The Created line is already scheduled by the time the copy fails, so it still @@ -259,18 +234,9 @@ describe("migration new", () => { BunServices.layer, ); return Effect.gen(function* () { - const exit = yield* migrationNew({ migrationName: "stdin_boom" }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(MigrationNewWriteError); - if (failure.value instanceof MigrationNewWriteError) { - expect(failure.value.message).toContain("failed to copy from stdin"); - } - } - } + const error = yield* migrationNew({ migrationName: "stdin_boom" }).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationNewWriteError); + expect(error.message).toContain("failed to copy from stdin"); const file = yield* onlyMigration(tmp.current); expect(stripAnsi(out.stdoutText)).toBe( `Created new migration at supabase/migrations/${file}\n`, diff --git a/apps/cli/src/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/commands/migration/repair/repair.integration.test.ts index 08186f90de..bc74194888 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { @@ -22,6 +22,13 @@ import { DbConfigResolver } from "../../../command-internal/db-config.service.ts import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { type DbSession, DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { + MigrationFileNotFoundError, + MigrationInvalidVersionError, + MigrationTargetFlagsError, + OperationCanceledError, +} from "../migration.errors.ts"; +import { MigrationRepairUpdateError } from "./repair.errors.ts"; import { migrationRepair, type MigrationRepairInput } from "./repair.handler.ts"; interface SetupOpts { @@ -159,7 +166,7 @@ const writeProjectFile = Effect.fnUntraced(function* (workdir: string, name: str const tmp = useTempWorkdir(); describe("migration repair", () => { - it.live("marks a version as applied by upserting from its local file", () => { + it.effect("marks a version as applied by upserting from its local file", () => { const { layer, execs, queries, out } = setup(tmp.current); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); @@ -173,37 +180,29 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.live("resolves the DB target before parsing positional versions", () => { + it.effect("resolves the DB target before parsing positional versions", () => { const { layer } = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { - const exit = yield* migrationRepair( + const error = yield* migrationRepair( input({ versions: ["not-a-number"], status: "applied" }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("ProjectRefNotLinkedError"); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(layer)); }); - it.live("caches the linked project even when the repair-all prompt is declined", () => { + it.effect("caches the linked project even when the repair-all prompt is declined", () => { const { layer, cache } = setup(tmp.current, { confirm: false }); return Effect.gen(function* () { - const exit = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( - Effect.exit, + const error = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( + Effect.flip, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); - } + expect(error).toBeInstanceOf(OperationCanceledError); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(VALID_REF); }).pipe(Effect.provide(layer)); }); - it.live("marks versions as reverted by deleting them", () => { + it.effect("marks versions as reverted by deleting them", () => { const { layer, queries } = setup(tmp.current); return Effect.gen(function* () { yield* migrationRepair( @@ -214,41 +213,31 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects a non-numeric version", () => { + it.effect("rejects a non-numeric version", () => { const { layer } = setup(tmp.current); return Effect.gen(function* () { - const exit = yield* migrationRepair( + const error = yield* migrationRepair( input({ versions: ["not-a-number"], status: "applied" }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationInvalidVersionError"); - // Unlike db reset's bare message, migration repair keeps the "failed to parse :" wrapper. - expect(Option.isSome(failure) && failure.value.message).toBe( - "failed to parse not-a-number: invalid version number", - ); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationInvalidVersionError); + // Unlike db reset's bare message, migration repair keeps the "failed to parse :" wrapper. + expect(error.message).toBe("failed to parse not-a-number: invalid version number"); }).pipe(Effect.provide(layer)); }); - it.live("rejects a version outside Go's int range before any DB mutation", () => { + it.effect("rejects a version outside Go's int range before any DB mutation", () => { const { layer, execs, queries } = setup(tmp.current); return Effect.gen(function* () { - const exit = yield* migrationRepair( + const error = yield* migrationRepair( input({ versions: ["99999999999999999999"], status: "applied" }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationInvalidVersionError"); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationInvalidVersionError); expect(execs).not.toContain("BEGIN"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(false); }).pipe(Effect.provide(layer)); }); - it.live("repair-all truncates and reapplies local files on confirm", () => { + it.effect("repair-all truncates and reapplies local files on confirm", () => { const { layer, execs, queries } = setup(tmp.current, { confirm: true }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); @@ -258,7 +247,7 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "repair-all with --status reverted wipes the whole history (no upserts, no deletes)", () => { // repair-all + reverted only queues TRUNCATE; DELETE is the non-repair-all path @@ -274,39 +263,31 @@ describe("migration repair", () => { }, ); - it.live("repair-all cancels on a declined prompt", () => { + it.effect("repair-all cancels on a declined prompt", () => { const { layer, execs } = setup(tmp.current, { confirm: false }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - const exit = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( - Effect.exit, + const error = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( + Effect.flip, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); - } + expect(error).toBeInstanceOf(OperationCanceledError); expect(execs).not.toContain("TRUNCATE supabase_migrations.schema_migrations"); }).pipe(Effect.provide(layer)); }); - it.live("repair-all without a TTY and no piped answer falls back to NO (cancel)", () => { + it.effect("repair-all without a TTY and no piped answer falls back to NO (cancel)", () => { // isTTY only changes the read timeout; stdin is still read either way. const { layer, out } = setup(tmp.current, { isTTY: false }); return Effect.gen(function* () { - const exit = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( - Effect.exit, + const error = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( + Effect.flip, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); - } + expect(error).toBeInstanceOf(OperationCanceledError); expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); - it.live("repair-all honors a piped 'y' answer without a TTY (proceeds)", () => { + it.effect("repair-all honors a piped 'y' answer without a TTY (proceeds)", () => { // Piped stdin is read even without a TTY, overriding the default no. const { layer, execs, queries } = setup(tmp.current, { isTTY: false, pipedInput: "y\n" }); return Effect.gen(function* () { @@ -317,7 +298,7 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.live("auto-confirms repair-all via SUPABASE_YES (no --yes flag)", () => { + it.effect("auto-confirms repair-all via SUPABASE_YES (no --yes flag)", () => { const { layer, execs, queries } = setup(tmp.current); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); @@ -327,7 +308,7 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer), (body) => withConfigEnv({ SUPABASE_YES: "1" }, body)); }); - it.live( + it.effect( "auto-confirms repair-all via SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { // SUPABASE_YES lives only in supabase/.env; the project env loads it before the prompt. @@ -342,22 +323,18 @@ describe("migration repair", () => { }, ); - it.live("surfaces a DB-config error before prompting (repair-all, unlinked)", () => { + it.effect("surfaces a DB-config error before prompting (repair-all, unlinked)", () => { const { layer, out } = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { - const exit = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( - Effect.exit, + const error = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( + Effect.flip, ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("ProjectRefNotLinkedError"); - } + expect(error).toBeInstanceOf(ProjectRefNotLinkedError); expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); - it.live("prints the repaired, finished, and suggestion lines on success", () => { + it.effect("prints the repaired, finished, and suggestion lines on success", () => { const { layer, out } = setup(tmp.current); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); @@ -372,7 +349,7 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.live("prints multiple repaired versions using Go's %v slice format", () => { + it.effect("prints multiple repaired versions using Go's %v slice format", () => { // The established format is space-separated and bracketed, with no commas; a // `.join(", ")` cleanup would silently change established output. const { layer, out } = setup(tmp.current); @@ -388,78 +365,69 @@ describe("migration repair", () => { }).pipe(Effect.provide(layer)); }); - it.live("reports a missing local file in applied mode", () => { + it.effect("reports a missing local file in applied mode", () => { const { layer } = setup(tmp.current); // no seeded file return Effect.gen(function* () { - const exit = yield* migrationRepair( + const error = yield* migrationRepair( input({ versions: ["20240101000000"], status: "applied" }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationFileNotFoundError"); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationFileNotFoundError); }).pipe(Effect.provide(layer)); }); - it.live("rolls back and reports an update failure", () => { + it.effect("rolls back and reports an update failure", () => { const { layer, execs } = setup(tmp.current, { failSql: "WHERE version = ANY" }); return Effect.gen(function* () { - const exit = yield* migrationRepair( + const error = yield* migrationRepair( input({ versions: ["20240101000000"], status: "reverted" }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationRepairUpdateError"); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationRepairUpdateError); expect(execs).toContain("ROLLBACK"); }).pipe(Effect.provide(layer)); }); - it.live("rejects --db-url combined with --linked", () => { + it.effect("rejects --db-url combined with --linked", () => { const { layer } = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--linked"] }); return Effect.gen(function* () { - const exit = yield* migrationRepair( + const error = yield* migrationRepair( input({ versions: ["20240101000000"], status: "applied", dbUrl: Option.some("postgresql://x"), }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationTargetFlagsError"); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); }).pipe(Effect.provide(layer)); }); - it.live("repairs the project given via --project-ref, overriding the default linked ref", () => { - // VALID_REF is the fake resolver's fallback; the flag must win over it and drive - // the cached ref. - const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache } = setup(tmp.current); - return Effect.gen(function* () { - yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - yield* migrationRepair( - input({ - versions: ["20240101000000"], - status: "applied", - projectRef: Option.some(FLAG_REF), - }), - ); - expect(cache.cached).toBe(true); - expect(cache.cachedRef).toBe(FLAG_REF); - expect(cache.cachedRef).not.toBe(VALID_REF); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "repairs the project given via --project-ref, overriding the default linked ref", + () => { + // VALID_REF is the fake resolver's fallback; the flag must win over it and drive + // the cached ref. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, cache } = setup(tmp.current); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); + yield* migrationRepair( + input({ + versions: ["20240101000000"], + status: "applied", + projectRef: Option.some(FLAG_REF), + }), + ); + expect(cache.cached).toBe(true); + expect(cache.cachedRef).toBe(FLAG_REF); + expect(cache.cachedRef).not.toBe(VALID_REF); + }).pipe(Effect.provide(layer)); + }, + ); - it.live("rejects --project-ref combined with an explicit --local target", () => { + it.effect("rejects --project-ref combined with an explicit --local target", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, execs, queries, cache } = setup(tmp.current, { args: ["--local"] }); return Effect.gen(function* () { - const exit = yield* migrationRepair( + const error = yield* migrationRepair( input({ versions: ["20240101000000"], status: "applied", @@ -467,22 +435,18 @@ describe("migration repair", () => { local: true, projectRef: Option.some(FLAG_REF), }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationTargetFlagsError"); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error.message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); expect(execs).toEqual([]); expect(queries).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); - it.live("emits a structured result in json mode", () => { + it.effect("emits a structured result in json mode", () => { const { layer, out } = setup(tmp.current, { format: "json" }); return Effect.gen(function* () { yield* migrationRepair(input({ versions: ["20240101000000"], status: "reverted" })); diff --git a/apps/cli/src/commands/migration/squash/squash.diff.ts b/apps/cli/src/commands/migration/squash/squash.diff.ts index 621aeb3926..66da2442a5 100644 --- a/apps/cli/src/commands/migration/squash/squash.diff.ts +++ b/apps/cli/src/commands/migration/squash/squash.diff.ts @@ -29,7 +29,7 @@ export function squashLineByLineDiff(before: string, after: string): string { let anchorIndex = 0; let out = ""; for (const line of afterTokens) { - const anchorText = anchorIndex < beforeTokens.length ? beforeTokens[anchorIndex]! : ""; + const anchorText = beforeTokens[anchorIndex] ?? ""; if (line === anchorText) { anchorIndex++; continue; diff --git a/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts b/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts index 1a50ba91a8..ef424514f1 100644 --- a/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts @@ -17,7 +17,7 @@ const readGoFixture = Effect.fnUntraced(function* (name: string) { }); describe("squashLineByLineDiff", () => { - it.live("diffs real pg_dump output into Go's exact diff.sql bytes", () => + it.effect("diffs real pg_dump output into Go's exact diff.sql bytes", () => Effect.gen(function* () { const before = yield* readGoFixture("before.sql"); const after = yield* readGoFixture("after.sql"); diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index 58181449eb..c003b6fac8 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path, Ref } from "effect"; import { ChildProcessSpawner } from "effect/process"; import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/process/ChildProcessSpawner"; @@ -90,6 +90,7 @@ const squashMigrations = Effect.fn("MigrationSquash.squashMigrations")(function* path: Path.Path, workdir: string, migrations: ReadonlyArray, + targetPath: string, localInputs: LocalDbContainerInputs, toml: DbTomlValues, ) { @@ -160,7 +161,6 @@ const squashMigrations = Effect.fn("MigrationSquash.squashMigrations")(function* projectEnvValues: localInputs.context.projectEnvValues, client: dumpClient, }); - const targetPath = migrations[migrations.length - 1]!; const targetRel = path.relative(workdir, targetPath); yield* Effect.scoped( Effect.gen(function* () { @@ -252,7 +252,6 @@ const squashMigrations = Effect.fn("MigrationSquash.squashMigrations")(function* projectEnvValues: localInputs.context.projectEnvValues, }); - const targetPath = migrations[migrations.length - 1]!; const targetRel = path.relative(workdir, targetPath); yield* Effect.scoped( Effect.gen(function* () { @@ -332,11 +331,11 @@ const squashToVersion = Effect.fn("MigrationSquash.squashToVersion")(function* ( const output = yield* Output; const migrations = yield* loadPartialMigrations(fs, path, migrationsDir, version); yield* Effect.annotateCurrentSpan({ "migration.count": migrations.length }); - if (migrations.length === 0) { + const local = migrations.at(-1); + if (local === undefined) { return yield* new MigrationSquashMissingVersionError({ message: "version not found" }); } - const local = migrations[migrations.length - 1]!; const rel = path.relative(workdir, local); if (migrations.length === 1) { yield* output.raw(`${bold(rel)} is already the earliest migration.\n`, "stderr"); @@ -348,7 +347,7 @@ const squashToVersion = Effect.fn("MigrationSquash.squashToVersion")(function* ( } satisfies SquashToVersionResult; } - yield* squashMigrations(spawner, fs, path, workdir, migrations, localInputs, toml); + yield* squashMigrations(spawner, fs, path, workdir, migrations, local, localInputs, toml); yield* output.raw(`Squashed local migrations to ${bold(rel)}\n`, "stderr"); const removed: Array = []; @@ -405,7 +404,7 @@ const baselineMigrations = Effect.fn("MigrationSquash.baselineMigrations")(funct debugLogger.debug(cause.message).pipe(Effect.as>([])), ), ); - if (local.length > 0) resolvedVersion = local[0]!; + resolvedVersion = local[0] ?? resolvedVersion; } // Printed before connecting, the opposite order from every other prompting migration @@ -469,7 +468,7 @@ const runSquash = Effect.fnUntraced(function* ( // Resolved linked ref, captured so the post-run finalizer caches the project // (GET /v1/projects/{ref}). - let linkedRefForCache: string | undefined; + const linkedRefForCache = yield* Ref.make(undefined); yield* Effect.gen(function* () { // Checked here, ahead of the root pre-run. @@ -503,7 +502,7 @@ const runSquash = Effect.fnUntraced(function* ( if (connType === "linked") { const projectRefResolver = yield* ProjectRefResolver; linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); - linkedRefForCache = linkedRef; + yield* Ref.set(linkedRefForCache, linkedRef); } const toml = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); if (toml.appliedRemote !== undefined) { @@ -534,7 +533,7 @@ const runSquash = Effect.fnUntraced(function* ( if (linkedRef === undefined) { linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); } - if (linkedRef !== undefined) linkedRefForCache = linkedRef; + if (linkedRef !== undefined) yield* Ref.set(linkedRefForCache, linkedRef); yield* Effect.annotateCurrentSpan({ "db.conn_type": connType, "db.is_local": cfg.isLocal }); // Loads after the flag-group check above, so a flag conflict surfaces before any @@ -622,8 +621,8 @@ const runSquash = Effect.fnUntraced(function* ( } }).pipe( Effect.ensuring( - Effect.suspend(() => - linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, + Effect.flatMap(Ref.get(linkedRefForCache), (ref) => + ref !== undefined ? linkedProjectCache.cache(ref) : Effect.void, ), ), ); diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index 52d89984cb..a48481c5e9 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -1,17 +1,7 @@ import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Config, - ConfigProvider, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, -} from "effect"; +import { Config, ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -63,7 +53,23 @@ import { import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; import { BundledPostgresClient } from "../../../command-internal/bundled-postgres-client.ts"; import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; +import { DbSetupError } from "../../../command-internal/db-bootstrap/db-setup.ts"; +import { HealthCheckTimeoutError } from "../../../command-internal/db-bootstrap/health-check.ts"; +import { ShadowDbError } from "../../../command-internal/db-bootstrap/shadow-database.ts"; +import { MigrationApplyError } from "../../../command-internal/migration-apply.ts"; +import { + MigrationFileNotFoundError, + MigrationInvalidVersionError, + MigrationPasswordFlagsError, + MigrationTargetFlagsError, +} from "../migration.errors.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; +import { + MigrationSquashBaselineError, + MigrationSquashDumpError, + MigrationSquashMissingVersionError, + MigrationSquashWriteError, +} from "./squash.errors.ts"; import { migrationSquash } from "./squash.handler.ts"; // Distinguishes squash's three pg_dump containers from the shadow's setup jobs by env: @@ -174,10 +180,8 @@ function faultyFsLayer(workdir: string, opts: FsFaultOpts): Layer.Layer { - if ( - opts.failWriteAllFromCall === undefined || - path !== migrationFile(opts.failWriteAllFromCall.file) - ) { + const failWriteAll = opts.failWriteAllFromCall; + if (failWriteAll === undefined || path !== migrationFile(failWriteAll.file)) { return file; } let writeAllCalls = 0; @@ -185,7 +189,7 @@ function faultyFsLayer(workdir: string, opts: FsFaultOpts): Layer.Layer { writeAllCalls += 1; - return writeAllCalls >= opts.failWriteAllFromCall!.fromCall + return writeAllCalls >= failWriteAll.fromCall ? Effect.fail(simulatedFsError(path, "writeAll")) : file.writeAll(buffer); }, @@ -458,12 +462,6 @@ const migrationExists = Effect.fnUntraced(function* (workdir: string, file: stri const stdout = (out: ReturnType) => stripAnsi(out.stdoutText); const stderr = (out: ReturnType) => stripAnsi(out.stderrText); -const failureTag = (exit: Exit.Exit): string | undefined => { - if (!Exit.isFailure(exit)) return undefined; - const failure = Cause.findErrorOption(exit.cause); - return Option.isSome(failure) ? (failure.value as { readonly _tag?: string })._tag : undefined; -}; - const tmp = useTempWorkdir(); describe("migration squash", () => { @@ -471,46 +469,39 @@ describe("migration squash", () => { it.effect("rejects --linked combined with --local", () => { const s = setup(tmp.current, { args: ["--linked", "--local"] }); return Effect.gen(function* () { - const exit = yield* migrationSquash(flags({ linked: true, local: true })).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationTargetFlagsError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", - ); - } + const error = yield* migrationSquash(flags({ linked: true, local: true })).pipe( + Effect.flip, + ); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error.message).toBe( + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + ); }).pipe(Effect.provide(s.layer)); }); it.effect("rejects --db-url combined with --password", () => { const s = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--password", "y"] }); return Effect.gen(function* () { - const exit = yield* migrationSquash( + const error = yield* migrationSquash( flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("y") }), - ).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationPasswordFlagsError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", - ); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationPasswordFlagsError); + expect(error.message).toBe( + "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", + ); }).pipe(Effect.provide(s.layer)); }); it.effect("rejects --project-ref on the default local target", () => { const s = setup(tmp.current); return Effect.gen(function* () { - const exit = yield* migrationSquash(flags({ projectRef: Option.some(VALID_REF) })).pipe( - Effect.exit, + const error = yield* migrationSquash(flags({ projectRef: Option.some(VALID_REF) })).pipe( + Effect.flip, + ); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error.message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); - expect(failureTag(exit)).toBe("MigrationTargetFlagsError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } // The guard fires before any resolver call, shadow/dump work, or cache write. expect(s.resolverCalls).toEqual([]); expect(s.shadowSpawned).toEqual([]); @@ -525,19 +516,16 @@ describe("migration squash", () => { args: ["--db-url", "postgresql://x", "--project-ref", VALID_REF], }); return Effect.gen(function* () { - const exit = yield* migrationSquash( + const error = yield* migrationSquash( flags({ dbUrl: Option.some("postgresql://x"), projectRef: Option.some(VALID_REF), }), - ).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationTargetFlagsError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error.message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); expect(s.resolverCalls).toEqual([]); expect(s.shadowSpawned).toEqual([]); expect(s.dumpCalls).toEqual([]); @@ -551,16 +539,11 @@ describe("migration squash", () => { () => { const s = setup(tmp.current); return Effect.gen(function* () { - const exit = yield* migrationSquash(flags({ version: Option.some("0_init") })).pipe( - Effect.exit, + const error = yield* migrationSquash(flags({ version: Option.some("0_init") })).pipe( + Effect.flip, ); - expect(failureTag(exit)).toBe("MigrationInvalidVersionError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "invalid version number", - ); - } + expect(error).toBeInstanceOf(MigrationInvalidVersionError); + expect(error.message).toBe("invalid version number"); }).pipe(Effect.provide(s.layer)); }, ); @@ -568,16 +551,11 @@ describe("migration squash", () => { it.effect("rejects an out-of-int64-range --version with the same bare message", () => { const s = setup(tmp.current); return Effect.gen(function* () { - const exit = yield* migrationSquash( + const error = yield* migrationSquash( flags({ version: Option.some("99999999999999999999") }), - ).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationInvalidVersionError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "invalid version number", - ); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationInvalidVersionError); + expect(error.message).toBe("invalid version number"); }).pipe(Effect.provide(s.layer)); }); @@ -585,14 +563,11 @@ describe("migration squash", () => { const s = setup(tmp.current); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); - const exit = yield* migrationSquash(flags({ version: Option.some("9") })).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationFileNotFoundError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "glob supabase/migrations/9_*.sql: file does not exist", - ); - } + const error = yield* migrationSquash(flags({ version: Option.some("9") })).pipe( + Effect.flip, + ); + expect(error).toBeInstanceOf(MigrationFileNotFoundError); + expect(error.message).toBe("glob supabase/migrations/9_*.sql: file does not exist"); }).pipe(Effect.provide(s.layer)); }); @@ -601,10 +576,10 @@ describe("migration squash", () => { // repair's identical ordering test. const s = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { - const exit = yield* migrationSquash(flags({ version: Option.some("not-a-number") })).pipe( - Effect.exit, + const error = yield* migrationSquash(flags({ version: Option.some("not-a-number") })).pipe( + Effect.flip, ); - expect(failureTag(exit)).toBe("ProjectRefNotLinkedError"); + expect(error).toBeInstanceOf(ProjectRefNotLinkedError); }).pipe(Effect.provide(s.layer)); }); @@ -623,14 +598,9 @@ describe("migration squash", () => { it.effect("fails with 'version not found' when the migrations directory is empty", () => { const s = setup(tmp.current); return Effect.gen(function* () { - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationSquashMissingVersionError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "version not found", - ); - } + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSquashMissingVersionError); + expect(error.message).toBe("version not found"); }).pipe(Effect.provide(s.layer)); }); @@ -640,8 +610,8 @@ describe("migration squash", () => { const s = setup(tmp.current); return Effect.gen(function* () { yield* seedMigration(tmp.current, "20211208000000_init.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationSquashMissingVersionError"); + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSquashMissingVersionError); }).pipe(Effect.provide(s.layer)); }, ); @@ -653,7 +623,7 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* writeProjectFile(tmp.current, "migrations", "not a directory"); const error = yield* migrationSquash(flags()).pipe(Effect.flip); - expect((error as { message: string }).message).toContain("failed to read directory"); + expect(error.message).toContain("failed to read directory"); }).pipe(Effect.provide(s.layer)); }, ); @@ -897,8 +867,8 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("ShadowDbError"); + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(ShadowDbError); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); // Nothing to release; the container was never created (see createShadowDatabase's doc). expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toEqual([]); @@ -916,8 +886,8 @@ describe("migration squash", () => { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); yield* writeProjectFile(tmp.current, "config.toml", '[db]\nhealth_timeout = "0s"\n'); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("HealthCheckTimeoutError"); + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(HealthCheckTimeoutError); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); expect(s.dumpCalls).toEqual([]); @@ -932,8 +902,8 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("DbSetupError"); + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(DbSetupError); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); expect(s.dumpCalls).toEqual([]); @@ -946,8 +916,8 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql", "create table boom;\n"); yield* seedMigration(tmp.current, "1_target.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationApplyError"); + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationApplyError); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); @@ -960,14 +930,9 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationSquashDumpError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "error running container: exit 1", - ); - } + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSquashDumpError); + expect(error.message).toBe("error running container: exit 1"); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }, @@ -985,8 +950,8 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationSquashDumpError"); + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSquashDumpError); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); // Truncated by the earlier O_TRUNC, then only the partial stream the dying // container wrote before failing; no separator/diff was ever appended. @@ -1011,16 +976,11 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationSquashWriteError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - const message = - Option.isSome(failure) && (failure.value as { message: string }).message; - expect(message).toContain("failed to open migration file:"); - // Relativized: the absolute tmp workdir never leaks. - expect(message).not.toContain(tmp.current); - } + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSquashWriteError); + expect(error.message).toContain("failed to open migration file:"); + // Relativized: the absolute tmp workdir never leaks. + expect(error.message).not.toContain(tmp.current); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }, @@ -1041,14 +1001,9 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationSquashWriteError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect( - Option.isSome(failure) && (failure.value as { message: string }).message, - ).toContain("failed to copy docker logs:"); - } + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSquashWriteError); + expect(error.message).toContain("failed to copy docker logs:"); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }, @@ -1068,16 +1023,11 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationSquashWriteError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - const message = - Option.isSome(failure) && (failure.value as { message: string }).message; - expect(message).toContain("failed to write line:"); - // Relativized: the absolute tmp workdir never leaks. - expect(message).not.toContain(tmp.current); - } + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSquashWriteError); + expect(error.message).toContain("failed to write line:"); + // Relativized: the absolute tmp workdir never leaks. + expect(error.message).not.toContain(tmp.current); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); expect(yield* readMigration(tmp.current, "1_target.sql")).toBe("full;\n"); }).pipe(Effect.provide(s.layer)); @@ -1116,19 +1066,17 @@ describe("migration squash", () => { yield* seedMigration(tmp.current, "1_target.sql"); yield* migrationSquash(flags()); const success = s.out.messages.find((m) => m.type === "success"); - const data = success?.data as { - readonly removed: ReadonlyArray; - readonly removeFailures: ReadonlyArray<{ - readonly path: string; - readonly message: string; - }>; - }; - expect(data.removed).toEqual([]); - expect(data.removeFailures).toHaveLength(1); - expect(data.removeFailures[0]?.path).toBe("supabase/migrations/0_init.sql"); - expect(data.removeFailures[0]?.message).toContain( - "FileSystem.remove (supabase/migrations/0_init.sql)", - ); + expect(success?.data).toMatchObject({ + removed: [], + removeFailures: [ + { + path: "supabase/migrations/0_init.sql", + message: expect.stringContaining( + "FileSystem.remove (supabase/migrations/0_init.sql)", + ), + }, + ], + }); }).pipe(Effect.provide(s.layer)); }, ); @@ -1238,14 +1186,9 @@ describe("migration squash", () => { const s = setupRemote({ confirm: true, failSql: "INSERT INTO supabase_migrations" }); return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationSquashBaselineError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect( - Option.isSome(failure) && (failure.value as { message: string }).message, - ).toContain("failed to update migration history:"); - } + const error = yield* migrationSquash(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationSquashBaselineError); + expect(error.message).toContain("failed to update migration history:"); expect(s.execs).toContain("ROLLBACK"); // Exactly one COMMIT: createMigrationTable's own setup transaction, which runs // before the baseline's BEGIN/DELETE/INSERT batch and never itself reaches COMMIT. @@ -1358,7 +1301,7 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedMigration(tmp.current, "0_init.sql"); yield* seedMigration(tmp.current, "1_target.sql"); - const exit = yield* migrationSquash(flags()).pipe(Effect.exit); + const error = yield* migrationSquash(flags()).pipe(Effect.flip); expect(s.debugLogs).toHaveLength(1); expect(s.debugLogs[0]).toContain("failed to read directory"); expect(s.debugLogs[0]).toContain("simulated failure"); @@ -1366,13 +1309,8 @@ describe("migration squash", () => { // The empty-version glob fails, surfacing as the baseline's missing-file error // and proving resolvedVersion genuinely stayed "" rather than falling back to // the squash target. - expect(failureTag(exit)).toBe("MigrationFileNotFoundError"); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "glob supabase/migrations/_*.sql: file does not exist", - ); - } + expect(error).toBeInstanceOf(MigrationFileNotFoundError); + expect(error.message).toBe("glob supabase/migrations/_*.sql: file does not exist"); }).pipe(Effect.provide(s.layer)); }, ); @@ -1384,10 +1322,10 @@ describe("migration squash", () => { args: ["--linked"], }); return Effect.gen(function* () { - const exit = yield* migrationSquash( + const error = yield* migrationSquash( flags({ linked: true, version: Option.some("bad") }), - ).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationInvalidVersionError"); + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationInvalidVersionError); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(VALID_REF); }).pipe(Effect.provide(s.layer)); @@ -1405,10 +1343,10 @@ describe("migration squash", () => { args: ["--linked", "--project-ref", FLAG_REF], }); return Effect.gen(function* () { - const exit = yield* migrationSquash( + const error = yield* migrationSquash( flags({ linked: true, projectRef: Option.some(FLAG_REF), version: Option.some("bad") }), - ).pipe(Effect.exit); - expect(failureTag(exit)).toBe("MigrationInvalidVersionError"); + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationInvalidVersionError); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); expect(s.cache.cachedRef).not.toBe(VALID_REF); diff --git a/apps/cli/src/commands/migration/up/up.handler.ts b/apps/cli/src/commands/migration/up/up.handler.ts index 4803aeb53b..6fbb4b1d45 100644 --- a/apps/cli/src/commands/migration/up/up.handler.ts +++ b/apps/cli/src/commands/migration/up/up.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Match, Option, Path } from "effect"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; @@ -88,35 +88,39 @@ const runUp = Effect.fnUntraced(function* ( const remote = yield* listRemoteMigrations(session); const local = yield* listLocalMigrationPaths(fs, path, migrationsDir); - const result = findPendingMigrations(local, remote); - - let pending: ReadonlyArray; - if (result.kind === "missing-local") { - return yield* new MigrationMissingLocalError({ - message: "Remote migration versions not found in local migrations directory.", - suggestion: suggestRevertHistory( - result.versions, - (target.connType ?? "local") === "local", - ), - }); - } else if (result.kind === "missing-remote") { - if (!flags.includeAll) { - return yield* new MigrationMissingRemoteError({ - message: - "Found local migration files to be inserted before the last migration on remote database.", - suggestion: suggestIgnoreFlag(result.paths), - }); - } - // Slices the same version-ordered list `result.paths` was taken from; indexing - // a name-ordered list with this offset would skip a pending migration and - // re-apply an already-applied one. - pending = [ - ...result.paths, - ...sortMigrationPathsByVersion(local).slice(remote.length + result.paths.length), - ]; - } else { - pending = result.paths; - } + const pending = yield* Match.value(findPendingMigrations(local, remote)).pipe( + Match.discriminatorsExhaustive("kind")({ + "missing-local": (result) => + Effect.fail( + new MigrationMissingLocalError({ + message: "Remote migration versions not found in local migrations directory.", + suggestion: suggestRevertHistory( + result.versions, + (target.connType ?? "local") === "local", + ), + }), + ), + "missing-remote": (result) => { + if (!flags.includeAll) { + return Effect.fail( + new MigrationMissingRemoteError({ + message: + "Found local migration files to be inserted before the last migration on remote database.", + suggestion: suggestIgnoreFlag(result.paths), + }), + ); + } + // Slices the same version-ordered list `result.paths` was taken from; indexing + // a name-ordered list with this offset would skip a pending migration and + // re-apply an already-applied one. + return Effect.succeed([ + ...result.paths, + ...sortMigrationPathsByVersion(local).slice(remote.length + result.paths.length), + ]); + }, + pending: (result) => Effect.succeed(result.paths), + }), + ); yield* upsertVaultSecrets(session, toml.vault); diff --git a/apps/cli/src/commands/migration/up/up.integration.test.ts b/apps/cli/src/commands/migration/up/up.integration.test.ts index 9edbb425fc..b7510a90b9 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { stripAnsi } from "../../../../tests/helpers/ansi.ts"; import { @@ -20,8 +20,10 @@ import { DbConfigResolver } from "../../../command-internal/db-config.service.ts import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { type DbSession, DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { MigrationApplyError } from "../../../command-internal/migration-apply.ts"; import { MigrationVaultError } from "../../../command-internal/vault.ts"; -import { MigrationMissingLocalError } from "./up.errors.ts"; +import { MigrationTargetFlagsError } from "../migration.errors.ts"; +import { MigrationMissingLocalError, MigrationMissingRemoteError } from "./up.errors.ts"; import { migrationUp } from "./up.handler.ts"; import type { MigrationUpFlags } from "./up.command.ts"; @@ -156,7 +158,7 @@ const insertedVersions = (queries: Array<{ sql: string; params?: ReadonlyArray { - it.live("applies pending migrations in order and prints progress", () => { + it.effect("applies pending migrations in order and prints progress", () => { const { layer, out, queries } = setup(tmp.current, { remote: ["20240101000000"] }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); @@ -175,40 +177,32 @@ describe("migration up", () => { }).pipe(Effect.provide(layer)); }); - it.live("errors with a revert suggestion when a remote version is missing locally", () => { + it.effect("errors with a revert suggestion when a remote version is missing locally", () => { const { layer } = setup(tmp.current, { remote: ["20240101000000", "20240199000000"] }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); - const exit = yield* migrationUp(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationMissingLocalError"); - const suggestion = - Option.isSome(failure) && failure.value instanceof MigrationMissingLocalError - ? failure.value.suggestion - : ""; - expect(suggestion).toContain("migration repair --local --status reverted"); - expect(suggestion).toContain("supabase db pull --local"); - } + const error = yield* migrationUp(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationMissingLocalError); + expect(error).toMatchObject({ + suggestion: expect.stringContaining("migration repair --local --status reverted"), + }); + expect(error).toMatchObject({ + suggestion: expect.stringContaining("supabase db pull --local"), + }); }).pipe(Effect.provide(layer)); }); - it.live("errors with an --include-all suggestion on an out-of-order local migration", () => { + it.effect("errors with an --include-all suggestion on an out-of-order local migration", () => { const { layer } = setup(tmp.current, { remote: ["20240102000000"] }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); yield* seed(tmp.current, "20240102000000_b.sql"); - const exit = yield* migrationUp(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationMissingRemoteError"); - } + const error = yield* migrationUp(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationMissingRemoteError); }).pipe(Effect.provide(layer)); }); - it.live("applies out-of-order migrations with --include-all in the right order", () => { + it.effect("applies out-of-order migrations with --include-all in the right order", () => { const { layer, queries } = setup(tmp.current, { remote: ["20240102000000"] }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); // out-of-order (before applied 02) @@ -219,7 +213,7 @@ describe("migration up", () => { }).pipe(Effect.provide(layer)); }); - it.live("creates a new [db.vault] secret before applying migrations", () => { + it.effect("creates a new [db.vault] secret before applying migrations", () => { const { layer, out, queries } = setup(tmp.current, { remote: [], }); @@ -233,7 +227,7 @@ describe("migration up", () => { }).pipe(Effect.provide(layer)); }); - it.live("reports a vault upsert failure", () => { + it.effect("reports a vault upsert failure", () => { const { layer } = setup(tmp.current, { remote: [], failVault: true, @@ -241,16 +235,12 @@ describe("migration up", () => { return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); yield* writeProjectFile(tmp.current, "config.toml", '[db.vault]\nmy_secret = "shhh"\n'); - const exit = yield* migrationUp(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value instanceof MigrationVaultError).toBe(true); - } + const error = yield* migrationUp(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationVaultError); }).pipe(Effect.provide(layer)); }); - it.live("updates an existing [db.vault] secret by id", () => { + it.effect("updates an existing [db.vault] secret by id", () => { const { layer, queries } = setup(tmp.current, { remote: [], existingVault: [{ id: "vault-id-1", name: "my_secret" }], @@ -265,21 +255,17 @@ describe("migration up", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects --db-url combined with --linked", () => { + it.effect("rejects --db-url combined with --linked", () => { const { layer } = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--linked"] }); return Effect.gen(function* () { - const exit = yield* migrationUp( + const error = yield* migrationUp( flags({ dbUrl: Option.some("postgresql://x"), linked: true, local: false }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationTargetFlagsError"); - } + ).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "applies on the project given via --project-ref --linked, overriding the linked ref", () => { // VALID_REF is the resolver's fallback if the flag were absent; the flag must win. @@ -296,28 +282,24 @@ describe("migration up", () => { }, ); - it.live("rejects --project-ref on the default local target", () => { + it.effect("rejects --project-ref on the default local target", () => { const FLAG_REF = "flagflagflagflagflag"; const { layer, execs, queries, cache } = setup(tmp.current, { remote: [] }); return Effect.gen(function* () { - const exit = yield* migrationUp(flags({ projectRef: Option.some(FLAG_REF) })).pipe( - Effect.exit, + const error = yield* migrationUp(flags({ projectRef: Option.some(FLAG_REF) })).pipe( + Effect.flip, + ); + expect(error).toBeInstanceOf(MigrationTargetFlagsError); + expect(error.message).toBe( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationTargetFlagsError"); - expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); - } expect(execs).toEqual([]); expect(queries).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); - it.live("emits a structured result in json", () => { + it.effect("emits a structured result in json", () => { const { layer, out } = setup(tmp.current, { format: "json", remote: [] }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql"); @@ -328,16 +310,12 @@ describe("migration up", () => { }).pipe(Effect.provide(layer)); }); - it.live("surfaces an apply failure", () => { + it.effect("surfaces an apply failure", () => { const { layer } = setup(tmp.current, { remote: [], failApply: true }); return Effect.gen(function* () { yield* seed(tmp.current, "20240101000000_a.sql", "create table boom;\n"); - const exit = yield* migrationUp(flags()).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationApplyError"); - } + const error = yield* migrationUp(flags()).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationApplyError); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/commands/seed/buckets/buckets.flags.unit.test.ts b/apps/cli/src/commands/seed/buckets/buckets.flags.unit.test.ts index ae83ee007d..a9c883f339 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.flags.unit.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.flags.unit.test.ts @@ -1,34 +1,45 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; import { assertSeedTargetsExclusive } from "./buckets.flags.ts"; describe("assertSeedTargetsExclusive", () => { - it("fails when both --local and --linked are set (cobra mutual exclusivity)", () => { - const exit = Effect.runSyncExit( - assertSeedTargetsExclusive(["seed", "buckets", "--local", "--linked"]), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( - "if any flags in the group [local linked] are set none of the others can be; [linked local] were all set", - ); - }); + it.effect("fails when both --local and --linked are set (cobra mutual exclusivity)", () => + Effect.gen(function* () { + const error = yield* assertSeedTargetsExclusive([ + "seed", + "buckets", + "--local", + "--linked", + ]).pipe(Effect.flip); + expect(error.message).toBe( + "if any flags in the group [local linked] are set none of the others can be; [linked local] were all set", + ); + }), + ); - it("fails for the --no-local --linked negation combo (both changed)", () => { - const exit = Effect.runSyncExit( - assertSeedTargetsExclusive(["seed", "buckets", "--no-local", "--linked"]), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("[linked local] were all set"); - }); + it.effect("fails for the --no-local --linked negation combo (both changed)", () => + Effect.gen(function* () { + const error = yield* assertSeedTargetsExclusive([ + "seed", + "buckets", + "--no-local", + "--linked", + ]).pipe(Effect.flip); + expect(error.message).toContain("[linked local] were all set"); + }), + ); - it("succeeds when at most one target flag is set", () => { - for (const args of [ - ["seed", "buckets", "--linked"], - ["seed", "buckets", "--local"], - ["seed", "buckets"], - ]) { - expect(Exit.isSuccess(Effect.runSyncExit(assertSeedTargetsExclusive(args)))).toBe(true); - } - }); + it.effect("succeeds when at most one target flag is set", () => + Effect.gen(function* () { + for (const args of [ + ["seed", "buckets", "--linked"], + ["seed", "buckets", "--local"], + ["seed", "buckets"], + ]) { + const exit = yield* assertSeedTargetsExclusive(args).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + } + }), + ); }); diff --git a/apps/cli/src/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/commands/seed/buckets/buckets.handler.ts index 6f35f9183f..49b0eeb8f5 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option } from "effect"; +import { Effect, FileSystem, Option, Ref } from "effect"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; @@ -36,7 +36,7 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( // Set once --linked resolves a ref; drives the post-run linked-project cache // write and org/project group identify. Empty on the local path, so the // cache is never written there. - let linkedRef = ""; + const linkedRef = yield* Ref.make(""); yield* Effect.gen(function* () { yield* validateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( @@ -70,7 +70,7 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( const projectRefResolver = yield* ProjectRefResolver; const projectRef = isLinked ? yield* projectRefResolver.loadProjectRef(flags.projectRef) : ""; - linkedRef = projectRef; + yield* Ref.set(linkedRef, projectRef); yield* Effect.annotateCurrentSpan({ "db.conn_type": isLinked ? "linked" : "local" }); yield* seedBucketsRun({ projectRef, emitSummary: true }); @@ -78,7 +78,9 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( // Caches the linked project and fires org/project group identify — only // on the --linked path, when `linkedRef` is set. Effect.ensuring( - Effect.suspend(() => (linkedRef === "" ? Effect.void : linkedProjectCache.cache(linkedRef))), + Effect.flatMap(Ref.get(linkedRef), (ref) => + ref === "" ? Effect.void : linkedProjectCache.cache(ref), + ), ), Effect.ensuring(telemetryState.flush), ); diff --git a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts index 88ac7cb8ed..443863c354 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -1,7 +1,17 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { loadCliConfig } from "@supabase/config/internal"; -import { Cause, ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Predicate, +} from "effect"; import * as HttpClient from "effect/http/HttpClient"; import type * as HttpClientError from "effect/http/HttpClientError"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -128,7 +138,7 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( HttpClient.make((request) => { const reqBody = request.body; let body: unknown; - if (reqBody._tag === "Uint8Array") { + if (Predicate.isTagged(reqBody, "Uint8Array")) { try { body = JSON.parse(new TextDecoder().decode(reqBody.body)); } catch { @@ -273,7 +283,7 @@ describe("seed buckets", () => { body.pipe(Effect.provide(BunServices.layer)), ); - it.live("short-circuits with no output when nothing is configured", () => + it.effect("short-circuits with no output when nothing is configured", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: 'project_id = "test"\n', @@ -285,38 +295,42 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("hard-fails a malformed SUPABASE_API_PORT even when nothing is configured to seed", () => - Effect.gen(function* () { - const { layer, requests } = yield* setupSeedBuckets(tmp.current, { - toml: 'project_id = "test"\n', - files: { "supabase/.env": "SUPABASE_API_PORT=not-a-port\n" }, - }); - const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("Invalid config for api.port: cannot parse"); - } - expect(requests).toHaveLength(0); - }).pipe(seedScenario), + it.effect( + "hard-fails a malformed SUPABASE_API_PORT even when nothing is configured to seed", + () => + Effect.gen(function* () { + const { layer, requests } = yield* setupSeedBuckets(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/.env": "SUPABASE_API_PORT=not-a-port\n" }, + }); + const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("Invalid config for api.port: cannot parse"); + } + expect(requests).toHaveLength(0); + }).pipe(seedScenario), ); - it.live("hard-fails a broken TLS cert/key pairing even when nothing is configured to seed", () => - Effect.gen(function* () { - const { layer, requests } = yield* setupSeedBuckets(tmp.current, { - toml: 'project_id = "test"\n[api.tls]\nenabled = true\ncert_path = "kong.crt"\n', - }); - const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "Missing required field in config: api.tls.key_path", - ); - } - expect(requests).toHaveLength(0); - }).pipe(seedScenario), + it.effect( + "hard-fails a broken TLS cert/key pairing even when nothing is configured to seed", + () => + Effect.gen(function* () { + const { layer, requests } = yield* setupSeedBuckets(tmp.current, { + toml: 'project_id = "test"\n[api.tls]\nenabled = true\ncert_path = "kong.crt"\n', + }); + const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "Missing required field in config: api.tls.key_path", + ); + } + expect(requests).toHaveLength(0); + }).pipe(seedScenario), ); - it.live( + it.effect( "hard-fails an undecryptable encrypted: service_role_key even when nothing is configured to seed", () => Effect.gen(function* () { @@ -332,7 +346,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("emits an empty JSON result for a no-op run (json mode)", () => + it.effect("emits an empty JSON result for a no-op run (json mode)", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: 'project_id = "test"\n', @@ -351,7 +365,7 @@ describe("seed buckets", () => { // doesn't reach this handler; see `assertSeedTargetsExclusive` in // buckets.flags.unit.test.ts for that coverage. - it.live("tolerates null string fields in 200 responses (Go encoding/json zero value)", () => + it.effect("tolerates null string fields in 200 responses (Go encoding/json zero value)", () => Effect.gen(function* () { // A JSON `null` for a string field decodes to "" and must not abort — a // list entry with `name: null` and a create response with `message: null` @@ -372,7 +386,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("tolerates a null element in a bucket list (Go zero-value struct)", () => + it.effect("tolerates a null element in a bucket list (Go zero-value struct)", () => Effect.gen(function* () { // A null array element decodes to an empty-name entry and must not abort // the run; the configured bucket is still created. A genuine type mismatch @@ -397,7 +411,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("creates a new bucket and updates an existing one (overwrite default yes)", () => + it.effect("creates a new bucket and updates an existing one (overwrite default yes)", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n[storage.buckets.private]\npublic = false\n", @@ -420,7 +434,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("skips the update when the overwrite prompt is declined", () => + it.effect("skips the update when the overwrite prompt is declined", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", @@ -436,7 +450,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live( + it.effect( "honors a piped decline for the overwrite prompt when non-interactive (db reset path)", () => Effect.gen(function* () { @@ -464,7 +478,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("creates configured vector buckets and leaves stale ones (prune default no)", () => + it.effect("creates configured vector buckets and leaves stale ones (prune default no)", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n[storage.vector.buckets.existing-vec]\n", @@ -496,7 +510,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("treats a null vectorBuckets list as empty (Go nil slice)", () => + it.effect("treats a null vectorBuckets list as empty (Go nil slice)", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", @@ -513,7 +527,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("prunes a stale vector bucket when the prompt is accepted", () => + it.effect("prunes a stale vector bucket when the prompt is accepted", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.keep-vec]\n", @@ -537,7 +551,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live( + it.effect( "prunes a stale vector bucket when the caller passes yes (db reset project-env path)", () => Effect.gen(function* () { @@ -573,7 +587,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("warns and continues when vector buckets are unavailable in the region", () => + it.effect("warns and continues when vector buckets are unavailable in the region", () => Effect.gen(function* () { const { layer, out } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", @@ -596,7 +610,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("warns and continues when the local vector service is unavailable", () => + it.effect("warns and continues when the local vector service is unavailable", () => Effect.gen(function* () { const { layer, out } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", @@ -620,7 +634,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("propagates an unclassified vector error", () => + it.effect("propagates an unclassified vector error", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", @@ -634,7 +648,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("uploads objects from a bucket's objects_path", () => + it.effect("uploads objects from a bucket's objects_path", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { // Relative objects_path resolves under supabase/. @@ -658,7 +672,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("sets the object Content-Type from the file bytes, not the extension", () => + it.effect("sets the object Content-Type from the file bytes, not the extension", () => Effect.gen(function* () { // Content-type is sniffed from the first 512 bytes; only a generic // text/plain sniff is refined by extension. A PNG named `.txt` uploads as @@ -692,7 +706,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("resolves an absolute objects_path as-is (Go IsAbs guard)", () => + it.effect("resolves an absolute objects_path as-is (Go IsAbs guard)", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -716,7 +730,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails with a config-load error on malformed config.toml", () => + it.effect("fails with a config-load error on malformed config.toml", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage\n" }); const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); @@ -724,7 +738,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("emits a structured result and suppresses prompts in json mode", () => + it.effect("emits a structured result and suppresses prompts in json mode", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", @@ -741,7 +755,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("treats a missing config file as embedded defaults: local no-op, no text output", () => + it.effect("treats a missing config file as embedded defaults: local no-op, no text output", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, {}); const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); @@ -751,7 +765,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("emits an empty JSON result for a missing config file (local no-op, json mode)", () => + it.effect("emits an empty JSON result for a missing config file (local no-op, json mode)", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { format: "json" }); const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); @@ -763,7 +777,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("does not skip a --linked run when the config file is absent", () => + it.effect("does not skip a --linked run when the config file is absent", () => Effect.gen(function* () { // A linked run never short-circuits on empty config: even with no config // file, the remote client is built, the service-role key fetched, and @@ -795,7 +809,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("honors an explicit external_url and service_role_key", () => + it.effect("honors an explicit external_url and service_role_key", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ @@ -823,7 +837,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("omits the Authorization header for an opaque sb_ service key", () => + it.effect("omits the Authorization header for an opaque sb_ service key", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ @@ -844,7 +858,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("regenerates the service-role key when it is set to an empty string", () => + it.effect("regenerates the service-role key when it is set to an empty string", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: ["[auth]", 'service_role_key = ""', "[storage.buckets.media]", "public = true"].join( @@ -863,7 +877,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("rejects a jwt_secret shorter than 16 characters", () => + it.effect("rejects a jwt_secret shorter than 16 characters", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: '[auth]\njwt_secret = "tooshort"\n[storage.buckets.media]\npublic = true\n', @@ -880,7 +894,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails on an invalid bucket file_size_limit before any Storage call", () => + it.effect("fails on an invalid bucket file_size_limit before any Storage call", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { // First bucket is valid; the second has an unparseable size — all sizes @@ -905,7 +919,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("rejects a malformed file_size_limit numeral (Go strconv.ParseFloat)", () => + it.effect("rejects a malformed file_size_limit numeral (Go strconv.ParseFloat)", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { // parseFloat would parse "1.2.3" as 1.2; the whole config must be @@ -922,7 +936,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails on an invalid storage-level file_size_limit (only vector buckets)", () => + it.effect("fails on an invalid storage-level file_size_limit (only vector buckets)", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { // No storage buckets inherit it, only a vector bucket is configured — the @@ -943,7 +957,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails on an invalid storage-level file_size_limit even with nothing to seed", () => + it.effect("fails on an invalid storage-level file_size_limit even with nothing to seed", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { // No buckets and no vector buckets — the storage-level file_size_limit is @@ -960,7 +974,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("inherits the storage-level file_size_limit when a bucket omits it", () => + it.effect("inherits the storage-level file_size_limit when a bucket omits it", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { // Custom storage-level limit; the bucket omits file_size_limit, so the @@ -977,13 +991,11 @@ describe("seed buckets", () => { (r) => r.method === "POST" && r.url.endsWith("/storage/v1/bucket"), ); // 5MiB = 5 * 1024 * 1024 (not the 50MiB bucket schema default). - expect((create?.body as { file_size_limit?: number } | undefined)?.file_size_limit).toBe( - 5 * 1024 * 1024, - ); + expect(create?.body).toMatchObject({ file_size_limit: 5 * 1024 * 1024 }); }).pipe(seedScenario), ); - it.live("derives the service-role key from auth.jwt_secret when no key is set", () => + it.effect("derives the service-role key from auth.jwt_secret when no key is set", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: [ @@ -1002,7 +1014,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("propagates a transport failure from the Storage gateway", () => + it.effect("propagates a transport failure from the Storage gateway", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", @@ -1013,7 +1025,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("appends Go's port-conflict hint on a malformed local response", () => + it.effect("appends Go's port-conflict hint on a malformed local response", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[api]\nport = 7654\n[storage.buckets.test]\npublic = true\n", @@ -1039,7 +1051,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("omits the port-conflict hint on a connection-refused local failure", () => + it.effect("omits the port-conflict hint on a connection-refused local failure", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { // Stack simply stopped → ECONNREFUSED. The local-gateway hint only fires @@ -1055,7 +1067,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("reports the external_url port (not api.port) in the local hint", () => + it.effect("reports the external_url port (not api.port) in the local hint", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { // external_url overrides the host:port the gateway targets, so the hint @@ -1080,7 +1092,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("omits the port-conflict hint for a non-loopback external_url", () => + it.effect("omits the port-conflict hint for a non-loopback external_url", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: '[api]\nexternal_url = "http://gateway.test:9999"\n[storage.buckets.test]\npublic = true\n', @@ -1094,7 +1106,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("omits the port-conflict hint on a --linked (remote) transport failure", () => + it.effect("omits the port-conflict hint on a --linked (remote) transport failure", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", @@ -1114,7 +1126,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails when a bucket create returns a non-object body (Go ParseJSON)", () => + it.effect("fails when a bucket create returns a non-object body (Go ParseJSON)", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", @@ -1132,7 +1144,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("skips vector seeding when enabled but no vector buckets are configured", () => + it.effect("skips vector seeding when enabled but no vector buckets are configured", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.buckets.images]\npublic = true\n", @@ -1148,7 +1160,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("falls back to the default host when external_url is empty", () => + it.effect("falls back to the default host when external_url is empty", () => seedScenario( // Clear both host overrides so getHostname resolves to loopback // deterministically, regardless of the test environment's DOCKER_HOST. @@ -1175,7 +1187,7 @@ describe("seed buckets", () => { ), ); - it.live("calls the gateway on the SUPABASE_API_PORT override, not the config.toml port", () => + it.effect("calls the gateway on the SUPABASE_API_PORT override, not the config.toml port", () => seedScenario( withEnvVar( "SUPABASE_API_PORT", @@ -1197,7 +1209,7 @@ describe("seed buckets", () => { ), ); - it.live("honors a SUPABASE_API_PORT set only in supabase/.env", () => + it.effect("honors a SUPABASE_API_PORT set only in supabase/.env", () => Effect.gen(function* () { // The dotenv walk participates in the override, same as the other // `projectEnvValues` consumers — no ambient env needed. @@ -1216,7 +1228,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("uses SUPABASE_SERVICES_HOSTNAME set only in supabase/.env", () => + it.effect("uses SUPABASE_SERVICES_HOSTNAME set only in supabase/.env", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", @@ -1233,24 +1245,26 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("switches the gateway to https when SUPABASE_API_TLS_ENABLED overrides the config", () => - Effect.gen(function* () { - const { layer, requests } = yield* setupSeedBuckets(tmp.current, { - toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", - files: { "supabase/.env": "SUPABASE_API_TLS_ENABLED=true\n" }, - routes: [ - { method: "GET", match: "/storage/v1/bucket", body: [] }, - { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, - ], - }); - const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(requests.length).toBeGreaterThan(0); - expect(requests.every((r) => r.url.startsWith("https:"))).toBe(true); - }).pipe(seedScenario), + it.effect( + "switches the gateway to https when SUPABASE_API_TLS_ENABLED overrides the config", + () => + Effect.gen(function* () { + const { layer, requests } = yield* setupSeedBuckets(tmp.current, { + toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + files: { "supabase/.env": "SUPABASE_API_TLS_ENABLED=true\n" }, + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => r.url.startsWith("https:"))).toBe(true); + }).pipe(seedScenario), ); - it.live("rejects SUPABASE_API_PORT=0 with the canonical missing-field error", () => + it.effect("rejects SUPABASE_API_PORT=0 with the canonical missing-field error", () => Effect.gen(function* () { // `api.enabled` with a zero port is invalid config (`validateResolvedConfig`); // the override must not smuggle a zero port into the gateway URL. @@ -1268,7 +1282,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("allows a zero api.port when the API is disabled, matching config validation", () => + it.effect("allows a zero api.port when the API is disabled, matching config validation", () => Effect.gen(function* () { // The canonical zero-port rejection is gated on `api.enabled` // (`validateResolvedConfig`); a disabled API with port 0 proceeds. @@ -1286,7 +1300,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("hard-fails on a malformed SUPABASE_API_PORT override before any gateway call", () => + it.effect("hard-fails on a malformed SUPABASE_API_PORT override before any gateway call", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", @@ -1304,7 +1318,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("honors a SUPABASE_API_EXTERNAL_URL set only in supabase/.env", () => + it.effect("honors a SUPABASE_API_EXTERNAL_URL set only in supabase/.env", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", @@ -1321,7 +1335,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("lets an external_url override win over a port override", () => + it.effect("lets an external_url override win over a port override", () => Effect.gen(function* () { // `resolveApiExternalUrl`: a non-empty external_url short-circuits // the scheme://host:port derivation, so the port override is inert here. @@ -1343,28 +1357,30 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("turns TLS cert validation on when SUPABASE_API_ENABLED=true overrides the config", () => - Effect.gen(function* () { - // The override works in both directions: a config with `enabled = false` - // skips the cert/key pairing check, so flipping it on via env must restore - // the established missing-field rejection. - const { layer, requests } = yield* setupSeedBuckets(tmp.current, { - toml: '[api]\nenabled = false\n[api.tls]\nenabled = true\ncert_path = "kong.crt"\n[storage.buckets.images]\npublic = true\n', - files: { "supabase/.env": "SUPABASE_API_ENABLED=true\n" }, - routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], - }); - const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain( - "Missing required field in config: api.tls.key_path", - ); - } - expect(requests).toHaveLength(0); - }).pipe(seedScenario), + it.effect( + "turns TLS cert validation on when SUPABASE_API_ENABLED=true overrides the config", + () => + Effect.gen(function* () { + // The override works in both directions: a config with `enabled = false` + // skips the cert/key pairing check, so flipping it on via env must restore + // the established missing-field rejection. + const { layer, requests } = yield* setupSeedBuckets(tmp.current, { + toml: '[api]\nenabled = false\n[api.tls]\nenabled = true\ncert_path = "kong.crt"\n[storage.buckets.images]\npublic = true\n', + files: { "supabase/.env": "SUPABASE_API_ENABLED=true\n" }, + routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], + }); + const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "Missing required field in config: api.tls.key_path", + ); + } + expect(requests).toHaveLength(0); + }).pipe(seedScenario), ); - it.live("skips TLS cert validation when SUPABASE_API_ENABLED=false overrides the config", () => + it.effect("skips TLS cert validation when SUPABASE_API_ENABLED=false overrides the config", () => Effect.gen(function* () { // cert_path without key_path fails validation when the gate is on; the // env-overridden `api.enabled` must switch that gate off, exactly like the @@ -1385,7 +1401,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("reads TLS cert/key paths supplied through env overrides", () => + it.effect("reads TLS cert/key paths supplied through env overrides", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[api.tls]\nenabled = true\n[storage.buckets.images]\npublic = true\n", @@ -1407,7 +1423,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails on an env-supplied cert path without a key path", () => + it.effect("fails on an env-supplied cert path without a key path", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[api.tls]\nenabled = true\n[storage.buckets.images]\npublic = true\n", @@ -1425,7 +1441,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("hard-fails on a malformed SUPABASE_API_TLS_ENABLED override", () => + it.effect("hard-fails on a malformed SUPABASE_API_TLS_ENABLED override", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", @@ -1443,7 +1459,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("sends a SUPABASE_AUTH_SERVICE_ROLE_KEY set only in supabase/.env as the api key", () => + it.effect("sends a SUPABASE_AUTH_SERVICE_ROLE_KEY set only in supabase/.env as the api key", () => Effect.gen(function* () { // The auth vars go through the same env/dotenv override composition as the // SUPABASE_API_* family. @@ -1462,7 +1478,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("derives the api key from a SUPABASE_AUTH_JWT_SECRET set only in supabase/.env", () => + it.effect("derives the api key from a SUPABASE_AUTH_JWT_SECRET set only in supabase/.env", () => Effect.gen(function* () { const secret = "a-dotenv-only-secret-at-least-16-chars"; const { layer, requests } = yield* setupSeedBuckets(tmp.current, { @@ -1481,7 +1497,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("decrypts an encrypted: service_role_key with the private key from supabase/.env", () => + it.effect("decrypts an encrypted: service_role_key with the private key from supabase/.env", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: `[auth]\nservice_role_key = "${VAULT_ENCRYPTED}"\n[storage.buckets.images]\npublic = true\n`, @@ -1498,7 +1514,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("hard-fails an undecryptable encrypted: service_role_key before any gateway call", () => + it.effect("hard-fails an undecryptable encrypted: service_role_key before any gateway call", () => Effect.gen(function* () { // An undecryptable `encrypted:` value aborts instead of being sent as // literal key material. @@ -1515,7 +1531,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("hard-fails an undecryptable encrypted: jwt_secret before any gateway call", () => + it.effect("hard-fails an undecryptable encrypted: jwt_secret before any gateway call", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: '[auth]\njwt_secret = "encrypted:not-a-real-ciphertext"\n[storage.buckets.images]\npublic = true\n', @@ -1530,7 +1546,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("tolerates bucket entries with a missing field (Go zero value)", () => + it.effect("tolerates bucket entries with a missing field (Go zero value)", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", @@ -1547,7 +1563,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails on a malformed bucket-list response before any mutation", () => + it.effect("fails on a malformed bucket-list response before any mutation", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", @@ -1571,7 +1587,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails on a non-array bucket-list response (misrouted gateway)", () => + it.effect("fails on a non-array bucket-list response (misrouted gateway)", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", @@ -1586,7 +1602,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("treats a non-200 2xx gateway response as an error (Go expects exactly 200)", () => + it.effect("treats a non-200 2xx gateway response as an error (Go expects exactly 200)", () => Effect.gen(function* () { const { layer } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", @@ -1603,7 +1619,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live( + it.effect( "trusts the Kong CA for an explicit https external_url even when tls.enabled is false", () => Effect.gen(function* () { @@ -1622,7 +1638,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("builds an https base URL with a host override when tls is enabled", () => + it.effect("builds an https base URL with a host override when tls is enabled", () => seedScenario( withEnvVar( "SUPABASE_SERVICES_HOSTNAME", @@ -1643,7 +1659,7 @@ describe("seed buckets", () => { ), ); - it.live("brackets an IPv6 local host when building the gateway URL", () => + it.effect("brackets an IPv6 local host when building the gateway URL", () => seedScenario( withEnvVar( "SUPABASE_SERVICES_HOSTNAME", @@ -1665,7 +1681,7 @@ describe("seed buckets", () => { ), ); - it.live("falls back to the TCP Docker daemon host when only DOCKER_HOST is set", () => + it.effect("falls back to the TCP Docker daemon host when only DOCKER_HOST is set", () => seedScenario( withEnvVar( "SUPABASE_SERVICES_HOSTNAME", @@ -1721,7 +1737,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("skips a dangling symlink without failing (Go isUploadableEntry parity)", () => + it.effect("skips a dangling symlink without failing (Go isUploadableEntry parity)", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1748,7 +1764,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("skips OS metadata files during the object walk (CLI-1950)", () => + it.effect("skips OS metadata files during the object walk (CLI-1950)", () => Effect.gen(function* () { // These files must never even be attempted for upload — covering the // "silently becomes a public object" failure mode, not just an @@ -1782,7 +1798,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live( + it.effect( "skips a .DS_Store file in a MIME-restricted bucket instead of uploading it (CLI-1950)", () => Effect.gen(function* () { @@ -1825,7 +1841,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("skips a .DS_Store file when objects_path points directly at it (CLI-1950)", () => + it.effect("skips a .DS_Store file when objects_path points directly at it (CLI-1950)", () => Effect.gen(function* () { // Covers collectFiles' single-file branch: objects_path resolves directly to // a junk-named file rather than a directory. @@ -1851,7 +1867,7 @@ describe("seed buckets", () => { // Root bypasses POSIX permission bits, so chmod 000 wouldn't block open() there // and the open-vs-stat distinction this test relies on would vanish. const isRoot = typeof process.getuid === "function" && process.getuid() === 0; - it.live.skipIf(isRoot)( + it.effect.skipIf(isRoot)( "skips a symlink to an unreadable regular file and keeps seeding siblings (Go opens, not stats)", () => Effect.gen(function* () { @@ -1894,40 +1910,42 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("does not descend into a symlinked directory (Go does not follow nested symlinks)", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(path.join(tmp.current, "supabase", "assets", "realdir"), { - recursive: true, - }); - yield* fs.writeFileString(path.join(tmp.current, "supabase", "assets", "a.txt"), "hello"); - yield* fs.writeFileString( - path.join(tmp.current, "supabase", "assets", "realdir", "c.txt"), - "world", - ); - yield* fs.symlink("./realdir", path.join(tmp.current, "supabase", "assets", "linkdir")); - const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { - toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', - routes: [ - { method: "GET", match: "/storage/v1/bucket", body: [] }, - { method: "POST", match: "/storage/v1/object/", body: {} }, - { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, - ], - }); - const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain("Skipping non-regular file: supabase/assets/linkdir"); - expect(out.stderrText).toContain( - "Uploading: supabase/assets/realdir/c.txt => images/realdir/c.txt", - ); - expect(out.stderrText).not.toContain("supabase/assets/linkdir/c.txt"); - const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); - expect(uploads).toHaveLength(2); - }).pipe(seedScenario), + it.effect( + "does not descend into a symlinked directory (Go does not follow nested symlinks)", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase", "assets", "realdir"), { + recursive: true, + }); + yield* fs.writeFileString(path.join(tmp.current, "supabase", "assets", "a.txt"), "hello"); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "assets", "realdir", "c.txt"), + "world", + ); + yield* fs.symlink("./realdir", path.join(tmp.current, "supabase", "assets", "linkdir")); + const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { + toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/object/", body: {} }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, + ], + }); + const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Skipping non-regular file: supabase/assets/linkdir"); + expect(out.stderrText).toContain( + "Uploading: supabase/assets/realdir/c.txt => images/realdir/c.txt", + ); + expect(out.stderrText).not.toContain("supabase/assets/linkdir/c.txt"); + const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); + expect(uploads).toHaveLength(2); + }).pipe(seedScenario), ); - it.live("follows a symlinked objects_path root and uploads its files (Go fs.WalkDir)", () => + it.effect("follows a symlinked objects_path root and uploads its files (Go fs.WalkDir)", () => Effect.gen(function* () { // A symlinked root is followed and its target walked; only nested // symlinks are skipped. @@ -1957,7 +1975,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--yes overwrites an existing bucket and echoes Go's prompt line", () => + it.effect("--yes overwrites an existing bucket and echoes Go's prompt line", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.assets]\npublic = true\n", @@ -1983,7 +2001,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live( + it.effect( "auto-confirms the overwrite from SUPABASE_YES in the project .env (Go loadNestedEnv, CLI-1878)", () => Effect.gen(function* () { @@ -2012,7 +2030,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--yes prunes a stale vector bucket and echoes Go's prompt line", () => + it.effect("--yes prunes a stale vector bucket and echoes Go's prompt line", () => Effect.gen(function* () { const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.vec1]\n", @@ -2036,7 +2054,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--linked seeds the remote storage project", () => + it.effect("--linked seeds the remote storage project", () => Effect.gen(function* () { const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { @@ -2064,35 +2082,39 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--project-ref --linked seeds the project given by the flag, overriding VALID_REF", () => - Effect.gen(function* () { - // `opts.projectRef` (the fake's own fallback) is left at its default - // (VALID_REF) — the flag must win over it and drive the storage - // gateway host. - const FLAG_REF = "flagflagflagflagflag"; - const { layer, out, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { - toml: "[storage.buckets.test]\npublic = true\n", - args: ["seed", "buckets", "--linked"], - routes: [ - { method: "GET", match: "/storage/v1/bucket", body: [] }, - { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, - ], - }); - const exit = yield* seedBuckets({ - linked: true, - local: false, - projectRef: Option.some(FLAG_REF), - }).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain("Creating Storage bucket: test"); - expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe(true); - expect(requests.some((r) => r.url.includes(VALID_REF))).toBe(false); - expect(linkedCache.cached).toBe(true); - expect(linkedCache.cachedRef).toBe(FLAG_REF); - }).pipe(seedScenario), + it.effect( + "--project-ref --linked seeds the project given by the flag, overriding VALID_REF", + () => + Effect.gen(function* () { + // `opts.projectRef` (the fake's own fallback) is left at its default + // (VALID_REF) — the flag must win over it and drive the storage + // gateway host. + const FLAG_REF = "flagflagflagflagflag"; + const { layer, out, requests, linkedCache } = yield* setupSeedBuckets(tmp.current, { + toml: "[storage.buckets.test]\npublic = true\n", + args: ["seed", "buckets", "--linked"], + routes: [ + { method: "GET", match: "/storage/v1/bucket", body: [] }, + { method: "POST", match: "/storage/v1/bucket", body: { name: "test" } }, + ], + }); + const exit = yield* seedBuckets({ + linked: true, + local: false, + projectRef: Option.some(FLAG_REF), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Creating Storage bucket: test"); + expect(requests.some((r) => r.url.startsWith(`https://${FLAG_REF}.supabase.co`))).toBe( + true, + ); + expect(requests.some((r) => r.url.includes(VALID_REF))).toBe(false); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(FLAG_REF); + }).pipe(seedScenario), ); - it.live("rejects --project-ref on the default local target", () => + it.effect("rejects --project-ref on the default local target", () => Effect.gen(function* () { // seed buckets defaults to local when no target flag is set — the guard // must fire from the flag alone, with no explicit --local needed. @@ -2116,7 +2138,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--linked=false still takes the linked path (Go flag.Changed, not value)", () => + it.effect("--linked=false still takes the linked path (Go flag.Changed, not value)", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", @@ -2139,7 +2161,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--local=false stays on the local path", () => + it.effect("--local=false stays on the local path", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", @@ -2163,7 +2185,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--linked fails before any Storage call when the api-keys list is empty", () => + it.effect("--linked fails before any Storage call when the api-keys list is empty", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", @@ -2185,7 +2207,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--linked surfaces tenant.GetApiKeys auth error on a non-200 api-keys response", () => + it.effect("--linked surfaces tenant.GetApiKeys auth error on a non-200 api-keys response", () => Effect.gen(function* () { // A non-200 api-keys response maps to `StorageAuthTokenError` with // "Authorization failed for the access token and project ref pair", not @@ -2215,7 +2237,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("caches the linked project on --linked but not on local", () => + it.effect("caches the linked project on --linked but not on local", () => Effect.gen(function* () { // Gated on a non-empty resolved ref: --linked writes the linked-project // cache and group identify; the local path does not. @@ -2247,7 +2269,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--linked uses SUPABASE_AUTH_SERVICE_ROLE_KEY env var when set", () => + it.effect("--linked uses SUPABASE_AUTH_SERVICE_ROLE_KEY env var when set", () => seedScenario( withEnvVar( "SUPABASE_AUTH_SERVICE_ROLE_KEY", @@ -2271,7 +2293,7 @@ describe("seed buckets", () => { ), ); - it.live("--linked falls back to the project's service-role key when the env var is empty", () => + it.effect("--linked falls back to the project's service-role key when the env var is empty", () => seedScenario( withEnvVar( "SUPABASE_AUTH_SERVICE_ROLE_KEY", @@ -2296,7 +2318,7 @@ describe("seed buckets", () => { ), ); - it.live("upserts analytics buckets when analytics.enabled and --linked", () => + it.effect("upserts analytics buckets when analytics.enabled and --linked", () => Effect.gen(function* () { const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { @@ -2326,7 +2348,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("does not upsert analytics buckets on local runs", () => + it.effect("does not upsert analytics buckets on local runs", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ @@ -2347,7 +2369,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("prunes a stale analytics bucket when the prompt is accepted", () => + it.effect("prunes a stale analytics bucket when the prompt is accepted", () => Effect.gen(function* () { const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { @@ -2386,7 +2408,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--linked fails when the project is not linked", () => + it.effect("--linked fails when the project is not linked", () => Effect.gen(function* () { const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer } = yield* setupSeedBuckets(tmp.current, { @@ -2400,7 +2422,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("succeeds on the TLS local path and uses an https base URL", () => + it.effect("succeeds on the TLS local path and uses an https base URL", () => seedScenario( // The mock replaces HttpClient.HttpClient directly (bypassing fetch), so // real TLS cert verification isn't exercised here — this only confirms the @@ -2425,7 +2447,7 @@ describe("seed buckets", () => { ), ); - it.live("reads cert_path and key_path from disk when both api.tls paths are set", () => + it.effect("reads cert_path and key_path from disk when both api.tls paths are set", () => Effect.gen(function* () { // Writes a dummy CA PEM and key to disk; both must be present and readable // for the handler to succeed. @@ -2451,7 +2473,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live( + it.effect( "re-roots an absolute cert_path/key_path under supabase/ (Go path.Join, no IsAbs guard)", () => Effect.gen(function* () { @@ -2486,7 +2508,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("--linked merges [remotes.*] storage config override before seeding", () => + it.effect("--linked merges [remotes.*] storage config override before seeding", () => Effect.gen(function* () { // The remote block overrides `base.public` to false and adds a `remote` // bucket; `mergeRemoteConfig` merges subtrees recursively rather than @@ -2523,7 +2545,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("local run uses base config (no [remotes.*] merge)", () => + it.effect("local run uses base config (no [remotes.*] merge)", () => Effect.gen(function* () { const remoteRef = VALID_REF; const { layer, out, requests } = yield* setupSeedBuckets(tmp.current, { @@ -2551,7 +2573,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails with exact error message on an invalid bucket name", () => + it.effect("fails with exact error message on an invalid bucket name", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { // "good-name" is valid; "bad/name" contains "/" which is not in the allowed set. @@ -2577,7 +2599,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("accepts valid bucket names that use allowed special characters", () => + it.effect("accepts valid bucket names that use allowed special characters", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: [ @@ -2599,7 +2621,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("local run: SUPABASE_AUTH_JWT_SECRET overrides auth.jwt_secret", () => + it.effect("local run: SUPABASE_AUTH_JWT_SECRET overrides auth.jwt_secret", () => seedScenario( // Use a custom secret; the derived JWT will differ from the default secret's JWT. withEnvVar( @@ -2632,7 +2654,7 @@ describe("seed buckets", () => { ), ); - it.live("local run: SUPABASE_AUTH_SERVICE_ROLE_KEY overrides auth.service_role_key", () => + it.effect("local run: SUPABASE_AUTH_SERVICE_ROLE_KEY overrides auth.service_role_key", () => seedScenario( withEnvVar( "SUPABASE_AUTH_SERVICE_ROLE_KEY", @@ -2664,7 +2686,7 @@ describe("seed buckets", () => { ), ); - it.live("fails when cert_path is set but key_path is missing", () => + it.effect("fails when cert_path is set but key_path is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -2688,7 +2710,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails when key_path is set but cert_path is missing", () => + it.effect("fails when key_path is set but cert_path is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -2712,7 +2734,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails when cert_path points to an unreadable file", () => + it.effect("fails when cert_path points to an unreadable file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -2730,7 +2752,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("fails when key_path points to an unreadable file", () => + it.effect("fails when key_path points to an unreadable file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -2753,7 +2775,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live("skips TLS validation when api.enabled is false (Go gates on c.Api.Enabled)", () => + it.effect("skips TLS validation when api.enabled is false (Go gates on c.Api.Enabled)", () => Effect.gen(function* () { // Cert/key pairing is validated only when api.enabled is true, so a config // with api.enabled=false and only cert_path set must not fail on the @@ -2773,7 +2795,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live( + it.effect( "fails before the api-keys fetch when --workdir names a config-less subdirectory of a real ancestor project", () => Effect.gen(function* () { @@ -2802,7 +2824,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live( + it.effect( "an explicit --workdir naming a directory that does not exist at all fails before any credential resolution", () => Effect.gen(function* () { @@ -2820,7 +2842,7 @@ describe("seed buckets", () => { }).pipe(seedScenario), ); - it.live( + it.effect( "seedBucketsRun succeeds with a caller-supplied resolvedConfig even when cliSettings.explicitWorkdir is true", () => Effect.gen(function* () { @@ -2866,7 +2888,7 @@ describe("seed buckets", () => { describe("stack backend", () => { const tmp = useTempWorkdir("supabase-seed-buckets-stack-"); - it.live("creates buckets and uploads objects through the stack's api endpoint and JWT", () => + it.effect("creates buckets and uploads objects through the stack's api endpoint and JWT", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', @@ -2894,7 +2916,7 @@ describe("stack backend", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "short-circuits with the empty summary despite a malformed SUPABASE_API_PORT, since the stack backend never reads it", () => Effect.gen(function* () { @@ -2912,7 +2934,7 @@ describe("stack backend", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("the same malformed SUPABASE_API_PORT still hard-fails under the legacy backend", () => + it.effect("the same malformed SUPABASE_API_PORT still hard-fails under the legacy backend", () => Effect.gen(function* () { const { layer, requests } = yield* setupSeedBuckets(tmp.current, { toml: 'project_id = "test"\n', @@ -2929,7 +2951,7 @@ describe("stack backend", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with StackStorageCapabilityError when Storage is disabled, before any request", () => Effect.gen(function* () { @@ -2949,7 +2971,7 @@ describe("stack backend", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with StackStorageUnavailableError when no stack is registered for the project", () => Effect.gen(function* () { diff --git a/apps/cli/src/commands/services/services.handler.ts b/apps/cli/src/commands/services/services.handler.ts index c19092ff51..1b93ca1823 100644 --- a/apps/cli/src/commands/services/services.handler.ts +++ b/apps/cli/src/commands/services/services.handler.ts @@ -76,17 +76,12 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) // Warns on a ref-file read error, but treats a NotFound race between the // exists() check and this read as simply unlinked (silent, no warning). // Only the "failed to load project ref: " prefix is compatibility-bearing. - const content = yield* fs - .readFileString(projectRefPath) - .pipe( - Effect.catch((cause) => - cause._tag === "PlatformError" && cause.reason._tag === "NotFound" - ? Effect.succeed("") - : output - .raw(`failed to load project ref: ${String(cause)}\n`, "stderr") - .pipe(Effect.as("")), - ), - ); + const content = yield* fs.readFileString(projectRefPath).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed("")), + Effect.catch((cause) => + output.raw(`failed to load project ref: ${String(cause)}\n`, "stderr").pipe(Effect.as("")), + ), + ); const trimmed = content.trim(); return trimmed.length === 0 ? Option.none() : Option.some(trimmed); }); @@ -147,8 +142,8 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) Option.getOrUndefined(linkedProjectRef), ).pipe( Effect.withSpan("services.readConfig"), - Effect.catch((error) => - output.raw(`${formatConfigLoadError(error)}\n`, "stderr").pipe(Effect.as(null)), + Effect.catchTag("DbConfigLoadError", (error) => + output.raw(`${error.message}\n`, "stderr").pipe(Effect.as(null)), ), ); const serviceVersions = @@ -252,7 +247,3 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) yield* output.raw(renderServicesTable(rows)); }).pipe(Effect.ensuring(cacheLinkedProject), Effect.ensuring(telemetryState.flush)); }); - -function formatConfigLoadError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index 58c5c555d3..1f713996e8 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -239,7 +239,7 @@ function expectFailureTag(exit: Exit.Exit, tag: string) { } describe("services", () => { - it.live("surfaces credential storage permission failures", () => { + it.effect("surfaces credential storage permission failures", () => { const { layer } = setup({ accessTokenFailure: PlatformError.systemError({ _tag: "PermissionDenied", @@ -299,7 +299,7 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("prints the services table by default", () => { + it.effect("prints the services table by default", () => { const { layer, out } = setup(); return Effect.gen(function* () { @@ -312,7 +312,7 @@ describe("services", () => { }); }); - it.live("emits a services JSON array for --output json", () => { + it.effect("emits a services JSON array for --output json", () => { const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { @@ -327,7 +327,7 @@ describe("services", () => { }); }); - it.live("reports the configured Postgres version for local projects", () => + it.effect("reports the configured Postgres version for local projects", () => Effect.gen(function* () { const workdir = yield* makeProjectWithDbMajorVersion(15); const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); @@ -344,7 +344,7 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("uses the stack artifact catalog when the stack backend is selected", () => + it.effect("uses the stack artifact catalog when the stack backend is selected", () => Effect.gen(function* () { const workdir = yield* makeProjectWithDbMajorVersion(15); const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); @@ -376,7 +376,7 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("applies the stack PostgreSQL environment override", () => + it.effect("applies the stack PostgreSQL environment override", () => Effect.gen(function* () { const workdir = yield* makeProjectWithDbMajorVersion(17); const fs = yield* FileSystem.FileSystem; @@ -399,7 +399,7 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("reports the pinned OrioleDB build selected by SUPABASE_DB_ORIOLEDB_VERSION", () => + it.effect("reports the pinned OrioleDB build selected by SUPABASE_DB_ORIOLEDB_VERSION", () => Effect.gen(function* () { const workdir = yield* makeProjectWithDbMajorVersion(17); const fs = yield* FileSystem.FileSystem; @@ -423,7 +423,7 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "falls back to stack catalog defaults for unsupported database config and ignores legacy pins", () => Effect.gen(function* () { @@ -452,7 +452,7 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("reports malformed config causes before falling back to the stack catalog", () => + it.effect("reports malformed config causes before falling back to the stack catalog", () => Effect.gen(function* () { const workdir = yield* makeProjectWithConfig("[db]\nmajor_version = "); const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); @@ -471,7 +471,7 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("ignores config.json and reads legacy config.toml for local image selection", () => + it.effect("ignores config.json and reads legacy config.toml for local image selection", () => Effect.gen(function* () { const workdir = yield* makeProjectWithConfigFiles({ toml: "[db]\nmajor_version = 15\n", @@ -491,7 +491,7 @@ describe("services", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("applies linked-project remote config overrides when choosing the local image", () => + it.effect("applies linked-project remote config overrides when choosing the local image", () => Effect.gen(function* () { const workdir = yield* makeProjectWithConfig(` [db] @@ -518,7 +518,7 @@ major_version = 15 }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("warns and skips the remote lookup for a malformed linked project ref", () => + it.effect("warns and skips the remote lookup for a malformed linked project ref", () => Effect.gen(function* () { const workdir = yield* makeWorkdir(); yield* writeTempFile(workdir, "project-ref", "not-a-valid-ref"); @@ -534,7 +534,7 @@ major_version = 15 // A token present doesn't bypass the format guard (the warning is // unconditional on login too) — same code path as the previous test, so this // isn't new branch coverage, just pinning that login state can't skip it. - it.live("still warns on a malformed ref even when logged in", () => + it.effect("still warns on a malformed ref even when logged in", () => Effect.gen(function* () { const workdir = yield* makeWorkdir(); yield* writeTempFile(workdir, "project-ref", "not-a-valid-ref"); @@ -657,7 +657,7 @@ major_version = 15 }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("reports pinned legacy temp service versions", () => + it.effect("reports pinned legacy temp service versions", () => Effect.gen(function* () { const workdir = yield* makeProjectWithDbMajorVersion(15); yield* writeTempFile(workdir, "postgres-version", "15.1.0.117\n"); @@ -678,7 +678,7 @@ major_version = 15 }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("reports the Deno 1 edge-runtime image instead of the temp pin", () => + it.effect("reports the Deno 1 edge-runtime image instead of the temp pin", () => Effect.gen(function* () { const workdir = yield* makeProjectWithConfig("[edge_runtime]\ndeno_version = 1\n"); yield* writeTempFile(workdir, "edge-runtime-version", "v9.9.9\n"); @@ -696,7 +696,7 @@ major_version = 15 }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("prints config load errors and falls back to the default matrix", () => + it.effect("prints config load errors and falls back to the default matrix", () => Effect.gen(function* () { const workdir = yield* makeProjectWithConfig("[db]\nmajor_version = "); yield* writeTempFile(workdir, "storage-version", "v9.9.9\n"); @@ -710,7 +710,7 @@ major_version = 15 }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("emits structured JSON for --output pretty combined with --output-format json", () => { + it.effect("emits structured JSON for --output pretty combined with --output-format json", () => { // --output pretty defers to --output-format json instead of forcing the // human-readable table. const { layer, out } = setup({ format: "json", goOutput: Option.some("pretty") }); @@ -730,7 +730,7 @@ major_version = 15 }); }); - it.live("emits structured JSON for --output-format stream-json", () => { + it.effect("emits structured JSON for --output-format stream-json", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { @@ -748,7 +748,7 @@ major_version = 15 }); }); - it.live("emits a TOML services array for --output toml", () => { + it.effect("emits a TOML services array for --output toml", () => { const { layer, out } = setup({ goOutput: Option.some("toml") }); return Effect.gen(function* () { @@ -761,7 +761,7 @@ major_version = 15 }); }); - it.live("emits a YAML services array for --output yaml", () => { + it.effect("emits a YAML services array for --output yaml", () => { const { layer, out } = setup({ goOutput: Option.some("yaml") }); return Effect.gen(function* () { @@ -772,7 +772,7 @@ major_version = 15 }); }); - it.live("rejects --output env", () => { + it.effect("rejects --output env", () => { const { layer } = setup({ goOutput: Option.some("env") }); return Effect.gen(function* () { @@ -781,7 +781,7 @@ major_version = 15 }); }); - it.live("warns to stderr when the project-ref file exists but cannot be read", () => + it.effect("warns to stderr when the project-ref file exists but cannot be read", () => // A directory at the ref path makes `exists()` true but `readFileString()` // fail (EISDIR), exercising the read-error branch distinct from "file absent". Effect.gen(function* () { @@ -800,7 +800,7 @@ major_version = 15 }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("flushes telemetry state after the command finishes", () => { + it.effect("flushes telemetry state after the command finishes", () => { const { layer, telemetry } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/services/services.layers.unit.test.ts b/apps/cli/src/commands/services/services.layers.unit.test.ts index c2bffae114..211e01f742 100644 --- a/apps/cli/src/commands/services/services.layers.unit.test.ts +++ b/apps/cli/src/commands/services/services.layers.unit.test.ts @@ -76,7 +76,7 @@ function ambientStubs() { } describe("servicesRuntimeLayer — IdentityStitch exposure", () => { - it.live( + it.effect( "exposes IdentityStitch at top level so withCommandTelemetry can read stitchedDistinctId()", () => { return Effect.gen(function* () { diff --git a/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts index 439b23f9d0..806e71b171 100644 --- a/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts +++ b/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts @@ -3,9 +3,9 @@ import { FileSystem, Path } from "effect"; import { expect, layer } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { edgeRuntimeNofileUlimit } from "../../../shared/stack-constants.ts"; -import { ConfigProvider, Deferred, Effect, Exit, Sink, Stream } from "effect"; -import { type ChildProcess, ChildProcessSpawner } from "effect/process"; -import { afterEach, beforeEach, vi } from "vitest"; +import { ConfigProvider, Effect, Exit, Sink, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { afterEach, assert, beforeEach, vi } from "vitest"; import { useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; @@ -32,23 +32,22 @@ function mockDockerSpawner( const encoder = new TextEncoder(); const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const args = command._tag === "StandardCommand" ? command.args : []; + Effect.sync(() => { + const standard = ChildProcess.isStandardCommand(command); + const args = standard ? command.args : []; calls.push({ args, - stdin: command._tag === "StandardCommand" ? command.options.stdin : undefined, + stdin: standard ? command.options.stdin : undefined, }); const result = handler?.(args) ?? { exitCode: 0 }; - const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), stdout: Stream.empty, stderr: result.stderr === undefined ? Stream.empty : Stream.make(encoder.encode(result.stderr)), all: Stream.empty, - exitCode: Deferred.await(exitDeferred), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), isRunning: Effect.succeed(false), stdin: Sink.drain, kill: () => Effect.void, @@ -66,7 +65,9 @@ function mockDockerSpawner( }, get runCall() { // Excludes `docker volume create`, whose first arg is "volume", not "create". - return calls.find((call) => call.args[0] === "create"); + const runCall = calls.find((call) => call.args[0] === "create"); + assert(runCall !== undefined, "docker create was never spawned"); + return runCall; }, }; } @@ -107,8 +108,8 @@ const envEntries = Effect.fnUntraced(function* (runCall: { const fs = yield* FileSystem.FileSystem; const envFileArgIndex = runCall.args.indexOf("--env-file"); const envFilePath = runCall.args[envFileArgIndex + 1]; - expect(envFilePath).toBeDefined(); - return (yield* fs.readFileString(envFilePath!)).split("\n").filter((line) => line.length > 0); + assert(envFilePath !== undefined, "docker create has no --env-file value"); + return (yield* fs.readFileString(envFilePath)).split("\n").filter((line) => line.length > 0); }); layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { @@ -145,8 +146,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord({})), ); - expect(mock.runCall).toBeDefined(); - expect(mock.runCall!.args.join(" ")).not.toContain(":/root/.cache/deno"); + expect(mock.runCall.args.join(" ")).not.toContain(":/root/.cache/deno"); expect(mock.calls.some((call) => call.args[0] === "volume")).toBe(false); }), ); @@ -163,7 +163,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), ); - const entries = yield* envEntries(mock.runCall!); + const entries = yield* envEntries(mock.runCall); expect(entries).toContain( "SUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_proj:5432/postgres", ); @@ -180,7 +180,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), ); - const entries = yield* envEntries(mock.runCall!); + const entries = yield* envEntries(mock.runCall); expect(entries).toContain("SUPABASE_ANON_KEY=anon.jwt.value"); expect(entries).toContain("SUPABASE_SERVICE_ROLE_KEY=service-role.jwt.value"); expect(entries).toContain( @@ -205,7 +205,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), ); - const runCall = mock.runCall!; + const runCall = mock.runCall; expect(runCall.args).not.toContain("-p"); expect(runCall.args.join(" ")).not.toContain("--inspect"); }), @@ -223,7 +223,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), ); - const runCall = mock.runCall!; + const runCall = mock.runCall; const ulimitIndex = runCall.args.indexOf("--ulimit"); expect(runCall.args[ulimitIndex + 1]).toBe(edgeRuntimeNofileUlimit("darwin").arg); expect(out.messages.filter((message) => message.type === "warn")).toEqual([]); @@ -263,7 +263,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), ); - const args = mock.runCall!.args; + const args = mock.runCall.args; expect(args[args.indexOf("--workdir") + 1]).toBe(tempWorkdir.current); }), ); @@ -278,7 +278,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), ); - expect(mock.runCall!.args).not.toContain("--workdir"); + expect(mock.runCall.args).not.toContain("--workdir"); }), ); @@ -294,7 +294,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), ); - const runCall = mock.runCall!; + const runCall = mock.runCall; expect(runCall.args).toContain(`com.supabase.cli.workdir=${tempWorkdir.current}`); }), ); @@ -316,7 +316,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), ); - const runCall = mock.runCall!; + const runCall = mock.runCall; const networkIndex = runCall.args.indexOf("--network"); expect(runCall.args[networkIndex + 1]).toBe("custom_network_override"); expect(runCall.args).toContain("registry.example.com/supabase/edge-runtime:v1.99.9"); @@ -340,7 +340,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { .filter((step) => step === "create" || step === "cp" || step === "start"); expect(containerSteps).toEqual(["create", "cp", "start"]); - const createArgs = mock.runCall!.args; + const createArgs = mock.runCall.args; const bindValues = createArgs.flatMap((arg, index) => createArgs[index - 1] === "-v" ? [arg] : [], ); @@ -386,7 +386,7 @@ layer(BunServices.layer)("startStackEdgeRuntimeContainer", (it) => { Effect.provide(out.layer), ); - const createArgs = mock.runCall!.args; + const createArgs = mock.runCall.args; expect(createArgs).toContain("--entrypoint"); expect(createArgs).toContain("sh"); const script = createArgs.at(-1); diff --git a/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts b/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts index a0bce99ffb..be69be3650 100644 --- a/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { assert, describe, expect, test } from "vitest"; import { buildGotrueContainerSpec, @@ -635,7 +635,9 @@ describe("buildGotrueEnv", () => { describe("JWT signing keys", () => { test("defaults to Go's hardcoded ES256 signing key when unset", () => { const env = buildGotrueEnv(baseEnvInput); - const keys = JSON.parse(env["GOTRUE_JWT_KEYS"] as string); + const jwtKeys = env["GOTRUE_JWT_KEYS"]; + assert(jwtKeys !== undefined); + const keys = JSON.parse(jwtKeys); expect(keys).toEqual([ { kty: "EC", @@ -660,9 +662,9 @@ describe("buildGotrueEnv", () => { ...baseEnvInput, signingKeys: [rsaKey], }); - expect(JSON.parse(env["GOTRUE_JWT_KEYS"] as string)).toEqual([ - { kty: "RSA", alg: "RS256", n: "modulus", e: "AQAB" }, - ]); + const jwtKeys = env["GOTRUE_JWT_KEYS"]; + assert(jwtKeys !== undefined); + expect(JSON.parse(jwtKeys)).toEqual([{ kty: "RSA", alg: "RS256", n: "modulus", e: "AQAB" }]); }); }); }); diff --git a/apps/cli/src/commands/start/services/studio.service.unit.test.ts b/apps/cli/src/commands/start/services/studio.service.unit.test.ts index ef1679b822..c57429fd1a 100644 --- a/apps/cli/src/commands/start/services/studio.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/studio.service.unit.test.ts @@ -7,6 +7,7 @@ import { buildStudioContainerSpec, buildStudioEnv, type BuildStudioEnvInput, + type StudioContainerInput, } from "./studio.service.ts"; const baseEnvInput: BuildStudioEnvInput = { @@ -144,12 +145,12 @@ describe("buildStudioEnv", () => { }); describe("buildStudioContainerSpec", () => { - const baseSpecInput = { + const baseSpecInput: StudioContainerInput = { image: "supabase/studio:2026.07.07-sha-a6a04f2", containerName: "supabase_studio_proj", networkId: "supabase_network_proj", port: 54323, - functionBinds: [] as ReadonlyArray, + functionBinds: [], env: baseEnvInput, }; diff --git a/apps/cli/src/commands/start/services/vector.service.ts b/apps/cli/src/commands/start/services/vector.service.ts index 3fc411576b..1d7c714c01 100644 --- a/apps/cli/src/commands/start/services/vector.service.ts +++ b/apps/cli/src/commands/start/services/vector.service.ts @@ -151,12 +151,14 @@ export function resolveVectorDockerSocketPlan( } function collectText(stream: Stream.Stream) { - const decoder = new TextDecoder(); - return Stream.runFold( - stream, - () => "", - (text, chunk) => text + decoder.decode(chunk, { stream: true }), - ).pipe(Effect.map((text) => text + decoder.decode())); + return Effect.suspend(() => { + const decoder = new TextDecoder(); + return Stream.runFold( + stream, + () => "", + (text, chunk) => text + decoder.decode(chunk, { stream: true }), + ).pipe(Effect.map((text) => text + decoder.decode())); + }); } /** diff --git a/apps/cli/src/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/commands/start/services/vector.service.unit.test.ts index f13f253707..4a1868f808 100644 --- a/apps/cli/src/commands/start/services/vector.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/vector.service.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, test } from "@effect/vitest"; -import { Deferred, Effect, Sink, Stream } from "effect"; -import { ChildProcessSpawner } from "effect/process"; +import { Effect, Sink, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { buildVectorContainerSpec, @@ -22,14 +22,11 @@ function mockSpawner( const spawned: Array> = []; const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const args = command._tag === "StandardCommand" ? command.args : []; + Effect.sync(() => { + const args = ChildProcess.isStandardCommand(command) ? command.args : []; spawned.push(args); const result = handler(args); - const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode)); - return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), stdout: Stream.fromIterable( @@ -39,7 +36,7 @@ function mockSpawner( result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], ), all: Stream.empty, - exitCode: Deferred.await(exitDeferred), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), isRunning: Effect.succeed(false), stdin: Sink.drain, kill: () => Effect.void, @@ -304,7 +301,7 @@ describe("buildVectorContainerSpec", () => { }); describe("resolveDockerDaemonHost", () => { - it.live("prefers an explicit DOCKER_HOST env var over any context inspection", () => { + it.effect("prefers an explicit DOCKER_HOST env var over any context inspection", () => { const mock = mockSpawner(() => ({ exitCode: 1 })); return resolveDockerDaemonHost( mock.spawner, @@ -318,7 +315,7 @@ describe("resolveDockerDaemonHost", () => { ); }); - it.live("falls back to the current docker context's endpoint when DOCKER_HOST is unset", () => { + it.effect("falls back to the current docker context's endpoint when DOCKER_HOST is unset", () => { const mock = mockSpawner((args) => { if (args[0] === "context" && args[1] === "inspect") { return { exitCode: 0, stdout: "unix:///Users/me/.colima/default/docker.sock\n" }; @@ -332,7 +329,7 @@ describe("resolveDockerDaemonHost", () => { ); }); - it.live("falls back to the platform default when docker context inspect fails", () => { + it.effect("falls back to the platform default when docker context inspect fails", () => { const mock = mockSpawner(() => ({ exitCode: 1 })); return resolveDockerDaemonHost(mock.spawner, {}, "darwin").pipe( Effect.map((host) => { @@ -341,7 +338,7 @@ describe("resolveDockerDaemonHost", () => { ); }); - it.live( + it.effect( "falls back to the platform default when docker context inspect returns an empty host", () => { const mock = mockSpawner(() => ({ exitCode: 0, stdout: "\n" })); @@ -353,7 +350,7 @@ describe("resolveDockerDaemonHost", () => { }, ); - it.live( + it.effect( "treats an empty DOCKER_HOST value as unset, falling through to context inspection", () => { const mock = mockSpawner((args) => { diff --git a/apps/cli/src/commands/start/start.gates.ts b/apps/cli/src/commands/start/start.gates.ts index 3c5b21aecd..30ddbd6fea 100644 --- a/apps/cli/src/commands/start/start.gates.ts +++ b/apps/cli/src/commands/start/start.gates.ts @@ -1,4 +1,5 @@ import type { CliConfig } from "@supabase/config"; +import { Predicate } from "effect"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; import type { @@ -45,9 +46,7 @@ export interface StartGateInputs { } function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; + return Predicate.isObject(value) ? value : undefined; } /** diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 10aff73144..5d3c6133f0 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -5,7 +5,7 @@ import { BunPath } from "@effect/platform-bun"; import { inferFunctionsManifest } from "@supabase/config/effect"; import { resolveCliConfigSubtree } from "@supabase/config/internal"; -import { Effect, FileSystem, Option, Path, Result } from "effect"; +import { Effect, FileSystem, Option, Path, Predicate, Result } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { CLI_VERSION } from "../../shared/cli/version.ts"; @@ -188,9 +188,17 @@ import { buildSupavisorContainerSpec } from "./services/supavisor.service.ts"; const ANALYTICS_API_KEY = "api-key"; function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; + return Predicate.isObject(value) ? value : undefined; +} + +function tryStartConfig(thunk: () => T): Effect.Effect { + return Effect.try({ + try: thunk, + catch: (cause) => + new StartInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); } /** @@ -370,20 +378,15 @@ const resolveKongEmailTemplateMounts = Effect.fn("start.resolveEmailTemplateMoun name: string, contentPath: string, ) => - Effect.try({ - try: () => - resolveEmailTemplateContentPath({ - section, - name, - contentPath, - contentPresent: false, - base: workdir, - }), - catch: (cause) => - new StartInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + tryStartConfig(() => + resolveEmailTemplateContentPath({ + section, + name, + contentPath, + contentPresent: false, + base: workdir, + }), + ); const mounts: Array = []; for (const [id, template] of Object.entries(email.template)) { const resolvedPath = yield* resolveContentPath("template", id, template.content_path); @@ -451,38 +454,28 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { cliSettings.workdir, (message) => new StartConfigLoadError({ message }), ); - const values = yield* Effect.try({ - try: () => - resolveLocalConfigValues( - context.config, - context.hostname, - cliSettings.workdir, - context.projectEnvValues, - context.loaded?.document, - ), - catch: (cause) => - new StartInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const values = yield* tryStartConfig(() => + resolveLocalConfigValues( + context.config, + context.hostname, + cliSettings.workdir, + context.projectEnvValues, + context.loaded?.document, + ), + ); const { config, projectId, projectEnvValues } = context; // Resolved here (project `.env` aware) so it can be threaded through to `startDatabase`'s // own `setup.experimental` below. const experimental = yield* resolveExperimentalWithProjectEnv(projectEnvValues); // Single source resolved once, fed to both Kong's template mounts and GoTrue's env builder — // see {@link resolveAuthEmail}'s doc comment. - const resolvedEmail = yield* Effect.try({ - try: () => - resolveAuthEmail( - config.auth.email, - asRecord(context.loaded?.document?.["auth"]), - projectEnvValues, - ), - catch: (cause) => - new StartInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const resolvedEmail = yield* tryStartConfig(() => + resolveAuthEmail( + config.auth.email, + asRecord(context.loaded?.document?.["auth"]), + projectEnvValues, + ), + ); // Kong mounts every configured template and every enabled notification's `content_path`, // regardless of `auth.enabled` — see {@link resolveKongEmailTemplateMounts} for why this // resolves and verifies each path once, here, rather than re-deriving it before Kong's @@ -504,18 +497,13 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); // `resolveLocalConfigValues`'s own SMS validation only runs when auth is enabled, so this is // the only place a malformed `auth.sms.*` override is caught when auth is disabled. - const smsForValidation = yield* Effect.try({ - try: () => - resolveAuthSms( - asRecord(context.loaded?.document?.["auth"]), - config.auth.sms, - projectEnvValues, - ), - catch: (cause) => - new StartInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const smsForValidation = yield* tryStartConfig(() => + resolveAuthSms( + asRecord(context.loaded?.document?.["auth"]), + config.auth.sms, + projectEnvValues, + ), + ); yield* wrapConfigOverride("auth.sms.max_frequency", () => parseGoDuration(smsForValidation.max_frequency), ); @@ -536,14 +524,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ) { yield* output.raw("WARN: no SMS provider is enabled. Disabling phone login\n", "stderr"); } - if (gotrueSessionsForValidation?.timebox !== undefined) { - yield* wrapConfigOverride("auth.sessions.timebox", () => - parseGoDuration(gotrueSessionsForValidation.timebox!), - ); + const sessionsTimebox = gotrueSessionsForValidation?.timebox; + if (sessionsTimebox !== undefined) { + yield* wrapConfigOverride("auth.sessions.timebox", () => parseGoDuration(sessionsTimebox)); } - if (gotrueSessionsForValidation?.inactivity_timeout !== undefined) { + const sessionsInactivityTimeout = gotrueSessionsForValidation?.inactivity_timeout; + if (sessionsInactivityTimeout !== undefined) { yield* wrapConfigOverride("auth.sessions.inactivity_timeout", () => - parseGoDuration(gotrueSessionsForValidation.inactivity_timeout!), + parseGoDuration(sessionsInactivityTimeout), ); } yield* wrapConfigOverride("auth.mfa.phone.max_frequency", () => @@ -638,8 +626,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // 3. A missing container proceeds to startup; other inspect failures propagate. Stopped // stacks are recovered unless Bitbucket's lack of named volumes makes removal destructive. const inspectDbState = inspectContainerState(spawner, dbContainerId).pipe( - Effect.catch((error) => - isContainerNotFoundMessage(error.message) ? Effect.void : Effect.fail(error), + Effect.catchIf( + (error) => isContainerNotFoundMessage(error.message), + () => Effect.void, ), Effect.tap((state) => Effect.annotateCurrentSpan({ @@ -720,19 +709,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // 5. Gate evaluation — see `start.gates.ts`. Wrapped because `envOverrideBool` throws // synchronously on an unparsable value, and this handler surfaces that as a typed error. - const gates = yield* Effect.try({ - try: () => - resolveStartGates({ - config, - projectEnvValues, - excludedKeys, - document: context.loaded?.document, - }), - catch: (cause) => - new StartInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const gates = yield* tryStartConfig(() => + resolveStartGates({ + config, + projectEnvValues, + excludedKeys, + document: context.loaded?.document, + }), + ); // 6. JWKS resolution runs unconditionally, before any image pull, regardless of which // services end up enabled. diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index ab28fdcd95..796196e783 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -4,6 +4,7 @@ import { BunServices } from "@effect/platform-bun"; import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; import { Cause, + Deferred, Effect, Exit, Fiber, @@ -12,14 +13,15 @@ import { Option, Path, PlatformError, + Predicate, Schema, Sink, Stream, } from "effect"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { vi } from "vitest"; +import { assert, vi } from "vitest"; import { mockAnalytics, @@ -130,8 +132,7 @@ function concatByteChunks(chunks: ReadonlyArray): Uint8Array | undefine } /** - * Resolves each spawned invocation immediately (no fake async delay) — unlike - * `stop.integration.test.ts`'s `mockRoutedContainerCliSpawner`, `start`'s own + * Resolves each spawned invocation immediately (no fake async delay) — `start`'s own * bring-up creates 10+ containers per scenario and never needs to exercise a * Docker-CLI-level race, so a synchronous mock keeps these tests fast. * @@ -155,10 +156,11 @@ function mockStartContainerCliSpawner( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { - const cmd = command._tag === "StandardCommand" ? command.command : ""; - const args = command._tag === "StandardCommand" ? command.args : []; - const env = command._tag === "StandardCommand" ? (command.options?.env ?? {}) : {}; - const stdin = command._tag === "StandardCommand" ? command.options.stdin : undefined; + const standard = ChildProcess.isStandardCommand(command); + const cmd = standard ? command.command : ""; + const args = standard ? command.args : []; + const env = standard ? (command.options?.env ?? {}) : {}; + const stdin = standard ? command.options.stdin : undefined; const onSecretCopy = opts.onSecretCopy; spawned.push({ command: cmd, args, env }); @@ -354,7 +356,9 @@ function mockStorageVectorHttpClient(existingVectorBuckets: ReadonlyArray = [] } if (request.method === "POST" && request.url.includes("/storage/v1/bucket")) { createdBucketRequests.push(request.url); - if (request.body._tag === "Uint8Array") { + if (Predicate.isTagged(request.body, "Uint8Array")) { try { createdBucketBodies.push(JSON.parse(new TextDecoder().decode(request.body.body))); } catch { @@ -578,13 +582,12 @@ const ALL_EXCLUDABLE_SUFFIXES: ReadonlyArray = [ function missingSuffixesForExcludeKey(excludeKey: string): ReadonlyArray { if (excludeKey === "edge-runtime") return []; - if (excludeKey === "storage-api") { - return [ - CONTAINER_SUFFIX_BY_EXCLUDE_KEY["storage-api"]!, - CONTAINER_SUFFIX_BY_EXCLUDE_KEY.imgproxy!, - ]; - } - return [CONTAINER_SUFFIX_BY_EXCLUDE_KEY[excludeKey]!]; + const missingKeys = excludeKey === "storage-api" ? [excludeKey, "imgproxy"] : [excludeKey]; + return missingKeys.map((key) => { + const suffix = CONTAINER_SUFFIX_BY_EXCLUDE_KEY[key]; + assert(suffix !== undefined, `no container suffix for exclude key "${key}"`); + return suffix; + }); } // This ciphertext decrypts to "value" under the keypair below (same fixture as @@ -602,7 +605,7 @@ describe("start integration", () => { }); describe("--exclude validation", () => { - it.live("warns on stderr for an invalid --exclude value, even when already running", () => + it.effect("warns on stderr for an invalid --exclude value, even when already running", () => Effect.gen(function* () { const { layer, out } = yield* setup({ route: (args) => { @@ -621,7 +624,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "rejects --exclude db and --exclude postgres as invalid, since Postgres has no exclude key", () => Effect.gen(function* () { @@ -639,7 +642,7 @@ describe("start integration", () => { }); describe("already running", () => { - it.live( + it.effect( "prints the already-running banner and renders status without creating any containers", () => Effect.gen(function* () { @@ -660,7 +663,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("reports the stack is already running with a machine payload in json mode", () => + it.effect("reports the stack is already running with a machine payload in json mode", () => Effect.gen(function* () { const { layer, out } = yield* setup({ format: "json", @@ -680,27 +683,29 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("reports the stack is already running with a machine payload in stream-json mode", () => - Effect.gen(function* () { - const { layer, out } = yield* setup({ - format: "stream-json", - route: (args) => { - if (args[0] === "container" && args[1] === "inspect") { - return { stdout: [HEALTHY_STATE] }; - } - if (args[0] === "ps") return { stdout: [] }; - return { exitCode: 0 }; - }, - }); + it.effect( + "reports the stack is already running with a machine payload in stream-json mode", + () => + Effect.gen(function* () { + const { layer, out } = yield* setup({ + format: "stream-json", + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + return { stdout: [HEALTHY_STATE] }; + } + if (args[0] === "ps") return { stdout: [] }; + return { exitCode: 0 }; + }, + }); - yield* start(flags()).pipe(Effect.provide(layer)); - const success = out.messages.find((m) => m.type === "success"); - expect(success?.data).toMatchObject({ DB_URL: expect.any(String) }); - expect(out.stderrText).not.toContain("is already running"); - }).pipe(Effect.provide(BunServices.layer)), + yield* start(flags()).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ DB_URL: expect.any(String) }); + expect(out.stderrText).not.toContain("is already running"); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails when the already-running DB container stops running before the health re-check", () => Effect.gen(function* () { @@ -725,29 +730,31 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when the already-running DB container is unhealthy on the health re-check", () => - Effect.gen(function* () { - let inspectCalls = 0; - const { layer } = yield* setup({ - route: (args) => { - if (args[0] === "container" && args[1] === "inspect") { - inspectCalls += 1; - if (inspectCalls === 1) return { stdout: [HEALTHY_STATE] }; - return { stdout: [STARTING_STATE] }; - } - return { exitCode: 0 }; - }, - }); + it.effect( + "fails when the already-running DB container is unhealthy on the health re-check", + () => + Effect.gen(function* () { + let inspectCalls = 0; + const { layer } = yield* setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") { + inspectCalls += 1; + if (inspectCalls === 1) return { stdout: [HEALTHY_STATE] }; + return { stdout: [STARTING_STATE] }; + } + return { exitCode: 0 }; + }, + }); - const exit = yield* Effect.exit(start(flags()).pipe(Effect.provide(layer))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("StatusDbNotReadyError"); - } - }).pipe(Effect.provide(BunServices.layer)), + const exit = yield* Effect.exit(start(flags()).pipe(Effect.provide(layer))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("StatusDbNotReadyError"); + } + }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails when the already-running DB container's health re-check inspect itself errors", () => Effect.gen(function* () { @@ -773,7 +780,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when listing running containers errors while already running", () => + it.effect("fails when listing running containers errors while already running", () => Effect.gen(function* () { const { layer } = yield* setup({ route: (args) => { @@ -793,7 +800,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("already running, --ignore-health-check skips the health re-check entirely", () => + it.effect("already running, --ignore-health-check skips the health re-check entirely", () => Effect.gen(function* () { let inspectCalls = 0; const { layer, child } = yield* setup({ @@ -814,7 +821,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("already running with every service still up omits the 'Stopped services' line", () => + it.effect("already running with every service still up omits the 'Stopped services' line", () => Effect.gen(function* () { const { layer, out } = yield* setup({ route: (args) => { @@ -831,7 +838,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on a bucket's invalid file_size_limit even when already running, matching Go's Config.Load", () => Effect.gen(function* () { @@ -864,7 +871,7 @@ describe("start integration", () => { }); describe("stopped project recovery", () => { - it.live("recreates stopped project containers without pruning the database volume", () => + it.effect("recreates stopped project containers without pruning the database volume", () => Effect.gen(function* () { const workdir = tempRoot.current; const route = defaultRoute(); @@ -932,7 +939,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("does not remove containers when the project id sanitizes to empty", () => + it.effect("does not remove containers when the project id sanitizes to empty", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configContents: 'project_id = "!!!"\n', @@ -958,7 +965,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("preserves a stopped Bitbucket database container", () => + it.effect("preserves a stopped Bitbucket database container", () => withEnvVar( "BITBUCKET_CLONE_DIR", "/opt/atlassian/pipelines/agent/build", @@ -990,7 +997,7 @@ describe("start integration", () => { ).pipe(Effect.provide(BunServices.layer)), ); - it.live("does not remove containers when re-inspect returns an unknown state", () => + it.effect("does not remove containers when re-inspect returns an unknown state", () => Effect.gen(function* () { let dbInspects = 0; const { layer, child } = yield* setup({ @@ -1014,7 +1021,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("does not recover a created database container with unknown volume state", () => + it.effect("does not recover a created database container with unknown volume state", () => Effect.gen(function* () { const { layer, child } = yield* setup({ route: (args) => { @@ -1044,7 +1051,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("validates custom TLS files before removing a stopped stack", () => + it.effect("validates custom TLS files before removing a stopped stack", () => Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; @@ -1086,7 +1093,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("validates function bind mounts before removing a stopped stack", () => + it.effect("validates function bind mounts before removing a stopped stack", () => Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; @@ -1123,7 +1130,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("removes remaining project containers when the stopped database disappears", () => + it.effect("removes remaining project containers when the stopped database disappears", () => Effect.gen(function* () { const workdir = tempRoot.current; const route = defaultRoute(); @@ -1179,7 +1186,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("cleans only current-workdir secrets when recovery fails", () => + it.effect("cleans only current-workdir secrets when recovery fails", () => Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; @@ -1236,7 +1243,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("keeps a stopped stack intact when a later config field fails to parse", () => + it.effect("keeps a stopped stack intact when a later config field fails to parse", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "not-a-duration"\n', @@ -1263,43 +1270,46 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("reports status instead of tearing down when the stack recovers before teardown", () => - Effect.gen(function* () { - let dbInspects = 0; - const { layer, out, child } = yield* setup({ - route: (args) => { - if ( - args[0] === "container" && - args[1] === "inspect" && - args[2] === "supabase_db_demo" - ) { - dbInspects += 1; - return { stdout: [dbInspects === 1 ? STOPPED_STATE : HEALTHY_STATE] }; - } - if (args[0] === "ps") { - return { stdout: ["supabase_db_demo"] }; - } - return { exitCode: 0 }; - }, - }); + it.effect( + "reports status instead of tearing down when the stack recovers before teardown", + () => + Effect.gen(function* () { + let dbInspects = 0; + const { layer, out, child } = yield* setup({ + route: (args) => { + if ( + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_db_demo" + ) { + dbInspects += 1; + return { stdout: [dbInspects === 1 ? STOPPED_STATE : HEALTHY_STATE] }; + } + if (args[0] === "ps") { + return { stdout: ["supabase_db_demo"] }; + } + return { exitCode: 0 }; + }, + }); - yield* start(flags()).pipe(Effect.provide(layer)); + yield* start(flags()).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("is already running"); - expect( - child.spawned.some( - (spawn) => - (spawn.args[0] === "ps" && spawn.args.includes("--all")) || spawn.args[1] === "prune", - ), - ).toBe(false); - expect(child.spawned.some((spawn) => spawn.args[0] === "stop")).toBe(false); - expect(createdContainerNames(child.spawned)).toEqual([]); - }).pipe(Effect.provide(BunServices.layer)), + expect(out.stderrText).toContain("is already running"); + expect( + child.spawned.some( + (spawn) => + (spawn.args[0] === "ps" && spawn.args.includes("--all")) || + spawn.args[1] === "prune", + ), + ).toBe(false); + expect(child.spawned.some((spawn) => spawn.args[0] === "stop")).toBe(false); + expect(createdContainerNames(child.spawned)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), ); }); describe("config load / validation failures", () => { - it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => + it.effect("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => Effect.gen(function* () { const path = yield* Path.Path; const missingWorkdir = path.join(tempRoot.current, "does-not-exist"); @@ -1318,7 +1328,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when the DB container inspect fails for a reason other than not-found", () => + it.effect("fails when the DB container inspect fails for a reason other than not-found", () => Effect.gen(function* () { const { layer, child } = yield* setup({ route: (args) => { @@ -1340,7 +1350,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a docker-unavailable error when neither docker nor podman can be spawned", () => Effect.gen(function* () { @@ -1361,7 +1371,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails on a malformed config.toml", () => + it.effect("fails on a malformed config.toml", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1382,7 +1392,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when auth.jwt_secret is configured but shorter than 16 characters", () => + it.effect("fails when auth.jwt_secret is configured but shorter than 16 characters", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configContents: 'project_id = "demo"\n[auth]\njwt_secret = "too-short"\n', @@ -1401,7 +1411,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "rejects an out-of-root auth.email.template content_path before any Docker work, even with auth disabled", () => Effect.gen(function* () { @@ -1428,7 +1438,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on a missing (but in-root) auth.email.template content_path before any Docker work, even with auth disabled", () => Effect.gen(function* () { @@ -1462,7 +1472,7 @@ describe("start integration", () => { }); describe("happy path", () => { - it.live( + it.effect( "brings up the full default stack (clean host, every container immediately healthy)", () => Effect.gen(function* () { @@ -1498,40 +1508,42 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("starts Studio in a fresh project on Podman, which rejects missing bind sources", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const snippetsDir = path.join(tempRoot.current, "supabase", "snippets"); - const route = defaultRoute(); - const { layer, child } = yield* setup({ - route: (args) => - Effect.gen(function* () { - const sources = args.flatMap((arg, index) => - args[0] === "create" && arg === "-v" - ? [bindMountSpecSource(args[index + 1] ?? "")] - : [], - ); - for (const source of sources.filter((s) => s.startsWith(tempRoot.current))) - if (!(yield* fs.exists(source))) - return { - exitCode: 125, - stderr: [`Error: statfs ${source}: no such file or directory`], - }; - return route(args); - }), - }); + it.effect( + "starts Studio in a fresh project on Podman, which rejects missing bind sources", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const snippetsDir = path.join(tempRoot.current, "supabase", "snippets"); + const route = defaultRoute(); + const { layer, child } = yield* setup({ + route: (args) => + Effect.gen(function* () { + const sources = args.flatMap((arg, index) => + args[0] === "create" && arg === "-v" + ? [bindMountSpecSource(args[index + 1] ?? "")] + : [], + ); + for (const source of sources.filter((s) => s.startsWith(tempRoot.current))) + if (!(yield* fs.exists(source))) + return { + exitCode: 125, + stderr: [`Error: statfs ${source}: no such file or directory`], + }; + return route(args); + }), + }); - yield* start(flags()).pipe(Effect.provide(layer)); + yield* start(flags()).pipe(Effect.provide(layer)); - expect(createdContainerNames(child.spawned).some((name) => name.includes("_studio_"))).toBe( - true, - ); - expect(yield* fs.exists(snippetsDir)).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), + expect( + createdContainerNames(child.spawned).some((name) => name.includes("_studio_")), + ).toBe(true); + expect(yield* fs.exists(snippetsDir)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("leaves supabase/snippets uncreated when Studio is excluded", () => + it.effect("leaves supabase/snippets uncreated when Studio is excluded", () => Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; @@ -1543,7 +1555,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails before creating anything when a file blocks supabase/snippets", () => + it.effect("fails before creating anything when a file blocks supabase/snippets", () => Effect.gen(function* () { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; @@ -1572,7 +1584,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("emits a machine status payload in json mode instead of the pretty table", () => + it.effect("emits a machine status payload in json mode instead of the pretty table", () => Effect.gen(function* () { const { layer, out } = yield* setup({ format: "json" }); @@ -1583,18 +1595,20 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("emits a machine status payload in stream-json mode instead of the pretty table", () => - Effect.gen(function* () { - const { layer, out } = yield* setup({ format: "stream-json" }); + it.effect( + "emits a machine status payload in stream-json mode instead of the pretty table", + () => + Effect.gen(function* () { + const { layer, out } = yield* setup({ format: "stream-json" }); - yield* start(flags()).pipe(Effect.provide(layer)); - const success = out.messages.find((m) => m.type === "success"); - expect(success?.data).toMatchObject({ DB_URL: expect.any(String) }); - expect(out.stderrText).not.toContain("Started"); - }).pipe(Effect.provide(BunServices.layer)), + yield* start(flags()).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ DB_URL: expect.any(String) }); + expect(out.stderrText).not.toContain("Started"); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fires cli_stack_started exactly once on a successful start", () => + it.effect("fires cli_stack_started exactly once on a successful start", () => Effect.gen(function* () { const { layer, analytics } = yield* setup(); @@ -1606,7 +1620,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("brings the stack up when podman rejects re-creating preserved volumes (#6020)", () => + it.effect("brings the stack up when podman rejects re-creating preserved volumes (#6020)", () => Effect.gen(function* () { const route = defaultRoute(); const { layer, analytics } = yield* setup({ @@ -1627,7 +1641,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "reuses the bring-up-resolved local config values for the final status print instead of re-deriving them", () => Effect.gen(function* () { @@ -1676,7 +1690,7 @@ describe("start integration", () => { }); describe("config-driven container-spec branches", () => { - it.live("fails when a configured third-party auth issuer returns an HTTP error", () => + it.effect("fails when a configured third-party auth issuer returns an HTTP error", () => Effect.gen(function* () { const { layer, child } = yield* setup({ httpClientLayer: Layer.succeed( @@ -1700,7 +1714,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("reads and mounts a configured API TLS cert/key pair for Kong", () => + it.effect("reads and mounts a configured API TLS cert/key pair for Kong", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1725,7 +1739,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when a configured API TLS cert file cannot be read", () => + it.effect("fails when a configured API TLS cert file cannot be read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1750,7 +1764,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when a configured API TLS key file cannot be read", () => + it.effect("fails when a configured API TLS key file cannot be read", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1775,7 +1789,7 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "brings up the stack with every optional config.toml section populated (bigquery analytics, session pool mode, passkey/webauthn, external provider, SMTP, email templates)", () => Effect.gen(function* () { @@ -1858,7 +1872,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( // "0s" performs exactly one immediate health probe with no retries, not a 30s fallback. // The mock heals on the first check either way, so this only proves "0s" doesn't hang, // not the retry count. @@ -1872,7 +1886,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails config loading on an unparseable db.health_timeout before any Docker work, matching Go's Config.Load", () => Effect.gen(function* () { @@ -1893,7 +1907,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid storage.file_size_limit even when storage is excluded, matching Go's Config.Load", () => Effect.gen(function* () { @@ -1914,7 +1928,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_STORAGE_S3_PROTOCOL_ENABLED even when storage is excluded, matching Go's Config.Load", () => withEnvVar( @@ -1937,7 +1951,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_STORAGE_ANALYTICS_ENABLED even when storage is excluded, matching Go's Config.Load", () => withEnvVar( @@ -1960,7 +1974,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES even when storage is excluded, matching Go's Config.Load", () => withEnvVar( @@ -1983,7 +1997,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_STORAGE_ANALYTICS_MAX_TABLES even when storage is excluded, matching Go's Config.Load", () => withEnvVar( @@ -2006,7 +2020,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS even when storage is excluded, matching Go's Config.Load", () => withEnvVar( @@ -2029,7 +2043,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_STORAGE_VECTOR_MAX_BUCKETS even when storage is excluded, matching Go's Config.Load", () => withEnvVar( @@ -2052,7 +2066,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_STORAGE_VECTOR_MAX_INDEXES even when storage is excluded, matching Go's Config.Load", () => withEnvVar( @@ -2075,7 +2089,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid auth.sms.max_frequency even when auth is disabled, matching Go's Config.Load", () => Effect.gen(function* () { @@ -2095,7 +2109,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS even when auth is disabled, matching Go's Config.Load", () => withEnvVar( @@ -2118,7 +2132,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_AUTH_WEB3_SOLANA_ENABLED even when auth is disabled, matching Go's Config.Load", () => withEnvVar( @@ -2141,7 +2155,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_AUTH_OAUTH_SERVER_ENABLED even when auth is disabled, matching Go's Config.Load", () => withEnvVar( @@ -2164,7 +2178,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED even when auth is disabled, matching Go's Config.Load", () => withEnvVar( @@ -2187,7 +2201,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid auth.passkey.enabled even when auth is disabled, matching Go's Config.Load", () => Effect.gen(function* () { @@ -2210,7 +2224,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid auth.external..enabled even when auth is disabled, matching Go's Config.Load", () => Effect.gen(function* () { @@ -2236,7 +2250,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on a per-function env field, matching Go's Config.Load rejecting an unknown functions[slug] key", () => Effect.gen(function* () { @@ -2261,7 +2275,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_EDGE_RUNTIME_POLICY even when edge-runtime is excluded, matching Go's Config.Load", () => withEnvVar( @@ -2284,7 +2298,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an invalid SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT even when edge-runtime is excluded, matching Go's Config.Load", () => withEnvVar( @@ -2307,7 +2321,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "warns about a Windows npipe Docker daemon before starting Vector, in text mode, and excludes it from the health watch list", () => // `resolveDockerDaemonHost` checks `DOCKER_HOST` before shelling out to `docker context @@ -2330,7 +2344,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("service gating", () => { - it.live( + it.effect( "skips analytics services (logflare + vector) together when analytics.enabled = false", () => Effect.gen(function* () { @@ -2349,7 +2363,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "skips storage and imgproxy together when storage.enabled = false, even with image_transformation on", () => Effect.gen(function* () { @@ -2368,7 +2382,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "ignores SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED when [storage.image_transformation] is absent from config.toml", () => // `storage.image_transformation` is a nil-unless-declared field: with no @@ -2387,7 +2401,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "starts Kong and Realtime even when api.enabled is false, since only Postgrest depends on it", () => Effect.gen(function* () { @@ -2407,7 +2421,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "excluding a single --exclude key skips exactly that container and starts every other excludable service", () => Effect.gen(function* () { @@ -2444,7 +2458,7 @@ content_path = "./supabase/templates/custom_notice.html" return spawned.filter((s) => s.args[0] === "run" && s.args[1] === "--rm"); } - it.live( + it.effect( "triggers the SetupLocalDatabase-equivalent pipeline (PG15+ one-shot migrate jobs) on a fresh volume", () => Effect.gen(function* () { @@ -2459,7 +2473,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "resolves an excluded service's migrate-job image through a project-dotenv-only registry override", () => Effect.gen(function* () { @@ -2486,7 +2500,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "does not attempt to resolve an excluded service's migrate-job image on a non-fresh-volume restart", () => Effect.gen(function* () { @@ -2514,7 +2528,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("skips the SetupLocalDatabase-equivalent pipeline on a non-fresh volume", () => + it.effect("skips the SetupLocalDatabase-equivalent pipeline on a non-fresh volume", () => Effect.gen(function* () { const { layer, out, child } = yield* setup(); @@ -2524,7 +2538,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on an undecryptable [db.vault] secret even on a non-fresh volume, matching Go's Config.Load", () => // `checkDbToml`'s internal call inside `startSetupLocalDatabase` only runs on a fresh @@ -2551,7 +2565,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on a bucket's invalid file_size_limit even on a non-fresh volume, matching Go's Config.Load", () => Effect.gen(function* () { @@ -2573,7 +2587,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live('prints "Starting database..." on a fresh volume, before Postgres is created', () => + it.effect('prints "Starting database..." on a fresh volume, before Postgres is created', () => Effect.gen(function* () { const { layer, out } = yield* setup({ route: freshVolumeRoute(defaultRoute()) }); @@ -2586,7 +2600,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( 'prints "Starting database from backup..." on a restart (an already-existing volume)', () => Effect.gen(function* () { @@ -2598,7 +2612,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "still writes supabase/.branches/_current_branch on a restart, even though the fresh-volume DB setup is skipped", () => Effect.gen(function* () { @@ -2614,7 +2628,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("seeds a configured bucket on a fresh volume with storage enabled", () => + it.effect("seeds a configured bucket on a fresh volume with storage enabled", () => Effect.gen(function* () { const http = mockStorageBucketHttpClient(); const { layer } = yield* setup({ @@ -2631,29 +2645,31 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("keeps a vector bucket missing from config.toml (prune declines without consent)", () => - Effect.gen(function* () { - const http = mockStorageVectorHttpClient(["embeddings", "stale-vec"]); - const { layer } = yield* setup({ - configContents: - 'project_id = "demo"\n[storage.vector]\nenabled = true\n[storage.vector.buckets.embeddings]\n', - route: freshVolumeRoute(defaultRoute()), - httpClientLayer: http.layer, - }); - // A truthy ambient `SUPABASE_YES` would auto-confirm the prune. - return yield* withEnvVar( - "SUPABASE_YES", - undefined, - Effect.gen(function* () { - yield* start(flags({ exclude: ["edge-runtime"] })); - expect(http.vectorListCalls).toBe(1); - expect(http.deletedVectorBuckets).toHaveLength(0); - }).pipe(Effect.provide(layer), withEmptyConfigEnv), - ); - }).pipe(Effect.provide(BunServices.layer)), + it.effect( + "keeps a vector bucket missing from config.toml (prune declines without consent)", + () => + Effect.gen(function* () { + const http = mockStorageVectorHttpClient(["embeddings", "stale-vec"]); + const { layer } = yield* setup({ + configContents: + 'project_id = "demo"\n[storage.vector]\nenabled = true\n[storage.vector.buckets.embeddings]\n', + route: freshVolumeRoute(defaultRoute()), + httpClientLayer: http.layer, + }); + // A truthy ambient `SUPABASE_YES` would auto-confirm the prune. + return yield* withEnvVar( + "SUPABASE_YES", + undefined, + Effect.gen(function* () { + yield* start(flags({ exclude: ["edge-runtime"] })); + expect(http.vectorListCalls).toBe(1); + expect(http.deletedVectorBuckets).toHaveLength(0); + }).pipe(Effect.provide(layer), withEmptyConfigEnv), + ); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "never asks before overwriting an existing bucket, so piped stdin stays untouched", () => Effect.gen(function* () { @@ -2677,7 +2693,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "never reads piped stdin for the seeding confirmations, so a piped y cannot consent", () => Effect.gen(function* () { @@ -2704,63 +2720,67 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("prunes a stale vector bucket on a fresh-volume start when SUPABASE_YES consents", () => - Effect.gen(function* () { - const http = mockStorageVectorHttpClient(["embeddings", "stale-vec"]); - const { layer } = yield* setup({ - configContents: - 'project_id = "demo"\n[storage.vector]\nenabled = true\n[storage.vector.buckets.embeddings]\n', - route: freshVolumeRoute(defaultRoute()), - httpClientLayer: http.layer, - }); - return yield* withConfigEnv( - { SUPABASE_YES: "1" }, - Effect.gen(function* () { - yield* start(flags({ exclude: ["edge-runtime"] })); - expect(http.deletedVectorBuckets).toHaveLength(1); - expect(http.deletedVectorBuckets[0]).toContain("stale-vec"); - }).pipe(Effect.provide(layer)), - ); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.live( - "does not seed a configured bucket on a non-fresh volume, even with storage enabled", + it.effect( + "prunes a stale vector bucket on a fresh-volume start when SUPABASE_YES consents", () => Effect.gen(function* () { - const http = mockStorageBucketHttpClient(); + const http = mockStorageVectorHttpClient(["embeddings", "stale-vec"]); const { layer } = yield* setup({ - configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', + configContents: + 'project_id = "demo"\n[storage.vector]\nenabled = true\n[storage.vector.buckets.embeddings]\n', + route: freshVolumeRoute(defaultRoute()), httpClientLayer: http.layer, }); - - yield* start(flags({ exclude: ["edge-runtime"] })).pipe(Effect.provide(layer)); - expect(http.createdBucketRequests).toHaveLength(0); + return yield* withConfigEnv( + { SUPABASE_YES: "1" }, + Effect.gen(function* () { + yield* start(flags({ exclude: ["edge-runtime"] })); + expect(http.deletedVectorBuckets).toHaveLength(1); + expect(http.deletedVectorBuckets[0]).toContain("stale-vec"); + }).pipe(Effect.provide(layer)), + ); }).pipe(Effect.provide(BunServices.layer)), ); - it.live("seeds against the env-overridden SUPABASE_API_PORT, not config.toml's raw port", () => - // `seedBucketsRun` must reuse `start`'s own already env-overridden config, so a - // `SUPABASE_API_PORT` override reaches the bucket-seeding gateway's base URL too. - withEnvVar( - "SUPABASE_API_PORT", - "65432", + it.effect( + "does not seed a configured bucket on a non-fresh volume, even with storage enabled", + () => Effect.gen(function* () { const http = mockStorageBucketHttpClient(); const { layer } = yield* setup({ configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', - route: freshVolumeRoute(defaultRoute()), httpClientLayer: http.layer, }); yield* start(flags({ exclude: ["edge-runtime"] })).pipe(Effect.provide(layer)); - expect(http.createdBucketRequests).toHaveLength(1); - expect(http.createdBucketRequests[0]).toContain(":65432/"); + expect(http.createdBucketRequests).toHaveLength(0); }).pipe(Effect.provide(BunServices.layer)), - ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( + "seeds against the env-overridden SUPABASE_API_PORT, not config.toml's raw port", + () => + // `seedBucketsRun` must reuse `start`'s own already env-overridden config, so a + // `SUPABASE_API_PORT` override reaches the bucket-seeding gateway's base URL too. + withEnvVar( + "SUPABASE_API_PORT", + "65432", + Effect.gen(function* () { + const http = mockStorageBucketHttpClient(); + const { layer } = yield* setup({ + configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', + route: freshVolumeRoute(defaultRoute()), + httpClientLayer: http.layer, + }); + + yield* start(flags({ exclude: ["edge-runtime"] })).pipe(Effect.provide(layer)); + expect(http.createdBucketRequests).toHaveLength(1); + expect(http.createdBucketRequests[0]).toContain(":65432/"); + }).pipe(Effect.provide(BunServices.layer)), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( "seeds against the env-overridden SUPABASE_API_EXTERNAL_URL, not config.toml's raw value", () => // `effectiveLocalStorageConfig` must use the overridden `api.external_url`, not the raw @@ -2783,7 +2803,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "seeds a bucket's default file_size_limit from the env-overridden SUPABASE_STORAGE_FILE_SIZE_LIMIT", () => // `effectiveLocalStorageConfig` previously left `storage.file_size_limit` as the @@ -2803,9 +2823,9 @@ content_path = "./supabase/templates/custom_notice.html" yield* start(flags({ exclude: ["edge-runtime"] })).pipe(Effect.provide(layer)); expect(http.createdBucketBodies).toHaveLength(1); - expect( - (http.createdBucketBodies[0] as { file_size_limit?: number })?.file_size_limit, - ).toBe(10 * 1024 * 1024); + expect(http.createdBucketBodies[0]).toMatchObject({ + file_size_limit: 10 * 1024 * 1024, + }); }).pipe(Effect.provide(BunServices.layer)), ).pipe(Effect.provide(BunServices.layer)), ); @@ -2817,7 +2837,7 @@ content_path = "./supabase/templates/custom_notice.html" return spawned.filter((s) => isEdgeRuntimeCreate(s.args)); } - it.live("creates and starts a real container when enabled and not excluded", () => + it.effect("creates and starts a real container when enabled and not excluded", () => Effect.gen(function* () { const { layer, child } = yield* setup(); @@ -2841,7 +2861,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("--exclude edge-runtime skips its container entirely", () => + it.effect("--exclude edge-runtime skips its container entirely", () => Effect.gen(function* () { const { layer, child } = yield* setup(); @@ -2850,7 +2870,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "keeps the host-side staged env artifacts after a successful bring-up (no eager cleanup)", () => Effect.gen(function* () { @@ -2878,7 +2898,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "logs 'Skipped serving Function' for a disabled function via Studio's bind mounts, even with Edge Runtime excluded", () => Effect.gen(function* () { @@ -2916,7 +2936,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "does not pick up an unrelated ancestor project's functions for a config-less --workdir subdirectory", () => Effect.gen(function* () { @@ -3046,7 +3066,7 @@ content_path = "./supabase/templates/custom_notice.html" 45_000, ); - it.live( + it.effect( "still fails when the daemon dies mid-pre-pull under --ignore-health-check — Go's exit-0 swallow is an unintended quirk this port deliberately does not reproduce (CLI-1987)", () => Effect.gen(function* () { @@ -3092,7 +3112,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("rollback on bring-up failure", () => { - it.live( + it.effect( "rolls back on a SIGINT-style interruption mid-bring-up, matching Go's context.Canceled rollback", () => Effect.gen(function* () { @@ -3104,13 +3124,14 @@ content_path = "./supabase/templates/custom_notice.html" // rollback being wired via `Effect.onError` (not `Effect.tapError`, which never sees a // pure interrupt's `Cause`). // - // Marking `db` never-healthy keeps `bringUp`'s Postgres health-check wait genuinely - // retrying on its real backoff, rather than the whole synchronous mock bring-up - // completing before this test's polling loop is even scheduled — which would make - // `Fiber.interrupt` a no-op on an already-succeeded fiber. + // Marking `db` never-healthy keeps `bringUp`'s Postgres health-check wait suspended in + // its retry loop, rather than the whole synchronous mock bring-up completing before + // this test's probe wait is even scheduled — which would make `Fiber.interrupt` a + // no-op on an already-succeeded fiber. const neverHealthy = new Set(); const route = defaultRoute({ neverHealthy }); let dbContainerId: string | undefined; + const dbProbed = yield* Deferred.make(); const { layer, child } = yield* setup({ route: (args) => { if (args[0] === "create") { @@ -3120,6 +3141,14 @@ content_path = "./supabase/templates/custom_notice.html" dbContainerId = name; } } + if ( + dbContainerId !== undefined && + args[0] === "container" && + args[1] === "inspect" && + args[2] === dbContainerId + ) { + return Deferred.succeed(dbProbed, undefined).pipe(Effect.as(route(args))); + } return route(args); }, }); @@ -3129,15 +3158,7 @@ content_path = "./supabase/templates/custom_notice.html" ); // Wait until the health check has actually probed the never-healthy `db` container, // proving the fiber is suspended inside the retry loop, not merely past `create`. - while ( - dbContainerId === undefined || - !child.spawned.some( - (s) => - s.args[0] === "container" && s.args[1] === "inspect" && s.args[2] === dbContainerId, - ) - ) { - yield* Effect.sleep("5 millis"); - } + yield* Deferred.await(dbProbed); // `Fiber.interrupt` only resolves once the target fiber (and its finalizers, // including the `Effect.onError` rollback) has fully completed. yield* Fiber.interrupt(fiber); @@ -3145,7 +3166,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "rolls back on a SIGINT-style interruption during the post-bring-up bulk health-check wait", () => Effect.gen(function* () { @@ -3153,11 +3174,12 @@ content_path = "./supabase/templates/custom_notice.html" // every container is created, so an interrupt landing anywhere in the tail that follows // (health-check wait, storage recheck-and-seed, bucket seed, analytics capture) must // still trigger `rollbackStart`. Marking `auth` never-healthy keeps - // `waitForHealthyServices` genuinely retrying so the interrupt lands inside this step, - // not before or after it. + // `waitForHealthyServices` suspended in its retry loop so the interrupt lands inside this + // step, not before or after it. const neverHealthy = new Set(); const route = defaultRoute({ neverHealthy }); let authContainerId: string | undefined; + const authProbed = yield* Deferred.make(); const { layer, child } = yield* setup({ route: (args) => { if (args[0] === "create") { @@ -3167,6 +3189,14 @@ content_path = "./supabase/templates/custom_notice.html" authContainerId = name; } } + if ( + authContainerId !== undefined && + args[0] === "container" && + args[1] === "inspect" && + args[2] === authContainerId + ) { + return Deferred.succeed(authProbed, undefined).pipe(Effect.as(route(args))); + } return route(args); }, // Sidesteps the PostgREST/Edge Runtime HTTP-HEAD readiness probes, so this only @@ -3180,17 +3210,7 @@ content_path = "./supabase/templates/custom_notice.html" // Wait until the bulk health check has probed the never-healthy `auth` container, // proving the fiber is suspended inside the retry loop, not merely past the "Waiting // for health checks..." message. - while ( - authContainerId === undefined || - !child.spawned.some( - (s) => - s.args[0] === "container" && - s.args[1] === "inspect" && - s.args[2] === authContainerId, - ) - ) { - yield* Effect.sleep("5 millis"); - } + yield* Deferred.await(authProbed); // `Fiber.interrupt` only resolves once the target fiber (and its finalizers, // including the `Effect.onError` rollback) has fully completed. yield* Fiber.interrupt(fiber); @@ -3198,7 +3218,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails and rolls back on a network create failure", () => + it.effect("fails and rolls back on a network create failure", () => Effect.gen(function* () { const base = defaultRoute(); const route = (args: ReadonlyArray): RouteResult => { @@ -3227,7 +3247,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails and rolls back on a container create failure", () => + it.effect("fails and rolls back on a container create failure", () => Effect.gen(function* () { const base = defaultRoute(); const route = (args: ReadonlyArray): RouteResult => { @@ -3249,7 +3269,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails and rolls back on a container start failure, surfacing the port-conflict suggestion", () => Effect.gen(function* () { @@ -3279,7 +3299,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails on a malformed auth.email.max_frequency before any Docker work, matching Go's Config.Load", () => Effect.gen(function* () { @@ -3300,7 +3320,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a typed config error on a malformed auth.email override even when auth itself is disabled", () => // With auth disabled, `resolveAuthEmail`'s unconditional call in `start.handler.ts` @@ -3326,7 +3346,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a typed config error on a malformed auth.sms override even when auth itself is disabled", () => // Same shape as the auth.email override test above: with auth disabled, @@ -3704,7 +3724,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("--network-id", () => { - it.live("overrides the generated network name and every container's --network flag", () => + it.effect("overrides the generated network name and every container's --network flag", () => Effect.gen(function* () { const { layer, child } = yield* setup({ networkId: Option.some("custom-net") }); @@ -3721,7 +3741,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("never spawns a create for a pre-created --network-id network", () => + it.effect("never spawns a create for a pre-created --network-id network", () => Effect.gen(function* () { const base = defaultRoute(); const route = (args: ReadonlyArray): RouteResult => { @@ -3745,7 +3765,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("falls back to SUPABASE_NETWORK_ID when the flag itself is omitted", () => + it.effect("falls back to SUPABASE_NETWORK_ID when the flag itself is omitted", () => // See `start.handler.ts`'s doc comment on this resolution for the full precedence. withConfigEnv( { SUPABASE_NETWORK_ID: "env-net" }, @@ -3763,7 +3783,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_API_PORT override", () => { - it.live("publishes Kong on the env-overridden API port, not config.api.port", () => + it.effect("publishes Kong on the env-overridden API port, not config.api.port", () => withEnvVar( "SUPABASE_API_PORT", "61234", @@ -3834,7 +3854,7 @@ content_path = "./supabase/templates/custom_notice.html" expected: empty, }, ]) { - it.live(scenario.name, () => + it.effect(scenario.name, () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -3876,7 +3896,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("storage migration pin", () => { - it.live( + it.effect( "threads a linked project's supabase/.temp/storage-migration pin into DB_MIGRATIONS_FREEZE_AT", () => Effect.gen(function* () { @@ -3898,7 +3918,7 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live('resolves to "" when no pin file exists', () => + it.effect('resolves to "" when no pin file exists', () => Effect.gen(function* () { const { layer, child } = yield* setup(); @@ -3913,7 +3933,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("linked service version pins", () => { - it.live( + it.effect( "resolves a supabase/.temp/storage-version pin into the pulled/created storage image tag", () => Effect.gen(function* () { @@ -3939,7 +3959,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("db.root_key", () => { - it.live( + it.effect( "delivers a configured db.root_key into the Postgres container's pgsodium root key via `docker cp`, never leaving it on host disk", () => Effect.gen(function* () { @@ -3967,7 +3987,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("container-not-found stderr shapes", () => { - it.live( + it.effect( "brings up the stack when the DB container's inspect reports 'No such object' instead of 'No such container'", () => Effect.gen(function* () { @@ -4006,7 +4026,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("Linux host-gateway mapping", () => { - it.live("adds --add-host host.docker.internal:host-gateway on Linux", () => + it.effect("adds --add-host host.docker.internal:host-gateway on Linux", () => Effect.gen(function* () { const { layer, child } = yield* setup(); @@ -4021,7 +4041,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("auth.email.smtp table-present default", () => { - it.live( + it.effect( "uses the configured SMTP server (not Mailpit) when [auth.email.smtp] omits enabled", () => Effect.gen(function* () { @@ -4041,7 +4061,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("custom auth.external providers", () => { - it.live("emits GOTRUE_EXTERNAL_* env vars for a provider outside the fixed schema set", () => + it.effect("emits GOTRUE_EXTERNAL_* env vars for a provider outside the fixed schema set", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configContents: @@ -4059,7 +4079,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_LOCAL_SMTP_ADMIN_EMAIL / SUPABASE_LOCAL_SMTP_SENDER_NAME overrides", () => { - it.live("honors env overrides for the Mailpit fallback's admin email and sender name", () => + it.effect("honors env overrides for the Mailpit fallback's admin email and sender name", () => withEnvVar( "SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", "override-admin@example.com", @@ -4083,7 +4103,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_LOCAL_SMTP_SMTP_PORT override", () => { - it.live( + it.effect( "fails with a typed config error, before any container is created, on an invalid SUPABASE_LOCAL_SMTP_SMTP_PORT", () => withEnvVar( @@ -4104,51 +4124,53 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_AUTH_SMS__* overrides", () => { - it.live("honors env overrides enabling Twilio SMS even when config.toml has it disabled", () => - withEnvVar( - "SUPABASE_AUTH_SMS_TWILIO_ENABLED", - "true", + it.effect( + "honors env overrides enabling Twilio SMS even when config.toml has it disabled", + () => withEnvVar( - "SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", - "override-account-sid", + "SUPABASE_AUTH_SMS_TWILIO_ENABLED", + "true", withEnvVar( - "SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID", - "override-message-service-sid", + "SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", + "override-account-sid", withEnvVar( - "SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN", - "override-auth-token", - Effect.gen(function* () { - // A complete, enabled provider is required, or SMS validation downgrades - // enable_signup to false regardless of the override — see the "disables phone login" - // test below for that behavior itself. - const { layer, child } = yield* setup({ - configContents: 'project_id = "demo"\n[auth.sms.twilio]\nenabled = false\n', - }); + "SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID", + "override-message-service-sid", + withEnvVar( + "SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN", + "override-auth-token", + Effect.gen(function* () { + // A complete, enabled provider is required, or SMS validation downgrades + // enable_signup to false regardless of the override — see the "disables phone login" + // test below for that behavior itself. + const { layer, child } = yield* setup({ + configContents: 'project_id = "demo"\n[auth.sms.twilio]\nenabled = false\n', + }); - yield* start(flags()).pipe(Effect.provide(layer)); - const gotrueCreate = child.spawned.find( - (s) => - s.args[0] === "create" && - containerNameFromCreateArgs(s.args).includes("_auth_"), - ); - expect(gotrueCreate?.env["GOTRUE_SMS_PROVIDER"]).toBe("twilio"); - expect(gotrueCreate?.env["GOTRUE_SMS_TWILIO_ACCOUNT_SID"]).toBe( - "override-account-sid", - ); - expect(gotrueCreate?.env["GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID"]).toBe( - "override-message-service-sid", - ); - expect(gotrueCreate?.env["GOTRUE_SMS_TWILIO_AUTH_TOKEN"]).toBe( - "override-auth-token", - ); - }).pipe(Effect.provide(BunServices.layer)), + yield* start(flags()).pipe(Effect.provide(layer)); + const gotrueCreate = child.spawned.find( + (s) => + s.args[0] === "create" && + containerNameFromCreateArgs(s.args).includes("_auth_"), + ); + expect(gotrueCreate?.env["GOTRUE_SMS_PROVIDER"]).toBe("twilio"); + expect(gotrueCreate?.env["GOTRUE_SMS_TWILIO_ACCOUNT_SID"]).toBe( + "override-account-sid", + ); + expect(gotrueCreate?.env["GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID"]).toBe( + "override-message-service-sid", + ); + expect(gotrueCreate?.env["GOTRUE_SMS_TWILIO_AUTH_TOKEN"]).toBe( + "override-auth-token", + ); + }).pipe(Effect.provide(BunServices.layer)), + ), ), ), ), - ), ); - it.live( + it.effect( "honors SUPABASE_AUTH_SMS_ENABLE_SIGNUP and SUPABASE_AUTH_SMS_MAX_FREQUENCY in GoTrue's env", () => withEnvVar( @@ -4175,7 +4197,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "disables phone login and warns when enable_signup is true with no SMS provider enabled", () => // SMS validation downgrades `enable_signup` to `false` (plus a stderr warning) — @@ -4202,7 +4224,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_AUTH_EMAIL_* overrides", () => { - it.live( + it.effect( "honors SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP and SUPABASE_AUTH_EMAIL_OTP_LENGTH in GoTrue's env", () => withEnvVar( @@ -4226,7 +4248,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "honors SUPABASE_AUTH_EMAIL_TEMPLATE__SUBJECT in GoTrue's mailer subject env", () => withEnvVar( @@ -4257,7 +4279,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails fast on SUPABASE_AUTH_EMAIL_TEMPLATE__CONTENT with no content_path configured, matching Go's Config.Validate", () => // The env override folds into the email template's content field before @@ -4287,7 +4309,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_DB_PORT override", () => { - it.live("publishes Postgres on the env-overridden DB port, not config.db.port", () => + it.effect("publishes Postgres on the env-overridden DB port, not config.db.port", () => withEnvVar( "SUPABASE_DB_PORT", "54329", @@ -4306,7 +4328,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_DB_SETTINGS_* env overrides", () => { - it.live("honors SUPABASE_DB_SETTINGS_SHARED_BUFFERS in the rendered postgresql.conf", () => + it.effect("honors SUPABASE_DB_SETTINGS_SHARED_BUFFERS in the rendered postgresql.conf", () => withEnvVar( "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", "256MB", @@ -4324,30 +4346,33 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("storage feature env overrides", () => { - it.live("honors SUPABASE_STORAGE_S3_PROTOCOL_ENABLED and SUPABASE_STORAGE_VECTOR_ENABLED", () => - withEnvVar( - "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", - "false", + it.effect( + "honors SUPABASE_STORAGE_S3_PROTOCOL_ENABLED and SUPABASE_STORAGE_VECTOR_ENABLED", + () => withEnvVar( - "SUPABASE_STORAGE_VECTOR_ENABLED", + "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", "false", - Effect.gen(function* () { - const { layer, child } = yield* setup(); + withEnvVar( + "SUPABASE_STORAGE_VECTOR_ENABLED", + "false", + Effect.gen(function* () { + const { layer, child } = yield* setup(); - yield* start(flags()).pipe(Effect.provide(layer)); - const storageCreate = child.spawned.find( - (s) => - s.args[0] === "create" && containerNameFromCreateArgs(s.args).includes("_storage_"), - ); - expect(storageCreate?.env["S3_PROTOCOL_ENABLED"]).toBe("false"); - }).pipe(Effect.provide(BunServices.layer)), - ), - ).pipe(Effect.provide(BunServices.layer)), + yield* start(flags()).pipe(Effect.provide(layer)); + const storageCreate = child.spawned.find( + (s) => + s.args[0] === "create" && + containerNameFromCreateArgs(s.args).includes("_storage_"), + ); + expect(storageCreate?.env["S3_PROTOCOL_ENABLED"]).toBe("false"); + }).pipe(Effect.provide(BunServices.layer)), + ), + ).pipe(Effect.provide(BunServices.layer)), ); }); describe("SUPABASE_ANALYTICS_* env overrides", () => { - it.live("honors SUPABASE_ANALYTICS_BACKEND/_GCP_* for both Logflare and Studio", () => + it.effect("honors SUPABASE_ANALYTICS_BACKEND/_GCP_* for both Logflare and Studio", () => withEnvVar( "SUPABASE_ANALYTICS_BACKEND", "bigquery", @@ -4386,7 +4411,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("auth.* env overrides reach GoTrue's container", () => { - it.live("honors SUPABASE_AUTH_ENABLE_SIGNUP for GOTRUE_DISABLE_SIGNUP", () => + it.effect("honors SUPABASE_AUTH_ENABLE_SIGNUP for GOTRUE_DISABLE_SIGNUP", () => withEnvVar( "SUPABASE_AUTH_ENABLE_SIGNUP", "false", @@ -4404,7 +4429,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - it.live("resolves the Deno 1 edge-runtime image tag, not the Deno 2 default", () => + it.effect("resolves the Deno 1 edge-runtime image tag, not the Deno 2 default", () => withEnvVar( "SUPABASE_EDGE_RUNTIME_DENO_VERSION", "1", @@ -4427,7 +4452,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_REALTIME_* env overrides", () => { - it.live( + it.effect( "honors SUPABASE_REALTIME_IP_VERSION/_MAX_HEADER_LENGTH for both the long-running container and the PG15+ setup job", () => withEnvVar( @@ -4463,7 +4488,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a typed config error, before any container is created, on an invalid SUPABASE_REALTIME_IP_VERSION", () => withEnvVar( @@ -4484,7 +4509,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("Studio API URL normalization", () => { - it.live( + it.effect( "rewrites the default studio.api_url to the Kong URL rather than passing it through raw", () => Effect.gen(function* () { @@ -4502,7 +4527,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_STORAGE_FILE_SIZE_LIMIT override", () => { - it.live( + it.effect( "honors the override for both Storage's container and the fresh-volume migrate job", () => withEnvVar( @@ -4529,7 +4554,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_DB_ORIOLEDB_VERSION override", () => { - it.live( + it.effect( "selects the OrioleDB Postgres image and enables the container's S3 env when set only via env", () => withEnvVar( @@ -4555,7 +4580,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live("honors SUPABASE_EXPERIMENTAL_S3_HOST/_REGION/_ACCESS_KEY/_SECRET_KEY", () => + it.effect("honors SUPABASE_EXPERIMENTAL_S3_HOST/_REGION/_ACCESS_KEY/_SECRET_KEY", () => withEnvVar( "SUPABASE_DB_ORIOLEDB_VERSION", "16.0.0.1", @@ -4594,7 +4619,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("Kong's embedded default TLS cert/key", () => { - it.live( + it.effect( "writes the embedded default cert/key when TLS is unconfigured, never empty files", () => Effect.gen(function* () { @@ -4614,7 +4639,7 @@ content_path = "./supabase/templates/custom_notice.html" // An empty but present `cert_path`/`key_path` is treated the same as absent — it must not // attempt a disk read. - it.live( + it.effect( "falls back to the embedded default cert/key when cert_path/key_path are present but empty", () => Effect.gen(function* () { @@ -4636,46 +4661,48 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_API_TLS_CERT_PATH/_KEY_PATH overrides", () => { - it.live("reads the env-overridden cert/key paths for Kong, not the (absent) TOML fields", () => - withEnvVar( - "SUPABASE_API_TLS_CERT_PATH", - "certs/env-server.crt", + it.effect( + "reads the env-overridden cert/key paths for Kong, not the (absent) TOML fields", + () => withEnvVar( - "SUPABASE_API_TLS_KEY_PATH", - "certs/env-server.key", - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const copied = new Map(); - const { layer, workdir, child } = yield* setup({ - configContents: 'project_id = "demo"\n[api.tls]\nenabled = true\n', - onSecretCopy: (containerPath, content) => { - copied.set(containerPath, content); - }, - }); - yield* fs.makeDirectory(path.join(workdir, "supabase", "certs"), { - recursive: true, - }); - yield* fs.writeFileString( - path.join(workdir, "supabase", "certs", "env-server.crt"), - "-----BEGIN CERTIFICATE-----env-cert", - ); - yield* fs.writeFileString( - path.join(workdir, "supabase", "certs", "env-server.key"), - "-----BEGIN PRIVATE KEY-----env-key", - ); + "SUPABASE_API_TLS_CERT_PATH", + "certs/env-server.crt", + withEnvVar( + "SUPABASE_API_TLS_KEY_PATH", + "certs/env-server.key", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const copied = new Map(); + const { layer, workdir, child } = yield* setup({ + configContents: 'project_id = "demo"\n[api.tls]\nenabled = true\n', + onSecretCopy: (containerPath, content) => { + copied.set(containerPath, content); + }, + }); + yield* fs.makeDirectory(path.join(workdir, "supabase", "certs"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "certs", "env-server.crt"), + "-----BEGIN CERTIFICATE-----env-cert", + ); + yield* fs.writeFileString( + path.join(workdir, "supabase", "certs", "env-server.key"), + "-----BEGIN PRIVATE KEY-----env-key", + ); - yield* start(flags()).pipe(Effect.provide(layer)); - expect(child.spawned.some((s) => s.args[0] === "create")).toBe(true); - expect(copied.get("/home/kong/localhost.crt")).toBe( - "-----BEGIN CERTIFICATE-----env-cert", - ); - expect(copied.get("/home/kong/localhost.key")).toBe( - "-----BEGIN PRIVATE KEY-----env-key", - ); - }).pipe(Effect.provide(BunServices.layer)), - ), - ).pipe(Effect.provide(BunServices.layer)), + yield* start(flags()).pipe(Effect.provide(layer)); + expect(child.spawned.some((s) => s.args[0] === "create")).toBe(true); + expect(copied.get("/home/kong/localhost.crt")).toBe( + "-----BEGIN CERTIFICATE-----env-cert", + ); + expect(copied.get("/home/kong/localhost.key")).toBe( + "-----BEGIN PRIVATE KEY-----env-key", + ); + }).pipe(Effect.provide(BunServices.layer)), + ), + ).pipe(Effect.provide(BunServices.layer)), ); }); @@ -4683,7 +4710,7 @@ content_path = "./supabase/templates/custom_notice.html" // The entire TLS cert/key disk read is nested inside the API-enabled check, so when API is // disabled, Kong keeps its embedded default cert/key regardless of // `api.tls.enabled`/cert_path/key_path. - it.live( + it.effect( "skips the configured cert/key read for Kong when API is disabled only via env override", () => withEnvVar( @@ -4719,7 +4746,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a typed config error, before any container is created, on an invalid SUPABASE_API_ENABLED", () => withEnvVar( @@ -4740,7 +4767,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_AUTH_JWT_EXPIRY reaches Postgres init", () => { - it.live("honors the override for Postgres's JWT_EXP, not just GoTrue's GOTRUE_JWT_EXP", () => + it.effect("honors the override for Postgres's JWT_EXP, not just GoTrue's GOTRUE_JWT_EXP", () => withEnvVar( "SUPABASE_AUTH_JWT_EXPIRY", "7200", @@ -4762,7 +4789,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("encrypted secrets reach GoTrue's container", () => { - it.live("decrypts an encrypted external OAuth provider secret (known provider)", () => + it.effect("decrypts an encrypted external OAuth provider secret (known provider)", () => withEnvVar( "DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY, @@ -4780,7 +4807,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "decrypts an encrypted external OAuth provider secret (custom/unmodeled provider)", () => withEnvVar( @@ -4801,7 +4828,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live("decrypts an encrypted Twilio SMS auth_token", () => + it.effect("decrypts an encrypted Twilio SMS auth_token", () => withEnvVar( "DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY, @@ -4821,31 +4848,33 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("Edge Runtime secrets", () => { - it.live("resolves configured [edge_runtime.secrets] into the runtime's env, not dropped", () => - Effect.gen(function* () { - const { layer, child } = yield* setup({ - configContents: - 'project_id = "demo"\n[edge_runtime.secrets]\nMY_SECRET = "shh-do-not-tell"\nmy_lower_secret = "keep-me"\nEMPTY_SECRET = ""\n', - }); + it.effect( + "resolves configured [edge_runtime.secrets] into the runtime's env, not dropped", + () => + Effect.gen(function* () { + const { layer, child } = yield* setup({ + configContents: + 'project_id = "demo"\n[edge_runtime.secrets]\nMY_SECRET = "shh-do-not-tell"\nmy_lower_secret = "keep-me"\nEMPTY_SECRET = ""\n', + }); - const fs = yield* FileSystem.FileSystem; - yield* start(flags()).pipe(Effect.provide(layer)); - const edgeRuntimeRunCall = child.spawned.find((s) => isEdgeRuntimeCreate(s.args)); - const args = edgeRuntimeRunCall?.args ?? []; - const envFileIndex = args.indexOf("--env-file"); - const envFilePath = envFileIndex !== -1 ? args[envFileIndex + 1] : undefined; - expect(envFilePath).toBeDefined(); - const envFileContent = yield* fs.readFileString(envFilePath ?? ""); - expect(envFileContent).toContain("MY_SECRET=shh-do-not-tell"); - // Names reach the container uppercased, and empty values are skipped — shared with - // `functions serve` via `toPlainEdgeRuntimeConfig`. - expect(envFileContent).toContain("MY_LOWER_SECRET=keep-me"); - expect(envFileContent).not.toContain("my_lower_secret="); - expect(envFileContent).not.toContain("EMPTY_SECRET="); - }).pipe(Effect.provide(BunServices.layer)), + const fs = yield* FileSystem.FileSystem; + yield* start(flags()).pipe(Effect.provide(layer)); + const edgeRuntimeRunCall = child.spawned.find((s) => isEdgeRuntimeCreate(s.args)); + const args = edgeRuntimeRunCall?.args ?? []; + const envFileIndex = args.indexOf("--env-file"); + const envFilePath = envFileIndex !== -1 ? args[envFileIndex + 1] : undefined; + expect(envFilePath).toBeDefined(); + const envFileContent = yield* fs.readFileString(envFilePath ?? ""); + expect(envFileContent).toContain("MY_SECRET=shh-do-not-tell"); + // Names reach the container uppercased, and empty values are skipped — shared with + // `functions serve` via `toPlainEdgeRuntimeConfig`. + expect(envFileContent).toContain("MY_LOWER_SECRET=keep-me"); + expect(envFileContent).not.toContain("my_lower_secret="); + expect(envFileContent).not.toContain("EMPTY_SECRET="); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "decrypts an encrypted [edge_runtime.secrets] entry into plaintext, not the raw ciphertext", () => // Mirrors "encrypted secrets reach GoTrue's container" above, for `edge_runtime.secrets`. @@ -4871,7 +4900,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a typed config error, before any container is created, on an undecryptable [edge_runtime.secrets] entry", () => // Caught eagerly by `checkDbToml`'s `assertDecryptableSecrets` pre-check, before the @@ -4898,7 +4927,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_API_* env overrides reach PostgREST and Studio", () => { - it.live("honors SUPABASE_API_SCHEMAS/_EXTRA_SEARCH_PATH/_MAX_ROWS in both containers", () => + it.effect("honors SUPABASE_API_SCHEMAS/_EXTRA_SEARCH_PATH/_MAX_ROWS in both containers", () => withEnvVar( "SUPABASE_API_SCHEMAS", "public,custom", @@ -4933,7 +4962,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a typed config error, before any container is created, on an invalid SUPABASE_API_MAX_ROWS", () => withEnvVar( @@ -4954,7 +4983,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_DB_POOLER_* env overrides reach Supavisor", () => { - it.live("SUPABASE_DB_POOLER_POOL_MODE=session flips the published host port to 5432", () => + it.effect("SUPABASE_DB_POOLER_POOL_MODE=session flips the published host port to 5432", () => withEnvVar( "SUPABASE_DB_POOLER_POOL_MODE", "session", @@ -4976,7 +5005,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a typed config error, before any container is created, on an invalid SUPABASE_DB_POOLER_POOL_MODE", () => withEnvVar( @@ -4997,7 +5026,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a typed config error, before any container is created, on an invalid SUPABASE_REALTIME_ENABLED", () => withEnvVar( @@ -5016,7 +5045,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live("SUPABASE_DB_POOLER_PORT overrides the published host port", () => + it.effect("SUPABASE_DB_POOLER_PORT overrides the published host port", () => withEnvVar( "SUPABASE_DB_POOLER_PORT", "60001", @@ -5040,7 +5069,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_ANALYTICS_PORT override", () => { - it.live("overrides the published Logflare host port", () => + it.effect("overrides the published Logflare host port", () => withEnvVar( "SUPABASE_ANALYTICS_PORT", "60002", @@ -5058,7 +5087,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with a typed config error, before any container is created, on an invalid SUPABASE_ANALYTICS_VECTOR_PORT", () => // `analytics.vector_port` decodes in the same Config.Load pass as `analytics.port` @@ -5117,7 +5146,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("auth.hook.* env overrides reach GoTrue's container", () => { - it.live("honors SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED/_URI", () => + it.effect("honors SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED/_URI", () => withEnvVar( "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED", "true", @@ -5149,7 +5178,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("auth.captcha.* env overrides reach GoTrue's container", () => { - it.live("honors SUPABASE_AUTH_CAPTCHA_ENABLED/_PROVIDER", () => + it.effect("honors SUPABASE_AUTH_CAPTCHA_ENABLED/_PROVIDER", () => withEnvVar( "SUPABASE_AUTH_CAPTCHA_ENABLED", "true", @@ -5176,7 +5205,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("nested auth security env overrides reach GoTrue's container", () => { - it.live("honors SUPABASE_AUTH_SESSIONS_TIMEBOX", () => + it.effect("honors SUPABASE_AUTH_SESSIONS_TIMEBOX", () => withEnvVar( "SUPABASE_AUTH_SESSIONS_TIMEBOX", "24h", @@ -5192,7 +5221,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live("honors SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED/_VERIFY_ENABLED", () => + it.effect("honors SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED/_VERIFY_ENABLED", () => withEnvVar( "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", "true", @@ -5216,7 +5245,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live("honors SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", () => + it.effect("honors SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", () => withEnvVar( "SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", "99", @@ -5232,7 +5261,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live("honors SUPABASE_AUTH_WEB3_SOLANA_ENABLED", () => + it.effect("honors SUPABASE_AUTH_WEB3_SOLANA_ENABLED", () => withEnvVar( "SUPABASE_AUTH_WEB3_SOLANA_ENABLED", "true", @@ -5248,7 +5277,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live("honors SUPABASE_AUTH_OAUTH_SERVER_ENABLED", () => + it.effect("honors SUPABASE_AUTH_OAUTH_SERVER_ENABLED", () => withEnvVar( "SUPABASE_AUTH_OAUTH_SERVER_ENABLED", "true", @@ -5266,7 +5295,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("auth.passkey/auth.webauthn env overrides reach GoTrue's container", () => { - it.live("honors SUPABASE_AUTH_PASSKEY_ENABLED", () => + it.effect("honors SUPABASE_AUTH_PASSKEY_ENABLED", () => withEnvVar( "SUPABASE_AUTH_PASSKEY_ENABLED", "true", @@ -5285,7 +5314,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live("honors SUPABASE_AUTH_WEBAUTHN_RP_ID/_RP_DISPLAY_NAME/_RP_ORIGINS", () => + it.effect("honors SUPABASE_AUTH_WEBAUTHN_RP_ID/_RP_DISPLAY_NAME/_RP_ORIGINS", () => withEnvVar( "SUPABASE_AUTH_WEBAUTHN_RP_ID", "env-rp-id", @@ -5317,7 +5346,7 @@ content_path = "./supabase/templates/custom_notice.html" ).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "coerces an env(...)-resolved passkey enabled string instead of reading it as disabled", () => // `auth.passkey`/`auth.webauthn` have no `@supabase/config` schema, so the pre-decode @@ -5345,7 +5374,7 @@ content_path = "./supabase/templates/custom_notice.html" ), ); - it.live( + it.effect( "splits an env(...)-resolved comma-separated rp_origins string instead of dropping it to []", () => withEnvVar( @@ -5371,7 +5400,7 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_EDGE_RUNTIME_POLICY override", () => { - it.live("honors the env-overridden Edge Runtime request policy", () => + it.effect("honors the env-overridden Edge Runtime request policy", () => withEnvVar( "SUPABASE_EDGE_RUNTIME_POLICY", "per_worker", diff --git a/apps/cli/src/commands/start/start.services.unit.test.ts b/apps/cli/src/commands/start/start.services.unit.test.ts index b34feac5c3..98d7f7b7ac 100644 --- a/apps/cli/src/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/commands/start/start.services.unit.test.ts @@ -1,6 +1,6 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; -import { Schema } from "effect"; -import { describe, expect, it } from "vitest"; +import { Predicate, Schema } from "effect"; +import { assert, describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; @@ -145,13 +145,16 @@ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => const overrides: Record = {}; for (const atom of GATE_ATOMS) { const segments = atom.split("."); + const leaf = segments.pop(); + assert(leaf !== undefined); let node = overrides; - for (let index = 0; index < segments.length - 1; index++) { - const segment = segments[index]!; + for (const segment of segments) { node[segment] ??= {}; - node = node[segment] as Record; + const child = node[segment]; + assert(Predicate.isObject(child)); + node = child; } - node[segments.at(-1)!] = enabled.has(atom); + node[leaf] = enabled.has(atom); } return decodeConfig({ project_id: "start-services-gate-cross-check", ...overrides }); } @@ -160,8 +163,7 @@ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => return path .split(".") .reduce( - (value, key) => - value && typeof value === "object" ? (value as Record)[key] : undefined, + (value, key) => (Predicate.hasProperty(value, key) ? value[key] : undefined), config, ); } @@ -187,12 +189,11 @@ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => } function expectGatesMatchMetadata(config: CliConfig, label: string) { - const realGates = realGatesFor(config); - for (const service of Object.keys(realGates) as ReadonlyArray) { + for (const [service, gate] of Object.entries(realGatesFor(config))) { const meta = startServiceMeta(service); - expect(meta, `start.services.ts is missing metadata for "${service}"`).toBeDefined(); - const expected = evaluateEnabledGate(meta!.enabledGate, config); - expect(realGates[service], `${service} (${label})`).toBe(expected); + assert(meta !== undefined, `start.services.ts is missing metadata for "${service}"`); + const expected = evaluateEnabledGate(meta.enabledGate, config); + expect(gate, `${service} (${label})`).toBe(expected); } } diff --git a/apps/cli/src/commands/start/start.string-slice-flags.integration.test.ts b/apps/cli/src/commands/start/start.string-slice-flags.integration.test.ts index 4942eb3cf1..b57b1634c2 100644 --- a/apps/cli/src/commands/start/start.string-slice-flags.integration.test.ts +++ b/apps/cli/src/commands/start/start.string-slice-flags.integration.test.ts @@ -75,7 +75,7 @@ describe("start --exclude flag (pflag CSV parity)", () => { ]; for (const { name, flag } of spellings) { - it.live( + it.effect( `${name}: malformed CSV fails at parse time with pflag's shorthand-framed diagnostic`, () => { const { layer } = setup(); diff --git a/apps/cli/src/commands/status/status.command.unit.test.ts b/apps/cli/src/commands/status/status.command.unit.test.ts index 02f4dac0cc..e5ccd939ab 100644 --- a/apps/cli/src/commands/status/status.command.unit.test.ts +++ b/apps/cli/src/commands/status/status.command.unit.test.ts @@ -5,7 +5,7 @@ import { normalizeCause } from "../../shared/output/normalize-error.ts"; import { statusExcludeFlag, statusOverrideNameFlag } from "./status.command.ts"; describe("status --override-name flag (pflag StringSlice parity)", () => { - it.live("splits a comma-separated value into multiple overrides", () => + it.effect("splits a comma-separated value into multiple overrides", () => Effect.gen(function* () { const [, overrideName] = yield* statusOverrideNameFlag .parse({ @@ -18,7 +18,7 @@ describe("status --override-name flag (pflag StringSlice parity)", () => { }), ); - it.live("accumulates repeated occurrences, each CSV-split", () => + it.effect("accumulates repeated occurrences, each CSV-split", () => Effect.gen(function* () { const [, overrideName] = yield* statusOverrideNameFlag .parse({ @@ -31,7 +31,7 @@ describe("status --override-name flag (pflag StringSlice parity)", () => { }), ); - it.live("defaults to an empty array when unset", () => + it.effect("defaults to an empty array when unset", () => Effect.gen(function* () { const [, overrideName] = yield* statusOverrideNameFlag .parse({ flags: {}, arguments: [] }) @@ -41,7 +41,7 @@ describe("status --override-name flag (pflag StringSlice parity)", () => { }), ); - it.live("keeps only the first CSV record of a multiline value (pflag reads ONE record)", () => + it.effect("keeps only the first CSV record of a multiline value (pflag reads ONE record)", () => Effect.gen(function* () { const [, overrideName] = yield* statusOverrideNameFlag .parse({ flags: { "override-name": ['a=1\nb"2'] }, arguments: [] }) @@ -51,7 +51,7 @@ describe("status --override-name flag (pflag StringSlice parity)", () => { }), ); - it.live("rejects malformed CSV (unterminated quote) with pflag's exact diagnostic", () => + it.effect("rejects malformed CSV (unterminated quote) with pflag's exact diagnostic", () => Effect.gen(function* () { const exit = yield* statusOverrideNameFlag .parse({ flags: { "override-name": ['"api.url=FOO'] }, arguments: [] }) @@ -66,7 +66,7 @@ describe("status --override-name flag (pflag StringSlice parity)", () => { }), ); - it.live("rejects a blank-only value with pflag's EOF diagnostic", () => + it.effect("rejects a blank-only value with pflag's EOF diagnostic", () => Effect.gen(function* () { const exit = yield* statusOverrideNameFlag .parse({ flags: { "override-name": ["\n"] }, arguments: [] }) @@ -83,7 +83,7 @@ describe("status --override-name flag (pflag StringSlice parity)", () => { }); describe("status --exclude flag (pflag StringSlice parity)", () => { - it.live("splits a comma-separated value into multiple exclusions", () => + it.effect("splits a comma-separated value into multiple exclusions", () => Effect.gen(function* () { const [, exclude] = yield* statusExcludeFlag .parse({ flags: { exclude: ["kong,auth"] }, arguments: [] }) @@ -93,7 +93,7 @@ describe("status --exclude flag (pflag StringSlice parity)", () => { }), ); - it.live("defaults to an empty array when unset", () => + it.effect("defaults to an empty array when unset", () => Effect.gen(function* () { const [, exclude] = yield* statusExcludeFlag .parse({ flags: {}, arguments: [] }) @@ -103,7 +103,7 @@ describe("status --exclude flag (pflag StringSlice parity)", () => { }), ); - it.live("rejects malformed CSV (bare quote) with pflag's exact diagnostic", () => + it.effect("rejects malformed CSV (bare quote) with pflag's exact diagnostic", () => Effect.gen(function* () { const exit = yield* statusExcludeFlag .parse({ flags: { exclude: ['a"b'] }, arguments: [] }) diff --git a/apps/cli/src/commands/status/status.handler.ts b/apps/cli/src/commands/status/status.handler.ts index aedb4812a3..d9933715d1 100644 --- a/apps/cli/src/commands/status/status.handler.ts +++ b/apps/cli/src/commands/status/status.handler.ts @@ -57,19 +57,15 @@ import type { StatusFlags } from "./status.command.ts"; * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a `fieldKey -> outputName` * map. Each entry must be `KEY=VALUE`; an unknown `KEY` is silently ignored, not an error. */ -function parseOverrides( - entries: ReadonlyArray, -): Effect.Effect, StatusOverrideParseError> { +const parseOverrides = Effect.fnUntraced(function* (entries: ReadonlyArray) { const knownKeys = new Set(STATUS_FIELDS.map((field) => field.fieldKey)); const overrides = new Map(); for (const entry of entries) { const separatorIndex = entry.indexOf("="); if (separatorIndex <= 0) { - return Effect.fail( - new StatusOverrideParseError({ - message: `invalid override-name entry, expected KEY=VALUE: ${entry}`, - }), - ); + return yield* new StatusOverrideParseError({ + message: `invalid override-name entry, expected KEY=VALUE: ${entry}`, + }); } const key = entry.slice(0, separatorIndex); const value = entry.slice(separatorIndex + 1); @@ -78,8 +74,8 @@ function parseOverrides( } overrides.set(key, value); } - return Effect.succeed(overrides); -} + return overrides; +}); /** The established `"Stopped services:", stopped` slice format. */ function formatGoStringSlice(items: ReadonlyArray): string { diff --git a/apps/cli/src/commands/status/status.integration.test.ts b/apps/cli/src/commands/status/status.integration.test.ts index c9f3f20f05..854ebe8421 100644 --- a/apps/cli/src/commands/status/status.integration.test.ts +++ b/apps/cli/src/commands/status/status.integration.test.ts @@ -1,6 +1,6 @@ import { generateKeyPairSync } from "node:crypto"; -import type { ApiClient, V1ListAllBranchesOutput } from "@supabase/api/effect"; +import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { @@ -18,7 +18,7 @@ import { Stdio, Stream, } from "effect"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import type * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequestModule from "effect/http/HttpClientRequest"; import { vi } from "vitest"; @@ -35,6 +35,7 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import type { CommandPlatformApiFactoryError } from "../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; +import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; import { runtimeInfoLayer } from "../../shared/runtime/runtime-info.layer.ts"; import { AccessTokenRequiredError } from "../../auth/errors.ts"; import { OutputFlag } from "../../command-internal/global-flags.ts"; @@ -166,33 +167,18 @@ function mockCommandPlatformApiFactoryDirect(opts: { * succeed and fails the `listAllBranches` call instead. */ readonly makeFails?: CommandPlatformApiFactoryError; }) { - const requests: Array<{ method: string; input: unknown }> = []; - const v1Proxy = new Proxy({} as ApiClient["v1"], { - get(_target, prop: string) { - return (input: unknown) => - Effect.gen(function* () { - requests.push({ method: prop, input }); - if (prop !== "listAllBranches") { - return yield* Effect.die(`Unmocked factory-backed CommandPlatformApi.v1.${prop}`); - } - if (opts.fail !== undefined) return yield* Effect.fail(opts.fail); - return opts.ok ?? []; - }); - }, - }); - const v2Proxy = new Proxy({} as ApiClient["v2"], { - get(_target, prop: string) { - return () => Effect.die(`Unmocked factory-backed CommandPlatformApi.v2.${prop}`); + const api = mockCommandPlatformApiService({ + v1: { + listAllBranches: () => + opts.fail !== undefined ? Effect.fail(opts.fail) : Effect.succeed(opts.ok ?? []), }, }); - const client = { - v1: v1Proxy, - v2: v2Proxy, - executeRaw: () => Effect.die("Unmocked executeRaw"), - } as ApiClient; - const make = opts.makeFails !== undefined ? Effect.fail(opts.makeFails) : Effect.succeed(client); + const make = + opts.makeFails !== undefined + ? Effect.fail(opts.makeFails) + : CommandPlatformApi.pipe(Effect.provide(api.layer)); const layer = Layer.succeed(CommandPlatformApiFactory, { make }); - return { layer, requests }; + return { layer, requests: api.requests }; } interface SpawnRecord { @@ -220,8 +206,9 @@ function mockRoutedContainerCliSpawner( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.gen(function* () { - const cmd = command._tag === "StandardCommand" ? command.command : ""; - const args = command._tag === "StandardCommand" ? command.args : []; + const standard = ChildProcess.isStandardCommand(command); + const cmd = standard ? command.command : ""; + const args = standard ? command.args : []; spawned.push({ command: cmd, args }); if (opts.dockerMissing === true && cmd === "docker") { @@ -246,13 +233,11 @@ function mockRoutedContainerCliSpawner( const result = route(args); const exitDeferred = yield* Deferred.make(); yield* Effect.forkDetach( - Effect.gen(function* () { - yield* Effect.sleep("5 millis"); - yield* Deferred.succeed( - exitDeferred, - ChildProcessSpawner.ExitCode(result.exitCode ?? 0), - ); - }), + Effect.yieldNow.pipe( + Effect.andThen( + Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode ?? 0)), + ), + ), ); const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); @@ -459,7 +444,7 @@ function setupFailureEnvelope(opts: FailureEnvelopeOpts) { } describe("status integration", () => { - it.live("shows the running stack as a pretty table", () => { + it.effect("shows the running stack as a pretty table", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* writeConfig(); @@ -475,7 +460,7 @@ describe("status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "sanitizes a dirty config.toml project_id before filtering, matching start's label", () => { const { layer, child } = setup(); @@ -492,7 +477,7 @@ describe("status integration", () => { }, ); - it.live("skips the db health check with --ignore-health-check", () => { + it.effect("skips the db health check with --ignore-health-check", () => { const { layer, child } = setup({ route: (args) => { // db inspect would fail if called; ps still needs to succeed. @@ -510,7 +495,7 @@ describe("status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "succeeds against an unhealthy db when --ignore-health-check is set (status.go:104-108)", () => { // Pairs with "fails when the db container is unhealthy" below to cover both sides of @@ -534,7 +519,7 @@ describe("status integration", () => { }, ); - it.live("reports stopped services on stderr", () => { + it.effect("reports stopped services on stderr", () => { const { layer, out } = setup({ route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), }); @@ -546,7 +531,7 @@ describe("status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when config.toml is malformed", () => { + it.effect("fails when config.toml is malformed", () => { const { layer, child } = setup(); return Effect.gen(function* () { yield* writeConfig("not valid toml ====="); @@ -559,7 +544,7 @@ describe("status integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { + it.effect("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { const { layer, child } = setup(); return Effect.gen(function* () { yield* writeConfig( @@ -581,7 +566,7 @@ project_id = "previewrefaaaaaaaaaa" }).pipe(Effect.provide(layer)); }); - it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { + it.effect("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { // Config validation checks every [remotes.*].project_id // against the ref pattern unconditionally on every config load — not only a // remote that ends up selected — so this must fail closed before status @@ -604,7 +589,7 @@ project_id = "short" }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "decodes a comma-separated string into an array field ([]string) for status to proceed", () => { const { layer } = setup(); @@ -617,7 +602,7 @@ project_id = "short" }, ); - it.live("warns on stderr for a deprecated auth.external provider", () => { + it.effect("warns on stderr for a deprecated auth.external provider", () => { // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits this warning via // `Console.error` only when `goViperCompat` is set. const { layer } = setup(); @@ -631,7 +616,7 @@ project_id = "short" }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => errorSpy.mockRestore()))); }); - it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => + it.effect("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => Effect.gen(function* () { // Must fail before falling through to the workdir-basename default. const path = yield* Path.Path; @@ -650,7 +635,7 @@ project_id = "short" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => + it.effect("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => Effect.gen(function* () { const path = yield* Path.Path; const filePath = path.join(tempRoot.current, "not-a-directory"); @@ -667,7 +652,7 @@ project_id = "short" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when auth.jwt_secret is configured but shorter than 16 characters", () => { + it.effect("fails when auth.jwt_secret is configured but shorter than 16 characters", () => { const { layer, child } = setup(); return withEnvVar( "SUPABASE_AUTH_JWT_SECRET", @@ -688,7 +673,7 @@ project_id = "short" ); }); - it.live("resolves auth email content_path keys from the same project-root base", () => { + it.effect("resolves auth email content_path keys from the same project-root base", () => { const { layer, child, workdir } = setup(); return Effect.gen(function* () { yield* writeConfig(`project_id = "demo" @@ -712,7 +697,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("honors SUPABASE_AUTH_JWT_SECRET over a config.toml value with -o env", () => { + it.effect("honors SUPABASE_AUTH_JWT_SECRET over a config.toml value with -o env", () => { const { layer, out } = setup({ goOutput: Option.some("env") }); return withEnvVar( "SUPABASE_AUTH_JWT_SECRET", @@ -726,7 +711,7 @@ content_path = "./supabase/templates/password_changed_notification.html" ); }); - it.live("signs anon/service_role keys asymmetrically when signing_keys_path is set", () => { + it.effect("signs anon/service_role keys asymmetrically when signing_keys_path is set", () => { // Uses the first key in `auth.signing_keys_path` (RS256/ES256) instead of HMAC. const { layer, out, workdir } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { @@ -742,7 +727,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("reports status using schema defaults when config.toml is missing entirely", () => + it.effect("reports status using schema defaults when config.toml is missing entirely", () => Effect.gen(function* () { // Without config.toml, the resolved project id falls back to the workdir basename, not the // module-level `ALL_RUNNING_NAMES` (fixed to "demo") — route `ps` off that basename instead. @@ -758,7 +743,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { + it.effect("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { const { layer, child } = setup({ route: defaultRoute({ runningNames: serviceContainerIds("env-file-project") }), }); @@ -781,7 +766,7 @@ content_path = "./supabase/templates/password_changed_notification.html" ); }); - it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { + it.effect("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { const { layer, child } = setup({ route: defaultRoute({ runningNames: serviceContainerIds("ambient-project") }), }); @@ -804,7 +789,7 @@ content_path = "./supabase/templates/password_changed_notification.html" ); }); - it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { + it.effect("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { const { layer, child } = setup({ route: defaultRoute({ runningNames: serviceContainerIds("root-env-project") }), }); @@ -823,7 +808,7 @@ content_path = "./supabase/templates/password_changed_notification.html" ); }); - it.live( + it.effect( "does not climb to an ancestor project's config.toml when workdir has none of its own", () => Effect.gen(function* () { @@ -846,48 +831,54 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { - const { layer, child } = setup({ - route: defaultRoute({ runningNames: serviceContainerIds("no-config-project") }), - }); - return withEnvVar( - "SUPABASE_PROJECT_ID", - undefined, - Effect.gen(function* () { - yield* writeSupabaseFile( - tempRoot.current, - ".env", - "SUPABASE_PROJECT_ID=no-config-project\n", - ); - yield* status(flags()); - const inspectCall = child.spawned.find( - (s) => s.args[0] === "container" && s.args[1] === "inspect", - ); - expect(inspectCall?.args).toContain(localDbContainerId("no-config-project")); - }).pipe(Effect.provide(layer)), - ); - }); + it.effect( + "resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", + () => { + const { layer, child } = setup({ + route: defaultRoute({ runningNames: serviceContainerIds("no-config-project") }), + }); + return withEnvVar( + "SUPABASE_PROJECT_ID", + undefined, + Effect.gen(function* () { + yield* writeSupabaseFile( + tempRoot.current, + ".env", + "SUPABASE_PROJECT_ID=no-config-project\n", + ); + yield* status(flags()); + const inspectCall = child.spawned.find( + (s) => s.args[0] === "container" && s.args[1] === "inspect", + ); + expect(inspectCall?.args).toContain(localDbContainerId("no-config-project")); + }).pipe(Effect.provide(layer)), + ); + }, + ); - it.live("honors SUPABASE_AUTH_JWT_SECRET from supabase/.env, not just the ambient shell", () => { - const { layer, out } = setup({ goOutput: Option.some("env") }); - return withEnvVar( - "SUPABASE_AUTH_JWT_SECRET", - undefined, - Effect.gen(function* () { - yield* writeConfig(`project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`); - yield* writeSupabaseFile( - tempRoot.current, - ".env", - `SUPABASE_AUTH_JWT_SECRET=${"c".repeat(32)}\n`, - ); - yield* status(flags()); - expect(out.stdoutText).toContain(`JWT_SECRET="${"c".repeat(32)}"`); - expect(out.stdoutText).not.toContain("a".repeat(32)); - }).pipe(Effect.provide(layer)), - ); - }); + it.effect( + "honors SUPABASE_AUTH_JWT_SECRET from supabase/.env, not just the ambient shell", + () => { + const { layer, out } = setup({ goOutput: Option.some("env") }); + return withEnvVar( + "SUPABASE_AUTH_JWT_SECRET", + undefined, + Effect.gen(function* () { + yield* writeConfig(`project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`); + yield* writeSupabaseFile( + tempRoot.current, + ".env", + `SUPABASE_AUTH_JWT_SECRET=${"c".repeat(32)}\n`, + ); + yield* status(flags()); + expect(out.stdoutText).toContain(`JWT_SECRET="${"c".repeat(32)}"`); + expect(out.stdoutText).not.toContain("a".repeat(32)); + }).pipe(Effect.provide(layer)), + ); + }, + ); - it.live("fails when both docker and podman are missing", () => { + it.effect("fails when both docker and podman are missing", () => { const { layer } = setup({ failSpawnFor: () => true }); return Effect.gen(function* () { yield* writeConfig(); @@ -899,7 +890,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("falls back to podman when docker is absent", () => { + it.effect("falls back to podman when docker is absent", () => { const { layer, child } = setup({ dockerMissing: true }); return Effect.gen(function* () { yield* writeConfig(); @@ -912,7 +903,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("fails when listing running containers errors", () => { + it.effect("fails when listing running containers errors", () => { const { layer } = setup({ route: (args) => { if (args[0] === "container" && args[1] === "inspect") { @@ -932,7 +923,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("fails when the db container is not running", () => { + it.effect("fails when the db container is not running", () => { const { layer } = setup({ route: defaultRoute({ dbInspectStdout: '{"Status":"exited","Running":false}', @@ -950,7 +941,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "succeeds against a paused-but-healthy db, matching Go's boolean-based running gate", () => { // Gates on the boolean `Running`, not the status string — `Running: true` can coexist @@ -967,7 +958,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live("fails when the db container is absent, preserving the real Docker stderr text", () => { + it.effect("fails when the db container is absent, preserving the real Docker stderr text", () => { const { layer } = setup({ route: defaultRoute({ dbInspectExitCode: 1, @@ -988,7 +979,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("fails when the db container is unhealthy", () => { + it.effect("fails when the db container is unhealthy", () => { const { layer } = setup({ route: defaultRoute({ dbInspectStdout: '{"Status":"running","Running":true,"Health":{"Status":"starting"}}', @@ -1004,7 +995,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("fails when db inspect errors for a reason other than not-found", () => { + it.effect("fails when db inspect errors for a reason other than not-found", () => { const { layer } = setup({ route: defaultRoute({ dbInspectExitCode: 1, dbInspectStderr: ["permission denied"] }), }); @@ -1018,7 +1009,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("outputs env vars with -o env", () => { + it.effect("outputs env vars with -o env", () => { const { layer, out } = setup({ goOutput: Option.some("env") }); return Effect.gen(function* () { yield* writeConfig(); @@ -1028,7 +1019,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("prints API_URL but no REST_URL with -o env when [api] enabled = false", () => { + it.effect("prints API_URL but no REST_URL with -o env when [api] enabled = false", () => { const { layer, out } = setup({ goOutput: Option.some("env"), route: defaultRoute({ @@ -1043,7 +1034,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("outputs a json object with -o json", () => { + it.effect("outputs a json object with -o json", () => { const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* writeConfig(); @@ -1054,11 +1045,11 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("omits excluded services from -o json", () => { + it.effect("omits excluded services from -o json", () => { const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* writeConfig(); - const storageId = serviceContainerIds("demo")[5]!; + const storageId = serviceContainerIds("demo")[5] ?? ""; yield* status(flags({ exclude: [storageId] })); const parsed = yield* goOutputFields(out.stdoutText); expect(parsed.STORAGE_S3_URL).toBeUndefined(); @@ -1066,12 +1057,12 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("omits every service named across multiple --exclude entries", () => { + it.effect("omits every service named across multiple --exclude entries", () => { const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* writeConfig(); - const authId = serviceContainerIds("demo")[1]!; - const storageId = serviceContainerIds("demo")[5]!; + const authId = serviceContainerIds("demo")[1] ?? ""; + const storageId = serviceContainerIds("demo")[5] ?? ""; yield* status(flags({ exclude: [authId, storageId] })); const parsed = yield* goOutputFields(out.stdoutText); expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); @@ -1080,24 +1071,27 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("merges an auto-detected stopped service with a --exclude entry (status.go:116)", () => { - const { layer, out } = setup({ - goOutput: Option.some("json"), - // kong (index 0) is absent from the running set, so it's auto-detected as stopped. - route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), - }); - return Effect.gen(function* () { - yield* writeConfig(); - const authId = serviceContainerIds("demo")[1]!; - yield* status(flags({ exclude: [authId] })); - const parsed = yield* goOutputFields(out.stdoutText); - expect(parsed.API_URL).toBeUndefined(); // excluded via the auto-detected stopped kong - expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); // excluded via --exclude - expect(parsed.DB_URL).toBeDefined(); // db.url is set unconditionally, before any gating - }).pipe(Effect.provide(layer)); - }); + it.effect( + "merges an auto-detected stopped service with a --exclude entry (status.go:116)", + () => { + const { layer, out } = setup({ + goOutput: Option.some("json"), + // kong (index 0) is absent from the running set, so it's auto-detected as stopped. + route: defaultRoute({ runningNames: ALL_RUNNING_NAMES.slice(1) }), + }); + return Effect.gen(function* () { + yield* writeConfig(); + const authId = serviceContainerIds("demo")[1] ?? ""; + yield* status(flags({ exclude: [authId] })); + const parsed = yield* goOutputFields(out.stdoutText); + expect(parsed.API_URL).toBeUndefined(); // excluded via the auto-detected stopped kong + expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); // excluded via --exclude + expect(parsed.DB_URL).toBeDefined(); // db.url is set unconditionally, before any gating + }).pipe(Effect.provide(layer)); + }, + ); - it.live("outputs yaml with -o yaml", () => { + it.effect("outputs yaml with -o yaml", () => { const { layer, out } = setup({ goOutput: Option.some("yaml") }); return Effect.gen(function* () { yield* writeConfig(); @@ -1106,7 +1100,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("outputs toml with -o toml", () => { + it.effect("outputs toml with -o toml", () => { const { layer, out } = setup({ goOutput: Option.some("toml") }); return Effect.gen(function* () { yield* writeConfig(); @@ -1115,7 +1109,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("remaps an output key with --override-name api.url=NEXT_PUBLIC_SUPABASE_URL", () => { + it.effect("remaps an output key with --override-name api.url=NEXT_PUBLIC_SUPABASE_URL", () => { const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* writeConfig(); @@ -1126,7 +1120,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("fails on a malformed --override-name entry", () => { + it.effect("fails on a malformed --override-name entry", () => { const { layer } = setup(); return Effect.gen(function* () { yield* writeConfig(); @@ -1138,7 +1132,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("silently ignores an --override-name entry with an unknown field key", () => { + it.effect("silently ignores an --override-name entry with an unknown field key", () => { const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* writeConfig(); @@ -1149,7 +1143,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("applies a valid --override-name entry alongside an unknown one", () => { + it.effect("applies a valid --override-name entry alongside an unknown one", () => { const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* writeConfig(); @@ -1162,7 +1156,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("emits a machine result with --output-format json when -o is unset", () => { + it.effect("emits a machine result with --output-format json when -o is unset", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { yield* writeConfig(); @@ -1173,7 +1167,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("-o takes priority over --output-format when both are passed", () => { + it.effect("-o takes priority over --output-format when both are passed", () => { const { layer, out } = setup({ format: "json", goOutput: Option.some("env") }); return Effect.gen(function* () { yield* writeConfig(); @@ -1184,7 +1178,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("lets --output pretty win over --output-format json", () => { + it.effect("lets --output pretty win over --output-format json", () => { // `-o pretty` is a complete format choice and must render the table, not defer to // --output-format. const { layer, out } = setup({ format: "json", goOutput: Option.some("pretty") }); @@ -1197,7 +1191,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry via ensuring even on failure", () => { + it.effect("flushes telemetry via ensuring even on failure", () => { const { layer, telemetry } = setup({ route: (args) => args[0] === "container" && args[1] === "inspect" ? { exitCode: 1 } : { exitCode: 0 }, @@ -1210,7 +1204,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }); describe("linked-state display (CLI-2167 follow-up)", () => { - it.live( + it.effect( "not linked: prints Not linked. as the first stdout line, then normal status output", () => { const { layer, out } = setup(); @@ -1223,7 +1217,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "linked to a real project: prints the Linked Project block (Org + Project) with zero Management API calls", () => { // A mock is wired here (unlike the "no layer at all" test below), so the empty @@ -1244,7 +1238,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "branch-linked with an api mock: prints the full block (Org + parent Project + resolved Branch) with a Checking linked branch... spinner", () => { const { layer, out, workdir } = setup({ branches: { ok: [LINKED_BRANCH] } }); @@ -1271,7 +1265,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "branch-linked with the production factory-fallback path (CommandPlatformApiFactory, not CommandPlatformApi directly): resolves the branch", () => { const { layer, out, workdir, apiFactoryMock } = setup({ @@ -1299,7 +1293,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "factory present but make() itself fails (e.g. no/invalid token): degrades the same as no API at all", () => { const { layer, out, workdir, apiFactoryMock } = setup({ @@ -1327,7 +1321,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "branch-linked with no Management API layer/factory at all: RICH degraded block (Org + parent Project + bare Branch ref), still succeeds", () => { const { layer, out, workdir } = setup(); @@ -1353,7 +1347,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "resolves the linked ref from SUPABASE_PROJECT_ID (env) when no project-ref file exists", () => { const { layer, out, workdir } = setup({ projectId: Option.some(LINKED_PLAIN_REF) }); @@ -1370,7 +1364,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "branch-linked with no cache file at all and no API confirmation: no-false-claim rule renders the bare project line only", () => { const { layer, out, workdir } = setup(); @@ -1389,7 +1383,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "no-false-claim rule: no cache, an API IS available but finds no matching branch — still the bare project line, no Branch/parent claim", () => { // Distinct from the "no API at all" test above: here a lookup runs and returns @@ -1412,7 +1406,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "no-false-claim rule, -o env: no matching branch found emits only LINKED_PROJECT_REF, no LINKED_BRANCH or LINKED_PARENT_PROJECT_REF", () => { const { layer, out, workdir } = setup({ @@ -1431,7 +1425,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "linked ref is non-ref-shaped: treated as not linked, the file content never reaches output (PR #6168 review)", () => { const { layer, out, workdir } = setup(); @@ -1445,7 +1439,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "-o json: a symlink/garbage project-ref file's content never reaches machine output (PR #6168 review, token-exfiltration vector)", () => { // A malicious worktree can symlink `supabase/.temp/project-ref` at a real token file; @@ -1463,7 +1457,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "linked to a real project with no cached name: bare Project value, Org line still shown", () => { const { layer, out, workdir } = setup(); @@ -1482,7 +1476,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "--output-format json, linked to a real (non-branch) project with no name: linked_project has project_ref + org fields but no project_name", () => { const { layer, out, workdir } = setup({ format: "json" }); @@ -1492,8 +1486,7 @@ content_path = "./supabase/templates/password_changed_notification.html" yield* writeLinkedProjectCacheFile(workdir, LINKED_PLAIN_REF); yield* status(flags()); const success = out.messages.find((m) => m.type === "success"); - const linkedProject = (success?.data as { linked_project?: Record }) - ?.linked_project; + const linkedProject = success?.data?.["linked_project"]; expect(linkedProject).toEqual({ project_ref: LINKED_PLAIN_REF, org_slug: "acme", @@ -1503,7 +1496,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "branch-linked with no cache at all: plain project block, ZERO API calls (cache alone is never proof of a link, PR #6168 review)", () => { // A mock is wired here (self-referential branch) so the zero-requests assertion is a @@ -1528,7 +1521,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "branch-linked with the branch lookup failing (status error): RICH degraded block, still succeeds", () => { const { layer, out, workdir } = setup({ @@ -1553,7 +1546,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "branch-linked with the branch lookup failing (transport error): RICH degraded block, still succeeds", () => { const { layer, out, workdir } = setup({ @@ -1578,7 +1571,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "-o env, branch-linked with the branch lookup failing: degraded machine payload still carries parent/name/org, only LINKED_BRANCH absent", () => { const { layer, out, workdir } = setup({ @@ -1602,7 +1595,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "-o json, branch-linked with the branch lookup failing: degraded machine payload still carries parent/name/org, only linked_branch absent", () => { const { layer, out, workdir } = setup({ @@ -1627,7 +1620,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "--output-format json, branch-linked with the branch lookup failing: structured linked_project still carries parent/name/org, no branch key", () => { const { layer, out, workdir } = setup({ @@ -1642,8 +1635,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }); yield* status(flags()); const success = out.messages.find((m) => m.type === "success"); - const linkedProject = (success?.data as { linked_project?: Record }) - ?.linked_project; + const linkedProject = success?.data?.["linked_project"]; expect(linkedProject).toEqual({ project_ref: LINKED_BRANCH_REF, parent_project_ref: LINKED_PARENT_REF, @@ -1655,7 +1647,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "failure preservation: a daemon connection failure still fails with its existing error, with the linked block already on stdout", () => { const { layer, out, workdir } = setup({ @@ -1681,7 +1673,7 @@ content_path = "./supabase/templates/password_changed_notification.html" // SUPABASE_PROJECT_ID (env) always wins before the project-ref file is ever read, so none // of these write a project-ref file — the cache below belongs to the workdir (project A), // not necessarily to whatever the env override points at (B). - it.live( + it.effect( "SUPABASE_PROJECT_ID overriding an unrelated workdir's cache: no positive lookup match degrades to the plain project line, no parent claim on cache presence alone", () => { const { layer, out, workdir } = setup({ @@ -1703,7 +1695,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "--output-format json, SUPABASE_PROJECT_ID override with no positive match: linked_project has only project_ref, no parent/org fields leaking from the unrelated cache", () => { const { layer, out, workdir } = setup({ @@ -1718,14 +1710,13 @@ content_path = "./supabase/templates/password_changed_notification.html" }); yield* status(flags()); const success = out.messages.find((m) => m.type === "success"); - const linkedProject = (success?.data as { linked_project?: Record }) - ?.linked_project; + const linkedProject = success?.data?.["linked_project"]; expect(linkedProject).toEqual({ project_ref: LINKED_BRANCH_REF }); }).pipe(Effect.provide(layer)); }, ); - it.live( + it.effect( "SUPABASE_PROJECT_ID pointing at a real branch of the cached parent: the lookup's POSITIVE confirmation still renders the full branch block (env-override CI workflow)", () => { const { layer, out, workdir } = setup({ @@ -1778,7 +1769,7 @@ content_path = "./supabase/templates/password_changed_notification.html" 10_000, ); - it.live( + it.effect( "a control-char/ANSI-laden project-ref file is treated as not linked; nothing of it reaches stdout (PR #6168 review)", () => { // The pattern gate in `resolveSoftLinkedRef` rejects any non-ref-shaped content @@ -1797,7 +1788,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "-o json, --override-name collides with the linked_project_ref field name: the overridden base value wins, the linked field never clobbers it (PR #6168 review)", () => { const { layer, out, workdir } = setup({ @@ -1821,7 +1812,7 @@ content_path = "./supabase/templates/password_changed_notification.html" ); describe("org line variants", () => { - it.live( + it.effect( "slug and id differ: renders ` ()` (Colum's default real-world state)", () => { const { layer, out, workdir } = setup(); @@ -1839,7 +1830,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "slug === id: renders the bare value once (Colum's real staging state), machine formats still carry both keys", () => { const { layer, out, workdir } = setup({ @@ -1860,7 +1851,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live("slug === id, text mode: renders the bare value once, not duplicated", () => { + it.effect("slug === id, text mode: renders the bare value once, not duplicated", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { yield* writeConfig(); @@ -1876,7 +1867,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "neither slug nor id known: the Org line is omitted entirely, and no org machine keys appear", () => { const { layer, out, workdir } = setup({ goOutput: Option.some("env") }); @@ -1896,7 +1887,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live("neither slug nor id known, text mode: the Org line is omitted entirely", () => { + it.effect("neither slug nor id known, text mode: the Org line is omitted entirely", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { yield* writeConfig(); @@ -1916,7 +1907,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("only the org slug is known (no id): renders the bare slug value", () => { + it.effect("only the org slug is known (no id): renders the bare slug value", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { yield* writeConfig(); @@ -1931,7 +1922,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("only the org id is known (no slug): renders the bare id value", () => { + it.effect("only the org id is known (no slug): renders the bare id value", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { yield* writeConfig(); @@ -1946,7 +1937,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "--output-format json, neither org field known: linked_project omits org_slug and org_id", () => { const { layer, out, workdir } = setup({ format: "json" }); @@ -1960,8 +1951,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }); yield* status(flags()); const success = out.messages.find((m) => m.type === "success"); - const linkedProject = (success?.data as { linked_project?: Record }) - ?.linked_project; + const linkedProject = success?.data?.["linked_project"]; expect(linkedProject).toEqual({ project_ref: LINKED_PLAIN_REF, project_name: "My Project", @@ -1971,27 +1961,32 @@ content_path = "./supabase/templates/password_changed_notification.html" ); }); - it.live("-o env, branch-linked: emits the six LINKED_ keys alongside the existing keys", () => { - const { layer, out, workdir } = setup({ - goOutput: Option.some("env"), - branches: { ok: [LINKED_BRANCH] }, - }); - return Effect.gen(function* () { - yield* writeConfig(); - yield* writeProjectRefFile(workdir, LINKED_BRANCH_REF); - yield* writeLinkedProjectCacheFile(workdir, LINKED_PARENT_REF, { name: "Parent Project" }); - yield* status(flags()); - expect(out.stdoutText).toContain(`LINKED_PROJECT_REF="${LINKED_BRANCH_REF}"`); - expect(out.stdoutText).toContain('LINKED_BRANCH="feature-x"'); - expect(out.stdoutText).toContain(`LINKED_PARENT_PROJECT_REF="${LINKED_PARENT_REF}"`); - expect(out.stdoutText).toContain('LINKED_PROJECT_NAME="Parent Project"'); - expect(out.stdoutText).toContain('LINKED_ORG_SLUG="acme"'); - expect(out.stdoutText).toContain('LINKED_ORG_ID="org_1"'); - expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); - }).pipe(Effect.provide(layer)); - }); + it.effect( + "-o env, branch-linked: emits the six LINKED_ keys alongside the existing keys", + () => { + const { layer, out, workdir } = setup({ + goOutput: Option.some("env"), + branches: { ok: [LINKED_BRANCH] }, + }); + return Effect.gen(function* () { + yield* writeConfig(); + yield* writeProjectRefFile(workdir, LINKED_BRANCH_REF); + yield* writeLinkedProjectCacheFile(workdir, LINKED_PARENT_REF, { + name: "Parent Project", + }); + yield* status(flags()); + expect(out.stdoutText).toContain(`LINKED_PROJECT_REF="${LINKED_BRANCH_REF}"`); + expect(out.stdoutText).toContain('LINKED_BRANCH="feature-x"'); + expect(out.stdoutText).toContain(`LINKED_PARENT_PROJECT_REF="${LINKED_PARENT_REF}"`); + expect(out.stdoutText).toContain('LINKED_PROJECT_NAME="Parent Project"'); + expect(out.stdoutText).toContain('LINKED_ORG_SLUG="acme"'); + expect(out.stdoutText).toContain('LINKED_ORG_ID="org_1"'); + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + }).pipe(Effect.provide(layer)); + }, + ); - it.live("-o env, not linked: emits no LINKED_ key at all", () => { + it.effect("-o env, not linked: emits no LINKED_ key at all", () => { const { layer, out } = setup({ goOutput: Option.some("env") }); return Effect.gen(function* () { yield* writeConfig(); @@ -2001,7 +1996,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "-o json, branch-linked: includes the six linked_ keys alongside the existing keys", () => { const { layer, out, workdir } = setup({ @@ -2027,7 +2022,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live("-o json, not linked: omits every linked_ key", () => { + it.effect("-o json, not linked: omits every linked_ key", () => { const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* writeConfig(); @@ -2038,7 +2033,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live( + it.effect( "-o yaml, branch-linked: includes linked_project_ref and linked_org_slug (smoke)", () => { const { layer, out, workdir } = setup({ @@ -2058,7 +2053,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "-o toml, branch-linked: includes linked_project_ref and linked_org_slug (smoke)", () => { const { layer, out, workdir } = setup({ @@ -2078,7 +2073,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "--output-format json, branch-linked: nests linked_project (with org) with zero progress events", () => { const { layer, out, workdir } = setup({ @@ -2108,7 +2103,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live("--output-format json, not linked: linked_project is null", () => { + it.effect("--output-format json, not linked: linked_project is null", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { yield* writeConfig(); @@ -2118,7 +2113,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.live("--output-format stream-json, branch-linked: nests linked_project (smoke)", () => { + it.effect("--output-format stream-json, branch-linked: nests linked_project (smoke)", () => { const { layer, out, workdir } = setup({ format: "stream-json", branches: { ok: [LINKED_BRANCH] }, @@ -2136,7 +2131,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }); describe("json/stream-json FAILURE envelope carries linked_project (CLI-2167 follow-up)", () => { - it.live( + it.effect( "--output-format json, branch-linked, daemon connection failure: envelope carries the full linked_project at the top level, error untouched", () => { const { layer, workdir, stdio, processControl } = setupFailureEnvelope({ @@ -2151,7 +2146,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }); yield* status(flags()).pipe(withJsonErrorHandling); expect(stdio.stdout).toHaveLength(1); - const envelope = yield* machineEnvelope(stdio.stdout[0]!); + const envelope = yield* machineEnvelope(stdio.stdout[0] ?? ""); expect(envelope._tag).toBe("Error"); expect(envelope.error).toMatchObject({ code: "StatusDbInspectError" }); expect(envelope.linked_project).toEqual({ @@ -2168,7 +2163,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "--output-format json, degraded branch-linked (no Management API layer at all), daemon connection failure: linked_project present without a branch key", () => { const { layer, workdir, stdio } = setupFailureEnvelope({ format: "json" }); @@ -2179,7 +2174,7 @@ content_path = "./supabase/templates/password_changed_notification.html" name: "Parent Project", }); yield* status(flags()).pipe(withJsonErrorHandling); - const envelope = yield* machineEnvelope(stdio.stdout[0]!); + const envelope = yield* machineEnvelope(stdio.stdout[0] ?? ""); expect(envelope.error).toMatchObject({ code: "StatusDbInspectError" }); expect(envelope.linked_project).toEqual({ project_ref: LINKED_BRANCH_REF, @@ -2193,14 +2188,14 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "--output-format json, not linked, daemon connection failure: linked_project is explicitly null, not absent", () => { const { layer, stdio } = setupFailureEnvelope({ format: "json" }); return Effect.gen(function* () { yield* writeConfig(); yield* status(flags()).pipe(withJsonErrorHandling); - const envelope = yield* machineEnvelope(stdio.stdout[0]!); + const envelope = yield* machineEnvelope(stdio.stdout[0] ?? ""); expect(envelope.error).toMatchObject({ code: "StatusDbInspectError" }); expect("linked_project" in envelope).toBe(true); expect(envelope.linked_project).toBeNull(); @@ -2208,7 +2203,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "--output-format stream-json, branch-linked, daemon connection failure: the terminal error event carries linked_project", () => { const { layer, workdir, stdio } = setupFailureEnvelope({ @@ -2223,7 +2218,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }); yield* status(flags()).pipe(withJsonErrorHandling); expect(stdio.stdout).toHaveLength(1); - const event = yield* machineEnvelope(stdio.stdout[0]!); + const event = yield* machineEnvelope(stdio.stdout[0] ?? ""); expect(event.type).toBe("error"); expect(event.error).toMatchObject({ code: "StatusDbInspectError" }); expect(event.linked_project).toEqual({ @@ -2244,7 +2239,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); - it.live( + it.effect( "inertness guard: without machineErrorContextLayer in scope, the envelope has no linked_project key at all, and the command still fails identically", () => { // Omitting the cell from the layer graph must silently skip both the handler's @@ -2262,7 +2257,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }); yield* status(flags()).pipe(withJsonErrorHandling); expect(stdio.stdout).toHaveLength(1); - const envelope = yield* machineEnvelope(stdio.stdout[0]!); + const envelope = yield* machineEnvelope(stdio.stdout[0] ?? ""); expect(envelope).toEqual({ _tag: "Error", error: { diff --git a/apps/cli/src/commands/status/status.string-slice-flags.integration.test.ts b/apps/cli/src/commands/status/status.string-slice-flags.integration.test.ts index e15ee76dd2..922c83d1c3 100644 --- a/apps/cli/src/commands/status/status.string-slice-flags.integration.test.ts +++ b/apps/cli/src/commands/status/status.string-slice-flags.integration.test.ts @@ -86,7 +86,7 @@ describe("status StringSlice flags (pflag CSV parity)", () => { ]; for (const { name, args, message } of cases) { - it.live(`${name} CSV fails at parse time with pflag's exact diagnostic`, () => { + it.effect(`${name} CSV fails at parse time with pflag's exact diagnostic`, () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); diff --git a/apps/cli/src/commands/stop/stop.integration.test.ts b/apps/cli/src/commands/stop/stop.integration.test.ts index 099aaca57f..1ae4855c3f 100644 --- a/apps/cli/src/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/stop/stop.integration.test.ts @@ -122,13 +122,11 @@ function mockRoutedContainerCliSpawner( const result = route(args); const exitDeferred = yield* Deferred.make(); yield* Effect.forkDetach( - Effect.gen(function* () { - yield* Effect.sleep("5 millis"); - yield* Deferred.succeed( - exitDeferred, - ChildProcessSpawner.ExitCode(result.exitCode ?? 0), - ); - }), + Effect.yieldNow.pipe( + Effect.andThen( + Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode ?? 0)), + ), + ), ); const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); @@ -224,7 +222,7 @@ const setup = (opts: SetupOpts = {}) => }); describe("stop integration", () => { - it.live( + it.effect( "stops the current project's containers with backup and suggests the volume command", () => Effect.gen(function* () { @@ -259,7 +257,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "reclaims staged-secret directories for containers it tears down, leaving unrelated ones alone", () => Effect.gen(function* () { @@ -282,7 +280,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "reclaims a container's staged-secret directory at its OWN labeled workdir, not this invocation's cwd", () => Effect.gen(function* () { @@ -326,26 +324,28 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("sanitizes a dirty config.toml project_id before filtering, matching start's label", () => - Effect.gen(function* () { - const { layer, child } = yield* setup({ - configuredProjectId: "My App!!", - route: defaultRoute(), - }); - yield* stop(flags()).pipe(Effect.provide(layer)); - const psCall = child.spawned.find((s) => s.args[0] === "ps"); - expect(psCall?.args).toEqual([ - "ps", - "--filter", - "label=com.supabase.cli.project=My_App_", - "--all", - "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', - ]); - }).pipe(Effect.provide(BunServices.layer)), + it.effect( + "sanitizes a dirty config.toml project_id before filtering, matching start's label", + () => + Effect.gen(function* () { + const { layer, child } = yield* setup({ + configuredProjectId: "My App!!", + route: defaultRoute(), + }); + yield* stop(flags()).pipe(Effect.provide(layer)); + const psCall = child.spawned.find((s) => s.args[0] === "ps"); + expect(psCall?.args).toEqual([ + "ps", + "--filter", + "label=com.supabase.cli.project=My_App_", + "--all", + "--format", + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + ]); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("keeps an explicit --project-id raw, unsanitized (Go's bypass)", () => + it.effect("keeps an explicit --project-id raw, unsanitized (Go's bypass)", () => Effect.gen(function* () { const { layer, child } = yield* setup({ skipConfig: true, route: defaultRoute() }); yield* stop(flags({ projectId: Option.some("Raw Value!!") })).pipe(Effect.provide(layer)); @@ -361,7 +361,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("stops every project's containers with --all without reading config.toml", () => + it.effect("stops every project's containers with --all without reading config.toml", () => Effect.gen(function* () { const { layer, child } = yield* setup({ skipConfig: true, route: defaultRoute() }); yield* stop(flags({ all: Option.some(true) })).pipe(Effect.provide(layer)); @@ -387,7 +387,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("suggests the bare-label volume command with --all when volumes remain", () => + it.effect("suggests the bare-label volume command with --all when volumes remain", () => Effect.gen(function* () { const { layer, out } = yield* setup({ skipConfig: true, @@ -402,7 +402,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("stops a named project with --project-id without reading config.toml", () => + it.effect("stops a named project with --project-id without reading config.toml", () => Effect.gen(function* () { const { layer, child } = yield* setup({ skipConfig: true, route: defaultRoute() }); yield* stop(flags({ projectId: Option.some("other-project") })).pipe(Effect.provide(layer)); @@ -418,7 +418,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("falls back to config.toml when --project-id is an empty string", () => + it.effect("falls back to config.toml when --project-id is an empty string", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "demo", @@ -437,7 +437,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => + it.effect("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "toml-project", @@ -457,7 +457,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => + it.effect("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "toml-project", @@ -481,7 +481,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "does not climb to an ancestor project's config.toml when workdir has none of its own", () => Effect.gen(function* () { @@ -509,7 +509,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => + it.effect("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => Effect.gen(function* () { const { layer, child } = yield* setup({ skipConfig: true, route: defaultRoute() }); yield* writeSupabaseFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=no-config-project\n"); @@ -526,7 +526,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => + it.effect("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "toml-project", @@ -546,7 +546,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => + it.effect("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => Effect.gen(function* () { const path = yield* Path.Path; // Must fail before falling through to the workdir-basename default. @@ -565,7 +565,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => + it.effect("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => Effect.gen(function* () { const path = yield* Path.Path; const filePath = path.join(tempRoot.current, "not-a-directory"); @@ -582,7 +582,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("rejects --project-id together with --all", () => + it.effect("rejects --project-id together with --all", () => Effect.gen(function* () { const { layer, child } = yield* setup({ skipConfig: true, route: defaultRoute() }); const exit = yield* Effect.exit( @@ -600,7 +600,7 @@ describe("stop integration", () => { // Presence-based, not value-based: `--all=false` still counts as "set" alongside // `--project-id`, so this must reject too, not just `--all=true`. - it.live("rejects --project-id together with an explicit --all=false", () => + it.effect("rejects --project-id together with an explicit --all=false", () => Effect.gen(function* () { const { layer, child } = yield* setup({ skipConfig: true, route: defaultRoute() }); const exit = yield* Effect.exit( @@ -616,7 +616,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("deletes data volumes with --no-backup", () => + it.effect("deletes data volumes with --no-backup", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "demo", @@ -637,29 +637,31 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("omits --all from docker's volume prune on a pre-1.42 API host, matching Go's gate", () => - Effect.gen(function* () { - // Docker's own `volume prune --all` requires API >= 1.42 and hard-fails (pruning - // nothing) on an older daemon. - const { layer, child } = yield* setup({ - configuredProjectId: "demo", - route: defaultRoute({ dockerApiVersion: "1.41" }), - }); - yield* stop(flags({ noBackup: true })).pipe(Effect.provide(layer)); - const volumePrune = child.spawned.find( - (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", - ); - expect(volumePrune?.args).toEqual([ - "volume", - "prune", - "--force", - "--filter", - "label=com.supabase.cli.project=demo", - ]); - }).pipe(Effect.provide(BunServices.layer)), + it.effect( + "omits --all from docker's volume prune on a pre-1.42 API host, matching Go's gate", + () => + Effect.gen(function* () { + // Docker's own `volume prune --all` requires API >= 1.42 and hard-fails (pruning + // nothing) on an older daemon. + const { layer, child } = yield* setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerApiVersion: "1.41" }), + }); + yield* stop(flags({ noBackup: true })).pipe(Effect.provide(layer)); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("includes --all in docker's volume prune when the API is exactly 1.42", () => + it.effect("includes --all in docker's volume prune when the API is exactly 1.42", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "demo", @@ -680,7 +682,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("--backup=false alone does not delete data volumes, matching Go's dead flag", () => + it.effect("--backup=false alone does not delete data volumes, matching Go's dead flag", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "demo", @@ -694,7 +696,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("--no-backup still deletes data volumes even when --backup stays true", () => + it.effect("--no-backup still deletes data volumes even when --backup stays true", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "demo", @@ -715,7 +717,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("keeps data volumes by default (no volume prune call)", () => + it.effect("keeps data volumes by default (no volume prune call)", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "demo", @@ -729,7 +731,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when config.toml is malformed", () => + it.effect("fails when config.toml is malformed", () => Effect.gen(function* () { yield* writeSupabaseFile(tempRoot.current, "config.toml", "not valid toml ====="); const { layer, child } = yield* setup({ skipConfig: true, route: defaultRoute() }); @@ -742,7 +744,7 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => + it.effect("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => Effect.gen(function* () { yield* writeSupabaseFile( tempRoot.current, @@ -766,7 +768,7 @@ project_id = "aaaaaaaaaaaaaaaaaaaa" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => + it.effect("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => Effect.gen(function* () { yield* writeSupabaseFile( tempRoot.current, @@ -787,7 +789,7 @@ project_id = "short" }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "decodes a comma-separated string into an array field ([]string) for stop to proceed", () => Effect.gen(function* () { @@ -814,7 +816,7 @@ additional_redirect_urls = "http://a,http://b" }).pipe(Effect.provide(BunServices.layer)), ); - it.live("warns on stderr for a deprecated auth.external provider", () => + it.effect("warns on stderr for a deprecated auth.external provider", () => Effect.gen(function* () { // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits this warning via // `Console.error` only when `goViperCompat` is set. @@ -850,7 +852,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails and never spawns docker when config.toml has an unsupported db.major_version", () => Effect.gen(function* () { @@ -871,7 +873,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("does not run config Validate for --all (bypasses config entirely)", () => + it.effect("does not run config Validate for --all (bypasses config entirely)", () => Effect.gen(function* () { yield* writeSupabaseFile( tempRoot.current, @@ -888,7 +890,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("does not run config Validate for --project-id (bypasses config entirely)", () => + it.effect("does not run config Validate for --project-id (bypasses config entirely)", () => Effect.gen(function* () { yield* writeSupabaseFile( tempRoot.current, @@ -905,7 +907,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when stopping a container errors", () => + it.effect("fails when stopping a container errors", () => Effect.gen(function* () { const { layer } = yield* setup({ configuredProjectId: "demo", @@ -923,38 +925,40 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("preserves a container's staged-secret directory when the stop stage itself fails", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - // The stop stage failing means container prune never runs, so `onContainersRemoved` never - // fires and `cleanupStartSecrets` must not delete anything. - const { layer, workdir } = yield* setup({ - configuredProjectId: "demo", - route: (args) => { - if (args[0] === "ps") return { stdout: ["c1\tsupabase_kong_demo"] }; - if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; - return { exitCode: 0 }; - }, - }); - const stagedDir = path.join( - workdir, - "supabase", - ".temp", - "start-secrets", - "supabase_kong_demo", - ); - yield* writeFileIn(stagedDir, "secret-0", "kong.yml contents"); - const exit = yield* Effect.exit(stop(flags()).pipe(Effect.provide(layer))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("StopContainerError"); - } - expect(yield* fs.exists(stagedDir)).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), + it.effect( + "preserves a container's staged-secret directory when the stop stage itself fails", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // The stop stage failing means container prune never runs, so `onContainersRemoved` never + // fires and `cleanupStartSecrets` must not delete anything. + const { layer, workdir } = yield* setup({ + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1\tsupabase_kong_demo"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + const stagedDir = path.join( + workdir, + "supabase", + ".temp", + "start-secrets", + "supabase_kong_demo", + ); + yield* writeFileIn(stagedDir, "secret-0", "kong.yml contents"); + const exit = yield* Effect.exit(stop(flags()).pipe(Effect.provide(layer))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("StopContainerError"); + } + expect(yield* fs.exists(stagedDir)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when a container cannot be spawned to stop it at all", () => + it.effect("fails when a container cannot be spawned to stop it at all", () => Effect.gen(function* () { // Distinct from a spawned `docker stop` exiting non-zero: here docker and podman both // fail to spawn. @@ -971,26 +975,28 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails the same way in json mode, where 'Stopping containers...' is never printed", () => - Effect.gen(function* () { - const { layer } = yield* setup({ - format: "json", - configuredProjectId: "demo", - route: (args) => { - if (args[0] === "ps") return { stdout: ["c1"] }; - if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; - return { exitCode: 0 }; - }, - }); - const exit = yield* Effect.exit(stop(flags()).pipe(Effect.provide(layer))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("StopContainerError"); - } - }).pipe(Effect.provide(BunServices.layer)), + it.effect( + "fails the same way in json mode, where 'Stopping containers...' is never printed", + () => + Effect.gen(function* () { + const { layer } = yield* setup({ + format: "json", + configuredProjectId: "demo", + route: (args) => { + if (args[0] === "ps") return { stdout: ["c1"] }; + if (args[0] === "stop") return { exitCode: 1, stderr: ["boom"] }; + return { exitCode: 0 }; + }, + }); + const exit = yield* Effect.exit(stop(flags()).pipe(Effect.provide(layer))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("StopContainerError"); + } + }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when container prune errors", () => + it.effect("fails when container prune errors", () => Effect.gen(function* () { const { layer } = yield* setup({ configuredProjectId: "demo", @@ -1007,7 +1013,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when volume prune errors", () => + it.effect("fails when volume prune errors", () => Effect.gen(function* () { const { layer } = yield* setup({ configuredProjectId: "demo", @@ -1024,7 +1030,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when network prune errors", () => + it.effect("fails when network prune errors", () => Effect.gen(function* () { const { layer } = yield* setup({ configuredProjectId: "demo", @@ -1044,7 +1050,7 @@ enabled = true // By the time a later prune stage fails, container-prune has already removed the containers; // a subsequent `stop` could no longer rediscover them via `docker ps`, so this cleanup must // still run (see the `Effect.ensuring` finalizer above). - it.live("still reclaims staged-secret directories when a later prune stage fails", () => + it.effect("still reclaims staged-secret directories when a later prune stage fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1069,7 +1075,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when the container list errors", () => + it.effect("fails when the container list errors", () => Effect.gen(function* () { const { layer } = yield* setup({ configuredProjectId: "demo", @@ -1086,7 +1092,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("falls back to podman when docker is absent", () => + it.effect("falls back to podman when docker is absent", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "demo", @@ -1102,7 +1108,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("omits --all from podman's volume prune (not a real Podman flag)", () => + it.effect("omits --all from podman's volume prune (not a real Podman flag)", () => Effect.gen(function* () { // Podman's `volume prune` has no `--all` flag; passing Docker's argv through would // hard-fail after containers are already stopped. Podman prunes every unused volume by @@ -1127,7 +1133,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("emits a machine result in json mode without spinner text", () => + it.effect("emits a machine result in json mode without spinner text", () => Effect.gen(function* () { const { layer, out } = yield* setup({ format: "json", @@ -1143,7 +1149,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("shows no volume suggestion when no volumes remain", () => + it.effect("shows no volume suggestion when no volumes remain", () => Effect.gen(function* () { const { layer, out } = yield* setup({ configuredProjectId: "demo", @@ -1154,7 +1160,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("flushes telemetry via ensuring even on failure", () => + it.effect("flushes telemetry via ensuring even on failure", () => Effect.gen(function* () { const { layer, telemetry } = yield* setup({ configuredProjectId: "demo", @@ -1165,7 +1171,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when container prune cannot spawn any container runtime", () => + it.effect("fails when container prune cannot spawn any container runtime", () => Effect.gen(function* () { const { layer } = yield* setup({ configuredProjectId: "demo", @@ -1180,7 +1186,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when volume prune cannot spawn any container runtime", () => + it.effect("fails when volume prune cannot spawn any container runtime", () => Effect.gen(function* () { const { layer } = yield* setup({ configuredProjectId: "demo", @@ -1195,7 +1201,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("fails when network prune cannot spawn any container runtime", () => + it.effect("fails when network prune cannot spawn any container runtime", () => Effect.gen(function* () { const { layer } = yield* setup({ configuredProjectId: "demo", @@ -1210,7 +1216,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("still reports success when the post-run volume listing fails", () => + it.effect("still reports success when the post-run volume listing fails", () => Effect.gen(function* () { // Best-effort (`Effect.orElseSucceed`): a listing error here is silently ignored, never // surfaced. @@ -1245,7 +1251,7 @@ enabled = true vi.spyOn(process.stderr, "write").mockImplementation(() => true), ); - it.live("reports Go's --debug Pruned lines to stderr, in stage order", () => + it.effect("reports Go's --debug Pruned lines to stderr, in stage order", () => Effect.gen(function* () { const { layer } = yield* setup({ debug: true, @@ -1276,7 +1282,7 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); - it.live("never writes Go's Pruned lines to stderr without --debug", () => + it.effect("never writes Go's Pruned lines to stderr without --debug", () => Effect.gen(function* () { const { layer } = yield* setup({ configuredProjectId: "demo", diff --git a/apps/cli/src/commands/test/new/new.integration.test.ts b/apps/cli/src/commands/test/new/new.integration.test.ts index 98c885f832..3f745f9c72 100644 --- a/apps/cli/src/commands/test/new/new.integration.test.ts +++ b/apps/cli/src/commands/test/new/new.integration.test.ts @@ -10,7 +10,11 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; -import { TestNewInvalidNameError } from "./new.errors.ts"; +import { + TestNewFileExistsError, + TestNewInvalidNameError, + TestNewWriteError, +} from "./new.errors.ts"; import { PGTAP_TEMPLATE } from "./new.template.ts"; import { testNew } from "./new.handler.ts"; @@ -70,7 +74,7 @@ const flags = (name: string, template?: "pgtap") => ({ }); describe("test new integration", () => { - it.live("creates a pgtap test file and prints the created path", () => { + it.effect("creates a pgtap test file and prints the created path", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -84,7 +88,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("pins the created test file to Go's exact 0644 mode under a permissive umask", () => { + it.effect("pins the created test file to Go's exact 0644 mode under a permissive umask", () => { const { layer, workdir } = setup(); return Effect.acquireUseRelease( Effect.sync(() => process.umask(0)), @@ -104,7 +108,7 @@ describe("test new integration", () => { ); }); - it.live("defaults the template to pgtap when --template is omitted", () => { + it.effect("defaults the template to pgtap when --template is omitted", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -115,7 +119,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("honors an explicit --template pgtap", () => { + it.effect("honors an explicit --template pgtap", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -127,7 +131,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a structured success in json mode (no human text)", () => { + it.effect("emits a structured success in json mode (no human text)", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { yield* testNew(flags("petjson")); @@ -140,7 +144,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits a structured success in stream-json mode", () => { + it.effect("emits a structured success in stream-json mode", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { yield* testNew(flags("petstream")); @@ -149,7 +153,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with TestNewFileExistsError when the file already exists", () => { + it.effect("fails with TestNewFileExistsError when the file already exists", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -159,39 +163,29 @@ describe("test new integration", () => { path.join(workdir, "supabase", "tests", "dupe_test.sql"), "-- existing\n", ); - const exit = yield* Effect.exit(testNew(flags("dupe"))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("TestNewFileExistsError"); - expect(causeText).toContain("supabase/tests/dupe_test.sql already exists."); - } + const error = yield* testNew(flags("dupe")).pipe(Effect.flip); + expect(error).toBeInstanceOf(TestNewFileExistsError); + expect(error.message).toContain("supabase/tests/dupe_test.sql already exists."); }).pipe(Effect.provide(layer)); }); - it.live("fails with TestNewWriteError when the write fails", () => { + it.effect("fails with TestNewWriteError when the write fails", () => { const { layer } = setup({ writeFails: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit(testNew(flags("nowrite"))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("TestNewWriteError"); - } + const error = yield* testNew(flags("nowrite")).pipe(Effect.flip); + expect(error).toBeInstanceOf(TestNewWriteError); }).pipe(Effect.provide(layer)); }); - it.live("fails with TestNewWriteError when the tests dir cannot be created", () => { + it.effect("fails with TestNewWriteError when the tests dir cannot be created", () => { const { layer } = setup({ mkdirFails: true }); return Effect.gen(function* () { - const exit = yield* Effect.exit(testNew(flags("nomkdir"))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("TestNewWriteError"); - } + const error = yield* testNew(flags("nomkdir")).pipe(Effect.flip); + expect(error).toBeInstanceOf(TestNewWriteError); }).pipe(Effect.provide(layer)); }); - it.live("creates test files under subdirectories that stay inside the tests directory", () => { + it.effect("creates test files under subdirectories that stay inside the tests directory", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -207,7 +201,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects a name that escapes the tests directory and writes nothing", () => { + it.effect("rejects a name that escapes the tests directory and writes nothing", () => { const { layer, telemetry, workdir } = setup(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -235,7 +229,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("follows an existing symlink to a shared test directory", () => { + it.effect("follows an existing symlink to a shared test directory", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -252,7 +246,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("rejects a name that escapes into a sibling directory sharing the tests prefix", () => { + it.effect("rejects a name that escapes into a sibling directory sharing the tests prefix", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -264,7 +258,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("sanitizes control characters in an invalid-name diagnostic", () => { + it.effect("sanitizes control characters in an invalid-name diagnostic", () => { const { layer } = setup(); return Effect.gen(function* () { const error = yield* testNew(flags("../../\u001b[2Jbad\r\n\t\u009bname")).pipe(Effect.flip); @@ -273,7 +267,7 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry via ensuring", () => { + it.effect("flushes telemetry via ensuring", () => { const { layer, telemetry } = setup(); return Effect.gen(function* () { yield* testNew(flags("petflush")); diff --git a/apps/cli/src/commands/unlink/unlink.integration.test.ts b/apps/cli/src/commands/unlink/unlink.integration.test.ts index 69c100b6eb..9282b3e04a 100644 --- a/apps/cli/src/commands/unlink/unlink.integration.test.ts +++ b/apps/cli/src/commands/unlink/unlink.integration.test.ts @@ -89,7 +89,7 @@ function setup(opts: SetupOpts = {}) { } describe("unlink integration", () => { - it.live("unlinks: removes the temp dir, deletes the keyring entry, prints Finished", () => { + it.effect("unlinks: removes the temp dir, deletes the keyring entry, prints Finished", () => { const { layer, out, credentials, workdir } = setup(); return Effect.gen(function* () { yield* seedProjectRef(workdir, VALID_REF); @@ -101,7 +101,7 @@ describe("unlink integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes 'Unlinking project: ' to stderr", () => { + it.effect("writes 'Unlinking project: ' to stderr", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { yield* seedProjectRef(workdir, VALID_REF); @@ -110,7 +110,7 @@ describe("unlink integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("succeeds when no credential is stored (keyring not-found ignored)", () => { + it.effect("succeeds when no credential is stored (keyring not-found ignored)", () => { // The mock returns true here; a real not-found returns false without erroring — // either way unlink succeeds. const { layer, out, workdir } = setup(); @@ -121,7 +121,7 @@ describe("unlink integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with ProjectRefNotLinkedError when the project-ref file is absent", () => { + it.effect("fails with ProjectRefNotLinkedError when the project-ref file is absent", () => { const { layer } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(unlink()); @@ -134,7 +134,7 @@ describe("unlink integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails when the keyring delete errors (permission denied)", () => { + it.effect("fails when the keyring delete errors (permission denied)", () => { const { layer, workdir } = setup({ deleteFails: true }); return Effect.gen(function* () { yield* seedProjectRef(workdir, VALID_REF); @@ -148,7 +148,7 @@ describe("unlink integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("fails with UnlinkTempRemovalError when the temp dir cannot be removed", () => { + it.effect("fails with UnlinkTempRemovalError when the temp dir cannot be removed", () => { const { layer, workdir } = setup({ removeFails: true }); return Effect.gen(function* () { yield* seedProjectRef(workdir, VALID_REF); @@ -162,7 +162,7 @@ describe("unlink integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("surfaces both messages when temp removal and keyring delete both fail", () => { + it.effect("surfaces both messages when temp removal and keyring delete both fail", () => { const { layer, workdir } = setup({ removeFails: true, deleteFails: true }); return Effect.gen(function* () { yield* seedProjectRef(workdir, VALID_REF); @@ -176,7 +176,7 @@ describe("unlink integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("flushes telemetry via ensuring", () => { + it.effect("flushes telemetry via ensuring", () => { const { layer, telemetry, workdir } = setup(); return Effect.gen(function* () { yield* seedProjectRef(workdir, VALID_REF); @@ -185,7 +185,7 @@ describe("unlink integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("json output: emits a structured success and suppresses the Finished line", () => { + it.effect("json output: emits a structured success and suppresses the Finished line", () => { const { layer, out, workdir } = setup({ format: "json" }); return Effect.gen(function* () { yield* seedProjectRef(workdir, VALID_REF); @@ -196,7 +196,7 @@ describe("unlink integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("stream-json output: emits a structured success", () => { + it.effect("stream-json output: emits a structured success", () => { const { layer, out, workdir } = setup({ format: "stream-json" }); return Effect.gen(function* () { yield* seedProjectRef(workdir, VALID_REF); From c6903760da868a3f27e635716406d44b4901f524 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:34:29 +0000 Subject: [PATCH 5/6] chore(api): sync Management API OpenAPI spec (#7138) This PR was automatically created to sync the generated `@supabase/api` package with the latest Management API OpenAPI document. Changes were detected in the upstream OpenAPI documents exposed by `https://api.supabase.com/api/v1-json` and `https://api.supabase.com/api/v2-json`. Co-authored-by: jgoux <1443499+jgoux@users.noreply.github.com> --- packages/api/src/generated/openapi.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 364bd682dd..374fd9a68e 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -13997,7 +13997,7 @@ } }, "402": { - "description": "This feature requires the Enterprise organization plan.", + "description": "This feature requires the Team, or Enterprise organization plan.", "content": { "application/json": { "schema": { @@ -14044,10 +14044,10 @@ ], "summary": "Assign or change an organization member role", "tags": ["Organizations"], - "x-allowed-plans": ["Enterprise"], + "x-allowed-plans": ["Team", "Enterprise"], "x-badges": [ { - "name": "Only available on Enterprise", + "name": "Only available on Team, Enterprise", "position": "before" } ], @@ -14181,7 +14181,7 @@ } }, "402": { - "description": "This feature requires the Enterprise organization plan.", + "description": "This feature requires the Team, or Enterprise organization plan.", "content": { "application/json": { "schema": { @@ -14218,14 +14218,14 @@ ], "summary": "Creates organization invitations", "tags": ["Organizations Members Invitations"], - "x-allowed-plans": ["Enterprise"], + "x-allowed-plans": ["Team", "Enterprise"], "x-badges": [ { "name": "OAuth scope: organizations:write", "position": "after" }, { - "name": "Only available on Enterprise", + "name": "Only available on Team, Enterprise", "position": "before" } ], @@ -14281,7 +14281,7 @@ } }, "402": { - "description": "This feature requires the Enterprise organization plan.", + "description": "This feature requires the Team, or Enterprise organization plan.", "content": { "application/json": { "schema": { @@ -14318,14 +14318,14 @@ ], "summary": "Deletes organization invitations by email", "tags": ["Organizations Members Invitations"], - "x-allowed-plans": ["Enterprise"], + "x-allowed-plans": ["Team", "Enterprise"], "x-badges": [ { "name": "OAuth scope: organizations:write", "position": "after" }, { - "name": "Only available on Enterprise", + "name": "Only available on Team, Enterprise", "position": "before" } ], From 7dfd9b8ed8e66db5318cbe19f8e6f19fa42c3400 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:54:14 +0000 Subject: [PATCH 6/6] chore(repo): format the develop-into-next merge --- .../db/advisors/advisors.integration.test.ts | 29 ++++----- .../migration/new/new.integration.test.ts | 61 ++++++++++--------- .../status/status.integration.test.ts | 39 ++++++------ .../commands/stop/stop.integration.test.ts | 3 +- 4 files changed, 68 insertions(+), 64 deletions(-) diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index e2731dbef6..1d1df0a5f1 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -700,22 +700,19 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.effect( - "fails on a 200 with a non-JSON content type (a JSON content type is required)", - () => { - // The body is only decoded when Content-Type contains "json"; otherwise this fails as a - // status-200 error. - const { layer } = setup({ securityNonJson: true, args: ["--linked"] }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("unexpected security advisors status 200"); - } - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("fails on a 200 with a non-JSON content type (a JSON content type is required)", () => { + // The body is only decoded when Content-Type contains "json"; otherwise this fails as a + // status-200 error. + const { layer } = setup({ securityNonJson: true, args: ["--linked"] }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("unexpected security advisors status 200"); + } + }).pipe(Effect.provide(layer)); + }); it.effect("fails with the JSON parse error when a JSON advisors body is malformed", () => { const { layer } = setup({ securityJsonBody: "{ not json", args: ["--linked"] }); diff --git a/apps/cli/src/commands/migration/new/new.integration.test.ts b/apps/cli/src/commands/migration/new/new.integration.test.ts index b50f2413ce..be5436e411 100644 --- a/apps/cli/src/commands/migration/new/new.integration.test.ts +++ b/apps/cli/src/commands/migration/new/new.integration.test.ts @@ -212,34 +212,37 @@ describe("migration new", () => { }).pipe(Effect.provide(layer)); }); - it.effect("fails the command when piped stdin errors mid-copy (failed to copy from stdin)", () => { - // The Created line is already scheduled by the time the copy fails, so it still - // prints to stdout before the error propagates to stderr. - const failingStdin = Layer.succeed(Stdin, { - isTTY: false, - readPipedBytes: Effect.succeed(Option.none()), - pipedBytesStream: Stream.fail(badArgument({ module: "Stdin", method: "read" })), - readPipedText: Effect.succeed(Option.none()), - readLine: () => Effect.succeed(Option.none()), - }); - const out = mockOutput(); - const telemetry = mockTelemetryStateTracked(); - const layer = Layer.mergeAll( - out.layer, - telemetry.layer, - failingStdin, - mockCommandSettings({ workdir: tmp.current }), - BunServices.layer, - ); - return Effect.gen(function* () { - const error = yield* migrationNew({ migrationName: "stdin_boom" }).pipe(Effect.flip); - expect(error).toBeInstanceOf(MigrationNewWriteError); - expect(error.message).toContain("failed to copy from stdin"); - const file = yield* onlyMigration(tmp.current); - expect(stripAnsi(out.stdoutText)).toBe( - `Created new migration at supabase/migrations/${file}\n`, + it.effect( + "fails the command when piped stdin errors mid-copy (failed to copy from stdin)", + () => { + // The Created line is already scheduled by the time the copy fails, so it still + // prints to stdout before the error propagates to stderr. + const failingStdin = Layer.succeed(Stdin, { + isTTY: false, + readPipedBytes: Effect.succeed(Option.none()), + pipedBytesStream: Stream.fail(badArgument({ module: "Stdin", method: "read" })), + readPipedText: Effect.succeed(Option.none()), + readLine: () => Effect.succeed(Option.none()), + }); + const out = mockOutput(); + const telemetry = mockTelemetryStateTracked(); + const layer = Layer.mergeAll( + out.layer, + telemetry.layer, + failingStdin, + mockCommandSettings({ workdir: tmp.current }), + BunServices.layer, ); - expect(telemetry.flushed).toBe(true); - }).pipe(Effect.provide(layer)); - }); + return Effect.gen(function* () { + const error = yield* migrationNew({ migrationName: "stdin_boom" }).pipe(Effect.flip); + expect(error).toBeInstanceOf(MigrationNewWriteError); + expect(error.message).toContain("failed to copy from stdin"); + const file = yield* onlyMigration(tmp.current); + expect(stripAnsi(out.stdoutText)).toBe( + `Created new migration at supabase/migrations/${file}\n`, + ); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/status/status.integration.test.ts b/apps/cli/src/commands/status/status.integration.test.ts index 46c6fdb68c..84bbeb6c7b 100644 --- a/apps/cli/src/commands/status/status.integration.test.ts +++ b/apps/cli/src/commands/status/status.integration.test.ts @@ -1048,24 +1048,27 @@ content_path = "./supabase/templates/password_changed_notification.html" }).pipe(Effect.provide(layer)); }); - it.effect("omits REST_URL with -o env when SUPABASE_API_ENABLED turns the api section off", () => { - const { layer, out } = setup({ - outputFlag: Option.some("env"), - route: defaultRoute({ - runningNames: ALL_RUNNING_NAMES.filter((name) => !name.includes("_rest_")), - }), - }); - return withEnvVar( - "SUPABASE_API_ENABLED", - "false", - Effect.gen(function* () { - yield* writeConfig('project_id = "demo"\n[api]\nenabled = true\n'); - yield* status(flags()); - expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); - expect(out.stdoutText).not.toContain("REST_URL="); - }).pipe(Effect.provide(layer)), - ); - }); + it.effect( + "omits REST_URL with -o env when SUPABASE_API_ENABLED turns the api section off", + () => { + const { layer, out } = setup({ + outputFlag: Option.some("env"), + route: defaultRoute({ + runningNames: ALL_RUNNING_NAMES.filter((name) => !name.includes("_rest_")), + }), + }); + return withEnvVar( + "SUPABASE_API_ENABLED", + "false", + Effect.gen(function* () { + yield* writeConfig('project_id = "demo"\n[api]\nenabled = true\n'); + yield* status(flags()); + expect(out.stdoutText).toContain('API_URL="http://127.0.0.1:54321"'); + expect(out.stdoutText).not.toContain("REST_URL="); + }).pipe(Effect.provide(layer)), + ); + }, + ); it.effect("outputs a json object with -o json", () => { const { layer, out } = setup({ outputFlag: Option.some("json") }); diff --git a/apps/cli/src/commands/stop/stop.integration.test.ts b/apps/cli/src/commands/stop/stop.integration.test.ts index 7cdabd1584..390ccbffeb 100644 --- a/apps/cli/src/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/stop/stop.integration.test.ts @@ -639,7 +639,8 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("omits --all from docker's volume prune on a pre-1.42 API host", () => Effect.gen(function* () { + it.effect("omits --all from docker's volume prune on a pre-1.42 API host", () => + Effect.gen(function* () { // Docker's own `volume prune --all` requires API >= 1.42 and hard-fails (pruning // nothing) on an older daemon. const { layer, child } = yield* setup({