@sqaoss/flowy 1.7.0 → 1.9.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
@@ -189,6 +189,20 @@ The self-hosted server supports the full planning workflow — `init`, `project`
189
189
 
190
190
  All commands output JSON to stdout; errors go to stderr as `{ "error": "message" }`.
191
191
 
192
+ ## GraphQL API
193
+
194
+ The CLI is a thin client over a GraphQL API. To integrate directly — or to
195
+ understand what the CLI sends — see the API reference for the bundled local
196
+ server:
197
+
198
+ - **[docs/API.md](docs/API.md)** — schema, example queries/mutations,
199
+ error-code catalogue (with CLI exit codes), and limits.
200
+ - **[docs/api/schema.graphql](docs/api/schema.graphql)** — the full SDL,
201
+ regenerable with `bun run sdl`.
202
+
203
+ The hosted service at `flowy-ai.fly.dev` exposes a superset of this schema plus
204
+ account/billing operations; its API is documented separately.
205
+
192
206
  ## Configuration
193
207
 
194
208
  Config is stored at `~/.config/flowy/config.json`. These environment variables override config:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,7 @@
50
50
  "check": "biome check --write .",
51
51
  "test": "vitest run",
52
52
  "test:watch": "vitest",
53
+ "sdl": "bun scripts/print-sdl.ts",
53
54
  "typecheck": "tsc --noEmit",
54
55
  "prepare": "husky"
55
56
  },
