diff --git a/packages/config/src/base.ts b/packages/config/src/base.ts index d84ba7c2c4..b4504d92f6 100644 --- a/packages/config/src/base.ts +++ b/packages/config/src/base.ts @@ -10,6 +10,7 @@ import { inbucket } from "./inbucket.ts"; import { realtime } from "./realtime.ts"; import { storage } from "./storage.ts"; import { studio } from "./studio.ts"; +import { workers } from "./workers.ts"; const projectId = Schema.optionalKey( Schema.String.annotate({ @@ -37,6 +38,7 @@ const baseProjectConfigFields = { realtime, storage, studio, + workers, experimental, }; @@ -52,6 +54,7 @@ const remoteProjectConfig = Schema.Struct({ realtime, storage, studio, + workers, experimental, }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); diff --git a/packages/config/src/workers.ts b/packages/config/src/workers.ts new file mode 100644 index 0000000000..c79f5f7024 --- /dev/null +++ b/packages/config/src/workers.ts @@ -0,0 +1,104 @@ +import dedent from "dedent"; +import { Effect, Schema } from "effect"; + +const tags = ["workers"]; + +const links = [ + { + name: "`supabase workers` CLI subcommands", + link: "https://supabase.com/docs/reference/cli/supabase-workers", + }, +]; + +/** + * Worker names end up in hostnames, so they are DNS labels — the same pattern + * the Management API validates `:name` against + * (`v2/projects/{ref}/workers/{name}`). `root` is excluded from the key pattern + * because `[workers]` carries both the project-wide `root` scalar and one + * sub-table per worker; without the exclusion the record's index signature also + * claims `root` and rejects its string value. + */ +const workerName = Schema.String.check( + Schema.isPattern(/^(?!root$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/), +); + +const worker = Schema.Struct({ + runtime: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Runtime the worker is built on: \`dockerfile\` to build the directory's own + Dockerfile, or one of the catalog runtimes (\`node\`, \`deno\`). Guessed from + marker files when unset. + `, + examples: ["node"], + tags, + links, + }), + ), + size: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Instance size, denominated by memory. Each size implies its own vCPU count, + so it is the one dial rather than two. + `, + examples: ["2gb"], + tags, + links, + }), + ), + instances: Schema.optionalKey( + Schema.Number.annotate({ + description: dedent` + Number of instances to run. Every deploy sends a complete spec, so a count + recorded here is what keeps a scaled worker scaled; \`--instances\` overrides + it for one deploy. Defaults to 1. + `, + examples: [3], + tags, + links, + }), + ), + source: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Directory holding the worker's code, relative to the project root, when it + does not live at \`supabase///\`. + `, + examples: ["packages/api"], + tags, + links, + }), + ), +}); + +/** + * `[workers]` — a project-wide `root` plus one `[workers.]` table per + * worker, mirroring the `[functions.]` convention in the same file. + * + * `root` names the directory workers are grouped in, relative to `supabase/`; + * a single worker whose code lives somewhere else entirely uses its own + * `source` instead, which is anchored to the project root and so can leave + * `supabase/`. + */ +export const workers = Schema.StructWithRest( + Schema.Struct({ + root: Schema.optionalKey( + Schema.String.annotate({ + description: dedent` + Directory workers are grouped in, relative to \`supabase/\`. Defaults to + \`workers\`. + `, + examples: ["services"], + tags, + links, + }), + ), + }), + [Schema.Record(workerName, worker)], +) + .annotate({ + default: {}, + description: "Worker-specific configuration keyed by worker name.", + tags, + }) + .pipe(Schema.withDecodingDefault(Effect.succeed({}))); diff --git a/packages/config/src/workers.unit.test.ts b/packages/config/src/workers.unit.test.ts new file mode 100644 index 0000000000..05bce87013 --- /dev/null +++ b/packages/config/src/workers.unit.test.ts @@ -0,0 +1,57 @@ +import { Schema } from "effect"; +import { describe, expect, test } from "vitest"; +import { workers } from "./workers.ts"; + +const decode = Schema.decodeUnknownSync(workers); + +const workerNamePattern = "^(?!root$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$"; + +describe("workers schema", () => { + test("decodes the project-wide root alongside per-worker tables", () => { + expect( + decode({ + root: "services", + api: { runtime: "node", size: "4gb", instances: 3, source: "packages/api" }, + }), + ).toEqual({ + root: "services", + api: { runtime: "node", size: "4gb", instances: 3, source: "packages/api" }, + }); + }); + + test("defaults to an empty section when the key is absent", () => { + expect(Schema.decodeUnknownSync(Schema.Struct({ workers }))({})).toEqual({ workers: {} }); + }); + + // Keys outside the DNS-label pattern fall outside the record's index + // signature and are dropped, the same way `[functions.]` treats a slug + // its own pattern does not match. `supabase workers new` validates the name + // up front so the CLI never writes one that would vanish here. + test("drops worker names that are not DNS labels", () => { + expect(decode({ Not_A_Label: {}, api: { runtime: "node" } })).toEqual({ + api: { runtime: "node" }, + }); + }); + + // Every dial is optional: a worker scaffolded by `supabase workers new` records + // only what it prompted for, and `push` resolves the rest from its own defaults. + test("decodes a worker table with no dials set", () => { + expect(decode({ api: {} })).toEqual({ api: {} }); + }); + + test("rejects a non-numeric instance count", () => { + expect(() => decode({ api: { instances: "three" } })).toThrow(); + }); + + test("includes worker properties in the generated JSON schema", () => { + const json = JSON.parse(JSON.stringify(Schema.toJsonSchemaDocument(workers).schema)); + const objectSchema = json.anyOf?.find((entry: { type?: string }) => entry?.type === "object"); + const workerSchema = objectSchema?.patternProperties?.[workerNamePattern]; + + expect(objectSchema?.properties?.root).toBeDefined(); + expect(workerSchema?.properties?.runtime).toBeDefined(); + expect(workerSchema?.properties?.size).toBeDefined(); + expect(workerSchema?.properties?.instances).toBeDefined(); + expect(workerSchema?.properties?.source).toBeDefined(); + }); +});