@medicine-wheel/events 0.0.0-stage → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,182 @@
1
- # Temporary Holding Version
1
+ # @medicine-wheel/events
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The events a Medicine Wheel writes when something happens on it: a cycle
4
+ opened, a beat recorded, a ceremony closed, a circle created, a chronicle
5
+ episode registered, a relation woven. One catalog, the envelope the events
6
+ travel in, the files that describe them for any consumer, the log they wait in,
7
+ and the seam a broker plugs into.
8
+
9
+ The wheel keeps each record's latest state in its store. This package keeps the
10
+ moments: every write the wheel commits leaves one line in `events.jsonl`
11
+ beside the store. Nothing leaves the machine until a publisher is configured.
12
+ Episode 548 of the Miadi Chronicle is choosing the broker
13
+ (jgwill/medicine-wheel#164, miadisabelle/kherix-hermeneia#70).
14
+
15
+ ## For a consumer
16
+
17
+ | file | what it is |
18
+ |---|---|
19
+ | `schema/wheel-events.schema.json` | One JSON Schema (draft-07) that validates any wheel event: the envelope, plus each event's type, subject kind and payload. |
20
+ | `schema/asyncapi.json` | An AsyncAPI 3.0 document: one channel per event, addressed by its topic (`medicine-wheel.cycle.opened`). AsyncAPI is to events what OpenAPI is to HTTP; AsyncAPI Studio renders it and generators read it. It names no server, because no broker is chosen. |
21
+
22
+ ```js
23
+ const schema = require('@medicine-wheel/events/schema/wheel-events.schema.json');
24
+ const { WHEEL_EVENT_CATALOG, filterEvents } = require('@medicine-wheel/events');
25
+ ```
26
+
27
+ A payload carries references and labels: ids, a direction, a type. A cycle's
28
+ question, a beat's prose and a diary entry's text stay in the wheel. To read
29
+ one, fetch the record by `subject.id` from the wheel that wrote the event
30
+ (`source.instance`).
31
+
32
+ There is one exception. When two chronicle episodes are related, the
33
+ `chronicle.lineage.*` events carry the sentence that says why
34
+ (`payload.relation`). That sentence is already committed to the episode's
35
+ `episode.yaml`, so the event repeats it rather than releasing it. These
36
+ events say so with `privacy: { classification: "chronicle", content_included: true }`.
37
+
38
+ ## The envelope
39
+
40
+ jgwill/Miadi's `MiadiEventEnvelope` 0.1.0
41
+ (`rispecs/events/00-miadi-event-spine.rispec.md`):
42
+
43
+ ```json
44
+ {
45
+ "id": "mwev:1792019860666:4c509d0b",
46
+ "schema_version": "0.1.0",
47
+ "event_type": "medicine-wheel.cycle.opened",
48
+ "source": { "kind": "miadi", "system": "medicine-wheel", "instance": "ilex:8040" },
49
+ "subject": { "kind": "cycle", "id": "cycle-1792019860642-hzqvkk" },
50
+ "occurred_at": "2026-10-14T23:17:40.666Z",
51
+ "observed_at": "2026-10-14T23:17:40.666Z",
52
+ "correlation_id": "cycle-1792019860642-hzqvkk",
53
+ "idempotency_key": "mwev:1792019860666:4c509d0b",
54
+ "privacy": { "classification": "internal", "content_included": false },
55
+ "payload": { "current_direction": "east" }
56
+ }
57
+ ```
58
+
59
+ - `idempotency_key` equals `id`. A resend of the same line is the same
60
+ occurrence; a receiver that keeps keys recognises it.
61
+ - `correlation_id` groups: a cycle and the beats recorded in it, a ceremony and
62
+ its closing, a conclusion and the person it is about.
63
+ - An amendment names what moved in `payload.changed`. A write that changes
64
+ nothing writes no event.
65
+
66
+ ## The events
67
+
68
+ | family | events |
69
+ |---|---|
70
+ | cycle | `cycle.opened`, `cycle.amended`, `cycle.archived` |
71
+ | beat | `beat.recorded`, `beat.amended`, `beat.witnessed` |
72
+ | ceremony | `ceremony.opened`, `ceremony.closed`, `ceremony.amended`, `ceremony.removed` |
73
+ | diary | `diary.written`, `diary.amended`, `diary.removed` |
74
+ | circle | `circle.created`, `circle.amended`, `circle.removed` |
75
+ | chronicle episode | `chronicle.episode.registered`, `chronicle.episode.amended`, `chronicle.episode.removed` |
76
+ | memory conclusion | `memory.conclusion.recorded`, `memory.conclusion.judged`, `memory.conclusion.amended`, `memory.conclusion.removed` |
77
+ | node | `node.created`, `node.amended`, `node.removed` |
78
+ | chronicle lineage | `chronicle.lineage.made`, `chronicle.lineage.amended`, `chronicle.lineage.removed` — a relation between two chronicle episodes, with the why |
79
+ | relation | `relation.made`, `relation.amended`, `relation.removed` |
80
+
81
+ `WHEEL_EVENT_CATALOG` says, for each: what happened, which write produces it,
82
+ what its correlation groups, and every payload field. `PayloadOf<'beat.recorded'>`
83
+ is its payload type.
84
+
85
+ ## For a producer
86
+
87
+ ```ts
88
+ import { buildEvent, cycleWritten } from '@medicine-wheel/events';
89
+ import { appendEvent, eventLogPath } from '@medicine-wheel/events/log';
90
+
91
+ const input = cycleWritten(before, after, Object.keys(patch)); // null when nothing moved
92
+ if (input) appendEvent(eventLogPath(dataDir), buildEvent(input, { instance: process.env.MW_INSTANCE }));
93
+ ```
94
+
95
+ The deciders (`cycleWritten`, `beatWritten`, `beatWitnessed`, `ceremonyWritten`,
96
+ `diaryWritten`, `nodeWritten`, `conclusionJudged`, `relationWritten` and the
97
+ `*Removed` ones) are shared by the wheel's HTTP routes and the MCP server's
98
+ local store, so the same act leaves the same event whichever door it used.
99
+
100
+ ## Carried by git
101
+
102
+ Before any broker exists, the log can travel through the data repository it
103
+ already lives in. This is not a publisher: nothing here sends one event and
104
+ waits for a receipt.
105
+
106
+ ```ts
107
+ import { commitEventLog, followEventLog } from '@medicine-wheel/events/git';
108
+ ```
109
+
110
+ **On the wheel's side,** `commitEventLog` commits `events.jsonl` and nothing
111
+ else. Anything else that is changed or staged stays exactly as it was. It then
112
+ pushes. It pushes only commits that touch the log alone. When the branch also
113
+ holds someone's own data commit, it holds the push and says why. A log the
114
+ repository ignores is reported as an error, not committed by force.
115
+
116
+ In the wheel, set `MW_EVENTS_GIT_SYNC=push` (or `commit`). The wheel then
117
+ commits:
118
+
119
+ - about 30 seconds after events are recorded, so a burst travels as one commit;
120
+ - on start;
121
+ - every 10 minutes.
122
+
123
+ `mw events sync` commits now. `GET /api/events/git` shows the last pass.
124
+
125
+ **Anywhere else,** clone the data repository, then follow it:
126
+
127
+ ```bash
128
+ npx -p @medicine-wheel/events mw-events follow ~/mw-data --exec ./on-event.sh
129
+ npx -p @medicine-wheel/events mw-events follow ~/mw-data --every 60 # pull every minute, print new events
130
+ ```
131
+
132
+ `follow` pulls, then hands each event this follower has not handled yet to
133
+ `--exec`, oldest first:
134
+
135
+ - The event's JSON goes on stdin.
136
+ - `MW_EVENT_ID`, `MW_EVENT_TYPE`, `MW_EVENT_SUBJECT_KIND`, `MW_EVENT_SUBJECT_ID`,
137
+ `MW_EVENT_CORRELATION` and `MW_EVENT_OBSERVED_AT` go in the environment.
138
+ - Exit 0 and the follower moves past that event. Anything else and it stops,
139
+ and the same event comes first next time.
140
+
141
+ The cursor lives in `--state-dir`, which defaults to
142
+ `~/.local/state/medicine-wheel-events`, never in the clone. Use `--name` to give
143
+ two followers of one clone separate places.
144
+
145
+ ## Publishing
146
+
147
+ ```ts
148
+ import { drainLog, httpPublisher, rewind, publisherStatus } from '@medicine-wheel/events/publish';
149
+
150
+ await drainLog(logFile, httpPublisher({ url, token }));
151
+ ```
152
+
153
+ Episode 548 draws an event's path as Named → Sent → Arrived | Failed →
154
+ Replayed. An event in the log is Named. `drainLog` sends each event after the
155
+ publisher's cursor, in order, and writes the outcome to `events-receipts.jsonl`:
156
+
157
+ - `arrived`: a 2xx answer.
158
+ - `failed`: unreachable, timed out, 408, 429 or 5xx. The pass stops, and the
159
+ next one starts from the same event.
160
+ - `refused`: any other answer. It is recorded and passed, so one bad event does
161
+ not hold back the rest.
162
+
163
+ `rewind` is Replay: it moves the cursor back, and the same lines go out again
164
+ with the same idempotency keys. Each publisher keeps its own cursor
165
+ (`events-cursor.<name>.json`).
166
+
167
+ `httpPublisher` POSTs the envelope as JSON with `Idempotency-Key` and
168
+ `X-Event-Type` headers. Each of these can receive it: a webhook endpoint, a
169
+ Hookdeck source, a Cloudflare Worker in front of a queue, or the Miadi
170
+ hooks-gateway once it accepts this envelope. A broker client would be another
171
+ `Publisher` beside it. The events and their names stay the same.
172
+
173
+ In the wheel, set `MW_EVENTS_PUBLISH_URL` (and `MW_EVENTS_PUBLISH_TOKEN`) to turn
174
+ publishing on. `GET /api/health` then reports how many events are still owed.
175
+
176
+ ## Foundations
177
+
178
+ [docs/foundations/event-driven-architecture.md](../../docs/foundations/event-driven-architecture.md)
179
+ covers the literature behind this package and maps each idea to the code:
180
+ notification versus event-carried state, the log, the outbox, idempotent
181
+ receivers, dead letters and AsyncAPI. It also covers why the logic lives in the
182
+ wheel and Miadi consumes it.