@@ -0,0 +1,440 @@
1
+ /**
2
+ * CLI <-> bundled-local-server CONTRACT GUARD (Flowy improvement plan P1-1).
3
+ *
4
+ * This test executes every GraphQL operation the Flowy CLI sends (the canonical
5
+ * set in `src/util/operations.ts`) against a live instance of the *bundled
6
+ * local server* (`server/src/index.ts`'s `createServer`), over its real HTTP +
7
+ * GraphQL transport. Because Yoga parses and validates each operation against
8
+ * the server's schema before executing it, this catches drift the unit tests
9
+ * (which call resolvers directly) cannot:
10
+ *
11
+ * - a renamed query/mutation (e.g. `readyTasks` -> `actionableTasks`),
12
+ * - a renamed or dropped field on a returned type (e.g. `Node.metadata`),
13
+ * - a renamed, dropped, or retyped argument (e.g. `search(limit:)`),
14
+ *
15
+ * any of which would make the CLI break at runtime. If you rename anything the
16
+ * CLI relies on, this test fails — forcing the CLI and the local server to move
17
+ * together.
18
+ *
19
+ * Scope: this guards the LOCAL side of the contract. The hosted `flowy-saas`
20
+ * repo keeps its own mirror of these query strings in
21
+ * `test/helpers/cli-queries.ts` and should assert them the same way against its
22
+ * Postgres schema.
23
+ *
24
+ * ── Intentional local / SaaS divergences (documented, NOT failures) ──────────
25
+ * The CLI's operations module also exports SAAS_ONLY_OPERATIONS. The bundled
26
+ * local server deliberately does NOT implement these — they require auth,
27
+ * billing, or audit infrastructure that only exists in the hosted backend:
28
+ *
29
+ * - `register` (setup.ts) — account creation; local has no accounts.
30
+ * - `whoami` (whoami.ts) — current user; local is unauthenticated.
31
+ * - `rotateApiKey` (key.ts) — API key lifecycle; local has no keys.
32
+ * - `createCheckout` (billing.ts)— Polar checkout; local has no billing.
33
+ *
34
+ * Conversely, the SaaS schema carries operations the local server lacks and the
35
+ * CLI does not yet call against local: `auditLog`/`getAuditLog` (P1-2 will port
36
+ * this to local), `ancestors`, and `nodes(status/limit/offset)` pagination.
37
+ * Status vocabulary and edge relations are shared (`part_of`, `blocks`); the
38
+ * SaaS schema additionally recognises `epic`/`depends_on`/`informs` relations
39
+ * the bundled server does not. The test below asserts the local server rejects
40
+ * each SAAS_ONLY operation (proving the divergence is real and explicit), and
41
+ * that it satisfies every LOCAL_CONTRACT_OPERATIONS entry.
42
+ */
43
+
44
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
45
+ import {
46
+ LOCAL_CONTRACT_OPERATIONS,
47
+ SAAS_ONLY_OPERATIONS,
48
+ } from '../../src/util/operations.ts'
49
+ import { createServer } from './index.ts'
50
+
51
+ let instance: ReturnType<typeof createServer>
52
+ let endpoint: string
53
+
54
+ /** Run an operation through the live server's HTTP/GraphQL transport. */
55
+ async function run<T = Record<string, unknown>>(
56
+ query: string,
57
+ variables?: Record<string, unknown>,
58
+ ): Promise<{ data: T | null; errors?: Array<{ message: string }> }> {
59
+ const res = await fetch(endpoint, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ query, variables }),
63
+ })
64
+ return (await res.json()) as {
65
+ data: T | null
66
+ errors?: Array<{ message: string }>
67
+ }
68
+ }
69
+
70
+ /** Run an operation and fail loudly if the server reports any GraphQL error. */
71
+ async function ok<T = Record<string, unknown>>(
72
+ query: string,
73
+ variables?: Record<string, unknown>,
74
+ ): Promise<T> {
75
+ const body = await run<T>(query, variables)
76
+ expect(
77
+ body.errors,
78
+ `operation produced GraphQL errors: ${JSON.stringify(body.errors)}`,
79
+ ).toBeUndefined()
80
+ expect(body.data).not.toBeNull()
81
+ return body.data as T
82
+ }
83
+
84
+ beforeAll(() => {
85
+ instance = createServer({ dbPath: ':memory:', port: 0 })
86
+ endpoint = `http://localhost:${instance.port}/graphql`
87
+ })
88
+
89
+ afterAll(() => {
90
+ instance.close()
91
+ })
92
+
93
+ describe('CLI/local-server contract (P1-1)', () => {
94
+ it('exercises every CLI operation against the bundled local server', async () => {
95
+ // --- Mutations: build a project -> feature -> task hierarchy ------------
96
+ const { createNode: project } = await ok<{
97
+ createNode: { id: string; type: string; status: string }
98
+ }>(LOCAL_CONTRACT_OPERATIONS.CREATE_PROJECT, {
99
+ type: 'project',
100
+ title: 'Contract Project',
101
+ })
102
+ expect(project.id).toMatch(/^proj_/)
103
+ expect(project.status).toBe('draft')
104
+
105
+ const { createNode: feature } = await ok<{
106
+ createNode: { id: string; description: string | null }
107
+ }>(LOCAL_CONTRACT_OPERATIONS.CREATE_NODE, {
108
+ type: 'feature',
109
+ title: 'Contract Feature',
110
+ description: 'A feature',
111
+ })
112
+ expect(feature.id).toMatch(/^feat_/)
113
+ expect(feature.description).toBe('A feature')
114
+
115
+ const { createNode: task } = await ok<{
116
+ createNode: { id: string }
117
+ }>(LOCAL_CONTRACT_OPERATIONS.CREATE_TASK, {
118
+ type: 'task',
119
+ title: 'Contract Task',
120
+ description: 'Do the thing',
121
+ })
122
+ expect(task.id).toMatch(/^task_/)
123
+
124
+ const { createNode: blocker } = await ok<{ createNode: { id: string } }>(
125
+ LOCAL_CONTRACT_OPERATIONS.CREATE_TASK,
126
+ { type: 'task', title: 'Blocker Task', description: 'first' },
127
+ )
128
+
129
+ // import.ts CREATE/UPDATE carry status + metadata; verify they round-trip.
130
+ const { createNode: imported } = await ok<{ createNode: { id: string } }>(
131
+ LOCAL_CONTRACT_OPERATIONS.IMPORT_CREATE,
132
+ {
133
+ type: 'task',
134
+ title: 'Imported Task',
135
+ description: 'imported',
136
+ status: 'in_progress',
137
+ metadata: JSON.stringify({ clientKey: 'k-1' }),
138
+ },
139
+ )
140
+ expect(imported.id).toMatch(/^task_/)
141
+
142
+ await ok(LOCAL_CONTRACT_OPERATIONS.IMPORT_UPDATE, {
143
+ id: imported.id,
144
+ title: 'Imported Task v2',
145
+ description: 'updated',
146
+ status: 'done',
147
+ metadata: JSON.stringify({ clientKey: 'k-1' }),
148
+ })
149
+
150
+ // --- Edges: part_of hierarchy + a blocks dependency --------------------
151
+ const { createEdge: featureEdge } = await ok<{
152
+ createEdge: { sourceId: string; targetId: string; relation: string }
153
+ }>(LOCAL_CONTRACT_OPERATIONS.CREATE_EDGE, {
154
+ sourceId: feature.id,
155
+ targetId: project.id,
156
+ relation: 'part_of',
157
+ })
158
+ expect(featureEdge.relation).toBe('part_of')
159
+
160
+ await ok(LOCAL_CONTRACT_OPERATIONS.LINK_TASK, {
161
+ sourceId: task.id,
162
+ targetId: feature.id,
163
+ relation: 'part_of',
164
+ })
165
+ await ok(LOCAL_CONTRACT_OPERATIONS.LINK_TASK, {
166
+ sourceId: blocker.id,
167
+ targetId: feature.id,
168
+ relation: 'part_of',
169
+ })
170
+ await ok(LOCAL_CONTRACT_OPERATIONS.IMPORT_EDGE, {
171
+ sourceId: imported.id,
172
+ targetId: feature.id,
173
+ relation: 'part_of',
174
+ })
175
+
176
+ // blocker blocks task
177
+ const { createEdge: blocksEdge } = await ok<{
178
+ createEdge: { relation: string; createdAt: string }
179
+ }>(LOCAL_CONTRACT_OPERATIONS.BLOCK_TASK, {
180
+ sourceId: blocker.id,
181
+ targetId: task.id,
182
+ relation: 'blocks',
183
+ })
184
+ expect(blocksEdge.relation).toBe('blocks')
185
+ expect(typeof blocksEdge.createdAt).toBe('string')
186
+
187
+ // --- Status / approval -------------------------------------------------
188
+ const { updateNode: statusUpdated } = await ok<{
189
+ updateNode: { status: string; updatedAt: string }
190
+ }>(LOCAL_CONTRACT_OPERATIONS.UPDATE_STATUS, {
191
+ id: task.id,
192
+ status: 'pending_review',
193
+ })
194
+ expect(statusUpdated.status).toBe('pending_review')
195
+
196
+ const { approveNode } = await ok<{ approveNode: { status: string } }>(
197
+ LOCAL_CONTRACT_OPERATIONS.APPROVE_NODE,
198
+ { id: task.id },
199
+ )
200
+ expect(approveNode.status).toBe('approved')
201
+
202
+ // update content (title/description/metadata)
203
+ const { updateNode } = await ok<{
204
+ updateNode: { title: string; metadata: string | null }
205
+ }>(LOCAL_CONTRACT_OPERATIONS.UPDATE_NODE, {
206
+ id: task.id,
207
+ title: 'Renamed Task',
208
+ description: 'New body',
209
+ metadata: JSON.stringify({ note: 'x' }),
210
+ })
211
+ expect(updateNode.title).toBe('Renamed Task')
212
+ expect(updateNode.metadata).toContain('note')
213
+
214
+ // --- Queries: reads ----------------------------------------------------
215
+ const getNode = await ok<{ node: { id: string } | null }>(
216
+ LOCAL_CONTRACT_OPERATIONS.GET_NODE,
217
+ { id: feature.id },
218
+ )
219
+ expect(getNode.node?.id).toBe(feature.id)
220
+
221
+ const getProject = await ok<{ node: { id: string } | null }>(
222
+ LOCAL_CONTRACT_OPERATIONS.GET_PROJECT,
223
+ { id: project.id },
224
+ )
225
+ expect(getProject.node?.id).toBe(project.id)
226
+
227
+ const exportProject = await ok<{
228
+ node: { metadata: string | null } | null
229
+ }>(LOCAL_CONTRACT_OPERATIONS.EXPORT_PROJECT, { id: project.id })
230
+ expect(exportProject.node).not.toBeNull()
231
+
232
+ const listForSet = await ok<{
233
+ nodes: Array<{ id: string; title: string }>
234
+ }>(LOCAL_CONTRACT_OPERATIONS.LIST_PROJECTS_FOR_SET, { type: 'project' })
235
+ expect(listForSet.nodes.some((n) => n.id === project.id)).toBe(true)
236
+
237
+ const listProjects = await ok<{ nodes: unknown[] }>(
238
+ LOCAL_CONTRACT_OPERATIONS.LIST_PROJECTS,
239
+ { type: 'project' },
240
+ )
241
+ expect(listProjects.nodes.length).toBeGreaterThan(0)
242
+
243
+ const allTasks = await ok<{ nodes: Array<{ type: string }> }>(
244
+ LOCAL_CONTRACT_OPERATIONS.ALL_TASKS,
245
+ { type: 'task' },
246
+ )
247
+ expect(allTasks.nodes.every((n) => n.type === 'task')).toBe(true)
248
+
249
+ const importExisting = await ok<{
250
+ nodes: Array<{ metadata: string | null }>
251
+ }>(LOCAL_CONTRACT_OPERATIONS.IMPORT_EXISTING, { type: 'task' })
252
+ expect(importExisting.nodes.length).toBeGreaterThan(0)
253
+
254
+ const descendants = await ok<{ descendants: Array<{ id: string }> }>(
255
+ LOCAL_CONTRACT_OPERATIONS.DESCENDANTS,
256
+ { nodeId: project.id, relation: 'part_of', maxDepth: 1 },
257
+ )
258
+ expect(descendants.descendants.some((n) => n.id === feature.id)).toBe(true)
259
+
260
+ const descendantsBrief = await ok<{
261
+ descendants: Array<{ status: string }>
262
+ }>(LOCAL_CONTRACT_OPERATIONS.DESCENDANTS_BRIEF, {
263
+ nodeId: project.id,
264
+ relation: 'part_of',
265
+ maxDepth: 1,
266
+ })
267
+ expect(descendantsBrief.descendants.length).toBeGreaterThan(0)
268
+
269
+ const listTasks = await ok<{ descendants: unknown[] }>(
270
+ LOCAL_CONTRACT_OPERATIONS.LIST_TASKS,
271
+ { nodeId: feature.id, relation: 'part_of', maxDepth: 1 },
272
+ )
273
+ expect(listTasks.descendants.length).toBeGreaterThan(0)
274
+
275
+ const exportDescendants = await ok<{
276
+ descendants: Array<{ metadata: string | null }>
277
+ }>(LOCAL_CONTRACT_OPERATIONS.EXPORT_DESCENDANTS, {
278
+ nodeId: project.id,
279
+ relation: 'part_of',
280
+ maxDepth: 100,
281
+ })
282
+ expect(exportDescendants.descendants.length).toBeGreaterThan(0)
283
+
284
+ // SUBTREE follows one relation (default part_of) and annotates each node
285
+ // with parentId/depth/relation. From the project, the part_of view is:
286
+ // project -> feature (depth 1) -> {task, blocker, imported} (depth 2),
287
+ // all reached via part_of edges. The blocker -> task `blocks` edge must NOT
288
+ // change anyone's parent/relation: every returned node carries
289
+ // relation 'part_of' and its part_of parent, never the blocks linkage.
290
+ const subtree = await ok<{
291
+ subtree: Array<{
292
+ id: string
293
+ parentId: string
294
+ depth: number
295
+ relation: string
296
+ }>
297
+ }>(LOCAL_CONTRACT_OPERATIONS.SUBTREE, {
298
+ nodeId: project.id,
299
+ relation: 'part_of',
300
+ maxDepth: 10,
301
+ })
302
+ const subtreeById = new Map(subtree.subtree.map((n) => [n.id, n]))
303
+ expect(subtreeById.get(feature.id)).toMatchObject({
304
+ parentId: project.id,
305
+ depth: 1,
306
+ relation: 'part_of',
307
+ })
308
+ expect(subtreeById.get(task.id)).toMatchObject({
309
+ parentId: feature.id,
310
+ depth: 2,
311
+ relation: 'part_of',
312
+ })
313
+ // the blocker is reached via its part_of edge to the feature (depth 2),
314
+ // NOT via the blocks edge that points at the task — proving blocks edges
315
+ // do not leak into the hierarchy view.
316
+ expect(subtreeById.get(blocker.id)).toMatchObject({
317
+ parentId: feature.id,
318
+ depth: 2,
319
+ relation: 'part_of',
320
+ })
321
+ expect(subtree.subtree.every((n) => n.relation === 'part_of')).toBe(true)
322
+
323
+ // task is blocked by an unfinished blocker, so readyTasks excludes it.
324
+ const ready = await ok<{ readyTasks: Array<{ id: string }> }>(
325
+ LOCAL_CONTRACT_OPERATIONS.READY_TASKS,
326
+ { projectId: project.id },
327
+ )
328
+ const readyIds = ready.readyTasks.map((t) => t.id)
329
+ expect(readyIds).toContain(blocker.id)
330
+ expect(readyIds).not.toContain(task.id)
331
+
332
+ // edges via SHOW_TASK: blockedBy (incoming) + blocks (outgoing)
333
+ const showTask = await ok<{
334
+ node: { id: string } | null
335
+ blockedBy: Array<{ id: string }>
336
+ blocks: Array<{ id: string }>
337
+ }>(LOCAL_CONTRACT_OPERATIONS.SHOW_TASK, { id: task.id })
338
+ expect(showTask.node?.id).toBe(task.id)
339
+ expect(showTask.blockedBy.map((n) => n.id)).toContain(blocker.id)
340
+
341
+ const taskDeps = await ok<{
342
+ blockedBy: Array<{ id: string }>
343
+ blocks: Array<{ id: string }>
344
+ }>(LOCAL_CONTRACT_OPERATIONS.TASK_DEPS, { id: blocker.id })
345
+ expect(taskDeps.blocks.map((n) => n.id)).toContain(task.id)
346
+
347
+ const importEdges = await ok<{ edges: Array<{ id: string }> }>(
348
+ LOCAL_CONTRACT_OPERATIONS.IMPORT_EDGES,
349
+ { nodeId: feature.id, relation: 'part_of' },
350
+ )
351
+ expect(Array.isArray(importEdges.edges)).toBe(true)
352
+
353
+ const exportEdges = await ok<{
354
+ edges: Array<{ id: string; metadata: string | null }>
355
+ }>(LOCAL_CONTRACT_OPERATIONS.EXPORT_EDGES, {
356
+ nodeId: feature.id,
357
+ relation: 'part_of',
358
+ })
359
+ expect(Array.isArray(exportEdges.edges)).toBe(true)
360
+
361
+ const search = await ok<{ search: Array<{ id: string }> }>(
362
+ LOCAL_CONTRACT_OPERATIONS.SEARCH,
363
+ { query: 'Contract', type: 'project', status: null, limit: 50 },
364
+ )
365
+ expect(search.search.some((n) => n.id === project.id)).toBe(true)
366
+
367
+ // --- Edge removal + node deletion --------------------------------------
368
+ const { removeEdge } = await ok<{ removeEdge: boolean }>(
369
+ LOCAL_CONTRACT_OPERATIONS.UNBLOCK_TASK,
370
+ { sourceId: blocker.id, targetId: task.id, relation: 'blocks' },
371
+ )
372
+ expect(removeEdge).toBe(true)
373
+
374
+ const { deleteNode } = await ok<{ deleteNode: boolean }>(
375
+ LOCAL_CONTRACT_OPERATIONS.DELETE_NODE,
376
+ { id: task.id },
377
+ )
378
+ expect(deleteNode).toBe(true)
379
+ })
380
+
381
+ it('covers the entire LOCAL_CONTRACT_OPERATIONS set (no op left untested)', () => {
382
+ // Guards against silently adding a CLI operation that the contract above
383
+ // never exercises. Keep this list in lockstep with the assertions above.
384
+ const exercised = new Set([
385
+ 'GET_NODE',
386
+ 'GET_PROJECT',
387
+ 'LIST_PROJECTS_FOR_SET',
388
+ 'LIST_PROJECTS',
389
+ 'ALL_TASKS',
390
+ 'DESCENDANTS',
391
+ 'DESCENDANTS_BRIEF',
392
+ 'LIST_TASKS',
393
+ 'SUBTREE',
394
+ 'READY_TASKS',
395
+ 'SHOW_TASK',
396
+ 'TASK_DEPS',
397
+ 'SEARCH',
398
+ 'CREATE_PROJECT',
399
+ 'CREATE_NODE',
400
+ 'CREATE_TASK',
401
+ 'UPDATE_NODE',
402
+ 'UPDATE_STATUS',
403
+ 'APPROVE_NODE',
404
+ 'DELETE_NODE',
405
+ 'CREATE_EDGE',
406
+ 'LINK_TASK',
407
+ 'BLOCK_TASK',
408
+ 'UNBLOCK_TASK',
409
+ 'IMPORT_EXISTING',
410
+ 'IMPORT_EDGES',
411
+ 'IMPORT_CREATE',
412
+ 'IMPORT_UPDATE',
413
+ 'IMPORT_EDGE',
414
+ 'EXPORT_PROJECT',
415
+ 'EXPORT_DESCENDANTS',
416
+ 'EXPORT_EDGES',
417
+ ])
418
+ expect(new Set(Object.keys(LOCAL_CONTRACT_OPERATIONS))).toEqual(exercised)
419
+ })
420
+
421
+ describe('intentional SaaS-only divergences (must NOT resolve on local)', () => {
422
+ for (const [name, query] of Object.entries(SAAS_ONLY_OPERATIONS)) {
423
+ it(`local server rejects ${name}`, async () => {
424
+ const variables: Record<string, Record<string, unknown>> = {
425
+ REGISTER: { email: 'a@b.co', tier: 'pro' },
426
+ CREATE_CHECKOUT: { tier: 'pro' },
427
+ }
428
+ const body = await run(query, variables[name] ?? {})
429
+ // The bundled local server's schema has no register/whoami/
430
+ // rotateApiKey/createCheckout, so Yoga rejects with a validation error
431
+ // ("Cannot query field ..." / "Unknown ..."). This is the contract: the
432
+ // CLI must NOT route these to a local backend.
433
+ expect(
434
+ body.errors,
435
+ `${name} unexpectedly resolved on the local server`,
436
+ ).toBeDefined()
437
+ })
438
+ }
439
+ })
440
+ })
@@ -580,7 +580,7 @@ describe('createResolvers', () => {
580
580
  })
