Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1897f6b
feat(stack): add managed stack persistence
jgoux Aug 11, 2026
bff450a
fix(stack): harden managed persistence
jgoux Aug 11, 2026
a08817c
fix(stack): make managed recovery race-safe
jgoux Aug 11, 2026
aa343ce
fix(stack): scope managed recovery
jgoux Aug 11, 2026
ec4c93f
chore(stack): tighten managed recovery contract
jgoux Aug 11, 2026
6f9b2e6
fix(stack): apply review triage fixes and register managed error tele…
jgoux Aug 12, 2026
96958bb
fix(stack): route managed errors to telemetry and normalize managed i…
jgoux Aug 12, 2026
48ef6b3
fix(stack): harden managed layer per deep review and prune dead surface
jgoux Aug 12, 2026
0b2aaa7
fix(stack): close managed recovery and parity gaps from delta review
jgoux Aug 12, 2026
81b719e
fix(stack): tolerate raced delete completion and require explicit sta…
jgoux Aug 12, 2026
908fc7e
fix(stack): restrict managed state permissions to the owning user
jgoux Aug 12, 2026
e33bc31
fix(stack): create the managed registry with owner-only permissions a…
jgoux Aug 12, 2026
348e0a3
refactor(stack): model managed errors as tagged errors
jgoux Aug 12, 2026
cc5d1ef
refactor(stack): make the managed core Effect-native
jgoux Aug 12, 2026
29397ef
test(stack): exercise the managed Effect surface and document the arc…
jgoux Aug 12, 2026
c39b3f0
refactor(stack): acquire the managed promise edge asynchronously
jgoux Aug 12, 2026
94957f6
fix(stack): restore transactional atomicity and interruption safety i…
jgoux Aug 12, 2026
b060d05
fix(stack): reject duplicate managed port keys with a coded error
jgoux Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/cli/src/shared/config/supabase-home.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,9 +10,12 @@ import { join } from "node:path";
* This is the single source of truth for the `SUPABASE_HOME` contract in the
* TypeScript CLI. It is a pure function: callers pass their own environment and
* home directory so it stays trivially testable and free of global state. The
* legacy and next shells both resolve through it; libraries such as
* `@supabase/stack` never read `SUPABASE_HOME` themselves and instead receive
* the resolved path from the CLI.
* legacy and next shells both resolve through it, and every CLI call into
* `@supabase/stack` passes the root resolved here explicitly, so this stays the
* authoritative resolution for anything the CLI drives. Library-side fallbacks
* do exist for non-CLI embedders — the managed layer's `resolveManagedStateRoot`
* reads `SUPABASE_HOME` itself when no root is supplied (CLI-2106) — but the
* CLI never relies on them.
*/
export const resolveSupabaseHome = (
env: Readonly<Record<string, string | undefined>>,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,15 @@ declare global {
readonly glob: (patterns: ReadonlyArray<string>) => Record<string, () => Promise<unknown>>;
}
}
import { MANAGED_ERROR_CODES, MANAGED_ERROR_TAG_BY_CODE } from "@supabase/stack/managed-model";
import {
CliErrorCategory,
CliErrorKind,
CliSuggestionType,
ErrorActionabilityFingerprintId,
ErrorActionabilityId,
isClassifiedExternalErrorTag,
isClassifiedManagedErrorCode,
} from "./error-actionability.ts";

