@sqaoss/flowy 1.4.0 → 1.6.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/package.json +1 -1
- package/server/src/resolvers.test.ts +196 -0
- package/server/src/resolvers.ts +68 -0
- package/server/src/schema.ts +2 -0
- package/src/commands/export.test.ts +242 -0
- package/src/commands/export.ts +130 -0
- package/src/commands/import.test.ts +287 -0
- package/src/commands/import.ts +181 -0
- package/src/commands/task.test.ts +140 -2
- package/src/commands/task.ts +79 -5
- package/src/index.test.ts +23 -0
- package/src/index.ts +4 -0
- package/src/util/manifest.test.ts +159 -0
- package/src/util/manifest.ts +192 -0
package/package.json
CHANGED
|
@@ -1099,4 +1099,200 @@ describe('createResolvers', () => {
|
|
|
1099
1099
|
expect(results).toEqual([])
|
|
1100
1100
|
})
|
|
1101
1101
|
})
|
|
1102
|
+
|
|
1103
|
+
describe('Query.readyTasks', () => {
|
|
1104
|
+
function setStatus(id: string, status: string): void {
|
|
1105
|
+
resolvers.Mutation.updateNode(null, { id, status })
|
|
1106
|
+
}
|
|
1107
|
+
function block(blockerId: string, blockedId: string): void {
|
|
1108
|
+
resolvers.Mutation.createEdge(null, {
|
|
1109
|
+
sourceId: blockerId,
|
|
1110
|
+
targetId: blockedId,
|
|
1111
|
+
relation: 'blocks',
|
|
1112
|
+
})
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
it('returns an unblocked, not-done task', () => {
|
|
1116
|
+
const t = create(resolvers, { type: 'task', title: 'Free' })
|
|
1117
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1118
|
+
expect(ready.map((n) => n.id)).toEqual([t.id])
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
it('excludes done and cancelled tasks', () => {
|
|
1122
|
+
const open = create(resolvers, { type: 'task', title: 'Open' })
|
|
1123
|
+
const done = create(resolvers, { type: 'task', title: 'Done' })
|
|
1124
|
+
const cancelled = create(resolvers, { type: 'task', title: 'Cancelled' })
|
|
1125
|
+
setStatus(done.id, 'done')
|
|
1126
|
+
setStatus(cancelled.id, 'cancelled')
|
|
1127
|
+
|
|
1128
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1129
|
+
const ids = ready.map((n) => n.id)
|
|
1130
|
+
expect(ids).toContain(open.id)
|
|
1131
|
+
expect(ids).not.toContain(done.id)
|
|
1132
|
+
expect(ids).not.toContain(cancelled.id)
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
it('excludes a task blocked by an unfinished blocker', () => {
|
|
1136
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1137
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1138
|
+
block(blocker.id, blocked.id)
|
|
1139
|
+
|
|
1140
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1141
|
+
const ids = ready.map((n) => n.id)
|
|
1142
|
+
expect(ids).toContain(blocker.id)
|
|
1143
|
+
expect(ids).not.toContain(blocked.id)
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
it('includes a task once all its blockers are done', () => {
|
|
1147
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1148
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1149
|
+
block(blocker.id, blocked.id)
|
|
1150
|
+
setStatus(blocker.id, 'done')
|
|
1151
|
+
|
|
1152
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1153
|
+
const ids = ready.map((n) => n.id)
|
|
1154
|
+
// The done blocker is itself excluded; the formerly-blocked task is ready.
|
|
1155
|
+
expect(ids).not.toContain(blocker.id)
|
|
1156
|
+
expect(ids).toContain(blocked.id)
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
it('treats a cancelled blocker as no longer blocking', () => {
|
|
1160
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1161
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1162
|
+
block(blocker.id, blocked.id)
|
|
1163
|
+
setStatus(blocker.id, 'cancelled')
|
|
1164
|
+
|
|
1165
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1166
|
+
expect(ready.map((n) => n.id)).toContain(blocked.id)
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
it('still blocks when any one of several blockers is unfinished', () => {
|
|
1170
|
+
const b1 = create(resolvers, { type: 'task', title: 'B1' })
|
|
1171
|
+
const b2 = create(resolvers, { type: 'task', title: 'B2' })
|
|
1172
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1173
|
+
block(b1.id, blocked.id)
|
|
1174
|
+
block(b2.id, blocked.id)
|
|
1175
|
+
setStatus(b1.id, 'done')
|
|
1176
|
+
// b2 still open -> blocked stays not-ready
|
|
1177
|
+
|
|
1178
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1179
|
+
expect(ready.map((n) => n.id)).not.toContain(blocked.id)
|
|
1180
|
+
})
|
|
1181
|
+
|
|
1182
|
+
it('returns only tasks, never features or projects', () => {
|
|
1183
|
+
const project = create(resolvers, { type: 'project', title: 'P' })
|
|
1184
|
+
const feature = create(resolvers, { type: 'feature', title: 'F' })
|
|
1185
|
+
const task = create(resolvers, { type: 'task', title: 'T' })
|
|
1186
|
+
|
|
1187
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1188
|
+
const ids = ready.map((n) => n.id)
|
|
1189
|
+
expect(ids).toContain(task.id)
|
|
1190
|
+
expect(ids).not.toContain(project.id)
|
|
1191
|
+
expect(ids).not.toContain(feature.id)
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
it('returns exactly the unblocked not-done tasks across a mixed fixture', () => {
|
|
1195
|
+
// free: ready. blocked-by-open: not ready. blocked-by-done: ready.
|
|
1196
|
+
// done: not ready. in_progress + unblocked: ready.
|
|
1197
|
+
const free = create(resolvers, { type: 'task', title: 'free' })
|
|
1198
|
+
const openBlocker = create(resolvers, { type: 'task', title: 'open-b' })
|
|
1199
|
+
const blockedByOpen = create(resolvers, { type: 'task', title: 'bbo' })
|
|
1200
|
+
const doneBlocker = create(resolvers, { type: 'task', title: 'done-b' })
|
|
1201
|
+
const blockedByDone = create(resolvers, { type: 'task', title: 'bbd' })
|
|
1202
|
+
const finished = create(resolvers, { type: 'task', title: 'finished' })
|
|
1203
|
+
const started = create(resolvers, { type: 'task', title: 'started' })
|
|
1204
|
+
|
|
1205
|
+
block(openBlocker.id, blockedByOpen.id)
|
|
1206
|
+
block(doneBlocker.id, blockedByDone.id)
|
|
1207
|
+
setStatus(doneBlocker.id, 'done')
|
|
1208
|
+
setStatus(finished.id, 'done')
|
|
1209
|
+
setStatus(started.id, 'in_progress')
|
|
1210
|
+
|
|
1211
|
+
const ready = resolvers.Query.readyTasks(null, {})
|
|
1212
|
+
const ids = new Set(ready.map((n) => n.id))
|
|
1213
|
+
expect(ids).toEqual(
|
|
1214
|
+
new Set([free.id, openBlocker.id, blockedByDone.id, started.id]),
|
|
1215
|
+
)
|
|
1216
|
+
})
|
|
1217
|
+
|
|
1218
|
+
it('scopes to a project via part_of when projectId is given', () => {
|
|
1219
|
+
const projA = create(resolvers, { type: 'project', title: 'A' })
|
|
1220
|
+
const featA = create(resolvers, { type: 'feature', title: 'FA' })
|
|
1221
|
+
const taskA = create(resolvers, { type: 'task', title: 'TA' })
|
|
1222
|
+
resolvers.Mutation.createEdge(null, {
|
|
1223
|
+
sourceId: featA.id,
|
|
1224
|
+
targetId: projA.id,
|
|
1225
|
+
relation: 'part_of',
|
|
1226
|
+
})
|
|
1227
|
+
resolvers.Mutation.createEdge(null, {
|
|
1228
|
+
sourceId: taskA.id,
|
|
1229
|
+
targetId: featA.id,
|
|
1230
|
+
relation: 'part_of',
|
|
1231
|
+
})
|
|
1232
|
+
|
|
1233
|
+
const projB = create(resolvers, { type: 'project', title: 'B' })
|
|
1234
|
+
const featB = create(resolvers, { type: 'feature', title: 'FB' })
|
|
1235
|
+
const taskB = create(resolvers, { type: 'task', title: 'TB' })
|
|
1236
|
+
resolvers.Mutation.createEdge(null, {
|
|
1237
|
+
sourceId: featB.id,
|
|
1238
|
+
targetId: projB.id,
|
|
1239
|
+
relation: 'part_of',
|
|
1240
|
+
})
|
|
1241
|
+
resolvers.Mutation.createEdge(null, {
|
|
1242
|
+
sourceId: taskB.id,
|
|
1243
|
+
targetId: featB.id,
|
|
1244
|
+
relation: 'part_of',
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
const ready = resolvers.Query.readyTasks(null, { projectId: projA.id })
|
|
1248
|
+
expect(ready.map((n) => n.id)).toEqual([taskA.id])
|
|
1249
|
+
})
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
describe('Query.edges', () => {
|
|
1253
|
+
function block(blockerId: string, blockedId: string): void {
|
|
1254
|
+
resolvers.Mutation.createEdge(null, {
|
|
1255
|
+
sourceId: blockerId,
|
|
1256
|
+
targetId: blockedId,
|
|
1257
|
+
relation: 'blocks',
|
|
1258
|
+
})
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
it('returns blockedBy: incoming blocks edges (sources that block the node)', () => {
|
|
1262
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1263
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1264
|
+
block(blocker.id, blocked.id)
|
|
1265
|
+
|
|
1266
|
+
const blockedBy = resolvers.Query.edges(null, {
|
|
1267
|
+
nodeId: blocked.id,
|
|
1268
|
+
relation: 'blocks',
|
|
1269
|
+
direction: 'incoming',
|
|
1270
|
+
})
|
|
1271
|
+
expect(blockedBy.map((n) => n.id)).toEqual([blocker.id])
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
it('returns blocks: outgoing blocks edges (targets the node blocks)', () => {
|
|
1275
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
1276
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
1277
|
+
block(blocker.id, blocked.id)
|
|
1278
|
+
|
|
1279
|
+
const blocks = resolvers.Query.edges(null, {
|
|
1280
|
+
nodeId: blocker.id,
|
|
1281
|
+
relation: 'blocks',
|
|
1282
|
+
direction: 'outgoing',
|
|
1283
|
+
})
|
|
1284
|
+
expect(blocks.map((n) => n.id)).toEqual([blocked.id])
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
it('returns an empty array for a node with no edges', () => {
|
|
1288
|
+
const lonely = create(resolvers, { type: 'task', title: 'Lonely' })
|
|
1289
|
+
expect(
|
|
1290
|
+
resolvers.Query.edges(null, {
|
|
1291
|
+
nodeId: lonely.id,
|
|
1292
|
+
relation: 'blocks',
|
|
1293
|
+
direction: 'incoming',
|
|
1294
|
+
}),
|
|
1295
|
+
).toEqual([])
|
|
1296
|
+
})
|
|
1297
|
+
})
|
|
1102
1298
|
})
|
package/server/src/resolvers.ts
CHANGED
|
@@ -194,6 +194,74 @@ export function createResolvers(db: Db) {
|
|
|
194
194
|
return selectNodes(rows)
|
|
195
195
|
},
|
|
196
196
|
|
|
197
|
+
// Nodes connected to `nodeId` by `relation`, following edges in the given
|
|
198
|
+
// direction. 'incoming' returns the *sources* of edges that point at the
|
|
199
|
+
// node (e.g. for relation 'blocks', the tasks that block it — blockedBy);
|
|
200
|
+
// 'outgoing' returns the *targets* of edges originating at the node (the
|
|
201
|
+
// tasks it blocks). Default direction is 'outgoing'.
|
|
202
|
+
edges: (
|
|
203
|
+
_: unknown,
|
|
204
|
+
args: {
|
|
205
|
+
nodeId: string
|
|
206
|
+
relation: string
|
|
207
|
+
direction?: string
|
|
208
|
+
},
|
|
209
|
+
) => {
|
|
210
|
+
const incoming = args.direction === 'incoming'
|
|
211
|
+
const sql = incoming
|
|
212
|
+
? `SELECT ${prefixedCols()} FROM nodes n
|
|
213
|
+
JOIN edges e ON n.id = e.source_id
|
|
214
|
+
WHERE e.target_id = ? AND e.relation = ?`
|
|
215
|
+
: `SELECT ${prefixedCols()} FROM nodes n
|
|
216
|
+
JOIN edges e ON n.id = e.target_id
|
|
217
|
+
WHERE e.source_id = ? AND e.relation = ?`
|
|
218
|
+
const rows = db.raw
|
|
219
|
+
.query(sql)
|
|
220
|
+
.all(args.nodeId, args.relation) as NodeRow[]
|
|
221
|
+
return selectNodes(rows)
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// Tasks that are actionable now: type 'task', status not done/cancelled,
|
|
225
|
+
// and not blocked by any incomplete blocker. A blocker is an incoming
|
|
226
|
+
// `blocks` edge whose source task is itself not done/cancelled. Optionally
|
|
227
|
+
// scoped to a project via the `part_of` hierarchy (task -> feature ->
|
|
228
|
+
// project).
|
|
229
|
+
readyTasks: (_: unknown, args: { projectId?: string }) => {
|
|
230
|
+
const params: string[] = []
|
|
231
|
+
let scope = ''
|
|
232
|
+
if (args.projectId) {
|
|
233
|
+
// Restrict to tasks reachable from the project through part_of edges
|
|
234
|
+
// (any depth). Edges point child -> parent, so we walk upward from
|
|
235
|
+
// each candidate task to see if the project is an ancestor.
|
|
236
|
+
scope = `AND n.id IN (
|
|
237
|
+
WITH RECURSIVE up(id) AS (
|
|
238
|
+
SELECT source_id FROM edges
|
|
239
|
+
WHERE target_id = ?1 AND relation = 'part_of'
|
|
240
|
+
UNION
|
|
241
|
+
SELECT e.source_id FROM edges e
|
|
242
|
+
JOIN up ON e.target_id = up.id AND e.relation = 'part_of'
|
|
243
|
+
)
|
|
244
|
+
SELECT id FROM up
|
|
245
|
+
)`
|
|
246
|
+
params.push(args.projectId)
|
|
247
|
+
}
|
|
248
|
+
const rows = db.raw
|
|
249
|
+
.query(
|
|
250
|
+
`SELECT ${prefixedCols()} FROM nodes n
|
|
251
|
+
WHERE n.type = 'task'
|
|
252
|
+
AND n.status NOT IN ('done', 'cancelled')
|
|
253
|
+
${scope}
|
|
254
|
+
AND NOT EXISTS (
|
|
255
|
+
SELECT 1 FROM edges b
|
|
256
|
+
JOIN nodes src ON src.id = b.source_id
|
|
257
|
+
WHERE b.target_id = n.id AND b.relation = 'blocks'
|
|
258
|
+
AND src.status NOT IN ('done', 'cancelled')
|
|
259
|
+
)`,
|
|
260
|
+
)
|
|
261
|
+
.all(...params) as NodeRow[]
|
|
262
|
+
return selectNodes(rows)
|
|
263
|
+
},
|
|
264
|
+
|
|
197
265
|
search: (
|
|
198
266
|
_: unknown,
|
|
199
267
|
args: {
|
package/server/src/schema.ts
CHANGED
|
@@ -24,6 +24,8 @@ export const typeDefs = /* GraphQL */ `
|
|
|
24
24
|
nodes(type: String): [Node!]!
|
|
25
25
|
descendants(nodeId: String!, relation: String, maxDepth: Int): [Node!]!
|
|
26
26
|
subtree(nodeId: String!, maxDepth: Int): [Node!]!
|
|
27
|
+
edges(nodeId: String!, relation: String!, direction: String): [Node!]!
|
|
28
|
+
readyTasks(projectId: String): [Node!]!
|
|
27
29
|
search(query: String!, type: String, status: String, limit: Int): [Node!]!
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('../util/client.ts', () => ({
|
|
4
|
+
graphql: vi.fn(),
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
vi.mock('../util/config.ts', () => ({
|
|
8
|
+
requireProject: vi.fn(() => ({ id: 'srv_proj', name: 'Demo' })),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock('../util/format.ts', () => ({
|
|
12
|
+
output: vi.fn(),
|
|
13
|
+
outputError: vi.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
vi.mock('node:fs', () => ({
|
|
17
|
+
writeFileSync: vi.fn(),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
const FLOWY_KEY_FIELD = '__flowyKey'
|
|
21
|
+
|
|
22
|
+
/** A server node row stamped with its client-key only (no edges in metadata). */
|
|
23
|
+
function srv(
|
|
24
|
+
id: string,
|
|
25
|
+
type: string,
|
|
26
|
+
title: string,
|
|
27
|
+
key: string,
|
|
28
|
+
extraMeta: Record<string, unknown> = {},
|
|
29
|
+
): Record<string, unknown> {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
type,
|
|
33
|
+
title,
|
|
34
|
+
description: null,
|
|
35
|
+
status: 'draft',
|
|
36
|
+
metadata: JSON.stringify({ ...extraMeta, [FLOWY_KEY_FIELD]: key }),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a graphql mock backed by a node set and a real-edge-model edge set.
|
|
42
|
+
* `edges` are SERVER-id triples; the `edges()` query returns connected nodes
|
|
43
|
+
* (with metadata) so export can resolve their client-keys.
|
|
44
|
+
*/
|
|
45
|
+
function mockServer(
|
|
46
|
+
graphql: ReturnType<typeof vi.fn>,
|
|
47
|
+
project: Record<string, unknown>,
|
|
48
|
+
descendants: Array<Record<string, unknown>>,
|
|
49
|
+
edges: Array<{ source: string; target: string; relation: string }> = [],
|
|
50
|
+
) {
|
|
51
|
+
const byId = new Map<string, Record<string, unknown>>()
|
|
52
|
+
for (const n of [project, ...descendants]) byId.set(n.id as string, n)
|
|
53
|
+
graphql.mockImplementation(
|
|
54
|
+
async (query: string, variables?: Record<string, unknown>) => {
|
|
55
|
+
if (query.includes('descendants')) return { descendants }
|
|
56
|
+
if (query.includes('node(')) return { node: project }
|
|
57
|
+
if (query.includes('edges(')) {
|
|
58
|
+
const out = edges
|
|
59
|
+
.filter(
|
|
60
|
+
(e) =>
|
|
61
|
+
e.source === variables?.nodeId &&
|
|
62
|
+
e.relation === variables?.relation,
|
|
63
|
+
)
|
|
64
|
+
.map((e) => {
|
|
65
|
+
const n = byId.get(e.target)
|
|
66
|
+
return { id: e.target, metadata: n?.metadata ?? null }
|
|
67
|
+
})
|
|
68
|
+
return { edges: out }
|
|
69
|
+
}
|
|
70
|
+
return {}
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('export command', () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
vi.clearAllMocks()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('exports a no-argument export command (optional output path)', async () => {
|
|
81
|
+
const { exportCommand } = await import('./export.ts')
|
|
82
|
+
expect(exportCommand.name()).toBe('export')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('emits a manifest with client-keys and edges read from the edge model', async () => {
|
|
86
|
+
const { graphql } = await import('../util/client.ts')
|
|
87
|
+
const { output } = await import('../util/format.ts')
|
|
88
|
+
const { exportCommand } = await import('./export.ts')
|
|
89
|
+
|
|
90
|
+
const project = srv('srv_proj', 'project', 'Demo', 'proj')
|
|
91
|
+
const feature = srv('srv_feat-1', 'feature', 'Auth', 'feat-1')
|
|
92
|
+
const task1 = srv('srv_task-1', 'task', 'Login', 'task-1', {
|
|
93
|
+
priority: 'high',
|
|
94
|
+
})
|
|
95
|
+
const task2 = srv('srv_task-2', 'task', 'Logout', 'task-2')
|
|
96
|
+
|
|
97
|
+
mockServer(
|
|
98
|
+
vi.mocked(graphql),
|
|
99
|
+
project,
|
|
100
|
+
[feature, task1, task2],
|
|
101
|
+
[
|
|
102
|
+
{ source: 'srv_feat-1', target: 'srv_proj', relation: 'part_of' },
|
|
103
|
+
{ source: 'srv_task-1', target: 'srv_feat-1', relation: 'part_of' },
|
|
104
|
+
{ source: 'srv_task-2', target: 'srv_feat-1', relation: 'part_of' },
|
|
105
|
+
{ source: 'srv_task-2', target: 'srv_task-1', relation: 'blocks' },
|
|
106
|
+
],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
await exportCommand.parseAsync([], { from: 'user' })
|
|
110
|
+
|
|
111
|
+
const manifest = vi.mocked(output).mock.calls.at(-1)?.[0] as {
|
|
112
|
+
version: number
|
|
113
|
+
nodes: Array<Record<string, unknown>>
|
|
114
|
+
edges: Array<Record<string, unknown>>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
expect(manifest.version).toBe(1)
|
|
118
|
+
expect(manifest.nodes).toHaveLength(4)
|
|
119
|
+
|
|
120
|
+
const byKey = Object.fromEntries(manifest.nodes.map((n) => [n.key, n]))
|
|
121
|
+
expect(byKey.proj).toMatchObject({ type: 'project', title: 'Demo' })
|
|
122
|
+
// The reserved client-key field is stripped from exported user metadata.
|
|
123
|
+
expect(byKey['task-1']).toMatchObject({
|
|
124
|
+
type: 'task',
|
|
125
|
+
parent: 'feat-1',
|
|
126
|
+
metadata: { priority: 'high' },
|
|
127
|
+
})
|
|
128
|
+
expect(
|
|
129
|
+
(byKey['task-1'] as { metadata: Record<string, unknown> }).metadata
|
|
130
|
+
.__flowyKey,
|
|
131
|
+
).toBeUndefined()
|
|
132
|
+
|
|
133
|
+
// 3 part_of edges + 1 blocks edge, expressed in client-keys.
|
|
134
|
+
expect(manifest.edges).toHaveLength(4)
|
|
135
|
+
expect(manifest.edges).toContainEqual({
|
|
136
|
+
source: 'task-2',
|
|
137
|
+
target: 'task-1',
|
|
138
|
+
relation: 'blocks',
|
|
139
|
+
})
|
|
140
|
+
expect(manifest.edges).toContainEqual({
|
|
141
|
+
source: 'feat-1',
|
|
142
|
+
target: 'proj',
|
|
143
|
+
relation: 'part_of',
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('captures an externally-created (task block) edge, not just import edges', async () => {
|
|
148
|
+
const { graphql } = await import('../util/client.ts')
|
|
149
|
+
const { output } = await import('../util/format.ts')
|
|
150
|
+
const { exportCommand } = await import('./export.ts')
|
|
151
|
+
|
|
152
|
+
const project = srv('srv_proj', 'project', 'Demo', 'proj')
|
|
153
|
+
const task1 = srv('srv_task-1', 'task', 'A', 'task-1')
|
|
154
|
+
const task2 = srv('srv_task-2', 'task', 'B', 'task-2')
|
|
155
|
+
|
|
156
|
+
mockServer(
|
|
157
|
+
vi.mocked(graphql),
|
|
158
|
+
project,
|
|
159
|
+
[task1, task2],
|
|
160
|
+
[
|
|
161
|
+
{ source: 'srv_task-1', target: 'srv_proj', relation: 'part_of' },
|
|
162
|
+
{ source: 'srv_task-2', target: 'srv_proj', relation: 'part_of' },
|
|
163
|
+
// This edge exists only in the edge model (e.g. created by `task block`),
|
|
164
|
+
// never recorded in any node metadata — export must still capture it.
|
|
165
|
+
{ source: 'srv_task-1', target: 'srv_task-2', relation: 'blocks' },
|
|
166
|
+
],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
await exportCommand.parseAsync([], { from: 'user' })
|
|
170
|
+
|
|
171
|
+
const manifest = vi.mocked(output).mock.calls.at(-1)?.[0] as {
|
|
172
|
+
edges: Array<Record<string, unknown>>
|
|
173
|
+
}
|
|
174
|
+
expect(manifest.edges).toContainEqual({
|
|
175
|
+
source: 'task-1',
|
|
176
|
+
target: 'task-2',
|
|
177
|
+
relation: 'blocks',
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('writes to a file when an output path is given', async () => {
|
|
182
|
+
const { graphql } = await import('../util/client.ts')
|
|
183
|
+
const { writeFileSync } = await import('node:fs')
|
|
184
|
+
const { exportCommand } = await import('./export.ts')
|
|
185
|
+
|
|
186
|
+
const project = srv('srv_proj', 'project', 'Demo', 'proj')
|
|
187
|
+
mockServer(vi.mocked(graphql), project, [])
|
|
188
|
+
|
|
189
|
+
await exportCommand.parseAsync(['out.json'], { from: 'user' })
|
|
190
|
+
|
|
191
|
+
expect(writeFileSync).toHaveBeenCalledWith(
|
|
192
|
+
'out.json',
|
|
193
|
+
expect.stringContaining('"version": 1'),
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('export output round-trips: it is a valid import manifest', async () => {
|
|
198
|
+
const { graphql } = await import('../util/client.ts')
|
|
199
|
+
const { output } = await import('../util/format.ts')
|
|
200
|
+
const { parseManifest, serializeManifest } = await import(
|
|
201
|
+
'../util/manifest.ts'
|
|
202
|
+
)
|
|
203
|
+
const { exportCommand } = await import('./export.ts')
|
|
204
|
+
|
|
205
|
+
const project = srv('srv_proj', 'project', 'Demo', 'proj')
|
|
206
|
+
const feature = srv('srv_feat-1', 'feature', 'Auth', 'feat-1')
|
|
207
|
+
const task = srv('srv_task-1', 'task', 'Login', 'task-1')
|
|
208
|
+
|
|
209
|
+
mockServer(
|
|
210
|
+
vi.mocked(graphql),
|
|
211
|
+
project,
|
|
212
|
+
[feature, task],
|
|
213
|
+
[
|
|
214
|
+
{ source: 'srv_feat-1', target: 'srv_proj', relation: 'part_of' },
|
|
215
|
+
{ source: 'srv_task-1', target: 'srv_feat-1', relation: 'part_of' },
|
|
216
|
+
{ source: 'srv_task-1', target: 'srv_feat-1', relation: 'blocks' },
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
await exportCommand.parseAsync([], { from: 'user' })
|
|
221
|
+
|
|
222
|
+
const manifest = vi.mocked(output).mock.calls.at(-1)?.[0]
|
|
223
|
+
// Re-parsing the exported manifest through the import parser must succeed
|
|
224
|
+
// and preserve structure — the export→import contract.
|
|
225
|
+
const reparsed = parseManifest(serializeManifest(manifest as never))
|
|
226
|
+
expect(reparsed).toEqual(manifest)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('reports errors via outputError', async () => {
|
|
230
|
+
const { graphql } = await import('../util/client.ts')
|
|
231
|
+
const { outputError } = await import('../util/format.ts')
|
|
232
|
+
const { exportCommand } = await import('./export.ts')
|
|
233
|
+
|
|
234
|
+
vi.mocked(graphql).mockRejectedValueOnce(new Error('boom'))
|
|
235
|
+
|
|
236
|
+
await exportCommand.parseAsync([], { from: 'user' })
|
|
237
|
+
|
|
238
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
239
|
+
expect.objectContaining({ message: 'boom' }),
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs'
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import { graphql } from '../util/client.ts'
|
|
4
|
+
import { requireProject } from '../util/config.ts'
|
|
5
|
+
import { output, outputError } from '../util/format.ts'
|
|
6
|
+
import {
|
|
7
|
+
MANIFEST_VERSION,
|
|
8
|
+
type Manifest,
|
|
9
|
+
type ManifestEdge,
|
|
10
|
+
type ManifestNode,
|
|
11
|
+
readClientKey,
|
|
12
|
+
serializeManifest,
|
|
13
|
+
stripClientKey,
|
|
14
|
+
} from '../util/manifest.ts'
|
|
15
|
+
|
|
16
|
+
/** Relations export captures from the real edge model. */
|
|
17
|
+
const RELATIONS = ['part_of', 'blocks'] as const
|
|
18
|
+
|
|
19
|
+
interface ServerNode {
|
|
20
|
+
id: string
|
|
21
|
+
type: string
|
|
22
|
+
title: string
|
|
23
|
+
description: string | null
|
|
24
|
+
status: string
|
|
25
|
+
metadata: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PROJECT_QUERY = `query ExportProject($id: String!) {
|
|
29
|
+
node(id: $id) { id type title description status metadata }
|
|
30
|
+
}`
|
|
31
|
+
|
|
32
|
+
const DESCENDANTS_QUERY = `query ExportDescendants($nodeId: String!, $relation: String, $maxDepth: Int) {
|
|
33
|
+
descendants(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
|
|
34
|
+
id type title description status metadata
|
|
35
|
+
}
|
|
36
|
+
}`
|
|
37
|
+
|
|
38
|
+
const EDGES_QUERY = `query ExportEdges($nodeId: String!, $relation: String!) {
|
|
39
|
+
edges(nodeId: $nodeId, relation: $relation, direction: "outgoing") {
|
|
40
|
+
id metadata
|
|
41
|
+
}
|
|
42
|
+
}`
|
|
43
|
+
|
|
44
|
+
export const exportCommand = new Command('export')
|
|
45
|
+
.description(
|
|
46
|
+
'Dump the active project (nodes + edges, with client-keys) as a manifest',
|
|
47
|
+
)
|
|
48
|
+
.argument('[output]', 'Write to this file instead of stdout')
|
|
49
|
+
.action(async (outputPath: string | undefined) => {
|
|
50
|
+
try {
|
|
51
|
+
const project = requireProject()
|
|
52
|
+
const root = await graphql<{ node: ServerNode | null }>(PROJECT_QUERY, {
|
|
53
|
+
id: project.id,
|
|
54
|
+
})
|
|
55
|
+
if (!root.node) {
|
|
56
|
+
throw new Error(`Active project ${project.id} not found.`)
|
|
57
|
+
}
|
|
58
|
+
const descendants = await graphql<{ descendants: ServerNode[] }>(
|
|
59
|
+
DESCENDANTS_QUERY,
|
|
60
|
+
{ nodeId: project.id, relation: 'part_of', maxDepth: 100 },
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const serverNodes = [root.node, ...descendants.descendants]
|
|
64
|
+
|
|
65
|
+
// Map server id -> client-key so edges (which the server returns by id)
|
|
66
|
+
// can be expressed in the manifest's client-key space. A node without a
|
|
67
|
+
// recorded key falls back to its server id so it still round-trips.
|
|
68
|
+
const keyOf = (id: string, metadata: string | null) =>
|
|
69
|
+
readClientKey(metadata) ?? id
|
|
70
|
+
const keyById = new Map<string, string>()
|
|
71
|
+
for (const sn of serverNodes)
|
|
72
|
+
keyById.set(sn.id, keyOf(sn.id, sn.metadata))
|
|
73
|
+
|
|
74
|
+
const nodes: ManifestNode[] = serverNodes.map((sn) => {
|
|
75
|
+
const node: ManifestNode = {
|
|
76
|
+
key: keyById.get(sn.id) ?? sn.id,
|
|
77
|
+
type: sn.type,
|
|
78
|
+
title: sn.title,
|
|
79
|
+
}
|
|
80
|
+
if (sn.description != null) node.description = sn.description
|
|
81
|
+
if (sn.status != null) node.status = sn.status
|
|
82
|
+
const userMeta = stripClientKey(sn.metadata)
|
|
83
|
+
if (userMeta) node.metadata = userMeta
|
|
84
|
+
return node
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Read edges back through the real edge model, so we capture ALL edges,
|
|
88
|
+
// including ones created outside import (e.g. `task block`), not just
|
|
89
|
+
// those import created.
|
|
90
|
+
const edges: ManifestEdge[] = []
|
|
91
|
+
const seen = new Set<string>()
|
|
92
|
+
for (const sn of serverNodes) {
|
|
93
|
+
const sourceKey = keyById.get(sn.id) ?? sn.id
|
|
94
|
+
for (const relation of RELATIONS) {
|
|
95
|
+
const data = await graphql<{
|
|
96
|
+
edges: Array<{ id: string; metadata: string | null }>
|
|
97
|
+
}>(EDGES_QUERY, { nodeId: sn.id, relation })
|
|
98
|
+
for (const target of data.edges) {
|
|
99
|
+
const targetKey =
|
|
100
|
+
keyById.get(target.id) ?? keyOf(target.id, target.metadata)
|
|
101
|
+
const k = `${sourceKey}|${targetKey}|${relation}`
|
|
102
|
+
if (seen.has(k)) continue
|
|
103
|
+
seen.add(k)
|
|
104
|
+
// part_of is surfaced as the node's `parent` so import re-derives it,
|
|
105
|
+
// and is also kept in the edge list for a complete dependency graph.
|
|
106
|
+
if (relation === 'part_of') {
|
|
107
|
+
const node = nodes.find((n) => n.key === sourceKey)
|
|
108
|
+
if (node) node.parent = targetKey
|
|
109
|
+
}
|
|
110
|
+
edges.push({ source: sourceKey, target: targetKey, relation })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const manifest: Manifest = { version: MANIFEST_VERSION, nodes, edges }
|
|
116
|
+
|
|
117
|
+
if (outputPath) {
|
|
118
|
+
writeFileSync(outputPath, serializeManifest(manifest))
|
|
119
|
+
output({
|
|
120
|
+
exported: nodes.length,
|
|
121
|
+
edges: edges.length,
|
|
122
|
+
file: outputPath,
|
|
123
|
+
})
|
|
124
|
+
} else {
|
|
125
|
+
output(manifest)
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
outputError(error)
|
|
129
|
+
}
|
|
130
|
+
})
|