@medicine-wheel/app 0.6.3 → 0.7.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
@@ -222,6 +222,36 @@ mw node list
222
222
  The `mw` CLI uses HTTP against the running server by default; MCP fallback is
223
223
  available via a local `MW_MCP_PATH`.
224
224
 
225
+ ## Contributors
226
+
227
+ ### Code Contributors
228
+
229
+ This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
230
+ <a href="https://github.com/undefined/undefined/graphs/contributors"><img src="https://opencollective.com/@medicine-wheel/app/contributors.svg?width=890&button=false" /></a>
231
+
232
+ ### Financial Contributors
233
+
234
+ Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/@medicine-wheel/app/contribute)]
235
+
236
+ #### Individuals
237
+
238
+ <a href="https://opencollective.com/@medicine-wheel/app"><img src="https://opencollective.com/@medicine-wheel/app/individuals.svg?width=890"></a>
239
+
240
+ #### Organizations
241
+
242
+ Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/@medicine-wheel/app/contribute)]
243
+
244
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/0/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/0/avatar.svg"></a>
245
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/1/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/1/avatar.svg"></a>
246
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/2/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/2/avatar.svg"></a>
247
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/3/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/3/avatar.svg"></a>
248
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/4/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/4/avatar.svg"></a>
249
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/5/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/5/avatar.svg"></a>
250
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/6/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/6/avatar.svg"></a>
251
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/7/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/7/avatar.svg"></a>
252
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/8/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/8/avatar.svg"></a>
253
+ <a href="https://opencollective.com/@medicine-wheel/app/organization/9/website"><img src="https://opencollective.com/@medicine-wheel/app/organization/9/avatar.svg"></a>
254
+
225
255
  ## License
226
256
 
227
257
  MIT see [LICENSE](LICENSE)
