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
3 changes: 3 additions & 0 deletions packages/config/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -37,6 +38,7 @@ const baseProjectConfigFields = {
realtime,
storage,
studio,
workers,

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 Regenerate the published config schema

Adding workers to ProjectConfigSchema without updating apps/docs/public/cli/config.schema.json leaves the schema served at PROJECT_CONFIG_SCHEMA_URL stale. That tracked asset currently has no workers property and sets top-level additionalProperties to false, so editors using the documented $schema URL will flag the newly supported [workers] section as invalid and provide no completion for it; regenerate the public asset via apps/cli/scripts/generate-docs.ts in this change.

Useful? React with 👍 / 👎.

experimental,
};

Expand All @@ -52,6 +54,7 @@ const remoteProjectConfig = Schema.Struct({
realtime,
storage,
studio,
workers,
experimental,
}).pipe(Schema.withDecodingDefault(Effect.succeed({})));

Expand Down
104 changes: 104 additions & 0 deletions packages/config/src/workers.ts
Original file line number Diff line number Diff line change
@@ -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])?$/),
);
Comment on lines +21 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the reserved root key when checks are disabled

When an unselected [remotes.<name>.workers] section sets root, loading the project config fails: packages/config/src/io.ts decodes every unselected remote with disableChecks: true, which disables this pattern check, so the record index signature also claims root and attempts to decode its string value as a worker struct. The exclusion needs to be represented in a way that survives check-disabled remote decoding.

Useful? React with 👍 / 👎.


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({
Comment on lines +49 to +50

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 Reject fractional worker instance counts

A config such as instances = 1.5 currently decodes successfully, but the generated V2DeployAWorkerInput schema requires instances to satisfy Schema.isInt() (packages/api/src/generated/contracts.ts:10655), and the API client validates that input before sending it. Consequently a value accepted by the project-config schema will make a later worker deploy fail; refine this field to an integer so the invalid config is rejected at load time.

Useful? React with 👍 / 👎.

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/<workers root>/<name>/\`.
`,
examples: ["packages/api"],
tags,
links,
}),
),
});

/**
* `[workers]` — a project-wide `root` plus one `[workers.<name>]` table per
* worker, mirroring the `[functions.<slug>]` 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({})));
57 changes: 57 additions & 0 deletions packages/config/src/workers.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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.<slug>]` 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();
});
});
Loading