From 6e237363f582f06c0e6d857fdba6b81dfa04bc35 Mon Sep 17 00:00:00 2001
From: djgrant <1670902+djgrant@users.noreply.github.com>
Date: Sun, 19 Jul 2026 17:53:29 +0100
Subject: [PATCH] Add self-contained reconciler example
---
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':