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
2 changes: 1 addition & 1 deletion .okf/architecture/catalog-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ generated: { by: codex, at: "2026-09-27T21:00:00+02:00" }

# Catalog distribution and freshness

Status: Accepted direction. Local work for #249 composes and injects validated definitions. Local work for #271 defines the v1 JSON document and a shared HTTP loader with validated cache fallback. The CLI and Recipe Builder still use a temporary bundled adapter. Persistent cache adapters, client wiring, publication, and deployed behavior remain unimplemented. GitHub issues own implementation scope, sequencing, and acceptance evidence; this document owns the reusable decisions and their consequences.
Status: Accepted direction. Local work for #249 composes and injects validated definitions. Local work for #271 defines the v1 JSON document and a shared HTTP loader with validated cache fallback. Local work for #272 generates the public assets and Vercel rules; deployed behavior remains unverified. The CLI and Recipe Builder still use a temporary bundled adapter. Persistent cache adapters and client wiring remain unimplemented. GitHub issues own implementation scope, sequencing, and acceptance evidence; this document owns the reusable decisions and their consequences.

## Separate content delivery from engine releases

Expand Down
2 changes: 2 additions & 0 deletions apps/docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
# React Router
/.react-router/
/build/
/public/registry/
/public/schemas/
9 changes: 9 additions & 0 deletions apps/docs/REGISTRY-DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Registry asset deployment

The docs site is deployed through Vercel. The repository's production GitHub deployment is created by `vercel[bot]`, the public docs response identifies Vercel, and the Vercel PR deployment metadata identifies `apps/docs` as the project root. [`vercel.json`](vercel.json) applies there.

`bun run --cwd apps/docs build` regenerates both public JSON files from the local domain schema and catalog definitions before building the site. The generated files live under `apps/docs/public/registry/v1/` and `apps/docs/public/schemas/v1/`; they are not fetched from production or committed. Vercel publishes them at `/registry/v1/catalog.json` and `/schemas/v1/stack.effect.schema.json` in one deployment. The v1 catalog's identifier fixture is in `test/fixtures/published-catalog-ids.json` and must retain every previously published ID.

After a production deployment, check both URLs with `curl -i`, then send `If-None-Match` using each returned ETag and expect `304`. Send a cross-origin GET and OPTIONS preflight with `Origin` and `Access-Control-Request-Headers: If-None-Match`; confirm CORS headers and exposed validators. A request to `/registry/v1/missing.json` must return `404`. Run the same checks against a publicly accessible Vercel preview before production. The current PR previews require Vercel SSO and return a `302` before the asset route runs, so their public HTTP behavior cannot be verified without a preview protection exception. A local docs build proves asset generation, not deployed response behavior.

To roll back a bad catalog, redeploy the previous known-good production deployment in the Vercel project's Deployments page. Verify its two asset URLs and HTTP behavior before resuming publication. If the schema alone is wrong, use the same rollback so the catalog and schema remain from one deployment. Then fix the source or generator and deploy a new build; do not edit generated assets by hand.
5 changes: 3 additions & 2 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
"scripts": {
"build": "react-router build",
"clean": "rm -rf build node_modules .turbo .react-router tsconfig.tsbuildinfo",
"dev": "bun run --cwd ../cli docs:generate && react-router dev",
"dev": "bun run --cwd ../cli docs:generate && bun run scripts/write-catalog-registry-assets.ts && react-router dev",
"lint": "oxlint .",
"prebuild": "bun run --cwd ../cli docs:generate",
"prebuild": "bun run --cwd ../cli docs:generate && bun run scripts/write-catalog-registry-assets.ts",
"start": "react-router-serve ./build/server/index.js",
"test": "vitest run",
"test:browser": "vitest run --project=browser",
Expand Down Expand Up @@ -64,6 +64,7 @@
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/browser-playwright": "^5.0.2",
"ajv": "^8.20.0",
"playwright": "^1.63.0",
"rehype-pretty-code": "^0.14.5",
"tailwindcss": "^4.3.3",
Expand Down
28 changes: 28 additions & 0 deletions apps/docs/scripts/catalog-registry-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { exportOfficialCatalog } from "@repo/catalog/authoring";
import { STACK_CONFIG_SCHEMA_URL, StackConfig } from "@repo/domain/Scaffold";
import { Effect, Schema } from "effect";

export const CATALOG_ASSET_PATH = "/registry/v1/catalog.json";
export const CONFIG_SCHEMA_ASSET_PATH = "/schemas/v1/stack.effect.schema.json";

const JsonString = Schema.fromJsonString(Schema.Unknown, { space: 2 });

export const generateCatalogRegistryAssets = Effect.fn(
"Docs.generateCatalogRegistryAssets",
)(function* () {
const catalog = yield* exportOfficialCatalog();
const configSchema = {
...Schema.toStandardJSONSchemaV1(StackConfig)["~standard"].jsonSchema.input(
{
target: "draft-2020-12",
},
),
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: STACK_CONFIG_SCHEMA_URL,
};
const encodedSchema = yield* Schema.encodeEffect(JsonString)(configSchema);
return {
[CATALOG_ASSET_PATH]: catalog,
[CONFIG_SCHEMA_ASSET_PATH]: `${encodedSchema}\n`,
};
});
20 changes: 20 additions & 0 deletions apps/docs/scripts/write-catalog-registry-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Effect } from "effect";
import { generateCatalogRegistryAssets } from "./catalog-registry-assets";

