@sqaoss/flowy 1.9.0 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,7 @@
50
50
  "check": "biome check --write .",
51
51
  "test": "vitest run",
52
52
  "test:watch": "vitest",
53
+ "test:e2e": "vitest run --config vitest.config.e2e.ts",
53
54
  "sdl": "bun scripts/print-sdl.ts",
54
55
  "typecheck": "tsc --noEmit",
55
56
  "prepare": "husky"
@@ -31,9 +31,11 @@
31
31
  * - `rotateApiKey` (key.ts) — API key lifecycle; local has no keys.
32
32
  * - `createCheckout` (billing.ts)— Polar checkout; local has no billing.
33
33
  *
34
- * Conversely, the SaaS schema carries operations the local server lacks and the
35
- * CLI does not yet call against local: `auditLog`/`getAuditLog` (P1-2 will port
36
- * this to local), `ancestors`, and `nodes(status/limit/offset)` pagination.
34
+ * Conversely, the SaaS schema carries some operations the local server still
35
+ * lacks and the CLI does not yet call against local: `ancestors` and
36
+ * `nodes(status/limit/offset)` pagination. (`auditLog` was such a divergence
37
+ * until P1-2/F27 ported it to the bundled local server; it is now part of
38
+ * LOCAL_CONTRACT_OPERATIONS and exercised below.)
37
39
  * Status vocabulary and edge relations are shared (`part_of`, `blocks`); the
38
40
  * SaaS schema additionally recognises `epic`/`depends_on`/`informs` relations
39
41
  * the bundled server does not. The test below asserts the local server rejects
@@ -364,6 +366,38 @@ describe('CLI/local-server contract (P1-1)', () => {
364
366
  )
365
367
  expect(search.search.some((n) => n.id === project.id)).toBe(true)
366
368
 
369
+ // auditLog (P1-2/F27): the task has accumulated a trail — a `create` on
370
+ // insert, a `status_change` to pending_review, an `approve`, plus the
371
+ // content `update`s. Entries come back newest-first and carry the
372
+ // SaaS-shaped fields.
373
+ const history = await ok<{
374
+ auditLog: Array<{
375
+ id: string
376
+ action: string
377
+ field: string | null
378
+ oldValue: string | null
379
+ newValue: string | null
380
+ snapshot: string | null
381
+ changedBy: string
382
+ createdAt: string
383
+ }>
384
+ }>(LOCAL_CONTRACT_OPERATIONS.AUDIT_LOG, { nodeId: task.id, limit: 50 })
385
+ const actions = history.auditLog.map((e) => e.action)
386
+ expect(actions).toContain('create')
387
+ expect(actions).toContain('status_change')
388
+ expect(actions).toContain('approve')
389
+ // changedBy default actor + ISO timestamps
390
+ expect(history.auditLog.every((e) => e.changedBy === 'local')).toBe(true)
391
+ expect(history.auditLog.every((e) => typeof e.createdAt === 'string')).toBe(
392
+ true,
393
+ )
394
+ // the status_change entry carries the field-level diff
395
+ const statusEntry = history.auditLog.find(
396
+ (e) => e.action === 'status_change',
397
+ )
398
+ expect(statusEntry?.field).toBe('status')
399
+ expect(statusEntry?.newValue).toBe('pending_review')
400
+
367
401
  // --- Edge removal + node deletion --------------------------------------
368
402
  const { removeEdge } = await ok<{ removeEdge: boolean }>(
369
403
  LOCAL_CONTRACT_OPERATIONS.UNBLOCK_TASK,
@@ -414,6 +448,7 @@ describe('CLI/local-server contract (P1-1)', () => {
414
448
  'EXPORT_PROJECT',
415
449
  'EXPORT_DESCENDANTS',
416
450
  'EXPORT_EDGES',
451
+ 'AUDIT_LOG',
417
452
  ])
418
453
  expect(new Set(Object.keys(LOCAL_CONTRACT_OPERATIONS))).toEqual(exercised)
419
454
  })
