@sqaoss/flowy 1.2.1 → 1.3.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 +5 -6
- package/server/src/db.test.ts +48 -0
- package/server/src/db.ts +2 -31
- package/server/src/index.errors.test.ts +202 -0
- package/server/src/migrations.test.ts +218 -0
- package/server/src/migrations.ts +140 -0
- package/server/src/resolvers.test.ts +236 -0
- package/server/src/resolvers.ts +136 -26
- package/server/src/schema.test.ts +39 -5
- package/server/src/schema.ts +15 -2
- package/src/util/client.test.ts +25 -0
- package/src/util/client.ts +3 -1
- package/src/util/format.test.ts +48 -0
- package/src/util/format.ts +6 -1
|
@@ -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', () => {
|
|
@@ -161,6 +214,189 @@ describe('createResolvers', () => {
|
|
|
161
214
|
}),
|
|
162
215
|
).toThrow('Node nonexistent not found')
|
|
163
216
|
})
|
|
217
|
+
|
|
218
|
+
it('updates the title, leaving other fields untouched', () => {
|
|
219
|
+
const node = create(resolvers, {
|
|
220
|
+
type: 'task',
|
|
221
|
+
title: 'Old title',
|
|
222
|
+
description: 'keep me',
|
|
223
|
+
})
|
|
224
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
225
|
+
id: node.id,
|
|
226
|
+
title: 'New title',
|
|
227
|
+
})
|
|
228
|
+
expect(updated.title).toBe('New title')
|
|
229
|
+
expect(updated.description).toBe('keep me')
|
|
230
|
+
expect(updated.status).toBe('draft')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('updates the description independently', () => {
|
|
234
|
+
const node = create(resolvers, {
|
|
235
|
+
type: 'task',
|
|
236
|
+
title: 'Title',
|
|
237
|
+
description: 'old desc',
|
|
238
|
+
})
|
|
239
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
240
|
+
id: node.id,
|
|
241
|
+
description: 'new desc',
|
|
242
|
+
})
|
|
243
|
+
expect(updated.description).toBe('new desc')
|
|
244
|
+
expect(updated.title).toBe('Title')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('updates metadata independently and round-trips', () => {
|
|
248
|
+
const node = create(resolvers, { type: 'task', title: 'Title' })
|
|
249
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
250
|
+
id: node.id,
|
|
251
|
+
metadata: '{"source":"import","effort":5}',
|
|
252
|
+
})
|
|
253
|
+
expect(JSON.parse(updated.metadata as string)).toEqual({
|
|
254
|
+
source: 'import',
|
|
255
|
+
effort: 5,
|
|
256
|
+
})
|
|
257
|
+
const found = find(resolvers, node.id)
|
|
258
|
+
expect(JSON.parse(found.metadata as string)).toEqual({
|
|
259
|
+
source: 'import',
|
|
260
|
+
effort: 5,
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('updates several fields at once', () => {
|
|
265
|
+
const node = create(resolvers, { type: 'task', title: 'Title' })
|
|
266
|
+
const updated = resolvers.Mutation.updateNode(null, {
|
|
267
|
+
id: node.id,
|
|
268
|
+
title: 'Renamed',
|
|
269
|
+
description: 'desc',
|
|
270
|
+
status: 'in_progress',
|
|
271
|
+
metadata: '{"k":"v"}',
|
|
272
|
+
})
|
|
273
|
+
expect(updated.title).toBe('Renamed')
|
|
274
|
+
expect(updated.description).toBe('desc')
|
|
275
|
+
expect(updated.status).toBe('in_progress')
|
|
276
|
+
expect(JSON.parse(updated.metadata as string)).toEqual({ k: 'v' })
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('rejects non-JSON metadata on update with VALIDATION_ERROR', () => {
|
|
280
|
+
const node = create(resolvers, { type: 'task', title: 'Title' })
|
|
281
|
+
try {
|
|
282
|
+
resolvers.Mutation.updateNode(null, {
|
|
283
|
+
id: node.id,
|
|
284
|
+
metadata: 'nope',
|
|
285
|
+
})
|
|
286
|
+
throw new Error('expected updateNode to throw')
|
|
287
|
+
} catch (err) {
|
|
288
|
+
const e = err as { message: string; extensions?: { code?: string } }
|
|
289
|
+
expect(e.message).toContain('metadata')
|
|
290
|
+
expect(e.extensions?.code).toBe('VALIDATION_ERROR')
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('not-found error carries the NOT_FOUND code', () => {
|
|
295
|
+
try {
|
|
296
|
+
resolvers.Mutation.updateNode(null, {
|
|
297
|
+
id: 'missing',
|
|
298
|
+
title: 'x',
|
|
299
|
+
})
|
|
300
|
+
throw new Error('expected updateNode to throw')
|
|
301
|
+
} catch (err) {
|
|
302
|
+
const e = err as { extensions?: { code?: string } }
|
|
303
|
+
expect(e.extensions?.code).toBe('NOT_FOUND')
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('rejects an empty title with VALIDATION_ERROR', () => {
|
|
308
|
+
const node = create(resolvers, { type: 'task', title: 'Title' })
|
|
309
|
+
try {
|
|
310
|
+
resolvers.Mutation.updateNode(null, { id: node.id, title: ' ' })
|
|
311
|
+
throw new Error('expected updateNode to throw')
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const e = err as { extensions?: { code?: string } }
|
|
314
|
+
expect(e.extensions?.code).toBe('VALIDATION_ERROR')
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('Mutation.deleteNode', () => {
|
|
320
|
+
it('deletes a leaf node and returns true', () => {
|
|
321
|
+
const node = create(resolvers, { type: 'task', title: 'Leaf' })
|
|
322
|
+
const result = resolvers.Mutation.deleteNode(null, { id: node.id })
|
|
323
|
+
expect(result).toBe(true)
|
|
324
|
+
expect(resolvers.Query.node(null, { id: node.id })).toBeNull()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('removes incident blocks edges when deleting a leaf', () => {
|
|
328
|
+
const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
|
|
329
|
+
const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
|
|
330
|
+
resolvers.Mutation.createEdge(null, {
|
|
331
|
+
sourceId: blocker.id,
|
|
332
|
+
targetId: blocked.id,
|
|
333
|
+
relation: 'blocks',
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
resolvers.Mutation.deleteNode(null, { id: blocked.id })
|
|
337
|
+
|
|
338
|
+
// the blocks edge that referenced the deleted node must be gone
|
|
339
|
+
const edges = db.raw
|
|
340
|
+
.query<{ c: number }, [string, string]>(
|
|
341
|
+
'SELECT COUNT(*) AS c FROM edges WHERE source_id = ? OR target_id = ?',
|
|
342
|
+
)
|
|
343
|
+
.get(blocked.id, blocked.id) as { c: number }
|
|
344
|
+
expect(edges.c).toBe(0)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('deletes a node together with its part_of edge to its parent', () => {
|
|
348
|
+
const project = create(resolvers, { type: 'project', title: 'P' })
|
|
349
|
+
const feature = create(resolvers, { type: 'feature', title: 'F' })
|
|
350
|
+
resolvers.Mutation.createEdge(null, {
|
|
351
|
+
sourceId: feature.id,
|
|
352
|
+
targetId: project.id,
|
|
353
|
+
relation: 'part_of',
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const result = resolvers.Mutation.deleteNode(null, { id: feature.id })
|
|
357
|
+
expect(result).toBe(true)
|
|
358
|
+
// parent survives
|
|
359
|
+
expect(resolvers.Query.node(null, { id: project.id })).not.toBeNull()
|
|
360
|
+
// the part_of edge is gone
|
|
361
|
+
const edges = db.raw
|
|
362
|
+
.query<{ c: number }, [string]>(
|
|
363
|
+
'SELECT COUNT(*) AS c FROM edges WHERE source_id = ?',
|
|
364
|
+
)
|
|
365
|
+
.get(feature.id) as { c: number }
|
|
366
|
+
expect(edges.c).toBe(0)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('refuses to delete a node that has children (CONFLICT)', () => {
|
|
370
|
+
const project = create(resolvers, { type: 'project', title: 'P' })
|
|
371
|
+
const feature = create(resolvers, { type: 'feature', title: 'F' })
|
|
372
|
+
resolvers.Mutation.createEdge(null, {
|
|
373
|
+
sourceId: feature.id,
|
|
374
|
+
targetId: project.id,
|
|
375
|
+
relation: 'part_of',
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
resolvers.Mutation.deleteNode(null, { id: project.id })
|
|
380
|
+
throw new Error('expected deleteNode to throw')
|
|
381
|
+
} catch (err) {
|
|
382
|
+
const e = err as { message: string; extensions?: { code?: string } }
|
|
383
|
+
expect(e.extensions?.code).toBe('CONFLICT')
|
|
384
|
+
expect(e.message).toContain('child')
|
|
385
|
+
}
|
|
386
|
+
// node not deleted
|
|
387
|
+
expect(resolvers.Query.node(null, { id: project.id })).not.toBeNull()
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('throws NOT_FOUND for a missing node', () => {
|
|
391
|
+
try {
|
|
392
|
+
resolvers.Mutation.deleteNode(null, { id: 'missing' })
|
|
393
|
+
throw new Error('expected deleteNode to throw')
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const e = err as { message: string; extensions?: { code?: string } }
|
|
396
|
+
expect(e.extensions?.code).toBe('NOT_FOUND')
|
|
397
|
+
expect(e.message).toContain('missing')
|
|
398
|
+
}
|
|
399
|
+
})
|
|
164
400
|
})
|
|
165
401
|
|
|
166
402
|
describe('Mutation.approveNode', () => {
|
package/server/src/resolvers.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
+
import { createGraphQLError } from 'graphql-yoga'
|
|
1
2
|
import { nanoid } from 'nanoid'
|
|
2
3
|
import type { FlowyDb } from './db.ts'
|
|
3
4
|
|
|
5
|
+
function validationError(message: string) {
|
|
6
|
+
return createGraphQLError(message, {
|
|
7
|
+
extensions: { code: 'VALIDATION_ERROR' },
|
|
8
|
+
})
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function notFoundError(message: string) {
|
|
12
|
+
return createGraphQLError(message, { extensions: { code: 'NOT_FOUND' } })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function conflictError(message: string) {
|
|
16
|
+
return createGraphQLError(message, { extensions: { code: 'CONFLICT' } })
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
type Db = FlowyDb
|
|
5
20
|
|
|
6
21
|
interface NodeRow {
|
|
@@ -44,6 +59,30 @@ const VALID_STATUSES = new Set([
|
|
|
44
59
|
'cancelled',
|
|
45
60
|
])
|
|
46
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
|
+
|
|
47
86
|
function generateId(type: string): string {
|
|
48
87
|
const prefix = PREFIX_MAP[type] ?? type
|
|
49
88
|
return `${prefix}_${nanoid(12)}`
|
|
@@ -163,7 +202,7 @@ export function createResolvers(db: Db) {
|
|
|
163
202
|
},
|
|
164
203
|
) => {
|
|
165
204
|
if (args.query.trim().length < 3) {
|
|
166
|
-
throw
|
|
205
|
+
throw validationError('Search query must be at least 3 characters')
|
|
167
206
|
}
|
|
168
207
|
const escaped = args.query.replace(/[%_\\]/g, '\\$&')
|
|
169
208
|
const conditions = [
|
|
@@ -191,58 +230,129 @@ export function createResolvers(db: Db) {
|
|
|
191
230
|
Mutation: {
|
|
192
231
|
createNode: (
|
|
193
232
|
_: unknown,
|
|
194
|
-
args: {
|
|
233
|
+
args: {
|
|
234
|
+
type: string
|
|
235
|
+
title: string
|
|
236
|
+
description?: string
|
|
237
|
+
status?: string
|
|
238
|
+
metadata?: string
|
|
239
|
+
},
|
|
195
240
|
): NodeGql => {
|
|
196
|
-
if (!args.title.trim()) throw
|
|
241
|
+
if (!args.title.trim()) throw validationError('Title is required')
|
|
197
242
|
if (args.description != null && !args.description.trim()) {
|
|
198
|
-
throw
|
|
243
|
+
throw validationError('Description cannot be empty')
|
|
199
244
|
}
|
|
245
|
+
if (args.status != null) assertValidStatus(args.status)
|
|
246
|
+
const metadata =
|
|
247
|
+
args.metadata != null ? normalizeMetadata(args.metadata) : null
|
|
200
248
|
const id = generateId(args.type)
|
|
201
249
|
const now = new Date().toISOString()
|
|
202
250
|
const description = args.description ?? null
|
|
251
|
+
const status = args.status ?? 'draft'
|
|
203
252
|
db.raw.run(
|
|
204
253
|
'INSERT INTO nodes (id, type, title, description, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
205
|
-
[id, args.type, args.title, description,
|
|
254
|
+
[id, args.type, args.title, description, status, metadata, now, now],
|
|
206
255
|
)
|
|
207
256
|
return {
|
|
208
257
|
id,
|
|
209
258
|
type: args.type,
|
|
210
259
|
title: args.title,
|
|
211
260
|
description,
|
|
212
|
-
status
|
|
213
|
-
metadata
|
|
261
|
+
status,
|
|
262
|
+
metadata,
|
|
214
263
|
createdAt: now,
|
|
215
264
|
updatedAt: now,
|
|
216
265
|
}
|
|
217
266
|
},
|
|
218
267
|
|
|
219
|
-
updateNode: (
|
|
268
|
+
updateNode: (
|
|
269
|
+
_: unknown,
|
|
270
|
+
args: {
|
|
271
|
+
id: string
|
|
272
|
+
title?: string
|
|
273
|
+
description?: string
|
|
274
|
+
status?: string
|
|
275
|
+
metadata?: string
|
|
276
|
+
},
|
|
277
|
+
) => {
|
|
220
278
|
const existing = db.raw
|
|
221
279
|
.query('SELECT * FROM nodes WHERE id = ?')
|
|
222
280
|
.get(args.id) as NodeRow | null
|
|
223
|
-
if (!existing) throw
|
|
224
|
-
|
|
225
|
-
if (args.
|
|
226
|
-
throw
|
|
227
|
-
|
|
228
|
-
|
|
281
|
+
if (!existing) throw notFoundError(`Node ${args.id} not found`)
|
|
282
|
+
|
|
283
|
+
if (args.title != null && !args.title.trim()) {
|
|
284
|
+
throw validationError('Title cannot be empty')
|
|
285
|
+
}
|
|
286
|
+
if (args.description != null && !args.description.trim()) {
|
|
287
|
+
throw validationError('Description cannot be empty')
|
|
288
|
+
}
|
|
289
|
+
if (args.status != null) assertValidStatus(args.status)
|
|
290
|
+
|
|
291
|
+
const next: NodeRow = {
|
|
292
|
+
...existing,
|
|
293
|
+
title: args.title ?? existing.title,
|
|
294
|
+
description:
|
|
295
|
+
args.description !== undefined
|
|
296
|
+
? args.description
|
|
297
|
+
: existing.description,
|
|
298
|
+
status: args.status ?? existing.status,
|
|
299
|
+
metadata:
|
|
300
|
+
args.metadata != null
|
|
301
|
+
? normalizeMetadata(args.metadata)
|
|
302
|
+
: existing.metadata,
|
|
229
303
|
}
|
|
230
304
|
const now = new Date().toISOString()
|
|
231
|
-
db.raw.run(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
305
|
+
db.raw.run(
|
|
306
|
+
'UPDATE nodes SET title = ?, description = ?, status = ?, metadata = ?, updated_at = ? WHERE id = ?',
|
|
307
|
+
[
|
|
308
|
+
next.title,
|
|
309
|
+
next.description,
|
|
310
|
+
next.status,
|
|
311
|
+
next.metadata,
|
|
312
|
+
now,
|
|
313
|
+
args.id,
|
|
314
|
+
],
|
|
315
|
+
)
|
|
316
|
+
return rowToNode({ ...next, updated_at: now })
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
deleteNode: (_: unknown, args: { id: string }): boolean => {
|
|
320
|
+
const existing = db.raw
|
|
321
|
+
.query('SELECT id FROM nodes WHERE id = ?')
|
|
322
|
+
.get(args.id) as { id: string } | null
|
|
323
|
+
if (!existing) throw notFoundError(`Node ${args.id} not found`)
|
|
324
|
+
|
|
325
|
+
// The hierarchy is client -> project -> feature -> task via `part_of`
|
|
326
|
+
// edges (source = child, target = parent). A node with children must
|
|
327
|
+
// not be orphaned; reject rather than cascade-delete the subtree.
|
|
328
|
+
const childCount = db.raw
|
|
329
|
+
.query(
|
|
330
|
+
'SELECT COUNT(*) AS c FROM edges WHERE target_id = ? AND relation = ?',
|
|
331
|
+
)
|
|
332
|
+
.get(args.id, 'part_of') as { c: number }
|
|
333
|
+
if (childCount.c > 0) {
|
|
334
|
+
throw conflictError(
|
|
335
|
+
`Cannot delete node ${args.id}: it has ${childCount.c} child node(s). Delete or re-link them first.`,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
db.raw.transaction(() => {
|
|
340
|
+
db.raw.run('DELETE FROM edges WHERE source_id = ? OR target_id = ?', [
|
|
341
|
+
args.id,
|
|
342
|
+
args.id,
|
|
343
|
+
])
|
|
344
|
+
db.raw.run('DELETE FROM nodes WHERE id = ?', [args.id])
|
|
345
|
+
})()
|
|
346
|
+
return true
|
|
237
347
|
},
|
|
238
348
|
|
|
239
349
|
approveNode: (_: unknown, args: { id: string }) => {
|
|
240
350
|
const existing = db.raw
|
|
241
351
|
.query('SELECT * FROM nodes WHERE id = ?')
|
|
242
352
|
.get(args.id) as NodeRow | null
|
|
243
|
-
if (!existing) throw
|
|
353
|
+
if (!existing) throw notFoundError(`Node ${args.id} not found`)
|
|
244
354
|
if (existing.status !== 'pending_review') {
|
|
245
|
-
throw
|
|
355
|
+
throw conflictError(
|
|
246
356
|
`Cannot approve node with status "${existing.status}", must be "pending_review"`,
|
|
247
357
|
)
|
|
248
358
|
}
|
|
@@ -261,7 +371,7 @@ export function createResolvers(db: Db) {
|
|
|
261
371
|
) => {
|
|
262
372
|
const validRelations = new Set(['part_of', 'blocks'])
|
|
263
373
|
if (!validRelations.has(args.relation)) {
|
|
264
|
-
throw
|
|
374
|
+
throw validationError(
|
|
265
375
|
`Invalid relation: ${args.relation}. Must be 'part_of' or 'blocks'`,
|
|
266
376
|
)
|
|
267
377
|
}
|
|
@@ -269,16 +379,16 @@ export function createResolvers(db: Db) {
|
|
|
269
379
|
.query('SELECT id FROM nodes WHERE id = ?')
|
|
270
380
|
.get(args.sourceId)
|
|
271
381
|
if (!sourceExists) {
|
|
272
|
-
throw
|
|
382
|
+
throw notFoundError(`Source node ${args.sourceId} not found`)
|
|
273
383
|
}
|
|
274
384
|
const targetExists = db.raw
|
|
275
385
|
.query('SELECT id FROM nodes WHERE id = ?')
|
|
276
386
|
.get(args.targetId)
|
|
277
387
|
if (!targetExists) {
|
|
278
|
-
throw
|
|
388
|
+
throw notFoundError(`Target node ${args.targetId} not found`)
|
|
279
389
|
}
|
|
280
390
|
if (args.relation === 'blocks' && args.sourceId === args.targetId) {
|
|
281
|
-
throw
|
|
391
|
+
throw validationError('A node cannot block itself')
|
|
282
392
|
}
|
|
283
393
|
const now = new Date().toISOString()
|
|
284
394
|
db.raw.run(
|
|
@@ -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
|
}
|
package/src/util/client.test.ts
CHANGED
|
@@ -31,6 +31,31 @@ describe('graphql client', () => {
|
|
|
31
31
|
expect(result).toEqual({ whoami: { id: '1' } })
|
|
32
32
|
})
|
|
33
33
|
|
|
34
|
+
test('attaches extensions.code to the thrown error', async () => {
|
|
35
|
+
vi.stubGlobal(
|
|
36
|
+
'fetch',
|
|
37
|
+
vi.fn().mockResolvedValue({
|
|
38
|
+
json: () =>
|
|
39
|
+
Promise.resolve({
|
|
40
|
+
errors: [
|
|
41
|
+
{
|
|
42
|
+
message: 'Search query must be at least 3 characters',
|
|
43
|
+
extensions: { code: 'VALIDATION_ERROR' },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
}),
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const { graphql } = await import('./client.ts')
|
|
51
|
+
await expect(
|
|
52
|
+
graphql('query { search(query: "ab") { id } }'),
|
|
53
|
+
).rejects.toMatchObject({
|
|
54
|
+
message: 'Search query must be at least 3 characters',
|
|
55
|
+
code: 'VALIDATION_ERROR',
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
34
59
|
test('throws original server message for unknown error codes', async () => {
|
|
35
60
|
vi.stubGlobal(
|
|
36
61
|
'fetch',
|
package/src/util/client.ts
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { outputError } from './format.ts'
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks()
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
describe('outputError', () => {
|
|
9
|
+
test('includes code in JSON when error has a code property', () => {
|
|
10
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
11
|
+
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never)
|
|
12
|
+
|
|
13
|
+
const err = Object.assign(
|
|
14
|
+
new Error('Search query must be at least 3 characters'),
|
|
15
|
+
{ code: 'VALIDATION_ERROR' },
|
|
16
|
+
)
|
|
17
|
+
outputError(err)
|
|
18
|
+
|
|
19
|
+
expect(errorSpy).toHaveBeenCalledTimes(1)
|
|
20
|
+
const printed = JSON.parse(errorSpy.mock.calls[0]![0] as string)
|
|
21
|
+
expect(printed).toEqual({
|
|
22
|
+
error: 'Search query must be at least 3 characters',
|
|
23
|
+
code: 'VALIDATION_ERROR',
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('omits code when error has no code property', () => {
|
|
28
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
29
|
+
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never)
|
|
30
|
+
|
|
31
|
+
outputError(new Error('plain failure'))
|
|
32
|
+
|
|
33
|
+
const printed = JSON.parse(errorSpy.mock.calls[0]![0] as string)
|
|
34
|
+
expect(printed).toEqual({ error: 'plain failure' })
|
|
35
|
+
expect(printed).not.toHaveProperty('code')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('exits with non-zero status', () => {
|
|
39
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
40
|
+
const exitSpy = vi
|
|
41
|
+
.spyOn(process, 'exit')
|
|
42
|
+
.mockImplementation(() => undefined as never)
|
|
43
|
+
|
|
44
|
+
outputError(new Error('boom'))
|
|
45
|
+
|
|
46
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
47
|
+
})
|
|
48
|
+
})
|
package/src/util/format.ts
CHANGED
|
@@ -4,6 +4,11 @@ export function output(data: unknown): void {
|
|
|
4
4
|
|
|
5
5
|
export function outputError(error: unknown): void {
|
|
6
6
|
const message = error instanceof Error ? error.message : String(error)
|
|
7
|
-
|
|
7
|
+
const rawCode =
|
|
8
|
+
error instanceof Error ? (error as { code?: unknown }).code : undefined
|
|
9
|
+
const code = typeof rawCode === 'string' ? rawCode : undefined
|
|
10
|
+
console.error(
|
|
11
|
+
JSON.stringify(code ? { error: message, code } : { error: message }),
|
|
12
|
+
)
|
|
8
13
|
process.exit(1)
|
|
9
14
|
}
|