@medicine-wheel/app 0.17.10 → 0.18.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
@@ -354,6 +354,48 @@ start, every five minutes, and after any projection that gets through —
354
354
  `honcho.pending` counts what waits. A running server holds its old
355
355
  build and its old environment — restart it for either to take effect.
356
356
 
357
+ ### Event log
358
+
359
+ Every write to a cycle, beat, ceremony, diary entry, node or relation leaves one
360
+ line in `events.jsonl` beside the store (`MW_EVENTS_FILE` overrides). Nodes
361
+ include circles, chronicle episodes and memory conclusions. Captures, inquiry
362
+ weaves, plan perspectives, charts and MMOTs are not recorded yet. Each line is
363
+ a `MiadiEventEnvelope` (jgwill/Miadi
364
+ `rispecs/events/00-miadi-event-spine.rispec.md`) namespaced `medicine-wheel.*`:
365
+ `cycle.opened`, `beat.recorded`, `ceremony.closed`, `circle.created`,
366
+ `chronicle.episode.registered`, `relation.made`, … — 29 in all, catalogued in
367
+ [`@medicine-wheel/events`](src/events/README.md).
368
+
369
+ - A payload names records and carries labels. A cycle's question, a beat's
370
+ words and a diary entry's text stay in the store.
371
+ - A write that changes nothing writes no event.
372
+ - A log that cannot be written never fails the write.
373
+ - The MCP server's local store records its own writes the same way.
374
+
375
+ ```bash
376
+ curl 'localhost:8040/api/events?type=cycle.&limit=20' # newest first; type, subject, correlation, since, limit
377
+ mw events --correlation <cycle-id> # a cycle's moments; also on the cycle's page
378
+ mw events schema [--asyncapi] # the JSON Schema, or the AsyncAPI document
379
+ ```
380
+
381
+ Set `MW_INSTANCE` (e.g. `ilex:8040`) to name this wheel in each event's
382
+ `source.instance`.
383
+
384
+ **Publishing.** Nothing leaves the machine until `MW_EVENTS_PUBLISH_URL` is set.
385
+ Once it is, the wheel POSTs each event, in log order, after every write and
386
+ every minute (`MW_EVENTS_PUBLISH_INTERVAL_MS`).
387
+
388
+ - **Headers:** `Idempotency-Key`, `X-Event-Type`, and `Authorization: Bearer
389
+ $MW_EVENTS_PUBLISH_TOKEN` when a token is set.
390
+ - **Delivery records:** a cursor and receipts beside the log record
391
+ `arrived`, `failed` (retried) or `refused` (passed).
392
+ - **Checking and replaying:** `mw events status`, `mw events publish` and
393
+ `mw events replay <event-id|all>` (also `GET`/`POST /api/events/publish`).
394
+ - **What can receive it:** a webhook endpoint, a Hookdeck source, a Cloudflare
395
+ Worker in front of a queue, or the Miadi hooks-gateway. A broker client is
396
+ another publisher beside it. The events keep their names
397
+ (jgwill/medicine-wheel#164, miadi-chronicle://548).
398
+
357
399
  ## License
358
400
 
359
401
  Indigenous Knowledge Stewardship License (IKSL) v1.0 MIT Derived see [LICENSE](LICENSE)
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
3
+ import { ceremonyRemoved } from "@medicine-wheel/events";
3
4
  import { getAllBeats } from "@/lib/store";
5
+ import { recordEvent } from "@/lib/event-log";
4
6
 
5
7
  type RouteContext = { params: Promise<{ id: string }> };
6
8
 
@@ -52,6 +54,7 @@ export async function DELETE(_request: Request, context: RouteContext) {
52
54
  );
53
55
  }
54
56
  await store.deleteCeremony(id);
57
+ recordEvent(ceremonyRemoved(ceremony));
55
58
  return NextResponse.json({ success: true, deleted: { id }, provider: detectProvider() });
