@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.
@@ -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', () => {
@@ -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 new Error('Search query must be at least 3 characters')
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: { type: string; title: string; description?: string },
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 new Error('Title is required')
241
+ if (!args.title.trim()) throw validationError('Title is required')
197
242
  if (args.description != null && !args.description.trim()) {
198
- throw new Error('Description cannot be empty')
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, 'draft', null, now, now],
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: 'draft',
213
- metadata: null,
261
+ status,
262
+ metadata,
214
263
  createdAt: now,
215
264
  updatedAt: now,
216
265
  }
217
266
  },
218
267
 
219
- updateNode: (_: unknown, args: { id: string; status?: string }) => {
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 new Error(`Node ${args.id} not found`)
224
- const newStatus = args.status ?? existing.status
225
- if (args.status && !VALID_STATUSES.has(args.status)) {
226
- throw new Error(
227
- `Invalid status: ${args.status}. Must be one of: ${[...VALID_STATUSES].join(', ')}`,
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('UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?', [
232
- newStatus,
233
- now,
234
- args.id,
235
- ])
236
- return rowToNode({ ...existing, status: newStatus, updated_at: now })
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 new Error(`Node ${args.id} not found`)
353
+ if (!existing) throw notFoundError(`Node ${args.id} not found`)
244
354
  if (existing.status !== 'pending_review') {
245
- throw new Error(
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 new Error(
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 new Error(`Source node ${args.sourceId} not found`)
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 new Error(`Target node ${args.targetId} not found`)
388
+ throw notFoundError(`Target node ${args.targetId} not found`)
279
389
  }
280
390
  if (args.relation === 'blocks' && args.sourceId === args.targetId) {
281
- throw new Error('A node cannot block itself')
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('Mutation type does not have deleteNode or deleteEdge', () => {
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 fields = mutationType.getFields()
90
- expect(fields.deleteNode).toBeUndefined()
91
- expect(fields.deleteEdge).toBeUndefined()
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
  })
@@ -28,9 +28,22 @@ export const typeDefs = /* GraphQL */ `
28
28
  }
29
29
 
30
30
  type Mutation {
31
- createNode(type: String!, title: String!, description: String): Node!
32
- updateNode(id: String!, status: String): Node!
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
  }
@@ -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',
@@ -47,7 +47,9 @@ export async function graphql<T = unknown>(
47
47
  )
48
48
  }
49
49
 
50
- throw new Error(error?.message)
50
+ const err = new Error(error?.message) as Error & { code?: string }
51
+ if (code) err.code = code
52
+ throw err
51
53
  }
52
54
 
53
55
  return json.data as T
@@ -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
+ })
@@ -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
- console.error(JSON.stringify({ error: message }))
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
  }