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
4,764 changes: 3,918 additions & 846 deletions apps/docs/public/cli/config.schema.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/config/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ artifacts (`./schema.json`, `./project-schema.json`).
`apps/cli` needs them, and internal-only keeps the published semver surface unchanged). Anything
here can change or vanish in any release.
- `@supabase/config/schema.json` — generated JSON Schema (draft 2020-12) for `CliConfig` (a
`dist/` build output).
`dist/` build output); number, integer, boolean, enum and string-list fields also accept `env(VAR)` strings.
- `@supabase/config/project-schema.json` (CLI-2234) — generated JSON Schema (draft 2020-12) for
`ProjectConfig`, derived from `ProjectConfigSchema` (`src/project-config/project-schema.ts`); a
`dist/` build output alongside `schema.json`.
Expand Down
1 change: 1 addition & 0 deletions packages/config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@vitest/coverage-v8": "catalog:",
"ajv": "^8.20.0",
"effect": "catalog:",
"semantic-release": "catalog:",
"typescript": "catalog:",
Expand Down
6 changes: 5 additions & 1 deletion packages/config/scripts/build-artifacts.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ describe("published JSON Schema artifacts", () => {
throw new Error("expected properties.api.properties.max_rows to be an object");
}
const maxRows = apiProperties["max_rows"];
expect(maxRows["type"]).toBe("number");
const anyOf = maxRows["anyOf"];
if (!Array.isArray(anyOf) || !isRecord(anyOf[0])) {
throw new Error("expected max_rows to be an anyOf of the number and an env() string");
}
expect(anyOf[0]["type"]).toBe("number");
expect(typeof maxRows["description"]).toBe("string");
expect(maxRows["default"]).toBe(1000);
});
Expand Down
5 changes: 4 additions & 1 deletion packages/config/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ export const CliConfigSchema = Schema.Struct({
});

export function toCliConfigJsonSchema() {
return toPublishedJsonSchemaDocument(CliConfigSchema, { onExcessProperty: "error" });
return toPublishedJsonSchemaDocument(CliConfigSchema, {
onExcessProperty: "error",
acceptEnvReferences: true,
});
}

export type CliConfig = typeof CliConfigSchema.Type;
Expand Down
86 changes: 82 additions & 4 deletions packages/config/src/json-schema-document.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
import { Schema } from "effect";
import { ENV_PATTERN } from "./lib/env.ts";

type Node = Record<string, unknown>;

const COERCED_TYPES = new Set(["number", "integer", "boolean"]);
const HOISTED_ANNOTATIONS = new Set(["title", "description", "default", "examples"]);
const DATA_KEYWORDS = new Set(["default", "examples", "enum", "const"]);
const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties"]);

/**
* Renders a published JSON Schema document, carrying the `x-secret` marker through and keeping
* pattern-keyed records open: the generator writes a boolean `additionalProperties` on them
* (effect 4.0) with no per-schema setting, while the published documents never carried one there.
* With `acceptEnvReferences`, number, integer, boolean, string-enum and string-list leaves also
* accept the `env(VAR)` references the loader resolves.
*/
export function toPublishedJsonSchemaDocument(
schema: Schema.Constraint,
options: { readonly onExcessProperty: "ignore" | "error" },
options: {
readonly onExcessProperty: "ignore" | "error";
readonly acceptEnvReferences?: boolean;
},
) {
const document = Schema.toJsonSchemaDocument(schema, {
onExcessProperty: options.onExcessProperty,
includeAnnotationKey: (key) => key === "x-secret",
});
const root = openPatternKeyedRecords(document.schema);
const definitions = openPatternKeyedRecords(document.definitions);
return {
$schema: "https://json-schema.org/draft/2020-12/schema",
...openPatternKeyedRecords(document.schema),
...(Object.keys(document.definitions).length > 0
? { $defs: openPatternKeyedRecords(document.definitions) }
...(options.acceptEnvReferences ? acceptEnvReferences(root) : root),
...(Object.keys(definitions).length > 0
? { $defs: options.acceptEnvReferences ? mapSchemas(definitions) : definitions }
: {}),
};
}
Expand All @@ -37,3 +52,66 @@ function isRecordLike(node: Record<string, unknown>): boolean {
const patternProperties = node["patternProperties"];
return typeof patternProperties === "object" && patternProperties !== null;
}

function acceptEnvReferences<T>(node: T): T {
if (Array.isArray(node)) return node.map(acceptEnvReferences) as T;
if (!isNode(node)) return node;
if (isEnvReferenceTarget(node)) return withEnvReferenceBranch(node) as T;
const out: Node = {};
for (const [key, value] of Object.entries(node)) {
if (DATA_KEYWORDS.has(key)) out[key] = value;
else if (SCHEMA_MAP_KEYWORDS.has(key)) out[key] = mapSchemas(value);
else if (key === "anyOf" && Array.isArray(value)) out[key] = acceptEnvReferencesInUnion(value);
else out[key] = acceptEnvReferences(value);
}
return out as T;
}

function mapSchemas(schemas: unknown): unknown {
if (!isNode(schemas)) return schemas;
return Object.fromEntries(
Object.entries(schemas).map(([name, schema]) => [name, acceptEnvReferences(schema)]),
);
}

function acceptEnvReferencesInUnion(members: ReadonlyArray<unknown>): Array<unknown> {
const accepted = members.map((member) =>
isEnvReferenceTarget(member) ? member : acceptEnvReferences(member),
);
const needsBranch = members.some(isEnvReferenceTarget) && !accepted.some(isEnvReferenceBranch);
return needsBranch ? [...accepted, envReferenceBranch()] : accepted;
}

function withEnvReferenceBranch(node: Node): Node {
const outer: Node = {};
const typed: Node = {};
for (const [key, value] of Object.entries(node)) {
(HOISTED_ANNOTATIONS.has(key) ? outer : typed)[key] = value;
}
return { ...outer, anyOf: [typed, envReferenceBranch()] };
}

function envReferenceBranch(): Node {
return { type: "string", pattern: ENV_PATTERN };
}

function isEnvReferenceBranch(node: unknown): boolean {
if (!isNode(node) || node["type"] !== "string") return false;
const checks = Array.isArray(node["allOf"]) ? [node, ...node["allOf"]] : [node];
return checks.some((check) => isNode(check) && check["pattern"] === ENV_PATTERN);
}
Comment thread
Coly010 marked this conversation as resolved.

function isEnvReferenceTarget(node: unknown): node is Node {
if (!isNode(node) || typeof node["type"] !== "string") return false;
if (COERCED_TYPES.has(node["type"])) return true;
if (node["type"] === "string") return !isFreeFormString(node);
return node["type"] === "array" && isFreeFormString(node["items"]);
}

function isFreeFormString(node: unknown): boolean {
return isNode(node) && node["type"] === "string" && !("enum" in node);
}

function isNode(value: unknown): value is Node {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Loading
Loading