581
581
 
582
582
  describe('Query.subtree', () => {
583
- it('returns full tree traversal across all edge types', () => {
583
+ it('returns the part_of hierarchy by default', () => {
584
584
  const project = resolvers.Mutation.createNode(null, {
585
585
  type: 'project',
586
586
  title: 'Root',
@@ -608,6 +608,125 @@ describe('createResolvers', () => {
608
608
  expect(tree).toHaveLength(2)
609
609
  })
610
610
 
611
+ it('returns parentId, depth and relation for each node (default part_of)', () => {
612
+ const project = resolvers.Mutation.createNode(null, {
613
+ type: 'project',
614
+ title: 'Root',
615
+ })
616
+ const feature = resolvers.Mutation.createNode(null, {
617
+ type: 'feature',
618
+ title: 'F1',
619
+ })
620
+ const task = resolvers.Mutation.createNode(null, {
621
+ type: 'task',
622
+ title: 'T1',
623
+ })
624
+ resolvers.Mutation.createEdge(null, {
625
+ sourceId: feature.id,
626
+ targetId: project.id,
627
+ relation: 'part_of',
628
+ })
629
+ resolvers.Mutation.createEdge(null, {
630
+ sourceId: task.id,
631
+ targetId: feature.id,
632
+ relation: 'part_of',
633
+ })
634
+
635
+ const tree = resolvers.Query.subtree(null, { nodeId: project.id })
636
+ const byId = new Map(tree.map((n) => [n.id, n]))
637
+
638
+ const featNode = byId.get(feature.id)
639
+ expect(featNode).toBeDefined()
640
+ expect(featNode?.depth).toBe(1)
641
+ expect(featNode?.parentId).toBe(project.id)
642
+ expect(featNode?.relation).toBe('part_of')
643
+
644
+ const taskNode = byId.get(task.id)
645
+ expect(taskNode).toBeDefined()
646
+ expect(taskNode?.depth).toBe(2)
647
+ expect(taskNode?.parentId).toBe(feature.id)
648
+ expect(taskNode?.relation).toBe('part_of')
649
+ })
650
+
651
+ it('excludes blocks edges from the default part_of view', () => {
652
+ const project = resolvers.Mutation.createNode(null, {
653
+ type: 'project',
654
+ title: 'Root',
655
+ })
656
+ const feature = resolvers.Mutation.createNode(null, {
657
+ type: 'feature',
658
+ title: 'F1',
659
+ })
660
+ const task = resolvers.Mutation.createNode(null, {
661
+ type: 'task',
662
+ title: 'T1',
663
+ })
664
+ const blocker = resolvers.Mutation.createNode(null, {
665
+ type: 'task',
666
+ title: 'Blocker',
667
+ })
668
+ resolvers.Mutation.createEdge(null, {
669
+ sourceId: feature.id,
670
+ targetId: project.id,
671
+ relation: 'part_of',
672
+ })
673
+ resolvers.Mutation.createEdge(null, {
674
+ sourceId: task.id,
675
+ targetId: feature.id,
676
+ relation: 'part_of',
677
+ })
678
+ // blocker blocks task: source=blocker, target=task. A blocks edge must NOT
679
+ // pull the blocker into the project's part_of hierarchy view.
680
+ resolvers.Mutation.createEdge(null, {
681
+ sourceId: blocker.id,
682
+ targetId: task.id,
683
+ relation: 'blocks',
684
+ })
685
+
686
+ const tree = resolvers.Query.subtree(null, { nodeId: project.id })
687
+ const ids = tree.map((n) => n.id)
688
+ expect(ids).toContain(feature.id)
689
+ expect(ids).toContain(task.id)
690
+ expect(ids).not.toContain(blocker.id)
691
+ expect(tree.every((n) => n.relation === 'part_of')).toBe(true)
692
+ })
693
+
694
+ it('follows only the requested relation when overridden', () => {
695
+ const task = resolvers.Mutation.createNode(null, {
696
+ type: 'task',
697
+ title: 'Target',
698
+ })
699
+ const blocker = resolvers.Mutation.createNode(null, {
700
+ type: 'task',
701
+ title: 'Blocker',
702
+ })
703
+ const child = resolvers.Mutation.createNode(null, {
704
+ type: 'task',
705
+ title: 'Part of target',
706
+ })
707
+ // blocker blocks task (source=blocker, target=task)
708
+ resolvers.Mutation.createEdge(null, {
709
+ sourceId: blocker.id,
710
+ targetId: task.id,
711
+ relation: 'blocks',
712
+ })
713
+ // child is part_of task (source=child, target=task)
714
+ resolvers.Mutation.createEdge(null, {
715
+ sourceId: child.id,
716
+ targetId: task.id,
717
+ relation: 'part_of',
718
+ })
719
+
720
+ const blocks = resolvers.Query.subtree(null, {
721
+ nodeId: task.id,
722
+ relation: 'blocks',
723
+ })
724
+ expect(blocks.map((n) => n.id)).toEqual([blocker.id])
725
+ expect(blocks[0].relation).toBe('blocks')
726
+ expect(blocks[0].depth).toBe(1)
727
+ expect(blocks[0].parentId).toBe(task.id)
728
+ })
729
+
611
730
  it('respects maxDepth', () => {
612
731
  const project = resolvers.Mutation.createNode(null, {
613
732
  type: 'project',
@@ -638,6 +757,7 @@ describe('createResolvers', () => {
638
757
  })
639
758
  expect(shallow).toHaveLength(1)
640
759
  expect(shallow[0].id).toBe(feature.id)
760
+ expect(shallow[0].depth).toBe(1)
641
761
  })
642
762
  })
643
763
 
@@ -40,6 +40,23 @@ export interface NodeGql {
40
40
  updatedAt: string
41
41
  }
42
42
 
43
+ /**
44
+ * A node returned from a subtree traversal, annotated with the edge that pulled
45
+ * it in: `parentId` (the node it descends from), `depth` (1 for the root's
46
+ * direct children), and `relation` (the relation of the linking edge).
47
+ */
48
+ export interface SubtreeNodeGql extends NodeGql {
49
+ parentId: string
50
+ depth: number
51
+ relation: string
52
+ }
53
+
54
+ interface SubtreeRow extends NodeRow {
55
+ parent_id: string
56
+ depth: number
57
+ edge_relation: string
58
+ }
59
+
43
60
  const NODE_COLS =
44
61
  'id, type, title, description, status, metadata, created_at, updated_at'
45
62
 
@@ -177,21 +194,39 @@ export function createResolvers(db: Db) {
177
194
  return selectNodes(rows)
178
195
  },
179
196
 
180
- subtree: (_: unknown, args: { nodeId: string; maxDepth?: number }) => {
197
+ // Walk the part_of hierarchy (or another `relation`, default 'part_of')
198
+ // downward from `nodeId`, returning each reachable node annotated with the
199
+ // edge that pulled it in: parentId, depth (root's direct children = 1) and
200
+ // relation. Following a single relation keeps the hierarchy view clean —
201
+ // `blocks` dependency edges no longer leak into the part_of tree.
202
+ subtree: (
203
+ _: unknown,
204
+ args: { nodeId: string; relation?: string; maxDepth?: number },
205
+ ): SubtreeNodeGql[] => {
206
+ const relation = args.relation ?? 'part_of'
181
207
  const maxDepth = args.maxDepth ?? 100
182
208
  if (maxDepth === 0) return []
183
209
  const rows = db.raw
184
210
  .query(
185
- `WITH RECURSIVE tree(id, depth) AS (
186
- SELECT source_id, 1 FROM edges WHERE target_id = ?1
211
+ `WITH RECURSIVE tree(id, parent_id, depth) AS (
212
+ SELECT source_id, target_id, 1 FROM edges
213
+ WHERE target_id = ?1 AND relation = ?3
187
214
  UNION ALL
188
- SELECT e.source_id, t.depth + 1 FROM edges e
189
- JOIN tree t ON e.target_id = t.id WHERE t.depth < ?2
215
+ SELECT e.source_id, e.target_id, t.depth + 1 FROM edges e
216
+ JOIN tree t ON e.target_id = t.id
217
+ WHERE t.depth < ?2 AND e.relation = ?3
190
218
  )
191
- SELECT DISTINCT ${prefixedCols()} FROM nodes n JOIN tree t ON n.id = t.id`,
219
+ SELECT ${prefixedCols()}, t.parent_id AS parent_id, t.depth AS depth, ?3 AS edge_relation
220
+ FROM nodes n JOIN tree t ON n.id = t.id
221
+ ORDER BY t.depth`,
192
222
  )
193
- .all(args.nodeId, maxDepth) as NodeRow[]
194
- return selectNodes(rows)
223
+ .all(args.nodeId, maxDepth, relation) as SubtreeRow[]
224
+ return rows.map((row) => ({
225
+ ...rowToNode(row),
226
+ parentId: row.parent_id,
227
+ depth: row.depth,
228
+ relation: row.edge_relation,
229
+ }))
195
230
  },
196
231
 
197
232
  // Nodes connected to `nodeId` by `relation`, following edges in the given