/**
Expand DownExpand Up@@ -195,6 +197,54 @@ describe("workspace package error tags have external adapters", () => {
}
});

// Managed failures are tagged errors that also declare a stable `code`, and the
// CLI's dispatch table is generated from the package's tag/code map. The
// generic scan above already requires an adapter for each tag; this guard is
// what keeps the two halves of the contract joined — the (class, tag, code)
// triples in the model must agree with the exported map, the code list, and the
// code-keyed classification table.
const MANAGED_TAGGED_CLASS_PATTERN =
/class\s+([A-Za-z0-9_]+)\s+extends\s+Data\.TaggedError\(\s*"([A-Za-z0-9_]+)",?\s*\)[\s\S]*?readonly\s+code\s*=\s*"([A-Z0-9_]+)"/g;

describe("managed registry error codes are classified", () => {
it("packages/stack/src/managed/model.ts", () => {
const modelPath = resolve(repoRoot, "packages/stack/src/managed/model.ts");
const matches = [...readFileSync(modelPath, "utf8").matchAll(MANAGED_TAGGED_CLASS_PATTERN)];
// One match per declared code: a class written in a shape this regex cannot
// see would otherwise pass vacuously instead of failing loudly.
expect(matches.length).toBe(MANAGED_ERROR_CODES.length);
const declaredCodes = new Set<string>(MANAGED_ERROR_CODES);
const scannedCodes = new Set<string>();
for (const match of matches) {
const className = match[1] ?? "";
const tag = match[2] ?? "";
const code = match[3] ?? "";
scannedCodes.add(code);
expect(tag, `${className} is tagged "${tag}" rather than its own export name`).toBe(
className,
);
expect(
declaredCodes.has(code),
`${className}'s code "${code}" is missing from MANAGED_ERROR_CODES`,
).toBe(true);
expect(
Reflect.get(MANAGED_ERROR_TAG_BY_CODE, code),
`MANAGED_ERROR_TAG_BY_CODE does not map "${code}" to ${className}`,
).toBe(tag);
expect(
isClassifiedManagedErrorCode(code),
`${className} ("${code}") has no entry in managedActionabilityByCode in error-actionability.ts`,
).toBe(true);
expect(
isClassifiedExternalErrorTag(tag),
`${className} ("${tag}") has no generated entry in externalActionabilityByTag in error-actionability.ts`,
).toBe(true);
}
// Every declared code is backed by a class, not just the other way round.
expect([...scannedCodes].sort()).toEqual([...declaredCodes].sort());
});
});

describe("Effect CLI parser errors have exhaustive handling", () => {
it("covers every exported parser error class", () => {
const tags = new Set<string>();
Expand Down
147 changes: 147 additions & 0 deletions apps/cli/src/shared/telemetry/error-actionability.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
import {
MANAGED_ERROR_CODES,
MANAGED_ERROR_TAG_BY_CODE,
type ManagedErrorCode,
} from "@supabase/stack/managed-model";
import { Cause, Option } from "effect";
import type { CliError as EffectCliError } from "effect/unstable/cli";

Expand DownExpand Up@@ -102,6 +107,20 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [
"invalid_url",
"internal_build",
"invalid_config",
"managed_identity",
"managed_identity_conflict",
"managed_initialization",
"managed_operation_in_progress",
"managed_operation_ownership",
"managed_owner_pid",
"managed_pending_update",
"managed_port",
"managed_port_change",
"managed_port_duplicate_key",
"managed_publication_timeout",
"managed_recovery",
"managed_stack_name",
"managed_stack_not_stopped",
"network",
"not_found",
"plan_limit",
Expand DownExpand Up@@ -755,8 +774,128 @@ const effectCliActionabilityByTag = {
UnrecognizedOption: () => actionability.invalidInput,
} satisfies Record<EffectCliAdapterTag, ErrorActionabilityAdapter>;

/**
* `@supabase/stack` managed registry failures, keyed by the stable `code`
* literal each class declares. `code` is the package's wire-level contract: it
* survives the identifier minification of release builds, and Node/Bun callers
* outside an Effect runtime branch on it. Dispatch, however, goes through
* `_tag` like every other external error — {@link managedActionabilityByTag}
* projects this table onto the tags via the package's own tag/code map.
*
* Keyed by the package's exported {@link ManagedErrorCode} union, so the table
* is exhaustive by construction: a new managed failure cannot be added in
* `@supabase/stack` without being classified here.
*/
const managedActionabilityByCode: Record<ManagedErrorCode, CliErrorActionabilityDeclaration> = {
INVALID_MANAGED_IDENTITY: {
...actionability.invalidInput,
fingerprint_suffix: "managed_identity",
},
DUPLICATE_MANAGED_IDENTITY: {
...actionability.invalidConfig,
fingerprint_suffix: "managed_identity_conflict",
},
MANAGED_INVALID_STACK_NAME: {
...actionability.invalidInput,
fingerprint_suffix: "managed_stack_name",
},
UNSUPPORTED_MANAGED_REGISTRY_VERSION: {
...actionability.invalidConfig,
fingerprint_suffix: "invalid_config",
},
MANAGED_STACK_NOT_FOUND: { ...actionability.invalidInput, fingerprint_suffix: "not_found" },
// Another caller owns the stack right now; the remediation is to settle that
// operation before retrying.
MANAGED_OPERATION_IN_PROGRESS: {
...actionability.stopStack,
fingerprint_suffix: "managed_operation_in_progress",
},
MANAGED_OPERATION_OWNERSHIP_MISMATCH: {
...actionability.stopStack,
fingerprint_suffix: "managed_operation_ownership",
},
MANAGED_STACK_PUBLICATION_TIMEOUT: {
...actionability.stopStack,
fingerprint_suffix: "managed_publication_timeout",
},
MANAGED_OPERATION_REQUIRES_RECONCILIATION: {
...actionability.stopStack,
fingerprint_suffix: "managed_recovery",
},
MANAGED_STACK_NOT_STOPPED: {
...actionability.stopStack,
fingerprint_suffix: "managed_stack_not_stopped",
},
MANAGED_RUNNING_STACK_PORT_CHANGE: {
...actionability.stopStack,
fingerprint_suffix: "managed_port_change",
},
// The operation owner pid comes from the CLI process itself, never from user
// input, so a rejected pid is a broken internal invariant.
MANAGED_INVALID_OWNER_PID: {
...actionability.impossibleState,
fingerprint_suffix: "managed_owner_pid",
},
// Only internal misuse of the repository can call `updateStack` on a still
// unpublished (pending) row; nothing a user does reaches this.
MANAGED_PENDING_STACK_UPDATE: {
...actionability.impossibleState,
fingerprint_suffix: "managed_pending_update",
},
MANAGED_PORT_ALREADY_RESERVED: {
...actionability.invalidConfig,
fingerprint_suffix: "port_conflict",
},
// The port number itself is unusable (fractional or outside 1-65535), which
// is the user's own configured value rather than a conflict with a peer.
MANAGED_INVALID_PORT: { ...actionability.invalidConfig, fingerprint_suffix: "managed_port" },
// Two of the user's own port assignments name the same key, which is the
// user's configured value rather than a conflict with another stack.
MANAGED_DUPLICATE_PORT_KEY: {
...actionability.invalidConfig,
fingerprint_suffix: "managed_port_duplicate_key",
},
// The registry derives every stack root itself, so a path that fails the
// containment check means the CLI passed a rejected argument.
UNSAFE_MANAGED_STACK_PATH: {
...actionability.impossibleState,
fingerprint_suffix: "bad_argument",
},
MANAGED_STACK_INITIALIZATION_FAILED: {
...actionability.startStack,
fingerprint_suffix: "managed_initialization",
},
};

