Skip to content

feat(config): expose config defaults and provide sparse mapping functions - #6205

Open
kanadgupta wants to merge 9 commits into
developfrom
kanad-claude/config-default-values-mapping-ced354
Open

feat(config): expose config defaults and provide sparse mapping functions#6205
kanadgupta wants to merge 9 commits into
developfrom
kanad-claude/config-default-values-mapping-ced354

Conversation

@kanadgupta

@kanadgupta kanadgupta commented Aug 14, 2026

Copy link
Copy Markdown
Member

Implements CLI-2155: a stored reference of config default values and a mapping function that omits values matching them, so config diff (CLI-2156) and config pull (CLI-2064) can compare sparse configs instead of full effective ones.

What changed

New API in @supabase/config (packages/config/src/sparse.ts, all pure and synchronous, operating on decoded ProjectConfig values):

  • getDefaultProjectConfig() — the default config, derived by decoding {} through ProjectConfigSchema (memoized). The schema's existing default annotations and decoding defaults remain the single source of truth; there is no hand-maintained defaults table to drift.
  • subtractProjectConfig(config, baseline) — returns the sparse config config − baseline: strict deep equality (order-sensitive arrays), sections emptied by subtraction dropped recursively. Directional, so CLI-2156 can subtract a remote block against the merged base config.
  • omitDefaultValues(config)subtractProjectConfig with the default config as baseline. The result is itself a valid config document: re-decoding refills the removed defaults, yielding the same effective config under the current schema's defaults. [remotes.*] blocks (config overrides for a specific persistent Supabase branch, bound by project_id) pass through untouched — pruning them against global defaults would silently change what a branch resolves to.

Refactor: io.ts had a private stripDefaults/isEqualValue walk used for writing minimal config files; that walk is now the shared subtractValue core in sparse.ts, consumed by both the new API and the save path, so there is one subtraction implementation in the package.

Design docs: ADR 0018 (docs/adr/0018-sparse-config-subtraction.md) records the decision, the remote-block baseline rule, what the output is depending on the baseline (defaults → a valid, effectively-equivalent config document whose meaning leans on the current defaults version; any other baseline → an overlay meaningful only relative to that baseline), and defines the working vocabulary inline (default config, sparse config, subtract, base config, remote block, drift).

Reviewer notes

  • The scope split with CLI-2156 is deliberate: the merge cascade and the Management-API-response→config shape translation land with the diff core, keeping this package free of packages/api types. See the ticket comment and ADR 0018's Alternatives.
  • subtractProjectConfig uses an overload with an untyped implementation signature rather than an as cast — TypeScript can't verify that a structural walk over unknown reconstructs a DeepPartial of its input; the unit tests pin the contract.
  • A sparse file's effective meaning leans on the schema's defaults, so a default changed in a future schema version changes what the file denotes — this is the PRFAQ's versioned-defaults open question, deliberately out of scope here per ADR 0018.

🤖 Generated with Claude Code

kanadgupta and others added 7 commits August 14, 2026 15:45
Adds ADR 0017 pinning the design for the config defaults mapping
function ahead of implementation: a pure, parameterized subtract core
in @supabase/config, defaults derived from schema annotations, strict
deep equality, and the remote-block baseline rule. Also seeds a root
CONTEXT.md glossary with the project's config vocabulary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ons (CLI-2155)

Adds getDefaultProjectConfig() (memoized decode of {} through the
schema, keeping annotations as the single source of truth for
defaults), subtractProjectConfig(config, baseline), and
omitDefaultValues(config), per ADR 0017. The generic subtraction walk
is extracted from io.ts's private stripDefaults so the save-time
minimal encoding and the new sparse API share one implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y (CLI-2155)

Introduces [remotes.*] blocks as config overrides for a specific
persistent Supabase branch (project_id binds the block; the label is a
user-chosen alias) in ADR 0017, CONTEXT.md, and the sparse.ts doc
comments. Replaces the flat "not a valid complete config" framing with
a split by baseline: subtracting the default config yields a valid
config document that re-decodes to the same effective config under the
current schema's defaults, while any other baseline yields an overlay
meaningful only relative to that baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…I-2155)

CONTEXT.md is not a convention this codebase follows. Its only content
not already defined in the ADR (base config, drift) is woven into ADR
0018's problem statement; the stale post-renumbering references (the
ADR's own 0017 heading, sparse.ts and sparse.unit.test.ts pointing at
ADR 0017) now say 0018.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kanadgupta kanadgupta changed the title feat(config): store config defaults and provide sparse mapping functions feat(config): expose config defaults and provide sparse mapping functions Aug 18, 2026
@kanadgupta
kanadgupta marked this pull request as ready for review August 18, 2026 23:46
@kanadgupta
kanadgupta requested a review from a team as a code owner August 18, 2026 23:46
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Supabase CLI preview

npx --yes https://pkg.pr.new/supabase/cli/supabase@24fab701f36cf5b98d53e962671a28d85640b1fd

