From 23977a41a9340fd33215fe2dab194fd934ac3a1e Mon Sep 17 00:00:00 2001 From: Daniel Grant <1670902+djgrant@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:13:18 +0100 Subject: [PATCH] Add self-contained reconciler example (#29) --- examples/reconciler/README.md | 25 ++++++++ examples/reconciler/package.json | 20 +++++++ examples/reconciler/src/index.ts | 33 +++++++++++ examples/reconciler/src/static-site.ts | 63 +++++++++++++++++++++ examples/reconciler/test/reconciler.test.ts | 36 ++++++++++++ examples/reconciler/tsconfig.json | 4 ++ examples/reconciler/tsup.config.ts | 8 +++ pnpm-lock.yaml | 19 +++++++ 8 files changed, 208 insertions(+) create mode 100644 examples/reconciler/README.md create mode 100644 examples/reconciler/package.json create mode 100644 examples/reconciler/src/index.ts create mode 100644 examples/reconciler/src/static-site.ts create mode 100644 examples/reconciler/test/reconciler.test.ts create mode 100644 examples/reconciler/tsconfig.json create mode 100644 examples/reconciler/tsup.config.ts diff --git a/examples/reconciler/README.md b/examples/reconciler/README.md new file mode 100644 index 0000000..428eea3 --- /dev/null +++ b/examples/reconciler/README.md @@ -0,0 +1,25 @@ +# Reconciler + +This example deploys two static sites from an ordinary Node.js program. It does not +compile a Notation project or start the Notation CLI. + +[`src/index.ts`](./src/index.ts) is the complete program. It defines the desired +resources inline, opens a SQLite state backend, and passes the resources directly to the +reconciler. [`src/static-site.ts`](./src/static-site.ts) defines the local provider +operations used to create, read, update, and delete each site. + +Run it from the repository root: + +```sh +pnpm --filter reconciler-example demo +``` + +The generated sites are written to `sites/`, and deployment state is stored in +`sites.db`. Change the resource configuration and run the command again to update the +sites. Remove a resource from the array and run it again to delete that site. + +Run the integration test with: + +```sh +pnpm --filter reconciler-example test +``` diff --git a/examples/reconciler/package.json b/examples/reconciler/package.json new file mode 100644 index 0000000..754b89a --- /dev/null +++ b/examples/reconciler/package.json @@ -0,0 +1,20 @@ +{ + "private": true, + "name": "reconciler-example", + "type": "module", + "scripts": { + "build": "tsup --clean", + "demo": "pnpm build && node dist/index.js", + "test": "vitest run --root ../.. examples/reconciler/test/reconciler.test.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@notation/reconciler": "workspace:*", + "@notation/resource": "workspace:*", + "@notation/state-sqlite": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.13.4", + "vitest": "^4.1.10" + } +} diff --git a/examples/reconciler/src/index.ts b/examples/reconciler/src/index.ts new file mode 100644 index 0000000..4a85756 --- /dev/null +++ b/examples/reconciler/src/index.ts @@ -0,0 +1,33 @@ +import { Reconciler, createResourceRegistry } from "@notation/reconciler"; +import { SqliteStateBackend } from "@notation/state-sqlite"; +import { StaticSite } from "./static-site"; + +const state = new SqliteStateBackend("sites.db"); + +const resources = [ + new StaticSite({ + id: "documentation", + config: { + siteDirectory: "sites/docs", + html: "

Documentation

\n", + }, + }), + new StaticSite({ + id: "status", + config: { + siteDirectory: "sites/status", + html: "

All systems operational

\n", + }, + }), +]; + +const reconciler = new Reconciler({ + state, + registry: createResourceRegistry([StaticSite]), +}); + +try { + await reconciler.deploy(resources); +} finally { + state.close(); +} diff --git a/examples/reconciler/src/static-site.ts b/examples/reconciler/src/static-site.ts new file mode 100644 index 0000000..297a00c --- /dev/null +++ b/examples/reconciler/src/static-site.ts @@ -0,0 +1,63 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { resource } from "@notation/resource"; + +type StaticSiteApi = { + Key: { siteDirectory: string }; + CreateParams: { siteDirectory: string; html: string }; + UpdateParams: { siteDirectory: string; html: string }; + ReadResult: { html: string }; +}; + +class SiteNotFound extends Error { + readonly name = "SiteNotFound"; +} + +const staticSite = resource({ type: "local/site/static" }); + +export const StaticSite = staticSite + .defineSchema({ + siteDirectory: { + propertyType: "param", + presence: "required", + primaryKey: true, + }, + html: { + propertyType: "param", + presence: "required", + }, + } as const) + .defineOperations({ + create: async ({ siteDirectory, html }) => { + await mkdir(siteDirectory, { recursive: true }); + await writeFile(path.join(siteDirectory, "index.html"), html, "utf8"); + }, + read: async ({ siteDirectory }) => { + try { + const html = await readFile( + path.join(siteDirectory, "index.html"), + "utf8", + ); + return { html }; + } catch (error) { + if (isFileMissing(error)) throw new SiteNotFound(siteDirectory); + throw error; + } + }, + update: async (_key, _patch, { siteDirectory, html }) => { + await writeFile(path.join(siteDirectory, "index.html"), html, "utf8"); + }, + delete: async ({ siteDirectory }) => { + await rm(siteDirectory, { recursive: true }); + }, + notFoundOnError: [{ name: "SiteNotFound", reason: "site was removed" }], + }); + +function isFileMissing(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ); +} diff --git a/examples/reconciler/test/reconciler.test.ts b/examples/reconciler/test/reconciler.test.ts new file mode 100644 index 0000000..7c0f2de --- /dev/null +++ b/examples/reconciler/test/reconciler.test.ts @@ -0,0 +1,36 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const directories: string[] = []; +const originalWorkingDirectory = process.cwd(); + +afterEach(async () => { + process.chdir(originalWorkingDirectory); + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("reconciler example", () => { + it("runs as a self-contained program", async () => { + const workingDirectory = await mkdtemp( + path.join(tmpdir(), "notation-reconciler-test-"), + ); + directories.push(workingDirectory); + process.chdir(workingDirectory); + + await import("../src/index"); + + await expect(readFile("sites/docs/index.html", "utf8")).resolves.toBe( + "

Documentation

\n", + ); + await expect(readFile("sites/status/index.html", "utf8")).resolves.toBe( + "

All systems operational

\n", + ); + await expect(readFile("sites.db")).resolves.not.toHaveLength(0); + }); +}); diff --git a/examples/reconciler/tsconfig.json b/examples/reconciler/tsconfig.json new file mode 100644 index 0000000..c8e3b89 --- /dev/null +++ b/examples/reconciler/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "tsconfig/base.json", + "include": ["src", "test"] +} diff --git a/examples/reconciler/tsup.config.ts b/examples/reconciler/tsup.config.ts new file mode 100644 index 0000000..818264f --- /dev/null +++ b/examples/reconciler/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8ee0b3..0b23b2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,25 @@ importers: specifier: workspace:* version: link:../../packages/cli + examples/reconciler: + dependencies: + '@notation/reconciler': + specifier: workspace:* + version: link:../../packages/reconciler + '@notation/resource': + specifier: workspace:* + version: link:../../packages/resource + '@notation/state-sqlite': + specifier: workspace:* + version: link:../../packages/state-sqlite + devDependencies: + '@types/node': + specifier: ^22.13.4 + version: 22.13.4 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.13.4)(vite@8.1.3(@types/node@22.13.4)(esbuild@0.28.1)(jiti@2.7.0)) + packages/aws: dependencies: '@notation/aws.iac':