@@ -42,6 +42,67 @@ describe('runMigrations', () => {
42
42
  db.close()
43
43
  })
44
44
 
45
+ it('creates the audit_log table with SaaS-mirrored columns on a fresh DB', () => {
46
+ const db = new Database(':memory:')
47
+ runMigrations(db)
48
+ expect(tableNames(db)).toContain('audit_log')
49
+ const cols = columnNames(db, 'audit_log')
50
+ for (const c of [
51
+ 'id',
52
+ 'node_id',
53
+ 'action',
54
+ 'field',
55
+ 'old_value',
56
+ 'new_value',
57
+ 'snapshot',
58
+ 'changed_by',
59
+ 'created_at',
60
+ ]) {
61
+ expect(cols).toContain(c)
62
+ }
63
+ db.close()
64
+ })
65
+
66
+ it('adds audit_log when upgrading an existing pre-audit DB', () => {
67
+ const db = new Database(':memory:')
68
+ // Simulate a DB already at the pre-audit version (nodes + edges + metadata,
69
+ // user_version = 2) that has never seen the audit migration.
70
+ db.run(`
71
+ CREATE TABLE nodes (
72
+ id TEXT PRIMARY KEY,
73
+ type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
74
+ title TEXT NOT NULL,
75
+ description TEXT,
76
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done', 'blocked', 'cancelled')),
77
+ metadata TEXT,
78
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
79
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
80
+ )
81
+ `)
82
+ db.run(`
83
+ CREATE TABLE edges (
84
+ source_id TEXT NOT NULL REFERENCES nodes(id),
85
+ target_id TEXT NOT NULL REFERENCES nodes(id),
86
+ relation TEXT NOT NULL CHECK(relation IN ('part_of', 'blocks')),
87
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
88
+ PRIMARY KEY (source_id, target_id, relation)
89
+ )
90
+ `)
91
+ db.run("INSERT INTO nodes (id, type, title) VALUES ('task_1', 'task', 'T')")
92
+ db.run('PRAGMA user_version = 2')
93
+
94
+ expect(tableNames(db)).not.toContain('audit_log')
95
+ runMigrations(db)
96
+ expect(userVersion(db)).toBe(LATEST_VERSION)
97
+ expect(tableNames(db)).toContain('audit_log')
98
+ // existing data preserved across the upgrade
99
+ const row = db
100
+ .query<{ id: string }, []>('SELECT id FROM nodes WHERE id = ?')
101
+ .get('task_1') as { id: string }
102
+ expect(row.id).toBe('task_1')
103
+ db.close()
104
+ })
105
+
45
106
  it('is a no-op when run twice (idempotent)', () => {
46
107
  const db = new Database(':memory:')
47
108
  runMigrations(db)
@@ -116,6 +116,33 @@ const MIGRATIONS: Migration[] = [
116
116
  db.run('CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)')
117
117
  db.run('CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status)')
118
118
  },
119
+
120
+ // 2 -> 3: add the `audit_log` table (F27). Mirrors the SaaS audit_log schema
121
+ // (id, node_id, action, field, old_value, new_value, snapshot, changed_by,
122
+ // created_at) so `flowy history` output is consistent across backends. The
123
+ // local server is single-tenant and has no users table, so `changed_by`
124
+ // carries a constant actor ('local') rather than a FK to a user. `node_id`
125
+ // is nullable and ON DELETE SET NULL so delete-audit rows survive the node.
126
+ // Uses IF NOT EXISTS so the step is idempotent on a DB that somehow already
127
+ // has the table.
128
+ (db) => {
129
+ db.run(`
130
+ CREATE TABLE IF NOT EXISTS audit_log (
131
+ id TEXT PRIMARY KEY,
132
+ node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL,
133
+ action TEXT NOT NULL,
134
+ field TEXT,
135
+ old_value TEXT,
136
+ new_value TEXT,
137
+ snapshot TEXT,
138
+ changed_by TEXT NOT NULL DEFAULT 'local',
139
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
140
+ )
141
+ `)
142
+ db.run(
143
+ 'CREATE INDEX IF NOT EXISTS idx_audit_log_node ON audit_log(node_id)',
144
+ )
145
+ },
119
146
  ]
120
147
 
121
148
  export const LATEST_VERSION = MIGRATIONS.length
@@ -1415,4 +1415,144 @@ describe('createResolvers', () => {
1415
1415
  ).toEqual([])
1416
1416
  })
