Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions apps/cli/src/commands/config/diff/diff.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,29 @@ describe("config diff integration", () => {
}).pipe(Effect.provide(layer));
});

it.live("a disabled Data API diffs clean against a declared enabled = false", () => {
const { layer, out, processControl } = setup({
toml: 'project_id = "test"\n[api]\nenabled = false\n',
v2: {
status: 200,
body: v2Response({
attributes: (attributes) => ({
...attributes,
api: {
...(attributes["api"] as Record<string, unknown>),
db_schema: "pg_pgrst_no_exposed_schemas",
},
}),
}),
},
});
return Effect.gen(function* () {
yield* configDiff({ ...noFlags, exitCode: true });
expect(out.stdoutText).toContain("No config differences found.");
expect(processControl.exitCode).toBeUndefined();
}).pipe(Effect.provide(layer));
});

it.live("a configured provider still diffs clean", () => {
const { layer, out } = setup({
toml: [
Expand Down
25 changes: 25 additions & 0 deletions apps/cli/src/commands/config/pull/pull.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,31 @@ describe("config pull integration", () => {
}).pipe(Effect.provide(layer));
});

it.live("a disabled Data API leaves a declared enabled = false untouched", () => {
const before = 'project_id = "test"\n[api]\nenabled = false\n';
const { layer, out } = setup({
toml: before,
yes: true,
v2: {
status: 200,
body: v2Response({
attributes: (attributes) => ({
...attributes,
api: {
...(attributes["api"] as Record<string, unknown>),
db_schema: "pg_pgrst_no_exposed_schemas",
},
}),
}),
},
});
return Effect.gen(function* () {
yield* configPull(noFlags);
expect(yield* readConfig).toBe(before);
expect(out.stdoutText).toContain("No config differences found.");
}).pipe(Effect.provide(layer));
});

it.live("declining the confirmation prompt leaves the file unchanged", () => {
const before = 'project_id = "test"\n[api]\nmax_rows = 500\n';
const { layer, out } = setup({
Expand Down
51 changes: 51 additions & 0 deletions apps/cli/src/commands/config/push/push.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,57 @@ max_rows = 1000
}).pipe(Effect.provide(layer));
});

it.live("reports up-to-date when the project already has the Data API disabled", () => {
const { layer, out, api } = setup({
toml: 'project_id = "test"\n[api]\nenabled = false\n',
yes: true,
v2: {
status: 200,
body: v2Response({
attributes: (a) => ({
...a,
api: {
...(a["api"] as Record<string, unknown>),
db_schema: "pg_pgrst_no_exposed_schemas",
},
}),
}),
},
});
return Effect.gen(function* () {
yield* configPush({ projectRef: Option.none() });
expect(out.stderrText).toContain("Remote API config is up to date.");
expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe(
false,
);
}).pipe(Effect.provide(layer));
});
Comment thread
7ttp marked this conversation as resolved.

it.live("re-enables a disabled Data API with the default schemas", () => {
const { layer, api } = setup({
toml: 'project_id = "test"\n[api]\nenabled = true\n',
yes: true,
v2: {
status: 200,
body: v2Response({
attributes: (a) => ({
...a,
api: {
...(a["api"] as Record<string, unknown>),
db_schema: "pg_pgrst_no_exposed_schemas",
},
}),
}),
},
});
return Effect.gen(function* () {
yield* configPush({ projectRef: Option.none() });
const update = api.requests.find((r) => r.method === "PATCH" && r.url.includes("/postgrest"));
expect(update).toBeDefined();
expect(update?.body).toMatchObject({ db_schema: "public,graphql_public" });
}).pipe(Effect.provide(layer));
});

it.live("stops a service when the user declines the prompt (exit 0)", () => {
const { layer, out, api } = setup({
toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0021-projectconfig-convergence-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,9 @@ inert/unrepresentable server-side when the container's own toggle is off, indepe
actor's write path — rather than "what legacy push happened to send"**:

- `api.{schemas,extra_search_path,max_rows}`: `api.enabled` isn't an independent wire field at all —
the registry derives it from `db_schema.length > 0`, the same fact `schemas` itself carries, and
the API arm already row-gates all three siblings on that same check independently. Symmetry with
something the API arm enforces on its own, not push imitation.
the registry derives it from `db_schema` via `remoteDataApiDisabled`, the same fact `schemas`
itself carries, and the API arm already row-gates all three siblings on that same check
independently. Symmetry with something the API arm enforces on its own, not push imitation.
- `db.network_restrictions.{allowed_cidrs,allowed_cidrs_v6}`: the flag's own schema description is
"Enable **management** of network restrictions," not "enable network restrictions" — a
deliberate, actor-independent opt-out with no v2 API field at all (the one entry with no API-arm
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ This ADR was first accepted with a self-contained translation table inside `conf
- **Three-way classification per comparable path**, driven by _declared_ presence (the raw pre-decode document), which a decoded config cannot recover: `update` (declared + reported, values differ), `remote_only` (reported while undeclared — or while the feature it belongs to is switched off and no actor's write path can express the declared state — and differing from the suppression baseline), `local_only` (a declared local projection value the response did not account for: parsed-but-never-pushed attributes and permission-truncated responses). The local operand follows ADR 0018: the branch's merged effective config when the target ref matches a `[remotes.*]` block's `project_id`, the base config otherwise.
- **`remote_only` suppression baseline**: the default config's own convergence projection, falling back — for push-gated containers the projection is silent on — to the raw default config's value (`db.network_restrictions`' allow-all default IS the platform's unconfigured state), then to the registry row's declared `unconfiguredValue` (the platform's own report of an unconfigured feature — GoTrue's `sessions_timebox: 0` canonicalizes to the STRING `"0s"`, pinned by the recorded `config_auth` fixtures). Platform-RENDERED strings (mailer template/notification subjects) get no pinned baseline at all: the platform authors those values and rewords them across environments, so a pinned copy cannot be simultaneously correct for prod and staging — their rows declare `platformRendered` instead, and any remote value is suppressed while the local projection is silent (a locally declared subject still classifies normally). "Unconfigured" is never inferred from type-level zero values — canonicalization can turn a platform zero into a non-zero shape, and PR #6295's review reproduced 15 noise lines on an untouched staging project from exactly that inference. A path with no baseline at any tier reports rather than guesses (over-report over under-report). An unconfigured project therefore diffs clean instead of flooding with platform-default noise.
- **Equality is meaning-based**: the normalizers canonicalize representations (durations, byte sizes, comma-joins) per ADR 0021, and the classifier's residual equality tolerates string/number and string/boolean scalar skew. Whether an array is a SET or a SEQUENCE is per-field wire knowledge and lives on the registry row (`arrayEquality`), defaulting to sequence — `api.schemas`' first entry is PostgREST's default schema and `api.extra_search_path` is a literal `search_path`, so reordering is drift; `auth.additional_redirect_urls` opts into set semantics.
- **Secrets are "present, unknown".** Both normalizers omit secret leaves (the platform only reports HMAC digests), so secrets can never classify; the registry's `isSecret` rows define the masked surface, and locally-declared ones are surfaced in `ConfigChangeSet.masked` so a clean change list is visibly a partial claim. The same visibility rule covers `ConfigChangeSet.unmanaged`: a declared comparable path the local projection drops can never classify either, and silently vanishing would print a false "no differences" over a real disagreement. This family is no longer whole-container omissions predicting what a push mapper happened to send (that mechanism, `applyPushUnmanagedOmissions`, was retired on CLI-2314 — see ADR 0021's 2026-09-04 addendum for the full re-derivation); it is now `DISABLED_SENTINEL_PRUNES`'s surviving sibling-only rules, each re-derived from a platform data-model fact independent of any actor's write path — e.g. the Data-API-off `api.{schemas,extra_search_path,max_rows}` siblings (`api.enabled` is itself derived from `db_schema.length > 0`, the same fact `schemas` carries), `auth.email.smtp`'s non-host fields (`smtp.enabled` is likewise derived from `smtp_host.length > 0`, not an independent field), `auth.captcha.provider`/`secret` (a disabled captcha's provider choice is genuinely retained-but-inert on the platform), `auth.oauth_server`'s two non-`enabled` siblings, `storage.analytics`/`storage.vector`'s quota fields, and `db.network_restrictions`'s CIDR fields (a document-only management opt-out with no v2 API counterpart at all). The command layer separately echoes which response blocks were carried — a block absent from the response, or present but EMPTY (the plausible permission-truncated shape), reports as not-returned — so partially-populated responses degrade to `local_only` + an explicit scope note instead of an error or silent omission. This resource is read via `api.executeRaw` rather than the generated client's `execute`, per ADR 0019 rule 2: a strict `Schema.Struct` decode would drop excess properties and reject unknown enum members (a new `pooler.pool_mode` value, say) before the lenient config mirror ever ran on the body, leaving nothing left for it to be lenient about — `diff.integration.test.ts`'s "an unknown enum value in the response degrades instead of failing" case pins the resulting degrade-not-fail behavior. Any future typed consumer of this resource, `config pull` included, must read it the same way, or deliberately reintroduce contract overrides for it.
- **Secrets are "present, unknown".** Both normalizers omit secret leaves (the platform only reports HMAC digests), so secrets can never classify; the registry's `isSecret` rows define the masked surface, and locally-declared ones are surfaced in `ConfigChangeSet.masked` so a clean change list is visibly a partial claim. The same visibility rule covers `ConfigChangeSet.unmanaged`: a declared comparable path the local projection drops can never classify either, and silently vanishing would print a false "no differences" over a real disagreement. This family is no longer whole-container omissions predicting what a push mapper happened to send (that mechanism, `applyPushUnmanagedOmissions`, was retired on CLI-2314 — see ADR 0021's 2026-09-04 addendum for the full re-derivation); it is now `DISABLED_SENTINEL_PRUNES`'s surviving sibling-only rules, each re-derived from a platform data-model fact independent of any actor's write path — e.g. the Data-API-off `api.{schemas,extra_search_path,max_rows}` siblings (`api.enabled` is itself derived from `db_schema` via `remoteDataApiDisabled`, the same fact `schemas` carries), `auth.email.smtp`'s non-host fields (`smtp.enabled` is likewise derived from `smtp_host.length > 0`, not an independent field), `auth.captcha.provider`/`secret` (a disabled captcha's provider choice is genuinely retained-but-inert on the platform), `auth.oauth_server`'s two non-`enabled` siblings, `storage.analytics`/`storage.vector`'s quota fields, and `db.network_restrictions`'s CIDR fields (a document-only management opt-out with no v2 API counterpart at all). The command layer separately echoes which response blocks were carried — a block absent from the response, or present but EMPTY (the plausible permission-truncated shape), reports as not-returned — so partially-populated responses degrade to `local_only` + an explicit scope note instead of an error or silent omission. This resource is read via `api.executeRaw` rather than the generated client's `execute`, per ADR 0019 rule 2: a strict `Schema.Struct` decode would drop excess properties and reject unknown enum members (a new `pooler.pool_mode` value, say) before the lenient config mirror ever ran on the body, leaving nothing left for it to be lenient about — `diff.integration.test.ts`'s "an unknown enum value in the response degrades instead of failing" case pins the resulting degrade-not-fail behavior. Any future typed consumer of this resource, `config pull` included, must read it the same way, or deliberately reintroduce contract overrides for it.
- **Change entries carry what a consumer needs verbatim**: `path` is a SEGMENT ARRAY end to end (an `sms.test_otp` phone-number key may itself contain a `.`; joining is display-only), `declared` distinguishes file-written values from schema-materialized defaults, and a `remote_only` entry keeps the materialized local default so "what would `config push` change?" is answerable from the change alone — the primary dashboard-drift use case, and what `config pull` will need to write back.
- **Command surface (review revision, PR #6295)**: the target flag is `link`'s settled vocabulary — a single `--project-ref` accepting a project ref or a branch name/UUID (ref-shaped values always read as refs; a UUID resolves without a linked parent) — not a bespoke `--target`. The global `-o/--output` flag is rejected outright — every value, `pretty` included — with a message pointing at `--output-format` (the CLI-2156 ruling: no Go-compat contract for net-new commands, recorded as the scoped exception to Legacy Shell Invariant #6). `--exit-code` gives drift its own exit code `2`, keeping `1` for failures, so scripts can tell "drifted" from "token expired". The machine payload versions its own contract (`schema_version: 1`, an integer) separately from the user-controlled `$schema` document reference (`config_schema`).
- The interpolation pipeline records the resolving env var name on `"environment"` value origins, so a change on an `env()`-fed property can name the variable involved.
Expand Down
2 changes: 1 addition & 1 deletion packages/config/src/project-config/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ export const DISABLED_SENTINEL_PRUNES: ReadonlyArray<{
/** Keys to drop when `enabled === false`; absent = drop every key but `enabled`. */
readonly dropKeys?: ReadonlyArray<string>;
}> = [
// `api.enabled` derives from `schemas.length > 0`; `extra_search_path`/`max_rows` configure a
// `api.enabled` and `schemas` share one `db_schema`; `extra_search_path`/`max_rows` configure a
// PostgREST that isn't exposed while the Data API is off.
{ containerPath: ["api"], dropKeys: ["schemas", "extra_search_path", "max_rows"] },
// `enabled` here means "manage network restrictions", a management opt-out — the platform has
Expand Down
20 changes: 12 additions & 8 deletions packages/config/src/project-config/project-config.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,14 +724,18 @@ describe("fromApiProjectConfig — api section", () => {
expect(result.api).toEqual({ schemas: ["public", "graphql_public"], enabled: true });
});

// An explicit `db_schema: ""` is the remote's disable sentinel, so only `enabled: false` is
// reported; an absent `db_schema` does not gate the siblings.
test("an empty db_schema disables the Data API and omits the sibling fields", () => {
const result = fromApiProjectConfig({
api: { db_schema: "", db_extra_search_path: "public", max_rows: 100 },
});
expect(result.api).toEqual({ enabled: false });
});
// An explicit `db_schema: ""` and the platform's `pg_pgrst_no_exposed_schemas` marker are the
// remote's disable sentinels, so only `enabled: false` is reported; an absent `db_schema` does
// not gate the siblings.
test.each(["", "pg_pgrst_no_exposed_schemas"])(
"db_schema %j disables the Data API and omits the sibling fields",
(dbSchema) => {
const result = fromApiProjectConfig({
api: { db_schema: dbSchema, db_extra_search_path: "public", max_rows: 100 },
});
expect(result.api).toEqual({ enabled: false });
},
);

test("max_rows is clamped to zero when negative", () => {
const result = fromApiProjectConfig({ api: { max_rows: -5 } });
Expand Down
16 changes: 11 additions & 5 deletions packages/config/src/project-config/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@ const apiExtraSearchPathPath = ["api", "db_extra_search_path"];
const apiMaxRowsPath = ["api", "max_rows"];

/**
* True when the remote reports the Data API disabled: an explicit `""` `db_schema`, not an
* absent one (which means the sparse input simply didn't mention it). The rows below gate on
* this so a disabled remote maps to exactly `{ api: { enabled: false } }`.
* True when the remote reports the Data API disabled: an explicit `""` `db_schema` or the
* platform's `pg_pgrst_no_exposed_schemas` marker, not an absent one (which means the sparse
* input simply didn't mention it). The marker can't name a real schema because Postgres
* reserves the `pg_` prefix. The rows below gate on this so a disabled remote maps to exactly
* `{ api: { enabled: false } }`.
*/
function remoteDataApiDisabled(attributes: Record<string, unknown>): boolean {
const api = attributes["api"];
return isObject(api) && api["db_schema"] === "";
const dbSchema = isObject(api) ? api["db_schema"] : undefined;
return dbSchema === "" || dbSchema === "pg_pgrst_no_exposed_schemas";
}

/**
Expand Down Expand Up @@ -79,7 +82,10 @@ const apiSectionRows: ReadonlyArray<ProjectConfigMappingRow> = [
// `apiPath` (see registry-row.ts).
configPath: ["api", "enabled"],
apiPath: apiDbSchemaPath,
transform: (value) => expectString(value, apiDbSchemaPath).length > 0,
transform: (value, attributes) => {
expectString(value, apiDbSchemaPath);
return !remoteDataApiDisabled(attributes);
},
},
{
configPath: ["api", "extra_search_path"],
Expand Down
Loading