@sqaoss/flowy 1.5.0 → 1.6.1

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.
@@ -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
+ }