@medicine-wheel/app 0.5.9 → 0.6.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/app/api/captures/route.ts +224 -0
- package/app/api/recordings/route.ts +14 -0
- package/package.json +24 -24
|
@@ -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
|
+
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.6.0",
|
|
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.
|
|
89
|
-
"@medicine-wheel/ceremony-protocol": "^0.
|
|
90
|
-
"@medicine-wheel/community-review": "^0.
|
|
91
|
-
"@medicine-wheel/consent-lifecycle": "^0.
|
|
92
|
-
"@medicine-wheel/creative-orientation": "^0.
|
|
93
|
-
"@medicine-wheel/data-store": "^0.
|
|
94
|
-
"@medicine-wheel/data-store-postgres": "^0.
|
|
95
|
-
"@medicine-wheel/fire-keeper": "^0.
|
|
96
|
-
"@medicine-wheel/github-ceremony": "^0.
|
|
97
|
-
"@medicine-wheel/graph-viz": "^0.
|
|
98
|
-
"@medicine-wheel/importance-unit": "^0.
|
|
99
|
-
"@medicine-wheel/mcp": "^4.
|
|
100
|
-
"@medicine-wheel/narrative-cluster": "^0.
|
|
101
|
-
"@medicine-wheel/narrative-engine": "^0.
|
|
102
|
-
"@medicine-wheel/ontology-core": "^0.
|
|
103
|
-
"@medicine-wheel/perception-layer": "^0.
|
|
104
|
-
"@medicine-wheel/prompt-decomposition": "^0.
|
|
105
|
-
"@medicine-wheel/relational-index": "^0.
|
|
106
|
-
"@medicine-wheel/relational-query": "^0.
|
|
107
|
-
"@medicine-wheel/session-reader": "^0.
|
|
108
|
-
"@medicine-wheel/storage-provider": "^0.
|
|
109
|
-
"@medicine-wheel/transformation-tracker": "^0.
|
|
110
|
-
"@medicine-wheel/ui-components": "^0.
|
|
88
|
+
"@medicine-wheel/ceremonial-diary": "^0.6.0",
|
|
89
|
+
"@medicine-wheel/ceremony-protocol": "^0.6.0",
|
|
90
|
+
"@medicine-wheel/community-review": "^0.6.0",
|
|
91
|
+
"@medicine-wheel/consent-lifecycle": "^0.6.0",
|
|
92
|
+
"@medicine-wheel/creative-orientation": "^0.6.0",
|
|
93
|
+
"@medicine-wheel/data-store": "^0.6.0",
|
|
94
|
+
"@medicine-wheel/data-store-postgres": "^0.6.0",
|
|
95
|
+
"@medicine-wheel/fire-keeper": "^0.6.0",
|
|
96
|
+
"@medicine-wheel/github-ceremony": "^0.6.0",
|
|
97
|
+
"@medicine-wheel/graph-viz": "^0.6.0",
|
|
98
|
+
"@medicine-wheel/importance-unit": "^0.6.0",
|
|
99
|
+
"@medicine-wheel/mcp": "^4.6.0",
|
|
100
|
+
"@medicine-wheel/narrative-cluster": "^0.6.0",
|
|
101
|
+
"@medicine-wheel/narrative-engine": "^0.6.0",
|
|
102
|
+
"@medicine-wheel/ontology-core": "^0.6.0",
|
|
103
|
+
"@medicine-wheel/perception-layer": "^0.6.0",
|
|
104
|
+
"@medicine-wheel/prompt-decomposition": "^0.6.0",
|
|
105
|
+
"@medicine-wheel/relational-index": "^0.6.0",
|
|
106
|
+
"@medicine-wheel/relational-query": "^0.6.0",
|
|
107
|
+
"@medicine-wheel/session-reader": "^0.6.0",
|
|
108
|
+
"@medicine-wheel/storage-provider": "^0.6.0",
|
|
109
|
+
"@medicine-wheel/transformation-tracker": "^0.6.0",
|
|
110
|
+
"@medicine-wheel/ui-components": "^0.6.0",
|
|
111
111
|
"@neondatabase/serverless": "^0.10.0",
|
|
112
112
|
"@xyflow/react": "^12.3.0",
|
|
113
113
|
"clsx": "^2.1.1",
|