1417
1417
  })
1418
+
1419
+ describe('audit_log — recording + Query.auditLog', () => {
1420
+ it('records a create audit entry on createNode', () => {
1421
+ const node = create(resolvers, { type: 'task', title: 'Audited' })
1422
+ const log = resolvers.Query.auditLog(null, { nodeId: node.id })
1423
+ expect(log).toHaveLength(1)
1424
+ expect(log[0]).toMatchObject({ nodeId: node.id, action: 'create' })
1425
+ // snapshot is a JSON string mirroring the created node
1426
+ expect(log[0]?.snapshot).toBeTypeOf('string')
1427
+ expect(JSON.parse(log[0]?.snapshot as string)).toMatchObject({
1428
+ id: node.id,
1429
+ title: 'Audited',
1430
+ })
1431
+ })
1432
+
1433
+ it('shapes entries like the SaaS auditLog field', () => {
1434
+ const node = create(resolvers, { type: 'task', title: 'Shape' })
1435
+ const [entry] = resolvers.Query.auditLog(null, { nodeId: node.id })
1436
+ expect(entry).toHaveProperty('id')
1437
+ expect(entry).toHaveProperty('nodeId')
1438
+ expect(entry).toHaveProperty('action')
1439
+ expect(entry).toHaveProperty('field')
1440
+ expect(entry).toHaveProperty('oldValue')
1441
+ expect(entry).toHaveProperty('newValue')
1442
+ expect(entry).toHaveProperty('snapshot')
1443
+ expect(entry).toHaveProperty('changedBy')
1444
+ expect(entry).toHaveProperty('createdAt')
1445
+ expect(entry?.changedBy).toBe('local')
1446
+ expect(entry?.createdAt).toBeTypeOf('string')
1447
+ })
1448
+
1449
+ it('records field-level diffs on updateNode (status_change vs update)', () => {
1450
+ const node = create(resolvers, { type: 'task', title: 'Orig' })
1451
+ resolvers.Mutation.updateNode(null, {
1452
+ id: node.id,
1453
+ title: 'Renamed',
1454
+ status: 'in_progress',
1455
+ })
1456
+ const log = resolvers.Query.auditLog(null, { nodeId: node.id })
1457
+ const actions = log.map((e) => e.action)
1458
+ // create + a title update + a status_change
1459
+ expect(actions).toContain('create')
1460
+ expect(actions).toContain('update')
1461
+ expect(actions).toContain('status_change')
1462
+
1463
+ const titleEntry = log.find((e) => e.field === 'title')
1464
+ expect(titleEntry).toMatchObject({
1465
+ action: 'update',
1466
+ oldValue: 'Orig',
1467
+ newValue: 'Renamed',
1468
+ })
1469
+ const statusEntry = log.find((e) => e.field === 'status')
1470
+ expect(statusEntry).toMatchObject({
1471
+ action: 'status_change',
1472
+ oldValue: 'draft',
1473
+ newValue: 'in_progress',
1474
+ })
1475
+ })
1476
+
1477
+ it('records an approve entry on approveNode', () => {
1478
+ const node = create(resolvers, { type: 'task', title: 'Appr' })
1479
+ resolvers.Mutation.updateNode(null, {
1480
+ id: node.id,
1481
+ status: 'pending_review',
1482
+ })
1483
+ resolvers.Mutation.approveNode(null, { id: node.id })
1484
+ const log = resolvers.Query.auditLog(null, { nodeId: node.id })
1485
+ const approve = log.find((e) => e.action === 'approve')
1486
+ expect(approve).toMatchObject({
1487
+ field: 'status',
1488
+ oldValue: 'pending_review',
1489
+ newValue: 'approved',
1490
+ })
1491
+ })
1492
+
1493
+ it('records create_edge / remove_edge against the source node', () => {
1494
+ const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
1495
+ const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
1496
+ resolvers.Mutation.createEdge(null, {
1497
+ sourceId: blocker.id,
1498
+ targetId: blocked.id,
1499
+ relation: 'blocks',
1500
+ })
1501
+ let log = resolvers.Query.auditLog(null, { nodeId: blocker.id })
1502
+ const created = log.find((e) => e.action === 'create_edge')
1503
+ expect(created).toMatchObject({
1504
+ field: 'blocks',
1505
+ newValue: blocked.id,
1506
+ })
1507
+
1508
+ resolvers.Mutation.removeEdge(null, {
1509
+ sourceId: blocker.id,
1510
+ targetId: blocked.id,
1511
+ relation: 'blocks',
1512
+ })
1513
+ log = resolvers.Query.auditLog(null, { nodeId: blocker.id })
1514
+ const removed = log.find((e) => e.action === 'remove_edge')
1515
+ expect(removed).toMatchObject({
1516
+ field: 'blocks',
1517
+ oldValue: blocked.id,
1518
+ })
1519
+ })
1520
+
1521
+ it('returns entries newest-first and respects limit', () => {
1522
+ const node = create(resolvers, { type: 'task', title: 'Limit' })
1523
+ resolvers.Mutation.updateNode(null, { id: node.id, title: 'A' })
1524
+ resolvers.Mutation.updateNode(null, { id: node.id, title: 'B' })
1525
+ const all = resolvers.Query.auditLog(null, { nodeId: node.id })
1526
+ // create, then two title updates => 3 entries, newest first
1527
+ expect(all.length).toBeGreaterThanOrEqual(3)
1528
+ expect(all[0]?.action).not.toBe('create')
1529
+ const limited = resolvers.Query.auditLog(null, {
1530
+ nodeId: node.id,
1531
+ limit: 1,
1532
+ })
1533
+ expect(limited).toHaveLength(1)
1534
+ })
1535
+
1536
+ it('records a delete entry (node_id nulled, snapshot retained)', () => {
1537
+ const node = create(resolvers, { type: 'task', title: 'Doomed' })
1538
+ resolvers.Mutation.deleteNode(null, { id: node.id })
1539
+ // node_id is set to null on delete (matching SaaS), so the row is no
1540
+ // longer returned by auditLog(nodeId), but a delete row exists with the
1541
+ // pre-delete snapshot.
1542
+ const direct = db.raw
1543
+ .query("SELECT action, snapshot FROM audit_log WHERE action = 'delete'")
1544
+ .all() as Array<{ action: string; snapshot: string | null }>
1545
+ expect(direct).toHaveLength(1)
1546
+ expect(JSON.parse(direct[0]?.snapshot as string)).toMatchObject({
1547
+ id: node.id,
1548
+ title: 'Doomed',
1549
+ })
1550
+ })
1551
+
1552
+ it('returns [] for a node with no history', () => {
1553
+ expect(
1554
+ resolvers.Query.auditLog(null, { nodeId: 'task_nonexistent' }),
1555
+ ).toEqual([])
1556
+ })
1557
+ })
1418
1558
  })
