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
126 changes: 126 additions & 0 deletions Chronicle/TypeScript/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<div align="center">

# 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)

</div>

---

## 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.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.

## 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 <http://localhost:8080> 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.
6 changes: 6 additions & 0 deletions Chronicle/TypeScript/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
services:
chronicle:
image: cratis/chronicle:latest-development
ports:
- 35000:35000
- 8080:8080
29 changes: 29 additions & 0 deletions Chronicle/TypeScript/events.ts
Original file line number Diff line number Diff line change
@@ -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 = ''
) {}
}
61 changes: 61 additions & 0 deletions Chronicle/TypeScript/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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);
});
25 changes: 25 additions & 0 deletions Chronicle/TypeScript/package.json
Original file line number Diff line number Diff line change
@@ -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.1",
"@cratis/fundamentals": "7.18.2",
"reflect-metadata": "0.2.2"
},
"devDependencies": {
"@types/node": "26.4.0",
"tsx": "4.23.12",
"typescript": "6.0.3"
}
}
25 changes: 25 additions & 0 deletions Chronicle/TypeScript/reactor.ts
Original file line number Diff line number Diff line change
@@ -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<VisitorWelcomed> {
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}!`);
}
}
24 changes: 24 additions & 0 deletions Chronicle/TypeScript/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
28 changes: 28 additions & 0 deletions samples.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading