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
34 changes: 34 additions & 0 deletions .github/workflows/config-types.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Config types

on:
pull_request:
paths:
- "config/metadata/**"
- "config-types/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/config-types.yml"
push:
branches: [notion-main]
paths:
- "config/metadata/**"
- "config-types/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/config-types.yml"

permissions:
contents: read

jobs:
config-types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
cache: npm
- run: npm ci
- run: npm test
- run: npm pack --dry-run
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ ko
ko*.tar.gz
crane
crane*.tar.gz

# TypeScript configuration package tooling
node_modules/
*.tgz
9 changes: 9 additions & 0 deletions NOTION_FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,12 @@ repo, then consumed by `docker/refinery/Dockerfile` in notion-next:

The pubsub package's Redis tests require a local Redis on `:6379`; all other tests
run standalone.

## TypeScript configuration types

This fork also owns the `@notionhq/refinery-config-types` package, generated
from the canonical configuration metadata. When upgrading metadata, run
`npm ci && npm run generate && npm test` and include the updated declarations.
See [config-types/README.md](config-types/README.md) for consumption, provenance,
and validation limitations. Carry the package and its CI check forward when
rebasing the Notion patch set onto a new upstream release.
70 changes: 70 additions & 0 deletions config-types/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Refinery configuration types

`@notionhq/refinery-config-types` is a types-only package generated from this
fork's canonical `config/metadata/configMeta.yaml` and `rulesMeta.yaml` files.
It exports `RefineryConfig`, `RefineryRules`, and individual section/sampler types.
There are no runtime dependencies or install/build hooks.

```typescript
import type { RefineryConfig, RefineryRules } from "@notionhq/refinery-config-types"

const config = {
General: { ConfigurationVersion: 2 },
} satisfies RefineryConfig

const rules = {
RulesVersion: 2,
Samplers: { __default__: { DeterministicSampler: { SampleRate: 1 } } },
} satisfies RefineryRules
```

## Regeneration and verification

From the repository root, with Node 22.13 or newer:

```sh
npm ci
npm run generate
npm test
npm pack --dry-run
```

Commit regenerated `config-types/index.d.ts` whenever the metadata or generator
changes. CI checks that generation is reproducible and compiles both valid and
intentionally invalid consumer examples. Generator fixture tests also compile
small schemas to check required fields, conflicting fields, conditional
requirements (including defaults), and failure on unknown field types or invalid
constraint references. Run them alone with:

```sh
node --experimental-strip-types --test config-types/generate.test.mjs
```

The declaration header records a hash
of both metadata files so packaging-only commits do not imply a schema change.

The small structural mapping in `generate.ts` connects rules, conditions, and
samplers because the metadata describes those object fields without their full
TypeScript structure. Unknown field types fail generation and must be mapped
when upgrading Refinery.

## Consumption and upgrades

The root package manifest supports an authenticated Git dependency pinned to a
full immutable commit SHA in `makenotion/refinery`. Generated declarations are
checked in, so consumers do not need Go, the generator, or its development tools.
The package is private to prevent accidental registry publication. Registry
publishing and notion-next dependency integration are separate changes.

Choose a package revision whose metadata matches the deployed Refinery version;
update the pin and generated consumer config when upgrading that version. A
packaging-only commit can differ from the image commit while having identical
metadata. Do not depend on a moving branch. Private Git authentication must be
verified in the consuming repository's install and CI environment.

These types check structure, choices, required fields, conflicting fields and
exactly one sampler per target. They do not replace `refinery --validate`:
numeric ranges, duration syntax, secret expansion and other runtime constraints
remain the binary's responsibility. Metadata may also include deprecated options
or differ from runtime implementation; the package follows that metadata rather
than silently introducing its own configuration schema.
110 changes: 110 additions & 0 deletions config-types/generate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { generateConfigTypes } from "./generate.ts";

