@sqaoss/flowy 1.2.0 → 1.2.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,17 +54,17 @@
54
54
  "prepare": "husky"
55
55
  },
56
56
  "dependencies": {
57
- "@semantic-release/changelog": "6.0.3",
58
- "@semantic-release/git": "10.0.1",
59
- "commander": "^14.0.3",
60
- "semantic-release": "25.0.3"
57
+ "commander": "^14.0.3"
61
58
  },
62
59
  "devDependencies": {
63
60
  "@biomejs/biome": "2.4.4",
64
61
  "@commitlint/cli": "^20.4.2",
65
62
  "@commitlint/config-conventional": "^20.4.2",
63
+ "@semantic-release/changelog": "6.0.3",
64
+ "@semantic-release/git": "10.0.1",
66
65
  "husky": "^9.1.7",
67
66
  "lint-staged": "^16.3.0",
67
+ "semantic-release": "25.0.3",
68
68
  "tdd-guard-vitest": "^0.1.6",
69
69
  "typescript": "^5",
70
70
  "vitest": "^4.1.2"
@@ -0,0 +1,97 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { createServer } from './index.ts'
3
+
4
+ interface GraphQLResponse {
5
+ data?: unknown
6
+ errors?: Array<{ message: string; extensions?: { code?: string } }>
7
+ }
8
+
9
+ describe('server error masking', () => {
10
+ let instance: ReturnType<typeof createServer> | undefined
11
+
12
+ afterEach(() => {
13
+ instance?.close()
14
+ instance = undefined
15
+ })
16
+
17
+ async function gql(
18
+ query: string,
19
+ variables?: Record<string, unknown>,
20
+ ): Promise<GraphQLResponse> {
21
+ const res = await fetch(`http://localhost:${instance!.port}/graphql`, {
22
+ method: 'POST',
23
+ headers: { 'Content-Type': 'application/json' },
24
+ body: JSON.stringify({ query, variables }),
25
+ })
26
+ return (await res.json()) as GraphQLResponse
27
+ }
28
+
29
+ it('surfaces a too-short search error with real message and VALIDATION_ERROR code', async () => {
30
+ instance = createServer({ dbPath: ':memory:', port: 0 })
31
+
32
+ const json = await gql('query ($q: String!) { search(query: $q) { id } }', {
33
+ q: 'ab',
34
+ })
35
+
36
+ expect(json.errors).toBeDefined()
37
+ const error = json.errors![0]
38
+ expect(error.message).toBe('Search query must be at least 3 characters')
39
+ expect(error.message).not.toBe('Unexpected error.')
40
+ expect(error.extensions?.code).toBe('VALIDATION_ERROR')
41
+ })
42
+
43
+ it('surfaces a not-found update error with real message and NOT_FOUND code', async () => {
44
+ instance = createServer({ dbPath: ':memory:', port: 0 })
45
+
46
+ const json = await gql(
47
+ 'mutation ($id: String!) { updateNode(id: $id, status: "done") { id } }',
48
+ { id: 'nonexistent' },
49
+ )
50
+
51
+ expect(json.errors).toBeDefined()
52
+ const error = json.errors![0]
53
+ expect(error.message).toBe('Node nonexistent not found')
54
+ expect(error.message).not.toBe('Unexpected error.')
55
+ expect(error.extensions?.code).toBe('NOT_FOUND')
56
+ })
57
+
58
+ it('surfaces an invalid-status error with real message and VALIDATION_ERROR code', async () => {
59
+ instance = createServer({ dbPath: ':memory:', port: 0 })
60
+
61
+ const created = (await gql(
62
+ 'mutation { createNode(type: "task", title: "T") { id } }',
63
+ )) as { data: { createNode: { id: string } } }
64
+ const id = created.data.createNode.id
65
+
66
+ const json = await gql(
67
+ 'mutation ($id: String!) { updateNode(id: $id, status: "bogus") { id } }',
68
+ { id },
69
+ )
70
+
71
+ expect(json.errors).toBeDefined()
72
+ const error = json.errors![0]
73
+ expect(error.message).toContain('Invalid status: bogus')
74
+ expect(error.message).not.toBe('Unexpected error.')
75
+ expect(error.extensions?.code).toBe('VALIDATION_ERROR')
76
+ })
77
+
78
+ it('surfaces an approve-wrong-status error with CONFLICT code', async () => {
79
+ instance = createServer({ dbPath: ':memory:', port: 0 })
80
+
81
+ const created = (await gql(
82
+ 'mutation { createNode(type: "feature", title: "F") { id } }',
83
+ )) as { data: { createNode: { id: string } } }
84
+ const id = created.data.createNode.id
85
+
86
+ const json = await gql(
87
+ 'mutation ($id: String!) { approveNode(id: $id) { id } }',
88
+ { id },
89
+ )
90
+
91
+ expect(json.errors).toBeDefined()
92
+ const error = json.errors![0]
93
+ expect(error.message).toContain('Cannot approve node with status "draft"')
94
+ expect(error.message).not.toBe('Unexpected error.')
95
+ expect(error.extensions?.code).toBe('CONFLICT')
96
+ })
97
+ })
@@ -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 {
@@ -163,7 +178,7 @@ export function createResolvers(db: Db) {
163
178
  },
164
179
  ) => {
165
180
  if (args.query.trim().length < 3) {
166
- throw new Error('Search query must be at least 3 characters')
181
+ throw validationError('Search query must be at least 3 characters')
167
182
  }
168
183
  const escaped = args.query.replace(/[%_\\]/g, '\\$&')
169
184
  const conditions = [
@@ -193,9 +208,9 @@ export function createResolvers(db: Db) {
193
208
  _: unknown,
194
209
  args: { type: string; title: string; description?: string },
195
210
  ): NodeGql => {
196
- if (!args.title.trim()) throw new Error('Title is required')
211
+ if (!args.title.trim()) throw validationError('Title is required')
197
212
  if (args.description != null && !args.description.trim()) {
198
- throw new Error('Description cannot be empty')
213
+ throw validationError('Description cannot be empty')
199
214
  }
200
215
  const id = generateId(args.type)
201
216
  const now = new Date().toISOString()
@@ -220,10 +235,10 @@ export function createResolvers(db: Db) {
220
235
  const existing = db.raw
221
236
  .query('SELECT * FROM nodes WHERE id = ?')
222
237
  .get(args.id) as NodeRow | null
223
- if (!existing) throw new Error(`Node ${args.id} not found`)
238
+ if (!existing) throw notFoundError(`Node ${args.id} not found`)
224
239
  const newStatus = args.status ?? existing.status
225
240
  if (args.status && !VALID_STATUSES.has(args.status)) {
226
- throw new Error(
241
+ throw validationError(
227
242
  `Invalid status: ${args.status}. Must be one of: ${[...VALID_STATUSES].join(', ')}`,
228
243
  )
229
244
  }
@@ -240,9 +255,9 @@ export function createResolvers(db: Db) {
240
255
  const existing = db.raw
241
256
  .query('SELECT * FROM nodes WHERE id = ?')
242
257
  .get(args.id) as NodeRow | null
243
- if (!existing) throw new Error(`Node ${args.id} not found`)
258
+ if (!existing) throw notFoundError(`Node ${args.id} not found`)
244
259
  if (existing.status !== 'pending_review') {
245
- throw new Error(
260
+ throw conflictError(
246
261
  `Cannot approve node with status "${existing.status}", must be "pending_review"`,
247
262
  )
248
263
  }
@@ -261,7 +276,7 @@ export function createResolvers(db: Db) {
261
276
  ) => {
262
277
  const validRelations = new Set(['part_of', 'blocks'])
263
278
  if (!validRelations.has(args.relation)) {
264
- throw new Error(
279
+ throw validationError(
265
280
  `Invalid relation: ${args.relation}. Must be 'part_of' or 'blocks'`,
266
281
  )
267
282
  }
@@ -269,16 +284,16 @@ export function createResolvers(db: Db) {
269
284
  .query('SELECT id FROM nodes WHERE id = ?')
270
285
  .get(args.sourceId)
271
286
  if (!sourceExists) {
272
- throw new Error(`Source node ${args.sourceId} not found`)
287
+ throw notFoundError(`Source node ${args.sourceId} not found`)
273
288
  }
274
289
  const targetExists = db.raw
275
290
  .query('SELECT id FROM nodes WHERE id = ?')
276
291
  .get(args.targetId)
277
292
  if (!targetExists) {
278
- throw new Error(`Target node ${args.targetId} not found`)
293
+ throw notFoundError(`Target node ${args.targetId} not found`)
279
294
  }
280
295
  if (args.relation === 'blocks' && args.sourceId === args.targetId) {
281
- throw new Error('A node cannot block itself')
296
+ throw validationError('A node cannot block itself')
282
297
  }
283
298
  const now = new Date().toISOString()
284
299
  db.raw.run(
@@ -53,7 +53,7 @@ projectCommand
53
53
  )
54
54
  const project = data.nodes.find((n) => n.title === name)
55
55
  if (!project) {
56
- throw new Error(`Project "${name}" not found`)
56
+ throw new Error(`Project "${name}" not found.`)
57
57
  }
58
58
  await setProject(project.id, project.title)
59
59
  } catch (error) {
@@ -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
  }