@sqaoss/flowy 1.2.2 → 1.3.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.
- package/docker-compose.yml +7 -2
- package/package.json +1 -2
- package/server/src/db.test.ts +48 -0
- package/server/src/db.ts +2 -31
- package/server/src/index.errors.test.ts +135 -0
- package/server/src/index.test.ts +29 -0
- package/server/src/index.ts +15 -5
- package/server/src/migrations.test.ts +218 -0
- package/server/src/migrations.ts +140 -0
- package/server/src/resolvers.test.ts +250 -3
- package/server/src/resolvers.ts +114 -17
- package/server/src/schema.test.ts +39 -5
- package/server/src/schema.ts +15 -2
- package/src/commands/feature.ts +12 -2
- package/src/commands/serve.test.ts +89 -0
- package/src/commands/serve.ts +76 -0
- package/src/commands/setup.test.ts +12 -14
- package/src/commands/setup.ts +14 -73
- package/src/commands/task.test.ts +8 -0
- package/src/commands/task.ts +11 -4
- package/src/index.test.ts +16 -0
- package/src/index.ts +2 -0
- package/src/util/client.test.ts +194 -81
- package/src/util/client.ts +127 -27
- package/src/util/description.test.ts +59 -7
- package/src/util/description.ts +59 -4
- package/src/util/format.test.ts +65 -0
- package/src/util/format.ts +22 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Versioned schema migrations for the bundled local SQLite server.
|
|
5
|
+
*
|
|
6
|
+
* The schema version is tracked with SQLite's `PRAGMA user_version`. Each entry
|
|
7
|
+
* in `MIGRATIONS` is a step whose 1-based ordinal is the version it produces:
|
|
8
|
+
* step at index `i` upgrades the DB from `user_version = i` to `user_version = i + 1`.
|
|
9
|
+
* On startup we apply every step whose target version exceeds the current
|
|
10
|
+
* `user_version`, in order, each wrapped in its own transaction so a failure
|
|
11
|
+
* leaves the DB at a clean, consistent version. Re-running is a no-op.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type Migration = (db: Database) => void
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Rebuild a table with a new schema, preserving its rows. SQLite cannot ALTER a
|
|
18
|
+
* CHECK constraint in place, so changing column constraints requires creating a
|
|
19
|
+
* new table, copying rows across the shared columns, dropping the old table, and
|
|
20
|
+
* renaming. The whole sequence runs inside a transaction.
|
|
21
|
+
*/
|
|
22
|
+
export function rebuildTable(
|
|
23
|
+
db: Database,
|
|
24
|
+
table: string,
|
|
25
|
+
createNewTableSql: (tmpName: string) => string,
|
|
26
|
+
columns: string[],
|
|
27
|
+
): void {
|
|
28
|
+
const tmp = `${table}__migrate_tmp`
|
|
29
|
+
const cols = columns.join(', ')
|
|
30
|
+
db.run('PRAGMA foreign_keys = OFF')
|
|
31
|
+
db.transaction(() => {
|
|
32
|
+
db.run(createNewTableSql(tmp))
|
|
33
|
+
db.run(`INSERT INTO ${tmp} (${cols}) SELECT ${cols} FROM ${table}`)
|
|
34
|
+
db.run(`DROP TABLE ${table}`)
|
|
35
|
+
db.run(`ALTER TABLE ${tmp} RENAME TO ${table}`)
|
|
36
|
+
})()
|
|
37
|
+
db.run('PRAGMA foreign_keys = ON')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MIGRATIONS: Migration[] = [
|
|
41
|
+
// 0 -> 1: base schema (nodes + edges + indexes). Uses IF NOT EXISTS so this
|
|
42
|
+
// step is harmless on a DB that predates user_version tracking but already
|
|
43
|
+
// has the tables (created by an old `CREATE TABLE IF NOT EXISTS` startup).
|
|
44
|
+
(db) => {
|
|
45
|
+
db.run(`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
|
|
49
|
+
title TEXT NOT NULL,
|
|
50
|
+
description TEXT,
|
|
51
|
+
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done')),
|
|
52
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
53
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
54
|
+
)
|
|
55
|
+
`)
|
|
56
|
+
db.run(`
|
|
57
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
58
|
+
source_id TEXT NOT NULL REFERENCES nodes(id),
|
|
59
|
+
target_id TEXT NOT NULL REFERENCES nodes(id),
|
|
60
|
+
relation TEXT NOT NULL CHECK(relation IN ('part_of', 'blocks')),
|
|
61
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
62
|
+
PRIMARY KEY (source_id, target_id, relation)
|
|
63
|
+
)
|
|
64
|
+
`)
|
|
65
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)')
|
|
66
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status)')
|
|
67
|
+
db.run(
|
|
68
|
+
'CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id, relation)',
|
|
69
|
+
)
|
|
70
|
+
db.run(
|
|
71
|
+
'CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id, relation)',
|
|
72
|
+
)
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// 1 -> 2: (a) add a writable `metadata` TEXT column if it is missing, then
|
|
76
|
+
// (b) rebuild `nodes` to widen the status CHECK to include 'blocked' and
|
|
77
|
+
// 'cancelled'. Adding the column first lets the CHECK-rebuild copy a uniform
|
|
78
|
+
// set of columns regardless of the pre-existing shape.
|
|
79
|
+
(db) => {
|
|
80
|
+
const hasMetadata = db
|
|
81
|
+
.query<{ name: string }, []>('PRAGMA table_info(nodes)')
|
|
82
|
+
.all()
|
|
83
|
+
.some((c) => c.name === 'metadata')
|
|
84
|
+
if (!hasMetadata) {
|
|
85
|
+
db.run('ALTER TABLE nodes ADD COLUMN metadata TEXT')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
rebuildTable(
|
|
89
|
+
db,
|
|
90
|
+
'nodes',
|
|
91
|
+
(tmp) => `
|
|
92
|
+
CREATE TABLE ${tmp} (
|
|
93
|
+
id TEXT PRIMARY KEY,
|
|
94
|
+
type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
|
|
95
|
+
title TEXT NOT NULL,
|
|
96
|
+
description TEXT,
|
|
97
|
+
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done', 'blocked', 'cancelled')),
|
|
98
|
+
metadata TEXT,
|
|
99
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
100
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
101
|
+
)
|
|
102
|
+
`,
|
|
103
|
+
[
|
|
104
|
+
'id',
|
|
105
|
+
'type',
|
|
106
|
+
'title',
|
|
107
|
+
'description',
|
|
108
|
+
'status',
|
|
109
|
+
'metadata',
|
|
110
|
+
'created_at',
|
|
111
|
+
'updated_at',
|
|
112
|
+
],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// Indexes are dropped with the old table; recreate them.
|
|
116
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)')
|
|
117
|
+
db.run('CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status)')
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
export const LATEST_VERSION = MIGRATIONS.length
|
|
122
|
+
|
|
123
|
+
function getUserVersion(db: Database): number {
|
|
124
|
+
const row = db
|
|
125
|
+
.query<{ user_version: number }, []>('PRAGMA user_version')
|
|
126
|
+
.get()
|
|
127
|
+
return row?.user_version ?? 0
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function runMigrations(db: Database): void {
|
|
131
|
+
const current = getUserVersion(db)
|
|
132
|
+
MIGRATIONS.forEach((migration, index) => {
|
|
133
|
+
const targetVersion = index + 1
|
|
134
|
+
if (targetVersion <= current) return
|
|
135
|
+
migration(db)
|
|
136
|
+
// PRAGMA user_version does not accept bound parameters; the value is an
|
|
137
|
+
// integer derived from our own loop index, never user input.
|
|
138
|
+
db.run(`PRAGMA user_version = ${targetVersion}`)
|
|
139
|
+
})
|
|
140
|
+
}
|
|
@@ -96,6 +96,59 @@ describe('createResolvers', () => {
|
|
|
96
96
|
expect(typeof node.createdAt).toBe('string')
|
|
97
97
|
expect(typeof node.updatedAt).toBe('string')
|
|
98
98
|
})
|
|
99
|
+
|
|
100
|
+
it('persists metadata as JSON and reads it back unchanged', () => {
|
|
101
|
+
const created = resolvers.Mutation.createNode(null, {
|
|
102
|
+
type: 'task',
|
|
103
|
+
title: 'With meta',
|
|
104
|
+
metadata: '{"priority":"high","effort":3}',
|
|
105
|
+
})
|
|
106
|
+
expect(created.metadata).toBe('{"priority":"high","effort":3}')
|
|
107
|
+
const found = find(resolvers, created.id)
|
|
108
|
+
expect(JSON.parse(found.metadata as string)).toEqual({
|
|
109
|
+
priority: 'high',
|
|
110
|
+
effort: 3,
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('accepts an explicit initial status', () => {
|
|
115
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
116
|
+
type: 'task',
|
|
117
|
+
title: 'Started',
|
|
118
|
+
status: 'in_progress',
|
|
119
|
+
})
|
|
120
|
+
expect(node.status).toBe('in_progress')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('rejects an invalid initial status with VALIDATION_ERROR', () => {
|
|
124
|
+
try {
|
|
125
|
+
resolvers.Mutation.createNode(null, {
|
|
126
|
+
type: 'task',
|
|
127
|
+
title: 'Bad',
|
|
128
|
+
status: 'bogus',
|
|
129
|
+
})
|
|
130
|
+
throw new Error('expected createNode to throw')
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const e = err as { message: string; extensions?: { code?: string } }
|
|
133
|
+
expect(e.message).toContain('Invalid status: bogus')
|
|
134
|
+
expect(e.extensions?.code).toBe('VALIDATION_ERROR')
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('rejects non-JSON metadata with VALIDATION_ERROR', () => {
|
|
139
|
+
try {
|
|
140
|
+
resolvers.Mutation.createNode(null, {
|
|
141
|
+
type: 'task',
|
|
142
|
+
title: 'Bad meta',
|
|
143
|
+
metadata: 'not json',
|
|
144
|
+
})
|
|
145
|
+
throw new Error('expected createNode to throw')
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const e = err as { message: string; extensions?: { code?: string } }
|
|
148
|
+
expect(e.message).toContain('metadata')
|
|
149
|
+
expect(e.extensions?.code).toBe('VALIDATION_ERROR')
|
|
150
|
+
}
|
|
151
|
+
})
|
|
99
152
|
})
|
|
100
153
|
|
|
101
154
|
describe('Query.node', () => {
|
|
@@ -112,9 +165,17 @@ describe('createResolvers', () => {
|
|
|
112
165
|
})
|
|
113
166
|
})
|
|
114
167
|
|
|
115
|
-
it('
|
|
116
|
-
|
|
117
|
-
|
|
168
|
+
it('throws NOT_FOUND for a non-existent id (no silent null)', () => {
|
|
169
|
+
expect(() => resolvers.Query.node(null, { id: 'nonexistent' })).toThrow(
|
|
170
|
+
'Node nonexistent not found',
|
|
171
|
+
)
|
|
172
|
+
try {
|
|
173
|
+
resolvers.Query.node(null, { id: 'nonexistent' })
|
|
174
|
+
} catch (error) {
|
|
175
|
+
expect(
|
|
176
|
+
(error as { extensions?: { code?: string } }).extensions?.code,
|
|
177
|
+
).toBe('NOT_FOUND')
|
|
178
|
+
}
|
|
118
179
|
})
|
|
119
180
|
})
|
|
120
181
|
|
|
@@ -161,6 +222,192 @@ describe('createResolvers', () => {
|
|
|
161
222
|
}),
|
|
162
223
|
).toThrow('Node nonexistent not found')
|
|
163
224
|
})
|
|
225
|
+
|
|
226
|
+
it('updates the title, leaving other fields untouched', () => {
|
|
227
|
+
const node = create(resolvers, {
|
|
228
|
+
type: 'task',
|
|
229
|
+
title: 'Old title',
|
|
230
|
+
description: 'keep me',
|
|
231
|
+
})
|
|
232
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
233
|
+
id: node.id,
|
|
234
|
+
title: 'New title',
|
|
235
|
+
})
|
|
236
|
+
expect(updated.title).toBe('New title')
|
|
237
|
+
expect(updated.description).toBe('keep me')
|
|
238
|
+
expect(updated.status).toBe('draft')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('updates the description independently', () => {
|
|
242
|
+
const node = create(resolvers, {
|
|
243
|
+
type: 'task',
|
|
244
|
+
title: 'Title',
|
|
245
|
+
description: 'old desc',
|
|
246
|
+
})
|
|
247
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
248
|
+
id: node.id,
|
|
249
|
+
description: 'new desc',
|
|
250
|
+
})
|
|
251
|
+
expect(updated.description).toBe('new desc')
|
|
252
|
+
expect(updated.title).toBe('Title')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('updates metadata independently and round-trips', () => {
|
|
256
|
+
const node = create(resolvers, { type: 'task', title: 'Title' })
|
|
257
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
258
|
+
id: node.id,
|
|
259
|
+
metadata: '{"source":"import","effort":5}',
|
|
260
|
+
})
|
|
261
|
+
expect(JSON.parse(updated.metadata as string)).toEqual({
|
|
262
|
+
source: 'import',
|
|
263
|
+
effort: 5,
|
|
264
|
+
})
|
|
265
|
+
const found = find(resolvers, node.id)
|
|
266
|
+
expect(JSON.parse(found.metadata as string)).toEqual({
|
|
267
|
+
source: 'import',
|
|
268
|
+
effort: 5,
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('updates several fields at once', () => {
|
|
273
|
+
const node = create(resolvers, { type: 'task', title: 'Title' })
|
|
274
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
275
|
+
id: node.id,
|
|
276
|
+
title: 'Renamed',
|
|
277
|
+
description: 'desc',
|
|
278
|
+
status: 'in_progress',
|
|
279
|
+
metadata: '{"k":"v"}',
|
|
280
|
+
})
|
|
281
|
+
expect(updated.title).toBe('Renamed')
|
|
282
|
+
expect(updated.description).toBe('desc')
|
|
283
|
+
expect(updated.status).toBe('in_progress')
|
|
284
|
+
expect(JSON.parse(updated.metadata as string)).toEqual({ k: 'v' })
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('rejects non-JSON metadata on update with VALIDATION_ERROR', () => {
|
|
288
|
+
const node = create(resolvers, { type: 'task', title: 'Title' })
|
|
289
|
+
try {
|
|
290
|
+
resolvers.Mutation.updateNode(null, {
|
|
291
|
+
id: node.id,
|
|
292
|
+
metadata: 'nope',
|
|
293
|
+
})
|
|
294
|
+
throw new Error('expected updateNode to throw')
|
|
295
|
+
} catch (err) {
|
|
296
|
+
const e = err as { message: string; extensions?: { code?: string } }
|
|
297
|
+
expect(e.message).toContain('metadata')
|
|
298
|
+
expect(e.extensions?.code).toBe('VALIDATION_ERROR')
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('not-found error carries the NOT_FOUND code', () => {
|
|
303
|
+
try {
|
|
304
|
+
resolvers.Mutation.updateNode(null, {
|
|
305
|
+
id: 'missing',
|
|
306
|
+
title: 'x',
|
|
307
|
+
})
|
|
308
|
+
throw new Error('expected updateNode to throw')
|
|
309
|
+
} catch (err) {
|
|
310
|
+
const e = err as { extensions?: { code?: string } }
|
|
311
|
+
expect(e.extensions?.code).toBe('NOT_FOUND')
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('rejects an empty title with VALIDATION_ERROR', () => {
|
|
316
|
+
const node = create(resolvers, { type: 'task', title: 'Title' })
|
|
317
|
+
try {
|
|
318
|
+
resolvers.Mutation.updateNode(null, { id: node.id, title: ' ' })
|
|
319
|
+
throw new Error('expected updateNode to throw')
|
|
320
|
+
} catch (err) {
|
|
321
|
+
const e = err as { extensions?: { code?: string } }
|
|
322
|
+
expect(e.extensions?.code).toBe('VALIDATION_ERROR')
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('Mutation.deleteNode', () => {
|
|
328
|
+
it('deletes a leaf node and returns true', () => {
|
|
329
|
+
const node = create(resolvers, { type: 'task', title: 'Leaf' })
|
|
330
|
+
const result = resolvers.Mutation.deleteNode(null, { id: node.id })
|
|
331
|
+
expect(result).toBe(true)
|
|
332
|
+
// After deletion the node is gone: Query.node now fails loud (NOT_FOUND).
|
|
333
|
+
expect(() => resolvers.Query.node(null, { id: node.id })).toThrow(
|
|
334
|
+
`Node ${node.id} not found`,
|
|
335
|
+
)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('removes incident blocks edges when deleting a leaf', () => {
|
|
339
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
340
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
341
|
+
resolvers.Mutation.createEdge(null, {
|
|
342
|
+
sourceId: blocker.id,
|
|
343
|
+
targetId: blocked.id,
|
|
344
|
+
relation: 'blocks',
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
resolvers.Mutation.deleteNode(null, { id: blocked.id })
|
|
348
|
+
|
|
349
|
+
// the blocks edge that referenced the deleted node must be gone
|
|
350
|
+
const edges = db.raw
|
|
351
|
+
.query<{ c: number }, [string, string]>(
|
|
352
|
+
'SELECT COUNT(*) AS c FROM edges WHERE source_id = ? OR target_id = ?',
|
|
353
|
+
)
|
|
354
|
+
.get(blocked.id, blocked.id) as { c: number }
|
|
355
|
+
expect(edges.c).toBe(0)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('deletes a node together with its part_of edge to its parent', () => {
|
|
359
|
+
const project = create(resolvers, { type: 'project', title: 'P' })
|
|
360
|
+
const feature = create(resolvers, { type: 'feature', title: 'F' })
|
|
361
|
+
resolvers.Mutation.createEdge(null, {
|
|
362
|
+
sourceId: feature.id,
|
|
363
|
+
targetId: project.id,
|
|
364
|
+
relation: 'part_of',
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const result = resolvers.Mutation.deleteNode(null, { id: feature.id })
|
|
368
|
+
expect(result).toBe(true)
|
|
369
|
+
// parent survives
|
|
370
|
+
expect(resolvers.Query.node(null, { id: project.id })).not.toBeNull()
|
|
371
|
+
// the part_of edge is gone
|
|
372
|
+
const edges = db.raw
|
|
373
|
+
.query<{ c: number }, [string]>(
|
|
374
|
+
'SELECT COUNT(*) AS c FROM edges WHERE source_id = ?',
|
|
375
|
+
)
|
|
376
|
+
.get(feature.id) as { c: number }
|
|
377
|
+
expect(edges.c).toBe(0)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('refuses to delete a node that has children (CONFLICT)', () => {
|
|
381
|
+
const project = create(resolvers, { type: 'project', title: 'P' })
|
|
382
|
+
const feature = create(resolvers, { type: 'feature', title: 'F' })
|
|
383
|
+
resolvers.Mutation.createEdge(null, {
|
|
384
|
+
sourceId: feature.id,
|
|
385
|
+
targetId: project.id,
|
|
386
|
+
relation: 'part_of',
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
resolvers.Mutation.deleteNode(null, { id: project.id })
|
|
391
|
+
throw new Error('expected deleteNode to throw')
|
|
392
|
+
} catch (err) {
|
|
393
|
+
const e = err as { message: string; extensions?: { code?: string } }
|
|
394
|
+
expect(e.extensions?.code).toBe('CONFLICT')
|
|
395
|
+
expect(e.message).toContain('child')
|
|
396
|
+
}
|
|
397
|
+
// node not deleted
|
|
398
|
+
expect(resolvers.Query.node(null, { id: project.id })).not.toBeNull()
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('throws NOT_FOUND for a missing node', () => {
|
|
402
|
+
try {
|
|
403
|
+
resolvers.Mutation.deleteNode(null, { id: 'missing' })
|
|
404
|
+
throw new Error('expected deleteNode to throw')
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const e = err as { message: string; extensions?: { code?: string } }
|
|
407
|
+
expect(e.extensions?.code).toBe('NOT_FOUND')
|
|
408
|
+
expect(e.message).toContain('missing')
|
|
409
|
+
}
|
|
410
|
+
})
|
|
164
411
|
})
|
|
165
412
|
|
|
166
413
|
describe('Mutation.approveNode', () => {
|
package/server/src/resolvers.ts
CHANGED
|
@@ -59,6 +59,30 @@ const VALID_STATUSES = new Set([
|
|
|
59
59
|
'cancelled',
|
|
60
60
|
])
|
|
61
61
|
|
|
62
|
+
function assertValidStatus(status: string): void {
|
|
63
|
+
if (!VALID_STATUSES.has(status)) {
|
|
64
|
+
throw validationError(
|
|
65
|
+
`Invalid status: ${status}. Must be one of: ${[...VALID_STATUSES].join(', ')}`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate that `metadata` is a JSON string and return its canonical form.
|
|
72
|
+
* Metadata is stored as a JSON string (the column and the GraphQL field are
|
|
73
|
+
* both String); callers pass a JSON-encoded string. Non-JSON input is rejected
|
|
74
|
+
* with a VALIDATION_ERROR so agents can self-correct.
|
|
75
|
+
*/
|
|
76
|
+
function normalizeMetadata(metadata: string): string {
|
|
77
|
+
let parsed: unknown
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(metadata)
|
|
80
|
+
} catch {
|
|
81
|
+
throw validationError('Invalid metadata: must be a valid JSON string')
|
|
82
|
+
}
|
|
83
|
+
return JSON.stringify(parsed)
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
function generateId(type: string): string {
|
|
63
87
|
const prefix = PREFIX_MAP[type] ?? type
|
|
64
88
|
return `${prefix}_${nanoid(12)}`
|
|
@@ -98,7 +122,9 @@ export function createResolvers(db: Db) {
|
|
|
98
122
|
return {
|
|
99
123
|
Query: {
|
|
100
124
|
node: (_: unknown, args: { id: string }) => {
|
|
101
|
-
|
|
125
|
+
const node = selectNode(db, args.id)
|
|
126
|
+
if (!node) throw notFoundError(`Node ${args.id} not found`)
|
|
127
|
+
return node
|
|
102
128
|
},
|
|
103
129
|
|
|
104
130
|
nodes: (_: unknown, args: { type?: string }) => {
|
|
@@ -206,49 +232,120 @@ export function createResolvers(db: Db) {
|
|
|
206
232
|
Mutation: {
|
|
207
233
|
createNode: (
|
|
208
234
|
_: unknown,
|
|
209
|
-
args: {
|
|
235
|
+
args: {
|
|
236
|
+
type: string
|
|
237
|
+
title: string
|
|
238
|
+
description?: string
|
|
239
|
+
status?: string
|
|
240
|
+
metadata?: string
|
|
241
|
+
},
|
|
210
242
|
): NodeGql => {
|
|
211
243
|
if (!args.title.trim()) throw validationError('Title is required')
|
|
212
244
|
if (args.description != null && !args.description.trim()) {
|
|
213
245
|
throw validationError('Description cannot be empty')
|
|
214
246
|
}
|
|
247
|
+
if (args.status != null) assertValidStatus(args.status)
|
|
248
|
+
const metadata =
|
|
249
|
+
args.metadata != null ? normalizeMetadata(args.metadata) : null
|
|
215
250
|
const id = generateId(args.type)
|
|
216
251
|
const now = new Date().toISOString()
|
|
217
252
|
const description = args.description ?? null
|
|
253
|
+
const status = args.status ?? 'draft'
|
|
218
254
|
db.raw.run(
|
|
219
255
|
'INSERT INTO nodes (id, type, title, description, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
220
|
-
[id, args.type, args.title, description,
|
|
256
|
+
[id, args.type, args.title, description, status, metadata, now, now],
|
|
221
257
|
)
|
|
222
258
|
return {
|
|
223
259
|
id,
|
|
224
260
|
type: args.type,
|
|
225
261
|
title: args.title,
|
|
226
262
|
description,
|
|
227
|
-
status
|
|
228
|
-
metadata
|
|
263
|
+
status,
|
|
264
|
+
metadata,
|
|
229
265
|
createdAt: now,
|
|
230
266
|
updatedAt: now,
|
|
231
267
|
}
|
|
232
268
|
},
|
|
233
269
|
|
|
234
|
-
updateNode: (
|
|
270
|
+
updateNode: (
|
|
271
|
+
_: unknown,
|
|
272
|
+
args: {
|
|
273
|
+
id: string
|
|
274
|
+
title?: string
|
|
275
|
+
description?: string
|
|
276
|
+
status?: string
|
|
277
|
+
metadata?: string
|
|
278
|
+
},
|
|
279
|
+
) => {
|
|
235
280
|
const existing = db.raw
|
|
236
281
|
.query('SELECT * FROM nodes WHERE id = ?')
|
|
237
282
|
.get(args.id) as NodeRow | null
|
|
238
283
|
if (!existing) throw notFoundError(`Node ${args.id} not found`)
|
|
239
|
-
|
|
240
|
-
if (args.
|
|
241
|
-
throw validationError(
|
|
242
|
-
|
|
243
|
-
|
|
284
|
+
|
|
285
|
+
if (args.title != null && !args.title.trim()) {
|
|
286
|
+
throw validationError('Title cannot be empty')
|
|
287
|
+
}
|
|
288
|
+
if (args.description != null && !args.description.trim()) {
|
|
289
|
+
throw validationError('Description cannot be empty')
|
|
290
|
+
}
|
|
291
|
+
if (args.status != null) assertValidStatus(args.status)
|
|
292
|
+
|
|
293
|
+
const next: NodeRow = {
|
|
294
|
+
...existing,
|
|
295
|
+
title: args.title ?? existing.title,
|
|
296
|
+
description:
|
|
297
|
+
args.description !== undefined
|
|
298
|
+
? args.description
|
|
299
|
+
: existing.description,
|
|
300
|
+
status: args.status ?? existing.status,
|
|
301
|
+
metadata:
|
|
302
|
+
args.metadata != null
|
|
303
|
+
? normalizeMetadata(args.metadata)
|
|
304
|
+
: existing.metadata,
|
|
244
305
|
}
|
|
245
306
|
const now = new Date().toISOString()
|
|
246
|
-
db.raw.run(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
307
|
+
db.raw.run(
|
|
308
|
+
'UPDATE nodes SET title = ?, description = ?, status = ?, metadata = ?, updated_at = ? WHERE id = ?',
|
|
309
|
+
[
|
|
310
|
+
next.title,
|
|
311
|
+
next.description,
|
|
312
|
+
next.status,
|
|
313
|
+
next.metadata,
|
|
314
|
+
now,
|
|
315
|
+
args.id,
|
|
316
|
+
],
|
|
317
|
+
)
|
|
318
|
+
return rowToNode({ ...next, updated_at: now })
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
deleteNode: (_: unknown, args: { id: string }): boolean => {
|
|
322
|
+
const existing = db.raw
|
|
323
|
+
.query('SELECT id FROM nodes WHERE id = ?')
|
|
324
|
+
.get(args.id) as { id: string } | null
|
|
325
|
+
if (!existing) throw notFoundError(`Node ${args.id} not found`)
|
|
326
|
+
|
|
327
|
+
// The hierarchy is client -> project -> feature -> task via `part_of`
|
|
328
|
+
// edges (source = child, target = parent). A node with children must
|
|
329
|
+
// not be orphaned; reject rather than cascade-delete the subtree.
|
|
330
|
+
const childCount = db.raw
|
|
331
|
+
.query(
|
|
332
|
+
'SELECT COUNT(*) AS c FROM edges WHERE target_id = ? AND relation = ?',
|
|
333
|
+
)
|
|
334
|
+
.get(args.id, 'part_of') as { c: number }
|
|
335
|
+
if (childCount.c > 0) {
|
|
336
|
+
throw conflictError(
|
|
337
|
+
`Cannot delete node ${args.id}: it has ${childCount.c} child node(s). Delete or re-link them first.`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
db.raw.transaction(() => {
|
|
342
|
+
db.raw.run('DELETE FROM edges WHERE source_id = ? OR target_id = ?', [
|
|
343
|
+
args.id,
|
|
344
|
+
args.id,
|
|
345
|
+
])
|
|
346
|
+
db.raw.run('DELETE FROM nodes WHERE id = ?', [args.id])
|
|
347
|
+
})()
|
|
348
|
+
return true
|
|
252
349
|
},
|
|
253
350
|
|
|
254
351
|
approveNode: (_: unknown, args: { id: string }) => {
|
|
@@ -70,7 +70,7 @@ describe('schema', () => {
|
|
|
70
70
|
expect(fields.tree).toBeUndefined()
|
|
71
71
|
})
|
|
72
72
|
|
|
73
|
-
it('Mutation type has createNode, updateNode, approveNode, createEdge, removeEdge', () => {
|
|
73
|
+
it('Mutation type has createNode, updateNode, approveNode, deleteNode, createEdge, removeEdge', () => {
|
|
74
74
|
const mutationType = schema.getType(
|
|
75
75
|
'Mutation',
|
|
76
76
|
) as import('graphql').GraphQLObjectType
|
|
@@ -78,16 +78,50 @@ describe('schema', () => {
|
|
|
78
78
|
expect(fields.createNode).toBeDefined()
|
|
79
79
|
expect(fields.updateNode).toBeDefined()
|
|
80
80
|
expect(fields.approveNode).toBeDefined()
|
|
81
|
+
expect(fields.deleteNode).toBeDefined()
|
|
81
82
|
expect(fields.createEdge).toBeDefined()
|
|
82
83
|
expect(fields.removeEdge).toBeDefined()
|
|
83
84
|
})
|
|
84
85
|
|
|
85
|
-
it('
|
|
86
|
+
it('createNode accepts type, title, description, status, metadata args', () => {
|
|
86
87
|
const mutationType = schema.getType(
|
|
87
88
|
'Mutation',
|
|
88
89
|
) as import('graphql').GraphQLObjectType
|
|
89
|
-
const
|
|
90
|
-
expect(
|
|
91
|
-
|
|
90
|
+
const args = mutationType.getFields().createNode.args.map((a) => a.name)
|
|
91
|
+
expect(args).toEqual(
|
|
92
|
+
expect.arrayContaining([
|
|
93
|
+
'type',
|
|
94
|
+
'title',
|
|
95
|
+
'description',
|
|
96
|
+
'status',
|
|
97
|
+
'metadata',
|
|
98
|
+
]),
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('updateNode accepts id, title, description, status, metadata args', () => {
|
|
103
|
+
const mutationType = schema.getType(
|
|
104
|
+
'Mutation',
|
|
105
|
+
) as import('graphql').GraphQLObjectType
|
|
106
|
+
const args = mutationType.getFields().updateNode.args.map((a) => a.name)
|
|
107
|
+
expect(args).toEqual(
|
|
108
|
+
expect.arrayContaining([
|
|
109
|
+
'id',
|
|
110
|
+
'title',
|
|
111
|
+
'description',
|
|
112
|
+
'status',
|
|
113
|
+
'metadata',
|
|
114
|
+
]),
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('deleteNode returns Boolean and takes an id arg', () => {
|
|
119
|
+
const mutationType = schema.getType(
|
|
120
|
+
'Mutation',
|
|
121
|
+
) as import('graphql').GraphQLObjectType
|
|
122
|
+
const deleteNode = mutationType.getFields().deleteNode
|
|
123
|
+
const args = deleteNode.args.map((a) => a.name)
|
|
124
|
+
expect(args).toEqual(expect.arrayContaining(['id']))
|
|
125
|
+
expect(String(deleteNode.type)).toContain('Boolean')
|
|
92
126
|
})
|
|
93
127
|
})
|
package/server/src/schema.ts
CHANGED
|
@@ -28,9 +28,22 @@ export const typeDefs = /* GraphQL */ `
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
type Mutation {
|
|
31
|
-
createNode(
|
|
32
|
-
|
|
31
|
+
createNode(
|
|
32
|
+
type: String!
|
|
33
|
+
title: String!
|
|
34
|
+
description: String
|
|
35
|
+
status: String
|
|
36
|
+
metadata: String
|
|
37
|
+
): Node!
|
|
38
|
+
updateNode(
|
|
39
|
+
id: String!
|
|
40
|
+
title: String
|
|
41
|
+
description: String
|
|
42
|
+
status: String
|
|
43
|
+
metadata: String
|
|
44
|
+
): Node!
|
|
33
45
|
approveNode(id: String!): Node!
|
|
46
|
+
deleteNode(id: String!): Boolean!
|
|
34
47
|
createEdge(sourceId: String!, targetId: String!, relation: String!): Edge!
|
|
35
48
|
removeEdge(sourceId: String!, targetId: String!, relation: String!): Boolean!
|
|
36
49
|
}
|