Preview package for commit 24fab70.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 635c7dd81a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const baselineObject = isObject(baseline) ? baseline : {};
const result: Record<string, unknown> = {};

for (const [key, child] of Object.entries(value)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve removals when subtracting optional fields

When config omits an optional field that exists in baseline—for example, config = decodeProjectConfig({}) and baseline = decodeProjectConfig({ api: { external_url: "https://example.com" } })—this loop never visits the baseline-only key and returns an empty overlay. Consumers therefore cannot distinguish this removal from equality, and applying the advertised overlay to the baseline leaves the old value intact, potentially hiding configuration drift. The subtraction representation needs to preserve baseline-only removals or explicitly reject such inputs.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining as out of scope for this API. The scenario (baseline has a key the config lacks) cannot arise in the designed call patterns: with the defaults baseline, decoding fills every defaulted field, and undefaulted optional fields are absent from the default config too; with the CLI-2156 baseline, the config is merge(base, remote) and merging only adds keys, so the config is always a key-superset of its baseline. Symmetric removed-key reporting is the diff core's concern (CLI-2156), not this mapping function's — CLI-2155 only asks to omit values matching defaults. See ADR 0018 (docs/adr/0018-sparse-config-subtraction.md) for the scope split.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One correction to my reasoning above: in the CLI-2156 remote-block call the config is not always a key-superset of its baseline — a base-only undefaulted field like api.external_url is baseline-only. The decline still holds, for a better reason: in that flow absence means inherit, so an empty overlay for a baseline-only key is the correct answer — reporting it as a removal would fabricate drift. Documented the asymmetric contract in subtractValue's docstring in 24fab70; symmetric removed-key reporting remains the diff core's concern (CLI-2156).

Comment thread packages/config/src/sparse.ts Outdated
const subtracted = subtractValue(child, baselineObject[key]);

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build subtraction results without prototype setters

For a valid record key named __proto__—accepted for remote labels and function names—this assignment invokes the legacy prototype setter because result is a normal object rather than creating an own property. The entry consequently disappears from Object.keys(result) and from the sparse output; if it is the only entry, the entire section is dropped. Construct the result with a null prototype or define keys as own data properties.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining. Reaching this line with an own __proto__ key requires the TOML/JSON parse and schema decode to have produced one through the same setter trap first, and record entries (remotes, functions) pass through wholesale without a rebuild when absent from the baseline. Plain-object construction with direct assignment is also the established idiom in this package (e.g. mergeRemoteSubtree in io.ts) — hardening one function against a config author's own file while its siblings share the pattern buys nothing. If we ever harden, it should be package-wide in one pass.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reversing my decline — I tested the premise and it's wrong. smol-toml parses [functions.__proto__] into an own key on a normal-prototype object, JSON.parse does the same, and the schema decode preserves both, so the key reaches this walk intact: result[key] = … silently dropped it, and baseline[key] read Object.prototype off the prototype chain. Fixed in 24fab70 by hardening the package's record walks in one pass (subtractValue, mergeRemoteSubtree, stripFunctionRecordDefaults): Object.hasOwn lookups and own-data-property construction via a shared setOwnProperty, with a unit test pinning the __proto__ round-trip.

*/
export function getDefaultProjectConfig(): ProjectConfig {
defaultProjectConfig ??= decodeProjectConfig({});
return defaultProjectConfig;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Protect the memoized defaults from caller mutation

When a consumer mutates the object returned here—possible from JavaScript or even type-checked code via APIs such as Object.assign—it permanently changes the module-wide subtraction baseline. Subsequent omitDefaultValues calls can then silently remove real overrides or retain actual defaults, and a later dynamic import of io.ts can derive its write baseline from the poisoned object. Return a clone or deeply freeze the cached value before exposing it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a717495: the memo is now deeply frozen before it is shared, so mutation attempts throw at runtime instead of poisoning the module-wide subtraction baseline. A unit test pins the frozen invariant.

Comment thread packages/config/src/sparse.ts Outdated
Comment on lines +139 to +140
config: ProjectConfig,
baseline: ProjectConfig,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept the actual remote-block shape in subtraction

When the documented CLI-2156/CLI-2064 flow passes config.remotes[name] as config, TypeScript rejects the call because a decoded remote block has the root sections but no required remotes property, while this parameter requires a complete ProjectConfig. The API therefore cannot be used for its advertised remote-block subtraction without a cast or an adapter that fabricates a remotes field; model the accepted input around the shared root/remote shape instead.

AGENTS.md reference: AGENTS.md:L75-L75

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a717495: subtractProjectConfig/omitDefaultValues now accept BaseProjectConfig (Omit<ProjectConfig, "remotes">), which both full configs and decoded remote blocks satisfy structurally — the ADR 0018 remote-block call now type-checks without a cast, and a unit test pins that exact call shape.

…defaults memo (CLI-2155)

Review follow-ups on #6205: subtractProjectConfig/omitDefaultValues now
take BaseProjectConfig (ProjectConfig without the nested remotes), so
the ADR 0018 remote-block call type-checks without a cast; the memoized
default config is deeply frozen so a caller mutation cannot poison the
shared subtraction baseline. Both pinned by unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@avallete avallete left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM a few nitpicks


- 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 baseline rule; misusing the default config as a remote block's baseline reintroduces the override-erasure bug this ADR exists to prevent.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more Negative worth recording: omitDefaultValues output is not sparse inside record-keyed entries. Decoding fills per-entry defaults, so functions.hello = { verify_jwt = false } comes back with enabled: true, import_map: "", entrypoint: "", static_files: [], env: {} and a decoded [remotes.x] block is a complete effective config (every section carries withDecodingDefault). The default config has functions: {} / remotes: {}, so subtraction keeps those entries whole, materialized defaults and all.

This cancels out in the diff (both sides subtract against each other), but a consumer rendering omitDefaultValues(localConfig) directly gets very non-sparse output, io.ts grew stripFunctionRecordDefaults for this on the encoded write path previously, and the typed API has no counterpart.

Fine to defer entry-level subtraction to the consumer that needs it, just might need to document the trap here so CLI-2156/2064 don't rediscover it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — reproduced exactly as described (functions.hello = { verify_jwt: false } comes back with all five materialized defaults, and a [remotes.*] entry survives as a 12-section effective config). Documented in 24fab70: the omitDefaultValues docstring and ADR 0018's Negatives now state that record-keyed entries survive whole with per-entry decoding defaults materialized, why (the default config's empty functions/remotes records offer no per-entry baseline), and that entry-level subtraction is deliberately the consumer's job — for remotes necessarily so, since the correct baseline is the merged base config. Left a pointer to stripFunctionRecordDefaults as the encoded-path precedent so CLI-2156/2064 find it instead of rediscovering the trap; if a consumer ends up rendering sparse output directly, that's the moment to unify the two into one typed entry-level strip.

Comment on lines +47 to +49
* Memoized (and the memo shared with callers) rather than computed at module
* load, so importing the package doesn't pay for a full schema decode. The
* memo is deeply frozen before it is shared: it doubles as the module-wide

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure of the claim in practice: io.ts:151 computes defaultEncodedProjectConfig at module load, and index.ts re-exports io.ts, so importing @supabase/config still pays the decode+encode at import time (pre-existing, unchanged by this PR).

Maybe soften to "so this module doesn't pay for a decode at load" or make io's constant lazy too, the memo itself is still worth having either way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — io.ts:151 was paying the decode+encode at import time anyway (and eagerly populating this memo through getDefaultProjectConfig()), so the claim didn't hold for anyone importing the package root; measured at ~14ms per import. Went with your second option in 24fab70: io's defaultEncodedProjectConfig is now a memoized function too (only the save path consumes it), so the comment here is accurate rather than softened — importing @supabase/config pays for no schema decode until something actually needs the defaults.

…e encoded defaults (CLI-2155)

Review follow-ups on #6205:

- A record entry named `__proto__` (a valid function name or remote label —
  both smol-toml and JSON.parse produce it as an own key, and the schema
  decode preserves it) was silently dropped by `result[key] = value` hitting
  the legacy prototype setter, and `baseline[key]` read `Object.prototype`
  off the prototype chain. All three record walks (`subtractValue`,
  `mergeRemoteSubtree`, `stripFunctionRecordDefaults`) now use `Object.hasOwn`
  lookups and define own data properties via a shared `setOwnProperty`; a unit
  test pins the `__proto__` round-trip.
- `io.ts`'s encoded-defaults constant is now memoized like
  `getDefaultProjectConfig`, so importing `@supabase/config` no longer pays a
  schema decode+encode at module load — making sparse.ts's lazy-memo comment
  actually true.
- Documented that `omitDefaultValues` output is sparse at the root scope only
  (record-keyed entries carry materialized per-entry decoding defaults), in
  the docstring and ADR 0018's Negatives, and that baseline-only keys are
  ignored by design (absence means inherit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24fab701f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +180 to +183
export function subtractProjectConfig(
config: BaseProjectConfig,
baseline: BaseProjectConfig,
): SparseProjectConfig;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Subtract merged remote configs, not decoded blocks

When the base overrides any field that the remote block omits, accepting a decoded remote block here produces a semantically incorrect overlay. remoteProjectConfig in base.ts applies decoding defaults to every section, so with a base db.port = 54399 and a remote that only overrides api.max_rows, the decoded remote contains the global default db.port = 54322; subtraction retains that value and a subsequent pull/write changes the branch from inheriting 54399 to explicitly using 54322. Fresh evidence after the earlier type-shape fix is the new test at sparse.unit.test.ts:139-153, which only covers a base override also explicitly present in the remote and therefore misses this case. Require the merged effective remote configuration here (or preserve the raw sparse block) rather than advertising decoded config.remotes[name] as a valid operand.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants