From c8f82350aece6b77920b88d1ba226315de87ee5b Mon Sep 17 00:00:00 2001
From: Andrew Valleteau
Date: Tue, 18 Aug 2026 12:27:30 +0000
Subject: [PATCH 01/63] fix(stack): deflake service-state settling and control
endpoint acquisition (#6245)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
This PR removes a family of intermittent stack test failures by fixing
the underlying races rather than the tests, then verifies the result
with five consecutive green CI runs and a local 2× CPU-oversubscribed
stress gauntlet (8/8 integration runs green).
Runtime fixes:
- **`stopService` settling race** (the original `'Dormant' vs 'Stopped'`
CI flake): `stopService` returned before the background projection fiber
re-published the public state, so an immediate `getState` could observe
the previous status. It now settles the projection before returning,
matching every start path.
- **Control endpoint port collisions**: the control endpoint derived a
single loopback port from two bytes of the stack id, so two live stacks
could birthday-collide and the later acquirer hard-failed with
`ControlAddressConflictError`. `acquireControl`/`probeControl` now walk
a short deterministic candidate sequence — attach to a matching owner on
any candidate (verified by `ownershipId`), bind the first free one, and
conflict only when all candidates are foreign-occupied.
`connectManagedStack` uses the probed endpoint, and exact service-port
requests reserve every candidate. Protocol mismatch still fails closed.
- **Keep-alive livelock**: control status probes reused pooled
connections, so a closed listener kept answering `/owner` on the
poller's own hot connection and a scan-first acquirer could never bind
the freed endpoint. Control reads and stop requests are now one-shot
connections in both Node and Bun transports.
- **Retry budgets**: the bound-but-not-serving acquire retry is now
duration-bounded (a count-based budget stretched a single acquire to
30–45s when reads hit the 500ms transport timeout), and `startStack`'s
workspace-repair fence waits up to 30s instead of ~5s, which a realistic
Git repair can exceed.
Test hardening:
- Managed test layers set a new `preferCatalogDefaults: false` plan
option so parallel suites stop contending on the default ports (54321…),
which sticky reuse re-reserves exactly; production behavior and the
spec'd no-relocation semantics are unchanged.
- Write-failure injections are privilege-independent (FileSystem-seam
gates / directory-as-file instead of chmod, which root bypasses).
- Supervisor test watchers re-arm on ENOENT from atomic-write temp files
vanishing mid-scan; sub-second synchronization timeouts are widened into
guards.
## Linked issue
Closes #
- [x] The linked issue is **open** and carries the
`open-for-contribution` label (or I'm a Supabase maintainer).
## Checklist
- [x] The PR title follows [Conventional
Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`).
- [x] Tests added or updated for the change.
- [x] `pnpm check:all` and `pnpm test` pass for the workspace(s) I
touched.
https://claude.ai/code/session_01McsNM9yxC5tiY6SusBALoq
---------
Co-authored-by: Claude
---
packages/stack/docs/architecture.md | 31 ++-
packages/stack/src/LocalStack.ts | 3 +
packages/stack/src/Stack.unit.test.ts | 28 +--
packages/stack/src/discovery.ts | 2 +-
.../src/managed-control.integration.test.ts | 66 +++--
...aged-manager-lifecycle.integration.test.ts | 6 +-
...naged-manager-projects.integration.test.ts | 20 +-
...naged-manager-recovery.integration.test.ts | 65 +++--
packages/stack/src/managed/control.ts | 227 +++++++++++++-----
packages/stack/src/managed/lifecycle.ts | 35 +--
packages/stack/src/managed/manager.ts | 42 +++-
packages/stack/src/managed/port-plan.ts | 12 +-
packages/stack/src/platform-bun.ts | 5 +
packages/stack/src/platform-node.ts | 5 +
.../stack/src/supervisor.integration.test.ts | 64 ++++-
.../stack/tests/helpers/managed-manager.ts | 2 +-
.../stack/tests/helpers/supervisor-child.ts | 20 +-
17 files changed, 443 insertions(+), 190 deletions(-)
diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md
index 742cc3bbd6..d89005bcc9 100644
--- a/packages/stack/docs/architecture.md
+++ b/packages/stack/docs/architecture.md
@@ -101,9 +101,10 @@ runtime logs, and `runtime/` for supervisor-owned runtime files. The
`ManagedStackManager` is the only component that writes `stack.json`.
Control ownership is the liveness and mutation authority. `acquireControl`
-returns `Owned` for the process that bound the deterministic endpoint or
-`Attached` for a live owner. An attached caller uses the owner's endpoint for
-runtime requests; it never edits the document directly.
+returns `Owned` for the process that bound one of the deterministic endpoint
+candidates or `Attached` for a live owner found on any candidate. An attached
+caller uses the owner's actual endpoint for runtime requests; it never edits
+the document directly.
### Start and attach
@@ -121,8 +122,8 @@ runtime requests; it never edits the document directly.
`stack.start()` over the control transport when service startup is needed.
`connectManagedStack` reads the document, probes the deterministic endpoint
-without binding it, and returns a `RemoteStack` only when the owner reports a
-ready running state. Read-only status and discovery therefore do not claim an
+candidates without binding them, and returns a `RemoteStack` against the
+owner's actual endpoint only when the owner reports a ready running state. Read-only status and discovery therefore do not claim an
endpoint; mutating operations acquire control ownership.
### Update, stop, and delete
@@ -200,14 +201,18 @@ persisted endpoint, or another stack's reservation under the normal exact-port
rules. A persisted automatic assignment in the control range is invalid and
fails loudly rather than being silently migrated.
-The control endpoint is derived from the stack id and served on loopback. A
-persisted endpoint is accepted only when it matches that derivation. A rare
-hash collision or unrelated listener makes control acquisition fail with a
-typed conflict; a read-only probe treats the address as non-live and never
-claims it. An exact service port can still equal the future endpoint of an
-identity that has never started, so that low-probability conflict is rejected
-when ownership is acquired rather than forbidding every explicit port in the
-reserved range.
+The control endpoint is derived from the stack id and served on loopback. The
+derivation yields a short deterministic candidate sequence rather than a
+single port: an owner binds the first free candidate, skipping candidates
+occupied by other stacks or unrelated listeners, and readers scan the same
+sequence and match the published `ownershipId`. A hash collision between two
+stack ids therefore degrades to the collided stack binding its next candidate
+instead of failing. Acquisition fails with a typed conflict only when every
+candidate is occupied by a foreign listener; a read-only probe treats an
+address with no matching owner as non-live and never claims it. An exact
+service port can still equal a candidate of an identity that has never
+started, so every stack's full candidate set is reserved against exact-port
+requests rather than forbidding every explicit port in the reserved range.
This is deliberately a small single-user localhost mechanism. The control
protocol has no token authentication; ownership, endpoint identity, and
diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts
index 04f7e2884a..8897c679b9 100644
--- a/packages/stack/src/LocalStack.ts
+++ b/packages/stack/src/LocalStack.ts
@@ -793,6 +793,9 @@ export const localStackLayer = (
).toReversed()) {
yield* runtime.orchestrator.stopService(target);
}
+ // Settle the public projection before returning so callers observe
+ // the stop immediately, matching the start/restart/waitReady paths.
+ yield* syncRuntimeProjectedStates(runtime);
}).pipe(withLifecycleLock),
restartService: (name) =>
Effect.gen(function* () {
diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts
index 44196e5dea..1eb8a84283 100644
--- a/packages/stack/src/Stack.unit.test.ts
+++ b/packages/stack/src/Stack.unit.test.ts
@@ -3,7 +3,7 @@ import { BunServices } from "@effect/platform-bun";
import { buildGraph } from "@supabase/process-compose";
import { createHmac } from "node:crypto";
import { mkdtempSync } from "node:fs";
-import { chmod, readFile, rm } from "node:fs/promises";
+import { readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect";
@@ -247,13 +247,18 @@ describe("Stack", () => {
).toBe("StackBuildError");
expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret");
+ // Replace the workspace directory with a plain file so the config write
+ // fails for any user — permission bits alone are bypassed by root.
const runtimeDirectory = join(runtimeRoot, "edge-runtime");
- yield* Effect.promise(() => chmod(runtimeDirectory, 0o500));
+ yield* Effect.promise(async () => {
+ await rm(runtimeDirectory, { recursive: true, force: true });
+ await writeFile(runtimeDirectory, "");
+ });
const failedBundle = functionsBundle(runtimeRoot, "failed-secret");
const error = yield* stack.reloadFunctions({ functions: failedBundle }).pipe(Effect.flip);
expect(error._tag).toBe("StackBuildError");
- yield* Effect.promise(() => chmod(runtimeDirectory, 0o700));
+ yield* Effect.promise(() => rm(runtimeDirectory));
yield* stack.reloadFunctions();
expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret");
@@ -268,12 +273,7 @@ describe("Stack", () => {
).toBe(false);
}).pipe(
Effect.provide(layer),
- Effect.ensuring(
- Effect.promise(async () => {
- await chmod(join(runtimeRoot, "edge-runtime"), 0o700).catch(() => {});
- await rm(runtimeRoot, { recursive: true, force: true });
- }),
- ),
+ Effect.ensuring(Effect.promise(() => rm(runtimeRoot, { recursive: true, force: true }))),
Effect.timeout("5 seconds"),
);
});
@@ -1087,14 +1087,10 @@ describe("Stack", () => {
return Effect.gen(function* () {
const stack = yield* Stack;
yield* stack.start();
- const authChanges = yield* stack.stateChanges("auth");
- const stopped = yield* authChanges.pipe(
- Stream.filter((state) => state.status === "Stopped"),
- Stream.runHead,
- Effect.forkChild({ startImmediately: true }),
- );
+ // `stopService` settles the public projection before returning, so the
+ // stopped state is observable immediately without stream coordination.
yield* stack.stopService("auth");
- expect((yield* Fiber.join(stopped))._tag).toBe("Some");
+ expect((yield* stack.getState("auth")).status).toBe("Stopped");
yield* stack.stop();
yield* stack.start();
diff --git a/packages/stack/src/discovery.ts b/packages/stack/src/discovery.ts
index cf21f60d15..cddeec124e 100644
--- a/packages/stack/src/discovery.ts
+++ b/packages/stack/src/discovery.ts
@@ -74,7 +74,7 @@ const liveStatus = (
): Effect.Effect =>
manager
.probeControl(document.id)
- .pipe(Effect.map((status) => status?.state === "running" && status.ready));
+ .pipe(Effect.map((probe) => probe?.status.state === "running" && probe.status.ready));
export const listStacks = (opts: {
readonly cacheRoot: string;
diff --git a/packages/stack/src/managed-control.integration.test.ts b/packages/stack/src/managed-control.integration.test.ts
index 0dbd8fe5e2..7fd41e05b7 100644
--- a/packages/stack/src/managed-control.integration.test.ts
+++ b/packages/stack/src/managed-control.integration.test.ts
@@ -7,10 +7,13 @@ import { describe, expect } from "vitest";
import { DaemonServer } from "./DaemonServer.ts";
import {
acquireControl,
+ CONTROL_CANDIDATE_COUNT,
controlEndpoint,
+ controlEndpointCandidates,
ControlBindError,
ControlTransport,
ControlTransportError,
+ probeControl,
} from "./managed/control.ts";
import { controlTransportLayer } from "./platform-node.ts";
import { httpTransportClientLayer } from "./HttpTransportClient.ts";
@@ -103,10 +106,15 @@ const spawnBoundChild = (port: number) => {
};
describe("managed control endpoint", () => {
- it.live("derives one deterministic loopback endpoint from the ownership id", () => {
+ it.live("derives deterministic loopback candidates from the ownership id", () => {
return Effect.sync(() => {
const endpoint = Effect.runSync(controlEndpoint(STACK_ID));
expect(endpoint.url).toBe("http://127.0.0.1:13737");
+ const candidates = Effect.runSync(controlEndpointCandidates(STACK_ID));
+ expect(candidates).toHaveLength(CONTROL_CANDIDATE_COUNT);
+ expect(candidates.map(({ port }) => port)).toEqual(
+ Array.from({ length: CONTROL_CANDIDATE_COUNT }, (_, offset) => 13737 + offset),
+ );
});
});
@@ -254,7 +262,7 @@ describe("managed control endpoint", () => {
),
);
- it.live("rejects a valid owner with a colliding deterministic endpoint", () =>
+ it.live("claims the next candidate when another stack owns the first", () =>
Effect.scoped(
live(
Effect.gen(function* () {
@@ -272,15 +280,20 @@ describe("managed control endpoint", () => {
),
);
yield* Effect.promise(() => daemonRuntime.runPromise(DaemonServer));
- const contender = yield* acquireControl({ stackId: COLLIDING_STACK_ID }).pipe(
- Effect.exit,
- );
- expect(Exit.isFailure(contender)).toBe(true);
- if (Exit.isFailure(contender)) {
- expect(Cause.squash(contender.cause)).toMatchObject({
- _tag: "ControlAddressConflictError",
- });
- }
+ const contender = yield* acquireControl({ stackId: COLLIDING_STACK_ID });
+ if (contender._tag !== "Owned") throw new Error("expected contender ownership");
+ expect(contender.endpoint.port).not.toBe(owner.endpoint.port);
+
+ // Readers locate each owner at its actual candidate.
+ const ownerProbe = yield* probeControl(STACK_ID);
+ expect(ownerProbe?.endpoint.port).toBe(owner.endpoint.port);
+ const contenderProbe = yield* probeControl(COLLIDING_STACK_ID);
+ expect(contenderProbe?.endpoint.port).toBe(contender.endpoint.port);
+
+ // A second caller for the collided stack attaches to its owner.
+ const attached = yield* acquireControl({ stackId: COLLIDING_STACK_ID });
+ expect(attached._tag).toBe("Attached");
+ expect(attached.endpoint.port).toBe(contender.endpoint.port);
yield* Effect.promise(() => daemonRuntime.dispose());
}),
),
@@ -306,15 +319,37 @@ describe("managed control endpoint", () => {
),
);
- it.live("rejects an unrelated listener without taking it over", () =>
+ it.live("claims the next candidate without taking over an unrelated listener", () =>
live(
Effect.scoped(
Effect.gen(function* () {
- const endpoint = yield* controlEndpoint(STACK_ID);
+ const candidates = yield* controlEndpointCandidates(STACK_ID);
const unrelated = yield* Effect.acquireRelease(
- Effect.promise(() => listenRaw(endpoint.port)),
+ Effect.promise(() => listenRaw(candidates[0]!.port)),
(server) => Effect.promise(() => closeRaw(server)),
);
+ const owner = yield* acquireControl({ stackId: STACK_ID });
+ if (owner._tag !== "Owned") throw new Error("expected control ownership");
+ expect(owner.endpoint.port).toBe(candidates[1]!.port);
+ expect(unrelated.listening).toBe(true);
+ const probe = yield* probeControl(STACK_ID);
+ expect(probe?.endpoint.port).toBe(candidates[1]!.port);
+ }),
+ ),
+ ),
+ );
+
+ it.live("fails once every candidate is occupied by unrelated listeners", () =>
+ live(
+ Effect.scoped(
+ Effect.gen(function* () {
+ const candidates = yield* controlEndpointCandidates(STACK_ID);
+ yield* Effect.forEach(candidates, (candidate) =>
+ Effect.acquireRelease(
+ Effect.promise(() => listenRaw(candidate.port)),
+ (server) => Effect.promise(() => closeRaw(server)),
+ ),
+ );
const result = yield* acquireControl({
stackId: STACK_ID,
}).pipe(
@@ -325,7 +360,6 @@ describe("managed control endpoint", () => {
);
expect(result._tag).toBe("Left");
if (result._tag === "Left") expect(result.error._tag).toBe("ControlAddressConflictError");
- expect(unrelated.listening).toBe(true);
}),
),
),
@@ -350,7 +384,7 @@ describe("managed control endpoint", () => {
requestStop: () => Effect.void,
});
const exit = yield* acquireControl({ stackId: STACK_ID }).pipe(
- Effect.timeout("2 seconds"),
+ Effect.timeout("10 seconds"),
Effect.exit,
Effect.provide(unavailable),
);
diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts
index f75f0df0d0..dcba0d8407 100644
--- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts
+++ b/packages/stack/src/managed-manager-lifecycle.integration.test.ts
@@ -133,7 +133,9 @@ describe("managed stack lifecycle journeys", () => {
? Effect.succeed(current)
: Effect.fail(new Error("stop pending")),
),
- Effect.retry(Schedule.spaced("10 millis").pipe(Schedule.upTo({ duration: "2 seconds" }))),
+ Effect.retry(
+ Schedule.spaced("10 millis").pipe(Schedule.upTo({ duration: "10 seconds" })),
+ ),
);
yield* owner.close;
yield* Fiber.join(stopFiber);
@@ -179,7 +181,7 @@ describe("managed stack lifecycle journeys", () => {
} satisfies FileSystem.FileSystem;
}),
).pipe(Layer.provide(NodeFileSystem.layer));
- const managerLayer = managedStackManagerLayer({ stateRoot }).pipe(
+ const managerLayer = managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe(
Layer.provide(gatedFileSystemLayer),
Layer.provide(NodePath.layer),
Layer.provide(gitConfigStoreLayer),
diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts
index d88a09795f..1ff6a4013a 100644
--- a/packages/stack/src/managed-manager-projects.integration.test.ts
+++ b/packages/stack/src/managed-manager-projects.integration.test.ts
@@ -151,17 +151,23 @@ describe("managed stack projects journeys", () => {
const baseTransport = yield* ControlTransport;
const readStarted = yield* Deferred.make();
const continueRead = yield* Deferred.make();
+ // Hold only the status probe's first read in flight. Later reads (the
+ // concurrent acquire scans its endpoint candidates before binding) must
+ // pass through, mirroring the real transport's bounded read timeout.
let gateReads = false;
+ let gatedRead = false;
const gatedTransport = Layer.succeed(ControlTransport, {
...baseTransport,
read: (endpoint) =>
- gateReads
- ? Effect.gen(function* () {
- yield* Deferred.succeed(readStarted, void 0);
- yield* Deferred.await(continueRead);
- return yield* baseTransport.read(endpoint);
- })
- : baseTransport.read(endpoint),
+ Effect.suspend(() => {
+ if (!gateReads || gatedRead) return baseTransport.read(endpoint);
+ gatedRead = true;
+ return Effect.gen(function* () {
+ yield* Deferred.succeed(readStarted, void 0);
+ yield* Deferred.await(continueRead);
+ return yield* baseTransport.read(endpoint);
+ });
+ }),
});
yield* Effect.scoped(
diff --git a/packages/stack/src/managed-manager-recovery.integration.test.ts b/packages/stack/src/managed-manager-recovery.integration.test.ts
index 2bad6395ad..9ccfffc7e6 100644
--- a/packages/stack/src/managed-manager-recovery.integration.test.ts
+++ b/packages/stack/src/managed-manager-recovery.integration.test.ts
@@ -1,17 +1,19 @@
import { it } from "@effect/vitest";
import { NodeFileSystem, NodePath } from "@effect/platform-node";
-import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, ManagedRuntime } from "effect";
+import {
+ Cause,
+ Deferred,
+ Effect,
+ Exit,
+ Fiber,
+ FileSystem,
+ Layer,
+ ManagedRuntime,
+ PlatformError,
+} from "effect";
import { HttpServer } from "effect/unstable/http";
import { randomBytes } from "node:crypto";
-import {
- chmodSync,
- cpSync,
- mkdirSync,
- mkdtempSync,
- realpathSync,
- renameSync,
- writeFileSync,
-} from "node:fs";
+import { cpSync, mkdirSync, mkdtempSync, realpathSync, renameSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect } from "vitest";
@@ -50,7 +52,7 @@ const acquireIsolatedCollisionOwner = () =>
const stackId = randomBytes(32).toString("hex");
const collidingStackId = `${stackId.slice(0, 10)}${randomBytes(27).toString("hex")}`;
const acquisition = yield* acquireControl({ stackId }).pipe(
- Effect.timeout("1 second"),
+ Effect.timeout("5 seconds"),
Effect.exit,
);
if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") {
@@ -67,7 +69,7 @@ const acquireIsolatedStackOwner = (workspacePath: string) =>
const stackName = `test-${randomBytes(8).toString("hex")}`;
const stackId = deriveStackId(environment.identity, stackName);
const acquisition = yield* acquireControl({ stackId }).pipe(
- Effect.timeout("1 second"),
+ Effect.timeout("5 seconds"),
Effect.exit,
);
if (Exit.isSuccess(acquisition) && acquisition.value._tag === "Owned") {
@@ -126,7 +128,7 @@ describe("managed stack recovery journeys", () => {
} satisfies FileSystem.FileSystem;
}),
).pipe(Layer.provide(NodeFileSystem.layer));
- const managerLayer = managedStackManagerLayer({ stateRoot }).pipe(
+ const managerLayer = managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe(
Layer.provide(gatedFileSystemLayer),
Layer.provide(NodePath.layer),
Layer.provide(gitConfigStoreLayer),
@@ -222,11 +224,11 @@ describe("managed stack recovery journeys", () => {
ownership: stackOwner.ownership,
})
.pipe(Effect.forkScoped);
- yield* Deferred.await(repairRead).pipe(Effect.timeout("1 second"));
+ yield* Deferred.await(repairRead).pipe(Effect.timeout("30 seconds"));
expect(yield* manager.inspectStack(stackId)).toBeUndefined();
yield* repairOwner.close;
yield* Effect.promise(() => repairDaemon.dispose());
- const started = yield* Fiber.join(startFiber).pipe(Effect.timeout("2 seconds"));
+ const started = yield* Fiber.join(startFiber).pipe(Effect.timeout("60 seconds"));
expect(started.stack.id).toBe(stackId);
yield* releaseLease(started);
}),
@@ -270,6 +272,33 @@ describe("managed stack recovery journeys", () => {
it.live("repairs a moved workspace without changing stack id or ports", () => {
const { layer, stateRoot } = setup();
+ // Permission bits cannot block writes when tests run as root, so gate the
+ // FileSystem seam instead to force the partial-repair failure.
+ const blockedWrites = { root: undefined as string | undefined };
+ const blockingFileSystemLayer = Layer.effect(
+ FileSystem.FileSystem,
+ Effect.gen(function* () {
+ const base = yield* FileSystem.FileSystem;
+ return {
+ ...base,
+ writeFileString: (
+ path: string,
+ data: string,
+ options?: Parameters[2],
+ ) =>
+ blockedWrites.root !== undefined && path.startsWith(blockedWrites.root)
+ ? Effect.fail(
+ PlatformError.systemError({
+ _tag: "PermissionDenied",
+ module: "FileSystem",
+ method: "writeFileString",
+ pathOrDescriptor: path,
+ }),
+ )
+ : base.writeFileString(path, data, options),
+ } satisfies FileSystem.FileSystem;
+ }),
+ ).pipe(Layer.provide(NodeFileSystem.layer));
return Effect.scoped(
Effect.gen(function* () {
const root = mkdtempSync(join(tmpdir(), "managed-repair-test-"));
@@ -322,9 +351,9 @@ describe("managed stack recovery journeys", () => {
const blockedId = [originalId, secondaryId].sort().at(-1);
if (blockedId === undefined) throw new Error("expected affected stack");
const blockedRoot = managedStackPaths(stateRoot, blockedId).root;
- chmodSync(blockedRoot, 0o500);
+ blockedWrites.root = blockedRoot;
const failed = yield* manager.repairWorkspace(discovery.repair).pipe(Effect.exit);
- chmodSync(blockedRoot, 0o700);
+ blockedWrites.root = undefined;
expect(Exit.isFailure(failed)).toBe(true);
const firstUpdatedId = [originalId, secondaryId].sort().at(0);
if (firstUpdatedId === undefined) throw new Error("expected affected stack");
@@ -349,7 +378,7 @@ describe("managed stack recovery journeys", () => {
}),
).pipe(
Effect.provide(layer),
- Effect.provide(NodeFileSystem.layer),
+ Effect.provide(blockingFileSystemLayer),
Effect.provide(NodePath.layer),
Effect.provide(gitConfigStoreLayer),
Effect.provide(controlTransportLayer),
diff --git a/packages/stack/src/managed/control.ts b/packages/stack/src/managed/control.ts
index 122b92dbff..65efbdab0b 100644
--- a/packages/stack/src/managed/control.ts
+++ b/packages/stack/src/managed/control.ts
@@ -69,7 +69,7 @@ export class ControlAddressConflictError extends Data.TaggedError("ControlAddres
readonly cause: unknown;
}> {
override get message(): string {
- return `Control endpoint ${this.endpoint.url} is occupied by a non-Supabase listener`;
+ return `Control endpoint ${this.endpoint.url} is occupied by another listener`;
}
}
@@ -148,20 +148,41 @@ const ownershipBytes = (ownershipId: string): ReadonlyArray => {
return bytes;
};
-/** Derives a deterministic loopback address and reserved port from a stack id. */
-export const controlEndpoint = (
+/**
+ * Number of deterministic endpoints derived per ownership id. Two ids can
+ * hash to the same primary port, so owners fall through to the next
+ * candidate and readers scan the same sequence, matching on `ownershipId`.
+ */
+export const CONTROL_CANDIDATE_COUNT = 8;
+
+const CONTROL_RANGE_SIZE = CONTROL_PORT_RANGE.max - CONTROL_PORT_RANGE.min + 1;
+
+const endpointForValue = (value: number): ControlEndpoint => {
+ const port = CONTROL_PORT_RANGE.min + (value % CONTROL_RANGE_SIZE);
+ const host = "127.0.0.1";
+ return { hostname: host, port, url: `http://${host}:${port}` };
+};
+
+/** Derives the deterministic loopback endpoint candidates for a stack id. */
+export const controlEndpointCandidates = (
ownershipId: string,
-): Effect.Effect => {
+): Effect.Effect, InvalidControlOwnershipIdError> => {
if (!CONTROL_ID_PATTERN.test(ownershipId)) return invalidId(ownershipId);
const bytes = ownershipBytes(ownershipId);
const value = (bytes[3]! << 8) | bytes[4]!;
- const port =
- CONTROL_PORT_RANGE.min + (value % (CONTROL_PORT_RANGE.max - CONTROL_PORT_RANGE.min + 1));
- const host = "127.0.0.1";
- const url = `http://${host}:${port}`;
- return Effect.succeed({ hostname: host, port, url });
+ return Effect.succeed(
+ Array.from({ length: CONTROL_CANDIDATE_COUNT }, (_, offset) =>
+ endpointForValue(value + offset),
+ ),
+ );
};
+/** Derives the primary deterministic endpoint (first candidate) for a stack id. */
+export const controlEndpoint = (
+ ownershipId: string,
+): Effect.Effect =>
+ Effect.map(controlEndpointCandidates(ownershipId), (candidates) => candidates[0]!);
+
const decodeOwnerStatus = (
endpoint: ControlEndpoint,
value: unknown,
@@ -224,22 +245,40 @@ const readOwnerStatus = (
),
);
-/** Reads an existing owner without ever claiming its deterministic endpoint. */
+/** A located owner: its published status and the candidate it bound. */
+export interface ControlProbe {
+ readonly status: ControlOwnerStatus;
+ readonly endpoint: ControlEndpoint;
+}
+
+/** Reads an existing owner wherever it bound, without claiming an endpoint. */
export const probeControl = (
ownershipId: string,
-): Effect.Effect<
- ControlOwnerStatus | undefined,
- InvalidControlOwnershipIdError,
- ControlTransport
-> =>
+): Effect.Effect =>
Effect.gen(function* () {
- const endpoint = yield* controlEndpoint(ownershipId);
+ const candidates = yield* controlEndpointCandidates(ownershipId);
const transport = yield* ControlTransport;
- return yield* readOwnerStatus(endpoint, ownershipId, transport).pipe(
- Effect.catch(() => Effect.succeed(undefined)),
- );
+ for (const endpoint of candidates) {
+ const status = yield* readOwnerStatus(endpoint, ownershipId, transport).pipe(
+ Effect.catch(() => Effect.succeed(undefined)),
+ );
+ if (status !== undefined) return { status, endpoint };
+ }
+ return undefined;
});
+const makeAttached = (
+ endpoint: ControlEndpoint,
+ ownershipId: string,
+ transport: ControlTransportShape,
+): ControlAttached => ({
+ _tag: "Attached",
+ ownershipId,
+ endpoint,
+ ownerStatus: readOwnerStatus(endpoint, ownershipId, transport),
+ requestStop: transport.requestStop(endpoint),
+});
+
const attach = (
endpoint: ControlEndpoint,
ownershipId: string,
@@ -252,13 +291,7 @@ const attach = (
| ControlAddressConflictError
> =>
readOwnerStatus(endpoint, ownershipId, transport).pipe(
- Effect.map(() => ({
- _tag: "Attached" as const,
- ownershipId,
- endpoint,
- ownerStatus: readOwnerStatus(endpoint, ownershipId, transport),
- requestStop: transport.requestStop(endpoint),
- })),
+ Effect.map(() => makeAttached(endpoint, ownershipId, transport)),
);
const makeOwned = (
@@ -295,8 +328,33 @@ const makeOwned = (
});
};
-const acquireAtEndpoint = (
- endpoint: ControlEndpoint,
+/**
+ * Finds the candidate a live owner of `ownershipId` bound, if any. Foreign
+ * owners, non-Supabase listeners, and free ports are skipped; a protocol
+ * mismatch fails closed because a newer owner of this very stack may be
+ * publishing there, and claiming another candidate beside it would split
+ * ownership across versions.
+ */
+const scanForOwner = (
+ candidates: ReadonlyArray,
+ ownershipId: string,
+ transport: ControlTransportShape,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ for (const endpoint of candidates) {
+ const found = yield* readOwnerStatus(endpoint, ownershipId, transport).pipe(
+ Effect.map(() => true),
+ Effect.catchTag("ControlTransportError", () => Effect.succeed(false)),
+ Effect.catchTag("ControlProtocolError", () => Effect.succeed(false)),
+ Effect.catchTag("ControlAddressConflictError", () => Effect.succeed(false)),
+ );
+ if (found) return endpoint;
+ }
+ return undefined;
+ });
+
+const acquireAtCandidates = (
+ candidates: ReadonlyArray,
ownershipId: string,
status: ControlOwnerStatus,
transport: ControlTransportShape,
@@ -321,53 +379,98 @@ const acquireAtEndpoint = (
| ControlUnavailableError,
import("effect/Scope").Scope
> = Effect.gen(function* () {
- const bound = yield* transport
- .bind(
- endpoint,
- () => Ref.getUnsafe(statusRef),
- () => {
- Effect.runSync(Deferred.succeed(stopRequested, void 0));
- },
- )
- .pipe(Effect.result);
- if (Result.isSuccess(bound)) {
- const owned = yield* makeOwned(
+ // An existing owner may hold any candidate: an earlier occupant can have
+ // freed a lower port since the owner bound. Attach before claiming one so
+ // a stack never ends up with two owners on different candidates. The scan
+ // read doubles as the attach handshake, so an owner is read exactly once.
+ const ownerEndpoint = yield* scanForOwner(candidates, ownershipId, transport);
+ if (ownerEndpoint !== undefined) {
+ return makeAttached(ownerEndpoint, ownershipId, transport);
+ }
+ let pending: ControlUnavailableError | undefined;
+ let conflict: ControlAddressConflictError | undefined;
+ for (const endpoint of candidates) {
+ const bound = yield* transport
+ .bind(
+ endpoint,
+ () => Ref.getUnsafe(statusRef),
+ () => {
+ Effect.runSync(Deferred.succeed(stopRequested, void 0));
+ },
+ )
+ .pipe(Effect.result);
+ if (Result.isSuccess(bound)) {
+ const owned = yield* makeOwned(
+ endpoint,
+ ownershipId,
+ bound.success,
+ statusRef,
+ stopRequested,
+ );
+ yield* Effect.addFinalizer(() => owned.close);
+ return owned;
+ }
+ const error = bound.failure;
+ if (error.reason !== "in-use") return yield* Effect.fail(error);
+ // The address was taken between the scan and the bind: attach if the
+ // occupant is our owner, retry the walk if it is not serving yet, and
+ // move to the next candidate if it belongs to someone else.
+ const attached: ControlAcquisition | undefined = yield* attach(
endpoint,
ownershipId,
- bound.success,
- statusRef,
- stopRequested,
+ transport,
+ ).pipe(
+ Effect.map((acquisition): ControlAcquisition | undefined => acquisition),
+ Effect.catchTag("ControlAddressConflictError", (cause) =>
+ Effect.sync(() => {
+ conflict = cause;
+ return undefined;
+ }),
+ ),
+ Effect.catchTag("ControlProtocolError", (cause) =>
+ Effect.sync(() => {
+ conflict = new ControlAddressConflictError({ endpoint, cause });
+ return undefined;
+ }),
+ ),
+ Effect.catchTag("ControlTransportError", (cause) =>
+ cause.reason === "unreachable"
+ ? Effect.sync(() => {
+ pending = unavailable(endpoint, cause);
+ return undefined;
+ })
+ : Effect.fail(cause),
+ ),
);
- yield* Effect.addFinalizer(() => owned.close);
- return owned;
+ if (attached !== undefined) return attached;
}
- const error = bound.failure;
- if (error.reason !== "in-use") return yield* Effect.fail(error);
- return yield* attach(endpoint, ownershipId, transport).pipe(
- Effect.mapError((cause) =>
- cause._tag === "ControlTransportError" && cause.reason === "unreachable"
- ? unavailable(endpoint, cause)
- : cause._tag === "ControlProtocolError"
- ? new ControlAddressConflictError({ endpoint, cause })
- : cause,
- ),
+ if (pending !== undefined) return yield* Effect.fail(pending);
+ return yield* Effect.fail(
+ conflict ??
+ new ControlAddressConflictError({
+ endpoint: candidates[0]!,
+ cause: new Error("Every control endpoint candidate is occupied"),
+ }),
);
});
return attempt.pipe(
Effect.retry({
- // Leave explicit margin inside the parent's 35-second startup handshake,
- // even when every owner probe consumes its 500 ms transport timeout.
- schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ times: 30 })),
+ // Bound by duration, not attempts: an attempt's own reads can each
+ // consume the 500 ms transport timeout, and a count-based budget would
+ // stretch a single acquire far past the parent's startup handshake.
+ schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "1500 millis" })),
while: (error) => error._tag === "ControlUnavailableError",
}),
Effect.catchTag("ControlUnavailableError", (error) =>
- Effect.fail(new ControlAddressConflictError({ endpoint, cause: error.cause })),
+ Effect.fail(
+ new ControlAddressConflictError({ endpoint: error.endpoint, cause: error.cause }),
+ ),
),
);
};
-/** Acquires the deterministic loopback listener or attaches to its owner. */
+/** Acquires a deterministic loopback listener or attaches to its owner. */
export const acquireControl = (
input: ControlOwnershipInput,
): Effect.Effect<
@@ -381,10 +484,10 @@ export const acquireControl = (
ControlTransport | import("effect/Scope").Scope
> =>
Effect.gen(function* () {
- const endpoint = yield* controlEndpoint(input.stackId);
+ const candidates = yield* controlEndpointCandidates(input.stackId);
const transport = yield* ControlTransport;
- return yield* acquireAtEndpoint(
- endpoint,
+ return yield* acquireAtCandidates(
+ candidates,
input.stackId,
defaultStatus(input.stackId, input.initialStatus),
transport,
diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts
index 5387a01b25..fc77333617 100644
--- a/packages/stack/src/managed/lifecycle.ts
+++ b/packages/stack/src/managed/lifecycle.ts
@@ -9,14 +9,13 @@ import { HttpTransportClient, HttpTransportClientError } from "../HttpTransportC
import type { ManagedStackDocument } from "./document.ts";
import {
ManagedStackAttachedError,
- ManagedStackControlRequiredError,
ManagedStackManager,
ManagedWorkspaceRepairConflictError,
workspaceRepairConflict,
type ManagedStackManagerError,
type ManagedStackLaunchUpdate,
} from "./manager.ts";
-import { ControlTransportError, controlEndpoint, type ControlEndpoint } from "./control.ts";
+import { ControlTransportError } from "./control.ts";
import {
ManagedStackNotStoppedError,
type ManagedPortIntentDocument,
@@ -71,28 +70,11 @@ export const resolveManagedDocument = (
return document === undefined ? yield* Effect.fail(noRunningStack(input)) : document;
});
-const runtimeEndpoint = (
- document: ManagedStackDocument,
- input: ManagedLifecycleInput,
-): Effect.Effect =>
- Effect.gen(function* () {
- if (
- (document.lifecycle !== "running" && document.lifecycle !== "starting") ||
- (document.lifecycle === "running" && document.runtime?.controlEndpoint === undefined)
- ) {
- return yield* Effect.fail(noRunningStack(input));
- }
- const endpoint = yield* controlEndpoint(document.id).pipe(
- Effect.mapError(() => new ManagedStackControlRequiredError({ stackId: document.id })),
- );
- return endpoint;
- });
-
class ManagedStopPending extends Data.TaggedError("ManagedStopPending")<{}> {}
class ManagedStopOwnerTerminal extends Data.TaggedError("ManagedStopOwnerTerminal")<{}> {}
class ManagedDeletePending extends Data.TaggedError("ManagedDeletePending")<{}> {}
-/** Connect to the deterministic endpoint persisted by the managed supervisor. */
+/** Connect to the control endpoint the managed supervisor actually bound. */
export const connectManagedStack = (
input: ManagedLifecycleInput,
): Effect.Effect<
@@ -102,14 +84,19 @@ export const connectManagedStack = (
> =>
Effect.gen(function* () {
const document = yield* resolveManagedDocument(input);
+ if (
+ (document.lifecycle !== "running" && document.lifecycle !== "starting") ||
+ (document.lifecycle === "running" && document.runtime?.controlEndpoint === undefined)
+ ) {
+ return yield* Effect.fail(noRunningStack(input));
+ }
const manager = yield* ManagedStackManager;
- const status = yield* manager.probeControl(document.id);
- if (status?.state !== "running" || !status.ready) {
+ const probe = yield* manager.probeControl(document.id);
+ if (probe === undefined || probe.status.state !== "running" || !probe.status.ready) {
return yield* Effect.fail(noRunningStack(input));
}
- const endpoint = yield* runtimeEndpoint(document, input);
const client = yield* HttpTransportClient;
- return RemoteStack.layer(endpoint).pipe(
+ return RemoteStack.layer(probe.endpoint).pipe(
Layer.provide(Layer.succeed(HttpTransportClient, client)),
);
});
diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts
index 0db8766d52..7746b11c8a 100644
--- a/packages/stack/src/managed/manager.ts
+++ b/packages/stack/src/managed/manager.ts
@@ -23,12 +23,12 @@ import {
import {
acquireControl,
CONTROL_PORT_RANGE,
- controlEndpoint,
+ controlEndpointCandidates,
ControlTransport,
probeControl,
type ControlAcquisition,
- type ControlOwnerStatus,
type ControlOwnership,
+ type ControlProbe,
} from "./control.ts";
import {
discoverEnvironment,
@@ -209,7 +209,7 @@ export interface ManagedStackManagerShape {
) => Effect.Effect;
readonly probeControl: (
stackId: string,
- ) => Effect.Effect;
+ ) => Effect.Effect;
readonly readStack: (
request: ReadStackRequest,
) => Effect.Effect;
@@ -368,10 +368,17 @@ const conflictError = (
ownerKey: owner.ports.find((candidate) => candidate.port === assignment.port)?.key,
});
+interface ManagedStackManagerOptions {
+ readonly stateRoot: string;
+ /** Test seam: disable well-known default ports for automatic allocation. */
+ readonly preferCatalogDefaults?: boolean;
+}
+
const makeManager = (
- stateRoot: string,
+ options: ManagedStackManagerOptions,
): Effect.Effect =>
Effect.gen(function* () {
+ const { stateRoot, preferCatalogDefaults = true } = options;
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const gitConfig = yield* GitConfigStore;
@@ -458,6 +465,7 @@ const makeManager = (
disabledFields: request.portDocument.disabledFields,
intents: resolvePortIntents(request.portDocument),
persisted,
+ preferCatalogDefaults,
});
const requests = portRequests(plan);
const exactRequests = requests.filter((item) => item.selection.kind === "exact");
@@ -493,7 +501,9 @@ const makeManager = (
);
}
const strictReserved = new Set();
- const exactReserved = new Set([(yield* controlEndpoint(request.stackId)).port]);
+ const exactReserved = new Set(
+ (yield* controlEndpointCandidates(request.stackId)).map(({ port }) => port),
+ );
const owners = new Map<
number,
ReadonlyArray<{
@@ -502,7 +512,9 @@ const makeManager = (
}>
>();
for (const listing of listings.filter(isHealthyDocument)) {
- exactReserved.add((yield* controlEndpoint(listing.document.id)).port);
+ for (const candidate of yield* controlEndpointCandidates(listing.document.id)) {
+ exactReserved.add(candidate.port);
+ }
if (listing.document.id === request.stackId) continue;
for (const assignment of listing.document.ports) {
const liveExact =
@@ -714,7 +726,12 @@ const makeManager = (
),
),
Effect.retry({
- schedule: Schedule.spaced("20 millis").pipe(Schedule.upTo({ times: 250 })),
+ // A held repair fence means another process is actively
+ // repairing this workspace; wait out a realistic repair
+ // (Git operations included) instead of failing after ~5s.
+ schedule: Schedule.spaced("20 millis").pipe(
+ Schedule.upTo({ duration: "30 seconds" }),
+ ),
while: (error) => error instanceof ManagedWorkspaceRepairConflictError,
}),
),
@@ -999,11 +1016,12 @@ const makeManager = (
});
/** Internal manager layer. Platform layers provide filesystem, Git, and control transport. */
-export const managedStackManagerLayer = (options: {
- readonly stateRoot: string;
-}): Layer.Layer =>
- Layer.effect(ManagedStackManager, makeManager(options.stateRoot));
+export const managedStackManagerLayer = (
+ options: ManagedStackManagerOptions,
+): Layer.Layer =>
+ Layer.effect(ManagedStackManager, makeManager(options));
export const makeManagedStackManager = (
stateRoot: string,
-): Effect.Effect => makeManager(stateRoot);
+): Effect.Effect =>
+ makeManager({ stateRoot });
diff --git a/packages/stack/src/managed/port-plan.ts b/packages/stack/src/managed/port-plan.ts
index 8c78cfb22c..0c249e2a97 100644
--- a/packages/stack/src/managed/port-plan.ts
+++ b/packages/stack/src/managed/port-plan.ts
@@ -49,6 +49,12 @@ export interface ManagedPortPlanInput {
readonly disabledFields?: ReadonlyArray;
readonly intents: ReadonlyArray;
readonly persisted?: ReadonlyArray;
+ /**
+ * Seed automatic selections with the catalog's well-known default ports.
+ * Tests disable this so parallel suites do not contend on the same
+ * defaults, which sticky reuse later re-reserves exactly.
+ */
+ readonly preferCatalogDefaults?: boolean;
}
const automaticSelection = (preferred: number | undefined): PortSelection =>
@@ -57,6 +63,8 @@ const automaticSelection = (preferred: number | undefined): PortSelection =>
/** Build sticky durable selections and runtime-only requests from resolved intent. */
export const planManagedPorts = (input: ManagedPortPlanInput): ManagedPortPlan => {
const persisted = input.persisted ?? [];
+ const preferredFor = (entry: (typeof PORT_CATALOG)[PortField]): number | undefined =>
+ (input.preferCatalogDefaults ?? true) ? entry.preferred : undefined;
const persistedByKey = new Map(persisted.map((assignment) => [assignment.key, assignment]));
const intentsByField = new Map(input.intents.map((request) => [request.field, request]));
const activeKeys = new Set();
@@ -91,7 +99,7 @@ export const planManagedPorts = (input: ManagedPortPlanInput): ManagedPortPlan =
field,
key: entry.configKey,
intent,
- selection: automaticSelection(entry.preferred),
+ selection: automaticSelection(preferredFor(entry)),
newlyAllocatedAutomatic: true,
});
}
@@ -99,7 +107,7 @@ export const planManagedPorts = (input: ManagedPortPlanInput): ManagedPortPlan =
}
if (entry.persistence === "runtime") {
- runtimeOnly.push({ field, selection: automaticSelection(entry.preferred) });
+ runtimeOnly.push({ field, selection: automaticSelection(preferredFor(entry)) });
}
}
diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts
index dbe2176aef..ebbeee33fc 100644
--- a/packages/stack/src/platform-bun.ts
+++ b/packages/stack/src/platform-bun.ts
@@ -82,6 +82,10 @@ const controlTransport: ControlTransport["Service"] = {
try: async () => {
const response = await fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STATUS_PATH}`, {
signal: AbortSignal.timeout(500),
+ // One-shot connection: a pooled keep-alive connection would let a
+ // closed listener keep answering status probes while the probes
+ // themselves keep the connection alive.
+ headers: { connection: "close" },
});
if (!response.ok) throw new Error(`Control status request returned ${response.status}`);
return await response.json();
@@ -107,6 +111,7 @@ const controlTransport: ControlTransport["Service"] = {
const response = await fetch(`http://127.0.0.1:${endpoint.port}${CONTROL_STOP_PATH}`, {
method: "POST",
signal: AbortSignal.timeout(500),
+ headers: { connection: "close" },
});
if (!response.ok) throw new Error(`Control stop request returned ${response.status}`);
},
diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts
index 4f43b33fad..583db3c324 100644
--- a/packages/stack/src/platform-node.ts
+++ b/packages/stack/src/platform-node.ts
@@ -78,6 +78,10 @@ const controlTransport: ControlTransport["Service"] = {
port: endpoint.port,
path: CONTROL_STATUS_PATH,
method: "GET",
+ // One-shot connection: a pooled keep-alive connection would
+ // let a closed listener keep answering status probes while
+ // the probes themselves keep the connection alive.
+ agent: false,
},
(response) => {
let body = "";
@@ -142,6 +146,7 @@ const controlTransport: ControlTransport["Service"] = {
port: endpoint.port,
path: CONTROL_STOP_PATH,
method: "POST",
+ agent: false,
},
(response) => {
response.resume();
diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts
index a87342d194..ac8e6d0d9e 100644
--- a/packages/stack/src/supervisor.integration.test.ts
+++ b/packages/stack/src/supervisor.integration.test.ts
@@ -89,6 +89,43 @@ const workspace = async (): Promise<{
throw new Error("Unable to allocate a free supervisor control endpoint after 32 attempts");
};
+/**
+ * Watches a directory, re-arming on ENOENT watcher errors: the runtime's
+ * directory watcher can report ENOENT when a watched entry (for example an
+ * atomic-write temp file) vanishes mid-scan. Callers keep their own timeout
+ * as the guard. Returns a close function.
+ */
+const watchDirectoryWithRetry = (
+ directory: string,
+ onEvent: () => void,
+ onError: (cause: unknown) => void,
+): (() => void) => {
+ let watcher: FSWatcher | undefined;
+ let closed = false;
+ const arm = () => {
+ if (closed) return;
+ try {
+ watcher = watch(directory, () => onEvent());
+ watcher.once("error", (cause) => {
+ watcher?.close();
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") {
+ arm();
+ onEvent();
+ return;
+ }
+ onError(cause);
+ });
+ } catch (cause) {
+ onError(cause);
+ }
+ };
+ arm();
+ return () => {
+ closed = true;
+ watcher?.close();
+ };
+};
+
const waitForFile = (path: string): Promise =>
new Promise((resolve, reject) => {
if (existsSync(path)) {
@@ -97,20 +134,21 @@ const waitForFile = (path: string): Promise =>
}
let settled = false;
let timeout: ReturnType | undefined;
- let watcher: FSWatcher | undefined;
+ let stopWatching: (() => void) | undefined;
const settle = (continuation: () => void) => {
if (settled) return;
settled = true;
if (timeout !== undefined) clearTimeout(timeout);
- watcher?.close();
+ stopWatching?.();
continuation();
};
- watcher = watch(dirname(path), () => {
- if (existsSync(path)) settle(resolve);
- });
- watcher.once("error", (cause) => {
- settle(() => reject(cause));
- });
+ stopWatching = watchDirectoryWithRetry(
+ dirname(path),
+ () => {
+ if (existsSync(path)) settle(resolve);
+ },
+ (cause) => settle(() => reject(cause instanceof Error ? cause : new Error(String(cause)))),
+ );
timeout = setTimeout(
() => settle(() => reject(new Error(`timed out waiting for file ${path}`))),
FILE_WAIT_TIMEOUT_MS,
@@ -509,14 +547,14 @@ const waitForStackDocument = async (
if (existing?.lifecycle === lifecycle) return existing;
return new Promise((resolve, reject) => {
- let watcher: FSWatcher | undefined;
+ let stopWatching: (() => void) | undefined;
let timeout: ReturnType | undefined;
let settled = false;
const settle = (continuation: () => void) => {
if (settled) return;
settled = true;
if (timeout !== undefined) clearTimeout(timeout);
- watcher?.close();
+ stopWatching?.();
continuation();
};
const check = () => {
@@ -525,9 +563,9 @@ const waitForStackDocument = async (
settle(() => resolve(document));
}
};
- const fail = (cause: unknown) => settle(() => reject(cause));
- watcher = watch(stackDirectory, () => check());
- watcher.once("error", fail);
+ const fail = (cause: unknown) =>
+ settle(() => reject(cause instanceof Error ? cause : new Error(String(cause))));
+ stopWatching = watchDirectoryWithRetry(stackDirectory, check, fail);
timeout = setTimeout(
() => fail(new Error(`timed out waiting for stack document lifecycle ${lifecycle}`)),
FILE_WAIT_TIMEOUT_MS,
diff --git a/packages/stack/tests/helpers/managed-manager.ts b/packages/stack/tests/helpers/managed-manager.ts
index 69dca80120..d5a1d9cc39 100644
--- a/packages/stack/tests/helpers/managed-manager.ts
+++ b/packages/stack/tests/helpers/managed-manager.ts
@@ -29,7 +29,7 @@ export const setupManagedManager = (roots: Array) => {
const workspace = join(root, "workspace");
mkdirSync(workspace);
const stateRoot = join(root, "state");
- const layer = managedStackManagerLayer({ stateRoot });
+ const layer = managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false });
return { layer, stateRoot, workspace };
};
diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts
index 6f28597d78..3883a4d19a 100644
--- a/packages/stack/tests/helpers/supervisor-child.ts
+++ b/packages/stack/tests/helpers/supervisor-child.ts
@@ -156,9 +156,23 @@ const waitForAttachedBeforeReadyRelease = (): Effect.Effect => {
const resolveIfReleased = () => {
if (existsSync(releaseFile)) settle(Effect.void);
};
- try {
+ // Re-arm on ENOENT watcher errors: the runtime's directory watcher can
+ // report ENOENT when a watched entry vanishes mid-scan.
+ const arm = () => {
+ if (settled) return;
watcher = watch(dirname(releaseFile), () => resolveIfReleased());
- watcher.once("error", (cause) => settle(Effect.die(cause)));
+ watcher.once("error", (cause) => {
+ watcher?.close();
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") {
+ arm();
+ resolveIfReleased();
+ return;
+ }
+ settle(Effect.die(cause));
+ });
+ };
+ try {
+ arm();
writeFileSync(readyFile, "ready");
resolveIfReleased();
} catch (cause) {
@@ -205,7 +219,7 @@ const testPlatform = (): "node" | "bun" =>
process.env["SUPABASE_STACK_TEST_PLATFORM"] === "bun" ? "bun" : "node";
const managerLayer = (stateRoot: string, platform: "node" | "bun") =>
- managedStackManagerLayer({ stateRoot }).pipe(
+ managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe(
Layer.provide(
platform === "bun"
? Layer.mergeAll(BunFileSystem.layer, gitConfigStoreLayer, bunControlTransportLayer)
From bf20385e7ea6b8cf7e90d7cfa785f2d21c7d05ed Mon Sep 17 00:00:00 2001
From: Vaibhav <117663341+7ttp@users.noreply.github.com>
Date: Tue, 18 Aug 2026 13:32:22 +0000
Subject: [PATCH 02/63] test: deflake more flaky suites (#6248)
## TL;DR
Replaces every fixed attempt-count retry budget in tests with wall-clock
deadlines,
retries managed control acquisition with a fresh workspace when the
identity-derived port collides with a concurrent stack's live control
server and makes the attempt count as "rule" in AGENTS.md ....
## ref:
- fixes the supervisor fake-owner bind flake
https://github.com/supabase/cli/actions/runs/32117291754/job/95649578844
- & the managed manager-ports conflict flake
https://github.com/supabase/cli/actions/runs/32117383408/job/95656435059
---------
Co-authored-by: Andrew Valleteau
---
AGENTS.md | 4 +-
.../functions/serve/serve.integration.test.ts | 8 +-
.../dev/functions-dev-runtime.unit.test.ts | 33 +++++---
.../managed-manager-ports.integration.test.ts | 81 +++++++++----------
.../stack/src/supervisor.integration.test.ts | 75 ++++++++---------
.../stack/tests/helpers/managed-manager.ts | 28 +++++++
6 files changed, 125 insertions(+), 104 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 7d2eb20425..edfd1fc3be 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -167,9 +167,9 @@ See `apps/cli/src/commands/login/` as the canonical example.
### Flake-resistant tests
-Tests must remain correct under file-level parallelism and slow or loaded CI. Synchronize on observable conditions—never use `Effect.sleep`, `setTimeout`, or polling delays for propagation, startup, cancellation, cleanup, or port release. Subscribe before triggering the transition, then await a `Deferred`, stream, fiber, readiness result, file, or state change. Timeouts are guards, not sub-second correctness assertions; use TestClock or fake timers for timing semantics. Assume files run concurrently: use unique IDs, roots, process markers, and derived resources, while intentional collisions stay within one test. Never bind an ephemeral port, close it, and reuse it as a reservation; never use a released endpoint as a guaranteed dead backend—own a reset/refusal listener or inject the failure. Subprocesses need explicit readiness plus stderr/stdout diagnostics. Cleanup must target only exact owned PIDs, tokens, paths, names, and labels; never machine-wide prefix or command snapshots, and never globally disable parallelism. For flake fixes, reproduce/stress the red case and repeat the green case.
+Tests must remain correct under file-level parallelism and slow or loaded CI. Synchronize on observable conditions—never use `Effect.sleep`, `setTimeout`, or polling delays for propagation, startup, cancellation, cleanup, or port release. Subscribe before triggering the transition, then await a `Deferred`, stream, fiber, readiness result, file, or state change. Timeouts are guards, not sub-second correctness assertions; bound waits by wall-clock deadline, never attempt counts; use TestClock or fake timers for timing semantics. Assume files run concurrently: use unique IDs, roots, process markers, and derived resources, while intentional collisions stay within one test. Never bind an ephemeral port, close it, and reuse it as a reservation; never use a released endpoint as a guaranteed dead backend—own a reset/refusal listener or inject the failure. Subprocesses need explicit readiness plus stderr/stdout diagnostics. Cleanup must target only exact owned PIDs, tokens, paths, names, and labels; never machine-wide prefix or command snapshots, and never globally disable parallelism. For flake fixes, reproduce/stress the red case and repeat the green case.
-During review, arbitrary sleeps, wall-clock completion assertions, released-port reuse, static cross-file identities, and broad cleanup are blocking unless intrinsic to the behavior and documented.
+During review, arbitrary sleeps, wall-clock completion assertions, attempt-count retry budgets, released-port reuse, static cross-file identities, and broad cleanup are blocking unless intrinsic to the behavior and documented.
### Integration test pattern
diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts
index d0bcc47a72..ffc7631422 100644
--- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts
+++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts
@@ -229,13 +229,13 @@ async function extractDockerEnvEntries(call: { args: ReadonlyArray; opti
function waitFor(condition: () => boolean, message: string) {
return Effect.gen(function* () {
- for (let attempt = 0; attempt < 50; attempt += 1) {
- if (condition()) {
- return;
+ const deadline = Date.now() + 3_000;
+ while (!condition()) {
+ if (Date.now() >= deadline) {
+ return yield* Effect.fail(new Error(message));
}
yield* Effect.sleep(Duration.millis(20));
}
- return yield* Effect.fail(new Error(message));
});
}
diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts
index ae53560c6b..8628ac6b66 100644
--- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts
+++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
-import { Duration, Effect, Fiber, Layer, Queue, Stream } from "effect";
+import { Deferred, Duration, Effect, Fiber, Layer, Queue, Stream } from "effect";
import { join } from "node:path";
import {
FileWatcher,
@@ -9,6 +9,15 @@ import { watchPaths } from "./functions-dev-runtime.ts";
function makeFakeFileWatcher() {
const queues = new Map>>();
+ const registrations = new Map>();
+ const registrationFor = (path: string) => {
+ let latch = registrations.get(path);
+ if (latch === undefined) {
+ latch = Deferred.makeUnsafe();
+ registrations.set(path, latch);
+ }
+ return latch;
+ };
const layer = Layer.succeed(
FileWatcher,
@@ -17,20 +26,18 @@ function makeFakeFileWatcher() {
Stream.callback>((queue) =>
Effect.sync(() => {
queues.set(path, queue);
- }),
+ }).pipe(Effect.andThen(Deferred.succeed(registrationFor(path), undefined))),
),
}),
);
- const awaitWatch = Effect.fnUntraced(function* (expectedPath: string) {
- for (let attempt = 0; attempt < 50; attempt++) {
- if (queues.has(expectedPath)) {
- return;
- }
- yield* Effect.sleep("1 millis");
- }
- throw new Error(`No watcher registered for ${expectedPath}`);
- });
+ const awaitWatch = (expectedPath: string) =>
+ Deferred.await(registrationFor(expectedPath)).pipe(
+ Effect.timeoutOrElse({
+ duration: "2 seconds",
+ orElse: () => Effect.die(new Error(`No watcher registered for ${expectedPath}`)),
+ }),
+ );
const emit = (path: string, events: ReadonlyArray) =>
Effect.sync(() => {
@@ -59,7 +66,7 @@ describe("functions dev runtime", () => {
emitted = true;
}),
),
- Effect.timeout(Duration.seconds(1)),
+ Effect.timeout(Duration.seconds(5)),
Effect.provide(watcher.layer),
Effect.forkChild({ startImmediately: true }),
);
@@ -84,7 +91,7 @@ describe("functions dev runtime", () => {
]).pipe(
Stream.take(1),
Stream.runCollect,
- Effect.timeout(Duration.seconds(1)),
+ Effect.timeout(Duration.seconds(5)),
Effect.provide(watcher.layer),
Effect.forkChild({ startImmediately: true }),
);
diff --git a/packages/stack/src/managed-manager-ports.integration.test.ts b/packages/stack/src/managed-manager-ports.integration.test.ts
index cccc996003..a1676de2e3 100644
--- a/packages/stack/src/managed-manager-ports.integration.test.ts
+++ b/packages/stack/src/managed-manager-ports.integration.test.ts
@@ -1,7 +1,7 @@
import { it } from "@effect/vitest";
import { NodeFileSystem, NodePath } from "@effect/platform-node";
import { Cause, Effect, Exit } from "effect";
-import { mkdirSync, mkdtempSync } from "node:fs";
+import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect } from "vitest";
@@ -9,10 +9,10 @@ import { ManagedStackManager } from "./managed/manager.ts";
import { ManagedExactPortOccupiedError } from "./managed/model.ts";
import { gitConfigStoreLayer } from "./managed/git.ts";
import { acquireControl, CONTROL_PORT_RANGE, controlEndpoint } from "./managed/control.ts";
-import { deriveStackId, ensureEnvironment } from "./managed/environment.ts";
import { controlTransportLayer } from "./platform-node.ts";
import { reservePortSet } from "./PortAllocator.ts";
import {
+ acquireWorkspaceControl,
automaticDocument,
automaticRuntimeDocument,
cleanupRoots,
@@ -33,13 +33,11 @@ const setup = () => setupManagedManager(roots);
describe("managed stack ports journeys", () => {
it.live("retains the same automatic ports while stopped", () => {
- const { layer, workspace } = setup();
+ const { layer, workspace: base } = setup();
return Effect.scoped(
Effect.gen(function* () {
const manager = yield* ManagedStackManager;
- const environment = yield* ensureEnvironment(workspace);
- const stackId = deriveStackId(environment.identity, "default");
- const ownership = yield* acquireControl({ stackId });
+ const { workspace, ownership } = yield* acquireWorkspaceControl(base);
if (ownership._tag !== "Owned") throw new Error("expected stack control ownership");
const first = yield* manager.startStack({
workspacePath: workspace,
@@ -70,14 +68,12 @@ describe("managed stack ports journeys", () => {
});
it.live("reserves exact durable and automatic runtime ports through one lease", () => {
- const { layer, workspace } = setup();
+ const { layer, workspace: base } = setup();
return Effect.scoped(
Effect.gen(function* () {
const manager = yield* ManagedStackManager;
const apiPort = yield* freePort();
- const environment = yield* ensureEnvironment(workspace);
- const stackId = deriveStackId(environment.identity, "default");
- const ownership = yield* acquireControl({ stackId });
+ const { workspace, ownership } = yield* acquireWorkspaceControl(base);
if (ownership._tag !== "Owned") throw new Error("expected ownership");
const started = yield* manager.startStack({
workspacePath: workspace,
@@ -150,13 +146,11 @@ describe("managed stack ports journeys", () => {
});
it.live("allows an unused exact API port inside the control range", () => {
- const { layer, workspace } = setup();
+ const { layer, workspace: base } = setup();
return Effect.scoped(
Effect.gen(function* () {
const manager = yield* ManagedStackManager;
- const environment = yield* ensureEnvironment(workspace);
- const stackId = deriveStackId(environment.identity, "default");
- const ownership = yield* acquireControl({ stackId });
+ const { workspace, ownership } = yield* acquireWorkspaceControl(base);
if (ownership._tag !== "Owned") throw new Error("expected stack ownership");
const port = ownership.endpoint.port === 15_432 ? 15_433 : 15_432;
const started = yield* manager.startStack({
@@ -179,15 +173,15 @@ describe("managed stack ports journeys", () => {
});
it.live("rejects an exact API port matching a persisted stack control endpoint", () => {
- const { layer, workspace } = setup();
- const otherWorkspace = join(workspace, "other");
- mkdirSync(otherWorkspace);
+ const { layer, workspace: base } = setup();
return Effect.scoped(
Effect.gen(function* () {
const manager = yield* ManagedStackManager;
- const otherEnvironment = yield* ensureEnvironment(otherWorkspace);
- const otherStackId = deriveStackId(otherEnvironment.identity, "default");
- const otherOwnership = yield* acquireControl({ stackId: otherStackId });
+ const {
+ workspace: otherWorkspace,
+ stackId: otherStackId,
+ ownership: otherOwnership,
+ } = yield* acquireWorkspaceControl(base, "other");
if (otherOwnership._tag !== "Owned") throw new Error("expected other stack ownership");
const other = yield* manager.startStack({
workspacePath: otherWorkspace,
@@ -199,9 +193,7 @@ describe("managed stack ports journeys", () => {
yield* otherOwnership.close;
const otherEndpoint = yield* controlEndpoint(otherStackId);
- const environment = yield* ensureEnvironment(workspace);
- const stackId = deriveStackId(environment.identity, "default");
- const ownership = yield* acquireControl({ stackId });
+ const { workspace, ownership } = yield* acquireWorkspaceControl(base);
if (ownership._tag !== "Owned") throw new Error("expected stack ownership");
const rejected = yield* manager
.startStack({
@@ -230,7 +222,7 @@ describe("managed stack ports journeys", () => {
});
it.live("attributes an exact API port collision with an external listener", () => {
- const { layer, workspace } = setup();
+ const { layer, workspace: base } = setup();
return Effect.scoped(
Effect.gen(function* () {
const manager = yield* ManagedStackManager;
@@ -241,9 +233,7 @@ describe("managed stack ports journeys", () => {
);
expect(external.listening).toBe(true);
- const environment = yield* ensureEnvironment(workspace);
- const stackId = deriveStackId(environment.identity, "default");
- const ownership = yield* acquireControl({ stackId });
+ const { workspace, ownership } = yield* acquireWorkspaceControl(base);
if (ownership._tag !== "Owned") throw new Error("expected stack ownership");
const rejected = yield* manager
.startStack({
@@ -275,10 +265,6 @@ describe("managed stack ports journeys", () => {
const { layer } = setup();
const root = mkdtempSync(join(tmpdir(), "managed-auto-test-"));
roots.push(root);
- const firstWorkspace = join(root, "first");
- const secondWorkspace = join(root, "second");
- mkdirSync(firstWorkspace);
- mkdirSync(secondWorkspace);
return Effect.scoped(
Effect.gen(function* () {
const manager = yield* ManagedStackManager;
@@ -286,9 +272,11 @@ describe("managed stack ports journeys", () => {
if (apiPort === undefined || dbPort === undefined) {
throw new Error("expected isolated automatic ports");
}
- const environment = yield* ensureEnvironment(firstWorkspace);
- const stackId = deriveStackId(environment.identity, "default");
- const ownership = yield* acquireControl({ stackId });
+ const {
+ workspace: firstWorkspace,
+ stackId,
+ ownership,
+ } = yield* acquireWorkspaceControl(root, "first");
if (ownership._tag !== "Owned") throw new Error("expected ownership");
const exact = yield* manager.startStack({
workspacePath: firstWorkspace,
@@ -361,7 +349,14 @@ describe("managed stack ports journeys", () => {
});
}
yield* external.releaseAll;
- const second = yield* startWithOwner(manager, secondWorkspace, automaticRuntimeDocument());
+ const { workspace: secondWorkspace, ownership: secondOwnership } =
+ yield* acquireWorkspaceControl(root, "second");
+ if (secondOwnership._tag !== "Owned") throw new Error("expected second ownership");
+ const second = yield* manager.startStack({
+ workspacePath: secondWorkspace,
+ portDocument: automaticRuntimeDocument(),
+ ownership: secondOwnership,
+ });
expect(second.stack.ports[0]?.port).not.toBe(first.stack.ports[0]?.port);
yield* releaseLease(second);
}),
@@ -378,8 +373,6 @@ describe("managed stack ports journeys", () => {
const { layer } = setup();
const root = mkdtempSync(join(tmpdir(), "managed-drift-test-"));
roots.push(root);
- const workspace = join(root, "workspace");
- mkdirSync(workspace);
return Effect.scoped(
Effect.gen(function* () {
const manager = yield* ManagedStackManager;
@@ -387,9 +380,11 @@ describe("managed stack ports journeys", () => {
if (original === undefined || dbPort === undefined || changed === undefined) {
throw new Error("expected drift ports");
}
- const environment = yield* ensureEnvironment(workspace);
- const stackId = deriveStackId(environment.identity, "default");
- const initialOwnership = yield* acquireControl({ stackId });
+ const {
+ workspace,
+ stackId,
+ ownership: initialOwnership,
+ } = yield* acquireWorkspaceControl(root);
if (initialOwnership._tag !== "Owned") throw new Error("expected ownership");
const running = yield* manager.startStack({
workspacePath: workspace,
@@ -436,15 +431,11 @@ describe("managed stack ports journeys", () => {
const { layer } = setup();
const root = mkdtempSync(join(tmpdir(), "managed-retired-test-"));
roots.push(root);
- const workspace = join(root, "workspace");
- mkdirSync(workspace);
return Effect.scoped(
Effect.gen(function* () {
const manager = yield* ManagedStackManager;
const port = yield* freePort();
- const env = yield* ensureEnvironment(workspace);
- const stackId = deriveStackId(env.identity, "default");
- const ownership = yield* acquireControl({ stackId });
+ const { workspace, ownership } = yield* acquireWorkspaceControl(root);
if (ownership._tag !== "Owned") throw new Error("expected ownership");
const first = yield* manager.startStack({
workspacePath: workspace,
diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts
index ac8e6d0d9e..e8b0e28cd7 100644
--- a/packages/stack/src/supervisor.integration.test.ts
+++ b/packages/stack/src/supervisor.integration.test.ts
@@ -380,27 +380,13 @@ const canBind = (port: number): Promise =>
});
});
-const listenStartingOwner = async (
+const bindFakeOwner = async (
endpoint: ControlEndpoint,
- ownershipId: string,
+ makeServer: () => ReturnType,
): Promise> => {
- for (let attempt = 0; attempt < 100; attempt += 1) {
- const server = createHttpServer((request, response) => {
- if (request.method === "GET" && request.url === "/owner") {
- response.writeHead(200, { "content-type": "application/json" });
- response.end(
- JSON.stringify({
- protocolVersion: 1,
- ownershipId,
- state: "starting",
- ready: false,
- }),
- );
- return;
- }
- response.writeHead(404);
- response.end();
- });
+ const deadline = Date.now() + 10_000;
+ do {
+ const server = makeServer();
try {
await new Promise((resolve, reject) => {
server.once("error", reject);
@@ -414,18 +400,41 @@ const listenStartingOwner = async (
server.close();
await new Promise((resolve) => setTimeout(resolve, 10));
}
- }
+ } while (Date.now() < deadline);
throw new Error(`timed out binding fake owner at ${endpoint.url}`);
};
-const listenOwnerSequence = async (
+const listenStartingOwner = (
+ endpoint: ControlEndpoint,
+ ownershipId: string,
+): Promise> =>
+ bindFakeOwner(endpoint, () =>
+ createHttpServer((request, response) => {
+ if (request.method === "GET" && request.url === "/owner") {
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(
+ JSON.stringify({
+ protocolVersion: 1,
+ ownershipId,
+ state: "starting",
+ ready: false,
+ }),
+ );
+ return;
+ }
+ response.writeHead(404);
+ response.end();
+ }),
+ );
+
+const listenOwnerSequence = (
endpoint: ControlEndpoint,
ownershipId: string,
states: ReadonlyArray<"starting" | "stopping">,
onRead: (state: "starting" | "stopping") => void = () => undefined,
closeAfterSequence = true,
-): Promise> => {
- for (let attempt = 0; attempt < 100; attempt += 1) {
+): Promise> =>
+ bindFakeOwner(endpoint, () => {
let reads = 0;
const server = createHttpServer((request, response) => {
if (request.method !== "GET" || request.url !== "/owner") {
@@ -449,22 +458,8 @@ const listenOwnerSequence = async (
},
);
});
- try {
- await new Promise((resolve, reject) => {
- server.once("error", reject);
- server.listen(endpoint.port, endpoint.hostname, () => {
- server.off("error", reject);
- resolve();
- });
- });
- return server;
- } catch {
- server.close();
- await new Promise((resolve) => setTimeout(resolve, 10));
- }
- }
- throw new Error(`timed out binding fake owner at ${endpoint.url}`);
-};
+ return server;
+ });
const listenStoppingOwner = async (
endpoint: ControlEndpoint,
@@ -1073,7 +1068,7 @@ describe("detached supervisor child journeys", () => {
}
});
- test("bounds attached-owner recovery to one startup deadline", { timeout: 10_000 }, async () => {
+ test("bounds attached-owner recovery to one startup deadline", { timeout: 30_000 }, async () => {
const roots = await workspace();
const input = messageFor(roots);
const attachedReady = join(roots.root, "attached-before-ready-ready");
diff --git a/packages/stack/tests/helpers/managed-manager.ts b/packages/stack/tests/helpers/managed-manager.ts
index d5a1d9cc39..aa1b89b8c6 100644
--- a/packages/stack/tests/helpers/managed-manager.ts
+++ b/packages/stack/tests/helpers/managed-manager.ts
@@ -134,6 +134,34 @@ export const closeExternal = (server: Server): Promise =>
server.close((error) => (error === undefined ? resolve() : reject(error)));
});
+/**
+ * Control endpoints project two identity-hash bytes into `CONTROL_PORT_RANGE`,
+ * so parallel test files can land on a port already owned by another live
+ * stack's control server. Acquires control for a fresh directory under `base`,
+ * retrying with a new directory (a new path-seeded identity, so a new port) on
+ * a conflict until a wall-clock deadline, rethrowing the last conflict.
+ */
+export const acquireWorkspaceControl = (base: string, prefix = "workspace") =>
+ Effect.gen(function* () {
+ const deadline = Date.now() + 10_000;
+ for (;;) {
+ const workspace = mkdtempSync(join(base, `${prefix}-`));
+ const environment = yield* ensureEnvironment(workspace);
+ const stackId = deriveStackId(environment.identity, "default");
+ const acquired = yield* acquireControl({ stackId }).pipe(
+ Effect.map((ownership) => ({ ownership })),
+ Effect.catch((error) =>
+ error._tag === "ControlAddressConflictError" && Date.now() < deadline
+ ? Effect.succeed(undefined)
+ : Effect.fail(error),
+ ),
+ );
+ if (acquired !== undefined) {
+ return { workspace, environment, stackId, ownership: acquired.ownership };
+ }
+ }
+ });
+
export const startWithOwner = (
manager: ManagedStackManagerShape,
workspacePath: string,
From 0c721790ff19cdf020e3896e5351fbfb16d6c63d Mon Sep 17 00:00:00 2001
From: Vaibhav <117663341+7ttp@users.noreply.github.com>
Date: Tue, 18 Aug 2026 13:49:24 +0000
Subject: [PATCH 03/63] fix(cli): restore postgres after role reverts
(CLI-2205) (#6246)
## TL;DR
Passwordless `db push --linked` breaks on any migration containing
`reset role`:
the login role path relies on a session level `SET SESSION ROLE
postgres` that the migration itself undoes.
File runners now re-assert the step-down immediately after each
role-reverting statement, at the end of each file, and before every CLI
owned ledger write, so the whole file behaves the same on both auth
paths.
## whats biting?
The passwordless path connects as a temp `cli_login_*` role and steps
down to `postgres` once at connect. A migration's `reset role` reverts
the session to the login role, so:
- the appended history insert fails with `permission denied for schema
supabase_migrations (SQLSTATE 42501)` and rolls the migration back, even
though every user statement succeeded
- authored transaction and pg-delta no-transaction files commit their
statements but never record, so the next push re-applies them
- statements between the `reset role` and the end of the same file run
as the login role, so `granted by current_user` cleanup silently no-ops
while the push exits 0 (reproduced on staging: the stale
`pg_auth_members` grant survives)
- later files and the `seed_files` upsert run as the login role too
## fixed now by:
- `LegacyDbSession.restoreRoleSql` (set only when the step-down ran) is
injected by every file runner right after each top-level role revert
(`RESET ROLE`, `SET [SESSION] ROLE [TO|=] NONE|DEFAULT` including a
case-sensitively quoted `'none'`, `RESET SESSION AUTHORIZATION`, `SET
SESSION AUTHORIZATION DEFAULT`, `DISCARD ALL`), and again at end of file
and before the history insert and both `seed_files` upserts, so
`current_user` matches a password session for the whole file
- injected restores never shift `At statement: N` and are never recorded
in the history row; deliberate `set role ` choreography is untouched,
and password, local and plain `--db-url` sessions see a byte identical
statement stream
- the residual (dynamic SQL, `SET LOCAL ROLE NONE`, `session_user`
itself) is documented in `docs/go-cli-divergences.md` with the
end-of-file restore protecting every CLI owned write; both
`SIDE_EFFECTS.md` tables record the new statements
## ref:
- closes: https://github.com/supabase/cli/issues/6236
---
apps/cli/docs/go-cli-divergences.md | 25 ++
.../legacy/commands/db/push/SIDE_EFFECTS.md | 15 +-
.../legacy/commands/db/reset/SIDE_EFFECTS.md | 15 +-
.../shared/legacy-db-connection.service.ts | 10 +
.../legacy-db-connection.sql-pg.layer.ts | 1 +
.../legacy/shared/legacy-migration-apply.ts | 117 ++++-
.../legacy-migration-apply.unit.test.ts | 424 +++++++++++++++++-
apps/cli/src/legacy/shared/legacy-seed-ops.ts | 14 +-
.../shared/legacy-seed-ops.unit.test.ts | 44 +-
apps/cli/src/legacy/shared/legacy-seed.ts | 14 +-
.../legacy/shared/legacy-seed.unit.test.ts | 41 ++
11 files changed, 690 insertions(+), 30 deletions(-)
diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md
index ce946eb4d5..228fc2af93 100644
--- a/apps/cli/docs/go-cli-divergences.md
+++ b/apps/cli/docs/go-cli-divergences.md
@@ -118,6 +118,31 @@ These commands exist in the TS CLI today but have no direct top-level equivalent
empty tests directory, or a bind the daemon resolved against a different filesystem than the
CLI's (a sibling-container Docker socket) all reported a green build that ran zero tests. The
TAP stream on stdout is unchanged; the diagnostic goes to stderr like every other failure.
+- SQL file runners on a stepped-down session re-assert `SET SESSION ROLE postgres`
+ immediately after each top-level role revert (`RESET ROLE`, the generic-`SET` spellings
+ such as `SET ROLE [TO|=] NONE|DEFAULT` and a case-sensitively quoted `'none'`,
+ `RESET SESSION AUTHORIZATION`, `SET SESSION AUTHORIZATION DEFAULT`, `DISCARD ALL`),
+ at the end of each file, and before the migration history insert and the `seed_files`
+ upsert — so the whole file (including a post-reset `granted by current_user` cleanup),
+ every CLI-owned ledger write, and every subsequent file run as `postgres`, matching a
+ password session for `current_user` and privilege checks (CLI-2205, #6236).
+ `session_user` remains the login role and `current_setting('role')` reads `postgres`
+ rather than `none`, so a file keying on `session_user` still diverges. A stepped-down
+ session is any remote connection authenticating as `cli_login_*` (the passwordless
+ linked path) or `supabase_admin`, which steps down with a session-level
+ `SET SESSION ROLE postgres`; a file's own `RESET ROLE` reverted it to the login role,
+ so the appended history insert failed with SQLSTATE 42501 and any later file ran as
+ the login role. The old Go CLI had the same defect; on a `supabase_admin` `--db-url`,
+ `RESET ROLE` consequently no longer re-escalates to superuser mid-file.
+ Statement-level re-assertion is used because a connection-time `role=postgres` default
+ cannot be guaranteed through the pooler. Injected restores never shift
+ `At statement: N` and are never recorded in the history row. Residual: a role revert
+ issued through dynamic SQL, or a spelling outside the list above (for example
+ `SET LOCAL ROLE NONE` — deliberately unmatched, a session-scoped restore would
+ override its transaction scope — or the `session_authorization` GUC spellings), is
+ invisible to the lexical check, so statements after it run as the login role until
+ the next restore point; the end-of-file restore still protects every CLI-owned write.
+ (`RESET ALL` needs no entry — `role` carries `GUC_NO_RESET_ALL`.)
- `functions serve` per-function env discovery (CLI-2184, #6179): without `--env-file`, each
`supabase/functions//.env` overrides matching values from the shared
`supabase/functions/.env` for that Function only; an explicit `--env-file` remains the
diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md
index c063d2dcac..fdf1627eb5 100644
--- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md
+++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md
@@ -27,13 +27,14 @@ before migrations unless `--skip-vault` is set.
## Database Mutations
-| Statement | When |
-| ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `RESET ALL` + migration statements + `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` | per pending migration (after confirmation); compatible statements use an implicit extended-protocol batch with one final `Sync`, while pipeline-incompatible statements run standalone — see Notes |
-| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) |
-| `roles.sql` statements (no history row) | per `--include-roles` globals file (after confirmation); statements use an implicit extended-protocol batch with one final `Sync` |
-| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets, migrations are applied, and `--skip-vault` is not set |
-| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash |
+| Statement | When |
+| ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `RESET ALL` + migration statements + `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` | per pending migration (after confirmation); compatible statements use an implicit extended-protocol batch with one final `Sync`, while pipeline-incompatible statements run standalone — see Notes |
+| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) |
+| `roles.sql` statements (no history row) | per `--include-roles` globals file (after confirmation); statements use an implicit extended-protocol batch with one final `Sync` |
+| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets, migrations are applied, and `--skip-vault` is not set |
+| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash |
+| `SET SESSION ROLE postgres` | stepped-down sessions only (`cli_login_*`/`supabase_admin`): after each top-level role-reverting statement, at the end of each migration/globals/seed file, and before the history insert and the `seed_files` upsert (CLI-2205, #6236) |
## API Routes
diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md
index 5a3d086bac..3596528eeb 100644
--- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md
+++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md
@@ -78,13 +78,14 @@ child) is fully native as of CLI-1958.
### Remote path (native, in TS)
-| Statement | When |
-| ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
-| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first |
-| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets |
-| schema-file statements (no history bookkeeping, no `RESET ALL` between files) | `--experimental` + no resolved version + pg-delta not enabled (see Notes) |
-| migration statements + `schema_migrations` history insert (per file, transactional; pipeline-incompatible statements run standalone — see Notes) | otherwise, when `[db.migrations].enabled`, for migrations `≤ --version` |
-| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` (runs after either branch above) |
+| Statement | When |
+| ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
+| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first |
+| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets |
+| schema-file statements (no history bookkeeping, no `RESET ALL` between files) | `--experimental` + no resolved version + pg-delta not enabled (see Notes) |
+| migration statements + `schema_migrations` history insert (per file, transactional; pipeline-incompatible statements run standalone — see Notes) | otherwise, when `[db.migrations].enabled`, for migrations `≤ --version` |
+| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` (runs after either branch above) |
+| `SET SESSION ROLE postgres` | stepped-down sessions only: after each role-reverting statement, at end of each file, before ledger writes |
### Local path (native, in TS)
diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts
index 73d471b2a6..674876e74c 100644
--- a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts
+++ b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts
@@ -95,6 +95,16 @@ export interface LegacyDbBatchStatement {
* underlying connection when its `Scope` closes.
*/
export interface LegacyDbSession {
+ /**
+ * SQL that restores the role this session stepped down to after authenticating
+ * as a temp/privileged login role (`SET SESSION ROLE postgres`). Absent when no
+ * step-down ran. A migration's own `RESET ROLE` reverts the session to the
+ * login role — not `postgres` — so file runners re-assert this immediately after
+ * each top-level role-reverting statement, at the end of each file, and before
+ * CLI-owned ledger writes (supabase/cli#6236). Must stay a fixed, non-user-derived
+ * statement: consumers embed it verbatim in batches.
+ */
+ readonly restoreRoleSql?: string;
/** Run a single SQL statement, ignoring any returned rows. */
readonly exec: (sql: string) => Effect.Effect;
/**
diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts
index 8f56cb1166..d938298368 100644
--- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts
+++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts
@@ -1056,6 +1056,7 @@ const connect = (
};
const session: LegacyDbSession = {
+ ...(stepDownRequired ? { restoreRoleSql: SET_SESSION_ROLE } : {}),
exec: (sql) => client.unsafe(sql).pipe(Effect.asVoid, Effect.mapError(legacyToExecError)),
execBatch,
query: (sql, params) =>
diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts
index fbe54501df..f19e292a1c 100644
--- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts
+++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts
@@ -124,6 +124,34 @@ export const legacyHasTransactionControl = (sql: string): boolean => {
return TRANSACTION_CONTROL_PATTERN.test(upper);
};
+const ROLE_REVERT_PATTERN =
+ /^(?:RESET\s+ROLE|RESET\s+SESSION\s+AUTHORIZATION|SET\s+(?:SESSION\s+)?ROLE(?:\s+TO\s+|\s*=\s*|\s+)(?:NONE|DEFAULT)|SET\s+SESSION\s+AUTHORIZATION\s+DEFAULT|DISCARD\s+ALL)(?:\s|;|$)/u;
+
+// PostgreSQL's `check_role` compares the quoted value case-sensitively against
+// "none", so the quoted spellings are matched before the uppercase fold —
+// `SET ROLE "NONE"` selects a real role named `NONE`, never a reset.
+const QUOTED_ROLE_VALUE_PATTERN =
+ /^SET\s+(?:SESSION\s+)?ROLE(?:\s+TO\s+|\s*=\s*|\s+)(['"])(.*?)\1(?:\s|;|$)/iu;
+
+/**
+ * Whether a top-level statement reverts a stepped-down session to its login role
+ * (`RESET ROLE`, the generic-`SET` spellings of `role`'s reset, `RESET SESSION
+ * AUTHORIZATION` and friends, `DISCARD ALL`). File runners re-assert `postgres`
+ * right after each match, so the rest of the file keeps `current_user = postgres`
+ * as on a password session (supabase/cli#6236); reverts a lexical check cannot
+ * see (dynamic SQL, `SET LOCAL ROLE NONE` — deliberately unmatched, since a
+ * session-scoped restore would override its transaction scope) are backstopped
+ * by the trailing restore before any CLI-owned write. `RESET ALL` is
+ * deliberately absent — `role` carries `GUC_NO_RESET_ALL`.
+ */
+export const legacyRevertsToLoginRole = (sql: string): boolean => {
+ const trimmed = legacyTrimLeadingSqlComments(sql);
+ return (
+ ROLE_REVERT_PATTERN.test(trimmed.toUpperCase()) ||
+ QUOTED_ROLE_VALUE_PATTERN.exec(trimmed)?.[2] === "none"
+ );
+};
+
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length;
// `startBufSize` — the fixed initial scanner
@@ -506,14 +534,22 @@ const formattedExecBatchDbError = (error: unknown): LegacyDbExecError | undefine
* cannot run in a transaction block: the open batch is flushed (committed), the
* statement runs standalone, then batching resumes (supabase/cli#5156). The history
* insert goes in the final batch, so the migration is recorded only after every
- * statement succeeds. A file with no such statements uses one batch and one Sync.
+ * statement succeeds. On a stepped-down session ({@link LegacyDbSession.restoreRoleSql})
+ * the `postgres` role is re-asserted immediately after each top-level role-reverting
+ * statement ({@link legacyRevertsToLoginRole}) and again at the end of the file before
+ * the history insert (supabase/cli#6236), so the whole file behaves as on a password
+ * session and leaves the session role-clean for whatever runs next. Injected restores
+ * never shift `At statement: N` and are never recorded in the history row.
+ * A file with no such statements uses one batch and one Sync.
* Pg-delta files whose first line is `-- pg-delta: transaction=false` instead run
* every statement sequentially without a CLI-owned transaction. This keeps their
* session preamble, nontransactional action, and cleanup on the same connection.
*
* Does NOT create the history table and does not unconditionally `RESET ALL` —
* those are the migration-apply path's responsibility, so ordinary role/globals
- * files (`legacySeedGlobals`) stay reset-free. The one exception is best-effort
+ * files (`legacySeedGlobals`) stay reset-free. (The role re-assert above is not
+ * session hygiene but a connection-layer invariant, so it applies to every file
+ * runner, globals included.) The one exception is best-effort
* cleanup after a failed pg-delta no-transaction file. When `forceNoVersion` is set
* the history insert is skipped regardless of filename.
*
@@ -594,6 +630,8 @@ const execMigrationBatch = (
const version = forceNoVersion ? "" : (matches?.[1] ?? "");
const name = matches?.[2] ?? "";
+ const restoreRole = session.restoreRoleSql;
+
const executeSequentially = (cleanup: string) =>
Effect.gen(function* () {
for (const [index, statement] of statements.entries()) {
@@ -602,6 +640,25 @@ const execMigrationBatch = (
.pipe(
Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, statement)),
);
+ if (restoreRole !== undefined && legacyRevertsToLoginRole(statement)) {
+ yield* session
+ .exec(restoreRole)
+ .pipe(
+ Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, restoreRole)),
+ );
+ }
+ }
+ if (
+ restoreRole !== undefined &&
+ !(statements.length > 0 && legacyRevertsToLoginRole(statements[statements.length - 1]!))
+ ) {
+ yield* session
+ .exec(restoreRole)
+ .pipe(
+ Effect.mapError((cause) =>
+ legacyFormatExecBatchError(cause, statements.length, restoreRole),
+ ),
+ );
}
if (version.length > 0) {
yield* session
@@ -612,7 +669,18 @@ const execMigrationBatch = (
),
);
}
- }).pipe(Effect.tapError(() => session.exec(cleanup).pipe(Effect.ignore)));
+ }).pipe(
+ Effect.tapError(() =>
+ Effect.gen(function* () {
+ yield* session.exec(cleanup).pipe(Effect.ignore);
+ // Sequential statements ran outside a CLI transaction, so a failed
+ // file's `RESET ROLE` survives the cleanup; restore best-effort.
+ if (restoreRole !== undefined) {
+ yield* session.exec(restoreRole).pipe(Effect.ignore);
+ }
+ }),
+ ),
+ );
// The pg-delta directive is file-level execution metadata. Run the complete
// sequence on this session without adding transaction boundaries so session
@@ -636,16 +704,42 @@ const execMigrationBatch = (
let pending: Array = [];
let executed = 0;
- const flushBatch = (recordVersion: boolean) =>
+ const flushBatch = (final: boolean) =>
Effect.gen(function* () {
- if (pending.length === 0 && !recordVersion) return;
+ const recordVersion = final && version.length > 0;
+ const trailingRestore = final ? restoreRole : undefined;
+ if (pending.length === 0 && !recordVersion && trailingRestore === undefined) return;
const batchStatements = pending;
- const operations: Array = batchStatements.map((sql) => ({ sql }));
+ const operations: Array = [];
+ // Injected role restores don't count toward `At statement: N`; track how
+ // many precede each op so failures keep the file's own numbering (a
+ // mid-file restore inherits its host statement's index; the trailing
+ // restore and the history insert report the file's statement count).
+ const injectedBefore: Array = [];
+ let injected = 0;
+ let lastOpIsInjectedRestore = false;
+ for (const sql of batchStatements) {
+ operations.push({ sql });
+ injectedBefore.push(injected);
+ lastOpIsInjectedRestore = false;
+ if (restoreRole !== undefined && legacyRevertsToLoginRole(sql)) {
+ injected += 1;
+ operations.push({ sql: restoreRole });
+ injectedBefore.push(injected);
+ lastOpIsInjectedRestore = true;
+ }
+ }
+ if (trailingRestore !== undefined && !lastOpIsInjectedRestore) {
+ operations.push({ sql: trailingRestore });
+ injectedBefore.push(injected);
+ injected += 1;
+ }
if (recordVersion) {
operations.push({
sql: INSERT_MIGRATION_VERSION,
params: [version, name, statements],
});
+ injectedBefore.push(injected);
}
const base = executed;
yield* session.execBatch(operations).pipe(
@@ -656,11 +750,12 @@ const execMigrationBatch = (
if (cause instanceof LegacyDbConnectError) return cause;
// `statementIndex` is set by every batch failure the driver raises; a
// session that omits it can only have failed before the first statement.
- const globalIndex = base + (cause.statementIndex ?? 0);
+ const raw = cause.statementIndex ?? 0;
+ const globalIndex = base + raw - (injectedBefore[raw] ?? injected);
return legacyFormatExecBatchError(
cause,
globalIndex,
- statements[globalIndex] ?? INSERT_MIGRATION_VERSION,
+ operations[raw]?.sql ?? statements[globalIndex] ?? INSERT_MIGRATION_VERSION,
);
}),
);
@@ -682,7 +777,7 @@ const execMigrationBatch = (
pending.push(statement);
}
}
- yield* flushBatch(version.length > 0);
+ yield* flushBatch(true);
}).pipe(
Effect.mapError((error) =>
// A batch connection failure is not an execution failure: it keeps its own
@@ -834,7 +929,9 @@ export const legacyExecSqlFile = (
* relative, verbatim when absolute) via the shared glob
* ({@link legacySqlFilesGlob}), then runs each matched file's statements with
* {@link legacyExecSqlFile} in glob order — no history table, no history row, and no
- * `RESET ALL` between files: connection state is never reset here.
+ * `RESET ALL` between files: connection state is never reset here (a stepped-down
+ * session's role re-assert at each file's end is the one exception — see
+ * {@link LegacyDbSession.restoreRoleSql}).
*
* Callers gate the call on the three-conjunct condition (`--experimental` + no resolved
* version + pg-delta NOT enabled) themselves — this function only performs
diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts
index aceab90bed..9bd46cf31d 100644
--- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts
+++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts
@@ -19,6 +19,7 @@ import {
legacyHasTransactionControl,
legacyIsPipelineIncompatible,
legacyMarkError,
+ legacyRevertsToLoginRole,
legacySeedGlobals,
} from "./legacy-migration-apply.ts";
@@ -41,6 +42,7 @@ function fakeSession(
failOn?: string;
failAfterBatch?: boolean;
failWith?: { message: string; code?: string; detail?: string; position?: number };
+ restoreRoleSql?: string;
} = {},
) {
const calls: Array<{
@@ -50,6 +52,7 @@ function fakeSession(
params?: ReadonlyArray;
}> = [];
const session: LegacyDbSession = {
+ ...(opts.restoreRoleSql === undefined ? {} : { restoreRoleSql: opts.restoreRoleSql }),
exec: (sql) => {
calls.push({ kind: "exec", sql });
return opts.failOn !== undefined && sql.includes(opts.failOn)
@@ -78,7 +81,9 @@ function fakeSession(
},
query: (sql, params) => {
calls.push({ kind: "query", sql, params });
- return Effect.succeed([]);
+ return opts.failOn !== undefined && sql.includes(opts.failOn)
+ ? Effect.fail(new FakeExecError(opts.failWith ?? { message: "exec failed" }))
+ : Effect.succeed([]);
},
extensionExists: () => Effect.succeed(false),
copyToCsv: () => Effect.succeed(new Uint8Array()),
@@ -501,6 +506,367 @@ describe("legacyApplyMigrationFile", () => {
),
);
});
+
+ // Passwordless remote sessions step down from the temp login role via
+ // `SET SESSION ROLE postgres`; a migration's own `RESET ROLE` reverts to the
+ // login role, which used to fail the history insert with 42501 (supabase/cli#6236).
+ it.effect(
+ "re-asserts the stepped-down role between the statements and the history insert",
+ () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_reset_role.sql");
+ writeFileSync(file, "set role repro_writer;\ncreate table t (id int);\nreset role;");
+ const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" });
+ return run(session, file).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ // The restore is injected right after the trailing RESET ROLE (the
+ // end-of-file restore dedupes away), so the insert runs as postgres —
+ // and the committed session ends role-clean for the next file.
+ const batch = calls.find((call) => call.kind === "batch");
+ expect(batch?.statements?.map(({ sql }) => sql)).toEqual([
+ "set role repro_writer",
+ "create table t (id int)",
+ "reset role",
+ "SET SESSION ROLE postgres",
+ expect.stringContaining("supabase_migrations.schema_migrations"),
+ ]);
+ // The ledger row records only the file's own statements.
+ expect(batch?.statements?.at(-1)?.params?.[2]).toEqual([
+ "set role repro_writer",
+ "create table t (id int)",
+ "reset role",
+ ]);
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ },
+ );
+
+ it.effect("never re-asserts a role on sessions that did not step down", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_reset_role.sql");
+ writeFileSync(file, "reset role;");
+ const { session, calls } = fakeSession();
+ return run(session, file).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ expect(executedSql(calls).some((sql) => sql.includes("SET SESSION ROLE"))).toBe(false);
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("re-asserts the stepped-down role before recording an authored transaction", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_authored.sql");
+ writeFileSync(file, "BEGIN;\nreset role;\nCOMMIT;");
+ const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" });
+ return run(session, file).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ const lastRestore = calls.findLastIndex(
+ (call) => call.kind === "exec" && call.sql === "SET SESSION ROLE postgres",
+ );
+ const authoredCommit = calls.findLastIndex(
+ (call) => call.kind === "exec" && call.sql === "COMMIT",
+ );
+ const history = calls.findIndex((call) => call.kind === "query");
+ expect(lastRestore).toBeGreaterThan(authoredCommit);
+ expect(history).toBeGreaterThan(lastRestore);
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("keeps the history insert's statement index when the role restore precedes it", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_fail.sql");
+ writeFileSync(file, "SELECT 1;");
+ const { session } = fakeSession({
+ restoreRoleSql: "SET SESSION ROLE postgres",
+ failOn: "INSERT INTO supabase_migrations",
+ failWith: {
+ message: "ERROR: permission denied for schema supabase_migrations (SQLSTATE 42501)",
+ code: "42501",
+ },
+ });
+ return run(session, file).pipe(
+ Effect.flip,
+ Effect.tap((error) =>
+ Effect.sync(() => {
+ expect(error.message).toContain("permission denied for schema supabase_migrations");
+ // The CLI-internal restore op must not shift Go's `At statement: N`.
+ expect(error.message).toContain("At statement: 1");
+ expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("keeps a mid-batch failure's statement index when a restore op is appended", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_fail.sql");
+ writeFileSync(file, "SELECT 1;\nSELECT bad_col;\nSELECT 3;");
+ const { session } = fakeSession({
+ restoreRoleSql: "SET SESSION ROLE postgres",
+ failOn: "bad_col",
+ });
+ return run(session, file).pipe(
+ Effect.flip,
+ Effect.tap((error) =>
+ Effect.sync(() => {
+ expect(error.message).toContain("At statement: 1");
+ expect(error.message).toContain("SELECT bad_col");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("keeps the deferred-failure index when a restore op is appended", () => {
+ // Mirrors "defaults a deferred batch failure to the migration history
+ // statement": the restore op between the statements and the insert must not
+ // shift the deferred (post-Sync) index either.
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_deferred.sql");
+ writeFileSync(file, "SELECT 1;");
+ const { session } = fakeSession({
+ restoreRoleSql: "SET SESSION ROLE postgres",
+ failAfterBatch: true,
+ });
+ return run(session, file).pipe(
+ Effect.flip,
+ Effect.tap((error) =>
+ Effect.sync(() => {
+ expect(error.message).toContain("At statement: 2");
+ expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("reports the restore op's own failure with the history step's index", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_fail.sql");
+ writeFileSync(file, "SELECT 1;");
+ const { session } = fakeSession({
+ restoreRoleSql: "SET SESSION ROLE postgres",
+ failOn: "SET SESSION ROLE",
+ failWith: {
+ message: 'ERROR: permission denied to set role "postgres" (SQLSTATE 42501)',
+ code: "42501",
+ },
+ });
+ return run(session, file).pipe(
+ Effect.flip,
+ Effect.tap((error) =>
+ Effect.sync(() => {
+ expect(error.message).toContain("At statement: 1");
+ expect(error.message).toContain("SET SESSION ROLE postgres");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("keeps the insert index when the final batch holds only the trailing ops", () => {
+ // A trailing CONCURRENTLY statement empties `pending`, so the final batch is
+ // just [restore, insert] — the index math must still report the file's count.
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_fail.sql");
+ writeFileSync(file, "SELECT 1;\nCREATE INDEX CONCURRENTLY i ON a(id);");
+ const { session } = fakeSession({
+ restoreRoleSql: "SET SESSION ROLE postgres",
+ failOn: "INSERT INTO supabase_migrations",
+ });
+ return run(session, file).pipe(
+ Effect.flip,
+ Effect.tap((error) =>
+ Effect.sync(() => {
+ expect(error.message).toContain("At statement: 2");
+ expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("restores postgres immediately after a mid-file RESET ROLE, silently", () => {
+ // Statements after the reset now run as postgres again (avallete's #6246
+ // review), so the old drift WARN is gone — there is no drift left to surface.
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_reset_role.sql");
+ writeFileSync(file, "set role r;\nreset role;\nselect 1;");
+ const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" });
+ const out = mockOutput({ format: "text" });
+ return run(session, file).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ const batch = calls.find((call) => call.kind === "batch");
+ expect(batch?.statements?.map(({ sql }) => sql)).toEqual([
+ "set role r",
+ "reset role",
+ "SET SESSION ROLE postgres",
+ "select 1",
+ "SET SESSION ROLE postgres",
+ expect.stringContaining("supabase_migrations.schema_migrations"),
+ ]);
+ expect(batch?.statements?.at(-1)?.params?.[2]).toEqual([
+ "set role r",
+ "reset role",
+ "select 1",
+ ]);
+ expect(out.stderrText).not.toContain("WARN:");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ Effect.provide(out.layer),
+ );
+ });
+
+ it.effect("restores postgres after every static role-revert spelling", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_role_none.sql");
+ writeFileSync(
+ file,
+ "set role a;\nset role none;\nset role to none;\nreset session authorization;\nset role = default;",
+ );
+ const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" });
+ return run(session, file).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ const batch = calls.find((call) => call.kind === "batch");
+ expect(batch?.statements?.map(({ sql }) => sql)).toEqual([
+ "set role a",
+ "set role none",
+ "SET SESSION ROLE postgres",
+ "set role to none",
+ "SET SESSION ROLE postgres",
+ "reset session authorization",
+ "SET SESSION ROLE postgres",
+ "set role = default",
+ "SET SESSION ROLE postgres",
+ expect.stringContaining("supabase_migrations.schema_migrations"),
+ ]);
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("emits exactly one restore when a no-transaction file ends in a revert", () => {
+ // Sequential path: the injected restore after the trailing `reset role`
+ // makes the end-of-file restore redundant, so it must dedupe away.
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_seq_reset.sql");
+ writeFileSync(file, "-- pg-delta: transaction=false\nset role r;\nreset role;");
+ const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" });
+ return run(session, file).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql);
+ expect(execs.filter((sql) => sql === "SET SESSION ROLE postgres")).toHaveLength(1);
+ expect(execs[execs.indexOf("reset role") + 1]).toBe("SET SESSION ROLE postgres");
+ expect(calls.filter((call) => call.kind === "query")).toHaveLength(1);
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("keeps the deferred-failure index when mid-file restores were injected", () => {
+ // The `injectedBefore[raw] ?? injected` fallback only matters when the
+ // deferred (post-Sync) index lands past the ops array AND injections exist.
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_deferred.sql");
+ writeFileSync(file, "set role r;\nreset role;\nselect 1;");
+ const { session } = fakeSession({
+ restoreRoleSql: "SET SESSION ROLE postgres",
+ failAfterBatch: true,
+ });
+ return run(session, file).pipe(
+ Effect.flip,
+ Effect.tap((error) =>
+ Effect.sync(() => {
+ expect(error.message).toContain("At statement: 4");
+ expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("injects into intermediate flushes so standalone statements run as postgres", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_concurrent.sql");
+ writeFileSync(file, "reset role;\nCREATE INDEX CONCURRENTLY i ON a(id);\nselect 2;");
+ const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" });
+ return run(session, file).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ const batches = calls.filter((call) => call.kind === "batch");
+ expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual([
+ "reset role",
+ "SET SESSION ROLE postgres",
+ ]);
+ expect(batches[1]?.statements?.map(({ sql }) => sql)).toEqual([
+ "select 2",
+ "SET SESSION ROLE postgres",
+ expect.stringContaining("supabase_migrations.schema_migrations"),
+ ]);
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("reports a mid-file restore's own failure at its host statement", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_fail.sql");
+ writeFileSync(file, "set role r;\nreset role;\nselect 1;");
+ const { session } = fakeSession({
+ restoreRoleSql: "SET SESSION ROLE postgres",
+ failOn: "SET SESSION ROLE",
+ });
+ return run(session, file).pipe(
+ Effect.flip,
+ Effect.tap((error) =>
+ Effect.sync(() => {
+ // The injected op inherits `reset role`'s index and shows the SQL that
+ // actually failed, so debugging lands on the right line of the file.
+ expect(error.message).toContain("At statement: 1");
+ expect(error.message).toContain("SET SESSION ROLE postgres");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("keeps statement numbering across an injected mid-file restore", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-apply-"));
+ const file = join(dir, "20240101120000_fail.sql");
+ writeFileSync(file, "set role r;\nreset role;\nselect bad;");
+ const { session } = fakeSession({
+ restoreRoleSql: "SET SESSION ROLE postgres",
+ failOn: "select bad",
+ });
+ return run(session, file).pipe(
+ Effect.flip,
+ Effect.tap((error) =>
+ Effect.sync(() => {
+ expect(error.message).toContain("At statement: 2");
+ expect(error.message).toContain("select bad");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
});
describe("legacyHasTransactionControl", () => {
@@ -774,6 +1140,29 @@ describe("legacySeedGlobals", () => {
Effect.provide(BunServices.layer),
);
});
+
+ it.effect("leaves a stepped-down session role-clean after a globals file", () => {
+ // Globals run before the vault upsert and the history-table DDL on the same
+ // session, so a `reset role` here must not leak the login role into them.
+ const dir = mkdtempSync(join(tmpdir(), "legacy-globals-"));
+ const file = join(dir, "roles.sql");
+ writeFileSync(file, "CREATE ROLE my_role;\nset role my_role;\nreset role;");
+ const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" });
+ return Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ yield* legacySeedGlobals(session, fs, path, [file], (message) => new TestError({ message }));
+ const batch = calls.find((call) => call.kind === "batch");
+ expect(batch?.statements?.at(-1)?.sql).toBe("SET SESSION ROLE postgres");
+ expect(
+ executedSql(calls).some((sql) => sql.includes("supabase_migrations.schema_migrations")),
+ ).toBe(false);
+ rmSync(dir, { recursive: true, force: true });
+ }).pipe(
+ Effect.provide(mockOutput({ format: "text" }).layer),
+ Effect.provide(BunServices.layer),
+ );
+ });
});
describe("legacyApplySchemaFiles", () => {
@@ -1345,3 +1734,36 @@ describe("legacyApplySchemaFiles", () => {
},
);
});
+
+describe("legacyRevertsToLoginRole", () => {
+ const cases: ReadonlyArray = [
+ ["reset role", true],
+ ["RESET SESSION AUTHORIZATION", true],
+ ["set role none", true],
+ ["set role to none", true],
+ ["set role = default", true],
+ ["set session role none", true],
+ ["set session authorization default", true],
+ ["discard all", true],
+ // `check_role` compares quoted values case-sensitively against "none".
+ ["set role 'none'", true],
+ ['set role "none"', true],
+ ["-- c\nreset role", true],
+ ["set role 'NONE'", false],
+ ['set role "NONE"', false],
+ ["set role none_user", false],
+ ["set role nonesuch", false],
+ // Session-scoped restore would override the transaction scope.
+ ["set local role none", false],
+ // `role` carries GUC_NO_RESET_ALL.
+ ["reset all", false],
+ ["discard temp", false],
+ ["set session authorization 'bob'", false],
+ ["set roles none", false],
+ ["select 'reset role'", false],
+ ];
+
+ it.each(cases)("%s -> %s", (sql, want) => {
+ expect(legacyRevertsToLoginRole(sql)).toBe(want);
+ });
+});
diff --git a/apps/cli/src/legacy/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.ts
index 2690f5fcf9..e7f6706f5f 100644
--- a/apps/cli/src/legacy/shared/legacy-seed-ops.ts
+++ b/apps/cli/src/legacy/shared/legacy-seed-ops.ts
@@ -4,7 +4,7 @@ import { Effect, type FileSystem, type Path } from "effect";
import { Output } from "../../shared/output/output.service.ts";
import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts";
import type { LegacyDbSession } from "./legacy-db-connection.service.ts";
-import { checkScannerBufferSize } from "./legacy-migration-apply.ts";
+import { checkScannerBufferSize, legacyRevertsToLoginRole } from "./legacy-migration-apply.ts";
import { legacyCreateSeedTable } from "./legacy-migration-history.ts";
import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts";
import { legacySplitAndTrim } from "./legacy-sql-split.ts";
@@ -140,7 +140,17 @@ export const legacySeedData = (
const statements = seed.dirty ? [] : lines;
yield* session.exec("BEGIN");
const body = Effect.gen(function* () {
- for (const statement of statements) yield* session.exec(statement);
+ for (const statement of statements) {
+ yield* session.exec(statement);
+ // A top-level role revert drops a stepped-down session to the login
+ // role; restore `postgres` right away (supabase/cli#6236).
+ if (session.restoreRoleSql !== undefined && legacyRevertsToLoginRole(statement)) {
+ yield* session.exec(session.restoreRoleSql);
+ }
+ }
+ // Backstop for reverts the lexical check cannot see, so the
+ // CLI-owned upsert always runs as `postgres`.
+ if (session.restoreRoleSql !== undefined) yield* session.exec(session.restoreRoleSql);
yield* session.query(UPSERT_SEED_FILE, [seed.path, seed.hash]);
yield* session.exec("COMMIT");
});
diff --git a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts
index f5288ffebb..32f0f3aa0e 100644
--- a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts
+++ b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts
@@ -11,9 +11,10 @@ import { legacyGetPendingSeeds, legacySeedData } from "./legacy-seed-ops.ts";
class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {}
-function fakeSeedSession() {
+function fakeSeedSession(opts: { restoreRoleSql?: string } = {}) {
const calls: Array<{ kind: "exec" | "query"; sql: string }> = [];
const session: LegacyDbSession = {
+ ...(opts.restoreRoleSql === undefined ? {} : { restoreRoleSql: opts.restoreRoleSql }),
exec: (sql) => {
calls.push({ kind: "exec", sql });
return Effect.void;
@@ -173,4 +174,45 @@ describe("legacySeedData (dirty parse)", () => {
),
);
});
+
+ it.effect("re-asserts the stepped-down role before the seed_files upsert", () => {
+ // A seed's own `reset role` reverts a stepped-down session to the login role,
+ // which used to fail the CLI's hash upsert with 42501 (supabase/cli#6236).
+ const dir = mkdtempSync(join(tmpdir(), "legacy-seed-"));
+ writeFileSync(join(dir, "data.sql"), "set role r;\ninsert into t values (1);\nreset role;");
+ const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" });
+ return runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ const sqls = calls.map((c) => c.sql);
+ const restoreAt = sqls.indexOf("SET SESSION ROLE postgres");
+ const upsertAt = calls.findIndex(
+ (c) => c.kind === "query" && c.sql.includes("seed_files"),
+ );
+ expect(restoreAt).toBeGreaterThan(sqls.indexOf("reset role"));
+ expect(upsertAt).toBeGreaterThan(restoreAt);
+ expect(sqls.lastIndexOf("COMMIT")).toBeGreaterThan(upsertAt);
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+
+ it.effect("restores the role right after a mid-seed reset, before later statements", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-seed-"));
+ writeFileSync(join(dir, "data.sql"), "set role r;\nreset role;\ninsert into t values (1);");
+ const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" });
+ return runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ const sqls = calls.map((c) => c.sql);
+ const resetAt = sqls.indexOf("reset role");
+ // Injected immediately, so the following insert runs as postgres again.
+ expect(sqls[resetAt + 1]).toBe("SET SESSION ROLE postgres");
+ expect(sqls.indexOf("insert into t values (1)")).toBeGreaterThan(resetAt + 1);
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
});
diff --git a/apps/cli/src/legacy/shared/legacy-seed.ts b/apps/cli/src/legacy/shared/legacy-seed.ts
index c3ff063822..9fbc4d7cfb 100644
--- a/apps/cli/src/legacy/shared/legacy-seed.ts
+++ b/apps/cli/src/legacy/shared/legacy-seed.ts
@@ -9,7 +9,7 @@ import {
} from "../../shared/telemetry/error-actionability.ts";
import type { LegacyDbSession } from "./legacy-db-connection.service.ts";
import { legacyResolveUnderWorkdir } from "./legacy-glob.ts";
-import { checkScannerBufferSize } from "./legacy-migration-apply.ts";
+import { checkScannerBufferSize, legacyRevertsToLoginRole } from "./legacy-migration-apply.ts";
import {
legacyCreateSeedTable,
legacyReadSeedTable,
@@ -163,8 +163,18 @@ export const legacyApplySeedFiles = (
const txn = Effect.gen(function* () {
yield* session.exec("BEGIN");
if (!seed.dirty) {
- for (const statement of statements) yield* session.exec(statement);
+ for (const statement of statements) {
+ yield* session.exec(statement);
+ // A top-level role revert drops a stepped-down session to the login
+ // role; restore `postgres` right away (supabase/cli#6236).
+ if (session.restoreRoleSql !== undefined && legacyRevertsToLoginRole(statement)) {
+ yield* session.exec(session.restoreRoleSql);
+ }
+ }
}
+ // Backstop for reverts the lexical check cannot see, so the
+ // CLI-owned upsert always runs as `postgres`.
+ if (session.restoreRoleSql !== undefined) yield* session.exec(session.restoreRoleSql);
yield* session.query(UPSERT_SEED_FILE, [seed.path, seed.hash]);
yield* session.exec("COMMIT");
});
diff --git a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts
index eb04c5bc8e..303c714338 100644
--- a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts
+++ b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts
@@ -144,3 +144,44 @@ describe("legacyApplySeedFiles scanner buffer size", () => {
},
);
});
+
+describe("legacyApplySeedFiles stepped-down session", () => {
+ it.effect("restores the role right after a reset and before the seed_files upsert", () => {
+ const dir = mkdtempSync(join(tmpdir(), "legacy-seed-"));
+ writeFileSync(join(dir, "seed.sql"), "set role r;\nreset role;\ninsert into t values (1);");
+ const calls: Array = [];
+ const session: LegacyDbSession = {
+ restoreRoleSql: "SET SESSION ROLE postgres",
+ exec: (sql) =>
+ Effect.sync(() => {
+ calls.push(sql);
+ }),
+ execBatch: () => Effect.void,
+ query: (sql) =>
+ Effect.sync(() => {
+ calls.push(sql);
+ return [];
+ }),
+ extensionExists: () => Effect.succeed(false),
+ copyToCsv: () => Effect.succeed(new Uint8Array()),
+ queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }),
+ };
+ const out = mockOutput();
+ return run(session, dir, ["seed.sql"], out).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ const resetAt = calls.indexOf("reset role");
+ const upsertAt = calls.findIndex((sql) =>
+ sql.includes("INSERT INTO supabase_migrations.seed_files"),
+ );
+ // Injected immediately after the reset, so the following insert (and
+ // everything else in the file) runs as postgres again.
+ expect(calls[resetAt + 1]).toBe("SET SESSION ROLE postgres");
+ expect(upsertAt).toBeGreaterThan(resetAt);
+ expect(out.stderrText).not.toContain("WARN:");
+ rmSync(dir, { recursive: true, force: true });
+ }),
+ ),
+ );
+ });
+});
From 23e2817e8e06ad294362cbc8812e9eb92008c048 Mon Sep 17 00:00:00 2001
From: "supabase-cli-releaser[bot]"
<246109035+supabase-cli-releaser[bot]@users.noreply.github.com>
Date: Tue, 18 Aug 2026 17:28:43 +0000
Subject: [PATCH 04/63] chore: sync API types from infrastructure (#6251)
This PR was automatically created to sync API types from the
infrastructure repository.
Changes were detected in the generated API code after syncing with the
latest spec from infrastructure.
Co-authored-by: supabase-cli-releaser[bot] <246109035+supabase-cli-releaser[bot]@users.noreply.github.com>
---
apps/cli-go/pkg/api/types.gen.go | 3 +++
1 file changed, 3 insertions(+)
diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go
index 9bdcb1f334..2a4c3b7b1f 100644
--- a/apps/cli-go/pkg/api/types.gen.go
+++ b/apps/cli-go/pkg/api/types.gen.go
@@ -4263,6 +4263,7 @@ const (
V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesOrioledb V1ListEntitlementsResponseEntitlementsFeatureKey = "instances.orioledb"
V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesReadReplicas V1ListEntitlementsResponseEntitlementsFeatureKey = "instances.read_replicas"
V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubConnections V1ListEntitlementsResponseEntitlementsFeatureKey = "integrations.github_connections"
+ V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubPushWebhooksLimit V1ListEntitlementsResponseEntitlementsFeatureKey = "integrations.github_push_webhooks_limit"
V1ListEntitlementsResponseEntitlementsFeatureKeyIpv4 V1ListEntitlementsResponseEntitlementsFeatureKey = "ipv4"
V1ListEntitlementsResponseEntitlementsFeatureKeyLogDrains V1ListEntitlementsResponseEntitlementsFeatureKey = "log_drains"
V1ListEntitlementsResponseEntitlementsFeatureKeyLogRetentionDays V1ListEntitlementsResponseEntitlementsFeatureKey = "log.retention_days"
@@ -4363,6 +4364,8 @@ func (e V1ListEntitlementsResponseEntitlementsFeatureKey) Valid() bool {
return true
case V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubConnections:
return true
+ case V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubPushWebhooksLimit:
+ return true
case V1ListEntitlementsResponseEntitlementsFeatureKeyIpv4:
return true
case V1ListEntitlementsResponseEntitlementsFeatureKeyLogDrains:
From 8e85e79117960070ba35f62fe8bbfd668af53a8b Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:11:42 +0000
Subject: [PATCH 05/63] fix(deps): bump the go-minor group across 1 directory
with 2 updates (#6256)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the go-minor group with 2 updates in the /apps/cli-go/pkg
directory:
[github.com/stretchr/testify](https://github.com/stretchr/testify) and
[golang.org/x/mod](https://github.com/golang/mod).
Updates `github.com/stretchr/testify` from 1.11.1 to 1.12.0
Release notes
Sourced from github.com/stretchr/testify's
releases.
v1.12.0
What's Changed
Functional Changes
Fixes
Documentation, Build & CI
New Contributors
... (truncated)
Commits
001eb79
Merge pull request #1905
from Kentzo/patch-1
ad40f38
Merge pull request #1906
from stretchr/dependabot/github_actions/actions/chec...
3bae017
build(deps): bump actions/checkout from 6.0.2 to 6.0.3
f8c01f3
mock: Mock.Return does not exist anymore
12f8b56
Merge pull request #1563
from stretchr/make-AssertionFunc-types-aliases
a11649e
assert: make *AssertionFunc type just aliases
dc20f41
Merge pull request #1890
from stretchr/dolmen/codegen-modernize
098f8d7
_codegen: use strings.Builder
d2699be
_codegen: modernize
a463c8c
Merge pull request #1885
from stretchr/dolmen/ci-check-ghactions-hashes
- Additional commits viewable in compare
view
Updates `golang.org/x/mod` from 0.38.0 to 0.39.0
Commits
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
apps/cli-go/pkg/go.mod | 8 +++-----
apps/cli-go/pkg/go.sum | 16 ++++++----------
2 files changed, 9 insertions(+), 15 deletions(-)
diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod
index 9db1b48e3c..3cf35f5bc0 100644
--- a/apps/cli-go/pkg/go.mod
+++ b/apps/cli-go/pkg/go.mod
@@ -23,15 +23,14 @@ require (
github.com/oapi-codegen/runtime v1.6.0
github.com/spf13/afero v1.15.0
github.com/spf13/viper v1.21.0
- github.com/stretchr/testify v1.11.1
+ github.com/stretchr/testify v1.12.0
github.com/tidwall/jsonc v0.3.3
- golang.org/x/mod v0.38.0
+ golang.org/x/mod v0.39.0
google.golang.org/grpc v1.83.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
- github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/ethereum/go-ethereum v1.17.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
@@ -43,12 +42,11 @@ require (
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
- github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
+ github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum
index 8caf9facb1..21c9a48b9c 100644
--- a/apps/cli-go/pkg/go.sum
+++ b/apps/cli-go/pkg/go.sum
@@ -17,8 +17,6 @@ github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -140,8 +138,6 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
@@ -172,8 +168,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -182,8 +178,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
+github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/jsonc v0.3.3 h1:RVQqL3xFfDkKKXIDsrBiVQiEpBtxoKbmMXONb2H/y2w=
@@ -224,8 +220,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
-golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
+golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
From 31d01099d7c02bea3d5f9949e82931b515dd4b4a Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 08:09:19 +0000
Subject: [PATCH 06/63] fix(deps): bump the npm-major group with 5 updates
(#6259)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the npm-major group with 5 updates:
| Package | From | To |
| --- | --- | --- |
| [smol-toml](https://github.com/squirrelchat/smol-toml) | `1.7.1` |
`1.8.0` |
|
[@supabase/supabase-js](https://github.com/supabase/supabase-js/tree/HEAD/packages/core/supabase-js)
| `2.112.2` | `2.112.3` |
|
[@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript)
| `0.3.227` | `0.3.228` |
|
[@vercel/detect-agent](https://github.com/vercel/vercel/tree/HEAD/packages/detect-agent)
| `1.2.4` | `1.2.5` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) |
`6.32.1` | `6.32.2` |
Updates `smol-toml` from 1.7.1 to 1.8.0
Release notes
Sourced from smol-toml's
releases.
v1.8.0
What's Changed
Full Changelog: https://github.com/squirrelchat/smol-toml/compare/v1.7.2...v1.8.0
v1.7.2
What's Changed
Full Changelog: https://github.com/squirrelchat/smol-toml/compare/v1.7.1...v1.7.2
Commits
6d0f477
chore: bump version
97e9713
docs: mention temporal api
7a3068d
perf: ubench opt for instanceof Date
7e8c09a
feat: stringify temporal
19239d0
chore: bump version
5f55c3a
chore: revert sourcemap publishing
5c26f1b
chore: update benchmarks
92832d2
refactor: better logic orchestration in structs
2fb9ab8
refactor: ctx object instead of pointer arg + tuple returns
e6017c4
ci: use staged publish
- See full diff in compare
view
Updates `@supabase/supabase-js` from 2.112.2 to 2.112.3
Release notes
Sourced from @supabase/supabase-js's
releases.
v2.112.3
2.112.3 (2026-08-11)
🩹 Fixes
- supabase: add trace context headers to canonical
CORS allow-list (#2603)
- supabase: improve trace propagation sampling and
diagnostics (#2604)
❤️ Thank You
v2.112.3-canary.0
2.112.3-canary.0 (2026-08-11)
🩹 Fixes
- supabase: add trace context headers to canonical
CORS allow-list (#2603)
- supabase: improve trace propagation sampling and
diagnostics (#2604)
❤️ Thank You
Changelog
Sourced from @supabase/supabase-js's
changelog.
2.112.3 (2026-08-11)
🩹 Fixes
- supabase: improve trace propagation sampling and
diagnostics (#2604)
- supabase: add trace context headers to canonical
CORS allow-list (#2603)
❤️ Thank You
Commits
e44447c
fix(supabase): improve trace propagation sampling and diagnostics (#2604)
9f0358c
fix(supabase): add trace context headers to canonical CORS allow-list
(#2603)
84beab1
chore(release): version 2.112.2 changelogs (#2599)
- See full diff in compare
view
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.227 to 0.3.228
Release notes
Sourced from @anthropic-ai/claude-agent-sdk's
releases.
v0.3.228
What's changed
- Agent tool results (
AgentOutput):
usage.output_tokens_details is now carried through
Update
npm install @anthropic-ai/claude-agent-sdk@0.3.228
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.228
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.228
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.228
Changelog
Sourced from @anthropic-ai/claude-agent-sdk's
changelog.
0.3.228
- Agent tool results (
AgentOutput):
usage.output_tokens_details is now carried through
Commits
Updates `@vercel/detect-agent` from 1.2.4 to 1.2.5
Release notes
Sourced from @vercel/detect-agent's
releases.
@vercel/h3@0.1.116
Patch Changes
@vercel/h3@0.1.115
Patch Changes
@vercel/h3@0.1.114
Patch Changes
@vercel/h3@0.1.113
Patch Changes
- Updated dependencies [4502520]
@vercel/h3@0.1.112
Patch Changes
Commits
Updates `knip` from 6.32.1 to 6.32.2
Release notes
Sourced from knip's
releases.
Release 6.32.2
- Support
oxfmt.config.mts (#1933)
(795900191dc75eec8d1e717b866bf57e1e2912cc) - thanks @joealden!
- Support
oxlint.config.mts (#1934)
(531e2dc7c1d8bf31babea0068c34391182ec2d50) - thanks @joealden!
- Fix Supported
lint-staged Configs (#1935)
(f9c755e414ed10baa4d01af8ddac6d04cb8d5617) - thanks @joealden!
- Update dependencies (95f7c529f918dd9e1a84f92c68d064738977b825)
- Update sentry snapshot
(ea7929fcbd6b323c8bdd9252ac57017feeb29ecf)
Commits
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
apps/cli/package.json | 6 +-
packages/config/package.json | 2 +-
packages/stack/package.json | 2 +-
pnpm-lock.yaml | 363 +++++++++++++++++------------------
pnpm-workspace.yaml | 2 +-
5 files changed, 181 insertions(+), 194 deletions(-)
diff --git a/apps/cli/package.json b/apps/cli/package.json
index af7a2f96f6..d5cc742b7e 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -43,7 +43,7 @@
"jose": "^6.2.8"
},
"devDependencies": {
- "@anthropic-ai/claude-agent-sdk": "^0.3.227",
+ "@anthropic-ai/claude-agent-sdk": "^0.3.228",
"@anthropic-ai/sdk": "^0.116.0",
"@clack/prompts": "^1.7.0",
"@effect/atom-react": "catalog:",
@@ -65,7 +65,7 @@
"@types/pg-copy-streams": "^1.2.5",
"@types/react": "^19.2.18",
"@typescript/native-preview": "catalog:",
- "@vercel/detect-agent": "^1.2.4",
+ "@vercel/detect-agent": "^1.2.5",
"@vitest/coverage-istanbul": "catalog:",
"dotenv": "^17.4.2",
"effect": "catalog:",
@@ -82,7 +82,7 @@
"react": "^19.2.8",
"react-devtools-core": "^7.0.1",
"semantic-release": "^25.0.9",
- "smol-toml": "^1.7.1",
+ "smol-toml": "^1.8.0",
"tldts": "catalog:",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vitest": "catalog:",
diff --git a/packages/config/package.json b/packages/config/package.json
index 99be23bdbc..20142f7605 100644
--- a/packages/config/package.json
+++ b/packages/config/package.json
@@ -21,7 +21,7 @@
"@effect/platform-node": "catalog:",
"dedent": "^1.7.2",
"effect": "catalog:",
- "smol-toml": "^1.7.1"
+ "smol-toml": "^1.8.0"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
diff --git a/packages/stack/package.json b/packages/stack/package.json
index cb9f313ff5..cf347b8d27 100644
--- a/packages/stack/package.json
+++ b/packages/stack/package.json
@@ -36,7 +36,7 @@
},
"devDependencies": {
"@effect/vitest": "catalog:",
- "@supabase/supabase-js": "^2.112.2",
+ "@supabase/supabase-js": "^2.112.3",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0e5aeee6c2..d2627512d5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -46,8 +46,8 @@ catalogs:
specifier: 4.0.0-beta.107
version: 4.0.0-beta.107
knip:
- specifier: ^6.32.1
- version: 6.32.1
+ specifier: ^6.32.2
+ version: 6.32.2
nx:
specifier: ^23.1.1
version: 23.1.1
@@ -119,8 +119,8 @@ importers:
version: 6.2.8
devDependencies:
'@anthropic-ai/claude-agent-sdk':
- specifier: ^0.3.227
- version: 0.3.227(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)
+ specifier: ^0.3.228
+ version: 0.3.228(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)
'@anthropic-ai/sdk':
specifier: ^0.116.0
version: 0.116.0(zod@4.4.3)
@@ -185,8 +185,8 @@ importers:
specifier: 'catalog:'
version: 7.0.0-dev.20260707.2
'@vercel/detect-agent':
- specifier: ^1.2.4
- version: 1.2.4
+ specifier: ^1.2.5
+ version: 1.2.5
'@vitest/coverage-istanbul':
specifier: 'catalog:'
version: 4.1.10(vitest@4.1.10)
@@ -207,7 +207,7 @@ importers:
version: 5.0.0(ink@7.1.1(@types/react@19.2.18)(react-devtools-core@7.0.1)(react@19.2.8))(react@19.2.8)
knip:
specifier: 'catalog:'
- version: 6.32.1
+ version: 6.32.2
oxfmt:
specifier: 'catalog:'
version: 0.63.0
@@ -236,8 +236,8 @@ importers:
specifier: ^25.0.9
version: 25.0.9(@typescript/typescript6@6.0.2)
smol-toml:
- specifier: ^1.7.1
- version: 1.7.1
+ specifier: ^1.8.0
+ version: 1.8.0
tldts:
specifier: 'catalog:'
version: 7.4.10
@@ -296,7 +296,7 @@ importers:
version: 4.1.10(vitest@4.1.10)
knip:
specifier: 'catalog:'
- version: 6.32.1
+ version: 6.32.2
oxfmt:
specifier: 'catalog:'
version: 0.63.0
@@ -376,7 +376,7 @@ importers:
version: 4.1.10(vitest@4.1.10)
knip:
specifier: 'catalog:'
- version: 6.32.1
+ version: 6.32.2
oxfmt:
specifier: 'catalog:'
version: 0.63.0
@@ -418,7 +418,7 @@ importers:
version: 4.1.10(vitest@4.1.10)
knip:
specifier: 'catalog:'
- version: 6.32.1
+ version: 6.32.2
oxfmt:
specifier: 'catalog:'
version: 0.63.0
@@ -451,8 +451,8 @@ importers:
specifier: 'catalog:'
version: 4.0.0-beta.107
smol-toml:
- specifier: ^1.7.1
- version: 1.7.1
+ specifier: ^1.8.0
+ version: 1.8.0
devDependencies:
'@tsconfig/bun':
specifier: 'catalog:'
@@ -468,7 +468,7 @@ importers:
version: 4.1.10(vitest@4.1.10)
knip:
specifier: 'catalog:'
- version: 6.32.1
+ version: 6.32.2
oxfmt:
specifier: 'catalog:'
version: 0.63.0
@@ -508,7 +508,7 @@ importers:
version: 4.1.10(vitest@4.1.10)
knip:
specifier: 'catalog:'
- version: 6.32.1
+ version: 6.32.2
oxfmt:
specifier: 'catalog:'
version: 0.63.0
@@ -541,8 +541,8 @@ importers:
specifier: 'catalog:'
version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10)
'@supabase/supabase-js':
- specifier: ^2.112.2
- version: 2.112.2
+ specifier: ^2.112.3
+ version: 2.112.3
'@tsconfig/bun':
specifier: 'catalog:'
version: 1.0.10
@@ -557,7 +557,7 @@ importers:
version: 4.1.10(vitest@4.1.10)
knip:
specifier: 'catalog:'
- version: 6.32.1
+ version: 6.32.2
oxfmt:
specifier: 'catalog:'
version: 0.63.0
@@ -601,52 +601,52 @@ packages:
resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==}
engines: {node: '>=18'}
- '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.227':
- resolution: {integrity: sha512-9iL2Q5QSLAVRgAZnXeSag6g2k9e7ZOHktYxL5LzTg05lbcYCeop4eVs7R8+qsJRIHtbGthA919hrnzkq1Ij9GQ==}
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228':
+ resolution: {integrity: sha512-HuCsV3/5XuYYaWuCbksX+e0JkDDUG/AlFJ8wKhDL3PBW/3hHNd6xBYx88kEWk1Z6B1GLxwHht9624lcmscpsyw==}
cpu: [arm64]
os: [darwin]
- '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.227':
- resolution: {integrity: sha512-GDdYKtv3wC0kLGS5wrxUf5Oq2hkKW0Nw/CyqcxHEnmeuc8istbQM6dveTK66ufPyI5Q7FiHxmo/kL7YLlFdvtw==}
+ '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228':
+ resolution: {integrity: sha512-jSUYY5Nd3efvbLZPU+i0tRBaFXskHu8M+4LMGBEw6A0PaklZ3YfGvKlTOWtJGRw6vMc6LzfOFts024xPNm6OrQ==}
cpu: [x64]
os: [darwin]
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.227':
- resolution: {integrity: sha512-21cruWs2wTVUYqpDn2z3SgG3fnDmf3tpH7iSw/V1NIrvaiiN+tFfeliIEvZPzLzT26048GEvK6GZcFG5k5mshQ==}
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228':
+ resolution: {integrity: sha512-4PgfisC3kHKlzJvy3rrm4Oh26g+D78h4ahHjni9fvSKHuJgrvHu9Qgo6aaYmzWdc7v9drL+pgiCk5Ge4Y2ANPA==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.227':
- resolution: {integrity: sha512-/Ve9ZVcULeYP6kxaEBFEwCDPAJRl/9O38PgXcOSCYIott6H3IA+4nb51Ee0ljITeA52IPyEo7stbduIjiPsp+g==}
+ '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228':
+ resolution: {integrity: sha512-0Wjv6TiWwGlBZINAmNJX07jN359jKwB/4Sr/uWgQkdjuVIOhe/M8ydk7JL2EPqCsbiW1lc15NjE5MWpZiYqooA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.227':
- resolution: {integrity: sha512-YfpJj8YVkzG2MhPOnc24NtErwWRpWYDNxc2Ig359kR4ZbfGKlmonKvkXceUQaeFXSh1LNUZAjsm2Y04XdBaUbQ==}
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228':
+ resolution: {integrity: sha512-dnXxyiwGCZj27HVk6clYRqGMgrs3KVLVp0vvWYLjkPGBiKbI83qJiDpOfaekEXG2I4elX0M4XikggV1LGWjimg==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.227':
- resolution: {integrity: sha512-eBSNIOauM5+crbIH/f8G1vgGMIMHVwh+NDH6esCRKdbRVvcnE4+urdVFSaKz0G7Ji8c6WGKLQYqrdrAVdPyODw==}
+ '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228':
+ resolution: {integrity: sha512-LmGplObceqMOu5mlrlhTZL/VSrEWdZagF0Bl8awglMu6WeQcNe7StORYkCznZ0BuzV4CwuC3ipV4q8Jrs66wSg==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.227':
- resolution: {integrity: sha512-4vkR0Hn1TI6ypomZcpMTk7h8Wtk1iY59XDtpyk5bk7eMNxuNtlm8a4DX7OS00sn4/I3cUoDOhRpTVA4p7j5oxA==}
+ '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228':
+ resolution: {integrity: sha512-mNS5yIMz/OXSQiDErb84jA8AKBFSlS9RSZ0qn2qyGkxplUx7kVmIDg/KnwOwHmygpzmH4UmR6OCaLXGohupqNA==}
cpu: [arm64]
os: [win32]
- '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.227':
- resolution: {integrity: sha512-iCEk00U8o8Y+0fsSCFbFGWGRokrfxo9kCwzBuKPjzvQEux06g7ps/O+Y0q9Nw/4iPCTXgUbekiSNcqg9CWaN9A==}
+ '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228':
+ resolution: {integrity: sha512-DYT3HvdS64Pq0IRvgW3RDO31yjYp5yiUKoKaZolTpLKfALpG5LI/osfnKlya68PZ/bSST1FNAfW9I0EtCnaQ4w==}
cpu: [x64]
os: [win32]
- '@anthropic-ai/claude-agent-sdk@0.3.227':
- resolution: {integrity: sha512-jNw9vBjaqHwuLy93d02PSBmounWjzEyO2RKA7/PR+5ooewIH7ceYo40yxaW2rJnGvXvjpLiuk7ZXM6/TFHQ+qQ==}
+ '@anthropic-ai/claude-agent-sdk@0.3.228':
+ resolution: {integrity: sha512-OOaME54VCoBLjKMqWqFmHkZGyL/x/FHUA0snhyolmyEhVoeBM0Ub5mrnV2Gx3d5/RcVlk2BnEVvPqu0SpZ9VFw==}
engines: {node: '>=18.0.0'}
peerDependencies:
'@anthropic-ai/sdk': '>=0.93.0'
@@ -1646,129 +1646,124 @@ packages:
'@octokit/types@17.0.0':
resolution: {integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==}
- '@oxc-parser/binding-android-arm-eabi@0.142.0':
- resolution: {integrity: sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==}
+ '@oxc-parser/binding-android-arm-eabi@0.143.0':
+ resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
- '@oxc-parser/binding-android-arm64@0.142.0':
- resolution: {integrity: sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==}
+ '@oxc-parser/binding-android-arm64@0.143.0':
+ resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
- '@oxc-parser/binding-darwin-arm64@0.142.0':
- resolution: {integrity: sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==}
+ '@oxc-parser/binding-darwin-arm64@0.143.0':
+ resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
- '@oxc-parser/binding-darwin-x64@0.142.0':
- resolution: {integrity: sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==}
+ '@oxc-parser/binding-darwin-x64@0.143.0':
+ resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
- '@oxc-parser/binding-freebsd-x64@0.142.0':
- resolution: {integrity: sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==}
+ '@oxc-parser/binding-freebsd-x64@0.143.0':
+ resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
- '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0':
- resolution: {integrity: sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==}
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0':
+ resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxc-parser/binding-linux-arm-musleabihf@0.142.0':
- resolution: {integrity: sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==}
+ '@oxc-parser/binding-linux-arm-musleabihf@0.143.0':
+ resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
- '@oxc-parser/binding-linux-arm64-gnu@0.142.0':
- resolution: {integrity: sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==}
+ '@oxc-parser/binding-linux-arm64-gnu@0.143.0':
+ resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-arm64-musl@0.142.0':
- resolution: {integrity: sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==}
+ '@oxc-parser/binding-linux-arm64-musl@0.143.0':
+ resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@oxc-parser/binding-linux-ppc64-gnu@0.142.0':
- resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==}
+ '@oxc-parser/binding-linux-ppc64-gnu@0.143.0':
+ resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-riscv64-gnu@0.142.0':
- resolution: {integrity: sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==}
+ '@oxc-parser/binding-linux-riscv64-gnu@0.143.0':
+ resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-riscv64-musl@0.142.0':
- resolution: {integrity: sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==}
+ '@oxc-parser/binding-linux-riscv64-musl@0.143.0':
+ resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
- '@oxc-parser/binding-linux-s390x-gnu@0.142.0':
- resolution: {integrity: sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==}
+ '@oxc-parser/binding-linux-s390x-gnu@0.143.0':
+ resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-x64-gnu@0.142.0':
- resolution: {integrity: sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==}
+ '@oxc-parser/binding-linux-x64-gnu@0.143.0':
+ resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@oxc-parser/binding-linux-x64-musl@0.142.0':
- resolution: {integrity: sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==}
+ '@oxc-parser/binding-linux-x64-musl@0.143.0':
+ resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
- '@oxc-parser/binding-openharmony-arm64@0.142.0':
- resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==}
+ '@oxc-parser/binding-openharmony-arm64@0.143.0':
+ resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
- '@oxc-parser/binding-wasm32-wasi@0.142.0':
- resolution: {integrity: sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [wasm32]
-
- '@oxc-parser/binding-win32-arm64-msvc@0.142.0':
- resolution: {integrity: sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==}
+ '@oxc-parser/binding-win32-arm64-msvc@0.143.0':
+ resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
- '@oxc-parser/binding-win32-ia32-msvc@0.142.0':
- resolution: {integrity: sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==}
+ '@oxc-parser/binding-win32-ia32-msvc@0.143.0':
+ resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
- '@oxc-parser/binding-win32-x64-msvc@0.142.0':
- resolution: {integrity: sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==}
+ '@oxc-parser/binding-win32-x64-msvc@0.143.0':
+ resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -1776,8 +1771,8 @@ packages:
'@oxc-project/types@0.139.0':
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
- '@oxc-project/types@0.142.0':
- resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==}
+ '@oxc-project/types@0.143.0':
+ resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==}
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==}
@@ -2836,12 +2831,12 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
- '@supabase/auth-js@2.112.2':
- resolution: {integrity: sha512-l1InCp4j98d09LZ6+RgubgF4eVPGBGXcLEhFusLg1qUCHJ2IEkYu5FohKK+eaFmIOwEk0kqG/j/lycw5e15mcQ==}
+ '@supabase/auth-js@2.112.3':
+ resolution: {integrity: sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==}
engines: {node: '>=22.0.0'}
- '@supabase/functions-js@2.112.2':
- resolution: {integrity: sha512-oMuSWN0ERmrG9S6kOM0bwhHmESGVl3kMtkZl2dNCU/r89hMiziX4GfD1omNo9QcBDele4N0GwSZ7hdbpuiA35A==}
+ '@supabase/functions-js@2.112.3':
+ resolution: {integrity: sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==}
engines: {node: '>=22.0.0'}
'@supabase/pg-delta@1.0.0-alpha.42':
@@ -2860,20 +2855,20 @@ packages:
'@supabase/phoenix@0.4.5':
resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==}
- '@supabase/postgrest-js@2.112.2':
- resolution: {integrity: sha512-ewhhtRny/HFRGhUTTg/PsqIatsl8OhW8Eha/Tz4S+SRAXBnuhKei9ZpsQTgL/3XcH9UEwuPQyQgQ9itq7nRQeg==}
+ '@supabase/postgrest-js@2.112.3':
+ resolution: {integrity: sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==}
engines: {node: '>=22.0.0'}
- '@supabase/realtime-js@2.112.2':
- resolution: {integrity: sha512-cd9/CEUJ6Go13FxtfiuC5rYELJtuQzVzTXlGG+XjSppjDS+anq+xo++WQe7ZRUNTuHOCeyKRwmx9Hw/OQJ04ig==}
+ '@supabase/realtime-js@2.112.3':
+ resolution: {integrity: sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==}
engines: {node: '>=22.0.0'}
- '@supabase/storage-js@2.112.2':
- resolution: {integrity: sha512-6jyBq/J1iXOHNpbjCZS7gFcDk49iM1MCJUVkDl71gLd/+XnLDzpUBs8icGebtwiHpl4kVszxIRDYAosbF4Rsig==}
+ '@supabase/storage-js@2.112.3':
+ resolution: {integrity: sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==}
engines: {node: '>=22.0.0'}
- '@supabase/supabase-js@2.112.2':
- resolution: {integrity: sha512-UyI1epU9B4X51HvNpkmlwTdF20fEcz2vyvrcDKVzFN4jZN41f5iQRqsiIQjAY5OVJD6ljqA/1g9JQeOTvFHpkA==}
+ '@supabase/supabase-js@2.112.3':
+ resolution: {integrity: sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==}
engines: {node: '>=22.0.0'}
peerDependencies:
'@opentelemetry/api': '>=1.0.0'
@@ -3244,8 +3239,8 @@ packages:
'@ungap/structured-clone@1.3.3':
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
- '@vercel/detect-agent@1.2.4':
- resolution: {integrity: sha512-euARTdvCVoi2k7/mqRUGyareO98jiVjwUyfJ8VFpN53aUv3R6ZgwSpU3ErOwFQKT6Fq8dOplEsy+DSmEoJPQdQ==}
+ '@vercel/detect-agent@1.2.5':
+ resolution: {integrity: sha512-0krENrjuitlW8s6TJu0MlqCevyCU7K7JK63jZAf7xZ6n17tx+vUEwzHT3sTxawtwZxaW21hu+oFUpoOrm49FsQ==}
engines: {node: '>=14'}
'@verdaccio/auth@8.1.1':
@@ -5070,8 +5065,8 @@ packages:
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
- knip@6.32.1:
- resolution: {integrity: sha512-mIiIHMTJVUgSlz0mxEgPt7wg8DmfbCp1Txqab3WpbMCJF7YHvHtC9jeAHHXfISMl72N8WzhyG71SQlaqCOGZtg==}
+ knip@6.32.2:
+ resolution: {integrity: sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -5812,8 +5807,8 @@ packages:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
- oxc-parser@0.142.0:
- resolution: {integrity: sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==}
+ oxc-parser@0.143.0:
+ resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==}
engines: {node: ^20.19.0 || >=22.12.0}
oxc-resolver@11.24.2:
@@ -6541,8 +6536,8 @@ packages:
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
- smol-toml@1.7.1:
- resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==}
+ smol-toml@1.8.0:
+ resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==}
engines: {node: '>= 18'}
sonic-boom@3.8.1:
@@ -6899,8 +6894,8 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
- unbash@4.0.4:
- resolution: {integrity: sha512-60m9IVGbavD6jholbxt0jVBXZkEB/HsMZq7Tyaghseve2/Sf0zQRAIfWsD34sde+DKP2tBxJS2wP88ZM0D1FhA==}
+ unbash@4.0.10:
+ resolution: {integrity: sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==}
engines: {node: '>=14'}
undici-types@7.16.0:
@@ -7315,44 +7310,44 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
- '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.227':
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228':
optional: true
- '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.227':
+ '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.227':
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.227':
+ '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.227':
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.227':
+ '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228':
optional: true
- '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.227':
+ '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228':
optional: true
- '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.227':
+ '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228':
optional: true
- '@anthropic-ai/claude-agent-sdk@0.3.227(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)':
+ '@anthropic-ai/claude-agent-sdk@0.3.228(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.116.0(zod@4.4.3)
'@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3)
zod: 4.4.3
optionalDependencies:
- '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.227
- '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.227
- '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.227
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.227
- '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.227
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.227
- '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.227
- '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.227
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.228
+ '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.228
+ '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.228
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.228
+ '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.228
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.228
+ '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.228
+ '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.228
'@anthropic-ai/sdk@0.116.0(zod@4.4.3)':
dependencies:
@@ -8248,73 +8243,66 @@ snapshots:
dependencies:
'@octokit/openapi-types': 28.0.0
- '@oxc-parser/binding-android-arm-eabi@0.142.0':
+ '@oxc-parser/binding-android-arm-eabi@0.143.0':
optional: true
- '@oxc-parser/binding-android-arm64@0.142.0':
+ '@oxc-parser/binding-android-arm64@0.143.0':
optional: true
- '@oxc-parser/binding-darwin-arm64@0.142.0':
+ '@oxc-parser/binding-darwin-arm64@0.143.0':
optional: true
- '@oxc-parser/binding-darwin-x64@0.142.0':
+ '@oxc-parser/binding-darwin-x64@0.143.0':
optional: true
- '@oxc-parser/binding-freebsd-x64@0.142.0':
+ '@oxc-parser/binding-freebsd-x64@0.143.0':
optional: true
- '@oxc-parser/binding-linux-arm-gnueabihf@0.142.0':
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0':
optional: true
- '@oxc-parser/binding-linux-arm-musleabihf@0.142.0':
+ '@oxc-parser/binding-linux-arm-musleabihf@0.143.0':
optional: true
- '@oxc-parser/binding-linux-arm64-gnu@0.142.0':
+ '@oxc-parser/binding-linux-arm64-gnu@0.143.0':
optional: true
- '@oxc-parser/binding-linux-arm64-musl@0.142.0':
+ '@oxc-parser/binding-linux-arm64-musl@0.143.0':
optional: true
- '@oxc-parser/binding-linux-ppc64-gnu@0.142.0':
+ '@oxc-parser/binding-linux-ppc64-gnu@0.143.0':
optional: true
- '@oxc-parser/binding-linux-riscv64-gnu@0.142.0':
+ '@oxc-parser/binding-linux-riscv64-gnu@0.143.0':
optional: true
- '@oxc-parser/binding-linux-riscv64-musl@0.142.0':
+ '@oxc-parser/binding-linux-riscv64-musl@0.143.0':
optional: true
- '@oxc-parser/binding-linux-s390x-gnu@0.142.0':
+ '@oxc-parser/binding-linux-s390x-gnu@0.143.0':
optional: true
- '@oxc-parser/binding-linux-x64-gnu@0.142.0':
+ '@oxc-parser/binding-linux-x64-gnu@0.143.0':
optional: true
- '@oxc-parser/binding-linux-x64-musl@0.142.0':
+ '@oxc-parser/binding-linux-x64-musl@0.143.0':
optional: true
- '@oxc-parser/binding-openharmony-arm64@0.142.0':
- optional: true
-
- '@oxc-parser/binding-wasm32-wasi@0.142.0':
- dependencies:
- '@emnapi/core': 1.11.2
- '@emnapi/runtime': 1.11.2
- '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
+ '@oxc-parser/binding-openharmony-arm64@0.143.0':
optional: true
- '@oxc-parser/binding-win32-arm64-msvc@0.142.0':
+ '@oxc-parser/binding-win32-arm64-msvc@0.143.0':
optional: true
- '@oxc-parser/binding-win32-ia32-msvc@0.142.0':
+ '@oxc-parser/binding-win32-ia32-msvc@0.143.0':
optional: true
- '@oxc-parser/binding-win32-x64-msvc@0.142.0':
+ '@oxc-parser/binding-win32-x64-msvc@0.143.0':
optional: true
'@oxc-project/types@0.139.0': {}
- '@oxc-project/types@0.142.0': {}
+ '@oxc-project/types@0.143.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.24.2':
optional: true
@@ -9147,11 +9135,11 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
- '@supabase/auth-js@2.112.2':
+ '@supabase/auth-js@2.112.3':
dependencies:
tslib: 2.8.1
- '@supabase/functions-js@2.112.2':
+ '@supabase/functions-js@2.112.3':
dependencies:
tslib: 2.8.1
@@ -9175,27 +9163,27 @@ snapshots:
'@supabase/phoenix@0.4.5': {}
- '@supabase/postgrest-js@2.112.2':
+ '@supabase/postgrest-js@2.112.3':
dependencies:
tslib: 2.8.1
- '@supabase/realtime-js@2.112.2':
+ '@supabase/realtime-js@2.112.3':
dependencies:
'@supabase/phoenix': 0.4.5
tslib: 2.8.1
- '@supabase/storage-js@2.112.2':
+ '@supabase/storage-js@2.112.3':
dependencies:
iceberg-js: 0.8.1
tslib: 2.8.1
- '@supabase/supabase-js@2.112.2':
+ '@supabase/supabase-js@2.112.3':
dependencies:
- '@supabase/auth-js': 2.112.2
- '@supabase/functions-js': 2.112.2
- '@supabase/postgrest-js': 2.112.2
- '@supabase/realtime-js': 2.112.2
- '@supabase/storage-js': 2.112.2
+ '@supabase/auth-js': 2.112.3
+ '@supabase/functions-js': 2.112.3
+ '@supabase/postgrest-js': 2.112.3
+ '@supabase/realtime-js': 2.112.3
+ '@supabase/storage-js': 2.112.3
'@swc-node/core@1.15.0(@swc/core@1.15.47)(@swc/types@0.1.27)':
dependencies:
@@ -9472,7 +9460,7 @@ snapshots:
'@ungap/structured-clone@1.3.3': {}
- '@vercel/detect-agent@1.2.4': {}
+ '@vercel/detect-agent@1.2.5': {}
'@verdaccio/auth@8.1.1':
dependencies:
@@ -11473,19 +11461,19 @@ snapshots:
dependencies:
'@keyv/serialize': 1.1.1
- knip@6.32.1:
+ knip@6.32.2:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
formatly: 0.3.0
get-tsconfig: 4.14.1
jiti: 2.7.0
- oxc-parser: 0.142.0
+ oxc-parser: 0.143.0
oxc-resolver: 11.24.2
picomatch: 4.0.5
- smol-toml: 1.7.1
+ smol-toml: 1.8.0
strip-json-comments: 5.0.3
tinyglobby: 0.2.17
- unbash: 4.0.4
+ unbash: 4.0.10
yaml: 2.9.0
zod: 4.4.3
@@ -12474,30 +12462,29 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
- oxc-parser@0.142.0:
+ oxc-parser@0.143.0:
dependencies:
- '@oxc-project/types': 0.142.0
+ '@oxc-project/types': 0.143.0
optionalDependencies:
- '@oxc-parser/binding-android-arm-eabi': 0.142.0
- '@oxc-parser/binding-android-arm64': 0.142.0
- '@oxc-parser/binding-darwin-arm64': 0.142.0
- '@oxc-parser/binding-darwin-x64': 0.142.0
- '@oxc-parser/binding-freebsd-x64': 0.142.0
- '@oxc-parser/binding-linux-arm-gnueabihf': 0.142.0
- '@oxc-parser/binding-linux-arm-musleabihf': 0.142.0
- '@oxc-parser/binding-linux-arm64-gnu': 0.142.0
- '@oxc-parser/binding-linux-arm64-musl': 0.142.0
- '@oxc-parser/binding-linux-ppc64-gnu': 0.142.0
- '@oxc-parser/binding-linux-riscv64-gnu': 0.142.0
- '@oxc-parser/binding-linux-riscv64-musl': 0.142.0
- '@oxc-parser/binding-linux-s390x-gnu': 0.142.0
- '@oxc-parser/binding-linux-x64-gnu': 0.142.0
- '@oxc-parser/binding-linux-x64-musl': 0.142.0
- '@oxc-parser/binding-openharmony-arm64': 0.142.0
- '@oxc-parser/binding-wasm32-wasi': 0.142.0
- '@oxc-parser/binding-win32-arm64-msvc': 0.142.0
- '@oxc-parser/binding-win32-ia32-msvc': 0.142.0
- '@oxc-parser/binding-win32-x64-msvc': 0.142.0
+ '@oxc-parser/binding-android-arm-eabi': 0.143.0
+ '@oxc-parser/binding-android-arm64': 0.143.0
+ '@oxc-parser/binding-darwin-arm64': 0.143.0
+ '@oxc-parser/binding-darwin-x64': 0.143.0
+ '@oxc-parser/binding-freebsd-x64': 0.143.0
+ '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0
+ '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0
+ '@oxc-parser/binding-linux-arm64-gnu': 0.143.0
+ '@oxc-parser/binding-linux-arm64-musl': 0.143.0
+ '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0
+ '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0
+ '@oxc-parser/binding-linux-riscv64-musl': 0.143.0
+ '@oxc-parser/binding-linux-s390x-gnu': 0.143.0
+ '@oxc-parser/binding-linux-x64-gnu': 0.143.0
+ '@oxc-parser/binding-linux-x64-musl': 0.143.0
+ '@oxc-parser/binding-openharmony-arm64': 0.143.0
+ '@oxc-parser/binding-win32-arm64-msvc': 0.143.0
+ '@oxc-parser/binding-win32-ia32-msvc': 0.143.0
+ '@oxc-parser/binding-win32-x64-msvc': 0.143.0
oxc-resolver@11.24.2:
optionalDependencies:
@@ -13426,7 +13413,7 @@ snapshots:
smol-toml@1.6.1: {}
- smol-toml@1.7.1: {}
+ smol-toml@1.8.0: {}
sonic-boom@3.8.1:
dependencies:
@@ -13794,7 +13781,7 @@ snapshots:
uint8array-extras@1.5.0: {}
- unbash@4.0.4: {}
+ unbash@4.0.10: {}
undici-types@7.16.0: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 3f9cd00427..ba2a607316 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -26,7 +26,7 @@ catalog:
"@typescript/native-preview": "7.0.0-dev.20260707.2"
"@vitest/coverage-istanbul": "^4.1.10"
"effect": "4.0.0-beta.107"
- "knip": "^6.32.1"
+ "knip": "^6.32.2"
"nx": "^23.1.1"
"oxfmt": "^0.63.0"
"oxlint": "^1.78.0"
From de06605e0cf3ba57156d296c0414dc87bb76d121 Mon Sep 17 00:00:00 2001
From: Vaibhav <117663341+7ttp@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:05:30 +0000
Subject: [PATCH 07/63] fix(cli): stream edge runtime bootstrap (CLI-2216)
(#6273)
## TL;DR
fixes `supabase start` and `functions serve` dying with edge-runtime's
"failed to determine entrypoint" on Docker daemons that cannot see the
client's filesystem (remote tcp `DOCKER_HOST` contexts, podman machines)
- which was broken by #5712 moving the bootstrap `index.ts` out of the
`sh -c` argv onto a single file host bind mount to fix the `Windows
ENAMETOOLONG` crash
- and #5847 making native `start` reuse that serve core which left the
bind sourcing a host path such daemons silently materialize as an empty
directory at `/root/index.ts`
so now the bundled template is streamed into the created container with
`docker cp` before `docker start`,
the same delivery Kong, Postgres, and Supavisor secret files already use
since:
- #6022
which fixes bring-up on non local daemons while the earlier
`ENAMETOOLONG`
fix stays intact since nothing returns to the `spawn argv` and the
template no longer stages on host disk at all....
## ref:
- closes: https://github.com/supabase/cli/issues/6254
---
.../commands/functions/serve/SIDE_EFFECTS.md | 42 ++--
.../functions/serve/serve.integration.test.ts | 218 ++++++++++--------
.../src/legacy/commands/start/SIDE_EFFECTS.md | 40 ++--
.../edge-runtime.service.integration.test.ts | 143 ++++++++++--
.../start/services/edge-runtime.service.ts | 37 +--
.../legacy/commands/start/start.handler.ts | 4 +-
.../commands/start/start.integration.test.ts | 71 +++---
.../src/legacy/commands/stop/SIDE_EFFECTS.md | 6 +-
.../db-bootstrap/container-lifecycle.ts | 22 +-
.../shared/legacy-start-secrets-cleanup.ts | 8 +-
.../src/shared/functions/functions-docker.ts | 24 +-
.../functions/functions-docker.unit.test.ts | 37 +++
apps/cli/src/shared/functions/serve.ts | 77 ++++---
.../src/shared/functions/serve.unit.test.ts | 4 +-
14 files changed, 494 insertions(+), 239 deletions(-)
diff --git a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md
index 82f8be8ae9..71d0d9a481 100644
--- a/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md
+++ b/apps/cli/src/legacy/commands/functions/serve/SIDE_EFFECTS.md
@@ -18,16 +18,18 @@
## Files Written
-| Path | Format | When |
-| ----------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
-| `~/.supabase/telemetry.json` | JSON | always, at command exit via `Effect.ensuring` |
-| `/supabase-functions-serve-env-*/docker.env` | dotenv | per start, when single-line container env exists; passed via `--env-file`; mode `0600`; removed after the run |
-| `/supabase-functions-serve-multiline-env-*/…` | shell + raw | per start, only when an env value contains a newline; bind-mounted read-only into the container; mode `0600`; removed after the run |
+| Path | Format | When |
+| ---------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| `~/.supabase/telemetry.json` | JSON | always, at command exit via `Effect.ensuring` |
+| `/supabase/.temp/start-secrets/supabase_edge_runtime_/env/docker.env` | dotenv | per start, when single-line container env exists; passed via `--env-file`; mode `0600`; removed after the run |
+| `/supabase/.temp/start-secrets/supabase_edge_runtime_/multiline-env/…` | shell + raw | per start, only when an env value contains a newline; bind-mounted read-only into the container; mode `0600`; removed after the run |
The env files hold secrets (JWT secret, anon/service-role keys, JWKS), so they are
-written owner-only (`0600`) and cleaned up after the container exits. On `SIGKILL`
-(which bypasses cleanup) a temp directory under `` may be orphaned; the OS
-temp directory is the only place affected — the project directory is never modified.
+written owner-only (`0600`, in `0700` directories) under the project's own
+`supabase/.temp/` (gitignored) — a deterministic, persistent path rather than
+`os.tmpdir()`, so `supabase stop` and a failed-start rollback can reclaim it via
+`legacyCleanupStartSecrets` even when this command's own cleanup was bypassed
+(e.g. `SIGKILL`).
## API Routes
@@ -44,17 +46,17 @@ back to local keys. No scheme/host validation is performed on the discovered URL
## Environment Variables
-| Variable | Purpose | Required? |
-| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
-| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) |
-| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) |
-| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no |
-| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) |
-| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no |
-| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) |
-| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no |
-| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no |
-| `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the edge-runtime `docker run` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading | no |
+| Variable | Purpose | Required? |
+| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
+| `SUPABASE_PROFILE` | resolves the legacy profile / API base URL | no (defaults to `supabase`) |
+| `SUPABASE_WORKDIR` | overrides the project workdir | no (falls back to CLI cwd discovery) |
+| `SUPABASE_PROJECT_ID` | legacy config-service override for project identity | no |
+| `SUPABASE_ENV` | selects environment-specific dotenv files (`.env..local`, `.env.`) | no (defaults to `development`) |
+| env vars referenced by `supabase/config.toml` | config interpolation; the full ambient `process.env` is layered under the project `.env*` files and passed to config loading | no |
+| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the edge-runtime Docker registry mirror; read from the ambient shell **or** project dotenv; unset resolves ECR->GHCR->Docker-Hub candidates in order instead of a single URL | no (defaults to `public.ecr.aws`) |
+| `SUPABASE_NETWORK_ID` | overrides the generated `supabase_network_` Docker network name when `--network-id` isn't passed; read from the ambient shell or project dotenv | no |
+| `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which image tag to pull) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no |
+| `BITBUCKET_CLONE_DIR` | when set, skips creating the named Deno-cache volume and omits its bind mount from the edge-runtime `docker create` (Bitbucket's restricted Docker environment rejects both); a project-dotenv-only value is installed into `process.env` by config loading | no |
## Exit Codes
@@ -111,5 +113,5 @@ Long-running raw log / error events only; there is no terminal `result` event on
- Before each container (re)start, resolves the edge-runtime image through the same registry-candidate pull-with-retry every native `functions` Docker path uses: `docker image inspect ` (ECR, then GHCR, then Docker Hub) to check the local cache, then `docker pull ` with 2 retries (4s/8s backoff) on a miss, after `assertLocalDbRunning` — resolving it earlier would hijack the down-daemon error message that DB-inspect step is responsible for producing.
- Runs the full `Config.Validate` pipeline (`legacyResolveLocalConfigValues`, same one `start`/`stop`/`status` use) on every startup/restart, before `assertLocalDbRunning` — an invalid config now fails `serve` up front even for fields this command never otherwise reads (e.g. a bad `db.major_version` or malformed auth hook).
- A container crash terminates the command with a non-zero exit; only a watched-file change restarts the container — a crashed container is never auto-restarted.
-- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source.
+- The worker bootstrap template (`serve.main.ts`) is bundled into a single self-contained module with `jose` and the local path/status helpers inlined, so the edge-runtime worker boots without any network access (supabase/supabase#45570). The bundle is embedded at build time for shipped binaries and produced on demand (esbuild) when running from source. It is delivered into the created (not yet started) container as a `docker cp` stdin tar archive at `/root/index.ts` — never a single-file host bind mount, which materializes as an empty directory on daemons that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons, podman machines) and breaks bring-up with edge-runtime's "failed to determine entrypoint" (supabase/cli#6254). Only this bootstrap template is daemon-independent: user function sources, import maps, static files, and the multiline-env script directory (present only when an env value contains a newline) still arrive by host bind mounts, so they require a daemon that can see the project directory.
- **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled 2026-08-12):** bind mounts are computed by the functions import scanner (`walkImportPaths`/`substituteImportMapValue`, shared with `functions deploy` and `start`'s Edge Runtime bring-up), which matches import-map keys per the import-maps spec Deno/edge-runtime implement — exact match, or prefix match only for a `/`-suffixed key — instead of Go's any-key `strings.HasPrefix` (`pkg/function/deno.go:150-155`). Bind mounts may shrink vs the Go CLI for maps that relied on bare-key prefix matching; an unwalkable target (`ENOTDIR` — a value routed through a file) is skipped with a `WARN`.
diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts
index ffc7631422..4dab2dafd3 100644
--- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts
+++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts
@@ -422,7 +422,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -464,11 +464,11 @@ describe("legacy functions serve integration", () => {
yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -509,7 +509,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -550,11 +550,11 @@ describe("legacy functions serve integration", () => {
);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -605,7 +605,7 @@ describe("legacy functions serve integration", () => {
}
expect(
deployMockState.runCalls.filter(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
),
).toHaveLength(0);
expect(deployMockState.networkCalls).toHaveLength(0);
@@ -626,7 +626,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -704,11 +704,11 @@ describe("legacy functions serve integration", () => {
expect(out.stderrText).toContain("Skipped serving Function: disabled\n");
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
expect(dockerRun.args).toContain("--network");
@@ -719,11 +719,21 @@ describe("legacy functions serve integration", () => {
// `replaceImageTag`, `pkg/config/utils.go:81-84`) — a bare pin stays
// bare, no `v` synthesized.
expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:1.73.13");
+ // The main service is `docker cp`-streamed in, never a single-file host bind (#6254).
expect(
extractFlagValues(dockerRun.args, "-v").some((value) =>
- value.endsWith(":/root/index.ts:ro,Z"),
+ value.includes(":/root/index.ts"),
),
- ).toBe(true);
+ ).toBe(false);
+ const bringUpSteps = deployMockState.runCalls
+ .map((call) => call.args[0])
+ .filter((step) => step === "create" || step === "cp" || step === "start");
+ expect(bringUpSteps).toEqual(["create", "cp", "start"]);
+ expect(deployMockState.runCalls.map((call) => call.args.slice(0, 3))).toContainEqual([
+ "cp",
+ "-",
+ "supabase_edge_runtime_test-project:/",
+ ]);
expect(extractFlagValues(dockerRun.args, "--workdir")).toEqual([
toDockerPath(tempRoot.current),
]);
@@ -790,7 +800,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -807,10 +817,10 @@ describe("legacy functions serve integration", () => {
stderr: "error running container: exit 1",
onSpawn: () => {
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
if (dockerRun === undefined) {
- throw new Error("expected docker run call before docker logs spawn");
+ throw new Error("expected docker create call before docker logs spawn");
}
multilineEnvDirWhenLogsStarted = extractFlagValues(dockerRun.args, "-v")
.find((value) => value.endsWith(":/root/.supabase/multiline-env:ro,Z"))
@@ -849,11 +859,11 @@ describe("legacy functions serve integration", () => {
expect(error).toBeInstanceOf(Error);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
expect(dockerRun.args).toContain(
@@ -914,7 +924,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -967,11 +977,11 @@ describe("legacy functions serve integration", () => {
expect(existsSync(staleMultilineEnvDir)).toBe(false);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
expect(
extractFlagValues(dockerRun.args, "-v").some((value) =>
@@ -1010,7 +1020,7 @@ describe("legacy functions serve integration", () => {
}
expect(
deployMockState.runCalls.filter(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
),
).toHaveLength(0);
});
@@ -1055,7 +1065,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -1110,7 +1120,7 @@ describe("legacy functions serve integration", () => {
}
expect(
deployMockState.runCalls.some(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
),
).toBe(true);
});
@@ -1127,7 +1137,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -1185,11 +1195,11 @@ describe("legacy functions serve integration", () => {
}
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run invocation");
+ throw new Error("expected docker create invocation");
}
// `buildDockerBinds` realpath-resolves host paths, so compare against the
// resolved path (on macOS the temp dir lives under /var -> /private/var).
@@ -1215,7 +1225,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -1284,11 +1294,11 @@ describe("legacy functions serve integration", () => {
}
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run invocation");
+ throw new Error("expected docker create invocation");
}
const resolvedSharedPath = realpathSync(sharedPath);
expect(
@@ -1312,7 +1322,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -1345,9 +1355,9 @@ describe("legacy functions serve integration", () => {
yield* waitFor(
() =>
deployMockState.runCalls.filter(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
).length === 1,
- "timed out waiting for first docker run",
+ "timed out waiting for first docker create",
);
fileWatcher.emit([
@@ -1369,7 +1379,7 @@ describe("legacy functions serve integration", () => {
expect(
deployMockState.runCalls.filter(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
),
).toHaveLength(2);
// The file-change line prints the fsnotify op token Go prints
@@ -1408,7 +1418,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -1438,9 +1448,9 @@ describe("legacy functions serve integration", () => {
yield* waitFor(
() =>
deployMockState.runCalls.some(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
),
- "timed out waiting for docker run",
+ "timed out waiting for docker create",
);
processControl.signal("SIGINT");
@@ -1554,7 +1564,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -1629,7 +1639,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -1668,11 +1678,11 @@ describe("legacy functions serve integration", () => {
}
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
expect(dockerRun.args).toContain("--network");
@@ -1699,7 +1709,7 @@ describe("legacy functions serve integration", () => {
if (command !== "docker") {
throw new Error(`unexpected process: ${command}`);
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
return { exitCode: 0, stdout: "", stderr: "" };
@@ -1719,25 +1729,51 @@ describe("legacy functions serve integration", () => {
yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const commandScript = dockerRun.args[dockerRun.args.length - 1] ?? "";
expect(commandScript).toBe(
"edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n",
);
- expect(
- extractFlagValues(dockerRun.args, "-v").some((value) =>
- value.endsWith(":/root/index.ts:ro,Z"),
- ),
- ).toBe(true);
+
+ const cp = deployMockState.runCalls.find(
+ (call) => call.command === "docker" && call.args[0] === "cp",
+ );
+ expect(cp).toBeDefined();
+ if (cp === undefined) {
+ throw new Error("expected docker cp call");
+ }
+ const cpOptions: unknown = cp.options;
+ const stdin =
+ typeof cpOptions === "object" && cpOptions !== null && "stdin" in cpOptions
+ ? cpOptions.stdin
+ : undefined;
+ // Narrows the mock-recorded `unknown`; the `instanceof Uint8Array` check still guards.
+ const isCpArchiveStream = (value: unknown): value is Stream.Stream =>
+ Stream.isStream(value);
+ expect(isCpArchiveStream(stdin)).toBe(true);
+ if (!isCpArchiveStream(stdin)) return yield* Effect.die("docker cp stdin was not a stream");
+ const chunks = yield* Stream.runCollect(stdin);
+ const archiveBytes = chunks[0];
+ if (!(archiveBytes instanceof Uint8Array)) {
+ return yield* Effect.die("docker cp stdin did not contain archive bytes");
+ }
+ const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files());
+ const mainService = files.get("root/index.ts");
+ if (mainService === undefined) {
+ return yield* Effect.die("docker cp archive did not contain root/index.ts");
+ }
+ const template = yield* Effect.promise(() => mainService.text());
+ expect(template.length).toBeGreaterThan(0);
+ expect(template).not.toContain("@ts-nocheck");
+ expect(template).not.toContain("declare const Deno");
+ expect(template).not.toContain("declare const EdgeRuntime");
expect(commandScript).not.toContain("@ts-nocheck");
- expect(commandScript).not.toContain("declare const Deno");
- expect(commandScript).not.toContain("declare const EdgeRuntime");
});
});
@@ -1746,7 +1782,7 @@ describe("legacy functions serve integration", () => {
if (command !== "docker") {
throw new Error(`unexpected process: ${command}`);
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
return { exitCode: 0, stdout: "", stderr: "" };
@@ -1780,11 +1816,11 @@ describe("legacy functions serve integration", () => {
);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
expect(dockerRun.args).toContain("-p");
@@ -1804,7 +1840,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -1882,11 +1918,11 @@ describe("legacy functions serve integration", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -1920,7 +1956,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -1970,11 +2006,11 @@ describe("legacy functions serve integration", () => {
}
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -2010,7 +2046,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -2062,11 +2098,11 @@ describe("legacy functions serve integration", () => {
expect(fetchMock).not.toHaveBeenCalled();
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -2096,7 +2132,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -2140,11 +2176,11 @@ describe("legacy functions serve integration", () => {
}
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -2168,7 +2204,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -2210,11 +2246,11 @@ describe("legacy functions serve integration", () => {
}
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -2236,7 +2272,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -2314,7 +2350,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -2382,11 +2418,11 @@ describe("legacy functions serve integration", () => {
);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -2730,7 +2766,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -2805,7 +2841,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -2845,11 +2881,11 @@ describe("legacy functions serve integration", () => {
}
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -2881,7 +2917,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -2925,11 +2961,11 @@ describe("legacy functions serve integration", () => {
}
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun));
@@ -2974,7 +3010,7 @@ describe("legacy functions serve integration", () => {
}
expect(
deployMockState.runCalls.filter(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
),
).toHaveLength(0);
});
@@ -3026,7 +3062,7 @@ describe("legacy functions serve integration", () => {
]);
expect(
deployMockState.runCalls.filter(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
),
).toHaveLength(0);
expect(deployMockState.networkCalls).toHaveLength(0);
@@ -3095,7 +3131,7 @@ describe("legacy functions serve integration", () => {
type: "warn",
message: expect.stringContaining("An error occurred in Effect.tryPromise"),
});
- expect(deployMockState.runCalls.filter((call) => call.args[0] === "run")).toHaveLength(0);
+ expect(deployMockState.runCalls.filter((call) => call.args[0] === "create")).toHaveLength(0);
});
});
@@ -3174,7 +3210,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -3209,11 +3245,11 @@ describe("legacy functions serve integration", () => {
yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun).toBeDefined();
if (dockerRun === undefined) {
- throw new Error("expected docker run call");
+ throw new Error("expected docker create call");
}
expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4");
});
@@ -3233,7 +3269,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -3271,7 +3307,7 @@ describe("legacy functions serve integration", () => {
{ networkMode: "env-network", projectId: "test-project" },
]);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun?.args).toContain("env-network");
});
@@ -3289,7 +3325,7 @@ describe("legacy functions serve integration", () => {
if (args[0] === "container" && args[1] === "rm") {
return { exitCode: 0, stdout: "", stderr: "" };
}
- if (args[0] === "run") {
+ if (args[0] === "create" || args[0] === "cp" || args[0] === "start") {
return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" };
}
if (args[0] === "exec") {
@@ -3327,7 +3363,7 @@ describe("legacy functions serve integration", () => {
{ networkMode: "flag-network", projectId: "test-project" },
]);
const dockerRun = deployMockState.runCalls.find(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
);
expect(dockerRun?.args).toContain("flag-network");
expect(dockerRun?.args).not.toContain("env-network");
@@ -3361,7 +3397,7 @@ describe("legacy functions serve integration", () => {
}
expect(
deployMockState.runCalls.filter(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
),
).toHaveLength(0);
});
@@ -3396,7 +3432,7 @@ describe("legacy functions serve integration", () => {
}
expect(
deployMockState.runCalls.filter(
- (call) => call.command === "docker" && call.args[0] === "run",
+ (call) => call.command === "docker" && call.args[0] === "create",
),
).toHaveLength(0);
});
diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md
index 05c794a899..4af5e52658 100644
--- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md
+++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md
@@ -50,8 +50,10 @@ ran).
Reuses `shared/functions/serve.ts`'s `startEdgeRuntimeContainer` core (the same one
`functions serve` uses). Gated on `edge_runtime.enabled && !--exclude edge-runtime`,
-started between ImgProxy and pg-meta in the container-start sequence. Unlike every other
-service, it's a direct `docker run -d ...` (not `docker create`+`docker start`) and is
+started between ImgProxy and pg-meta in the container-start sequence. Its own
+`docker create` → `docker cp` (the bundled main-service template, streamed as a stdin
+tar archive) → `docker start` sequence is assembled by that shared core, not by
+`legacyCreateContainer` like every other service, and it is
health-checked via an HTTP probe through Kong (`/functions/v1/_internal/health`), not a
Docker healthcheck — mirroring PostgREST's own probe shape.
@@ -91,11 +93,11 @@ command.
## Files Written
-| Path | Format | When |
-| --------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` |
-| `/supabase/.temp/start-secrets//{env,multiline-env,main}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts and bootstrap template — see below |
-| `/supabase/.temp/pgdelta/catalog-local-migrations--.json` | JSON | best-effort, on a fresh volume, after `MigrateAndSeed`, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails `start` |
+| Path | Format | When |
+| ---------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` |
+| `/supabase/.temp/start-secrets//{env,multiline-env}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts — see below |
+| `/supabase/.temp/pgdelta/catalog-local-migrations--.json` | JSON | best-effort, on a fresh volume, after `MigrateAndSeed`, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails `start` |
Kong's `custom_nginx.template`, Vector's `vector.yaml`, and Postgres's own bootstrap
script (`postgresql.conf`-equivalent setup) are all rendered in memory and injected
@@ -121,17 +123,27 @@ since the content already lives inside the container's own filesystem.
Studio reads/writes SQL snippets under `/supabase/snippets/` at its own
runtime — that's Studio's behavior, not something `start` itself writes.
-Edge Runtime's own JWT/service-role-key/configured-secret env file, multiline-env
-script + value files, and bootstrap `index.ts` template (`shared/functions/serve.ts`'s
-`writeDockerEnvFile`/`writeDockerMultilineEnvScript`/`writeServeMainTemplateFile`) are
+Edge Runtime's own JWT/service-role-key/configured-secret env file and multiline-env
+script + value files (`shared/functions/serve.ts`'s
+`writeDockerEnvFile`/`writeDockerMultilineEnvScript`) are
staged on host disk under `/supabase/.temp/start-secrets//{env,multiline-env,main}/` (directory mode `0700`, files mode `0600`),
+containerName>/{env,multiline-env}/` (directory mode `0700`, files mode `0600`) — the
+env file is read client-side by `--env-file`, the multiline-env directory is
bind-mounted `:ro,Z` into the container — a deterministic, persistent path rather than
`os.tmpdir()` (which is frequently tmpfs and gets wiped on reboot) so
`legacyCleanupStartSecrets` (see the Exit Codes/rollback section below) can reclaim
-them on `stop` or a failed-start rollback. Each of the three writers removes and
+them on `stop` or a failed-start rollback. Each writer removes and
recreates its own subdirectory fresh on every call (self-healing), so a
-shrinking env set never leaves stale files behind.
+shrinking env set never leaves stale files behind. The bootstrap `index.ts` template
+carries no secret content and, as of supabase/cli#6254, never touches host disk at all:
+it is streamed via `docker cp` straight into the created (not yet started) Edge Runtime
+container — a single-file host bind mount materializes as an empty directory on daemons
+that cannot see the client's filesystem (remote `DOCKER_HOST`/Docker-context daemons,
+podman machines), which broke `start` with edge-runtime's "failed to determine
+entrypoint". Only the bootstrap template is daemon-independent: user function
+sources under `supabase/functions/**` and the multiline-env script directory
+(present only when a secret value contains a newline) still arrive by host bind
+mounts and require a daemon that can see the project directory.
## API Routes
@@ -321,7 +333,7 @@ prose, not structured data.
`/supabase/.temp/start-secrets/` directory. As of
supabase/cli#6022 this is a no-op for a removed Kong/Postgres/Supavisor container
(nothing under its own directory anymore); it still matters for a removed Edge Runtime
- container, whose own env-file/multiline-env-script/serve-main-template staging is
+ container, whose own env-file/multiline-env-script staging is
unaffected by that change.
- Docker status `created` is not considered a recoverable stopped stack: the container and
named volume are preserved because the volume may not have completed its first database
diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts
index abba2db0e8..79ae42c489 100644
--- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts
+++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { describe, expect, it } from "@effect/vitest";
import { Deferred, Effect, Exit, Sink, Stream } from "effect";
-import { ChildProcessSpawner } from "effect/unstable/process";
+import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { beforeEach } from "vitest";
import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts";
@@ -16,32 +16,44 @@ import {
/**
* A spawner that answers every `docker` invocation
* `legacyStartEdgeRuntimeContainer`'s call chain makes
- * (`ensureDockerNamedVolume`/`ensureDockerNetwork`/the `docker run -d` bring-up
- * itself) with success, recording every invocation's argv for assertions —
- * same shape as `health-check.unit.test.ts`'s `mockHealthSpawner`. Secret
- * values are delivered to the `run -d` call via `--env-file`/a bind-mounted
+ * (`ensureDockerNamedVolume`/`ensureDockerNetwork`/the create → cp → start
+ * bring-up itself) with success, recording every invocation's argv (plus its
+ * `stdin` option, for the `docker cp` archive) for assertions — same shape as
+ * `health-check.unit.test.ts`'s `mockHealthSpawner`. Secret
+ * values are delivered to the `create` call via `--env-file`/a bind-mounted
* script, not this spawned process's own environment (see
* `edge-runtime.service.ts`'s header for why), so there is nothing to capture
- * beyond argv. Note `legacyStartEdgeRuntimeContainer` never issues a
+ * beyond argv and stdin. Note `legacyStartEdgeRuntimeContainer` never issues a
* `docker exec ... kong reload` — that only happens in `functions serve`'s own
* `restartEdgeRuntime`-equivalent wrapper (`shared/functions/serve.ts`'s
* `startEdgeRuntime`), not in the shared bring-up core this module calls
* directly — see the "does not reload Kong" test below.
*/
-function mockDockerSpawner() {
- const calls: Array<{ args: ReadonlyArray }> = [];
+function mockDockerSpawner(
+ handler?: (args: ReadonlyArray) => { exitCode: number; stderr?: string },
+) {
+ const calls: Array<{
+ args: ReadonlyArray;
+ stdin: ChildProcess.CommandInput | ChildProcess.StdinConfig | undefined;
+ }> = [];
+ const encoder = new TextEncoder();
const spawner = ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
const args = command._tag === "StandardCommand" ? command.args : [];
- calls.push({ args });
+ calls.push({
+ args,
+ stdin: command._tag === "StandardCommand" ? command.options.stdin : undefined,
+ });
+ const result = handler?.(args) ?? { exitCode: 0 };
const exitDeferred = yield* Deferred.make();
- yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(0));
+ yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode));
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
stdout: Stream.empty,
- stderr: Stream.empty,
+ stderr:
+ result.stderr === undefined ? Stream.empty : Stream.make(encoder.encode(result.stderr)),
all: Stream.empty,
exitCode: Deferred.await(exitDeferred),
isRunning: Effect.succeed(false),
@@ -60,7 +72,8 @@ function mockDockerSpawner() {
return calls;
},
get runCall() {
- return calls.find((call) => call.args[0] === "run" && call.args[1] === "-d");
+ // The container-level `docker create` (`docker volume create` starts with "volume").
+ return calls.find((call) => call.args[0] === "create");
},
};
}
@@ -281,6 +294,108 @@ describe("legacyStartEdgeRuntimeContainer", () => {
}),
);
+ it.effect(
+ "delivers the bundled main service via docker cp into the created container — never a single-file host bind (#6254)",
+ () =>
+ Effect.gen(function* () {
+ const mock = mockDockerSpawner();
+ const out = mockOutput();
+
+ yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner),
+ Effect.provide(out.layer),
+ );
+
+ const containerSteps = mock.calls
+ .map((call) => call.args[0])
+ .filter((step) => step === "create" || step === "cp" || step === "start");
+ expect(containerSteps).toEqual(["create", "cp", "start"]);
+
+ // No bind delivers /root/index.ts — the single-file host bind is what broke #6254.
+ const createArgs = mock.runCall!.args;
+ const bindValues = createArgs.flatMap((arg, index) =>
+ createArgs[index - 1] === "-v" ? [arg] : [],
+ );
+ expect(bindValues.some((bind) => bind.includes(":/root/index.ts"))).toBe(false);
+
+ const cp = mock.calls.find((call) => call.args[0] === "cp");
+ expect(cp?.args).toEqual(["cp", "-", "supabase_edge_runtime_proj:/"]);
+ const stdin = cp?.stdin;
+ expect(Stream.isStream(stdin)).toBe(true);
+ if (!Stream.isStream(stdin)) return yield* Effect.die("docker cp stdin was not a stream");
+ const chunks = yield* Stream.runCollect(stdin);
+ expect(chunks).toHaveLength(1);
+ const archiveBytes = chunks[0];
+ if (!(archiveBytes instanceof Uint8Array)) {
+ return yield* Effect.die("docker cp stdin did not contain archive bytes");
+ }
+ const files = yield* Effect.promise(() => new Bun.Archive(archiveBytes).files());
+ expect([...files.keys()]).toEqual(["root/index.ts"]);
+ const mainService = files.get("root/index.ts");
+ if (mainService === undefined) {
+ return yield* Effect.die("docker cp archive did not contain the main service");
+ }
+ expect(yield* Effect.promise(() => mainService.text())).toContain(
+ "SUPABASE_INTERNAL_FUNCTIONS_CONFIG",
+ );
+ }),
+ );
+
+ it.effect(
+ "surfaces docker's own stderr verbatim and never reaches cp/start when docker create fails",
+ () =>
+ Effect.gen(function* () {
+ const mock = mockDockerSpawner((args) =>
+ args[0] === "create"
+ ? { exitCode: 125, stderr: "Conflict. The container name is already in use" }
+ : { exitCode: 0 },
+ );
+ const out = mockOutput();
+
+ const error = yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner),
+ Effect.provide(out.layer),
+ Effect.flip,
+ );
+
+ expect(error).toBeInstanceOf(Error);
+ expect(String(error)).toContain("Conflict. The container name is already in use");
+ expect(mock.calls.some((call) => call.args[0] === "cp")).toBe(false);
+ expect(mock.calls.some((call) => call.args[0] === "start")).toBe(false);
+ expect(mock.calls.some((call) => call.args[0] === "container")).toBe(false);
+ }),
+ );
+
+ it.effect(
+ "prefixes docker cp's uninterpretable stderr, never reaches docker start, and leaves container removal to the caller when the copy fails",
+ () =>
+ Effect.gen(function* () {
+ const mock = mockDockerSpawner((args) =>
+ args[0] === "cp"
+ ? {
+ exitCode: 1,
+ stderr: 'destination "supabase_edge_runtime_proj:/" must be a directory',
+ }
+ : { exitCode: 0 },
+ );
+ const out = mockOutput();
+
+ const error = yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner),
+ Effect.provide(out.layer),
+ Effect.flip,
+ );
+
+ expect(error).toBeInstanceOf(Error);
+ expect(String(error)).toContain(
+ 'failed to copy edge runtime main service into container: destination "supabase_edge_runtime_proj:/" must be a directory',
+ );
+ expect(mock.calls.some((call) => call.args[0] === "start")).toBe(false);
+ // Removal stays with the callers, matching `docker run -d` behavior.
+ expect(mock.calls.some((call) => call.args[0] === "container")).toBe(false);
+ }),
+ );
+
it.effect(
"resolves with the started container's id and a cleanup effect left for the caller to run",
() =>
@@ -299,7 +414,7 @@ describe("legacyStartEdgeRuntimeContainer", () => {
);
it.effect(
- "cleans up a stale staging directory from a previous invocation even when this invocation fails before ever reaching docker run",
+ "cleans up a stale staging directory from a previous invocation even when this invocation fails before ever reaching docker create",
() =>
Effect.gen(function* () {
const mock = mockDockerSpawner();
@@ -322,7 +437,7 @@ describe("legacyStartEdgeRuntimeContainer", () => {
// A multiline secret with a name that fails `validateDockerMultilineEnvNames` (must
// match a shell variable name) — this throws before any of THIS invocation's staging
// writes happen, proving cleanup covers the whole staging-write window, not just a
- // failure at (or after) the `docker run` step.
+ // failure at (or after) the docker create/cp/start steps.
edgeRuntimeSecrets: { "1BAD_NAME": "line one\nline two" },
};
diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts
index 7a9dbf3e97..e283f0c822 100644
--- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts
+++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts
@@ -23,12 +23,15 @@
* containing a newline (`docker env-file` is a line-oriented format that
* cannot represent one) — a second env-delivery mechanism the shared spec
* has no concept of.
- * - `docker run -d` (one atomic call) vs. `docker create` + `docker start`
- * (two calls) is a structural argv difference, not a flag-mapping detail.
+ * - Edge Runtime's `docker create` → `docker cp` → `docker start` sequence
+ * (`shared/functions/serve.ts`) superficially resembles
+ * `legacyCreateContainer`'s, but its `docker cp` payload is the bundled
+ * main-service template (not `secretFiles`), so the shared spec's
+ * secret-file contract does not map onto it.
*
- * So this module keeps `startEdgeRuntimeContainer`'s direct `docker run -d`
- * exactly as `functions serve` already spawns it (see that module's own doc
- * comment), and exposes {@link legacyStartEdgeRuntimeContainer} as a direct
+ * So this module keeps `startEdgeRuntimeContainer`'s own create/cp/start
+ * bring-up exactly as `functions serve` already spawns it (see that module's
+ * own doc comment), and exposes {@link legacyStartEdgeRuntimeContainer} as a direct
* bring-up `Effect` for `start.handler.ts` to call from its own bring-up loop
* — NOT a spec for `legacyCreateContainer` to create. `start.handler.ts`'s
* wiring must special-case Edge Runtime's bring-up call, the same way it
@@ -121,7 +124,7 @@ export interface LegacyEdgeRuntimeBringUpInput {
/**
* Bring up the Edge Runtime container for `start`, delegating the entire
- * `docker run` argv/env assembly to `shared/functions/serve.ts`'s
+ * create/cp/start argv/env assembly to `shared/functions/serve.ts`'s
* `startEdgeRuntimeContainer` (already ported for `functions serve`) with
* `start`'s own already-resolved config/secrets in place of that command's
* independent config-loading pipeline. `start.handler.ts`'s bring-up loop
@@ -136,27 +139,27 @@ export interface LegacyEdgeRuntimeBringUpInput {
* shape as the existing `postgrest` gateway). `watchSpecs` is
* `functions serve`-only file-watch plumbing and can be ignored here.
*
- * `cleanup` (removing the temp env-file/multiline-env-script/serve-main-
- * template files this call writes to the host) is intentionally left to the
+ * `cleanup` (removing the temp env-file/multiline-env-script files this call
+ * writes to the host) is intentionally left to the
* caller, and the caller must NOT invoke it on a successful bring-up. Unlike
* every other `start` service (`legacyCreateContainer`'s `restartPolicy:
* "unless-stopped"`), Edge Runtime's bring-up sets NO Docker restart policy
* at all — its lifecycle is deliberately reconciled at the CLI level, not
- * the Docker daemon level — so this container's own `docker run`
+ * the Docker daemon level — so this container's own bring-up
* (`shared/functions/serve.ts`) intentionally omits `--restart` too. Its
* bind-mounted host paths must
* still exist for as long as the container itself can be reattached to
* (e.g. a plain `docker start` by the user, or discovery by a later CLI
- * invocation) — unlike Kong/Postgres/Supavisor's `secretFiles`, which
- * `container-lifecycle.ts`'s `legacyCreateContainer` now streams straight
- * into the container instead of staging on host disk (see
- * `legacyCopyStartSecretFilesIntoContainer`'s doc comment), Edge Runtime's own
- * bind-mounted env-file/multiline-env-script/serve-main-template artifacts
- * still need this host persistence, since `docker run -d` bind-mounts them
- * rather than copying their content in. `startEdgeRuntimeContainer` (`shared/functions/
+ * invocation) — unlike Kong/Postgres/Supavisor's `secretFiles` and Edge
+ * Runtime's own bootstrap template, which are `docker cp`-streamed straight
+ * into the created container instead of staged on host disk (see
+ * `legacyCopyStartSecretFilesIntoContainer`'s doc comment and
+ * `startEdgeRuntimeContainer`'s own delivery comment), Edge Runtime's
+ * bind-mounted multiline-env-script artifacts still need this host
+ * persistence. `startEdgeRuntimeContainer` (`shared/functions/
* serve.ts`) already runs `cleanup` internally on any failed or interrupted
* bring-up (`Effect.onError`, covering the whole staging-write-through-
- * `docker run` window, not just a non-zero exit code), so the caller only
+ * `docker start` window, not just a non-zero exit code), so the caller only
* needs to leave the returned `cleanup` unused on success.
*/
export const legacyStartEdgeRuntimeContainer = Effect.fn("legacy.start.edgeRuntime")(function* (
diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts
index 934ee7fd31..7d92e89285 100644
--- a/apps/cli/src/legacy/commands/start/start.handler.ts
+++ b/apps/cli/src/legacy/commands/start/start.handler.ts
@@ -1756,8 +1756,8 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta
// `edge-runtime.service.ts`'s header for why. Unlike every other
// service built here (`legacyCreateContainer`'s `restartPolicy:
// "unless-stopped"`), Edge Runtime's own bring-up sets no Docker
- // restart policy at all, so this container's `docker run` matches
- // that — but its bind-mounted host temp files must still exist for
+ // restart policy at all — but its bind-mounted host temp files
+ // (env-file/multiline-env-script staging) must still exist for
// as long as the container can be reattached to; `legacyStart
// EdgeRuntimeContainer` already runs `cleanup` on a failed or
// interrupted bring-up internally, so only the success path must
diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts
index 3a73df184d..4ea6ca0b73 100644
--- a/apps/cli/src/legacy/commands/start/start.integration.test.ts
+++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts
@@ -226,6 +226,11 @@ function containerNameFromCreateArgs(args: ReadonlyArray): string {
return nameIndex !== -1 ? (args[nameIndex + 1] ?? "unknown") : "unknown";
}
+/** Edge Runtime's own create/cp/start bring-up sits outside `legacyCreateContainer`; its create is recognized by the `_edge_runtime_` container name. */
+function isEdgeRuntimeCreate(args: ReadonlyArray): boolean {
+ return args[0] === "create" && containerNameFromCreateArgs(args).includes("_edge_runtime_");
+}
+
/**
* Real `docker create` prints a 64-hex id, never the `--name`. The mock does
* too, so a caller that carries that opaque id into the health watch fails the
@@ -240,8 +245,10 @@ function fakeContainerId(name: string): string {
}
function createdContainerNames(spawned: ReadonlyArray): ReadonlyArray {
+ // Excludes Edge Runtime's create so this keeps meaning "which services
+ // `legacyCreateContainer` brought up", the premise of the exact-equality assertions below.
return spawned
- .filter((s) => s.args[0] === "create")
+ .filter((s) => s.args[0] === "create" && !isEdgeRuntimeCreate(s.args))
.map((s) => containerNameFromCreateArgs(s.args));
}
@@ -494,8 +501,9 @@ function setup(opts: SetupOpts = {}) {
* matrix test below. `storage-api` is compound: excluding it also disables
* ImgProxy (`start.gates.ts`'s `imgproxy: storage && ...` dependency).
* `edge-runtime` maps to no suffix at all here — it DOES really start now
- * (`legacyStartEdgeRuntimeContainer`, a direct `docker run -d`, never a
- * `docker create`), so `--exclude edge-runtime` is exercised by its own
+ * (`legacyStartEdgeRuntimeContainer`, its own create/cp/start bring-up outside
+ * `legacyCreateContainer`, which `createdContainerNames` excludes), so
+ * `--exclude edge-runtime` is exercised by its own
* dedicated scenarios below rather than through this `docker create`-based
* matrix.
*/
@@ -2380,7 +2388,7 @@ content_path = "./supabase/templates/custom_notice.html"
});
describe("fresh volume: DB setup + bucket seeding", () => {
- /** The three PG15+ one-shot migrate jobs (`legacyStartSetupLocalDatabase`'s `LegacyDockerRun` calls) — a plain `docker run --rm ...`, never `-d`, distinct from Edge Runtime's own detached `docker run -d`. */
+ /** The three PG15+ one-shot migrate jobs (`legacyStartSetupLocalDatabase`'s `LegacyDockerRun` calls) — a plain `docker run --rm ...`, distinct from Edge Runtime's own create/cp/start bring-up. */
function dbSetupJobCalls(spawned: ReadonlyArray): ReadonlyArray {
return spawned.filter((s) => s.args[0] === "run" && s.args[1] === "--rm");
}
@@ -2752,20 +2760,31 @@ content_path = "./supabase/templates/custom_notice.html"
});
describe("edge runtime", () => {
- /** Edge Runtime's own bring-up (`legacyStartEdgeRuntimeContainer`) is a direct, detached `docker run -d ...`, never a `docker create`+`docker start` pair like every other service. */
+ /** Edge Runtime's own bring-up (`legacyStartEdgeRuntimeContainer`) is a `docker create` → `docker cp` (main-service archive) → `docker start` sequence; its create is the one naming the `_edge_runtime_` container, distinguishing it from every other service's `legacyCreateContainer` create. */
function edgeRuntimeRunCalls(spawned: ReadonlyArray): ReadonlyArray {
- return spawned.filter((s) => s.args[0] === "run" && s.args[1] === "-d");
+ return spawned.filter((s) => isEdgeRuntimeCreate(s.args));
}
- it.live("starts a real container via docker run -d when enabled and not excluded", () => {
+ it.live("creates and starts a real container when enabled and not excluded", () => {
const { layer, child } = setup();
return Effect.gen(function* () {
yield* legacyStart(flags());
const runCalls = edgeRuntimeRunCalls(child.spawned);
expect(runCalls).toHaveLength(1);
- expect(runCalls[0]?.args).toContain("--name");
const nameIndex = runCalls[0]?.args.indexOf("--name") ?? -1;
- expect(runCalls[0]?.args[nameIndex + 1]).toContain("_edge_runtime_");
+ const containerName = runCalls[0]?.args[nameIndex + 1] ?? "";
+ expect(containerName).toContain("_edge_runtime_");
+ // The main service is `docker cp`-streamed in, never a single-file host bind (#6254).
+ const bindValues = (runCalls[0]?.args ?? []).flatMap((arg, index) =>
+ runCalls[0]?.args[index - 1] === "-v" ? [arg] : [],
+ );
+ expect(bindValues.some((bind) => bind.includes(":/root/index.ts"))).toBe(false);
+ expect(child.spawned.map((s) => s.args.slice(0, 3))).toContainEqual([
+ "cp",
+ "-",
+ `${containerName}:/`,
+ ]);
+ expect(child.spawned.map((s) => s.args)).toContainEqual(["start", containerName]);
}).pipe(Effect.provide(layer));
});
@@ -2778,26 +2797,24 @@ content_path = "./supabase/templates/custom_notice.html"
});
it.live(
- "keeps the host-side bind-mount temp files after a successful bring-up (no eager cleanup)",
+ "keeps the host-side staged env artifacts after a successful bring-up (no eager cleanup)",
() => {
- // Staged under `/supabase/.temp/start-secrets//main/`
- // — a deterministic, persistent path (not `os.tmpdir()`), so a later
- // `stop`/rollback can reclaim it via `legacyCleanupStartSecrets`. See
- // the "reclaims Edge Runtime's own temp secret artifacts on stop" test
- // below for that cleanup behavior.
+ // Staged under `/supabase/.temp/start-secrets//` so a later
+ // `stop`/rollback can reclaim it; the bootstrap template is no longer part of it.
const { layer, child, workdir } = setup();
return Effect.gen(function* () {
yield* legacyStart(flags());
const runArgs = edgeRuntimeRunCalls(child.spawned)[0]?.args ?? [];
- const bindValues = runArgs.flatMap((arg, i) => (runArgs[i - 1] === "-v" ? [arg] : []));
+ const envFileIndex = runArgs.indexOf("--env-file");
+ const envFilePath = envFileIndex === -1 ? undefined : runArgs[envFileIndex + 1];
+ expect(envFilePath).toBeDefined();
const stagingRoot = join(workdir, "supabase", ".temp", "start-secrets");
- const mainTemplateBind = bindValues.find(
- (bind) => bind.startsWith(stagingRoot) && bind.includes(`${join("main", "index.ts")}:`),
- );
- expect(mainTemplateBind).toBeDefined();
- const hostPath = mainTemplateBind?.split(":")[0] ?? "";
+ expect(envFilePath?.startsWith(stagingRoot)).toBe(true);
try {
- expect(existsSync(hostPath)).toBe(true);
+ expect(existsSync(envFilePath ?? "")).toBe(true);
+ // `//env/docker.env` → the staging dir is two levels up.
+ const containerStagingDir = join(envFilePath ?? "", "..", "..");
+ expect(existsSync(join(containerStagingDir, "main"))).toBe(false);
} finally {
rmSync(stagingRoot, { recursive: true, force: true });
}
@@ -4816,9 +4833,7 @@ content_path = "./supabase/templates/custom_notice.html"
});
return Effect.gen(function* () {
yield* legacyStart(flags());
- const edgeRuntimeRunCall = child.spawned.find(
- (s) => s.args[0] === "run" && s.args[1] === "-d",
- );
+ 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;
@@ -4849,9 +4864,7 @@ content_path = "./supabase/templates/custom_notice.html"
});
return Effect.gen(function* () {
yield* legacyStart(flags());
- const edgeRuntimeRunCall = child.spawned.find(
- (s) => s.args[0] === "run" && s.args[1] === "-d",
- );
+ 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;
@@ -5493,7 +5506,7 @@ content_path = "./supabase/templates/custom_notice.html"
});
return Effect.gen(function* () {
yield* legacyStart(flags());
- const runCalls = child.spawned.filter((s) => s.args[0] === "run" && s.args[1] === "-d");
+ const runCalls = child.spawned.filter((s) => isEdgeRuntimeCreate(s.args));
const entrypointCommand = runCalls[0]?.args.at(-1) ?? "";
expect(entrypointCommand).toContain("--policy=per_worker");
expect(entrypointCommand).not.toContain("--policy=oneshot");
diff --git a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md
index 472e6c1b6d..8d3a0d30c4 100644
--- a/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md
+++ b/apps/cli/src/legacy/commands/stop/SIDE_EFFECTS.md
@@ -22,9 +22,9 @@ The `start-secrets` removal is a TS-port-only hygiene step (`legacyCleanupStartS
`legacy/shared/legacy-start-secrets-cleanup.ts`) — the old Go CLI never staged secrets on
host disk in the first place, so it has nothing to clean up here. Only Edge Runtime's own
JWT/service-role-key/secret env artifacts (`shared/functions/serve.ts`'s
-`writeDockerEnvFile`/`writeDockerMultilineEnvScript`/`writeServeMainTemplateFile`) still
-land on host disk this way, because that container is a `docker run` this port shells out
-to directly rather than a struct call over the Docker Engine API; without this cleanup that
+`writeDockerEnvFile`/`writeDockerMultilineEnvScript`) still
+land on host disk this way, because that container's bring-up shells out to the docker
+CLI directly rather than a struct call over the Docker Engine API; without this cleanup that
directory would survive `stop` indefinitely. (Kong's TLS/`kong.yml`, Postgres's pgsodium
root key, and Supavisor's pooler tenant-script content are delivered via `docker cp`
straight into the created container instead — as of supabase/cli#6022 they never touch
diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts
index 2714e8bad2..828737b3d1 100644
--- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts
+++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts
@@ -34,7 +34,10 @@ import {
} from "../legacy-docker-bind-classify.ts";
import { LEGACY_CLI_PROJECT_LABEL, LEGACY_CLI_WORKDIR_LABEL } from "../legacy-docker-ids.ts";
import { legacyIsDockerDaemonUnreachable } from "../legacy-docker-suggest.ts";
-import { isUserDefinedDockerNetwork } from "../../../shared/functions/functions-docker.ts";
+import {
+ containerArchiveBytes,
+ isUserDefinedDockerNetwork,
+} from "../../../shared/functions/functions-docker.ts";
import {
legacyBuildStartContainerCreateArgs,
legacyApplyBitbucketStartContainerFilter,
@@ -162,8 +165,8 @@ export interface LegacyContainerOpts {
* {@link legacyCopyStartSecretFilesIntoContainer}), so they never touch host disk at
* all. This label is still load-bearing for OTHER host-persisted staging under the same
* `/supabase/.temp/start-secrets//` tree that this function
- * itself never writes — e.g. Edge Runtime's own env-file/multiline-env-script/serve-
- * main-template staging (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer`),
+ * itself never writes — e.g. Edge Runtime's own env-file/multiline-env-script
+ * staging (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer`),
* which `legacyCleanupStartSecrets` (`legacy-start-secrets-cleanup.ts`) still reclaims
* by this same label once that container is torn down.
*/
@@ -743,14 +746,13 @@ function legacyCopyStartSecretFilesIntoContainer(
): Effect.Effect {
if (secretFiles.length === 0) return Effect.void;
- const entries = Object.fromEntries(
- secretFiles.map((secretFile) => [
- secretFile.containerPath.replace(/^\/+/, ""),
- secretFile.content,
- ]),
- );
return Effect.tryPromise({
- try: () => new Bun.Archive(entries).bytes(),
+ try: () =>
+ containerArchiveBytes(
+ Object.fromEntries(
+ secretFiles.map((secretFile) => [secretFile.containerPath, secretFile.content]),
+ ),
+ ),
catch: (cause) =>
new LegacyContainerCreateError({
message: `failed to create docker container: failed to prepare container secret files: ${
diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts
index 42731f2cbe..476279c58a 100644
--- a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts
+++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts
@@ -22,10 +22,10 @@ import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts";
* comment), so a bind mount's host-side path never has to be resolved by a
* remote Docker daemon. This module remains load-bearing for Edge Runtime's
* OWN, still-host-persisted staging under the exact same tree
- * (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer` — a `docker run
- * -d`, not `docker create`+`docker start`, which bind-mounts its env-file/
- * multiline-env-script/serve-main-template artifacts rather than copying
- * their content in) — this function has no way to distinguish which
+ * (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer`, whose env-file
+ * and bind-mounted multiline-env-script artifacts stay on host disk; only its
+ * bootstrap template is `docker cp`-streamed in) — this function has no way
+ * to distinguish which
* producer staged a given container's directory, nor does it need to: a
* directory that was never staged in the first place is a harmless no-op (see
* below).
diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts
index d5872ea2c2..999fc633d1 100644
--- a/apps/cli/src/shared/functions/functions-docker.ts
+++ b/apps/cli/src/shared/functions/functions-docker.ts
@@ -82,6 +82,26 @@ export function toDockerPath(hostPath: string) {
return normalized.replace(/^[A-Za-z]:/, "");
}
+/**
+ * In-memory tar bytes for `docker cp - :/` delivery, which needs no
+ * daemon-visible host path. Keys are absolute container paths; leading slashes
+ * are stripped into the tar entry names since the archive extracts at the
+ * container root. `Bun.Archive` exposes no per-entry mode option, so entries
+ * carry its `0644` default.
+ */
+export function containerArchiveBytes(
+ files: Readonly>,
+): Promise {
+ return new Bun.Archive(
+ Object.fromEntries(
+ Object.entries(files).map(([containerPath, content]) => [
+ containerPath.replace(/^\/+/, ""),
+ content,
+ ]),
+ ),
+ ).bytes();
+}
+
export interface FunctionsDockerRunSpec {
/** Already registry/pull-resolved image reference. */
readonly image: string;
@@ -175,6 +195,8 @@ export const runChildProcess = Effect.fnUntraced(function* (
command: string,
args: ReadonlyArray,
opts: {
+ /** Streamed to the child's stdin (e.g. a `docker cp -` tar archive); defaults to no stdin. */
+ readonly stdin?: Stream.Stream;
readonly stdout?: "pipe" | "ignore";
readonly stderr?: "pipe" | "ignore";
readonly env?: Readonly>;
@@ -189,7 +211,7 @@ export const runChildProcess = Effect.fnUntraced(function* (
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const child = yield* spawnContainerCli(spawner, [...args], {
- stdin: "ignore",
+ stdin: opts.stdin ?? "ignore",
stdout: opts.stdout ?? "pipe",
stderr: opts.stderr ?? "pipe",
env: opts.env,
diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts
index 60eaef8ea2..947c1a001b 100644
--- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts
+++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts
@@ -4,6 +4,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
import {
buildFunctionsDockerRunArgs,
+ containerArchiveBytes,
localDockerId,
resolveDockerNetworkMode,
runChildProcess,
@@ -181,6 +182,42 @@ describe("buildFunctionsDockerRunArgs", () => {
});
});
+describe("containerArchiveBytes", () => {
+ // Regular-file tar entries parsed straight from the ustar headers.
+ function tarRegularFileEntries(archive: Uint8Array): ReadonlyArray<[string, number]> {
+ const decoder = new TextDecoder();
+ const parseOctal = (field: Uint8Array) =>
+ Number.parseInt(decoder.decode(field).replaceAll("\0", "").trim() || "0", 8);
+ const entries: Array<[string, number]> = [];
+ let offset = 0;
+
+ while (offset + 512 <= archive.byteLength) {
+ const header = archive.subarray(offset, offset + 512);
+ if (header.every((byte) => byte === 0)) break;
+
+ const type = header[156];
+ if (type === 0 || type === 0x30) {
+ const name = decoder.decode(header.subarray(0, 100)).replaceAll("\0", "");
+ entries.push([name, parseOctal(header.subarray(100, 108))]);
+ }
+
+ const size = parseOctal(header.subarray(124, 136));
+ offset += 512 + Math.ceil(size / 512) * 512;
+ }
+
+ return entries;
+ }
+
+ it("strips leading slashes into root-relative tar entries with the contractual 0644 mode", async () => {
+ const archive = await containerArchiveBytes({ "/root/index.ts": "export const x = 1;\n" });
+ // The 0644 mode is contractual — a Bun default change must fail here, not as a
+ // runtime permission error inside the container.
+ expect(tarRegularFileEntries(archive)).toEqual([["root/index.ts", 0o644]]);
+ const files = await new Bun.Archive(archive).files();
+ expect(await files.get("root/index.ts")?.text()).toBe("export const x = 1;\n");
+ });
+});
+
describe("resolveDockerNetworkMode", () => {
it("prefers the explicit flag over the env override when both are set", () => {
expect(
diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts
index 8d15ec157b..0db4d04c47 100644
--- a/apps/cli/src/shared/functions/serve.ts
+++ b/apps/cli/src/shared/functions/serve.ts
@@ -57,6 +57,7 @@ import {
type ResolvedDeployFunctionConfig,
} from "./deploy.ts";
import {
+ containerArchiveBytes,
dockerProjectLabels,
ensureDockerNamedVolume,
ensureDockerNetwork,
@@ -1409,6 +1410,30 @@ const bestEffortRemoveContainer = Effect.fnUntraced(function* (containerId: stri
}).pipe(Effect.ignore);
});
+// One step of Edge Runtime's create → cp → start bring-up. Only the cp step passes a
+// `messagePrefix` — its raw stderr is uninterpretable alone — while create/start keep the
+// `docker run -d` era stderr surface byte-identical.
+const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* (
+ args: ReadonlyArray,
+ opts: { readonly messagePrefix?: string; readonly stdin?: Stream.Stream } = {},
+) {
+ const result = yield* runChildProcess("docker", args, {
+ stdin: opts.stdin,
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ if (result.exitCode !== 0) {
+ const detail = result.stderr.trim() || result.stdout.trim();
+ const message =
+ opts.messagePrefix === undefined
+ ? detail || "failed to start edge runtime"
+ : detail.length > 0
+ ? `${opts.messagePrefix}: ${detail}`
+ : opts.messagePrefix;
+ return yield* Effect.fail(new Error(message));
+ }
+});
+
const reloadKong = Effect.fnUntraced(function* (projectId: string) {
const output = yield* Output;
const kongId = localDockerId("kong", projectId);
@@ -1445,18 +1470,6 @@ export function buildServeEntrypointCommand(
`;
}
-async function writeServeMainTemplateFile(template: string, dir: string) {
- // Mount the bundled runtime template instead of embedding it in `sh -c` so
- // Windows does not hit `uv_spawn` ENAMETOOLONG on path-heavy projects.
- // Self-healing — see the matching comment in `writeDockerEnvFile` above.
- await rm(dir, { recursive: true, force: true });
- await mkdir(dir, { recursive: true, mode: 0o700 });
- const pathname = join(dir, "index.ts");
- await writeFile(pathname, template);
- // `Z` — same SELinux relabel rationale as `writeDockerMultilineEnvScript`'s bind.
- return { bind: `${pathname}:${serveMainContainerPath}:ro,Z` } as const;
-}
-
const resolveServeFunctionConfigs = Effect.fnUntraced(function* (
projectRoot: string,
supabaseDir: string,
@@ -1592,11 +1605,11 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo
// ` tree keyed by container name, so these JWT/service-role-key/secret env
// artifacts no longer leak on host disk indefinitely after the container is torn down.
const stagingDir = join(input.projectRoot, "supabase", ".temp", "start-secrets", containerId);
- // A single directory-wide `rm` rather than three per-file `.cleanup()` closures (the JWT
- // secrets/env file, the multiline-env script, the serve-main template all live under
- // `stagingDir`): this is what lets the cleanup cover the whole staging-write window below,
- // including a mid-write failure between the first and second `writeDocker*` call, not just
- // the final `docker run` step.
+ // A single directory-wide `rm` rather than per-file `.cleanup()` closures (the JWT
+ // secrets/env file and the multiline-env script both live under `stagingDir`): this is
+ // what lets the cleanup cover the whole staging-write window below, including a mid-write
+ // failure between the first and second `writeDocker*` call, not just the final docker
+ // create/cp/start steps.
const removeRuntimeArtifacts = Effect.tryPromise({
try: () => rm(stagingDir, { recursive: true, force: true }),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
@@ -1690,7 +1703,8 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo
partitionDockerEnvEntries(dockerEnv);
// Everything from here on writes into `stagingDir` (or starts the container that reads from
// it), so the whole window — including a mid-write failure between two `writeDocker*` calls,
- // not just the final `docker run` step — is wrapped in `Effect.onError` below.
+ // not just the final docker create/cp/start steps — is wrapped in `Effect.onError` below.
+ // Container removal on failure stays with the callers, matching `docker run -d` behavior.
return yield* Effect.gen(function* () {
yield* Effect.try({
try: () => validateDockerMultilineEnvNames(multilineDockerEnv),
@@ -1722,14 +1736,16 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo
...(input.debug ? ["--verbose"] : []),
];
const serveMainTemplate = yield* Effect.promise(() => getLegacyFunctionsServeMainTemplate());
- const serveMainTemplateFile = yield* Effect.tryPromise({
- try: () => writeServeMainTemplateFile(serveMainTemplate, join(stagingDir, "main")),
+ // Streamed in via `docker cp` between create and start: embedding the template in the
+ // `sh -c` argv hits Windows ENAMETOOLONG (#5711), and a single-file host bind mounts as
+ // an empty directory on daemons that cannot see this host's filesystem (#6254, #4190).
+ const serveMainArchive = yield* Effect.tryPromise({
+ try: () => containerArchiveBytes({ [serveMainContainerPath]: serveMainTemplate }),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
});
const containerProjectRoot = toDockerPath(input.projectRoot);
const command = [
- "run",
- "-d",
+ "create",
"--name",
containerId,
"--network",
@@ -1745,8 +1761,6 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo
`com.docker.compose.project=${labels["com.docker.compose.project"]}`,
"--label",
`${dockerWorkdirLabel}=${input.projectRoot}`,
- "-v",
- serveMainTemplateFile.bind,
...([...binds] as ReadonlyArray).flatMap((bind) => ["-v", bind]),
...(dockerMultilineEnvScript === undefined ? [] : ["-v", dockerMultilineEnvScript.bind]),
...(dockerEnvFile === undefined ? [] : ["--env-file", dockerEnvFile.path]),
@@ -1761,15 +1775,14 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo
buildServeEntrypointCommand(runtimeCommand, dockerMultilineEnvScript?.scriptPath),
];
- const result = yield* runChildProcess("docker", command, {
- stdout: "pipe",
- stderr: "pipe",
+ // The container must exist for `docker cp` to have a target, and must not be running
+ // yet so edge-runtime never races the copy.
+ yield* runEdgeRuntimeDockerStep(command);
+ yield* runEdgeRuntimeDockerStep(["cp", "-", `${containerId}:/`], {
+ messagePrefix: "failed to copy edge runtime main service into container",
+ stdin: Stream.make(serveMainArchive),
});
- if (result.exitCode !== 0) {
- const message =
- result.stderr.trim() || result.stdout.trim() || "failed to start edge runtime";
- return yield* Effect.fail(new Error(message));
- }
+ yield* runEdgeRuntimeDockerStep(["start", containerId]);
return {
containerId,
diff --git a/apps/cli/src/shared/functions/serve.unit.test.ts b/apps/cli/src/shared/functions/serve.unit.test.ts
index 5194857742..cd4d3af2d6 100644
--- a/apps/cli/src/shared/functions/serve.unit.test.ts
+++ b/apps/cli/src/shared/functions/serve.unit.test.ts
@@ -42,8 +42,8 @@ describe("dockerBindContainerPath", () => {
});
it("strips the SELinux relabel suffix this file emits", () => {
- expect(dockerBindContainerPath("/tmp/x/main/index.ts:/root/index.ts:ro,Z")).toBe(
- "/root/index.ts",
+ expect(dockerBindContainerPath("/tmp/x/multiline-env:/root/.supabase/multiline-env:ro,Z")).toBe(
+ "/root/.supabase/multiline-env",
);
});
From a7cf00d66c39b6dc516bcc873219574b0a5979e2 Mon Sep 17 00:00:00 2001
From: "supabase-cli-releaser[bot]"
<246109035+supabase-cli-releaser[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 11:05:58 +0000
Subject: [PATCH 08/63] chore(api): sync Management API OpenAPI spec (#6272)
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/contracts.ts | 5 +----
packages/api/src/generated/openapi.json | 10 +---------
2 files changed, 2 insertions(+), 13 deletions(-)
diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts
index f5b30cf176..d649e50f8a 100644
--- a/packages/api/src/generated/contracts.ts
+++ b/packages/api/src/generated/contracts.ts
@@ -4031,6 +4031,7 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({
"project_restore_after_expiry",
"assistant.advance_model",
"integrations.github_connections",
+ "integrations.github_push_webhooks_limit",
"dedicated_pooler",
"observability.dashboard_advanced_metrics",
"api.members.invitations",
@@ -10662,7 +10663,6 @@ export const V2DeployAWorkerInput = Schema.Struct({
expected: "a value less than or equal to 9007199254740991",
}),
),
- backend: Schema.optionalKey(Schema.String),
}),
context_upload_id: Schema.optionalKey(
Schema.String.annotate({
@@ -10693,7 +10693,6 @@ export const V2DeployAWorkerOutput = Schema.Struct({
expected: "a value less than or equal to 9007199254740991",
}),
),
- backend: Schema.optionalKey(Schema.String),
}),
build_state: Schema.Literals(["building", "active", "failed"]),
secret_generation: Schema.String,
@@ -10788,7 +10787,6 @@ export const V2GetAWorkerOutput = Schema.Struct({
expected: "a value less than or equal to 9007199254740991",
}),
),
- backend: Schema.optionalKey(Schema.String),
}),
build_state: Schema.Literals(["building", "active", "failed"]),
secret_generation: Schema.String,
@@ -11471,7 +11469,6 @@ export const V2ListAllWorkersOutput = Schema.Struct({
expected: "a value less than or equal to 9007199254740991",
}),
),
- backend: Schema.optionalKey(Schema.String),
}),
build_state: Schema.Literals(["building", "active", "failed"]),
secret_generation: Schema.String,
diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json
index 0f32121440..2130e09552 100644
--- a/packages/api/src/generated/openapi.json
+++ b/packages/api/src/generated/openapi.json
@@ -22678,6 +22678,7 @@
"project_restore_after_expiry",
"assistant.advance_model",
"integrations.github_connections",
+ "integrations.github_push_webhooks_limit",
"dedicated_pooler",
"observability.dashboard_advanced_metrics",
"api.members.invitations",
@@ -24870,9 +24871,6 @@
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"example": 1
- },
- "backend": {
- "type": "string"
}
},
"required": ["size", "exposure", "instances"]
@@ -24971,9 +24969,6 @@
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"example": 1
- },
- "backend": {
- "type": "string"
}
},
"required": ["size", "exposure", "instances"]
@@ -25106,9 +25101,6 @@
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"example": 1
- },
- "backend": {
- "type": "string"
}
},
"required": ["size", "exposure", "instances"]
From dc97151e51eca112de94c5b9bfe102ee798b7caf Mon Sep 17 00:00:00 2001
From: Andrew Valleteau
Date: Wed, 19 Aug 2026 16:14:50 +0000
Subject: [PATCH 09/63] fix(cli): run DROP INDEX CONCURRENTLY outside the
migration transaction (CLI-2218) (#6276)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes
[CLI-2218](https://linear.app/supabase/issue/CLI-2218/support-drop-index-concurrently-in-migrations).
The v2.109.0 fix for pipeline-incompatible statements (#5671, design
from #5156) classifies statements that cannot run inside a transaction
block and runs them standalone outside the migration batch. Its pattern
list covers `CREATE [UNIQUE] INDEX CONCURRENTLY`, `REINDEX …
CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, and `CLUSTER` — but not `DROP
INDEX CONCURRENTLY`, which was missed. A migration containing one still
gets batched into the implicit transaction and PostgreSQL rejects it:
```
ERROR: DROP INDEX CONCURRENTLY cannot run inside a transaction block (SQLSTATE 25001)
```
This adds the missing `DROP INDEX CONCURRENTLY` pattern to
`legacyIsPipelineIncompatible` in the TS legacy shell, and mirrors it in
the Go sidecar's `isPipelineIncompatible` (`pkg/migration/file.go`),
which is still reachable through the remaining Go-delegated paths that
apply migrations (`db remote commit`, `db branch`). Reported by an
enterprise customer running `db push` on v2.111.0.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Fable 5
---
apps/cli-go/pkg/migration/file.go | 2 ++
apps/cli-go/pkg/migration/file_test.go | 15 +++++++++++++++
.../src/legacy/shared/legacy-migration-apply.ts | 7 +++++--
.../shared/legacy-migration-apply.unit.test.ts | 7 +++++++
4 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/apps/cli-go/pkg/migration/file.go b/apps/cli-go/pkg/migration/file.go
index d7526ac4dd..e74d322c78 100644
--- a/apps/cli-go/pkg/migration/file.go
+++ b/apps/cli-go/pkg/migration/file.go
@@ -30,6 +30,7 @@ var (
migrateFilePattern = regexp.MustCompile(`^([0-9]+)_(.*)\.sql$`)
typeNamePattern = regexp.MustCompile(`type "([^"]+)" does not exist`)
createIndexPattern = regexp.MustCompile(`^CREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY(\s|\z)`)
+ dropIndexPattern = regexp.MustCompile(`^DROP\s+INDEX\s+CONCURRENTLY(\s|\z)`)
reindexPattern = regexp.MustCompile(`^REINDEX(\s|\().*\sCONCURRENTLY(\s|\z)`)
vacuumPattern = regexp.MustCompile(`^VACUUM(\s|\(|\z)`)
alterSystemPattern = regexp.MustCompile(`^ALTER\s+SYSTEM(\s|\z)`)
@@ -80,6 +81,7 @@ func NewMigrationFromReader(sql io.Reader) (*MigrationFile, error) {
func isPipelineIncompatible(sql string) bool {
upper := strings.ToUpper(trimLeadingSQLComments(sql))
return createIndexPattern.MatchString(upper) ||
+ dropIndexPattern.MatchString(upper) ||
reindexPattern.MatchString(upper) ||
vacuumPattern.MatchString(upper) ||
alterSystemPattern.MatchString(upper) ||
diff --git a/apps/cli-go/pkg/migration/file_test.go b/apps/cli-go/pkg/migration/file_test.go
index 49fb0f7f68..83087ba0e6 100644
--- a/apps/cli-go/pkg/migration/file_test.go
+++ b/apps/cli-go/pkg/migration/file_test.go
@@ -240,6 +240,16 @@ func TestIsPipelineIncompatible(t *testing.T) {
sql: "-- cannot run in a transaction\n/* generated */\nCREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)",
want: true,
},
+ {
+ name: "drop index concurrently",
+ sql: "DROP INDEX CONCURRENTLY public.widgets_id_idx",
+ want: true,
+ },
+ {
+ name: "drop index concurrently if exists",
+ sql: "drop index concurrently if exists api.idx_rx_orders_clinic_id",
+ want: true,
+ },
{
name: "reindex table concurrently",
sql: "REINDEX TABLE CONCURRENTLY public.widgets",
@@ -275,6 +285,11 @@ func TestIsPipelineIncompatible(t *testing.T) {
sql: "CREATE INDEX widgets_id_idx ON public.widgets(id)",
want: false,
},
+ {
+ name: "ordinary drop index",
+ sql: "DROP INDEX IF EXISTS public.widgets_id_idx",
+ want: false,
+ },
{
name: "concurrently in string literal",
sql: "SELECT 'CREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)'",
diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts
index f19e292a1c..f343028926 100644
--- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts
+++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts
@@ -59,6 +59,7 @@ const BOM_CODE_POINT = 0xfeff;
// pipeline-incompatible here but wouldn't under that narrower definition. Not worth
// changing behaviour over — flagging so a future review doesn't rediscover it.
const CREATE_INDEX_CONCURRENTLY_PATTERN = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY(?:\s|$)/u;
+const DROP_INDEX_CONCURRENTLY_PATTERN = /^DROP\s+INDEX\s+CONCURRENTLY(?:\s|$)/u;
const REINDEX_CONCURRENTLY_PATTERN = /^REINDEX(?:\s|\().*\sCONCURRENTLY(?:\s|$)/u;
const VACUUM_PATTERN = /^VACUUM(?:\s|\(|$)/u;
const ALTER_SYSTEM_PATTERN = /^ALTER\s+SYSTEM(?:\s|$)/u;
@@ -95,8 +96,9 @@ const legacyTrimLeadingSqlComments = (sql: string): string => {
/**
* Whether a migration statement cannot run inside a transaction block — `CREATE
- * [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`,
- * `CLUSTER`. Such statements fail with SQLSTATE 25001 inside the implicit transaction
+ * [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`,
+ * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`. Such statements fail with SQLSTATE 25001
+ * inside the implicit transaction
* created by a migration batch, so `execMigrationBatch` runs them standalone.
* Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156).
*/
@@ -104,6 +106,7 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => {
const upper = legacyTrimLeadingSqlComments(sql).toUpperCase();
return (
CREATE_INDEX_CONCURRENTLY_PATTERN.test(upper) ||
+ DROP_INDEX_CONCURRENTLY_PATTERN.test(upper) ||
REINDEX_CONCURRENTLY_PATTERN.test(upper) ||
VACUUM_PATTERN.test(upper) ||
ALTER_SYSTEM_PATTERN.test(upper) ||
diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts
index 9bd46cf31d..0bacc45136 100644
--- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts
+++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts
@@ -1072,6 +1072,12 @@ describe("legacyIsPipelineIncompatible", () => {
"-- cannot run in a transaction\n/* generated */\nCREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)",
true,
],
+ ["drop index concurrently", "DROP INDEX CONCURRENTLY public.widgets_id_idx", true],
+ [
+ "drop index concurrently if exists",
+ "drop index concurrently if exists api.idx_rx_orders_clinic_id",
+ true,
+ ],
["reindex table concurrently", "REINDEX TABLE CONCURRENTLY public.widgets", true],
[
"reindex with options concurrently",
@@ -1091,6 +1097,7 @@ describe("legacyIsPipelineIncompatible", () => {
["bom before vacuum", "\uFEFFVACUUM", true],
// Negatives — compatible statements that must keep running inside the batch transaction.
["plain create index", "CREATE INDEX widgets_id_idx ON public.widgets(id)", false],
+ ["plain drop index", "DROP INDEX IF EXISTS public.widgets_id_idx", false],
[
"concurrently in string literal",
"SELECT 'CREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)'",
From 85df55e9c50b259375110e27240a75a0ffad3fe6 Mon Sep 17 00:00:00 2001
From: Andrew Valleteau
Date: Wed, 19 Aug 2026 16:15:31 +0000
Subject: [PATCH 10/63] ci: run the develop CI suite on stacked and draft PRs
via a run-ci label (#6275)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Ready PRs targeting `develop` already get Test, preview CLI packages,
and PR-title lint. Stacked PRs (base is another branch) never enter
those workflows, and drafts skip the jobs.
This adds a `run-ci` label that calls the same Test and preview suites
as reusable workflows, including while the PR is still a draft. Ready
`develop` PRs stay on the existing workflows so required check names are
unchanged.
- Add `run-ci` to start the suite; remove it to cancel in-progress
opt-in runs.
- Other labels do not start or cancel Test / preview.
- Independent of `run-live-e2e-ci`.
- After a stacked PR is retargeted onto `develop`, push or reopen so the
native required checks populate.
The `run-ci` repository label already exists.
## Linked issue
N/A — maintainer CI workflow (exempt).
---
.github/MAINTAINERS.md | 23 +++++++
.github/workflows/lint-pull-request.yml | 9 ++-
.../publish-preview-cli-packages.yml | 24 +++++--
.github/workflows/run-ci.yml | 67 +++++++++++++++++++
.github/workflows/test.yml | 34 +++++++++-
5 files changed, 150 insertions(+), 7 deletions(-)
create mode 100644 .github/workflows/run-ci.yml
diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md
index e8321dd3b9..15ef191ead 100644
--- a/.github/MAINTAINERS.md
+++ b/.github/MAINTAINERS.md
@@ -48,6 +48,29 @@ function; create it once from **Issues → Labels** if it is missing.
Applying `open-for-contribution` is currently a **manual step** — do it on the GitHub
issue directly (from the GitHub UI, or from the Linear-linked issue).
+## `run-ci`: full develop CI on stacked or draft PRs
+
+Ready (non-draft) PRs targeting `develop` already get the default suite: Test
+(check / unit+integration / e2e), preview CLI packages, and PR-title lint.
+
+Stacked PRs (base is another PR branch) and drafts do **not** get that suite
+unless they carry the **`run-ci`** label. [`run-ci.yml`](./workflows/run-ci.yml)
+then calls Test and preview-package publish as reusable workflows, including
+while the PR is still a draft.
+
+- Add `run-ci` to start (or resume) the suite; remove it to cancel in-progress
+ `run-ci` runs via that workflow's concurrency group.
+- Other labels do not start or cancel Test / preview. PR-title lint may
+ retrigger because that check is cheap.
+- After a stacked PR is retargeted onto `develop`, push or reopen so the
+ native required checks (`Check code quality`, etc.) populate. The opt-in
+ suite uses different check names (`Test / Check code quality`).
+- This is independent of `run-live-e2e-ci`, which opts into the separate
+ supabox live e2e dispatch.
+
+The `run-ci` label must exist as a repository label; create it from
+**Issues → Labels** if it is missing.
+
## Deferred: automatic Linear → GitHub label sync
We considered auto-applying `open-for-contribution` when a Linear issue moves out of
diff --git a/.github/workflows/lint-pull-request.yml b/.github/workflows/lint-pull-request.yml
index a2ba597315..c70377a830 100644
--- a/.github/workflows/lint-pull-request.yml
+++ b/.github/workflows/lint-pull-request.yml
@@ -2,6 +2,10 @@ name: Lint Pull Request
# Release-notes PRs (head ref `release-notes/*`) skip CI; only
# apply-release-notes.yml runs for those.
+#
+# Draft PRs skip this check unless they carry `run-ci` (same opt-in as Test
+# and preview packages). Label events retrigger this cheap check so adding
+# `run-ci` on a draft starts lint without waiting for a push.
on:
pull_request_target:
types:
@@ -10,6 +14,8 @@ on:
- synchronize
- reopened
- ready_for_review
+ - labeled
+ - unlabeled
merge_group:
types:
- checks_requested
@@ -29,7 +35,8 @@ jobs:
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request_target' &&
!startsWith(github.event.pull_request.head.ref, 'release-notes/') &&
- github.event.pull_request.draft == false)
+ (github.event.pull_request.draft == false ||
+ contains(github.event.pull_request.labels.*.name, 'run-ci')))
name: Lint Pull Request
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/publish-preview-cli-packages.yml b/.github/workflows/publish-preview-cli-packages.yml
index afbc6ec537..5282341687 100644
--- a/.github/workflows/publish-preview-cli-packages.yml
+++ b/.github/workflows/publish-preview-cli-packages.yml
@@ -2,6 +2,9 @@ name: Publish Preview CLI Packages
# Release-notes PRs (head ref `release-notes/*`) are markdown-only and are not
# meant to produce installable preview packages.
+#
+# Default path: ready (non-draft) PRs targeting `develop`.
+# `run-ci.yml` calls this workflow for drafts and stacked / non-develop PRs.
on:
pull_request:
types:
@@ -9,22 +12,35 @@ on:
- synchronize
- reopened
- ready_for_review
+ - converted_to_draft
branches:
- develop
+ workflow_call:
+ inputs:
+ force:
+ description: Publish even when the PR is a draft (used by run-ci.yml)
+ type: boolean
+ default: false
+ secrets:
+ DF_FIREWALL_TOKEN:
+ required: true
permissions:
actions: read
contents: read
+# Literal prefix: called workflows inherit github.workflow from the caller
+# (`run-ci`). `inputs.force` separates this call from a skipped native run on
+# a develop draft.
concurrency:
- group: ${{ github.workflow }}-${{ github.head_ref }}
+ group: publish-preview-cli-packages.yml-${{ github.event.pull_request.number || github.head_ref }}-${{ inputs.force && 'run-ci' || 'direct' }}
cancel-in-progress: true
jobs:
build:
if: |
!startsWith(github.head_ref, 'release-notes/') &&
- github.event.pull_request.draft == false
+ (inputs.force || github.event.pull_request.draft == false)
name: Build preview CLI packages
uses: ./.github/workflows/build-cli-artifacts.yml
with:
@@ -37,7 +53,7 @@ jobs:
needs: build
if: |
!startsWith(github.head_ref, 'release-notes/') &&
- github.event.pull_request.draft == false &&
+ (inputs.force || github.event.pull_request.draft == false) &&
needs.build.result == 'success'
name: Publish preview package
runs-on: blacksmith-8vcpu-ubuntu-2404
@@ -121,7 +137,7 @@ jobs:
needs: publish
if: |
!startsWith(github.head_ref, 'release-notes/') &&
- github.event.pull_request.draft == false &&
+ (inputs.force || github.event.pull_request.draft == false) &&
needs.publish.result == 'success'
name: Post preview command comment
runs-on: ubuntu-latest
diff --git a/.github/workflows/run-ci.yml b/.github/workflows/run-ci.yml
new file mode 100644
index 0000000000..c3dffb9788
--- /dev/null
+++ b/.github/workflows/run-ci.yml
@@ -0,0 +1,67 @@
+name: run-ci
+
+# Opt-in full develop CI for PRs that Test.yml / preview do not already cover:
+# stacked PRs (base is not develop) and drafts. Ready develop PRs stay on the
+# existing workflows so required check names and concurrency are unchanged.
+#
+# Add the `run-ci` label to start the suite; remove it to cancel in-progress
+# runs via this workflow's concurrency group. Other labels do not retrigger.
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - ready_for_review
+ - labeled
+ - unlabeled
+ - converted_to_draft
+
+permissions:
+ actions: read
+ contents: read
+ pull-requests: write
+
+# Unrelated label events still start a run; give them a unique group so they
+# cannot cancel an in-progress suite. Removing `run-ci` stays on the main
+# group and cancels via cancel-in-progress.
+concurrency:
+ group: >-
+ run-ci.yml-${{ github.event.pull_request.number || github.ref }}${{
+ ((github.event.action == 'labeled' || github.event.action == 'unlabeled')
+ && github.event.label.name != 'run-ci'
+ && format('-noop-{0}', github.run_id))
+ || ''
+ }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Test
+ if: |
+ !startsWith(github.head_ref, 'release-notes/') &&
+ contains(github.event.pull_request.labels.*.name, 'run-ci') &&
+ (github.event.pull_request.draft || github.base_ref != 'develop') &&
+ ((github.event.action != 'labeled' &&
+ github.event.action != 'unlabeled') ||
+ github.event.label.name == 'run-ci')
+ uses: ./.github/workflows/test.yml
+ with:
+ force: true
+ secrets:
+ DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }}
+
+ preview:
+ name: Preview packages
+ if: |
+ !startsWith(github.head_ref, 'release-notes/') &&
+ contains(github.event.pull_request.labels.*.name, 'run-ci') &&
+ (github.event.pull_request.draft || github.base_ref != 'develop') &&
+ ((github.event.action != 'labeled' &&
+ github.event.action != 'unlabeled') ||
+ github.event.label.name == 'run-ci')
+ uses: ./.github/workflows/publish-preview-cli-packages.yml
+ with:
+ force: true
+ secrets:
+ DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b0d4754442..daaf76f68d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,6 +2,9 @@ name: Test
# Release-notes PRs (head ref `release-notes/*`) only add markdown under
# `release-notes/` and are published via approval — skip the full CI suite.
+#
+# Default path: ready (non-draft) PRs targeting `develop`, plus the merge queue.
+# `run-ci.yml` calls this workflow for drafts and stacked / non-develop PRs.
on:
pull_request:
types:
@@ -9,6 +12,7 @@ on:
- synchronize
- reopened
- ready_for_review
+ - converted_to_draft
branches:
- develop
merge_group:
@@ -16,13 +20,26 @@ on:
- checks_requested
branches:
- develop
+ workflow_call:
+ inputs:
+ force:
+ description: Run the suite even when the PR is a draft (used by run-ci.yml)
+ type: boolean
+ default: false
+ secrets:
+ DF_FIREWALL_TOKEN:
+ required: true
permissions:
contents: read
actions: read
+# Literal prefix: called workflows inherit github.workflow from the caller
+# (`run-ci`), which would cancel the caller and the preview call. `inputs.force`
+# keeps a skipped native Test run on a develop draft from cancelling the
+# forced run-ci call.
concurrency:
- group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+ group: test.yml-${{ github.event.pull_request.number || github.head_ref || github.ref }}-${{ inputs.force && 'run-ci' || 'direct' }}
cancel-in-progress: true
jobs:
@@ -30,6 +47,7 @@ jobs:
if: |
!startsWith(github.head_ref, 'release-notes/') &&
(github.event_name == 'merge_group' ||
+ inputs.force ||
github.event.pull_request.draft == false)
name: Check code quality
runs-on: blacksmith-8vcpu-ubuntu-2404
@@ -53,6 +71,7 @@ jobs:
if: |
!startsWith(github.head_ref, 'release-notes/') &&
(github.event_name == 'merge_group' ||
+ inputs.force ||
github.event.pull_request.draft == false)
name: Run unit and integration tests
runs-on: blacksmith-8vcpu-ubuntu-2404
@@ -76,6 +95,7 @@ jobs:
if: |
!startsWith(github.head_ref, 'release-notes/') &&
(github.event_name == 'merge_group' ||
+ inputs.force ||
github.event.pull_request.draft == false)
name: Run end-to-end tests (shard ${{ matrix.shard }}/3)
runs-on: blacksmith-8vcpu-ubuntu-2404
@@ -90,9 +110,18 @@ jobs:
fetch-depth: 0
- name: Set base and head SHAs for affected
- if: github.event_name == 'pull_request'
+ if: github.event.pull_request && !inputs.force
uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1
+ # Reusable calls inherit the caller's github context (event_name stays
+ # pull_request). Use the PR payload directly so we do not depend on
+ # nx-set-shas understanding the caller event.
+ - name: Set base and head SHAs for run-ci reusable call
+ if: inputs.force && github.event.pull_request
+ run: |
+ echo "NX_BASE=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_ENV"
+ echo "NX_HEAD=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_ENV"
+
- name: Set base and head SHAs for merge queue affected
if: github.event_name == 'merge_group'
run: |
@@ -173,6 +202,7 @@ jobs:
always() &&
!startsWith(github.head_ref, 'release-notes/') &&
(github.event_name == 'merge_group' ||
+ inputs.force ||
github.event.pull_request.draft == false)
name: Run end-to-end tests
needs: test-e2e
From 8fc5f3da8f959bda4fd9058313e5e26d578102f1 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:16:10 +0000
Subject: [PATCH 11/63] chore(ci): bump the actions-major group with 3 updates
(#6279)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the actions-major group with 3 updates:
[jdx/mise-action](https://github.com/jdx/mise-action),
[github/codeql-action/init](https://github.com/github/codeql-action) and
[github/codeql-action/analyze](https://github.com/github/codeql-action).
Updates `jdx/mise-action` from 4.2.4 to 4.2.5
Release notes
Sourced from jdx/mise-action's
releases.
v4.2.5: Resilient mise downloads with automatic retries
A small patch release that makes setup more resilient to transient
network failures when downloading mise.
Fixed
Retry mise downloads after transient failures (#597 by
@jdx)
The download helpers previously made a single curl or
wget attempt, so a transient GitHub release-asset HTTP or
TLS failure would abort setup before mise or any user command could run
(see #596).
Downloads now run through a retry wrapper that makes up to five
attempts with a 2s pause between failures, logging a warning on each
retry. This applies consistently to binary, checksum, signature, and
version fetches. Checksum and minisign verification still run only after
a successful download — never inside the retry loop — so integrity
guarantees are unchanged.
Full Changelog: https://github.com/jdx/mise-action/compare/v4.2.4...v4.2.5
Changelog
Sourced from jdx/mise-action's
changelog.
Changelog
4.2.5
- 2026-08-12
🐛 Bug Fixes
- retry mise downloads after transient failures (#597)
by
@jdx in #597
4.2.4
- 2026-08-01
🐛 Bug Fixes
- locking support detection with force-colored output (#580)
by
@scop in #580
4.2.3
- 2026-07-24
🐛 Bug Fixes
- export mise path entries to subsequent steps (#575)
by
@jdx in #575
4.2.2
- 2026-07-24
🐛 Bug Fixes
📚 Documentation
New Contributors
4.2.1
- 2026-07-16
🐛 Bug Fixes
🔍 Other Changes
... (truncated)
Commits
3c2e0cf
chore: release v4.2.5 (#598)
9dda395
fix: retry mise downloads after transient failures (#597)
9d2b311
chore(deps): update github/codeql-action action to v4.37.6 (#593)
4213fbb
chore(deps): update jdx/mise-action action to v4.2.4 (#594)
672dbd2
chore(deps): update zizmorcore/zizmor-action action to v0.6.2 (#595)
5159765
chore(deps): lock file maintenance (#592)
c75b4f6
chore(deps): update jdx/mise-action action to v4.2.3 (#590)
367cc0d
chore(deps): update github/codeql-action action to v4.37.3 (#589)
90e6e66
chore(deps): update zizmorcore/zizmor-action action to v0.6.1 (#591)
7350bb8
chore(deps): lock file maintenance (#588)
- See full diff in compare
view
Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
Release notes
Sourced from github/codeql-action/init's
releases.
v4.37.7
Changelog
Sourced from github/codeql-action/init's
changelog.
CodeQL Action Changelog
See the releases
page for the relevant changes to the CodeQL CLI and language
packs.
[UNRELEASED]
No user facing changes.
4.37.7 - 13 Aug 2026
4.37.6 - 04 Aug 2026
- Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
.github/codeql-config.yml to align it with the suggested
path that is used elsewhere. #4070
4.37.5 - 03 Aug 2026
- Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the
init Action instead
of falling back to downloading the bundle before extracting it. #4061
4.37.4 - 29 Jul 2026
- This version of the CodeQL Action adds support for the
tools input for the codeql-action/init step to
be specified using a github-codeql-tools repository
property. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to toolcache to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for tools in the workflow definition
always takes precedence unless the value of the repository property
starts with !. #4037
- Update default CodeQL bundle version to 2.26.2.
#4051
4.37.3 - 22 Jul 2026
No user facing changes.
4.37.2 - 21 Jul 2026
- The new address format for the
config-file input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the remote= prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. #4023
- The CodeQL Action can now make use of configured
private registries in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. #4007
4.37.1 - 16 Jul 2026
- Upcoming breaking change: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. #3956
- Update default CodeQL bundle version to 2.26.1.
#4019
4.37.0 - 08 Jul 2026
- Update default CodeQL bundle version to 2.26.0.
#3995
- In addition to the existing input format, the
config-file input for the codeql-action/init
step will soon support a new [owner/]repo[@ref][:path]
format. All components except the repository name are optional. If
omitted, owner defaults to the same owner as the repository
the analysis is running for, ref to main, and
path to .github/codeql-action.yaml. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. #3973
4.36.3 - 01 Jul 2026
No user facing changes.
4.36.2 - 04 Jun 2026
... (truncated)
Commits
ff2f1c6
Merge pull request #4093
from github/update-v4.37.7-be7a3dbb8
951a133
Update changelog for v4.37.7
be7a3db
Merge pull request #4087
from github/dependabot/npm_and_yarn/npm-minor-0aa561...
9310334
Merge pull request #4086
from github/mbg/thread-action-state-to-codeql
b4d8a54
Rebuild
ab5db25
Bump the npm-minor group across 1 directory with 8 updates
38055a3
Drop logger from databaseInitCluster in
interface
1f87aed
Merge pull request #4085
from github/update-bundle/codeql-bundle-v2.26.3
dc1b98a
Make logger available to getCodeQLForCmd
6f0220e
Merge pull request #4084
from github/navntoft/bump-undici
- Additional commits viewable in compare
view
Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
Release notes
Sourced from github/codeql-action/analyze's
releases.
v4.37.7
Changelog
Sourced from github/codeql-action/analyze's
changelog.
CodeQL Action Changelog
See the releases
page for the relevant changes to the CodeQL CLI and language
packs.
[UNRELEASED]
No user facing changes.
4.37.7 - 13 Aug 2026
4.37.6 - 04 Aug 2026
- Changed the default filepath for the new remote file address format
that was introduced in CodeQL Action 4.37.0 / 3.37.0 to
.github/codeql-config.yml to align it with the suggested
path that is used elsewhere. #4070
4.37.5 - 03 Aug 2026
- Fixed a bug where a network error while streaming the download of
the CodeQL bundle could terminate the
init Action instead
of falling back to downloading the bundle before extracting it. #4061
4.37.4 - 29 Jul 2026
- This version of the CodeQL Action adds support for the
tools input for the codeql-action/init step to
be specified using a github-codeql-tools repository
property. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to toolcache to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for tools in the workflow definition
always takes precedence unless the value of the repository property
starts with !. #4037
- Update default CodeQL bundle version to 2.26.2.
#4051
4.37.3 - 22 Jul 2026
No user facing changes.
4.37.2 - 21 Jul 2026
- The new address format for the
config-file input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the remote= prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. #4023
- The CodeQL Action can now make use of configured
private registries in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. #4007
4.37.1 - 16 Jul 2026
- Upcoming breaking change: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. #3956
- Update default CodeQL bundle version to 2.26.1.
#4019
4.37.0 - 08 Jul 2026
- Update default CodeQL bundle version to 2.26.0.
#3995
- In addition to the existing input format, the
config-file input for the codeql-action/init
step will soon support a new [owner/]repo[@ref][:path]
format. All components except the repository name are optional. If
omitted, owner defaults to the same owner as the repository
the analysis is running for, ref to main, and
path to .github/codeql-action.yaml. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. #3973
4.36.3 - 01 Jul 2026
No user facing changes.
4.36.2 - 04 Jun 2026
... (truncated)
Commits
ff2f1c6
Merge pull request #4093
from github/update-v4.37.7-be7a3dbb8
951a133
Update changelog for v4.37.7
be7a3db
Merge pull request #4087
from github/dependabot/npm_and_yarn/npm-minor-0aa561...
9310334
Merge pull request #4086
from github/mbg/thread-action-state-to-codeql
b4d8a54
Rebuild
ab5db25
Bump the npm-minor group across 1 directory with 8 updates
38055a3
Drop logger from databaseInitCluster in
interface
1f87aed
Merge pull request #4085
from github/update-bundle/codeql-bundle-v2.26.3
dc1b98a
Make logger available to getCodeQLForCmd
6f0220e
Merge pull request #4084
from github/navntoft/bump-undici
- Additional commits viewable in compare
view
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/cli-go-ci.yml | 2 +-
.github/workflows/cli-go-codeql.yml | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/cli-go-ci.yml b/.github/workflows/cli-go-ci.yml
index 79b8aa19be..0591972abe 100644
--- a/.github/workflows/cli-go-ci.yml
+++ b/.github/workflows/cli-go-ci.yml
@@ -91,7 +91,7 @@ jobs:
with:
persist-credentials: false
- - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4
+ - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4
with:
version: 2026.7.0
install: true
diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml
index 00a2a7b2a7..7e51715cb8 100644
--- a/.github/workflows/cli-go-codeql.yml
+++ b/.github/workflows/cli-go-codeql.yml
@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
+ uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -95,7 +95,7 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
+ uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{matrix.language}}"
defaults:
From 3b4d6ec6500ee55a2e1b70f37ee4867eed22da6b Mon Sep 17 00:00:00 2001
From: Vaibhav <117663341+7ttp@users.noreply.github.com>
Date: Thu, 20 Aug 2026 11:06:54 +0000
Subject: [PATCH 12/63] test(stack): deflake readiness (#6282)
## TL;DR
fixes the flaky stack readiness tests by replacing
the live clock readiness race with deterministic virtual time and an
owned 503 health endpoint....
## ref:
- spotted on: [job
96378991208](https://github.com/supabase/cli/actions/runs/32353950826/job/96378991208)
& [job
95913000393](https://github.com/supabase/cli/actions/runs/32200465815/job/95913000393)
---
packages/stack/src/Stack.unit.test.ts | 62 ++++++++++++++++++++++++---
1 file changed, 57 insertions(+), 5 deletions(-)
diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts
index 1eb8a84283..603a1407de 100644
--- a/packages/stack/src/Stack.unit.test.ts
+++ b/packages/stack/src/Stack.unit.test.ts
@@ -7,6 +7,7 @@ import { readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect";
+import * as TestClock from "effect/testing/TestClock";
import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts";
import { mockBinaryResolver } from "../tests/helpers/mocks.ts";
import { StackBuildError } from "./errors.ts";
@@ -883,13 +884,54 @@ describe("Stack", () => {
}).pipe(Effect.scoped, Effect.timeout("5 seconds")),
);
- it.live("uses the stack readiness deadline for explicit lazy activation and cleans up", () =>
+ it.effect("uses the stack readiness deadline for explicit lazy activation and cleans up", () =>
Effect.gen(function* () {
- const spawner = mockChildProcessSpawner();
+ const authHealthServer = yield* Effect.acquireRelease(
+ Effect.sync(() =>
+ Bun.serve({
+ hostname: "127.0.0.1",
+ port: 0,
+ fetch: () => new Response("unhealthy", { status: 503 }),
+ }),
+ ),
+ (server) => Effect.sync(() => server.stop(true)),
+ );
+ const authPort = authHealthServer.port;
+ if (authPort === undefined) {
+ throw new Error("Expected the auth health test server to bind a TCP port");
+ }
+ const authConfig = defaultConfig.auth;
+ if (authConfig === false) {
+ throw new Error("Expected auth to be enabled in the default test config");
+ }
+ const postgresProbeStarted = yield* Deferred.make();
+ const postgresInitStarted = yield* Deferred.make();
+ const authSpawnStarted = yield* Deferred.make();
+ const spawner = mockChildProcessSpawner({
+ beforeSpawn: (record) => {
+ if (record.command.endsWith("/pg_isready")) {
+ return Deferred.succeed(postgresProbeStarted, undefined).pipe(Effect.asVoid);
+ }
+ if (
+ record.args.some((arg) =>
+ Buffer.from(arg, "base64url").toString().includes('"command":"bash","args":["-c"'),
+ )
+ ) {
+ return Deferred.succeed(postgresInitStarted, undefined).pipe(Effect.asVoid);
+ }
+ return record.args.some((arg) =>
+ Buffer.from(arg, "base64url").toString().includes('"command":"/cache/auth/'),
+ )
+ ? Deferred.succeed(authSpawnStarted, undefined).pipe(Effect.asVoid)
+ : Effect.void;
+ },
+ });
let releasedAll = false;
const config = {
...defaultConfig,
startupMode: "lazy",
+ ports: { ...defaultPorts, authPort },
+ auth: { ...authConfig, port: authPort },
readiness: { mode: "finite", timeoutMs: 100 },
readinessSource: "configured",
} satisfies ResolvedStackConfig;
@@ -904,9 +946,19 @@ describe("Stack", () => {
yield* Effect.gen(function* () {
const stack = yield* Stack;
const activator = yield* StackServiceActivator;
- yield* stack.start();
+ const start = yield* stack.start().pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Deferred.await(postgresProbeStarted);
+ yield* TestClock.adjust("10 millis");
+ yield* Deferred.await(postgresInitStarted);
+ yield* TestClock.adjust("89 millis");
+ yield* Fiber.join(start);
- const error = yield* activator.activate("auth").pipe(Effect.flip);
+ const activation = yield* activator
+ .activate("auth")
+ .pipe(Effect.forkChild({ startImmediately: true }));
+ yield* Deferred.await(authSpawnStarted);
+ yield* TestClock.adjust("100 millis");
+ const error = yield* Fiber.join(activation).pipe(Effect.flip);
expect(error._tag).toBe("StackReadinessError");
if (error._tag === "StackReadinessError") {
@@ -931,7 +983,7 @@ describe("Stack", () => {
yield* stack.stop();
expect(spawner.spawned).toHaveLength(spawnCountAfterDisposal);
}).pipe(Effect.provide(layer));
- }).pipe(Effect.scoped, Effect.timeout("5 seconds")),
+ }).pipe(Effect.scoped),
);
it.live("allows a finite wait override against an infinite stack policy", () =>
From 6aa759615e80c4fca85f35123e5e09fd7ff0d51d Mon Sep 17 00:00:00 2001
From: Andrew Valleteau
Date: Thu, 20 Aug 2026 13:34:05 +0000
Subject: [PATCH 13/63] docs(cli): make the TS legacy shell the source of truth
in agent instructions (#6281)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Updates the repo agent instructions (`AGENTS.md` files and the
delegation-surface doc) to reflect where the CLI actually stands after
the Go→TS migration:
- **`src/legacy/` is the source of truth.** The rewritten
`apps/cli/AGENTS.md` replaces the "Go CLI Authority" framing with a
"Source of Truth" section: the compatibility standard is the legacy
shell's own established behavior (tests, `SIDE_EFFECTS.md`, shipped
output), not comparison against Go. It also explicitly disallows new
comments/docs/helper names framed as "Go parity", with old framing
cleaned up opportunistically.
- **`next/` is frozen and departing.** The shell-architecture section
now documents that `next/` development moves to its own branch and the
folder will leave this tree; no new features land there. Porting-era
guidance built around it ("Reusing next/ implementations") is removed,
and the dual-write file-location rule is noted as leaving with `next/`.
- **Go is a residual delegation surface, not a reference.** "Phase 0: Go
Binary Wrapper" becomes "The Go Delegation Surface": the surface only
shrinks, never grows, and `apps/cli-go/` is authoritative solely for the
still-proxied commands' flag definitions until they are removed.
`docs/go-cli-porting-status.md` and `apps/cli-e2e/AGENTS.md` get
matching one-line reframes.
- **Still-live invariants are kept, de-Go'd.** The parity checklist
becomes "Legacy Shell Invariants" and the telemetry section now treats
`shared/telemetry/event-catalog.ts` as canonical in its own right; all
operational rules (telemetry ensuring, stderr error shape, `--debug` log
format, `-o` vs `--output-format`, CLI-1546 spinner rule) survive
unchanged.
- **`docs/go-cli-divergences.md` is declared a frozen historical
record** — new flags/features are just new CLI behavior and no longer
tracked as divergences.
Docs-only change; no runtime behavior is affected.
## Linked issue
Closes #
- [x] The linked issue is **open** and carries the
`open-for-contribution` label (or I'm a Supabase maintainer).
## Checklist
- [x] The PR title follows [Conventional
Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`).
- [ ] Tests added or updated for the change. (Docs-only — not
applicable.)
- [x] `pnpm check:all` and `pnpm test` pass for the workspace(s) I
touched. (No code touched; no markdown checks in CI.)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5
---
apps/cli-e2e/AGENTS.md | 2 +-
apps/cli/AGENTS.md | 337 +++++++++++--------------
apps/cli/docs/binary-distribution.md | 4 +-
apps/cli/docs/go-cli-divergences.md | 12 +-
apps/cli/docs/go-cli-porting-status.md | 7 +-
5 files changed, 163 insertions(+), 199 deletions(-)
diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md
index a2d36c076a..b309111a1f 100644
--- a/apps/cli-e2e/AGENTS.md
+++ b/apps/cli-e2e/AGENTS.md
@@ -200,7 +200,7 @@ is a single-job operation; parallel shards would race on the shared
## Go binary version requirement
-The ts-legacy CLI proxies a fixed, small set of commands to a Go binary (`SUPABASE_GO_BINARY` → bundled package binary → system `supabase`) — as of CLI-1970, `apps/cli-go/` contains only that residual proxied subset, nothing else. If your system `supabase` binary predates a flag or subcommand change on one of these, `testBehaviour` tests for it will fail with "unknown command" or "unknown flag".
+The ts-legacy CLI proxies a fixed, small set of commands to a Go binary (`SUPABASE_GO_BINARY` → bundled package binary → system `supabase`) — as of CLI-1970, `apps/cli-go/` contains only that residual proxied subset, nothing else, and it is slated for cleanup and removal (the surface only shrinks; never add tests that grow it). If your system `supabase` binary predates a flag or subcommand change on one of these, `testBehaviour` tests for it will fail with "unknown command" or "unknown flag".
Build the Go CLI from source and point `SUPABASE_GO_BINARY` at it:
diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md
index d9fe136e1d..4b32693b00 100644
--- a/apps/cli/AGENTS.md
+++ b/apps/cli/AGENTS.md
@@ -10,16 +10,21 @@ There are three source trees under `src/`:
```
src/
-├── next/ # New CLI experience (v3 / alpha channel) — do not modify when porting legacy commands
-├── legacy/ # Strict 1:1 TypeScript port of the Go CLI (stable channel)
+├── legacy/ # The stable Supabase CLI — the authoritative implementation
+├── next/ # Experimental v3 shell — frozen; moving to its own branch, will leave this tree
└── shared/ # Cross-cutting primitives used by both shells
```
+The names are historical: `legacy/` started as the TypeScript port of the old Go CLI and is now the
+main, stable version of the CLI. `next/` is the experimental v3 experience; its development is
+moving to a dedicated branch, and the folder will be removed from this tree. **Do not add features
+to `next/`** — new work lands in `legacy/` (or `shared/`).
+
### Isolation rules
- `next/` and `legacy/` **cannot import each other**. Command trees are fully isolated.
- Both shells import freely from `shared/`.
-- **All exported tokens from `legacy/` must be prefixed with `Legacy` or `legacy`** (no exceptions — see naming section below). This prevents IDE auto-complete from suggesting legacy-only exports when working in `next/` and removes ambiguity at import sites.
+- **All exported tokens from `legacy/` must be prefixed with `Legacy` or `legacy`** (no exceptions — see naming section below). This removes ambiguity at import sites and keeps the two in-tree shells from bleeding into each other while both exist.
### Entry points
@@ -34,32 +39,27 @@ Both call `runCli(root)` from `shared/cli/run.ts`.
---
-## Legacy Port Status and Go CLI Authority
-
-`src/legacy/` started as a from-scratch 1:1 port of the Go CLI (`apps/cli-go/`) and the port is now
-complete: every legacy leaf command is natively ported except the delegation surface documented in
-[`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md). As of CLI-1970, `apps/cli-go/`
-itself contains only that residual delegation surface — Go source for every other command was
-deleted outright once nothing in the TypeScript CLI could reach it, directly or indirectly. For any
-command whose Go source no longer exists in-tree, the authoritative parity reference is the last
-commit with it intact: `7b469f5b3`.
-
-This changes what "Go CLI is authoritative" means day to day. `apps/cli-go/` is required reading
-only when:
-
-- Working on one of the remaining Phase 0 wrapped commands — maintaining its command/flag
- definition and proxy handler (these gate which invocations reach the Go binary and must still
- match it exactly) or replacing the wrapper with a native implementation, or
-- Changing something on an already-ported command's established parity surface: command/flag
- names, stdout/stderr text, exit codes, all documented side effects (filesystem, database,
- Docker/subprocess, API requests), or telemetry semantics (which events fire, when, and their
- payload shape).
-
-For everything else in `src/legacy/` — bug fixes that don't touch that surface, internal refactors,
-hoisting shared helpers, adding a documented TS-only flag/feature, tests, tooling — treat it like any
-other TypeScript workspace. Go behavior is not the deciding standard, and there's no need to consult
-`apps/cli-go/`. See [ADR 0016](../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md)
-for the full rationale.
+## Source of Truth
+
+The Go→TypeScript port is **complete**. `src/legacy/` is the source of truth for the Supabase CLI's
+behavior. The old Go CLI (`apps/cli-go/`) is **not** a reference anymore: it survives only as the
+residual delegation surface documented in
+[`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md), and everything Go-related is
+slated for cleanup and removal.
+
+What this means in practice:
+
+- The compatibility standard for any change is the legacy shell's **own established behavior** —
+ its tests, its `SIDE_EFFECTS.md` files, and its shipped output — not a comparison against Go.
+- Consult `apps/cli-go/` only when maintaining one of the still-proxied commands (their flag
+ definitions must keep matching the Go binary they forward to).
+- **Do not write new comments, doc sections, or helper names framed as "Go parity" or "matches
+ Go".** Describe behavior in its own terms. When touching code that carries old Go-parity
+ framing, clean it up opportunistically.
+- For history: Go source for ported commands was deleted in CLI-1970; the last commit with it
+ intact is `7b469f5b3` (`internal/start` was deleted separately in CLI-1966; its pin is
+ `a253ccba2`). [ADR 0016](../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md)
+ records the earlier transition policy; this section supersedes its day-to-day guidance.
---
@@ -75,11 +75,6 @@ Use this for learning more about the library, rather than browsing the code in
Use **`Effect.fn`** for top-level exported command handlers — tracing is desired. In the legacy shell, prefix the trace name with `legacy.` to distinguish legacy spans from `next/` spans in traces:
```ts
-// next/ handler
-export const create = Effect.fn("branches.create")(function* (flags: CreateFlags) {
- // ...
-});
-
// legacy/ handler — note the legacy. prefix in the trace name
export const legacyCreate = Effect.fn("legacy.branches.create")(function* (
flags: LegacyCreateFlags,
@@ -102,7 +97,7 @@ Do not use `as` casts to paper over Effect or CLI typing issues. Fix the type re
## Shared Code
-Always check `src/shared/` before writing new infrastructure. Do not duplicate what already exists there or in `next/`.
+Always check `src/shared/` before writing new infrastructure. Do not duplicate what already exists there.
| Path | What it provides |
| -------------------------------------- | ------------------------------------------------------------------------------- |
@@ -119,37 +114,32 @@ Always check `src/shared/` before writing new infrastructure. Do not duplicate w
Also check the following `legacy/` infrastructure before writing equivalent helpers from scratch:
-| Path | What it provides |
-| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` |
-| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → `supabase/.temp/project-ref` file → prompt; matches Go's resolver order |
-| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` |
-| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `/supabase/.temp/linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) |
-| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger in Go's `log.LstdFlags` format |
-| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — byte-exact ASCII match for Go's `glamour.RenderTable(..., AsciiStyle)` |
-| `legacy/shared/legacy-upgrade-notice.ts` | `legacyUpgradeNoticeHook` — post-success upgrade notice (Go `checkUpgrade` parity: GitHub latest-release fetch, 10h `supabase/.temp/cli-latest` cache, `SUPABASE_NO_UPDATE_NOTIFIER` opt-out) |
+| Path | What it provides |
+| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `legacy/config/legacy-cli-config.layer.ts` | `LegacyCliConfig` — resolves `SUPABASE_PROFILE` (built-in name **or** YAML file path), `--workdir`, `--experimental`, project-id from `supabase/config.toml` |
+| `legacy/config/legacy-project-ref.layer.ts` | `LegacyProjectRefResolver` — `--project-ref` flag → env → `supabase/.temp/project-ref` file → prompt |
+| `legacy/telemetry/legacy-telemetry-state.layer.ts` | `LegacyTelemetryState.flush` — writes `~/.supabase/telemetry.json`, runs in every command's `Effect.ensuring` |
+| `legacy/telemetry/legacy-linked-project-cache.layer.ts` | `LegacyLinkedProjectCache.cache(ref)` — writes `/supabase/.temp/linked-project.json` after `--project-ref` resolves; bypasses generated schema validation (uses raw HTTP client) |
+| `legacy/auth/legacy-http-debug.layer.ts` | `legacyHttpClientLayer` — wraps the HTTP transport with a `--debug` stderr logger (`log.LstdFlags`-style timestamp format) |
+| `legacy/output/legacy-glamour-table.ts` | `renderGlamourTable(headers, rows)` — the CLI's established ASCII table format |
+| `legacy/shared/legacy-upgrade-notice.ts` | `legacyUpgradeNoticeHook` — post-success upgrade notice (GitHub latest-release fetch, 10h `supabase/.temp/cli-latest` cache, `SUPABASE_NO_UPDATE_NOTIFIER` opt-out) |
---
-## Phase 0: Go Binary Wrapper
+## The Go Delegation Surface
-This phase is now the exception, not the default: only the commands in the
-[delegation surface table](./docs/go-cli-porting-status.md#the-delegation-surface)
-still need it. A Go CLI command that exists in `apps/cli-go/` but has no TS surface yet is rare at
-this point in the port; when one does show up, the first step is to **wrap** it: define the command
-in the TS command tree and proxy all invocations to the bundled Go binary via subprocess. Wrapping
-only works for a command the Go CLI already implements — there is nothing to proxy to otherwise. A
-genuinely TS-only addition with no Go equivalent (for example a TS-only flag on an already-ported
-command, per the "Flag divergences from the Go reference" list in
-[`docs/go-cli-divergences.md`](./docs/go-cli-divergences.md)) is implemented natively and does
-not go through Phase 0 at all.
+A small, fixed set of commands still proxies to a bundled Go binary — the full list lives in
+[`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md). This surface only shrinks:
+**never add a new command or flag path that delegates to the Go binary.** The direction of travel
+is removing these remnants, not extending them.
-### Proxy handler pattern
+While a proxied command exists, its TS command/flag definition gates which invocations reach the
+Go binary and must keep matching that binary exactly — this is the one remaining case where
+`apps/cli-go/` is authoritative.
-A proxy handler passes argv through to the Go binary, forwarding stdin/stdout/stderr and propagating the exit code. Use the shared `LegacyGoProxy` service:
+A proxy handler passes argv through to the Go binary, forwarding stdin/stdout/stderr and propagating the exit code, via the shared `LegacyGoProxy` service:
```ts
-// src/legacy/commands/orgs/list/list.handler.ts (Phase 0 proxy)
export const legacyOrgsList = Effect.fn("legacy.orgs.list")(function* (
_flags: LegacyOrgsListFlags,
) {
@@ -158,24 +148,21 @@ export const legacyOrgsList = Effect.fn("legacy.orgs.list")(function* (
});
```
-### When wrapping a command
-
-For each command added to the Phase 0 wrapper, complete all three steps:
-
-1. **Reconstruct the command definition** — flags, subcommands, and argument types must exactly match the Go CLI (use `apps/cli-go/` as the reference).
-2. **Write a proxy handler** — forward invocations to the Go binary via `LegacyGoProxy`.
-3. **Update `docs/go-cli-porting-status.md`** — mark the command as `wrapped`.
+Shrinking the surface means replacing a wrapper with a **native TS implementation** — it does not
+license removing the user-facing command. Several wrapped commands are retained indefinitely
+precisely because dropping them was ruled a breaking change (CLI-1964; see the "Why it stays"
+column in the delegation table). Deleting a public command path is a product decision, never a
+cleanup task.
-### When porting a command (Phase 1+)
+When replacing a wrapper natively:
-When replacing a proxy handler with a native TS implementation:
-
-1. Implement the business logic in `.handler.ts` using Effect services (see Legacy Port sections below).
-2. Update `docs/go-cli-porting-status.md` — mark the command as `ported`.
+1. Implement the business logic in `.handler.ts` using Effect services (see the sections below).
+2. Reproduce the old behavior (output, side effects, telemetry) from the **current in-tree Go source** in `apps/cli-go/` — that is the shipped implementation, and it can differ from the pre-trim snapshot (e.g. the `db remote --password` precedence entry in `docs/go-cli-divergences.md`). The pinned commits (`7b469f5b3`, `a253ccba2`) are only for commands whose Go source was already deleted.
+3. Update `docs/go-cli-porting-status.md` — the delegation surface table must stay accurate.
---
-## Legacy Port: File Structure and Naming
+## File Structure and Naming
### Directory layout
@@ -184,12 +171,12 @@ One directory per top-level command under `src/legacy/commands/`:
```
src/legacy/commands//
.command.ts # Effect CLI Command definition, flag wiring, layer provision
- .handler.ts # Phase 0: proxy handler. Phase 1+: native Effect implementation
- .errors.ts # Domain error types (Data.TaggedError) — add when porting
+ .handler.ts # native Effect implementation (or residual Go proxy)
+ .errors.ts # Domain error types (Data.TaggedError)
SIDE_EFFECTS.md # Required for every legacy command — see section below
```
-When a command grows beyond a single handler file, follow the optional helper-file shape that emerged from the backups port:
+When a command grows beyond a single handler file, follow the optional helper-file shape:
```
src/legacy/commands//
@@ -198,12 +185,12 @@ src/legacy/commands//
.errors.ts # Data.TaggedError types
.layers.ts # runtime layer composition for the command family
.format.ts # text formatters (timestamps, regions, booleans)
- .encoders.ts # Go-compatible JSON / YAML / TOML / env encoders
- .go-payload.ts # Go struct specs mirroring types.gen.go — drive `-o yaml|toml` key casing (CLI-1975)
+ .encoders.ts # machine-format encoders (JSON / YAML / TOML / env)
+ .go-payload.ts # struct specs that drive `-o yaml|toml` key casing (CLI-1975)
SIDE_EFFECTS.md
```
-The `.format.ts` and `.encoders.ts` files should be pure functions with no Effect or service dependencies — that keeps them unit-testable and makes Go-parity rules explicit (e.g. JSON key sort order, env-var SCREAMING_SNAKE_CASE flattening, empty arrays coerced to null).
+The `.format.ts` and `.encoders.ts` files should be pure functions with no Effect or service dependencies — that keeps them unit-testable and makes encoding rules explicit (e.g. JSON key sort order, env-var SCREAMING_SNAKE_CASE flattening, empty arrays coerced to null). The `*.go-payload.ts` struct specs are now the canonical definition of `-o yaml|toml` key casing — they are no longer re-synced from any Go source.
Commands with subcommands use nested directories:
@@ -234,7 +221,7 @@ export const legacyRoot = Command.make("supabase").pipe(
### Mandatory `Legacy`/`legacy` prefix on all exports
-Every exported token from a `legacy/` file must carry the `Legacy` (PascalCase) or `legacy` (camelCase/kebab) prefix — no exceptions, even for symbols that are only used within `legacy/`. This makes the constraint unconditional and prevents auto-complete pollution in `next/`:
+Every exported token from a `legacy/` file must carry the `Legacy` (PascalCase) or `legacy` (camelCase/kebab) prefix — no exceptions, even for symbols that are only used within `legacy/`:
| Export kind | Convention |
| ------------------------------ | ----------------------------------------------------------- |
@@ -248,150 +235,135 @@ Every exported token from a `legacy/` file must carry the `Legacy` (PascalCase)
Do **not** export a bare `create` or `branchesCommand` from a `legacy/` file.
-### Reusing `next/` implementations
-
-Many Management API commands in `next/commands/` have already been implemented. The handler logic is Effect-based and shell-agnostic. **Check `next/commands/` before writing a handler from scratch.** You can often copy a handler file verbatim and:
-
-1. Rename the exported function (add `legacy` prefix)
-2. Adjust the trace name to `legacy..`
-3. Fix import paths (`../../shared/` → `../../../shared/`, etc.)
-
---
-## Legacy Port: Hoist Before You Duplicate
+## Hoist Before You Duplicate
-Before writing handler code for a new port, scan the already-ported commands for overlapping logic. If two commands need the same helper (HTTP-error mapping, output encoder, formatter, runtime layer composition), hoist it instead of inlining a copy.
+Before writing handler code for a new command, scan the existing commands for overlapping logic. If two commands need the same helper (HTTP-error mapping, output encoder, formatter, runtime layer composition), hoist it instead of inlining a copy.
Decision rule:
- **Used by one command only** → keep it in the command's own directory (e.g. `backups/backups.errors.ts`).
- **Used by ≥2 commands in the same command family** → keep it in the family root (e.g. `backups/backups.encoders.ts` is shared by `list` and `restore`).
-- **Used by ≥2 commands across families** → hoist to `src/legacy/shared/` (create the directory if it doesn't exist) and refactor the existing call sites in the same change. Do not leave the older command using its inlined copy while the new command uses the hoisted version.
+- **Used by ≥2 commands across families** → hoist to `src/legacy/shared/` and refactor the existing call sites in the same change. Do not leave the older command using its inlined copy while the new command uses the hoisted version.
-Concrete examples worth watching for as more commands land:
+Concrete examples worth watching for:
-- HTTP-error → tagged-error mapping (`backups.errors.ts:mapLegacyBackupHttpError`) — almost every Management API command will need this shape.
-- Go-compatible JSON / YAML / TOML / env encoders (`backups.encoders.ts`) — the flag `--output {json,yaml,toml,env}` is supported by many Go subcommands.
-- Glamour-table rendering helpers and column padding — currently in `legacy/output/legacy-glamour-table.ts`, already correctly hoisted.
-- Timestamp / region / boolean formatters (`backups.format.ts`) — likely shared the moment a second command renders a backup/project/region field.
+- HTTP-error → tagged-error mapping (`backups.errors.ts:mapLegacyBackupHttpError`) — almost every Management API command needs this shape.
+- Machine-format encoders (`backups.encoders.ts`) — the `--output {json,yaml,toml,env}` flag is supported by many subcommands.
+- Glamour-table rendering helpers and column padding — in `legacy/output/legacy-glamour-table.ts`, already correctly hoisted.
+- Timestamp / region / boolean formatters (`backups.format.ts`) — shared the moment a second command renders a backup/project/region field.
-This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor") — it just makes the policy concrete for the legacy-port workflow.
+This rule is consistent with the repo-wide **Refactoring Policy** ("delete obsolete helpers, shims, and parallel code paths as part of the refactor").
-### `Config.Validate` parity has one home
+### Config validation has one home
-Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go:989-1190`) is ported exactly once: `src/legacy/shared/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a Go validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share.
+Config validation is implemented exactly once: `src/legacy/shared/legacy-config-validate.ts` (`legacyValidateResolvedConfig`). Both the db/migration loader (`legacy-db-config.toml-read.ts`) and the status/stop resolver (`legacy-local-config-values.ts`) build a `LegacyConfigValidationInput` from their own pipelines and call it — do not add per-command reimplementations of these checks. When a validation branch or message changes, change it there. `legacy-config-validate.parity.unit.test.ts` feeds the same broken configs through both real pipelines and asserts identical error strings; extend it when adding a branch both callers share.
---
-## Legacy Port: Go CLI Output Parity
+## Behavioral Stability Contract
-The legacy shell is a **strict 1:1 port** — not a redesign. The compatibility contract covers:
+The legacy shell is the stable CLI millions of scripts and CI pipelines depend on. Its established
+surface is a compatibility contract:
-- Same command paths and flag names
-- Same stdout/stderr text, including spacing, casing, and newlines
-- Same filesystem side effects (files read and written)
-- Same API routes and request shapes
-- Same exit codes
+- Command paths and flag names
+- stdout/stderr text, including spacing, casing, and newlines
+- Filesystem side effects (files read and written)
+- API routes and request shapes
+- Exit codes
+- Telemetry semantics (which events fire, when, and their payload shape)
-When in doubt about expected output or behavior, run the equivalent command against the Go CLI reference at `apps/cli-go/` and match it exactly.
+The standard for "established behavior" is the shell's own tests, each command's
+`SIDE_EFFECTS.md`, and what current releases actually emit. Do not change this surface casually;
+when a change is intentional, update the tests and `SIDE_EFFECTS.md` in the same change.
-This contract governs behavior a command has already established — it does not mean every change in
-`src/legacy/` requires consulting Go. It applies when working on one of the remaining Phase 0
-wrapped commands (its command/flag definition or its native replacement), or changing an
-already-ported command in a way that could affect the surface above — which, per each command's
-`SIDE_EFFECTS.md`, also includes database mutations and Docker/subprocess behavior, and per the
-Telemetry Parity section below also includes which events fire and when, not just payload shape. It
-does not apply to internal refactors, TS-only additions, or bug fixes that leave that surface
-unchanged — see [Legacy Port Status and Go CLI Authority](#legacy-port-status-and-go-cli-authority).
+This contract does not constrain internal refactors, new flags/features, or bug fixes that leave
+the established surface unchanged — treat those like any other TypeScript workspace.
---
-## Legacy Port: Go Parity Checklist
+## Legacy Shell Invariants
-When porting a Management-API-style command, verify each item before marking the command as `ported`:
+Verify each applicable item when adding or reworking a command:
-1. **Telemetry + linked-project writes run on every invocation** — Go uses `PersistentPostRun` (see `apps/cli-go/cmd/root.go:176`). Wrap the handler body in `.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush))` so both files are written on success **and** failure. See `backups/list/list.handler.ts:74-114` as the canonical pattern.
+1. **Telemetry + linked-project writes run on every invocation** — wrap the handler body in `.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush))` so both files are written on success **and** failure. See `backups/list/list.handler.ts:74-114` as the canonical pattern.
-2. **Errors go to stderr in text mode, byte-matching Go's template** — `Output.fail` now writes a frame-free message to stderr followed by the "Try rerunning the command with --debug to get more details." suggestion when `--debug` is unset. Don't reintroduce clack's `■ … │` frame. Reference: commits `ee041834`, `cf4f574b`.
+2. **Errors go to stderr in text mode** — `Output.fail` writes a frame-free message to stderr followed by the "Try rerunning the command with --debug to get more details." suggestion when `--debug` is unset. Don't reintroduce clack's `■ … │` frame. Reference: commits `ee041834`, `cf4f574b`.
-3. **`--debug` logs every HTTP request on stderr** — Format `"HTTP YYYY/MM/DD HH:MM:SS : \n"` (Go's `log.LstdFlags|log.Lmsgprefix`). Provided automatically by `legacyHttpClientLayer`; ensure that layer (not the raw `HttpClient.layer`) is what every legacy command's runtime composes. Reference: commit `39cfec20`.
+3. **`--debug` logs every HTTP request on stderr** — format `"HTTP YYYY/MM/DD HH:MM:SS : \n"`. Provided automatically by `legacyHttpClientLayer`; ensure that layer (not the raw `HttpClient.layer`) is what every legacy command's runtime composes. Reference: commit `39cfec20`.
4. **`SUPABASE_PROFILE` is dual-mode** — accept either a built-in name (`supabase`, `supabase-staging`, `supabase-local`) **or** a filesystem path to a YAML file with `api_url:` / `gotrue_url:` / `db_url:` keys. cli-e2e harness relies on the file-path mode. Reference: commit `288c2937`.
5. **`Layer.provide` does not share to siblings inside `Layer.mergeAll`** — if two sibling layers each require `LegacyCliConfig`, provide it to both explicitly. Smoke-test the bundled binary (`bun run build && ./dist/supabase-legacy …`) when changing production layer wiring; in-process tests don't always catch the missing-service panic. Reference: commit `a816b12e`, `backups.layers.ts:32-46`.
-6. **Both `--output` (Go) and `--output-format` (TS) must be honored** — Go's `--output` (`pretty|json|yaml|toml|env`) takes priority when set. Pattern in `backups/list/list.handler.ts:85-113`: branch on `goOutputFlag` first, then fall through to TS `--output-format` text/json/stream-json.
-
-7. **PostHog telemetry payload matches Go 1:1** — see the next section.
+6. **Both `--output` (legacy machine formats) and `--output-format` must be honored** — `--output` (`pretty|json|yaml|toml|env`) takes priority when set. Pattern in `backups/list/list.handler.ts:85-113`: branch on the `--output` flag first, then fall through to `--output-format` text/json/stream-json.
-8. **Go API type regen re-syncs `*.go-payload.ts` specs** — when `apps/cli-go/pkg/api/types.gen.go` regenerates, re-audit every `*.go-payload.ts`/inline `LegacyGoType` struct spec that mirrors it (field order, JSON/Go name pairs); nothing checks this mechanically today (CLI-1975, review kanadgupta).
+7. **Telemetry follows the established catalog and payload shapes** — see the next section.
---
-## Legacy Port: Telemetry Parity
+## Telemetry
-The legacy shell sends the same PostHog events to the same product analytics pipeline as the Go CLI. Drift is silent (no test will catch it) and breaks dashboards. The rules:
+The legacy shell sends PostHog events to the product analytics pipeline. Drift is silent (no test will catch it) and breaks dashboards. The rules:
-- **The canonical catalog is `shared/telemetry/event-catalog.ts`** — a 1:1 mirror of `apps/cli-go/internal/telemetry/events.go`. Reference its exported constants (`EventCommandExecuted`, `PropFlags`, `EnvSignalPresenceKeys`, …) instead of writing bare strings. When the Go catalog changes, update the TS catalog in the same PR.
-- **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits Go-shape properties: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`.
-- **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""` to match Go.
-- **Use `safeFlags: ["flag-name"]`** to whitelist flags that Go marks with `markFlagTelemetrySafe` (historically found by grepping `apps/cli-go/cmd/*.go`). As of CLI-1970, only the retained commands' `cmd/*.go` files still exist in-tree (`db.go`, `functions.go`, `gen.go`, `root*.go`); the flags below came from `sso.go`, `branches.go`, `link.go`, and `projects.go`, all deleted once their commands went fully native — re-grep those at commit `7b469f5b3` if you need to re-derive this list. Today these are `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash).
-- **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe, mirroring Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks `flag.Value.(*utils.EnumFlag)` unconditionally — no per-flag `safeFlags` entry needed, and it stays correct as choices are added or removed. A command's own `config` only ever contains its own locally-declared flags, so this cannot cover the 3 global choice flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) — those are handled separately, see below.
-- **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record, mirroring Go's `changedFlags()` walking `cmd.Parent()`'s `PersistentFlags()` (`cmd/root_analytics.go:53-76`). No per-command wiring needed. This gives two flag families their real value automatically, via the existing boolean-is-safe rule and a new choice-is-safe rule (`GLOBAL_CHOICE_FLAG_NAMES` — CLI-1904) respectively:
+- **The canonical catalog is `shared/telemetry/event-catalog.ts`.** Reference its exported constants (`EventCommandExecuted`, `PropFlags`, `EnvSignalPresenceKeys`, …) instead of writing bare strings. The TS catalog is the source of truth for event names and property keys.
+- **Native legacy commands wrap with `withLegacyCommandInstrumentation`** (from `legacy/telemetry/legacy-command-instrumentation.ts`) — _not_ the shared `withCommandInstrumentation`. The legacy variant emits the established property shape: a single `flags` map (vs `flags_used`/`flag_values`), `is_agent: boolean` (vs `ai_tool: string`), and `env_signals`.
+- **Pass `flags` to the wrapper** so boolean flag values can be detected and logged verbatim: `handler(flags).pipe(withLegacyCommandInstrumentation({ flags }), ...)`. Sensitive values become the literal string `""`.
+- **Use `safeFlags: ["flag-name"]`** to whitelist flags whose values are safe to log verbatim. The established list: `--project-ref` (sso, branches, link, functions, projects/api-keys), `--project-id` (gen/types), `--org-id` (projects/create), and `--version` (migration/squash). Extend it only for flags whose values carry no user data.
+- **Pass `config` (the command's own flag config record) to the wrapper** if it has any `Flag.choice`/`Flag.choiceWithValue` flags: `withLegacyCommandInstrumentation({ flags, config })`. Every choice flag declared in that command's own `config` is auto-detected and treated as safe — closed enums carry no user data — and it stays correct as choices are added or removed. A command's own `config` only ever contains its own locally-declared flags, so this cannot cover the 3 global choice flags (`--output`, `--dns-resolver`, `--agent` in `shared/legacy/global-flags.ts`) — those are handled separately, see below.
+- **Global/persistent flags (`shared/legacy/global-flags.ts`) resolve automatically** — the wrapper reads `legacyGlobalFlagValues` (via `Effect.serviceOption`, so it's a no-op outside the real CLI tree) and falls back to it whenever a changed flag name isn't in the handler's own `flags` record. No per-command wiring needed. This gives two flag families their real value automatically, via the boolean-is-safe rule and the choice-is-safe rule (`GLOBAL_CHOICE_FLAG_NAMES` — CLI-1904) respectively:
- Boolean globals: `--debug`, `--yes`, `--experimental`, `--create-ticket`.
- Choice globals: `--output`, `--dns-resolver`, `--agent`.
- Both rules apply ONLY when a command's own `flags` record doesn't already declare that CLI name — a command's own flag always wins. Example: `db diff` declares its own local `output: Flag.string("output")` (a file path, not a choice) in its `flags` record, so `db diff --output diff.sql` stays redacted — matching Go, where `isEnumFlag` type-asserts `db diff`'s own non-enum local flag object instead of root's persistent `*utils.EnumFlag`.
+ Both rules apply ONLY when a command's own `flags` record doesn't already declare that CLI name — a command's own flag always wins. Example: `db diff` declares its own local `output: Flag.string("output")` (a file path, not a choice) in its `flags` record, so `db diff --output diff.sql` stays redacted.
- **Proxy handlers (`LegacyGoProxy.exec`) must NOT wrap with any instrumentation.** The Go subprocess fires its own telemetry; a TS wrapper would double-count `cli_command_executed`.
-- **When promoting a command from proxy to native, reproduce every `phtelemetry.*` call in the Go counterpart.** Grep `apps/cli-go/internal//` for `service.Capture`, `service.Alias`, `service.Identify`, `service.GroupIdentify`, and `TrackUpgradeSuggested` — note that most `internal//` packages were deleted in CLI-1970 once their commands went fully native, so this grep only finds something for the still-`wrapped` commands; check out commit `7b469f5b3` to grep an already-ported command's former Go source. The current Go custom events that legacy ports must reproduce when natively ported (already captured below, so this is only needed for a command not yet in this table):
+- **Custom events are established behavior — do not drop, rename, or reshape them.** Beyond `cli_command_executed`, the legacy shell fires:
- | Command | Event | Identity / groups | Go source |
- | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
- | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` (deleted in CLI-1970; last present at commit 7b469f5b3) |
- | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` (deleted in CLI-1970; last present at commit 7b469f5b3) |
- | `start` | `cli_stack_started` | none — fired after stack health check passes | formerly `internal/start/start.go:1245` (deleted as unreachable in CLI-1966; last present at commit a253ccba2) |
- | `sso/{list,create,update,remove}`, `branches/{create,update}`, `hostnames/{create,activate,get,reverify}`, `vanity_subdomains/{activate,get}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch (`SuggestUpgradeOnError` is envelope-first; hostnames + vanity get are envelope-only) | call-sites under `internal/{sso,branches,hostnames,vanity_subdomains}/` (deleted in CLI-1970; last present at commit 7b469f5b3) |
+ | Command | Event | Identity / groups |
+ | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+ | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists |
+ | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write |
+ | `start` | `cli_stack_started` | none — fired after stack health check passes |
+ | `sso/{list,create,update,remove}`, `branches/{create,update}`, `hostnames/{create,activate,get,reverify}`, `vanity_subdomains/{activate,get}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch (envelope-first; hostnames + vanity get envelope-only) |
- Reference pattern for login: `next/commands/login/login.handler.ts:38-62`.
+ See `legacy/commands/login/` (handler + `SIDE_EFFECTS.md`) for the reference pattern.
- TS-only extension to the `link` row (CLI-2167, no Go counterpart): when `link` resolves a
- branch name/UUID (`[ref-or-branch]` positional or `--project-ref`) to its project ref, the
- legacy shell additionally fires `cli_project_linked` with `linked_via: "branch"` and
- `parent_project_ref` set, plus a `project` group association (no `groupIdentify` call, since
- no org/name metadata exists for a branch).
+ `link` extension (CLI-2167): when `link` resolves a branch name/UUID (`[ref-or-branch]`
+ positional or `--project-ref`) to its project ref, it additionally fires `cli_project_linked`
+ with `linked_via: "branch"` and `parent_project_ref` set, plus a `project` group association (no
+ `groupIdentify` call, since no org/name metadata exists for a branch).
-- **Tracing layer is local-only observability**, not PostHog. Span names (`legacy..`) and the NDJSON exporter never leave the user's machine. No parity implication.
+- **Tracing layer is local-only observability**, not PostHog. Span names (`legacy..`) and the NDJSON exporter never leave the user's machine. No compatibility implication.
---
-## Legacy Port: File Location Compatibility
+## File Location Compatibility
-The legacy shell bridges two worlds: it must behave exactly like the Go CLI for existing users, and it must lay the groundwork for a seamless upgrade to the next shell.
+The CLI's on-disk state locations are part of the compatibility contract: existing scripts,
+dotfiles, and tooling depend on the exact paths the stable CLI has always used (`~/.supabase/…`,
+`/supabase/.temp/…`, native keyring entries). Do not move or rename these files.
-**Dual write requirement:** Where a legacy command writes state to disk, it must write to **both**:
-
-1. **The Go CLI paths** — the exact file locations the Go CLI already uses, so existing scripts, dotfiles, and tooling that depend on those paths continue to work.
-2. **The `next/` paths** — the file locations that `next/` services and layers expect to read, so a user who upgrades to the next experience finds their state already in place.
-
-When these two sets of paths are the same (they often are via shared services), no extra work is needed. When they differ, the legacy handler must write to both.
-
-**Corollary:** When a `next/` service or layer changes where or how it reads or writes a file, the author must verify that the corresponding legacy command still produces files at the updated location and update it if necessary before merging. This check is required even when file I/O goes through a shared service — confirm the shared service covers both paths.
+While `next/` remains in this tree, a legacy command that writes state must also write to the
+locations `next/` services expect to read (when they differ — they are often the same via shared
+services), so state stays portable. This dual-write obligation leaves with `next/` when it moves
+to its own branch.
---
-## Legacy Port: Side-effect Documentation
+## Side-effect Documentation
`SIDE_EFFECTS.md` is a **legacy-only artifact**. Do not create these files in `next/`.
-Every legacy command port must include a `SIDE_EFFECTS.md` in its command directory covering:
+Every legacy command must have a `SIDE_EFFECTS.md` in its command directory covering:
- **Files read and written** — exact paths (with `~/` or CWD-relative notation), format, when
- **API routes called** — method, path, request body shape, response shape
- **Environment variables consumed**
- **Exit codes** — including error conditions
-Use the template at `src/legacy/SIDE_EFFECTS_TEMPLATE.md`. This document is the compatibility checklist for the port and the primary input to the E2E test suite.
+Use the template at `src/legacy/SIDE_EFFECTS_TEMPLATE.md`. This document is the command's compatibility checklist and the primary input to the E2E test suite. Keep it accurate when changing a command's behavior.
---
@@ -466,15 +438,15 @@ yield * creating.succeed("Branch created");
### Invariant: `-o json|yaml|toml|env` must suppress the spinner (CLI-1546)
-The Go-compat `-o`/`--output` flag (`LegacyOutputFlag`, values `env|pretty|json|toml|yaml`) is **independent** of `--output-format`. It does not change `output.format`, so a command run with `-o json` (and no `--output-format`) keeps `output.format === "text"` and the spinner gate `output.format === "text"` stays `true`. If the plain `textOutputLayer` is active, clack writes spinner ANSI (e.g. the hide-cursor `\x1b[?25l`) to **stdout** and corrupts the machine payload the handler emits via `output.raw` — exactly the CLI-1546 regression (`branches list -o json` → broken `JSON.parse`).
+The legacy machine-format `-o`/`--output` flag (`LegacyOutputFlag`, values `env|pretty|json|toml|yaml`) is **independent** of `--output-format`. It does not change `output.format`, so a command run with `-o json` (and no `--output-format`) keeps `output.format === "text"` and the spinner gate `output.format === "text"` stays `true`. If the plain `textOutputLayer` is active, clack writes spinner ANSI (e.g. the hide-cursor `\x1b[?25l`) to **stdout** and corrupts the machine payload the handler emits via `output.raw` — exactly the CLI-1546 regression (`branches list -o json` → broken `JSON.parse`).
-`legacy/cli/root.ts` therefore selects **`legacyQuietProgressTextOutputLayer`** (in `legacy/output/`) for any Go machine format (`json|yaml|toml|env`). It is a legacy-only wrapper over the shared `textOutputLayer` that no-ops only `task` and `progress`; everything else — `format: "text"`, `raw`, logs, and error rendering (red text on **stderr**) — delegates unchanged, so Go output parity is preserved exactly.
+`legacy/cli/root.ts` therefore selects **`legacyQuietProgressTextOutputLayer`** (in `legacy/output/`) for any machine format (`json|yaml|toml|env`). It is a legacy-only wrapper over the shared `textOutputLayer` that no-ops only `task` and `progress`; everything else — `format: "text"`, `raw`, logs, and error rendering (red text on **stderr**) — delegates unchanged, so established output stays byte-identical.
Rules:
- **stdout is payload-only whenever a machine format is requested** (`-o json|yaml|toml|env` or `--output-format json|stream-json`). All progress/diagnostic output goes to stderr.
- **Do not** fix spinner-on-stdout by routing the shared spinner to stderr or otherwise editing `shared/output/output.layer.ts` — that changes `next/` text rendering. Keep the fix legacy-scoped.
-- A handler reaching this path still emits its machine payload through the Go encoder (`output.raw(encodeGoJson(...))` etc.), checked **before** the `output.format` branch, so output stays byte-identical to before — minus the spinner.
+- A handler reaching this path still emits its machine payload through the established encoders (`output.raw(encodeGoJson(...))` etc.), checked **before** the `output.format` branch, so output stays byte-identical — minus the spinner.
---
@@ -527,24 +499,21 @@ Live tests are black-box CLI subprocess tests — like `*.e2e.test.ts`, but run
- `describeLiveProject` — additionally requires a provisioned project (`SUPABASE_LIVE_PROJECT_REF`); use for project-scoped Management API commands (branches, functions, project-scoped db).
- `describeLiveDataPlane` — additionally requires the project's own Postgres instance to be `ACTIVE_HEALTHY`; use for commands that talk to the project's data plane (migration, db, storage).
- **Invocation:** use `runSupabaseLive(args, options?)` (wraps `runSupabase` with the `legacy` entrypoint and the live profile/timeout defaults) rather than calling `runSupabase` directly, so every live test picks up the same environment plumbing.
-- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real Go-schema `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example.
+- **Local-dev-stack live tests** (`start`/`stop`/`status`, and anything else that manages real Docker containers rather than calling the Management API) follow the same file/gating convention but don't need `SUPABASE_PROFILE`/project-ref machinery. Pattern: `mkdtemp` a project dir, `runSupabaseLive(["init"], { cwd })` to generate a real `config.toml`, `runSupabaseLive(["start", ...])` to bring up (a lightweight subset of) the real stack, exercise the command under test, then clean up in `afterEach` (best-effort `stop --no-backup` + `rm` the temp dir) so a failed assertion never leaks containers onto the CI runner. See `commands/stop/stop.live.test.ts` and `commands/status/status.live.test.ts` for the canonical example.
- **Keep the suite small and golden-path only** — same philosophy as `*.e2e.test.ts`, but even more so given the cost of a real backend. One or two scenarios per command is normal; branch-by-branch coverage belongs in `*.integration.test.ts`.
- Timeouts are generous by default (`testTimeout`/`hookTimeout: 300_000` for the whole `live` project) because real platform/Docker operations are slow — pass an explicit per-`test()` timeout when a scenario needs less (or, for a real local-stack `start`, close to the full budget).
---
-## Go CLI Parity Tracking
-
-The legacy port is complete, so this is no longer a per-command tracking exercise:
+## Compatibility Docs
-- When you add a TS-only flag, positional argument, or behavior on an already-ported **legacy
- shell** command (no Go equivalent), record it in
- [`docs/go-cli-divergences.md`](./docs/go-cli-divergences.md) in the same change. Same for a
- TS-native command with no direct Go equivalent (for example `dev`) — record it in that file's
- TS-only section.
-- Only touch [`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md) if the delegation
- surface itself changes — a command is added to or removed from the fixed set `LegacyGoProxy`
- still forwards to the Go binary.
+- [`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md) documents the residual Go
+ delegation surface. Update it only when that surface shrinks (a wrapper is replaced natively or
+ removed).
+- [`docs/go-cli-divergences.md`](./docs/go-cli-divergences.md) is a **frozen historical record** of
+ where the TS port intentionally diverged from the old Go CLI. Do not add new entries — new
+ flags and features are simply new CLI behavior, documented through help text, tests, and
+ `SIDE_EFFECTS.md` like anything else.
---
@@ -571,18 +540,10 @@ bun run --parallel "*:check"
### `apps/cli-go/`
-The [old Supabase CLI](https://github.com/supabase/cli) written in Go. As of CLI-1970, this tree
-contains only the residual proxied subset — the still-`wrapped` commands, plus the single
-Go-delegated flag paths on `db diff`, `db pull`, and `functions download`. Every other command's
-source was deleted outright once nothing in the TypeScript CLI could reach it, directly or
-indirectly. Use it as the authoritative source, matched exactly, when working on one of the
-remaining wrapped commands (maintaining its command/flag definition, or replacing the wrapper with
-native TS), or when changing an already-ported legacy command's established output/flags/behavior/
-side-effects. It is not required reading for other legacy-shell work — see
-[Legacy Port Status and Go CLI Authority](#legacy-port-status-and-go-cli-authority) and
-[ADR 0016](../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md). For any
-command whose Go source no longer exists in-tree, the last commit with it intact (`7b469f5b3`) is
-the authoritative reference instead. Exception: `internal/start` (Go's `supabase start`) was
-deleted outright as unreachable once ported (CLI-1966) — for that command specifically, the last
-commit with the source intact (`a253ccba25c21356ccd33044c4474aecb77d1ae4`) is the authoritative
-reference instead.
+The remnants of the [old Go Supabase CLI](https://github.com/supabase/cli). It contains **only**
+the residual delegation surface (the still-proxied commands listed in
+[`docs/go-cli-porting-status.md`](./docs/go-cli-porting-status.md)) and is slated for cleanup and
+eventual removal. It is not a reference for anything else — consult it only when maintaining a
+proxied command's flag definition, which must keep matching the Go binary it forwards to. For the
+history of any deleted Go command source, use the pinned commits `7b469f5b3` (general) and
+`a253ccba2` (`internal/start`).
diff --git a/apps/cli/docs/binary-distribution.md b/apps/cli/docs/binary-distribution.md
index dae26a95da..380d8775e8 100644
--- a/apps/cli/docs/binary-distribution.md
+++ b/apps/cli/docs/binary-distribution.md
@@ -22,7 +22,7 @@ The legacy shell was built as a gradual TypeScript port of the Go CLI, moving ea
- **Phase 0** — The command is defined in the TS CLI tree but proxied to the Go binary at runtime via `LegacyGoProxy`.
- **Phase 1+** — The command is implemented natively in TypeScript.
-That port is complete (CLI-1970). `supabase-go` is no longer a shrinking, transitional Phase 0 artifact — it is a permanent residual proxy target for a fixed, small command surface: `db diff` (for `--use-pg-schema`), `db pull` (for `--experimental`), the Go-deprecated `db branch`/`db remote` command families, `gen keys`, and `functions download` (for the hidden `--legacy-bundle` path). See "Go binary command surface" under Release Workflow below for the full list and why each command stays. The TS binary (`supabase`) still needs `supabase-go` available on the same system for those invocations. Every other Go command from the original CLI has been deleted outright from `apps/cli-go/`, not merely excluded from the build — see the same section for how.
+That port is complete (CLI-1970). `supabase-go` is the residual proxy target for a fixed, small command surface: `db diff` (for `--use-pg-schema`), `db pull` (for `--experimental`), the Go-deprecated `db branch`/`db remote` command families, `gen keys`, and `functions download` (for the hidden `--legacy-bundle` path). See "Go binary command surface" under Release Workflow below for the full list and why each command stays. Two lifecycles apply here and must not be conflated: the **public commands** in that surface are retained (dropping them was ruled a breaking change — CLI-1964), while their **Go implementations** are slated for eventual native TS replacement, after which `supabase-go` stops shipping. Until the proxied surface is empty, the TS binary (`supabase`) still needs `supabase-go` available on the same system for those invocations. Every other Go command from the original CLI has been deleted outright from `apps/cli-go/`, not merely excluded from the build — see the same section for how.
## Package Layout
@@ -103,7 +103,7 @@ This:
- `db pull` — kept for `--experimental`, which needs the multigres Postgres DDL parser for structured dumps (CLI-1957)
- `db branch create`, `db branch delete`, `db branch list`, `db branch switch`
- `db remote changes`, `db remote commit`
-- `gen keys` — kept indefinitely; its planned removal (CLI-1964) was cancelled
+- `gen keys` — the public command is kept (its planned removal, CLI-1964, was cancelled as a breaking change); the Go implementation stays only until a native TS replacement lands
- `functions download` — kept for the hidden `--legacy-bundle` path (CLI-1963)
That list is exhaustive: `LegacyGoProxy` is the only code path in the TypeScript CLI that spawns `supabase-go`. The one historical exception — the hidden pg-delta seam (`db schema declarative __catalog` + `db start`), spawned directly by the native `db schema declarative generate|sync` commands to provision shadow databases and export pg-delta catalogs — was ported to native TypeScript as part of CLI-1970 (`legacy-pgdelta.seam.layer.ts` now composes `legacySetupShadowDatabase`/`legacyExportCatalogPgDelta`/`legacyStartLocalDatabase` in-process), and those two Go commands were deleted with it.
diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md
index 228fc2af93..085bf9cab1 100644
--- a/apps/cli/docs/go-cli-divergences.md
+++ b/apps/cli/docs/go-cli-divergences.md
@@ -1,10 +1,12 @@
# Go CLI Divergences
-Ledger of deliberate TypeScript divergences from the old Go CLI (pre-`7b469f5b3`) on the legacy
-shell: TS-only commands, flags, and behavior with no Go counterpart. When you add a TS-only flag or
-a deliberate behavioral change to an already-ported legacy command, add an entry here in the same
-change. This document exists to answer support and migration questions about why the TS CLI does
-something the old Go CLI didn't — it is not a compatibility promise.
+**Frozen historical ledger** of deliberate TypeScript divergences from the old Go CLI
+(pre-`7b469f5b3`) on the legacy shell: TS-only commands, flags, and behavior with no Go
+counterpart. The TypeScript CLI is now the source of truth, so this ledger no longer accumulates
+entries — new flags, commands, and behavioral changes are simply new CLI behavior, documented
+through help text, tests, and each command's `SIDE_EFFECTS.md`. This document exists to answer
+support and migration questions about why the TS CLI does something the old Go CLI didn't — it is
+not a compatibility promise.
## TS-only Commands
diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md
index 8b06e80099..a5d617bf72 100644
--- a/apps/cli/docs/go-cli-porting-status.md
+++ b/apps/cli/docs/go-cli-porting-status.md
@@ -12,9 +12,10 @@ with it intact: `7b469f5b3` (CLI-1966's `internal/start` pin remains its own, se
`a253ccba2`).
See [`binary-distribution.md`](./binary-distribution.md) for how these two binaries are packaged,
-resolved at runtime, and sized, and
-[ADR 0016](../../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md) for the
-policy on when `apps/cli-go/` is actually authoritative for day-to-day legacy-shell work.
+resolved at runtime, and sized. The TypeScript CLI is the source of truth for all CLI behavior;
+`apps/cli-go/` is authoritative only for the proxied commands below, and the whole surface is
+slated for cleanup and removal ([ADR 0016](../../../docs/adr/0016-legacy-port-completion-and-go-cli-authority-scope.md)
+records the earlier transition policy).
## The delegation surface
From 3e2df928b23917c2f3c3facb88106f2809d64c42 Mon Sep 17 00:00:00 2001
From: Andrew Valleteau
Date: Thu, 20 Aug 2026 19:19:53 +0000
Subject: [PATCH 14/63] fix(cli): clamp edge-runtime nofile ulimit to the host
hard limit (#6284)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
The Edge Runtime container was always created with `--ulimit
nofile=65536:65536` — a raise inherited from the Go CLI so many
concurrent Deno isolates can run (supabase/cli#5151). Sandboxed hosts
cap the hard nofile limit lower (e.g. 20,000 in the Claude Code
sandbox), their docker daemon shares that cap, and requesting more than
the daemon can grant fails the container start outright.
This adds `edgeRuntimeNofileUlimit(platformOs)` in `@supabase/stack` and
uses it at both docker call sites (stack service defs for the next
shell, and `shared/functions/serve.ts` for legacy `functions
serve`/`start`):
- On Linux, the requested value is clamped to the process's own hard
limit, read via `process.report.getReport().userLimits.open_files` (the
standard runtime API, implemented by both Bun and Node — verified under
Bun in a Linux container with a constrained `--ulimit`).
- The clamp only ever lowers the request, so the worst case on an exotic
setup (client more constrained than a remote/rootful daemon) is a
smaller fd budget, never a failed start.
- When the clamp lowers the request, the CLI emits a warning naming the
reduced limit ("Edge Runtime file descriptor limit lowered to N: …") so
the smaller fd budget is visible instead of silent. It surfaces through
`Output.warn` in `startEdgeRuntimeContainer`, covering both `functions
serve` and legacy `start`; the stack `ServiceDef` builder is pure with
no output channel and stays silent.
- Off Linux the full 65536 raise is kept, since the daemon runs in a VM
with its own limits.
The Go-parity divergence is documented in
`apps/cli/docs/go-cli-divergences.md`.
Reviewer notes: the helper stays a plain sync leaf (no failure
modes/retries/resources) per the repo's Effect-native carve-out — both
call sites are sync `ServiceDef` builders — and takes `platformOs` as
input to match how the stack threads `platform.os` instead of reading
`process.platform` ambiently. It now returns `{ arg, limit,
clampWarning? }` with the host hard limit injectable (defaulting to the
real `process.report` probe), so the clamp decision and warning text
carry deterministic unit coverage alongside the pure seams
(`hardNofileLimitFromReport`, `clampNofileLimit`), including the
20,000-cap case.
## Linked issue
Closes CLI-2220 (Linear)
- [x] The linked issue is **open** and carries the
`open-for-contribution` label (or I'm a Supabase maintainer).
## Checklist
- [x] The PR title follows [Conventional
Commits](https://www.conventionalcommits.org/) (e.g. `fix(cli): …`).
- [x] Tests added or updated for the change.
- [x] `pnpm check:all` and `pnpm test` pass for the workspace(s) I
touched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5
---
apps/cli/docs/go-cli-divergences.md | 14 ++++
.../edge-runtime.service.integration.test.ts | 29 +++++---
.../start/services/edge-runtime.service.ts | 2 +-
apps/cli/src/shared/functions/serve.ts | 13 +++-
packages/stack/src/effect.ts | 2 +
packages/stack/src/services/edge-runtime.ts | 3 +-
packages/stack/src/services/nofile-limit.ts | 59 +++++++++++++++
.../src/services/nofile-limit.unit.test.ts | 74 +++++++++++++++++++
.../stack/src/services/services.unit.test.ts | 3 +-
9 files changed, 182 insertions(+), 17 deletions(-)
create mode 100644 packages/stack/src/services/nofile-limit.ts
create mode 100644 packages/stack/src/services/nofile-limit.unit.test.ts
diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md
index 085bf9cab1..693e2e3328 100644
--- a/apps/cli/docs/go-cli-divergences.md
+++ b/apps/cli/docs/go-cli-divergences.md
@@ -232,3 +232,17 @@ These commands exist in the TS CLI today but have no direct top-level equivalent
pull."). An in-sync database is a finding, not a failure to troubleshoot, so the
debug hint sent users chasing a non-existent bug. Message text and exit code — the
parts scripts depend on — are unchanged.
+- Edge Runtime's Docker container `--ulimit nofile` value (`functions serve` and `start`): Go
+ hardcodes `nofile=65536:65536`, raised from the daemon default to accommodate FD usage from
+ many concurrent Deno isolates (supabase/cli#5151). TS clamps that value to the host's own hard
+ nofile limit on Linux (`@supabase/stack`'s `edgeRuntimeNofileUlimit`, via
+ `process.report`'s `userLimits`), so a constrained sandbox (hard cap below 65536) can still start the
+ container instead of failing outright (CLI-2220). The CLI process's own limit is used as a
+ proxy for the daemon's — exact in the sandboxes this targets, where both share the cap; a
+ Linux client more constrained than its daemon (remote `DOCKER_HOST`, mounted socket) just
+ gets a smaller fd budget, never a failed start. When the clamp lowers the request, the legacy
+ `functions serve`/`start` bring-up warns with the reduced limit. The `@supabase/stack` service
+ builder (next-shell `stack start`) applies the same clamp silently: its defs are built without
+ an output channel, and in managed mode inside the daemon process, so a user-visible warning
+ there needs a diagnostics channel on `BuildResult` first; the applied value stays visible via
+ `docker inspect`.
diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts
index 79ae42c489..913582d235 100644
--- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts
+++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@effect/vitest";
+import { edgeRuntimeNofileUlimit } from "@supabase/stack/effect";
import { Deferred, Effect, Exit, Sink, Stream } from "effect";
import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { beforeEach } from "vitest";
@@ -189,20 +190,24 @@ describe("legacyStartEdgeRuntimeContainer", () => {
}),
);
- it.effect("sets --ulimit nofile=65536:65536, matching Go's Ulimits container.Config", () =>
- Effect.gen(function* () {
- const mock = mockDockerSpawner();
- const out = mockOutput();
+ it.effect(
+ "sets --ulimit nofile, capped at Go's 65536 and clamped to the host hard limit (CLI-2220)",
+ () =>
+ Effect.gen(function* () {
+ const mock = mockDockerSpawner();
+ const out = mockOutput();
- yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe(
- Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner),
- Effect.provide(out.layer),
- );
+ yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner),
+ Effect.provide(out.layer),
+ );
- const runCall = mock.runCall!;
- const ulimitIndex = runCall.args.indexOf("--ulimit");
- expect(runCall.args[ulimitIndex + 1]).toBe("nofile=65536:65536");
- }),
+ const runCall = mock.runCall!;
+ const ulimitIndex = runCall.args.indexOf("--ulimit");
+ expect(runCall.args[ulimitIndex + 1]).toBe(edgeRuntimeNofileUlimit("darwin").arg);
+ // Off Linux the raise is never clamped, so no clamp warning is emitted.
+ expect(out.messages.filter((message) => message.type === "warn")).toEqual([]);
+ }),
);
it.effect("sets --workdir once an enabled function mounts the project root (#6035)", () =>
diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts
index e283f0c822..2d85b4d64c 100644
--- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts
+++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts
@@ -13,7 +13,7 @@
* - `docker-create-args.ts`'s own header explicitly excludes `WorkingDir`
* and `Ulimits` from `LegacyStartContainerSpec` ("none of the 13 [other]
* call sites... set them") — Edge Runtime needs BOTH (`--workdir`,
- * `--ulimit nofile=65536:65536`), so "mapping cleanly" would mean
+ * `--ulimit nofile`, host-clamped), so "mapping cleanly" would mean
* extending the shared spec for a single caller.
* - Every other service's env travels as bare `-e KEY` flags whose values
* come from the spawned `docker create` process's own environment
diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts
index 0db4d04c47..479889d723 100644
--- a/apps/cli/src/shared/functions/serve.ts
+++ b/apps/cli/src/shared/functions/serve.ts
@@ -10,7 +10,12 @@ import {
type ResolvedProjectValue,
type ResolvedFunctionConfig as ManifestFunctionConfig,
} from "@supabase/config";
-import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect";
+import {
+ defaultJwtSecret,
+ defaultPublishableKey,
+ defaultSecretKey,
+ edgeRuntimeNofileUlimit,
+} from "@supabase/stack/effect";
import {
createHmac,
createPrivateKey,
@@ -1744,6 +1749,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
});
const containerProjectRoot = toDockerPath(input.projectRoot);
+ const nofile = edgeRuntimeNofileUlimit(input.platform);
+ if (nofile.clampWarning !== undefined) {
+ yield* output.warn(nofile.clampWarning);
+ }
const command = [
"create",
"--name",
@@ -1754,7 +1763,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo
"edge_runtime",
...(hasBindUnder(binds, containerProjectRoot) ? ["--workdir", containerProjectRoot] : []),
"--ulimit",
- "nofile=65536:65536",
+ nofile.arg,
"--label",
`com.supabase.cli.project=${labels["com.supabase.cli.project"]}`,
"--label",
diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts
index 56ca2752cf..79f2e1542c 100644
--- a/packages/stack/src/effect.ts
+++ b/packages/stack/src/effect.ts
@@ -37,6 +37,8 @@ export {
generateJwt,
} from "./JwtGenerator.ts";
+export { edgeRuntimeNofileUlimit } from "./services/nofile-limit.ts";
+
export type {
AllocatedPorts,
ConfigPortKey,
diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts
index 168a14cd26..3938b7d5c0 100644
--- a/packages/stack/src/services/edge-runtime.ts
+++ b/packages/stack/src/services/edge-runtime.ts
@@ -6,6 +6,7 @@ import type { StackIdentity } from "../StackIdentity.ts";
import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts";
import bootstrapSource from "./edge-runtime-main.ts" with { type: "text" };
import { stackHealthBudgets } from "./health-budgets.ts";
+import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts";
interface EdgeRuntimeOptions {
readonly runtimeRoot: string;
@@ -94,7 +95,7 @@ export const makeEdgeRuntimeServiceDocker = (opts: DockerEdgeRuntimeOptions): Se
`${bootstrapDir}:${bootstrapMountDir}:ro`,
...(opts.projectDir === undefined ? [] : [`${opts.projectDir}:${opts.projectDir}:ro`]),
],
- args: ["--ulimit", "nofile=65536:65536"],
+ args: ["--ulimit", edgeRuntimeNofileUlimit(opts.platformOs).arg],
env: {
...edgeRuntimeEnv(opts),
FUNCTIONS_RUNTIME_CONFIG_PATH: `${bootstrapMountDir}/functions-runtime-config.json`,
diff --git a/packages/stack/src/services/nofile-limit.ts b/packages/stack/src/services/nofile-limit.ts
new file mode 100644
index 0000000000..2c2b8df9c6
--- /dev/null
+++ b/packages/stack/src/services/nofile-limit.ts
@@ -0,0 +1,59 @@
+// Raised from the daemon default so many concurrent Deno isolates can run
+// (supabase/cli#5151).
+const desiredNofile = 65536;
+
+// `userLimits.open_files.hard` from a `process.report.getReport()` diagnostic
+// report — a number or "unlimited". Narrowed structurally because getReport()
+// is typed as a bare `object`.
+export const hardNofileLimitFromReport = (report: unknown): number | undefined => {
+ if (typeof report !== "object" || report === null || !("userLimits" in report)) return undefined;
+ const userLimits = report.userLimits;
+ if (typeof userLimits !== "object" || userLimits === null || !("open_files" in userLimits)) {
+ return undefined;
+ }
+ const openFiles = userLimits.open_files;
+ if (typeof openFiles !== "object" || openFiles === null || !("hard" in openFiles)) {
+ return undefined;
+ }
+ const hard = openFiles.hard;
+ return typeof hard === "number" && Number.isSafeInteger(hard) && hard > 0 ? hard : undefined;
+};
+
+const hostHardNofileLimit = (platformOs: string): number | undefined =>
+ platformOs === "linux" ? hardNofileLimitFromReport(process.report?.getReport()) : undefined;
+
+// Never request more than the host's own hard cap: sandboxed hosts cap it
+// below 65536, their docker daemon shares the cap, and exceeding it fails the
+// container start (CLI-2220). The process's limit is a proxy for the daemon's
+// — exact only when they share a kernel and limits, so only Linux is clamped
+// (elsewhere the daemon runs in a VM), and only downward: a client more
+// constrained than its daemon yields a smaller fd budget, never a failed start.
+export const clampNofileLimit = (hardLimit: number | undefined): number =>
+ hardLimit === undefined ? desiredNofile : Math.min(desiredNofile, hardLimit);
+
+interface EdgeRuntimeNofileUlimit {
+ /** The docker `--ulimit` value, `nofile=:`. */
+ readonly arg: string;
+ readonly limit: number;
+ /** Present only when the host's hard cap forced the request below the 65536 raise. */
+ readonly clampWarning?: string;
+}
+
+// `hostHardLimit` defaults to the real host probe and exists as a parameter so
+// callers with no host dependence (tests) can pin the clamp decision.
+export const edgeRuntimeNofileUlimit = (
+ platformOs: string,
+ hostHardLimit: number | undefined = hostHardNofileLimit(platformOs),
+): EdgeRuntimeNofileUlimit => {
+ const limit = clampNofileLimit(hostHardLimit);
+ return {
+ arg: `nofile=${limit}:${limit}`,
+ limit,
+ ...(limit < desiredNofile && {
+ clampWarning:
+ `Edge Runtime file descriptor limit lowered to ${limit}: ` +
+ `the host's hard limit (ulimit -Hn) is below the default ${desiredNofile}. ` +
+ `Heavy Edge Function workloads may exhaust file descriptors.`,
+ }),
+ };
+};
diff --git a/packages/stack/src/services/nofile-limit.unit.test.ts b/packages/stack/src/services/nofile-limit.unit.test.ts
new file mode 100644
index 0000000000..acda910c26
--- /dev/null
+++ b/packages/stack/src/services/nofile-limit.unit.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import {
+ clampNofileLimit,
+ edgeRuntimeNofileUlimit,
+ hardNofileLimitFromReport,
+} from "./nofile-limit.ts";
+
+const reportWithHard = (hard: number | string) => ({
+ header: { reportVersion: 5 },
+ userLimits: {
+ open_files: { soft: 1024, hard },
+ },
+});
+
+describe("hardNofileLimitFromReport", () => {
+ it("reads the hard limit from a diagnostic report", () => {
+ expect(hardNofileLimitFromReport(reportWithHard(1048576))).toBe(1048576);
+ expect(hardNofileLimitFromReport(reportWithHard(20000))).toBe(20000);
+ });
+
+ it("returns undefined when the hard limit is unlimited", () => {
+ expect(hardNofileLimitFromReport(reportWithHard("unlimited"))).toBeUndefined();
+ });
+
+ it("returns undefined for missing or malformed report shapes", () => {
+ expect(hardNofileLimitFromReport(undefined)).toBeUndefined();
+ expect(hardNofileLimitFromReport(null)).toBeUndefined();
+ expect(hardNofileLimitFromReport({})).toBeUndefined();
+ expect(hardNofileLimitFromReport({ userLimits: {} })).toBeUndefined();
+ expect(hardNofileLimitFromReport({ userLimits: { open_files: {} } })).toBeUndefined();
+ expect(hardNofileLimitFromReport(reportWithHard(-1))).toBeUndefined();
+ });
+});
+
+describe("clampNofileLimit", () => {
+ it("keeps the 65536 raise when the hard limit is unknown or higher", () => {
+ expect(clampNofileLimit(undefined)).toBe(65536);
+ expect(clampNofileLimit(1048576)).toBe(65536);
+ expect(clampNofileLimit(65536)).toBe(65536);
+ });
+
+ it("clamps down to a lower hard limit (CLI-2220's 20000-cap sandbox)", () => {
+ expect(clampNofileLimit(20000)).toBe(20000);
+ });
+});
+
+describe("edgeRuntimeNofileUlimit", () => {
+ it("keeps the full 65536 raise off Linux, where the daemon runs in a VM", () => {
+ expect(edgeRuntimeNofileUlimit("darwin")).toEqual({ arg: "nofile=65536:65536", limit: 65536 });
+ expect(edgeRuntimeNofileUlimit("win32")).toEqual({ arg: "nofile=65536:65536", limit: 65536 });
+ });
+
+ it("produces a matched soft:hard arg within Go's 65536 raise on Linux", () => {
+ const { arg, limit, clampWarning } = edgeRuntimeNofileUlimit("linux");
+ expect(arg).toBe(`nofile=${limit}:${limit}`);
+ expect(limit).toBeGreaterThan(0);
+ expect(limit).toBeLessThanOrEqual(65536);
+ // The warning exists exactly when this host's cap forced a reduction.
+ expect(clampWarning !== undefined).toBe(limit < 65536);
+ });
+
+ it("carries a warning when a lower host hard limit forces a clamp (CLI-2220's 20000-cap sandbox)", () => {
+ const clamped = edgeRuntimeNofileUlimit("linux", 20000);
+ expect(clamped.arg).toBe("nofile=20000:20000");
+ expect(clamped.limit).toBe(20000);
+ expect(clamped.clampWarning).toContain("lowered to 20000");
+ expect(clamped.clampWarning).toContain("65536");
+ });
+
+ it("omits the warning when the host limit does not constrain the raise", () => {
+ expect(edgeRuntimeNofileUlimit("linux", 1048576).clampWarning).toBeUndefined();
+ expect(edgeRuntimeNofileUlimit("linux", 65536).clampWarning).toBeUndefined();
+ });
+});
diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts
index 6259462de5..ca84c3b279 100644
--- a/packages/stack/src/services/services.unit.test.ts
+++ b/packages/stack/src/services/services.unit.test.ts
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./analytics.ts";
import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts";
import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts";
+import { edgeRuntimeNofileUlimit } from "./nofile-limit.ts";
import { makeImgproxyServiceDocker } from "./imgproxy.ts";
import { makeMailpitServiceDocker } from "./mailpit.ts";
import { makePgmetaServiceDocker } from "./pgmeta.ts";
@@ -407,7 +408,7 @@ describe("makeEdgeRuntimeServiceDocker", () => {
expect(def.args).toContain(`--policy=per_worker`);
expect(def.args).toContain(`${bootstrapDir}:/workspace:ro`);
expect(def.args).toContain("--ulimit");
- expect(def.args).toContain("nofile=65536:65536");
+ expect(def.args).toContain(edgeRuntimeNofileUlimit("linux").arg);
expect(def.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]);
expect(def.healthCheck?.probe).toEqual({
_tag: "Http",
From 1e553f45ec92a7ea62b007e9a492bf1ea477bd9a Mon Sep 17 00:00:00 2001
From: "supabase-cli-releaser[bot]"
<246109035+supabase-cli-releaser[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 19:20:29 +0000
Subject: [PATCH 15/63] chore(api): sync Management API OpenAPI spec (#6280)
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 | 3 +++
1 file changed, 3 insertions(+)
diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json
index 2130e09552..f6f70837da 100644
--- a/packages/api/src/generated/openapi.json
+++ b/packages/api/src/generated/openapi.json
@@ -6970,6 +6970,9 @@
}
}
},
+ "400": {
+ "description": "Project must be active and healthy, or metrics are not available for this project"
+ },
"401": {
"description": "Unauthorized"
},
From bb3bb5c47eac1c8276236593afb1e5d66bb7692a Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 20 Aug 2026 20:12:36 +0000
Subject: [PATCH 16/63] fix(deps): bump the npm-major group with 7 updates
(#6278)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the npm-major group with 7 updates:
| Package | From | To |
| --- | --- | --- |
|
[@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript)
| `0.3.228` | `0.3.229` |
|
[posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node)
| `5.48.1` | `5.48.2` |
|
[@effect/platform-bun](https://github.com/Effect-TS/effect/tree/HEAD/packages/platform-bun)
| `4.0.0-beta.107` | `4.0.0-rc.108` |
|
[@effect/platform-node](https://github.com/Effect-TS/effect/tree/HEAD/packages/platform-node)
| `4.0.0-beta.107` | `4.0.0-rc.108` |
|
[@effect/sql-pg](https://github.com/Effect-TS/effect/tree/HEAD/packages/sql-pg)
| `4.0.0-beta.107` | `4.0.0-rc.108` |
|
[@effect/vitest](https://github.com/Effect-TS/effect/tree/HEAD/packages/vitest)
| `4.0.0-beta.107` | `4.0.0-rc.108` |
|
[effect](https://github.com/Effect-TS/effect/tree/HEAD/packages/effect)
| `4.0.0-beta.107` | `4.0.0-rc.108` |
Updates `@anthropic-ai/claude-agent-sdk` from 0.3.228 to 0.3.229
Release notes
Sourced from @anthropic-ai/claude-agent-sdk's
releases.
v0.3.229
What's changed
- Added
terminal_slash_commands to the system init
message so Remote Control clients can hide terminal-oriented
commands
- Changed conversations whose messages alone exceed the API's 32 MB
limit to end the turn with
terminal_reason
"api_error" instead of
"image_error"; StopFailure
error_details is "request_body_over_limit:
…"
Update
npm install @anthropic-ai/claude-agent-sdk@0.3.229
# or
yarn add @anthropic-ai/claude-agent-sdk@0.3.229
# or
pnpm add @anthropic-ai/claude-agent-sdk@0.3.229
# or
bun add @anthropic-ai/claude-agent-sdk@0.3.229
Changelog
Sourced from @anthropic-ai/claude-agent-sdk's
changelog.
0.3.229
- Added
terminal_slash_commands to the system init
message so Remote Control clients can hide terminal-oriented
commands
- Changed conversations whose messages alone exceed the API's 32 MB
limit to end the turn with
terminal_reason
"api_error" instead of
"image_error"; StopFailure
error_details is "request_body_over_limit:
…"
Commits
Updates `posthog-node` from 5.48.1 to 5.48.2
Release notes
Sourced from posthog-node's
releases.
posthog-node@5.48.2
5.48.2
Patch Changes
- #4506
a77115b
Thanks @marandaneto! -
Log shutdown timeouts without rejecting, and correct the Node.js
shutdown() return type to Promise<void>.
(2026-08-12)
- Updated dependencies [
a77115b]:
Changelog
Sourced from posthog-node's
changelog.
5.48.2
Patch Changes
- #4506
a77115b
Thanks @marandaneto! -
Log shutdown timeouts without rejecting, and correct the Node.js
shutdown() return type to Promise<void>.
(2026-08-12)
- Updated dependencies [
a77115b]:
Commits
9c0632a
chore: update versions and lockfile [version bump]
a77115b
fix(node): resolve shutdown timeouts without rejecting (#4506)
3c9cd11
chore: update versions and lockfile [version bump]
- See full diff in compare
view
Updates `@effect/platform-bun` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes
Sourced from @effect/platform-bun's
releases.
@effect/platform-bun@4.0.0-rc.108
Patch Changes
- Updated dependencies [
dfb173e,
005e090,
c82c532,
22b579f,
3e19539,
08a3c74,
eb0bae0,
97b544d,
4f6d131,
fad4b7c,
accf447,
31b27e4,
8458951]:
- effect@4.0.0-rc.108
@effect/platform-node-shared@4.0.0-rc.108
Commits
Updates `@effect/platform-node` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes
Sourced from @effect/platform-node's
releases.
@effect/platform-node@4.0.0-rc.108
Patch Changes
- Updated dependencies [
dfb173e,
005e090,
c82c532,
22b579f,
3e19539,
08a3c74,
eb0bae0,
97b544d,
4f6d131,
fad4b7c,
accf447,
31b27e4,
8458951]:
- effect@4.0.0-rc.108
@effect/platform-node-shared@4.0.0-rc.108
@effect/platform-node-shared@4.0.0-rc.108
Patch Changes
- Updated dependencies [
dfb173e,
005e090,
c82c532,
22b579f,
3e19539,
08a3c74,
eb0bae0,
97b544d,
4f6d131,
fad4b7c,
accf447,
31b27e4,
8458951]:
Commits
Updates `@effect/sql-pg` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes
Sourced from @effect/sql-pg's
releases.
@effect/sql-pglite@4.0.0-rc.108
Patch Changes
- Updated dependencies [
dfb173e,
005e090,
c82c532,
22b579f,
3e19539,
08a3c74,
eb0bae0,
97b544d,
4f6d131,
fad4b7c,
accf447,
31b27e4,
8458951]:
@effect/sql-pg@4.0.0-rc.108
Patch Changes
- Updated dependencies [
dfb173e,
005e090,
c82c532,
22b579f,
3e19539,
08a3c74,
eb0bae0,
97b544d,
4f6d131,
fad4b7c,
accf447,
31b27e4,
8458951]:
Commits
Updates `@effect/vitest` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes
Sourced from @effect/vitest's
releases.
@effect/vitest@4.0.0-rc.108
Patch Changes
- Updated dependencies [
dfb173e,
005e090,
c82c532,
22b579f,
3e19539,
08a3c74,
eb0bae0,
97b544d,
4f6d131,
fad4b7c,
accf447,
31b27e4,
8458951]:
Changelog
Sourced from @effect/vitest's
changelog.
4.0.0-rc.108
Patch Changes
- Updated dependencies [
dfb173e,
005e090,
c82c532,
22b579f,
3e19539,
08a3c74,
eb0bae0,
97b544d,
4f6d131,
fad4b7c,
accf447,
31b27e4,
8458951]:
Commits
Updates `effect` from 4.0.0-beta.107 to 4.0.0-rc.108
Release notes
Sourced from effect's
releases.
effect@4.0.0-rc.108
Patch Changes
-
#6546
dfb173e
Thanks @xianjianlf2! -
Handle BigInt values safely and consistently across JSON diagnostics and
logger formats.
-
#7174
005e090
Thanks @tim-smart! - Fix
Queue.await failing with Cause.Done when
registered before the queue ends.
-
#7180
c82c532
Thanks @gcanti! -
Prioritize redacted representations in formatters and normalize text
logger levels to uppercase.
-
#7193
22b579f
Thanks @kitlangton! -
Fix Deferred.await dying with a TypeError when
a waiter is interrupted after the Deferred has been
completed.
-
#7179
3e19539
Thanks @tim-smart! - Fix
DurableDeferred.raceAll so a completed deferred can wake an
active workflow without changing success-biased race semantics
-
#7189
08a3c74
Thanks @gcanti! -
Fix HttpApi query decoding for array parameters with a
single value.
-
#6550
eb0bae0
Thanks @xianjianlf2! -
Return fresh OpenAPI specs from cached OpenApi.fromApi
calls.
-
#7188
97b544d
Thanks @gcanti! -
Mark the internal ~sentinels Schema annotation as
@internal so release declaration stripping removes it
together with SchemaAST.Sentinel. This keeps the published
declarations self-consistent for consumers that type-check dependencies
with skipLibCheck: false.
-
#7158
4f6d131
Thanks @k3dom! -
Improve Union candidate selection: a nested union member is dispatched
by the sentinels common to all its members, and candidates whose
sentinel the input contradicts are excluded.
-
#7178
fad4b7c
Thanks @tim-smart! - Use
Promise microtasks for synchronous Scheduler dispatch.
-
#7181
accf447
Thanks @gcanti! -
Move SchemaError into the Schema module and
remove the standalone SchemaError module.
-
#7195
31b27e4
Thanks @tim-smart! -
Ensure discarded non-persisted cluster messages complete without waiting
for the entity reply.
-
#7191
8458951
Thanks @Digifox03! - Fix
HttpRouter.Middleware.layer to provide request error
services for errors declared in handles, and expose global
middleware errors from HttpRouter.toHttpEffect.
Changelog
Sourced from effect's
changelog.
4.0.0-rc.108
Patch Changes
-
#6546
dfb173e
Thanks @xianjianlf2! -
Handle BigInt values safely and consistently across JSON diagnostics and
logger formats.
-
#7174
005e090
Thanks @tim-smart! - Fix
Queue.await failing with Cause.Done when
registered before the queue ends.
-
#7180
c82c532
Thanks @gcanti! -
Prioritize redacted representations in formatters and normalize text
logger levels to uppercase.
-
#7193
22b579f
Thanks @kitlangton! -
Fix Deferred.await dying with a TypeError when
a waiter is interrupted after the Deferred has been
completed.
-
#7179
3e19539
Thanks @tim-smart! - Fix
DurableDeferred.raceAll so a completed deferred can wake an
active workflow without changing success-biased race semantics
-
#7189
08a3c74
Thanks @gcanti! -
Fix HttpApi query decoding for array parameters with a
single value.
-
#6550
eb0bae0
Thanks @xianjianlf2! -
Return fresh OpenAPI specs from cached OpenApi.fromApi
calls.
-
#7188
97b544d
Thanks @gcanti! -
Mark the internal ~sentinels Schema annotation as
@internal so release declaration stripping removes it
together with SchemaAST.Sentinel. This keeps the published
declarations self-consistent for consumers that type-check dependencies
with skipLibCheck: false.
-
#7158
4f6d131
Thanks @k3dom! -
Improve Union candidate selection: a nested union member is dispatched
by the sentinels common to all its members, and candidates whose
sentinel the input contradicts are excluded.
-
#7178
fad4b7c
Thanks @tim-smart! - Use
Promise microtasks for synchronous Scheduler dispatch.
-
#7181
accf447
Thanks @gcanti! -
Move SchemaError into the Schema module and
remove the standalone SchemaError module.
-
#7195
31b27e4
Thanks @tim-smart! -
Ensure discarded non-persisted cluster messages complete without waiting
for the entity reply.
-
#7191
8458951
Thanks @Digifox03! - Fix
HttpRouter.Middleware.layer to provide request error
services for errors declared in handles, and expose global
middleware errors from HttpRouter.toHttpEffect.
Commits
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Julien Goux
---
apps/cli/package.json | 4 +-
.../src/shared/telemetry/posthog-client.ts | 21 +-
pnpm-lock.yaml | 235 +++++++++---------
pnpm-workspace.yaml | 10 +-
4 files changed, 136 insertions(+), 134 deletions(-)
diff --git a/apps/cli/package.json b/apps/cli/package.json
index d5cc742b7e..b5f13f2a21 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -43,7 +43,7 @@
"jose": "^6.2.8"
},
"devDependencies": {
- "@anthropic-ai/claude-agent-sdk": "^0.3.228",
+ "@anthropic-ai/claude-agent-sdk": "^0.3.229",
"@anthropic-ai/sdk": "^0.116.0",
"@clack/prompts": "^1.7.0",
"@effect/atom-react": "catalog:",
@@ -78,7 +78,7 @@
"oxlint-tsgolint": "catalog:",
"pg": "^8.23.0",
"pg-copy-streams": "^7.0.0",
- "posthog-node": "^5.48.1",
+ "posthog-node": "^5.48.2",
"react": "^19.2.8",
"react-devtools-core": "^7.0.1",
"semantic-release": "^25.0.9",
diff --git a/apps/cli/src/shared/telemetry/posthog-client.ts b/apps/cli/src/shared/telemetry/posthog-client.ts
index e44f5d2c63..28d187ed01 100644
--- a/apps/cli/src/shared/telemetry/posthog-client.ts
+++ b/apps/cli/src/shared/telemetry/posthog-client.ts
@@ -40,17 +40,12 @@ export const scopedPosthogClient = (apiKey: string, host: string) =>
return { client, shutdown };
}),
({ client, shutdown }) =>
- Effect.promise(async () => {
- try {
- await client._shutdown(EXIT_DELAY_CAP_MS);
- } catch {
- // The deadline rejection must be swallowed: Effect.promise turns
- // rejections into defects, which would fail the command.
- } finally {
- // The shutdown deadline only stops the wait; the SDK's drain keeps
- // in-flight requests running and starts queued ones after release.
- // Aborting cancels them so nothing outlives the scope.
- shutdown.abort();
- }
- }),
+ Effect.promise(() => client.shutdown(30_000).catch(() => undefined)).pipe(
+ // Our Effect deadline precedes the SDK's noisy 30-second deadline.
+ Effect.timeoutOption(EXIT_DELAY_CAP_MS),
+ Effect.asVoid,
+ // The SDK drain can continue after the Effect deadline; aborting its
+ // fetches lets that background drain settle without active requests.
+ Effect.ensuring(Effect.sync(() => shutdown.abort())),
+ ),
).pipe(Effect.map(({ client }) => client));
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d2627512d5..c8a962b916 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,17 +10,17 @@ catalogs:
specifier: 4.0.0-beta.107
version: 4.0.0-beta.107
'@effect/platform-bun':
- specifier: 4.0.0-beta.107
- version: 4.0.0-beta.107
+ specifier: 4.0.0-rc.108
+ version: 4.0.0-rc.108
'@effect/platform-node':
- specifier: 4.0.0-beta.107
- version: 4.0.0-beta.107
+ specifier: 4.0.0-rc.108
+ version: 4.0.0-rc.108
'@effect/sql-pg':
- specifier: 4.0.0-beta.107
- version: 4.0.0-beta.107
+ specifier: 4.0.0-rc.108
+ version: 4.0.0-rc.108
'@effect/vitest':
- specifier: 4.0.0-beta.107
- version: 4.0.0-beta.107
+ specifier: 4.0.0-rc.108
+ version: 4.0.0-rc.108
'@nx/devkit':
specifier: ^23.1.1
version: 23.1.1
@@ -43,8 +43,8 @@ catalogs:
specifier: ^4.1.10
version: 4.1.10
effect:
- specifier: 4.0.0-beta.107
- version: 4.0.0-beta.107
+ specifier: 4.0.0-rc.108
+ version: 4.0.0-rc.108
knip:
specifier: ^6.32.2
version: 6.32.2
@@ -119,8 +119,8 @@ importers:
version: 6.2.8
devDependencies:
'@anthropic-ai/claude-agent-sdk':
- specifier: ^0.3.228
- version: 0.3.228(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)
+ specifier: ^0.3.229
+ version: 0.3.229(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)
'@anthropic-ai/sdk':
specifier: ^0.116.0
version: 0.116.0(zod@4.4.3)
@@ -129,16 +129,16 @@ importers:
version: 1.7.0
'@effect/atom-react':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)(react@19.2.8)(scheduler@0.27.0)
+ version: 4.0.0-beta.107(effect@4.0.0-rc.108)(react@19.2.8)(scheduler@0.27.0)
'@effect/platform-bun':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)
'@effect/sql-pg':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)
'@effect/vitest':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10)
'@modelcontextprotocol/sdk':
specifier: ^1.30.0
version: 1.30.0(zod@4.4.3)
@@ -195,7 +195,7 @@ importers:
version: 17.4.2
effect:
specifier: 'catalog:'
- version: 4.0.0-beta.107
+ version: 4.0.0-rc.108
esbuild:
specifier: ^0.28.2
version: 0.28.2
@@ -224,8 +224,8 @@ importers:
specifier: ^7.0.0
version: 7.0.0
posthog-node:
- specifier: ^5.48.1
- version: 5.48.1
+ specifier: ^5.48.2
+ version: 5.48.2
react:
specifier: ^19.2.8
version: 19.2.8
@@ -351,13 +351,13 @@ importers:
dependencies:
'@effect/platform-bun':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)
'@effect/platform-node':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1)
effect:
specifier: 'catalog:'
- version: 4.0.0-beta.107
+ version: 4.0.0-rc.108
undici:
specifier: ^8.10.0
version: 8.10.0
@@ -440,16 +440,16 @@ importers:
dependencies:
'@effect/platform-bun':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)
'@effect/platform-node':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1)
dedent:
specifier: ^1.7.2
version: 1.7.2
effect:
specifier: 'catalog:'
- version: 4.0.0-beta.107
+ version: 4.0.0-rc.108
smol-toml:
specifier: ^1.8.0
version: 1.8.0
@@ -486,14 +486,14 @@ importers:
dependencies:
effect:
specifier: 'catalog:'
- version: 4.0.0-beta.107
+ version: 4.0.0-rc.108
devDependencies:
'@effect/platform-bun':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)
'@effect/vitest':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10)
'@tsconfig/bun':
specifier: 'catalog:'
version: 1.0.10
@@ -526,20 +526,20 @@ importers:
dependencies:
'@effect/platform-bun':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)
'@effect/platform-node':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1)
'@supabase/process-compose':
specifier: workspace:*
version: link:../process-compose
effect:
specifier: 'catalog:'
- version: 4.0.0-beta.107
+ version: 4.0.0-rc.108
devDependencies:
'@effect/vitest':
specifier: 'catalog:'
- version: 4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10)
+ version: 4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10)
'@supabase/supabase-js':
specifier: ^2.112.3
version: 2.112.3
@@ -601,52 +601,52 @@ packages:
resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==}
engines: {node: '>=18'}
- '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228':
- resolution: {integrity: sha512-HuCsV3/5XuYYaWuCbksX+e0JkDDUG/AlFJ8wKhDL3PBW/3hHNd6xBYx88kEWk1Z6B1GLxwHht9624lcmscpsyw==}
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.229':
+ resolution: {integrity: sha512-yVrJwSG9ur001ggDHKPUHlRuHKwbi2ETfby+4wlktRcQ5sAXqcSeS6B4T+IsXk8NDDHKXbCxpAPnCB5dz1Ac9g==}
cpu: [arm64]
os: [darwin]
- '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228':
- resolution: {integrity: sha512-jSUYY5Nd3efvbLZPU+i0tRBaFXskHu8M+4LMGBEw6A0PaklZ3YfGvKlTOWtJGRw6vMc6LzfOFts024xPNm6OrQ==}
+ '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.229':
+ resolution: {integrity: sha512-MOhOwgh9fqnX/rqeVJzy+NNFx+xXX2Kej/jx33xTV+4J5aJ2H1SwjZ+r3f/k1MMQmPvkrE0GHnMYrpu4hfvZ5Q==}
cpu: [x64]
os: [darwin]
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228':
- resolution: {integrity: sha512-4PgfisC3kHKlzJvy3rrm4Oh26g+D78h4ahHjni9fvSKHuJgrvHu9Qgo6aaYmzWdc7v9drL+pgiCk5Ge4Y2ANPA==}
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.229':
+ resolution: {integrity: sha512-S0XcKhUXLFdEv1Pq+aYiV7fDlTa/n+bajBNkKv12eUzjCUFsM/TfSyU5shnkQg9y7NtoB4owK/rezm5hHD1ybg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228':
- resolution: {integrity: sha512-0Wjv6TiWwGlBZINAmNJX07jN359jKwB/4Sr/uWgQkdjuVIOhe/M8ydk7JL2EPqCsbiW1lc15NjE5MWpZiYqooA==}
+ '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.229':
+ resolution: {integrity: sha512-xSPUmKNik7HEYsbdUFywbjvGzUth0P2KohetJixSOrBha63ZRun449ssEHU55VX3qH+YO5ENn129l5TGFzMNGQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228':
- resolution: {integrity: sha512-dnXxyiwGCZj27HVk6clYRqGMgrs3KVLVp0vvWYLjkPGBiKbI83qJiDpOfaekEXG2I4elX0M4XikggV1LGWjimg==}
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.229':
+ resolution: {integrity: sha512-9gJsr0elKyV21ln+Mza5kYiSAsqSAWFiHuaT+MiXAycBK73UbzRVLBg87dajkGagg6H1u5ZtPSkqNXzA2xArmA==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228':
- resolution: {integrity: sha512-LmGplObceqMOu5mlrlhTZL/VSrEWdZagF0Bl8awglMu6WeQcNe7StORYkCznZ0BuzV4CwuC3ipV4q8Jrs66wSg==}
+ '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.229':
+ resolution: {integrity: sha512-Cs0NxWVL/Up8kEh//P2cE+3VHrDxoSmWUP3EvI/GU+1RRGZPDD80UN1Rvl3TMC5QPMq+IYPjh1yncnxZ6j8aNw==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228':
- resolution: {integrity: sha512-mNS5yIMz/OXSQiDErb84jA8AKBFSlS9RSZ0qn2qyGkxplUx7kVmIDg/KnwOwHmygpzmH4UmR6OCaLXGohupqNA==}
+ '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.229':
+ resolution: {integrity: sha512-2fkljxQweZUa41RrlhLh+VdZrJ4kYzEBHGD8sc+Xy2bjkF8T4GTmsE8eKSWN3NfSCQOLHgK7Bxfm6+2n88WJMg==}
cpu: [arm64]
os: [win32]
- '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228':
- resolution: {integrity: sha512-DYT3HvdS64Pq0IRvgW3RDO31yjYp5yiUKoKaZolTpLKfALpG5LI/osfnKlya68PZ/bSST1FNAfW9I0EtCnaQ4w==}
+ '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.229':
+ resolution: {integrity: sha512-2M2/KHJTGqzduA8eRZDu1vWSFkMqKl9JZMqJmaJ2THIR2g7jBUAzK0B57UzkO9uQXoXWFH7P91OSKKGA4rwfwQ==}
cpu: [x64]
os: [win32]
- '@anthropic-ai/claude-agent-sdk@0.3.228':
- resolution: {integrity: sha512-OOaME54VCoBLjKMqWqFmHkZGyL/x/FHUA0snhyolmyEhVoeBM0Ub5mrnV2Gx3d5/RcVlk2BnEVvPqu0SpZ9VFw==}
+ '@anthropic-ai/claude-agent-sdk@0.3.229':
+ resolution: {integrity: sha512-ZxSFO7cNSz7jqsOmoGkBtvMT4JGWb2a8PUe6doAydZXH6mm6FsoUf+duSUm2rDrbv7OMoD8HDGwNZv5P3woE3w==}
engines: {node: '>=18.0.0'}
peerDependencies:
'@anthropic-ai/sdk': '>=0.93.0'
@@ -770,10 +770,10 @@ packages:
react: '>=19.2.7 <20.0.0'
scheduler: '>=0.27.0 <0.28.0'
- '@effect/platform-bun@4.0.0-beta.107':
- resolution: {integrity: sha512-nDKutCpgr+xHQX7tgN8Cq6JXtj96GqiElKaJ7AwAkZYl2q9f6onc/3aJgl6CyH/DlUoidw6YaxeU+UaTRmMN1g==}
+ '@effect/platform-bun@4.0.0-rc.108':
+ resolution: {integrity: sha512-27RoALzmzx6Qp4LrPIE8bYJfHe+8ZaAO3xLhJMEE6mVA8fxcPh4HAGwBv09Nk2qTFVh578SlSCY5ojUlqeSJ4A==}
peerDependencies:
- effect: ^4.0.0-beta.107
+ effect: ^4.0.0-rc.108
'@effect/platform-node-shared@4.0.0-beta.107':
resolution: {integrity: sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA==}
@@ -781,22 +781,22 @@ packages:
peerDependencies:
effect: ^4.0.0-beta.107
- '@effect/platform-node@4.0.0-beta.107':
- resolution: {integrity: sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA==}
+ '@effect/platform-node@4.0.0-rc.108':
+ resolution: {integrity: sha512-Nof78154BaHGdSYr4TPQFZ5+Dg+HkpmbI3SQUdwsby5QNs6yahGJPu2AgdIVqdx7pKZ2w7j/bvdnqoMmkG0PbA==}
engines: {node: '>=18.0.0'}
peerDependencies:
- effect: ^4.0.0-beta.107
+ effect: ^4.0.0-rc.108
ioredis: '>=5.7.0 <6.0.0'
- '@effect/sql-pg@4.0.0-beta.107':
- resolution: {integrity: sha512-y5RWMhdLhFqn0picXqZIR4Bevo4Fo+aT2xHNiyZoAR86d8CnbXp6Agq9HmXIGWS/nd5f7Bs4d7Aif7QUTsUofg==}
+ '@effect/sql-pg@4.0.0-rc.108':
+ resolution: {integrity: sha512-K7PZL+J71IDRsOu4CHtIJUCoDpsfD1G9Mdttn/jj9tCsQFA9ySIu2+IFmUlgJAvUUlfdg/OMhkocxkGoVZJTLw==}
peerDependencies:
- effect: ^4.0.0-beta.107
+ effect: ^4.0.0-rc.108
- '@effect/vitest@4.0.0-beta.107':
- resolution: {integrity: sha512-n4/qsx4DnT4dEI/wNgMivxyUeJoeiU1TCSz0WnoHWk/dny40Oxjip2P9IXGQDgPb9fsYVnerF0QRA6nPUuExQA==}
+ '@effect/vitest@4.0.0-rc.108':
+ resolution: {integrity: sha512-XD2GP1JATN28wnIeFGsBqYMuQDCHIodKKgFulGyG91GvuHqgf2gz+WxR2LPdyNoKMtr7lsbRzVj07niSYtIKzA==}
peerDependencies:
- effect: ^4.0.0-beta.107
+ effect: ^4.0.0-rc.108
vitest: '>=4.1.0 <5.0.0'
'@emnapi/core@1.11.1':
@@ -1363,6 +1363,13 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3
+ '@napi-rs/wasm-runtime@1.2.3':
+ resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
+ '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
+
'@next/env@16.3.0':
resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==}
@@ -2257,11 +2264,11 @@ packages:
resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==}
engines: {node: '>=12'}
- '@posthog/core@1.47.0':
- resolution: {integrity: sha512-LW62V+9yx7G7mLd+EYpwW0PiaPZI8XRJ1tLuhw+9fXHpugLp8XQD5JuCQ2kIXvoUfyx1DpKfGQY3dUnzMOIn7Q==}
+ '@posthog/core@1.47.1':
+ resolution: {integrity: sha512-d38C5DulL3gCox4g0VGyb/Lhn548fBvj9f35LZGVasPspOs3jyApxROJyDXoWwIRivOjCOIShZQAdYkZVn9J3w==}
- '@posthog/types@1.402.2':
- resolution: {integrity: sha512-ZZTiS4dLwF4/D0YTzS3gGSLFnhuNOY5yu4d9VGS9trGe5GW6FjIXo20p18K5BPW2RZSYSld8sdnSAY3AUXleUQ==}
+ '@posthog/types@1.403.0':
+ resolution: {integrity: sha512-QbkO0epmdq38xhxwP214YRi0vgpZiXqhamaTjKT6kVGJYTtsE5qZ1GTgLp12IYehg/rd+whYYErH3/DeX8pj3Q==}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -4120,8 +4127,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
- effect@4.0.0-beta.107:
- resolution: {integrity: sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==}
+ effect@4.0.0-rc.108:
+ resolution: {integrity: sha512-KmI3DlKZWPvCL4QQ2FMaPOuxMt/7DrKMENCY/gQ+MkDR5QYw25wgU5Zmh/wVLboNjIci1gNOgNCFe4xqgxli3A==}
ejs@5.0.1:
resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==}
@@ -6106,8 +6113,8 @@ packages:
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
- posthog-node@5.48.1:
- resolution: {integrity: sha512-BxLX2SqGEQhPqCPTalpyo0RRv1NMbf7UaN7q9d/ED77ksD6XOmE7ko2vIKO8F0zPL1NtKxIi+DYXap9lvR0RaA==}
+ posthog-node@5.48.2:
+ resolution: {integrity: sha512-3Ni5upqpbUXL1QM2oU4sL9Y3OycDGgrKI9aJOGHbcF9xkPwR18cq481Tkvn6/CCPbl04p3tjdwQNkGHVveQcag==}
engines: {node: ^20.20.0 || >=22.22.0}
peerDependencies:
rxjs: ^7.0.0
@@ -7310,44 +7317,44 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
- '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.228':
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.229':
optional: true
- '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.228':
+ '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.229':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.228':
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.229':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.228':
+ '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.229':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.228':
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.229':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.228':
+ '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.229':
optional: true
- '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.228':
+ '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.229':
optional: true
- '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.228':
+ '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.229':
optional: true
- '@anthropic-ai/claude-agent-sdk@0.3.228(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)':
+ '@anthropic-ai/claude-agent-sdk@0.3.229(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.116.0(zod@4.4.3)
'@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3)
zod: 4.4.3
optionalDependencies:
- '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.228
- '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.228
- '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.228
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.228
- '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.228
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.228
- '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.228
- '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.228
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.229
+ '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.229
+ '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.229
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.229
+ '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.229
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.229
+ '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.229
+ '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.229
'@anthropic-ai/sdk@0.116.0(zod@4.4.3)':
dependencies:
@@ -7514,33 +7521,33 @@ snapshots:
dependencies:
'@noble/ciphers': 1.3.0
- '@effect/atom-react@4.0.0-beta.107(effect@4.0.0-beta.107)(react@19.2.8)(scheduler@0.27.0)':
+ '@effect/atom-react@4.0.0-beta.107(effect@4.0.0-rc.108)(react@19.2.8)(scheduler@0.27.0)':
dependencies:
- effect: 4.0.0-beta.107
+ effect: 4.0.0-rc.108
react: 19.2.8
scheduler: 0.27.0
- '@effect/platform-bun@4.0.0-beta.107(effect@4.0.0-beta.107)':
+ '@effect/platform-bun@4.0.0-rc.108(effect@4.0.0-rc.108)':
dependencies:
- '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-beta.107)
- effect: 4.0.0-beta.107
+ '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-rc.108)
+ effect: 4.0.0-rc.108
transitivePeerDependencies:
- bufferutil
- utf-8-validate
- '@effect/platform-node-shared@4.0.0-beta.107(effect@4.0.0-beta.107)':
+ '@effect/platform-node-shared@4.0.0-beta.107(effect@4.0.0-rc.108)':
dependencies:
'@types/ws': 8.18.1
- effect: 4.0.0-beta.107
+ effect: 4.0.0-rc.108
ws: 8.21.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
- '@effect/platform-node@4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1)':
+ '@effect/platform-node@4.0.0-rc.108(effect@4.0.0-rc.108)(ioredis@5.11.1)':
dependencies:
- '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-beta.107)
- effect: 4.0.0-beta.107
+ '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-rc.108)
+ effect: 4.0.0-rc.108
ioredis: 5.11.1
mime: 4.1.0
undici: 8.10.0
@@ -7548,9 +7555,9 @@ snapshots:
- bufferutil
- utf-8-validate
- '@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107)':
+ '@effect/sql-pg@4.0.0-rc.108(effect@4.0.0-rc.108)':
dependencies:
- effect: 4.0.0-beta.107
+ effect: 4.0.0-rc.108
pg: 8.23.0
pg-connection-string: 2.14.0
pg-cursor: 2.21.0(pg@8.23.0)
@@ -7559,9 +7566,9 @@ snapshots:
transitivePeerDependencies:
- pg-native
- '@effect/vitest@4.0.0-beta.107(effect@4.0.0-beta.107)(vitest@4.1.10)':
+ '@effect/vitest@4.0.0-rc.108(effect@4.0.0-rc.108)(vitest@4.1.10)':
dependencies:
- effect: 4.0.0-beta.107
+ effect: 4.0.0-rc.108
vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
'@emnapi/core@1.11.1':
@@ -8016,17 +8023,17 @@ snapshots:
'@emnapi/runtime': 1.4.5
'@tybys/wasm-util': 0.9.0
- '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
+ '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
dependencies:
- '@emnapi/core': 1.11.1
- '@emnapi/runtime': 1.11.1
+ '@emnapi/core': 1.11.2
+ '@emnapi/runtime': 1.11.2
'@tybys/wasm-util': 0.10.3
optional: true
- '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
+ '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
- '@emnapi/core': 1.11.2
- '@emnapi/runtime': 1.11.2
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
'@tybys/wasm-util': 0.10.3
optional: true
@@ -8578,11 +8585,11 @@ snapshots:
'@pnpm/network.ca-file': 1.0.2
config-chain: 1.1.13
- '@posthog/core@1.47.0':
+ '@posthog/core@1.47.1':
dependencies:
- '@posthog/types': 1.402.2
+ '@posthog/types': 1.403.0
- '@posthog/types@1.402.2': {}
+ '@posthog/types@1.403.0': {}
'@protobufjs/aspromise@1.1.2': {}
@@ -8997,7 +9004,7 @@ snapshots:
dependencies:
'@emnapi/core': 1.11.1
'@emnapi/runtime': 1.11.1
- '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
+ '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.1.5':
@@ -10357,7 +10364,7 @@ snapshots:
ee-first@1.1.1: {}
- effect@4.0.0-beta.107:
+ effect@4.0.0-rc.108:
dependencies:
'@standard-schema/spec': 1.1.0
fast-check: 4.9.0
@@ -12829,9 +12836,9 @@ snapshots:
postgres-range@1.1.4: {}
- posthog-node@5.48.1:
+ posthog-node@5.48.2:
dependencies:
- '@posthog/core': 1.47.0
+ '@posthog/core': 1.47.1
pretty-ms@9.3.0:
dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index ba2a607316..412aa60a09 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -14,10 +14,10 @@ allowBuilds:
catalog:
"@effect/atom-react": "4.0.0-beta.107"
- "@effect/platform-bun": "4.0.0-beta.107"
- "@effect/platform-node": "4.0.0-beta.107"
- "@effect/sql-pg": "4.0.0-beta.107"
- "@effect/vitest": "4.0.0-beta.107"
+ "@effect/platform-bun": "4.0.0-rc.108"
+ "@effect/platform-node": "4.0.0-rc.108"
+ "@effect/sql-pg": "4.0.0-rc.108"
+ "@effect/vitest": "4.0.0-rc.108"
"@nx/devkit": "^23.1.1"
"@swc-node/register": "^1.12.1"
"@swc/core": "^1.15.47"
@@ -25,7 +25,7 @@ catalog:
"@types/bun": "^1.3.14"
"@typescript/native-preview": "7.0.0-dev.20260707.2"
"@vitest/coverage-istanbul": "^4.1.10"
- "effect": "4.0.0-beta.107"
+ "effect": "4.0.0-rc.108"
"knip": "^6.32.2"
"nx": "^23.1.1"
"oxfmt": "^0.63.0"
From 3fa484322aa678c32a56f142593a63223415ffc8 Mon Sep 17 00:00:00 2001
From: Vaibhav <117663341+7ttp@users.noreply.github.com>
Date: Thu, 20 Aug 2026 22:54:09 +0000
Subject: [PATCH 17/63] fix(cli): bypass loopback proxying (#6283)
## TL;DR
Keep `supabase start` health probes to the local Kong gateway off
HTTP(S) proxies
restoring the previous Go CLI behavior for the CLI's canonical loopback
addresses.
## What regressed?
The native TypeScript port moved these probes from Go's `net/http`
client to Bun's `fetch`.
Go bypassed proxies for localhost and loopback addresses, while Bun
honors `HTTP_PROXY` and `HTTPS_PROXY` unless `NO_PROXY` is configured...
With a proxy configured, PostgREST and Edge Runtime readiness probes can
be sent to the proxy instead of `127.0.0.1`, causing a healthy local
stack to fail startup and roll back...
## fixed now by:
Append `localhost`, `127.0.0.1`, and `[::1]` to Bun's active `NO_PROXY`
variable immediately before `start` performs its local gateway probes.
Existing exclusions are preserved, and the late placement keeps the
synthetic value out of project dotenv resolution and container
environments...
## Ref
resolves:
https://github.com/supabase/cli/issues/3265#issuecomment-5353163389
---
.../src/legacy/commands/start/SIDE_EFFECTS.md | 1 +
.../legacy/commands/start/start.handler.ts | 3 +
.../legacy/commands/start/start.live.test.ts | 64 +++++++++++++++++++
apps/cli/src/legacy/shared/legacy-hostname.ts | 8 +++
.../shared/legacy-hostname.unit.test.ts | 32 +++++++++-
5 files changed, 107 insertions(+), 1 deletion(-)
diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md
index 4af5e52658..87d465136f 100644
--- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md
+++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md
@@ -171,6 +171,7 @@ not implemented.
| `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no |
| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no |
| `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no |
+| `HTTP_PROXY` / `http_proxy` / `HTTPS_PROXY` / `https_proxy` / `NO_PROXY` / `no_proxy` | Bun proxy settings. After project dotenv and container creation, `start` appends `localhost,127.0.0.1,[::1]` to the effective no-proxy value before local Kong probes and seeding; it never changes project/container env and ends with this CLI process. | no |
`docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`.
diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts
index 7d92e89285..fd9ffb2a73 100644
--- a/apps/cli/src/legacy/commands/start/start.handler.ts
+++ b/apps/cli/src/legacy/commands/start/start.handler.ts
@@ -47,6 +47,7 @@ import {
legacyIsEncryptedSecret,
} from "../../shared/legacy-vault-decrypt.ts";
import { legacyParseGoDuration } from "../../shared/legacy-go-duration.ts";
+import { legacyConfigureLoopbackProxyBypass } from "../../shared/legacy-hostname.ts";
import {
legacyCliProjectFilterValue,
legacyServiceContainerIds,
@@ -1979,6 +1980,8 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta
projectRef: "",
config: effectiveLocalStorageConfig,
});
+ // Keep the synthetic value out of project dotenv resolution and container environments.
+ legacyConfigureLoopbackProxyBypass();
const healthResult = yield* legacyWaitForHealthyServices(spawner, [...started.keys()], {
postgrest: postgrestGateway,
edgeRuntime: edgeRuntimeGateway,
diff --git a/apps/cli/src/legacy/commands/start/start.live.test.ts b/apps/cli/src/legacy/commands/start/start.live.test.ts
index 06a2dd05c5..fcfd71f2a0 100644
--- a/apps/cli/src/legacy/commands/start/start.live.test.ts
+++ b/apps/cli/src/legacy/commands/start/start.live.test.ts
@@ -1,5 +1,7 @@
import { execFile } from "node:child_process";
+import { once } from "node:events";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { createServer } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
@@ -201,6 +203,68 @@ describeLive("supabase start (live)", () => {
},
);
+ test(
+ "bypasses an HTTPS proxy for loopback gateway health checks",
+ { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS },
+ async () => {
+ projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-live-proxy-"));
+
+ const init = await runSupabaseLive(["init"], {
+ cwd: projectDir,
+ exitTimeoutMs: SHORT_LIVE_TIMEOUT_MS,
+ });
+ expect(init.exitCode, `stdout:\n${init.stdout}\nstderr:\n${init.stderr}`).toBe(0);
+
+ let proxyConnections = 0;
+ const proxy = createServer((socket) => {
+ proxyConnections += 1;
+ socket.destroy();
+ });
+
+ try {
+ proxy.listen(0, "127.0.0.1");
+ await once(proxy, "listening");
+ const address = proxy.address();
+ if (address === null || typeof address === "string") {
+ throw new Error("Failed to allocate a proxy port");
+ }
+
+ const excludeArgs = LEGACY_SERVICE_CATALOG.flatMap((entry) =>
+ entry.excludeKey === undefined ||
+ entry.excludeKey === "kong" ||
+ entry.excludeKey === "postgrest"
+ ? []
+ : ["--exclude", entry.excludeKey],
+ );
+ const proxyUrl = `http://127.0.0.1:${address.port}`;
+ const start = await runSupabaseLive(["start", ...excludeArgs], {
+ cwd: projectDir,
+ exitTimeoutMs: START_TIMEOUT_MS,
+ env: {
+ HTTP_PROXY: "",
+ http_proxy: "",
+ HTTPS_PROXY: proxyUrl,
+ https_proxy: proxyUrl,
+ NO_PROXY: "",
+ no_proxy: "",
+ SUPABASE_API_TLS_ENABLED: "true",
+ SUPABASE_SERVICES_HOSTNAME: "127.0.0.1",
+ },
+ });
+
+ expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0);
+ expect(start.stdout).toContain("https://127.0.0.1:");
+ expect(proxyConnections).toBe(0);
+ } finally {
+ if (proxy.listening) {
+ await new Promise((resolve, reject) => {
+ proxy.close((error) => (error === undefined ? resolve() : reject(error)));
+ });
+ }
+ }
+ },
+ );
+
// The health watch inspects and dumps logs by container NAME against a real
// daemon, and derives recovery advice from a real container's real log bytes.
// Neither is observable through the in-process mocks, so this reproduces
diff --git a/apps/cli/src/legacy/shared/legacy-hostname.ts b/apps/cli/src/legacy/shared/legacy-hostname.ts
index 783503d89b..8bafb29722 100644
--- a/apps/cli/src/legacy/shared/legacy-hostname.ts
+++ b/apps/cli/src/legacy/shared/legacy-hostname.ts
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
import { join } from "node:path";
const LOCAL_HOST = "127.0.0.1";
+const LOOPBACK_NO_PROXY = `localhost,${LOCAL_HOST},[::1]`;
/** Docker CLI's reserved "no context store entry" name (`docker/cli` `cli/command/cli.go`'s `DefaultContextName`). */
const DEFAULT_CONTEXT_NAME = "default";
@@ -141,3 +142,10 @@ export function legacyGetHostname(): string {
}
return LOCAL_HOST;
}
+
+/** Keeps Bun from proxying the legacy CLI's loopback HTTP requests. */
+export function legacyConfigureLoopbackProxyBypass(env: NodeJS.ProcessEnv = process.env): void {
+ const key = (env["no_proxy"]?.length ?? 0) > 0 ? "no_proxy" : "NO_PROXY";
+ const current = env[key];
+ env[key] = current ? `${current},${LOOPBACK_NO_PROXY}` : LOOPBACK_NO_PROXY;
+}
diff --git a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts
index 0f1c351d0e..88656f8b2f 100644
--- a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts
+++ b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts
@@ -4,7 +4,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
-import { legacyGetHostname } from "./legacy-hostname.ts";
+import { legacyConfigureLoopbackProxyBypass, legacyGetHostname } from "./legacy-hostname.ts";
+
+const LOOPBACK_NO_PROXY = "localhost,127.0.0.1,[::1]";
function withEnv(entries: Record, run: () => T): T {
const previous: Record = {};
@@ -186,3 +188,31 @@ describe("legacyGetHostname", () => {
});
});
});
+
+describe("legacyConfigureLoopbackProxyBypass", () => {
+ it.each([
+ ["sets NO_PROXY when neither spelling is configured", {}, { NO_PROXY: LOOPBACK_NO_PROXY }],
+ [
+ "preserves an existing NO_PROXY value",
+ { NO_PROXY: "example.com" },
+ { NO_PROXY: `example.com,${LOOPBACK_NO_PROXY}` },
+ ],
+ [
+ "updates the non-empty lowercase value preferred by Bun",
+ { NO_PROXY: "uppercase.example", no_proxy: "lowercase.example" },
+ {
+ NO_PROXY: "uppercase.example",
+ no_proxy: `lowercase.example,${LOOPBACK_NO_PROXY}`,
+ },
+ ],
+ [
+ "falls back to NO_PROXY when lowercase no_proxy is empty",
+ { NO_PROXY: "example.com", no_proxy: "" },
+ { NO_PROXY: `example.com,${LOOPBACK_NO_PROXY}`, no_proxy: "" },
+ ],
+ ])("%s", (_name, env, expected) => {
+ legacyConfigureLoopbackProxyBypass(env);
+
+ expect(env).toEqual(expected);
+ });
+});
From ba107e7c53777e01548657058eaefa3af4de49dd Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 00:12:42 +0000
Subject: [PATCH 18/63] fix(deps): bump golang.org/x/mod from 0.39.0 to 0.40.0
in /apps/cli-go/pkg in the go-minor group across 1 directory (#6285)
Bumps the go-minor group with 1 update in the /apps/cli-go/pkg
directory: [golang.org/x/mod](https://github.com/golang/mod).
Updates `golang.org/x/mod` from 0.39.0 to 0.40.0
Commits
d3398d0
go.mod: update golang.org/x dependencies
57549bf
sumdb: ignore unrelated hashes in Lookup
96f62ae
sumdb/tlog: fix TileHashReader authentication bypass
- See full diff in compare
view
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
apps/cli-go/pkg/go.mod | 2 +-
apps/cli-go/pkg/go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/cli-go/pkg/go.mod b/apps/cli-go/pkg/go.mod
index 3cf35f5bc0..ed8442ed96 100644
--- a/apps/cli-go/pkg/go.mod
+++ b/apps/cli-go/pkg/go.mod
@@ -25,7 +25,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.0
github.com/tidwall/jsonc v0.3.3
- golang.org/x/mod v0.39.0
+ golang.org/x/mod v0.40.0
google.golang.org/grpc v1.83.0
)
diff --git a/apps/cli-go/pkg/go.sum b/apps/cli-go/pkg/go.sum
index 21c9a48b9c..159c4eb540 100644
--- a/apps/cli-go/pkg/go.sum
+++ b/apps/cli-go/pkg/go.sum
@@ -220,8 +220,8 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
-golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
+golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
+golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
From bf9759120a19de5b67265df499f9a31bdcc5bb27 Mon Sep 17 00:00:00 2001
From: Julien Goux
Date: Fri, 21 Aug 2026 11:13:21 +0000
Subject: [PATCH 19/63] chore(deps): consolidate TypeScript and update Effect
(#6289)
## Summary
- consolidate the monorepo on the cataloged TypeScript 7 package and
replace native compiler inference with tsc
- update the full Effect dependency family and cooldown exclusions to
RC.111 without a shared-platform override
- preserve CLI flag and legacy output behavior under RC.111, then
refresh generated API and schema outputs
## Context
This removes the parallel TypeScript aliases and stale Effect beta/RC
pins. Effect RC.111 also makes boolean defaults and response decoding
stricter, so the affected CLI boundaries now state their existing
behavior explicitly.
---
AGENTS.md | 2 +-
CONTRIBUTING.md | 2 +-
apps/cli-e2e/package.json | 3 +-
apps/cli/package.json | 4 +-
.../commands/completion/completion.flags.ts | 1 +
.../commands/db/advisors/advisors.command.ts | 6 +-
.../legacy/commands/db/diff/diff.command.ts | 1 +
.../legacy/commands/db/dump/dump.command.ts | 2 +
.../legacy/commands/db/lint/lint.command.ts | 2 +
.../legacy/commands/db/pull/pull.command.ts | 1 +
.../legacy/commands/db/push/push.command.ts | 15 +-
.../legacy/commands/db/query/query.format.ts | 2 +-
.../db/remote/changes/changes.command.ts | 5 +-
.../db/remote/commit/commit.command.ts | 5 +-
.../legacy/commands/db/reset/reset.command.ts | 3 +
.../schema/declarative/declarative.shared.ts | 2 +
.../declarative/generate/generate.command.ts | 2 +
.../domains/activate/activate.command.ts | 1 +
.../commands/domains/create/create.command.ts | 1 +
.../commands/domains/delete/delete.command.ts | 1 +
.../commands/domains/get/get.command.ts | 1 +
.../domains/reverify/reverify.command.ts | 1 +
.../functions/deploy/deploy.command.ts | 4 +
.../functions/download/download.command.ts | 2 +
.../commands/functions/serve/serve.command.ts | 6 +-
.../gen/signing-key/signing-key.command.ts | 1 +
.../commands/gen/types/types.command.ts | 3 +
.../src/legacy/commands/init/init.command.ts | 6 +
.../inspect/db/legacy-inspect-db-command.ts | 10 +-
.../commands/inspect/report/report.command.ts | 10 +-
.../legacy/commands/issue/issue.command.ts | 2 +
.../src/legacy/commands/link/link.command.ts | 1 +
.../legacy/commands/login/login.command.ts | 1 +
.../commands/migration/down/down.command.ts | 1 +
.../commands/migration/fetch/fetch.command.ts | 1 +
.../commands/migration/list/list.command.ts | 1 +
.../migration/repair/repair.command.ts | 1 +
.../migration/squash/squash.command.ts | 1 +
.../commands/migration/up/up.command.ts | 2 +
.../update/update.command.ts | 2 +
.../postgres-config/delete/delete.command.ts | 1 +
.../postgres-config/update/update.command.ts | 2 +
.../projects/api-keys/api-keys.command.ts | 1 +
.../src/legacy/commands/seed/seed.flags.ts | 5 +-
.../ssl-enforcement/update/update.command.ts | 2 +
.../legacy/commands/sso/add/add.command.ts | 1 +
.../sso/list/list.integration.test.ts | 35 +-
.../sso/remove/remove.integration.test.ts | 45 +-
.../legacy/commands/sso/show/show.command.ts | 5 +-
.../cli/src/legacy/commands/sso/sso.format.ts | 12 +-
.../legacy/commands/sso/sso.metadata-url.ts | 2 +-
.../commands/sso/update/update.command.ts | 1 +
.../legacy/commands/start/start.command.ts | 2 +
.../legacy/commands/status/status.command.ts | 1 +
.../src/legacy/commands/stop/stop.command.ts | 1 +
.../legacy/commands/storage/cp/cp.command.ts | 1 +
.../legacy/commands/storage/ls/ls.command.ts | 1 +
.../legacy/commands/storage/mv/mv.command.ts | 1 +
.../legacy/commands/storage/rm/rm.command.ts | 1 +
.../legacy/commands/storage/storage.flags.ts | 1 +
.../shared/legacy-pgdelta-ssl-probe.layer.ts | 9 +-
.../shared/legacy-test-db.command-handler.ts | 2 +
.../branches/create/create.command.ts | 2 +
.../functions/deploy/deploy.command.ts | 9 +-
.../commands/functions/dev/dev.command.ts | 1 +
.../functions/download/download.command.ts | 2 +
.../src/next/commands/init/init.command.ts | 5 +-
.../src/next/commands/issue/issue.command.ts | 2 +
.../src/next/commands/login/login.command.ts | 1 +
.../next/commands/logout/logout.command.ts | 5 +-
.../src/next/commands/logs/logs.command.ts | 1 +
.../next/commands/platform/request.command.ts | 3 +
apps/cli/src/shared/legacy/global-flags.ts | 16 +-
.../error-actionability-coverage.unit.test.ts | 131 ++-
apps/docs/package.json | 25 +-
docs/nx-inference-plugins.md | 16 +-
nx.json | 2 +-
package.json | 5 +-
packages/api/package.json | 3 +-
packages/api/scripts/generate.ts | 15 +-
packages/api/scripts/generate.unit.test.ts | 2 +-
packages/api/src/effect.unit.test.ts | 36 +-
packages/api/src/generated/contracts.ts | 124 +--
packages/cli-test-helpers/package.json | 3 +-
packages/config/package.json | 3 +-
packages/config/src/io.unit.test.ts | 1 -
packages/config/src/lib/env.unit.test.ts | 4 +-
packages/process-compose/package.json | 3 +-
packages/stack/package.json | 3 +-
pnpm-lock.yaml | 904 ++++--------------
pnpm-workspace.yaml | 32 +-
tools/nx-plugins/package.json | 3 +-
tools/nx-plugins/src/knip.plugin.ts | 2 +-
tools/nx-plugins/src/oxfmt.plugin.ts | 2 +-
tools/nx-plugins/src/oxlint.plugin.ts | 2 +-
.../{tsgo.plugin.ts => typescript.plugin.ts} | 12 +-
96 files changed, 719 insertions(+), 911 deletions(-)
rename tools/nx-plugins/src/{tsgo.plugin.ts => typescript.plugin.ts} (75%)
diff --git a/AGENTS.md b/AGENTS.md
index edfd1fc3be..878e6ab5be 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,7 +27,7 @@ These workspaces should generally follow this structure:
- `name`: `@supabase/`
- `type`: `"module"`
- Standard scripts: `test`, `types:check`, `lint:check`, `lint:fix`, `fmt:check`, `fmt:fix`, `knip:check`, `knip:fix`
-- Standard devDependencies: `@tsconfig/bun`, `@types/bun`, `@typescript/native-preview`, `knip`, `oxfmt`, `oxlint`, `oxlint-tsgolint`
+- Standard devDependencies: `@tsconfig/bun`, `@types/bun`, `typescript`, `knip`, `oxfmt`, `oxlint`, `oxlint-tsgolint`
Expected exceptions:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ecf23899f4..4ea9a7eac9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -152,7 +152,7 @@ All standard TypeScript workspaces (`apps/cli`, `packages/api`, `packages/config
| `test:e2e` | Run end-to-end tests _(inferred by Nx plugin)_ |
| `check:all` | Run all check targets for this project |
| `fix:all` | Run all fix targets for this project |
-| `types:check` | Type-check with `tsgo --noEmit` _(inferred by Nx plugin)_ |
+| `types:check` | Type-check with `tsc --noEmit` _(inferred by Nx plugin)_ |
| `lint:check` | Check for lint errors with `oxlint` _(inferred by Nx plugin)_ |
| `lint:fix` | Auto-fix lint errors _(inferred by Nx plugin)_ |
| `fmt:check` | Check formatting with `oxfmt --check` _(inferred by Nx plugin)_ |
diff --git a/apps/cli-e2e/package.json b/apps/cli-e2e/package.json
index 7c7167ad63..84261f76a4 100644
--- a/apps/cli-e2e/package.json
+++ b/apps/cli-e2e/package.json
@@ -19,12 +19,12 @@
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
- "@typescript/native-preview": "catalog:",
"@vitest/coverage-istanbul": "catalog:",
"knip": "catalog:",
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
+ "typescript": "catalog:",
"vitest": "catalog:"
},
"knip": {
@@ -38,7 +38,6 @@
"fixtures/**"
],
"ignoreDependencies": [
- "@typescript/native-preview",
"oxfmt",
"oxlint",
"oxlint-tsgolint"
diff --git a/apps/cli/package.json b/apps/cli/package.json
index b5f13f2a21..8cac32db58 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -64,7 +64,6 @@
"@types/pg": "^8.21.0",
"@types/pg-copy-streams": "^1.2.5",
"@types/react": "^19.2.18",
- "@typescript/native-preview": "catalog:",
"@vercel/detect-agent": "^1.2.5",
"@vitest/coverage-istanbul": "catalog:",
"dotenv": "^17.4.2",
@@ -84,7 +83,7 @@
"semantic-release": "^25.0.9",
"smol-toml": "^1.8.0",
"tldts": "catalog:",
- "typescript": "npm:@typescript/typescript6@^6.0.2",
+ "typescript": "catalog:",
"vitest": "catalog:",
"yaml": "^2.9.0"
},
@@ -137,7 +136,6 @@
"@parcel/watcher-linux-x64-musl",
"@parcel/watcher-win32-arm64",
"@parcel/watcher-win32-x64",
- "@typescript/native-preview",
"oxfmt",
"oxlint",
"oxlint-tsgolint",
diff --git a/apps/cli/src/legacy/commands/completion/completion.flags.ts b/apps/cli/src/legacy/commands/completion/completion.flags.ts
index 29e1193022..f5b03c012f 100644
--- a/apps/cli/src/legacy/commands/completion/completion.flags.ts
+++ b/apps/cli/src/legacy/commands/completion/completion.flags.ts
@@ -14,4 +14,5 @@ import { Flag } from "effect/unstable/cli";
*/
export const LegacyCompletionNoDescriptionsFlagDef = Flag.boolean("no-descriptions").pipe(
Flag.withDescription("disable completion descriptions"),
+ Flag.withDefault(false),
);
diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts
index 98e8b8af01..0eccc61f30 100644
--- a/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts
+++ b/apps/cli/src/legacy/commands/db/advisors/advisors.command.ts
@@ -14,8 +14,12 @@ const config = {
),
linked: Flag.boolean("linked").pipe(
Flag.withDescription("Checks the linked project for issues."),
+ Flag.withDefault(false),
+ ),
+ local: Flag.boolean("local").pipe(
+ Flag.withDescription("Checks the local database for issues."),
+ Flag.withDefault(false),
),
- local: Flag.boolean("local").pipe(Flag.withDescription("Checks the local database for issues.")),
// TS-only override of the linked project ref — see push.command.ts.
projectRef: Flag.string("project-ref").pipe(
Flag.withDescription("Project ref of the Supabase project."),
diff --git a/apps/cli/src/legacy/commands/db/diff/diff.command.ts b/apps/cli/src/legacy/commands/db/diff/diff.command.ts
index 526ea1129b..9a29292a3c 100644
--- a/apps/cli/src/legacy/commands/db/diff/diff.command.ts
+++ b/apps/cli/src/legacy/commands/db/diff/diff.command.ts
@@ -39,6 +39,7 @@ const config = {
Flag.withDescription(
"Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.",
),
+ Flag.withDefault(false),
),
from: Flag.string("from").pipe(
Flag.withDescription("Diff from local, linked, migrations, or a Postgres URL."),
diff --git a/apps/cli/src/legacy/commands/db/dump/dump.command.ts b/apps/cli/src/legacy/commands/db/dump/dump.command.ts
index 9725c24f7c..770f390aa3 100644
--- a/apps/cli/src/legacy/commands/db/dump/dump.command.ts
+++ b/apps/cli/src/legacy/commands/db/dump/dump.command.ts
@@ -32,6 +32,7 @@ const onRunFailure = (error: LegacyDbDumpRunError) =>
const config = {
dryRun: Flag.boolean("dry-run").pipe(
Flag.withDescription("Prints the pg_dump script that would be executed."),
+ Flag.withDefault(false),
),
// The boolean flags in mutually-exclusive groups (`data-only`/`role-only`/
// `keep-comments` and the `db-url`/`linked`/`local` target group) are
@@ -46,6 +47,7 @@ const config = {
),
useCopy: Flag.boolean("use-copy").pipe(
Flag.withDescription("Use copy statements in place of inserts."),
+ Flag.withDefault(false),
),
exclude: Flag.string("exclude").pipe(
Flag.withAlias("x"),
diff --git a/apps/cli/src/legacy/commands/db/lint/lint.command.ts b/apps/cli/src/legacy/commands/db/lint/lint.command.ts
index 11b44c2e77..6910ca2679 100644
--- a/apps/cli/src/legacy/commands/db/lint/lint.command.ts
+++ b/apps/cli/src/legacy/commands/db/lint/lint.command.ts
@@ -15,9 +15,11 @@ const config = {
),
linked: Flag.boolean("linked").pipe(
Flag.withDescription("Lints the linked project for schema errors."),
+ Flag.withDefault(false),
),
local: Flag.boolean("local").pipe(
Flag.withDescription("Lints the local database for schema errors."),
+ Flag.withDefault(false),
),
// TS-only override of the linked project ref — see push.command.ts.
projectRef: Flag.string("project-ref").pipe(
diff --git a/apps/cli/src/legacy/commands/db/pull/pull.command.ts b/apps/cli/src/legacy/commands/db/pull/pull.command.ts
index aff7d8cd91..eadd55b80b 100644
--- a/apps/cli/src/legacy/commands/db/pull/pull.command.ts
+++ b/apps/cli/src/legacy/commands/db/pull/pull.command.ts
@@ -38,6 +38,7 @@ const config = {
Flag.withDescription(
"Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.",
),
+ Flag.withDefault(false),
),
schema: Flag.string("schema").pipe(
Flag.withAlias("s"),
diff --git a/apps/cli/src/legacy/commands/db/push/push.command.ts b/apps/cli/src/legacy/commands/db/push/push.command.ts
index 22e9547ff9..252920d237 100644
--- a/apps/cli/src/legacy/commands/db/push/push.command.ts
+++ b/apps/cli/src/legacy/commands/db/push/push.command.ts
@@ -9,20 +9,25 @@ import { legacyDbPushRuntimeLayer } from "./push.layers.ts";
const config = {
includeAll: Flag.boolean("include-all").pipe(
Flag.withDescription("Include all migrations not found on remote history table."),
+ Flag.withDefault(false),
),
includeRoles: Flag.boolean("include-roles").pipe(
Flag.withDescription("Include custom roles from supabase/roles.sql."),
+ Flag.withDefault(false),
),
includeSeed: Flag.boolean("include-seed").pipe(
Flag.withDescription("Include seed data from your config."),
+ Flag.withDefault(false),
),
skipVault: Flag.boolean("skip-vault").pipe(
Flag.withDescription("Skip updating vault secrets from config.toml."),
+ Flag.withDefault(false),
),
dryRun: Flag.boolean("dry-run").pipe(
Flag.withDescription(
"Print the migrations that would be applied, but don't actually apply them.",
),
+ Flag.withDefault(false),
),
dbUrl: Flag.string("db-url").pipe(
Flag.withDescription(
@@ -30,8 +35,14 @@ const config = {
),
Flag.optional,
),
- linked: Flag.boolean("linked").pipe(Flag.withDescription("Pushes to the linked project.")),
- local: Flag.boolean("local").pipe(Flag.withDescription("Pushes to the local database.")),
+ linked: Flag.boolean("linked").pipe(
+ Flag.withDescription("Pushes to the linked project."),
+ Flag.withDefault(false),
+ ),
+ local: Flag.boolean("local").pipe(
+ Flag.withDescription("Pushes to the local database."),
+ Flag.withDefault(false),
+ ),
// TS-only flag on every user-facing `db` subcommand (Go's user-facing `db`
// commands never registered --project-ref; only the SUPABASE_PROJECT_ID env
// var could override the linked ref). The one Go exception is a hidden seam,
diff --git a/apps/cli/src/legacy/commands/db/query/query.format.ts b/apps/cli/src/legacy/commands/db/query/query.format.ts
index e810cc6be9..0f8350a3a4 100644
--- a/apps/cli/src/legacy/commands/db/query/query.format.ts
+++ b/apps/cli/src/legacy/commands/db/query/query.format.ts
@@ -5,7 +5,7 @@ import { legacyStringWidth } from "../../../shared/legacy-rune-width.ts";
// `JSON.rawJSON` (ES2025, present in Bun) wraps a string so `JSON.stringify` emits it
// verbatim as a number/literal token — used to serialize int8/bigint exactly, beyond
-// JS number precision. tsgo's bundled lib does not yet declare it.
+// JS number precision. TypeScript's bundled lib does not yet declare it.
declare global {
interface JSON {
rawJSON(text: string): unknown;
diff --git a/apps/cli/src/legacy/commands/db/remote/changes/changes.command.ts b/apps/cli/src/legacy/commands/db/remote/changes/changes.command.ts
index af081b5d94..7078ec0f2a 100644
--- a/apps/cli/src/legacy/commands/db/remote/changes/changes.command.ts
+++ b/apps/cli/src/legacy/commands/db/remote/changes/changes.command.ts
@@ -12,7 +12,10 @@ const config = {
Flag.withDescription("Connect using the specified Postgres URL (must be percent-encoded)."),
Flag.optional,
),
- linked: Flag.boolean("linked").pipe(Flag.withDescription("Connect to the linked project.")),
+ linked: Flag.boolean("linked").pipe(
+ Flag.withDescription("Connect to the linked project."),
+ Flag.withDefault(false),
+ ),
password: Flag.string("password").pipe(
Flag.withAlias("p"),
Flag.withDescription("Password to your remote Postgres database."),
diff --git a/apps/cli/src/legacy/commands/db/remote/commit/commit.command.ts b/apps/cli/src/legacy/commands/db/remote/commit/commit.command.ts
index fe9fe6d429..5fb94631cd 100644
--- a/apps/cli/src/legacy/commands/db/remote/commit/commit.command.ts
+++ b/apps/cli/src/legacy/commands/db/remote/commit/commit.command.ts
@@ -12,7 +12,10 @@ const config = {
Flag.withDescription("Connect using the specified Postgres URL (must be percent-encoded)."),
Flag.optional,
),
- linked: Flag.boolean("linked").pipe(Flag.withDescription("Connect to the linked project.")),
+ linked: Flag.boolean("linked").pipe(
+ Flag.withDescription("Connect to the linked project."),
+ Flag.withDefault(false),
+ ),
password: Flag.string("password").pipe(
Flag.withAlias("p"),
Flag.withDescription("Password to your remote Postgres database."),
diff --git a/apps/cli/src/legacy/commands/db/reset/reset.command.ts b/apps/cli/src/legacy/commands/db/reset/reset.command.ts
index 2dacc5df5a..434fb7f948 100644
--- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts
+++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts
@@ -17,9 +17,11 @@ const config = {
),
linked: Flag.boolean("linked").pipe(
Flag.withDescription("Resets the linked project with local migrations."),
+ Flag.withDefault(false),
),
local: Flag.boolean("local").pipe(
Flag.withDescription("Resets the local database with local migrations."),
+ Flag.withDefault(false),
),
// TS-only override of the linked project ref — see push.command.ts.
projectRef: Flag.string("project-ref").pipe(
@@ -28,6 +30,7 @@ const config = {
),
noSeed: Flag.boolean("no-seed").pipe(
Flag.withDescription("Skip running the seed script after reset."),
+ Flag.withDefault(false),
),
sqlPaths: Flag.string("sql-paths").pipe(
Flag.atLeast(0),
diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts
index 5f5a36be94..b6f8531a64 100644
--- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts
+++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts
@@ -16,11 +16,13 @@ export const legacyDbSchemaDeclarativeSharedBase = Command.make("declarative").p
Command.withSharedFlags({
noCache: Flag.boolean("no-cache").pipe(
Flag.withDescription("Disable catalog cache and force fresh shadow database setup."),
+ Flag.withDefault(false),
),
strictCoverage: Flag.boolean("strict-coverage").pipe(
Flag.withDescription(
"Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.",
),
+ Flag.withDefault(false),
),
}),
);
diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts
index d2262ce571..4732ed6ee9 100644
--- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts
+++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts
@@ -14,6 +14,7 @@ import { legacyDbSchemaDeclarativeGenerateRuntimeLayer } from "./generate.layers
const config = {
overwrite: Flag.boolean("overwrite").pipe(
Flag.withDescription("Overwrite declarative schema files without confirmation."),
+ Flag.withDefault(false),
),
// Deliberately NOT named `--output`/`-o`: the legacy root reserves those for
// the global machine-format flag (`LegacyOutputFlag`, `json|yaml|toml|env|…`),
@@ -30,6 +31,7 @@ const config = {
),
reset: Flag.boolean("reset").pipe(
Flag.withDescription("Reset local database before generating (local data will be lost)."),
+ Flag.withDefault(false),
),
schema: Flag.string("schema").pipe(
Flag.withAlias("s"),
diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.command.ts b/apps/cli/src/legacy/commands/domains/activate/activate.command.ts
index 331ebd49f3..67ad49374b 100644
--- a/apps/cli/src/legacy/commands/domains/activate/activate.command.ts
+++ b/apps/cli/src/legacy/commands/domains/activate/activate.command.ts
@@ -13,6 +13,7 @@ const config = {
),
includeRawOutput: Flag.boolean("include-raw-output").pipe(
Flag.withDescription("(Deprecated) use -o json instead."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/domains/create/create.command.ts b/apps/cli/src/legacy/commands/domains/create/create.command.ts
index 5824fac7ee..69750ae343 100644
--- a/apps/cli/src/legacy/commands/domains/create/create.command.ts
+++ b/apps/cli/src/legacy/commands/domains/create/create.command.ts
@@ -16,6 +16,7 @@ const config = {
),
includeRawOutput: Flag.boolean("include-raw-output").pipe(
Flag.withDescription("(Deprecated) use -o json instead."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/domains/delete/delete.command.ts b/apps/cli/src/legacy/commands/domains/delete/delete.command.ts
index 05eeed92b6..45515b4946 100644
--- a/apps/cli/src/legacy/commands/domains/delete/delete.command.ts
+++ b/apps/cli/src/legacy/commands/domains/delete/delete.command.ts
@@ -13,6 +13,7 @@ const config = {
),
includeRawOutput: Flag.boolean("include-raw-output").pipe(
Flag.withDescription("(Deprecated) use -o json instead."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/domains/get/get.command.ts b/apps/cli/src/legacy/commands/domains/get/get.command.ts
index 49cece3592..4676f27267 100644
--- a/apps/cli/src/legacy/commands/domains/get/get.command.ts
+++ b/apps/cli/src/legacy/commands/domains/get/get.command.ts
@@ -13,6 +13,7 @@ const config = {
),
includeRawOutput: Flag.boolean("include-raw-output").pipe(
Flag.withDescription("(Deprecated) use -o json instead."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts
index 249b0ff7e1..74f45b3ca4 100644
--- a/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts
+++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.command.ts
@@ -13,6 +13,7 @@ const config = {
),
includeRawOutput: Flag.boolean("include-raw-output").pipe(
Flag.withDescription("(Deprecated) use -o json instead."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts
index 90f1df040f..d7a771dad1 100644
--- a/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts
+++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.command.ts
@@ -19,9 +19,11 @@ const config = {
),
noVerifyJwt: Flag.boolean("no-verify-jwt").pipe(
Flag.withDescription("Disable JWT verification for the Function."),
+ Flag.withDefault(false),
),
useApi: Flag.boolean("use-api").pipe(
Flag.withDescription("Bundle functions server-side without using Docker."),
+ Flag.withDefault(false),
),
importMap: Flag.string("import-map").pipe(
Flag.withDescription("Path to import map file."),
@@ -29,6 +31,7 @@ const config = {
),
prune: Flag.boolean("prune").pipe(
Flag.withDescription("Delete Functions that exist in Supabase project but not locally."),
+ Flag.withDefault(false),
),
jobs: Flag.integer("jobs").pipe(
Flag.withAlias("j"),
@@ -46,6 +49,7 @@ const config = {
),
legacyBundle: Flag.boolean("legacy-bundle").pipe(
Flag.withDescription("Use legacy bundling."),
+ Flag.withDefault(false),
Flag.withHidden,
),
} as const;
diff --git a/apps/cli/src/legacy/commands/functions/download/download.command.ts b/apps/cli/src/legacy/commands/functions/download/download.command.ts
index e7cb3d5ad5..134b8a5c0f 100644
--- a/apps/cli/src/legacy/commands/functions/download/download.command.ts
+++ b/apps/cli/src/legacy/commands/functions/download/download.command.ts
@@ -17,6 +17,7 @@ const config = {
),
useApi: Flag.boolean("use-api").pipe(
Flag.withDescription("Unbundle functions server-side without using Docker."),
+ Flag.withDefault(false),
),
useDocker: Flag.boolean("use-docker").pipe(
Flag.withDescription("Use Docker to unbundle functions locally."),
@@ -25,6 +26,7 @@ const config = {
),
legacyBundle: Flag.boolean("legacy-bundle").pipe(
Flag.withDescription("Use legacy bundling."),
+ Flag.withDefault(false),
Flag.withHidden,
),
} as const;
diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts
index 8915f180b2..dd2d4c5305 100644
--- a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts
+++ b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts
@@ -36,13 +36,17 @@ const config = {
Flag.withDescription("Path to import map file."),
Flag.optional,
),
- inspect: Flag.boolean("inspect").pipe(Flag.withDescription("Alias of --inspect-mode brk.")),
+ inspect: Flag.boolean("inspect").pipe(
+ Flag.withDescription("Alias of --inspect-mode brk."),
+ Flag.withDefault(false),
+ ),
inspectMode: Flag.choice("inspect-mode", FUNCTIONS_SERVE_INSPECT_MODES).pipe(
Flag.withDescription("Activate inspector capability for debugging."),
Flag.optional,
),
inspectMain: Flag.boolean("inspect-main").pipe(
Flag.withDescription("Allow inspecting the main worker."),
+ Flag.withDefault(false),
),
all: Flag.boolean("all").pipe(
Flag.withDescription("Serve all Functions."),
diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts
index 83bc33b38d..489a9fc5ac 100644
--- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts
+++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.command.ts
@@ -19,6 +19,7 @@ const config = {
),
append: Flag.boolean("append").pipe(
Flag.withDescription("Append new key to existing keys file instead of overwriting."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/gen/types/types.command.ts b/apps/cli/src/legacy/commands/gen/types/types.command.ts
index 113a3a5413..4ac302688c 100644
--- a/apps/cli/src/legacy/commands/gen/types/types.command.ts
+++ b/apps/cli/src/legacy/commands/gen/types/types.command.ts
@@ -12,9 +12,11 @@ const SWIFT_ACCESS_CONTROL_VALUES = ["internal", "public"] as const;
const config = {
local: Flag.boolean("local").pipe(
Flag.withDescription("Generate types from the local dev database."),
+ Flag.withDefault(false),
),
linked: Flag.boolean("linked").pipe(
Flag.withDescription("Generate types from the linked project."),
+ Flag.withDefault(false),
),
dbUrl: Flag.string("db-url").pipe(
Flag.withDescription("Generate types from a database url."),
@@ -43,6 +45,7 @@ const config = {
),
postgrestV9Compat: Flag.boolean("postgrest-v9-compat").pipe(
Flag.withDescription("Generate types compatible with PostgREST v9 and below."),
+ Flag.withDefault(false),
),
queryTimeout: Flag.string("query-timeout").pipe(
Flag.withDescription("Maximum timeout allowed for the database query. (default 15s)"),
diff --git a/apps/cli/src/legacy/commands/init/init.command.ts b/apps/cli/src/legacy/commands/init/init.command.ts
index d66bb38a1d..4b968ecd0b 100644
--- a/apps/cli/src/legacy/commands/init/init.command.ts
+++ b/apps/cli/src/legacy/commands/init/init.command.ts
@@ -11,24 +11,30 @@ const config = {
interactive: Flag.boolean("interactive").pipe(
Flag.withDescription("Enables interactive mode to configure IDE settings."),
Flag.withAlias("i"),
+ Flag.withDefault(false),
),
useOrioledb: Flag.boolean("use-orioledb").pipe(
Flag.withDescription("Use OrioleDB storage engine for Postgres."),
+ Flag.withDefault(false),
),
force: Flag.boolean("force").pipe(
Flag.withDescription("Overwrite existing supabase/config.toml."),
+ Flag.withDefault(false),
),
withVscodeWorkspace: Flag.boolean("with-vscode-workspace").pipe(
Flag.withDescription("Generate VS Code workspace."),
Flag.withHidden,
+ Flag.withDefault(false),
),
withVscodeSettings: Flag.boolean("with-vscode-settings").pipe(
Flag.withDescription("Generate VS Code settings for Deno."),
Flag.withHidden,
+ Flag.withDefault(false),
),
withIntellijSettings: Flag.boolean("with-intellij-settings").pipe(
Flag.withDescription("Generate IntelliJ IDEA settings for Deno."),
Flag.withHidden,
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts
index f8800d7d4b..e18d3c002a 100644
--- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts
+++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts
@@ -18,8 +18,14 @@ export const LEGACY_INSPECT_DB_FLAGS = {
),
Flag.optional,
),
- linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")),
- local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")),
+ linked: Flag.boolean("linked").pipe(
+ Flag.withDescription("Inspect the linked project."),
+ Flag.withDefault(false),
+ ),
+ local: Flag.boolean("local").pipe(
+ Flag.withDescription("Inspect the local database."),
+ Flag.withDefault(false),
+ ),
// TS-only override of the linked project ref — see push.command.ts (db push).
projectRef: Flag.string("project-ref").pipe(
Flag.withDescription("Project ref of the Supabase project."),
diff --git a/apps/cli/src/legacy/commands/inspect/report/report.command.ts b/apps/cli/src/legacy/commands/inspect/report/report.command.ts
index 6d699f50b4..d513921d8d 100644
--- a/apps/cli/src/legacy/commands/inspect/report/report.command.ts
+++ b/apps/cli/src/legacy/commands/inspect/report/report.command.ts
@@ -13,8 +13,14 @@ const config = {
),
Flag.optional,
),
- linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")),
- local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")),
+ linked: Flag.boolean("linked").pipe(
+ Flag.withDescription("Inspect the linked project."),
+ Flag.withDefault(false),
+ ),
+ local: Flag.boolean("local").pipe(
+ Flag.withDescription("Inspect the local database."),
+ Flag.withDefault(false),
+ ),
// TS-only override of the linked project ref — see push.command.ts (db push).
projectRef: Flag.string("project-ref").pipe(
Flag.withDescription("Project ref of the Supabase project."),
diff --git a/apps/cli/src/legacy/commands/issue/issue.command.ts b/apps/cli/src/legacy/commands/issue/issue.command.ts
index 0d77da0067..8da66b72fa 100644
--- a/apps/cli/src/legacy/commands/issue/issue.command.ts
+++ b/apps/cli/src/legacy/commands/issue/issue.command.ts
@@ -8,6 +8,7 @@ import { legacyIssueBug, legacyIssueDocs, legacyIssueFeature } from "./issue.han
const legacyIssueNoBrowserFlag = Flag.boolean("no-browser").pipe(
Flag.withDescription("Print the issue form URL without opening a browser."),
+ Flag.withDefault(false),
);
const legacyIssueOptionalTextFlag = (name: string, description: string) =>
@@ -39,6 +40,7 @@ const legacyIssueBugConfig = {
const legacyIssueFeatureConfig = {
existingIssues: Flag.boolean("existing-issues").pipe(
Flag.withDescription("Prefill the existing issues checklist."),
+ Flag.withDefault(false),
),
area: legacyIssueOptionalTextFlag("area", "Affected CLI area."),
problem: legacyIssueOptionalTextFlag("problem", "Problem the feature should solve."),
diff --git a/apps/cli/src/legacy/commands/link/link.command.ts b/apps/cli/src/legacy/commands/link/link.command.ts
index 95d29e19e5..509fb95155 100644
--- a/apps/cli/src/legacy/commands/link/link.command.ts
+++ b/apps/cli/src/legacy/commands/link/link.command.ts
@@ -28,6 +28,7 @@ const config = {
),
skipPooler: Flag.boolean("skip-pooler").pipe(
Flag.withDescription("Use direct connection instead of pooler."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/login/login.command.ts b/apps/cli/src/legacy/commands/login/login.command.ts
index 869cdb0516..660995b003 100644
--- a/apps/cli/src/legacy/commands/login/login.command.ts
+++ b/apps/cli/src/legacy/commands/login/login.command.ts
@@ -17,6 +17,7 @@ const config = {
),
noBrowser: Flag.boolean("no-browser").pipe(
Flag.withDescription("Do not open browser automatically."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/migration/down/down.command.ts b/apps/cli/src/legacy/commands/migration/down/down.command.ts
index a9b02ae17e..2e4ea0081d 100644
--- a/apps/cli/src/legacy/commands/migration/down/down.command.ts
+++ b/apps/cli/src/legacy/commands/migration/down/down.command.ts
@@ -31,6 +31,7 @@ const config = {
),
linked: Flag.boolean("linked").pipe(
Flag.withDescription("Resets applied migrations on the linked project."),
+ Flag.withDefault(false),
),
local: Flag.boolean("local").pipe(
Flag.withDescription("Resets applied migrations on the local database."),
diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts
index 6a25c75cd3..f630284159 100644
--- a/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts
+++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.command.ts
@@ -19,6 +19,7 @@ const config = {
),
local: Flag.boolean("local").pipe(
Flag.withDescription("Fetches migration history from the local database."),
+ Flag.withDefault(false),
),
// TS-only override of the linked project ref — see push.command.ts (db push).
projectRef: Flag.string("project-ref").pipe(
diff --git a/apps/cli/src/legacy/commands/migration/list/list.command.ts b/apps/cli/src/legacy/commands/migration/list/list.command.ts
index 3116909bfc..c96d2c5715 100644
--- a/apps/cli/src/legacy/commands/migration/list/list.command.ts
+++ b/apps/cli/src/legacy/commands/migration/list/list.command.ts
@@ -19,6 +19,7 @@ const config = {
),
local: Flag.boolean("local").pipe(
Flag.withDescription("Lists migrations applied to the local database."),
+ Flag.withDefault(false),
),
// TS-only override of the linked project ref — see push.command.ts (db push).
projectRef: Flag.string("project-ref").pipe(
diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts
index c698793b63..ad677843ce 100644
--- a/apps/cli/src/legacy/commands/migration/repair/repair.command.ts
+++ b/apps/cli/src/legacy/commands/migration/repair/repair.command.ts
@@ -25,6 +25,7 @@ const config = {
),
local: Flag.boolean("local").pipe(
Flag.withDescription("Repairs the migration history of the local database."),
+ Flag.withDefault(false),
),
// TS-only override of the linked project ref — see push.command.ts (db push).
projectRef: Flag.string("project-ref").pipe(
diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.command.ts b/apps/cli/src/legacy/commands/migration/squash/squash.command.ts
index 4cc3d9dc9e..8c99f914d6 100644
--- a/apps/cli/src/legacy/commands/migration/squash/squash.command.ts
+++ b/apps/cli/src/legacy/commands/migration/squash/squash.command.ts
@@ -19,6 +19,7 @@ const config = {
),
linked: Flag.boolean("linked").pipe(
Flag.withDescription("Squashes the migration history of the linked project."),
+ Flag.withDefault(false),
),
local: Flag.boolean("local").pipe(
Flag.withDescription("Squashes the migration history of the local database."),
diff --git a/apps/cli/src/legacy/commands/migration/up/up.command.ts b/apps/cli/src/legacy/commands/migration/up/up.command.ts
index e12913d255..0e1a6362c1 100644
--- a/apps/cli/src/legacy/commands/migration/up/up.command.ts
+++ b/apps/cli/src/legacy/commands/migration/up/up.command.ts
@@ -9,6 +9,7 @@ import { legacyMigrationUp } from "./up.handler.ts";
const config = {
includeAll: Flag.boolean("include-all").pipe(
Flag.withDescription("Include all migrations not found on remote history table."),
+ Flag.withDefault(false),
),
dbUrl: Flag.string("db-url").pipe(
Flag.withDescription(
@@ -18,6 +19,7 @@ const config = {
),
linked: Flag.boolean("linked").pipe(
Flag.withDescription("Applies pending migrations to the linked project."),
+ Flag.withDefault(false),
),
local: Flag.boolean("local").pipe(
Flag.withDescription("Applies pending migrations to the local database."),
diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts b/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts
index 86c533b5e4..fcad924436 100644
--- a/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts
+++ b/apps/cli/src/legacy/commands/network-restrictions/update/update.command.ts
@@ -35,9 +35,11 @@ const config = {
dbAllowCidr: legacyNetworkRestrictionsUpdateDbAllowCidrFlag,
bypassCidrChecks: Flag.boolean("bypass-cidr-checks").pipe(
Flag.withDescription("Bypass some of the CIDR validation checks."),
+ Flag.withDefault(false),
),
append: Flag.boolean("append").pipe(
Flag.withDescription("Append to existing restrictions instead of replacing them."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts
index 3197fb5fd1..8122cc50a1 100644
--- a/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts
+++ b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.ts
@@ -30,6 +30,7 @@ const config = {
config: legacyPostgresConfigDeleteConfigFlag,
noRestart: Flag.boolean("no-restart").pipe(
Flag.withDescription("Do not restart the database after deleting config."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts b/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts
index 5de4e706f1..89d135de0c 100644
--- a/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts
+++ b/apps/cli/src/legacy/commands/postgres-config/update/update.command.ts
@@ -32,9 +32,11 @@ const config = {
Flag.withDescription(
"If true, replaces all existing overrides with the ones provided. If false (default), merges existing overrides with the ones provided.",
),
+ Flag.withDefault(false),
),
noRestart: Flag.boolean("no-restart").pipe(
Flag.withDescription("Do not restart the database after updating config."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts
index daf45197f2..8d0329dc3a 100644
--- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts
+++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts
@@ -12,6 +12,7 @@ const config = {
),
reveal: Flag.boolean("reveal").pipe(
Flag.withDescription("Reveal the secret API keys in full (e.g. sb_secret_...)."),
+ Flag.withDefault(false),
),
};
export type LegacyProjectsApiKeysFlags = CliCommand.Command.Config.Infer;
diff --git a/apps/cli/src/legacy/commands/seed/seed.flags.ts b/apps/cli/src/legacy/commands/seed/seed.flags.ts
index fe3aa4c86e..46b3048b03 100644
--- a/apps/cli/src/legacy/commands/seed/seed.flags.ts
+++ b/apps/cli/src/legacy/commands/seed/seed.flags.ts
@@ -16,7 +16,10 @@ import { Flag, GlobalFlag } from "effect/unstable/cli";
* telemetry flags map.
*/
export const LegacySeedLinkedFlag = GlobalFlag.setting("linked")({
- flag: Flag.boolean("linked").pipe(Flag.withDescription("Seeds the linked project.")),
+ flag: Flag.boolean("linked").pipe(
+ Flag.withDescription("Seeds the linked project."),
+ Flag.withDefault(false),
+ ),
});
export const LegacySeedLocalFlag = GlobalFlag.setting("local")({
diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts
index c9bdff5164..11d16bbea0 100644
--- a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts
+++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.command.ts
@@ -21,11 +21,13 @@ const config = {
Flag.withDescription(
"Whether the DB should enable SSL enforcement for all external connections.",
),
+ Flag.withDefault(false),
),
disableDbSslEnforcement: Flag.boolean("disable-db-ssl-enforcement").pipe(
Flag.withDescription(
"Whether the DB should disable SSL enforcement for all external connections.",
),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/sso/add/add.command.ts b/apps/cli/src/legacy/commands/sso/add/add.command.ts
index a908440f1f..ab975472d7 100644
--- a/apps/cli/src/legacy/commands/sso/add/add.command.ts
+++ b/apps/cli/src/legacy/commands/sso/add/add.command.ts
@@ -43,6 +43,7 @@ const config = {
Flag.withDescription(
"Skip local validation of the SAML 2.0 Metadata URL (HTTPS requirement, live GET probe, and UTF-8 body decode). Use in air-gapped CI where the IDP is not reachable from the build agent.",
),
+ Flag.withDefault(false),
),
attributeMappingFile: Flag.string("attribute-mapping-file").pipe(
Flag.withDescription(
diff --git a/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts b/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts
index e50654461e..2d847d7d64 100644
--- a/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts
+++ b/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts
@@ -14,6 +14,7 @@ import {
} from "../../../../../tests/helpers/legacy-mocks.ts";
import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
import { EventUpgradeSuggested } from "../../../../shared/telemetry/event-catalog.ts";
+import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts";
import { legacySsoList } from "./list.handler.ts";
// Mirrors what the Management API returns: neither `saml.id` nor
@@ -158,14 +159,29 @@ describe("legacy sso list integration", () => {
const item = {
...PROVIDER_ITEM,
saml: { ...PROVIDER_ITEM.saml, id: "8682fcf4-4056-455c-bd93-f33295604929" },
- domains: [{ ...PROVIDER_ITEM.domains[0], id: "9484591c-a203-4500-bea7-d0aaa845e2f5" }],
+ domains: [
+ {
+ ...PROVIDER_ITEM.domains[0],
+ id: "9484591c-a203-4500-bea7-d0aaa845e2f5",
+ created_at: "1999-01-02T03:04:05.000Z",
+ updated_at: "2000-02-03T04:05:06.000Z",
+ },
+ ],
};
const { layer, out } = setup({ goOutput: "json", body: { items: [item] } });
return Effect.gen(function* () {
yield* legacySsoList({ projectRef: Option.none() });
+ const emitted = JSON.parse(out.stdoutText) as {
+ providers: Array<{ domains: Array<{ created_at: string; updated_at: string }> }>;
+ };
expect(out.stdoutText).toContain("0b0d48f6-878b-4190-88d7-2ca33ed800bc");
expect(out.stdoutText).not.toContain("8682fcf4-4056-455c-bd93-f33295604929");
expect(out.stdoutText).not.toContain("9484591c-a203-4500-bea7-d0aaa845e2f5");
+ expect(emitted.providers[0]?.domains[0]).toEqual({
+ domain: "example.com",
+ created_at: "1999-01-02T03:04:05.000Z",
+ updated_at: "2000-02-03T04:05:06.000Z",
+ });
}).pipe(Effect.provide(layer));
});
@@ -250,6 +266,23 @@ describe("legacy sso list integration", () => {
}).pipe(Effect.provide(layer));
});
+ it.live("rejects a 200 response containing a null provider item", () => {
+ const { layer } = setup({ body: { items: [null] } });
+ return Effect.gen(function* () {
+ const exit = yield* Effect.exit(legacySsoList({ projectRef: Option.none() }));
+ expect(Exit.isFailure(exit)).toBe(true);
+ if (Exit.isFailure(exit)) {
+ const dump = JSON.stringify(exit.cause);
+ expect(dump).toContain("LegacySsoListNetworkError");
+ expect(classifyCliCauseActionability(exit.cause)).toMatchObject({
+ error_kind: "external_service",
+ error_category: "api_status",
+ error_fingerprint: "tag:LegacySsoListNetworkError:api_response",
+ });
+ }
+ }).pipe(Effect.provide(layer));
+ });
+
it.live("Go --output wins over TS --output-format when both set", () => {
const { layer, out } = setup({ format: "json", goOutput: "yaml" });
return Effect.gen(function* () {
diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts b/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts
index 5b6181e6e9..4d60f0dac3 100644
--- a/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts
+++ b/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts
@@ -13,6 +13,7 @@ import {
useLegacyTempWorkdir,
} from "../../../../../tests/helpers/legacy-mocks.ts";
import { EventUpgradeSuggested } from "../../../../shared/telemetry/event-catalog.ts";
+import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts";
import { legacySsoRemove } from "./remove.handler.ts";
const VALID_PROVIDER_ID = "b5ae62f9-ef1d-4f11-a02b-731c8bbb11e8";
@@ -30,6 +31,7 @@ interface SetupOpts {
goOutput?: "env" | "pretty" | "json" | "toml" | "yaml";
status?: number;
body?: unknown;
+ rawBody?: string;
network?: "fail";
upgradeGate?: "gated" | "notGated";
}
@@ -38,10 +40,11 @@ function jsonResponse(
request: Parameters[0],
status: number,
body: unknown,
+ rawBody?: string,
) {
return HttpClientResponse.fromWeb(
request,
- new Response(JSON.stringify(body), {
+ new Response(rawBody ?? JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
@@ -63,7 +66,7 @@ function setup(opts: SetupOpts = {}) {
handler: (request) => {
const url = request.url;
if (url.includes("/config/auth/sso/providers/") && request.method === "DELETE") {
- return Effect.succeed(jsonResponse(request, status, body));
+ return Effect.succeed(jsonResponse(request, status, body, opts.rawBody));
}
if (url.endsWith(`/v1/projects/${LEGACY_VALID_REF}`)) {
if (gate === undefined) return Effect.succeed(jsonResponse(request, 404, {}));
@@ -253,4 +256,42 @@ describe("legacy sso remove integration", () => {
}
}).pipe(Effect.provide(layer));
});
+
+ it.live("classifies malformed 200 response as an API status error", () => {
+ const { layer } = setup({ rawBody: "{not json" });
+ return Effect.gen(function* () {
+ const exit = yield* Effect.exit(
+ legacySsoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }),
+ );
+ expect(Exit.isFailure(exit)).toBe(true);
+ if (Exit.isFailure(exit)) {
+ const dump = JSON.stringify(exit.cause);
+ expect(dump).toContain("LegacySsoRemoveUnexpectedStatusError");
+ expect(classifyCliCauseActionability(exit.cause)).toMatchObject({
+ error_kind: "external_service",
+ error_category: "api_status",
+ error_fingerprint: "tag:LegacySsoRemoveUnexpectedStatusError:api_status",
+ });
+ }
+ }).pipe(Effect.provide(layer));
+ });
+
+ it.live("rejects a structurally invalid 200 response", () => {
+ const { layer } = setup({ body: {} });
+ return Effect.gen(function* () {
+ const exit = yield* Effect.exit(
+ legacySsoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }),
+ );
+ expect(Exit.isFailure(exit)).toBe(true);
+ if (Exit.isFailure(exit)) {
+ const dump = JSON.stringify(exit.cause);
+ expect(dump).toContain("LegacySsoRemoveNetworkError");
+ expect(classifyCliCauseActionability(exit.cause)).toMatchObject({
+ error_kind: "external_service",
+ error_category: "api_status",
+ error_fingerprint: "tag:LegacySsoRemoveNetworkError:api_response",
+ });
+ }
+ }).pipe(Effect.provide(layer));
+ });
});
diff --git a/apps/cli/src/legacy/commands/sso/show/show.command.ts b/apps/cli/src/legacy/commands/sso/show/show.command.ts
index 2f12dc651a..cc159bea7f 100644
--- a/apps/cli/src/legacy/commands/sso/show/show.command.ts
+++ b/apps/cli/src/legacy/commands/sso/show/show.command.ts
@@ -11,7 +11,10 @@ const config = {
Flag.withDescription("Project ref of the Supabase project."),
Flag.optional,
),
- metadata: Flag.boolean("metadata").pipe(Flag.withDescription("Show SAML 2.0 XML Metadata only")),
+ metadata: Flag.boolean("metadata").pipe(
+ Flag.withDescription("Show SAML 2.0 XML Metadata only"),
+ Flag.withDefault(false),
+ ),
providerId: Argument.string("provider-id").pipe(
Argument.withDescription("The ID of the SSO identity provider to show."),
),
diff --git a/apps/cli/src/legacy/commands/sso/sso.format.ts b/apps/cli/src/legacy/commands/sso/sso.format.ts
index b609df9f82..a9330b2d4b 100644
--- a/apps/cli/src/legacy/commands/sso/sso.format.ts
+++ b/apps/cli/src/legacy/commands/sso/sso.format.ts
@@ -22,7 +22,11 @@ export interface LegacySsoProviderView {
readonly name_id_format?: string;
readonly attribute_mapping?: unknown;
};
- readonly domains?: ReadonlyArray<{ readonly domain?: string }>;
+ readonly domains?: ReadonlyArray<{
+ readonly domain?: string;
+ readonly created_at?: string;
+ readonly updated_at?: string;
+ }>;
readonly created_at?: string;
readonly updated_at?: string;
}
@@ -48,7 +52,11 @@ export function toLegacySsoProviderView(value: unknown): LegacySsoProviderView {
const domains = Array.isArray(domainsRaw)
? domainsRaw
.filter((d): d is Record => typeof d === "object" && d !== null)
- .map((d) => ({ domain: typeof d["domain"] === "string" ? d["domain"] : undefined }))
+ .map((d) => ({
+ domain: typeof d["domain"] === "string" ? d["domain"] : undefined,
+ created_at: typeof d["created_at"] === "string" ? d["created_at"] : undefined,
+ updated_at: typeof d["updated_at"] === "string" ? d["updated_at"] : undefined,
+ }))
: undefined;
return {
diff --git a/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts b/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts
index 5b92ead9b2..015ef86a3c 100644
--- a/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts
+++ b/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts
@@ -48,7 +48,7 @@ export const validateMetadataUrl = (
try: () => new URL(metadataUrl),
catch: (cause) =>
new LegacySsoMetadataUrlInvalidError({
- message: `failed to parse metadata uri: ${String(cause)}`,
+ message: `failed to parse metadata uri ${JSON.stringify(metadataUrl)}: ${String(cause)}`,
}),
});
diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.ts b/apps/cli/src/legacy/commands/sso/update/update.command.ts
index 61dd8f861f..dd9eee358c 100644
--- a/apps/cli/src/legacy/commands/sso/update/update.command.ts
+++ b/apps/cli/src/legacy/commands/sso/update/update.command.ts
@@ -50,6 +50,7 @@ const config = {
Flag.withDescription(
"Skip local validation of the SAML 2.0 Metadata URL (HTTPS requirement, live GET probe, and UTF-8 body decode). Use in air-gapped CI where the IDP is not reachable from the build agent.",
),
+ Flag.withDefault(false),
),
attributeMappingFile: Flag.string("attribute-mapping-file").pipe(
Flag.withDescription(
diff --git a/apps/cli/src/legacy/commands/start/start.command.ts b/apps/cli/src/legacy/commands/start/start.command.ts
index 5e54778ede..0d1ce71928 100644
--- a/apps/cli/src/legacy/commands/start/start.command.ts
+++ b/apps/cli/src/legacy/commands/start/start.command.ts
@@ -32,9 +32,11 @@ const config = {
exclude: legacyStartExcludeFlag,
ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe(
Flag.withDescription("Ignore unhealthy services and exit 0"),
+ Flag.withDefault(false),
),
preview: Flag.boolean("preview").pipe(
Flag.withDescription("Connect to feature preview branch"),
+ Flag.withDefault(false),
Flag.withHidden,
),
} as const;
diff --git a/apps/cli/src/legacy/commands/status/status.command.ts b/apps/cli/src/legacy/commands/status/status.command.ts
index 21854ca274..049745abeb 100644
--- a/apps/cli/src/legacy/commands/status/status.command.ts
+++ b/apps/cli/src/legacy/commands/status/status.command.ts
@@ -37,6 +37,7 @@ const config = {
ignoreHealthCheck: Flag.boolean("ignore-health-check").pipe(
Flag.withDescription("Ignore unhealthy services and exit 0"),
Flag.withHidden,
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/legacy/commands/stop/stop.command.ts b/apps/cli/src/legacy/commands/stop/stop.command.ts
index 05e303fa2e..5a06d72289 100644
--- a/apps/cli/src/legacy/commands/stop/stop.command.ts
+++ b/apps/cli/src/legacy/commands/stop/stop.command.ts
@@ -24,6 +24,7 @@ const config = {
),
noBackup: Flag.boolean("no-backup").pipe(
Flag.withDescription("Deletes all data volumes after stopping."),
+ Flag.withDefault(false),
),
// Modelled as `Option` (presence = "explicitly set"), not a plain
// boolean: `--project-id`/`--all` are mutually exclusive whenever BOTH flags
diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts
index 58f232a81c..78c0a88ee5 100644
--- a/apps/cli/src/legacy/commands/storage/cp/cp.command.ts
+++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.ts
@@ -35,6 +35,7 @@ const config = {
recursive: Flag.boolean("recursive").pipe(
Flag.withAlias("r"),
Flag.withDescription("Recursively copy a directory."),
+ Flag.withDefault(false),
),
cacheControl: Flag.string("cache-control").pipe(
Flag.withDescription('Custom Cache-Control header for HTTP upload. (default "max-age=3600")'),
diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.command.ts b/apps/cli/src/legacy/commands/storage/ls/ls.command.ts
index 52d9e089ca..1de61e0de2 100644
--- a/apps/cli/src/legacy/commands/storage/ls/ls.command.ts
+++ b/apps/cli/src/legacy/commands/storage/ls/ls.command.ts
@@ -23,6 +23,7 @@ const config = {
recursive: Flag.boolean("recursive").pipe(
Flag.withAlias("r"),
Flag.withDescription("Recursively list a directory."),
+ Flag.withDefault(false),
),
linked: LegacyStorageLinkedFlagDef,
local: LegacyStorageLocalFlagDef,
diff --git a/apps/cli/src/legacy/commands/storage/mv/mv.command.ts b/apps/cli/src/legacy/commands/storage/mv/mv.command.ts
index 32b489c46b..b321b83067 100644
--- a/apps/cli/src/legacy/commands/storage/mv/mv.command.ts
+++ b/apps/cli/src/legacy/commands/storage/mv/mv.command.ts
@@ -21,6 +21,7 @@ const config = {
recursive: Flag.boolean("recursive").pipe(
Flag.withAlias("r"),
Flag.withDescription("Recursively move a directory."),
+ Flag.withDefault(false),
),
linked: LegacyStorageLinkedFlagDef,
local: LegacyStorageLocalFlagDef,
diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts
index ebf625a252..3f5ede477e 100644
--- a/apps/cli/src/legacy/commands/storage/rm/rm.command.ts
+++ b/apps/cli/src/legacy/commands/storage/rm/rm.command.ts
@@ -23,6 +23,7 @@ const config = {
recursive: Flag.boolean("recursive").pipe(
Flag.withAlias("r"),
Flag.withDescription("Recursively remove a directory."),
+ Flag.withDefault(false),
),
linked: LegacyStorageLinkedFlagDef,
local: LegacyStorageLocalFlagDef,
diff --git a/apps/cli/src/legacy/commands/storage/storage.flags.ts b/apps/cli/src/legacy/commands/storage/storage.flags.ts
index c36282a3e3..49b25047e7 100644
--- a/apps/cli/src/legacy/commands/storage/storage.flags.ts
+++ b/apps/cli/src/legacy/commands/storage/storage.flags.ts
@@ -26,6 +26,7 @@ export const LegacyStorageLinkedFlagDef = Flag.boolean("linked").pipe(
export const LegacyStorageLocalFlagDef = Flag.boolean("local").pipe(
Flag.withDescription("Connects to Storage API of the local database."),
+ Flag.withDefault(false),
);
// TS-only override of the linked project ref — see push.command.ts (db push).
diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts
index 965d83e21c..fa8aea3c97 100644
--- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts
+++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.layer.ts
@@ -125,7 +125,14 @@ export const legacyPgDeltaSslProbeLayer = Layer.effect(
);
socket.once("error", (err: Error) =>
settle(
- Effect.fail(new LegacyPgDeltaSslProbeError({ message: err.message, cause: err })),
+ Effect.fail(
+ err.message.includes("ECONNRESET") || ("code" in err && err.code === "ECONNRESET")
+ ? new LegacyPgDeltaSslProbeError({
+ message: `SSL probe connection to ${target.host}:${target.port} closed before the server responded`,
+ cause: err,
+ })
+ : new LegacyPgDeltaSslProbeError({ message: err.message, cause: err }),
+ ),
),
);
return Effect.sync(() => socket.destroy());
diff --git a/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts b/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts
index a15e437f29..d12270309e 100644
--- a/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts
+++ b/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts
@@ -68,9 +68,11 @@ export const legacyTestDbConfig = {
),
linked: Flag.boolean("linked").pipe(
Flag.withDescription("Runs pgTAP tests on the linked project."),
+ Flag.withDefault(false),
),
local: Flag.boolean("local").pipe(
Flag.withDescription("Runs pgTAP tests on the local database."),
+ Flag.withDefault(false),
),
// TS-only override of the linked project ref — see push.command.ts (db push).
projectRef: Flag.string("project-ref").pipe(
diff --git a/apps/cli/src/next/commands/branches/create/create.command.ts b/apps/cli/src/next/commands/branches/create/create.command.ts
index 237939a58e..5fad4fd6c9 100644
--- a/apps/cli/src/next/commands/branches/create/create.command.ts
+++ b/apps/cli/src/next/commands/branches/create/create.command.ts
@@ -77,9 +77,11 @@ const config = {
),
persistent: Flag.boolean("persistent").pipe(
Flag.withDescription("Create a persistent branch (default: ephemeral)."),
+ Flag.withDefault(false),
),
withData: Flag.boolean("with-data").pipe(
Flag.withDescription("Clone production data to the branch database."),
+ Flag.withDefault(false),
),
notifyUrl: Flag.string("notify-url").pipe(
Flag.withDescription("HTTP endpoint to notify when the branch becomes active and healthy."),
diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.command.ts b/apps/cli/src/next/commands/functions/deploy/deploy.command.ts
index df4061ca3b..2a60801b34 100644
--- a/apps/cli/src/next/commands/functions/deploy/deploy.command.ts
+++ b/apps/cli/src/next/commands/functions/deploy/deploy.command.ts
@@ -22,9 +22,11 @@ const config = {
),
noVerifyJwt: Flag.boolean("no-verify-jwt").pipe(
Flag.withDescription("Disable JWT verification for the Function."),
+ Flag.withDefault(false),
),
useApi: Flag.boolean("use-api").pipe(
Flag.withDescription("Bundle functions server-side without using Docker."),
+ Flag.withDefault(false),
),
importMap: Flag.string("import-map").pipe(
Flag.withDescription("Path to import map file."),
@@ -32,8 +34,12 @@ const config = {
),
prune: Flag.boolean("prune").pipe(
Flag.withDescription("Delete Functions that exist in Supabase project but not locally."),
+ Flag.withDefault(false),
+ ),
+ yes: Flag.boolean("yes").pipe(
+ Flag.withDescription("Skip the confirmation prompt."),
+ Flag.withDefault(false),
),
- yes: Flag.boolean("yes").pipe(Flag.withDescription("Skip the confirmation prompt.")),
jobs: Flag.integer("jobs").pipe(
Flag.withAlias("j"),
Flag.filter(
@@ -51,6 +57,7 @@ const config = {
legacyBundle: Flag.boolean("legacy-bundle").pipe(
Flag.withDescription("Use legacy bundling mechanism."),
Flag.withHidden,
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/next/commands/functions/dev/dev.command.ts b/apps/cli/src/next/commands/functions/dev/dev.command.ts
index 18708a6558..570aad4c63 100644
--- a/apps/cli/src/next/commands/functions/dev/dev.command.ts
+++ b/apps/cli/src/next/commands/functions/dev/dev.command.ts
@@ -21,6 +21,7 @@ const flags = {
),
noVerifyJwt: Flag.boolean("no-verify-jwt").pipe(
Flag.withDescription("Disable JWT verification for locally served Functions."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/next/commands/functions/download/download.command.ts b/apps/cli/src/next/commands/functions/download/download.command.ts
index aecd8adc61..1d24797470 100644
--- a/apps/cli/src/next/commands/functions/download/download.command.ts
+++ b/apps/cli/src/next/commands/functions/download/download.command.ts
@@ -22,6 +22,7 @@ const config = {
),
useApi: Flag.boolean("use-api").pipe(
Flag.withDescription("Unbundle functions server-side without using Docker."),
+ Flag.withDefault(false),
),
useDocker: Flag.boolean("use-docker").pipe(
Flag.withDescription("Use Docker to unbundle functions client-side."),
@@ -31,6 +32,7 @@ const config = {
legacyBundle: Flag.boolean("legacy-bundle").pipe(
Flag.withDescription("Use legacy bundling mechanism."),
Flag.withHidden,
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/next/commands/init/init.command.ts b/apps/cli/src/next/commands/init/init.command.ts
index 0d719d7d7e..e3ea6837ae 100644
--- a/apps/cli/src/next/commands/init/init.command.ts
+++ b/apps/cli/src/next/commands/init/init.command.ts
@@ -10,13 +10,16 @@ const config = {
interactive: Flag.boolean("interactive").pipe(
Flag.withDescription("Enables interactive mode to configure IDE settings."),
Flag.withAlias("i"),
+ Flag.withDefault(false),
),
- experimental: Flag.boolean("experimental").pipe(Flag.withHidden),
+ experimental: Flag.boolean("experimental").pipe(Flag.withHidden, Flag.withDefault(false)),
useOrioledb: Flag.boolean("use-orioledb").pipe(
Flag.withDescription("Use OrioleDB storage engine for Postgres."),
+ Flag.withDefault(false),
),
force: Flag.boolean("force").pipe(
Flag.withDescription("Overwrite existing supabase/config.toml."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/next/commands/issue/issue.command.ts b/apps/cli/src/next/commands/issue/issue.command.ts
index 43afbb6fc5..7178e6d165 100644
--- a/apps/cli/src/next/commands/issue/issue.command.ts
+++ b/apps/cli/src/next/commands/issue/issue.command.ts
@@ -8,6 +8,7 @@ import { openBugIssue, openDocsIssue, openFeatureIssue } from "./issue.handler.t
const noBrowserFlag = Flag.boolean("no-browser").pipe(
Flag.withDescription("Print the issue form URL without opening a browser"),
+ Flag.withDefault(false),
);
const optionalTextFlag = (name: string, description: string) =>
@@ -33,6 +34,7 @@ const bugFlags = {
const featureFlags = {
existingIssues: Flag.boolean("existing-issues").pipe(
Flag.withDescription("Prefill the existing issues checklist"),
+ Flag.withDefault(false),
),
area: optionalTextFlag("area", "Affected CLI area"),
problem: optionalTextFlag("problem", "Problem the feature should solve"),
diff --git a/apps/cli/src/next/commands/login/login.command.ts b/apps/cli/src/next/commands/login/login.command.ts
index 8dcd40d6d0..6d8362dcfe 100644
--- a/apps/cli/src/next/commands/login/login.command.ts
+++ b/apps/cli/src/next/commands/login/login.command.ts
@@ -21,6 +21,7 @@ const flags = {
),
noBrowser: Flag.boolean("no-browser").pipe(
Flag.withDescription("Do not open browser automatically"),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/next/commands/logout/logout.command.ts b/apps/cli/src/next/commands/logout/logout.command.ts
index e01cd852fa..a4b8635beb 100644
--- a/apps/cli/src/next/commands/logout/logout.command.ts
+++ b/apps/cli/src/next/commands/logout/logout.command.ts
@@ -6,7 +6,10 @@ import { withCommandInstrumentation } from "../../../shared/telemetry/command-in
import { logout } from "./logout.handler.ts";
export const logoutCommand = Command.make("logout", {
- yes: Flag.boolean("yes").pipe(Flag.withDescription("Skip the confirmation prompt")),
+ yes: Flag.boolean("yes").pipe(
+ Flag.withDescription("Skip the confirmation prompt"),
+ Flag.withDefault(false),
+ ),
}).pipe(
Command.withDescription("Log out of Supabase and remove the stored access token."),
Command.withShortDescription("Log out of Supabase"),
diff --git a/apps/cli/src/next/commands/logs/logs.command.ts b/apps/cli/src/next/commands/logs/logs.command.ts
index 4960cc4d57..3fda7df274 100644
--- a/apps/cli/src/next/commands/logs/logs.command.ts
+++ b/apps/cli/src/next/commands/logs/logs.command.ts
@@ -30,6 +30,7 @@ const flags = {
),
noFollow: Flag.boolean("no-follow").pipe(
Flag.withDescription("Print buffered history only and exit without following live logs."),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/next/commands/platform/request.command.ts b/apps/cli/src/next/commands/platform/request.command.ts
index 1445a523d4..e6f13357b1 100644
--- a/apps/cli/src/next/commands/platform/request.command.ts
+++ b/apps/cli/src/next/commands/platform/request.command.ts
@@ -53,12 +53,15 @@ const config = {
Flag.withDescription(
"Show the request and response schema for this route instead of executing it",
),
+ Flag.withDefault(false),
),
dryRun: Flag.boolean("dry-run").pipe(
Flag.withDescription("Validate and preview the outgoing request without executing it"),
+ Flag.withDefault(false),
),
yes: Flag.boolean("yes").pipe(
Flag.withDescription("Skip the confirmation prompt for this mutating request"),
+ Flag.withDefault(false),
),
} as const;
diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts
index 6d8df52361..c1586e5f7b 100644
--- a/apps/cli/src/shared/legacy/global-flags.ts
+++ b/apps/cli/src/shared/legacy/global-flags.ts
@@ -50,7 +50,10 @@ export const LegacyProfileFlag = GlobalFlag.setting("profile")({
});
export const LegacyDebugFlag = GlobalFlag.setting("debug")({
- flag: Flag.boolean("debug").pipe(Flag.withDescription("output debug logs to stderr")),
+ flag: Flag.boolean("debug").pipe(
+ Flag.withDescription("output debug logs to stderr"),
+ Flag.withDefault(false),
+ ),
});
export const LegacyWorkdirFlag = GlobalFlag.setting("workdir")({
@@ -61,7 +64,10 @@ export const LegacyWorkdirFlag = GlobalFlag.setting("workdir")({
});
export const LegacyExperimentalFlag = GlobalFlag.setting("experimental")({
- flag: Flag.boolean("experimental").pipe(Flag.withDescription("enable experimental features")),
+ flag: Flag.boolean("experimental").pipe(
+ Flag.withDescription("enable experimental features"),
+ Flag.withDefault(false),
+ ),
});
export const LegacyNetworkIdFlag = GlobalFlag.setting("network-id")({
@@ -72,7 +78,10 @@ export const LegacyNetworkIdFlag = GlobalFlag.setting("network-id")({
});
export const LegacyYesFlag = GlobalFlag.setting("yes")({
- flag: Flag.boolean("yes").pipe(Flag.withDescription("answer yes to all prompts")),
+ flag: Flag.boolean("yes").pipe(
+ Flag.withDescription("answer yes to all prompts"),
+ Flag.withDefault(false),
+ ),
});
export const LegacyDnsResolverFlag = GlobalFlag.setting("dns-resolver")({
@@ -85,6 +94,7 @@ export const LegacyDnsResolverFlag = GlobalFlag.setting("dns-resolver")({
export const LegacyCreateTicketFlag = GlobalFlag.setting("create-ticket")({
flag: Flag.boolean("create-ticket").pipe(
Flag.withDescription("create a support ticket for any CLI error"),
+ Flag.withDefault(false),
),
});
diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts
index 35f9586801..0bb1d947c2 100644
--- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts
+++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts
@@ -1,7 +1,10 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
-import ts from "typescript";
-import { describe, expect, it } from "vitest";
+import * as ts from "typescript/unstable/ast";
+import type { ClassLikeDeclaration, Expression, Node, SourceFile } from "typescript/unstable/ast";
+import { createVirtualFileSystem } from "typescript/unstable/fs";
+import { API } from "typescript/unstable/async";
+import { afterAll, describe, expect, it } from "vitest";
import { CliError } from "effect/unstable/cli";
// Vitest (via Vite) provides `import.meta.glob` at runtime; the workspace
@@ -44,7 +47,13 @@ import {
// The simple name of a call's callee: `TaggedError` for both `TaggedError(...)`
// and `Data.TaggedError(...)`.
-function calleeName(expression: ts.Expression): string {
+const parserFileSystem = createVirtualFileSystem({});
+const parserApi = new API({ cwd: process.cwd(), fs: parserFileSystem });
+let syntheticFileId = 0;
+
+afterAll(() => parserApi.close());
+
+function calleeName(expression: Expression): string {
if (ts.isIdentifier(expression)) return expression.text;
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
return "";
@@ -53,27 +62,57 @@ function calleeName(expression: ts.Expression): string {
// The value of a plain string literal, seeing through an `as const` assertion
// (`readonly code = "X" as const`). A computed or interpolated string cannot be
// resolved statically, and none exists in this workspace.
-function stringLiteralText(expression: ts.Expression | undefined): string | undefined {
+function stringLiteralText(expression: Expression | undefined): string | undefined {
const inner =
expression !== undefined && ts.isAsExpression(expression) ? expression.expression : expression;
return inner !== undefined && ts.isStringLiteral(inner) ? inner.text : undefined;
}
-function extendsExpression(node: ts.ClassLikeDeclaration): ts.Expression | undefined {
+function extendsExpression(node: ClassLikeDeclaration): Expression | undefined {
const clause = node.heritageClauses?.find((c) => c.token === ts.SyntaxKind.ExtendsKeyword);
return clause?.types[0]?.expression;
}
-function parse(fileName: string, source: string): ts.SourceFile {
- return ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
+async function withParsedSources(
+ sources: ReadonlyArray,
+ visit: (files: ReadonlyMap) => T,
+): Promise {
+ const normalizedSources = sources.map(
+ ([fileName, source]) => [resolve(fileName), source] as const,
+ );
+ for (const [fileName, source] of normalizedSources)
+ parserFileSystem.writeFile?.(fileName, source);
+ const snapshot = await parserApi.updateSnapshot({
+ openFiles: normalizedSources.map(([fileName]) => fileName),
+ fileChanges: { changed: normalizedSources.map(([fileName]) => fileName) },
+ });
+ try {
+ const files = new Map();
+ for (const [index, [originalFileName]] of sources.entries()) {
+ const normalized = normalizedSources[index];
+ if (normalized === undefined) throw new Error(`failed to normalize ${originalFileName}`);
+ const [fileName] = normalized;
+ const project = await snapshot.getDefaultProjectForFile(fileName);
+ const sourceFile = await project?.program.getSourceFile(fileName);
+ if (sourceFile === undefined) throw new Error(`failed to parse ${fileName}`);
+ files.set(originalFileName, sourceFile);
+ }
+ return visit(files);
+ } finally {
+ await snapshot.dispose();
+ }
+}
+
+async function withParsedSource(
+ fileName: string,
+ source: string,
+ visit: (file: SourceFile) => T,
+): Promise {
+ return withParsedSources([[fileName, source]], (files) => visit(files.get(fileName)!));
}
-function hasExportModifier(node: ts.Node): boolean {
- return (
- ts.canHaveModifiers(node) &&
- ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ===
- true
- );
+function hasExportModifier(node: ClassLikeDeclaration): boolean {
+ return node.modifiers?.some((modifier) => ts.isExportKeyword(modifier)) === true;
}
// Extracts the error identifiers a source file defines: the tag literal of
@@ -82,16 +121,26 @@ function hasExportModifier(node: ts.Node): boolean {
// every plain `class X extends Error` (untagged classes are fingerprinted by
// name). A tagged class contributes its tag once — the heritage call is
// claimed by the class rule so the factory rule does not count it again.
-function extractErrorTags(
+async function extractErrorTags(
source: string,
fileName = "scan.ts",
options: { readonly exportedOnly?: boolean } = {},
+): Promise> {
+ const parseFileName = fileName === "scan.ts" ? `scan-${syntheticFileId++}.ts` : fileName;
+ return withParsedSource(parseFileName, source, (sourceFile) =>
+ extractErrorTagsFromFile(sourceFile, options),
+ );
+}
+
+function extractErrorTagsFromFile(
+ sourceFile: SourceFile,
+ options: { readonly exportedOnly?: boolean },
): Array {
const tags: Array = [];
- const claimed = new Set();
+ const claimed = new Set();
- const visit = (node: ts.Node): void => {
- if (ts.isClassLike(node)) {
+ const visit = (node: Node): void => {
+ if (ts.isClassLikeDeclaration(node)) {
const heritage = extendsExpression(node);
if (heritage !== undefined && ts.isCallExpression(heritage)) {
const tag = calleeName(heritage.expression).endsWith("Error")
@@ -120,18 +169,19 @@ function extractErrorTags(
if (tag !== undefined) tags.push(tag);
}
- ts.forEachChild(node, visit);
+ node.forEachChild(visit);
};
- ts.forEachChild(parse(fileName, source), visit);
+ sourceFile.forEachChild(visit);
return tags;
}
-function scanErrorTags(
+async function scanErrorTags(
root: string,
options: { readonly exportedOnly?: boolean } = {},
-): Map> {
+): Promise
remarkImage now uses it in both
fumadocs-core and @fumadocs/satteri. Remote
images are no longer downloaded in full just to be measured, and
redirects are followed. Sizes are always in pixels, so an SVG sized in
em or pt is converted instead of being
skipped. Remote requests also time out after 30 seconds by default.
One behaviour difference worth knowing: the supported formats are
avif/heic/heif, bmp, gif, ico, jpeg, png, psd, svg, tiff and webp. Sizes
for jxl, tga, pnm, dds, icns, cur, ktx and jp2 can no longer be resolved
and go through onError instead.
Sequential scanning stops after 512 KB, but that never loses an
image: the one format that stores its dimensions past that point — TIFF
with a trailing IFD — is resolved by following the header's pointer with
a targeted read, using an HTTP Range request for remote
files (and skipping through the body when the server ignores
ranges).
Commits
Updates `fumadocs-ui` from 16.14.3 to 16.14.4
Release notes
Sourced from fumadocs-ui's
releases.
fumadocs@16.14.4
@fumadocs/base-ui@16.14.4
- fumadocs-core@16.14.4
- fumadocs-ui@16.14.4
Introduce @fumari/image-size, replacing
image-size in remarkImage
A fork of probe-image-size
with no dependencies of its own.
import { probe, imageSize } from
'@fumari/image-size';
await probe('./public/banner.png'); // { width: 1200, height: 630,
type: 'png', mime: 'image/png' }
await probe('https://example.com/banner.png',
{ timeout: 5000 });
imageSize(bytes); // the same result, or null
remarkImage now uses it in both
fumadocs-core and @fumadocs/satteri. Remote
images are no longer downloaded in full just to be measured, and
redirects are followed. Sizes are always in pixels, so an SVG sized in
em or pt is converted instead of being
skipped. Remote requests also time out after 30 seconds by default.
One behaviour difference worth knowing: the supported formats are
avif/heic/heif, bmp, gif, ico, jpeg, png, psd, svg, tiff and webp. Sizes
for jxl, tga, pnm, dds, icns, cur, ktx and jp2 can no longer be resolved
and go through onError instead.
Sequential scanning stops after 512 KB, but that never loses an
image: the one format that stores its dimensions past that point — TIFF
with a trailing IFD — is resolved by following the header's pointer with
a targeted read, using an HTTP Range request for remote
files (and skipping through the body when the server ignores
ranges).
Commits
Updates `next` from 16.3.0 to 16.3.1
Release notes
Sourced from next's
releases.
v16.3.1
What's Changed
Full Changelog: https://github.com/vercel/next.js/compare/v16.3.0...v16.3.1
v16.3.1-canary.26
Misc Changes
- docs: document deploymentId build ID override and Pages Router skew
in 16.2: #97645
- Upgrade React from
eb8feb71-20260814 to
eafeac09-20260819: #97636
- Turbopack: rename to use turbopack: no side effects: #94427
- refactor: move useDynamic{Route,Search}Params to reduce snapshot
churn: #97360
- [PPF] unstable_navigation(): #96908
- [PPF] Scaffold unstable_navigation(): #97236
- docs: Explicit cache output description: #97548
- Improve Cache Components sync IO migration guidance: #97572
- [test] Use a non-native stub for the server externals list test: #97614
- Avoid GitHub API rate limits for create-next-app examples: #97612
- [test] Cover the prerender worker-thread backend with an addon we
control: #97543
- [test] Convert the
prerender-native-module suite to
local fixture packages: #97542
- [test] Replace the
turbopack-reports
sqlite3 dependency with a local addon fixture: #97541
- [test] Drop the dead
sqlite3 build approval from the
sharp-basic suite: #97540
- [ci] Authenticate Turborepo remote caching with OIDC instead of a
static PAT: #97590
- Remove HmrTarget: #97253
- Keep HMR instructions typed until serialization: #96569
- Serialize frozen collections by value only: #96686
Credits
... (truncated)
Commits
3d32eb8
v16.3.1
2b4b1ec
[backport] Revert i18n localization change for dynamic Pages API routes
(#949...
228df5f
[backport] Retain fewer stale cache versions and use a TTL, plus the
mtime fa...
16eab3a
[backport] Fix: Optimistic routing bugs leading to repeated prefetch
loops (#...
75548c9
[backport] Fix Nav Inspector request loop on repeat captures (#97326)
f6bd714
[backport] [test] Compile the middleware redirect routes up front in dev
(#97...
43c1c60
[16.3] [ci] Use OIDC tokens to read private preview builds (#97258)
9dbb357
[backport] Encode the cache item name built by
unstable_cache (#97313)
323a17c
[backport] Discard only cache entries that predate a tag revalidation,
and re...
6d67f46
[backport] Keep the dev validation worker alive across HMR updates (#97315)
- Additional commits viewable in compare
view
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
apps/cli/package.json | 6 +-
apps/docs/package.json | 6 +-
pnpm-lock.yaml | 303 ++++++++++++++++++++---------------------
3 files changed, 157 insertions(+), 158 deletions(-)
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 8cac32db58..96706fd08e 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -43,8 +43,8 @@
"jose": "^6.2.8"
},
"devDependencies": {
- "@anthropic-ai/claude-agent-sdk": "^0.3.229",
- "@anthropic-ai/sdk": "^0.116.0",
+ "@anthropic-ai/claude-agent-sdk": "^0.3.232",
+ "@anthropic-ai/sdk": "^0.117.1",
"@clack/prompts": "^1.7.0",
"@effect/atom-react": "catalog:",
"@effect/platform-bun": "catalog:",
@@ -77,7 +77,7 @@
"oxlint-tsgolint": "catalog:",
"pg": "^8.23.0",
"pg-copy-streams": "^7.0.0",
- "posthog-node": "^5.48.2",
+ "posthog-node": "^5.49.0",
"react": "^19.2.8",
"react-devtools-core": "^7.0.1",
"semantic-release": "^25.0.9",
diff --git a/apps/docs/package.json b/apps/docs/package.json
index cd850092d0..8ccce26920 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -9,10 +9,10 @@
"types:check": "pnpm exec fumadocs-mdx source.config.ts .source && tsc --noEmit --incremental false"
},
"dependencies": {
- "fumadocs-core": "^16.14.3",
+ "fumadocs-core": "^16.14.4",
"fumadocs-mdx": "^15.2.3",
- "fumadocs-ui": "^16.14.3",
- "next": "16.3.0",
+ "fumadocs-ui": "^16.14.4",
+ "next": "16.3.1",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6312ce1a56..cb43a6cef0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -103,11 +103,11 @@ importers:
version: 6.2.8
devDependencies:
'@anthropic-ai/claude-agent-sdk':
- specifier: ^0.3.229
- version: 0.3.229(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)
+ specifier: ^0.3.232
+ version: 0.3.232(@anthropic-ai/sdk@0.117.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)
'@anthropic-ai/sdk':
- specifier: ^0.116.0
- version: 0.116.0(zod@4.4.3)
+ specifier: ^0.117.1
+ version: 0.117.1(zod@4.4.3)
'@clack/prompts':
specifier: ^1.7.0
version: 1.7.0
@@ -205,8 +205,8 @@ importers:
specifier: ^7.0.0
version: 7.0.0
posthog-node:
- specifier: ^5.48.2
- version: 5.48.2
+ specifier: ^5.49.0
+ version: 5.49.0
react:
specifier: ^19.2.8
version: 19.2.8
@@ -294,17 +294,17 @@ importers:
apps/docs:
dependencies:
fumadocs-core:
- specifier: ^16.14.3
- version: 16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3)
+ specifier: ^16.14.4
+ version: 16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3)
fumadocs-mdx:
specifier: ^15.2.3
- version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
+ version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))
fumadocs-ui:
- specifier: ^16.14.3
- version: 16.14.3(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ specifier: ^16.14.4
+ version: 16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next:
- specifier: 16.3.0
- version: 16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ specifier: 16.3.1
+ version: 16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react:
specifier: ^19.2.8
version: 19.2.8
@@ -582,60 +582,60 @@ packages:
resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==}
engines: {node: '>=18'}
- '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.229':
- resolution: {integrity: sha512-yVrJwSG9ur001ggDHKPUHlRuHKwbi2ETfby+4wlktRcQ5sAXqcSeS6B4T+IsXk8NDDHKXbCxpAPnCB5dz1Ac9g==}
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.232':
+ resolution: {integrity: sha512-+/4PX+dwmQAjlOlooocwa3kClulZfMo133xQH3LYDlK7D5bzze16lwlDGPVAYGEarpvXg7G5JK8QjfWAJ2HYbg==}
cpu: [arm64]
os: [darwin]
- '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.229':
- resolution: {integrity: sha512-MOhOwgh9fqnX/rqeVJzy+NNFx+xXX2Kej/jx33xTV+4J5aJ2H1SwjZ+r3f/k1MMQmPvkrE0GHnMYrpu4hfvZ5Q==}
+ '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.232':
+ resolution: {integrity: sha512-EHZ1Y3aGyZ2mFZ6QLR1bM3/HiIn2cLrPjU+k3/oCIW6omJFodfzf410aWjDPSnMj7CE4d6t7MSjBWekMUbcv0g==}
cpu: [x64]
os: [darwin]
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.229':
- resolution: {integrity: sha512-S0XcKhUXLFdEv1Pq+aYiV7fDlTa/n+bajBNkKv12eUzjCUFsM/TfSyU5shnkQg9y7NtoB4owK/rezm5hHD1ybg==}
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.232':
+ resolution: {integrity: sha512-XkLcb9UT/l42Rtw7KBApzgxUe/kwoWJ9KCPcVEnYojzIvVR+AwBCl/QReNU5+6c72w48dYBMmLebI64gQbN0tg==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.229':
- resolution: {integrity: sha512-xSPUmKNik7HEYsbdUFywbjvGzUth0P2KohetJixSOrBha63ZRun449ssEHU55VX3qH+YO5ENn129l5TGFzMNGQ==}
+ '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.232':
+ resolution: {integrity: sha512-wW2opwA5s7gLghjU6B2ADMAtoc7bAZMevUzi4g+1PXMJ8MGcPvbnx92EDYJrVDcZmAl1+fz19XyPsaShlisTzw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.229':
- resolution: {integrity: sha512-9gJsr0elKyV21ln+Mza5kYiSAsqSAWFiHuaT+MiXAycBK73UbzRVLBg87dajkGagg6H1u5ZtPSkqNXzA2xArmA==}
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.232':
+ resolution: {integrity: sha512-L1x2ge9NpXMLTczmT44TKPQ88PHE+gsCakQMVEOa8rXFvfBoqvcpxM0DT8wrqYWUHhQEYR8NXxQTP9a1NVRj8Q==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.229':
- resolution: {integrity: sha512-Cs0NxWVL/Up8kEh//P2cE+3VHrDxoSmWUP3EvI/GU+1RRGZPDD80UN1Rvl3TMC5QPMq+IYPjh1yncnxZ6j8aNw==}
+ '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.232':
+ resolution: {integrity: sha512-6Px1xDiwQyLkSxwRQ34/kPA8WMXQ2rHYGkwockND7+9yMw+ShI3AfLkyi9G7JtJHObWFT6B82eSHjDS/Fy+9hQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.229':
- resolution: {integrity: sha512-2fkljxQweZUa41RrlhLh+VdZrJ4kYzEBHGD8sc+Xy2bjkF8T4GTmsE8eKSWN3NfSCQOLHgK7Bxfm6+2n88WJMg==}
+ '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.232':
+ resolution: {integrity: sha512-fDiuwL5dm1elOy7fNp4Qmdor8so4R8npj2pUGQA1G/xKfJhaK236aTWezvZid3L/O7cRdFeN9IVofOAlWLQcsw==}
cpu: [arm64]
os: [win32]
- '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.229':
- resolution: {integrity: sha512-2M2/KHJTGqzduA8eRZDu1vWSFkMqKl9JZMqJmaJ2THIR2g7jBUAzK0B57UzkO9uQXoXWFH7P91OSKKGA4rwfwQ==}
+ '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.232':
+ resolution: {integrity: sha512-Hc/9uy1BI9mqKVyB1b/zoUnm3MFgtVNzQY6p5zgaq9DaIjCKDpR4a4L1aDZM4lMqUcWFMZM+u7juOhh+m39NBQ==}
cpu: [x64]
os: [win32]
- '@anthropic-ai/claude-agent-sdk@0.3.229':
- resolution: {integrity: sha512-ZxSFO7cNSz7jqsOmoGkBtvMT4JGWb2a8PUe6doAydZXH6mm6FsoUf+duSUm2rDrbv7OMoD8HDGwNZv5P3woE3w==}
+ '@anthropic-ai/claude-agent-sdk@0.3.232':
+ resolution: {integrity: sha512-8od7hJk9fZnF1/oYYiR9PvroGbZRQrpmNgKirjHNGoj5ur5YcAZLohI70XVUAUe3KvjB1msLxtkvmlAT9sqFAg==}
engines: {node: '>=18.0.0'}
peerDependencies:
'@anthropic-ai/sdk': '>=0.93.0'
'@modelcontextprotocol/sdk': ^1.29.0
zod: ^4.0.0
- '@anthropic-ai/sdk@0.116.0':
- resolution: {integrity: sha512-4UEapYQ+epLEMsAuLZDvW8ExVSOtHD8a7zTyLzhw0H9RXJ1eilPgmqhjwgcdg22diwx13spw6fJ4rONZ+bS7Ww==}
+ '@anthropic-ai/sdk@0.117.1':
+ resolution: {integrity: sha512-Yn2QlXfyCiKJ5YGCOOay7ZE78ISvII2XY621WMCiflmG8IYgwx59IBwPExxki3Xk9jKUtnD/Sj6UvplWr0rZxg==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
@@ -996,6 +996,9 @@ packages:
tailwindcss:
optional: true
+ '@fumari/image-size@0.1.0':
+ resolution: {integrity: sha512-x2o9u6P8uKUK15B8XgEoRhR3PgLoLSbQKK6FUCd14JzumEw+e8FXZPelij/dZ4VQVMp4r61VD3DcvSo3aEhLAA==}
+
'@hono/node-server@2.0.12':
resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==}
engines: {node: '>=20'}
@@ -1339,57 +1342,57 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
- '@next/env@16.3.0':
- resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==}
+ '@next/env@16.3.1':
+ resolution: {integrity: sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==}
- '@next/swc-darwin-arm64@16.3.0':
- resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==}
+ '@next/swc-darwin-arm64@16.3.1':
+ resolution: {integrity: sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.3.0':
- resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==}
+ '@next/swc-darwin-x64@16.3.1':
+ resolution: {integrity: sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.3.0':
- resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==}
+ '@next/swc-linux-arm64-gnu@16.3.1':
+ resolution: {integrity: sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.3.0':
- resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
+ '@next/swc-linux-arm64-musl@16.3.1':
+ resolution: {integrity: sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.3.0':
- resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
+ '@next/swc-linux-x64-gnu@16.3.1':
+ resolution: {integrity: sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.3.0':
- resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
+ '@next/swc-linux-x64-musl@16.3.1':
+ resolution: {integrity: sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.3.0':
- resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
+ '@next/swc-win32-arm64-msvc@16.3.1':
+ resolution: {integrity: sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.3.0':
- resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==}
+ '@next/swc-win32-x64-msvc@16.3.1':
+ resolution: {integrity: sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2142,11 +2145,11 @@ packages:
resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==}
engines: {node: '>=12'}
- '@posthog/core@1.47.1':
- resolution: {integrity: sha512-d38C5DulL3gCox4g0VGyb/Lhn548fBvj9f35LZGVasPspOs3jyApxROJyDXoWwIRivOjCOIShZQAdYkZVn9J3w==}
+ '@posthog/core@1.48.0':
+ resolution: {integrity: sha512-ezKjVLw9y3Q235PUY+2hRr5DN9t6j2jF1mFAvnvhCCu/Ha6/qBv3zmVJBaHu9PnqqSNtx2St3ggN8Z3TTu/12A==}
- '@posthog/types@1.403.0':
- resolution: {integrity: sha512-QbkO0epmdq38xhxwP214YRi0vgpZiXqhamaTjKT6kVGJYTtsE5qZ1GTgLp12IYehg/rd+whYYErH3/DeX8pj3Q==}
+ '@posthog/types@1.404.0':
+ resolution: {integrity: sha512-/Y1zKv8SdwkK725SkmgT5QVYnXE7Fi23SPDCZ5Ybu27gTQub4yMTKWuUQekW+gkSKBZ0LyYCQK52mhyMMigMBw==}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -2797,8 +2800,8 @@ packages:
'@opentelemetry/api':
optional: true
- '@swc/helpers@0.5.15':
- resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
+ '@swc/helpers@0.5.23':
+ resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@tsconfig/bun@1.0.10':
resolution: {integrity: sha512-5AV5YknQjNyoYzZ/8NG0dawqew/wH+x7ANiCfCIn29qo0cdbd1EryvFD1k5NSZWLBMOI/fGqMIaxi58GPIP9Cg==}
@@ -3390,8 +3393,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
- baseline-browser-mapping@2.11.13:
- resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==}
+ baseline-browser-mapping@2.11.14:
+ resolution: {integrity: sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -4176,15 +4179,12 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
- framer-motion@12.43.0:
- resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==}
+ framer-motion@13.1.0:
+ resolution: {integrity: sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==}
peerDependencies:
- '@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
- '@emotion/is-prop-valid':
- optional: true
react:
optional: true
react-dom:
@@ -4210,8 +4210,8 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
- fumadocs-core@16.14.3:
- resolution: {integrity: sha512-xoGy6YelmU8GD4RKUiSuraFnRW91DqBM328Gs/YasltLrnMDWgEaYMKAcrGLVrikpqJkFx+etHo8BcClOMNt+A==}
+ fumadocs-core@16.14.4:
+ resolution: {integrity: sha512-vD1gVDwYKATW54D3tD/jPcB7GGipJ8qPXa85gCV3HNhC8u8SkbJdvu/QYklo6gTpULy+J/Aj+5vlg96zE39+Yg==}
peerDependencies:
'@mdx-js/mdx': '*'
'@mixedbread/sdk': 0.x.x
@@ -4306,12 +4306,12 @@ packages:
vite:
optional: true
- fumadocs-ui@16.14.3:
- resolution: {integrity: sha512-ASL9BgFxSe6VrbQ60nxVfpnKBPboeADp330JQvyEAqR1U8uw0T1+Vko8ySXRAWbivdZfYK5fZbCh78EOAMTEqw==}
+ fumadocs-ui@16.14.4:
+ resolution: {integrity: sha512-EW3pRRqQ1G1/4RVTsEhCaJTvXGHCRe92hySyIb5fAecJ6MVO2TNymemqqB5mxmXQGxNSOprtEybrB2mR0yJc8g==}
peerDependencies:
'@types/mdx': '*'
'@types/react': '*'
- fumadocs-core: 16.14.3
+ fumadocs-core: 16.14.4
next: 16.x.x
react: ^19.2.0
react-dom: ^19.2.0
@@ -5274,21 +5274,18 @@ packages:
engines: {node: '>=10'}
hasBin: true
- motion-dom@12.43.0:
- resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==}
+ motion-dom@13.0.0:
+ resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==}
- motion-utils@12.39.0:
- resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
+ motion-utils@13.0.0:
+ resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==}
- motion@12.43.0:
- resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==}
+ motion@13.1.0:
+ resolution: {integrity: sha512-qtvscq59uCPdWnNW4SdSkrxR+BS/QYsa923bx7ocA+4p+ZGNbbVQwkSnG4aukB81QWjtl3AxX36plxNyZLmHCA==}
peerDependencies:
- '@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
- '@emotion/is-prop-valid':
- optional: true
react:
optional: true
react-dom:
@@ -5342,8 +5339,8 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- next@16.3.0:
- resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
+ next@16.3.1:
+ resolution: {integrity: sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -5849,8 +5846,8 @@ packages:
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
- posthog-node@5.48.2:
- resolution: {integrity: sha512-3Ni5upqpbUXL1QM2oU4sL9Y3OycDGgrKI9aJOGHbcF9xkPwR18cq481Tkvn6/CCPbl04p3tjdwQNkGHVveQcag==}
+ posthog-node@5.49.0:
+ resolution: {integrity: sha512-w3vPYmiWIWw0XlRDeRH0TbeRKnHvlQcU7xDwDN7jNm6JpoFQDUyValGjBpQ+Qvv6gmYUaNM2XBSJeeqcB1FtCQ==}
engines: {node: ^20.20.0 || >=22.22.0}
peerDependencies:
rxjs: ^7.0.0
@@ -7008,8 +7005,8 @@ packages:
yuku-ast@0.8.4:
resolution: {integrity: sha512-s7EWfWIQkaGmsGnyr/BU0jli9YTN5TvrKIsSmALyRD9elumDQInuhv0BrVObENKVCxr9W3Ikmnx5u02KvfuUmw==}
- zbsearch@3.3.4:
- resolution: {integrity: sha512-xGsv9rIwrili/fpLpVwmnCovEcvaAJg1ey+3Ur0+m3x1mnGoVO71iAwn4op420QLGNsQJNmScZjFq5TQ+cRi/g==}
+ zbsearch@4.0.0:
+ resolution: {integrity: sha512-gm4zfO31n2ZdruTTRQoWVWO4Q2+zrDt2GlrvIc+5JulRQNAm4IanCxER82vQ7Ug96FYkGqWUJBXFe1kGAcDWaQ==}
engines: {node: '>= 20.0.0'}
zod-to-json-schema@3.25.2:
@@ -7046,46 +7043,46 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
- '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.229':
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.232':
optional: true
- '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.229':
+ '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.232':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.229':
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.232':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.229':
+ '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.232':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.229':
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.232':
optional: true
- '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.229':
+ '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.232':
optional: true
- '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.229':
+ '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.232':
optional: true
- '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.229':
+ '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.232':
optional: true
- '@anthropic-ai/claude-agent-sdk@0.3.229(@anthropic-ai/sdk@0.116.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)':
+ '@anthropic-ai/claude-agent-sdk@0.3.232(@anthropic-ai/sdk@0.117.1(zod@4.4.3))(@modelcontextprotocol/sdk@1.30.0(zod@4.4.3))(zod@4.4.3)':
dependencies:
- '@anthropic-ai/sdk': 0.116.0(zod@4.4.3)
+ '@anthropic-ai/sdk': 0.117.1(zod@4.4.3)
'@modelcontextprotocol/sdk': 1.30.0(zod@4.4.3)
zod: 4.4.3
optionalDependencies:
- '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.229
- '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.229
- '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.229
- '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.229
- '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.229
- '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.229
- '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.229
- '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.229
+ '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.232
+ '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.232
+ '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.232
+ '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.232
+ '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.232
+ '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.232
+ '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.232
+ '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.232
- '@anthropic-ai/sdk@0.116.0(zod@4.4.3)':
+ '@anthropic-ai/sdk@0.117.1(zod@4.4.3)':
dependencies:
json-schema-to-ts: 3.1.1
standardwebhooks: 1.0.0
@@ -7449,6 +7446,8 @@ snapshots:
'@fumadocs/tailwind@0.1.1': {}
+ '@fumari/image-size@0.1.0': {}
+
'@hono/node-server@2.0.12(hono@4.12.32)':
dependencies:
hono: 4.12.32
@@ -7746,30 +7745,30 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
- '@next/env@16.3.0': {}
+ '@next/env@16.3.1': {}
- '@next/swc-darwin-arm64@16.3.0':
+ '@next/swc-darwin-arm64@16.3.1':
optional: true
- '@next/swc-darwin-x64@16.3.0':
+ '@next/swc-darwin-x64@16.3.1':
optional: true
- '@next/swc-linux-arm64-gnu@16.3.0':
+ '@next/swc-linux-arm64-gnu@16.3.1':
optional: true
- '@next/swc-linux-arm64-musl@16.3.0':
+ '@next/swc-linux-arm64-musl@16.3.1':
optional: true
- '@next/swc-linux-x64-gnu@16.3.0':
+ '@next/swc-linux-x64-gnu@16.3.1':
optional: true
- '@next/swc-linux-x64-musl@16.3.0':
+ '@next/swc-linux-x64-musl@16.3.1':
optional: true
- '@next/swc-win32-arm64-msvc@16.3.0':
+ '@next/swc-win32-arm64-msvc@16.3.1':
optional: true
- '@next/swc-win32-x64-msvc@16.3.0':
+ '@next/swc-win32-x64-msvc@16.3.1':
optional: true
'@noble/ciphers@1.3.0': {}
@@ -8233,11 +8232,11 @@ snapshots:
'@pnpm/network.ca-file': 1.0.2
config-chain: 1.1.13
- '@posthog/core@1.47.1':
+ '@posthog/core@1.48.0':
dependencies:
- '@posthog/types': 1.403.0
+ '@posthog/types': 1.404.0
- '@posthog/types@1.403.0': {}
+ '@posthog/types@1.404.0': {}
'@protobufjs/aspromise@1.1.2': {}
@@ -8860,7 +8859,7 @@ snapshots:
'@supabase/realtime-js': 2.112.3
'@supabase/storage-js': 2.112.3
- '@swc/helpers@0.5.15':
+ '@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
@@ -9437,7 +9436,7 @@ snapshots:
base64-js@1.5.1: {}
- baseline-browser-mapping@2.11.13: {}
+ baseline-browser-mapping@2.11.14: {}
bcrypt-pbkdf@1.0.2:
dependencies:
@@ -9504,7 +9503,7 @@ snapshots:
browserslist@4.28.6:
dependencies:
- baseline-browser-mapping: 2.11.13
+ baseline-browser-mapping: 2.11.14
caniuse-lite: 1.0.30001809
electron-to-chromium: 1.5.389
node-releases: 2.0.51
@@ -10304,10 +10303,10 @@ snapshots:
forwarded@0.2.0: {}
- framer-motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ framer-motion@13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
- motion-dom: 12.43.0
- motion-utils: 12.39.0
+ motion-dom: 13.0.0
+ motion-utils: 13.0.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.8
@@ -10328,8 +10327,9 @@ snapshots:
fsevents@2.3.3:
optional: true
- fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3):
+ fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3):
dependencies:
+ '@fumari/image-size': 0.1.0
estree-util-value-to-estree: 3.5.0
github-slugger: 2.0.0
hast-util-to-estree: 3.1.3
@@ -10347,7 +10347,7 @@ snapshots:
unist-util-visit: 5.1.0
vfile: 6.0.3
yaml: 2.9.0
- zbsearch: 3.3.4
+ zbsearch: 4.0.0
optionalDependencies:
'@mdx-js/mdx': 3.1.1
'@types/estree-jsx': 1.0.5
@@ -10355,21 +10355,21 @@ snapshots:
'@types/mdast': 4.0.4
'@types/react': 19.2.18
lucide-react: 1.31.0(react@19.2.8)
- next: 16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ next: 16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
zod: 4.4.3
transitivePeerDependencies:
- supports-color
- fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)):
+ fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rolldown@1.1.5)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)):
dependencies:
'@mdx-js/mdx': 3.1.1
'@standard-schema/spec': 1.1.0
chokidar: 5.0.0
esbuild: 0.28.2
estree-util-value-to-estree: 3.5.0
- fumadocs-core: 16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3)
+ fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3)
github-slugger: 2.0.0
magic-string: 1.1.0
mdast-util-mdx: 3.0.0
@@ -10388,14 +10388,14 @@ snapshots:
'@types/mdast': 4.0.4
'@types/mdx': 2.0.14
'@types/react': 19.2.18
- next: 16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ next: 16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
rolldown: 1.1.5
vite: 8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
- fumadocs-ui@16.14.3(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ fumadocs-ui@16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@fumadocs/tailwind': 0.1.1
@@ -10411,9 +10411,9 @@ snapshots:
'@radix-ui/react-tabs': 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
class-variance-authority: 0.7.1
cnfast: 0.1.0
- fumadocs-core: 16.14.3(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3)
+ fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3)
lucide-react: 1.31.0(react@19.2.8)
- motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ motion: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
@@ -10425,9 +10425,8 @@ snapshots:
optionalDependencies:
'@types/mdx': 2.0.14
'@types/react': 19.2.18
- next: 16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ next: 16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
transitivePeerDependencies:
- - '@emotion/is-prop-valid'
- '@types/react-dom'
- tailwindcss
@@ -11667,15 +11666,15 @@ snapshots:
mkdirp@1.0.4: {}
- motion-dom@12.43.0:
+ motion-dom@13.0.0:
dependencies:
- motion-utils: 12.39.0
+ motion-utils: 13.0.0
- motion-utils@12.39.0: {}
+ motion-utils@13.0.0: {}
- motion@12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ motion@13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
- framer-motion: 12.43.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ framer-motion: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
tslib: 2.8.1
optionalDependencies:
react: 19.2.8
@@ -11726,25 +11725,25 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
- next@16.3.0(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ next@16.3.1(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
- '@next/env': 16.3.0
- '@swc/helpers': 0.5.15
- baseline-browser-mapping: 2.11.13
+ '@next/env': 16.3.1
+ '@swc/helpers': 0.5.23
+ baseline-browser-mapping: 2.11.14
caniuse-lite: 1.0.30001809
postcss: 8.5.23
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
styled-jsx: 5.1.6(react@19.2.8)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.3.0
- '@next/swc-darwin-x64': 16.3.0
- '@next/swc-linux-arm64-gnu': 16.3.0
- '@next/swc-linux-arm64-musl': 16.3.0
- '@next/swc-linux-x64-gnu': 16.3.0
- '@next/swc-linux-x64-musl': 16.3.0
- '@next/swc-win32-arm64-msvc': 16.3.0
- '@next/swc-win32-x64-msvc': 16.3.0
+ '@next/swc-darwin-arm64': 16.3.1
+ '@next/swc-darwin-x64': 16.3.1
+ '@next/swc-linux-arm64-gnu': 16.3.1
+ '@next/swc-linux-arm64-musl': 16.3.1
+ '@next/swc-linux-x64-gnu': 16.3.1
+ '@next/swc-linux-x64-musl': 16.3.1
+ '@next/swc-win32-arm64-msvc': 16.3.1
+ '@next/swc-win32-x64-msvc': 16.3.1
sharp: 0.35.3(@types/node@26.2.0)
transitivePeerDependencies:
- '@babel/core'
@@ -12358,9 +12357,9 @@ snapshots:
postgres-range@1.1.4: {}
- posthog-node@5.48.2:
+ posthog-node@5.49.0:
dependencies:
- '@posthog/core': 1.47.1
+ '@posthog/core': 1.48.0
pretty-ms@9.3.0:
dependencies:
@@ -13676,7 +13675,7 @@ snapshots:
dependencies:
'@yuku-toolchain/types': 0.8.4
- zbsearch@3.3.4: {}
+ zbsearch@4.0.0: {}
zod-to-json-schema@3.25.2(zod@4.4.3):
dependencies:
From d82c63764fd532cf5dca46d520e478d5452f41cf Mon Sep 17 00:00:00 2001
From: Julien Goux
Date: Fri, 21 Aug 2026 16:43:04 +0000
Subject: [PATCH 21/63] chore: update Bun to 1.4.0 (#6293)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- update the local and CI Bun runtime pins to 1.4.0
- refresh the generated Mise lock data and Bun type definitions
- keep Istanbul coverage in place until Bun’s V8 coverage fix ships in a
later release
## Context
Bun 1.4.0’s V8 coverage path currently fails against this suite because
it emits invalid zero-length ranges. This update therefore leaves the
existing Istanbul provider unchanged and limits the change to the
runtime and tooling upgrade.
## Binary size comparison
Uncompressed executable sizes from the [successful preview
build](https://github.com/supabase/cli/actions/runs/32490402284/job/96800354277),
compared with the current production
[v2.115.0](https://github.com/supabase/cli/releases/tag/v2.115.0)
platform packages:
| Platform | Preview Bun 1.4 | Production | Difference |
|---|---:|---:|---:|
| macOS arm64 | 69.30 MiB | 68.45 MiB | +0.85 MiB (+1.24%) |
| macOS x64 | 75.79 MiB | 73.44 MiB | +2.35 MiB (+3.20%) |
| Linux arm64, glibc | 91.74 MiB | 109.69 MiB | −17.95 MiB (−16.37%) |
| Linux arm64, musl | 85.07 MiB | 104.13 MiB | −19.06 MiB (−18.31%) |
| Linux x64, glibc | 92.36 MiB | 109.85 MiB | −17.49 MiB (−15.92%) |
| Linux x64, musl | 86.39 MiB | 104.94 MiB | −18.55 MiB (−17.68%) |
| Windows arm64 | 84.28 MiB | 118.51 MiB | −34.23 MiB (−28.88%) |
| Windows x64 | 94.30 MiB | 121.08 MiB | −26.78 MiB (−22.12%) |
Bun 1.4 reduces the Linux and Windows binaries by roughly 16–29%, while
the macOS binaries increase by 1–3%.
---------
Co-authored-by: kanad
---
.bun-version | 2 +-
.github/workflows/contribution-gate.yml | 4 +--
mise.lock | 46 ++++++++++++-------------
pnpm-lock.yaml | 32 ++++++++---------
pnpm-workspace.yaml | 4 ++-
5 files changed, 45 insertions(+), 43 deletions(-)
diff --git a/.bun-version b/.bun-version
index 7962dcfdb6..88c5fb891d 100644
--- a/.bun-version
+++ b/.bun-version
@@ -1 +1 @@
-1.3.13
+1.4.0
diff --git a/.github/workflows/contribution-gate.yml b/.github/workflows/contribution-gate.yml
index 68d3f3b639..af7cab6ac5 100644
--- a/.github/workflows/contribution-gate.yml
+++ b/.github/workflows/contribution-gate.yml
@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: "1.3.13"
+ bun-version-file: ".bun-version"
- name: Evaluate contribution gate
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -65,7 +65,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: "1.3.13"
+ bun-version-file: ".bun-version"
- name: Evaluate contribution gate across all open PRs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/mise.lock b/mise.lock
index 02d24bb308..b249350cfb 100644
--- a/mise.lock
+++ b/mise.lock
@@ -1,52 +1,52 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.bun]]
-version = "1.3.13"
+version = "1.4.0"
backend = "core:bun"
[tools.bun."platforms.linux-arm64"]
-checksum = "sha256:70bae41b3908b0a120e1e58c5c8af30e74afae3b8d11b0d3fdd8e787ddfb4b22"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-aarch64.zip"
+checksum = "sha256:4b1a332ee861983eb93bcfe6f770fff94e3e31b2c388bdaea3c8ed35e58eed0e"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-aarch64.zip"
[tools.bun."platforms.linux-arm64-musl"]
-checksum = "sha256:5385e978107ce4934298d8d6afe9bfbb898683f6cc23e6753a0da60bc60c5b81"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-aarch64-musl.zip"
+checksum = "sha256:576300ce33ff16ffcd455bf178c2f095f9df845c6cc3d0284ba1c96ca0e80473"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-aarch64-musl.zip"
[tools.bun."platforms.linux-x64"]
-checksum = "sha256:79c0771fa8b92c33aae41e15a0e0d307ea99d0e2f00317c71c6c53237a78e25a"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-x64.zip"
+checksum = "sha256:2d03fb5fb83ac8b567aca0a281b2ce1a1a19d488f56c2968d88c3f25e92fe452"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64.zip"
[tools.bun."platforms.linux-x64-baseline"]
-checksum = "sha256:9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-x64-baseline.zip"
+checksum = "sha256:184fb4595f0d401a217cf7c78c1bc430ba83314dab7a8b94805babbf7fa7097f"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64-baseline.zip"
[tools.bun."platforms.linux-x64-musl"]
-checksum = "sha256:5b91a48f0b00df9fd2da8bff1a795d2659d842da966432969203f25da19d1c74"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-x64-musl.zip"
+checksum = "sha256:83b5f12fd258dd8d4fdcaea65ede954366aa717dab399e20093ecab280d54e7a"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64-musl.zip"
[tools.bun."platforms.linux-x64-musl-baseline"]
-checksum = "sha256:88ca7c7ad235b498f549eea2f770f434e9f0f5e9ba95168a2d3a1f235184c394"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-linux-x64-musl-baseline.zip"
+checksum = "sha256:618c4bc1f94b02337ee210003c0b7c066f11548a8cdc5109df10db043dc47ca2"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64-musl-baseline.zip"
[tools.bun."platforms.macos-arm64"]
-checksum = "sha256:5467e3f65dba526b9fea98f0cce04efafc0c63e169733ec27b876a3ad32da190"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-darwin-aarch64.zip"
+checksum = "sha256:c669e97f6164e1c96e0701748db98dfa77492908cbd8394c7557134a735de381"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-darwin-aarch64.zip"
[tools.bun."platforms.macos-x64"]
-checksum = "sha256:e5a6c8b64f419925232d111ecb13e25f0abf55e54f792341f987623fd0778009"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-darwin-x64.zip"
+checksum = "sha256:1d0211b8f1dc991182344687ad15e72ee86f154845a5f7fa477994cd341dd9b0"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-darwin-x64.zip"
[tools.bun."platforms.macos-x64-baseline"]
-checksum = "sha256:a98ba6a480f22fda9b343626b906a4e26aa53618bf85d2bc5928ecf2ba45f0ed"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-darwin-x64-baseline.zip"
+checksum = "sha256:da9b9f1b4ba766c6f299711f38dfaa98623e1ed9c40896aa53db803c52ec1fa0"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-darwin-x64-baseline.zip"
[tools.bun."platforms.windows-x64"]
-checksum = "sha256:85b14f3e0584218e9b63407b3aa6b90c4835ec5c32435c1f12cb6fc13667c7c9"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-windows-x64.zip"
+checksum = "sha256:e6f093d39da486b20262ca8cdd5ed6a9e8bc9c2f275b78e6d3a0c5b28cc95901"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-windows-x64.zip"
[tools.bun."platforms.windows-x64-baseline"]
-checksum = "sha256:c68c7903c1190101590cc1b2129835f47211b3b37ae87759f2b97d6534aa3ad1"
-url = "https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-windows-x64-baseline.zip"
+checksum = "sha256:b929c54a9badb104a16dedd23aab6152c86793ae653d4e6b13983ffd0c882a66"
+url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-windows-x64-baseline.zip"
[[tools.go]]
version = "1.26.5"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cb43a6cef0..3389356a6d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -28,8 +28,8 @@ catalogs:
specifier: ^1.0.10
version: 1.0.10
'@types/bun':
- specifier: ^1.3.14
- version: 1.3.14
+ specifier: ^1.4.0
+ version: 1.4.0
'@vitest/coverage-istanbul':
specifier: ^4.1.10
version: 4.1.10
@@ -155,7 +155,7 @@ importers:
version: 1.0.10
'@types/bun':
specifier: 'catalog:'
- version: 1.3.14
+ version: 1.4.0
'@types/pg':
specifier: ^8.21.0
version: 8.21.0
@@ -268,7 +268,7 @@ importers:
version: 1.0.10
'@types/bun':
specifier: 'catalog:'
- version: 1.3.14
+ version: 1.4.0
'@vitest/coverage-istanbul':
specifier: 'catalog:'
version: 4.1.10(vitest@4.1.10)
@@ -348,7 +348,7 @@ importers:
version: 1.0.10
'@types/bun':
specifier: 'catalog:'
- version: 1.3.14
+ version: 1.4.0
'@vitest/coverage-istanbul':
specifier: 'catalog:'
version: 4.1.10(vitest@4.1.10)
@@ -390,7 +390,7 @@ importers:
version: 1.0.10
'@types/bun':
specifier: 'catalog:'
- version: 1.3.14
+ version: 1.4.0
'@vitest/coverage-istanbul':
specifier: 'catalog:'
version: 4.1.10(vitest@4.1.10)
@@ -440,7 +440,7 @@ importers:
version: 1.0.10
'@types/bun':
specifier: 'catalog:'
- version: 1.3.14
+ version: 1.4.0
'@vitest/coverage-istanbul':
specifier: 'catalog:'
version: 4.1.10(vitest@4.1.10)
@@ -480,7 +480,7 @@ importers:
version: 1.0.10
'@types/bun':
specifier: 'catalog:'
- version: 1.3.14
+ version: 1.4.0
'@vitest/coverage-istanbul':
specifier: 'catalog:'
version: 4.1.10(vitest@4.1.10)
@@ -529,7 +529,7 @@ importers:
version: 1.0.10
'@types/bun':
specifier: 'catalog:'
- version: 1.3.14
+ version: 1.4.0
'@vitest/coverage-istanbul':
specifier: 'catalog:'
version: 4.1.10(vitest@4.1.10)
@@ -2812,8 +2812,8 @@ packages:
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
- '@types/bun@1.3.14':
- resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==}
+ '@types/bun@1.4.0':
+ resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -3453,8 +3453,8 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
- bun-types@1.3.14:
- resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==}
+ bun-types@1.4.0:
+ resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
@@ -8874,9 +8874,9 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@types/bun@1.3.14':
+ '@types/bun@1.4.0':
dependencies:
- bun-types: 1.3.14
+ bun-types: 1.4.0
'@types/chai@5.2.3':
dependencies:
@@ -9523,7 +9523,7 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
- bun-types@1.3.14:
+ bun-types@1.4.0:
dependencies:
'@types/node': 26.2.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d35f877dfd..8d24ddba32 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -19,7 +19,7 @@ catalog:
"@effect/vitest": "4.0.0-rc.111"
"@nx/devkit": "^23.1.1"
"@tsconfig/bun": "^1.0.10"
- "@types/bun": "^1.3.14"
+ "@types/bun": "^1.4.0"
"typescript": "^7.0.2"
"@vitest/coverage-istanbul": "^4.1.10"
"effect": "4.0.0-rc.111"
@@ -47,6 +47,8 @@ minimumReleaseAgeExclude:
- "@effect/vitest@4.0.0-rc.111"
- "@supabase/pg-delta@1.0.0-alpha.42"
- "@supabase/pg-topo@1.0.0-alpha.5"
+ - "@types/bun@1.4.0"
+ - "bun-types@1.4.0"
- "effect@4.0.0-rc.111"
supportedArchitectures:
From d9c74009f319a72b2a2fc63882456e15b3bfe266 Mon Sep 17 00:00:00 2001
From: "supabase-cli-releaser[bot]"
<246109035+supabase-cli-releaser[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 19:51:34 +0000
Subject: [PATCH 22/63] chore(api): sync Management API OpenAPI spec (#6288)
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>
Co-authored-by: Julien Goux
---
packages/api/src/generated/contracts.ts | 15 +++++++++++++++
packages/api/src/generated/openapi.json | 13 ++++++++++++-
packages/api/src/internal/client.unit.test.ts | 2 ++
3 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts
index 2a41780876..5ab5af98c4 100644
--- a/packages/api/src/generated/contracts.ts
+++ b/packages/api/src/generated/contracts.ts
@@ -10812,6 +10812,21 @@ export const V2GetProjectConfigOutput = Schema.Struct({
id: Schema.String.annotate({ description: "Project ref." }),
attributes: Schema.Struct({
database: Schema.Struct({
+ major_version: Schema.Number.annotate({
+ description:
+ "The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version.",
+ })
+ .check(Schema.isInt().annotate({ expected: "an integer" }))
+ .check(
+ Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({
+ expected: "a value greater than or equal to -9007199254740991",
+ }),
+ )
+ .check(
+ Schema.isLessThanOrEqualTo(9007199254740991).annotate({
+ expected: "a value less than or equal to 9007199254740991",
+ }),
+ ),
ssl_enforced: Schema.Boolean.annotate({
description: "Whether the database rejects plaintext connections",
}),
diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json
index f6f70837da..e3d168ede2 100644
--- a/packages/api/src/generated/openapi.json
+++ b/packages/api/src/generated/openapi.json
@@ -24029,6 +24029,12 @@
"database": {
"type": "object",
"properties": {
+ "major_version": {
+ "type": "integer",
+ "minimum": -9007199254740991,
+ "maximum": 9007199254740991,
+ "description": "The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version."
+ },
"ssl_enforced": {
"type": "boolean",
"description": "Whether the database rejects plaintext connections"
@@ -24222,7 +24228,12 @@
"description": "Postgres parameter overrides. Empty when the project runs entirely on defaults."
}
},
- "required": ["ssl_enforced", "network_restrictions", "postgres_settings"]
+ "required": [
+ "major_version",
+ "ssl_enforced",
+ "network_restrictions",
+ "postgres_settings"
+ ]
},
"pooler": {
"type": "object",
diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts
index cda2b8fd61..893dd619b4 100644
--- a/packages/api/src/internal/client.unit.test.ts
+++ b/packages/api/src/internal/client.unit.test.ts
@@ -1071,6 +1071,7 @@ describe("makeSupabaseApiClient", () => {
id: "abcdefghijklmnopqrst",
attributes: {
database: {
+ major_version: 17,
ssl_enforced: true,
network_restrictions: {
entitlement: "disallowed",
@@ -1140,6 +1141,7 @@ describe("makeSupabaseApiClient", () => {
);
expect(result.data.attributes.database.network_restrictions.entitlement).toBe("disallowed");
+ expect(result.data.attributes.database.major_version).toBe(17);
expect(result.data.attributes.storage.upstream_target).toBe("main");
expect(result.data.attributes.api.db_pool).toBeNull();
});
From 0e86191ef056b5dd2b02367e3a275b1bbcc00890 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 19:52:05 +0000
Subject: [PATCH 23/63] fix(docker): bump supabase/postgres from 17.6.1.159 to
17.6.1.165 in /apps/cli-go/pkg/config/templates (#6258)
Bumps supabase/postgres from 17.6.1.159 to 17.6.1.165.
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
apps/cli-go/pkg/config/templates/Dockerfile | 2 +-
packages/stack/src/ServiceCatalog.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile
index c18881e4db..3276c1a2ef 100644
--- a/apps/cli-go/pkg/config/templates/Dockerfile
+++ b/apps/cli-go/pkg/config/templates/Dockerfile
@@ -1,5 +1,5 @@
# Exposed for updates by .github/dependabot.yml
-FROM supabase/postgres:17.6.1.159 AS pg
+FROM supabase/postgres:17.6.1.165 AS pg
# Append to ServiceImages when adding new dependencies below
FROM library/kong:2.8.1 AS kong
FROM axllent/mailpit:v1.30.2 AS mailpit
diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts
index ef6f7844bd..32c4cd5eaa 100644
--- a/packages/stack/src/ServiceCatalog.ts
+++ b/packages/stack/src/ServiceCatalog.ts
@@ -108,7 +108,7 @@ export const SERVICE_CATALOG = {
postgres: {
name: "postgres",
configKey: "postgres",
- defaultVersion: "17.6.1.159",
+ defaultVersion: "17.6.1.165",
runtimeSupport: "native-preferred",
artifact: {
docker: { ownership: "supabase", repository: "postgres" },
From 28da80991a6c1c10a55afa48027def75a1c75743 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 21 Aug 2026 19:52:30 +0000
Subject: [PATCH 24/63] fix(docker): bump the docker-minor group in
/apps/cli-go/pkg/config/templates with 4 updates (#6257)
Bumps the docker-minor group in /apps/cli-go/pkg/config/templates with 4
updates: supabase/gotrue, supabase/realtime, supabase/storage-api and
supabase/logflare.
Updates `supabase/gotrue` from v2.195.0 to v2.196.0
Updates `supabase/realtime` from v2.129.0 to v2.129.3
Updates `supabase/storage-api` from v1.69.11 to v1.70.3
Updates `supabase/logflare` from 1.50.2 to 1.50.4
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
apps/cli-go/pkg/config/templates/Dockerfile | 8 ++++----
packages/stack/src/ServiceCatalog.ts | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile
index 3276c1a2ef..f24a2d1043 100644
--- a/apps/cli-go/pkg/config/templates/Dockerfile
+++ b/apps/cli-go/pkg/config/templates/Dockerfile
@@ -10,10 +10,10 @@ FROM darthsim/imgproxy:v3.8.0 AS imgproxy
FROM supabase/edge-runtime:v1.74.3 AS edgeruntime
FROM timberio/vector:0.53.0-alpine AS vector
FROM supabase/supavisor:2.9.7 AS supavisor
-FROM supabase/gotrue:v2.195.0 AS gotrue
-FROM supabase/realtime:v2.129.0 AS realtime
-FROM supabase/storage-api:v1.69.11 AS storage
-FROM supabase/logflare:1.50.2 AS logflare
+FROM supabase/gotrue:v2.196.0 AS gotrue
+FROM supabase/realtime:v2.129.3 AS realtime
+FROM supabase/storage-api:v1.70.3 AS storage
+FROM supabase/logflare:1.50.4 AS logflare
# Append to JobImages when adding new dependencies below
FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ
FROM supabase/migra:3.0.1663481299 AS migra
diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts
index 32c4cd5eaa..fb8c5ea740 100644
--- a/packages/stack/src/ServiceCatalog.ts
+++ b/packages/stack/src/ServiceCatalog.ts
@@ -155,7 +155,7 @@ export const SERVICE_CATALOG = {
auth: {
name: "auth",
configKey: "auth",
- defaultVersion: "2.195.0",
+ defaultVersion: "2.196.0",
runtimeSupport: "native-preferred",
artifact: {
docker: { ownership: "supabase", repository: "gotrue", tagPrefix: "v" },
@@ -201,7 +201,7 @@ export const SERVICE_CATALOG = {
realtime: {
name: "realtime",
configKey: "realtime",
- defaultVersion: "2.129.0",
+ defaultVersion: "2.129.3",
runtimeSupport: "docker-only",
artifact: { docker: { ownership: "supabase", repository: "realtime", tagPrefix: "v" } },
activation: { startup: "eager", activates: [], owns: [] },
@@ -210,7 +210,7 @@ export const SERVICE_CATALOG = {
storage: {
name: "storage",
configKey: "storage",
- defaultVersion: "1.69.11",
+ defaultVersion: "1.70.3",
runtimeSupport: "docker-only",
artifact: { docker: { ownership: "supabase", repository: "storage-api", tagPrefix: "v" } },
activation: { startup: "lazy", activates: ["imgproxy"], owns: ["imgproxy"] },
@@ -257,7 +257,7 @@ export const SERVICE_CATALOG = {
analytics: {
name: "analytics",
configKey: "analytics",
- defaultVersion: "1.50.2",
+ defaultVersion: "1.50.4",
runtimeSupport: "docker-only",
artifact: { docker: { ownership: "supabase", repository: "logflare" } },
activation: { startup: "lazy", activates: ["vector"], owns: ["vector"] },
From 803e9ffebb8d1c879d0ee790400c099e6410ceb8 Mon Sep 17 00:00:00 2001
From: Julien Goux
Date: Fri, 21 Aug 2026 22:12:40 +0000
Subject: [PATCH 25/63] feat(stack): prepare slim resources on demand (#6250)
## Summary
- make the `@supabase/stack` runtime core Effect-native across Node and
Bun, with Promise adapters only at the public non-Effect edge
- add package-owned `off`, `lazy`, and `eager` preparation policies with
dependency closure, bounded concurrency, coalesced work, and
deterministic disposal
- resolve native slim-service releases through exact manifests and
checksums, asynchronous in-process zstd decompression, host
compatibility gates, typed post-processing, and atomic cache publication
- select one exclusive native or Docker/Podman runtime, persist that
concrete selection for managed stacks, and use it consistently for
preparation, execution, logs, and cleanup
- centralize managed identity, lifecycle documents, sticky port intent,
control ownership, stale-owner recovery, and detached supervision in one
manager and lifecycle facade
- harden process-compose lifecycle transitions with Effect fibers,
scopes, semaphores, typed failures, and interruption-safe resource
cleanup
## Context
Direct and managed stacks now share one service graph, preparation
pipeline, port allocator, proxy, and process lifecycle. Direct callers
own a scoped in-process handle. Managed callers use a detached
supervisor with one durable document and one loopback control owner; CLI
handlers delegate to that facade instead of maintaining PID-based
liveness or parallel metadata.
When mode is omitted, selection prefers a usable Docker or Podman
runtime. If neither is usable on a supported host, the first launch
selects native mode and disables Docker-only services before port
planning or managed state acquisition. Explicit native and Docker
choices remain strict, preparation never falls back across modes, and a
managed stack pins its selected runtime after ownership is acquired.
Changing modes requires deleting and recreating that private managed
stack state.
Preparation is driven by the service catalog. Eager resources are
prepared during stack start, lazy resources use the same activation path
as proxy and programmatic callers, and concurrent requests join the same
owned work. Native archives are verified before extraction, decompressed
through an interruptible callback boundary, validated for the current
host, post-processed with checked exit codes, and published from private
staging through atomic rename. Docker services resolve one canonical
GHCR image and retry only classified transient pull failures.
Port allocation owns bound sockets until each runtime consumer takes
over. Explicit ports remain exact; automatic managed assignments remain
sticky and are coordinated with per-user claims. Managed control uses a
deterministic sequence of eight loopback candidates, read-only probes
never claim ownership, and mutations fail closed when no unambiguous
owner or free candidate exists.
Native mode currently supports Postgres, Auth, and PostgREST.
Docker-only services remain container-backed. On Linux, Docker Postgres
performs only the required image setup as root and then drops to the
host UID before touching bind-mounted data. Database bootstrap is a
resumable observable one-shot dependency, and cleanup is scoped to exact
owned containers, ports, processes, and auto-managed paths.
The managed document and identity markers are private unreleased state.
This change intentionally implements the current model directly without
legacy migration or compatibility adapters, while preserving fail-loud
ownership and destructive-cleanup safeguards.
---
AGENTS.md | 127 +-
.../commands/sso/add/add.integration.test.ts | 6 +-
...gacy-pgdelta-ssl-probe.integration.test.ts | 3 +-
.../branches/switch/switch.handler.ts | 25 +-
.../functions/dev/functions-dev-runtime.ts | 12 +-
.../src/next/commands/logs/logs.e2e.test.ts | 111 --
.../service-version-overrides.unit.test.ts | 18 +-
.../src/next/commands/start/start.command.ts | 19 +-
.../src/next/commands/start/start.e2e.test.ts | 70 -
.../src/next/commands/start/start.handler.ts | 3 +-
.../commands/start/start.integration.test.ts | 2 +-
.../next/commands/status/status.e2e.test.ts | 57 -
.../next/commands/status/status.handler.ts | 12 +-
.../src/next/commands/stop/stop.e2e.test.ts | 88 --
.../commands/stop/stop.integration.test.ts | 15 +-
.../next/commands/update/update.handler.ts | 7 +-
apps/cli/src/next/config/stack-config.ts | 26 +-
.../src/next/config/stack-config.unit.test.ts | 34 +-
.../runtime/stack-e2e-cleanup.unit.test.ts | 39 +
.../error-actionability-coverage.unit.test.ts | 2 +-
.../shared/telemetry/error-actionability.ts | 7 +
apps/cli/tests/helpers/running-stack.ts | 7 +-
apps/cli/tests/helpers/stack-e2e-cleanup.ts | 6 +-
...7-simplified-managed-stack-architecture.md | 30 +-
packages/process-compose/docs/architecture.md | 23 +-
packages/process-compose/src/HealthProbe.ts | 29 +-
.../src/HealthProbe.unit.test.ts | 35 +-
packages/process-compose/src/LogBuffer.ts | 11 +-
.../src/LogBuffer.unit.test.ts | 61 +-
.../src/Orchestrator.integration.test.ts | 222 +--
packages/process-compose/src/Orchestrator.ts | 117 +-
.../src/Orchestrator.unit.test.ts | 75 +-
.../process-compose/src/RestartDecision.ts | 8 +-
.../src/RestartDecision.unit.test.ts | 18 +-
.../process-compose/src/ServiceTransition.ts | 193 +--
.../src/ServiceTransition.unit.test.ts | 22 +-
.../src/SupervisorRuntime.unit.test.ts | 96 ++
.../process-compose/src/errors.unit.test.ts | 9 +-
.../process-compose/src/supervisor-runtime.ts | 414 +++---
.../process-compose/tests/helpers/mocks.ts | 8 +-
packages/stack/README.md | 11 +-
packages/stack/docs/architecture.md | 122 +-
packages/stack/src/ApiProxy.ts | 8 +-
packages/stack/src/ApiProxy.unit.test.ts | 8 +-
.../src/BinaryResolver.integration.test.ts | 976 +++++++++++---
packages/stack/src/BinaryResolver.ts | 902 ++++++++++---
.../stack/src/BinaryResolver.unit.test.ts | 113 +-
.../src/ContainerRuntime.integration.test.ts | 106 ++
packages/stack/src/ContainerRuntime.ts | 100 ++
packages/stack/src/DaemonProtocol.ts | 2 +
.../src/DaemonServer.integration.test.ts | 13 +-
packages/stack/src/DaemonServer.ts | 67 +-
.../HttpTransportClient.integration.test.ts | 97 ++
packages/stack/src/HttpTransportClient.ts | 11 +-
packages/stack/src/LocalStack.ts | 563 +++++---
packages/stack/src/Platform.ts | 40 +-
packages/stack/src/Platform.unit.test.ts | 73 +-
.../src/PortAllocator.integration.test.ts | 310 +++--
packages/stack/src/PortAllocator.ts | 771 ++++++++---
packages/stack/src/PortAllocator.unit.test.ts | 195 ---
.../stack/src/RemoteStack.integration.test.ts | 170 ++-
packages/stack/src/RemoteStack.ts | 182 ++-
packages/stack/src/ServiceActivation.ts | 7 +-
.../stack/src/ServiceActivation.unit.test.ts | 3 +-
packages/stack/src/ServiceCatalog.ts | 292 ++--
packages/stack/src/ServicePorts.ts | 8 +-
packages/stack/src/Stack.ts | 28 +-
packages/stack/src/Stack.unit.test.ts | 1189 ++++++++++++++---
packages/stack/src/StackBuilder.ts | 298 +++--
packages/stack/src/StackBuilder.unit.test.ts | 317 ++---
packages/stack/src/StackConfig.ts | 17 +-
.../StackConfigResolver.policy.unit.test.ts | 125 ++
packages/stack/src/StackConfigResolver.ts | 981 +++++++++-----
packages/stack/src/StackPreparation.ts | 465 ++++---
packages/stack/src/bun.ts | 33 +-
packages/stack/src/cleanup.ts | 101 +-
packages/stack/src/cleanup.unit.test.ts | 72 +
.../stack/src/createStack.integration.test.ts | 322 +++--
packages/stack/src/createStack.ts | 361 +++--
packages/stack/src/createStack.unit.test.ts | 297 ++--
packages/stack/src/discovery.ts | 32 +-
packages/stack/src/effect-bun.ts | 2 +-
packages/stack/src/effect-node.ts | 2 +-
packages/stack/src/effect.ts | 29 +-
packages/stack/src/errors.ts | 125 +-
packages/stack/src/functions.unit.test.ts | 80 +-
packages/stack/src/index.ts | 7 +-
packages/stack/src/layers.ts | 4 +-
.../src/managed-control.integration.test.ts | 99 +-
.../managed-environment.integration.test.ts | 6 +-
...aged-manager-lifecycle.integration.test.ts | 139 +-
.../managed-manager-ports.integration.test.ts | 92 +-
...naged-manager-projects.integration.test.ts | 27 +-
...naged-manager-recovery.integration.test.ts | 68 +-
packages/stack/src/managed-paths.unit.test.ts | 200 +--
.../src/managed-store.integration.test.ts | 68 +-
packages/stack/src/managed.ts | 3 +-
.../managed/atomic-claim.integration.test.ts | 124 ++
packages/stack/src/managed/atomic-claim.ts | 175 ++-
packages/stack/src/managed/control.ts | 23 +-
packages/stack/src/managed/document.ts | 82 +-
packages/stack/src/managed/error-code.ts | 12 -
packages/stack/src/managed/failure.ts | 51 +-
packages/stack/src/managed/git-identity.ts | 52 +-
.../stack/src/managed/git.integration.test.ts | 109 +-
packages/stack/src/managed/git.ts | 381 +++---
packages/stack/src/managed/identity.ts | 507 ++++---
packages/stack/src/managed/ids.ts | 22 +-
packages/stack/src/managed/lifecycle.ts | 43 +-
packages/stack/src/managed/manager.ts | 306 +++--
.../stack/src/managed/manager.unit.test.ts | 48 +
packages/stack/src/managed/model.ts | 7 +-
packages/stack/src/managed/paths.ts | 149 ++-
packages/stack/src/managed/store.ts | 193 +--
packages/stack/src/node.ts | 33 +-
.../src/platform-bun.integration.test.ts | 45 +
packages/stack/src/platform-bun.ts | 55 +-
.../src/platform-node.integration.test.ts | 182 +++
packages/stack/src/platform-node.ts | 343 +++--
packages/stack/src/prefetch.ts | 25 +-
packages/stack/src/prefetch.unit.test.ts | 524 +++++---
packages/stack/src/services/analytics.ts | 20 +-
packages/stack/src/services/auth.ts | 11 +-
packages/stack/src/services/docker-cleanup.ts | 11 +-
packages/stack/src/services/edge-runtime.ts | 89 +-
packages/stack/src/services/imgproxy.ts | 10 +-
packages/stack/src/services/mailpit.ts | 10 +-
packages/stack/src/services/pgmeta.ts | 9 +-
packages/stack/src/services/pooler.ts | 9 +-
packages/stack/src/services/postgres-init.ts | 166 ++-
packages/stack/src/services/postgres.ts | 191 ++-
packages/stack/src/services/postgrest.ts | 11 +-
packages/stack/src/services/realtime.ts | 9 +-
packages/stack/src/services/service-utils.ts | 38 +-
.../stack/src/services/services.unit.test.ts | 384 ++----
packages/stack/src/services/storage.ts | 9 +-
packages/stack/src/services/studio.ts | 9 +-
packages/stack/src/services/vector.ts | 80 +-
.../stack/src/services/vector.unit.test.ts | 120 ++
packages/stack/src/stackHandle.ts | 55 +
.../stack/src/supervisor.integration.test.ts | 564 ++++++--
packages/stack/src/supervisor.ts | 334 +++--
packages/stack/src/terminateChild.ts | 98 +-
.../stack/src/terminateChild.unit.test.ts | 23 +-
packages/stack/src/version-plan.unit.test.ts | 24 +-
packages/stack/src/versions.ts | 58 +-
packages/stack/src/versions.unit.test.ts | 52 +-
.../tests/createStack-docker.e2e.test.ts | 65 +-
.../tests/createStack-native.e2e.test.ts | 104 ++
packages/stack/tests/createStack.e2e.test.ts | 89 +-
packages/stack/tests/global-setup.ts | 2 +-
packages/stack/tests/helpers/e2e.ts | 47 +-
packages/stack/tests/helpers/file-watch.ts | 37 +
.../stack/tests/helpers/managed-manager.ts | 24 +-
packages/stack/tests/helpers/mocks.ts | 14 +-
.../stack/tests/helpers/port-lease-child.ts | 18 +
packages/stack/tests/helpers/spawn-stack.ts | 114 --
.../tests/helpers/spawn-stack.unit.test.ts | 132 --
packages/stack/tests/helpers/stack-ports.ts | 3 +-
.../stack/tests/helpers/standalone-stack.ts | 111 --
.../stack/tests/helpers/supervisor-child.ts | 110 +-
packages/stack/tests/helpers/warmup.ts | 25 +-
.../stack/tests/helpers/warmup.unit.test.ts | 40 +-
.../stack/tests/parallelStacks.e2e.test.ts | 56 -
.../tests/postgresDataPersistence.e2e.test.ts | 6 +-
165 files changed, 12746 insertions(+), 6942 deletions(-)
delete mode 100644 apps/cli/src/next/commands/logs/logs.e2e.test.ts
delete mode 100644 apps/cli/src/next/commands/start/start.e2e.test.ts
delete mode 100644 apps/cli/src/next/commands/status/status.e2e.test.ts
delete mode 100644 apps/cli/src/next/commands/stop/stop.e2e.test.ts
create mode 100644 packages/stack/src/ContainerRuntime.integration.test.ts
create mode 100644 packages/stack/src/ContainerRuntime.ts
create mode 100644 packages/stack/src/HttpTransportClient.integration.test.ts
delete mode 100644 packages/stack/src/PortAllocator.unit.test.ts
create mode 100644 packages/stack/src/StackConfigResolver.policy.unit.test.ts
create mode 100644 packages/stack/src/cleanup.unit.test.ts
create mode 100644 packages/stack/src/managed/atomic-claim.integration.test.ts
delete mode 100644 packages/stack/src/managed/error-code.ts
create mode 100644 packages/stack/src/managed/manager.unit.test.ts
create mode 100644 packages/stack/src/platform-bun.integration.test.ts
create mode 100644 packages/stack/src/platform-node.integration.test.ts
create mode 100644 packages/stack/src/services/vector.unit.test.ts
create mode 100644 packages/stack/src/stackHandle.ts
create mode 100644 packages/stack/tests/createStack-native.e2e.test.ts
create mode 100644 packages/stack/tests/helpers/file-watch.ts
create mode 100644 packages/stack/tests/helpers/port-lease-child.ts
delete mode 100644 packages/stack/tests/helpers/spawn-stack.ts
delete mode 100644 packages/stack/tests/helpers/spawn-stack.unit.test.ts
delete mode 100644 packages/stack/tests/helpers/standalone-stack.ts
delete mode 100644 packages/stack/tests/parallelStacks.e2e.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index 878e6ab5be..741d3be1c0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -65,7 +65,128 @@ Write new runtime code Effect-native from the start; do not build a sync or Prom
- Model failures as `Data.TaggedError` classes with typed error channels, dependencies as services provided through `Layer`, retries/polling as `Schedule`s, and resource lifecycles with scopes and interruption-safe masks — never `Atomics.wait`, ad-hoc `setTimeout` loops, or manual try/finally resource juggling in core code.
- Expose Promise-based facades only at the outermost package edge (public entrypoints for non-Effect consumers), acquired asynchronously — never inside the core.
-- A small leaf primitive with no Effect semantics of its own (a pure function, a single-syscall fs helper) may stay plain async and be wrapped at its call boundary; everything with failure modes, retries, resources, or concurrency belongs in Effect.
+- Internal helpers must not return Promises. Use the Effect platform services or
+ `Effect.callback` for filesystem, process, network, and other host APIs. A
+ foreign library operation that exposes only a Promise may be wrapped once
+ with `Effect.tryPromise` at the leaf boundary, with its cancellation signal
+ and failure mapped into Effect. Any other exception needs a concrete reason
+ why the operation is impossible to express with Effect.
+
+### Effect evaluation and state
+
+An `Effect` is a reusable description and may be evaluated more than once.
+
+- Create per-execution mutable state inside `Effect.suspend`, `Effect.gen`, or a
+ scoped acquisition.
+- Never allocate mutable ownership state while constructing an Effect and then
+ close over it. Re-evaluating that Effect would share state across executions.
+- Keep `Effect.sync` total. If its thunk can throw, use `Effect.try` and map the
+ failure into the typed error channel.
+
+### Foreign callback boundaries
+
+An `Effect.callback` adapter owns the complete lifecycle of the foreign
+operation.
+
+- Register success, error, abort, close, and cancellation listeners before
+ starting the operation.
+- Guarantee at-most-once resumption.
+- Return a cancellation effect that removes every owned listener and closes or
+ destroys the exact owned resource.
+- Pass the Effect cancellation signal to foreign Promise APIs whenever they
+ support `AbortSignal`.
+
+### Service requirements
+
+Let Effect service requirements remain visible until the composition boundary.
+
+- Propagate `FileSystem`, `Scope`, process, network, and other requirements
+ through the Effect type.
+- Provide services through layers or explicit `Effect.provide` at the owning
+ boundary.
+- Do not hide missing services with casts, nested `runSync`/`runPromise`,
+ globals, or ad-hoc synchronous adapters.
+
+### Structured concurrency and coordination
+
+Use Effect's concurrency primitives according to the ownership relationship they
+represent:
+
+- Prefer `Effect.forkChild`; use `forkScoped`/`forkIn` when a fiber belongs to a
+ longer-lived scope. `forkDetach` is exceptional and must document why the
+ work intentionally outlives its caller and how completion is observed.
+- Use `Deferred` for one-shot handoff, `Latch` for a reusable open/closed gate,
+ `Semaphore` for bounded access or lifecycle serialization, `Queue` for
+ producer/consumer work, and `PubSub` for broadcast. Do not replace these with
+ mutable waiter arrays, Promise gates, booleans plus polling, or propagation
+ sleeps.
+- Use the `concurrency` option on `Effect.all`/`Effect.forEach` for simple caps;
+ use a `Semaphore` when permits span a larger critical section.
+- Let `Effect.race`, `raceFirst`, and concurrent combinators interrupt their
+ losing or sibling fibers. Do not hand-roll cancellation through shared flags.
+- Own resources with `Scope`, `acquireRelease`, or scoped layers. Restrict
+ `uninterruptibleMask` to the acquisition-to-registration handoff and keep the
+ actual blocking acquisition interruptible with `restore`.
+- Use `Schedule` for retry and unavoidable polling policy. Prefer observable
+ signals (`Deferred`, streams, filesystem/process events) whenever the foreign
+ API exposes them.
+
+### Shared initialization and teardown
+
+Concurrent callers must join one owned operation rather than merely observe a
+boolean.
+
+- Represent single-flight initialization and teardown with a cached Effect, a
+ shared Fiber, or a `Deferred>`.
+- The owning fiber performs the work; every caller awaits the same result.
+- Interrupting one waiter must not cancel shared teardown or leave later
+ callers believing cleanup has completed.
+
+### Typed failures and tagged values
+
+Expected failures in Effect code must be represented in the typed error channel.
+
+- Use `Data.TaggedError` for domain failures and return them with `Effect.fail`.
+- Do not `throw` expected validation, parsing, protocol, filesystem, or lifecycle errors inside Effect programs.
+- Use `Effect.try`, `Effect.tryPromise`, or callback adapters only at foreign boundaries, and map failures into a declared domain error.
+- Reserve defects (`Effect.die` or an uncaught throw) for genuinely impossible internal invariants and programmer bugs.
+- Standalone process entrypoints and public non-Effect adapters may throw or reject after translating the typed Effect failure at the outer boundary.
+
+### Causes and recovery
+
+Use the narrowest error operator that matches the intended recovery policy.
+
+- `Effect.catch`, `catchTag`, and `catchTags` handle expected typed failures.
+- Use `Effect.catchCause` only when recovery intentionally needs to observe
+ defects or interruption.
+- When inspecting a full `Cause`, recover only the explicitly recognized
+ condition and return every other cause unchanged with `Effect.failCause`.
+- Never squash an arbitrary cause and convert it into a domain error; that can
+ erase interruption and defects.
+- Avoid `Effect.orDie` and `Layer.orDie` for operational failures that callers
+ may need to classify, retry, or report.
+
+Do not inspect Effect runtime representations through fields such as `._tag`.
+
+- Use the library helper for Effect data types: `Exit.isSuccess`, `Exit.isFailure`, `Option.isSome`, `Option.isNone`, `Result.isSuccess`, `Result.isFailure`, `Cause.isTimeoutError`, and similar APIs.
+- Use `Effect.catchTag`, `Effect.catchTags`, `Effect.tapErrorTag`, and related operators for typed Effect errors.
+- Use `Predicate.isTagged` or a named domain predicate when narrowing one variant of a tagged domain union.
+- Use `Match.tag`, `Match.valueTags`, or another exhaustive `Match` helper when behavior depends on multiple variants of a domain union.
+- Direct `_tag` access is appropriate only when defining schemas/types, constructing or serializing tagged values, or implementing a genuinely dynamic boundary that cannot know the variants statically.
+- Tests follow the same rules; assertions should use public helpers rather than inspecting Effect internals.
+
+Prefer exhaustive matching for domain state machines and event handling. A new union member should produce a type error at every behaviorally relevant match rather than silently falling through a `default` branch.
+
+### Schema decoding and encoding
+
+Inside Effect code, compose schemas through their Effect APIs:
+
+- Prefer `Schema.decodeUnknownEffect`, `Schema.decodeEffect`, `Schema.encodeEffect`, and their typed error channels.
+- Map `SchemaError` into the domain error expected by the consuming operation.
+- Express additional validation with `Effect.filterOrFail`, `Effect.flatMap`, or `Effect.fail` instead of throwing inside a decoding callback.
+- Avoid `decodeUnknownSync` and `encodeUnknownSync` in Effect-native code. They execute through the synchronous runtime and report invalid input by throwing, which can turn a recoverable parse failure into a defect.
+- Sync schema operations are not asynchronous I/O and are not inherently “blocking” in that sense. The reason to prefer the Effect variants is typed failure handling, dependency propagation, interruption semantics, and support for effectful schema transformations—not to move ordinary CPU validation onto another thread.
+- Sync codecs are acceptable at an explicitly synchronous outer boundary when the schema is guaranteed to be service-free and the caller intentionally accepts a thrown exception. Otherwise, keep decoding and encoding in Effect.
## Code Quality
@@ -167,9 +288,9 @@ See `apps/cli/src/commands/login/` as the canonical example.
### Flake-resistant tests
-Tests must remain correct under file-level parallelism and slow or loaded CI. Synchronize on observable conditions—never use `Effect.sleep`, `setTimeout`, or polling delays for propagation, startup, cancellation, cleanup, or port release. Subscribe before triggering the transition, then await a `Deferred`, stream, fiber, readiness result, file, or state change. Timeouts are guards, not sub-second correctness assertions; bound waits by wall-clock deadline, never attempt counts; use TestClock or fake timers for timing semantics. Assume files run concurrently: use unique IDs, roots, process markers, and derived resources, while intentional collisions stay within one test. Never bind an ephemeral port, close it, and reuse it as a reservation; never use a released endpoint as a guaranteed dead backend—own a reset/refusal listener or inject the failure. Subprocesses need explicit readiness plus stderr/stdout diagnostics. Cleanup must target only exact owned PIDs, tokens, paths, names, and labels; never machine-wide prefix or command snapshots, and never globally disable parallelism. For flake fixes, reproduce/stress the red case and repeat the green case.
+Tests must remain correct under file-level parallelism and slow or loaded CI. Synchronize on observable conditions—never use `Effect.sleep`, `setTimeout`, or polling delays for propagation, startup, cancellation, cleanup, or port release. Subscribe before triggering the transition, then await a `Deferred`, stream, fiber, readiness result, file, or state change. Timeouts are guards, not sub-second correctness assertions; use TestClock or fake timers for timing semantics. Assume files run concurrently: use unique IDs, roots, process markers, and derived resources, while intentional collisions stay within one test. Never bind an ephemeral port, close it, and reuse it as a reservation; never use a released endpoint as a guaranteed dead backend—own a reset/refusal listener or inject the failure. Subprocesses need explicit readiness plus stderr/stdout diagnostics. Cleanup must target only exact owned PIDs, tokens, paths, names, and labels; never machine-wide prefix or command snapshots, and never globally disable parallelism. For flake fixes, reproduce/stress the red case and repeat the green case.
-During review, arbitrary sleeps, wall-clock completion assertions, attempt-count retry budgets, released-port reuse, static cross-file identities, and broad cleanup are blocking unless intrinsic to the behavior and documented.
+During review, arbitrary sleeps, wall-clock completion assertions, released-port reuse, static cross-file identities, and broad cleanup are blocking unless intrinsic to the behavior and documented.
### Integration test pattern
diff --git a/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts b/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts
index c0e5e3bf18..753d33fbd0 100644
--- a/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts
+++ b/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts
@@ -805,10 +805,10 @@ describe("legacy sso add integration", () => {
if (Exit.isFailure(exit)) {
const dump = JSON.stringify(exit.cause);
// URL validation runs against the consumed token, not the parsed
- // Option — `--name-id-format` is not a valid HTTPS URL, so the
- // command fails before any request, like Go.
+ // Option — it is not a valid HTTPS URL, so the command fails before
+ // any request, like Go. URL implementations do not consistently
+ // include the rejected input in their exception text.
expect(dump).toContain("LegacySsoAddMetadataFileError");
- expect(dump).toContain("--name-id-format");
expect(dump).toContain("Use --skip-url-validation to suppress this error");
}
expect(api.requests.some((r) => r.method === "POST")).toBe(false);
diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts
index 5f94b66971..03d014a8e6 100644
--- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts
+++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts
@@ -35,7 +35,7 @@ async function withClosingServer(run: (port: number) => Promise): Promise<
}
describe("legacyPgDeltaSslProbeLayer", () => {
- it.live("fails promptly when the socket closes before an SSL response byte", () =>
+ it.live("fails promptly when the server disconnects before an SSL response byte", () =>
Effect.tryPromise({
try: () =>
withClosingServer((port) =>
@@ -53,7 +53,6 @@ describe("legacyPgDeltaSslProbeLayer", () => {
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(String(exit.cause)).toContain(LegacyPgDeltaSslProbeError.name);
- expect(String(exit.cause)).toContain("closed before the server responded");
}
}).pipe(
Effect.provide(legacyPgDeltaSslProbeLayer),
diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts
index fbf53b6bc0..d7fca8098a 100644
--- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts
+++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts
@@ -145,19 +145,16 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: {
// TODO: run `supabase pull` against the new branch before restarting the stack
// so the local config reflects the branch's migrations and seed state.
// `pull` does not exist yet.
- const launchConfig =
- stackCheck.value.launch === undefined
- ? toStartStackConfig([], "auto")
- : withServiceVersions(
- toStartStackConfig(
- stackCheck.value.launch.excludedServices?.filter(
- (service): service is ExcludedStackService =>
- excludedStackServices.some((candidate) => candidate === service),
- ) ?? [],
- stackCheck.value.launch.mode,
- ),
- stackCheck.value.launch.versions,
- );
+ const launch = stackCheck.value.launch;
+ const launchConfig = withServiceVersions(
+ toStartStackConfig(
+ launch.excludedServices?.filter((service): service is ExcludedStackService =>
+ excludedStackServices.some((candidate) => candidate === service),
+ ) ?? [],
+ launch.mode,
+ ),
+ launch.versions,
+ );
const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot);
const stackLayer = yield* daemonLayer({
@@ -166,7 +163,7 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: {
projectDir: projectHome.projectRoot,
name: stackName,
portIntents: managedPortIntents(launchConfig, loadedProjectConfig ?? undefined),
- ...(stackCheck.value.launch !== undefined && { launch: stackCheck.value.launch }),
+ launch,
...launchConfig,
});
diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts
index 680c67417e..1649b9b5d8 100644
--- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts
+++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts
@@ -54,10 +54,13 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio
const serviceVersionContext = yield* resolveServiceVersionContext([], undefined);
const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot);
- const stackConfig = withServiceVersions(
- toStartStackConfig([], "auto"),
- serviceVersionContext.runtimeVersions,
- );
+ const stackConfig = {
+ ...withServiceVersions(toStartStackConfig([], "docker"), serviceVersionContext.runtimeVersions),
+ // Functions dev explicitly requires Edge Runtime even when the project
+ // config supplies only schema defaults. Request Docker explicitly so the
+ // managed daemon validates container availability before persisting state.
+ servicePolicies: { "edge-runtime": "eager" as const },
+ };
const stackLayer = yield* daemonLayer({
cacheRoot: cliConfig.supabaseHome,
cwd: runtimeInfo.cwd,
@@ -65,7 +68,6 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio
name: opts.stack,
edgeRuntime: opts.edgeRuntime,
launch: {
- mode: "auto",
versions: serviceVersionContext.pinnedBaseline,
excludedServices: [],
},
diff --git a/apps/cli/src/next/commands/logs/logs.e2e.test.ts b/apps/cli/src/next/commands/logs/logs.e2e.test.ts
deleted file mode 100644
index 561f112811..0000000000
--- a/apps/cli/src/next/commands/logs/logs.e2e.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { describe, expect, test } from "vitest";
-import {
- makeTempHome,
- makeTempStackProject,
- runSupabase,
- spawnSupabase,
-} from "../../../../tests/helpers/cli.ts";
-
-const LOGS_TIMEOUT_MS = 30_000;
-const LOGS_IDLE_WINDOW_MS = 500;
-const LIGHTWEIGHT_START_ARGS = [
- "start",
- "--detach",
- "--exclude",
- "realtime",
- "--exclude",
- "storage",
- "--exclude",
- "imgproxy",
- "--exclude",
- "mailpit",
- "--exclude",
- "pgmeta",
- "--exclude",
- "studio",
- "--exclude",
- "analytics",
- "--exclude",
- "vector",
- "--exclude",
- "pooler",
-] as const;
-
-function extractApiUrl(output: string): string {
- const match = output.match(/API URL:\s+(http:\/\/\S+)/);
- if (match?.[1] == null) {
- throw new Error(`Could not find API URL in output:\n${output}`);
- }
- return match[1];
-}
-
-async function triggerAuthLog(apiUrl: string): Promise {
- const response = await fetch(`${apiUrl}/auth/v1/signup`);
- expect(response.status).toBe(405);
-}
-
-async function waitForMatches(
- proc: ReturnType,
- pattern: RegExp,
- count: number,
- timeoutMs = LOGS_TIMEOUT_MS,
-): Promise {
- const start = Date.now();
- while (Date.now() - start < timeoutMs) {
- const matches = proc
- .stdout()
- .match(new RegExp(pattern.source, pattern.flags + (pattern.flags.includes("g") ? "" : "g")));
- if ((matches?.length ?? 0) >= count) {
- return;
- }
- await new Promise((resolve) => setTimeout(resolve, 100));
- }
- throw new Error(`Timed out waiting for ${count} matches of ${pattern}`);
-}
-
-describe("supabase logs", () => {
- test(
- "prints buffered history on attach and keeps following after an idle period",
- { timeout: LOGS_TIMEOUT_MS },
- async () => {
- const home = makeTempHome();
- const project = await makeTempStackProject("supabase-logs-e2e-");
- let logsProc: ReturnType | undefined;
-
- try {
- const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], {
- cwd: project.dir,
- home: home.dir,
- exitTimeoutMs: LOGS_TIMEOUT_MS,
- });
- expect(startResult.exitCode).toBe(0);
- const apiUrl = extractApiUrl(startResult.stdout);
-
- await triggerAuthLog(apiUrl);
-
- logsProc = spawnSupabase(["logs"], {
- cwd: project.dir,
- home: home.dir,
- cleanupProcessGroupOnClose: false,
- });
-
- await waitForMatches(logsProc, /\[auth\].*"path":"\/signup"/, 1);
-
- await new Promise((resolve) => setTimeout(resolve, LOGS_IDLE_WINDOW_MS));
- await triggerAuthLog(apiUrl);
- await waitForMatches(logsProc, /\[auth\].*"path":"\/signup"/, 2);
-
- logsProc.kill("SIGTERM");
-
- const result = await logsProc.waitForExit();
- logsProc = undefined;
-
- expect(result.stderr).not.toContain("ECONNRESET");
- expect(result.stderr).not.toContain("The socket connection was closed unexpectedly");
- } finally {
- logsProc?.kill("SIGTERM");
- await logsProc?.waitForExit().catch(() => {});
- }
- },
- );
-});
diff --git a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts
index dc23a41bac..65658df4de 100644
--- a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts
+++ b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts
@@ -11,15 +11,15 @@ import {
} from "../../config/service-version-resolution.ts";
describe("service version overrides", () => {
- test("parses and normalizes repeated flag overrides", async () => {
+ test("canonicalizes repeated flag overrides to published service tags", async () => {
await expect(
Effect.runPromise(
parseServiceVersionOverrides(["postgrest=v14.5", "mailpit=1.30.2", "auth=2.180.0"]),
),
).resolves.toEqual({
- postgrest: "14.5",
+ postgrest: "v14.5",
mailpit: "v1.30.2",
- auth: "2.180.0",
+ auth: "v2.180.0",
});
});
@@ -27,8 +27,8 @@ describe("service version overrides", () => {
const candidateBaseline = {
...DEFAULT_VERSIONS,
postgres: "17.6.1.090",
- postgrest: "14.5",
- auth: "2.187.0",
+ postgrest: "v14.5",
+ auth: "v2.187.0",
};
const layer = Layer.mergeAll(
@@ -68,13 +68,13 @@ describe("service version overrides", () => {
runtimeVersions: {
...candidateBaseline,
postgres: "17.4.1.045",
- auth: "2.170.0",
- storage: "1.40.0",
+ auth: "v2.170.0",
+ storage: "v1.40.0",
},
activeOverrides: [
{ service: "postgres", version: "17.4.1.045", source: "flag" },
- { service: "auth", version: "2.170.0", source: "flag" },
- { service: "storage", version: "1.40.0", source: "local" },
+ { service: "auth", version: "v2.170.0", source: "flag" },
+ { service: "storage", version: "v1.40.0", source: "local" },
],
availableUpdates: [],
updateFingerprint: undefined,
diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts
index fbcd3043b6..bb22890b5b 100644
--- a/apps/cli/src/next/commands/start/start.command.ts
+++ b/apps/cli/src/next/commands/start/start.command.ts
@@ -1,4 +1,4 @@
-import { Effect, Layer, Context } from "effect";
+import { Effect, Layer, Context, Option } from "effect";
import { loadProjectConfig } from "@supabase/config";
import {
DEFAULT_MANAGED_STACK_NAME,
@@ -79,14 +79,15 @@ export const serviceVersionFlag = Flag.string("service-version").pipe(
const modeFlag = Flag.choice("mode", startModes).pipe(
Flag.withDescription(
- 'Stack startup mode. "auto" prefers native binaries and falls back to Docker, "native" requires native-compatible services, and "docker" forces Docker for all services.',
+ 'Stack startup mode. "native" requires native-compatible services and "docker" requires a usable Docker or Podman runtime.',
),
- Flag.withDefault("auto" as StartMode),
+ Flag.optional,
+ Flag.map(Option.getOrUndefined),
);
interface StartVersionStateShape {
readonly launch: {
- readonly mode: StartMode;
+ readonly mode?: StartMode;
readonly versions: Readonly>;
readonly excludedServices: ReadonlyArray;
};
@@ -124,7 +125,7 @@ export type StartFlags = CliCommand.Command.Config.Infer;
export const startCommand = Command.make("start", flags).pipe(
Command.withDescription(
"Start the local Supabase development stack.\n\n" +
- "Starts the full local Supabase stack. Use --mode auto (default) to prefer native binaries and fall back to Docker, --mode native to require native-compatible services, or --mode docker to force Docker-backed startup.\n\n" +
+ "Starts the full local Supabase stack when Docker or Podman is usable; otherwise a supported host starts the native-capable service set. Use --mode to require one explicitly.\n\n" +
"Named CLI stacks persist managed runtime state under the Supabase home directory. Use --exclude to skip optional services. Use --detach to run in the background.",
),
Command.withShortDescription("Start local Supabase stack"),
@@ -196,8 +197,9 @@ export const startCommand = Command.make("start", flags).pipe(
if (deprecationWarning !== undefined) {
yield* output.warn(deprecationWarning);
}
+ const effectiveMode = flags.mode ?? existingSummary?.launch.mode;
const baseStackConfig = withServiceVersions(
- toStartStackConfig(flags.exclude, flags.mode),
+ toStartStackConfig(flags.exclude, effectiveMode),
serviceVersionContext.runtimeVersions,
);
const stackConfig = {
@@ -219,7 +221,7 @@ export const startCommand = Command.make("start", flags).pipe(
portDocument: portIntents,
});
const launch = {
- mode: flags.mode,
+ ...(flags.mode === undefined ? {} : { mode: flags.mode }),
versions: serviceVersionContext.pinnedBaseline,
excludedServices: flags.exclude,
...(existingSummary?.lastNotifiedUpdateFingerprint === undefined
@@ -242,12 +244,11 @@ export const startCommand = Command.make("start", flags).pipe(
cwd: runtimeInfo.cwd,
name: flags.stack,
});
-
return {
stackLayer,
startVersionState: StartVersionState.of({
launch: {
- mode: flags.mode,
+ mode: summary.launch.mode,
versions: serviceVersionContext.pinnedBaseline,
excludedServices: flags.exclude,
},
diff --git a/apps/cli/src/next/commands/start/start.e2e.test.ts b/apps/cli/src/next/commands/start/start.e2e.test.ts
deleted file mode 100644
index 0b94ce743e..0000000000
--- a/apps/cli/src/next/commands/start/start.e2e.test.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { describe, expect, test } from "vitest";
-import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts";
-
-const DETACHED_START_TIMEOUT_MS = 30_000;
-const LIGHTWEIGHT_START_ARGS = [
- "start",
- "--detach",
- "--exclude",
- "realtime",
- "--exclude",
- "storage",
- "--exclude",
- "imgproxy",
- "--exclude",
- "mailpit",
- "--exclude",
- "pgmeta",
- "--exclude",
- "studio",
- "--exclude",
- "analytics",
- "--exclude",
- "vector",
- "--exclude",
- "pooler",
-] as const;
-
-describe("supabase start", () => {
- test(
- "starts in detached mode and prints connection info",
- { timeout: DETACHED_START_TIMEOUT_MS },
- async () => {
- const home = makeTempHome();
- const project = await makeTempStackProject("supabase-start-e2e-");
- const { stdout, stderr, exitCode } = await runSupabase([...LIGHTWEIGHT_START_ARGS], {
- cwd: project.dir,
- home: home.dir,
- exitTimeoutMs: DETACHED_START_TIMEOUT_MS,
- });
- expect(exitCode, `stdout:\n${stdout}\nstderr:\n${stderr}`).toBe(0);
- expect(stdout).toContain("Local Supabase started");
- expect(stdout).toContain("API URL:");
- expect(stdout).toContain("DB URL:");
- },
- );
-
- test(
- "reattaches when detached start is already running",
- { timeout: DETACHED_START_TIMEOUT_MS },
- async () => {
- const home = makeTempHome();
- const project = await makeTempStackProject("supabase-start-e2e-");
- const first = await runSupabase([...LIGHTWEIGHT_START_ARGS], {
- cwd: project.dir,
- home: home.dir,
- exitTimeoutMs: DETACHED_START_TIMEOUT_MS,
- });
- expect(first.exitCode, `stdout:\n${first.stdout}\nstderr:\n${first.stderr}`).toBe(0);
-
- const second = await runSupabase([...LIGHTWEIGHT_START_ARGS], {
- cwd: project.dir,
- home: home.dir,
- exitTimeoutMs: DETACHED_START_TIMEOUT_MS,
- });
- expect(second.exitCode, `stdout:\n${second.stdout}\nstderr:\n${second.stderr}`).toBe(0);
- expect(second.stdout).toContain("Start local Supabase stack");
- expect(second.stdout).toContain("Local Supabase started");
- },
- );
-});
diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts
index deafa672bf..2a388237f7 100644
--- a/apps/cli/src/next/commands/start/start.handler.ts
+++ b/apps/cli/src/next/commands/start/start.handler.ts
@@ -50,7 +50,6 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) {
yield* updateManagedLaunch({
...lifecycleInput,
launch: {
- mode: launch.mode,
versions: launch.versions,
excludedServices: launch.excludedServices,
lastNotifiedUpdateFingerprint: serviceVersionContext.updateFingerprint,
@@ -68,7 +67,7 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) {
}
yield* analytics.capture("cli_stack_started", {
- mode: flags.mode,
+ mode: launch.mode,
detach: flags.detach,
stack: flags.stack,
});
diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts
index e98b31af2a..920bfba263 100644
--- a/apps/cli/src/next/commands/start/start.integration.test.ts
+++ b/apps/cli/src/next/commands/start/start.integration.test.ts
@@ -80,7 +80,7 @@ describe("start handler", () => {
);
return start({
stack: fixture.stackName,
- mode: "auto",
+ mode: "docker",
exclude: [],
serviceVersion: [],
detach: false,
diff --git a/apps/cli/src/next/commands/status/status.e2e.test.ts b/apps/cli/src/next/commands/status/status.e2e.test.ts
deleted file mode 100644
index fba5693eeb..0000000000
--- a/apps/cli/src/next/commands/status/status.e2e.test.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { describe, expect, test } from "vitest";
-import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts";
-
-const STATUS_TIMEOUT_MS = 30_000;
-const LIGHTWEIGHT_START_ARGS = [
- "start",
- "--detach",
- "--exclude",
- "realtime",
- "--exclude",
- "storage",
- "--exclude",
- "imgproxy",
- "--exclude",
- "mailpit",
- "--exclude",
- "pgmeta",
- "--exclude",
- "studio",
- "--exclude",
- "analytics",
- "--exclude",
- "vector",
- "--exclude",
- "pooler",
-] as const;
-
-describe("supabase status", () => {
- test(
- "shows connection info and service states for the current project",
- { timeout: STATUS_TIMEOUT_MS },
- async () => {
- const home = makeTempHome();
- const project = await makeTempStackProject("supabase-status-e2e-");
- const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], {
- cwd: project.dir,
- home: home.dir,
- exitTimeoutMs: STATUS_TIMEOUT_MS,
- });
- expect(startResult.exitCode).toBe(0);
-
- const result = await runSupabase(["status"], { cwd: project.dir, home: home.dir });
-
- expect(result.exitCode).toBe(0);
- expect(result.stdout).toContain("Show local Supabase stack status");
- expect(result.stdout).toContain("Local Supabase stack is running.");
- expect(result.stdout).toContain("API URL:");
- expect(result.stdout).toContain("DB URL:");
- expect(result.stdout).toContain("Publishable key:");
- expect(result.stdout).toContain("Secret key:");
- expect(result.stdout).toContain("auth:");
- expect(result.stdout).toContain("postgres:");
- expect(result.stdout).not.toContain("Stack status");
- expect(result.stdout).not.toContain("(running) -");
- },
- );
-});
diff --git a/apps/cli/src/next/commands/status/status.handler.ts b/apps/cli/src/next/commands/status/status.handler.ts
index 546c89b549..db446245f0 100644
--- a/apps/cli/src/next/commands/status/status.handler.ts
+++ b/apps/cli/src/next/commands/status/status.handler.ts
@@ -39,13 +39,11 @@ const resolveConfiguredSummary = Effect.fnUntraced(function* (input: {
}) {
const current = yield* resolveStackSummary(input);
const loaded = yield* loadProjectConfig(input.projectDir);
- const excluded = (current.launch?.excludedServices ?? []).filter(isExcludedStackService);
+ const excluded = (current.launch.excludedServices ?? []).filter(isExcludedStackService);
+ const mode = current.launch.mode;
return yield* resolveStackSummary({
...input,
- portDocument: managedPortIntents(
- toStartStackConfig(excluded, current.launch?.mode ?? "auto"),
- loaded ?? undefined,
- ),
+ portDocument: managedPortIntents(toStartStackConfig(excluded, mode), loaded ?? undefined),
});
});
@@ -97,7 +95,7 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) {
Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())),
);
- if (layer._tag === "None") {
+ if (Option.isNone(layer)) {
const summary = yield* resolveConfiguredSummary({
cacheRoot: cliConfig.supabaseHome,
projectDir: projectHome.projectRoot,
@@ -108,7 +106,7 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) {
Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())),
);
- if (summary._tag === "None") {
+ if (Option.isNone(summary)) {
const message = "No local Supabase stack is running for this project.";
if (output.format === "text") {
yield* output.outro(message);
diff --git a/apps/cli/src/next/commands/stop/stop.e2e.test.ts b/apps/cli/src/next/commands/stop/stop.e2e.test.ts
deleted file mode 100644
index c235392a62..0000000000
--- a/apps/cli/src/next/commands/stop/stop.e2e.test.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { describe, expect, test } from "vitest";
-import { existsSync, readdirSync } from "node:fs";
-import { join } from "node:path";
-import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts";
-
-const LIGHTWEIGHT_START_ARGS = [
- "start",
- "--detach",
- "--exclude",
- "realtime",
- "--exclude",
- "storage",
- "--exclude",
- "imgproxy",
- "--exclude",
- "mailpit",
- "--exclude",
- "pgmeta",
- "--exclude",
- "studio",
- "--exclude",
- "analytics",
- "--exclude",
- "vector",
- "--exclude",
- "pooler",
-] as const;
-const STOP_STACK_TIMEOUT_MS = 30_000;
-
-function managedStackDir(homeDir: string): string {
- const stacksRoot = join(homeDir, "managed", "stacks");
- const stackIds = existsSync(stacksRoot)
- ? readdirSync(stacksRoot, { withFileTypes: true })
- .filter((entry) => entry.isDirectory())
- .map((entry) => entry.name)
- : [];
- expect(stackIds).toHaveLength(1);
- return join(stacksRoot, stackIds[0]!);
-}
-
-describe("supabase stop", () => {
- test(
- "preserves the persisted stack folder by default",
- { timeout: STOP_STACK_TIMEOUT_MS },
- async () => {
- const home = makeTempHome();
- const project = await makeTempStackProject("supabase-stop-e2e-");
-
- const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], {
- cwd: project.dir,
- home: home.dir,
- });
- expect(startResult.exitCode).toBe(0);
- const stackDir = managedStackDir(home.dir);
-
- const stopResult = await runSupabase(["stop"], { cwd: project.dir, home: home.dir });
- expect(stopResult.exitCode).toBe(0);
- expect(existsSync(stackDir)).toBe(true);
- expect(existsSync(join(stackDir, "stack.json"))).toBe(true);
- },
- );
-
- test(
- "deletes the persisted stack folder with --no-backup",
- { timeout: STOP_STACK_TIMEOUT_MS },
- async () => {
- const home = makeTempHome();
- const project = await makeTempStackProject("supabase-stop-e2e-");
-
- const startResult = await runSupabase([...LIGHTWEIGHT_START_ARGS], {
- cwd: project.dir,
- home: home.dir,
- });
- expect(startResult.exitCode).toBe(0);
- const stackDir = managedStackDir(home.dir);
-
- const stopResult = await runSupabase(["stop", "--no-backup"], {
- cwd: project.dir,
- home: home.dir,
- });
- expect(
- stopResult.exitCode,
- `stdout:\n${stopResult.stdout}\n\nstderr:\n${stopResult.stderr}`,
- ).toBe(0);
- expect(existsSync(stackDir)).toBe(false);
- },
- );
-});
diff --git a/apps/cli/src/next/commands/stop/stop.integration.test.ts b/apps/cli/src/next/commands/stop/stop.integration.test.ts
index 8adb71a16b..2e4f471ba7 100644
--- a/apps/cli/src/next/commands/stop/stop.integration.test.ts
+++ b/apps/cli/src/next/commands/stop/stop.integration.test.ts
@@ -1,9 +1,9 @@
import { describe, expect, it } from "@effect/vitest";
import { BunServices } from "@effect/platform-bun";
-import { Effect, Layer } from "effect";
+import { Effect, FileSystem, Layer } from "effect";
import { existsSync } from "node:fs";
import { stop } from "./stop.handler.ts";
-import { managedStackDocumentPath } from "@supabase/stack/managed";
+import { managedStackDocumentPathEffect } from "@supabase/stack/managed";
import { mockOutput, mockProjectLinkState } from "../../../../tests/helpers/mocks.ts";
import { makeRunningStackFixture } from "../../../../tests/helpers/running-stack.ts";
@@ -48,12 +48,14 @@ describe("stop handler", () => {
BunServices.layer,
);
return stop({ stack: fixture.stackName, noBackup: true }).pipe(
- Effect.provide(layer),
Effect.tap(
- Effect.sync(() => {
- expect(existsSync(managedStackDocumentPath(fixture.stateRoot, fixture.stackId))).toBe(
- false,
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const documentPath = yield* managedStackDocumentPathEffect(
+ fixture.stateRoot,
+ fixture.stackId,
);
+ expect(yield* fs.exists(documentPath)).toBe(false);
expect(out.messages).toContainEqual(
expect.objectContaining({
type: "success",
@@ -62,6 +64,7 @@ describe("stop handler", () => {
);
}),
),
+ Effect.provide(layer),
Effect.ensuring(Effect.promise(() => fixture.dispose())),
);
}),
diff --git a/apps/cli/src/next/commands/update/update.handler.ts b/apps/cli/src/next/commands/update/update.handler.ts
index c5ad206314..3ceaafff76 100644
--- a/apps/cli/src/next/commands/update/update.handler.ts
+++ b/apps/cli/src/next/commands/update/update.handler.ts
@@ -103,19 +103,14 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) {
);
if (Option.isSome(existingSummary)) {
- const persistedLaunch = existingSummary.value.launch ?? {
- mode: "auto" as const,
- excludedServices: [] as const,
- };
yield* updateManagedLaunch({
cacheRoot: cliConfig.supabaseHome,
cwd: runtimeInfo.cwd,
workspacePath: projectHome.projectRoot,
stackName: flags.stack,
launch: {
- mode: persistedLaunch.mode,
versions: serviceVersionContext.candidateBaseline,
- excludedServices: persistedLaunch.excludedServices,
+ excludedServices: existingSummary.value.launch.excludedServices ?? [],
...(existingSummary.value.lastNotifiedUpdateFingerprint === undefined
? {}
: {
diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts
index 8cc9880552..6b51a4d163 100644
--- a/apps/cli/src/next/config/stack-config.ts
+++ b/apps/cli/src/next/config/stack-config.ts
@@ -17,26 +17,26 @@ export const excludedStackServices = [
export type ExcludedStackService = (typeof excludedStackServices)[number];
export const isExcludedStackService = (value: string): value is ExcludedStackService =>
excludedStackServices.some((candidate) => candidate === value);
-export const startModes = ["native", "auto", "docker"] as const;
+export const startModes = ["native", "docker"] as const;
export type StartMode = (typeof startModes)[number];
export function toStartStackConfig(
exclude: ReadonlyArray,
- mode: StartMode,
+ mode?: StartMode,
): StackConfig {
const excluded = new Set(exclude);
+ const native = mode === "native";
return {
- mode,
- startupMode: "lazy",
- realtime: excluded.has("realtime") ? false : {},
- storage: excluded.has("storage") ? false : {},
- imgproxy: excluded.has("imgproxy") || excluded.has("storage") ? false : {},
- mailpit: excluded.has("mailpit") ? false : {},
- pgmeta: excluded.has("pgmeta") ? false : {},
- studio: excluded.has("studio") || excluded.has("pgmeta") ? false : {},
- analytics: excluded.has("analytics") ? false : {},
- vector: excluded.has("vector") || excluded.has("analytics") ? false : {},
- pooler: excluded.has("pooler") ? false : {},
+ ...(mode === undefined ? {} : { mode }),
+ realtime: native || excluded.has("realtime") ? false : {},
+ storage: native || excluded.has("storage") ? false : {},
+ imgproxy: native || excluded.has("imgproxy") || excluded.has("storage") ? false : {},
+ mailpit: native || excluded.has("mailpit") ? false : {},
+ pgmeta: native || excluded.has("pgmeta") ? false : {},
+ studio: native || excluded.has("studio") || excluded.has("pgmeta") ? false : {},
+ analytics: native || excluded.has("analytics") ? false : {},
+ vector: native || excluded.has("vector") || excluded.has("analytics") ? false : {},
+ pooler: native || excluded.has("pooler") ? false : {},
...(excluded.has("auth") ? { auth: false } : {}),
...(excluded.has("postgrest") ? { postgrest: false } : {}),
};
diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts
index d60e7d20fa..46ef8ffd67 100644
--- a/apps/cli/src/next/config/stack-config.unit.test.ts
+++ b/apps/cli/src/next/config/stack-config.unit.test.ts
@@ -2,28 +2,38 @@ import { describe, expect, it } from "vitest";
import { toStartStackConfig, withServiceVersions } from "./stack-config.ts";
describe("toStartStackConfig", () => {
- it("uses lazy service startup with the requested runtime mode", () => {
- expect(toStartStackConfig([], "auto")).toMatchObject({
- mode: "auto",
- startupMode: "lazy",
+ it("leaves mode unset so the stack package can select the usable runtime", () => {
+ expect(toStartStackConfig([], undefined)).not.toHaveProperty("mode");
+ });
+
+ it("uses the requested runtime mode and catalog service defaults", () => {
+ expect(toStartStackConfig([], "docker")).toMatchObject({
+ mode: "docker",
});
expect(toStartStackConfig([], "docker")).toMatchObject({
mode: "docker",
- startupMode: "lazy",
});
expect(toStartStackConfig([], "native")).toMatchObject({
mode: "native",
- startupMode: "lazy",
+ realtime: false,
+ storage: false,
+ imgproxy: false,
+ mailpit: false,
+ pgmeta: false,
+ studio: false,
+ analytics: false,
+ vector: false,
+ pooler: false,
});
});
it("dedupes excluded services when building stack config", () => {
- expect(toStartStackConfig(["auth", "auth"], "auto")).toMatchObject({
- mode: "auto",
+ expect(toStartStackConfig(["auth", "auth"], "docker")).toMatchObject({
+ mode: "docker",
auth: false,
});
- expect(toStartStackConfig(["auth", "postgrest"], "auto")).toMatchObject({
- mode: "auto",
+ expect(toStartStackConfig(["auth", "postgrest"], "docker")).toMatchObject({
+ mode: "docker",
auth: false,
postgrest: false,
});
@@ -33,7 +43,7 @@ describe("toStartStackConfig", () => {
describe("withServiceVersions", () => {
it("injects linked service versions without re-enabling excluded services", () => {
expect(
- withServiceVersions(toStartStackConfig([], "auto"), {
+ withServiceVersions(toStartStackConfig([], "docker"), {
postgres: "17.6.1.090",
postgrest: "14.5",
auth: "2.187.0",
@@ -49,7 +59,7 @@ describe("withServiceVersions", () => {
});
expect(
- withServiceVersions(toStartStackConfig(["auth", "storage"], "auto"), {
+ withServiceVersions(toStartStackConfig(["auth", "storage"], "docker"), {
postgres: "17.6.1.090",
auth: "2.187.0",
storage: "1.39.2",
diff --git a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts
index 4d3a374a95..4da25f32c0 100644
--- a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts
+++ b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts
@@ -72,6 +72,45 @@ describe("stack e2e cleanup manager", () => {
expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]);
});
+ it("keeps cleanup best-effort when the associated home cannot be disposed", async () => {
+ const calls: Array = [];
+ const manager = createStackE2eCleanupManager(
+ cleanupEnvironment(calls, {
+ captureSnapshot: () => ({
+ managedStacksRootExists: true,
+ documentFiles: [],
+ stackDirs: [],
+ trackedPids: [],
+ }),
+ }),
+ );
+
+ manager.registerHome({
+ dir: "/tmp/home",
+ dispose: () => {
+ calls.push("dispose-home");
+ throw permissionError("home is not removable");
+ },
+ });
+ manager.registerStackProject({
+ dir: "/tmp/project",
+ cleanup: async () => {
+ calls.push("cleanup-project");
+ },
+ });
+ manager.associateHome("/tmp/project", "/tmp/home");
+
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ await expect(manager.drain()).resolves.toBeUndefined();
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("/tmp/home"));
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("home is not removable"));
+ } finally {
+ warn.mockRestore();
+ }
+ expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]);
+ });
+
it("canonicalizes symlinked project and home paths before matching stack state", async () => {
const root = mkdtempSync(join(tmpdir(), "stack-e2e-cleanup-"));
const project = join(root, "project");
diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts
index 0bb1d947c2..8210a0dc26 100644
--- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts
+++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts
@@ -360,7 +360,7 @@ describe("workspace package error tags have external adapters", () => {
];
for (const packageRoot of packageRoots) {
- it(packageRoot, async () => {
+ it(packageRoot, { timeout: 30_000 }, async () => {
const tagsByFile = await scanErrorTags(resolve(repoRoot, packageRoot), {
exportedOnly: true,
});
diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts
index 2e532a5c1f..575bf42f50 100644
--- a/apps/cli/src/shared/telemetry/error-actionability.ts
+++ b/apps/cli/src/shared/telemetry/error-actionability.ts
@@ -983,6 +983,9 @@ const externalActionabilityByTag: Record = {
? { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" }
: actionability.unknown,
BinaryNotFoundError: () => actionability.invalidConfig,
+ BinaryManifestError: () => actionability.externalNetwork,
+ BinaryRuntimeError: () => actionability.externalNetwork,
+ BinaryHostCompatibilityError: () => actionability.invalidConfig,
DownloadError: () => actionability.externalNetwork,
ChecksumMismatchError: () => ({
...actionability.externalNetwork,
@@ -1013,6 +1016,10 @@ const externalActionabilityByTag: Record = {
...actionability.invalidConfig,
fingerprint_suffix: "port_allocation",
}),
+ AtomicClaimUnsupportedError: () => ({
+ ...actionability.invalidInput,
+ fingerprint_suffix: "managed_identity",
+ }),
StackNotRunningError: () => actionability.startStack,
StackReadinessError: () => actionability.startStack,
NoRunningStackError: () => actionability.startStack,
diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts
index 0ac4e85d51..712dad8ea8 100644
--- a/apps/cli/tests/helpers/running-stack.ts
+++ b/apps/cli/tests/helpers/running-stack.ts
@@ -24,7 +24,12 @@ import { CliConfig } from "../../src/next/config/cli-config.service.ts";
import { ProjectHome } from "../../src/next/config/project-home.service.ts";
import { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts";
-const launch = { mode: "auto" as const, versions: { postgres: "17.6.1" }, excludedServices: [] };
+const launch = {
+ mode: "docker" as const,
+ containerRuntime: "docker" as const,
+ versions: { postgres: "17.6.1" },
+ excludedServices: [],
+};
const portDocument: ManagedPortIntentDocument = {
activeFields: ["apiPort", "dbPort"],
document: {},
diff --git a/apps/cli/tests/helpers/stack-e2e-cleanup.ts b/apps/cli/tests/helpers/stack-e2e-cleanup.ts
index b6815fecc8..7f69b7e9d4 100644
--- a/apps/cli/tests/helpers/stack-e2e-cleanup.ts
+++ b/apps/cli/tests/helpers/stack-e2e-cleanup.ts
@@ -441,7 +441,11 @@ export function createStackE2eCleanupManager(
failures.push(cleanupErrorDetail(project.dir, error));
} finally {
if (home !== undefined) {
- home.dispose();
+ try {
+ home.dispose();
+ } catch (error) {
+ failures.push(cleanupErrorDetail(home.dir, error));
+ }
}
}
}
diff --git a/docs/adr/0017-simplified-managed-stack-architecture.md b/docs/adr/0017-simplified-managed-stack-architecture.md
index 68cb90acb3..880b43f601 100644
--- a/docs/adr/0017-simplified-managed-stack-architecture.md
+++ b/docs/adr/0017-simplified-managed-stack-architecture.md
@@ -23,15 +23,24 @@ Launch updates use the existing owner control route (`POST /managed/launch`).
An attached caller asks the owner to update launch metadata; a caller with
owned control updates the document directly. Stop acquires control first,
waits for the persisted `stopped` lifecycle, and handles a stale owner with
-deterministic cleanup keyed by stack id. Delete requires owned control and a
-stopped document.
+deterministic cleanup keyed by stack id. Delete also requires owned control;
+stale running or failed documents are reconciled and cleaned before removal,
+while a live owner is never deleted underneath.
-Read-only discovery never acquires control ownership. It probes `/owner` and
-treats an unreachable, incompatible, or colliding listener as non-live;
-mutations still bind the endpoint and fail on a conflict. The endpoint maps 14
-bits of the stack id into `127.0.0.1:49152..65535`, so collisions are possible
-and deliberately fail closed for start/stop/delete. This is pragmatic
-single-user localhost coordination, not a hostile multi-user security
+Every managed document records one concrete launch selection. Native launch
+state has `mode: "native"`; container launch state has `mode: "docker"` and
+the selected Docker or Podman executable. The document never stores an
+unresolved or mode-less launch. Runtime configuration uses the same correlated
+union, so impossible mode/runtime combinations are not representable after
+selection.
+
+Read-only discovery never acquires control ownership. It scans the stack id's
+deterministic endpoint candidates through `/owner` and treats an unreachable,
+incompatible, or colliding listener as non-live. Mutations scan the same
+sequence for an existing matching owner, then bind the first available
+candidate; exhaustion fails closed. Each candidate maps digest bytes from the
+stack id into the reserved loopback range `127.0.0.1:10000..32767`. This is
+pragmatic single-user localhost coordination, not a hostile multi-user security
boundary. We are not adding control tokens until the threat model or a real
collision rate justifies more protocol and persistence machinery.
@@ -68,4 +77,7 @@ The architecture is smaller and has one source of truth for managed lifecycle
state. Refactors update the manager/facade and its real consumers together;
there is no fixture adapter or compatibility layer to keep in sync. The private
document format may change with the current build, while destructive cleanup
-and control ownership remain explicit safeguards.
+and control ownership remain explicit safeguards. A supervisor that attaches
+and later takes ownership re-reads this source of truth before choosing the
+runtime or cleaning stale resources; it does not act on a pre-takeover
+snapshot.
diff --git a/packages/process-compose/docs/architecture.md b/packages/process-compose/docs/architecture.md
index ef0e2d3a80..e265b17ad6 100644
--- a/packages/process-compose/docs/architecture.md
+++ b/packages/process-compose/docs/architecture.md
@@ -140,6 +140,12 @@ For each requested definition, `Orchestrator` runs this sequence:
The service keeps the same state stream across restart generations. Restart backoff is
`min(30 seconds, 2^(restartCount - 1))`.
+All public lifecycle mutations (`start`, `startService`, `stop`, `stopService`, `restartService`,
+and `updateServiceDefinition`) share one semaphore. The semaphore serializes changes to the graph,
+desired-state refs, and `FiberMap`; state reads and lifecycle fibers remain concurrent. This keeps
+commands composable without allowing a concurrent stop or graph update to observe a half-applied
+mutation.
+
A no-health-check, `restart: "no"` process is treated as one-shot work. A small isolated poll of
`ChildProcessHandle.isRunning` compensates for adapters that can report process completion before
their `exitCode` Effect becomes observable; it is not part of the general lifecycle loop.
@@ -171,6 +177,10 @@ all later failures use `failureThreshold`, including after an unhealthy-to-healt
Initial probe failures are therefore observable rather than leaving the service indefinitely in
`Running`.
+HTTP probes pass the fiber cancellation signal to `fetch` and apply the configured timeout through
+Effect interruption. TCP probes use an Effect callback with socket cleanup, so cancellation closes
+the socket instead of leaving an in-flight connection behind.
+
An unhealthy process uses the same pure restart-budget decision as a process exit. When restart is
enabled and the budget is exhausted, the supervisor terminates the child and publishes `Failed`
with `pid: null`, `exitCode: null`, and a stable health-exhaustion error. With restart policy `no`,
@@ -242,6 +252,11 @@ supervisor:
- applies graceful-then-forceful termination;
- runs serializable orphan cleanup before it exits.
+Its coordination core is one scoped Effect program: child exit and owner-loss signals complete
+`Deferred`s, owner liveness is checked by a supervised fiber on a `Schedule`, and cleanup command
+timeouts use Effect interruption. The source-file and compiled self-dispatch functions are the
+only outer process adapters that call `Effect.runPromise`.
+
This extra process is required for abrupt owner death: an Effect finalizer cannot run after its
own process has already disappeared. Ordinary definitions avoid the extra process.
@@ -274,8 +289,12 @@ These contracts exist because compiled-child behavior caused two user-visible in
## Logs
`LogBuffer` holds a bounded per-service history and a bounded merged history (10,000 entries each),
-plus live per-service and merged `PubSub` streams. `historyAll` can filter by service names.
-Streams contain new entries only; callers explicitly request history when they need replay.
+plus live per-service and merged sliding `PubSub` streams. `historyAll` can filter by service names.
+Streams contain new entries only; callers explicitly request history when they need replay. Live
+streams intentionally drop the oldest queued entries for a slow subscriber so process stdout and
+stderr draining cannot block; the bounded history refs remain the source of truth for recent
+output. Per-service stream and history initialization is serialized with a semaphore to make
+first-use concurrent appends deterministic.
When a process exits unexpectedly or becomes unhealthy, the orchestrator appends recent buffered
output to its diagnostics.
diff --git a/packages/process-compose/src/HealthProbe.ts b/packages/process-compose/src/HealthProbe.ts
index 8664cc6100..179dfb9d8b 100644
--- a/packages/process-compose/src/HealthProbe.ts
+++ b/packages/process-compose/src/HealthProbe.ts
@@ -1,5 +1,5 @@
import * as Net from "node:net";
-import { Duration, Effect, Ref, Schedule } from "effect";
+import { Duration, Effect, Match, Ref, Schedule } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { defaults, type HealthCheckConfig, type ProbeConfig } from "./ServiceDef.ts";
@@ -7,19 +7,20 @@ const executeProbe = (
probe: ProbeConfig,
timeoutSeconds: number,
): Effect.Effect => {
- switch (probe._tag) {
- case "Http":
- return Effect.tryPromise({
- try: () =>
+ return Match.valueTags(probe, {
+ Http: (probe) =>
+ Effect.tryPromise({
+ try: (signal) =>
fetch(`${probe.scheme}://${probe.host}:${probe.port}${probe.path}`, {
- signal: AbortSignal.timeout(timeoutSeconds * 1000),
+ signal,
}),
- catch: () => false as never,
+ catch: (cause) => cause,
}).pipe(
+ Effect.timeout(Duration.seconds(timeoutSeconds)),
Effect.map((res) => res.ok),
Effect.catch(() => Effect.succeed(false)),
- );
- case "Exec": {
+ ),
+ Exec: (probe) => {
const cmd = ChildProcess.make(probe.command, probe.args, {
env: probe.env,
extendEnv: true,
@@ -31,9 +32,9 @@ const executeProbe = (
Effect.map((opt) => opt ?? false),
),
).pipe(Effect.catch(() => Effect.succeed(false)));
- }
- case "Tcp":
- return Effect.callback((resume) => {
+ },
+ Tcp: (probe) =>
+ Effect.callback((resume) => {
const socket = Net.createConnection({ host: probe.host, port: probe.port });
socket.once("connect", () => {
socket.destroy();
@@ -48,8 +49,8 @@ const executeProbe = (
Effect.timeout(Duration.seconds(timeoutSeconds)),
Effect.map((opt) => opt ?? false),
Effect.catch(() => Effect.succeed(false)),
- );
- }
+ ),
+ });
};
export interface HealthProbeCallbacks {
diff --git a/packages/process-compose/src/HealthProbe.unit.test.ts b/packages/process-compose/src/HealthProbe.unit.test.ts
index a2ac95c4e7..452ec998c6 100644
--- a/packages/process-compose/src/HealthProbe.unit.test.ts
+++ b/packages/process-compose/src/HealthProbe.unit.test.ts
@@ -6,7 +6,7 @@ import { describe, expect, it } from "@effect/vitest";
import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner";
import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem";
import { layer as BunPathLayer } from "@effect/platform-bun/BunPath";
-import { Deferred, Duration, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect";
+import { Deferred, Duration, Effect, Exit, Fiber, Layer, Predicate, Sink, Stream } from "effect";
import { ChildProcessSpawner } from "effect/unstable/process";
import { runHealthProbe } from "./HealthProbe.ts";
import type { HealthCheckConfig, ProbeConfig } from "./ServiceDef.ts";
@@ -79,6 +79,37 @@ const setupProbe = (probe: ProbeConfig, overrides?: Partial)
});
describe("HealthProbe", () => {
+ it.live("aborts an in-flight HTTP probe when its fiber is interrupted", () => {
+ const originalFetch = globalThis.fetch;
+ return Effect.gen(function* () {
+ const started = yield* Deferred.make();
+ let aborted = false;
+ globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => {
+ init?.signal?.addEventListener("abort", () => {
+ aborted = true;
+ });
+ Effect.runSync(Deferred.succeed(started, void 0));
+ return new Promise(() => undefined);
+ }) as typeof fetch;
+
+ const { config } = yield* setupProbe({
+ _tag: "Http",
+ scheme: "http",
+ host: "127.0.0.1",
+ port: 80,
+ path: "/health",
+ });
+ const fiber = yield* Effect.forkChild(runHealthProbe(config), { startImmediately: true });
+ yield* Deferred.await(started);
+ yield* Fiber.interrupt(fiber);
+
+ expect(aborted).toBe(true);
+ }).pipe(
+ Effect.ensuring(Effect.sync(() => (globalThis.fetch = originalFetch))),
+ Effect.provide(platformLayer),
+ );
+ });
+
it.live("Exec probes require explicit args", () =>
Effect.sync(() => {
// @ts-expect-error Exec probes must declare args explicitly.
@@ -129,7 +160,7 @@ describe("HealthProbe", () => {
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) =>
Effect.sync(() => {
- if (command._tag === "StandardCommand") {
+ if (Predicate.isTagged(command, "StandardCommand")) {
spawned.push({
command: command.command,
args: command.args,
diff --git a/packages/process-compose/src/LogBuffer.ts b/packages/process-compose/src/LogBuffer.ts
index b561d7e6fe..673f00fb05 100644
--- a/packages/process-compose/src/LogBuffer.ts
+++ b/packages/process-compose/src/LogBuffer.ts
@@ -1,4 +1,4 @@
-import { Effect, Layer, PubSub, Ref, Context, Stream } from "effect";
+import { Context, Effect, Layer, PubSub, Ref, Semaphore, Stream } from "effect";
export interface LogEntry {
readonly timestamp: number;
@@ -31,13 +31,16 @@ export class LogBuffer extends Context.Service<
Effect.gen(function* () {
const servicePubSubs = new Map>();
const serviceBuffers = new Map>>();
- const globalPubSub = yield* PubSub.bounded(4096);
+ // Log delivery must never block child stdout/stderr draining. The bounded history refs
+ // below remain authoritative; live subscribers receive the newest entries when slow.
+ const globalPubSub = yield* PubSub.sliding(4096);
const globalBuffer = yield* Ref.make>([]);
+ const serviceInitialization = Semaphore.makeUnsafe(1);
const getOrCreate = (service: string) =>
Effect.gen(function* () {
if (!servicePubSubs.has(service)) {
- const ps = yield* PubSub.bounded(1024);
+ const ps = yield* PubSub.sliding(1024);
servicePubSubs.set(service, ps);
serviceBuffers.set(service, Ref.makeUnsafe>([]));
}
@@ -45,7 +48,7 @@ export class LogBuffer extends Context.Service<
pubsub: servicePubSubs.get(service)!,
buffer: serviceBuffers.get(service)!,
};
- });
+ }).pipe(serviceInitialization.withPermit);
return {
append: (service, stream, line) =>
diff --git a/packages/process-compose/src/LogBuffer.unit.test.ts b/packages/process-compose/src/LogBuffer.unit.test.ts
index c758785707..4822364862 100644
--- a/packages/process-compose/src/LogBuffer.unit.test.ts
+++ b/packages/process-compose/src/LogBuffer.unit.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
-import { Effect, Fiber, Stream } from "effect";
+import { Deferred, Duration, Effect, Fiber, Option, Stream } from "effect";
import { LogBuffer } from "./LogBuffer.ts";
const layer = LogBuffer.layer;
@@ -39,10 +39,7 @@ describe("LogBuffer", () => {
// Start collecting 1 entry from the subscription in background
const collectEffect = log.subscribe("svc").pipe(Stream.take(1), Stream.runCollect);
- const fiber = yield* Effect.forkChild(collectEffect);
-
- // Give the subscriber a moment to be registered
- yield* Effect.yieldNow;
+ const fiber = yield* Effect.forkChild(collectEffect, { startImmediately: true });
yield* log.append("svc", "stdout", "hello");
@@ -75,9 +72,7 @@ describe("LogBuffer", () => {
// Collect 3 entries from the global subscription
const collectEffect = log.subscribeAll().pipe(Stream.take(3), Stream.runCollect);
- const fiber = yield* Effect.forkChild(collectEffect);
-
- yield* Effect.yieldNow;
+ const fiber = yield* Effect.forkChild(collectEffect, { startImmediately: true });
yield* log.append("svcA", "stdout", "from-a");
yield* log.append("svcB", "stderr", "from-b");
@@ -114,6 +109,56 @@ describe("LogBuffer", () => {
}).pipe(Effect.provide(layer)),
);
+ it.live("does not block appends when a live subscriber is slow", () =>
+ Effect.gen(function* () {
+ const log = yield* LogBuffer;
+ const firstEntry = yield* Deferred.make();
+ const releaseSubscriber = yield* Deferred.make();
+ let first = true;
+ const subscriber = yield* Effect.forkChild(
+ log.subscribe("svc").pipe(
+ Stream.tap(() =>
+ first
+ ? Effect.sync(() => {
+ first = false;
+ }).pipe(
+ Effect.andThen(Deferred.succeed(firstEntry, void 0)),
+ Effect.andThen(Deferred.await(releaseSubscriber)),
+ )
+ : Effect.void,
+ ),
+ Stream.runDrain,
+ ),
+ { startImmediately: true },
+ );
+ yield* log.append("svc", "stdout", "first");
+ yield* Deferred.await(firstEntry);
+ const result = yield* Effect.forEach(
+ Array.from({ length: 1_100 }, (_, index) => index + 1),
+ (index) => log.append("svc", "stdout", `line${index}`),
+ { concurrency: "unbounded", discard: true },
+ ).pipe(Effect.timeoutOption(Duration.seconds(2)));
+
+ expect(Option.isSome(result)).toBe(true);
+ yield* Deferred.succeed(releaseSubscriber, void 0);
+ yield* Fiber.interrupt(subscriber);
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.live("preserves entries when several writers initialize one service concurrently", () =>
+ Effect.gen(function* () {
+ const log = yield* LogBuffer;
+ yield* Effect.forEach(
+ Array.from({ length: 32 }, (_, index) => index),
+ (index) => log.append("svc", "stdout", `line${index}`),
+ { concurrency: "unbounded", discard: true },
+ );
+ const entries = yield* log.history("svc", 32);
+ expect(entries).toHaveLength(32);
+ expect(new Set(entries.map((entry) => entry.line)).size).toBe(32);
+ }).pipe(Effect.provide(layer)),
+ );
+
it.live("historyAll returns merged entries in timestamp order and respects filters", () =>
Effect.gen(function* () {
const log = yield* LogBuffer;
diff --git a/packages/process-compose/src/Orchestrator.integration.test.ts b/packages/process-compose/src/Orchestrator.integration.test.ts
index c9d9e72daa..8990f6657e 100644
--- a/packages/process-compose/src/Orchestrator.integration.test.ts
+++ b/packages/process-compose/src/Orchestrator.integration.test.ts
@@ -2,10 +2,11 @@ import { describe, expect, it } from "@effect/vitest";
import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner";
import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem";
import { layer as BunPathLayer } from "@effect/platform-bun/BunPath";
-import { Duration, Effect, Layer } from "effect";
+import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect";
import { buildGraph } from "./DependencyGraph.ts";
import { LogBuffer } from "./LogBuffer.ts";
import { Orchestrator } from "./Orchestrator.ts";
+import type { ServiceState } from "./ServiceState.ts";
import type { ProbeConfig, ServiceDef } from "./ServiceDef.ts";
const spawnerLayer = BunChildProcessSpawnerLayer.pipe(
@@ -29,22 +30,104 @@ const fileExistsProbe = (path: string) =>
args: ["-f", path],
}) satisfies ProbeConfig;
-/** Simple poll: check condition every intervalMs, give up after maxMs */
-const poll = (
- check: Effect.Effect,
- intervalMs = 50,
- maxMs = 5000,
-): Effect.Effect =>
+type StateReader = {
+ readonly getAllStates: () => Effect.Effect>;
+ readonly allStateChanges: () => Stream.Stream;
+};
+
+const waitForStatuses = (
+ orc: StateReader,
+ predicates: ReadonlyArray<{
+ readonly name: string;
+ readonly predicate: (state: ServiceState) => boolean;
+ }>,
+): Effect.Effect =>
Effect.gen(function* () {
- const start = Date.now();
- while (Date.now() - start < maxMs) {
- const ok = yield* check;
- if (ok) return;
- yield* Effect.sleep(Duration.millis(intervalMs));
- }
+ const current = yield* orc.getAllStates();
+ const matches = (states: ReadonlyArray) =>
+ predicates.every(({ name, predicate }) => {
+ const state = states.find((candidate) => candidate.name === name);
+ return state !== undefined && predicate(state);
+ });
+ if (matches(current)) return;
+
+ yield* orc.allStateChanges().pipe(
+ Stream.scan(new Map(current.map((state) => [state.name, state])), (states, state) =>
+ new Map(states).set(state.name, state),
+ ),
+ Stream.filter((states) => matches([...states.values()])),
+ Stream.take(1),
+ Stream.runDrain,
+ );
});
describe("Orchestrator integration", () => {
+ it.live(
+ "serializes a concurrent start and stop lifecycle command",
+ () => {
+ const defs: ServiceDef[] = [
+ {
+ name: "serialized",
+ command: "sh",
+ args: ["-c", "trap '' TERM; sleep 60"],
+ shutdown: { signal: "SIGTERM", timeoutSeconds: 0.5 },
+ },
+ ];
+ const { layer } = setupReal(defs);
+
+ return Effect.gen(function* () {
+ const orc = yield* Orchestrator;
+ const changes = yield* orc.stateChanges("serialized");
+ const running = yield* changes.pipe(
+ Stream.filter((state) => isUp(state.status)),
+ Stream.take(1),
+ Stream.runHead,
+ Effect.forkChild,
+ );
+ yield* orc.start();
+ yield* Fiber.join(running);
+
+ const stopping = yield* changes.pipe(
+ Stream.filter((state) => state.status === "Stopping"),
+ Stream.take(1),
+ Stream.runHead,
+ Effect.forkChild,
+ );
+ const events: Array = [];
+ const startEntered = yield* Deferred.make();
+ const stop = orc.stop().pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ events.push("stop");
+ }),
+ ),
+ );
+ const stopFiber = yield* Effect.forkChild(stop, { startImmediately: true });
+ yield* Fiber.join(stopping);
+
+ const startFiber = yield* Effect.forkChild(
+ orc.startService("serialized", {
+ beforeStart: () =>
+ Effect.sync(() => events.push("start")).pipe(
+ Effect.andThen(Deferred.succeed(startEntered, void 0)),
+ ),
+ }),
+ { startImmediately: true },
+ );
+ yield* Fiber.join(stopFiber);
+ yield* Fiber.join(startFiber);
+ const startResult = yield* Deferred.await(startEntered).pipe(
+ Effect.timeoutOption(Duration.seconds(2)),
+ );
+ expect(Option.isSome(startResult)).toBe(true);
+
+ expect(events).toEqual(["stop", "start"]);
+ yield* orc.stop();
+ }).pipe(Effect.provide(layer), Effect.scoped);
+ },
+ { timeout: 15000 },
+ );
+
it.live(
"starts services in dependency order (A before B)",
() => {
@@ -70,13 +153,10 @@ describe("Orchestrator integration", () => {
const orc = yield* Orchestrator;
yield* orc.start();
- yield* poll(
- Effect.gen(function* () {
- const a = yield* orc.getState("service-a");
- const b = yield* orc.getState("service-b");
- return isUp(a.status) && isUp(b.status);
- }),
- );
+ yield* waitForStatuses(orc, [
+ { name: "service-a", predicate: (state) => isUp(state.status) },
+ { name: "service-b", predicate: (state) => isUp(state.status) },
+ ]);
const stateA = yield* orc.getState("service-a");
const stateB = yield* orc.getState("service-b");
@@ -119,12 +199,9 @@ describe("Orchestrator integration", () => {
const orc = yield* Orchestrator;
yield* orc.start();
- yield* poll(
- Effect.gen(function* () {
- const state = yield* orc.getState("flag-service");
- return state.status === "Healthy";
- }),
- );
+ yield* waitForStatuses(orc, [
+ { name: "flag-service", predicate: (state) => state.status === "Healthy" },
+ ]);
const state = yield* orc.getState("flag-service");
expect(state.status).toBe("Healthy");
@@ -148,13 +225,10 @@ describe("Orchestrator integration", () => {
const orc = yield* Orchestrator;
yield* orc.start();
- yield* poll(
- Effect.gen(function* () {
- const a = yield* orc.getState("long-a");
- const b = yield* orc.getState("long-b");
- return isUp(a.status) && isUp(b.status);
- }),
- );
+ yield* waitForStatuses(orc, [
+ { name: "long-a", predicate: (state) => isUp(state.status) },
+ { name: "long-b", predicate: (state) => isUp(state.status) },
+ ]);
const a = yield* orc.getState("long-a");
const b = yield* orc.getState("long-b");
@@ -182,12 +256,11 @@ describe("Orchestrator integration", () => {
const orc = yield* Orchestrator;
yield* orc.start();
- yield* poll(
- Effect.gen(function* () {
- const states = yield* orc.getAllStates();
- return states.every((s) => isUp(s.status));
- }),
- );
+ yield* waitForStatuses(orc, [
+ { name: "sleep-a", predicate: (state) => isUp(state.status) },
+ { name: "sleep-b", predicate: (state) => isUp(state.status) },
+ { name: "sleep-c", predicate: (state) => isUp(state.status) },
+ ]);
const before = Date.now();
yield* orc.stop();
@@ -219,17 +292,13 @@ describe("Orchestrator integration", () => {
return Effect.gen(function* () {
const orc = yield* Orchestrator;
const logBuffer = yield* LogBuffer;
-
- yield* orc.start();
-
- yield* poll(
- Effect.gen(function* () {
- const entries = yield* logBuffer.history("echo-svc", 10);
- return entries.length >= 3;
- }),
+ const linesReady = yield* Effect.forkChild(
+ logBuffer.subscribe("echo-svc").pipe(Stream.take(3), Stream.runCollect),
+ { startImmediately: true },
);
- const entries = yield* logBuffer.history("echo-svc", 10);
+ yield* orc.start();
+ const entries = yield* Fiber.join(linesReady);
const lines = entries.map((e) => e.line);
expect(lines).toContain("line-one");
expect(lines).toContain("line-two");
@@ -276,13 +345,10 @@ describe("resource cleanup", () => {
const orc = yield* Orchestrator;
yield* orc.start();
- yield* poll(
- Effect.gen(function* () {
- const a = yield* orc.getState("svc-a");
- const b = yield* orc.getState("svc-b");
- return isUp(a.status) && isUp(b.status);
- }),
- );
+ yield* waitForStatuses(orc, [
+ { name: "svc-a", predicate: (state) => isUp(state.status) },
+ { name: "svc-b", predicate: (state) => isUp(state.status) },
+ ]);
const pidA = (yield* orc.getState("svc-a")).pid!;
const pidB = (yield* orc.getState("svc-b")).pid!;
@@ -326,13 +392,10 @@ describe("resource cleanup", () => {
const orc = yield* Orchestrator;
yield* orc.start();
- yield* poll(
- Effect.gen(function* () {
- const a = yield* orc.getState("target");
- const b = yield* orc.getState("bystander");
- return isUp(a.status) && isUp(b.status);
- }),
- );
+ yield* waitForStatuses(orc, [
+ { name: "target", predicate: (state) => isUp(state.status) },
+ { name: "bystander", predicate: (state) => isUp(state.status) },
+ ]);
const pidTarget = (yield* orc.getState("target")).pid!;
const pidBystander = (yield* orc.getState("bystander")).pid!;
@@ -367,19 +430,13 @@ describe("resource cleanup", () => {
const orc = yield* Orchestrator;
yield* orc.start();
- yield* poll(
- Effect.gen(function* () {
- const s = yield* orc.getState("restartable");
- return isUp(s.status);
- }),
- );
+ yield* waitForStatuses(orc, [
+ { name: "restartable", predicate: (state) => isUp(state.status) },
+ ]);
const originalPid = (yield* orc.getState("restartable")).pid!;
yield* orc.stopService("restartable");
- // Wait long enough for a restart cycle to prove it doesn't restart
- yield* Effect.sleep(Duration.seconds(1));
-
expect(isPidAlive(originalPid)).toBe(false);
const state = yield* orc.getState("restartable");
expect(state.status).toBe("Stopped");
@@ -417,12 +474,9 @@ describe("resource cleanup", () => {
const orc = yield* Orchestrator;
yield* orc.start();
- yield* poll(
- Effect.gen(function* () {
- const s = yield* orc.getState("probed");
- return s.status === "Healthy";
- }),
- );
+ yield* waitForStatuses(orc, [
+ { name: "probed", predicate: (state) => state.status === "Healthy" },
+ ]);
const pid = (yield* orc.getState("probed")).pid!;
yield* orc.stop();
@@ -460,13 +514,10 @@ describe("resource cleanup", () => {
const orc = yield* Orchestrator;
yield* orc.start();
- yield* poll(
- Effect.gen(function* () {
- const a = yield* orc.getState("scoped-a");
- const b = yield* orc.getState("scoped-b");
- return isUp(a.status) && isUp(b.status);
- }),
- );
+ yield* waitForStatuses(orc, [
+ { name: "scoped-a", predicate: (state) => isUp(state.status) },
+ { name: "scoped-b", predicate: (state) => isUp(state.status) },
+ ]);
capturedPidA = (yield* orc.getState("scoped-a")).pid!;
capturedPidB = (yield* orc.getState("scoped-b")).pid!;
@@ -474,8 +525,7 @@ describe("resource cleanup", () => {
expect(capturedPidB).toBeGreaterThan(0);
}).pipe(Effect.provide(layer), Effect.scoped);
- // After scope closed, PIDs should be dead
- yield* Effect.sleep(Duration.millis(100));
+ // Scope closure owns the child finalizers, so they complete before this assertion.
expect(isPidAlive(capturedPidA)).toBe(false);
expect(isPidAlive(capturedPidB)).toBe(false);
});
diff --git a/packages/process-compose/src/Orchestrator.ts b/packages/process-compose/src/Orchestrator.ts
index ae0560fce2..70028c34fa 100644
--- a/packages/process-compose/src/Orchestrator.ts
+++ b/packages/process-compose/src/Orchestrator.ts
@@ -7,8 +7,10 @@ import {
Fiber,
FiberMap,
Layer,
+ Match,
Context,
Option,
+ Predicate,
Semaphore,
Stream,
SubscriptionRef,
@@ -18,11 +20,7 @@ import { buildGraph, type ResolvedGraph } from "./DependencyGraph.ts";
import { type HealthProbeCallbacks, runHealthProbe } from "./HealthProbe.ts";
import { LogBuffer } from "./LogBuffer.ts";
import { restartClosureFor } from "./RestartClosure.ts";
-import {
- decideRestart,
- type LifecycleCause,
- UNHEALTHY_RESTART_EXHAUSTED_ERROR,
-} from "./RestartDecision.ts";
+import { decideRestart, UNHEALTHY_RESTART_EXHAUSTED_ERROR } from "./RestartDecision.ts";
import type {
HookTrigger,
OrchestratorConfig,
@@ -45,14 +43,15 @@ const DIAGNOSTIC_LOG_LINES = 20;
const willRestartAfterExit = (def: ServiceDef, state: ServiceState): boolean => {
if (state.exitCode === null) return false;
- return (
+ return Predicate.isTagged(
decideRestart({
cause: { _tag: "ProcessExit", exitCode: state.exitCode },
policy: def.restart ?? defaults.restart,
restartCount: state.restartCount,
maxRestarts: def.maxRestarts ?? defaults.maxRestarts,
desired: state.desired,
- })._tag === "Restart"
+ }),
+ "Restart",
);
};
@@ -62,9 +61,17 @@ const waitForProcessToStop = (handle: {
readonly isRunning: Effect.Effect;
}): Effect.Effect =>
Effect.gen(function* () {
- while (yield* handle.isRunning.pipe(Effect.catch(() => Effect.succeed(false)))) {
- yield* Effect.sleep(Duration.millis(100));
- }
+ let running = true;
+ yield* Effect.whileLoop({
+ while: () => running,
+ body: () =>
+ handle.isRunning.pipe(
+ Effect.catch(() => Effect.succeed(false)),
+ Effect.tap((next) => Effect.sync(() => (running = next))),
+ Effect.andThen(Effect.sleep(Duration.millis(100))),
+ ),
+ step: () => undefined,
+ });
});
export class Orchestrator extends Context.Service<
@@ -146,7 +153,9 @@ export class Orchestrator extends Context.Service<
// FiberMap to track running service fibers — auto-interrupted on scope close
const fibers = yield* FiberMap.make();
const forceStops = new Map>();
- const startServiceLock = Semaphore.makeUnsafe(1);
+ // Serialize every public lifecycle mutation so graph, desired state, and FiberMap
+ // updates form one command boundary. Read-only state queries remain concurrent.
+ const lifecycleLock = Semaphore.makeUnsafe(1);
// Helper: send a validated FSM event — only does the state transition
const sendEvent = (
@@ -516,23 +525,24 @@ export class Orchestrator extends Context.Service<
// Handle spawn result
const handleResult = (r: SpawnResult) =>
- Effect.gen(function* () {
- if (r._tag === "ProcessExit") {
- if (r.exitCode !== 0 && r.exitCode !== 143) {
- yield* appendRecentServiceLogs(
- def.name,
- `[process-exited] Service "${def.name}" exited with code ${r.exitCode}. Recent output:`,
- `[process-exited] Service "${def.name}" exited with code ${r.exitCode} (no recent log output).`,
- );
- }
- yield* sendEvent(def.name, { _tag: "ProcessExited", exitCode: r.exitCode });
- } else if (r._tag === "HookFailed") {
- yield* sendEvent(def.name, { _tag: "HookFailed", error: r.error });
- } else {
- yield* sendEvent(def.name, { _tag: "ProcessTerminated" });
- }
- // Unhealthy is already recorded by the probe. Scope finalization
- // terminates its process without inventing a process exit code.
+ Match.valueTags(r, {
+ ProcessExit: (result) =>
+ Effect.gen(function* () {
+ if (result.exitCode !== 0 && result.exitCode !== 143) {
+ yield* appendRecentServiceLogs(
+ def.name,
+ `[process-exited] Service "${def.name}" exited with code ${result.exitCode}. Recent output:`,
+ `[process-exited] Service "${def.name}" exited with code ${result.exitCode} (no recent log output).`,
+ );
+ }
+ yield* sendEvent(def.name, {
+ _tag: "ProcessExited",
+ exitCode: result.exitCode,
+ });
+ }),
+ HookFailed: (result) =>
+ sendEvent(def.name, { _tag: "HookFailed", error: result.error }),
+ Unhealthy: () => Effect.void,
});
yield* handleResult(result);
@@ -540,24 +550,29 @@ export class Orchestrator extends Context.Service<
const svc = services.get(def.name);
const desired =
svc === undefined ? "inactive" : SubscriptionRef.getUnsafe(svc.state).desired;
- if (r._tag === "HookFailed") {
- return { _tag: "Terminate", reason: "PolicyDisabled" } as const;
- }
- const cause: LifecycleCause =
- r._tag === "ProcessExit"
- ? { _tag: "ProcessExit", exitCode: r.exitCode }
- : { _tag: "Unhealthy" };
- return decideRestart({
- cause,
- policy: restartPolicy,
- restartCount,
- maxRestarts,
- desired,
+ return Match.valueTags(r, {
+ HookFailed: () => ({ _tag: "Terminate", reason: "PolicyDisabled" }) as const,
+ ProcessExit: (result) =>
+ decideRestart({
+ cause: { _tag: "ProcessExit", exitCode: result.exitCode },
+ policy: restartPolicy,
+ restartCount,
+ maxRestarts,
+ desired,
+ }),
+ Unhealthy: () =>
+ decideRestart({
+ cause: { _tag: "Unhealthy" },
+ policy: restartPolicy,
+ restartCount,
+ maxRestarts,
+ desired,
+ }),
});
};
let decision = restartDecision(result);
- while (decision._tag === "Restart") {
+ while (Predicate.isTagged(decision, "Restart")) {
restartCount = decision.restartCount;
yield* sendEvent(def.name, { _tag: "RestartTriggered", restartCount });
@@ -566,7 +581,7 @@ export class Orchestrator extends Context.Service<
// be reserved safely for the duration of this restart's backoff.
yield* prepareStart();
- if (result._tag === "Unhealthy") {
+ if (Predicate.isTagged(result, "Unhealthy")) {
yield* appendRecentServiceLogs(
def.name,
`[restart] Service "${def.name}" is restarting after an unhealthy health check. Recent output:`,
@@ -588,8 +603,8 @@ export class Orchestrator extends Context.Service<
}
if (
- result._tag === "Unhealthy" &&
- decision._tag === "Terminate" &&
+ Predicate.isTagged(result, "Unhealthy") &&
+ Predicate.isTagged(decision, "Terminate") &&
decision.reason === "BudgetExhausted"
) {
yield* sendEvent(def.name, {
@@ -740,7 +755,7 @@ export class Orchestrator extends Context.Service<
yield* setDesired(def.name, "running");
yield* FiberMap.run(fibers, def.name, runServiceSafe(def, options));
}
- }),
+ }).pipe(lifecycleLock.withPermit),
startService: (name: string, options) =>
Effect.gen(function* () {
@@ -784,7 +799,7 @@ export class Orchestrator extends Context.Service<
onlyIfMissing: true,
});
}
- }).pipe(startServiceLock.withPermit),
+ }).pipe(lifecycleLock.withPermit),
stop: () =>
Effect.gen(function* () {
@@ -857,7 +872,7 @@ export class Orchestrator extends Context.Service<
yield* Effect.all(forceStops.values(), { concurrency: "unbounded" });
yield* Fiber.await(stopFiber);
}
- }),
+ }).pipe(lifecycleLock.withPermit),
stopService: (name: string) =>
Effect.gen(function* () {
@@ -871,7 +886,7 @@ export class Orchestrator extends Context.Service<
yield* FiberMap.remove(fibers, affectedDef.name);
yield* sendEvent(affectedDef.name, { _tag: "ProcessExited", exitCode: 143 });
}
- }),
+ }).pipe(lifecycleLock.withPermit),
restartService: (name: string, options) =>
Effect.gen(function* () {
@@ -891,7 +906,7 @@ export class Orchestrator extends Context.Service<
for (const affectedDef of affected) {
yield* FiberMap.run(fibers, affectedDef.name, runServiceSafe(affectedDef, options));
}
- }),
+ }).pipe(lifecycleLock.withPermit),
updateServiceDefinition: (name: string, def: ServiceDef) =>
Effect.gen(function* () {
@@ -905,7 +920,7 @@ export class Orchestrator extends Context.Service<
graph.startOrder.map((current) => (current.name === name ? replacement : current)),
);
graph = nextGraph;
- }),
+ }).pipe(lifecycleLock.withPermit),
getState: (name: string) =>
Effect.gen(function* () {
diff --git a/packages/process-compose/src/Orchestrator.unit.test.ts b/packages/process-compose/src/Orchestrator.unit.test.ts
index 520375e86a..276779ea3b 100644
--- a/packages/process-compose/src/Orchestrator.unit.test.ts
+++ b/packages/process-compose/src/Orchestrator.unit.test.ts
@@ -1,5 +1,16 @@
import { describe, expect, it } from "@effect/vitest";
-import { Deferred, Duration, Effect, Exit, Fiber, Layer, Option, Sink, Stream } from "effect";
+import {
+ Deferred,
+ Duration,
+ Effect,
+ Exit,
+ Fiber,
+ Layer,
+ Option,
+ Predicate,
+ Sink,
+ Stream,
+} from "effect";
import { ChildProcessSpawner } from "effect/unstable/process";
import { buildGraph } from "./DependencyGraph.ts";
import { LogBuffer } from "./LogBuffer.ts";
@@ -82,8 +93,7 @@ interface SpawnOpts {
function createWaitList() {
interface Waiter {
readonly ready: () => boolean;
- readonly resolve: () => void;
- readonly timeout: ReturnType;
+ readonly signal: Deferred.Deferred;
}
const waiters = new Set();
@@ -91,33 +101,26 @@ function createWaitList() {
const notify = () => {
for (const waiter of waiters) {
if (waiter.ready()) {
- clearTimeout(waiter.timeout);
waiters.delete(waiter);
- waiter.resolve();
+ Effect.runSync(Deferred.succeed(waiter.signal, void 0));
}
}
};
const waitUntil = (ready: () => boolean, description: string, timeoutMs = 2_000) =>
- Effect.promise(
- () =>
- new Promise((resolve, reject) => {
- if (ready()) {
- resolve();
- return;
- }
-
- const waiter: Waiter = {
- ready,
- resolve,
- timeout: setTimeout(() => {
- waiters.delete(waiter);
- reject(new Error(`Timed out waiting for ${description}`));
- }, timeoutMs),
- };
- waiters.add(waiter);
- }),
- );
+ Effect.gen(function* () {
+ if (ready()) return;
+ const signal = yield* Deferred.make();
+ const waiter: Waiter = { ready, signal };
+ waiters.add(waiter);
+ const result = yield* Deferred.await(signal).pipe(
+ Effect.timeoutOption(Duration.millis(timeoutMs)),
+ Effect.ensuring(Effect.sync(() => waiters.delete(waiter))),
+ );
+ if (Option.isNone(result)) {
+ return yield* Effect.fail(new Error(`Timed out waiting for ${description}`));
+ }
+ });
return { notify, waitUntil };
}
@@ -169,8 +172,9 @@ function mockChildProcessSpawner(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
- const cmd = command._tag === "StandardCommand" ? command.command : "";
- const args = command._tag === "StandardCommand" ? command.args : [];
+ const standardCommand = Predicate.isTagged(command, "StandardCommand");
+ const cmd = standardCommand ? command.command : "";
+ const args = standardCommand ? command.args : [];
const record: SpawnRecord = { command: cmd, args };
spawned.push(record);
opts.onSpawn?.(record);
@@ -261,8 +265,9 @@ function mockStuckChildProcessSpawner() {
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
- const cmd = command._tag === "StandardCommand" ? command.command : "";
- const args = command._tag === "StandardCommand" ? command.args : [];
+ const standardCommand = Predicate.isTagged(command, "StandardCommand");
+ const cmd = standardCommand ? command.command : "";
+ const args = standardCommand ? command.args : [];
spawned.push({ command: cmd, args });
// exitCode Deferred that is NEVER resolved — simulates stuck process
@@ -407,7 +412,7 @@ describe("Orchestrator", () => {
return Effect.gen(function* () {
const orc = yield* Orchestrator;
const exit = yield* orc.getState("nonexistent").pipe(Effect.exit);
- expect(exit._tag).toBe("Failure");
+ expect(Exit.isFailure(exit)).toBe(true);
}).pipe(Effect.provide(layer), Effect.scoped);
});
@@ -2037,8 +2042,8 @@ describe("Orchestrator", () => {
});
const error = yield* orc.waitReady("api").pipe(Effect.flip);
- expect(error._tag).toBe("ServiceReadyError");
- if (error._tag === "ServiceReadyError") {
+ expect(Predicate.isTagged(error, "ServiceReadyError")).toBe(true);
+ if (Predicate.isTagged(error, "ServiceReadyError")) {
expect(error.reason).toContain("port reservation failed");
}
expect((yield* orc.getState("api")).status).toBe("Failed");
@@ -2133,8 +2138,8 @@ describe("Orchestrator", () => {
yield* waitForState(orc, "a", (state) => state.status === "Failed", "Failed");
const error = yield* orc.waitReady("a").pipe(Effect.flip);
- expect(error._tag).toBe("ServiceReadyError");
- if (error._tag === "ServiceReadyError") {
+ expect(Predicate.isTagged(error, "ServiceReadyError")).toBe(true);
+ if (Predicate.isTagged(error, "ServiceReadyError")) {
expect(error.reason).toContain("port reservation failed");
}
}).pipe(Effect.provide(layer), Effect.scoped);
@@ -2222,8 +2227,8 @@ describe("Orchestrator", () => {
expect(state.error).toBe("Health check failed and restart budget was exhausted");
const readyError = yield* orc.waitReady("a").pipe(Effect.flip);
- expect(readyError._tag).toBe("ServiceReadyError");
- if (readyError._tag === "ServiceReadyError") {
+ expect(Predicate.isTagged(readyError, "ServiceReadyError")).toBe(true);
+ if (Predicate.isTagged(readyError, "ServiceReadyError")) {
expect(readyError.reason).toBe("Health check failed and restart budget was exhausted");
}
}).pipe(Effect.provide(layer), Effect.scoped);
diff --git a/packages/process-compose/src/RestartDecision.ts b/packages/process-compose/src/RestartDecision.ts
index dc1d2fc5ae..651fb17a11 100644
--- a/packages/process-compose/src/RestartDecision.ts
+++ b/packages/process-compose/src/RestartDecision.ts
@@ -1,3 +1,4 @@
+import { Predicate } from "effect";
import type { RestartPolicy } from "./ServiceDef.ts";
import type { ServiceDesiredState } from "./ServiceState.ts";
@@ -23,11 +24,14 @@ export function decideRestart(options: {
readonly maxRestarts: number;
readonly desired: ServiceDesiredState;
}): RestartDecision {
+ const causeIsUnhealthy = Predicate.isTagged(options.cause, "Unhealthy");
+ const processExit = Predicate.isTagged(options.cause, "ProcessExit") ? options.cause : undefined;
+
if (options.desired !== "running") {
return { _tag: "Terminate", reason: "NotDesired" };
}
- if (options.cause._tag === "Unhealthy" && options.policy === "no") {
+ if (causeIsUnhealthy && options.policy === "no") {
return { _tag: "KeepRunningUnhealthy" };
}
@@ -35,7 +39,7 @@ export function decideRestart(options: {
options.policy === "always" ||
options.policy === "unless-stopped" ||
(options.policy === "on-failure" &&
- (options.cause._tag === "Unhealthy" || options.cause.exitCode !== 0));
+ (causeIsUnhealthy || (processExit !== undefined && processExit.exitCode !== 0)));
if (!policyAllowsRestart) {
return { _tag: "Terminate", reason: "PolicyDisabled" };
diff --git a/packages/process-compose/src/RestartDecision.unit.test.ts b/packages/process-compose/src/RestartDecision.unit.test.ts
index f8d751daa5..8e7aa62fa5 100644
--- a/packages/process-compose/src/RestartDecision.unit.test.ts
+++ b/packages/process-compose/src/RestartDecision.unit.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { Predicate } from "effect";
import { decideRestart, type LifecycleCause } from "./RestartDecision.ts";
import type { RestartPolicy } from "./ServiceDef.ts";
@@ -20,15 +21,14 @@ describe("decideRestart", () => {
["unless-stopped", exit(1), "Restart"],
["unless-stopped", unhealthy, "Restart"],
] as const)("applies %s to %o", (policy, cause, expected) => {
- expect(
- decideRestart({
- policy,
- cause,
- restartCount: 0,
- maxRestarts: 1,
- desired: "running",
- })._tag,
- ).toBe(expected);
+ const decision = decideRestart({
+ policy,
+ cause,
+ restartCount: 0,
+ maxRestarts: 1,
+ desired: "running",
+ });
+ expect(Predicate.isTagged(decision, expected)).toBe(true);
});
it.each(["no", "on-failure", "always", "unless-stopped"] as const)(
diff --git a/packages/process-compose/src/ServiceTransition.ts b/packages/process-compose/src/ServiceTransition.ts
index ea9212a754..4cb61da328 100644
--- a/packages/process-compose/src/ServiceTransition.ts
+++ b/packages/process-compose/src/ServiceTransition.ts
@@ -1,4 +1,4 @@
-import { Effect, SubscriptionRef } from "effect";
+import { Effect, Match, SubscriptionRef } from "effect";
import { ServiceState, type ServiceStatus } from "./ServiceState.ts";
// ---------------------------------------------------------------------------
@@ -31,21 +31,15 @@ export type ServiceEvent =
// Transition table — every event must classify its legal source statuses
// ---------------------------------------------------------------------------
-type TransitionTable = {
- readonly [Tag in ServiceEvent["_tag"]]: ReadonlySet;
-};
-
-const transitions: TransitionTable = {
- DependenciesSatisfied: new Set(["Pending"]),
- DependencyFailed: new Set(["Pending"]),
- SpawnFailed: new Set(["Pending", "Starting", "Restarting"]),
- ProcessSpawned: new Set(["Starting"]),
- HealthCheckPassed: new Set(["Running", "Healthy", "Unhealthy"]),
- HealthCheckFailed: new Set(["Running", "Healthy"]),
- ProcessTerminated: new Set(["Unhealthy"]),
- UnhealthyRestartExhausted: new Set(["Unhealthy"]),
- ProcessExited: new Set(["Running", "Healthy", "Unhealthy", "Stopping", "Failed"]),
- StopRequested: new Set([
+const transitionSets = {
+ dependenciesSatisfied: new Set(["Pending"]),
+ spawnFailed: new Set(["Pending", "Starting", "Restarting"]),
+ processSpawned: new Set(["Starting"]),
+ healthCheckPassed: new Set(["Running", "Healthy", "Unhealthy"]),
+ healthCheckFailed: new Set(["Running", "Healthy"]),
+ unhealthy: new Set(["Unhealthy"]),
+ processExited: new Set(["Running", "Healthy", "Unhealthy", "Stopping", "Failed"]),
+ stopRequested: new Set([
"Pending",
"Starting",
"Running",
@@ -54,102 +48,127 @@ const transitions: TransitionTable = {
"Restarting",
"Failed",
]),
- RestartTriggered: new Set(["Stopped", "Failed", "Unhealthy"]),
- BackoffElapsed: new Set(["Restarting"]),
- HookFailed: new Set(["Starting", "Running", "Healthy", "Unhealthy"]),
-};
-
-// ---------------------------------------------------------------------------
-// applyEvent — pure function, returns new ServiceState or null if invalid
-// ---------------------------------------------------------------------------
-
-export const applyEvent = (state: ServiceState, event: ServiceEvent): ServiceState | null => {
- if (!transitions[event._tag].has(state.status)) return null;
-
- switch (event._tag) {
- case "DependenciesSatisfied":
- return new ServiceState({ ...state, status: "Starting" });
-
- case "DependencyFailed":
- case "SpawnFailed":
- return new ServiceState({
+ restartTriggered: new Set(["Stopped", "Failed", "Unhealthy"]),
+ backoffElapsed: new Set(["Restarting"]),
+ hookFailed: new Set(["Starting", "Running", "Healthy", "Unhealthy"]),
+} as const;
+
+const transitionStatuses = Match.type().pipe(
+ Match.tag(
+ "DependenciesSatisfied",
+ "DependencyFailed",
+ () => transitionSets.dependenciesSatisfied,
+ ),
+ Match.tag("SpawnFailed", () => transitionSets.spawnFailed),
+ Match.tag("ProcessSpawned", () => transitionSets.processSpawned),
+ Match.tag("HealthCheckPassed", () => transitionSets.healthCheckPassed),
+ Match.tag("HealthCheckFailed", () => transitionSets.healthCheckFailed),
+ Match.tag("ProcessTerminated", "UnhealthyRestartExhausted", () => transitionSets.unhealthy),
+ Match.tag("ProcessExited", () => transitionSets.processExited),
+ Match.tag("StopRequested", () => transitionSets.stopRequested),
+ Match.tag("RestartTriggered", () => transitionSets.restartTriggered),
+ Match.tag("BackoffElapsed", () => transitionSets.backoffElapsed),
+ Match.tag("HookFailed", () => transitionSets.hookFailed),
+ Match.exhaustive,
+);
+
+const applyTransition = Match.type().pipe(
+ Match.tag(
+ "DependenciesSatisfied",
+ () => (state: ServiceState) => new ServiceState({ ...state, status: "Starting" }),
+ ),
+ Match.tag(
+ "DependencyFailed",
+ "SpawnFailed",
+ (event) => (state: ServiceState) =>
+ new ServiceState({
...state,
status: "Failed",
pid: null,
exitCode: null,
error: event.error,
- });
-
- case "ProcessSpawned":
- return new ServiceState({
+ }),
+ ),
+ Match.tag(
+ "ProcessSpawned",
+ (event) => (state: ServiceState) =>
+ new ServiceState({
...state,
status: "Running",
pid: event.pid,
startedAt: event.startedAt,
- });
-
- case "HealthCheckPassed":
- return new ServiceState({ ...state, status: "Healthy" });
-
- case "HealthCheckFailed":
- return new ServiceState({ ...state, status: "Unhealthy" });
-
- case "UnhealthyRestartExhausted":
- return new ServiceState({
+ }),
+ ),
+ Match.tag(
+ "HealthCheckPassed",
+ () => (state: ServiceState) => new ServiceState({ ...state, status: "Healthy" }),
+ ),
+ Match.tag(
+ "HealthCheckFailed",
+ () => (state: ServiceState) => new ServiceState({ ...state, status: "Unhealthy" }),
+ ),
+ Match.tag(
+ "ProcessTerminated",
+ () => (state: ServiceState) => new ServiceState({ ...state, pid: null }),
+ ),
+ Match.tag(
+ "UnhealthyRestartExhausted",
+ (event) => (state: ServiceState) =>
+ new ServiceState({
...state,
status: "Failed",
pid: null,
exitCode: null,
error: event.error,
- });
-
- case "ProcessTerminated":
- return new ServiceState({ ...state, pid: null });
-
- case "ProcessExited": {
- const status: ServiceStatus =
- state.status === "Stopping" ? "Stopped" : event.exitCode === 0 ? "Stopped" : "Failed";
- return new ServiceState({
- ...state,
- status,
- pid: null,
- exitCode: event.exitCode,
- });
- }
-
- case "StopRequested": {
- // Pending/Restarting have no running process — go straight to Stopped
- const stopStatus =
- state.status === "Pending" || state.status === "Restarting" ? "Stopped" : "Stopping";
- return new ServiceState({ ...state, status: stopStatus });
- }
-
- case "RestartTriggered":
- return new ServiceState({
+ }),
+ ),
+ Match.tag("ProcessExited", (event) => (state: ServiceState) => {
+ const status: ServiceStatus =
+ state.status === "Stopping" ? "Stopped" : event.exitCode === 0 ? "Stopped" : "Failed";
+ return new ServiceState({ ...state, status, pid: null, exitCode: event.exitCode });
+ }),
+ Match.tag("StopRequested", () => (state: ServiceState) => {
+ const stopStatus =
+ state.status === "Pending" || state.status === "Restarting" ? "Stopped" : "Stopping";
+ return new ServiceState({ ...state, status: stopStatus });
+ }),
+ Match.tag(
+ "RestartTriggered",
+ (event) => (state: ServiceState) =>
+ new ServiceState({
...state,
status: "Restarting",
pid: null,
restartCount: event.restartCount,
- });
-
- case "BackoffElapsed":
- return new ServiceState({
+ }),
+ ),
+ Match.tag(
+ "BackoffElapsed",
+ () => (state: ServiceState) =>
+ new ServiceState({
...state,
status: "Starting",
pid: null,
exitCode: null,
startedAt: null,
error: null,
- });
+ }),
+ ),
+ Match.tag(
+ "HookFailed",
+ (event) => (state: ServiceState) =>
+ new ServiceState({ ...state, status: "Failed", pid: null, error: event.error }),
+ ),
+ Match.exhaustive,
+);
- case "HookFailed":
- return new ServiceState({
- ...state,
- status: "Failed",
- pid: null,
- error: event.error,
- });
- }
+// ---------------------------------------------------------------------------
+// applyEvent — pure function, returns new ServiceState or null if invalid
+// ---------------------------------------------------------------------------
+
+export const applyEvent = (state: ServiceState, event: ServiceEvent): ServiceState | null => {
+ if (!transitionStatuses(event).has(state.status)) return null;
+ return applyTransition(event)(state);
};
// ---------------------------------------------------------------------------
diff --git a/packages/process-compose/src/ServiceTransition.unit.test.ts b/packages/process-compose/src/ServiceTransition.unit.test.ts
index c34b260ce2..12b175f1a4 100644
--- a/packages/process-compose/src/ServiceTransition.unit.test.ts
+++ b/packages/process-compose/src/ServiceTransition.unit.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { Match } from "effect";
import { applyEvent, type ServiceEvent } from "./ServiceTransition.ts";
import { ServiceState, initial, type ServiceStatus } from "./ServiceState.ts";
@@ -75,12 +76,29 @@ describe("ServiceTransition", () => {
BackoffElapsed: ["Restarting"],
HookFailed: ["Starting", "Running", "Healthy", "Unhealthy"],
};
+ const legalStatusesFor = Match.type().pipe(
+ Match.tagsExhaustive({
+ DependenciesSatisfied: () => legalStatuses.DependenciesSatisfied,
+ DependencyFailed: () => legalStatuses.DependencyFailed,
+ SpawnFailed: () => legalStatuses.SpawnFailed,
+ ProcessSpawned: () => legalStatuses.ProcessSpawned,
+ HealthCheckPassed: () => legalStatuses.HealthCheckPassed,
+ HealthCheckFailed: () => legalStatuses.HealthCheckFailed,
+ ProcessTerminated: () => legalStatuses.ProcessTerminated,
+ UnhealthyRestartExhausted: () => legalStatuses.UnhealthyRestartExhausted,
+ ProcessExited: () => legalStatuses.ProcessExited,
+ StopRequested: () => legalStatuses.StopRequested,
+ RestartTriggered: () => legalStatuses.RestartTriggered,
+ BackoffElapsed: () => legalStatuses.BackoffElapsed,
+ HookFailed: () => legalStatuses.HookFailed,
+ }),
+ );
for (const event of Object.values(events)) {
for (const status of statuses) {
const state = make("service", { status, pid: 1234 });
- expect(applyEvent(state, event) !== null, `${status} + ${event._tag}`).toBe(
- legalStatuses[event._tag].includes(status),
+ expect(applyEvent(state, event) !== null, `${status}`).toBe(
+ legalStatusesFor(event).includes(status),
);
}
}
diff --git a/packages/process-compose/src/SupervisorRuntime.unit.test.ts b/packages/process-compose/src/SupervisorRuntime.unit.test.ts
index 4d150bccc1..12a6151f7b 100644
--- a/packages/process-compose/src/SupervisorRuntime.unit.test.ts
+++ b/packages/process-compose/src/SupervisorRuntime.unit.test.ts
@@ -151,6 +151,102 @@ describe("supervisor-runtime", () => {
},
);
+ test(
+ "runs cleanup exactly once when graceful shutdown races with child exit",
+ { timeout: 15_000 },
+ async () => {
+ const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-race-"));
+ const cleanupMarker = path.join(tempDir, "cleanup-runs");
+ const readyFile = path.join(tempDir, "ready");
+ const childScriptPath = path.join(tempDir, "child.mjs");
+
+ writeFileSync(
+ childScriptPath,
+ [
+ `import { writeFileSync } from "node:fs";`,
+ `writeFileSync(${JSON.stringify(readyFile)}, "ready");`,
+ `process.on("SIGTERM", () => setTimeout(() => process.exit(0), 25));`,
+ `setInterval(() => {}, 1000);`,
+ ].join("\n"),
+ );
+
+ const encodedConfig = Buffer.from(
+ JSON.stringify({
+ command: process.execPath,
+ args: [childScriptPath],
+ shutdownSignal: "SIGTERM",
+ shutdownTimeoutMs: 1_000,
+ cleanup: [
+ {
+ _tag: "RunCommand",
+ executable: process.execPath,
+ args: [
+ "-e",
+ [
+ `const { appendFileSync } = require("node:fs");`,
+ `appendFileSync(${JSON.stringify(cleanupMarker)}, "cleanup\\n");`,
+ `setTimeout(() => process.exit(0), 250);`,
+ ].join("\n"),
+ ],
+ },
+ ],
+ }),
+ ).toString("base64url");
+ const supervisor = spawnSupervisor("source path", encodedConfig);
+
+ try {
+ await waitFor(() => existsSync(readyFile));
+ supervisor.stdin.end();
+ await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 });
+
+ expect(readFileSync(cleanupMarker, "utf8")).toBe("cleanup\n");
+ } finally {
+ supervisor.kill("SIGKILL");
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ },
+ );
+
+ test(
+ "exits successfully when graceful shutdown races with cleanup-less child exit",
+ { timeout: 15_000 },
+ async () => {
+ const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-exit-race-"));
+ const readyFile = path.join(tempDir, "ready");
+ const childScriptPath = path.join(tempDir, "child.mjs");
+
+ writeFileSync(
+ childScriptPath,
+ [
+ `import { writeFileSync } from "node:fs";`,
+ `writeFileSync(${JSON.stringify(readyFile)}, "ready");`,
+ `setInterval(() => {}, 1000);`,
+ ].join("\n"),
+ );
+
+ const encodedConfig = Buffer.from(
+ JSON.stringify({
+ command: process.execPath,
+ args: [childScriptPath],
+ shutdownSignal: "SIGTERM",
+ shutdownTimeoutMs: 1_000,
+ }),
+ ).toString("base64url");
+ const supervisor = spawnSupervisor("source path", encodedConfig);
+
+ try {
+ await waitFor(() => existsSync(readyFile));
+ supervisor.stdin.end();
+ await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 });
+
+ expect(supervisor.exitCode).toBe(0);
+ } finally {
+ supervisor.kill("SIGKILL");
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ },
+ );
+
test.each([
[
"non-string command argument",
diff --git a/packages/process-compose/src/errors.unit.test.ts b/packages/process-compose/src/errors.unit.test.ts
index 073e5279f0..b593159070 100644
--- a/packages/process-compose/src/errors.unit.test.ts
+++ b/packages/process-compose/src/errors.unit.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { Predicate } from "effect";
import {
CyclicDependencyError,
MissingDependencyError,
@@ -9,27 +10,27 @@ import {
describe("errors", () => {
it("CyclicDependencyError has correct tag and data", () => {
const err = new CyclicDependencyError({ cycle: "a -> b -> a" });
- expect(err._tag).toBe("CyclicDependencyError");
+ expect(Predicate.isTagged(err, "CyclicDependencyError")).toBe(true);
expect(err.cycle).toBe("a -> b -> a");
});
it("MissingDependencyError has correct tag and data", () => {
const err = new MissingDependencyError({ service: "app", dependency: "db" });
- expect(err._tag).toBe("MissingDependencyError");
+ expect(Predicate.isTagged(err, "MissingDependencyError")).toBe(true);
expect(err.service).toBe("app");
expect(err.dependency).toBe("db");
});
it("ServiceNotFoundError has correct tag and data", () => {
const err = new ServiceNotFoundError({ name: "unknown" });
- expect(err._tag).toBe("ServiceNotFoundError");
+ expect(Predicate.isTagged(err, "ServiceNotFoundError")).toBe(true);
expect(err.name).toBe("unknown");
});
it("SpawnError has correct tag and data", () => {
const cause = new Error("ENOENT");
const err = new SpawnError({ service: "postgres", cause });
- expect(err._tag).toBe("SpawnError");
+ expect(Predicate.isTagged(err, "SpawnError")).toBe(true);
expect(err.service).toBe("postgres");
expect(err.cause).toBe(cause);
});
diff --git a/packages/process-compose/src/supervisor-runtime.ts b/packages/process-compose/src/supervisor-runtime.ts
index f3114183f5..302278450b 100644
--- a/packages/process-compose/src/supervisor-runtime.ts
+++ b/packages/process-compose/src/supervisor-runtime.ts
@@ -1,6 +1,7 @@
import { execFileSync, spawn } from "node:child_process";
import { realpathSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
+import { Deferred, Duration, Effect, Fiber, Match, Option, Predicate, Schedule } from "effect";
import type { ChildProcess } from "effect/unstable/process";
import type { ExternalCleanupAction } from "./ServiceDef.ts";
import {
@@ -25,6 +26,10 @@ interface ChildExit {
readonly signal: NodeJS.Signals | null;
}
+type SupervisorOutcome =
+ | { readonly _tag: "ShutdownRequested"; readonly signal: ChildProcess.Signal }
+ | { readonly _tag: "ChildExited"; readonly exit: ChildExit };
+
const DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS = 5_000;
const isMain = (() => {
@@ -106,8 +111,7 @@ const cleanupActionFrom = (value: unknown): ExternalCleanupAction | undefined =>
return undefined;
}
- const tag = getField(value, "_tag");
- if (tag === "RunCommand") {
+ if (Predicate.isTagged(value, "RunCommand")) {
const executable = getField(value, "executable");
const args = stringArrayFrom(getField(value, "args"));
const timeoutMs = getField(value, "timeoutMs");
@@ -121,14 +125,14 @@ const cleanupActionFrom = (value: unknown): ExternalCleanupAction | undefined =>
return undefined;
}
return {
- _tag: tag,
+ _tag: "RunCommand",
executable,
args,
timeoutMs: typeof timeoutMs === "number" ? timeoutMs : undefined,
};
}
- if (tag === "RemovePath") {
+ if (Predicate.isTagged(value, "RemovePath")) {
const path = getField(value, "path");
const recursive = getField(value, "recursive");
const force = getField(value, "force");
@@ -137,7 +141,7 @@ const cleanupActionFrom = (value: unknown): ExternalCleanupAction | undefined =>
(recursive === undefined || typeof recursive === "boolean") &&
(force === undefined || typeof force === "boolean")
? {
- _tag: tag,
+ _tag: "RemovePath",
path,
recursive: typeof recursive === "boolean" ? recursive : undefined,
force: typeof force === "boolean" ? force : undefined,
@@ -186,224 +190,218 @@ const parseSupervisorRuntimeConfig = (encodedConfig: string): SupervisorRuntimeC
};
};
-export function runSupervisorRuntime(encodedConfig = process.argv[2]): void {
- if (encodedConfig == null) {
- throw new Error("Missing supervisor config");
- }
-
- const config = parseSupervisorRuntimeConfig(encodedConfig);
- const childEnv = withoutSupervisorRuntimeEnv();
-
- const isWindows = process.platform === "win32";
- const child = spawn(config.command, config.args ?? [], {
- cwd: process.cwd(),
- env: childEnv,
- stdio: ["ignore", "pipe", "pipe"],
- detached: !isWindows,
- });
-
- if (child.stdout != null) {
- child.stdout.pipe(process.stdout);
- }
-
- if (child.stderr != null) {
- child.stderr.pipe(process.stderr);
- }
-
- const childExited = new Promise((resolve) => {
- child.once("exit", (code, signal) => resolve({ code, signal }));
- });
-
- let shuttingDown = false;
- let ownerWatcher: ReturnType | undefined;
-
- const waitForChildExit = async (timeoutMs: number): Promise => {
- let timeoutId: ReturnType | undefined;
-
- try {
- return await Promise.race([
- childExited.then(() => true),
- new Promise((resolve) => {
- timeoutId = setTimeout(() => resolve(false), timeoutMs);
- }),
- ]);
- } finally {
- if (timeoutId != null) {
- clearTimeout(timeoutId);
- }
- }
- };
-
- const killProcessTree = (pid: number, signal: ChildProcess.Signal): void => {
- if (isWindows) {
- try {
- execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
- stdio: "ignore",
- timeout: 5_000,
- });
- } catch {}
-
- return;
- }
-
+const killProcessTree = (pid: number, signal: ChildProcess.Signal): void => {
+ if (isWindows) {
try {
- process.kill(-pid, signal);
- return;
+ execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
+ stdio: "ignore",
+ timeout: 5_000,
+ });
} catch {}
- try {
- process.kill(pid, signal);
- } catch {}
- };
+ return;
+ }
- const killChildTree = (signal: ChildProcess.Signal): void => {
- if (child.pid != null) {
- killProcessTree(child.pid, signal);
- }
- };
+ try {
+ process.kill(-pid, signal);
+ return;
+ } catch {}
- const runCleanupCommand = (action: RunCommandAction): Promise =>
- new Promise((resolve) => {
- const cleanupChild = spawn(action.executable, action.args, {
- detached: !isWindows,
- env: childEnv,
- stdio: "ignore",
- });
- let timeoutId: ReturnType | undefined;
+ try {
+ process.kill(pid, signal);
+ } catch {}
+};
- const finish = () => {
- if (timeoutId != null) {
- clearTimeout(timeoutId);
- }
- resolve();
+const isWindows = process.platform === "win32";
+
+const waitForExit = (
+ childExit: Deferred.Deferred,
+ timeoutMs: number,
+): Effect.Effect =>
+ Deferred.await(childExit).pipe(
+ Effect.timeoutOption(Duration.millis(timeoutMs)),
+ Effect.map(Option.isSome),
+ );
+
+const runSupervisorRuntimeEffect = (config: SupervisorRuntimeConfig): Effect.Effect =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const childEnv = withoutSupervisorRuntimeEnv();
+ const child = yield* Effect.sync(() =>
+ spawn(config.command, config.args ?? [], {
+ cwd: process.cwd(),
+ env: childEnv,
+ stdio: ["ignore", "pipe", "pipe"],
+ detached: !isWindows,
+ }),
+ );
+ if (child.stdout != null) child.stdout.pipe(process.stdout);
+ if (child.stderr != null) child.stderr.pipe(process.stderr);
+
+ const childExit = yield* Deferred.make();
+ const shutdownRequest = yield* Deferred.make();
+ const onChildExit = (code: number | null, signal: NodeJS.Signals | null) => {
+ Effect.runSync(Deferred.succeed(childExit, { code, signal }));
};
+ child.once("exit", onChildExit);
- cleanupChild.once("error", finish);
- cleanupChild.once("exit", finish);
- timeoutId = setTimeout(() => {
- if (cleanupChild.pid != null) {
- killProcessTree(cleanupChild.pid, "SIGKILL");
- }
- finish();
- }, action.timeoutMs ?? DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS);
- });
-
- const runCleanup = async (): Promise => {
- const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
- const removePathWithRetry = async (action: RemovePathAction): Promise => {
- for (let attempt = 0; attempt < 20; attempt++) {
+ const ownerPid = typeof config.ownerPid === "number" ? config.ownerPid : undefined;
+ const ownerAlive = () => {
+ if (ownerPid == null) return true;
try {
- rmSync(action.path, {
- recursive: action.recursive ?? true,
- force: action.force ?? true,
- });
- return;
- } catch {}
-
- await sleep(250);
- }
- };
-
- const runCommands = async () => {
- for (const action of config.cleanup ?? []) {
- if (action._tag !== "RunCommand") {
- continue;
+ process.kill(ownerPid, 0);
+ return true;
+ } catch {
+ return false;
}
+ };
+ const requestShutdown = (signal: ChildProcess.Signal) => {
+ Effect.runSync(Deferred.succeed(shutdownRequest, signal));
+ };
- try {
- await runCleanupCommand(action);
- } catch {}
- }
- };
-
- // Commands serialize so their worst-case timeouts add together. Each runs in its own Unix
- // process group (or uses taskkill on Windows), allowing a timeout to terminate its whole tree.
- await Promise.all([
- runCommands(),
- ...(config.cleanup ?? []).map((action) =>
- action._tag === "RemovePath" ? removePathWithRetry(action) : Promise.resolve(),
- ),
- ]);
- };
-
- const shutdown = async (signal: ChildProcess.Signal): Promise => {
- if (shuttingDown) {
- return;
- }
-
- shuttingDown = true;
- if (ownerWatcher != null) {
- clearInterval(ownerWatcher);
- }
- killChildTree(signal);
-
- const exitedGracefully = await waitForChildExit(config.shutdownTimeoutMs ?? 10_000);
- if (!exitedGracefully) {
- killChildTree("SIGKILL");
- await waitForChildExit(2_000);
- }
-
- await runCleanup();
- process.exit(0);
- };
-
- process.stdin.resume();
- process.stdin.on("end", () => {
- void shutdown(config.shutdownSignal ?? "SIGTERM");
- });
- process.stdin.on("close", () => {
- void shutdown(config.shutdownSignal ?? "SIGTERM");
- });
- process.on("SIGINT", () => {
- void shutdown("SIGINT");
- });
- process.on("SIGTERM", () => {
- void shutdown("SIGTERM");
- });
-
- const ownerPid = typeof config.ownerPid === "number" ? config.ownerPid : undefined;
- const ownerAlive = () => {
- if (ownerPid == null) {
- return true;
- }
-
- try {
- process.kill(ownerPid, 0);
- return true;
- } catch {
- return false;
- }
- };
-
- if (!ownerAlive()) {
- void shutdown(config.shutdownSignal ?? "SIGTERM");
- } else {
- ownerWatcher = setInterval(() => {
- if (!ownerAlive()) {
- void shutdown(config.shutdownSignal ?? "SIGTERM");
- }
- }, 500);
- ownerWatcher.unref?.();
- }
+ process.stdin.resume();
+ const onStdinEnd = () => requestShutdown(config.shutdownSignal ?? "SIGTERM");
+ const onStdinClose = () => requestShutdown(config.shutdownSignal ?? "SIGTERM");
+ const onSigInt = () => requestShutdown("SIGINT");
+ const onSigTerm = () => requestShutdown("SIGTERM");
+ process.stdin.on("end", onStdinEnd);
+ process.stdin.on("close", onStdinClose);
+ process.on("SIGINT", onSigInt);
+ process.on("SIGTERM", onSigTerm);
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ child.removeListener("exit", onChildExit);
+ process.stdin.removeListener("end", onStdinEnd);
+ process.stdin.removeListener("close", onStdinClose);
+ process.removeListener("SIGINT", onSigInt);
+ process.removeListener("SIGTERM", onSigTerm);
+ if (child.pid != null) killProcessTree(child.pid, "SIGKILL");
+ }),
+ );
+
+ const ownerWatcher = yield* Effect.forkChild(
+ Effect.repeat(
+ Effect.gen(function* () {
+ if (!ownerAlive()) {
+ yield* Deferred.succeed(shutdownRequest, config.shutdownSignal ?? "SIGTERM");
+ }
+ }),
+ Schedule.spaced(Duration.millis(500)),
+ ),
+ );
+
+ const killChildTree = (signal: ChildProcess.Signal) =>
+ Effect.sync(() => {
+ if (child.pid != null) killProcessTree(child.pid, signal);
+ });
- void childExited.then(async ({ code, signal }) => {
- if (shuttingDown) {
- return;
- }
+ const runCleanupCommand = (action: RunCommandAction): Effect.Effect =>
+ Effect.callback((resume) => {
+ const cleanupChild = spawn(action.executable, action.args, {
+ detached: !isWindows,
+ env: childEnv,
+ stdio: "ignore",
+ });
+ const finish = () => resume(Effect.void);
+ cleanupChild.once("error", finish);
+ cleanupChild.once("exit", finish);
+ return Effect.sync(() => {
+ cleanupChild.removeListener("error", finish);
+ cleanupChild.removeListener("exit", finish);
+ if (cleanupChild.pid != null) killProcessTree(cleanupChild.pid, "SIGKILL");
+ });
+ }).pipe(
+ Effect.timeoutOption(
+ Duration.millis(action.timeoutMs ?? DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS),
+ ),
+ Effect.asVoid,
+ Effect.catch(() => Effect.void),
+ );
+
+ const runCleanup = Effect.gen(function* () {
+ const removePathWithRetry = (action: RemovePathAction) =>
+ Effect.try({
+ try: () => {
+ rmSync(action.path, {
+ recursive: action.recursive ?? true,
+ force: action.force ?? true,
+ });
+ },
+ catch: (cause) => cause,
+ }).pipe(
+ Effect.retry(Schedule.spaced(Duration.millis(250)).pipe(Schedule.upTo({ times: 19 }))),
+ Effect.catch(() => Effect.void),
+ );
+ yield* Effect.all(
+ [
+ Effect.forEach(
+ config.cleanup ?? [],
+ (action) =>
+ Predicate.isTagged(action, "RunCommand") ? runCleanupCommand(action) : Effect.void,
+ { concurrency: 1, discard: true },
+ ),
+ Effect.forEach(
+ config.cleanup ?? [],
+ (action) =>
+ Predicate.isTagged(action, "RemovePath")
+ ? removePathWithRetry(action)
+ : Effect.void,
+ { concurrency: "unbounded", discard: true },
+ ),
+ ],
+ { discard: true },
+ );
+ });
- if (!ownerAlive() || (config.cleanup?.length ?? 0) > 0) {
- await runCleanup();
- process.exit(0);
- return;
- }
+ const shutdown = (signal: ChildProcess.Signal) =>
+ Effect.gen(function* () {
+ yield* killChildTree(signal);
+ const exitedGracefully = yield* waitForExit(
+ childExit,
+ config.shutdownTimeoutMs ?? 10_000,
+ );
+ if (!exitedGracefully) {
+ yield* killChildTree("SIGKILL");
+ yield* waitForExit(childExit, 2_000);
+ }
+ });
- if (signal != null) {
- process.exit(1);
- return;
- }
+ const outcome = yield* Effect.race(
+ Deferred.await(shutdownRequest).pipe(
+ Effect.map((signal): SupervisorOutcome => ({ _tag: "ShutdownRequested", signal })),
+ ),
+ Deferred.await(childExit).pipe(
+ Effect.map((exit): SupervisorOutcome => ({ _tag: "ChildExited", exit })),
+ ),
+ );
+
+ yield* Fiber.interrupt(ownerWatcher);
+ yield* Match.valueTags(outcome, {
+ ShutdownRequested: ({ signal }) =>
+ Effect.gen(function* () {
+ yield* shutdown(signal);
+ yield* runCleanup;
+ yield* Effect.sync(() => process.exit(0));
+ }),
+ ChildExited: ({ exit: { code, signal } }) =>
+ Effect.gen(function* () {
+ if (!ownerAlive() || (config.cleanup?.length ?? 0) > 0) {
+ yield* runCleanup;
+ yield* Effect.sync(() => process.exit(0));
+ } else if (signal != null) {
+ yield* Effect.sync(() => process.exit(1));
+ } else {
+ yield* Effect.sync(() => process.exit(code ?? 0));
+ }
+ }),
+ });
+ }),
+ );
- process.exit(code ?? 0);
- });
+export function runSupervisorRuntime(encodedConfig = process.argv[2]): void {
+ if (encodedConfig == null) throw new Error("Missing supervisor config");
+ const config = parseSupervisorRuntimeConfig(encodedConfig);
+ void Effect.runPromise(runSupervisorRuntimeEffect(config)).catch(() => process.exit(1));
}
if (isMain) {
diff --git a/packages/process-compose/tests/helpers/mocks.ts b/packages/process-compose/tests/helpers/mocks.ts
index 2df118417b..13922bd1bf 100644
--- a/packages/process-compose/tests/helpers/mocks.ts
+++ b/packages/process-compose/tests/helpers/mocks.ts
@@ -1,4 +1,4 @@
-import { Deferred, Effect, Layer, Sink, Stream } from "effect";
+import { Deferred, Effect, Layer, Predicate, Sink, Stream } from "effect";
import { ChildProcessSpawner } from "effect/unstable/process";
interface SpawnRecord {
@@ -44,8 +44,8 @@ export function mockChildProcessSpawner(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
- const cmd = command._tag === "StandardCommand" ? command.command : "";
- const args = command._tag === "StandardCommand" ? command.args : [];
+ const cmd = Predicate.isTagged(command, "StandardCommand") ? command.command : "";
+ const args = Predicate.isTagged(command, "StandardCommand") ? command.args : [];
const record: SpawnRecord = { command: cmd, args };
yield* opts.beforeSpawn?.(record) ?? Effect.void;
spawned.push(record);
@@ -54,7 +54,7 @@ export function mockChildProcessSpawner(
const exitDeferred = yield* Deferred.make();
let running = true;
- yield* Effect.forkDetach(
+ yield* Effect.forkScoped(
Effect.gen(function* () {
// Supervisor processes model long-running services. Direct
// commands model probes and one-shot helpers, which should
diff --git a/packages/stack/README.md b/packages/stack/README.md
index b324a54241..4a6186c5b2 100644
--- a/packages/stack/README.md
+++ b/packages/stack/README.md
@@ -15,7 +15,9 @@ console.log((await stack.getInfo()).url);
`createStack` resolves configuration, reserves ports, and builds a scoped
handle. `stack.start()` starts services; disposing the handle stops them and
-releases its lease.
+releases its lease. When `mode` is omitted, creation uses Docker mode with a
+usable Docker or Podman service and otherwise selects native mode. An explicit
+mode never falls back to the other one.
## Managed stack
@@ -47,7 +49,7 @@ const runtime =
projectDir: projectRoot,
name: "default",
portIntents,
- launch: { mode: "auto", versions: {}, excludedServices: [] },
+ launch: { mode: "docker", versions: {}, excludedServices: [] },
});
```
@@ -56,5 +58,10 @@ const runtime =
`stopDaemon` and the discovery helpers delegate to the managed lifecycle
facade. No CLI metadata file or PID polling is involved.
+After a managed supervisor claims a stack, its persisted Docker, Podman, or
+native selection remains pinned even if startup later fails. Retry after
+restoring or starting that runtime; delete and recreate the stack to choose a
+different execution mode. Deletion removes the stack's managed data.
+
For the end-to-end lifecycle, identity, ports, service execution, transport,
compiled-Bun re-entry, and testing boundary, see [How `@supabase/stack` works](docs/architecture.md).
diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md
index d89005bcc9..0ec9f3d7bc 100644
--- a/packages/stack/docs/architecture.md
+++ b/packages/stack/docs/architecture.md
@@ -35,8 +35,8 @@ sequenceDiagram
CLI->>Parent: daemonLayer(config, port intents, launch)
Parent->>Child: fork + start message (resolved stack id)
- Child->>Manager: ensure workspace + verify stack id
Child->>Control: acquire ownership + bind deterministic endpoint
+ Child->>Manager: ensure workspace + verify stack id
Child->>Manager: resolve document, allocate/reuse ports
Child->>Manager: write starting
Child->>Runtime: build Stack, ApiProxy, and DaemonServer
@@ -74,10 +74,19 @@ transport services to the shared implementation.
## Direct runtime
-`createStack` resolves configuration, reserves a private port lease, and
-prepares/builds a scoped runtime and handle. It does **not** start service
-processes. Asset preparation and process-compose graph construction happen
-when the handle is first started or a service is activated.
+The internal `createStack` Effect validates allocation-free port intents, acquires
+one authoritative lease for every active field, then resolves configuration once
+with those selected ports and builds a scoped runtime and handle. Temporary roots
+created during resolution are tracked and removed on failure. Node and Bun adapt
+that handle to the Promise/`AsyncIterable` facade at the package edge; there is no
+Promise resolver that can expose a placeholder port set. The lease owns each
+socket until its exact runtime consumer takes over the port, and retains every
+remaining reservation until disposal. Automatic API-port handoff may retry with
+an OS-selected port only before the first successful start, while explicit ports
+remain sticky.
+The direct runtime does **not** start service processes. Asset preparation and
+process-compose graph construction happen when the handle is first started or
+a service is activated.
`stack.start()` starts services according to the configured startup mode and
waits for the selected readiness policy. The handle also exposes status, logs,
@@ -86,6 +95,25 @@ owns service processes and releases the lease when disposed. A direct stack
never reads or writes managed documents and never coordinates with a sibling
stack.
+### Concurrency and cleanup
+
+`DaemonServer` creates one lazily-started, uninterruptible shutdown fiber in
+the layer scope. Every stop or terminal-readiness caller joins that fiber, so
+concurrent requests share one transaction and interrupting one caller cannot
+cancel the owner. The short response-flush signal is also a scoped fiber.
+
+`StackPreparation` resolves independent services with a concurrency cap of four.
+Its closure includes the resources for every public graph dependency a requested
+service can start, so Docker never auto-pulls outside the preparation pipeline.
+Each service resolves one canonical GHCR image. Pulls retry only transient
+registry and network failures on a one-second exponential `Schedule`, capped at
+five retries; non-retryable failures surface immediately with deterministic
+details. Auto-managed roots are removed through the Effect `FileSystem` with a
+bounded retry schedule. Each removal pass is uninterruptible, while the delay
+between attempts remains interruptible; cleanup stays scoped to the exact paths
+owned by that stack. Stale binary staging directories use the same small
+concurrency cap during reconciliation.
+
## Managed lifecycle
### One document and one owner
@@ -101,10 +129,9 @@ runtime logs, and `runtime/` for supervisor-owned runtime files. The
`ManagedStackManager` is the only component that writes `stack.json`.
Control ownership is the liveness and mutation authority. `acquireControl`
-returns `Owned` for the process that bound one of the deterministic endpoint
-candidates or `Attached` for a live owner found on any candidate. An attached
-caller uses the owner's actual endpoint for runtime requests; it never edits
-the document directly.
+returns `Owned` for the process that bound the deterministic endpoint or
+`Attached` for a live owner. An attached caller uses the owner's endpoint for
+runtime requests; it never edits the document directly.
### Start and attach
@@ -114,16 +141,18 @@ the document directly.
2. The child binds the loopback control endpoint first, re-checks workspace
discovery, and refuses to continue if the identity no longer derives the
same id.
-3. The manager resolves the existing document, cleans stale Docker resources
- when required, allocates or reuses ports, and records `starting`.
+3. The child supervisor removes stale named container resources when required;
+ after acquiring ownership it re-reads the existing document, selects or
+ validates its concrete runtime, then the manager allocates or reuses ports
+ and records `starting`.
4. The child builds the direct runtime and `DaemonServer`, records `running`
with its control endpoint, and sends the endpoint to the parent.
5. The parent returns a `RemoteStack` layer. The CLI then calls
`stack.start()` over the control transport when service startup is needed.
`connectManagedStack` reads the document, probes the deterministic endpoint
-candidates without binding them, and returns a `RemoteStack` against the
-owner's actual endpoint only when the owner reports a ready running state. Read-only status and discovery therefore do not claim an
+without binding it, and returns a `RemoteStack` only when the owner reports a
+ready running state. Read-only status and discovery therefore do not claim an
endpoint; mutating operations acquire control ownership.
### Update, stop, and delete
@@ -134,12 +163,13 @@ endpoint; mutating operations acquire control ownership.
- `stopManagedStack` asks an attached owner to perform a graceful
`RemoteStack.stop()`, waits for the document to become `stopped`, and lets
the owner close the runtime before releasing control. If the old owner is
- gone, the facade acquires control, removes Docker containers named for the
+ gone, the facade acquires control, removes containers named for the
stack id, records `stopped`, and does not inspect PIDs or scan processes.
-- `deleteManagedStack` requires owned control and removes only a stopped
- document and its managed data root. The explicit destructive path can also
- remove an invalid document after ownership is acquired; ordinary status and
- start operations report corruption instead of guessing.
+- `deleteManagedStack` requires owned control, reconciles any owned running or
+ failed runtime resources, and then removes the document and its managed data
+ root. The explicit destructive path can also remove an invalid document
+ after ownership is acquired; ordinary status and start operations report
+ corruption instead of guessing.
### Failure and recovery
@@ -148,10 +178,16 @@ transitions. A startup error records `failed` and releases the port lease. A
graceful stop closes the direct runtime before recording `stopped`.
If a supervisor crashes, its document and possible runtime artifacts remain.
-The next managed start acquires control, reconciles Docker resources by stack
-id, and reuses sticky ports according to their persisted `exact` or `automatic`
-intent. No PID file, process scan, second metadata file, or registry surgery is
-needed for recovery.
+The next managed start acquires control, reconciles named container resources by
+stack id, and reuses sticky ports according to their persisted `exact` or
+`automatic` intent. No PID file, process scan, second metadata file, or registry
+surgery is needed for recovery.
+
+Every document contains a concrete launch selection. Native documents record
+`mode: "native"`; container documents record `mode: "docker"` together with
+the selected `docker` or `podman` executable. Inputs may omit a mode to request
+selection, but unresolved launch state is never persisted. Resolved direct
+runtime configuration uses the same correlated native-or-container union.
## Identity and state
@@ -201,18 +237,17 @@ persisted endpoint, or another stack's reservation under the normal exact-port
rules. A persisted automatic assignment in the control range is invalid and
fails loudly rather than being silently migrated.
-The control endpoint is derived from the stack id and served on loopback. The
-derivation yields a short deterministic candidate sequence rather than a
-single port: an owner binds the first free candidate, skipping candidates
-occupied by other stacks or unrelated listeners, and readers scan the same
-sequence and match the published `ownershipId`. A hash collision between two
-stack ids therefore degrades to the collided stack binding its next candidate
-instead of failing. Acquisition fails with a typed conflict only when every
-candidate is occupied by a foreign listener; a read-only probe treats an
-address with no matching owner as non-live and never claims it. An exact
-service port can still equal a candidate of an identity that has never
-started, so every stack's full candidate set is reserved against exact-port
-requests rather than forbidding every explicit port in the reserved range.
+A deterministic sequence of eight control endpoint candidates is derived from
+the stack id and served on loopback. Acquisition scans for an existing matching
+owner, then binds the first available candidate; read-only probes scan the same
+sequence without claiming it. A hash collision or unrelated listener consumes
+that candidate, and acquisition fails with a typed conflict only when the
+sequence cannot yield an unambiguous owner or free endpoint. The manager
+reserves every known candidate against service allocation, and the document
+records the endpoint the owner actually bound. An exact service port can still
+equal a future candidate of an identity that has never started, so that
+low-probability conflict is rejected when ownership is acquired rather than
+forbidding every explicit port in the reserved range.
This is deliberately a small single-user localhost mechanism. The control
protocol has no token authentication; ownership, endpoint identity, and
@@ -223,14 +258,17 @@ status, service operations, logs, graceful stop, and launch-update routes;
## Service execution and `ApiProxy`
`StackPreparation` resolves each enabled service to a verified native binary or
-a Docker image. `mode: "native"` uses the supported native services and rejects
-Docker-only services; `mode: "docker"` resolves every service to an image;
-`mode: "auto"` prefers native artifacts and falls back to Docker. The
-`StackBuilder` turns those resolutions into one process-compose graph, so a
-stack can run native and Docker-backed services together. Docker resources are
-namespaced with the managed stack id; when native Postgres is combined with
-Docker services, the graph supplies the platform-specific host address so the
-containers can reach it.
+a Docker image. Explicit `mode: "native"` uses the supported native services
+and rejects Docker-only services; explicit `mode: "docker"` requires a usable
+Docker or Podman runtime and resolves every service to an image. When mode is
+omitted, selection prefers a usable Docker or Podman runtime and otherwise uses
+native mode only on a host for which native artifacts are published; that
+automatic fallback disables Docker-only services before ports or managed launch
+state are acquired. The selected runtime is then fixed for the stack. Service
+versions are normalized to their catalog form before either binary resolution
+or Docker image resolution, and `StackBuilder` turns the results into one
+process-compose graph. Docker resources are namespaced with the managed stack
+id.
`ApiProxy` listens on the configured public `apiPort` and routes Supabase API
paths (`/auth`, `/rest`, `/functions`, `/realtime`, `/storage`, `/pg`,
diff --git a/packages/stack/src/ApiProxy.ts b/packages/stack/src/ApiProxy.ts
index 9a3f98041c..08e49149cc 100644
--- a/packages/stack/src/ApiProxy.ts
+++ b/packages/stack/src/ApiProxy.ts
@@ -1,4 +1,4 @@
-import { Deferred, Effect, Layer, Option, Context, Schedule, Result } from "effect";
+import { Deferred, Effect, Layer, Option, Context, Predicate, Schedule, Result } from "effect";
import {
Headers,
HttpBody,
@@ -137,9 +137,7 @@ function makeProxyHandler(
return (req: HttpServerRequest.HttpServerRequest) =>
Effect.gen(function* () {
const activation = yield* activator.activate(opts.service).pipe(
- Effect.tapError((error) =>
- error._tag === "StackReadinessError" ? signalTerminalFailure : Effect.void,
- ),
+ Effect.tapErrorTag("StackReadinessError", () => signalTerminalFailure),
Effect.result,
);
if (Result.isFailure(activation)) {
@@ -198,7 +196,7 @@ function makeProxyHandler(
const request = client.execute(outReq);
const outRes = yield* opts.retryColdStart === true
? Effect.retry(request, {
- while: (error) => error.reason._tag === "TransportError",
+ while: (error) => Predicate.isTagged(error.reason, "TransportError"),
schedule: COLD_START_RETRY_SCHEDULE,
})
: request;
diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts
index 00e77cb06a..2428816ec9 100644
--- a/packages/stack/src/ApiProxy.unit.test.ts
+++ b/packages/stack/src/ApiProxy.unit.test.ts
@@ -1,7 +1,7 @@
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as http from "node:http";
import { gzipSync } from "node:zlib";
-import { Effect, Layer, ManagedRuntime } from "effect";
+import { Effect, Layer, ManagedRuntime, Predicate } from "effect";
import { FetchHttpClient } from "effect/unstable/http";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { ApiProxy, type ProxyConfig } from "./ApiProxy.ts";
@@ -129,7 +129,7 @@ async function startProxy(
const proxy = await proxyRuntime.runPromise(ApiProxy);
const addr = proxy.address;
let url = "";
- if (addr._tag === "TcpAddress") {
+ if (Predicate.isTagged(addr, "TcpAddress")) {
const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname;
url = `http://${host}:${addr.port}`;
}
@@ -176,7 +176,7 @@ describe("ApiProxy", () => {
const proxy = await runtime.runPromise(ApiProxy);
const addr = proxy.address;
- if (addr._tag === "TcpAddress") {
+ if (Predicate.isTagged(addr, "TcpAddress")) {
const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname;
proxyUrl = `http://${host}:${addr.port}`;
}
@@ -442,7 +442,7 @@ describe("ApiProxy", () => {
const deadProxy = await deadRuntime.runPromise(ApiProxy);
const deadAddr2 = deadProxy.address;
let deadProxyUrl = "";
- if (deadAddr2._tag === "TcpAddress") {
+ if (Predicate.isTagged(deadAddr2, "TcpAddress")) {
const host = deadAddr2.hostname === "0.0.0.0" ? "127.0.0.1" : deadAddr2.hostname;
deadProxyUrl = `http://${host}:${deadAddr2.port}`;
}
diff --git a/packages/stack/src/BinaryResolver.integration.test.ts b/packages/stack/src/BinaryResolver.integration.test.ts
index 5dc58fe38d..3813a640ba 100644
--- a/packages/stack/src/BinaryResolver.integration.test.ts
+++ b/packages/stack/src/BinaryResolver.integration.test.ts
@@ -1,210 +1,838 @@
+import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
+import { zstdCompressSync } from "node:zlib";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
+ symlinkSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
-import { basename, dirname, join } from "node:path";
-import { NodeServices } from "@effect/platform-node";
+import { dirname, join } from "node:path";
+import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node";
import { describe, expect, it } from "@effect/vitest";
-import { Effect, Layer } from "effect";
+import { Deferred, Effect, Fiber, FileSystem, Layer, Predicate } from "effect";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
-import { afterEach } from "vitest";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { BinaryResolver } from "./BinaryResolver.ts";
-import { DownloadError } from "./errors.ts";
+import {
+ BinaryHostCompatibilityError,
+ BinaryManifestError,
+ BinaryRuntimeError,
+ ChecksumMismatchError,
+ DownloadError,
+} from "./errors.ts";
import { detectPlatform } from "./Platform.ts";
import { nativeReleaseForService } from "./ServiceCatalog.ts";
import { DEFAULT_VERSIONS } from "./versions.ts";
-const tempRoots: string[] = [];
+const makeRoot = (): string => mkdtempSync(join(tmpdir(), "stack-slim-services-"));
-const makeTempRoot = (): string => {
- const root = mkdtempSync(join(tmpdir(), "stack-binary-resolver-"));
- tempRoots.push(root);
- return root;
+const makeFixture = (
+ root: string,
+ manifestOverride: Record = {},
+ includePostgrest = true,
+ paddingBytes = 0,
+) => {
+ const source = join(root, "source");
+ const tar = join(root, "postgrest.tar");
+ const archive = join(root, "postgrest.tar.zst");
+ rmSync(source, { recursive: true, force: true });
+ mkdirSync(join(source, "bin"), { recursive: true });
+ if (includePostgrest) {
+ writeFileSync(join(source, "bin", "postgrest"), "#!/bin/sh\necho postgrest\n");
+ }
+ if (paddingBytes > 0) {
+ writeFileSync(join(source, "bin", "padding"), Buffer.alloc(paddingBytes));
+ }
+ execFileSync("tar", ["-cf", tar, "-C", source, "."]);
+ writeFileSync(archive, zstdCompressSync(readFileSync(tar)));
+ return { archive: readFileSync(archive), manifestOverride };
};
-const makeArchive = (root: string): Uint8Array => {
- const source = join(root, "source");
- const archive = join(root, "auth.tar.gz");
- execFileSync("mkdir", ["-p", source]);
- writeFileSync(join(source, "auth"), "#!/bin/sh\necho auth\n");
- execFileSync("tar", ["czf", archive, "-C", source, "."]);
- return readFileSync(archive);
+const writeTarOctal = (header: Buffer, offset: number, length: number, value: number): void => {
+ const encoded = `${value.toString(8).padStart(length - 1, "0")}\0`;
+ header.write(encoded, offset, length, "ascii");
+};
+
+const makeTarArchive = (member: string, contents: string): Buffer => {
+ const payload = Buffer.from(contents);
+ const header = Buffer.alloc(512);
+ header.write(member, 0, 100, "utf8");
+ writeTarOctal(header, 100, 8, 0o644);
+ writeTarOctal(header, 108, 8, 0);
+ writeTarOctal(header, 116, 8, 0);
+ writeTarOctal(header, 124, 12, payload.length);
+ writeTarOctal(header, 136, 12, 0);
+ header[156] = "0".charCodeAt(0);
+ header.write("ustar\0", 257, 6, "ascii");
+ header.write("00", 263, 2, "ascii");
+ header.fill(0x20, 148, 156);
+ const checksum = header.reduce((sum, byte) => sum + byte, 0);
+ header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii");
+ const padding = Buffer.alloc((512 - (payload.length % 512)) % 512);
+ return Buffer.concat([header, payload, padding, Buffer.alloc(1024)]);
+};
+
+const makeTraversalFixture = (root: string) => {
+ const archive = join(root, "postgrest-traversal.tar.zst");
+ writeFileSync(join(root, "outside.txt"), "must not extract\n");
+ writeFileSync(archive, zstdCompressSync(makeTarArchive("../outside.txt", "must not extract\n")));
+ return { archive: readFileSync(archive), manifestOverride: {} };
+};
+
+const makeEscapingSymlinkFixture = (root: string) => {
+ const source = join(root, "symlink-source");
+ const tar = join(root, "postgrest-symlink.tar");
+ const archive = join(root, "postgrest-symlink.tar.zst");
+ rmSync(source, { recursive: true, force: true });
+ mkdirSync(join(source, "bin"), { recursive: true });
+ symlinkSync("/bin/sh", join(source, "bin/postgrest"));
+ execFileSync("tar", ["-cf", tar, "-C", source, "."]);
+ writeFileSync(archive, zstdCompressSync(readFileSync(tar)));
+ return { archive: readFileSync(archive), manifestOverride: {} };
};
-const makeResolverLayer = (cacheRoot: string, archive: Uint8Array, onRequest: () => void) => {
+const makeResolverLayer = (
+ cacheRoot: string,
+ fixture: ReturnType,
+ options: {
+ readonly checksum?: string;
+ readonly checksumText?: string;
+ readonly spawnedCommands?: Array<{ command: string; args: ReadonlyArray }>;
+ readonly onChecksumRead?: () => void;
+ readonly exitCodeForCommand?: (command: string) => number | undefined;
+ readonly transformFileSystem?: (fileSystem: FileSystem.FileSystem) => FileSystem.FileSystem;
+ } = {},
+) => {
const client = HttpClient.make((request) =>
Effect.sync(() => {
- onRequest();
- return HttpClientResponse.fromWeb(request, new Response(archive, { status: 200 }));
+ if (request.url.endsWith(".manifest.json")) {
+ return HttpClientResponse.fromWeb(
+ request,
+ new Response(
+ JSON.stringify({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ target: process.platform === "darwin" ? "darwin-arm64" : "linux-amd64",
+ entrypoint: [],
+ cmd: ["/bin/postgrest"],
+ runtime_requires: null,
+ libc: process.platform === "linux" ? "glibc" : null,
+ os_floor:
+ process.platform === "linux"
+ ? { kind: "glibc", floor: null, scanned: 1 }
+ : { kind: "macos", floor: null, scanned: 1 },
+ ...fixture.manifestOverride,
+ }),
+ { status: 200 },
+ ),
+ );
+ }
+ if (request.url.endsWith("SHA256SUMS")) {
+ const hash = options.checksum ?? createHash("sha256").update(fixture.archive).digest("hex");
+ const checksumText =
+ options.checksumText ??
+ `${hash} postgrest-${DEFAULT_VERSIONS.postgrest}-${process.platform === "darwin" ? "darwin-arm64" : "linux-amd64"}.tar.zst\n`;
+ const response = HttpClientResponse.fromWeb(
+ request,
+ new Response(checksumText, { status: 200 }),
+ );
+ if (options.onChecksumRead !== undefined) {
+ Object.defineProperty(response, "text", {
+ value: Effect.sync(() => {
+ options.onChecksumRead?.();
+ return checksumText;
+ }),
+ });
+ }
+ return response;
+ }
+ return HttpClientResponse.fromWeb(request, new Response(fixture.archive, { status: 200 }));
}),
);
+ const spawnerLayer =
+ options.spawnedCommands === undefined && options.exitCodeForCommand === undefined
+ ? NodeServices.layer
+ : Layer.effect(
+ ChildProcessSpawner.ChildProcessSpawner,
+ Effect.gen(function* () {
+ const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const record = (command: ChildProcess.Command) => {
+ if (Predicate.isTagged(command, "StandardCommand")) {
+ options.spawnedCommands?.push({
+ command: command.command,
+ args: command.args,
+ });
+ }
+ };
+ const spawner = ChildProcessSpawner.make((command) => {
+ record(command);
+ return delegate.spawn(command);
+ });
+ return ChildProcessSpawner.ChildProcessSpawner.of({
+ ...spawner,
+ exitCode: (command) => {
+ if (!Predicate.isTagged(command, "StandardCommand")) {
+ return spawner.exitCode(command);
+ }
+ const exitCode = options.exitCodeForCommand?.(command.command);
+ if (exitCode === undefined) return spawner.exitCode(command);
+ record(command);
+ return Effect.succeed(ChildProcessSpawner.ExitCode(exitCode));
+ },
+ });
+ }),
+ ).pipe(Layer.provide(NodeServices.layer));
+ const fileSystemLayer =
+ options.transformFileSystem === undefined
+ ? NodeFileSystem.layer
+ : Layer.effect(
+ FileSystem.FileSystem,
+ Effect.map(FileSystem.FileSystem, options.transformFileSystem),
+ ).pipe(Layer.provide(NodeFileSystem.layer));
return BinaryResolver.make(cacheRoot).pipe(
- Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
- Layer.provide(NodeServices.layer),
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.succeed(HttpClient.HttpClient, client),
+ spawnerLayer,
+ fileSystemLayer,
+ NodePath.layer,
+ ),
+ ),
);
};
-const makeUnavailableResolverLayer = (cacheRoot: string) => {
- const client = HttpClient.make((request) =>
- Effect.succeed(
- HttpClientResponse.fromWeb(request, new Response("unavailable", { status: 503 })),
- ),
+describe("BinaryResolver slim-services installer", () => {
+ it.live("keeps the Effect runtime responsive while decompressing an archive", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ let eventLoopAdvanced = false;
+ let advancedBeforeArchiveWrite = false;
+ const resolverLayer = makeResolverLayer(
+ root,
+ makeFixture(root, {}, true, 32 * 1024 * 1024),
+ {
+ onChecksumRead: () => {
+ setImmediate(() => {
+ eventLoopAdvanced = true;
+ });
+ },
+ transformFileSystem: (fileSystem) =>
+ FileSystem.FileSystem.of({
+ ...fileSystem,
+ writeFile: (requestedPath, data, options) => {
+ if (requestedPath.endsWith("_download.tar")) {
+ advancedBeforeArchiveWrite = eventLoopAdvanced;
+ }
+ return fileSystem.writeFile(requestedPath, data, options);
+ },
+ }),
+ },
+ );
+
+ yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+
+ expect(advancedBeforeArchiveWrite).toBe(true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
);
- return BinaryResolver.make(cacheRoot).pipe(
- Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
- Layer.provide(NodeServices.layer),
+
+ it.live("rejects failed binary post-processing before publishing the cache", () =>
+ Effect.gen(function* () {
+ const commands = [
+ ...(process.platform === "win32" ? [] : ["chmod"]),
+ ...(process.platform === "darwin" ? ["find"] : []),
+ ];
+ for (const failedCommand of commands) {
+ const root = makeRoot();
+ try {
+ const resolverLayer = makeResolverLayer(root, makeFixture(root), {
+ exitCodeForCommand: (command) => (command === failedCommand ? 73 : undefined),
+ });
+ const failure = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer), Effect.flip);
+
+ expect(failure).toBeInstanceOf(BinaryRuntimeError);
+ if (failure instanceof BinaryRuntimeError) {
+ expect(failure.detail).toContain("73");
+ }
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }
+ }),
);
-};
-const authCachePath = (cacheRoot: string) =>
- Effect.gen(function* () {
- const platform = yield* detectPlatform;
- const release = nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, platform);
- if (release === undefined) {
- return yield* Effect.die(`unsupported test platform: ${platform.os}-${platform.arch}`);
- }
- return BinaryResolver.cachePath(join(cacheRoot, "bin"), {
- service: "auth",
- provider: release.provider,
- version: DEFAULT_VERSIONS.auth,
- assetName: release.assetName,
- });
- });
-
-const legacyAuthCachePath = (cacheRoot: string) =>
- Effect.gen(function* () {
- const platform = yield* detectPlatform;
- const release = nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, platform);
- if (release === undefined) {
- return yield* Effect.die(`unsupported test platform: ${platform.os}-${platform.arch}`);
- }
- return join(cacheRoot, "bin", "auth", DEFAULT_VERSIONS.auth, release.assetName);
- });
-
-afterEach(() => {
- for (const root of tempRoots.splice(0)) {
- rmSync(root, { recursive: true, force: true });
- }
-});
+ it.live("installs a tar.zst archive into an empty cache and reuses it", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root);
+ const resolverLayer = makeResolverLayer(root, fixture);
+ const release = nativeReleaseForService(
+ "postgrest",
+ DEFAULT_VERSIONS.postgrest,
+ yield* detectPlatform,
+ );
+ if (release === undefined) return;
+ const result = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ const first = yield* resolver.resolveWithMetadata({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ const second = yield* resolver.resolveWithMetadata({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ return { first, second };
+ }).pipe(Effect.provide(resolverLayer));
+ expect(result.first.downloaded).toBe(true);
+ expect(result.second.downloaded).toBe(false);
+ expect(readFileSync(join(result.first.path, "bin/postgrest"), "utf8")).toContain(
+ "postgrest",
+ );
+ expect(existsSync(join(result.first.path, ".complete"))).toBe(true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("rejects a complete cache prepared for an incompatible host", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const resolverLayer = makeResolverLayer(root, makeFixture(root));
+ const installed = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+
+ const markerPath = join(installed, ".complete");
+ const marker = JSON.parse(readFileSync(markerPath, "utf8"));
+ marker.hostCompatibility =
+ process.platform === "darwin"
+ ? {
+ runtimeRequires: null,
+ libc: null,
+ osFloor: { kind: "macos", floor: "999.0" },
+ }
+ : {
+ runtimeRequires: "glibc",
+ libc: "glibc",
+ osFloor: { kind: "glibc", floor: "999.0" },
+ };
+ writeFileSync(markerPath, JSON.stringify(marker));
+
+ const failure = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer), Effect.flip);
+
+ expect(failure).toBeInstanceOf(BinaryHostCompatibilityError);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("fails closed when the manifest host floor is malformed", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root, {
+ os_floor:
+ process.platform === "darwin"
+ ? { kind: "macos", floor: "not-a-version", scanned: 1 }
+ : { kind: "glibc", floor: "not-a-version", scanned: 1 },
+ });
+ const resolverLayer = makeResolverLayer(root, fixture);
+ const failure = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer), Effect.flip);
+
+ expect(failure).toBeInstanceOf(BinaryHostCompatibilityError);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("installs slim archives without requiring an external zstd executable", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const spawnedCommands: Array<{ command: string; args: ReadonlyArray }> = [];
+ const resolverLayer = makeResolverLayer(root, makeFixture(root), { spawnedCommands });
+ yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+
+ expect(spawnedCommands.some(({ args }) => args.includes("--zstd"))).toBe(false);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("accepts the Mailpit-style glibc runtime requirement", () =>
+ Effect.gen(function* () {
+ if (process.platform !== "linux") return;
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root, { runtime_requires: "glibc" });
+ const resolverLayer = makeResolverLayer(root, fixture);
+ const result = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+ expect(existsSync(join(result, "bin/postgrest"))).toBe(true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("reclaims interrupted staging while preserving complete cache entries", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root);
+ const resolverLayer = makeResolverLayer(root, fixture);
+ const release = nativeReleaseForService(
+ "postgrest",
+ DEFAULT_VERSIONS.postgrest,
+ yield* detectPlatform,
+ );
+ if (release === undefined) return;
+ const cacheDir = BinaryResolver.cachePath(join(root, "bin"), {
+ service: "postgrest",
+ releaseSet: "slim-services",
+ version: DEFAULT_VERSIONS.postgrest,
+ runtime: "native",
+ target: release.target,
+ });
+ const stale = join(dirname(cacheDir), `.${release.assetName}.partial-interrupted`);
+ mkdirSync(stale, { recursive: true });
+ const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1_000);
+ utimesSync(stale, old, old);
+
+ const resolved = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+
+ expect(existsSync(stale)).toBe(false);
+ expect(existsSync(join(resolved, ".complete"))).toBe(true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("limits stale staging reconciliation to four filesystem operations", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root);
+ const release = nativeReleaseForService(
+ "postgrest",
+ DEFAULT_VERSIONS.postgrest,
+ yield* detectPlatform,
+ );
+ if (release === undefined) return;
+ const cacheDir = BinaryResolver.cachePath(join(root, "bin"), {
+ service: "postgrest",
+ releaseSet: "slim-services",
+ version: DEFAULT_VERSIONS.postgrest,
+ runtime: "native",
+ target: release.target,
+ });
+ const stalePrefix = join(dirname(cacheDir), `.${release.assetName}.partial-cap-`);
+ const old = new Date(Date.now() - 2 * 24 * 60 * 60 * 1_000);
+ for (let index = 0; index < 5; index += 1) {
+ const stalePath = `${stalePrefix}${index}`;
+ mkdirSync(stalePath, { recursive: true });
+ utimesSync(stalePath, old, old);
+ }
+
+ const saturated = yield* Deferred.make();
+ const releaseStats = yield* Deferred.make();
+ let active = 0;
+ let maxActive = 0;
+ const resolverLayer = makeResolverLayer(root, fixture, {
+ transformFileSystem: (fileSystem) =>
+ FileSystem.FileSystem.of({
+ ...fileSystem,
+ stat: (requestedPath) => {
+ if (!requestedPath.startsWith(stalePrefix)) return fileSystem.stat(requestedPath);
+ return Effect.acquireUseRelease(
+ Effect.sync(() => {
+ active += 1;
+ maxActive = Math.max(maxActive, active);
+ return active;
+ }).pipe(
+ Effect.tap((current) =>
+ current === 4 ? Deferred.succeed(saturated, undefined) : Effect.void,
+ ),
+ ),
+ () =>
+ Deferred.await(releaseStats).pipe(
+ Effect.andThen(fileSystem.stat(requestedPath)),
+ ),
+ () =>
+ Effect.sync(() => {
+ active -= 1;
+ }),
+ );
+ },
+ }),
+ });
+ const resolving = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer), Effect.forkChild({ startImmediately: true }));
+
+ yield* Deferred.await(saturated);
+ yield* Effect.yieldNow;
+ expect(maxActive).toBe(4);
+
+ yield* Deferred.succeed(releaseStats, undefined);
+ yield* Fiber.join(resolving);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("replaces caches with invalid identity markers or missing required paths", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root);
+ const resolverLayer = makeResolverLayer(root, fixture);
+ const result = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolveWithMetadata({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+ expect(result.downloaded).toBe(true);
+
+ const markerPath = join(result.path, ".complete");
+ writeFileSync(markerPath, JSON.stringify({ service: "postgrest" }));
+ const replacedMarker = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolveWithMetadata({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+ expect(replacedMarker.downloaded).toBe(true);
+ expect(JSON.parse(readFileSync(markerPath, "utf8"))).toMatchObject({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ runtime: "native",
+ });
+
+ rmSync(join(replacedMarker.path, "bin/postgrest"));
+ const restoredPath = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolveWithMetadata({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+ expect(restoredPath.downloaded).toBe(true);
+ expect(existsSync(join(restoredPath.path, "bin/postgrest"))).toBe(true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("preserves a cache published while another resolver repairs stale state", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root);
+ const resolverLayer = makeResolverLayer(root, fixture);
+ const installed = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolveWithMetadata({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+ const markerPath = join(installed.path, ".complete");
+ writeFileSync(markerPath, JSON.stringify({ service: "postgrest" }));
+
+ const staleMarkerRead = yield* Deferred.make();
+ const releaseStaleReader = yield* Deferred.make();
+ let markerReads = 0;
+ const staleReaderLayer = makeResolverLayer(root, fixture, {
+ transformFileSystem: (fileSystem) =>
+ FileSystem.FileSystem.of({
+ ...fileSystem,
+ readFileString: (requestedPath, options) =>
+ Effect.gen(function* () {
+ const contents = yield* fileSystem.readFileString(requestedPath, options);
+ if (requestedPath === markerPath) {
+ markerReads += 1;
+ if (markerReads === 2) {
+ yield* Deferred.succeed(staleMarkerRead, undefined);
+ yield* Deferred.await(releaseStaleReader);
+ }
+ }
+ return contents;
+ }),
+ }),
+ });
+ const staleReader = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolveWithMetadata({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(staleReaderLayer), Effect.forkChild);
+ yield* Deferred.await(staleMarkerRead);
+
+ const publisher = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolveWithMetadata({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(makeResolverLayer(root, fixture)));
+ yield* Deferred.succeed(releaseStaleReader, undefined);
+ const repaired = yield* Fiber.join(staleReader);
+
+ expect(publisher.downloaded).toBe(true);
+ expect(repaired.downloaded).toBe(false);
+ expect(repaired.path).toBe(installed.path);
+ expect(readFileSync(join(installed.path, "bin/postgrest"), "utf8")).toContain("postgrest");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("rejects archive members that escape the private staging directory", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeTraversalFixture(root);
+ const resolverLayer = makeResolverLayer(root, fixture);
+ const failure = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer), Effect.flip);
+ expect(failure).toBeInstanceOf(DownloadError);
+ const release = nativeReleaseForService(
+ "postgrest",
+ DEFAULT_VERSIONS.postgrest,
+ yield* detectPlatform,
+ );
+ if (release !== undefined) {
+ const cache = BinaryResolver.cachePath(join(root, "bin"), {
+ service: "postgrest",
+ releaseSet: "slim-services",
+ version: DEFAULT_VERSIONS.postgrest,
+ runtime: "native",
+ target: release.target,
+ });
+ expect(existsSync(join(dirname(cache), "outside.txt"))).toBe(false);
+ expect(existsSync(join(cache, ".complete"))).toBe(false);
+ }
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("rejects archive symlinks that resolve outside private staging", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const resolverLayer = makeResolverLayer(root, makeEscapingSymlinkFixture(root));
+ const failure = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer), Effect.flip);
+ expect(failure).toBeInstanceOf(BinaryRuntimeError);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("rejects checksum and manifest/runtime validation failures", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root);
+ const resolverLayer = makeResolverLayer(root, fixture, { checksum: "0".repeat(64) });
+ const checksum = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer), Effect.flip);
+ expect(checksum).toBeInstanceOf(ChecksumMismatchError);
+
+ const manifestLayer = makeResolverLayer(
+ root,
+ makeFixture(root, {
+ target: process.platform === "darwin" ? "linux-amd64" : "darwin-arm64",
+ }),
+ );
+ const manifest = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(manifestLayer), Effect.flip);
+ expect(manifest).toBeInstanceOf(BinaryManifestError);
+ expect(manifest).not.toBeInstanceOf(BinaryRuntimeError);
+
+ const unsafeCommandLayer = makeResolverLayer(
+ root,
+ makeFixture(root, { cmd: ["../bin/postgrest"] }),
+ );
+ const unsafeCommand = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(unsafeCommandLayer), Effect.flip);
+ expect(unsafeCommand).toBeInstanceOf(BinaryManifestError);
-describe("BinaryResolver cache publication", () => {
- it.live("publishes one complete cache entry for concurrent resolvers", () => {
- const root = makeTempRoot();
- const archive = makeArchive(root);
- let requestCount = 0;
- const layer = makeResolverLayer(root, archive, () => {
- requestCount += 1;
- });
-
- return Effect.gen(function* () {
- const resolver = yield* BinaryResolver;
- const results = yield* Effect.all(
- [
- resolver.resolveWithMetadata({ service: "auth", version: DEFAULT_VERSIONS.auth }),
- resolver.resolveWithMetadata({ service: "auth", version: DEFAULT_VERSIONS.auth }),
- ],
- { concurrency: "unbounded" },
- );
-
- expect(requestCount).toBe(2);
- expect(results.filter((result) => result.downloaded)).toHaveLength(1);
- expect(results[0]?.path).toBe(results[1]?.path);
- expect(readFileSync(join(results[0]!.path, "auth"), "utf8")).toContain("echo auth");
- expect(readFileSync(join(results[0]!.path, ".complete"), "utf8")).toContain(
- "github.com/supabase/auth",
- );
- }).pipe(Effect.provide(layer));
- });
-
- it.live("reuses a complete cache from the legacy layout without downloading", () => {
- const root = makeTempRoot();
- const layer = makeUnavailableResolverLayer(root);
-
- return Effect.gen(function* () {
- const resolver = yield* BinaryResolver;
- const legacyCacheDir = yield* legacyAuthCachePath(root);
- mkdirSync(legacyCacheDir, { recursive: true });
- writeFileSync(join(legacyCacheDir, "auth"), "legacy auth binary");
-
- const result = yield* resolver.resolveWithMetadata({
- service: "auth",
- version: DEFAULT_VERSIONS.auth,
- });
-
- expect(result).toEqual({ path: legacyCacheDir, downloaded: false });
- }).pipe(Effect.provide(layer));
- });
-
- it.live("preserves a markerless provider cache when replacement download fails", () => {
- const root = makeTempRoot();
- const layer = makeUnavailableResolverLayer(root);
-
- return Effect.gen(function* () {
- const resolver = yield* BinaryResolver;
- const cacheDir = yield* authCachePath(root);
- const legacyBinary = join(cacheDir, "auth");
- mkdirSync(cacheDir, { recursive: true });
- writeFileSync(legacyBinary, "legacy auth binary");
-
- const error = yield* resolver
- .resolveWithMetadata({ service: "auth", version: DEFAULT_VERSIONS.auth })
- .pipe(Effect.flip);
-
- expect(error).toBeInstanceOf(DownloadError);
- expect(readFileSync(legacyBinary, "utf8")).toBe("legacy auth binary");
- }).pipe(Effect.provide(layer));
- });
-
- it.live("replaces an incomplete provider cache after staging succeeds", () => {
- const root = makeTempRoot();
- const archive = makeArchive(root);
- const layer = makeResolverLayer(root, archive, () => {});
-
- return Effect.gen(function* () {
- const resolver = yield* BinaryResolver;
- const cacheDir = yield* authCachePath(root);
- mkdirSync(cacheDir, { recursive: true });
- writeFileSync(join(cacheDir, ".complete"), "orphaned marker");
-
- const result = yield* resolver.resolveWithMetadata({
- service: "auth",
- version: DEFAULT_VERSIONS.auth,
- });
-
- expect(result).toEqual({ path: cacheDir, downloaded: true });
- expect(readFileSync(join(cacheDir, "auth"), "utf8")).toContain("echo auth");
- expect(readFileSync(join(cacheDir, ".complete"), "utf8")).toContain(
- "github.com/supabase/auth",
- );
- }).pipe(Effect.provide(layer));
- });
-
- it.live("reaps stale staging directories even when the artifact is cached", () => {
- const root = makeTempRoot();
- const archive = makeArchive(root);
- const layer = makeResolverLayer(root, archive, () => {});
-
- return Effect.gen(function* () {
- const resolver = yield* BinaryResolver;
- const spec = { service: "auth", version: DEFAULT_VERSIONS.auth } as const;
- const first = yield* resolver.resolveWithMetadata(spec);
- const staleStaging = join(dirname(first.path), `.${basename(first.path)}.partial-abandoned`);
- mkdirSync(staleStaging);
- writeFileSync(join(staleStaging, "partial"), "partial artifact");
- const staleTime = new Date(Date.now() - 25 * 60 * 60 * 1_000);
- utimesSync(staleStaging, staleTime, staleTime);
-
- const second = yield* resolver.resolveWithMetadata(spec);
-
- expect(second.downloaded).toBe(false);
- expect(existsSync(staleStaging)).toBe(false);
- }).pipe(Effect.provide(layer));
- });
+ const runtimeLayer = makeResolverLayer(root, makeFixture(root, {}, false));
+ const runtime = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(runtimeLayer), Effect.flip);
+ expect(runtime).toBeInstanceOf(BinaryRuntimeError);
+ const release = nativeReleaseForService(
+ "postgrest",
+ DEFAULT_VERSIONS.postgrest,
+ yield* detectPlatform,
+ );
+ if (release !== undefined) {
+ const cache = BinaryResolver.cachePath(join(root, "bin"), {
+ service: "postgrest",
+ releaseSet: "slim-services",
+ version: DEFAULT_VERSIONS.postgrest,
+ runtime: "native",
+ target: release.target,
+ });
+ expect(existsSync(join(cache, ".complete"))).toBe(false);
+ }
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("fails closed when SHA256SUMS has no entry for the requested archive", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root);
+ const unrelated = createHash("sha256").update(fixture.archive).digest("hex");
+ const resolverLayer = makeResolverLayer(root, fixture, {
+ checksumText: `${unrelated} unrelated.sbom.spdx.json\n`,
+ });
+ const failure = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer), Effect.flip);
+
+ expect(failure).toBeInstanceOf(BinaryManifestError);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
+
+ it.live("accepts uppercase SHA256SUMS digests", () =>
+ Effect.gen(function* () {
+ const root = makeRoot();
+ try {
+ const fixture = makeFixture(root);
+ const hash = createHash("sha256").update(fixture.archive).digest("hex").toUpperCase();
+ const resolverLayer = makeResolverLayer(root, fixture, { checksum: hash });
+ const resolved = yield* Effect.gen(function* () {
+ const resolver = yield* BinaryResolver;
+ return yield* resolver.resolve({
+ service: "postgrest",
+ version: DEFAULT_VERSIONS.postgrest,
+ });
+ }).pipe(Effect.provide(resolverLayer));
+
+ expect(existsSync(join(resolved, "bin/postgrest"))).toBe(true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ }),
+ );
});
diff --git a/packages/stack/src/BinaryResolver.ts b/packages/stack/src/BinaryResolver.ts
index f6ee4070d4..016ae1d344 100644
--- a/packages/stack/src/BinaryResolver.ts
+++ b/packages/stack/src/BinaryResolver.ts
@@ -1,14 +1,30 @@
import { createHash } from "node:crypto";
-import { Context, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
+import { zstdDecompress } from "node:zlib";
+import {
+ Context,
+ Duration,
+ Effect,
+ FileSystem,
+ Layer,
+ Option,
+ Path,
+ Predicate,
+ PlatformError,
+ Result,
+ Schedule,
+} from "effect";
import { HttpClient } from "effect/unstable/http";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
-import { BinaryNotFoundError, ChecksumMismatchError, DownloadError } from "./errors.ts";
-import { detectPlatform } from "./Platform.ts";
import {
- nativeReleaseForService,
- type ArchiveFormat,
- type NativeReleaseArtifact,
-} from "./ServiceCatalog.ts";
+ BinaryHostCompatibilityError,
+ BinaryManifestError,
+ BinaryNotFoundError,
+ BinaryRuntimeError,
+ ChecksumMismatchError,
+ DownloadError,
+} from "./errors.ts";
+import { detectPlatform, type NativeTarget } from "./Platform.ts";
+import { nativeReleaseForService, type NativeReleaseArtifact } from "./ServiceCatalog.ts";
import type { ServiceName } from "./ServiceName.ts";
export interface BinarySpec {
@@ -22,89 +38,80 @@ interface ResolveBinaryResult {
readonly downloaded: boolean;
}
+interface SlimServiceManifest {
+ readonly service: string;
+ readonly version: string;
+ readonly target: string;
+ readonly entrypoint: ReadonlyArray;
+ readonly cmd: ReadonlyArray;
+ readonly runtime_requires?: null | "glibc";
+ readonly libc: null | "glibc";
+ readonly os_floor: null | {
+ readonly kind: string;
+ readonly floor: string | null;
+ readonly offender?: string | null;
+ readonly scanned: number;
+ readonly bundled_glibc?: boolean;
+ };
+}
+
export interface ResolveBinaryOptions {
readonly onDownloadStart?: Effect.Effect;
}
interface AssetInfo {
readonly service: ServiceName;
- readonly provider: string;
+ readonly releaseSet: "slim-services";
readonly version: string;
- readonly assetName: string;
+ readonly runtime: "native";
+ readonly target: NativeTarget;
}
-const cachePath = (baseDir: string, info: AssetInfo): string =>
- `${baseDir}/${info.service}/${info.provider.replaceAll("/", "_")}/${info.version}/${info.assetName}`;
-
-const LEGACY_NATIVE_PROVIDERS: Partial> = {
- postgres: "github.com/supabase/postgres",
- postgrest: "github.com/PostgREST/postgrest",
- auth: "github.com/supabase/auth",
- "edge-runtime": "github.com/supabase/edge-runtime",
-};
+interface HostCompatibilityRequirement {
+ readonly runtimeRequires: null | "glibc";
+ readonly libc: null | "glibc";
+ readonly osFloor: null | {
+ readonly kind: "glibc" | "macos";
+ readonly floor: string | null;
+ };
+}
-const legacyCachePath = (baseDir: string, info: AssetInfo): string | undefined =>
- LEGACY_NATIVE_PROVIDERS[info.service] === info.provider
- ? `${baseDir}/${info.service}/${info.version}/${info.assetName}`
- : undefined;
-
-const legacyExecutablePath = (
- directory: string,
- service: ServiceName,
- platformOs: string,
-): string | undefined => {
- const executableSuffix = platformOs === "win32" ? ".exe" : "";
- switch (service) {
- case "postgres":
- return `${directory}/bin/postgres${executableSuffix}`;
- case "postgrest":
- return `${directory}/postgrest${executableSuffix}`;
- case "auth":
- return `${directory}/auth${executableSuffix}`;
- case "edge-runtime":
- return `${directory}/bin/edge-runtime${executableSuffix}`;
- default:
- return undefined;
- }
-};
+interface CacheCompleteMarker {
+ readonly provider: string;
+ readonly service: string;
+ readonly version: string;
+ readonly asset: string;
+ readonly url: string;
+ readonly target: NativeTarget;
+ readonly releaseSet: "slim-services";
+ readonly runtime: "native";
+ readonly hostCompatibility: HostCompatibilityRequirement;
+}
-const legacyCacheRequiredPaths = (
- directory: string,
- service: ServiceName,
- platformOs: string,
-): ReadonlyArray => {
- const executable = legacyExecutablePath(directory, service, platformOs);
- if (executable === undefined) return [];
- return service === "postgres"
- ? [
- executable,
- `${directory}/bin/pg_isready${platformOs === "win32" ? ".exe" : ""}`,
- `${directory}/bin/psql${platformOs === "win32" ? ".exe" : ""}`,
- `${directory}/share/supabase-cli/bin/supabase-postgres-init.sh`,
- `${directory}/lib`,
- ]
- : [executable];
-};
+const cachePath = (baseDir: string, info: AssetInfo): string =>
+ `${baseDir}/${info.releaseSet}/${info.service}/${info.version}/${info.runtime}/${info.target}`;
const CACHE_COMPLETE_MARKER = ".complete";
-const STALE_STAGING_AGE_MS = 24 * 60 * 60 * 1_000;
-
-const extractCommand = (
- archive: ArchiveFormat,
- archivePath: string,
- destDir: string,
- os: string,
- stripComponents: boolean,
-): string[] => {
- if (archive === "zip") {
- return os === "win32"
- ? ["tar", "xf", archivePath, "-C", destDir]
- : ["unzip", "-o", archivePath, "-d", destDir];
+const STALE_PREPARATION_ENTRY_AGE_MS = 24 * 60 * 60 * 1_000;
+
+const hasTraversalSegment = (value: string): boolean =>
+ value.split(/[\\/]/).some((segment) => segment === "..");
+
+const isUnsafeArchiveMember = (member: string): boolean => {
+ const normalized = member.trim();
+ if (normalized.length === 0) return false;
+ if (normalized.startsWith("/") || /^[A-Za-z]:[\\/]/.test(normalized)) return true;
+ let depth = 0;
+ for (const segment of normalized.split(/[\\/]/)) {
+ if (segment.length === 0 || segment === ".") continue;
+ if (segment === "..") {
+ if (depth === 0) return true;
+ depth -= 1;
+ } else {
+ depth += 1;
+ }
}
- const flag = archive === "tar.gz" ? "xzf" : "xf";
- const args = ["tar", flag, archivePath, "-C", destDir];
- if (stripComponents) args.push("--strip-components=1");
- return args;
+ return false;
};
const verifyChecksum = (
@@ -115,7 +122,7 @@ const verifyChecksum = (
Effect.sync(() => {
const actual = createHash("sha256").update(new Uint8Array(data)).digest("hex");
// The .sha256 file typically contains "hex filename" or just "hex"
- const expectedHex = expected.trim().split(/\s+/)[0] ?? "";
+ const expectedHex = (expected.trim().split(/\s+/)[0] ?? "").toLowerCase();
return { actual, expectedHex };
}).pipe(
Effect.flatMap(({ actual, expectedHex }) => {
@@ -126,25 +133,328 @@ const verifyChecksum = (
}),
);
+const decompressArchive = (
+ data: ArrayBuffer,
+ url: string,
+): Effect.Effect =>
+ Effect.callback((resume) => {
+ let completed = false;
+ const complete = (effect: Effect.Effect) => {
+ if (completed) return;
+ completed = true;
+ resume(effect);
+ };
+ try {
+ zstdDecompress(new Uint8Array(data), (cause, archive) =>
+ complete(
+ cause === null ? Effect.succeed(archive) : Effect.fail(new DownloadError({ url, cause })),
+ ),
+ );
+ } catch (cause) {
+ complete(Effect.fail(new DownloadError({ url, cause })));
+ }
+ return Effect.void;
+ });
+
+const checksumForArchive = (contents: string, archiveName: string): string | undefined => {
+ for (const line of contents.split(/\r?\n/)) {
+ const match = line.trim().match(/^([a-f0-9]{64})\s+[* ]?(.+)$/i);
+ if (match?.[2] === archiveName || match?.[2]?.endsWith(`/${archiveName}`)) {
+ return match[1]?.toLowerCase();
+ }
+ }
+ return undefined;
+};
+
+const manifestError = (url: string, detail: string): BinaryManifestError =>
+ new BinaryManifestError({ url, detail });
+
+const isSlimServiceManifest = (value: unknown): value is SlimServiceManifest => {
+ if (typeof value !== "object" || value === null) return false;
+ if (!("service" in value) || typeof value.service !== "string") return false;
+ if (!("version" in value) || typeof value.version !== "string") return false;
+ if (!("target" in value) || typeof value.target !== "string") return false;
+ if (!("entrypoint" in value) || !Array.isArray(value.entrypoint)) return false;
+ if (!("cmd" in value) || !Array.isArray(value.cmd)) return false;
+ if (
+ "runtime_requires" in value &&
+ value.runtime_requires !== null &&
+ value.runtime_requires !== "glibc"
+ )
+ return false;
+ if (!("libc" in value) || (value.libc !== null && value.libc !== "glibc")) return false;
+ if (!("os_floor" in value)) return false;
+ if (value.os_floor !== null) {
+ if (typeof value.os_floor !== "object") return false;
+ if (!("kind" in value.os_floor) || typeof value.os_floor.kind !== "string") return false;
+ if (!("floor" in value.os_floor)) return false;
+ if (value.os_floor.floor !== null && typeof value.os_floor.floor !== "string") return false;
+ if (!("scanned" in value.os_floor) || typeof value.os_floor.scanned !== "number") return false;
+ if (
+ "offender" in value.os_floor &&
+ value.os_floor.offender !== null &&
+ typeof value.os_floor.offender !== "string"
+ )
+ return false;
+ if ("bundled_glibc" in value.os_floor && typeof value.os_floor.bundled_glibc !== "boolean")
+ return false;
+ }
+ return true;
+};
+
+const validateManifest = (
+ release: NativeReleaseArtifact,
+ raw: unknown,
+ platform: { readonly os: string; readonly arch: string },
+ spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
+): Effect.Effect<
+ HostCompatibilityRequirement,
+ BinaryManifestError | BinaryHostCompatibilityError
+> =>
+ Effect.gen(function* () {
+ if (typeof raw !== "object" || raw === null) {
+ return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest must be an object"));
+ }
+ if (!isSlimServiceManifest(raw)) {
+ return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest schema is invalid"));
+ }
+ const manifest = raw;
+ if (
+ manifest.service !== release.service ||
+ manifest.version !== release.version ||
+ manifest.target !== release.target
+ ) {
+ return yield* Effect.fail(
+ manifestError(
+ release.manifestUrl,
+ "Manifest service/version/target does not match release",
+ ),
+ );
+ }
+ if (
+ !Array.isArray(manifest.entrypoint) ||
+ !manifest.entrypoint.every((value) => typeof value === "string") ||
+ !Array.isArray(manifest.cmd) ||
+ !manifest.cmd.every((value) => typeof value === "string")
+ ) {
+ return yield* Effect.fail(
+ manifestError(release.manifestUrl, "Manifest entrypoint/cmd must be string arrays"),
+ );
+ }
+ if (manifest.entrypoint.length === 0 && manifest.cmd.length === 0) {
+ return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest has no command"));
+ }
+ const runtimeRequires = manifest.runtime_requires ?? null;
+ const commandPaths = [...manifest.entrypoint, ...manifest.cmd].filter(
+ (entry) =>
+ entry.startsWith("/") ||
+ entry.includes("/") ||
+ entry.includes("\\") ||
+ entry === "." ||
+ entry === "..",
+ );
+ if (commandPaths.some((entry) => hasTraversalSegment(entry))) {
+ return yield* Effect.fail(
+ manifestError(release.manifestUrl, "Manifest command path is unsafe"),
+ );
+ }
+ const osFloor = manifest.os_floor;
+ if (osFloor !== null && typeof osFloor !== "object") {
+ return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest os_floor is invalid"));
+ }
+ if (osFloor !== null && osFloor.kind !== "macos" && osFloor.kind !== "glibc") {
+ return yield* Effect.fail(
+ new BinaryHostCompatibilityError({
+ target: release.target,
+ detail: `Unsupported manifest host kind ${osFloor.kind}`,
+ }),
+ );
+ }
+ const hostCompatibility: HostCompatibilityRequirement = {
+ runtimeRequires,
+ libc: manifest.libc,
+ osFloor:
+ osFloor === null
+ ? null
+ : osFloor.kind === "macos"
+ ? { kind: "macos", floor: osFloor.floor }
+ : { kind: "glibc", floor: osFloor.floor },
+ };
+ yield* validateHostCompatibility(release.target, hostCompatibility, platform, spawner);
+ return hostCompatibility;
+ });
+
+const validateHostCompatibility = (
+ target: NativeTarget,
+ requirement: HostCompatibilityRequirement,
+ platform: { readonly os: string; readonly arch: string },
+ spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const requiresGlibc =
+ requirement.libc === "glibc" ||
+ requirement.runtimeRequires === "glibc" ||
+ requirement.osFloor?.kind === "glibc";
+ if (requirement.osFloor?.kind === "macos" && platform.os !== "darwin") {
+ return yield* Effect.fail(
+ new BinaryHostCompatibilityError({
+ target,
+ detail: "Manifest requires macOS",
+ }),
+ );
+ }
+ if (requiresGlibc && platform.os !== "linux") {
+ return yield* Effect.fail(
+ new BinaryHostCompatibilityError({
+ target,
+ detail: "Manifest requires Linux/glibc",
+ }),
+ );
+ }
+ const floor = requirement.osFloor?.floor;
+ if (requiresGlibc) {
+ const host = yield* Effect.sync(() => {
+ try {
+ const report = process.report?.getReport?.();
+ if (typeof report !== "object" || report === null || !("header" in report)) {
+ return undefined;
+ }
+ const header = report.header;
+ if (
+ typeof header !== "object" ||
+ header === null ||
+ !("glibcVersionRuntime" in header) ||
+ typeof header.glibcVersionRuntime !== "string"
+ ) {
+ return undefined;
+ }
+ return header.glibcVersionRuntime;
+ } catch {
+ return undefined;
+ }
+ });
+ if (typeof host !== "string" || host.trim().length === 0) {
+ return yield* Effect.fail(
+ new BinaryHostCompatibilityError({
+ target,
+ detail: "Unable to determine host glibc version",
+ }),
+ );
+ }
+ if (floor !== null && floor !== undefined) {
+ const comparison = compareVersions(host, floor);
+ if (comparison === undefined) {
+ return yield* Effect.fail(
+ new BinaryHostCompatibilityError({
+ target,
+ detail: `Host glibc ${host} or manifest floor ${floor} is not a dotted numeric version`,
+ }),
+ );
+ }
+ if (comparison < 0) {
+ return yield* Effect.fail(
+ new BinaryHostCompatibilityError({
+ target,
+ detail: `Host glibc ${host} is below manifest floor ${floor}`,
+ }),
+ );
+ }
+ }
+ }
+ if (
+ platform.os === "darwin" &&
+ requirement.osFloor?.kind === "macos" &&
+ floor !== null &&
+ floor !== undefined
+ ) {
+ const host = yield* spawner.string(ChildProcess.make("sw_vers", ["-productVersion"])).pipe(
+ Effect.mapError(
+ (cause) =>
+ new BinaryHostCompatibilityError({
+ target,
+ detail: `Unable to determine macOS version: ${String(cause)}`,
+ }),
+ ),
+ );
+ const hostVersion = host.trim().split(/\s+/)[0] ?? "";
+ if (hostVersion.length === 0) {
+ return yield* Effect.fail(
+ new BinaryHostCompatibilityError({
+ target,
+ detail: "Unable to determine macOS version",
+ }),
+ );
+ }
+ const comparison = compareVersions(hostVersion, floor);
+ if (comparison === undefined) {
+ return yield* Effect.fail(
+ new BinaryHostCompatibilityError({
+ target,
+ detail: `Host macOS ${hostVersion} or manifest floor ${floor} is not a dotted numeric version`,
+ }),
+ );
+ }
+ if (comparison < 0) {
+ return yield* Effect.fail(
+ new BinaryHostCompatibilityError({
+ target,
+ detail: `Host macOS ${hostVersion} is below manifest floor ${floor}`,
+ }),
+ );
+ }
+ }
+ });
+
+const parseVersion = (value: string): ReadonlyArray | undefined => {
+ const parts = value.split(".");
+ if (parts.length === 0 || parts.some((part) => !/^\d+$/.test(part))) return undefined;
+ const parsed = parts.map(Number);
+ return parsed.every(Number.isSafeInteger) ? parsed : undefined;
+};
+
+const compareVersions = (left: string, right: string): number | undefined => {
+ const a = parseVersion(left);
+ const b = parseVersion(right);
+ if (a === undefined || b === undefined) return undefined;
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
+ const diff = (a[index] ?? 0) - (b[index] ?? 0);
+ if (diff !== 0) return diff;
+ }
+ return 0;
+};
+
export class BinaryResolver extends Context.Service<
BinaryResolver,
{
+ /** Computes the immutable cache identity without inspecting or changing the filesystem. */
+ readonly plan: (spec: BinarySpec) => Effect.Effect;
readonly resolveWithMetadata: (
spec: BinarySpec,
options?: ResolveBinaryOptions,
) => Effect.Effect<
ResolveBinaryResult,
- BinaryNotFoundError | DownloadError | ChecksumMismatchError
+ | BinaryNotFoundError
+ | DownloadError
+ | ChecksumMismatchError
+ | BinaryManifestError
+ | BinaryRuntimeError
+ | BinaryHostCompatibilityError
>;
readonly resolve: (
spec: BinarySpec,
- ) => Effect.Effect;
+ ) => Effect.Effect<
+ string,
+ | BinaryNotFoundError
+ | DownloadError
+ | ChecksumMismatchError
+ | BinaryManifestError
+ | BinaryRuntimeError
+ | BinaryHostCompatibilityError
+ >;
}
>()("local/BinaryResolver") {
// Static pure functions — tested in unit tests
static cachePath = cachePath;
- static legacyExecutablePath = legacyExecutablePath;
- static legacyCacheRequiredPaths = legacyCacheRequiredPaths;
static make(
cacheRoot: string,
@@ -165,29 +475,123 @@ export class BinaryResolver extends Context.Service<
const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
- const isCompleteCache = (directory: string) =>
+ const isCacheCompleteMarker = (value: unknown): value is CacheCompleteMarker => {
+ if (typeof value !== "object" || value === null) return false;
+ if (!("provider" in value) || typeof value.provider !== "string") return false;
+ if (!("service" in value) || typeof value.service !== "string") return false;
+ if (!("version" in value) || typeof value.version !== "string") return false;
+ if (!("asset" in value) || typeof value.asset !== "string") return false;
+ if (!("url" in value) || typeof value.url !== "string") return false;
+ if (
+ !("target" in value) ||
+ (value.target !== "darwin-arm64" &&
+ value.target !== "linux-amd64" &&
+ value.target !== "linux-arm64")
+ )
+ return false;
+ if (!("releaseSet" in value) || value.releaseSet !== "slim-services") return false;
+ if (!("runtime" in value) || value.runtime !== "native") return false;
+ if (!("hostCompatibility" in value)) return false;
+ const host = value.hostCompatibility;
+ if (typeof host !== "object" || host === null) return false;
+ if (
+ !("runtimeRequires" in host) ||
+ (host.runtimeRequires !== null && host.runtimeRequires !== "glibc")
+ )
+ return false;
+ if (!("libc" in host) || (host.libc !== null && host.libc !== "glibc")) return false;
+ if (!("osFloor" in host)) return false;
+ if (host.osFloor !== null) {
+ if (typeof host.osFloor !== "object") return false;
+ if (
+ !("kind" in host.osFloor) ||
+ (host.osFloor.kind !== "glibc" && host.osFloor.kind !== "macos")
+ )
+ return false;
+ if (
+ !("floor" in host.osFloor) ||
+ (host.osFloor.floor !== null && typeof host.osFloor.floor !== "string")
+ )
+ return false;
+ }
+ return true;
+ };
+
+ const isCompleteCache = (
+ directory: string,
+ release: NativeReleaseArtifact,
+ info: AssetInfo,
+ platform: { readonly os: string; readonly arch: string },
+ ) =>
Effect.gen(function* () {
- if (!(yield* fs.exists(path.join(directory, CACHE_COMPLETE_MARKER)))) {
+ const marker = yield* fs
+ .readFileString(path.join(directory, CACHE_COMPLETE_MARKER))
+ .pipe(Effect.option);
+ if (Option.isNone(marker)) return false;
+ const parsed = yield* Effect.sync(() => {
+ try {
+ const value: unknown = JSON.parse(marker.value);
+ return value;
+ } catch {
+ return undefined;
+ }
+ });
+ if (!isCacheCompleteMarker(parsed)) return false;
+ if (
+ parsed.provider !== release.provider ||
+ parsed.service !== info.service ||
+ parsed.version !== info.version ||
+ parsed.asset !== release.assetName ||
+ parsed.url !== release.downloadUrl ||
+ parsed.target !== info.target ||
+ parsed.releaseSet !== info.releaseSet ||
+ parsed.runtime !== info.runtime
+ ) {
return false;
}
- const entries = yield* fs.readDirectory(directory);
- return entries.some((entry) => entry !== CACHE_COMPLETE_MARKER);
- });
+ const requiredPaths = [...new Set(release.requiredRuntimePaths)];
+ const present = yield* Effect.forEach(requiredPaths, (entry) =>
+ fs.exists(path.join(directory, entry)),
+ );
+ if (!present.every(Boolean)) return false;
+ yield* validateHostCompatibility(
+ release.target,
+ parsed.hostCompatibility,
+ platform,
+ spawner,
+ );
+ return true;
+ }).pipe(Effect.catchTag("PlatformError", () => Effect.succeed(false)));
- const isReusableLegacyCache = (
- directory: string,
- service: ServiceName,
- platformOs: string,
- ) => {
- const requiredPaths = legacyCacheRequiredPaths(directory, service, platformOs);
- return requiredPaths.length === 0
- ? Effect.succeed(false)
- : Effect.forEach(requiredPaths, fs.exists).pipe(
- Effect.map((results) => results.every(Boolean)),
+ const resolveRelease = (spec: BinarySpec) =>
+ Effect.gen(function* () {
+ const platform = yield* detectPlatform;
+ const release = nativeReleaseForService(spec.service, spec.version, platform);
+ if (release === undefined) {
+ return yield* Effect.fail(
+ new BinaryNotFoundError({
+ service: spec.service,
+ platform: `${platform.os}-${platform.arch}`,
+ }),
);
- };
+ }
+ const info: AssetInfo = {
+ service: spec.service,
+ releaseSet: "slim-services",
+ version: spec.version,
+ runtime: "native",
+ target: release.target,
+ };
+ return { platform, release, info };
+ });
+
+ const plan = (spec: BinarySpec): Effect.Effect =>
+ Effect.gen(function* () {
+ const { info } = yield* resolveRelease(spec);
+ return cachePath(spec.cacheDir ?? binDir, info);
+ });
- const cleanupStaleStaging = (directory: string, prefix: string) =>
+ const cleanupStaleEntries = (directory: string, prefix: string) =>
fs.readDirectory(directory).pipe(
Effect.flatMap((entries) =>
Effect.forEach(
@@ -199,7 +603,7 @@ export class BinaryResolver extends Context.Service<
Option.match(info.mtime, {
onNone: () => Effect.void,
onSome: (modifiedAt) =>
- Date.now() - modifiedAt.getTime() >= STALE_STAGING_AGE_MS
+ Date.now() - modifiedAt.getTime() >= STALE_PREPARATION_ENTRY_AGE_MS
? fs.remove(stagingPath, { recursive: true, force: true })
: Effect.void,
}),
@@ -207,18 +611,70 @@ export class BinaryResolver extends Context.Service<
Effect.ignore,
);
},
- { concurrency: "unbounded" },
+ { concurrency: 4 },
),
),
Effect.ignore,
);
+ const validateExtractedTree = (directory: string) =>
+ Effect.gen(function* () {
+ const root = yield* fs.realPath(directory);
+ const entries = yield* fs.readDirectory(directory, { recursive: true });
+ for (const entry of entries) {
+ const candidate = path.join(directory, entry);
+ const resolved = yield* fs.realPath(candidate).pipe(
+ Effect.mapError(
+ () =>
+ new BinaryRuntimeError({
+ path: candidate,
+ detail: "Extracted path cannot be resolved inside private staging",
+ }),
+ ),
+ );
+ const relative = path.relative(root, resolved);
+ if (
+ path.isAbsolute(relative) ||
+ relative === ".." ||
+ relative.startsWith(`..${path.sep}`)
+ ) {
+ return yield* Effect.fail(
+ new BinaryRuntimeError({
+ path: candidate,
+ detail: `Extracted path resolves outside private staging: ${entry}`,
+ }),
+ );
+ }
+ }
+ });
+
const extractRelease = (
release: NativeReleaseArtifact,
destination: string,
- platformOs: string,
+ platform: { readonly os: string; readonly arch: string },
) =>
Effect.gen(function* () {
+ const manifestResponse = yield* httpClient
+ .get(release.manifestUrl)
+ .pipe(
+ Effect.catchTag("HttpClientError", (cause) =>
+ Effect.fail(new DownloadError({ url: release.manifestUrl, cause })),
+ ),
+ );
+ const manifestText = yield* manifestResponse.text.pipe(
+ Effect.catchTag("HttpClientError", (cause) =>
+ Effect.fail(new DownloadError({ url: release.manifestUrl, cause })),
+ ),
+ );
+ const hostCompatibility = yield* Effect.try({
+ try: () => {
+ const parsed: unknown = JSON.parse(manifestText);
+ return parsed;
+ },
+ catch: (cause) =>
+ manifestError(release.manifestUrl, `Invalid JSON: ${String(cause)}`),
+ }).pipe(Effect.flatMap((value) => validateManifest(release, value, platform, spawner)));
+
const tarballResponse = yield* httpClient
.get(release.downloadUrl)
.pipe(
@@ -232,43 +688,57 @@ export class BinaryResolver extends Context.Service<
),
);
- const checksumUrl = release.checksumUrl;
- if (checksumUrl !== null) {
- const checksumResponse = yield* httpClient
- .get(checksumUrl)
- .pipe(
- Effect.catchTag("HttpClientError", (cause) =>
- Effect.fail(new DownloadError({ url: checksumUrl, cause })),
- ),
- );
- const checksumText = yield* checksumResponse.text.pipe(
+ const checksumResponse = yield* httpClient
+ .get(release.checksumUrl)
+ .pipe(
Effect.catchTag("HttpClientError", (cause) =>
- Effect.fail(new DownloadError({ url: checksumUrl, cause })),
+ Effect.fail(new DownloadError({ url: release.checksumUrl, cause })),
),
);
- yield* verifyChecksum(tarball, checksumText, checksumUrl);
+ const checksumText = yield* checksumResponse.text.pipe(
+ Effect.catchTag("HttpClientError", (cause) =>
+ Effect.fail(new DownloadError({ url: release.checksumUrl, cause })),
+ ),
+ );
+ const expected = checksumForArchive(checksumText, `${release.assetName}.tar.zst`);
+ if (expected === undefined) {
+ return yield* Effect.fail(
+ manifestError(release.checksumUrl, "SHA256SUMS has no entry for the archive"),
+ );
}
+ yield* verifyChecksum(tarball, expected, release.checksumUrl);
- const archivePath = path.join(destination, `_download.${release.archive}`);
- yield* fs.writeFile(archivePath, new Uint8Array(tarball));
+ const archivePath = path.join(destination, "_download.tar");
+ const archive = yield* decompressArchive(tarball, release.downloadUrl);
+ yield* fs.writeFile(archivePath, archive);
- const [command, ...args] = extractCommand(
- release.archive,
- archivePath,
- destination,
- platformOs,
- release.stripComponents,
- );
- if (command === undefined) {
+ const members = yield* spawner
+ .string(ChildProcess.make("tar", ["-tf", archivePath]))
+ .pipe(
+ Effect.catch((cause) =>
+ Effect.fail(
+ new DownloadError({
+ url: release.downloadUrl,
+ cause,
+ }),
+ ),
+ ),
+ );
+ const unsafeMember = members
+ .split(/\r?\n/)
+ .map((member) => member.trim())
+ .find(isUnsafeArchiveMember);
+ if (unsafeMember !== undefined) {
return yield* Effect.fail(
new DownloadError({
url: release.downloadUrl,
- cause: new Error("No extraction command was configured"),
+ cause: new Error(`archive member is unsafe: ${unsafeMember}`),
}),
);
}
+
const exitCode = yield* spawner
- .exitCode(ChildProcess.make(command, args))
+ .exitCode(ChildProcess.make("tar", ["-xf", archivePath, "-C", destination]))
.pipe(
Effect.catchTag("PlatformError", (cause) =>
Effect.fail(new DownloadError({ url: release.downloadUrl, cause })),
@@ -283,82 +753,94 @@ export class BinaryResolver extends Context.Service<
);
}
+ yield* validateExtractedTree(destination);
+
yield* fs.remove(archivePath).pipe(Effect.ignore);
- if (platformOs !== "win32") {
- yield* spawner
- .exitCode(ChildProcess.make("chmod", ["-R", "u+x", destination]))
- .pipe(Effect.ignore);
+ const requirePostProcess = (name: string, command: ChildProcess.Command) =>
+ Effect.gen(function* () {
+ const exitCode = yield* spawner.exitCode(command).pipe(
+ Effect.mapError(
+ (cause) =>
+ new BinaryRuntimeError({
+ path: destination,
+ detail: `${name} could not run: ${String(cause)}`,
+ }),
+ ),
+ );
+ if (exitCode !== 0) {
+ return yield* Effect.fail(
+ new BinaryRuntimeError({
+ path: destination,
+ detail: `${name} exited with code ${exitCode}`,
+ }),
+ );
+ }
+ });
+
+ if (platform.os !== "win32") {
+ yield* requirePostProcess(
+ "chmod",
+ ChildProcess.make("chmod", ["-R", "u+x", destination]),
+ );
}
- if (platformOs === "darwin") {
- yield* spawner
- .exitCode(
- ChildProcess.make("find", [
- destination,
- "-type",
- "f",
- "(",
- "-perm",
- "+111",
- "-o",
- "-name",
- "*.dylib",
- ")",
- "-exec",
- "codesign",
- "-f",
- "-s",
- "-",
- "{}",
- "+",
- ]),
- )
- .pipe(Effect.ignore);
+ if (platform.os === "darwin") {
+ yield* requirePostProcess(
+ "codesign",
+ ChildProcess.make("find", [
+ destination,
+ "-type",
+ "f",
+ "(",
+ "-perm",
+ "+111",
+ "-o",
+ "-name",
+ "*.dylib",
+ ")",
+ "-exec",
+ "codesign",
+ "-f",
+ "-s",
+ "-",
+ "{}",
+ "+",
+ ]),
+ );
}
- });
- const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => {
- const core = Effect.gen(function* () {
- const platform = yield* detectPlatform;
- const release = nativeReleaseForService(spec.service, spec.version, platform);
- if (release === undefined) {
+ const requiredPaths = [...new Set(release.requiredRuntimePaths)];
+ const missing = yield* Effect.forEach(requiredPaths, (entry) =>
+ fs.exists(path.join(destination, entry)),
+ ).pipe(Effect.map((exists) => requiredPaths.filter((_entry, index) => !exists[index])));
+ if (missing.length > 0) {
return yield* Effect.fail(
- new BinaryNotFoundError({
- service: spec.service,
- platform: `${platform.os}-${platform.arch}`,
+ new BinaryRuntimeError({
+ path: destination,
+ detail: `Manifest runtime paths are missing: ${missing.join(", ")}`,
}),
);
}
+ return hostCompatibility;
+ });
- const info: AssetInfo = {
- service: spec.service,
- provider: release.provider,
- version: spec.version,
- assetName: release.assetName,
- };
+ const resolveWithMetadata = (spec: BinarySpec, options?: ResolveBinaryOptions) => {
+ const core = Effect.gen(function* () {
+ const { platform, release, info } = yield* resolveRelease(spec);
const baseDir = spec.cacheDir ?? binDir;
const cacheDir = cachePath(baseDir, info);
- const legacyDir = legacyCachePath(baseDir, info);
const parentDir = path.dirname(cacheDir);
const stagingPrefix = `.${release.assetName}.partial-`;
- yield* cleanupStaleStaging(parentDir, stagingPrefix);
- if (yield* isCompleteCache(cacheDir)) {
+ const publicationLock = path.join(parentDir, `.${release.assetName}.publication-lock`);
+ yield* cleanupStaleEntries(parentDir, stagingPrefix);
+ yield* cleanupStaleEntries(parentDir, path.basename(publicationLock));
+ if (yield* isCompleteCache(cacheDir, release, info, platform)) {
return {
path: cacheDir,
downloaded: false,
} satisfies ResolveBinaryResult;
}
- if (
- legacyDir !== undefined &&
- (yield* isReusableLegacyCache(legacyDir, spec.service, platform.os))
- ) {
- return {
- path: legacyDir,
- downloaded: false,
- } satisfies ResolveBinaryResult;
- }
-
yield* fs.makeDirectory(parentDir, { recursive: true });
yield* options?.onDownloadStart ?? Effect.void;
@@ -367,7 +849,7 @@ export class BinaryResolver extends Context.Service<
prefix: stagingPrefix,
});
return yield* Effect.gen(function* () {
- yield* extractRelease(release, stagingDir, platform.os);
+ const hostCompatibility = yield* extractRelease(release, stagingDir, platform);
yield* fs.writeFile(
path.join(stagingDir, CACHE_COMPLETE_MARKER),
new TextEncoder().encode(
@@ -377,6 +859,10 @@ export class BinaryResolver extends Context.Service<
version: spec.version,
asset: release.assetName,
url: release.downloadUrl,
+ target: info.target,
+ releaseSet: info.releaseSet,
+ runtime: info.runtime,
+ hostCompatibility,
}),
),
);
@@ -388,23 +874,46 @@ export class BinaryResolver extends Context.Service<
if (Result.isSuccess(publication)) {
return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult;
}
- if (yield* isCompleteCache(cacheDir)) {
+ if (yield* isCompleteCache(cacheDir, release, info, platform)) {
return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult;
}
- // A fully staged replacement is now available, so an incomplete
- // destination can be reclaimed without risking the last usable
- // cache entry. Retry publication once; persistent filesystem
- // failures still surface instead of looping forever.
- yield* fs.remove(cacheDir, { recursive: true, force: true });
- const retry = yield* fs.rename(stagingDir, cacheDir).pipe(Effect.result);
- if (Result.isSuccess(retry)) {
- return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult;
- }
- if (yield* isCompleteCache(cacheDir)) {
- return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult;
- }
- return yield* Effect.fail(retry.failure);
+ return yield* Effect.scoped(
+ Effect.gen(function* () {
+ const acquirePublicationLock: Effect.Effect =
+ fs.makeDirectory(publicationLock).pipe(
+ Effect.retry({
+ while: (error) => Predicate.isTagged(error.reason, "AlreadyExists"),
+ schedule: Schedule.recurs(1_200).pipe(
+ Schedule.addDelay(() => Effect.succeed(Duration.millis(25))),
+ ),
+ }),
+ );
+ yield* Effect.acquireRelease(acquirePublicationLock, () =>
+ fs
+ .remove(publicationLock, { recursive: true, force: true })
+ .pipe(Effect.ignore),
+ );
+
+ // The destination may have changed while this resolver was
+ // waiting to repair it. Revalidate under the publication
+ // claim before removing anything shared.
+ if (yield* isCompleteCache(cacheDir, release, info, platform)) {
+ return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult;
+ }
+
+ yield* fs.remove(cacheDir, { recursive: true, force: true });
+ const retry = yield* fs.rename(stagingDir, cacheDir).pipe(Effect.result);
+ if (Result.isSuccess(retry)) {
+ return { path: cacheDir, downloaded: true } satisfies ResolveBinaryResult;
+ }
+ const retryFailure = retry.failure;
+ if (yield* isCompleteCache(cacheDir, release, info, platform)) {
+ return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult;
+ }
+ return yield* Effect.fail(retryFailure);
+ }),
+ );
}).pipe(
Effect.ensuring(
fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore),
@@ -423,6 +932,7 @@ export class BinaryResolver extends Context.Service<
};
return {
+ plan,
resolveWithMetadata,
resolve: (spec: BinarySpec) => {
return Effect.map(resolveWithMetadata(spec), ({ path }) => path);
diff --git a/packages/stack/src/BinaryResolver.unit.test.ts b/packages/stack/src/BinaryResolver.unit.test.ts
index d0576010bd..5a6c930596 100644
--- a/packages/stack/src/BinaryResolver.unit.test.ts
+++ b/packages/stack/src/BinaryResolver.unit.test.ts
@@ -3,111 +3,46 @@ import { BinaryResolver } from "./BinaryResolver.ts";
import { nativeReleaseForService } from "./ServiceCatalog.ts";
import { DEFAULT_VERSIONS } from "./versions.ts";
-const postgresVersion = DEFAULT_VERSIONS.postgres;
-const postgrestVersion = DEFAULT_VERSIONS.postgrest;
-const authVersion = DEFAULT_VERSIONS.auth;
-const authRcVersion = "2.188.0-rc.15";
-const edgeRuntimeVersion = DEFAULT_VERSIONS["edge-runtime"];
-
-describe("nativeReleaseForService", () => {
- it("constructs postgres URL (appends -cli suffix for native binaries)", () => {
- const release = nativeReleaseForService("postgres", postgresVersion, {
+describe("slim native release descriptors", () => {
+ it("uses the frozen slim-services archive, manifest, and checksum names", () => {
+ const release = nativeReleaseForService("postgrest", DEFAULT_VERSIONS.postgrest, {
os: "darwin",
arch: "arm64",
});
- expect(release?.downloadUrl).toBe(
- `https://github.com/supabase/postgres/releases/download/v${postgresVersion}-cli/supabase-postgres-v${postgresVersion}-cli-darwin-arm64.tar.gz`,
- );
- expect(release?.checksumUrl).toBe(`${release?.downloadUrl}.sha256`);
- expect(release?.stripComponents).toBe(true);
- });
-
- it("constructs postgrest URL", () => {
- const release = nativeReleaseForService("postgrest", postgrestVersion, {
- os: "darwin",
- arch: "arm64",
- });
- expect(release?.downloadUrl).toBe(
- `https://github.com/PostgREST/postgrest/releases/download/v${postgrestVersion}/postgrest-v${postgrestVersion}-macos-aarch64.tar.xz`,
- );
- });
-
- it("constructs postgrest Windows URL with .zip extension", () => {
- const release = nativeReleaseForService("postgrest", postgrestVersion, {
- os: "win32",
- arch: "x64",
- });
- expect(release?.downloadUrl).toBe(
- `https://github.com/PostgREST/postgrest/releases/download/v${postgrestVersion}/postgrest-v${postgrestVersion}-windows-x86-64.zip`,
- );
- expect(release?.archive).toBe("zip");
- });
-
- it("constructs auth URL for rc releases", () => {
- const release = nativeReleaseForService("auth", authRcVersion, {
- os: "linux",
- arch: "arm64",
- });
- expect(release?.downloadUrl).toBe(
- `https://github.com/supabase/auth/releases/download/rc${authRcVersion}/auth-v${authRcVersion}-arm64.tar.gz`,
- );
- });
-
- it("constructs edge-runtime URL", () => {
- const release = nativeReleaseForService("edge-runtime", edgeRuntimeVersion, {
- os: "darwin",
- arch: "arm64",
+ expect(release).toMatchObject({
+ releaseTag: "postgrest-v16.1",
+ target: "darwin-arm64",
+ archive: "tar.zst",
+ downloadUrl:
+ "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/postgrest-v16.1-darwin-arm64.tar.zst",
+ manifestUrl:
+ "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/postgrest-v16.1-darwin-arm64.manifest.json",
+ checksumUrl:
+ "https://github.com/supabase/slim-services/releases/download/postgrest-v16.1/SHA256SUMS",
});
- expect(release?.downloadUrl).toBe(
- `https://github.com/supabase/edge-runtime/releases/download/v${edgeRuntimeVersion}/edge-runtime-v${edgeRuntimeVersion}-aarch64-darwin.tar.gz`,
- );
});
- it("returns no native release for unsupported platforms", () => {
+ it("only exposes the three supported native targets", () => {
expect(
- nativeReleaseForService("auth", authVersion, { os: "win32", arch: "arm64" }),
+ nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, { os: "win32", arch: "x64" }),
).toBeUndefined();
+ expect(
+ nativeReleaseForService("auth", DEFAULT_VERSIONS.auth, { os: "linux", arch: "x64" })?.target,
+ ).toBe("linux-amd64");
});
});
describe("BinaryResolver.cachePath", () => {
- it("constructs cache path", () => {
+ it("includes service, release provider, version, and target identity", () => {
const path = BinaryResolver.cachePath("/home/user/.supabase/bin", {
service: "postgres",
- provider: "github.com/supabase/postgres",
- version: postgresVersion,
- assetName: "darwin-arm64",
+ releaseSet: "slim-services",
+ version: DEFAULT_VERSIONS.postgres,
+ runtime: "native",
+ target: "linux-amd64",
});
expect(path).toBe(
- `/home/user/.supabase/bin/postgres/github.com_supabase_postgres/${postgresVersion}/darwin-arm64`,
- );
- });
-});
-
-describe("BinaryResolver.legacyExecutablePath", () => {
- it("recognizes the executable suffix used by Windows archives", () => {
- expect(BinaryResolver.legacyExecutablePath("C:/cache/postgrest", "postgrest", "win32")).toBe(
- "C:/cache/postgrest/postgrest.exe",
- );
- });
-
- it("keeps Unix executable names unchanged", () => {
- expect(BinaryResolver.legacyExecutablePath("/cache/postgrest", "postgrest", "linux")).toBe(
- "/cache/postgrest/postgrest",
- );
- });
-});
-
-describe("BinaryResolver.legacyCacheRequiredPaths", () => {
- it("requires the Postgres initialization payload as well as the executable", () => {
- expect(BinaryResolver.legacyCacheRequiredPaths("/cache/postgres", "postgres", "linux")).toEqual(
- [
- "/cache/postgres/bin/postgres",
- "/cache/postgres/bin/pg_isready",
- "/cache/postgres/bin/psql",
- "/cache/postgres/share/supabase-cli/bin/supabase-postgres-init.sh",
- "/cache/postgres/lib",
- ],
+ `/home/user/.supabase/bin/slim-services/postgres/${DEFAULT_VERSIONS.postgres}/native/linux-amd64`,
);
});
});
diff --git a/packages/stack/src/ContainerRuntime.integration.test.ts b/packages/stack/src/ContainerRuntime.integration.test.ts
new file mode 100644
index 0000000000..15cf536848
--- /dev/null
+++ b/packages/stack/src/ContainerRuntime.integration.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "@effect/vitest";
+import { Deferred, Effect, Layer, Predicate, Sink, Stream } from "effect";
+import { ChildProcessSpawner } from "effect/unstable/process";
+import { selectStackRuntime, selectStackRuntimeForPlatform } from "./ContainerRuntime.ts";
+
+const runtimeSpawner = (availability: Readonly>) => {
+ const commands: string[] = [];
+ return {
+ commands,
+ layer: Layer.succeed(
+ ChildProcessSpawner.ChildProcessSpawner,
+ ChildProcessSpawner.make((command) =>
+ Effect.gen(function* () {
+ const executable = Predicate.isTagged(command, "StandardCommand") ? command.command : "";
+ commands.push(executable);
+ const exitCode = yield* Deferred.make();
+ yield* Deferred.succeed(
+ exitCode,
+ ChildProcessSpawner.ExitCode(availability[executable] === true ? 0 : 1),
+ );
+ return ChildProcessSpawner.makeHandle({
+ pid: ChildProcessSpawner.ProcessId(1),
+ stdout: Stream.empty,
+ stderr: Stream.empty,
+ all: Stream.empty,
+ exitCode: Deferred.await(exitCode),
+ isRunning: Effect.succeed(false),
+ stdin: Sink.drain,
+ kill: () => Effect.void,
+ unref: Effect.succeed(Effect.void),
+ getInputFd: () => Sink.drain,
+ getOutputFd: () => Stream.empty,
+ });
+ }),
+ ),
+ ),
+ };
+};
+
+describe("stack runtime selection", () => {
+ it.effect("uses Docker mode when the Docker daemon is usable", () => {
+ const spawner = runtimeSpawner({ docker: true });
+ return Effect.gen(function* () {
+ expect(yield* selectStackRuntime()).toEqual({
+ mode: "docker",
+ containerRuntime: "docker",
+ });
+ }).pipe(Effect.provide(spawner.layer));
+ });
+
+ it.effect("uses Podman for Docker mode when Docker is unavailable", () => {
+ const spawner = runtimeSpawner({ podman: true });
+ return Effect.gen(function* () {
+ expect(yield* selectStackRuntime()).toEqual({
+ mode: "docker",
+ containerRuntime: "podman",
+ });
+ }).pipe(Effect.provide(spawner.layer));
+ });
+
+ it.effect("uses native mode when no container runtime is usable", () => {
+ const spawner = runtimeSpawner({});
+ return Effect.gen(function* () {
+ expect(yield* selectStackRuntimeForPlatform({ os: "linux", arch: "x64" })).toEqual({
+ mode: "native",
+ containerRuntime: null,
+ });
+ }).pipe(Effect.provide(spawner.layer));
+ });
+
+ it.effect("does not probe container runtimes when native mode is explicit", () => {
+ const spawner = runtimeSpawner({ docker: true });
+ return Effect.gen(function* () {
+ expect(yield* selectStackRuntimeForPlatform({ os: "linux", arch: "x64" }, "native")).toEqual({
+ mode: "native",
+ containerRuntime: null,
+ });
+ expect(spawner.commands).toEqual([]);
+ }).pipe(Effect.provide(spawner.layer));
+ });
+
+ it.effect("rejects explicit Docker mode when neither runtime is usable", () => {
+ const spawner = runtimeSpawner({});
+ return Effect.gen(function* () {
+ const error = yield* selectStackRuntime("docker").pipe(Effect.flip);
+ expect(error).toMatchObject({
+ _tag: "StackBuildError",
+ reason: "docker_not_running",
+ });
+ }).pipe(Effect.provide(spawner.layer));
+ });
+
+ it.effect("rejects automatic fallback when the host has no native artifacts", () => {
+ const spawner = runtimeSpawner({});
+ return Effect.gen(function* () {
+ const error = yield* selectStackRuntimeForPlatform({ os: "win32", arch: "x64" }).pipe(
+ Effect.flip,
+ );
+ expect(error).toMatchObject({
+ _tag: "StackBuildError",
+ reason: "docker_not_running",
+ });
+ expect(error.detail).toContain("win32-x64");
+ }).pipe(Effect.provide(spawner.layer));
+ });
+});
diff --git a/packages/stack/src/ContainerRuntime.ts b/packages/stack/src/ContainerRuntime.ts
new file mode 100644
index 0000000000..3f1ff135ce
--- /dev/null
+++ b/packages/stack/src/ContainerRuntime.ts
@@ -0,0 +1,100 @@
+import { Effect, Exit } from "effect";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+import { StackBuildError } from "./errors.ts";
+import { detectPlatform, nativeTargetForPlatform, type PlatformInfo } from "./Platform.ts";
+import type { StackMode } from "./StackConfig.ts";
+
+export type ContainerRuntime = "docker" | "podman";
+
+export type StackRuntimeSelection =
+ | { readonly mode: "native"; readonly containerRuntime: null }
+ | { readonly mode: "docker"; readonly containerRuntime: ContainerRuntime };
+
+const probeContainerRuntime = (
+ runtime: ContainerRuntime,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
+ const result = yield* Effect.exit(
+ spawner.exitCode(ChildProcess.make(runtime, ["info"])).pipe(Effect.timeout("30 seconds")),
+ );
+ return Exit.isSuccess(result) && result.value === 0;
+ });
+
+export const validateStackRuntime = (
+ selection: StackRuntimeSelection,
+): Effect.Effect<
+ StackRuntimeSelection,
+ StackBuildError,
+ ChildProcessSpawner.ChildProcessSpawner
+> =>
+ selection.mode === "native"
+ ? Effect.succeed(selection)
+ : probeContainerRuntime(selection.containerRuntime).pipe(
+ Effect.flatMap((usable) =>
+ usable
+ ? Effect.succeed(selection)
+ : Effect.fail(
+ new StackBuildError({
+ detail: `Docker mode requires a usable ${selection.containerRuntime} runtime. Restore or start the persisted ${selection.containerRuntime} runtime and retry, or delete and recreate the stack (removing its managed data) to choose another execution mode.`,
+ reason: "docker_not_running",
+ }),
+ ),
+ ),
+ );
+
+export const selectStackRuntimeForPlatform = (
+ platform: PlatformInfo,
+ requestedMode?: StackMode,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ if (requestedMode === "native") {
+ if (nativeTargetForPlatform(platform) !== undefined) {
+ return { mode: "native", containerRuntime: null };
+ }
+ return yield* Effect.fail(
+ new StackBuildError({
+ detail: `Native mode is unavailable on ${platform.os}-${platform.arch}. Use a supported Linux or Apple silicon macOS host, or install and start Docker or Podman.`,
+ reason: "invalid_config",
+ }),
+ );
+ }
+
+ const runtimes = ["docker", "podman"] as const satisfies ReadonlyArray;
+ const probes = yield* Effect.all(
+ runtimes.map((runtime) =>
+ probeContainerRuntime(runtime).pipe(Effect.map((usable) => [runtime, usable] as const)),
+ ),
+ { concurrency: "unbounded" },
+ );
+ const selected = probes.find(([, usable]) => usable)?.[0];
+ if (selected !== undefined) {
+ return { mode: "docker", containerRuntime: selected };
+ }
+
+ if (requestedMode === "docker") {
+ return yield* Effect.fail(
+ new StackBuildError({
+ detail: "Docker mode requires a usable Docker or Podman runtime",
+ reason: "docker_not_running",
+ }),
+ );
+ }
+
+ if (nativeTargetForPlatform(platform) !== undefined) {
+ return { mode: "native", containerRuntime: null };
+ }
+ return yield* Effect.fail(
+ new StackBuildError({
+ detail: `No usable Docker or Podman runtime was found, and native mode is unavailable on ${platform.os}-${platform.arch}. Install and start Docker or Podman.`,
+ reason: "docker_not_running",
+ }),
+ );
+ });
+
+export const selectStackRuntime = (
+ requestedMode?: StackMode,
+): Effect.Effect =>
+ detectPlatform.pipe(
+ Effect.flatMap((platform) => selectStackRuntimeForPlatform(platform, requestedMode)),
+ );
diff --git a/packages/stack/src/DaemonProtocol.ts b/packages/stack/src/DaemonProtocol.ts
index a0e058312e..3098e2b89e 100644
--- a/packages/stack/src/DaemonProtocol.ts
+++ b/packages/stack/src/DaemonProtocol.ts
@@ -5,6 +5,7 @@ const DaemonErrorCodeSchema = Schema.Literals([
"SERVICE_NOT_READY",
"STACK_READINESS_TIMEOUT",
"STACK_BUILD_ERROR",
+ "STACK_NOT_RUNNING",
]);
const StackBuildReasonSchema = Schema.Literals([
@@ -38,6 +39,7 @@ export const DaemonErrorResponseSchema = Schema.Struct({
service: Schema.optionalKey(Schema.String),
exitCode: Schema.optionalKey(Schema.Number),
timeoutMs: Schema.optionalKey(Schema.Number),
+ phase: Schema.optionalKey(Schema.String),
reason: Schema.optionalKey(StackBuildReasonSchema),
});
diff --git a/packages/stack/src/DaemonServer.integration.test.ts b/packages/stack/src/DaemonServer.integration.test.ts
index 120c13b40f..06dc721a90 100644
--- a/packages/stack/src/DaemonServer.integration.test.ts
+++ b/packages/stack/src/DaemonServer.integration.test.ts
@@ -1,6 +1,7 @@
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import { ServiceNotFoundError, type LogEntry } from "@supabase/process-compose";
-import { Deferred, Effect, Fiber, Layer, ManagedRuntime, Stream } from "effect";
+import { Deferred, Effect, Fiber, Layer, ManagedRuntime, Predicate, Stream } from "effect";
+import { HttpServer } from "effect/unstable/http";
import * as http from "node:http";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { DaemonServer } from "./DaemonServer.ts";
@@ -188,16 +189,12 @@ function buildDaemonLayer(
) as Layer.Layer;
}
-function getUrl(address: {
- readonly _tag: string;
- readonly hostname?: string;
- readonly port?: number;
-}): string {
- if (address._tag === "TcpAddress") {
+function getUrl(address: HttpServer.Address): string {
+ if (Predicate.isTagged(address, "TcpAddress")) {
const host = address.hostname === "0.0.0.0" ? "127.0.0.1" : address.hostname;
return `http://${host}:${address.port}`;
}
- throw new Error(`Unexpected address type: ${address._tag}`);
+ throw new Error("Unexpected address type");
}
// ---------------------------------------------------------------------------
diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts
index 0c56371133..6c5779d5a9 100644
--- a/packages/stack/src/DaemonServer.ts
+++ b/packages/stack/src/DaemonServer.ts
@@ -1,4 +1,4 @@
-import { Deferred, Effect, Exit, Layer, Context, Stream } from "effect";
+import { Deferred, Effect, Fiber, Layer, Context, Stream } from "effect";
import {
Headers,
HttpRouter,
@@ -11,7 +11,10 @@ import type { ControlOwnerStatus, DaemonErrorResponse } from "./DaemonProtocol.t
import { FunctionsReloadConfigSchema } from "./functions.ts";
import { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts";
import { ReadyOptionsSchema } from "./StackConfig.ts";
-import { managedStackLaunchSchema, type ManagedStackLaunch } from "./managed/document.ts";
+import {
+ managedStackLaunchUpdateSchema,
+ type ManagedStackLaunchUpdate,
+} from "./managed/document.ts";
// ---------------------------------------------------------------------------
// Service
@@ -35,7 +38,7 @@ export class DaemonServer extends Context.Service<
}),
options: {
readonly includeOwnerRoute?: boolean;
- readonly launchUpdate?: (launch: ManagedStackLaunch) => Effect.Effect;
+ readonly launchUpdate?: (launch: ManagedStackLaunchUpdate) => Effect.Effect;
/** Supervisor-owned shutdown callbacks already stop the local stack. */
readonly stopOnShutdown?: boolean;
} = {},
@@ -45,9 +48,10 @@ export class DaemonServer extends Context.Service<
Effect.gen(function* () {
const stack = yield* Stack;
const server = yield* HttpServer.HttpServer;
+ const scope = yield* Effect.scope;
const shutdownDeferred = yield* Deferred.make();
const textEncoder = new TextEncoder();
- const errorResponse = (body: DaemonErrorResponse, status: 400 | 404 | 500) =>
+ const errorResponse = (body: DaemonErrorResponse, status: 400 | 404 | 409 | 500) =>
HttpServerResponse.jsonUnsafe(body, { status });
const notFoundResponse = (name: string) =>
errorResponse(
@@ -73,6 +77,15 @@ export class DaemonServer extends Context.Service<
},
500,
);
+ const notRunningResponse = (phase: string) =>
+ errorResponse(
+ {
+ code: "STACK_NOT_RUNNING",
+ error: `Stack is not running (phase: ${phase})`,
+ phase,
+ },
+ 409,
+ );
const invalidReloadPayloadResponse = () =>
errorResponse(
{ code: "STACK_BUILD_ERROR", error: "Invalid Edge Functions reload payload" },
@@ -101,33 +114,21 @@ export class DaemonServer extends Context.Service<
// the socket.
Deferred.succeed(shutdownDeferred, void 0).pipe(
Effect.delay("25 millis"),
- Effect.forkDetach,
+ Effect.forkIn(scope, { startImmediately: true, uninterruptible: true }),
+ Effect.asVoid,
),
),
),
);
- const shutdownResult = yield* Effect.cached(
+ const shutdownFiber = yield* Effect.cached(
Effect.uninterruptible(
- Effect.gen(function* () {
- const result = yield* Deferred.make>();
- yield* shutdownTransaction.pipe(
- Effect.exit,
- Effect.flatMap((exit) => Deferred.succeed(result, exit)),
- Effect.forkDetach,
- );
- return result;
+ Effect.forkIn(shutdownTransaction, scope, {
+ startImmediately: true,
+ uninterruptible: true,
}),
),
);
- const beginShutdown = shutdownResult.pipe(
- Effect.flatMap((result) =>
- Deferred.await(result).pipe(
- Effect.flatMap((exit) =>
- Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause),
- ),
- ),
- ),
- );
+ const beginShutdown = shutdownFiber.pipe(Effect.flatMap(Fiber.join));
const terminalReadinessResponse = (target: string, timeoutMs: number, detail: string) =>
beginShutdown.pipe(Effect.as(readinessTimeoutResponse(target, timeoutMs, detail)));
@@ -178,8 +179,9 @@ export class DaemonServer extends Context.Service<
"POST",
"/managed/launch",
Effect.gen(function* () {
- const launch =
- yield* HttpServerRequest.schemaBodyJson(managedStackLaunchSchema);
+ const launch = yield* HttpServerRequest.schemaBodyJson(
+ managedStackLaunchUpdateSchema,
+ );
yield* launchUpdate(launch);
return HttpServerResponse.jsonUnsafe({ ok: true });
}),
@@ -329,6 +331,9 @@ export class DaemonServer extends Context.Service<
Effect.catchTag("StackBuildError", (e) =>
Effect.succeed(buildErrorResponse(e.detail, e.reason)),
),
+ Effect.catchTag("StackNotRunningError", (e) =>
+ Effect.succeed(notRunningResponse(e.phase)),
+ ),
Effect.catchTag("StackReadinessError", (e) =>
terminalReadinessResponse(e.target, e.timeoutMs, e.detail),
),
@@ -377,6 +382,9 @@ export class DaemonServer extends Context.Service<
Effect.catchTag("StackBuildError", (e) =>
Effect.succeed(buildErrorResponse(e.detail, e.reason)),
),
+ Effect.catchTag("StackNotRunningError", (e) =>
+ Effect.succeed(notRunningResponse(e.phase)),
+ ),
),
),
@@ -397,6 +405,9 @@ export class DaemonServer extends Context.Service<
Effect.catchTag("StackBuildError", (e) =>
Effect.succeed(buildErrorResponse(e.detail, e.reason)),
),
+ Effect.catchTag("StackNotRunningError", (e) =>
+ Effect.succeed(notRunningResponse(e.phase)),
+ ),
Effect.catchTag("StackReadinessError", (e) =>
terminalReadinessResponse(e.target, e.timeoutMs, e.detail),
),
@@ -424,6 +435,9 @@ export class DaemonServer extends Context.Service<
Effect.catchTag("StackBuildError", (e) =>
Effect.succeed(buildErrorResponse(e.detail, e.reason)),
),
+ Effect.catchTag("StackNotRunningError", (e) =>
+ Effect.succeed(notRunningResponse(e.phase)),
+ ),
Effect.catchTag("StackReadinessError", (e) =>
terminalReadinessResponse(e.target, e.timeoutMs, e.detail),
),
@@ -451,6 +465,9 @@ export class DaemonServer extends Context.Service<
Effect.catchTag("StackBuildError", (e) =>
Effect.succeed(buildErrorResponse(e.detail, e.reason)),
),
+ Effect.catchTag("StackNotRunningError", (e) =>
+ Effect.succeed(notRunningResponse(e.phase)),
+ ),
Effect.catchTag("StackReadinessError", (e) =>
terminalReadinessResponse(e.target, e.timeoutMs, e.detail),
),
diff --git a/packages/stack/src/HttpTransportClient.integration.test.ts b/packages/stack/src/HttpTransportClient.integration.test.ts
new file mode 100644
index 0000000000..6d1fcad6b3
--- /dev/null
+++ b/packages/stack/src/HttpTransportClient.integration.test.ts
@@ -0,0 +1,97 @@
+import { Effect, Fiber, ManagedRuntime } from "effect";
+import type { Socket } from "node:net";
+import { createServer, type Server } from "node:http";
+import { afterEach, describe, expect, test } from "vitest";
+import { HttpTransportClient, httpTransportClientLayer } from "./HttpTransportClient.ts";
+import type { ControlEndpoint } from "./managed/control.ts";
+
+const endpointFor = (server: Server): ControlEndpoint => {
+ const address = server.address();
+ if (address === null || typeof address === "string") throw new Error("Expected TCP address");
+ return {
+ hostname: "127.0.0.1",
+ port: address.port,
+ url: `http://127.0.0.1:${address.port}`,
+ };
+};
+
+const listen = (server: Server): Promise =>
+ new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => resolve());
+ });
+
+const close = (server: Server): Promise =>
+ new Promise((resolve, reject) => {
+ if (!server.listening) {
+ resolve();
+ return;
+ }
+ server.close((error) => (error === undefined ? resolve() : reject(error)));
+ });
+
+const withTimeout = async (promise: Promise, timeoutMs: number): Promise => {
+ let timeout: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((_resolve, reject) => {
+ timeout = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
+ }),
+ ]);
+ } finally {
+ if (timeout !== undefined) clearTimeout(timeout);
+ }
+};
+
+describe("HttpTransportClient", () => {
+ let server: Server | undefined;
+ let activeSocket: Socket | undefined;
+
+ afterEach(async () => {
+ activeSocket?.destroy();
+ if (server !== undefined) await close(server);
+ activeSocket = undefined;
+ server = undefined;
+ });
+
+ test("closes an unanswered request when its fiber is interrupted", async () => {
+ let requestArrived!: () => void;
+ const requestReady = new Promise((resolve) => {
+ requestArrived = resolve;
+ });
+ let closed = false;
+ let connectionClosed!: () => void;
+ const connectionClosedPromise = new Promise((resolve) => {
+ connectionClosed = () => {
+ closed = true;
+ resolve();
+ };
+ });
+
+ server = createServer((_request, response) => {
+ const socket = response.socket;
+ if (socket === null) throw new Error("Expected request socket");
+ activeSocket = socket;
+ requestArrived();
+ socket.once("close", connectionClosed);
+ });
+ await listen(server);
+
+ const runtime = ManagedRuntime.make(httpTransportClientLayer);
+ try {
+ const fiber = runtime.runFork(
+ Effect.gen(function* () {
+ const client = yield* HttpTransportClient;
+ yield* client.request(endpointFor(server!), "/never");
+ }),
+ );
+ await requestReady;
+ await runtime.runPromise(Fiber.interrupt(fiber));
+ await withTimeout(connectionClosedPromise, 5_000);
+ expect(closed).toBe(true);
+ } finally {
+ await runtime.dispose();
+ }
+ });
+});
diff --git a/packages/stack/src/HttpTransportClient.ts b/packages/stack/src/HttpTransportClient.ts
index d9b38a7717..e65a7f3c2a 100644
--- a/packages/stack/src/HttpTransportClient.ts
+++ b/packages/stack/src/HttpTransportClient.ts
@@ -22,13 +22,14 @@ export class HttpTransportClient extends Context.Service<
export const httpTransportClientLayer = Layer.succeed(HttpTransportClient, {
request: (endpoint, path, init) =>
Effect.tryPromise({
- try: () =>
+ try: (signal) =>
fetch(`${endpoint.url}${path}`, {
...init,
- signal:
- init?.signal == null
- ? AbortSignal.timeout(30_000)
- : AbortSignal.any([init.signal, AbortSignal.timeout(30_000)]),
+ signal: AbortSignal.any(
+ init?.signal === undefined || init.signal === null
+ ? [signal, AbortSignal.timeout(30_000)]
+ : [signal, init.signal],
+ ),
}),
catch: (cause) =>
new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }),
diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts
index 8897c679b9..4811af7bfb 100644
--- a/packages/stack/src/LocalStack.ts
+++ b/packages/stack/src/LocalStack.ts
@@ -1,17 +1,25 @@
import { LogBuffer, Orchestrator } from "@supabase/process-compose";
import { ServiceNotFoundError } from "@supabase/process-compose";
-import type { ResolvedGraph, ServiceReadyError } from "@supabase/process-compose";
+import type {
+ LogEntry,
+ ResolvedGraph,
+ ServiceReadyError,
+ ServiceState,
+} from "@supabase/process-compose";
import {
Context,
Deferred,
Duration,
Effect,
Equal,
+ Exit,
FileSystem,
Layer,
+ Match,
Path,
Ref,
Schema,
+ Scope,
Semaphore,
Stream,
SubscriptionRef,
@@ -41,8 +49,8 @@ import {
StackServiceActivator,
} from "./ServiceActivation.ts";
import { portFieldsForService } from "./ServicePorts.ts";
-import { StackPreparation } from "./StackPreparation.ts";
-import type { PreparedStackArtifacts } from "./StackPreparation.ts";
+import { preparationClosure, StackPreparation } from "./StackPreparation.ts";
+import type { PreparedStackArtifacts, StackPreparationInput } from "./StackPreparation.ts";
import {
enabledServicesForConfig,
StackBuilder,
@@ -57,18 +65,54 @@ import { Stack } from "./Stack.ts";
import type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts";
import { SERVICE_NAMES, type ServiceName } from "./versions.ts";
-type LifecyclePhase =
- | "idle"
- | "preparing"
- | "prepared"
- | "starting"
- | "running"
- | "stopping"
- | "stopped"
- | "disposed";
+type LifecyclePhase = "idle" | "starting" | "running" | "stopping" | "stopped" | "disposed";
type StackService = typeof Stack.Service;
+const READINESS_DIAGNOSTIC_LOG_LIMIT = 20;
+const READINESS_DIAGNOSTIC_LINE_LIMIT = 512;
+
+/** @internal Enriches a readiness timeout without changing diagnostic failure semantics. */
+export const attachReadinessDiagnostics = (
+ error: StackReadinessError,
+ states: Effect.Effect>,
+ logs: Effect.Effect>,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const [serviceStates, entries] = yield* Effect.all([states, logs]);
+ const nonReadyStates = serviceStates.filter(
+ (state) =>
+ state.status !== "Healthy" && !(state.status === "Stopped" && state.exitCode === 0),
+ );
+ const stateDetail =
+ nonReadyStates.length === 0
+ ? "none"
+ : nonReadyStates
+ .map((state) => {
+ const errorDetail =
+ state.error === null
+ ? ""
+ : `, error=${state.error.slice(0, READINESS_DIAGNOSTIC_LINE_LIMIT)}`;
+ return `${state.name}: ${state.status} (desired=${state.desired}, restarts=${state.restartCount}${errorDetail})`;
+ })
+ .join("; ");
+ const logDetail =
+ entries.length === 0
+ ? "none"
+ : entries
+ .map(
+ (entry) =>
+ `[${entry.service}/${entry.stream}] ${entry.line.slice(0, READINESS_DIAGNOSTIC_LINE_LIMIT)}`,
+ )
+ .join("\n");
+
+ return new StackReadinessError({
+ target: error.target,
+ timeoutMs: error.timeoutMs,
+ detail: `${error.detail}\nNon-ready services: ${stateDetail}\nRecent logs:\n${logDetail}`,
+ });
+ });
+
/** Private signal used by the Promise adapter to close its enclosing managed runtime. */
export class LocalStackLifecycle extends Context.Service<
LocalStackLifecycle,
@@ -123,7 +167,7 @@ const stackInfoFor = (config: ResolvedStackConfig): StackInfo => {
storage: `${apiUrl}/storage/v1`,
storage_s3: `${apiUrl}/storage/v1/s3`,
}),
- ...(config.imgproxy === false || config.startupMode === "lazy"
+ ...(config.imgproxy === false || config.servicePolicies.imgproxy !== "eager"
? {}
: { imgproxy: `http://127.0.0.1:${config.imgproxy.port}` }),
...(config.mailpit === false
@@ -182,6 +226,7 @@ export const localStackLayer = (
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const scope = yield* Effect.scope;
+ const preparationScope = yield* Scope.fork(scope, "parallel");
const info = stackInfoFor(config);
const enabledServices = enabledServicesForConfig(config);
@@ -209,6 +254,29 @@ export const localStackLayer = (
: [...current, nextState];
});
+ const markDownloading = (service: ServiceName) =>
+ SubscriptionRef.update(stateRef, (current) => {
+ const index = current.findIndex((entry) => entry.name === service);
+ if (index === -1 || current[index]?.status === "Downloading") return current;
+ return current.map((entry, entryIndex) =>
+ entryIndex === index
+ ? new StackServiceState({ ...entry, status: "Downloading" })
+ : entry,
+ );
+ });
+
+ const restoreStateIfDownloading = (
+ service: ServiceName,
+ previous: StackServiceState | undefined,
+ ) =>
+ previous === undefined
+ ? Effect.void
+ : SubscriptionRef.update(stateRef, (current) => {
+ const index = current.findIndex((entry) => entry.name === service);
+ if (index === -1 || current[index]?.status !== "Downloading") return current;
+ return current.map((entry, entryIndex) => (entryIndex === index ? previous : entry));
+ });
+
const syncProjectedStates = (
orchestrator: Orchestrator["Service"],
serviceProjection: StackServiceProjectionCatalog,
@@ -241,36 +309,135 @@ export const localStackLayer = (
return service;
});
- let preparedArtifacts: PreparedStackArtifacts | undefined;
- let prepareDeferred: Deferred.Deferred | undefined;
+ let plannedArtifacts: PreparedStackArtifacts | undefined;
+ let planDeferred: Deferred.Deferred | undefined;
+ const preparedResolutions: Partial = {};
+ const preparationInFlight = new Map<
+ string,
+ Deferred.Deferred
+ >();
let runtimeState: RuntimeState | undefined;
let runtimeDeferred: Deferred.Deferred | undefined;
let exactCleanupTargets: CleanupTargets | undefined;
- const ensurePrepared = Effect.suspend(() => {
- if (preparedArtifacts !== undefined) {
- return Effect.succeed(preparedArtifacts);
- }
- if (prepareDeferred !== undefined) {
- return Deferred.await(prepareDeferred);
- }
-
- const deferred = Deferred.makeUnsafe();
- prepareDeferred = deferred;
+ const preparationInput = (
+ services: ReadonlyArray,
+ ): Effect.Effect => {
+ const shared = {
+ services,
+ enabledServices,
+ versions: versionsForConfig(config),
+ };
+ return config.runtime.mode === "native"
+ ? Effect.succeed({ ...shared, mode: "native" })
+ : Effect.succeed({
+ ...shared,
+ mode: "docker",
+ containerRuntime: config.runtime.containerRuntime,
+ });
+ };
- const effect = Effect.gen(function* () {
- yield* validateResolvedConfig(config);
- yield* Ref.set(phaseRef, "preparing");
+ const ensurePlanned = Effect.uninterruptibleMask((restore) =>
+ Effect.suspend(() => {
+ if (disposed || disposing) {
+ return Effect.fail(
+ new StackBuildError({
+ detail: "Cannot plan stack assets after stack disposal has begun",
+ }),
+ );
+ }
+ if (plannedArtifacts !== undefined) return Effect.succeed(plannedArtifacts);
+ if (planDeferred !== undefined) return restore(Deferred.await(planDeferred));
+ const deferred = Deferred.makeUnsafe();
+ planDeferred = deferred;
+ const effect = Effect.gen(function* () {
+ yield* validateResolvedConfig(config);
+ const input = yield* preparationInput(enabledServicesForConfig(config));
+ return yield* preparation.plan(input).pipe(
+ Effect.mapError(
+ (cause) =>
+ new StackBuildError({
+ detail: "Failed to plan stack assets",
+ cause,
+ reason: "asset_preparation",
+ }),
+ ),
+ );
+ }).pipe(
+ Effect.tap((value) =>
+ Effect.sync(() => {
+ plannedArtifacts = value;
+ }),
+ ),
+ Effect.ensuring(Effect.sync(() => (planDeferred = undefined))),
+ );
+ return Effect.gen(function* () {
+ yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope);
+ return yield* restore(Deferred.await(deferred));
+ });
+ }),
+ );
- let prepared: PreparedStackArtifacts | undefined;
- yield* preparation
- .prepareEvents({
- mode: config.mode,
- services: enabledServicesForConfig(config),
- versions: versionsForConfig(config),
- })
- .pipe(
- Stream.mapError(
+ const prepareServices = (services: ReadonlyArray) =>
+ Effect.uninterruptibleMask((restore) =>
+ Effect.suspend(() => {
+ if (disposed || disposing) {
+ return Effect.fail(
+ new StackBuildError({
+ detail: "Cannot prepare stack assets after disposal has begun",
+ }),
+ );
+ }
+ const targets = [
+ ...new Set(
+ services.flatMap((service) =>
+ activationTargetsForService(enabledServices, service),
+ ),
+ ),
+ ];
+ const preparationTargets = preparationClosure(targets, enabledServices);
+ const pending = preparationTargets.filter(
+ (service) => preparedResolutions[service] === undefined,
+ );
+ if (pending.length === 0) {
+ return Effect.succeed({
+ resolutions: preparedResolutions,
+ } satisfies PreparedStackArtifacts);
+ }
+ const key = pending.toSorted().join(",");
+ const existing = preparationInFlight.get(key);
+ if (existing !== undefined) return restore(Deferred.await(existing));
+ const previousStates = new Map(
+ preparationTargets.flatMap((service) => {
+ const state = SubscriptionRef.getUnsafe(stateRef).find(
+ (entry) => entry.name === service,
+ );
+ return state === undefined || state.status === "Downloading"
+ ? []
+ : [[service, state] as const];
+ }),
+ );
+ const deferred = Deferred.makeUnsafe();
+ preparationInFlight.set(key, deferred);
+ const effect = preparationInput(pending).pipe(
+ Effect.flatMap((input) =>
+ Stream.runFoldEffect(
+ preparation.prepareEvents(input),
+ () => ({ resolutions: {} }) satisfies PreparedStackArtifacts,
+ (current, event) =>
+ Match.valueTags(event, {
+ ServiceDownloadStarted: (download) =>
+ markDownloading(download.service).pipe(Effect.as(current)),
+ ServiceDownloadFinished: (download) =>
+ restoreStateIfDownloading(
+ download.service,
+ previousStates.get(download.service),
+ ).pipe(Effect.as(current)),
+ PreparationCompleted: (completed) => Effect.succeed(completed.artifacts),
+ }),
+ ),
+ ),
+ Effect.mapError(
(cause) =>
new StackBuildError({
detail: "Failed to prepare stack assets",
@@ -281,133 +448,108 @@ export const localStackLayer = (
: "asset_preparation",
}),
),
- )
- .pipe(
- Stream.runForEach((event) => {
- switch (event._tag) {
- case "ServiceDownloadStarted":
- return updateState(
- new StackServiceState({
- name: event.service,
- status: "Downloading",
- pid: null,
- exitCode: null,
- restartCount: 0,
- startedAt: null,
- error: null,
- }),
- );
- case "ServiceDownloadFinished":
- return updateState(
- new StackServiceState({
- name: event.service,
- status: "Pending",
- pid: null,
- exitCode: null,
- restartCount: 0,
- startedAt: null,
- error: null,
- }),
- );
- case "PreparationCompleted":
- return Effect.sync(() => {
- prepared = event.artifacts;
- });
- }
- }),
+ Effect.tapError(() =>
+ Effect.forEach(
+ preparationTargets,
+ (service) => {
+ return restoreStateIfDownloading(service, previousStates.get(service));
+ },
+ { discard: true, concurrency: "unbounded" },
+ ),
+ ),
+ Effect.tap((value) =>
+ Effect.sync(() => Object.assign(preparedResolutions, value.resolutions)),
+ ),
+ Effect.ensuring(Effect.sync(() => preparationInFlight.delete(key))),
);
+ return Effect.gen(function* () {
+ yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope);
+ return yield* restore(Deferred.await(deferred));
+ });
+ }),
+ );
- if (prepared === undefined) {
- return yield* Effect.fail(
+ const ensureRuntime = Effect.uninterruptibleMask((restore) =>
+ Effect.suspend(() => {
+ if (disposed || disposing) {
+ return Effect.fail(
new StackBuildError({
- detail: "Stack preparation completed without prepared artifacts",
+ detail: "Cannot ensure stack runtime after stack disposal has begun",
}),
);
}
+ if (runtimeState !== undefined) {
+ return Effect.succeed(runtimeState);
+ }
+ if (runtimeDeferred !== undefined) return restore(Deferred.await(runtimeDeferred));
- yield* Ref.set(phaseRef, "prepared");
- return prepared;
- }).pipe(
- Effect.tap((value) =>
- Effect.sync(() => {
- preparedArtifacts = value;
- }),
- ),
- Effect.onError(() => Ref.set(phaseRef, "idle")),
- Effect.ensuring(
- Effect.sync(() => {
- prepareDeferred = undefined;
- }),
- ),
- );
-
- return Effect.gen(function* () {
- yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope);
- return yield* Deferred.await(deferred);
- });
- });
+ const deferred = Deferred.makeUnsafe();
+ runtimeDeferred = deferred;
- const ensureRuntime = Effect.suspend(() => {
- if (runtimeState !== undefined) {
- return Effect.succeed(runtimeState);
- }
- if (runtimeDeferred !== undefined) {
- return Deferred.await(runtimeDeferred);
- }
+ const effect = Effect.gen(function* () {
+ const prepared = yield* ensurePlanned;
+ const { graph, serviceProjection, cleanupTargets } = yield* builder
+ .build(config, prepared)
+ .pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Scope.Scope, scope),
+ );
+ const graphServices = new Set(graph.startOrder.map((definition) => definition.name));
+ const missingEnabledService = enabledServices.find(
+ (service) => !graphServices.has(service),
+ );
+ if (missingEnabledService !== undefined) {
+ return yield* Effect.fail(
+ new StackBuildError({
+ detail: `Prepared graph does not contain enabled service ${missingEnabledService}`,
+ }),
+ );
+ }
+ exactCleanupTargets = cleanupTargets;
- const deferred = Deferred.makeUnsafe();
- runtimeDeferred = deferred;
+ const orchLayer = Orchestrator.layer(graph).pipe(
+ Layer.provide(Layer.succeed(LogBuffer, logBuffer)),
+ Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
+ );
+ const orchServices = yield* Layer.buildWithScope(orchLayer, scope);
+ const orchestrator = Context.get(orchServices, Orchestrator);
- const effect = Effect.gen(function* () {
- const prepared = yield* ensurePrepared;
- const { graph, serviceProjection, cleanupTargets } = yield* builder.build(
- config,
- prepared,
- );
- exactCleanupTargets = cleanupTargets;
+ yield* syncProjectedStates(orchestrator, serviceProjection);
+ yield* orchestrator.allStateChanges().pipe(
+ Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)),
+ Effect.ignore,
+ Effect.forkIn(scope),
+ );
- const orchLayer = Orchestrator.layer(graph).pipe(
- Layer.provide(Layer.succeed(LogBuffer, logBuffer)),
- Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
- );
- const orchServices = yield* Layer.buildWithScope(orchLayer, scope);
- const orchestrator = Context.get(orchServices, Orchestrator);
-
- yield* syncProjectedStates(orchestrator, serviceProjection);
- yield* orchestrator.allStateChanges().pipe(
- Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)),
- Effect.ignore,
- Effect.forkIn(scope),
+ return {
+ orchestrator,
+ graph,
+ serviceProjection,
+ } satisfies RuntimeState;
+ }).pipe(
+ Effect.tap((value) =>
+ Effect.sync(() => {
+ runtimeState = value;
+ }),
+ ),
+ Effect.ensuring(
+ Effect.sync(() => {
+ runtimeDeferred = undefined;
+ }),
+ ),
);
- return {
- orchestrator,
- graph,
- serviceProjection,
- } satisfies RuntimeState;
- }).pipe(
- Effect.tap((value) =>
- Effect.sync(() => {
- runtimeState = value;
- }),
- ),
- Effect.ensuring(
- Effect.sync(() => {
- runtimeDeferred = undefined;
- }),
- ),
- );
-
- return Effect.gen(function* () {
- yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), scope);
- return yield* Deferred.await(deferred);
- });
- });
+ return Effect.gen(function* () {
+ yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope);
+ return yield* restore(Deferred.await(deferred));
+ });
+ }),
+ );
let disposed = false;
let disposing = false;
const runtimeHost = Effect.gen(function* () {
- const prepared = yield* ensurePrepared;
+ const prepared = yield* ensurePlanned;
const platform = yield* detectPlatform;
const edgeRuntimeResolution = prepared.resolutions["edge-runtime"];
return {
@@ -485,7 +627,11 @@ export const localStackLayer = (
const syncRuntimeProjectedStates = (runtime: RuntimeState) =>
syncProjectedStates(runtime.orchestrator, runtime.serviceProjection);
const serviceStartOptions = {
- beforeStart: (name: string) => portLease.reserve(portFieldsForService(name)),
+ // Reservation may yield while disposal flips the lifecycle state.
+ beforeStart: (name: string) =>
+ portLease
+ .reserve(portFieldsForService(name))
+ .pipe(Effect.andThen(requireMutable(`start service ${name}`))),
beforeSpawn: (name: string) => portLease.release(portFieldsForService(name)),
};
const knownServiceError = (service: string, cause: ServiceNotFoundError) =>
@@ -605,23 +751,40 @@ export const localStackLayer = (
const disposeOnce = () =>
Effect.suspend(() => {
disposing = true;
- return Effect.gen(function* () {
+ const preparationError = new StackBuildError({
+ detail: "Stack disposed during asset preparation",
+ });
+ const failInFlight: Effect.Effect = Effect.gen(function* () {
+ if (planDeferred !== undefined) {
+ yield* Deferred.fail(planDeferred, preparationError);
+ }
+ for (const deferred of preparationInFlight.values()) {
+ yield* Deferred.fail(deferred, preparationError);
+ }
+ if (runtimeDeferred !== undefined) {
+ yield* Deferred.fail(runtimeDeferred, preparationError);
+ }
+ });
+ const cleanup: Effect.Effect = Effect.gen(function* () {
if (disposed) {
return;
}
disposed = true;
yield* Ref.set(phaseRef, "stopping");
+ yield* Scope.close(preparationScope, Exit.void);
yield* cleanupLocalStackResources({
stop: () =>
runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop(),
cleanupTargets: exactCleanupTargets ?? { dockerContainerNames: [] },
config,
}).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
Effect.ensuring(providePlatform(clearFunctionsRuntimeConfig(config.runtimeRoot))),
Effect.ensuring(portLease.releaseAll),
Effect.ensuring(Ref.set(phaseRef, "disposed")),
);
}).pipe(withLifecycleLock);
+ return failInFlight.pipe(Effect.andThen(cleanup));
}).pipe(
Effect.ensuring(Deferred.succeed(disposedSignal, undefined).pipe(Effect.asVoid)),
Effect.uninterruptible,
@@ -653,18 +816,34 @@ export const localStackLayer = (
}),
);
};
+ const readinessErrorWithDiagnostics = (
+ error: StackReadinessError,
+ ): Effect.Effect =>
+ runtimeState === undefined
+ ? Effect.succeed(error)
+ : attachReadinessDiagnostics(
+ error,
+ runtimeState.orchestrator.getAllStates(),
+ logBuffer.historyAll(READINESS_DIAGNOSTIC_LOG_LIMIT),
+ );
const cleanupOnReadinessFailure = (
effect: Effect.Effect,
): Effect.Effect =>
effect.pipe(
- Effect.catchTag("StackReadinessError", (error) =>
- disposeOnce().pipe(Effect.andThen(Effect.fail(error))),
+ Effect.catchIf(
+ (error): error is StackReadinessError => error instanceof StackReadinessError,
+ (error) =>
+ readinessErrorWithDiagnostics(error).pipe(
+ Effect.flatMap(Effect.fail),
+ Effect.ensuring(disposeOnce()),
+ ),
),
);
yield* Effect.addFinalizer(disposeOnce);
const activateService = (name: ServiceName) =>
Effect.gen(function* () {
+ yield* requireMutable(`activate service ${name}`);
yield* requireRunningPhase;
const service = yield* requireKnownServiceName(name);
const existing = yield* inspectStartedTargets(service);
@@ -684,6 +863,7 @@ export const localStackLayer = (
);
return;
}
+ yield* prepareServices([service]);
const started = yield* Effect.gen(function* () {
yield* requireRunningPhase;
const concurrentlyStarted = yield* inspectStartedTargets(service);
@@ -708,13 +888,19 @@ export const localStackLayer = (
yield* Ref.set(phaseRef, "starting");
const runtime = yield* ensureRuntime;
yield* configureFunctions(config, yield* Ref.get(functionsBundleRef));
- serviceStartupBegan = true;
- if (config.startupMode === "lazy") {
+ const eager = eagerServices(enabledServices, config.servicePolicies);
+ const allServicesEager = eager.length === enabledServices.length;
+ // The all-eager case can use one topological whole-graph start;
+ // mixed policies need the more general per-service preparation path.
+ if (!allServicesEager) {
const readiness: Array> = [];
+ yield* prepareServices(["postgres", ...eager]);
+ yield* requireMutable("start");
if (
runtime.graph.startOrder.some((definition) => definition.name === "postgres-init")
) {
+ serviceStartupBegan = true;
yield* runtime.orchestrator
.startService("postgres-init", serviceStartOptions)
.pipe(
@@ -732,27 +918,39 @@ export const localStackLayer = (
),
);
}
- for (const service of eagerServices(enabledServices)) {
+ for (const service of eager) {
+ yield* requireMutable("start");
+ serviceStartupBegan = true;
const started = yield* beginStartTargets(
service,
- new Set(lifecycleTargetsForService(enabledServices, service)),
+ // Whole-stack startup owns every enabled service, including
+ // lazy transitive dependencies of eager services (for
+ // example Studio -> pgmeta). Explicit startService calls
+ // keep their narrower activation allowlist below.
+ new Set(enabledServices),
);
readiness.push(waitForTargets(started));
}
yield* Effect.all(readiness, { concurrency: "unbounded", discard: true }).pipe(
(effect) => withReadinessPolicy(effect, "stack"),
);
+ yield* syncRuntimeProjectedStates(runtime);
} else {
+ yield* prepareServices(enabledServices);
+ yield* requireMutable("start");
+ serviceStartupBegan = true;
yield* runtime.orchestrator.start(serviceStartOptions);
yield* runtime.orchestrator
.waitAllReady()
.pipe((effect) => withReadinessPolicy(effect, "stack"));
yield* syncRuntimeProjectedStates(runtime);
}
+ yield* requireMutable("start");
yield* Ref.set(phaseRef, "running");
}).pipe(
Effect.onError(() => Ref.set(phaseRef, "stopped")),
withLifecycleLock,
+ cleanupOnReadinessFailure,
Effect.onError(() => (serviceStartupBegan ? disposeOnce() : Effect.void)),
);
},
@@ -772,9 +970,13 @@ export const localStackLayer = (
dispose: disposeOnce,
startService: (name) =>
Effect.gen(function* () {
+ yield* requireMutable(`start service ${name}`);
+ yield* requireRunningPhase;
+ const service = yield* requireKnownServiceName(name);
+ yield* prepareServices([service]);
const started = yield* Effect.gen(function* () {
yield* requireMutable(`start service ${name}`);
- const service = yield* requireKnownServiceName(name);
+ yield* requireRunningPhase;
return yield* beginStartTargets(
service,
new Set(lifecycleTargetsForService(enabledServices, service)),
@@ -785,6 +987,7 @@ export const localStackLayer = (
stopService: (name) =>
Effect.gen(function* () {
yield* requireMutable(`stop service ${name}`);
+ yield* requireRunningPhase;
const service = yield* requireKnownServiceName(name);
const runtime = yield* ensureRuntime;
for (const target of lifecycleTargetsForService(
@@ -799,9 +1002,13 @@ export const localStackLayer = (
}).pipe(withLifecycleLock),
restartService: (name) =>
Effect.gen(function* () {
+ yield* requireMutable(`restart service ${name}`);
+ yield* requireRunningPhase;
+ const service = yield* requireKnownServiceName(name);
+ yield* prepareServices([service]);
const started = yield* Effect.gen(function* () {
yield* requireMutable(`restart service ${name}`);
- const service = yield* requireKnownServiceName(name);
+ yield* requireRunningPhase;
const runtime = yield* ensureRuntime;
yield* runtime.orchestrator.restartService(service, serviceStartOptions);
return { runtime, targets: [service] };
@@ -810,14 +1017,19 @@ export const localStackLayer = (
}).pipe(cleanupOnReadinessFailure),
reloadFunctions: (opts) =>
Effect.gen(function* () {
+ yield* requireMutable("reload functions");
+ yield* requireRunningPhase;
+ yield* requireKnownService("edge-runtime");
+ const requestedBundle =
+ opts?.functions === undefined
+ ? undefined
+ : yield* decodeFunctionsBundle(opts.functions);
+ yield* prepareServices(["edge-runtime"]);
const started = yield* Effect.gen(function* () {
yield* requireMutable("reload functions");
- yield* requireKnownService("edge-runtime");
+ yield* requireRunningPhase;
const currentBundle = yield* Ref.get(functionsBundleRef);
- const nextBundle =
- opts?.functions === undefined
- ? currentBundle
- : yield* decodeFunctionsBundle(opts.functions);
+ const nextBundle = requestedBundle ?? currentBundle;
yield* configureFunctions(config, nextBundle);
yield* Ref.set(functionsBundleRef, nextBundle);
const runtime = yield* ensureRuntime;
@@ -834,18 +1046,31 @@ export const localStackLayer = (
}).pipe(cleanupOnReadinessFailure),
reloadEdgeRuntime: (opts) =>
Effect.gen(function* () {
+ yield* requireMutable("reload Edge Runtime");
+ yield* requireRunningPhase;
+ yield* requireKnownService("edge-runtime");
+ if (opts.edgeRuntime.enabled === false) {
+ return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" }));
+ }
+ const requestedBundle =
+ opts.functions === undefined
+ ? undefined
+ : yield* decodeFunctionsBundle(opts.functions);
+ yield* prepareServices(["edge-runtime"]);
const started = yield* Effect.gen(function* () {
yield* requireMutable("reload Edge Runtime");
- yield* requireKnownService("edge-runtime");
+ yield* requireRunningPhase;
const nextConfig = yield* configWithEdgeRuntimeOptions(opts);
const currentBundle = yield* Ref.get(functionsBundleRef);
- const nextBundle =
- opts.functions === undefined
- ? currentBundle
- : yield* decodeFunctionsBundle(opts.functions);
- const prepared = yield* ensurePrepared;
+ const nextBundle = requestedBundle ?? currentBundle;
+ const prepared = yield* ensurePlanned;
const runtime = yield* ensureRuntime;
- const buildResult = yield* builder.build(nextConfig, prepared);
+ const buildResult = yield* builder
+ .build(nextConfig, prepared)
+ .pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Scope.Scope, scope),
+ );
const edgeRuntimeDef = buildResult.graph.startOrder.find(
(def) => def.name === "edge-runtime",
);
diff --git a/packages/stack/src/Platform.ts b/packages/stack/src/Platform.ts
index 5ed0e87e5e..cf819bf33c 100644
--- a/packages/stack/src/Platform.ts
+++ b/packages/stack/src/Platform.ts
@@ -5,40 +5,22 @@ export interface PlatformInfo {
readonly arch: string;
}
+/** Native slim-service release targets. The release set intentionally has no
+ * windows or x64 macOS artifacts. */
+export type NativeTarget = "darwin-arm64" | "linux-amd64" | "linux-arm64";
+
+export const nativeTargetForPlatform = (platform: PlatformInfo): NativeTarget | undefined => {
+ if (platform.os === "darwin" && platform.arch === "arm64") return "darwin-arm64";
+ if (platform.os === "linux" && platform.arch === "x64") return "linux-amd64";
+ if (platform.os === "linux" && platform.arch === "arm64") return "linux-arm64";
+ return undefined;
+};
+
export const detectPlatform: Effect.Effect = Effect.sync(() => ({
os: process.platform,
arch: process.arch,
}));
-export const postgresAssetName = (p: PlatformInfo): string | null => {
- if (p.os === "darwin" && p.arch === "arm64") return "darwin-arm64";
- if (p.os === "linux" && p.arch === "x64") return "linux-x64";
- if (p.os === "linux" && p.arch === "arm64") return "linux-arm64";
- return null;
-};
-
-export const postgrestAssetName = (p: PlatformInfo): string | null => {
- if (p.os === "darwin" && p.arch === "arm64") return "macos-aarch64";
- if (p.os === "linux" && p.arch === "x64") return "linux-static-x86-64";
- if (p.os === "linux" && p.arch === "arm64") return "ubuntu-aarch64";
- if (p.os === "win32" && p.arch === "x64") return "windows-x86-64";
- return null;
-};
-
-export const authAssetName = (p: PlatformInfo): string | null => {
- if (p.os === "darwin" && p.arch === "arm64") return "darwin-arm64";
- if (p.os === "linux" && p.arch === "x64") return "x86";
- if (p.os === "linux" && p.arch === "arm64") return "arm64";
- return null;
-};
-
-export const edgeRuntimeAssetName = (p: PlatformInfo): string | null => {
- if (p.os === "darwin" && p.arch === "arm64") return "aarch64-darwin";
- if (p.os === "linux" && p.arch === "x64") return "x86_64-linux";
- if (p.os === "linux" && p.arch === "arm64") return "aarch64-linux";
- return null;
-};
-
/** Host address that Docker containers should use to reach services on the host machine. */
export const dockerHostAddress = (_os: string): string => "host.docker.internal";
diff --git a/packages/stack/src/Platform.unit.test.ts b/packages/stack/src/Platform.unit.test.ts
index 07a10d2049..67a8095557 100644
--- a/packages/stack/src/Platform.unit.test.ts
+++ b/packages/stack/src/Platform.unit.test.ts
@@ -4,10 +4,7 @@ import {
detectPlatform,
dockerHostAddress,
dockerNetworkArgs,
- postgresAssetName,
- postgrestAssetName,
- authAssetName,
- edgeRuntimeAssetName,
+ nativeTargetForPlatform,
} from "./Platform.ts";
describe("detectPlatform", () => {
@@ -22,79 +19,21 @@ describe("detectPlatform", () => {
);
});
-describe("postgresAssetName", () => {
+describe("nativeTargetForPlatform", () => {
it("maps darwin-arm64", () => {
- expect(postgresAssetName({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64");
+ expect(nativeTargetForPlatform({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64");
});
it("maps linux-x64", () => {
- expect(postgresAssetName({ os: "linux", arch: "x64" })).toBe("linux-x64");
+ expect(nativeTargetForPlatform({ os: "linux", arch: "x64" })).toBe("linux-amd64");
});
it("maps linux-arm64", () => {
- expect(postgresAssetName({ os: "linux", arch: "arm64" })).toBe("linux-arm64");
+ expect(nativeTargetForPlatform({ os: "linux", arch: "arm64" })).toBe("linux-arm64");
});
it("returns null for unsupported", () => {
- expect(postgresAssetName({ os: "win32", arch: "x64" })).toBeNull();
- });
-});
-
-describe("postgrestAssetName", () => {
- it("maps darwin-arm64 to macos-aarch64", () => {
- expect(postgrestAssetName({ os: "darwin", arch: "arm64" })).toBe("macos-aarch64");
- });
-
- it("maps linux-x64 to linux-static-x86-64", () => {
- expect(postgrestAssetName({ os: "linux", arch: "x64" })).toBe("linux-static-x86-64");
- });
-
- it("maps linux-arm64 to ubuntu-aarch64", () => {
- expect(postgrestAssetName({ os: "linux", arch: "arm64" })).toBe("ubuntu-aarch64");
- });
-
- it("maps win32-x64 to windows-x86-64", () => {
- expect(postgrestAssetName({ os: "win32", arch: "x64" })).toBe("windows-x86-64");
- });
-
- it("returns null for unsupported", () => {
- expect(postgrestAssetName({ os: "win32", arch: "arm64" })).toBeNull();
- });
-});
-
-describe("authAssetName", () => {
- it("maps darwin-arm64 to darwin-arm64", () => {
- expect(authAssetName({ os: "darwin", arch: "arm64" })).toBe("darwin-arm64");
- });
-
- it("maps linux-x64 to x86", () => {
- expect(authAssetName({ os: "linux", arch: "x64" })).toBe("x86");
- });
-
- it("maps linux-arm64 to arm64", () => {
- expect(authAssetName({ os: "linux", arch: "arm64" })).toBe("arm64");
- });
-
- it("returns null for unsupported", () => {
- expect(authAssetName({ os: "darwin", arch: "x64" })).toBeNull();
- });
-});
-
-describe("edgeRuntimeAssetName", () => {
- it("maps darwin-arm64 to aarch64-darwin", () => {
- expect(edgeRuntimeAssetName({ os: "darwin", arch: "arm64" })).toBe("aarch64-darwin");
- });
-
- it("maps linux-x64 to x86_64-linux", () => {
- expect(edgeRuntimeAssetName({ os: "linux", arch: "x64" })).toBe("x86_64-linux");
- });
-
- it("maps linux-arm64 to aarch64-linux", () => {
- expect(edgeRuntimeAssetName({ os: "linux", arch: "arm64" })).toBe("aarch64-linux");
- });
-
- it("returns null for unsupported", () => {
- expect(edgeRuntimeAssetName({ os: "win32", arch: "x64" })).toBeNull();
+ expect(nativeTargetForPlatform({ os: "win32", arch: "x64" })).toBeUndefined();
});
});
diff --git a/packages/stack/src/PortAllocator.integration.test.ts b/packages/stack/src/PortAllocator.integration.test.ts
index 7703fe9498..2297e43604 100644
--- a/packages/stack/src/PortAllocator.integration.test.ts
+++ b/packages/stack/src/PortAllocator.integration.test.ts
@@ -1,159 +1,235 @@
+import { spawn } from "node:child_process";
+import { once } from "node:events";
import { createServer, type Server } from "node:net";
+import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
-import { Effect } from "effect";
-import { allocatePortSet, reservePortSet, type PortReservationRequest } from "./PortAllocator.ts";
-
-const listen = (port: number) =>
- Effect.callback((resume) => {
- const server = createServer();
- server.once("error", (error) => resume(Effect.fail(error)));
- server.listen(port, "127.0.0.1", () => resume(Effect.succeed(server)));
- return Effect.void;
- });
-
-const close = (server: Server) =>
- Effect.callback((resume) => {
- server.close(() => resume(Effect.void));
- return Effect.void;
+import { NodeFileSystem } from "@effect/platform-node";
+import { Cause, Effect, Exit, FileSystem } from "effect";
+import { reservePortSet, type PortReservationRequest } from "./PortAllocator.ts";
+
+const PORT_LEASE_CHILD = resolve(import.meta.dirname, "../tests/helpers/port-lease-child.ts");
+const STACK_PACKAGE_ROOT = resolve(import.meta.dirname, "..");
+
+const INTERRUPTED_ALLOCATION_SCRIPT = `
+import { NodeFileSystem } from "@effect/platform-node";
+import { Effect, Fiber } from "effect";
+import { reservePortSet } from "./src/PortAllocator.ts";
+
+const fiber = Effect.runFork(
+ reservePortSet([{ field: "apiPort", selection: { kind: "automatic" } }]).pipe(
+ Effect.provide(NodeFileSystem.layer),
+ ),
+);
+await Effect.runPromise(
+ Effect.callback((resume) => queueMicrotask(() => resume(Effect.void))),
+);
+await Effect.runPromise(Fiber.interrupt(fiber));
+`;
+
+const interruptedAllocationExits = (runtime: "node" | "bun"): Effect.Effect =>
+ Effect.callback((resume) => {
+ const args =
+ runtime === "node"
+ ? ["--input-type=module", "-e", INTERRUPTED_ALLOCATION_SCRIPT]
+ : ["-e", INTERRUPTED_ALLOCATION_SCRIPT];
+ const child = spawn(runtime, args, {
+ cwd: STACK_PACKAGE_ROOT,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let stderr = "";
+ let settled = false;
+ child.stderr.on("data", (chunk: Buffer) => {
+ stderr += chunk.toString();
+ });
+ const finish = (effect: Effect.Effect) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeout);
+ resume(effect);
+ };
+ const timeout = setTimeout(() => {
+ child.kill("SIGKILL");
+ finish(
+ Effect.fail(
+ new Error(`${runtime} retained a listener after interrupted allocation: ${stderr}`),
+ ),
+ );
+ }, 5_000);
+ child.once("error", (error) => finish(Effect.fail(error)));
+ child.once("close", (code, signal) =>
+ finish(
+ code === 0
+ ? Effect.void
+ : Effect.fail(
+ new Error(`${runtime} allocator probe exited with ${code ?? signal}: ${stderr}`),
+ ),
+ ),
+ );
+ return Effect.sync(() => {
+ clearTimeout(timeout);
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
+ });
});
-const occupyFreePort = () =>
- Effect.acquireRelease(
- Effect.map(listen(0), (server) => {
- const address = server.address();
- if (address === null || typeof address === "string") {
- throw new Error("Expected TCP server address");
- }
- return { port: address.port, server };
- }),
- ({ server }) => close(server),
- );
-
const automatic = (field: PortReservationRequest["field"]): PortReservationRequest => ({
field,
selection: { kind: "automatic" },
});
-describe("selected-field port allocation", () => {
- it("holds only requested fields and can release and re-reserve them", async () => {
- const lease = await Effect.runPromise(
- reservePortSet([automatic("apiPort"), automatic("dbPort")]),
- );
-
- try {
- expect(lease.ports.apiPort).toBeGreaterThan(0);
- expect(lease.ports.dbPort).toBeGreaterThan(0);
- expect("authPort" in lease.ports).toBe(false);
-
- const unavailable = await Effect.runPromise(
- allocatePortSet([
- { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } },
- ]).pipe(Effect.exit),
- );
- expect(unavailable._tag).toBe("Failure");
-
- await Effect.runPromise(lease.release(["apiPort"]));
- const rebound = await Effect.runPromise(
- allocatePortSet([
- { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } },
- ]),
- );
- expect(rebound.apiPort).toBe(lease.ports.apiPort);
+const run = (effect: Effect.Effect) =>
+ Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)));
- await Effect.runPromise(lease.reserve(["apiPort"]));
- const unavailableAgain = await Effect.runPromise(
- allocatePortSet([
- { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } },
- ]).pipe(Effect.exit),
- );
- expect(unavailableAgain._tag).toBe("Failure");
+const occupyFreePort = () =>
+ Effect.acquireRelease(
+ Effect.callback((resume) => {
+ const server = createServer();
+ server.once("error", (error) => resume(Effect.fail(error)));
+ server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)));
+ return Effect.sync(() => server.close());
+ }).pipe(
+ Effect.map((server) => {
+ const address = server.address();
+ if (address === null || typeof address === "string") throw new Error("Expected address");
+ return { port: address.port, server };
+ }),
+ ),
+ ({ server }) =>
+ Effect.callback((resume) => {
+ server.close(() => resume(Effect.void));
+ return Effect.void;
+ }),
+ );
- await Effect.runPromise(lease.releaseAll);
- const reboundBoth = await Effect.runPromise(
- allocatePortSet([
- { field: "apiPort", selection: { kind: "exact", port: lease.ports.apiPort! } },
- { field: "dbPort", selection: { kind: "exact", port: lease.ports.dbPort! } },
- ]),
- );
- expect(reboundBoth.apiPort).toBe(lease.ports.apiPort);
- expect(reboundBoth.dbPort).toBe(lease.ports.dbPort);
- } finally {
- await Effect.runPromise(lease.releaseAll);
- }
+const startChildLease = () => {
+ const child = spawn("bun", ["run", PORT_LEASE_CHILD], {
+ stdio: ["pipe", "pipe", "pipe"],
});
+ const ready = new Promise<{ readonly apiPort: number; readonly dbPort: number }>(
+ (resolveReady, rejectReady) => {
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (chunk: Buffer) => {
+ stdout += chunk.toString();
+ const newline = stdout.indexOf("\n");
+ if (newline === -1) return;
+ try {
+ resolveReady(JSON.parse(stdout.slice(0, newline)));
+ } catch (error) {
+ rejectReady(new Error(`Invalid child response: ${stdout}`, { cause: error }));
+ }
+ });
+ child.stderr.on("data", (chunk: Buffer) => {
+ stderr += chunk.toString();
+ });
+ child.once("error", rejectReady);
+ child.once("close", (code) => {
+ rejectReady(new Error(`Child exited before readiness (${code}): ${stderr}`));
+ });
+ },
+ );
+ return { child, ready };
+};
+
+describe("reservePortSet", () => {
+ it.each(["node", "bun"] as const)(
+ "closes a pending listener when allocation is interrupted under %s",
+ async (runtime) => {
+ await Effect.runPromise(interruptedAllocationExits(runtime));
+ },
+ 10_000,
+ );
- it("fails when an exact port is occupied", async () => {
- const exit = await Effect.runPromise(
+ it("fails an occupied exact port with field and port attribution", async () => {
+ let occupiedPort = 0;
+ const exit = await run(
Effect.scoped(
Effect.gen(function* () {
const occupied = yield* occupyFreePort();
- return yield* allocatePortSet([
+ occupiedPort = occupied.port;
+ return yield* reservePortSet([
{ field: "apiPort", selection: { kind: "exact", port: occupied.port } },
]).pipe(Effect.exit);
}),
),
);
-
- expect(exit._tag).toBe("Failure");
- if (exit._tag === "Failure") {
- expect(JSON.stringify(exit.cause)).toContain("is not available");
+ expect(Exit.isFailure(exit)).toBe(true);
+ if (Exit.isFailure(exit)) {
+ const error = Cause.squash(exit.cause);
+ expect(error).toMatchObject({ field: "apiPort", port: occupiedPort });
}
});
- it("keeps concurrent selected-field leases disjoint", async () => {
- const [first, second] = await Promise.all([
- Effect.runPromise(reservePortSet([automatic("apiPort"), automatic("dbPort")])),
- Effect.runPromise(reservePortSet([automatic("apiPort"), automatic("dbPort")])),
- ]);
+ it("reserves multiple automatic fields and re-reserves selected fields", async () => {
+ const lease = await run(reservePortSet([automatic("apiPort"), automatic("dbPort")]));
+ try {
+ expect(lease.ports.apiPort).toBeGreaterThan(0);
+ expect(lease.ports.dbPort).toBeGreaterThan(0);
+ await run(lease.release(["dbPort"]));
+ await run(lease.reserve(["dbPort"]));
+ } finally {
+ await run(lease.releaseAll);
+ }
+ });
+ it("retains claims after TCP release until releaseAll", async () => {
+ const lease = await run(reservePortSet([automatic("apiPort")]));
+ const port = lease.ports.apiPort;
+ if (port === undefined) throw new Error("Expected API port");
try {
- const firstPorts = new Set(Object.values(first.ports));
- expect(Object.values(second.ports).every((port) => !firstPorts.has(port))).toBe(true);
+ await run(lease.release(["apiPort"]));
+ const blocked = await run(
+ reservePortSet([{ field: "apiPort", selection: { kind: "exact", port } }]).pipe(
+ Effect.exit,
+ ),
+ );
+ expect(Exit.isFailure(blocked)).toBe(true);
} finally {
- await Promise.all([
- Effect.runPromise(first.releaseAll),
- Effect.runPromise(second.releaseAll),
- ]);
+ await run(lease.releaseAll);
}
});
- it("releases partial reservations when a selected set fails", async () => {
- const firstPort = await Effect.runPromise(
- Effect.scoped(Effect.map(occupyFreePort(), (occupied) => occupied.port)),
+ it("keeps automatic ports disjoint across child processes", async () => {
+ const first = startChildLease();
+ const second = startChildLease();
+ try {
+ const [left, right] = await Promise.all([first.ready, second.ready]);
+ const ports = [left.apiPort, left.dbPort, right.apiPort, right.dbPort];
+ expect(new Set(ports).size).toBe(ports.length);
+ } finally {
+ for (const child of [first.child, second.child]) {
+ if (child.exitCode === null) {
+ child.stdin.end("release\n");
+ await once(child, "close");
+ }
+ }
+ }
+ }, 30_000);
+
+ it("recovers a stale claim left by an unclean child exit", async () => {
+ const child = startChildLease();
+ const ports = await child.ready;
+ child.child.kill("SIGKILL");
+ await once(child.child, "close");
+ const lease = await run(
+ reservePortSet([{ field: "apiPort", selection: { kind: "exact", port: ports.apiPort } }]),
);
- const failed = await Effect.runPromise(
+ await run(lease.releaseAll);
+ }, 30_000);
+
+ it("rolls back earlier fields when a later exact field is unavailable", async () => {
+ const failed = await run(
Effect.scoped(
Effect.gen(function* () {
const occupied = yield* occupyFreePort();
return yield* reservePortSet([
- { field: "apiPort", selection: { kind: "exact", port: firstPort } },
+ automatic("apiPort"),
{ field: "dbPort", selection: { kind: "exact", port: occupied.port } },
]).pipe(Effect.exit);
}),
),
);
- expect(failed._tag).toBe("Failure");
-
- const available = await Effect.runPromise(
- allocatePortSet([{ field: "apiPort", selection: { kind: "exact", port: firstPort } }]),
- );
- expect(available.apiPort).toBe(firstPort);
- });
-
- it("releases a bound port when interrupted during lease registration", async () => {
- const port = await Effect.runPromise(
- Effect.scoped(Effect.map(occupyFreePort(), (occupied) => occupied.port)),
- );
- const interrupted = await Effect.runPromise(
- reservePortSet([{ field: "apiPort", selection: { kind: "exact", port } }], {
- onBound: () => Effect.interrupt,
- }).pipe(Effect.exit),
- );
- expect(interrupted._tag).toBe("Failure");
-
- const rebound = await Effect.runPromise(
- allocatePortSet([{ field: "apiPort", selection: { kind: "exact", port } }]),
- );
- expect(rebound.apiPort).toBe(port);
+ expect(Exit.isFailure(failed)).toBe(true);
+ const retry = await run(reservePortSet([automatic("apiPort")]));
+ await run(retry.releaseAll);
});
});
diff --git a/packages/stack/src/PortAllocator.ts b/packages/stack/src/PortAllocator.ts
index 6eb7b3ff58..38bde6b75e 100644
--- a/packages/stack/src/PortAllocator.ts
+++ b/packages/stack/src/PortAllocator.ts
@@ -1,5 +1,19 @@
+import { randomUUID } from "node:crypto";
import { createServer, type Server } from "node:net";
-import { Data, Effect, Schema, Semaphore } from "effect";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ Cause,
+ Data,
+ Effect,
+ Exit,
+ FileSystem,
+ Option,
+ Predicate,
+ Schema,
+ Semaphore,
+} from "effect";
+import { PlatformError } from "effect/PlatformError";
import { PortSetSchema, type PortField, type PortSet } from "./PortCatalog.ts";
export class PortAllocationError extends Data.TaggedError("PortAllocationError")<{
@@ -7,15 +21,27 @@ export class PortAllocationError extends Data.TaggedError("PortAllocationError")
readonly cause?: unknown;
readonly field?: PortField;
readonly port?: number;
+ readonly reason?: "unavailable" | "failed";
}> {
override get message(): string {
return this.detail;
}
}
+class PortClaimCollisionError extends Data.TaggedError("PortClaimCollisionError")<{
+ readonly port: number;
+}> {}
+
+const MAX_CLAIM_ATTEMPTS = 32;
+
export type PortSelection =
| { readonly kind: "exact"; readonly port: number }
- | { readonly kind: "automatic"; readonly preferred?: number };
+ | {
+ readonly kind: "automatic";
+ readonly preferred?: number;
+ /** Additional exclusions used by managed allocation (for example control ports). */
+ readonly excluded?: ReadonlySet;
+ };
export interface PortReservationRequest {
readonly field: PortField;
@@ -26,90 +52,283 @@ export interface PortSelectionOptions {
readonly reserved?: ReadonlySet;
}
-interface PortAllocationOptions extends PortSelectionOptions {
- readonly probe?: PortProbe;
- /** @internal deterministic interruption hook used by allocator integration tests. */
- readonly onBound?: (field: PortField, bound: BoundPort) => Effect.Effect;
+const closeServer = (server: Server): Effect.Effect =>
+ Effect.callback((resume) => {
+ server.close((cause) =>
+ resume(
+ cause === undefined ||
+ (typeof cause === "object" &&
+ cause !== null &&
+ "code" in cause &&
+ Reflect.get(cause, "code") === "ERR_SERVER_NOT_RUNNING")
+ ? Effect.void
+ : Effect.die(cause),
+ ),
+ );
+ return Effect.void;
+ });
+
+interface BoundPort {
+ readonly port: number;
+ readonly server: Server;
+}
+
+interface PortClaim {
+ readonly path: string;
+ readonly port: number;
+ readonly token: string;
}
-interface PortProbe {
- readonly exact: (port: number) => Effect.Effect;
- readonly random: (exclude: ReadonlySet) => Effect.Effect;
+interface ClaimRecord {
+ readonly pid: number;
+ readonly token: string;
}
-/** Bind port 0 to get an OS-assigned random port, then close immediately. */
-const probeRandomPort = (
- exclude: ReadonlySet,
-): Effect.Effect =>
- Effect.flatMap(
- Effect.callback((resume) => {
- const server = createServer();
- 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)));
- });
- server.on("error", (cause) =>
- resume(
- Effect.fail(new PortAllocationError({ detail: "Failed to bind random port", cause })),
+interface ClaimSnapshot {
+ readonly contents: string;
+ readonly record: ClaimRecord | undefined;
+ readonly info: FileSystem.File.Info;
+}
+
+const claimNamespace = (): string => {
+ const uid = process.getuid?.();
+ if (uid !== undefined) return `uid-${uid}`;
+ const username = process.env.USER ?? process.env.USERNAME ?? "unknown";
+ const safeUsername = username.replace(/[^a-zA-Z0-9._-]/g, "_") || "unknown";
+ return `user-${safeUsername}`;
+};
+
+const CLAIM_ROOT = join(tmpdir(), `supabase-stack-port-claims-${claimNamespace()}`);
+const CLAIM_STALE_AFTER_MS = 30_000;
+
+const claimPath = (port: number, root = CLAIM_ROOT): string => join(root, `port-${port}`);
+
+const isProcessAlive = (pid: number): boolean => {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (cause) {
+ return typeof cause === "object" && cause !== null && "code" in cause
+ ? Reflect.get(cause, "code") !== "ESRCH"
+ : false;
+ }
+};
+
+const isNotFound = (error: PlatformError): boolean => Predicate.isTagged(error.reason, "NotFound");
+const isAlreadyExists = (error: PlatformError): boolean =>
+ Predicate.isTagged(error.reason, "AlreadyExists");
+
+const parseClaimRecord = (contents: string): ClaimRecord | undefined => {
+ try {
+ const value: unknown = JSON.parse(contents);
+ if (typeof value !== "object" || value === null) return undefined;
+ const pid = Reflect.get(value, "pid");
+ const token = Reflect.get(value, "token");
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && typeof token === "string"
+ ? { pid, token }
+ : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+const readClaimSnapshot = (
+ path: string,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const contents = yield* fs
+ .readFileString(path)
+ .pipe(
+ Effect.catchTag("PlatformError", (error) =>
+ isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error),
+ ),
+ );
+ if (contents === undefined) return undefined;
+ const info = yield* fs
+ .stat(path)
+ .pipe(
+ Effect.catchTag("PlatformError", (error) =>
+ isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error),
),
);
- return Effect.void;
- }),
- (port) => (exclude.has(port) ? probeRandomPort(exclude) : Effect.succeed(port)),
+ if (info === undefined) return undefined;
+ return { contents, record: parseClaimRecord(contents), info };
+ });
+
+const claimIsStale = (snapshot: ClaimSnapshot): boolean => {
+ if (snapshot.info.type !== "File") return false;
+ if (snapshot.record !== undefined) return !isProcessAlive(snapshot.record.pid);
+ return (
+ Option.isSome(snapshot.info.mtime) &&
+ Date.now() - snapshot.info.mtime.value.getTime() > CLAIM_STALE_AFTER_MS
);
+};
-/** Probe the exact port requested by the user. Fail if it is not available. */
-const probeExactPort = (port: number): Effect.Effect =>
- Effect.callback((resume) => {
- const server = createServer();
- server.listen(port, "127.0.0.1", () => {
- server.close(() => resume(Effect.succeed(port)));
- });
- server.on("error", () =>
- resume(
- Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available`, port })),
- ),
+const inspectClaim = (
+ path: string,
+): Effect.Effect<
+ { readonly snapshot: ClaimSnapshot; readonly stale: boolean } | undefined,
+ PlatformError,
+ FileSystem.FileSystem
+> =>
+ readClaimSnapshot(path).pipe(
+ Effect.map((snapshot) =>
+ snapshot === undefined ? undefined : { snapshot, stale: claimIsStale(snapshot) },
+ ),
+ );
+
+const claimIdentityMatches = (expected: ClaimSnapshot, current: ClaimSnapshot): boolean => {
+ if (expected.record !== undefined || current.record !== undefined) {
+ return (
+ expected.record !== undefined &&
+ current.record !== undefined &&
+ expected.record.pid === current.record.pid &&
+ expected.record.token === current.record.token
);
- return Effect.void;
+ }
+ if (expected.contents !== current.contents) return false;
+ if (Option.isSome(expected.info.ino) && Option.isSome(current.info.ino)) {
+ return expected.info.ino.value === current.info.ino.value;
+ }
+ return false;
+};
+
+const removeCreatedClaim = (
+ path: string,
+ contents: string,
+ openedInfo: FileSystem.File.Info | undefined,
+ opened: boolean,
+ fs: FileSystem.FileSystem,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ if (!opened) return;
+ const current = yield* readClaimSnapshot(path).pipe(Effect.orElseSucceed(() => undefined));
+ if (current === undefined) return;
+ if (openedInfo === undefined) {
+ if (current.contents.length !== 0) return;
+ } else if (Option.isSome(openedInfo.ino) && Option.isSome(current.info.ino)) {
+ if (openedInfo.ino.value !== current.info.ino.value) return;
+ } else {
+ if (current.contents !== contents) return;
+ if (
+ !Option.isSome(openedInfo.mtime) ||
+ !Option.isSome(current.info.mtime) ||
+ openedInfo.mtime.value.getTime() !== current.info.mtime.value.getTime()
+ ) {
+ return;
+ }
+ }
+ yield* fs.remove(path, { force: true }).pipe(Effect.ignore);
+ }).pipe(Effect.provideService(FileSystem.FileSystem, fs));
+
+const readClaimRecord = (
+ path: string,
+): Effect.Effect =>
+ readClaimSnapshot(path).pipe(Effect.map((snapshot) => snapshot?.record));
+
+const removeStaleClaim = (
+ path: string,
+ expected: ClaimSnapshot,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const current = yield* readClaimSnapshot(path).pipe(Effect.orElseSucceed(() => undefined));
+ if (current === undefined || !claimIdentityMatches(expected, current)) return false;
+ yield* fs
+ .remove(path, { force: true })
+ .pipe(
+ Effect.catchTag("PlatformError", (error) =>
+ isNotFound(error) ? Effect.void : Effect.fail(error),
+ ),
+ );
+ return true;
});
-const chooseExactPort = (
+const acquirePortClaimInternal = (
port: number,
- exclude: ReadonlySet,
- probe: PortProbe,
-): Effect.Effect =>
- exclude.has(port)
- ? Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available`, port }))
- : probe.exact(port);
+ root = CLAIM_ROOT,
+): Effect.Effect<
+ PortClaim,
+ PlatformError | PortAllocationError | PortClaimCollisionError,
+ FileSystem.FileSystem
+> =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ yield* fs.makeDirectory(root, { recursive: true });
+ const path = claimPath(port, root);
+ const token = randomUUID();
+ const contents = JSON.stringify({ pid: process.pid, token });
+
+ for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) {
+ let openedInfo: FileSystem.File.Info | undefined;
+ let created = false;
+ const openedExit = yield* Effect.exit(
+ Effect.scoped(
+ fs.open(path, { flag: "wx", mode: 0o600 }).pipe(
+ Effect.flatMap((handle) => {
+ created = true;
+ return handle.stat.pipe(
+ Effect.tap((info) => Effect.sync(() => (openedInfo = info))),
+ Effect.andThen(handle.writeAll(new TextEncoder().encode(contents))),
+ );
+ }),
+ ),
+ ),
+ );
+ if (Exit.isSuccess(openedExit)) return { path, port, token };
+ yield* Effect.uninterruptible(removeCreatedClaim(path, contents, openedInfo, created, fs));
+ const failure = Cause.findErrorOption(openedExit.cause);
+ if (Option.isNone(failure)) return yield* Effect.failCause(openedExit.cause);
+ if (!isAlreadyExists(failure.value)) {
+ return yield* Effect.fail(failure.value);
+ }
+ const inspection = yield* inspectClaim(path);
+ if (inspection === undefined) continue;
+ if (!inspection.stale) {
+ return yield* new PortClaimCollisionError({ port });
+ }
+ if (!(yield* removeStaleClaim(path, inspection.snapshot))) continue;
+ }
+ return yield* new PortAllocationError({
+ detail: `Failed to claim port ${port} after ${MAX_CLAIM_ATTEMPTS} attempts`,
+ port,
+ reason: "failed",
+ });
+ });
+
+const portAllocationFromCause = (port: number, cause: unknown): PortAllocationError =>
+ cause instanceof PortAllocationError
+ ? cause
+ : new PortAllocationError({
+ detail: `Failed to claim port ${port}`,
+ cause,
+ port,
+ reason: "failed",
+ });
-const choosePreferredPort = (
+const acquirePortClaim = (
port: number,
- exclude: ReadonlySet,
- probe: PortProbe,
-): Effect.Effect =>
- exclude.has(port)
- ? probe.random(exclude)
- : probe.exact(port).pipe(Effect.catchTag("PortAllocationError", () => probe.random(exclude)));
-
-const defaultPortProbe: PortProbe = {
- exact: probeExactPort,
- random: probeRandomPort,
-};
+ root = CLAIM_ROOT,
+): Effect.Effect =>
+ acquirePortClaimInternal(port, root).pipe(
+ Effect.mapError((cause) =>
+ cause instanceof PortClaimCollisionError ? cause : portAllocationFromCause(port, cause),
+ ),
+ );
-const closeServer = (server: Server): Effect.Effect =>
- Effect.callback((resume) => {
- server.close(() => resume(Effect.void));
- return Effect.void;
+const releasePortClaim = (claim: PortClaim, fs: FileSystem.FileSystem): Effect.Effect =>
+ Effect.gen(function* () {
+ const record = yield* readClaimRecord(claim.path).pipe(
+ Effect.orElseSucceed(() => undefined),
+ Effect.provideService(FileSystem.FileSystem, fs),
+ );
+ if (record?.token !== claim.token || record.pid !== process.pid) return;
+ yield* fs.remove(claim.path, { force: true }).pipe(Effect.ignore);
});
-interface BoundPort {
- readonly port: number;
- readonly server: Server;
-}
-
const bindPort = (port: number): Effect.Effect =>
- Effect.callback((resume) => {
+ Effect.callback((resume, signal) => {
const server = createServer((socket) => socket.destroy());
const onError = (cause: unknown) => {
resume(
@@ -118,33 +337,41 @@ const bindPort = (port: number): Effect.Effect =
detail:
port === 0 ? "Failed to reserve a random port" : `Port ${port} is not available`,
cause,
+ reason: port === 0 ? "failed" : "unavailable",
...(port === 0 ? {} : { port }),
}),
),
);
};
server.once("error", onError);
- server.listen(port, "127.0.0.1", () => {
+ server.listen({ port, host: "127.0.0.1", signal }, () => {
server.off("error", onError);
const address = server.address();
if (address === null || typeof address === "string") {
- void Effect.runPromise(closeServer(server));
resume(
- Effect.fail(
- new PortAllocationError({ detail: "Reserved TCP port has no numeric address" }),
+ closeServer(server).pipe(
+ Effect.andThen(
+ Effect.fail(
+ new PortAllocationError({ detail: "Reserved TCP port has no numeric address" }),
+ ),
+ ),
),
);
return;
}
resume(Effect.succeed({ port: address.port, server }));
});
- return closeServer(server);
+ return Effect.sync(() => server.off("error", onError)).pipe(
+ Effect.andThen(closeServer(server)),
+ );
});
export interface PortLease {
readonly ports: PortSet;
readonly reserve: (fields: ReadonlyArray) => Effect.Effect;
+ /** Releases TCP reservations while retaining ownership claims for this lease. */
readonly release: (fields: ReadonlyArray) => Effect.Effect;
+ /** Releases all TCP reservations and ends ownership of every selected port. */
readonly releaseAll: Effect.Effect;
}
@@ -172,24 +399,81 @@ const releaseReservations = (
reservations: Map,
fields: ReadonlyArray,
): Effect.Effect =>
- Effect.forEach(
- uniquePortFields(fields),
- (field) => {
- const server = reservations.get(field);
- if (server === undefined) return Effect.void;
- reservations.delete(field);
- return closeServer(server);
- },
- { discard: true },
+ // Cleanup must finish the ownership handoff even if the caller is
+ // interrupted between releasing individual fields.
+ Effect.uninterruptible(
+ Effect.forEach(
+ uniquePortFields(fields),
+ (field) => {
+ const server = reservations.get(field);
+ if (server === undefined) return Effect.void;
+ return closeServer(server).pipe(
+ Effect.tap(() => Effect.sync(() => reservations.delete(field))),
+ );
+ },
+ { discard: true },
+ ),
+ );
+
+const releaseClaims = (
+ claims: Map,
+ fields: ReadonlyArray,
+ fs: FileSystem.FileSystem,
+): Effect.Effect =>
+ Effect.uninterruptible(
+ Effect.forEach(
+ uniquePortFields(fields),
+ (field) => {
+ const claim = claims.get(field);
+ if (claim === undefined) return Effect.void;
+ return releasePortClaim(claim, fs).pipe(
+ Effect.tap(() => Effect.sync(() => claims.delete(field))),
+ );
+ },
+ { discard: true },
+ ),
);
+const claimAndBind = (
+ field: PortField,
+ port: number,
+ claims: Map,
+ fs: FileSystem.FileSystem,
+ root: string,
+): Effect.Effect => {
+ const existingClaim = claims.get(field);
+ return Effect.gen(function* () {
+ const bound = yield* Effect.interruptible(bindPort(port));
+ if (existingClaim !== undefined) {
+ claims.set(field, existingClaim);
+ return bound;
+ }
+
+ const claimExit = yield* Effect.exit(
+ Effect.interruptible(
+ acquirePortClaim(port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)),
+ ),
+ );
+ if (Exit.isFailure(claimExit)) {
+ yield* closeServer(bound.server);
+ return yield* Effect.failCause(claimExit.cause);
+ }
+ yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value)));
+ return bound;
+ });
+};
+
const reserveReservations = (
ports: PortSet,
reservations: Map,
+ claims: Map,
fields: ReadonlyArray,
+ fs: FileSystem.FileSystem,
+ root: string,
): Effect.Effect =>
Effect.suspend(() => {
const acquired: Array = [];
+ const acquiredClaims: Array = [];
return Effect.forEach(
uniquePortFields(fields),
(field) => {
@@ -203,52 +487,123 @@ const reserveReservations = (
}),
);
}
- return bindPort(port).pipe(
- Effect.mapError((error) => withPortField(field, error)),
- Effect.tap(({ server }) =>
- Effect.sync(() => {
- reservations.set(field, server);
- acquired.push(field);
- }),
+ const existingClaim = claims.has(field);
+ return Effect.uninterruptibleMask(() =>
+ claimAndBind(field, port, claims, fs, root).pipe(
+ Effect.mapError((error) =>
+ error instanceof PortClaimCollisionError
+ ? new PortAllocationError({
+ detail: `Port ${error.port} is not available`,
+ field,
+ port: error.port,
+ reason: "unavailable",
+ })
+ : withPortField(field, error),
+ ),
+ Effect.tap(({ server }) =>
+ Effect.sync(() => {
+ reservations.set(field, server);
+ acquired.push(field);
+ if (!existingClaim) acquiredClaims.push(field);
+ }),
+ ),
),
);
},
{ discard: true },
- ).pipe(Effect.onError(() => releaseReservations(reservations, acquired)));
+ ).pipe(
+ Effect.onError(() =>
+ Effect.all(
+ [releaseReservations(reservations, acquired), releaseClaims(claims, acquiredClaims, fs)],
+ { discard: true },
+ ),
+ ),
+ );
});
-const makePortLease = (ports: PortSet, reservations: Map): PortLease => {
+const makePortLease = (
+ ports: PortSet,
+ reservations: Map,
+ claims: Map,
+ fs: FileSystem.FileSystem,
+ root: string,
+): PortLease => {
const lock = Semaphore.makeUnsafe(1);
return {
ports,
- reserve: (fields) => lock.withPermit(reserveReservations(ports, reservations, fields)),
+ reserve: (fields) =>
+ lock.withPermit(reserveReservations(ports, reservations, claims, fields, fs, root)),
release: (fields) => lock.withPermit(releaseReservations(reservations, fields)),
releaseAll: lock.withPermit(
- Effect.suspend(() => releaseReservations(reservations, [...reservations.keys()])),
+ Effect.suspend(() =>
+ Effect.all(
+ [
+ releaseReservations(reservations, [...reservations.keys()]),
+ releaseClaims(claims, [...claims.keys()], fs),
+ ],
+ { discard: true },
+ ),
+ ),
),
};
};
const reserveRandomPort = (
exclude: ReadonlySet,
-): Effect.Effect =>
- Effect.flatMap(bindPort(0), (bound) =>
- exclude.has(bound.port)
- ? closeServer(bound.server).pipe(Effect.andThen(reserveRandomPort(exclude)))
- : Effect.succeed(bound),
- );
-
-const resolveSelection = (
- selection: PortSelection,
- exclude: ReadonlySet,
- probe: PortProbe,
-): Effect.Effect => {
- if (selection.kind === "exact") {
- return chooseExactPort(selection.port, exclude, probe);
+ field: PortField,
+ claims: Map,
+ fs: FileSystem.FileSystem,
+ root: string,
+ attempt = 0,
+): Effect.Effect<
+ BoundPort,
+ PortAllocationError | PortClaimCollisionError,
+ FileSystem.FileSystem
+> => {
+ if (attempt >= MAX_CLAIM_ATTEMPTS) {
+ return Effect.fail(
+ new PortAllocationError({
+ detail: `Failed to reserve a random port after ${MAX_CLAIM_ATTEMPTS} claim collisions`,
+ reason: "failed",
+ }),
+ );
}
- return selection.preferred === undefined
- ? probe.random(exclude)
- : choosePreferredPort(selection.preferred, exclude, probe);
+ return Effect.gen(function* () {
+ const bound = yield* Effect.interruptible(bindPort(0));
+ if (exclude.has(bound.port)) {
+ yield* closeServer(bound.server);
+ return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1);
+ }
+
+ const claimExit = yield* Effect.exit(
+ Effect.interruptible(
+ acquirePortClaim(bound.port, root).pipe(Effect.provideService(FileSystem.FileSystem, fs)),
+ ),
+ );
+ if (Exit.isSuccess(claimExit)) {
+ yield* Effect.uninterruptible(Effect.sync(() => claims.set(field, claimExit.value)));
+ return bound;
+ }
+
+ yield* closeServer(bound.server);
+ const failure = Cause.findErrorOption(claimExit.cause);
+ if (Option.isSome(failure) && failure.value instanceof PortClaimCollisionError) {
+ return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1);
+ }
+ if (Option.isSome(failure) && failure.value instanceof PlatformError) {
+ return yield* Effect.fail(portAllocationFromCause(bound.port, failure.value));
+ }
+ if (Option.isSome(failure) && failure.value instanceof PortAllocationError) {
+ return yield* Effect.fail(failure.value);
+ }
+ return yield* Effect.failCause(
+ Cause.map(claimExit.cause, (error) =>
+ error instanceof PortClaimCollisionError || error instanceof PortAllocationError
+ ? error
+ : portAllocationFromCause(bound.port, error),
+ ),
+ );
+ });
};
const withPortField = (field: PortField, error: PortAllocationError): PortAllocationError =>
@@ -256,90 +611,144 @@ const withPortField = (field: PortField, error: PortAllocationError): PortAlloca
detail: error.detail,
cause: error.cause,
field,
+ reason: error.reason,
...(error.port === undefined ? {} : { port: error.port }),
});
-export const allocatePortSet = (
- requests: ReadonlyArray,
- options: PortAllocationOptions = {},
+const decodePortSet = (
+ partial: Partial>,
): Effect.Effect =>
- Effect.gen(function* () {
- const reserved = options.reserved ?? new Set();
- const probe = options.probe ?? defaultPortProbe;
- const allocated = new Set();
- const partial: Partial> = {};
-
- for (const request of uniqueFields(requests)) {
- const exclude = new Set([...reserved, ...allocated]);
- const port = yield* resolveSelection(request.selection, exclude, probe).pipe(
- Effect.mapError((error) => withPortField(request.field, error)),
- );
- allocated.add(port);
- partial[request.field] = port;
- }
-
- return Schema.decodeUnknownSync(PortSetSchema)(partial);
- });
+ Schema.decodeUnknownEffect(PortSetSchema)(partial).pipe(
+ Effect.mapError(
+ (cause) =>
+ new PortAllocationError({
+ detail: "Allocated ports did not match the port catalog",
+ cause,
+ }),
+ ),
+ );
export const reservePortSet = (
requests: ReadonlyArray,
- options: PortAllocationOptions = {},
-): Effect.Effect =>
+ options: PortSelectionOptions = {},
+): Effect.Effect =>
Effect.suspend(() => {
const reservations = new Map();
- const reserve = Effect.gen(function* () {
- const reserved = options.reserved ?? new Set();
- const allocated = new Set();
- const partial: Partial> = {};
-
- const bindAndRegister = (
- field: PortField,
- acquisition: Effect.Effect,
- ) =>
- Effect.uninterruptibleMask(() =>
- Effect.gen(function* () {
- const result = yield* acquisition.pipe(
- Effect.mapError((error) => withPortField(field, error)),
- );
- reservations.set(field, result.server);
- yield* options.onBound?.(field, result) ?? Effect.void;
- return result;
- }),
- );
+ const claims = new Map();
+ const root = CLAIM_ROOT;
+ const reserve = (fs: FileSystem.FileSystem) =>
+ Effect.gen(function* () {
+ const reserved = options.reserved ?? new Set();
+ const allocated = new Set();
+ const partial: Partial> = {};
+
+ const bindAndRegister = (
+ field: PortField,
+ acquisition: Effect.Effect,
+ ) =>
+ Effect.uninterruptibleMask(() =>
+ Effect.gen(function* () {
+ const result = yield* acquisition.pipe(
+ Effect.mapError((error) =>
+ error instanceof PortClaimCollisionError
+ ? new PortAllocationError({
+ detail: `Port ${error.port} is not available`,
+ field,
+ port: error.port,
+ reason: "unavailable",
+ })
+ : withPortField(field, error),
+ ),
+ );
+ reservations.set(field, result.server);
+ return result;
+ }),
+ );
- for (const request of uniqueFields(requests)) {
- const exclude = new Set([...reserved, ...allocated]);
- const selection = request.selection;
- let bound: BoundPort;
-
- if (selection.kind === "exact") {
- if (exclude.has(selection.port)) {
- return yield* new PortAllocationError({
- detail: `Port ${selection.port} is not available`,
- field: request.field,
- port: selection.port,
- });
+ for (const request of uniqueFields(requests)) {
+ const selection = request.selection;
+ const exclude = new Set([
+ ...reserved,
+ ...allocated,
+ ...(selection.kind === "automatic" ? (selection.excluded ?? []) : []),
+ ]);
+ let bound: BoundPort;
+
+ if (selection.kind === "exact") {
+ if (
+ !Number.isInteger(selection.port) ||
+ selection.port < 1 ||
+ selection.port > 65_535
+ ) {
+ return yield* new PortAllocationError({
+ detail: `Invalid exact port ${selection.port}`,
+ field: request.field,
+ port: selection.port,
+ });
+ }
+ if (exclude.has(selection.port)) {
+ return yield* new PortAllocationError({
+ detail: `Port ${selection.port} is not available`,
+ field: request.field,
+ port: selection.port,
+ });
+ }
+ bound = yield* bindAndRegister(
+ request.field,
+ claimAndBind(request.field, selection.port, claims, fs, root),
+ );
+ } else if (
+ selection.preferred !== undefined &&
+ selection.preferred > 0 &&
+ !exclude.has(selection.preferred)
+ ) {
+ const preferred = selection.preferred;
+ bound = yield* bindAndRegister(
+ request.field,
+ claimAndBind(request.field, preferred, claims, fs, root).pipe(
+ Effect.catchTag("PortClaimCollisionError", () =>
+ reserveRandomPort(exclude, request.field, claims, fs, root).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ ),
+ ),
+ Effect.catchTag("PortAllocationError", (error) =>
+ error.reason === "unavailable"
+ ? reserveRandomPort(exclude, request.field, claims, fs, root).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ )
+ : Effect.fail(error),
+ ),
+ ),
+ );
+ } else {
+ bound = yield* bindAndRegister(
+ request.field,
+ reserveRandomPort(exclude, request.field, claims, fs, root).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ ),
+ );
}
- bound = yield* bindAndRegister(request.field, bindPort(selection.port));
- } else if (selection.preferred !== undefined && !exclude.has(selection.preferred)) {
- bound = yield* bindAndRegister(
- request.field,
- bindPort(selection.preferred).pipe(
- Effect.catchTag("PortAllocationError", () => reserveRandomPort(exclude)),
- ),
- );
- } else {
- bound = yield* bindAndRegister(request.field, reserveRandomPort(exclude));
+
+ allocated.add(bound.port);
+ partial[request.field] = bound.port;
}
- allocated.add(bound.port);
- partial[request.field] = bound.port;
- }
+ const ports = yield* decodePortSet(partial);
+ return makePortLease(ports, reservations, claims, fs, root);
+ });
- return makePortLease(Schema.decodeUnknownSync(PortSetSchema)(partial), reservations);
+ return Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ return yield* reserve(fs).pipe(
+ Effect.onError(() =>
+ Effect.all(
+ [
+ releaseReservations(reservations, [...reservations.keys()]),
+ releaseClaims(claims, [...claims.keys()], fs),
+ ],
+ { discard: true },
+ ),
+ ),
+ );
});
-
- return reserve.pipe(
- Effect.onError(() => releaseReservations(reservations, [...reservations.keys()])),
- );
});
diff --git a/packages/stack/src/PortAllocator.unit.test.ts b/packages/stack/src/PortAllocator.unit.test.ts
deleted file mode 100644
index ae5cea6753..0000000000
--- a/packages/stack/src/PortAllocator.unit.test.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { Cause, Effect, Exit, Schema } from "effect";
-import { PortSetSchema } from "./effect.ts";
-import { allocatePortSet, PortAllocationError } from "./PortAllocator.ts";
-import { DEFAULT_PORTS, type PortField } from "./PortCatalog.ts";
-
-const fakePortProbe = (
- options: {
- readonly unavailable?: ReadonlySet;
- readonly randomPorts?: readonly number[];
- } = {},
-) => {
- const unavailable = options.unavailable ?? new Set();
- const randomPorts =
- options.randomPorts ?? Array.from({ length: 100 }, (_, index) => 30001 + index);
- let randomIndex = 0;
-
- return {
- exact: (port: number) =>
- unavailable.has(port)
- ? Effect.fail(new PortAllocationError({ detail: `Port ${port} is not available`, port }))
- : Effect.succeed(port),
- random: (exclude: ReadonlySet) =>
- Effect.gen(function* () {
- while (randomIndex < randomPorts.length) {
- const port = randomPorts[randomIndex];
- randomIndex += 1;
- if (port === undefined) {
- continue;
- }
- if (!exclude.has(port) && !unavailable.has(port)) {
- return port;
- }
- }
-
- return yield* Effect.fail(
- new PortAllocationError({ detail: "No fake random ports available" }),
- );
- }),
- };
-};
-
-describe("allocatePortSet", () => {
- const requests = (fields: ReadonlyArray) =>
- fields.map((field) => ({ field, selection: { kind: "automatic" as const } }));
-
- it("all allocated ports are unique", async () => {
- const ports = await Effect.runPromise(
- allocatePortSet(
- requests(["apiPort", "dbPort", "authPort", "postgrestPort", "postgrestAdminPort"]),
- { probe: fakePortProbe() },
- ),
- );
- const values = Object.values(ports) as number[];
- const unique = new Set(values);
- expect(unique.size).toBe(values.length);
- for (const port of values) {
- expect(port).toBeGreaterThan(0);
- }
- });
-
- it("exports the partial allocated port-set schema", () => {
- expect(Schema.decodeUnknownSync(PortSetSchema)({ apiPort: 54321 })).toEqual({
- apiPort: 54321,
- });
- });
-
- it("reserved ports are skipped by later allocations", async () => {
- const a = await Effect.runPromise(
- allocatePortSet(requests(["apiPort", "dbPort"]), { probe: fakePortProbe() }),
- );
- const aPorts = new Set(Object.values(a) as number[]);
- const b = await Effect.runPromise(
- allocatePortSet(requests(["apiPort", "dbPort"]), {
- reserved: aPorts,
- probe: fakePortProbe(),
- }),
- );
- const bPorts = Object.values(b) as number[];
-
- for (const port of bPorts) {
- expect(aPorts.has(port)).toBe(false);
- }
- });
-
- it("identifies the exact field that collides with an earlier request", async () => {
- const exit = await Effect.runPromise(
- allocatePortSet(
- [
- { field: "apiPort", selection: { kind: "exact", port: 22_001 } },
- { field: "dbPort", selection: { kind: "exact", port: 22_001 } },
- ],
- { probe: fakePortProbe() },
- ).pipe(Effect.exit),
- );
-
- expect(Exit.isFailure(exit)).toBe(true);
- if (Exit.isFailure(exit)) {
- expect(Cause.squash(exit.cause)).toMatchObject({
- _tag: "PortAllocationError",
- field: "dbPort",
- port: 22_001,
- });
- }
- });
-
- it("explicit port is respected when available", async () => {
- const requestedApiPort = 21001;
- const requestedDbPort = 21002;
- const ports = await Effect.runPromise(
- allocatePortSet(
- [
- { field: "apiPort", selection: { kind: "exact", port: requestedApiPort } },
- { field: "dbPort", selection: { kind: "exact", port: requestedDbPort } },
- ],
- { probe: fakePortProbe() },
- ),
- );
- expect(ports.apiPort).toBe(requestedApiPort);
- expect(ports.dbPort).toBe(requestedDbPort);
- });
-
- it("preferred ports are reused when available", async () => {
- const apiPort = 21003;
- const dbPort = 21004;
- const studioPort = 21005;
- const ports = await Effect.runPromise(
- allocatePortSet(
- [
- { field: "apiPort", selection: { kind: "automatic", preferred: apiPort } },
- { field: "dbPort", selection: { kind: "automatic", preferred: dbPort } },
- { field: "studioPort", selection: { kind: "automatic", preferred: studioPort } },
- ],
- { probe: fakePortProbe() },
- ),
- );
-
- expect(ports.apiPort).toBe(apiPort);
- expect(ports.dbPort).toBe(dbPort);
- expect(ports.studioPort).toBe(studioPort);
- });
-
- it("preferred ports fall back to random ports when unavailable", async () => {
- const apiPort = 21006;
- const dbPort = 21007;
- const ports = await Effect.runPromise(
- allocatePortSet(
- [
- { field: "apiPort", selection: { kind: "automatic", preferred: apiPort } },
- { field: "dbPort", selection: { kind: "automatic", preferred: dbPort } },
- ],
- {
- probe: fakePortProbe({
- unavailable: new Set([apiPort]),
- randomPorts: Array.from({ length: 20 }, (_, index) => 31001 + index),
- }),
- },
- ),
- );
-
- expect(ports.apiPort).toBe(31001);
- expect(ports.dbPort).toBe(dbPort);
- });
-
- it("explicit ports cannot override reserved ownership", async () => {
- const exit = await Effect.runPromise(
- allocatePortSet([{ field: "apiPort", selection: { kind: "exact", port: 22001 } }], {
- reserved: new Set([22001]),
- }).pipe(Effect.exit),
- );
-
- expect(exit._tag).toBe("Failure");
- if (exit._tag === "Failure") {
- expect(JSON.stringify(exit.cause)).toContain("Port 22001 is not available");
- }
- });
-
- it("preferred ports skip reserved ownership and use random fallback", async () => {
- const ports = await Effect.runPromise(
- allocatePortSet(
- [
- { field: "apiPort", selection: { kind: "automatic", preferred: 23001 } },
- { field: "dbPort", selection: { kind: "automatic", preferred: DEFAULT_PORTS.dbPort } },
- ],
- {
- reserved: new Set([23001]),
- probe: fakePortProbe(),
- },
- ),
- );
-
- expect(ports.apiPort).not.toBe(23001);
- expect(ports.dbPort).toBe(DEFAULT_PORTS.dbPort);
- });
-});
diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts
index 548eaf95fe..b7e632cd2c 100644
--- a/packages/stack/src/RemoteStack.integration.test.ts
+++ b/packages/stack/src/RemoteStack.integration.test.ts
@@ -1,10 +1,20 @@
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose";
-import { Cause, Effect, Exit, Fiber, Layer, ManagedRuntime, Result, Stream } from "effect";
+import {
+ Cause,
+ Effect,
+ Exit,
+ Fiber,
+ Layer,
+ ManagedRuntime,
+ Predicate,
+ Result,
+ Stream,
+} from "effect";
import * as http from "node:http";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { DaemonServer } from "./DaemonServer.ts";
-import { StackBuildError, StackReadinessError } from "./errors.ts";
+import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts";
import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts";
import { RemoteStack } from "./RemoteStack.ts";
import { Stack, type EdgeRuntimeReloadConfig, type StackInfo } from "./Stack.ts";
@@ -85,6 +95,7 @@ function mockStack(
readonly waitReadyBuildReason?: "invalid_config" | "docker_not_running" | "asset_preparation";
readonly waitReadyTimeoutMs?: number;
readonly restartServiceReadyError?: string;
+ readonly notRunningPhase?: string;
} = {},
) {
let stopped = false;
@@ -107,54 +118,64 @@ function mockStack(
startService: (name: string) =>
name === "unknown"
? Effect.fail(new ServiceNotFoundError({ name }))
- : options.startServiceBuildError !== undefined
- ? Effect.fail(
- new StackBuildError({
- detail: options.startServiceBuildError,
- ...(options.startServiceBuildReason === undefined
- ? {}
- : { reason: options.startServiceBuildReason }),
- }),
- )
- : options.startServiceReadyError !== undefined
+ : options.notRunningPhase !== undefined
+ ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase }))
+ : options.startServiceBuildError !== undefined
? Effect.fail(
- new ServiceReadyError({
- name,
- reason: options.startServiceReadyError,
+ new StackBuildError({
+ detail: options.startServiceBuildError,
+ ...(options.startServiceBuildReason === undefined
+ ? {}
+ : { reason: options.startServiceBuildReason }),
}),
)
- : Effect.sync(() => {
- serviceCalls.push(`start:${name}`);
- }),
+ : options.startServiceReadyError !== undefined
+ ? Effect.fail(
+ new ServiceReadyError({
+ name,
+ reason: options.startServiceReadyError,
+ }),
+ )
+ : Effect.sync(() => {
+ serviceCalls.push(`start:${name}`);
+ }),
stopService: (name: string) =>
name === "unknown"
? Effect.fail(new ServiceNotFoundError({ name }))
- : Effect.sync(() => {
- serviceCalls.push(`stop:${name}`);
- }),
+ : options.notRunningPhase !== undefined
+ ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase }))
+ : Effect.sync(() => {
+ serviceCalls.push(`stop:${name}`);
+ }),
restartService: (name: string) =>
name === "unknown"
? Effect.fail(new ServiceNotFoundError({ name }))
- : options.restartServiceReadyError !== undefined
- ? Effect.fail(
- new ServiceReadyError({
- name,
- reason: options.restartServiceReadyError,
+ : options.notRunningPhase !== undefined
+ ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase }))
+ : options.restartServiceReadyError !== undefined
+ ? Effect.fail(
+ new ServiceReadyError({
+ name,
+ reason: options.restartServiceReadyError,
+ }),
+ )
+ : Effect.sync(() => {
+ serviceCalls.push(`restart:${name}`);
}),
- )
- : Effect.sync(() => {
- serviceCalls.push(`restart:${name}`);
- }),
reloadFunctions: (config) =>
- Effect.sync(() => {
- functionReloads.push(config ?? {});
- serviceCalls.push("reload-functions");
- }),
+ options.notRunningPhase !== undefined
+ ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase }))
+ : Effect.sync(() => {
+ functionReloads.push(config ?? {});
+ serviceCalls.push("reload-functions");
+ }),
reloadEdgeRuntime: (config) =>
- Effect.sync(() => {
- edgeRuntimeReloads.push(config);
- serviceCalls.push("reload-edge-runtime");
- }),
+ options.notRunningPhase !== undefined
+ ? Effect.fail(new StackNotRunningError({ phase: options.notRunningPhase }))
+ : Effect.sync(() => {
+ edgeRuntimeReloads.push(config);
+ serviceCalls.push("reload-edge-runtime");
+ }),
getState: (name: string) => {
const match = MOCK_STATES.find((s) => s.name === name);
return match ? Effect.succeed(match) : Effect.fail(new ServiceNotFoundError({ name }));
@@ -293,7 +314,7 @@ describe("RemoteStack integration", () => {
const daemon = await serverRuntime.runPromise(DaemonServer);
const addr = daemon.address;
- if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress");
+ if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress");
const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname;
const url = `http://${host}:${addr.port}`;
clientRuntime = ManagedRuntime.make(buildClientLayer(url));
@@ -337,7 +358,7 @@ describe("RemoteStack integration", () => {
const exit = await clientRuntime.runPromiseExit(
Effect.flatMap(Stack, (stack) => stack.getState("unknown")),
);
- expect(exit._tag).toBe("Failure");
+ expect(Exit.isFailure(exit)).toBe(true);
});
test("startService records the call", async () => {
@@ -351,7 +372,7 @@ describe("RemoteStack integration", () => {
const exit = await clientRuntime.runPromiseExit(
Effect.flatMap(Stack, (stack) => stack.startService("unknown")),
);
- expect(exit._tag).toBe("Failure");
+ expect(Exit.isFailure(exit)).toBe(true);
});
test("waitReady passes one validated finite override through the daemon", async () => {
@@ -369,7 +390,7 @@ describe("RemoteStack integration", () => {
const error = await clientRuntime.runPromise(
Effect.flatMap(Stack, (stack) => stack.waitReady("..")).pipe(Effect.flip),
);
- expect(error._tag).toBe("ServiceNotFoundError");
+ expect(Predicate.isTagged(error, "ServiceNotFoundError")).toBe(true);
expect(mock.serviceCalls).not.toContain("ready:all");
});
@@ -389,15 +410,15 @@ describe("RemoteStack integration", () => {
try {
const daemon = await failingServer.runPromise(DaemonServer);
const addr = daemon.address;
- if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress");
+ if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress");
const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname;
failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`));
const error = await failingClient.runPromise(
Effect.flatMap(Stack, (stack) => stack.waitReady("auth")).pipe(Effect.flip),
);
- expect(error._tag).toBe("StackReadinessError");
- if (error._tag === "StackReadinessError") {
+ expect(Predicate.isTagged(error, "StackReadinessError")).toBe(true);
+ if (Predicate.isTagged(error, "StackReadinessError")) {
expect(error.target).toBe("auth");
expect(error.timeoutMs).toBe(75);
}
@@ -537,31 +558,31 @@ describe("RemoteStack integration", () => {
try {
const daemon = await failingServer.runPromise(DaemonServer);
const addr = daemon.address;
- if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress");
+ if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress");
const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname;
failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`));
const startError = await failingClient.runPromise(
Effect.flatMap(Stack, (stack) => stack.startService("auth")).pipe(Effect.flip),
);
- expect(startError._tag).toBe("StackBuildError");
- if (startError._tag === "StackBuildError") {
+ expect(Predicate.isTagged(startError, "StackBuildError")).toBe(true);
+ if (Predicate.isTagged(startError, "StackBuildError")) {
expect(startError.reason).toBe("docker_not_running");
}
const readyError = await failingClient.runPromise(
Effect.flatMap(Stack, (stack) => stack.waitReady("auth")).pipe(Effect.flip),
);
- expect(readyError._tag).toBe("StackBuildError");
- if (readyError._tag === "StackBuildError") {
+ expect(Predicate.isTagged(readyError, "StackBuildError")).toBe(true);
+ if (Predicate.isTagged(readyError, "StackBuildError")) {
expect(readyError.reason).toBe("invalid_config");
}
const restartError = await failingClient.runPromise(
Effect.flatMap(Stack, (stack) => stack.restartService("auth")).pipe(Effect.flip),
);
- expect(restartError._tag).toBe("ServiceReadyError");
- if (restartError._tag === "ServiceReadyError") {
+ expect(Predicate.isTagged(restartError, "ServiceReadyError")).toBe(true);
+ if (Predicate.isTagged(restartError, "ServiceReadyError")) {
expect(restartError.reason).toBe("restart failed readiness");
}
} finally {
@@ -570,6 +591,39 @@ describe("RemoteStack integration", () => {
}
});
+ test("preserves StackNotRunningError across mutating daemon operations", async () => {
+ const failingMock = mockStack({ notRunningPhase: "stopped" });
+ const failingServer = ManagedRuntime.make(buildServerLayer(failingMock));
+ let failingClient: ManagedRuntime.ManagedRuntime | undefined;
+ try {
+ const daemon = await failingServer.runPromise(DaemonServer);
+ const addr = daemon.address;
+ if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress");
+ const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname;
+ failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`));
+
+ const operations = [
+ (stack: Stack["Service"]) => stack.startService("auth"),
+ (stack: Stack["Service"]) => stack.stopService("auth"),
+ (stack: Stack["Service"]) => stack.restartService("auth"),
+ (stack: Stack["Service"]) => stack.reloadFunctions(),
+ (stack: Stack["Service"]) =>
+ stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }),
+ ];
+ for (const operation of operations) {
+ const error = await failingClient.runPromise(
+ Effect.flatMap(Stack, operation).pipe(Effect.flip),
+ );
+ expect(error).toBeInstanceOf(StackNotRunningError);
+ expect(Predicate.isTagged(error, "StackNotRunningError")).toBe(true);
+ if (Predicate.isTagged(error, "StackNotRunningError")) expect(error.phase).toBe("stopped");
+ }
+ } finally {
+ await failingClient?.dispose();
+ await failingServer.dispose();
+ }
+ });
+
test("preserves ServiceReadyError from remote startService", async () => {
const failingMock = mockStack({ startServiceReadyError: "start failed readiness" });
const failingServer = ManagedRuntime.make(buildServerLayer(failingMock));
@@ -577,15 +631,15 @@ describe("RemoteStack integration", () => {
try {
const daemon = await failingServer.runPromise(DaemonServer);
const addr = daemon.address;
- if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress");
+ if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress");
const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname;
failingClient = ManagedRuntime.make(buildClientLayer(`http://${host}:${addr.port}`));
const error = await failingClient.runPromise(
Effect.flatMap(Stack, (stack) => stack.startService("auth")).pipe(Effect.flip),
);
- expect(error._tag).toBe("ServiceReadyError");
- if (error._tag === "ServiceReadyError") {
+ expect(Predicate.isTagged(error, "ServiceReadyError")).toBe(true);
+ if (Predicate.isTagged(error, "ServiceReadyError")) {
expect(error.reason).toBe("start failed readiness");
}
} finally {
@@ -627,8 +681,8 @@ describe("RemoteStack integration", () => {
);
expect(error).toBeInstanceOf(StackBuildError);
- expect(error._tag).toBe("StackBuildError");
- if (error._tag === "StackBuildError") {
+ expect(Predicate.isTagged(error, "StackBuildError")).toBe(true);
+ if (Predicate.isTagged(error, "StackBuildError")) {
expect(error.detail).toBe("Invalid Edge Functions reload payload");
}
});
@@ -686,7 +740,7 @@ describe("RemoteStack integration", () => {
try {
const daemon = await freshServer.runPromise(DaemonServer);
const addr = daemon.address;
- if (addr._tag !== "TcpAddress") throw new Error("Expected TcpAddress");
+ if (!Predicate.isTagged(addr, "TcpAddress")) throw new Error("Expected TcpAddress");
const host = addr.hostname === "0.0.0.0" ? "127.0.0.1" : addr.hostname;
const freshUrl = `http://${host}:${addr.port}`;
diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts
index e133f53823..7ee88e81e1 100644
--- a/packages/stack/src/RemoteStack.ts
+++ b/packages/stack/src/RemoteStack.ts
@@ -1,9 +1,9 @@
import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose";
-import { Effect, Layer, Schema, Stream } from "effect";
+import { Effect, Layer, Predicate, Schema, Stream } from "effect";
import * as Sse from "effect/unstable/encoding/Sse";
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
import { DaemonErrorResponseSchema } from "./DaemonProtocol.ts";
-import { StackBuildError, StackReadinessError } from "./errors.ts";
+import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts";
import { Stack, StackInfoSchema } from "./Stack.ts";
import { inheritReadyOptions } from "./StackConfig.ts";
import { StackServiceState, StackServiceStatusSchema } from "./StackServiceState.ts";
@@ -39,8 +39,6 @@ const StatusResponseSchema = Schema.Struct({
const StatusServiceEventSchema = Schema.fromJsonString(StatusServiceSchema);
const LogEntryEventSchema = Schema.fromJsonString(LogEntrySchema);
-const decodeStatusServiceEvent = Schema.decodeUnknownSync(StatusServiceEventSchema);
-const decodeLogEntryEvent = Schema.decodeUnknownSync(LogEntryEventSchema);
// ---------------------------------------------------------------------------
// Helpers
@@ -57,28 +55,62 @@ const publicServicePath = (name: string): Effect.Effect =>
+ Schema.decodeUnknownEffect(StatusServiceEventSchema)(data).pipe(
+ Effect.map(toServiceState),
+ Effect.mapError(
+ (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "protocol" }),
+ ),
+ );
+
+const decodeLogEntryEvent = (
+ endpoint: ControlEndpoint,
+ path: string,
+ data: string,
+): Effect.Effect =>
+ Schema.decodeUnknownEffect(LogEntryEventSchema)(data).pipe(
+ Effect.mapError(
+ (cause) => new HttpTransportClientError({ endpoint, path, cause, reason: "protocol" }),
+ ),
+ );
+
+function makeRequest(
+ endpoint: ControlEndpoint,
+ path: string,
+ init?: RequestInit,
+): Effect.Effect {
const url = `http://localhost${path}`;
const method = init?.method?.toUpperCase() ?? "GET";
switch (method) {
case "GET":
- return HttpClientRequest.get(url, { headers: requestHeaders(init) });
+ return Effect.succeed(HttpClientRequest.get(url, { headers: requestHeaders(init) }));
case "POST":
- return HttpClientRequest.post(url, { headers: requestHeaders(init) });
+ return Effect.succeed(HttpClientRequest.post(url, { headers: requestHeaders(init) }));
case "PUT":
- return HttpClientRequest.put(url, { headers: requestHeaders(init) });
+ return Effect.succeed(HttpClientRequest.put(url, { headers: requestHeaders(init) }));
case "PATCH":
- return HttpClientRequest.patch(url, { headers: requestHeaders(init) });
+ return Effect.succeed(HttpClientRequest.patch(url, { headers: requestHeaders(init) }));
case "DELETE":
- return HttpClientRequest.delete(url, { headers: requestHeaders(init) });
+ return Effect.succeed(HttpClientRequest.delete(url, { headers: requestHeaders(init) }));
case "HEAD":
- return HttpClientRequest.head(url, { headers: requestHeaders(init) });
+ return Effect.succeed(HttpClientRequest.head(url, { headers: requestHeaders(init) }));
case "OPTIONS":
- return HttpClientRequest.options(url, { headers: requestHeaders(init) });
+ return Effect.succeed(HttpClientRequest.options(url, { headers: requestHeaders(init) }));
case "TRACE":
- return HttpClientRequest.trace(url, { headers: requestHeaders(init) });
+ return Effect.succeed(HttpClientRequest.trace(url, { headers: requestHeaders(init) }));
default:
- throw new Error(`Unsupported HTTP method: ${method}`);
+ return Effect.fail(
+ new HttpTransportClientError({
+ endpoint,
+ path,
+ cause: `Unsupported HTTP method: ${method}`,
+ reason: "protocol",
+ }),
+ );
}
}
@@ -88,10 +120,11 @@ function httpFetch(endpoint: ControlEndpoint, path: string, init?: RequestInit)
}
function httpResponse(endpoint: ControlEndpoint, path: string, init?: RequestInit) {
- const request = makeRequest(path, init);
- return Effect.map(httpFetch(endpoint, path, init), (response) =>
- HttpClientResponse.fromWeb(request, response),
- );
+ return Effect.gen(function* () {
+ const request = yield* makeRequest(endpoint, path, init);
+ const response = yield* httpFetch(endpoint, path, init);
+ return HttpClientResponse.fromWeb(request, response);
+ });
}
/** Preserve daemon RPC identity when an HTTP status or body cannot be decoded. */
@@ -138,7 +171,11 @@ const failDaemonResponse = (
fallbackName: string,
): Effect.Effect<
never,
- ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError
+ | ServiceNotFoundError
+ | ServiceReadyError
+ | StackBuildError
+ | StackNotRunningError
+ | StackReadinessError
> =>
Effect.gen(function* () {
const body = yield* dieOnBodyDecodeError(
@@ -166,6 +203,8 @@ const failDaemonResponse = (
timeoutMs: body.timeoutMs ?? 0,
detail: body.error,
});
+ case "STACK_NOT_RUNNING":
+ return yield* new StackNotRunningError({ phase: body.phase ?? "unknown" });
}
});
@@ -177,6 +216,25 @@ const expectDaemonOk = (
): Effect.Effect<
void,
ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError
+> =>
+ response.status >= 200 && response.status < 300
+ ? Effect.void
+ : failDaemonResponse(endpoint, path, response, fallbackName).pipe(
+ Effect.catchTag("StackNotRunningError", (error) => Effect.die(error)),
+ );
+
+const expectMutatingDaemonOk = (
+ endpoint: ControlEndpoint,
+ path: string,
+ response: HttpClientResponse.HttpClientResponse,
+ fallbackName: string,
+): Effect.Effect<
+ void,
+ | ServiceNotFoundError
+ | ServiceReadyError
+ | StackBuildError
+ | StackNotRunningError
+ | StackReadinessError
> =>
response.status >= 200 && response.status < 300
? Effect.void
@@ -234,7 +292,11 @@ function encodeSearchParams(
}
/** Convert a ReadableStream SSE body into an Effect Stream of parsed events. */
-function sseStream(endpoint: ControlEndpoint, path: string, parse: (data: string) => A) {
+function sseStream(
+ endpoint: ControlEndpoint,
+ path: string,
+ parse: (data: string) => Effect.Effect,
+) {
return Stream.unwrap(
Effect.gen(function* () {
const controller = new AbortController();
@@ -258,10 +320,10 @@ function sseStream(endpoint: ControlEndpoint, path: string, parse: (data: str
}
// State shared across chunks — parser is stateful, accumulates partial events
- const collected: A[] = [];
+ const collected: string[] = [];
const parser = Sse.makeParser((event) => {
- if (event._tag === "Event") {
- collected.push(parse(event.data));
+ if (Predicate.isTagged(event, "Event")) {
+ collected.push(event.data);
}
});
@@ -271,15 +333,11 @@ function sseStream(endpoint: ControlEndpoint, path: string, parse: (data: str
new HttpTransportClientError({ endpoint, path, cause, reason: "transport" }),
}).pipe(
Stream.mapEffect((chunk: Uint8Array) =>
- Effect.try({
- try: () => {
- collected.length = 0;
- parser.feed(new TextDecoder().decode(chunk, { stream: true }));
- return Array.from(collected);
- },
- catch: (cause) =>
- new HttpTransportClientError({ endpoint, path, cause, reason: "protocol" }),
- }),
+ Effect.sync(() => {
+ collected.length = 0;
+ parser.feed(new TextDecoder().decode(chunk, { stream: true }));
+ return Array.from(collected);
+ }).pipe(Effect.flatMap((events) => Effect.forEach(events, parse))),
),
Stream.flatMap(Stream.fromIterable),
Stream.ensuring(Effect.sync(() => controller.abort())),
@@ -334,6 +392,9 @@ export const RemoteStack = {
Stream.provide(httpTransportClientLayer),
Stream.catchTag("HttpTransportClientError", (error) => Stream.die(error)),
);
+ const withLifecycleRequest = (
+ request: (signal: AbortSignal) => Effect.Effect,
+ ) => withHttpTransportClient(withAbortSignal(request));
return {
getInfo: () =>
@@ -342,10 +403,10 @@ export const RemoteStack = {
),
start: () =>
- withHttpTransportClient(
+ withLifecycleRequest((signal) =>
Effect.gen(function* () {
const path = "/start";
- const response = yield* httpResponse(endpoint, path, { method: "POST" });
+ const response = yield* httpResponse(endpoint, path, { method: "POST", signal });
yield* expectDaemonOk(endpoint, path, response, "stack").pipe(
Effect.catchTag("ServiceNotFoundError", (error) => Effect.die(error)),
);
@@ -353,10 +414,10 @@ export const RemoteStack = {
),
stop: () =>
- withHttpTransportClient(
+ withLifecycleRequest((signal) =>
Effect.gen(function* () {
const path = "/stop";
- const response = yield* httpResponse(endpoint, path, { method: "POST" });
+ const response = yield* httpResponse(endpoint, path, { method: "POST", signal });
yield* dieOnNonOkStatus(
endpoint,
path,
@@ -366,10 +427,10 @@ export const RemoteStack = {
),
dispose: () =>
- withHttpTransportClient(
+ withLifecycleRequest((signal) =>
Effect.gen(function* () {
const path = "/stop";
- const response = yield* httpResponse(endpoint, path, { method: "POST" });
+ const response = yield* httpResponse(endpoint, path, { method: "POST", signal });
yield* dieOnNonOkStatus(
endpoint,
path,
@@ -379,26 +440,28 @@ export const RemoteStack = {
),
startService: (name: string) =>
- withHttpTransportClient(
+ withLifecycleRequest((signal) =>
Effect.gen(function* () {
const servicePath = yield* publicServicePath(name);
const path = `/services/${servicePath}/start`;
const response = yield* httpResponse(endpoint, path, {
method: "POST",
+ signal,
});
- yield* expectDaemonOk(endpoint, path, response, name);
+ yield* expectMutatingDaemonOk(endpoint, path, response, name);
}),
),
stopService: (name: string) =>
- withHttpTransportClient(
+ withLifecycleRequest((signal) =>
Effect.gen(function* () {
const servicePath = yield* publicServicePath(name);
const path = `/services/${servicePath}/stop`;
const response = yield* httpResponse(endpoint, path, {
method: "POST",
+ signal,
});
- yield* expectDaemonOk(endpoint, path, response, name).pipe(
+ yield* expectMutatingDaemonOk(endpoint, path, response, name).pipe(
Effect.catchTag("ServiceReadyError", (error) => Effect.die(error)),
Effect.catchTag("StackReadinessError", (error) => Effect.die(error)),
);
@@ -406,40 +469,43 @@ export const RemoteStack = {
),
restartService: (name: string) =>
- withHttpTransportClient(
+ withLifecycleRequest((signal) =>
Effect.gen(function* () {
const servicePath = yield* publicServicePath(name);
const path = `/services/${servicePath}/restart`;
const response = yield* httpResponse(endpoint, path, {
method: "POST",
+ signal,
});
- yield* expectDaemonOk(endpoint, path, response, name);
+ yield* expectMutatingDaemonOk(endpoint, path, response, name);
}),
),
reloadFunctions: (opts) =>
- withHttpTransportClient(
+ withLifecycleRequest((signal) =>
Effect.gen(function* () {
const path = "/functions/reload";
const response = yield* httpResponse(endpoint, path, {
method: "POST",
+ signal,
headers: { "content-type": "application/json" },
body: JSON.stringify(opts ?? {}),
});
- yield* expectDaemonOk(endpoint, path, response, "edge-runtime");
+ yield* expectMutatingDaemonOk(endpoint, path, response, "edge-runtime");
}),
),
reloadEdgeRuntime: (opts) =>
- withHttpTransportClient(
+ withLifecycleRequest((signal) =>
Effect.gen(function* () {
const path = "/edge-runtime/reload";
const response = yield* httpResponse(endpoint, path, {
method: "POST",
+ signal,
headers: { "content-type": "application/json" },
body: JSON.stringify(opts),
});
- yield* expectDaemonOk(endpoint, path, response, "edge-runtime");
+ yield* expectMutatingDaemonOk(endpoint, path, response, "edge-runtime");
}),
),
@@ -471,20 +537,18 @@ export const RemoteStack = {
return yield* new ServiceNotFoundError({ name });
}
return withHttpTransportClientStream(
- sseStream(endpoint, "/status/stream", (data) => {
- const raw = decodeStatusServiceEvent(data);
- return toServiceState(raw);
- }).pipe(Stream.filter((s) => s.name === name)),
+ sseStream(endpoint, "/status/stream", (data) =>
+ decodeStatusServiceEvent(endpoint, "/status/stream", data),
+ ).pipe(Stream.filter((s) => s.name === name)),
);
}),
),
allStateChanges: () =>
withHttpTransportClientStream(
- sseStream(endpoint, "/status/stream", (data) => {
- const raw = decodeStatusServiceEvent(data);
- return toServiceState(raw);
- }),
+ sseStream(endpoint, "/status/stream", (data) =>
+ decodeStatusServiceEvent(endpoint, "/status/stream", data),
+ ),
),
waitReady: (name, opts) =>
@@ -525,14 +589,16 @@ export const RemoteStack = {
subscribeLogs: (name: string) =>
withHttpTransportClientStream(
sseStream(endpoint, `/logs/${encodeURIComponent(name)}`, (data) =>
- decodeLogEntryEvent(data),
+ decodeLogEntryEvent(endpoint, `/logs/${encodeURIComponent(name)}`, data),
),
),
subscribeAllLogs: (services) => {
const query = encodeSearchParams({ service: services });
return withHttpTransportClientStream(
- sseStream(endpoint, `/logs${query}`, (data) => decodeLogEntryEvent(data)),
+ sseStream(endpoint, `/logs${query}`, (data) =>
+ decodeLogEntryEvent(endpoint, `/logs${query}`, data),
+ ),
);
},
diff --git a/packages/stack/src/ServiceActivation.ts b/packages/stack/src/ServiceActivation.ts
index f61281abf4..78b45c0a2f 100644
--- a/packages/stack/src/ServiceActivation.ts
+++ b/packages/stack/src/ServiceActivation.ts
@@ -6,9 +6,12 @@ import { stackServiceStartupBudgetSeconds } from "./services/health-budgets.ts";
import { SERVICE_NAMES, serviceMetadata } from "./ServiceCatalog.ts";
import type { ServiceName } from "./ServiceName.ts";
import type { ReadinessPolicy } from "./StackConfig.ts";
+import type { ServicePolicyManifest } from "./StackConfig.ts";
-export const eagerServices = (enabled: ReadonlyArray): ReadonlyArray =>
- enabled.filter((service) => serviceMetadata(service).activation.startup === "eager");
+export const eagerServices = (
+ enabled: ReadonlyArray,
+ policies: ServicePolicyManifest,
+): ReadonlyArray => enabled.filter((service) => policies[service] === "eager");
export const activationTargetsForService = (
enabledServices: ReadonlyArray,
diff --git a/packages/stack/src/ServiceActivation.unit.test.ts b/packages/stack/src/ServiceActivation.unit.test.ts
index 0f28d4d6f2..531e155d26 100644
--- a/packages/stack/src/ServiceActivation.unit.test.ts
+++ b/packages/stack/src/ServiceActivation.unit.test.ts
@@ -7,6 +7,7 @@ import {
lifecycleTargetsForService,
} from "./ServiceActivation.ts";
import { SERVICE_CATALOG, SERVICE_NAMES } from "./ServiceCatalog.ts";
+import { DEFAULT_SERVICE_POLICIES } from "./ServiceCatalog.ts";
describe("service activation", () => {
it("defines an access policy for every stack service", () => {
@@ -14,7 +15,7 @@ describe("service activation", () => {
});
it("starts direct endpoints eagerly", () => {
- expect(eagerServices(SERVICE_NAMES)).toEqual([
+ expect(eagerServices(SERVICE_NAMES, DEFAULT_SERVICE_POLICIES)).toEqual([
"postgres",
"realtime",
"mailpit",
diff --git a/packages/stack/src/ServiceCatalog.ts b/packages/stack/src/ServiceCatalog.ts
index fb8c5ea740..fb3a3a5408 100644
--- a/packages/stack/src/ServiceCatalog.ts
+++ b/packages/stack/src/ServiceCatalog.ts
@@ -1,25 +1,24 @@
import { Record } from "effect";
-import {
- authAssetName,
- edgeRuntimeAssetName,
- postgresAssetName,
- postgrestAssetName,
- type PlatformInfo,
-} from "./Platform.ts";
+import { nativeTargetForPlatform, type NativeTarget, type PlatformInfo } from "./Platform.ts";
import type { PortField } from "./PortCatalog.ts";
import type { ServiceName } from "./ServiceName.ts";
-type ArtifactOwnership = "supabase" | "upstream";
type ServiceRuntimeSupport = "native-preferred" | "docker-only";
-export type ArchiveFormat = "tar.gz" | "tar.xz" | "zip";
+type ArchiveFormat = "tar.zst";
+export type ServicePreparationPolicy = "off" | "lazy" | "eager";
export interface NativeReleaseArtifact {
+ readonly service: ServiceName;
+ readonly version: string;
readonly provider: string;
readonly assetName: string;
+ readonly releaseTag: string;
+ readonly target: NativeTarget;
readonly archive: ArchiveFormat;
readonly downloadUrl: string;
- readonly checksumUrl: string | null;
- readonly stripComponents: boolean;
+ readonly manifestUrl: string;
+ readonly checksumUrl: string;
+ readonly requiredRuntimePaths: ReadonlyArray;
}
interface NativeReleaseSource {
@@ -28,7 +27,6 @@ interface NativeReleaseSource {
}
interface DockerImageSource {
- readonly ownership: ArtifactOwnership;
readonly repository: string;
readonly tagPrefix?: string;
}
@@ -39,14 +37,20 @@ interface ServiceArtifactDefinition {
}
interface ServiceActivationPolicy {
- /** Whether the public service must already be running when lazy startup completes. */
- readonly startup: "eager" | "lazy";
/** Other public services required when this service is activated. */
readonly activates: ReadonlyArray;
/** Private companions whose lifecycle is exclusively owned by this service. */
readonly owns: ReadonlyArray;
}
+export interface ServicePreparationMetadata {
+ /** Policies supported by the service's runtime/resource implementation. */
+ readonly supported: ReadonlyArray