@sqaoss/flowy 1.5.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/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/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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,287 @@
|
|
|
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/format.ts', () => ({
|
|
8
|
+
output: vi.fn(),
|
|
9
|
+
outputError: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
vi.mock('node:fs', () => ({
|
|
13
|
+
readFileSync: vi.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const FLOWY_KEY_FIELD = '__flowyKey'
|
|
17
|
+
|
|
18
|
+
/** A small representative backlog: 1 project, 1 feature, 2 tasks, 1 blocks edge. */
|
|
19
|
+
const MANIFEST = {
|
|
20
|
+
version: 1,
|
|
21
|
+
nodes: [
|
|
22
|
+
{ key: 'proj', type: 'project', title: 'Demo' },
|
|
23
|
+
{ key: 'feat-1', type: 'feature', title: 'Auth', parent: 'proj' },
|
|
24
|
+
{
|
|
25
|
+
key: 'task-1',
|
|
26
|
+
type: 'task',
|
|
27
|
+
title: 'Login',
|
|
28
|
+
parent: 'feat-1',
|
|
29
|
+
status: 'draft',
|
|
30
|
+
metadata: { priority: 'high' },
|
|
31
|
+
},
|
|
32
|
+
{ key: 'task-2', type: 'task', title: 'Logout', parent: 'feat-1' },
|
|
33
|
+
],
|
|
34
|
+
edges: [{ source: 'task-2', target: 'task-1', relation: 'blocks' }],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse the JSON metadata string a CLI mutation sent to the server. */
|
|
38
|
+
function metaOf(variables: Record<string, unknown>): Record<string, unknown> {
|
|
39
|
+
return JSON.parse(variables.metadata as string)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('import command', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.clearAllMocks()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('exports a single-argument import command', async () => {
|
|
48
|
+
const { importCommand } = await import('./import.ts')
|
|
49
|
+
expect(importCommand.name()).toBe('import')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('metadata carries only the client-key, never edge data', async () => {
|
|
53
|
+
const { graphql } = await import('../util/client.ts')
|
|
54
|
+
const { readFileSync } = await import('node:fs')
|
|
55
|
+
const { importCommand } = await import('./import.ts')
|
|
56
|
+
|
|
57
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(MANIFEST))
|
|
58
|
+
vi.mocked(graphql).mockImplementation(async (query: string) => {
|
|
59
|
+
if (query.includes('nodes(')) return { nodes: [] }
|
|
60
|
+
if (query.includes('createNode')) return { createNode: { id: 'srv_x' } }
|
|
61
|
+
if (query.includes('createEdge')) return { createEdge: {} }
|
|
62
|
+
return {}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await importCommand.parseAsync(['manifest.json'], { from: 'user' })
|
|
66
|
+
|
|
67
|
+
for (const [q, vars] of vi.mocked(graphql).mock.calls) {
|
|
68
|
+
if (!q.includes('createNode')) continue
|
|
69
|
+
const meta = metaOf(vars ?? {})
|
|
70
|
+
expect(typeof meta[FLOWY_KEY_FIELD]).toBe('string')
|
|
71
|
+
// The dropped edge-stamp hack must not reappear in any form.
|
|
72
|
+
expect(meta.__flowy).toBeUndefined()
|
|
73
|
+
expect(meta.edges).toBeUndefined()
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('first run: creates every node and edge, returns a key→id map', async () => {
|
|
78
|
+
const { graphql } = await import('../util/client.ts')
|
|
79
|
+
const { output } = await import('../util/format.ts')
|
|
80
|
+
const { readFileSync } = await import('node:fs')
|
|
81
|
+
const { importCommand } = await import('./import.ts')
|
|
82
|
+
|
|
83
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(MANIFEST))
|
|
84
|
+
|
|
85
|
+
// No existing nodes for any type.
|
|
86
|
+
vi.mocked(graphql).mockImplementation(
|
|
87
|
+
async (query: string, variables?: Record<string, unknown>) => {
|
|
88
|
+
if (query.includes('nodes(')) return { nodes: [] }
|
|
89
|
+
if (query.includes('createNode')) {
|
|
90
|
+
const key = metaOf(variables ?? {})[FLOWY_KEY_FIELD] as string
|
|
91
|
+
return { createNode: { id: `srv_${key}` } }
|
|
92
|
+
}
|
|
93
|
+
if (query.includes('updateNode')) {
|
|
94
|
+
return { updateNode: { id: variables?.id } }
|
|
95
|
+
}
|
|
96
|
+
// No pre-existing nodes → the existing-edges query should never run.
|
|
97
|
+
if (query.includes('edges(')) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'edges() should not be queried with no existing nodes',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
if (query.includes('createEdge')) return { createEdge: {} }
|
|
103
|
+
return {}
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
await importCommand.parseAsync(['manifest.json'], { from: 'user' })
|
|
108
|
+
|
|
109
|
+
const calls = vi.mocked(graphql).mock.calls
|
|
110
|
+
const created = calls.filter(([q]) => q.includes('createNode'))
|
|
111
|
+
const updated = calls.filter(([q]) => q.includes('updateNode'))
|
|
112
|
+
const edges = calls.filter(([q]) => q.includes('createEdge'))
|
|
113
|
+
|
|
114
|
+
// All 4 nodes created, none updated on a clean import.
|
|
115
|
+
expect(created).toHaveLength(4)
|
|
116
|
+
expect(updated).toHaveLength(0)
|
|
117
|
+
|
|
118
|
+
// part_of edges (feat-1→proj, task-1→feat-1, task-2→feat-1) + 1 blocks = 4.
|
|
119
|
+
expect(edges).toHaveLength(4)
|
|
120
|
+
|
|
121
|
+
// Output is the key→id map.
|
|
122
|
+
const mapArg = vi.mocked(output).mock.calls.at(-1)?.[0] as {
|
|
123
|
+
map: Record<string, string>
|
|
124
|
+
}
|
|
125
|
+
expect(mapArg.map).toMatchObject({
|
|
126
|
+
proj: 'srv_proj',
|
|
127
|
+
'feat-1': 'srv_feat-1',
|
|
128
|
+
'task-1': 'srv_task-1',
|
|
129
|
+
'task-2': 'srv_task-2',
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('re-import is idempotent: existing keys update, never re-create', async () => {
|
|
134
|
+
const { graphql } = await import('../util/client.ts')
|
|
135
|
+
const { readFileSync } = await import('node:fs')
|
|
136
|
+
const { importCommand } = await import('./import.ts')
|
|
137
|
+
|
|
138
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(MANIFEST))
|
|
139
|
+
|
|
140
|
+
// Every manifest node already exists (carrying its client-key), and every
|
|
141
|
+
// edge already exists in the real edge model.
|
|
142
|
+
const existing = (type: string) => {
|
|
143
|
+
const byType: Record<string, Array<Record<string, unknown>>> = {
|
|
144
|
+
project: [node('proj', 'srv_proj')],
|
|
145
|
+
feature: [node('feat-1', 'srv_feat-1')],
|
|
146
|
+
task: [node('task-1', 'srv_task-1'), node('task-2', 'srv_task-2')],
|
|
147
|
+
}
|
|
148
|
+
return byType[type] ?? []
|
|
149
|
+
}
|
|
150
|
+
const edgesOf = (nodeId: string, relation: string) =>
|
|
151
|
+
EDGES.filter((e) => e.source === nodeId && e.relation === relation).map(
|
|
152
|
+
(e) => ({ id: e.target }),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
vi.mocked(graphql).mockImplementation(
|
|
156
|
+
async (query: string, variables?: Record<string, unknown>) => {
|
|
157
|
+
if (query.includes('nodes(')) {
|
|
158
|
+
return { nodes: existing(variables?.type as string) }
|
|
159
|
+
}
|
|
160
|
+
if (query.includes('edges(')) {
|
|
161
|
+
return {
|
|
162
|
+
edges: edgesOf(
|
|
163
|
+
variables?.nodeId as string,
|
|
164
|
+
variables?.relation as string,
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (query.includes('updateNode')) {
|
|
169
|
+
return { updateNode: { id: variables?.id } }
|
|
170
|
+
}
|
|
171
|
+
if (query.includes('createNode')) {
|
|
172
|
+
throw new Error('createNode must not be called on re-import')
|
|
173
|
+
}
|
|
174
|
+
if (query.includes('createEdge')) return { createEdge: {} }
|
|
175
|
+
return {}
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
await importCommand.parseAsync(['manifest.json'], { from: 'user' })
|
|
180
|
+
|
|
181
|
+
const calls = vi.mocked(graphql).mock.calls
|
|
182
|
+
const created = calls.filter(([q]) => q.includes('createNode'))
|
|
183
|
+
const updated = calls.filter(([q]) => q.includes('updateNode'))
|
|
184
|
+
const edges = calls.filter(([q]) => q.includes('createEdge'))
|
|
185
|
+
|
|
186
|
+
// No node is re-created; all four are updated in place.
|
|
187
|
+
expect(created).toHaveLength(0)
|
|
188
|
+
expect(updated).toHaveLength(4)
|
|
189
|
+
|
|
190
|
+
// Every edge already exists in the edge model → none re-created.
|
|
191
|
+
expect(edges).toHaveLength(0)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('does not re-create an edge that already exists in the edge model', async () => {
|
|
195
|
+
const { graphql } = await import('../util/client.ts')
|
|
196
|
+
const { readFileSync } = await import('node:fs')
|
|
197
|
+
const { importCommand } = await import('./import.ts')
|
|
198
|
+
|
|
199
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(MANIFEST))
|
|
200
|
+
|
|
201
|
+
const existing = (type: string) => {
|
|
202
|
+
const byType: Record<string, Array<Record<string, unknown>>> = {
|
|
203
|
+
project: [node('proj', 'srv_proj')],
|
|
204
|
+
feature: [node('feat-1', 'srv_feat-1')],
|
|
205
|
+
task: [node('task-1', 'srv_task-1'), node('task-2', 'srv_task-2')],
|
|
206
|
+
}
|
|
207
|
+
return byType[type] ?? []
|
|
208
|
+
}
|
|
209
|
+
// Only the part_of edges exist server-side; the blocks edge does not.
|
|
210
|
+
const present = EDGES.filter((e) => e.relation === 'part_of')
|
|
211
|
+
const edgesOf = (nodeId: string, relation: string) =>
|
|
212
|
+
present
|
|
213
|
+
.filter((e) => e.source === nodeId && e.relation === relation)
|
|
214
|
+
.map((e) => ({ id: e.target }))
|
|
215
|
+
|
|
216
|
+
vi.mocked(graphql).mockImplementation(
|
|
217
|
+
async (query: string, variables?: Record<string, unknown>) => {
|
|
218
|
+
if (query.includes('nodes(')) {
|
|
219
|
+
return { nodes: existing(variables?.type as string) }
|
|
220
|
+
}
|
|
221
|
+
if (query.includes('edges(')) {
|
|
222
|
+
return {
|
|
223
|
+
edges: edgesOf(
|
|
224
|
+
variables?.nodeId as string,
|
|
225
|
+
variables?.relation as string,
|
|
226
|
+
),
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (query.includes('updateNode')) {
|
|
230
|
+
return { updateNode: { id: variables?.id } }
|
|
231
|
+
}
|
|
232
|
+
if (query.includes('createEdge')) return { createEdge: {} }
|
|
233
|
+
return {}
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
await importCommand.parseAsync(['manifest.json'], { from: 'user' })
|
|
238
|
+
|
|
239
|
+
const edges = vi
|
|
240
|
+
.mocked(graphql)
|
|
241
|
+
.mock.calls.filter(([q]) => q.includes('createEdge'))
|
|
242
|
+
expect(edges).toHaveLength(1)
|
|
243
|
+
expect(edges[0]?.[1]).toMatchObject({
|
|
244
|
+
sourceId: 'srv_task-2',
|
|
245
|
+
targetId: 'srv_task-1',
|
|
246
|
+
relation: 'blocks',
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('reports a parse error via outputError', async () => {
|
|
251
|
+
const { outputError } = await import('../util/format.ts')
|
|
252
|
+
const { readFileSync } = await import('node:fs')
|
|
253
|
+
const { importCommand } = await import('./import.ts')
|
|
254
|
+
|
|
255
|
+
vi.mocked(readFileSync).mockReturnValue('{not json')
|
|
256
|
+
|
|
257
|
+
await importCommand.parseAsync(['bad.json'], { from: 'user' })
|
|
258
|
+
|
|
259
|
+
expect(outputError).toHaveBeenCalledWith(
|
|
260
|
+
expect.objectContaining({
|
|
261
|
+
message: expect.stringMatching(/invalid json/i),
|
|
262
|
+
}),
|
|
263
|
+
)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
/** The full edge set this manifest implies, expressed in SERVER ids. */
|
|
268
|
+
const EDGES = [
|
|
269
|
+
{ source: 'srv_feat-1', target: 'srv_proj', relation: 'part_of' },
|
|
270
|
+
{ source: 'srv_task-1', target: 'srv_feat-1', relation: 'part_of' },
|
|
271
|
+
{ source: 'srv_task-2', target: 'srv_feat-1', relation: 'part_of' },
|
|
272
|
+
{ source: 'srv_task-2', target: 'srv_task-1', relation: 'blocks' },
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build an existing server node row stamped with its client-key only.
|
|
277
|
+
* Edges are NOT stored in metadata — they live in the real edge model and are
|
|
278
|
+
* mocked separately via the `edges()` query.
|
|
279
|
+
*/
|
|
280
|
+
function node(key: string, id: string): Record<string, unknown> {
|
|
281
|
+
return {
|
|
282
|
+
id,
|
|
283
|
+
type: 'x',
|
|
284
|
+
title: key,
|
|
285
|
+
metadata: JSON.stringify({ [FLOWY_KEY_FIELD]: key }),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import { graphql } from '../util/client.ts'
|
|
4
|
+
import { output, outputError } from '../util/format.ts'
|
|
5
|
+
import {
|
|
6
|
+
buildNodeMetadata,
|
|
7
|
+
type Manifest,
|
|
8
|
+
type ManifestNode,
|
|
9
|
+
parseManifest,
|
|
10
|
+
readClientKey,
|
|
11
|
+
} from '../util/manifest.ts'
|
|
12
|
+
|
|
13
|
+
const NODE_TYPES = ['project', 'feature', 'task'] as const
|
|
14
|
+
|
|
15
|
+
/** Relations import materializes; existing-edge dedup queries each of these. */
|
|
16
|
+
const RELATIONS = ['part_of', 'blocks'] as const
|
|
17
|
+
|
|
18
|
+
interface ServerNode {
|
|
19
|
+
id: string
|
|
20
|
+
type: string
|
|
21
|
+
title: string
|
|
22
|
+
metadata: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface DesiredEdge {
|
|
26
|
+
sourceKey: string
|
|
27
|
+
targetKey: string
|
|
28
|
+
relation: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function edgeKey(source: string, target: string, relation: string): string {
|
|
32
|
+
return `${source}|${target}|${relation}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** All edges the manifest implies: implicit `part_of` from `parent` + explicit edges. */
|
|
36
|
+
function desiredEdges(manifest: Manifest): DesiredEdge[] {
|
|
37
|
+
const edges: DesiredEdge[] = []
|
|
38
|
+
const seen = new Set<string>()
|
|
39
|
+
const add = (sourceKey: string, targetKey: string, relation: string) => {
|
|
40
|
+
const k = edgeKey(sourceKey, targetKey, relation)
|
|
41
|
+
if (seen.has(k)) return
|
|
42
|
+
seen.add(k)
|
|
43
|
+
edges.push({ sourceKey, targetKey, relation })
|
|
44
|
+
}
|
|
45
|
+
for (const node of manifest.nodes) {
|
|
46
|
+
if (node.parent) add(node.key, node.parent, 'part_of')
|
|
47
|
+
}
|
|
48
|
+
for (const e of manifest.edges) add(e.source, e.target, e.relation)
|
|
49
|
+
return edges
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read every existing node, mapping its stored client-key to its server id. */
|
|
53
|
+
async function loadExisting(): Promise<Map<string, string>> {
|
|
54
|
+
const idByKey = new Map<string, string>()
|
|
55
|
+
for (const type of NODE_TYPES) {
|
|
56
|
+
const data = await graphql<{ nodes: ServerNode[] }>(
|
|
57
|
+
`query ImportExisting($type: String) {
|
|
58
|
+
nodes(type: $type) { id type title metadata }
|
|
59
|
+
}`,
|
|
60
|
+
{ type },
|
|
61
|
+
)
|
|
62
|
+
for (const node of data.nodes) {
|
|
63
|
+
const key = readClientKey(node.metadata)
|
|
64
|
+
if (key) idByKey.set(key, node.id)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return idByKey
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Collect the edges that already exist for the given nodes, as a set of
|
|
72
|
+
* `<sourceId>|<targetId>|<relation>` triples. Read back through the real edge
|
|
73
|
+
* model (`Query.edges`), so externally-created edges (e.g. `task block`) are
|
|
74
|
+
* recognized and never duplicated.
|
|
75
|
+
*/
|
|
76
|
+
async function loadExistingEdges(nodeIds: string[]): Promise<Set<string>> {
|
|
77
|
+
const existing = new Set<string>()
|
|
78
|
+
for (const nodeId of nodeIds) {
|
|
79
|
+
for (const relation of RELATIONS) {
|
|
80
|
+
const data = await graphql<{ edges: Array<{ id: string }> }>(
|
|
81
|
+
`query ImportEdges($nodeId: String!, $relation: String!) {
|
|
82
|
+
edges(nodeId: $nodeId, relation: $relation, direction: "outgoing") { id }
|
|
83
|
+
}`,
|
|
84
|
+
{ nodeId, relation },
|
|
85
|
+
)
|
|
86
|
+
for (const target of data.edges) {
|
|
87
|
+
existing.add(edgeKey(nodeId, target.id, relation))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return existing
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const CREATE_NODE = `mutation ImportCreate($type: String!, $title: String!, $description: String, $status: String, $metadata: String) {
|
|
95
|
+
createNode(type: $type, title: $title, description: $description, status: $status, metadata: $metadata) { id }
|
|
96
|
+
}`
|
|
97
|
+
|
|
98
|
+
const UPDATE_NODE = `mutation ImportUpdate($id: String!, $title: String, $description: String, $status: String, $metadata: String) {
|
|
99
|
+
updateNode(id: $id, title: $title, description: $description, status: $status, metadata: $metadata) { id }
|
|
100
|
+
}`
|
|
101
|
+
|
|
102
|
+
const CREATE_EDGE = `mutation ImportEdge($sourceId: String!, $targetId: String!, $relation: String!) {
|
|
103
|
+
createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) { sourceId targetId relation }
|
|
104
|
+
}`
|
|
105
|
+
|
|
106
|
+
async function upsertNode(
|
|
107
|
+
node: ManifestNode,
|
|
108
|
+
existingId: string | undefined,
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const metadata = buildNodeMetadata(node.key, node.metadata)
|
|
111
|
+
if (existingId) {
|
|
112
|
+
await graphql<{ updateNode: { id: string } }>(UPDATE_NODE, {
|
|
113
|
+
id: existingId,
|
|
114
|
+
title: node.title,
|
|
115
|
+
description: node.description ?? null,
|
|
116
|
+
status: node.status ?? null,
|
|
117
|
+
metadata,
|
|
118
|
+
})
|
|
119
|
+
return existingId
|
|
120
|
+
}
|
|
121
|
+
const data = await graphql<{ createNode: { id: string } }>(CREATE_NODE, {
|
|
122
|
+
type: node.type,
|
|
123
|
+
title: node.title,
|
|
124
|
+
description: node.description ?? null,
|
|
125
|
+
status: node.status ?? null,
|
|
126
|
+
metadata,
|
|
127
|
+
})
|
|
128
|
+
return data.createNode.id
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const importCommand = new Command('import')
|
|
132
|
+
.description(
|
|
133
|
+
'Ingest a manifest of projects/features/tasks + edges (idempotent by client-key)',
|
|
134
|
+
)
|
|
135
|
+
.argument('<manifest>', 'Path to a JSON manifest file')
|
|
136
|
+
.action(async (manifestPath: string) => {
|
|
137
|
+
try {
|
|
138
|
+
const manifest = parseManifest(readFileSync(manifestPath, 'utf-8'))
|
|
139
|
+
const existing = await loadExisting()
|
|
140
|
+
|
|
141
|
+
// Pass 1 — upsert every node, stamping its client-key into metadata.
|
|
142
|
+
// Known keys update, new keys create, so a re-import never duplicates.
|
|
143
|
+
const idByKey = new Map<string, string>()
|
|
144
|
+
for (const node of manifest.nodes) {
|
|
145
|
+
idByKey.set(node.key, await upsertNode(node, existing.get(node.key)))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Dedup against edges that already exist server-side. Only nodes that
|
|
149
|
+
// pre-existed this import can already have edges, so query just those.
|
|
150
|
+
const preExistingIds = manifest.nodes
|
|
151
|
+
.filter((n) => existing.has(n.key))
|
|
152
|
+
.map((n) => idByKey.get(n.key))
|
|
153
|
+
.filter((id): id is string => id != null)
|
|
154
|
+
const present = await loadExistingEdges(preExistingIds)
|
|
155
|
+
|
|
156
|
+
// Pass 2 — materialize edges, deduped by (source,target,relation). Skip
|
|
157
|
+
// any edge that already exists so the non-idempotent createEdge is never
|
|
158
|
+
// asked to re-link.
|
|
159
|
+
let edgeCount = 0
|
|
160
|
+
for (const edge of desiredEdges(manifest)) {
|
|
161
|
+
const sourceId = idByKey.get(edge.sourceKey)
|
|
162
|
+
const targetId = idByKey.get(edge.targetKey)
|
|
163
|
+
if (!sourceId || !targetId) continue
|
|
164
|
+
if (present.has(edgeKey(sourceId, targetId, edge.relation))) continue
|
|
165
|
+
await graphql(CREATE_EDGE, {
|
|
166
|
+
sourceId,
|
|
167
|
+
targetId,
|
|
168
|
+
relation: edge.relation,
|
|
169
|
+
})
|
|
170
|
+
edgeCount++
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
output({
|
|
174
|
+
imported: idByKey.size,
|
|
175
|
+
edges: edgeCount,
|
|
176
|
+
map: Object.fromEntries(idByKey),
|
|
177
|
+
})
|
|
178
|
+
} catch (error) {
|
|
179
|
+
outputError(error)
|
|
180
|
+
}
|
|
181
|
+
})
|
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
|
+
}
|