@medicine-wheel/app 0.5.8 → 0.5.9

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.
@@ -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);
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.9",
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.9",
89
+ "@medicine-wheel/ceremony-protocol": "^0.5.9",
90
+ "@medicine-wheel/community-review": "^0.5.9",
91
+ "@medicine-wheel/consent-lifecycle": "^0.5.9",
92
+ "@medicine-wheel/creative-orientation": "^0.5.9",
93
+ "@medicine-wheel/data-store": "^0.5.9",
94
+ "@medicine-wheel/data-store-postgres": "^0.5.9",
95
+ "@medicine-wheel/fire-keeper": "^0.5.9",
96
+ "@medicine-wheel/github-ceremony": "^0.5.9",
97
+ "@medicine-wheel/graph-viz": "^0.5.9",
98
+ "@medicine-wheel/importance-unit": "^0.5.9",
99
+ "@medicine-wheel/mcp": "^4.5.9",
100
+ "@medicine-wheel/narrative-cluster": "^0.5.9",
101
+ "@medicine-wheel/narrative-engine": "^0.5.9",
102
+ "@medicine-wheel/ontology-core": "^0.5.9",
103
+ "@medicine-wheel/perception-layer": "^0.5.9",
104
+ "@medicine-wheel/prompt-decomposition": "^0.5.9",
105
+ "@medicine-wheel/relational-index": "^0.5.9",
106
+ "@medicine-wheel/relational-query": "^0.5.9",
107
+ "@medicine-wheel/session-reader": "^0.5.9",
108
+ "@medicine-wheel/storage-provider": "^0.5.9",
109
+ "@medicine-wheel/transformation-tracker": "^0.5.9",
110
+ "@medicine-wheel/ui-components": "^0.5.9",
111
111
  "@neondatabase/serverless": "^0.10.0",
112
112
  "@xyflow/react": "^12.3.0",
113
113
  "clsx": "^2.1.1",