@sqaoss/flowy 1.9.0 → 1.11.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/README.md +3 -1
- package/package.json +5 -2
- package/server/src/contract.test.ts +53 -7
- package/server/src/index.errors.test.ts +6 -3
- package/server/src/index.ts +10 -1
- package/server/src/migrations.test.ts +61 -0
- package/server/src/migrations.ts +27 -0
- package/server/src/resolvers.test.ts +355 -16
- package/server/src/resolvers.ts +315 -38
- package/server/src/schema.ts +32 -1
- package/skills/using-flowy/SKILL.md +3 -0
- package/src/commands/history.test.ts +99 -0
- package/src/commands/history.ts +20 -0
- package/src/commands/search.test.ts +90 -0
- package/src/commands/search.ts +19 -3
- package/src/commands/status.test.ts +71 -0
- package/src/commands/status.ts +18 -4
- package/src/index.test.ts +16 -0
- package/src/index.ts +2 -0
- package/src/util/operations.test.ts +1 -0
- package/src/util/operations.ts +26 -3
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
5
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
6
|
+
let stderr: ReturnType<typeof vi.spyOn>
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockOutput = vi.fn()
|
|
10
|
+
mockOutputError = vi.fn()
|
|
11
|
+
mockGraphql = vi.fn()
|
|
12
|
+
stderr = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
13
|
+
|
|
14
|
+
vi.doMock('../util/format.ts', () => ({
|
|
15
|
+
output: mockOutput,
|
|
16
|
+
outputError: mockOutputError,
|
|
17
|
+
}))
|
|
18
|
+
vi.doMock('../util/client.ts', () => ({
|
|
19
|
+
graphql: mockGraphql,
|
|
20
|
+
}))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.resetModules()
|
|
25
|
+
vi.restoreAllMocks()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('search command', () => {
|
|
29
|
+
test('sends the SearchResult envelope query and outputs nodes + meta', async () => {
|
|
30
|
+
mockGraphql.mockResolvedValue({
|
|
31
|
+
search: {
|
|
32
|
+
nodes: [{ id: 'proj_1', title: 'Auth' }],
|
|
33
|
+
truncated: false,
|
|
34
|
+
total: 1,
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const { searchCommand } = await import('./search.ts')
|
|
39
|
+
await searchCommand.parseAsync(['Auth'], { from: 'user' })
|
|
40
|
+
|
|
41
|
+
const query = mockGraphql.mock.calls[0]?.[0] as string
|
|
42
|
+
expect(query).toContain('nodes')
|
|
43
|
+
expect(query).toContain('truncated')
|
|
44
|
+
expect(query).toContain('total')
|
|
45
|
+
|
|
46
|
+
expect(mockOutput).toHaveBeenCalledWith({
|
|
47
|
+
nodes: [{ id: 'proj_1', title: 'Auth' }],
|
|
48
|
+
truncated: false,
|
|
49
|
+
total: 1,
|
|
50
|
+
})
|
|
51
|
+
// no truncation warning when not truncated
|
|
52
|
+
expect(stderr).not.toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('renders a clear truncation marker when results are capped', async () => {
|
|
56
|
+
mockGraphql.mockResolvedValue({
|
|
57
|
+
search: {
|
|
58
|
+
nodes: new Array(50).fill({ id: 'x' }),
|
|
59
|
+
truncated: true,
|
|
60
|
+
total: 137,
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const { searchCommand } = await import('./search.ts')
|
|
65
|
+
await searchCommand.parseAsync(['Task'], { from: 'user' })
|
|
66
|
+
|
|
67
|
+
const outputArg = mockOutput.mock.calls[0]?.[0] as {
|
|
68
|
+
truncated: boolean
|
|
69
|
+
total: number
|
|
70
|
+
}
|
|
71
|
+
expect(outputArg.truncated).toBe(true)
|
|
72
|
+
expect(outputArg.total).toBe(137)
|
|
73
|
+
|
|
74
|
+
expect(stderr).toHaveBeenCalledOnce()
|
|
75
|
+
const warning = stderr.mock.calls[0]?.[0] as string
|
|
76
|
+
expect(warning).toMatch(/truncated/i)
|
|
77
|
+
expect(warning).toContain('50')
|
|
78
|
+
expect(warning).toContain('137')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('outputs error when the query fails', async () => {
|
|
82
|
+
mockGraphql.mockRejectedValue(new Error('boom'))
|
|
83
|
+
const { searchCommand } = await import('./search.ts')
|
|
84
|
+
await searchCommand.parseAsync(['Auth'], { from: 'user' })
|
|
85
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
86
|
+
expect.objectContaining({ message: 'boom' }),
|
|
87
|
+
)
|
|
88
|
+
expect(mockOutput).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
})
|
package/src/commands/search.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { graphql } from '../util/client.ts'
|
|
|
3
3
|
import { output, outputError } from '../util/format.ts'
|
|
4
4
|
import { SEARCH } from '../util/operations.ts'
|
|
5
5
|
|
|
6
|
+
interface SearchResult {
|
|
7
|
+
nodes: unknown[]
|
|
8
|
+
truncated: boolean
|
|
9
|
+
total: number
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export const searchCommand = new Command('search')
|
|
7
13
|
.description('Search nodes by text')
|
|
8
14
|
.argument('<query>', 'Search query')
|
|
@@ -11,13 +17,23 @@ export const searchCommand = new Command('search')
|
|
|
11
17
|
.option('--limit <n>', 'Limit results', '50')
|
|
12
18
|
.action(async (query: string, opts) => {
|
|
13
19
|
try {
|
|
14
|
-
const
|
|
20
|
+
const limit = opts.limit ? Number.parseInt(opts.limit, 10) : undefined
|
|
21
|
+
const data = await graphql<{ search: SearchResult }>(SEARCH, {
|
|
15
22
|
query,
|
|
16
23
|
type: opts.type,
|
|
17
24
|
status: opts.status,
|
|
18
|
-
limit
|
|
25
|
+
limit,
|
|
19
26
|
})
|
|
20
|
-
|
|
27
|
+
const { nodes, truncated, total } = data.search
|
|
28
|
+
// The truncation marker rides in the JSON envelope so callers can detect
|
|
29
|
+
// it programmatically; when capped we also warn on stderr so it is not
|
|
30
|
+
// silently lost in a human-read terminal.
|
|
31
|
+
output({ nodes, truncated, total })
|
|
32
|
+
if (truncated) {
|
|
33
|
+
console.error(
|
|
34
|
+
`Results truncated: showing ${nodes.length} of ${total} matches. Raise --limit to see more.`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
21
37
|
} catch (error) {
|
|
22
38
|
outputError(error)
|
|
23
39
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
5
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockOutput = vi.fn()
|
|
9
|
+
mockOutputError = vi.fn()
|
|
10
|
+
mockGraphql = vi.fn().mockResolvedValue({
|
|
11
|
+
updateNode: { id: 'task_1', type: 'task', title: 'T', status: 'done' },
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
vi.doMock('../util/format.ts', () => ({
|
|
15
|
+
output: mockOutput,
|
|
16
|
+
outputError: mockOutputError,
|
|
17
|
+
}))
|
|
18
|
+
vi.doMock('../util/client.ts', () => ({
|
|
19
|
+
graphql: mockGraphql,
|
|
20
|
+
}))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.resetModules()
|
|
25
|
+
vi.restoreAllMocks()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('status command', () => {
|
|
29
|
+
test('accepts a valid status and sends the update', async () => {
|
|
30
|
+
const { statusCommand } = await import('./status.ts')
|
|
31
|
+
await statusCommand.parseAsync(['task_1', 'in_progress'], { from: 'user' })
|
|
32
|
+
|
|
33
|
+
expect(mockGraphql).toHaveBeenCalledOnce()
|
|
34
|
+
expect(mockGraphql.mock.calls[0]?.[1]).toEqual({
|
|
35
|
+
id: 'task_1',
|
|
36
|
+
status: 'in_progress',
|
|
37
|
+
})
|
|
38
|
+
expect(mockOutput).toHaveBeenCalledOnce()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('rejects an invalid status client-side (.choices) without a request', async () => {
|
|
42
|
+
const { statusCommand } = await import('./status.ts')
|
|
43
|
+
// Commander throws on an invalid choice; configure it not to exit the
|
|
44
|
+
// process so the test can assert.
|
|
45
|
+
statusCommand.exitOverride()
|
|
46
|
+
statusCommand.configureOutput({ writeErr: () => {}, writeOut: () => {} })
|
|
47
|
+
|
|
48
|
+
await expect(
|
|
49
|
+
statusCommand.parseAsync(['task_1', 'bogus'], { from: 'user' }),
|
|
50
|
+
).rejects.toThrow(/Allowed choices|bogus/i)
|
|
51
|
+
|
|
52
|
+
expect(mockGraphql).not.toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('exposes the full canonical status vocabulary as choices', async () => {
|
|
56
|
+
const { statusCommand, STATUS_CHOICES } = await import('./status.ts')
|
|
57
|
+
expect(STATUS_CHOICES).toEqual([
|
|
58
|
+
'draft',
|
|
59
|
+
'pending_review',
|
|
60
|
+
'approved',
|
|
61
|
+
'in_progress',
|
|
62
|
+
'done',
|
|
63
|
+
'blocked',
|
|
64
|
+
'cancelled',
|
|
65
|
+
])
|
|
66
|
+
const statusArg = statusCommand.registeredArguments.find(
|
|
67
|
+
(a) => a.name() === 'status',
|
|
68
|
+
)
|
|
69
|
+
expect(statusArg?.argChoices).toEqual([...STATUS_CHOICES])
|
|
70
|
+
})
|
|
71
|
+
})
|
package/src/commands/status.ts
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
|
-
import { Command } from 'commander'
|
|
1
|
+
import { Argument, Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
3
|
import { output, outputError } from '../util/format.ts'
|
|
4
4
|
import { UPDATE_STATUS } from '../util/operations.ts'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The canonical Flowy status vocabulary, mirrored from the server's
|
|
8
|
+
* VALID_STATUSES. Used for client-side `.choices()` validation so an invalid
|
|
9
|
+
* status is rejected before a request is ever sent.
|
|
10
|
+
*/
|
|
11
|
+
export const STATUS_CHOICES = [
|
|
12
|
+
'draft',
|
|
13
|
+
'pending_review',
|
|
14
|
+
'approved',
|
|
15
|
+
'in_progress',
|
|
16
|
+
'done',
|
|
17
|
+
'blocked',
|
|
18
|
+
'cancelled',
|
|
19
|
+
] as const
|
|
20
|
+
|
|
6
21
|
export const statusCommand = new Command('status')
|
|
7
22
|
.description('Update a node status (shorthand)')
|
|
8
23
|
.argument('<id>', 'Node ID')
|
|
9
|
-
.
|
|
10
|
-
'<status>',
|
|
11
|
-
'New status (draft, pending_review, approved, in_progress, done, blocked, cancelled)',
|
|
24
|
+
.addArgument(
|
|
25
|
+
new Argument('<status>', 'New status').choices([...STATUS_CHOICES]),
|
|
12
26
|
)
|
|
13
27
|
.action(async (id: string, status: string) => {
|
|
14
28
|
try {
|
package/src/index.test.ts
CHANGED
|
@@ -30,6 +30,9 @@ vi.mock('./commands/task.ts', () => ({
|
|
|
30
30
|
vi.mock('./commands/tree.ts', () => ({
|
|
31
31
|
treeCommand: { name: () => 'tree' },
|
|
32
32
|
}))
|
|
33
|
+
vi.mock('./commands/history.ts', () => ({
|
|
34
|
+
historyCommand: { name: () => 'history' },
|
|
35
|
+
}))
|
|
33
36
|
vi.mock('./commands/whoami.ts', () => ({
|
|
34
37
|
whoamiCommand: { name: () => 'whoami' },
|
|
35
38
|
}))
|
|
@@ -96,4 +99,17 @@ describe('index.ts command registration', () => {
|
|
|
96
99
|
expect(indexSource).toContain('program.addCommand(importCommand)')
|
|
97
100
|
expect(indexSource).toContain('program.addCommand(exportCommand)')
|
|
98
101
|
})
|
|
102
|
+
|
|
103
|
+
test('registers the history command', async () => {
|
|
104
|
+
const { readFileSync } = await import('node:fs')
|
|
105
|
+
const indexSource = readFileSync(
|
|
106
|
+
new URL('./index.ts', import.meta.url).pathname,
|
|
107
|
+
'utf-8',
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(indexSource).toContain(
|
|
111
|
+
"import { historyCommand } from './commands/history.ts'",
|
|
112
|
+
)
|
|
113
|
+
expect(indexSource).toContain('program.addCommand(historyCommand)')
|
|
114
|
+
})
|
|
99
115
|
})
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { billingCommand } from './commands/billing.ts'
|
|
|
16
16
|
import { clientCommand } from './commands/client.ts'
|
|
17
17
|
import { exportCommand } from './commands/export.ts'
|
|
18
18
|
import { featureCommand } from './commands/feature.ts'
|
|
19
|
+
import { historyCommand } from './commands/history.ts'
|
|
19
20
|
import { importCommand } from './commands/import.ts'
|
|
20
21
|
import { initCommand } from './commands/init.ts'
|
|
21
22
|
import { keyCommand } from './commands/key.ts'
|
|
@@ -46,6 +47,7 @@ program.addCommand(billingCommand)
|
|
|
46
47
|
program.addCommand(keyCommand)
|
|
47
48
|
program.addCommand(searchCommand)
|
|
48
49
|
program.addCommand(treeCommand)
|
|
50
|
+
program.addCommand(historyCommand)
|
|
49
51
|
program.addCommand(whoamiCommand)
|
|
50
52
|
program.addCommand(importCommand)
|
|
51
53
|
program.addCommand(exportCommand)
|
|
@@ -81,6 +81,7 @@ describe('commands send canonical operations (no re-inlined queries)', () => {
|
|
|
81
81
|
'approve.ts': ['APPROVE_NODE'],
|
|
82
82
|
'search.ts': ['SEARCH'],
|
|
83
83
|
'tree.ts': ['SUBTREE'],
|
|
84
|
+
'history.ts': ['AUDIT_LOG'],
|
|
84
85
|
'whoami.ts': ['WHOAMI'],
|
|
85
86
|
'billing.ts': ['CREATE_CHECKOUT'],
|
|
86
87
|
'key.ts': ['ROTATE_API_KEY'],
|
package/src/util/operations.ts
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* field, or an argument the CLI uses, the contract test fails — catching drift
|
|
16
16
|
* before it ships. See `server/src/contract.test.ts` for the documented list
|
|
17
17
|
* of intentional local/SaaS divergences (SaaS-only `whoami`, `register`,
|
|
18
|
-
* `rotateApiKey`, `createCheckout
|
|
18
|
+
* `rotateApiKey`, `createCheckout`). `auditLog` was a SaaS-only divergence
|
|
19
|
+
* until P1-2/F27 ported it to the bundled local server; it is now part of the
|
|
20
|
+
* shared LOCAL_CONTRACT_OPERATIONS set.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
// --- Nodes: read --------------------------------------------------------------
|
|
@@ -114,10 +116,30 @@ export const TASK_DEPS = `query TaskDeps($id: String!) {
|
|
|
114
116
|
}
|
|
115
117
|
}`
|
|
116
118
|
|
|
117
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* search.ts — full-text search with optional type/status/limit filters.
|
|
121
|
+
* Returns a SearchResult envelope (F32): `nodes` is the page capped at `limit`,
|
|
122
|
+
* `truncated` flags that more matches exist than were returned, and `total` is
|
|
123
|
+
* the unbounded match count — so the CLI can show a truncation marker instead
|
|
124
|
+
* of silently dropping rows at the default cap.
|
|
125
|
+
*/
|
|
118
126
|
export const SEARCH = `query Search($query: String!, $type: String, $status: String, $limit: Int) {
|
|
119
127
|
search(query: $query, type: $type, status: $status, limit: $limit) {
|
|
120
|
-
id type title description status
|
|
128
|
+
nodes { id type title description status }
|
|
129
|
+
truncated
|
|
130
|
+
total
|
|
131
|
+
}
|
|
132
|
+
}`
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* history.ts — audit history for a node, newest first. Served by BOTH backends:
|
|
136
|
+
* the SaaS server (always) and, since P1-2/F27, the bundled local server. The
|
|
137
|
+
* selection set mirrors the SaaS `auditLog` query (flowy-saas
|
|
138
|
+
* `test/helpers/cli-queries.ts`) so output is identical across backends.
|
|
139
|
+
*/
|
|
140
|
+
export const AUDIT_LOG = `query AuditLog($nodeId: String!, $limit: Int) {
|
|
141
|
+
auditLog(nodeId: $nodeId, limit: $limit) {
|
|
142
|
+
id action field oldValue newValue snapshot changedBy createdAt
|
|
121
143
|
}
|
|
122
144
|
}`
|
|
123
145
|
|
|
@@ -318,6 +340,7 @@ export const LOCAL_CONTRACT_OPERATIONS = {
|
|
|
318
340
|
EXPORT_PROJECT,
|
|
319
341
|
EXPORT_DESCENDANTS,
|
|
320
342
|
EXPORT_EDGES,
|
|
343
|
+
AUDIT_LOG,
|
|
321
344
|
} as const
|
|
322
345
|
|
|
323
346
|
/**
|