// Compile the generated fixture as a consumer would. @ts-expect-error also
// fails compilation if a constraint is accidentally weakened.
function checkConsumer(fields, examples) {
const directory = mkdtempSync(join(tmpdir(), "refinery-types-fixture-"));
try {
const declarations = generateConfigTypes({
groups: [{ name: "Example", fields }],
});
writeFileSync(join(directory, "index.d.ts"), declarations);
writeFileSync(
join(directory, "consumer.ts"),
'import type { RefineryConfig } from "./index";\n' + examples,
);
const result = spawnSync(process.execPath, [
fileURLToPath(new URL("../node_modules/typescript/bin/tsc", import.meta.url)),
"--noEmit", "--strict", "--exactOptionalPropertyTypes",
"--skipLibCheck", "--types", "node",
"--typeRoots", fileURLToPath(new URL("../node_modules/@types", import.meta.url)),
join(directory, "consumer.ts"),
], { encoding: "utf8" });
assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}

test("required fields require the containing group", () => {
checkConsumer([
{ name: "Value", type: "string", validations: [{ type: "required" }] },
], `
const valid: RefineryConfig = { Example: { Value: "ok" } };
// @ts-expect-error globally required group is missing
const missingGroup: RefineryConfig = {};
// @ts-expect-error required field is missing
const missingField: RefineryConfig = { Example: {} };
`);
});

test("requiredInGroup permits omission of the group, but not its field", () => {
checkConsumer([
{ name: "Value", type: "string", validations: [{ type: "requiredInGroup" }] },
], `
const omitted: RefineryConfig = {};
const valid: RefineryConfig = { Example: { Value: "ok" } };
// @ts-expect-error group is present without its required field
const invalid: RefineryConfig = { Example: {} };
`);
});

test("conflictsWith rejects both fields, including through a variable", () => {
checkConsumer([
{ name: "Left", type: "string", validations: [{ type: "conflictsWith", arg: "Right" }] },
{ name: "Right", type: "string" },
], `
const neither: RefineryConfig = { Example: {} };
const left: RefineryConfig = { Example: { Left: "a" } };
const right: RefineryConfig = { Example: { Right: "b" } };
const both = { Left: "a", Right: "b" };
// @ts-expect-error mutually exclusive fields
const invalid: RefineryConfig = { Example: both };
`);
});

test("requiredWith requires its dependent field only when the trigger is set", () => {
checkConsumer([
{ name: "Trigger", type: "bool" },
{ name: "Dependent", type: "string", validations: [{ type: "requiredWith", arg: "Trigger" }] },
], `
const neither: RefineryConfig = { Example: {} };
const dependent: RefineryConfig = { Example: { Dependent: "a" } };
const both: RefineryConfig = { Example: { Trigger: true, Dependent: "a" } };
// @ts-expect-error missing dependent field
const invalid: RefineryConfig = { Example: { Trigger: true } };
`);
});

test("a default satisfies requiredWith without an explicit dependent field", () => {
checkConsumer([
{ name: "Trigger", type: "bool" },
{ name: "Dependent", type: "string", default: "default",
validations: [{ type: "requiredWith", arg: "Trigger" }] },
], `
const valid: RefineryConfig = { Example: { Trigger: true } };
`);
});

test("unknown field types fail generation instead of silently weakening types", () => {
assert.throws(() => generateConfigTypes({
groups: [{ name: "Example", fields: [{ name: "Value", type: "futuretype" }] }],
}), /Unhandled metadata field type "futuretype" for Example.Value/);
});

test("invalid constraint references fail generation", () => {
for (const type of ["requiredWith", "conflictsWith"]) {
assert.throws(() => generateConfigTypes({
groups: [{ name: "Example", fields: [
{ name: "Value", type: "string", validations: [{ type, arg: "Missing" }] },
] }],
}), new RegExp("Invalid " + type + " reference for Example.Value"));
}
});
Loading
Loading