From eda11feb419b2321121de902e5056ca84ea16744 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 28 Aug 2026 20:07:17 +0200 Subject: [PATCH 1/2] feat: add minimal Chronicle TypeScript client sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Chronicle/TypeScript — a small runnable Node.js/TypeScript sample using the @cratis/chronicle client: append a VisitorArrived event, let a reactor respond with a VisitorWelcomed side-effect event, and read the combined history back. Includes a single-container docker-compose.yml, pinned npm dependencies, a catalog entry in samples.json, and a row in the samples table. --- Chronicle/TypeScript/README.md | 133 ++++++++++++++++++++++++ Chronicle/TypeScript/docker-compose.yml | 6 ++ Chronicle/TypeScript/events.ts | 29 ++++++ Chronicle/TypeScript/index.ts | 61 +++++++++++ Chronicle/TypeScript/package.json | 25 +++++ Chronicle/TypeScript/reactor.ts | 25 +++++ Chronicle/TypeScript/tsconfig.json | 24 +++++ README.md | 1 + samples.json | 28 +++++ 9 files changed, 332 insertions(+) create mode 100644 Chronicle/TypeScript/README.md create mode 100644 Chronicle/TypeScript/docker-compose.yml create mode 100644 Chronicle/TypeScript/events.ts create mode 100644 Chronicle/TypeScript/index.ts create mode 100644 Chronicle/TypeScript/package.json create mode 100644 Chronicle/TypeScript/reactor.ts create mode 100644 Chronicle/TypeScript/tsconfig.json diff --git a/Chronicle/TypeScript/README.md b/Chronicle/TypeScript/README.md new file mode 100644 index 00000000..849321fc --- /dev/null +++ b/Chronicle/TypeScript/README.md @@ -0,0 +1,133 @@ +
+ +# Chronicle TypeScript Client + +### Append a fact from Node.js. Let a reactor respond. Read the combined history. + +**Chronicle · Node.js · TypeScript** + +[Back to all samples](../../README.md) + +
+ +--- + +## The idea + +A guestbook records visitors. The program appends one immutable `VisitorArrived` event, a reactor responds by appending a `VisitorWelcomed` event, and the program reads the complete history back — all from Node.js with the [`@cratis/chronicle`](https://www.npmjs.com/package/@cratis/chronicle) client. + +```mermaid +flowchart LR + program[Node.js program] -->|append VisitorArrived| log[Chronicle event log] + log -->|observe| reactor[ConciergeReactor] + reactor -->|append VisitorWelcomed| log + log -->|history| program +``` + +There is no HTTP API, projection, or frontend in the way. This sample is about the TypeScript client and the event log itself. + +## Pinned versions + +| Piece | Version | +| --- | --- | +| [`@cratis/chronicle`](https://www.npmjs.com/package/@cratis/chronicle) | `3.1.0` | +| [`@cratis/fundamentals`](https://www.npmjs.com/package/@cratis/fundamentals) | `7.18.2` | +| Chronicle server image | `cratis/chronicle:latest-development` | +| Node.js | 23 or newer | + +The npm dependencies are pinned exactly in [`package.json`](./package.json). The `latest-development` image tag is a moving development tag; the sample intentionally tracks the current development build of the Chronicle server. + +> [!WARNING] +> **Known incompatibility (2026-08-28):** the published `@cratis/chronicle` 3.1.0 client uses gRPC contracts (16.13.4) that call `EventStores/Ensure`, while Chronicle servers from v16.33.1 onward renamed that RPC to `EnsureEventStore`. Until a client release adopts the renamed contracts, `latest-development` fails with `UNIMPLEMENTED: Service is unimplemented`. The last verified compatible server tag is `cratis/chronicle:16.33.0-development`: +> +> ```bash +> docker run --rm -p 35000:35000 -p 8080:8080 cratis/chronicle:16.33.0-development +> ``` + +## Run it + +You need Node.js 23 or newer, npm, and Docker. + +Start Chronicle with the included [`docker-compose.yml`](./docker-compose.yml) — the development image bundles MongoDB, so one container is enough: + +```bash +cd Chronicle/TypeScript +docker compose up -d +``` + +Install the dependencies and run the sample: + +```bash +npm install +npm start +``` + +You should see the three steps in order: + +```text +[append] VisitorArrived('Ada') appended to 'reception-guestbook' at sequence 0. +[react] ConciergeReactor saw VisitorArrived('Ada') at sequence 0 and responds with a VisitorWelcomed event. +[read] Guestbook 'reception-guestbook' history — 2 event(s): + [seq 0] VisitorArrived: {"name":"Ada"} + [seq 1] VisitorWelcomed: {"name":"Ada","greeting":"Welcome, Ada!"} +``` + +Pass a name to record someone else — every run appends new facts to the same history: + +```bash +npm start -- Grace +``` + +Open Chronicle Workbench at and select the `TypeScriptGuestbook` event store to inspect the same history visually. + +## Clean up + +Stop and remove the container when you are done: + +```bash +docker compose down +``` + +The event store lives inside the container, so removing it also removes the recorded history. Remove the sample's local artifacts with: + +```bash +npm run clean +``` + +## Code tour + +| File | What it shows | +| --- | --- | +| [`events.ts`](./events.ts) | Two small, past-tense `@eventType()` classes | +| [`reactor.ts`](./reactor.ts) | A `@reactor()` that returns a side-effect event | +| [`index.ts`](./index.ts) | Connect, append, wait for observers, and read the history | +| [`docker-compose.yml`](./docker-compose.yml) | The single-container local Chronicle server | + +The client setup is intentionally short: + +```typescript +const client = new ChronicleClient(ChronicleOptions.development()); +const store = await client.getEventStore('TypeScriptGuestbook'); +const result = await store.eventLog.append(GUESTBOOK_ID, new VisitorArrived(name)); +``` + +After the append, `result.waitForCompletion()` waits until every observer — here the `ConciergeReactor` — has caught up, so the read that follows sees the reactor's side effect instead of racing it. + +Set the `CHRONICLE_CONNECTION` environment variable to point the sample at a different Chronicle server (for example `chronicle://localhost:35000`). + +## Build check + +```bash +npm run compile +``` + +Type-checks the sample with the TypeScript compiler. + +## Make it yours + +- Record a `VisitorLeft` event and react to it differently. +- Give `VisitorWelcomed` its own event source to build a separate welcome log. +- Move on to the [Chronicle Backend](../Backend/README.md) sample to see the same append-and-read journey from .NET behind an HTTP API. + +> [!NOTE] +> This focused sample deliberately leaves out projections, read models, constraints, transactions, tenancy, authentication, and production configuration. diff --git a/Chronicle/TypeScript/docker-compose.yml b/Chronicle/TypeScript/docker-compose.yml new file mode 100644 index 00000000..62a73e71 --- /dev/null +++ b/Chronicle/TypeScript/docker-compose.yml @@ -0,0 +1,6 @@ +services: + chronicle: + image: cratis/chronicle:latest-development + ports: + - 35000:35000 + - 8080:8080 diff --git a/Chronicle/TypeScript/events.ts b/Chronicle/TypeScript/events.ts new file mode 100644 index 00000000..5ee4a758 --- /dev/null +++ b/Chronicle/TypeScript/events.ts @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { eventType } from '@cratis/chronicle'; + +/** + * A visitor has signed the guestbook. + * This event is the source of truth for every visit — if there is no + * VisitorArrived event, the visit never happened. + */ +@eventType() +export class VisitorArrived { + constructor(readonly name: string = '') {} +} + +/** + * A visitor has been welcomed. + * + * Appended by {@link ConciergeReactor} as a side effect of a {@link VisitorArrived} + * event — the program never appends this event directly, which is what makes the + * reaction visible in the history this sample reads back. + */ +@eventType() +export class VisitorWelcomed { + constructor( + readonly name: string = '', + readonly greeting: string = '' + ) {} +} diff --git a/Chronicle/TypeScript/index.ts b/Chronicle/TypeScript/index.ts new file mode 100644 index 00000000..f703801f --- /dev/null +++ b/Chronicle/TypeScript/index.ts @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import 'reflect-metadata'; +import { ChronicleClient, ChronicleOptions } from '@cratis/chronicle'; +import { VisitorArrived, VisitorWelcomed } from './events'; + +// Side-effect import so the @reactor decorator runs and the reactor is +// discovered and registered with the event store on connect. +import './reactor'; + +/** The single guestbook every visit in this sample is recorded against. */ +const GUESTBOOK_ID = 'reception-guestbook'; + +async function run(): Promise { + // The sample keeps its artifacts (events, reactor) as flat files next to this + // one, so discovery only needs to scan the top level — this also keeps the + // sample's local node_modules out of the scan. + const discoveryPatterns = ['*.ts', '!*.d.ts', '!*.spec.ts']; + const options = process.env.CHRONICLE_CONNECTION + ? ChronicleOptions.fromConnectionString(process.env.CHRONICLE_CONNECTION, { discoveryPatterns }) + : ChronicleOptions.development({ discoveryPatterns }); + + const client = new ChronicleClient(options); + + try { + const store = await client.getEventStore('TypeScriptGuestbook'); + + // 1. Append — record the immutable fact that a visitor arrived. + const name = process.argv[2] ?? 'Ada'; + const result = await store.eventLog.append(GUESTBOOK_ID, new VisitorArrived(name)); + console.log(`[append] VisitorArrived('${name}') appended to '${GUESTBOOK_ID}' at sequence ${result.sequenceNumber.value}.`); + + // 2. React — wait until every observer of the append (the ConciergeReactor) + // has either caught up or failed. Reading immediately without waiting can + // race the reactor's asynchronous processing and miss its side effect. + const completion = await result.waitForCompletion(); + if (!completion.isSuccess) { + console.error(`[react] ${completion.failedPartitions.length} observer partition(s) failed while catching up on the append.`); + process.exitCode = 1; + return; + } + + // 3. Read — the history now holds both the appended fact and the + // reactor's side-effect event. + const history = await store.eventLog.getForEventSourceIdAndEventTypes(GUESTBOOK_ID, [VisitorArrived, VisitorWelcomed]); + console.log(`[read] Guestbook '${GUESTBOOK_ID}' history — ${history.length} event(s):`); + for (const entry of history) { + console.log(` [seq ${entry.context.sequenceNumber}] ${entry.eventType.id.value}: ${JSON.stringify(entry.content)}`); + } + } finally { + client.dispose(); + } + + process.exit(process.exitCode ?? 0); +} + +run().catch(error => { + console.error('Unhandled error:', error); + process.exit(1); +}); diff --git a/Chronicle/TypeScript/package.json b/Chronicle/TypeScript/package.json new file mode 100644 index 00000000..41438eb2 --- /dev/null +++ b/Chronicle/TypeScript/package.json @@ -0,0 +1,25 @@ +{ + "name": "@cratis/sample-chronicle-typescript", + "version": "0.0.0", + "description": "Minimal Node.js TypeScript sample for the Cratis Chronicle client: append an event, react to it, and read the history", + "author": "Cratis", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "compile": "tsc -b", + "build": "tsc -b", + "start": "tsx index.ts", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "dependencies": { + "@cratis/chronicle": "3.1.0", + "@cratis/fundamentals": "7.18.2", + "reflect-metadata": "0.2.2" + }, + "devDependencies": { + "@types/node": "26.4.0", + "tsx": "4.23.12", + "typescript": "6.0.3" + } +} diff --git a/Chronicle/TypeScript/reactor.ts b/Chronicle/TypeScript/reactor.ts new file mode 100644 index 00000000..bb9fe668 --- /dev/null +++ b/Chronicle/TypeScript/reactor.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { reactor, EventContext } from '@cratis/chronicle'; +import { VisitorArrived, VisitorWelcomed } from './events'; + +/** + * Reacts to visitors signing the guestbook by welcoming them. + * + * Reactors are the "if this then that" mechanism of event sourcing: they observe + * events and produce side effects. Returning an event from a handler appends it — + * here to the same event source that triggered the reactor — so the welcome + * becomes a fact in the history alongside the arrival. + * + * Key rules: + * - Handlers must be idempotent — the reactor may be called more than once for the same event. + * - Never query state inside a reactor; use the event data directly. + */ +@reactor() +export class ConciergeReactor { + async visitorArrived(event: VisitorArrived, context: EventContext): Promise { + console.log(`[react] ConciergeReactor saw VisitorArrived('${event.name}') at sequence ${context.sequenceNumber} and responds with a VisitorWelcomed event.`); + return new VisitorWelcomed(event.name, `Welcome, ${event.name}!`); + } +} diff --git a/Chronicle/TypeScript/tsconfig.json b/Chronicle/TypeScript/tsconfig.json new file mode 100644 index 00000000..4152969c --- /dev/null +++ b/Chronicle/TypeScript/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "forceConsistentCasingInFileNames": true, + "ignoreDeprecations": "6.0" + }, + "include": [ + "./**/*.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/README.md b/README.md index 81383fdc..9782ed82 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ From a small Chronicle event-sourcing process to a React application composed wi | Sample | Experience | Products | Start here | | --- | --- | --- | --- | | **[Chronicle Backend](./Chronicle/Backend/README.md)** | HTTP API | Chronicle | Append one immutable fact and read an event source's history. | +| **[Chronicle TypeScript Client](./Chronicle/TypeScript/README.md)** | Terminal | Chronicle | Append a fact from Node.js, let a reactor respond, and read the history. | | **[Chronicle Processing](./Chronicle/Processing/README.md)** | HTTP API | Chronicle, Fundamentals | Compare a projection, reducer, and reactor on one event stream. | | **[Idea Loom — Arc + React](./Arc/React/README.md)** | React | Arc, Components, Fundamentals | Follow a typed command and observable query from C# to a polished UI. | | **[Chronicle Multi-Tenancy](./Chronicle/MultiTenancy/README.md)** | HTTP API | Arc, Chronicle, Fundamentals | Isolate the same typed workflow across tenant namespaces. | diff --git a/samples.json b/samples.json index 162377b1..70ce35b1 100644 --- a/samples.json +++ b/samples.json @@ -66,6 +66,34 @@ ], "featured": true }, + { + "id": "chronicle-typescript", + "title": "Chronicle TypeScript Client", + "tagline": "Append a fact from Node.js, let a reactor respond, and read the combined history.", + "track": "getting-started", + "path": "Chronicle/TypeScript", + "sourceUrl": "https://github.com/Cratis/Samples/tree/main/Chronicle/TypeScript", + "ui": "Terminal", + "level": "Start here", + "products": ["Chronicle"], + "runtime": ["Node.js", "Chronicle"], + "prerequisites": ["Node.js 23 or newer", "npm", "Docker"], + "highlights": [ + "Connect to Chronicle from Node.js with the @cratis/chronicle client", + "Append an immutable event and wait for its observers to catch up", + "Return a side-effect event from a reactor and read both facts back" + ], + "verification": [ + "npm --prefix Chronicle/TypeScript install", + "npm --prefix Chronicle/TypeScript run compile" + ], + "limitations": [ + "The sample is a single console run and intentionally leaves out projections, read models, and a frontend.", + "It runs against the local Chronicle development container and does not include automated specs.", + "The published @cratis/chronicle client must match the Chronicle server's gRPC contracts; the README lists the currently verified server tag." + ], + "featured": true + }, { "id": "chronicle-processing", "title": "Chronicle Processing", From 604e291f845f22fd53bc6a2be8f1527f1731fbc7 Mon Sep 17 00:00:00 2001 From: woksin Date: Fri, 28 Aug 2026 20:36:45 +0200 Subject: [PATCH 2/2] fix: pin @cratis/chronicle 3.1.1 which works against current Chronicle servers - 3.1.1 adopts chronicle.contracts 17 with the renamed EnsureEventStore RPC, so the sample runs against cratis/chronicle:latest-development - remove the known-incompatibility warning that no longer applies --- Chronicle/TypeScript/README.md | 9 +-------- Chronicle/TypeScript/package.json | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Chronicle/TypeScript/README.md b/Chronicle/TypeScript/README.md index 849321fc..148bae9d 100644 --- a/Chronicle/TypeScript/README.md +++ b/Chronicle/TypeScript/README.md @@ -30,20 +30,13 @@ There is no HTTP API, projection, or frontend in the way. This sample is about t | Piece | Version | | --- | --- | -| [`@cratis/chronicle`](https://www.npmjs.com/package/@cratis/chronicle) | `3.1.0` | +| [`@cratis/chronicle`](https://www.npmjs.com/package/@cratis/chronicle) | `3.1.1` | | [`@cratis/fundamentals`](https://www.npmjs.com/package/@cratis/fundamentals) | `7.18.2` | | Chronicle server image | `cratis/chronicle:latest-development` | | Node.js | 23 or newer | The npm dependencies are pinned exactly in [`package.json`](./package.json). The `latest-development` image tag is a moving development tag; the sample intentionally tracks the current development build of the Chronicle server. -> [!WARNING] -> **Known incompatibility (2026-08-28):** the published `@cratis/chronicle` 3.1.0 client uses gRPC contracts (16.13.4) that call `EventStores/Ensure`, while Chronicle servers from v16.33.1 onward renamed that RPC to `EnsureEventStore`. Until a client release adopts the renamed contracts, `latest-development` fails with `UNIMPLEMENTED: Service is unimplemented`. The last verified compatible server tag is `cratis/chronicle:16.33.0-development`: -> -> ```bash -> docker run --rm -p 35000:35000 -p 8080:8080 cratis/chronicle:16.33.0-development -> ``` - ## Run it You need Node.js 23 or newer, npm, and Docker. diff --git a/Chronicle/TypeScript/package.json b/Chronicle/TypeScript/package.json index 41438eb2..c3c8e291 100644 --- a/Chronicle/TypeScript/package.json +++ b/Chronicle/TypeScript/package.json @@ -13,7 +13,7 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { - "@cratis/chronicle": "3.1.0", + "@cratis/chronicle": "3.1.1", "@cratis/fundamentals": "7.18.2", "reflect-metadata": "0.2.2" },