@medicine-wheel/app 0.6.4 → 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 +30 -0
- package/app/accountability/page.tsx +2 -2
- package/app/api/edges/route.ts +20 -2
- package/app/api/nodes/[id]/web/route.ts +287 -0
- package/app/api/nodes/route.ts +44 -11
- package/app/episodes/[id]/page.tsx +223 -0
- package/app/episodes/page.tsx +222 -0
- package/app/graph/page.tsx +224 -14
- package/app/nodes/page.tsx +5 -2
- package/app/relations/page.tsx +1 -1
- package/components/navigation.tsx +1 -0
- package/lib/api-paging.ts +49 -0
- package/package.json +24 -24
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]) => {
|
package/app/api/edges/route.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/app/api/nodes/route.ts
CHANGED
|
@@ -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) => !(
|
|
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: [...
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|