@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.
@@ -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
+ })
@@ -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 data = await graphql<{ search: unknown[] }>(SEARCH, {
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: opts.limit ? Number.parseInt(opts.limit, 10) : undefined,
25
+ limit,
19
26
  })
20
- output(data.search)
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
+ })
@@ -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
- .argument(
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'],
@@ -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`, `auditLog`, `ancestors`).
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
- /** search.ts — full-text search with optional type/status/limit filters. */
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
  /**