const publicDirectory = fileURLToPath(new URL("../public/", import.meta.url));

await Effect.runPromise(
Effect.gen(function* () {
const assets = yield* generateCatalogRegistryAssets();
yield* Effect.forEach(Object.entries(assets), ([path, source]) =>
Effect.promise(async () => {
const destination = join(publicDirectory, path.slice(1));
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, source);
}),
);
}),
);
53 changes: 53 additions & 0 deletions apps/docs/test/catalog-registry-assets.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { CatalogDocument } from "@repo/domain/Catalog";
import { STACK_CONFIG_SCHEMA_URL } from "@repo/domain/Scaffold";
import Ajv2020 from "ajv/dist/2020.js";
import { Effect, Schema } from "effect";
import { describe, expect } from "vitest";
import {
CATALOG_ASSET_PATH,
CONFIG_SCHEMA_ASSET_PATH,
generateCatalogRegistryAssets,
} from "../scripts/catalog-registry-assets";
import publishedIds from "./fixtures/published-catalog-ids.json";

describe("catalog registry assets", () => {
it.effect("keeps every published target and module identifier", () =>
Effect.gen(function* () {
const assets = yield* generateCatalogRegistryAssets();
const catalog = yield* Schema.decodeEffect(
Schema.fromJsonString(CatalogDocument),
)(assets[CATALOG_ASSET_PATH]);

expect(catalog.targets.map((target) => target.kind)).toEqual(
publishedIds.targets,
);
expect(catalog.modules.map((module) => module.id)).toEqual(
publishedIds.modules,
);
}),
);

it.effect(
"validates old and annotated configurations with the hosted Draft 2020-12 schema",
() =>
Effect.gen(function* () {
const assets = yield* generateCatalogRegistryAssets();
const schema = yield* Schema.decodeEffect(
Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
)(assets[CONFIG_SCHEMA_ASSET_PATH]);
const validate = new Ajv2020().compile(schema);
const old = { name: "old-project", runtime: { _tag: "bun" } };

expect(schema).toMatchObject({
$id: STACK_CONFIG_SCHEMA_URL,
$schema: "https://json-schema.org/draft/2020-12/schema",
});
expect(validate(old)).toBe(true);
expect(validate({ $schema: STACK_CONFIG_SCHEMA_URL, ...old })).toBe(
true,
);
expect(validate({ ...old, runtime: { _tag: "unknown" } })).toBe(false);
}),
);
});
import { it } from "@effect/vitest";
84 changes: 84 additions & 0 deletions apps/docs/test/fixtures/published-catalog-ids.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{
"targets": [
"workspace",
"client-react",
"client-foldkit",
"server",
"server-mcp",
"cli",
"package"
],
"modules": [
"workspace-typescript-6",
"workspace-typescript-7",
"workspace-monorepo-turbo",
"workspace-monorepo-nx",
"workspace-monorepo-vite-plus",
"workspace-quality-biome",
"workspace-quality-biome-lint",
"workspace-quality-biome-format",
"workspace-quality-oxfmt",
"workspace-quality-dprint",
"workspace-quality-oxlint",
"workspace-test-vitest",
"workspace-devenv-git",
"workspace-devenv-nix-flake",
"workspace-devenv-devcontainer",
"workspace-devenv-husky",
"config-typescript-vite",
"domain-api-contracts",
"domain-rpc-contracts",
"domain-todo-contracts",
"domain-todo-http-contracts",
"domain-todo-rpc-contracts",
"domain-chat-contracts",
"domain-chat-managed-contracts",
"domain-ws-contracts",
"server-http-api",
"server-http-rpc",
"server-http-api-todos",
"server-http-rpc-todos",
"server-chat-rpc",
"server-chat-runtime-managed",
"server-ws-presence",
"server-devtools",
"mcp-tools",
"mcp-toolkit-datetime",
"mcp-toolkit-math",
"mcp-prompts",
"mcp-prompt-hello",
"mcp-resources",
"mcp-resource-primer",
"client-react-web-worker",
"client-react-http-api",
"client-react-http-api-todos",
"client-react-http-rpc",
"client-react-chat",
"client-react-ws-presence",
"client-react-devtools",
"client-foldkit-devtools",
"client-foldkit-http-api",
"client-foldkit-http-rpc",
"client-foldkit-ws-presence",
"client-foldkit-chat",
"package-ai-core",
"package-ai-toolkit-think",
"package-ai-toolkit-datetime",
"package-ai-toolkit-math",
"package-ai-chat-toolkit-datetime",
"package-ai-chat-toolkit-math",
"package-ai-toolkit-memory",
"package-ai-toolkit-plan",
"package-ai-toolkit-webfetch",
"package-ai-chat-service",
"package-db-sqlite",
"package-db-postgres",
"package-db-todo-repository",
"package-presence-service",
"cli-command-hello",
"cli-chat-driver",
"cli-command-chat-ask",
"cli-command-chat-terminal",
"cli-devtools"
]
}
67 changes: 67 additions & 0 deletions apps/docs/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"headers": [
{
"source": "/registry/v1/catalog.json",
"headers": [
{ "key": "Content-Type", "value": "application/json; charset=utf-8" },
{
"key": "Cache-Control",
"value": "public, max-age=0, must-revalidate"
},
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{
"key": "Access-Control-Allow-Methods",
"value": "GET, HEAD, OPTIONS"
},
{
"key": "Access-Control-Allow-Headers",
"value": "If-None-Match, If-Modified-Since"
},
{
"key": "Access-Control-Expose-Headers",
"value": "ETag, Last-Modified"
}
]
},
{
"source": "/schemas/v1/stack.effect.schema.json",
"headers": [
{ "key": "Content-Type", "value": "application/json; charset=utf-8" },
{
"key": "Cache-Control",
"value": "public, max-age=0, must-revalidate"
},
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{
"key": "Access-Control-Allow-Methods",
"value": "GET, HEAD, OPTIONS"
},
{
"key": "Access-Control-Allow-Headers",
"value": "If-None-Match, If-Modified-Since"
},
{
"key": "Access-Control-Expose-Headers",
"value": "ETag, Last-Modified"
}
]
}
],
"routes": [
{
"src": "/(registry|schemas/v1)/.*",
"methods": ["OPTIONS"],
"status": 204,
"headers": {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "If-None-Match, If-Modified-Since"
}
},
{ "handle": "filesystem" },
{ "src": "/registry/.*", "status": 404 },
{ "src": "/schemas/v1/.*", "status": 404 },
{ "src": "/.*", "dest": "/index.html" }
]
}
3 changes: 2 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 22 additions & 1 deletion packages/domain/src/Scaffold.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Schema } from "effect";
import { describe, expect, it } from "vitest";
import { TargetIdentity, TargetKey, TargetKind } from "./Catalog";
import { ContributionTokenContext, StackConfig } from "./Scaffold";
import {
ContributionTokenContext,
STACK_CONFIG_SCHEMA_URL,
StackConfig,
} from "./Scaffold";

