@medicine-wheel/app 0.5.8 → 0.5.10

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.
@@ -0,0 +1,224 @@
1
+ import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import {
4
+ createProvider,
5
+ detectProvider,
6
+ captureRecordId,
7
+ CAPTURE_KINDS,
8
+ CAPTURE_ORIGINS,
9
+ type CaptureFilters,
10
+ } from "@medicine-wheel/storage-provider";
11
+
12
+ /**
13
+ * The capture registry's HTTP door: records + URIs only, never bytes. The
14
+ * bytes stay behind the capture service (@miadi/capture and the gmtermux
15
+ * edge); this route makes what was captured queryable for chronicle surfaces
16
+ * such as forgewright episode views.
17
+ *
18
+ * `/api/captures` is the CANONICAL path (capture-vocabulary.spec.md §8).
19
+ * `/api/recordings` remains as a deprecated alias re-exporting these same
20
+ * handlers — see app/api/recordings/route.ts for the removal condition.
21
+ */
22
+
23
+ /** The query params `GET /api/captures` understands. Anything else is a 400. */
24
+ const CAPTURE_FILTER_PARAMS = [
25
+ "episode_path",
26
+ "episode_number",
27
+ "composition",
28
+ "kind",
29
+ "origin",
30
+ "device",
31
+ "filename",
32
+ ] as const;
33
+
34
+ /**
35
+ * A silently-ignored filter is the failure this route refuses to repeat from
36
+ * /api/nodes' history: a consumer that asks `?kinds=audio` and receives a
37
+ * filtered-looking payload it never filtered has been lied to. An unknown
38
+ * param is a 400 naming what is accepted, not a shrug.
39
+ */
40
+ function rejectUnknownParams(searchParams: URLSearchParams) {
41
+ const unknown = [...new Set(searchParams.keys())].filter(
42
+ (key) => !(CAPTURE_FILTER_PARAMS as readonly string[]).includes(key),
43
+ );
44
+ if (unknown.length === 0) return null;
45
+
46
+ return NextResponse.json(
47
+ {
48
+ error: `Unknown query parameter${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")} — nothing was filtered.`,
49
+ accepted: [...CAPTURE_FILTER_PARAMS],
50
+ },
51
+ { status: 400 },
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Build filters from validated params. A value that cannot mean anything
57
+ * (`?kind=holograph`, `?episode_number=abc`) is answered with a 400 naming the
58
+ * accepted values, for the same honesty reason unknown params are.
59
+ */
60
+ function captureFiltersFromSearchParams(
61
+ searchParams: URLSearchParams,
62
+ ): { filters: CaptureFilters } | { rejection: NextResponse } {
63
+ const filters: CaptureFilters = {};
64
+
65
+ // An empty value means "no filter" — forms submit "" for an unchosen option.
66
+ const episodePath = searchParams.get("episode_path");
67
+ const episodeNumber = searchParams.get("episode_number");
68
+ const composition = searchParams.get("composition");
69
+ const kind = searchParams.get("kind");
70
+ const origin = searchParams.get("origin");
71
+ const device = searchParams.get("device");
72
+ const filename = searchParams.get("filename");
73
+
74
+ if (episodePath) filters.episode_path = episodePath;
75
+ if (episodeNumber) {
76
+ const parsed = Number(episodeNumber);
77
+ if (!Number.isInteger(parsed)) {
78
+ return {
79
+ rejection: NextResponse.json(
80
+ { error: `episode_number must be an integer, got: ${episodeNumber} — nothing was filtered.` },
81
+ { status: 400 },
82
+ ),
83
+ };
84
+ }
85
+ filters.episode_number = parsed;
86
+ }
87
+ if (composition) filters.composition = composition;
88
+ if (kind) {
89
+ if (!(CAPTURE_KINDS as readonly string[]).includes(kind)) {
90
+ return {
91
+ rejection: NextResponse.json(
92
+ {
93
+ error: `Unknown kind: ${kind} — nothing was filtered.`,
94
+ accepted: [...CAPTURE_KINDS],
95
+ },
96
+ { status: 400 },
97
+ ),
98
+ };
99
+ }
100
+ filters.kind = kind as CaptureFilters["kind"];
101
+ }
102
+ if (origin) {
103
+ if (!(CAPTURE_ORIGINS as readonly string[]).includes(origin)) {
104
+ return {
105
+ rejection: NextResponse.json(
106
+ {
107
+ error: `Unknown origin: ${origin} — nothing was filtered.`,
108
+ accepted: [...CAPTURE_ORIGINS],
109
+ },
110
+ { status: 400 },
111
+ ),
112
+ };
113
+ }
114
+ filters.origin = origin as CaptureFilters["origin"];
115
+ }
116
+ if (device) filters.device = device;
117
+ if (filename) filters.filename = filename;
118
+
119
+ return { filters };
120
+ }
121
+
122
+ const CaptureRegisterSchema = z
123
+ .object({
124
+ // Derived from filename (+ episode_path) via captureRecordId when absent.
125
+ id: z.string().trim().min(1).optional(),
126
+ filename: z
127
+ .string({ required_error: "filename is required" })
128
+ .trim()
129
+ .min(1, "cannot be empty"),
130
+ kind: z.enum(CAPTURE_KINDS),
131
+ origin: z.enum(CAPTURE_ORIGINS),
132
+ // Where the bytes live — the registry stores this pointer, never bytes.
133
+ uri: z.string({ required_error: "uri is required" }).trim().min(1, "cannot be empty"),
134
+ // Capture provenance — observed, not demanded. All optional.
135
+ device: z.string().optional(),
136
+ started_at: z.string().optional(),
137
+ stopped_at: z.string().optional(),
138
+ duration_seconds: z.number().nonnegative().optional(),
139
+ bytes: z.number().int().nonnegative().optional(),
140
+ sha256: z.string().optional(),
141
+ mimetype: z.string().optional(),
142
+ // Associations — 0, 1, or 2 belongings; at most declared, never demanded
143
+ // (capture-vocabulary.spec.md §5). No xor, no refinement: a take with
144
+ // neither belonging is an inbox take, whole and waiting for a choice.
145
+ episode_path: z.string().optional(),
146
+ episode_number: z.number().int().optional(),
147
+ composition: z.string().optional(),
148
+ source_artifact: z.string().optional(),
149
+ registered_at: z.string().optional(),
150
+ source: z.string().optional(),
151
+ })
152
+ // Future fields survive registration, as every sibling family preserves them.
153
+ .passthrough();
154
+
155
+ export async function GET(request: Request) {
156
+ try {
157
+ const { searchParams } = new URL(request.url);
158
+
159
+ const unknownRejection = rejectUnknownParams(searchParams);
160
+ if (unknownRejection) return unknownRejection;
161
+
162
+ const built = captureFiltersFromSearchParams(searchParams);
163
+ if ("rejection" in built) return built.rejection;
164
+ const { filters } = built;
165
+ const filtering = Object.keys(filters).length > 0;
166
+
167
+ const store = await createProvider();
168
+ const captures = await store.listCaptures(filters);
169
+
170
+ return NextResponse.json({
171
+ captures,
172
+ // DEPRECATED echo of the same array under the old key. forgewright's
173
+ // fetchRecordingRecords reads `recordings` (or `records`) fail-closed —
174
+ // a body that only said `captures` would land as silent empty enrichment.
175
+ // Remove when forgewright's fetchRecordingRecords targets /api/captures
176
+ // (coupling point, capture-vocabulary.spec.md).
177
+ recordings: captures,
178
+ provider: detectProvider(),
179
+ count: captures.length,
180
+ // Echoed only when asked for, so a caller can tell an empty result from
181
+ // an unapplied filter — the same contract /api/nodes keeps.
182
+ ...(filtering ? { filters } : {}),
183
+ });
184
+ } catch (error: unknown) {
185
+ const message = error instanceof Error ? error.message : String(error);
186
+ return NextResponse.json({ error: message }, { status: 500 });
187
+ }
188
+ }
189
+
190
+ export async function POST(request: Request) {
191
+ try {
192
+ const body = await request.json().catch(() => null);
193
+ const parsed = CaptureRegisterSchema.safeParse(body);
194
+ if (!parsed.success) {
195
+ return NextResponse.json(
196
+ {
197
+ error: "Invalid capture — nothing was registered.",
198
+ issues: parsed.error.issues.map(
199
+ (issue) => `${issue.path.join(".") || "body"}: ${issue.message}`,
200
+ ),
201
+ },
202
+ { status: 400 },
203
+ );
204
+ }
205
+
206
+ const data = parsed.data;
207
+ const record = {
208
+ ...data,
209
+ id: data.id ?? captureRecordId(data.filename, data.episode_path),
210
+ registered_at: data.registered_at ?? new Date().toISOString(),
211
+ };
212
+
213
+ const store = await createProvider();
214
+ const capture = await store.registerCapture(record);
215
+
216
+ return NextResponse.json(
217
+ { success: true, capture, provider: detectProvider() },
218
+ { status: 201 },
219
+ );
220
+ } catch (error: unknown) {
221
+ const message = error instanceof Error ? error.message : String(error);
222
+ return NextResponse.json({ error: message }, { status: 500 });
223
+ }
224
+ }
@@ -16,25 +16,93 @@ const NodeCreateSchema = z.object({
16
16
  metadata: z.record(z.unknown()).optional(),
17
17
  });
18
18
 
19
+ /**
20
+ * The query params `GET /api/nodes` understands. Anything else is a 400.
21
+ *
22
+ * Node `type` is a closed enum of six and cannot grow, so every artifact kind
23
+ * the ecosystem has invented — `chronicle_episode`, `structured_plan`,
24
+ * `service`, `stc_chart`, `product_goal` — got in through `metadata.kind`
25
+ * instead, and containment got in through `metadata.parent_id` (31 nodes point
26
+ * at `chronicle:miadi-chronicle` that way). Both were reachable only by
27
+ * fetching the whole graph and filtering client-side; forgewright's
28
+ * `chronicle/client.ts` and this repo's own `HttpStore.searchNodes` each do
29
+ * exactly that, the latter under the comment "Server has no search endpoint
30
+ * yet". So the wheel needed no schema change — it needed a way to ask.
31
+ *
32
+ * Query params rather than a scoped `/api/nodes/:id/children` route because the
33
+ * question consumers actually have is "artifacts of kind X belonging to episode
34
+ * Y", which is `kind` AND `parent_id` together; a scoped route would still need
35
+ * `?kind=` bolted onto it, and `type`/`direction` already set the query-param
36
+ * precedent on this same route.
37
+ */
38
+ const NODE_FILTER_PARAMS = ["type", "direction", "kind", "parent_id"] as const;
39
+
40
+ /**
41
+ * A silently-ignored filter is the failure this route exists to prevent: a
42
+ * consumer that asks `?kinds=service` and gets a filtered-looking payload it
43
+ * never filtered has been lied to. An unknown param is therefore a 400 naming
44
+ * what is accepted, not a shrug.
45
+ */
46
+ function rejectUnknownParams(searchParams: URLSearchParams) {
47
+ const unknown = [...new Set(searchParams.keys())].filter(
48
+ (key) => !(NODE_FILTER_PARAMS as readonly string[]).includes(key),
49
+ );
50
+ if (unknown.length === 0) return null;
51
+
52
+ return NextResponse.json(
53
+ {
54
+ error: `Unknown query parameter${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")} — nothing was filtered.`,
55
+ accepted: [...NODE_FILTER_PARAMS],
56
+ },
57
+ { status: 400 },
58
+ );
59
+ }
60
+
19
61
  export async function GET(request: Request) {
20
62
  try {
21
63
  const { searchParams } = new URL(request.url);
22
- const type = searchParams.get("type");
23
- const direction = searchParams.get("direction");
64
+
65
+ const rejection = rejectUnknownParams(searchParams);
66
+ if (rejection) return rejection;
67
+
68
+ // An empty value means "no filter" — forms submit "" for an unchosen
69
+ // option, the same reason POST preprocesses "" on `direction`.
70
+ const filters = Object.fromEntries(
71
+ NODE_FILTER_PARAMS.map((key) => [key, searchParams.get(key) || undefined]),
72
+ ) as Partial<Record<(typeof NODE_FILTER_PARAMS)[number], string>>;
73
+ const filtering = Object.values(filters).some(Boolean);
24
74
 
25
75
  const store = await createProvider();
26
- let nodes = await store.getAllNodes();
27
76
 
28
- if (type) {
29
- nodes = nodes.filter((n) => n.type === type);
30
- } else if (direction) {
31
- nodes = nodes.filter((n) => n.direction === direction);
77
+ // Unfiltered reads keep the provider's own default page size, so this route
78
+ // answers a bare GET exactly as it always has. A *filtered* read must see
79
+ // the whole store or it answers from a truncated window — `getAllNodes()`
80
+ // defaults to 100 and the live wheel already holds 76, so filtering after
81
+ // the default slice would start returning quietly incomplete sets rather
82
+ // than failing loudly.
83
+ let nodes = filtering
84
+ ? await store.getAllNodes(Number.MAX_SAFE_INTEGER)
85
+ : await store.getAllNodes();
86
+
87
+ // Every supplied filter narrows (AND). Previously `type` and `direction`
88
+ // were `else if`, so `?type=x&direction=y` silently dropped the direction —
89
+ // the same silent-ignore defect the 400 above closes. Single-param requests
90
+ // are unaffected.
91
+ if (filters.type) nodes = nodes.filter((n) => n.type === filters.type);
92
+ if (filters.direction) nodes = nodes.filter((n) => n.direction === filters.direction);
93
+ if (filters.kind) nodes = nodes.filter((n) => n.metadata?.kind === filters.kind);
94
+ if (filters.parent_id) {
95
+ nodes = nodes.filter((n) => n.metadata?.parent_id === filters.parent_id);
32
96
  }
33
97
 
34
98
  return NextResponse.json({
35
99
  nodes,
36
100
  provider: detectProvider(),
37
- count: nodes.length
101
+ count: nodes.length,
102
+ // Echoed only when asked for, so an unfiltered response stays byte-identical
103
+ // to what every existing consumer already parses — and so a caller can tell
104
+ // an empty result from an unapplied filter.
105
+ ...(filtering ? { filters } : {}),
38
106
  });
39
107
  } catch (error: unknown) {
40
108
  const message = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * DEPRECATED ALIAS — `/api/recordings` answers with the same handlers as the
3
+ * canonical `/api/captures` route (capture-vocabulary.spec.md §8, the registry
4
+ * noun ruling).
5
+ *
6
+ * This alias is deliberate strangler design, not leftovers: forgewright's
7
+ * episode-recordings-section branch GETs {MW_API_URL}/api/recordings
8
+ * fail-closed — on any mismatch it returns silent empty enrichment, and
9
+ * nothing may fail quiet. The alias keeps that reader fed until it migrates.
10
+ *
11
+ * Remove when forgewright's fetchRecordingRecords targets /api/captures
12
+ * (coupling point, capture-vocabulary.spec.md).
13
+ */
14
+ export { GET, POST } from "../captures/route";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
5
5
  "bin": {
6
6
  "mw": "dist/cli/mw.js",
@@ -85,29 +85,29 @@
85
85
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
86
86
  },
87
87
  "dependencies": {
88
- "@medicine-wheel/ceremonial-diary": "^0.5.8",
89
- "@medicine-wheel/ceremony-protocol": "^0.5.8",
90
- "@medicine-wheel/community-review": "^0.5.8",
91
- "@medicine-wheel/consent-lifecycle": "^0.5.8",
92
- "@medicine-wheel/creative-orientation": "^0.5.8",
93
- "@medicine-wheel/data-store": "^0.5.8",
94
- "@medicine-wheel/data-store-postgres": "^0.5.8",
95
- "@medicine-wheel/fire-keeper": "^0.5.8",
96
- "@medicine-wheel/github-ceremony": "^0.5.8",
97
- "@medicine-wheel/graph-viz": "^0.5.8",
98
- "@medicine-wheel/importance-unit": "^0.5.8",
99
- "@medicine-wheel/mcp": "^4.5.8",
100
- "@medicine-wheel/narrative-cluster": "^0.5.8",
101
- "@medicine-wheel/narrative-engine": "^0.5.8",
102
- "@medicine-wheel/ontology-core": "^0.5.8",
103
- "@medicine-wheel/perception-layer": "^0.5.8",
104
- "@medicine-wheel/prompt-decomposition": "^0.5.8",
105
- "@medicine-wheel/relational-index": "^0.5.8",
106
- "@medicine-wheel/relational-query": "^0.5.8",
107
- "@medicine-wheel/session-reader": "^0.5.8",
108
- "@medicine-wheel/storage-provider": "^0.5.8",
109
- "@medicine-wheel/transformation-tracker": "^0.5.8",
110
- "@medicine-wheel/ui-components": "^0.5.8",
88
+ "@medicine-wheel/ceremonial-diary": "^0.5.10",
89
+ "@medicine-wheel/ceremony-protocol": "^0.5.10",
90
+ "@medicine-wheel/community-review": "^0.5.10",
91
+ "@medicine-wheel/consent-lifecycle": "^0.5.10",
92
+ "@medicine-wheel/creative-orientation": "^0.5.10",
93
+ "@medicine-wheel/data-store": "^0.5.10",
94
+ "@medicine-wheel/data-store-postgres": "^0.5.10",
95
+ "@medicine-wheel/fire-keeper": "^0.5.10",
96
+ "@medicine-wheel/github-ceremony": "^0.5.10",
97
+ "@medicine-wheel/graph-viz": "^0.5.10",
98
+ "@medicine-wheel/importance-unit": "^0.5.10",
99
+ "@medicine-wheel/mcp": "^4.5.10",
100
+ "@medicine-wheel/narrative-cluster": "^0.5.10",
101
+ "@medicine-wheel/narrative-engine": "^0.5.10",
102
+ "@medicine-wheel/ontology-core": "^0.5.10",
103
+ "@medicine-wheel/perception-layer": "^0.5.10",
104
+ "@medicine-wheel/prompt-decomposition": "^0.5.10",
105
+ "@medicine-wheel/relational-index": "^0.5.10",
106
+ "@medicine-wheel/relational-query": "^0.5.10",
107
+ "@medicine-wheel/session-reader": "^0.5.10",
108
+ "@medicine-wheel/storage-provider": "^0.5.10",
109
+ "@medicine-wheel/transformation-tracker": "^0.5.10",
110
+ "@medicine-wheel/ui-components": "^0.5.10",
111
111
  "@neondatabase/serverless": "^0.10.0",
112
112
  "@xyflow/react": "^12.3.0",
113
113
  "clsx": "^2.1.1",