56
59
  } catch (error: unknown) {
57
60
  const message = error instanceof Error ? error.message : String(error);
@@ -4,6 +4,8 @@ import { createProvider, detectProvider } from "@medicine-wheel/storage-provider
4
4
  import { ceremonyBelongsToEpisode, ceremonyEpisodePath } from "@/lib/ceremony-response";
5
5
  import { projectCeremony } from "@medicine-wheel/honcho";
6
6
  import { projectAfterWrite } from "@/lib/honcho-projection";
7
+ import { ceremonyWritten } from "@medicine-wheel/events";
8
+ import { recordEvent } from "@/lib/event-log";
7
9
 
8
10
  /** Episode directory names are `YYYY-MM-DD-episode-NNN-slug`; nothing else may bind. */
9
11
  const EPISODE_PATH = /^\d{4}-\d{2}-\d{2}-episode-\d{3,}-[a-z0-9-]+$/;
@@ -159,10 +161,14 @@ export async function POST(request: Request) {
159
161
  ...(typeof body.subject_id === "string" ? { subject_id: body.subject_id } : {}),
160
162
  };
161
163
 
164
+ const existed = typeof body.id === "string" && body.id ? await store.getCeremony(body.id) : null;
162
165
  await store.logCeremony(ceremony);
163
166
  // The river: the stored ceremony leaves for Honcho in the background when
164
167
  // HONCHO_URL is set. Never awaited.
165
168
  projectAfterWrite({ kind: "ceremony", id: ceremony.id }, () => projectCeremony(ceremony));
169
+ // The event log (#164). A closing names the ceremony it closes, and both
170
+ // share that ceremony's id as their correlation.
171
+ recordEvent(ceremonyWritten(Boolean(existed), ceremony));
166
172
  return NextResponse.json({ success: true, ceremony, provider: detectProvider() }, { status: 201 });
167
173
  } catch (error: unknown) {
168
174
  const message = error instanceof Error ? error.message : String(error);
@@ -1,5 +1,7 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
3
+ import { diaryRemoved } from "@medicine-wheel/events";
4
+ import { recordEvent } from "@/lib/event-log";
3
5
 
4
6
  type RouteContext = { params: Promise<{ id: string }> };
5
7
 
@@ -23,6 +25,7 @@ export async function DELETE(_request: Request, context: RouteContext) {
23
25
  const entry = await store.getDiaryEntry(id);
24
26
  if (!entry) return NextResponse.json({ error: `No diary entry with id ${id}` }, { status: 404 });
25
27
  await store.deleteDiaryEntry(id);
28
+ recordEvent(diaryRemoved(entry));
26
29
  return NextResponse.json({ success: true, deleted: { id }, provider: detectProvider() });
27
30
  } catch (error: unknown) {
28
31
  const message = error instanceof Error ? error.message : String(error);
@@ -5,6 +5,8 @@ import type { DiaryEntryFilters, DiaryEntryRecord } from "@medicine-wheel/storag
5
5
  import { createDiaryEntry } from "@medicine-wheel/ceremonial-diary";
6
6
  import { projectDiaryEntry } from "@medicine-wheel/honcho";
7
7
  import { projectAfterWrite } from "@/lib/honcho-projection";
8
+ import { diaryWritten } from "@medicine-wheel/events";
9
+ import { recordEvent } from "@/lib/event-log";
8
10
 
9
11
  /**
10
12
  * The ceremonial diary door (0.14.0).
@@ -113,6 +115,7 @@ export async function POST(request: Request) {
113
115
  }
114
116
  metadata.ceremony_id = body.ceremony_id;
115
117
  }
118
+ const existed = typeof body.id === "string" && body.id ? await store.getDiaryEntry(body.id) : null;
116
119
  const entry = await createDiaryEntry(store, {
117
120
  ...(typeof body.id === "string" && body.id ? { id: body.id } : {}),
118
121
  ...(typeof body.timestamp === "string" && body.timestamp ? { timestamp: body.timestamp } : {}),
@@ -127,6 +130,9 @@ export async function POST(request: Request) {
127
130
  // The river: the participant's voice leaves for Honcho in the background
128
131
  // when HONCHO_URL is set. Never awaited.
129
132
  projectAfterWrite({ kind: "diary", id: entry.id }, () => projectDiaryEntry(entry));
133
+ // The event log (#164): who wrote, in which phase and ceremony — not what
134
+ // they wrote, which stays in the diary.
135
+ recordEvent(diaryWritten(Boolean(existed), entry));
130
136
  return NextResponse.json({ success: true, entry, provider: detectProvider() }, { status: 201 });
131
137
  } catch (error: unknown) {
132
138
  const message = error instanceof Error ? error.message : String(error);
@@ -5,7 +5,9 @@ import {
5
5
  detectProvider,
6
6
  EdgeNotFoundError,
7
7
  } from "@medicine-wheel/storage-provider";
8
+ import { relationRemoved, relationWritten } from "@medicine-wheel/events";
8
9
  import { parseLimit } from "@/lib/api-paging";
10
+ import { recordEvent } from "@/lib/event-log";
9
11
 
10
12
  const EdgeCreateSchema = z.object({
11
13
  from_id: z.string({ required_error: "from_id is required" }).trim().min(1),
@@ -160,7 +162,8 @@ export async function POST(request: Request) {
160
162
  );
161
163
  }
162
164
 
163
- const replaced = Boolean(await store.getEdge(parsed.data.from_id, parsed.data.to_id));
165
+ const previous = await store.getEdge(parsed.data.from_id, parsed.data.to_id);
166
+ const replaced = Boolean(previous);
164
167
 
165
168
  const edge = {
166
169
  from_id: parsed.data.from_id,
@@ -174,6 +177,9 @@ export async function POST(request: Request) {
174
177
  };
175
178
 
176
179
  await store.createEdge(edge);
180
+ // The event log (#164): a relation made, or — over an existing pair —
181
+ // amended in what moved.
182
+ recordEvent(relationWritten(previous, edge));
177
183
  return NextResponse.json(
178
184
  { success: true, edge, replaced, provider: detectProvider() },
179
185
  { status: 201 },
@@ -208,7 +214,9 @@ export async function PATCH(request: Request) {
208
214
  }
209
215
 
210
216
  const store = await createProvider();
217
+ const previous = await store.getEdge(pair.from, pair.to);
211
218
  const edge = await store.updateEdge(pair.from, pair.to, parsed.data);
219
+ recordEvent(relationWritten(previous ?? edge, edge));
212
220
  return NextResponse.json({ success: true, edge, provider: detectProvider() });
213
221
  } catch (error: unknown) {
214
222
  if (error instanceof EdgeNotFoundError) {
@@ -225,7 +233,9 @@ export async function DELETE(request: Request) {
225
233
  if (!pair) return pairRequired();
226
234
 
227
235
  const store = await createProvider();
236
+ const previous = await store.getEdge(pair.from, pair.to);
228
237
  await store.deleteEdge(pair.from, pair.to);
238
+ recordEvent(relationRemoved(previous ?? { from_id: pair.from, to_id: pair.to }));
229
239
  return NextResponse.json({
230
240
  success: true,
231
241
  deleted: { from_id: pair.from, to_id: pair.to },
@@ -0,0 +1,42 @@
1
+ import { NextResponse } from "next/server";
2
+ import { eventPublisherReport, publishPending, replayEvents } from "@/lib/event-log";
3
+
4
+ const NO_PUBLISHER = {
5
+ error: "No publisher is configured — the events wait in events.jsonl.",
6
+ hint: "Set MW_EVENTS_PUBLISH_URL (and MW_EVENTS_PUBLISH_TOKEN) and restart the wheel.",
7
+ };
8
+
9
+ /**
10
+ * The publisher's side of the log (#164) — episode 548's Send, Receipt,
11
+ * Failure and Replay.
12
+ *
13
+ * GET: where the configured publisher stands (`pending`, the last event it is
14
+ * done with, the last arrival, the last failure) and its latest receipts.
15
+ * POST: send what waits now. `{ "replay_from": "<event id>" }` first moves the
16
+ * cursor back so that event and every later one go out again, with the same
17
+ * idempotency keys; `{ "replay_from": "all" }` replays the whole log.
18
+ */
19
+ export async function GET() {
20
+ const report = eventPublisherReport();
21
+ if (!report) return NextResponse.json(NO_PUBLISHER, { status: 409 });
22
+ return NextResponse.json(report);
23
+ }
24
+
25
+ export async function POST(request: Request) {
26
+ const body = (await request.json().catch(() => ({}))) as { replay_from?: unknown };
27
+ let replay: { pending: number } | null = null;
28
+ if (body?.replay_from !== undefined) {
29
+ if (typeof body.replay_from !== "string" || !body.replay_from) {
30
+ return NextResponse.json({ error: 'replay_from must be an event id, or "all"' }, { status: 400 });
31
+ }
32
+ try {
33
+ replay = replayEvents(body.replay_from === "all" ? undefined : body.replay_from);
34
+ } catch (error: unknown) {
35
+ return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 404 });
36
+ }
37
+ if (!replay) return NextResponse.json(NO_PUBLISHER, { status: 409 });
38
+ }
39
+ const result = await publishPending();
40
+ if (!result) return NextResponse.json(NO_PUBLISHER, { status: 409 });
41
+ return NextResponse.json({ ...(replay ? { replayed: replay.pending } : {}), ...result });
42
+ }
@@ -0,0 +1,39 @@
1
+ import { NextResponse } from "next/server";
2
+ import { parseLimit } from "@/lib/api-paging";
3
+ import { filterEvents, readEvents } from "@/lib/event-log";
4
+
5
+ /**
6
+ * The wheel's event log, read (#164).
7
+ *
8
+ * `?type=cycle.opened` (or the namespaced `medicine-wheel.cycle.opened`; a
9
+ * trailing dot, `cycle.`, takes the family), `?subject=<record id>`,
10
+ * `?correlation=<id>` (a cycle's events and the beats recorded in it),
11
+ * `?since=<ISO time>`, `?limit=<n>|all`. Newest first, so the moments a person
12
+ * is looking for come before the history behind them. `total` counts every
13
+ * match, so a caller holding a page knows it holds one.
14
+ */
15
+ export async function GET(request: Request) {
16
+ try {
17
+ const url = new URL(request.url);
18
+ const limit = parseLimit(url.searchParams.get("limit"));
19
+ if (limit instanceof NextResponse) return limit;
20
+ const since = url.searchParams.get("since") ?? undefined;
21
+ if (since !== undefined && Number.isNaN(Date.parse(since))) {
22
+ return NextResponse.json(
23
+ { error: `Invalid since: ${since} — nothing was returned.`, hint: "since must be an ISO 8601 time." },
24
+ { status: 400 },
25
+ );
26
+ }
27
+ const matching = filterEvents(readEvents(), {
28
+ type: url.searchParams.get("type") ?? undefined,
29
+ subject: url.searchParams.get("subject") ?? undefined,
30
+ correlation: url.searchParams.get("correlation") ?? undefined,
31
+ since,
32
+ }).reverse();
33
+ const events = limit === null ? matching : matching.slice(0, limit);
34
+ return NextResponse.json({ events, total: matching.length });
35
+ } catch (error: unknown) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ return NextResponse.json({ error: message }, { status: 500 });
38
+ }
39
+ }
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { createProvider, detectProvider } from '@medicine-wheel/storage-provider';
3
3
  import { honchoProjectionStatus } from '@/lib/honcho-projection';
