Skip to content
Open
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
66 changes: 66 additions & 0 deletions docs/adr/0018-sparse-config-subtraction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 0018. Sparse Config Subtraction

**Status**: proposed
**Date**: 2026-08-18

## Problem Statement

`config diff` (CLI-2156) and `config pull` (CLI-2064) compare a project's remote configuration against the local `config.toml` to surface *drift*: any difference between the project's effective remote configuration and the local file. The remote endpoint (`GET /v2/projects/{ref}/config`) returns the *effective* config — every setting reported, defaulted or not — and a locally decoded `ProjectConfig` likewise has every default filled in. Comparing these full objects directly would drown the user in hundreds of identical default values. CLI-2155 asks for a stored reference of config defaults and a mapping function that omits values matching them, so diffs stay readable and pulled files stay sparse.

The trap is where that mapping recurses. A `[remotes.<label>]` block declares config overrides for a specific [persistent Supabase branch](https://supabase.com/docs/guides/local-development/cli/config#branching-config): the branch's project ref in `project_id` binds the block to the branch (the label is a user-chosen alias and is never matched on), any root config option can be overridden inside it, and unspecified options inherit from the *base config* — the root-scope fields of the file, before any remote block is overlaid. The block is therefore itself a sparse overlay over the merged base config. A value in a remote block that happens to equal a *global default* is not redundant: if the base config overrides the same property, removing the remote's value silently changes what that branch resolves to.

## Decision

`@supabase/config` exports a pure, parameterized subtraction core:

- `getDefaultProjectConfig()` — the default config, derived by decoding `{}` through `ProjectConfigSchema` (memoized). The schema's `default` annotations and decoding defaults are the single source of truth; no hand-maintained defaults table exists.
- `subtractProjectConfig(config, baseline)` — returns the sparse config `config − baseline`: every value strictly deep-equal (order-sensitive) to the baseline's is removed, then sections left empty are dropped recursively.
- `omitDefaultValues(config)` — `subtractProjectConfig` with the default config as baseline.

What the output *is* depends on the baseline. In the primary case — subtracting the default config (`omitDefaultValues`) — the result is itself a valid config document: re-decoding refills exactly what was removed, so it denotes the same effective config *under the current schema's defaults*. That parenthetical is load-bearing: a sparse file's meaning leans on the defaults reference, so a default that changes in a future schema version changes the file's effective meaning — the dependency the PRFAQ's versioned-defaults open question exists to manage. Subtracting any other baseline — a remote block against the merged base config — yields an overlay that is meaningful only relative to that baseline and is not a standalone config. At the type level, `SparseProjectConfig` is a deep-partial of `ProjectConfig` either way and must be re-decoded before use where a complete config is required.

Subtraction never recurses into `remotes` when the baseline is the default config: the default config has no remote blocks, so under subtract semantics user remote blocks survive untouched. Both operands must be *effective* configs — values in which every absence has already been resolved. A standalone-decoded `[remotes.*]` block is not one: decoding a sparse fragment materializes global defaults in every section it omitted, where the block meant to inherit from the base config. Subtracting such a block retains those materialized defaults wherever the base overrides the same field (base `db.port = 54399`, remote omits `db` → the decoded block carries the global default `54322`, which survives subtraction), and writing the overlay back flips the branch from inheriting the base's value to explicitly pinning the global default. **To sparsify a branch's config (CLI-2156/2064), subtract its merged effective config — the raw remote subtree merged over the raw base document *before* decoding, as `io.ts`'s `mergeRemoteSubtree` does precisely so remote schema defaults never leak in — against the base effective config, never the default config.**

All functions are pure and synchronous, operating on decoded config values, with no Effect in the public signature. Subtraction accepts the root-scope shape (`BaseProjectConfig`, a `ProjectConfig` without the nested `remotes`), keeping `remotes` out of its contract — an effective config translated from the Management API has no `remotes` of its own and still fits without a cast or a fabricated field.

## Rationale

- **One core instead of a cascade.** The merge-and-prune cascade sketched in CLI-2155's planning comment reduces algebraically to `subtract(merge(local, remote), defaults)`. Exporting the subtraction gives `config diff` and `config pull` the shared comparison core without binding this package to the Management API's response shape (translating that shape to `ProjectConfig` is CLI-2156's concern).
- **Defaults derived, not duplicated.** Every field's default already lives in the schema (`default` annotations plus `withDecodingDefaultKey`). Decoding `{}` materializes them; a parallel hand-written defaults object would drift.
- **Strict deep equality, array order matters.** Order is semantically load-bearing for values like `api.extra_search_path` (Postgres `search_path` resolution order). Treating reordered arrays as equal would prune values that behave differently from the default. The false-positive cost (a semantically-default-but-reordered array survives as harmless noise) is far cheaper than wrongly deleting meaningful config.
- **Dropping empty sections is lossless.** Every section carries a section-level decoding default, so an absent section and an empty section decode identically; empty carcass headers are pure diff noise.
- **Pure functions.** The subtraction has no failure channel, no resources, and no concurrency — the repo's Effect-native policy explicitly exempts such leaf primitives. Purity also keeps the module portable if the package later grows browser-compatible entry points.

## Consequences

### Positive

- Diffs and pulled configs contain only values that differ from their baseline.
- One walk implementation serves defaults-stripping and remote-block sparsification.
- Schema remains the single source of truth for defaults; changing a default in the schema changes the subtraction behavior with no second edit.
- A unit test pins the invariant that all schema defaults are mutually valid (decoding `{}` succeeds), so a conflicting default fails CI loudly.

### Negative

- Fields declared `optionalKey` without a `default` annotation can never be pruned; if a platform default exists for such a field, it must be added to the schema before subtraction can see it.
- A user's explicitly-written default value (`max_rows = 1000` typed by hand) is indistinguishable from an omitted one and will be pruned; intent is not preserved.
- Callers must know the remote-block rules, and neither is enforceable at the type level: the config operand must be the branch's *merged effective* config (a standalone-decoded `[remotes.*]` block materializes global defaults where it meant to inherit), and the baseline must be the merged base config, never the default config. Violating either reintroduces the silent branch-behavior change this ADR exists to prevent.
- `omitDefaultValues` output is sparse at the root scope only: record-keyed entries (`functions.*`, `remotes.*`) pass through whole with their per-entry decoding defaults materialized, since the default config's empty records offer no per-entry baseline. This cancels out in a diff (both sides carry the same materialized defaults), but a consumer rendering the sparse config directly must strip entry-level defaults itself, as the encoded write path does with `stripFunctionRecordDefaults`.

## Alternatives Considered

1. **Merge-and-prune cascade as a single function** (`(apiResponse, configToml) → massaged config`): binds `@supabase/config` to the Management API response shape and entangles this package with remote-to-local translation, which belongs to the diff core (CLI-2156).
2. **Recursing into `remotes` against global defaults**: looks obviously correct, is subtly wrong — pruning a remote's `api.max_rows = 1000` (global default) under a base that sets `500` changes the branch's effective value from 1000 to 500.
3. **Hand-written defaults reference object**: duplicates ~100 defaults already declared in the schema and drifts silently.
4. **Order-insensitive array comparison**: prunes reordered arrays whose order is semantically meaningful (`extra_search_path`).

## Related Decisions

- [ADR 0009](0009-configuration-schema-and-validation.md): Configuration Schema & Validation — the umbrella charter this decision answers a slice of (default config generation, `@supabase/config` package architecture)
- [ADR 0006](0006-environment-management.md): Environment Management — remote blocks and branch mapping semantics

## See Also

- [CLI-2155](https://linear.app/supabase/issue/CLI-2155/store-the-config-default-values-and-provide-mapping-function) — this ticket
- [CLI-2156](https://linear.app/supabase/issue/CLI-2156/add-supabase-config-diff-to-the-cli) — `config diff`, consumer of the subtract core
- [CLI-2064](https://linear.app/supabase/issue/CLI-2064/add-supabase-config-pull-to-the-cli) — `config pull`
35 changes: 18 additions & 17 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,24 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi

## ADR index

| ID | Title | Status |
| ---- | ------------------------------------------------------------------------------------------ | -------- |
| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted |
| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted |
| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted |
| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted |
| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted |
| 0005 | [OpenAPI-Driven Code Generation for CRUD Commands](0005-openapi-driven-code-generation.md) | proposed |
| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed |
| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed |
| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed |
| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed |
| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed |
| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed |
| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | superseded |
| 0016 | [Legacy Port Completion and Go CLI Authority Scope](0016-legacy-port-completion-and-go-cli-authority-scope.md) | proposed |
| 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | accepted |
| ID | Title | Status |
| ---- | -------------------------------------------------------------------------------------------------------------- | ---------- |
| 0000 | [Use ADR to Record Decisions](0000-use-adr-to-record-decisions.md) | accepted |
| 0001 | [CLI DX Architecture: The 7 Pillars](0001-cli-dx-architecture-pillars.md) | accepted |
| 0002 | [CLI Product Metrics](0002-cli-product-metrics.md) | accepted |
| 0003 | [Self-Documenting CLI & Documentation Strategy](0003-self-documenting-cli.md) | accepted |
| 0004 | [CLI Design Goals & Development Workflows](0004-cli-design-goals-and-workflows.md) | accepted |
| 0005 | [OpenAPI-Driven Code Generation for CRUD Commands](0005-openapi-driven-code-generation.md) | proposed |
| 0006 | [Environment Management & Variable Resolution](0006-environment-management.md) | proposed |
| 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed |
| 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed |
| 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed |
| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed |
| 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | proposed |
| 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | superseded |
| 0016 | [Legacy Port Completion and Go CLI Authority Scope](0016-legacy-port-completion-and-go-cli-authority-scope.md) | proposed |
| 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | accepted |
| 0018 | [Sparse Config Subtraction](0018-sparse-config-subtraction.md) | proposed |

## Template

Expand Down
7 changes: 7 additions & 0 deletions packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,12 @@ export { type ProjectPaths, findProjectPaths, findProjectRoot } from "./paths.ts
export { projectConfigStoreLayer } from "./project-config.layer.ts";
export { ProjectConfigStore } from "./project-config.service.ts";
export { PROJECT_CONFIG_SCHEMA_URL } from "./schema-metadata.ts";
export {
type BaseProjectConfig,
type SparseProjectConfig,
getDefaultProjectConfig,
omitDefaultValues,
subtractProjectConfig,
} from "./sparse.ts";
export { KONG_LOCAL_CA_CERT } from "./tls.ts";
export { ENV_CAPTURE_REGEX } from "./lib/env.ts";
92 changes: 24 additions & 68 deletions packages/config/src/io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "./errors.ts";
import { interpolateEnvReferencesAgainstSchema } from "./lib/env.ts";
import { findProjectPaths } from "./paths.ts";
import { getDefaultProjectConfig, setOwnProperty, subtractValue } from "./sparse.ts";
import { loadProjectEnvironment, type ProjectEnvironment } from "./project.ts";

const projectConfigSchemaKey = "$schema";
Expand Down Expand Up @@ -147,7 +148,17 @@ const decodeRemotesWithoutChecks = Schema.decodeUnknownSync(RemotesSchema, {
disableChecks: true,
});
const encodeProjectConfig = Schema.encodeSync(ProjectConfigSchema);
const defaultEncodedProjectConfig = encodeProjectConfig(decodeProjectConfig({}));

let defaultEncodedProjectConfig: ReturnType<typeof encodeProjectConfig> | undefined;

/**
* Memoized like `getDefaultProjectConfig` — only the save path needs the
* encoded defaults, so importing the package pays for no schema decode.
*/
function getDefaultEncodedProjectConfig(): ReturnType<typeof encodeProjectConfig> {
defaultEncodedProjectConfig ??= encodeProjectConfig(getDefaultProjectConfig());
return defaultEncodedProjectConfig;
}
const defaultEncodedFunctionConfig = {
enabled: true,
verify_jwt: true,
Expand Down Expand Up @@ -187,9 +198,12 @@ function mergeRemoteSubtree(
): Record<string, unknown> {
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(remote)) {
const existing = result[key];
result[key] =
isObject(existing) && isObject(value) ? mergeRemoteSubtree(existing, value) : value;
const existing = Object.hasOwn(result, key) ? result[key] : undefined;
setOwnProperty(
result,
key,
isObject(existing) && isObject(value) ? mergeRemoteSubtree(existing, value) : value,
);
}
return result;
}
Expand Down Expand Up @@ -346,68 +360,6 @@ const applyRemoteOverride = Effect.fnUntraced(function* (
return { document: merged, appliedRemote: name, remoteLeafPaths };
});

function isEqualValue(left: unknown, right: unknown): boolean {
if (Array.isArray(left) && Array.isArray(right)) {
if (left.length !== right.length) {
return false;
}

for (let index = 0; index < left.length; index += 1) {
if (!isEqualValue(left[index], right[index])) {
return false;
}
}

return true;
}

if (isObject(left) && isObject(right)) {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);

if (leftKeys.length !== rightKeys.length) {
return false;
}

for (const key of leftKeys) {
if (!(key in right) || !isEqualValue(left[key], right[key])) {
return false;
}
}

return true;
}

return Object.is(left, right);
}

function stripDefaults(value: unknown, defaults: unknown): unknown {
if (defaults === undefined) {
return value;
}

if (Array.isArray(value)) {
return isEqualValue(value, defaults) ? undefined : value;
}

if (isObject(value)) {
const defaultObject = isObject(defaults) ? defaults : {};
const result: Record<string, unknown> = {};

for (const [key, child] of Object.entries(value)) {
const stripped = stripDefaults(child, defaultObject[key]);

if (stripped !== undefined) {
result[key] = stripped;
}
}

return Object.keys(result).length === 0 ? undefined : result;
}

return isEqualValue(value, defaults) ? undefined : value;
}

function stripFunctionRecordDefaults(value: unknown): unknown {
if (!isObject(value)) {
return value;
Expand All @@ -420,15 +372,19 @@ function stripFunctionRecordDefaults(value: unknown): unknown {

const functions: Record<string, unknown> = {};
for (const [name, functionConfig] of Object.entries(functionsValue)) {
functions[name] = stripDefaults(functionConfig, defaultEncodedFunctionConfig) ?? {};
setOwnProperty(
functions,
name,
subtractValue(functionConfig, defaultEncodedFunctionConfig) ?? {},
);
}

return { ...value, functions };
}

function encodeMinimalProjectConfig(config: ProjectConfig): Record<string, unknown> {
const encoded = stripFunctionRecordDefaults(encodeProjectConfig(config));
const stripped = stripDefaults(encoded, defaultEncodedProjectConfig);
const stripped = subtractValue(encoded, getDefaultEncodedProjectConfig());
return isObject(stripped) ? stripped : {};
}

Expand Down
Loading
Loading