describe("@repo/domain Scaffold", () => {
it("accepts realistic target identities users are expected to provide", () => {
Expand Down Expand Up @@ -201,6 +205,23 @@ describe("StackConfig TypeScript version", () => {
});
});

describe("StackConfig editor schema metadata", () => {
it("decodes old configurations and the canonical editor URL", () => {
const old = Schema.decodeSync(StackConfig)({
name: "old-project",
runtime: { _tag: "bun" },
});
const annotated = Schema.decodeSync(StackConfig)({
$schema: STACK_CONFIG_SCHEMA_URL,
name: "annotated-project",
runtime: { _tag: "bun" },
});

expect(old.$schema).toBeUndefined();
expect(annotated.$schema).toBe(STACK_CONFIG_SCHEMA_URL);
});
});

describe("StackConfig Deno runtime", () => {
it("decodes Deno with Deno-owned package and workspace commands", () => {
const config = Schema.decodeSync(StackConfig)({
Expand Down
4 changes: 4 additions & 0 deletions packages/domain/src/Scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ export const makeRuntime = (

export const TypeScriptVersion = Schema.Literals(["6", "7"]);

export const STACK_CONFIG_SCHEMA_URL =
"https://stack-effect.lloydrichards.dev/schemas/v1/stack.effect.schema.json";

export class StackConfig extends Schema.Class<StackConfig>("StackConfig")({
$schema: Schema.optional(Schema.String),
name: Schema.NonEmptyString,
runtime: Runtime,
typescript: Schema.optional(TypeScriptVersion),
Expand Down
Loading