@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.
@@ -40,6 +40,36 @@ export interface NodeGql {
40
40
  updatedAt: string
41
41
  }
42
42
 
43
+ /**
44
+ * An audit-log entry, shaped to match the SaaS `auditLog` GraphQL field
45
+ * (id, nodeId, action, field, oldValue, newValue, snapshot, changedBy,
46
+ * createdAt) so `flowy history` output is consistent across backends.
47
+ * `snapshot` is a JSON string (or null), mirroring SaaS.
48
+ */
49
+ export interface AuditEntryGql {
50
+ id: string
51
+ nodeId: string | null
52
+ action: string
53
+ field: string | null
54
+ oldValue: string | null
55
+ newValue: string | null
56
+ snapshot: string | null
57
+ changedBy: string
58
+ createdAt: string
59
+ }
60
+
61
+ interface AuditRow {
62
+ id: string
63
+ node_id: string | null
64
+ action: string
65
+ field: string | null
66
+ old_value: string | null
67
+ new_value: string | null
68
+ snapshot: string | null
69
+ changed_by: string
70
+ created_at: string
71
+ }
72
+
43
73
  /**
44
74
  * A node returned from a subtree traversal, annotated with the edge that pulled
45
75
  * it in: `parentId` (the node it descends from), `depth` (1 for the root's
@@ -51,6 +81,18 @@ export interface SubtreeNodeGql extends NodeGql {
51
81
  relation: string
52
82
  }
53
83
 
84
+ /**
85
+ * Search results plus truncation metadata (F32). `nodes` is capped at `limit`;
86
+ * `total` is the unbounded match count and `truncated` is true when `total`
87
+ * exceeds the returned page — letting the CLI show a clear "results truncated"
88
+ * marker instead of silently dropping matches at the default cap.
89
+ */
90
+ export interface SearchResultGql {
91
+ nodes: NodeGql[]
92
+ truncated: boolean
93
+ total: number
94
+ }
95
+
54
96
  interface SubtreeRow extends NodeRow {
55
97
  parent_id: string
56
98
  depth: number
@@ -84,6 +126,36 @@ function assertValidStatus(status: string): void {
84
126
  }
85
127
  }
86
128
 