4
+ import { eventLogStatus } from '@/lib/event-log';
4
5
 
5
6
  export async function GET() {
6
7
  const providerType = detectProvider();
@@ -31,6 +32,10 @@ export async function GET() {
31
32
  // reports enabled projects every stored beat, ceremony and diary entry;
32
33
  // `pending` counts those still waiting for Honcho to take them (#147).
33
34
  honcho: honchoProjectionStatus(),
35
+ // The event log (#164): how many events the wheel has written beside its
36
+ // store, when the last one was, and — with a publisher configured — how
37
+ // many it still owes. Read them on /api/events.
38
+ events: eventLogStatus(),
34
39
  env: {
35
40
  MW_STORAGE_PROVIDER: process.env.MW_STORAGE_PROVIDER || 'not set',
36
41
  DATABASE_URL: process.env.DATABASE_URL ? 'configured' : 'not configured',
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { MEMORY_PROJECTION_KIND } from '@medicine-wheel/honcho';
3
3
  import { createProvider } from '@medicine-wheel/storage-provider';
4
+ import { conclusionJudged } from '@medicine-wheel/events';
5
+ import { recordEvent } from '@/lib/event-log';
4
6
 
5
7
  export const dynamic = 'force-dynamic';
6
8
  type RouteContext = { params: Promise<{ id: string }> };
@@ -27,6 +29,8 @@ export async function PATCH(request: Request, context: RouteContext) {
27
29
  const updated = await store.updateNode(id, {
28
30
  metadata: { ...node.metadata, status: body.status, judged_by: by, judged_at: new Date().toISOString() },
29
31
  });
32
+ // The event log (#164): a person confirmed or rejected it.
33
+ recordEvent(conclusionJudged(node, updated));
30
34
  return NextResponse.json({ node: updated }, { headers: { 'cache-control': 'no-store' } });
31
35
  } catch (error) {
32
36
  return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { honchoIdFor, memoryProjectionNode, type MemoryProjectionKind, type MemoryProjectionStatus } from '@medicine-wheel/honcho';
3
3
  import { createProvider } from '@medicine-wheel/storage-provider';
4
+ import { nodeWritten } from '@medicine-wheel/events';
5
+ import { recordEvent } from '@/lib/event-log';
4
6
 
5
7
  export const dynamic = 'force-dynamic';
6
8
 
@@ -36,7 +38,10 @@ export async function POST(request: Request) {
36
38
  });
37
39
  node.metadata.about = about;
38
40
  const store = await createProvider();
41
+ const before = await store.getNode(node.id);
39
42
  await store.createNode(node as never);
43
+ // The event log (#164): memory.conclusion.recorded, about its person.
44
+ recordEvent(nodeWritten(before, node));
40
45
  return NextResponse.json({ node }, { status: 201, headers: { 'cache-control': 'no-store' } });
41
46
  } catch (error) {
42
47
  return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
@@ -1,6 +1,8 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { getBeat, witnessBeat } from "@/lib/store";
4
+ import { beatWitnessed } from "@medicine-wheel/events";
5
+ import { recordEvent } from "@/lib/event-log";
4
6
 
5
7
  type RouteContext = { params: Promise<{ id: string }> };
6
8
 
@@ -49,7 +51,11 @@ export async function PATCH(request: Request, context: RouteContext) {
49
51
  { status: 400 },
50
52
  );
51
53
  }
54
+ const before = getBeat(id);
52
55
  const beat = witnessBeat(id, parsed.data);
53
56
  if (!beat) return notFound(id);
57
+ // The event log (#164): a witness added or a speaker named. A name already
58
+ // witnessing, or the speaker restated, adds nothing and records nothing.
59
+ recordEvent(beatWitnessed(before, beat));
54
60
  return NextResponse.json(beat);
55
61
  }
@@ -1,7 +1,9 @@
1
1
  import { NextResponse } from "next/server";
2
- import { getAllBeats, createBeat } from "@/lib/store";
2
+ import { getAllBeats, getBeat, createBeat } from "@/lib/store";
3
3
  import { projectBeat } from "@medicine-wheel/honcho";
4
4
  import { projectAfterWrite } from "@/lib/honcho-projection";
5
+ import { beatWritten } from "@medicine-wheel/events";
6
+ import { recordEvent } from "@/lib/event-log";
5
7
 
6
8
  export async function GET() {
7
9
  try {
@@ -14,6 +16,7 @@ export async function GET() {
14
16
  export async function POST(request: Request) {
15
17
  try {
16
18
  const body = await request.json();
19
+ const before = typeof body.id === "string" && body.id ? getBeat(body.id) : undefined;
17
20
  // Pass the caller's id and timestamp through when supplied. A client that
18
21
  // authored a beat holds its id and will look it up again — minting a fresh
19
22
  // one here hands back a beat the caller can never find.
@@ -38,6 +41,11 @@ export async function POST(request: Request) {
38
41
  // The river: a stored beat leaves for Honcho in the background when
39
42
  // HONCHO_URL is set. Never awaited — the wheel answers on its own clock.
40
43
  projectAfterWrite({ kind: "beat", id: beat.id }, () => projectBeat(beat));
44
+ // The event log (#164): a new beat is recorded; a known one is amended
45
+ // only in what moved — re-posting a beat as it stands is not an occurrence.
46
+ // Read back through the same normalization as `before`, so a field the
47
+ // reader fills in is not mistaken for one the write moved.
48
+ recordEvent(beatWritten(before, before ? getBeat(beat.id) ?? beat : beat));
41
49
  // Warnings ride on the created beat rather than replacing it, so clients
42
50
  // that read the beat back by id keep working while advisory findings stop
43
51
  // being computed-and-discarded.
@@ -1,5 +1,8 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { getAllCycles, createCycle, upsertCycle } from "@/lib/store";
3
+ import { cycleWritten } from "@medicine-wheel/events";
4
+ import type { ApiMedicineWheelCycle } from "@/lib/cycle-response";
5
+ import { recordEvent } from "@/lib/event-log";
3
6
 
4
7
  export async function GET() {
5
8
  try {
@@ -18,15 +21,19 @@ export async function POST(request: Request) {
18
21
  // id here is how archiving and beat-binding duplicated cycles instead of
19
22
  // amending them; refusing outright is how a client's own id vanished.
20
23
  if (typeof body.id === "string" && body.id.length > 0) {
21
- const existed = getAllCycles().some((c) => c.id === body.id);
22
- const cycle = upsertCycle(body);
23
- return NextResponse.json(cycle, { status: existed ? 200 : 201 });
24
+ const before: ApiMedicineWheelCycle | undefined = getAllCycles().find((c) => c.id === body.id);
25
+ const cycle: ApiMedicineWheelCycle = upsertCycle(body);
26
+ // The event log (#164): opened, archived, or amended in the fields that
27
+ // moved. Writing back what is already stored is not an occurrence.
28
+ recordEvent(cycleWritten(before, cycle, Object.keys(body)));
29
+ return NextResponse.json(cycle, { status: before ? 200 : 201 });
24
30
  }
25
31
 
26
32
  const cycle = createCycle({
27
33
  research_question: body.research_question,
28
34
  current_direction: body.current_direction,
29
35
  });
36
+ recordEvent(cycleWritten(null, cycle, []));
30
37
  return NextResponse.json(cycle, { status: 201 });
31
38
  } catch (error: any) {
32
39
  return NextResponse.json({ error: error.message }, { status: 500 });
@@ -7,6 +7,8 @@ import {
7
7
  NodeNotFoundError,
8
8
  NodeHasRelationsError,
9
9
  } from "@medicine-wheel/storage-provider";
10
+ import { nodeRemoved, nodeWritten } from "@medicine-wheel/events";
11
+ import { recordEvent } from "@/lib/event-log";
10
12
 
11
13
  // `direction: null` releases the node from its direction.
12
14
  const NodePatchSchema = z
@@ -82,8 +84,8 @@ export async function PATCH(request: Request, context: RouteContext) {
82
84
  // silently drops its siblings breaks that everywhere it is used. A caller
83
85
  // that genuinely wants to clear a key now says so with `null`.
84
86
  let patch = parsed.data;
87
+ const existing = await store.getNode(id);
85
88
  if (patch.metadata) {
86
- const existing = await store.getNode(id);
87
89
  if (existing) {
88
90
  const merged: Record<string, unknown> = {
89
91
  ...(existing.metadata ?? {}),
@@ -99,6 +101,8 @@ export async function PATCH(request: Request, context: RouteContext) {
99
101
  }
100
102
 
101
103
  const node = await store.updateNode(id, patch);
104
+ // The event log (#164): amended in the fields and metadata keys that moved.
105
+ recordEvent(nodeWritten(existing, node));
102
106
  return NextResponse.json({ success: true, node, provider: detectProvider() });
103
107
  } catch (error: unknown) {
104
108
  if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
@@ -110,7 +114,9 @@ export async function DELETE(_request: Request, context: RouteContext) {
110
114
  try {
111
115
  const { id } = await context.params;
112
116
  const store = await createProvider();
117
+ const node = await store.getNode(id);
113
118
  await store.deleteNode(id);
119
+ if (node) recordEvent(nodeRemoved(node));
114
120
  return NextResponse.json({ success: true, deleted: id, provider: detectProvider() });
115
121
  } catch (error: unknown) {
116
122
  if (error instanceof NodeNotFoundError) return notFound(error.nodeId);
@@ -2,7 +2,9 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { DirectionNameSchema, NodeTypeSchema } from "@medicine-wheel/ontology-core";
4
4
  import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
5
+ import { nodeWritten } from "@medicine-wheel/events";
5
6
  import { parseLimit } from "@/lib/api-paging";
7
+ import { recordEvent } from "@/lib/event-log";
6
8
 
7
9
  const NodeCreateSchema = z.object({
8
10
  id: z.string().trim().min(1).optional(),
@@ -171,7 +173,11 @@ export async function POST(request: Request) {
171
173
  updated_at: new Date().toISOString(),
172
174
  };
173
175
 
176
+ const before = parsed.data.id ? await store.getNode(parsed.data.id) : null;
174
177
  await store.createNode(node);
178
+ // The event log (#164): a circle created, an episode registered, a node
179
+ // created — or, under a known id, amended in what moved.
180
+ recordEvent(nodeWritten(before, node));
175
181
  return NextResponse.json({ success: true, node, provider: detectProvider() }, { status: 201 });
176
182
  } catch (error: unknown) {
177
183
  const message = error instanceof Error ? error.message : String(error);
@@ -5,6 +5,7 @@ import type { FormEvent } from "react";
5
5
  import { type MedicineWheelCycle, type DirectionName } from "@/lib/types";
6
6
  import { extractCycles } from "@/lib/cycle-response";
7
7
  import { toast } from "sonner";
8
+ import { CycleEvents } from "@/components/cycle-events";
8
9
 
9
10
  const dirVar = (d: string) => `var(--mw-${d})`;
10
11
  const dirInk = (d: string) => `var(--mw-${d}-ink)`;
@@ -138,6 +139,7 @@ export default function CyclesPage() {
138
139
  <p className="font-bold">{cycle.relations_mapped}</p>
139
140
  </div>
140
141
  </div>
142
+ <CycleEvents cycleId={cycle.id} />
141
143
  <p className="mt-3 text-xs text-muted-foreground">Cycle ID: {cycle.id}</p>
142
144
  </div>
143
145
  )}
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { shortName, type WheelEvent } from "@medicine-wheel/events";
5
+ import { relativeTime, absoluteTime } from "@/lib/format-time";
6
+
7
+ /**
8
+ * A cycle's moments, newest first: when it was opened, amended, archived, and
9
+ * each beat recorded or witnessed in it (#164). Read from `/api/events` by the
10
+ * cycle's id as correlation. The events carry ids, not words, so a beat's title
11
+ * is read from the beats the wheel holds.
12
+ */
13
+
14
+ /** `chronicle.episode.registered` → "Chronicle episode registered". */
15
+ export function eventLabel(eventType: string): string {
16
+ const words = shortName(eventType).split(".").join(" ").replace(/_/g, " ");
17
+ return words.charAt(0).toUpperCase() + words.slice(1);
18
+ }
19
+
20
+ /** What one line adds beside its label: the beat's title, the fields that moved, the witnesses added. */
21
+ export function eventDetail(event: WheelEvent, beatTitles: Record<string, string>): string {
22
+ const payload = event.payload as Record<string, unknown>;
23
+ const parts: string[] = [];
24
+ if (event.subject.kind === "beat" && beatTitles[event.subject.id]) parts.push(`“${beatTitles[event.subject.id]}”`);
25
+ if (Array.isArray(payload.changed) && payload.changed.length) parts.push(`changed ${payload.changed.join(", ")}`);
26
+ if (Array.isArray(payload.added_witnesses) && payload.added_witnesses.length) {
27
+ parts.push(`${payload.added_witnesses.length} witness${payload.added_witnesses.length === 1 ? "" : "es"} added`);
28
+ }
29
+ if (typeof payload.speaker === "string" && event.event_type.endsWith("witnessed")) parts.push(`speaker ${payload.speaker}`);
30
+ if (typeof payload.current_direction === "string" && event.event_type.endsWith("cycle.opened")) parts.push(`in the ${payload.current_direction}`);
31
+ return parts.join(" · ");
32
+ }
33
+
34
+ export function CycleEvents({ cycleId }: { cycleId: string }) {
35
+ const [events, setEvents] = useState<WheelEvent[] | null>(null);
36
+ const [total, setTotal] = useState(0);
37
+ const [beatTitles, setBeatTitles] = useState<Record<string, string>>({});
38
+
39
+ useEffect(() => {
40
+ let live = true;
41
+ fetch(`/api/events?correlation=${encodeURIComponent(cycleId)}&limit=50`)
42
+ .then((r) => r.json())
43
+ .then((d) => {
44
+ if (!live) return;
45
+ setEvents(Array.isArray(d.events) ? d.events : []);
46
+ setTotal(typeof d.total === "number" ? d.total : 0);
47
+ })
48
+ .catch(() => live && setEvents([]));
49
+ fetch("/api/narrative/beats")
50
+ .then((r) => r.json())
51
+ .then((d) => {
52
+ if (!live || !Array.isArray(d)) return;
53
+ setBeatTitles(Object.fromEntries(d.map((b: { id: string; title: string }) => [b.id, b.title])));
54
+ })
55
+ .catch(() => {});
56
+ return () => { live = false; };
57
+ }, [cycleId]);
58
+
59
+ return (
60
+ <div className="mt-4" onClick={(e) => e.stopPropagation()}>
61
+ <p className="text-xs font-medium text-muted-foreground mb-2">
62
+ Moments {events && total > events.length ? `(${events.length} of ${total})` : ""}
63
+ </p>
64
+ {events === null && <p className="text-xs text-muted-foreground">Reading the event log…</p>}
65
+ {events?.length === 0 && (
66
+ <p className="text-xs text-muted-foreground">No events yet. This wheel records them from the moment the event log was installed; earlier moments were not kept.</p>
67
+ )}
68
+ {!!events?.length && (
69
+ <ol className="space-y-1.5 border-l pl-3">
70
+ {events.map((event) => (
71
+ <li key={event.id} className="text-xs">
72
+ <span className="text-muted-foreground" title={absoluteTime(event.observed_at)}>{relativeTime(event.observed_at)}</span>
73
+ {" · "}
74
+ <span className="font-medium">{eventLabel(event.event_type)}</span>
75
+ {eventDetail(event, beatTitles) && <span className="text-muted-foreground"> · {eventDetail(event, beatTitles)}</span>}
76
+ </li>
77
+ ))}
78
+ </ol>
79
+ )}
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The folders of an episode, drawn — every one open to begin with, so a file's
3
+ * folder is plain to see. Presentational: the panel owns which folders are
4
+ * closed and how a file's own row looks.
5
+ *
6
+ * @see lib/episode-file-tree.ts — how the paths are grouped and in what order
7
+ */
8
+
9
+ import type { ReactNode } from "react";
10
+ import type { FileTreeFolder, TreeableFile } from "@/lib/episode-file-tree";
11
+
12
+ export interface EpisodeFileTreeProps<T extends TreeableFile> {
13
+ tree: FileTreeFolder<T>;
14
+ /** Paths of the folders the reader closed. Any folder not here is open. */
15
+ collapsed: ReadonlySet<string>;
16
+ onToggleFolder: (path: string) => void;
17
+ /** One file's row, and whatever opens beneath it. `name` is the path's last segment. */
18
+ renderFile: (file: T, name: string) => ReactNode;
19
+ }
20
+
21
+ export function EpisodeFileTree<T extends TreeableFile>({
22
+ tree,
23
+ collapsed,
24
+ onToggleFolder,
25
+ renderFile,
26
+ }: EpisodeFileTreeProps<T>) {
27
+ return (
28
+ <ul className="space-y-1">
29
+ {tree.children.map((node) => {
30
+ if (node.type === "file") {
31
+ return <li key={node.path}>{renderFile(node.file, node.name)}</li>;
32
+ }
33
+ const isOpen = !collapsed.has(node.path);
34
+ return (
35
+ <li key={node.path} data-folder={node.path}>
36
+ <button
37
+ type="button"
38
+ onClick={() => onToggleFolder(node.path)}
39
+ aria-expanded={isOpen}
40
+ title={node.path}
41
+ className="w-full p-2 flex items-baseline gap-2 text-left text-sm font-semibold hover:underline"
42
+ >
43
+ <span aria-hidden="true" className="text-muted-foreground w-[1ch]">
44
+ {isOpen ? "▾" : "▸"}
45
+ </span>
46
+ <span aria-hidden="true">{isOpen ? "📂" : "📁"}</span>
47
+ <span className="font-mono break-all">{node.name}</span>
48
+ <span className="ml-auto text-xs font-normal text-muted-foreground shrink-0">
49
+ {node.fileCount} {node.fileCount === 1 ? "file" : "files"}
50
+ </span>
51
+ </button>
52
+ {isOpen && (
53
+ <div className="ml-4 pl-2 border-l">
54
+ <EpisodeFileTree
55
+ tree={node}
56
+ collapsed={collapsed}
57
+ onToggleFolder={onToggleFolder}
58
+ renderFile={renderFile}
59
+ />
60
+ </div>
61
+ )}
62
+ </li>
63
+ );
64
+ })}
65
+ </ul>
66
+ );
67
+ }