@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.
- package/app/api/captures/route.ts +224 -0
- package/app/api/nodes/route.ts +76 -8
- 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
|
+
}
|
package/app/api/nodes/route.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
-
const
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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.
|
|
89
|
-
"@medicine-wheel/ceremony-protocol": "^0.5.
|
|
90
|
-
"@medicine-wheel/community-review": "^0.5.
|
|
91
|
-
"@medicine-wheel/consent-lifecycle": "^0.5.
|
|
92
|
-
"@medicine-wheel/creative-orientation": "^0.5.
|
|
93
|
-
"@medicine-wheel/data-store": "^0.5.
|
|
94
|
-
"@medicine-wheel/data-store-postgres": "^0.5.
|
|
95
|
-
"@medicine-wheel/fire-keeper": "^0.5.
|
|
96
|
-
"@medicine-wheel/github-ceremony": "^0.5.
|
|
97
|
-
"@medicine-wheel/graph-viz": "^0.5.
|
|
98
|
-
"@medicine-wheel/importance-unit": "^0.5.
|
|
99
|
-
"@medicine-wheel/mcp": "^4.5.
|
|
100
|
-
"@medicine-wheel/narrative-cluster": "^0.5.
|
|
101
|
-
"@medicine-wheel/narrative-engine": "^0.5.
|
|
102
|
-
"@medicine-wheel/ontology-core": "^0.5.
|
|
103
|
-
"@medicine-wheel/perception-layer": "^0.5.
|
|
104
|
-
"@medicine-wheel/prompt-decomposition": "^0.5.
|
|
105
|
-
"@medicine-wheel/relational-index": "^0.5.
|
|
106
|
-
"@medicine-wheel/relational-query": "^0.5.
|
|
107
|
-
"@medicine-wheel/session-reader": "^0.5.
|
|
108
|
-
"@medicine-wheel/storage-provider": "^0.5.
|
|
109
|
-
"@medicine-wheel/transformation-tracker": "^0.5.
|
|
110
|
-
"@medicine-wheel/ui-components": "^0.5.
|
|
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",
|