@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 +5 -5
- package/server/src/index.errors.test.ts +97 -0
- package/server/src/resolvers.ts +26 -11
- package/src/commands/project.ts +1 -1
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sqaoss/flowy",
|
|
3
|
-
"version": "1.2.
|
|
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
|
-
"
|
|
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
|
+
})
|
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 {
|
|
@@ -163,7 +178,7 @@ export function createResolvers(db: Db) {
|
|
|
163
178
|
},
|
|
164
179
|
) => {
|
|
165
180
|
if (args.query.trim().length < 3) {
|
|
166
|
-
throw
|
|
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
|
|
211
|
+
if (!args.title.trim()) throw validationError('Title is required')
|
|
197
212
|
if (args.description != null && !args.description.trim()) {
|
|
198
|
-
throw
|
|
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
|
|
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
|
|
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
|
|
258
|
+
if (!existing) throw notFoundError(`Node ${args.id} not found`)
|
|
244
259
|
if (existing.status !== 'pending_review') {
|
|
245
|
-
throw
|
|
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
|
|
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
|
|
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
|
|
293
|
+
throw notFoundError(`Target node ${args.targetId} not found`)
|
|
279
294
|
}
|
|
280
295
|
if (args.relation === 'blocks' && args.sourceId === args.targetId) {
|
|
281
|
-
throw
|
|
296
|
+
throw validationError('A node cannot block itself')
|
|
282
297
|
}
|
|
283
298
|
const now = new Date().toISOString()
|
|
284
299
|
db.raw.run(
|
package/src/commands/project.ts
CHANGED
|
@@ -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) {
|
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
|
}
|