129
+ /**
130
+ * Allowed status transitions for the OPT-IN lifecycle enforcement
131
+ * (`FLOWY_ENFORCE_STATUS_LIFECYCLE`). The canonical forward flow is
132
+ * `draft → pending_review → approved → in_progress → done`; `blocked` and
133
+ * `cancelled` are reachable from active states and recoverable back into the
134
+ * flow. A same-status update is always a no-op and never checked here. When
135
+ * enforcement is OFF (the default) this map is unused and any vocabulary-valid
136
+ * status is accepted, preserving the prior behaviour.
137
+ */
138
+ const ALLOWED_TRANSITIONS: Record<string, Set<string>> = {
139
+ draft: new Set(['pending_review', 'cancelled']),
140
+ pending_review: new Set(['approved', 'draft', 'cancelled']),
141
+ approved: new Set(['in_progress', 'pending_review', 'cancelled']),
142
+ in_progress: new Set(['done', 'blocked', 'cancelled']),
143
+ done: new Set(['in_progress']),
144
+ blocked: new Set(['in_progress', 'cancelled']),
145
+ cancelled: new Set(['draft']),
146
+ }
147
+
148
+ function assertValidTransition(from: string, to: string): void {
149
+ if (from === to) return
150
+ if (!ALLOWED_TRANSITIONS[from]?.has(to)) {
151
+ throw validationError(
152
+ `Illegal status transition: ${from} → ${to}. Allowed from "${from}": ${
153
+ [...(ALLOWED_TRANSITIONS[from] ?? [])].join(', ') || '(none)'
154
+ }`,
155
+ )
156
+ }
157
+ }
158
+
87
159
  /**
88
160
  * Validate that `metadata` is a JSON string and return its canonical form.
89
161
  * Metadata is stored as a JSON string (the column and the GraphQL field are
@@ -105,6 +177,57 @@ function generateId(type: string): string {
105
177
  return `${prefix}_${nanoid(12)}`
106
178
  }
107
179
 
180
+ // The bundled local server is single-tenant and unauthenticated, so every
181
+ // audit entry is attributed to a single constant actor. SaaS uses the user id.
182
+ const LOCAL_ACTOR = 'local'
183
+
184
+ interface AuditInput {
185
+ nodeId: string | null
186
+ action: string
187
+ field?: string | null
188
+ oldValue?: string | null
189
+ newValue?: string | null
190
+ snapshot?: Record<string, unknown> | null
191
+ }
192
+
193
+ /**
194
+ * Write one audit_log row. Call inside the same transaction as the mutation so
195
+ * the change and its audit trail commit (or roll back) together. SQLite's
196
+ * `datetime('now')` resolves to whole seconds, which is too coarse to order
197
+ * multiple entries written in the same call; we pass an explicit ISO timestamp
198
+ * with sub-second precision so `ORDER BY created_at DESC` is stable.
199
+ */
200
+ function insertAudit(db: Db, input: AuditInput): void {
201
+ db.raw.run(
202
+ 'INSERT INTO audit_log (id, node_id, action, field, old_value, new_value, snapshot, changed_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
203
+ [
204
+ `audit_${nanoid(12)}`,
205
+ input.nodeId,
206
+ input.action,
207
+ input.field ?? null,
208
+ input.oldValue ?? null,
209
+ input.newValue ?? null,
210
+ input.snapshot != null ? JSON.stringify(input.snapshot) : null,
211
+ LOCAL_ACTOR,
212
+ new Date().toISOString(),
213
+ ],
214
+ )
215
+ }
216
+
217
+ function rowToAudit(row: AuditRow): AuditEntryGql {
218
+ return {
219
+ id: row.id,
220
+ nodeId: row.node_id,
221
+ action: row.action,
222
+ field: row.field,
223
+ oldValue: row.old_value,
224
+ newValue: row.new_value,
225
+ snapshot: row.snapshot,
226
+ changedBy: row.changed_by,
227
+ createdAt: row.created_at,
228
+ }
229
+ }
230
+
108
231
  function rowToNode(row: NodeRow): NodeGql {
109
232
  return {
110
233
  id: row.id,
@@ -135,7 +258,18 @@ function prefixedCols() {
135
258
  .join(', ')
136
259
  }
137
260
 
138
- export function createResolvers(db: Db) {
261
+ export interface ResolverOptions {
262
+ /**
263
+ * Enforce the canonical status lifecycle on `updateNode` status changes.
264
+ * OFF by default — when false (the default) any vocabulary-valid status is
265
+ * accepted, matching pre-F32 behaviour. Wired from
266
+ * `FLOWY_ENFORCE_STATUS_LIFECYCLE` in `index.ts`.
267
+ */
268
+ enforceStatusLifecycle?: boolean
269
+ }
270
+
271
+ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
272
+ const enforceStatusLifecycle = opts.enforceStatusLifecycle ?? false
139
273
  return {
140
274
  Query: {
141
275
  node: (_: unknown, args: { id: string }) => {
@@ -305,7 +439,7 @@ export function createResolvers(db: Db) {
305
439
  status?: string
306
440
  limit?: number
307
441
  },
308
- ) => {
442
+ ): SearchResultGql => {
309
443
  if (args.query.trim().length < 3) {
310
444
  throw validationError('Search query must be at least 3 characters')
311
445
  }
@@ -322,13 +456,41 @@ export function createResolvers(db: Db) {
322
456
  conditions.push('status = ?')
323
457
  params.push(args.status)
324
458
  }
459
+ const where = conditions.join(' AND ')
460
+ const limit = args.limit ?? 50
461
+ // Fetch one extra row so we can tell "exactly at the cap" from
462
+ // "more matches exist" without a second COUNT for the common case.
463
+ const rows = db.raw
464
+ .query(`SELECT ${NODE_COLS} FROM nodes WHERE ${where} LIMIT ?`)
465
+ .all(...params, limit + 1) as NodeRow[]
466
+ const truncated = rows.length > limit
467
+ const page = truncated ? rows.slice(0, limit) : rows
468
+ const total = truncated
469
+ ? (
470
+ db.raw
471
+ .query(`SELECT COUNT(*) AS c FROM nodes WHERE ${where}`)
472
+ .get(...params) as { c: number }
473
+ ).c
474
+ : page.length
475
+ return { nodes: selectNodes(page), truncated, total }
476
+ },
477
+
478
+ // Audit history for a node, newest first. Shaped to match the SaaS
479
+ // `auditLog` field so `flowy history` output is consistent across
480
+ // backends. `delete` entries set node_id to null (the node is gone), so
481
+ // they are not returned here — the deletion is still recorded in the
482
+ // table with the pre-delete snapshot.
483
+ auditLog: (
484
+ _: unknown,
485
+ args: { nodeId: string; limit?: number },
486
+ ): AuditEntryGql[] => {
325
487
  const limit = args.limit ?? 50
326
488
  const rows = db.raw
327
489
  .query(
328
- `SELECT ${NODE_COLS} FROM nodes WHERE ${conditions.join(' AND ')} LIMIT ?`,
490
+ 'SELECT * FROM audit_log WHERE node_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?',
329
491
  )
330
- .all(...params, limit) as NodeRow[]
331
- return selectNodes(rows)
492
+ .all(args.nodeId, limit) as AuditRow[]
493
+ return rows.map(rowToAudit)
332
494
  },
333
495
  },
334
496
 
@@ -354,11 +516,7 @@ export function createResolvers(db: Db) {
354
516
  const now = new Date().toISOString()
355
517
  const description = args.description ?? null
356
518
  const status = args.status ?? 'draft'
357
- db.raw.run(
358
- 'INSERT INTO nodes (id, type, title, description, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
359
- [id, args.type, args.title, description, status, metadata, now, now],
360
- )
361
- return {
519
+ const node: NodeGql = {
362
520
  id,
363
521
  type: args.type,
364
522
  title: args.title,
@@ -368,6 +526,27 @@ export function createResolvers(db: Db) {
368
526
  createdAt: now,
369
527
  updatedAt: now,
370
528
  }
529
+ db.raw.transaction(() => {
530
+ db.raw.run(
531
+ 'INSERT INTO nodes (id, type, title, description, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
532
+ [
533
+ id,
534
+ args.type,
535
+ args.title,
536
+ description,
537
+ status,
538
+ metadata,
539
+ now,
540
+ now,
541
+ ],
542
+ )
543
+ insertAudit(db, {
544
+ nodeId: id,
545
+ action: 'create',
546
+ snapshot: node as unknown as Record<string, unknown>,
547
+ })
548
+ })()
549
+ return node
371
550
  },
372
551
 
373
552
  updateNode: (
@@ -391,7 +570,12 @@ export function createResolvers(db: Db) {
391
570
  if (args.description != null && !args.description.trim()) {
392
571
  throw validationError('Description cannot be empty')
393
572
  }
394
- if (args.status != null) assertValidStatus(args.status)
573
+ if (args.status != null) {
574
+ assertValidStatus(args.status)
575
+ if (enforceStatusLifecycle) {
576
+ assertValidTransition(existing.status, args.status)
577
+ }
578
+ }
395
579
 
396
580
  const next: NodeRow = {
397
581
  ...existing,
@@ -406,25 +590,77 @@ export function createResolvers(db: Db) {
406
590
  ? normalizeMetadata(args.metadata)
407
591
  : existing.metadata,
408
592
  }
593
+ // Field-level diffs, mirroring SaaS: title/description/metadata changes
594
+ // are 'update' entries; a status change is a 'status_change' entry.
595
+ const diffs: Array<{
596
+ field: string
597
+ action: string
598
+ oldValue: string | null
599
+ newValue: string | null
600
+ }> = []
601
+ if (next.title !== existing.title) {
602
+ diffs.push({
603
+ field: 'title',
604
+ action: 'update',
605
+ oldValue: existing.title,
606
+ newValue: next.title,
607
+ })
608
+ }
609
+ if (next.description !== existing.description) {
610
+ diffs.push({
611
+ field: 'description',
612
+ action: 'update',
613
+ oldValue: existing.description,
614
+ newValue: next.description,
615
+ })
616
+ }
617
+ if (next.status !== existing.status) {
618
+ diffs.push({
619
+ field: 'status',
620
+ action: 'status_change',
621
+ oldValue: existing.status,
622
+ newValue: next.status,
623
+ })
624
+ }
625
+ if (next.metadata !== existing.metadata) {
626
+ diffs.push({
627
+ field: 'metadata',
628
+ action: 'update',
629
+ oldValue: existing.metadata,
630
+ newValue: next.metadata,
631
+ })
632
+ }
633
+
409
634
  const now = new Date().toISOString()
410
- db.raw.run(
411
- 'UPDATE nodes SET title = ?, description = ?, status = ?, metadata = ?, updated_at = ? WHERE id = ?',
412
- [
413
- next.title,
414
- next.description,
415
- next.status,
416
- next.metadata,
417
- now,
418
- args.id,
419
- ],
420
- )
635
+ db.raw.transaction(() => {
636
+ db.raw.run(
637
+ 'UPDATE nodes SET title = ?, description = ?, status = ?, metadata = ?, updated_at = ? WHERE id = ?',
638
+ [
639
+ next.title,
640
+ next.description,
641
+ next.status,
642
+ next.metadata,
643
+ now,
644
+ args.id,
645
+ ],
646
+ )
647
+ for (const d of diffs) {
648
+ insertAudit(db, {
649
+ nodeId: args.id,
650
+ action: d.action,
651
+ field: d.field,
652
+ oldValue: d.oldValue,
653
+ newValue: d.newValue,
654
+ })
655
+ }
656
+ })()
421
657
  return rowToNode({ ...next, updated_at: now })
422
658
  },
423
659
 
424
660
  deleteNode: (_: unknown, args: { id: string }): boolean => {
425
661
  const existing = db.raw
426
- .query('SELECT id FROM nodes WHERE id = ?')
427
- .get(args.id) as { id: string } | null
662
+ .query(`SELECT ${NODE_COLS} FROM nodes WHERE id = ?`)
663
+ .get(args.id) as NodeRow | null
428
664
  if (!existing) throw notFoundError(`Node ${args.id} not found`)
429
665
 
430
666
  // The hierarchy is client -> project -> feature -> task via `part_of`
@@ -442,6 +678,15 @@ export function createResolvers(db: Db) {
442
678
  }
443
679
 
444
680
  db.raw.transaction(() => {
681
+ // Record the delete with the pre-delete snapshot BEFORE removing the
682
+ // node. node_id is null (the node is gone) to match SaaS. The FK's
683
+ // ON DELETE SET NULL also nulls node_id on the node's prior audit
684
+ // rows when the node row below is deleted.
685
+ insertAudit(db, {
686
+ nodeId: null,
687
+ action: 'delete',
688
+ snapshot: rowToNode(existing) as unknown as Record<string, unknown>,
689
+ })
445
690
  db.raw.run('DELETE FROM edges WHERE source_id = ? OR target_id = ?', [
446
691
  args.id,
447
692
  args.id,
@@ -462,11 +707,19 @@ export function createResolvers(db: Db) {
462
707
  )
463
708
  }
464
709
  const now = new Date().toISOString()
465
- db.raw.run('UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?', [
466
- 'approved',
467
- now,
468
- args.id,
469
- ])
710
+ db.raw.transaction(() => {
711
+ db.raw.run(
712
+ 'UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?',
713
+ ['approved', now, args.id],
714
+ )
715
+ insertAudit(db, {
716
+ nodeId: args.id,
717
+ action: 'approve',
718
+ field: 'status',
719
+ oldValue: 'pending_review',
720
+ newValue: 'approved',
721
+ })
722
+ })()
470
723
  return rowToNode({ ...existing, status: 'approved', updated_at: now })
471
724
  },
472
725
 
@@ -496,10 +749,20 @@ export function createResolvers(db: Db) {
496
749
  throw validationError('A node cannot block itself')
497
750
  }
498
751
  const now = new Date().toISOString()
499
- db.raw.run(
500
- 'INSERT INTO edges (source_id, target_id, relation, created_at) VALUES (?, ?, ?, ?)',
501
- [args.sourceId, args.targetId, args.relation, now],
502
- )
752
+ db.raw.transaction(() => {
753
+ db.raw.run(
754
+ 'INSERT INTO edges (source_id, target_id, relation, created_at) VALUES (?, ?, ?, ?)',
755
+ [args.sourceId, args.targetId, args.relation, now],
756
+ )
757
+ // Record the edge against its source node so `auditLog(sourceId)`
758
+ // surfaces it. field = relation; newValue = the target it now links.
759
+ insertAudit(db, {
760
+ nodeId: args.sourceId,
761
+ action: 'create_edge',
762
+ field: args.relation,
763
+ newValue: args.targetId,
764
+ })
765
+ })()
503
766
  return {
504
767
  sourceId: args.sourceId,
505
768
  targetId: args.targetId,
@@ -512,11 +775,25 @@ export function createResolvers(db: Db) {
512
775
  _: unknown,
513
776
  args: { sourceId: string; targetId: string; relation: string },
514
777
  ) => {
515
- const result = db.raw.run(
516
- 'DELETE FROM edges WHERE source_id = ? AND target_id = ? AND relation = ?',
517
- [args.sourceId, args.targetId, args.relation],
518
- )
519
- return result.changes > 0
778
+ let changed = false
779
+ db.raw.transaction(() => {
780
+ const result = db.raw.run(
781
+ 'DELETE FROM edges WHERE source_id = ? AND target_id = ? AND relation = ?',
782
+ [args.sourceId, args.targetId, args.relation],
783
+ )
784
+ changed = result.changes > 0
785
+ // Only audit an edge that actually existed. oldValue = the target the
786
+ // edge linked to before removal.
787
+ if (changed) {
788
+ insertAudit(db, {
789
+ nodeId: args.sourceId,
790
+ action: 'remove_edge',
791
+ field: args.relation,
792
+ oldValue: args.targetId,
793
+ })
794
+ }
795
+ })()
796
+ return changed
520
797
  },
521
798
  },
522
799
  }
@@ -19,6 +19,21 @@ export const typeDefs = /* GraphQL */ `
19
19
  createdAt: String!
20
20
  }
21
21
 
22
+ # An audit-log entry. Shaped to match the SaaS \`auditLog\` field so
23
+ # \`flowy history\` output is consistent across backends. \`snapshot\` is a
24
+ # JSON-encoded string (or null).
25
+ type AuditEntry {
26
+ id: String!
27
+ nodeId: String
28
+ action: String!
29
+ field: String
30
+ oldValue: String
31
+ newValue: String
32
+ snapshot: String
33
+ changedBy: String!
34
+ createdAt: String!
35
+ }
36
+
22
37
  # A node returned from a subtree traversal, annotated with how it was reached:
23
38
  # the parent it descends from (parentId), how many edges down the root it sits
24
39
  # (depth, root's direct children are depth 1), and the relation of the edge
@@ -37,6 +52,16 @@ export const typeDefs = /* GraphQL */ `
37
52
  relation: String!
38
53
  }
39
54
 
55
+ # Search results plus truncation metadata (F32). \`nodes\` is the page capped
56
+ # at the requested \`limit\`; \`total\` is the unbounded match count and
57
+ # \`truncated\` is true when more matches exist than were returned, so the CLI
58
+ # can surface a "results truncated" marker instead of silently dropping rows.
59
+ type SearchResult {
60
+ nodes: [Node!]!
61
+ truncated: Boolean!
62
+ total: Int!
63
+ }
64
+
40
65
  type Query {
41
66
  node(id: String!): Node
42
67
  nodes(type: String): [Node!]!
@@ -44,7 +69,13 @@ export const typeDefs = /* GraphQL */ `
44
69
  subtree(nodeId: String!, relation: String, maxDepth: Int): [SubtreeNode!]!
45
70
  edges(nodeId: String!, relation: String!, direction: String): [Node!]!
46
71
  readyTasks(projectId: String): [Node!]!
47
- search(query: String!, type: String, status: String, limit: Int): [Node!]!
72
+ search(
73
+ query: String!
74
+ type: String
75
+ status: String
76
+ limit: Int
77
+ ): SearchResult!
78
+ auditLog(nodeId: String!, limit: Int): [AuditEntry!]!
48
79
  }
49
80
 
50
81
  type Mutation {
@@ -154,6 +154,9 @@ flowy status <id> done
154
154
  flowy search "query" --type task --status draft --limit 10
155
155
  flowy tree <id> --depth 3 # show subtree from any entity
156
156
  ```
157
+ `search` prints `{ nodes, truncated, total }`. When `truncated` is `true` the
158
+ results were capped at `--limit` (default 50) and `total` more matches exist —
159
+ raise `--limit` to see them.
157
160
 
158
161
  ### Import and Export
159
162
  ```bash
@@ -0,0 +1,99 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ describe('history command', () => {
4
+ test('exports a flat command with id argument and limit option', async () => {
5
+ const { historyCommand } = await import('./history.ts')
6
+ expect(historyCommand.name()).toBe('history')
7
+ expect(historyCommand.commands).toHaveLength(0)
8
+ const limitOpt = historyCommand.options.find((o) => o.long === '--limit')
9
+ expect(limitOpt).toBeDefined()
10
+ })
11
+
12
+ describe('action', () => {
13
+ beforeEach(() => {
14
+ vi.resetModules()
15
+ })
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks()
19
+ vi.unstubAllEnvs()
20
+ })
21
+
22
+ test('sends the AUDIT_LOG op with the node id and prints results', async () => {
23
+ const calls: Array<{ query: string; variables: unknown }> = []
24
+ vi.doMock('../util/client.ts', () => ({
25
+ graphql: vi.fn(async (query: string, variables: unknown) => {
26
+ calls.push({ query, variables })
27
+ return {
28
+ auditLog: [
29
+ {
30
+ id: 'audit_1',
31
+ action: 'create',
32
+ field: null,
33
+ oldValue: null,
34
+ newValue: null,
35
+ snapshot: '{"id":"task_1"}',
36
+ changedBy: 'local',
37
+ createdAt: '2026-06-13T00:00:00.000Z',
38
+ },
39
+ ],
40
+ }
41
+ }),
42
+ }))
43
+ const outputs: unknown[] = []
44
+ vi.doMock('../util/format.ts', () => ({
45
+ output: vi.fn((data: unknown) => outputs.push(data)),
46
+ outputError: vi.fn(),
47
+ }))
48
+
49
+ const { AUDIT_LOG } = await import('../util/operations.ts')
50
+ const { historyCommand } = await import('./history.ts')
51
+ await historyCommand.parseAsync(['task_1'], { from: 'user' })
52
+
53
+ expect(calls).toHaveLength(1)
54
+ expect(calls[0]!.query).toBe(AUDIT_LOG)
55
+ expect(calls[0]!.variables).toMatchObject({ nodeId: 'task_1' })
56
+
57
+ const rendered = outputs[0] as Array<Record<string, unknown>>
58
+ expect(rendered[0]).toMatchObject({ id: 'audit_1', action: 'create' })
59
+ })
60
+
61
+ test('passes --limit through to the op', async () => {
62
+ const calls: Array<{ variables: unknown }> = []
63
+ vi.doMock('../util/client.ts', () => ({
64
+ graphql: vi.fn(async (_query: string, variables: unknown) => {
65
+ calls.push({ variables })
66
+ return { auditLog: [] }
67
+ }),
68
+ }))
69
+ vi.doMock('../util/format.ts', () => ({
70
+ output: vi.fn(),
71
+ outputError: vi.fn(),
72
+ }))
73
+
74
+ const { historyCommand } = await import('./history.ts')
75
+ await historyCommand.parseAsync(['task_1', '--limit', '5'], {
76
+ from: 'user',
77
+ })
78
+
79
+ expect(calls[0]!.variables).toMatchObject({ nodeId: 'task_1', limit: 5 })
80
+ })
81
+
82
+ test('reports errors via outputError', async () => {
83
+ vi.doMock('../util/client.ts', () => ({
84
+ graphql: vi.fn(async () => {
85
+ throw new Error('boom')
86
+ }),
87
+ }))
88
+ const errors: unknown[] = []
89
+ vi.doMock('../util/format.ts', () => ({
90
+ output: vi.fn(),
91
+ outputError: vi.fn((e: unknown) => errors.push(e)),
92
+ }))
93
+
94
+ const { historyCommand } = await import('./history.ts')
95
+ await historyCommand.parseAsync(['task_1'], { from: 'user' })
96
+ expect(errors).toHaveLength(1)
97
+ })
98
+ })
99
+ })
@@ -0,0 +1,20 @@
1
+ import { Command } from 'commander'
2
+ import { graphql } from '../util/client.ts'
3
+ import { output, outputError } from '../util/format.ts'
4
+ import { AUDIT_LOG } from '../util/operations.ts'
5
+
6
+ export const historyCommand = new Command('history')
7
+ .description('Show the audit history of a node (newest first)')
8
+ .argument('<id>', 'Node ID')
9
+ .option('--limit <n>', 'Limit results', '50')
10
+ .action(async (id: string, opts) => {
11
+ try {
12
+ const data = await graphql<{ auditLog: unknown[] }>(AUDIT_LOG, {
13
+ nodeId: id,
14
+ limit: opts.limit ? Number.parseInt(opts.limit, 10) : undefined,
15
+ })
16
+ output(data.auditLog)
17
+ } catch (error) {
18
+ outputError(error)
19
+ }
20
+ })