import type { CliConfigValueOrigin } from "./config-document.ts"; import { type CliConfigWithRawPresence, type ConfigAbsencePolicy, comparableProjectConfigPaths, fromConfigDocument, isComparableProjectConfigPath, type ProjectConfig, } from "./project-config/project-config.ts"; import { projectConfigMappingRows } from "./project-config/registry.ts"; import { getDefaultCliConfig } from "./sparse.ts"; /** * Config drift classification between the local project config and the * effective remote configuration reported by the Management API * (`GET /v2/projects/{ref}/config`). Pure and synchronous: fetching the * response, resolving the target, and rendering output are the caller's job * (`supabase config diff`, and `config pull` after it). See ADR 0022. * * Both operands are convergence projections from this package's normalizers * (ADR 0021): the local operand is derived here from the loaded `{config, * document}` pair via `fromConfigDocument` (raw-presence-masked, * canonicalized, secrets omitted), and the caller builds `remote` with * `fromApiProjectConfig(response)`. The comparable surface is the mapping * registry's — a path with no registry row is unmanaged by construction — * and the raw document's declared-key set drives `update` vs `remote_only`, * since a decoded config cannot distinguish "the file wrote the default" * from "the file is silent". * * Paths are segment arrays everywhere in this module's API (a record key — * an `auth.sms.test_otp` phone number, a `[remotes.*]` name — may itself * contain a `.`, so dotted strings are lossy); joining is display-only and * belongs to the renderer. */ export type ConfigChangeClass = "update" | "remote_only" | "local_only"; export interface ConfigChange { /** * Config path segments within the hosted subset, e.g. `["api", * "max_rows"]`. Join for display only — a segment may contain a `.`. */ readonly path: ReadonlyArray; /** * `update`: declared locally and reported remotely, values differ. * `remote_only`: reported remotely while the file does not declare it (or * push cannot communicate the declared state), and differing from the * unconfigured baseline. `local_only`: the local projection carries a * declared value the response did not account for. */ readonly class: ConfigChangeClass; /** * Local convergence-projected value; `undefined` when the projection is * silent. For an undeclared `remote_only` path this is the materialized * schema default — the value a `config push` would write over the remote — * so consumers can answer "what would push change?" without re-deriving it. */ readonly local: unknown; /** Remote value; `undefined` when the response did not report it. */ readonly remote: unknown; /** * Whether the raw document declares this path — distinguishes "the file * wrote this value" from "the local side is a schema-materialized default". */ readonly declared: boolean; /** Environment variables local `env()` references resolved from, if any. */ readonly envVariables?: ReadonlyArray | undefined; } export interface ConfigChangeCounts { readonly update: number; readonly remote_only: number; readonly local_only: number; readonly total: number; } export interface ConfigChangeSet { /** Reportable differences, ordered by path. */ readonly changes: ReadonlyArray; /** * Managed secret paths the file sets a value for (the registry's * `isSecret` rows). These were never compared — the platform reports HMAC * digests, and both normalizers omit secret leaves — so a clean `changes` * list is still only a partial claim; callers must surface this. */ readonly masked: ReadonlyArray>; /** * Comparable non-secret paths the file declares but the local projection * dropped — declared state no actor's write path can express, because the * owning feature is switched off (see ADR 0021's addendum for examples). The * reason isn't always self-contained: a path can be pruned because a * different, sibling path is undeclared. Never compared on the local side, * so — like `masked` — a clean `changes` list is only a partial claim; * callers must surface this. */ readonly unmanaged: ReadonlyArray>; readonly counts: ConfigChangeCounts; /** * Which {@link ConfigAbsencePolicy} `options.local` selected for this call — * `"absent-is-hands-off"` when `options.local.document` was supplied, * `"absent-is-default"` otherwise. See that type's docstring for the danger * matrix this protects against, and {@link DiffProjectConfigOptions.local} * for the classification cliff omitting `document` falls off. */ readonly absencePolicy: ConfigAbsencePolicy; } export interface DiffProjectConfigOptions { /** * The loaded local config: the `{config, document}` pair `fromConfigDocument` * accepts. Omitting `document` is a cliff, not gentle degradation: every * path's `declared` flag becomes `false`, so `local_only` never fires and * `unmanaged`/`masked` are always empty, and this selects * {@link ConfigAbsencePolicy} `"absent-is-default"` instead of * `"absent-is-hands-off"`. */ readonly local: CliConfigWithRawPresence & { readonly valueOrigins?: ReadonlyArray | undefined; }; /** The remote operand: `fromApiProjectConfig(response)`. */ readonly remote: ProjectConfig; } function isPlainRecord(value: unknown): value is Readonly> { return typeof value === "object" && value !== null && !Array.isArray(value); } function pathKey(path: ReadonlyArray): string { return JSON.stringify(path); } /** Walks a segment path through records with own-key checks only. */ function valueAtPath(root: unknown, path: ReadonlyArray): unknown { let current: unknown = root; for (const segment of path) { if (!isPlainRecord(current) || !Object.hasOwn(current, segment)) { return undefined; } current = current[segment]; } return current; } function isDeclaredAtPath( root: Readonly>, path: ReadonlyArray, ): boolean { let current: unknown = root; for (const [index, segment] of path.entries()) { if (!isPlainRecord(current) || !Object.hasOwn(current, segment)) { return false; } if (index < path.length - 1) { current = current[segment]; } } return true; } /** Collects leaf paths as segment arrays (arrays are leaves; records recurse). */ function collectLeafPaths(root: ProjectConfig): Array> { const leaves: Array> = []; const walk = (value: unknown, prefix: ReadonlyArray): void => { if (isPlainRecord(value)) { for (const [key, child] of Object.entries(value)) { walk(child, [...prefix, key]); } return; } if (prefix.length > 0) { leaves.push(prefix); } }; walk(root, []); return leaves; } /** Segment-wise path order — the display order of the change list. */ function comparePaths(a: ReadonlyArray, b: ReadonlyArray): number { const length = Math.min(a.length, b.length); for (let index = 0; index < length; index++) { const left = a[index] as string; const right = b[index] as string; if (left !== right) { return left < right ? -1 : 1; } } return a.length - b.length; } function scalarEqual(a: unknown, b: unknown): boolean { if (a === b) { return true; } // Type-aware comparison: both operands are already canonicalized by the // convergence normalizers, but representation skew across schema versions // ("8080" vs 8080, "true" vs true) is still not drift. if (typeof a === "string" && typeof b === "number") { const parsed = Number(a.trim()); return a.trim() !== "" && Number.isFinite(parsed) && parsed === b; } if (typeof a === "number" && typeof b === "string") { return scalarEqual(b, a); } if (typeof a === "string" && typeof b === "boolean") { return a.trim().toLowerCase() === String(b); } if (typeof a === "boolean" && typeof b === "string") { return scalarEqual(b, a); } return false; } function canonicalArrayElement(value: unknown): string { if (typeof value === "string") { return `s:${value}`; } if (typeof value === "number" || typeof value === "boolean") { // Scalars fold to their string form so "1" and 1 compare equal, matching // the scalar type-awareness above. return `s:${String(value)}`; } return `j:${JSON.stringify(value)}`; } export type ConfigArrayEquality = "set" | "sequence"; /** * Type-aware value equality. Scalars tolerate string/number and * string/boolean representation skew. Arrays default to sequence semantics — * order matters unless the field's registry row opts into `"set"` (e.g. * `auth.additional_redirect_urls` is membership-only, while `api.schemas`' * order matters). Defaulting to sequence over-reports rather than * under-reports drift. */ export function isEqualConfigValue( a: unknown, b: unknown, arrayEquality: ConfigArrayEquality = "sequence", ): boolean { if (Array.isArray(a) && Array.isArray(b)) { if (arrayEquality === "set") { // Set semantics: membership only. Duplicates carry no meaning for a // set-mode field, so they must not register as drift. const left = new Set(a.map(canonicalArrayElement)); const right = new Set(b.map(canonicalArrayElement)); return left.size === right.size && [...left].every((element) => right.has(element)); } if (a.length !== b.length) { return false; } const left = a.map(canonicalArrayElement); const right = b.map(canonicalArrayElement); return left.every((element, index) => element === right[index]); } return scalarEqual(a, b); } /** Deduped secret (`isSecret`) row config paths, in registry order. */ const secretConfigPaths: ReadonlyArray> = (() => { const seen = new Set(); const paths: Array> = []; for (const row of projectConfigMappingRows) { if (row.isSecret !== true || seen.has(pathKey(row.configPath))) { continue; } seen.add(pathKey(row.configPath)); paths.push(row.configPath); } return paths; })(); // Per-path row knowledge the classifier consumes, first row wins — matching // `comparableProjectConfigPaths`'s own dedupe order for paths several rows // share. const arrayEqualityByPathKey: ReadonlyMap = (() => { const map = new Map(); for (const row of projectConfigMappingRows) { if (row.arrayEquality !== undefined && !map.has(pathKey(row.configPath))) { map.set(pathKey(row.configPath), row.arrayEquality); } } return map; })(); const unconfiguredValueByPathKey: ReadonlyMap = (() => { const map = new Map(); for (const row of projectConfigMappingRows) { if (row.unconfiguredValue !== undefined && !map.has(pathKey(row.configPath))) { map.set(pathKey(row.configPath), row.unconfiguredValue); } } return map; })(); /** * Paths whose row is `platformRendered` (`./project-config/registry-row.ts`): * the platform authors the value itself, so a local-silent report at that * path suppresses unconditionally rather than matching a pinned baseline. */ const platformRenderedPathKeys: ReadonlySet = (() => { const keys = new Set(); for (const row of projectConfigMappingRows) { if (row.platformRendered === true) { keys.add(pathKey(row.configPath)); } } return keys; })(); /** * Equality at a specific path: array semantics come from the path's registry * row (or the nearest mapped ancestor — a mapped container's descendant * leaves inherit its row), defaulting to sequence. */ function equalsAtPath(path: ReadonlyArray, a: unknown, b: unknown): boolean { for (let length = path.length; length >= 1; length--) { const equality = arrayEqualityByPathKey.get(pathKey(path.slice(0, length))); if (equality !== undefined) { return isEqualConfigValue(a, b, equality); } } return isEqualConfigValue(a, b); } /** * Nearest-ancestor prefix membership: true when `path` or any ancestor * prefix is a key in `set` — mirrors {@link equalsAtPath}'s own ancestor walk * so a mapped container's descendant leaves inherit its membership too. */ function hasAncestorPathKey(path: ReadonlyArray, set: ReadonlySet): boolean { for (let length = path.length; length >= 1; length--) { if (set.has(pathKey(path.slice(0, length)))) { return true; } } return false; } // The default config's own convergence projection — the first `remote_only` // suppression baseline tier. Lazy so importing this module never pays for a // full schema decode up front. Calls `fromConfigDocument` on a bare // `EffectiveConfig`, an intentional internal `"absent-is-default"` use: the // suppression tiers below need a fully schema-materialized baseline, not a // raw-presence-masked one. let defaultProjectionMemo: ProjectConfig | undefined; function defaultProjection(): ProjectConfig { defaultProjectionMemo ??= fromConfigDocument(getDefaultCliConfig()); return defaultProjectionMemo; } /** * Classifies every comparable path into the change set. Pure: no I/O, no * dependency on command flags or output formatting. Runs `fromConfigDocument` * over `options.local`, so a document the registry cannot canonicalize throws * `ProjectConfigParseError` — callers translate at their own boundary. */ export function diffProjectConfig(options: DiffProjectConfigOptions): ConfigChangeSet { const local = fromConfigDocument(options.local); const declaredRoot = options.local.document ?? {}; const absencePolicy: ConfigAbsencePolicy = options.local.document !== undefined ? "absent-is-hands-off" : "absent-is-default"; const envReferences = new Map>(); for (const origin of options.local.valueOrigins ?? []) { if (origin.source === "environment" && origin.envVariables !== undefined) { envReferences.set(pathKey(origin.path), origin.envVariables); } } const changes: Array = []; const paths = new Map>(); for (const path of [...collectLeafPaths(local), ...collectLeafPaths(options.remote)]) { paths.set(pathKey(path), path); } // Declared comparable paths the local projection dropped: push cannot // communicate them, so they were never compared locally. // `comparableProjectConfigPaths` already excludes secret rows, so this never // overlaps `masked`. Hoisted above the classification loop and excluded // there first — an unmanaged path (ADR 0022) must never reach a branch that // compares it against the remote report. const unmanaged = comparableProjectConfigPaths .filter( (path) => isDeclaredAtPath(declaredRoot, path) && valueAtPath(local, path) === undefined, ) .sort(comparePaths); const unmanagedPathKeys = new Set(unmanaged.map(pathKey)); for (const path of paths.values()) { if (!isComparableProjectConfigPath(path)) { continue; } if (hasAncestorPathKey(path, unmanagedPathKeys)) { continue; } const localValue = valueAtPath(local, path); const remoteValue = valueAtPath(options.remote, path); const declared = isDeclaredAtPath(declaredRoot, path); const envVariables = envReferences.get(pathKey(path)); if (localValue !== undefined && remoteValue !== undefined) { if (equalsAtPath(path, localValue, remoteValue)) { continue; } // A declared value differing from the remote is an update; an // undeclared one is remote-side drift against the local projection's // materialized default, which stays populated on the change so // consumers can see what a push would write. changes.push({ path, class: declared ? "update" : "remote_only", local: localValue, remote: remoteValue, declared, ...(envVariables === undefined ? {} : { envVariables }), }); continue; } if (remoteValue !== undefined) { // Suppresses a silent-local, declared remote value when the row is // `platformRendered`, or when it matches the unconfigured baseline: the // default projection, the raw default config, then the registry row's // `unconfiguredValue`, tried in order. With no baseline match the value // is reported — never inferred as unconfigured from a type-level zero. const baseline = valueAtPath(defaultProjection(), path) ?? valueAtPath(getDefaultCliConfig(), path) ?? unconfiguredValueByPathKey.get(pathKey(path)); const suppressed = // Exact match, not the unmanaged exclusion's ancestor walk: every // platformRendered row is a leaf path. platformRenderedPathKeys.has(pathKey(path)) || (baseline !== undefined && equalsAtPath(path, baseline, remoteValue)); if (!suppressed) { changes.push({ path, class: "remote_only", local: undefined, remote: remoteValue, declared, ...(envVariables === undefined ? {} : { envVariables }), }); } continue; } if (declared) { changes.push({ path, class: "local_only", local: localValue, remote: undefined, declared, ...(envVariables === undefined ? {} : { envVariables }), }); } } changes.sort((a, b) => comparePaths(a.path, b.path)); const masked = secretConfigPaths .filter((path) => isDeclaredAtPath(declaredRoot, path)) .sort(comparePaths); const update = changes.filter((change) => change.class === "update").length; const remote_only = changes.filter((change) => change.class === "remote_only").length; const local_only = changes.filter((change) => change.class === "local_only").length; return { changes, masked, unmanaged, counts: { update, remote_only, local_only, total: update + remote_only + local_only }, absencePolicy, }; }