/**
* The managed table above, re-keyed by the `_tag` of the class that declares
* each code. Generated from `@supabase/stack`'s own tag/code map so the
* eighteen managed tags are classified without restating a single verdict:
* {@link managedActionabilityByCode} stays the one place a managed failure is
* classified, and a tag/code pair the package renames cannot silently fall
* through to `unknown`.
*/
const managedActionabilityByTag: Record<string, ErrorActionabilityAdapter> = Object.fromEntries(
MANAGED_ERROR_CODES.map((code) => {
const declaration = managedActionabilityByCode[code];
return [MANAGED_ERROR_TAG_BY_CODE[code], () => declaration];
}),
);

/**
* Whether a `@supabase/stack` managed error code has a classification in
* {@link managedActionabilityByCode}. Used by the coverage test to keep the
* table exhaustive against the managed classes; the tags themselves are checked
* through {@link isClassifiedExternalErrorTag}, which the generated entries
* satisfy.
*/
export function isClassifiedManagedErrorCode(code: string): boolean {
return Object.hasOwn(managedActionabilityByCode, code);
}

const externalActionabilityByTag: Record<string, ErrorActionabilityAdapter> = {
...effectCliActionabilityByTag,
...managedActionabilityByTag,

// effect PlatformError — OS/filesystem operations. `reason` is
// `BadArgument | SystemError`; BadArgument means the CLI itself passed a
Expand DownExpand Up@@ -1030,6 +1169,14 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability {
}
}

// ManagedStackInitializationError is only a wrapper: the real provisioning
// failure (a Docker pull, a config parse, ...) is preserved in `cause`, and
// the generic initialization verdict would hide the actionable one.
if (isErrorRecord(error) && tag === "ManagedStackInitializationError") {
const cause = classifiableCause(error);
if (cause !== undefined) return classifyAtDepth(cause, depth + 1);
}

if (tag !== undefined && isErrorRecord(error)) {
// Own-property lookup: a sanitized tag like "constructor" must not pick
// up Object.prototype members as adapters.
Expand Down
131 changes: 131 additions & 0 deletions apps/cli/src/shared/telemetry/error-actionability.unit.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -598,6 +598,137 @@ describe("classifyCliErrorActionability", () => {
expect(classifyCliErrorActionability(other).error_kind).toBe("unknown");
});

// Managed registry errors are tagged errors that also declare a stable
// `code`: the tag routes them to an adapter generated from the package's
// tag/code map, and the code keys the verdict that adapter resolves.
// `managed-model.unit.test.ts` in `@supabase/stack` pins the real classes to
// the (tag, code) pairs reproduced here.
it.each([
[
"InvalidManagedIdentityError",
"INVALID_MANAGED_IDENTITY",
"managed_identity",
"invalid_input",
],
["InvalidManagedPortError", "MANAGED_INVALID_PORT", "managed_port", "invalid_config"],
[
"UnsafeManagedStackPathError",
"UNSAFE_MANAGED_STACK_PATH",
"bad_argument",
"impossible_state",
],
// The operation pid and the pending-update guard are both internal
// invariants: the CLI supplies the pid, and only repository misuse can
// update an unpublished row.
[
"InvalidManagedOwnerPidError",
"MANAGED_INVALID_OWNER_PID",
"managed_owner_pid",
"impossible_state",
],
[
"ManagedPendingStackUpdateError",
"MANAGED_PENDING_STACK_UPDATE",
"managed_pending_update",
"impossible_state",
],
// Each of these five used to share a suffix with an unrelated failure, so
// distinct defects grouped together as repeats (CLI-2106).
[
"ManagedOperationInProgressError",
"MANAGED_OPERATION_IN_PROGRESS",
"managed_operation_in_progress",
"invalid_config",
],
[
"ManagedOperationOwnershipError",
"MANAGED_OPERATION_OWNERSHIP_MISMATCH",
"managed_operation_ownership",
"invalid_config",
],
[
"ManagedStackPublicationTimeoutError",
"MANAGED_STACK_PUBLICATION_TIMEOUT",
"managed_publication_timeout",
"invalid_config",
],
[
"ManagedStackNotStoppedError",
"MANAGED_STACK_NOT_STOPPED",
"managed_stack_not_stopped",
"invalid_config",
],
[
"ManagedRunningStackPortChangeError",
"MANAGED_RUNNING_STACK_PORT_CHANGE",
"managed_port_change",
"invalid_config",
],
])("classifies %s through its generated tag adapter", (tag, code, suffix, category) => {
const error = new Error("managed registry failure");
error.name = tag;
Object.defineProperty(error, "_tag", { value: tag });
Object.defineProperty(error, "code", { value: code });
const result = classifyCliErrorActionability(error);
expect(result.error_category).toBe(category);
expect(result.error_fingerprint).toBe(`tag:${tag}:${suffix}`);
});

it("leaves an unregistered managed-shaped failure unclassified", () => {
const unrecognized = new Error("managed failure");
unrecognized.name = "ManagedFutureError";
Object.defineProperty(unrecognized, "_tag", { value: "ManagedFutureError" });
Object.defineProperty(unrecognized, "code", { value: "MANAGED_FUTURE_FAILURE" });
expect(classifyCliErrorActionability(unrecognized).error_kind).toBe("unknown");
});

// ManagedStackInitializationError wraps the real provisioning failure in
// `cause`; reporting the generic initialization verdict would lose it.
it("classifies the provisioning cause of a managed initialization failure", () => {
const wrapped = new Error("managed stack initialization failed");
wrapped.name = "ManagedStackInitializationError";
Object.defineProperty(wrapped, "_tag", { value: "ManagedStackInitializationError" });
Object.defineProperty(wrapped, "code", { value: "MANAGED_STACK_INITIALIZATION_FAILED" });
Object.defineProperty(wrapped, "cause", {
value: { _tag: "DockerPullError", image: "postgres", daemonDown: true },
});
expect(classifyCliErrorActionability(wrapped)).toEqual(
classifyCliErrorActionability({
_tag: "DockerPullError",
image: "postgres",
daemonDown: true,
}),
);
});

it("falls back to the managed initialization verdict for an opaque cause", () => {
const wrapped = new Error("managed stack initialization failed");
wrapped.name = "ManagedStackInitializationError";
Object.defineProperty(wrapped, "_tag", { value: "ManagedStackInitializationError" });
Object.defineProperty(wrapped, "code", { value: "MANAGED_STACK_INITIALIZATION_FAILED" });
Object.defineProperty(wrapped, "cause", { value: { detail: "opaque" } });
const result = classifyCliErrorActionability(wrapped);
expect(result.error_kind).toBe("user_actionable");
expect(result.suggested_command).toBe("supabase start");
expect(result.error_fingerprint).toBe(
"tag:ManagedStackInitializationError:managed_initialization",
);
});

it("classifies a managed cause nested inside a stack wrapper", () => {
const managed = new Error("port already reserved");
managed.name = "ManagedPortReservationError";
Object.defineProperty(managed, "_tag", { value: "ManagedPortReservationError" });
Object.defineProperty(managed, "code", { value: "MANAGED_PORT_ALREADY_RESERVED" });
const result = classifyCliErrorActionability({
_tag: "StackBuildError",
detail: "x",
cause: managed,
});
expect(result.error_category).toBe("invalid_config");
expect(result.error_fingerprint).toBe("tag:ManagedPortReservationError:port_conflict");
});

it("classifies the preserved tagged cause of a StackError wrapper", () => {
const wrapped = new Error("stack failure");
wrapped.name = "StackError";
Expand Down
Loading
Loading