@@ -21,8 +21,8 @@ export default function AccountabilityPage() {
21
21
  useEffect(() => {
22
22
  Promise.all([
23
23
  fetch("/api/resources").then((r) => r.json()),
24
- fetch("/api/nodes").then((r) => r.json()),
25
- fetch("/api/edges").then((r) => r.json()),
24
+ fetch("/api/nodes?limit=all").then((r) => r.json()),
25
+ fetch("/api/edges?limit=all").then((r) => r.json()),
26
26
  fetch("/api/ceremonies").then((r) => r.json()),
27
27
  fetch("/api/narrative/beats").then((r) => r.json()),
28
28
  ]).then(([res, nodesResponse, e, c, b]) => {
@@ -0,0 +1,24 @@
1
+ import { NextResponse } from "next/server";
2
+ import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
3
+
4
+ type RouteContext = { params: Promise<{ id: string }> };
5
+
6
+ export async function GET(_request: Request, context: RouteContext) {
7
+ try {
8
+ const { id } = await context.params;
9
+ const store = await createProvider();
10
+ const ceremony = await store.getCeremony(id);
11
+
12
+ if (!ceremony) {
13
+ return NextResponse.json(
14
+ { error: `Ceremony not found: ${id}` },
15
+ { status: 404 },
16
+ );
17
+ }
18
+
19
+ return NextResponse.json({ ceremony, provider: detectProvider() });
20
+ } catch (error: unknown) {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ return NextResponse.json({ error: message }, { status: 500 });
23
+ }
24
+ }
@@ -1,14 +1,21 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
3
+ import { ceremonyBelongsToEpisode } from "@/lib/ceremony-response";
3
4
 
4
5
  export async function GET(request: Request) {
5
6
  try {
6
7
  const { searchParams } = new URL(request.url);
7
8
  const direction = searchParams.get("direction");
8
9
  const type = searchParams.get("type");
10
+ const episodePath = searchParams.get("episode_path");
9
11
 
10
12
  const store = await createProvider();
11
- let ceremonies = await store.getAllCeremonies();
13
+ // Provider list methods intentionally default to one page (100). A scoped
14
+ // episode query must search the complete ceremony store before filtering,
15
+ // or an older but valid ceremony silently disappears from its episode.
16
+ let ceremonies = await store.getAllCeremonies(
17
+ episodePath ? Number.MAX_SAFE_INTEGER : undefined,
18
+ );
12
19
 
13
20
  if (direction) {
14
21
  ceremonies = ceremonies.filter((c) => c.direction === direction);
@@ -18,6 +25,12 @@ export async function GET(request: Request) {
18
25
  ceremonies = ceremonies.filter((c) => c.type === type);
19
26
  }
20
27
 
28
+ if (episodePath) {
29
+ ceremonies = ceremonies.filter((ceremony) =>
30
+ ceremonyBelongsToEpisode(ceremony, episodePath),
31
+ );
32
+ }
33
+
21
34
  return NextResponse.json({
22
35
  ceremonies,
23
36
  provider: detectProvider(),
@@ -5,6 +5,7 @@ import {
5
5
  detectProvider,
6
6
  EdgeNotFoundError,
7
7
  } from "@medicine-wheel/storage-provider";
8
+ import { parseLimit } from "@/lib/api-paging";
8
9
 
9
10
  const EdgeCreateSchema = z.object({
10
11
  from_id: z.string({ required_error: "from_id is required" }).trim().min(1),
@@ -83,10 +84,27 @@ function unexpected(error: unknown) {
83
84
  return NextResponse.json({ error: message }, { status: 500 });
84
85
  }
85
86
 
86
- export async function GET() {
87
+ /**
88
+ * Relations, oldest-visible-first as the provider orders them.
89
+ *
90
+ * The response stays a bare array. Five callers parse it that way — the graph,
91
+ * relations, nodes and accountability pages, and `mcp/src/http-store.ts` — so
92
+ * wrapping it in an object to carry a total would break the MCP server to fix a
93
+ * page. `?limit=all` is the honest ask instead, and a caller that wants the
94
+ * count of what it received can read `.length` of a complete answer.
95
+ *
96
+ * Without it this route took `getAllEdges()`'s 100-row default in silence: the
97
+ * graph drew 100 of 191 relations, and 27 of those 100 pointed at nodes the
98
+ * nodes route had not sent, so React Flow dropped them without a word.
99
+ */
100
+ export async function GET(request: Request) {
87
101
  try {
102
+ const { searchParams } = new URL(request.url);
103
+ const limit = parseLimit(searchParams.get("limit"));
104
+ if (limit instanceof NextResponse) return limit;
105
+
88
106
  const store = await createProvider();
89
- const edges = await store.getAllEdges();
107
+ const edges = await store.getAllEdges(limit ?? Number.MAX_SAFE_INTEGER);
90
108
  return NextResponse.json(edges);
91
109
  } catch (error: unknown) {
92
110
  return unexpected(error);
@@ -0,0 +1,287 @@
1
+ import { NextResponse } from "next/server";
2
+ import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
3
+ import { traverse } from "@medicine-wheel/relational-query";
4
+ import type { TraversalDirection } from "@medicine-wheel/relational-query";
5
+ import type { RelationalNode } from "@medicine-wheel/ontology-core";
6
+ import type { RelationalEdge as OntologyEdge } from "@medicine-wheel/ontology-core";
7
+ import type { RelationalEdge as StoredEdge } from "@medicine-wheel/storage-provider";
8
+
9
+ /**
10
+ * One node's relational web — the node, everything within N hops, and the
11
+ * relations among them.
12
+ *
13
+ * `@medicine-wheel/relational-query` has shipped `traverse` and `neighborhood`
14
+ * with depth limits, direction filters, ceremony boundaries and OCAP guards for
15
+ * some time, and has been a declared dependency of this app the whole while.
16
+ * Nothing imported it: `grep -rn "@medicine-wheel/relational-query" app/ lib/`
17
+ * returned nothing. The wheel could always answer "this node and what touches
18
+ * it" and had no way to be asked. This route is the asking.
19
+ *
20
+ * It exists because the graph draws every node it is given on one wheel. At 205
21
+ * nodes that is a field of dots in which 37% have no edge at all and the largest
22
+ * hubs are systemd units, and no amount of layout work fixes a question nobody
23
+ * can narrow. The fix for "I cannot navigate" is not a prettier wheel; it is
24
+ * being able to ask for less.
25
+ */
26
+
27
+ /**
28
+ * Hops from the root.
29
+ *
30
+ * One, not two. Two was the first default and it was a trap: with a chronicle
31
+ * root that every episode `belongs_to`, depth 2 from any episode goes
32
+ * episode → root → all 81 other episodes. Episode 011 has exactly one relation
33
+ * and its "2-hop neighbourhood" was 83 nodes — the corpus, wearing the name of a
34
+ * neighbourhood. `maxExpandDegree` below fixes the cause; this default keeps the
35
+ * first thing a person sees small.
36
+ */
37
+ const DEFAULT_DEPTH = 1;
38
+
39
+ /**
40
+ * A node with more relations than this is shown but not expanded through.
41
+ *
42
+ * 20 is chosen against the measured shape of this wheel rather than as a round
43
+ * number: the chronicle root sits at **82**, and the next-largest nodes are
44
+ * `gaia` at 17, `ilex` at 14 and `episode-332` at 14. So the threshold separates
45
+ * the one true container from the busiest ordinary nodes with room on both
46
+ * sides, and a corpus that grows a second container will cross it before any
47
+ * episode does. Override per request with `?hub=`; `?hub=0` disables suppression.
48
+ */
49
+ const DEFAULT_MAX_EXPAND_DEGREE = 20;
50
+
51
+ /**
52
+ * Beyond this the answer stops being a neighbourhood and becomes the store with
53
+ * extra steps — at depth 6 a connected component is usually fully covered, and
54
+ * the caller wanted `/api/nodes?limit=all` instead.
55
+ */
56
+ const MAX_DEPTH = 6;
57
+
58
+ /**
59
+ * `follow`, not `direction`.
60
+ *
61
+ * This route has two unrelated notions of direction and collapsing them would be
62
+ * a quiet, permanent trap: the wheel's `DirectionName` (east/south/west/north)
63
+ * and the traversal's `TraversalDirection` (outgoing/incoming/both). `direction`
64
+ * already means the former on `/api/nodes`, and a reader who assumed it meant
65
+ * the same here would get a silently different query. So edge-following is
66
+ * `follow`, and `direction` keeps its established meaning.
67
+ */
68
+ const FOLLOW_VALUES = ["outgoing", "incoming", "both"] as const;
69
+
70
+ const WEB_QUERY_PARAMS = ["depth", "follow", "direction", "kind", "type", "hub"] as const;
71
+
72
+ function badRequest(error: string, extra: Record<string, unknown> = {}) {
73
+ return NextResponse.json({ error, ...extra }, { status: 400 });
74
+ }
75
+
76
+ function parseDepth(raw: string | null): number | NextResponse {
77
+ if (raw === null || raw === "") return DEFAULT_DEPTH;
78
+ const parsed = Number(raw);
79
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_DEPTH) {
80
+ return badRequest(`Invalid depth: ${raw} — nothing was traversed.`, {
81
+ hint: `depth must be an integer from 1 to ${MAX_DEPTH}.`,
82
+ });
83
+ }
84
+ return parsed;
85
+ }
86
+
87
+ function parseFollow(raw: string | null): TraversalDirection | NextResponse {
88
+ if (raw === null || raw === "") return "both";
89
+ if ((FOLLOW_VALUES as readonly string[]).includes(raw)) return raw as TraversalDirection;
90
+ return badRequest(`Invalid follow: ${raw} — nothing was traversed.`, {
91
+ accepted: [...FOLLOW_VALUES],
92
+ });
93
+ }
94
+
95
+ export async function GET(
96
+ request: Request,
97
+ { params }: { params: Promise<{ id: string }> },
98
+ ) {
99
+ try {
100
+ const { id } = await params;
101
+ const { searchParams } = new URL(request.url);
102
+
103
+ // The same contract as /api/nodes: an unrecognised parameter is a 400 naming
104
+ // what is accepted, never a filtered-looking payload that filtered nothing.
105
+ const unknown = [...new Set(searchParams.keys())].filter(
106
+ (key) => !(WEB_QUERY_PARAMS as readonly string[]).includes(key),
107
+ );
108
+ if (unknown.length > 0) {
109
+ return badRequest(
110
+ `Unknown query parameter${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")} — nothing was traversed.`,
111
+ { accepted: [...WEB_QUERY_PARAMS] },
112
+ );
113
+ }
114
+
115
+ const depth = parseDepth(searchParams.get("depth"));
116
+ if (depth instanceof NextResponse) return depth;
117
+
118
+ const follow = parseFollow(searchParams.get("follow"));
119
+ if (follow instanceof NextResponse) return follow;
120
+
121
+ const rawHub = searchParams.get("hub");
122
+ let maxExpandDegree: number | undefined = DEFAULT_MAX_EXPAND_DEGREE;
123
+ if (rawHub !== null && rawHub !== "") {
124
+ const parsed = Number(rawHub);
125
+ if (!Number.isInteger(parsed) || parsed < 0) {
126
+ return badRequest(`Invalid hub: ${rawHub} — nothing was traversed.`, {
127
+ hint: "hub must be a non-negative integer; 0 expands through everything.",
128
+ });
129
+ }
130
+ // 0 means "no suppression", which is the honest spelling of "expand
131
+ // through everything" — not "suppress nodes with more than zero edges".
132
+ maxExpandDegree = parsed === 0 ? undefined : parsed;
133
+ }
134
+
135
+ const store = await createProvider();
136
+
137
+ // The whole store, deliberately. A traversal over a 100-row page would walk
138
+ // a graph the store does not have and return a neighbourhood that is missing
139
+ // arbitrary neighbours — the same silent truncation the paging fix closed on
140
+ // the list routes, but harder to notice here because the answer is small by
141
+ // design and looks complete.
142
+ const [allNodes, allEdges] = await Promise.all([
143
+ store.getAllNodes(Number.MAX_SAFE_INTEGER),
144
+ store.getAllEdges(Number.MAX_SAFE_INTEGER),
145
+ ]);
146
+
147
+ const root = allNodes.find((n) => n.id === id);
148
+ if (!root) {
149
+ return NextResponse.json(
150
+ {
151
+ error: `No node with id ${id}.`,
152
+ hint: "Check the id on /nodes, or list them with /api/nodes?limit=all.",
153
+ },
154
+ { status: 404 },
155
+ );
156
+ }
157
+
158
+ // `storage-provider`'s RelationalEdge deliberately makes `id` optional —
159
+ // relations are identified by their (from_id, to_id) pair, which is why
160
+ // /api/edges addresses them as `?id=<from>:<to>`. `traverse` takes
161
+ // ontology-core's edge, where `id` is required. Rather than loosen the
162
+ // ontology or tighten the store, the pair-derived id is supplied here, using
163
+ // the composite form the edges route already established.
164
+ const traversable: OntologyEdge[] = allEdges.map((e) => ({
165
+ ...e,
166
+ id: e.id ?? `${e.from_id}:${e.to_id}`,
167
+ }));
168
+
169
+ const result = traverse(id, allNodes, traversable, [], {
170
+ maxDepth: depth,
171
+ direction: follow,
172
+ maxExpandDegree,
173
+ // The corpus already spells "container" directly, so say it rather than
174
+ // inferring it from degree. `chronicle_root` is one by definition however
175
+ // many episodes it holds; degree stays as the backstop for containers
176
+ // nobody has named. Suppressible with `?hub=0` along with the threshold.
177
+ containerKinds: maxExpandDegree === undefined ? [] : ["chronicle_root"],
178
+ });
179
+
180
+ const nodeById = new Map(allNodes.map((n) => [n.id, n]));
181
+ let nodes = [...result.visitedNodes]
182
+ .map((visitedId) => nodeById.get(visitedId))
183
+ .filter((n): n is RelationalNode => n !== undefined);
184
+
185
+ // Filters narrow what is *returned*, never what is *walked*. Filtering during
186
+ // the walk would cut the graph at every non-matching node and silently hide
187
+ // matches that sit two hops behind one — a neighbourhood is defined by the
188
+ // relations, and the filter is a view of it.
189
+ const kind = searchParams.get("kind") || undefined;
190
+ const type = searchParams.get("type") || undefined;
191
+ const direction = searchParams.get("direction") || undefined;
192
+ if (kind) nodes = nodes.filter((n) => n.metadata?.kind === kind);
193
+ if (type) nodes = nodes.filter((n) => n.type === type);
194
+ if (direction) nodes = nodes.filter((n) => n.direction === direction);
195
+
196
+ // The root always comes back, even when it fails its own filter. A web with
197
+ // no centre is not a web, and dropping it would make an empty result
198
+ // indistinguishable from a missing node.
199
+ if (!nodes.some((n) => n.id === root.id)) nodes = [root, ...nodes];
200
+
201
+ // Induced edges only: both endpoints present. An edge to a node that was not
202
+ // returned is exactly the dangling reference that made the main graph draw
203
+ // 53 unconnected dots out of 100 — a renderer receives it, cannot place one
204
+ // end, and drops it without a word.
205
+ // Filtered from the stored edges, not the traversable copy, so the response
206
+ // carries what the store actually holds rather than this route's synthetic
207
+ // ids. A consumer that round-trips an edge back to /api/edges must send the
208
+ // store's own shape.
209
+ // Reported by the traversal that made the decision, never recomputed here.
210
+ // The first version re-derived it from undirected whole-store degree while
211
+ // the walk had suppressed on direction-filtered degree — so under
212
+ // `?follow=outgoing` the walk expanded straight through the chronicle root
213
+ // (out-degree 0) while this told the caller it held 82 relations. Only one
214
+ // place can know, and it is not this one.
215
+ const nameById = new Map(allNodes.map((n) => [n.id, n.name]));
216
+ const hubsHeld = result.heldAtHubs.map((hold) => ({
217
+ id: hold.nodeId,
218
+ name: nameById.get(hold.nodeId) ?? hold.nodeId,
219
+ degree: hold.degree,
220
+ reason: hold.reason,
221
+ unexpanded: hold.unexpanded.length,
222
+ }));
223
+
224
+ const returnedIds = new Set(nodes.map((n) => n.id));
225
+
226
+ /**
227
+ * "There is more further out" — measured at the frontier, not inferred from
228
+ * why the walk stopped.
229
+ *
230
+ * `maxDepthReached` is the traversal's own flag and it means *the walk hit
231
+ * the limit*, which is not the same claim: a walk that reached every node in
232
+ * a component at exactly its depth limit sets it, with nothing beyond. And
233
+ * hub suppression sets nothing at all, so a walk that withheld 81 nodes
234
+ * reported `false` — the failure that started this fix, pointing the other
235
+ * way. Both are answered by asking the graph instead: does any returned node
236
+ * have an edge to a node that was not returned?
237
+ */
238
+ const hasMoreBeyond = allEdges.some(
239
+ (e) =>
240
+ (returnedIds.has(e.from_id) && !returnedIds.has(e.to_id)) ||
241
+ (returnedIds.has(e.to_id) && !returnedIds.has(e.from_id)),
242
+ );
243
+ const edges: StoredEdge[] = allEdges.filter(
244
+ (e) => returnedIds.has(e.from_id) && returnedIds.has(e.to_id),
245
+ );
246
+
247
+ return NextResponse.json({
248
+ root,
249
+ nodes,
250
+ edges,
251
+ provider: detectProvider(),
252
+ count: nodes.length,
253
+ depth,
254
+ follow,
255
+ // Which returned nodes were reached but not expanded through, and how much
256
+ // sits behind each. Reported rather than silently applied: a neighbourhood
257
+ // that quietly stopped at a container is the same class of lie as a list
258
+ // that quietly stopped at 100 rows.
259
+ hubs: hubsHeld,
260
+ maxExpandDegree: maxExpandDegree ?? null,
261
+ // True when there is more further out, for either reason: the walk hit the
262
+ // depth limit, or it stopped at a container.
263
+ //
264
+ // `maxDepthReached` alone was wrong here and wrong in the direction that
265
+ // hides things. Suppression skips the queue push, so a hub-terminated walk
266
+ // never sets it — measured on the live wheel, episode 011 at depth 3 came
267
+ // back `truncated: false` with 81 nodes withheld, and the page's "there is
268
+ // more further out" line went dark for exactly the node that motivated the
269
+ // feature. Both are the same claim to a reader: you are not seeing all of it.
270
+ //
271
+ // A container is only truncating when it actually withheld something. A
272
+ // `chronicle_root` with one child is held by kind and hides nothing, and
273
+ // saying "there is more further out" there would be the same false claim
274
+ // pointing the other way.
275
+ truncated: hasMoreBeyond,
276
+ // Kept apart so a caller can still tell the two reasons apart.
277
+ depthLimited: result.maxDepthReached,
278
+ // Crossings a ceremony or OCAP guard refused. Empty here because this route
279
+ // sets no guards, but carried so the shape does not change when it does.
280
+ escalations: result.escalations,
281
+ ...(kind || type || direction ? { filters: { kind, type, direction } } : {}),
282
+ });
283
+ } catch (error: unknown) {
284
+ const message = error instanceof Error ? error.message : String(error);
285
+ return NextResponse.json({ error: message }, { status: 500 });
286
+ }
287
+ }
@@ -2,6 +2,7 @@ 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 { parseLimit } from "@/lib/api-paging";
5
6
 
6
7
  const NodeCreateSchema = z.object({
7
8
  id: z.string().trim().min(1).optional(),
@@ -37,6 +38,20 @@ const NodeCreateSchema = z.object({
37
38
  */
38
39
  const NODE_FILTER_PARAMS = ["type", "direction", "kind", "parent_id"] as const;
39
40
 
41
+ /**
42
+ * `limit` is accepted but is not a filter — it pages, it does not narrow. Kept in
43
+ * its own list so `filters` stays a description of what was *matched*, and so the
44
+ * echoed `filters` object never claims a page size was a predicate.
45
+ *
46
+ * It exists because the provider's default page size is 100 and a bare read took
47
+ * it silently: the graph drew 100 of 205 nodes and its own panel reported "100
48
+ * Nodes" as though that were the store. `?limit=all` asks for everything;
49
+ * `?limit=<n>` asks for n. The response now always carries `total`, so a caller
50
+ * that forgets to ask can still tell it was handed a window.
51
+ */
52
+ const NODE_PAGING_PARAMS = ["limit"] as const;
53
+ const NODE_QUERY_PARAMS = [...NODE_FILTER_PARAMS, ...NODE_PAGING_PARAMS] as const;
54
+
40
55
  /**
41
56
  * A silently-ignored filter is the failure this route exists to prevent: a
42
57
  * consumer that asks `?kinds=service` and gets a filtered-looking payload it
@@ -45,14 +60,14 @@ const NODE_FILTER_PARAMS = ["type", "direction", "kind", "parent_id"] as const;
45
60
  */
46
61
  function rejectUnknownParams(searchParams: URLSearchParams) {
47
62
  const unknown = [...new Set(searchParams.keys())].filter(
48
- (key) => !(NODE_FILTER_PARAMS as readonly string[]).includes(key),
63
+ (key) => !(NODE_QUERY_PARAMS as readonly string[]).includes(key),
49
64
  );
50
65
  if (unknown.length === 0) return null;
51
66
 
52
67
  return NextResponse.json(
53
68
  {
54
69
  error: `Unknown query parameter${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")} — nothing was filtered.`,
55
- accepted: [...NODE_FILTER_PARAMS],
70
+ accepted: [...NODE_QUERY_PARAMS],
56
71
  },
57
72
  { status: 400 },
58
73
  );
@@ -72,17 +87,25 @@ export async function GET(request: Request) {
72
87
  ) as Partial<Record<(typeof NODE_FILTER_PARAMS)[number], string>>;
73
88
  const filtering = Object.values(filters).some(Boolean);
74
89
 
90
+ const limit = parseLimit(searchParams.get("limit"));
91
+ if (limit instanceof NextResponse) return limit;
92
+
75
93
  const store = await createProvider();
76
94
 
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();
95
+ // Always read the whole store, then page here. Two reasons, both paid for:
96
+ //
97
+ // A *filtered* read must see everything or it answers from a truncated
98
+ // window — filtering after the provider's 100-row slice returns quietly
99
+ // incomplete sets rather than failing loudly.
100
+ //
101
+ // An *unfiltered* read must know the true size even when it returns a page,
102
+ // because the previous behaviour let `getAllNodes()` take its 100-row default
103
+ // in silence: the graph rendered 100 of 205 nodes and reported "100 Nodes".
104
+ // `total` below is what makes a windowed answer legible as one.
105
+ const allNodes = await store.getAllNodes(Number.MAX_SAFE_INTEGER);
106
+ const total = allNodes.length;
107
+
108
+ let nodes = allNodes;
86
109
 
87
110
  // Every supplied filter narrows (AND). Previously `type` and `direction`
88
111
  // were `else if`, so `?type=x&direction=y` silently dropped the direction —
@@ -95,10 +118,20 @@ export async function GET(request: Request) {
95
118
  nodes = nodes.filter((n) => n.metadata?.parent_id === filters.parent_id);
96
119
  }
97
120
 
121
+ // Paging is applied after filtering so `?kind=x&limit=10` means "ten of the
122
+ // matching nodes", not "the matches among the first ten".
123
+ const matched = nodes.length;
124
+ if (limit !== null && nodes.length > limit) nodes = nodes.slice(0, limit);
125
+
98
126
  return NextResponse.json({
99
127
  nodes,
100
128
  provider: detectProvider(),
101
129
  count: nodes.length,
130
+ // `total` is the whole store; `matched` is what the filters selected. When
131
+ // count < matched the caller is holding a page, and can now see that it is.
132
+ total,
133
+ ...(filtering ? { matched } : {}),
134
+ truncated: nodes.length < matched,
102
135
  // Echoed only when asked for, so an unfiltered response stays byte-identical
103
136
  // to what every existing consumer already parses — and so a caller can tell
104
137
  // an empty result from an unapplied filter.