@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/src/commands/task.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
|
-
import { requireFeature } from '../util/config.ts'
|
|
3
|
+
import { requireFeature, resolveProject } from '../util/config.ts'
|
|
4
4
|
import { resolveDescription } from '../util/description.ts'
|
|
5
5
|
import { output, outputError } from '../util/format.ts'
|
|
6
6
|
|
|
@@ -53,8 +53,45 @@ taskCommand
|
|
|
53
53
|
taskCommand
|
|
54
54
|
.command('list')
|
|
55
55
|
.description('List tasks in the active feature')
|
|
56
|
-
.
|
|
56
|
+
.option(
|
|
57
|
+
'--ready',
|
|
58
|
+
'Only actionable tasks: not done/cancelled and with zero unfinished blockers',
|
|
59
|
+
)
|
|
60
|
+
.option('--all', 'List every task across the whole backlog')
|
|
61
|
+
.option(
|
|
62
|
+
'--project <id>',
|
|
63
|
+
'Scope --ready/--all to a project (defaults to the active project)',
|
|
64
|
+
)
|
|
65
|
+
.action(async (opts) => {
|
|
57
66
|
try {
|
|
67
|
+
if (opts.ready) {
|
|
68
|
+
const projectId =
|
|
69
|
+
opts.project ?? (opts.all ? undefined : resolveProject()?.id)
|
|
70
|
+
const data = await graphql<{ readyTasks: unknown[] }>(
|
|
71
|
+
`query ReadyTasks($projectId: String) {
|
|
72
|
+
readyTasks(projectId: $projectId) {
|
|
73
|
+
id type title status createdAt
|
|
74
|
+
}
|
|
75
|
+
}`,
|
|
76
|
+
{ projectId: projectId ?? null },
|
|
77
|
+
)
|
|
78
|
+
output(data.readyTasks)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (opts.all) {
|
|
83
|
+
const data = await graphql<{ nodes: unknown[] }>(
|
|
84
|
+
`query AllTasks($type: String!) {
|
|
85
|
+
nodes(type: $type) {
|
|
86
|
+
id type title status createdAt
|
|
87
|
+
}
|
|
88
|
+
}`,
|
|
89
|
+
{ type: 'task' },
|
|
90
|
+
)
|
|
91
|
+
output(data.nodes)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
const featureId = requireFeature()
|
|
59
96
|
const data = await graphql<{ descendants: unknown[] }>(
|
|
60
97
|
`query ListTasks($nodeId: String!, $relation: String!, $maxDepth: Int) {
|
|
@@ -75,19 +112,33 @@ taskCommand
|
|
|
75
112
|
|
|
76
113
|
taskCommand
|
|
77
114
|
.command('show')
|
|
78
|
-
.description('Show task details')
|
|
115
|
+
.description('Show task details, including its blockedBy/blocks dependencies')
|
|
79
116
|
.argument('<id>', 'Task ID')
|
|
80
117
|
.action(async (id: string) => {
|
|
81
118
|
try {
|
|
82
|
-
const data = await graphql<{
|
|
119
|
+
const data = await graphql<{
|
|
120
|
+
node: Record<string, unknown>
|
|
121
|
+
blockedBy: unknown[]
|
|
122
|
+
blocks: unknown[]
|
|
123
|
+
}>(
|
|
83
124
|
`query ShowTask($id: String!) {
|
|
84
125
|
node(id: $id) {
|
|
85
126
|
id type title description status metadata createdAt updatedAt
|
|
86
127
|
}
|
|
128
|
+
blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
|
|
129
|
+
id type title status
|
|
130
|
+
}
|
|
131
|
+
blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
|
|
132
|
+
id type title status
|
|
133
|
+
}
|
|
87
134
|
}`,
|
|
88
135
|
{ id },
|
|
89
136
|
)
|
|
90
|
-
output(
|
|
137
|
+
output({
|
|
138
|
+
...data.node,
|
|
139
|
+
blockedBy: data.blockedBy,
|
|
140
|
+
blocks: data.blocks,
|
|
141
|
+
})
|
|
91
142
|
} catch (error) {
|
|
92
143
|
outputError(error)
|
|
93
144
|
}
|
|
@@ -189,3 +240,26 @@ taskCommand
|
|
|
189
240
|
outputError(error)
|
|
190
241
|
}
|
|
191
242
|
})
|
|
243
|
+
|
|
244
|
+
taskCommand
|
|
245
|
+
.command('deps')
|
|
246
|
+
.description('List a task’s dependencies: what blocks it and what it blocks')
|
|
247
|
+
.argument('<id>', 'Task ID')
|
|
248
|
+
.action(async (id: string) => {
|
|
249
|
+
try {
|
|
250
|
+
const data = await graphql<{ blockedBy: unknown[]; blocks: unknown[] }>(
|
|
251
|
+
`query TaskDeps($id: String!) {
|
|
252
|
+
blockedBy: edges(nodeId: $id, relation: "blocks", direction: "incoming") {
|
|
253
|
+
id type title status
|
|
254
|
+
}
|
|
255
|
+
blocks: edges(nodeId: $id, relation: "blocks", direction: "outgoing") {
|
|
256
|
+
id type title status
|
|
257
|
+
}
|
|
258
|
+
}`,
|
|
259
|
+
{ id },
|
|
260
|
+
)
|
|
261
|
+
output({ id, blockedBy: data.blockedBy, blocks: data.blocks })
|
|
262
|
+
} catch (error) {
|
|
263
|
+
outputError(error)
|
|
264
|
+
}
|
|
265
|
+
})
|
package/src/index.test.ts
CHANGED
|
@@ -42,6 +42,12 @@ vi.mock('./commands/key.ts', () => ({
|
|
|
42
42
|
vi.mock('./commands/serve.ts', () => ({
|
|
43
43
|
serveCommand: { name: () => 'serve' },
|
|
44
44
|
}))
|
|
45
|
+
vi.mock('./commands/import.ts', () => ({
|
|
46
|
+
importCommand: { name: () => 'import' },
|
|
47
|
+
}))
|
|
48
|
+
vi.mock('./commands/export.ts', () => ({
|
|
49
|
+
exportCommand: { name: () => 'export' },
|
|
50
|
+
}))
|
|
45
51
|
|
|
46
52
|
describe('index.ts command registration', () => {
|
|
47
53
|
test('registers billing and key commands', async () => {
|
|
@@ -73,4 +79,21 @@ describe('index.ts command registration', () => {
|
|
|
73
79
|
)
|
|
74
80
|
expect(indexSource).toContain('program.addCommand(serveCommand)')
|
|
75
81
|
})
|
|
82
|
+
|
|
83
|
+
test('registers the import and export commands', async () => {
|
|
84
|
+
const { readFileSync } = await import('node:fs')
|
|
85
|
+
const indexSource = readFileSync(
|
|
86
|
+
new URL('./index.ts', import.meta.url).pathname,
|
|
87
|
+
'utf-8',
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
expect(indexSource).toContain(
|
|
91
|
+
"import { importCommand } from './commands/import.ts'",
|
|
92
|
+
)
|
|
93
|
+
expect(indexSource).toContain(
|
|
94
|
+
"import { exportCommand } from './commands/export.ts'",
|
|
95
|
+
)
|
|
96
|
+
expect(indexSource).toContain('program.addCommand(importCommand)')
|
|
97
|
+
expect(indexSource).toContain('program.addCommand(exportCommand)')
|
|
98
|
+
})
|
|
76
99
|
})
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,9 @@ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
|
14
14
|
import { approveCommand } from './commands/approve.ts'
|
|
15
15
|
import { billingCommand } from './commands/billing.ts'
|
|
16
16
|
import { clientCommand } from './commands/client.ts'
|
|
17
|
+
import { exportCommand } from './commands/export.ts'
|
|
17
18
|
import { featureCommand } from './commands/feature.ts'
|
|
19
|
+
import { importCommand } from './commands/import.ts'
|
|
18
20
|
import { initCommand } from './commands/init.ts'
|
|
19
21
|
import { keyCommand } from './commands/key.ts'
|
|
20
22
|
import { projectCommand } from './commands/project.ts'
|
|
@@ -45,5 +47,7 @@ program.addCommand(keyCommand)
|
|
|
45
47
|
program.addCommand(searchCommand)
|
|
46
48
|
program.addCommand(treeCommand)
|
|
47
49
|
program.addCommand(whoamiCommand)
|
|
50
|
+
program.addCommand(importCommand)
|
|
51
|
+
program.addCommand(exportCommand)
|
|
48
52
|
|
|
49
53
|
program.parse()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildNodeMetadata,
|
|
4
|
+
FLOWY_KEY_FIELD,
|
|
5
|
+
type Manifest,
|
|
6
|
+
parseManifest,
|
|
7
|
+
readClientKey,
|
|
8
|
+
serializeManifest,
|
|
9
|
+
stripClientKey,
|
|
10
|
+
} from './manifest.ts'
|
|
11
|
+
|
|
12
|
+
const VALID: Manifest = {
|
|
13
|
+
version: 1,
|
|
14
|
+
nodes: [
|
|
15
|
+
{ key: 'proj', type: 'project', title: 'Demo' },
|
|
16
|
+
{ key: 'feat-1', type: 'feature', title: 'Auth', parent: 'proj' },
|
|
17
|
+
{
|
|
18
|
+
key: 'task-1',
|
|
19
|
+
type: 'task',
|
|
20
|
+
title: 'Login',
|
|
21
|
+
parent: 'feat-1',
|
|
22
|
+
status: 'draft',
|
|
23
|
+
metadata: { priority: 'high' },
|
|
24
|
+
},
|
|
25
|
+
{ key: 'task-2', type: 'task', title: 'Logout', parent: 'feat-1' },
|
|
26
|
+
],
|
|
27
|
+
edges: [{ source: 'task-2', target: 'task-1', relation: 'blocks' }],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('parseManifest', () => {
|
|
31
|
+
test('parses a well-formed JSON manifest', () => {
|
|
32
|
+
const m = parseManifest(JSON.stringify(VALID))
|
|
33
|
+
expect(m.version).toBe(1)
|
|
34
|
+
expect(m.nodes).toHaveLength(4)
|
|
35
|
+
expect(m.edges).toHaveLength(1)
|
|
36
|
+
expect(m.nodes[0]).toMatchObject({ key: 'proj', type: 'project' })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('defaults edges to an empty array when omitted', () => {
|
|
40
|
+
const m = parseManifest(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
version: 1,
|
|
43
|
+
nodes: [{ key: 'proj', type: 'project', title: 'Demo' }],
|
|
44
|
+
}),
|
|
45
|
+
)
|
|
46
|
+
expect(m.edges).toEqual([])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('throws on invalid JSON', () => {
|
|
50
|
+
expect(() => parseManifest('{not json')).toThrow(/invalid json/i)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('throws when nodes is missing', () => {
|
|
54
|
+
expect(() => parseManifest(JSON.stringify({ version: 1 }))).toThrow(
|
|
55
|
+
/nodes/i,
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('throws when a node lacks a key', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
parseManifest(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
version: 1,
|
|
64
|
+
nodes: [{ type: 'project', title: 'Demo' }],
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
).toThrow(/key/i)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('throws when a node lacks a type', () => {
|
|
71
|
+
expect(() =>
|
|
72
|
+
parseManifest(
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
version: 1,
|
|
75
|
+
nodes: [{ key: 'x', title: 'Demo' }],
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
).toThrow(/type/i)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('throws on a duplicate client-key', () => {
|
|
82
|
+
expect(() =>
|
|
83
|
+
parseManifest(
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
version: 1,
|
|
86
|
+
nodes: [
|
|
87
|
+
{ key: 'dup', type: 'project', title: 'A' },
|
|
88
|
+
{ key: 'dup', type: 'feature', title: 'B' },
|
|
89
|
+
],
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
).toThrow(/duplicate/i)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('throws when an edge references an unknown key', () => {
|
|
96
|
+
expect(() =>
|
|
97
|
+
parseManifest(
|
|
98
|
+
JSON.stringify({
|
|
99
|
+
version: 1,
|
|
100
|
+
nodes: [{ key: 'a', type: 'project', title: 'A' }],
|
|
101
|
+
edges: [{ source: 'a', target: 'ghost', relation: 'blocks' }],
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
).toThrow(/ghost/i)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('serializeManifest', () => {
|
|
109
|
+
test('round-trips through parse', () => {
|
|
110
|
+
const text = serializeManifest(VALID)
|
|
111
|
+
expect(parseManifest(text)).toEqual(VALID)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('emits pretty-printed JSON', () => {
|
|
115
|
+
const text = serializeManifest(VALID)
|
|
116
|
+
expect(text).toContain('\n')
|
|
117
|
+
expect(text.endsWith('\n')).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('node metadata client-key', () => {
|
|
122
|
+
test('the reserved field is a single scalar key, not an edge namespace', () => {
|
|
123
|
+
expect(FLOWY_KEY_FIELD).toBe('__flowyKey')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('buildNodeMetadata stamps the client-key alongside user metadata', () => {
|
|
127
|
+
const meta = JSON.parse(buildNodeMetadata('task-1', { priority: 'high' }))
|
|
128
|
+
expect(meta).toEqual({ priority: 'high', __flowyKey: 'task-1' })
|
|
129
|
+
// No edge data is ever stored in metadata.
|
|
130
|
+
expect(meta.__flowy).toBeUndefined()
|
|
131
|
+
expect(meta.edges).toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('buildNodeMetadata works with no user metadata', () => {
|
|
135
|
+
expect(JSON.parse(buildNodeMetadata('proj'))).toEqual({
|
|
136
|
+
__flowyKey: 'proj',
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('readClientKey extracts the key', () => {
|
|
141
|
+
expect(readClientKey(buildNodeMetadata('feat-1', { a: 1 }))).toBe('feat-1')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('readClientKey returns null for absent/invalid metadata', () => {
|
|
145
|
+
expect(readClientKey(null)).toBeNull()
|
|
146
|
+
expect(readClientKey('{not json')).toBeNull()
|
|
147
|
+
expect(readClientKey(JSON.stringify({ a: 1 }))).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('stripClientKey returns only user metadata', () => {
|
|
151
|
+
expect(
|
|
152
|
+
stripClientKey(buildNodeMetadata('x', { priority: 'high' })),
|
|
153
|
+
).toEqual({ priority: 'high' })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('stripClientKey returns undefined when only the key was stored', () => {
|
|
157
|
+
expect(stripClientKey(buildNodeMetadata('x'))).toBeUndefined()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flowy import/export manifest format.
|
|
3
|
+
*
|
|
4
|
+
* The manifest is the migration unit for a backlog: projects, features and
|
|
5
|
+
* tasks (`nodes`) plus their dependency `edges`, all addressed by a stable
|
|
6
|
+
* **client-key** rather than a server id. Import upserts by client-key
|
|
7
|
+
* (idempotent); export reconstructs the same shape from the server. Keeping
|
|
8
|
+
* all format knowledge in this one module means the on-disk format (JSON
|
|
9
|
+
* today — see roadmap §G, an open owner decision) can change without touching
|
|
10
|
+
* the import/export command logic.
|
|
11
|
+
*
|
|
12
|
+
* Edges live in the real edge model (`createEdge` / `Query.edges`), not in
|
|
13
|
+
* node metadata. The only thing import stamps into metadata is the node's
|
|
14
|
+
* client-key (for idempotent node upsert); export reads edges back via
|
|
15
|
+
* `Query.edges`, so it captures edges created by any path (e.g. `task block`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface ManifestNode {
|
|
19
|
+
/** Stable client-key — the idempotency anchor. */
|
|
20
|
+
key: string
|
|
21
|
+
/** One of: project, feature, task. */
|
|
22
|
+
type: string
|
|
23
|
+
title: string
|
|
24
|
+
description?: string
|
|
25
|
+
status?: string
|
|
26
|
+
/** Client-key of the parent node; drives the implicit `part_of` edge. */
|
|
27
|
+
parent?: string
|
|
28
|
+
/** Arbitrary user metadata (the reserved `__flowyKey` field is stripped). */
|
|
29
|
+
metadata?: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ManifestEdge {
|
|
33
|
+
/** Client-key of the source node. */
|
|
34
|
+
source: string
|
|
35
|
+
/** Client-key of the target node. */
|
|
36
|
+
target: string
|
|
37
|
+
relation: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Manifest {
|
|
41
|
+
version: number
|
|
42
|
+
nodes: ManifestNode[]
|
|
43
|
+
edges: ManifestEdge[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The current manifest schema version. */
|
|
47
|
+
export const MANIFEST_VERSION = 1
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reserved metadata field holding a node's client-key — the only thing import
|
|
51
|
+
* writes into metadata, purely so a re-import can find the node by its stable
|
|
52
|
+
* key and update in place rather than duplicating. User metadata lives
|
|
53
|
+
* alongside it at the top level and is preserved untouched; export strips this
|
|
54
|
+
* one field back out. Edges are NOT stored here (see the module header).
|
|
55
|
+
*/
|
|
56
|
+
export const FLOWY_KEY_FIELD = '__flowyKey'
|
|
57
|
+
|
|
58
|
+
/** Read the reserved client-key field from a server node's metadata string. */
|
|
59
|
+
export function readClientKey(
|
|
60
|
+
metadata: string | null | undefined,
|
|
61
|
+
): string | null {
|
|
62
|
+
if (!metadata) return null
|
|
63
|
+
let parsed: unknown
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(metadata)
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
if (!isObject(parsed)) return null
|
|
70
|
+
const key = parsed[FLOWY_KEY_FIELD]
|
|
71
|
+
return typeof key === 'string' ? key : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Strip the reserved client-key field, returning only user metadata (or undefined). */
|
|
75
|
+
export function stripClientKey(
|
|
76
|
+
metadata: string | null | undefined,
|
|
77
|
+
): Record<string, unknown> | undefined {
|
|
78
|
+
if (!metadata) return undefined
|
|
79
|
+
let parsed: unknown
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(metadata)
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
if (!isObject(parsed)) return undefined
|
|
86
|
+
const { [FLOWY_KEY_FIELD]: _key, ...rest } = parsed
|
|
87
|
+
return Object.keys(rest).length > 0 ? rest : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Build the metadata JSON string for a node: user metadata plus the client-key. */
|
|
91
|
+
export function buildNodeMetadata(
|
|
92
|
+
key: string,
|
|
93
|
+
userMetadata?: Record<string, unknown>,
|
|
94
|
+
): string {
|
|
95
|
+
return JSON.stringify({ ...(userMetadata ?? {}), [FLOWY_KEY_FIELD]: key })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fail(message: string): never {
|
|
99
|
+
throw new Error(message)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
103
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Parse and validate a manifest from its serialized form. */
|
|
107
|
+
export function parseManifest(text: string): Manifest {
|
|
108
|
+
let raw: unknown
|
|
109
|
+
try {
|
|
110
|
+
raw = JSON.parse(text)
|
|
111
|
+
} catch {
|
|
112
|
+
fail('Invalid JSON: manifest is not valid JSON.')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!isObject(raw)) fail('Invalid manifest: expected a JSON object.')
|
|
116
|
+
if (!Array.isArray(raw.nodes)) {
|
|
117
|
+
fail('Invalid manifest: "nodes" must be an array.')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const seen = new Set<string>()
|
|
121
|
+
const nodes: ManifestNode[] = raw.nodes.map((entry, i) => {
|
|
122
|
+
if (!isObject(entry))
|
|
123
|
+
fail(`Invalid manifest: nodes[${i}] is not an object.`)
|
|
124
|
+
if (typeof entry.key !== 'string' || entry.key.length === 0) {
|
|
125
|
+
fail(`Invalid manifest: nodes[${i}] is missing a string "key".`)
|
|
126
|
+
}
|
|
127
|
+
if (typeof entry.type !== 'string' || entry.type.length === 0) {
|
|
128
|
+
fail(`Invalid manifest: node "${entry.key}" is missing a "type".`)
|
|
129
|
+
}
|
|
130
|
+
if (typeof entry.title !== 'string') {
|
|
131
|
+
fail(`Invalid manifest: node "${entry.key}" is missing a "title".`)
|
|
132
|
+
}
|
|
133
|
+
if (seen.has(entry.key)) {
|
|
134
|
+
fail(`Invalid manifest: duplicate client-key "${entry.key}".`)
|
|
135
|
+
}
|
|
136
|
+
seen.add(entry.key)
|
|
137
|
+
if (entry.parent != null && typeof entry.parent !== 'string') {
|
|
138
|
+
fail(`Invalid manifest: node "${entry.key}" has a non-string "parent".`)
|
|
139
|
+
}
|
|
140
|
+
if (entry.metadata != null && !isObject(entry.metadata)) {
|
|
141
|
+
fail(`Invalid manifest: node "${entry.key}" has non-object "metadata".`)
|
|
142
|
+
}
|
|
143
|
+
const node: ManifestNode = {
|
|
144
|
+
key: entry.key,
|
|
145
|
+
type: entry.type,
|
|
146
|
+
title: entry.title,
|
|
147
|
+
}
|
|
148
|
+
if (typeof entry.description === 'string')
|
|
149
|
+
node.description = entry.description
|
|
150
|
+
if (typeof entry.status === 'string') node.status = entry.status
|
|
151
|
+
if (typeof entry.parent === 'string') node.parent = entry.parent
|
|
152
|
+
if (isObject(entry.metadata)) node.metadata = entry.metadata
|
|
153
|
+
return node
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
for (const node of nodes) {
|
|
157
|
+
if (node.parent != null && !seen.has(node.parent)) {
|
|
158
|
+
fail(
|
|
159
|
+
`Invalid manifest: node "${node.key}" references unknown parent "${node.parent}".`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const rawEdges = Array.isArray(raw.edges) ? raw.edges : []
|
|
165
|
+
const edges: ManifestEdge[] = rawEdges.map((entry, i) => {
|
|
166
|
+
if (!isObject(entry))
|
|
167
|
+
fail(`Invalid manifest: edges[${i}] is not an object.`)
|
|
168
|
+
const { source, target, relation } = entry
|
|
169
|
+
if (typeof source !== 'string' || typeof target !== 'string') {
|
|
170
|
+
fail(`Invalid manifest: edges[${i}] needs string "source" and "target".`)
|
|
171
|
+
}
|
|
172
|
+
if (typeof relation !== 'string' || relation.length === 0) {
|
|
173
|
+
fail(`Invalid manifest: edges[${i}] is missing a "relation".`)
|
|
174
|
+
}
|
|
175
|
+
if (!seen.has(source)) {
|
|
176
|
+
fail(`Invalid manifest: edge source "${source}" is not a known node key.`)
|
|
177
|
+
}
|
|
178
|
+
if (!seen.has(target)) {
|
|
179
|
+
fail(`Invalid manifest: edge target "${target}" is not a known node key.`)
|
|
180
|
+
}
|
|
181
|
+
return { source, target, relation }
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const version =
|
|
185
|
+
typeof raw.version === 'number' ? raw.version : MANIFEST_VERSION
|
|
186
|
+
return { version, nodes, edges }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Serialize a manifest to its on-disk form (pretty-printed JSON, trailing newline). */
|
|
190
|
+
export function serializeManifest(manifest: Manifest): string {
|
|
191
|
+
return `${JSON.stringify(manifest, null, 2)}\n`
|
|
192
|
+
}
|