@@ -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
@@ -105,6 +135,57 @@ function generateId(type: string): string {
105
135
  return `${prefix}_${nanoid(12)}`
106
136
  }
107
137
 
138
+ // The bundled local server is single-tenant and unauthenticated, so every
139
+ // audit entry is attributed to a single constant actor. SaaS uses the user id.
140
+ const LOCAL_ACTOR = 'local'
141
+
142
+ interface AuditInput {
143
+ nodeId: string | null
144
+ action: string
145
+ field?: string | null
146
+ oldValue?: string | null
147
+ newValue?: string | null
148
+ snapshot?: Record<string, unknown> | null
149
+ }
150
+
151
+ /**
152
+ * Write one audit_log row. Call inside the same transaction as the mutation so
153
+ * the change and its audit trail commit (or roll back) together. SQLite's
154
+ * `datetime('now')` resolves to whole seconds, which is too coarse to order
155
+ * multiple entries written in the same call; we pass an explicit ISO timestamp
156
+ * with sub-second precision so `ORDER BY created_at DESC` is stable.
157
+ */
158
+ function insertAudit(db: Db, input: AuditInput): void {
159
+ db.raw.run(
160
+ 'INSERT INTO audit_log (id, node_id, action, field, old_value, new_value, snapshot, changed_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
161
+ [
162
+ `audit_${nanoid(12)}`,
163
+ input.nodeId,
164
+ input.action,
165
+ input.field ?? null,
166
+ input.oldValue ?? null,
167
+ input.newValue ?? null,
168
+ input.snapshot != null ? JSON.stringify(input.snapshot) : null,
169
+ LOCAL_ACTOR,
170
+ new Date().toISOString(),
171
+ ],
172
+ )
173
+ }
174
+
175
+ function rowToAudit(row: AuditRow): AuditEntryGql {
176
+ return {
177
+ id: row.id,
178
+ nodeId: row.node_id,
179
+ action: row.action,
180
+ field: row.field,
181
+ oldValue: row.old_value,
182
+ newValue: row.new_value,
183
+ snapshot: row.snapshot,
184
+ changedBy: row.changed_by,
185
+ createdAt: row.created_at,
186
+ }
187
+ }
188
+
108
189
  function rowToNode(row: NodeRow): NodeGql {
109
190
  return {
110
191
  id: row.id,
@@ -330,6 +411,24 @@ export function createResolvers(db: Db) {
330
411
  .all(...params, limit) as NodeRow[]
331
412
  return selectNodes(rows)
332
413
  },
414
+
415
+ // Audit history for a node, newest first. Shaped to match the SaaS
416
+ // `auditLog` field so `flowy history` output is consistent across
417
+ // backends. `delete` entries set node_id to null (the node is gone), so
418
+ // they are not returned here — the deletion is still recorded in the
419
+ // table with the pre-delete snapshot.
420
+ auditLog: (
421
+ _: unknown,
422
+ args: { nodeId: string; limit?: number },
423
+ ): AuditEntryGql[] => {
424
+ const limit = args.limit ?? 50
425
+ const rows = db.raw
426
+ .query(
427
+ 'SELECT * FROM audit_log WHERE node_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?',
428
+ )
429
+ .all(args.nodeId, limit) as AuditRow[]
430
+ return rows.map(rowToAudit)
431
+ },
333
432
  },
334
433
 
335
434
  Mutation: {
@@ -354,11 +453,7 @@ export function createResolvers(db: Db) {
354
453
  const now = new Date().toISOString()
355
454
  const description = args.description ?? null
356
455
  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 {
456
+ const node: NodeGql = {
362
457
  id,
363
458
  type: args.type,
364
459
  title: args.title,
@@ -368,6 +463,27 @@ export function createResolvers(db: Db) {
368
463
  createdAt: now,
369
464
  updatedAt: now,
370
465
  }
466
+ db.raw.transaction(() => {
467
+ db.raw.run(
468
+ 'INSERT INTO nodes (id, type, title, description, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
469
+ [
470
+ id,
471
+ args.type,
472
+ args.title,
473
+ description,
474
+ status,
475
+ metadata,
476
+ now,
477
+ now,
478
+ ],
479
+ )
480
+ insertAudit(db, {
481
+ nodeId: id,
482
+ action: 'create',
483
+ snapshot: node as unknown as Record<string, unknown>,
484
+ })
485
+ })()
486
+ return node
371
487
  },
372
488
 
373
489
  updateNode: (
@@ -406,25 +522,77 @@ export function createResolvers(db: Db) {
406
522
  ? normalizeMetadata(args.metadata)
407
523
  : existing.metadata,
408
524
  }
525
+ // Field-level diffs, mirroring SaaS: title/description/metadata changes
526
+ // are 'update' entries; a status change is a 'status_change' entry.
527
+ const diffs: Array<{
528
+ field: string
529
+ action: string
530
+ oldValue: string | null
531
+ newValue: string | null
532
+ }> = []
533
+ if (next.title !== existing.title) {
534
+ diffs.push({
535
+ field: 'title',
536
+ action: 'update',
537
+ oldValue: existing.title,
538
+ newValue: next.title,
539
+ })
540
+ }
541
+ if (next.description !== existing.description) {
542
+ diffs.push({
543
+ field: 'description',
544
+ action: 'update',
545
+ oldValue: existing.description,
546
+ newValue: next.description,
547
+ })
548
+ }
549
+ if (next.status !== existing.status) {
550
+ diffs.push({
551
+ field: 'status',
552
+ action: 'status_change',
553
+ oldValue: existing.status,
554
+ newValue: next.status,
555
+ })
556
+ }
557
+ if (next.metadata !== existing.metadata) {
558
+ diffs.push({
559
+ field: 'metadata',
560
+ action: 'update',
561
+ oldValue: existing.metadata,
562
+ newValue: next.metadata,
563
+ })
564
+ }
565
+
409
566
  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
- )
567
+ db.raw.transaction(() => {
568
+ db.raw.run(
569
+ 'UPDATE nodes SET title = ?, description = ?, status = ?, metadata = ?, updated_at = ? WHERE id = ?',
570
+ [
571
+ next.title,
572
+ next.description,
573
+ next.status,
574
+ next.metadata,
575
+ now,
576
+ args.id,
577
+ ],
578
+ )
579
+ for (const d of diffs) {
580
+ insertAudit(db, {
581
+ nodeId: args.id,
582
+ action: d.action,
583
+ field: d.field,
584
+ oldValue: d.oldValue,
585
+ newValue: d.newValue,
586
+ })
587
+ }
588
+ })()
421
589
  return rowToNode({ ...next, updated_at: now })
422
590
  },
423
591
 
424
592
  deleteNode: (_: unknown, args: { id: string }): boolean => {
425
593
  const existing = db.raw
426
- .query('SELECT id FROM nodes WHERE id = ?')
427
- .get(args.id) as { id: string } | null
594
+ .query(`SELECT ${NODE_COLS} FROM nodes WHERE id = ?`)
595
+ .get(args.id) as NodeRow | null
428
596
  if (!existing) throw notFoundError(`Node ${args.id} not found`)
429
597
 
430
598
  // The hierarchy is client -> project -> feature -> task via `part_of`
@@ -442,6 +610,15 @@ export function createResolvers(db: Db) {
442
610
  }
443
611
 
444
612
  db.raw.transaction(() => {
613
+ // Record the delete with the pre-delete snapshot BEFORE removing the
614
+ // node. node_id is null (the node is gone) to match SaaS. The FK's
615
+ // ON DELETE SET NULL also nulls node_id on the node's prior audit
616
+ // rows when the node row below is deleted.
617
+ insertAudit(db, {
618
+ nodeId: null,
619
+ action: 'delete',
620
+ snapshot: rowToNode(existing) as unknown as Record<string, unknown>,
621
+ })
445
622
  db.raw.run('DELETE FROM edges WHERE source_id = ? OR target_id = ?', [
446
623
  args.id,
447
624
  args.id,
@@ -462,11 +639,19 @@ export function createResolvers(db: Db) {
462
639
  )
463
640
  }
464
641
  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
- ])
642
+ db.raw.transaction(() => {
643
+ db.raw.run(
644
+ 'UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?',
645
+ ['approved', now, args.id],
646
+ )
647
+ insertAudit(db, {
648
+ nodeId: args.id,
649
+ action: 'approve',
650
+ field: 'status',
651
+ oldValue: 'pending_review',
652
+ newValue: 'approved',
653
+ })
654
+ })()
470
655
  return rowToNode({ ...existing, status: 'approved', updated_at: now })
471
656
  },
472
657
 
@@ -496,10 +681,20 @@ export function createResolvers(db: Db) {
496
681
  throw validationError('A node cannot block itself')
497
682
  }
498
683
  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
- )
684
+ db.raw.transaction(() => {
685
+ db.raw.run(
686
+ 'INSERT INTO edges (source_id, target_id, relation, created_at) VALUES (?, ?, ?, ?)',
687
+ [args.sourceId, args.targetId, args.relation, now],
688
+ )
689
+ // Record the edge against its source node so `auditLog(sourceId)`
690
+ // surfaces it. field = relation; newValue = the target it now links.
691
+ insertAudit(db, {
692
+ nodeId: args.sourceId,
693
+ action: 'create_edge',
694
+ field: args.relation,
695
+ newValue: args.targetId,
696
+ })
697
+ })()
503
698
  return {
504
699
  sourceId: args.sourceId,
505
700
  targetId: args.targetId,
@@ -512,11 +707,25 @@ export function createResolvers(db: Db) {
512
707
  _: unknown,
513
708
  args: { sourceId: string; targetId: string; relation: string },
514
709
  ) => {
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
710
+ let changed = false
711
+ db.raw.transaction(() => {
712
+ const result = db.raw.run(
713
+ 'DELETE FROM edges WHERE source_id = ? AND target_id = ? AND relation = ?',
714
+ [args.sourceId, args.targetId, args.relation],
715
+ )
716
+ changed = result.changes > 0
717
+ // Only audit an edge that actually existed. oldValue = the target the
718
+ // edge linked to before removal.
719
+ if (changed) {
720
+ insertAudit(db, {
721
+ nodeId: args.sourceId,
722
+ action: 'remove_edge',
723
+ field: args.relation,
724
+ oldValue: args.targetId,
725
+ })
726
+ }
727
+ })()
728
+ return changed
520
729
  },
521
730
  },
522
731
  }
@@ -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
@@ -45,6 +60,7 @@ export const typeDefs = /* GraphQL */ `
45
60
  edges(nodeId: String!, relation: String!, direction: String): [Node!]!
46
61
  readyTasks(projectId: String): [Node!]!
47
62
  search(query: String!, type: String, status: String, limit: Int): [Node!]!
63
+ auditLog(nodeId: String!, limit: Int): [AuditEntry!]!
48
64
  }
49
65
 
50
66
  type Mutation {
@@ -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
+ })
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 --------------------------------------------------------------
@@ -121,6 +123,18 @@ export const SEARCH = `query Search($query: String!, $type: String, $status: Str
121
123
  }
122
124
  }`
123
125
 
126
+ /**
127
+ * history.ts — audit history for a node, newest first. Served by BOTH backends:
128
+ * the SaaS server (always) and, since P1-2/F27, the bundled local server. The
129
+ * selection set mirrors the SaaS `auditLog` query (flowy-saas
130
+ * `test/helpers/cli-queries.ts`) so output is identical across backends.
131
+ */
132
+ export const AUDIT_LOG = `query AuditLog($nodeId: String!, $limit: Int) {
133
+ auditLog(nodeId: $nodeId, limit: $limit) {
134
+ id action field oldValue newValue snapshot changedBy createdAt
135
+ }
136
+ }`
137
+
124
138
  // --- Nodes: write -------------------------------------------------------------
125
139
 
126
140
  /** project.ts `create`, init.ts — create a node by type/title. */
@@ -318,6 +332,7 @@ export const LOCAL_CONTRACT_OPERATIONS = {
318
332
  EXPORT_PROJECT,
319
333
  EXPORT_DESCENDANTS,
320
334
  EXPORT_EDGES,
335
+ AUDIT_LOG,
321
336
  } as const
322
337
 
323
338
  /**