@sqaoss/flowy 1.8.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 +2 -1
- package/server/src/contract.test.ts +76 -8
- package/server/src/migrations.test.ts +61 -0
- package/server/src/migrations.ts +27 -0
- package/server/src/resolvers.test.ts +261 -1
- package/server/src/resolvers.ts +284 -40
- package/server/src/schema.ts +35 -1
- package/src/commands/history.test.ts +99 -0
- package/src/commands/history.ts +20 -0
- package/src/commands/tree.test.ts +89 -1
- package/src/commands/tree.ts +6 -0
- 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 +21 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sqaoss/flowy",
|
|
3
|
-
"version": "1.
|
|
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
|
|
35
|
-
* CLI does not yet call against local: `
|
|
36
|
-
*
|
|
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
|
|
@@ -281,11 +283,44 @@ describe('CLI/local-server contract (P1-1)', () => {
|
|
|
281
283
|
})
|
|
282
284
|
expect(exportDescendants.descendants.length).toBeGreaterThan(0)
|
|
283
285
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
286
|
+
// SUBTREE follows one relation (default part_of) and annotates each node
|
|
287
|
+
// with parentId/depth/relation. From the project, the part_of view is:
|
|
288
|
+
// project -> feature (depth 1) -> {task, blocker, imported} (depth 2),
|
|
289
|
+
// all reached via part_of edges. The blocker -> task `blocks` edge must NOT
|
|
290
|
+
// change anyone's parent/relation: every returned node carries
|
|
291
|
+
// relation 'part_of' and its part_of parent, never the blocks linkage.
|
|
292
|
+
const subtree = await ok<{
|
|
293
|
+
subtree: Array<{
|
|
294
|
+
id: string
|
|
295
|
+
parentId: string
|
|
296
|
+
depth: number
|
|
297
|
+
relation: string
|
|
298
|
+
}>
|
|
299
|
+
}>(LOCAL_CONTRACT_OPERATIONS.SUBTREE, {
|
|
300
|
+
nodeId: project.id,
|
|
301
|
+
relation: 'part_of',
|
|
302
|
+
maxDepth: 10,
|
|
303
|
+
})
|
|
304
|
+
const subtreeById = new Map(subtree.subtree.map((n) => [n.id, n]))
|
|
305
|
+
expect(subtreeById.get(feature.id)).toMatchObject({
|
|
306
|
+
parentId: project.id,
|
|
307
|
+
depth: 1,
|
|
308
|
+
relation: 'part_of',
|
|
309
|
+
})
|
|
310
|
+
expect(subtreeById.get(task.id)).toMatchObject({
|
|
311
|
+
parentId: feature.id,
|
|
312
|
+
depth: 2,
|
|
313
|
+
relation: 'part_of',
|
|
314
|
+
})
|
|
315
|
+
// the blocker is reached via its part_of edge to the feature (depth 2),
|
|
316
|
+
// NOT via the blocks edge that points at the task — proving blocks edges
|
|
317
|
+
// do not leak into the hierarchy view.
|
|
318
|
+
expect(subtreeById.get(blocker.id)).toMatchObject({
|
|
319
|
+
parentId: feature.id,
|
|
320
|
+
depth: 2,
|
|
321
|
+
relation: 'part_of',
|
|
322
|
+
})
|
|
323
|
+
expect(subtree.subtree.every((n) => n.relation === 'part_of')).toBe(true)
|
|
289
324
|
|
|
290
325
|
// task is blocked by an unfinished blocker, so readyTasks excludes it.
|
|
291
326
|
const ready = await ok<{ readyTasks: Array<{ id: string }> }>(
|
|
@@ -331,6 +366,38 @@ describe('CLI/local-server contract (P1-1)', () => {
|
|
|
331
366
|
)
|
|
332
367
|
expect(search.search.some((n) => n.id === project.id)).toBe(true)
|
|
333
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
|
+
|
|
334
401
|
// --- Edge removal + node deletion --------------------------------------
|
|
335
402
|
const { removeEdge } = await ok<{ removeEdge: boolean }>(
|
|
336
403
|
LOCAL_CONTRACT_OPERATIONS.UNBLOCK_TASK,
|
|
@@ -381,6 +448,7 @@ describe('CLI/local-server contract (P1-1)', () => {
|
|
|
381
448
|
'EXPORT_PROJECT',
|
|
382
449
|
'EXPORT_DESCENDANTS',
|
|
383
450
|
'EXPORT_EDGES',
|
|
451
|
+
'AUDIT_LOG',
|
|
384
452
|
])
|
|
385
453
|
expect(new Set(Object.keys(LOCAL_CONTRACT_OPERATIONS))).toEqual(exercised)
|
|
386
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)
|
package/server/src/migrations.ts
CHANGED
|
@@ -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
|
|
@@ -580,7 +580,7 @@ describe('createResolvers', () => {
|
|
|
580
580
|
})
|
|
581
581
|
|
|
582
582
|
describe('Query.subtree', () => {
|
|
583
|
-
it('returns
|
|
583
|
+
it('returns the part_of hierarchy by default', () => {
|
|
584
584
|
const project = resolvers.Mutation.createNode(null, {
|
|
585
585
|
type: 'project',
|
|
586
586
|
title: 'Root',
|
|
@@ -608,6 +608,125 @@ describe('createResolvers', () => {
|
|
|
608
608
|
expect(tree).toHaveLength(2)
|
|
609
609
|
})
|
|
610
610
|
|
|
611
|
+
it('returns parentId, depth and relation for each node (default part_of)', () => {
|
|
612
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
613
|
+
type: 'project',
|
|
614
|
+
title: 'Root',
|
|
615
|
+
})
|
|
616
|
+
const feature = resolvers.Mutation.createNode(null, {
|
|
617
|
+
type: 'feature',
|
|
618
|
+
title: 'F1',
|
|
619
|
+
})
|
|
620
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
621
|
+
type: 'task',
|
|
622
|
+
title: 'T1',
|
|
623
|
+
})
|
|
624
|
+
resolvers.Mutation.createEdge(null, {
|
|
625
|
+
sourceId: feature.id,
|
|
626
|
+
targetId: project.id,
|
|
627
|
+
relation: 'part_of',
|
|
628
|
+
})
|
|
629
|
+
resolvers.Mutation.createEdge(null, {
|
|
630
|
+
sourceId: task.id,
|
|
631
|
+
targetId: feature.id,
|
|
632
|
+
relation: 'part_of',
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
const tree = resolvers.Query.subtree(null, { nodeId: project.id })
|
|
636
|
+
const byId = new Map(tree.map((n) => [n.id, n]))
|
|
637
|
+
|
|
638
|
+
const featNode = byId.get(feature.id)
|
|
639
|
+
expect(featNode).toBeDefined()
|
|
640
|
+
expect(featNode?.depth).toBe(1)
|
|
641
|
+
expect(featNode?.parentId).toBe(project.id)
|
|
642
|
+
expect(featNode?.relation).toBe('part_of')
|
|
643
|
+
|
|
644
|
+
const taskNode = byId.get(task.id)
|
|
645
|
+
expect(taskNode).toBeDefined()
|
|
646
|
+
expect(taskNode?.depth).toBe(2)
|
|
647
|
+
expect(taskNode?.parentId).toBe(feature.id)
|
|
648
|
+
expect(taskNode?.relation).toBe('part_of')
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('excludes blocks edges from the default part_of view', () => {
|
|
652
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
653
|
+
type: 'project',
|
|
654
|
+
title: 'Root',
|
|
655
|
+
})
|
|
656
|
+
const feature = resolvers.Mutation.createNode(null, {
|
|
657
|
+
type: 'feature',
|
|
658
|
+
title: 'F1',
|
|
659
|
+
})
|
|
660
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
661
|
+
type: 'task',
|
|
662
|
+
title: 'T1',
|
|
663
|
+
})
|
|
664
|
+
const blocker = resolvers.Mutation.createNode(null, {
|
|
665
|
+
type: 'task',
|
|
666
|
+
title: 'Blocker',
|
|
667
|
+
})
|
|
668
|
+
resolvers.Mutation.createEdge(null, {
|
|
669
|
+
sourceId: feature.id,
|
|
670
|
+
targetId: project.id,
|
|
671
|
+
relation: 'part_of',
|
|
672
|
+
})
|
|
673
|
+
resolvers.Mutation.createEdge(null, {
|
|
674
|
+
sourceId: task.id,
|
|
675
|
+
targetId: feature.id,
|
|
676
|
+
relation: 'part_of',
|
|
677
|
+
})
|
|
678
|
+
// blocker blocks task: source=blocker, target=task. A blocks edge must NOT
|
|
679
|
+
// pull the blocker into the project's part_of hierarchy view.
|
|
680
|
+
resolvers.Mutation.createEdge(null, {
|
|
681
|
+
sourceId: blocker.id,
|
|
682
|
+
targetId: task.id,
|
|
683
|
+
relation: 'blocks',
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
const tree = resolvers.Query.subtree(null, { nodeId: project.id })
|
|
687
|
+
const ids = tree.map((n) => n.id)
|
|
688
|
+
expect(ids).toContain(feature.id)
|
|
689
|
+
expect(ids).toContain(task.id)
|
|
690
|
+
expect(ids).not.toContain(blocker.id)
|
|
691
|
+
expect(tree.every((n) => n.relation === 'part_of')).toBe(true)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('follows only the requested relation when overridden', () => {
|
|
695
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
696
|
+
type: 'task',
|
|
697
|
+
title: 'Target',
|
|
698
|
+
})
|
|
699
|
+
const blocker = resolvers.Mutation.createNode(null, {
|
|
700
|
+
type: 'task',
|
|
701
|
+
title: 'Blocker',
|
|
702
|
+
})
|
|
703
|
+
const child = resolvers.Mutation.createNode(null, {
|
|
704
|
+
type: 'task',
|
|
705
|
+
title: 'Part of target',
|
|
706
|
+
})
|
|
707
|
+
// blocker blocks task (source=blocker, target=task)
|
|
708
|
+
resolvers.Mutation.createEdge(null, {
|
|
709
|
+
sourceId: blocker.id,
|
|
710
|
+
targetId: task.id,
|
|
711
|
+
relation: 'blocks',
|
|
712
|
+
})
|
|
713
|
+
// child is part_of task (source=child, target=task)
|
|
714
|
+
resolvers.Mutation.createEdge(null, {
|
|
715
|
+
sourceId: child.id,
|
|
716
|
+
targetId: task.id,
|
|
717
|
+
relation: 'part_of',
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
const blocks = resolvers.Query.subtree(null, {
|
|
721
|
+
nodeId: task.id,
|
|
722
|
+
relation: 'blocks',
|
|
723
|
+
})
|
|
724
|
+
expect(blocks.map((n) => n.id)).toEqual([blocker.id])
|
|
725
|
+
expect(blocks[0].relation).toBe('blocks')
|
|
726
|
+
expect(blocks[0].depth).toBe(1)
|
|
727
|
+
expect(blocks[0].parentId).toBe(task.id)
|
|
728
|
+
})
|
|
729
|
+
|
|
611
730
|
it('respects maxDepth', () => {
|
|
612
731
|
const project = resolvers.Mutation.createNode(null, {
|
|
613
732
|
type: 'project',
|
|
@@ -638,6 +757,7 @@ describe('createResolvers', () => {
|
|
|
638
757
|
})
|
|
639
758
|
expect(shallow).toHaveLength(1)
|
|
640
759
|
expect(shallow[0].id).toBe(feature.id)
|
|
760
|
+
expect(shallow[0].depth).toBe(1)
|
|
641
761
|
})
|
|
642
762
|
})
|
|
643
763
|
|
|
@@ -1295,4 +1415,144 @@ describe('createResolvers', () => {
|
|
|
1295
1415
|
).toEqual([])
|
|
1296
1416
|
})
|
|
1297
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
|
+
})
|
|
1298
1558
|
})
|
package/server/src/resolvers.ts
CHANGED
|
@@ -40,6 +40,53 @@ 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
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A node returned from a subtree traversal, annotated with the edge that pulled
|
|
75
|
+
* it in: `parentId` (the node it descends from), `depth` (1 for the root's
|
|
76
|
+
* direct children), and `relation` (the relation of the linking edge).
|
|
77
|
+
*/
|
|
78
|
+
export interface SubtreeNodeGql extends NodeGql {
|
|
79
|
+
parentId: string
|
|
80
|
+
depth: number
|
|
81
|
+
relation: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface SubtreeRow extends NodeRow {
|
|
85
|
+
parent_id: string
|
|
86
|
+
depth: number
|
|
87
|
+
edge_relation: string
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
const NODE_COLS =
|
|
44
91
|
'id, type, title, description, status, metadata, created_at, updated_at'
|
|
45
92
|
|
|
@@ -88,6 +135,57 @@ function generateId(type: string): string {
|
|
|
88
135
|
return `${prefix}_${nanoid(12)}`
|
|
89
136
|
}
|
|
90
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
|
+
|
|
91
189
|
function rowToNode(row: NodeRow): NodeGql {
|
|
92
190
|
return {
|
|
93
191
|
id: row.id,
|
|
@@ -177,21 +275,39 @@ export function createResolvers(db: Db) {
|
|
|
177
275
|
return selectNodes(rows)
|
|
178
276
|
},
|
|
179
277
|
|
|
180
|
-
|
|
278
|
+
// Walk the part_of hierarchy (or another `relation`, default 'part_of')
|
|
279
|
+
// downward from `nodeId`, returning each reachable node annotated with the
|
|
280
|
+
// edge that pulled it in: parentId, depth (root's direct children = 1) and
|
|
281
|
+
// relation. Following a single relation keeps the hierarchy view clean —
|
|
282
|
+
// `blocks` dependency edges no longer leak into the part_of tree.
|
|
283
|
+
subtree: (
|
|
284
|
+
_: unknown,
|
|
285
|
+
args: { nodeId: string; relation?: string; maxDepth?: number },
|
|
286
|
+
): SubtreeNodeGql[] => {
|
|
287
|
+
const relation = args.relation ?? 'part_of'
|
|
181
288
|
const maxDepth = args.maxDepth ?? 100
|
|
182
289
|
if (maxDepth === 0) return []
|
|
183
290
|
const rows = db.raw
|
|
184
291
|
.query(
|
|
185
|
-
`WITH RECURSIVE tree(id, depth) AS (
|
|
186
|
-
SELECT source_id, 1 FROM edges
|
|
292
|
+
`WITH RECURSIVE tree(id, parent_id, depth) AS (
|
|
293
|
+
SELECT source_id, target_id, 1 FROM edges
|
|
294
|
+
WHERE target_id = ?1 AND relation = ?3
|
|
187
295
|
UNION ALL
|
|
188
|
-
SELECT e.source_id, t.depth + 1 FROM edges e
|
|
189
|
-
|
|
296
|
+
SELECT e.source_id, e.target_id, t.depth + 1 FROM edges e
|
|
297
|
+
JOIN tree t ON e.target_id = t.id
|
|
298
|
+
WHERE t.depth < ?2 AND e.relation = ?3
|
|
190
299
|
)
|
|
191
|
-
SELECT
|
|
300
|
+
SELECT ${prefixedCols()}, t.parent_id AS parent_id, t.depth AS depth, ?3 AS edge_relation
|
|
301
|
+
FROM nodes n JOIN tree t ON n.id = t.id
|
|
302
|
+
ORDER BY t.depth`,
|
|
192
303
|
)
|
|
193
|
-
.all(args.nodeId, maxDepth) as
|
|
194
|
-
return
|
|
304
|
+
.all(args.nodeId, maxDepth, relation) as SubtreeRow[]
|
|
305
|
+
return rows.map((row) => ({
|
|
306
|
+
...rowToNode(row),
|
|
307
|
+
parentId: row.parent_id,
|
|
308
|
+
depth: row.depth,
|
|
309
|
+
relation: row.edge_relation,
|
|
310
|
+
}))
|
|
195
311
|
},
|
|
196
312
|
|
|
197
313
|
// Nodes connected to `nodeId` by `relation`, following edges in the given
|
|
@@ -295,6 +411,24 @@ export function createResolvers(db: Db) {
|
|
|
295
411
|
.all(...params, limit) as NodeRow[]
|
|
296
412
|
return selectNodes(rows)
|
|
297
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
|
+
},
|
|
298
432
|
},
|
|
299
433
|
|
|
300
434
|
Mutation: {
|
|
@@ -319,11 +453,7 @@ export function createResolvers(db: Db) {
|
|
|
319
453
|
const now = new Date().toISOString()
|
|
320
454
|
const description = args.description ?? null
|
|
321
455
|
const status = args.status ?? 'draft'
|
|
322
|
-
|
|
323
|
-
'INSERT INTO nodes (id, type, title, description, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
324
|
-
[id, args.type, args.title, description, status, metadata, now, now],
|
|
325
|
-
)
|
|
326
|
-
return {
|
|
456
|
+
const node: NodeGql = {
|
|
327
457
|
id,
|
|
328
458
|
type: args.type,
|
|
329
459
|
title: args.title,
|
|
@@ -333,6 +463,27 @@ export function createResolvers(db: Db) {
|
|
|
333
463
|
createdAt: now,
|
|
334
464
|
updatedAt: now,
|
|
335
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
|
|
336
487
|
},
|
|
337
488
|
|
|
338
489
|
updateNode: (
|
|
@@ -371,25 +522,77 @@ export function createResolvers(db: Db) {
|
|
|
371
522
|
? normalizeMetadata(args.metadata)
|
|
372
523
|
: existing.metadata,
|
|
373
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
|
+
|
|
374
566
|
const now = new Date().toISOString()
|
|
375
|
-
db.raw.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
})()
|
|
386
589
|
return rowToNode({ ...next, updated_at: now })
|
|
387
590
|
},
|
|
388
591
|
|
|
389
592
|
deleteNode: (_: unknown, args: { id: string }): boolean => {
|
|
390
593
|
const existing = db.raw
|
|
391
|
-
.query(
|
|
392
|
-
.get(args.id) as
|
|
594
|
+
.query(`SELECT ${NODE_COLS} FROM nodes WHERE id = ?`)
|
|
595
|
+
.get(args.id) as NodeRow | null
|
|
393
596
|
if (!existing) throw notFoundError(`Node ${args.id} not found`)
|
|
394
597
|
|
|
395
598
|
// The hierarchy is client -> project -> feature -> task via `part_of`
|
|
@@ -407,6 +610,15 @@ export function createResolvers(db: Db) {
|
|
|
407
610
|
}
|
|
408
611
|
|
|
409
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
|
+
})
|
|
410
622
|
db.raw.run('DELETE FROM edges WHERE source_id = ? OR target_id = ?', [
|
|
411
623
|
args.id,
|
|
412
624
|
args.id,
|
|
@@ -427,11 +639,19 @@ export function createResolvers(db: Db) {
|
|
|
427
639
|
)
|
|
428
640
|
}
|
|
429
641
|
const now = new Date().toISOString()
|
|
430
|
-
db.raw.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
+
})()
|
|
435
655
|
return rowToNode({ ...existing, status: 'approved', updated_at: now })
|
|
436
656
|
},
|
|
437
657
|
|
|
@@ -461,10 +681,20 @@ export function createResolvers(db: Db) {
|
|
|
461
681
|
throw validationError('A node cannot block itself')
|
|
462
682
|
}
|
|
463
683
|
const now = new Date().toISOString()
|
|
464
|
-
db.raw.
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
+
})()
|
|
468
698
|
return {
|
|
469
699
|
sourceId: args.sourceId,
|
|
470
700
|
targetId: args.targetId,
|
|
@@ -477,11 +707,25 @@ export function createResolvers(db: Db) {
|
|
|
477
707
|
_: unknown,
|
|
478
708
|
args: { sourceId: string; targetId: string; relation: string },
|
|
479
709
|
) => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
485
729
|
},
|
|
486
730
|
},
|
|
487
731
|
}
|
package/server/src/schema.ts
CHANGED
|
@@ -19,14 +19,48 @@ 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
|
+
|
|
37
|
+
# A node returned from a subtree traversal, annotated with how it was reached:
|
|
38
|
+
# the parent it descends from (parentId), how many edges down the root it sits
|
|
39
|
+
# (depth, root's direct children are depth 1), and the relation of the edge
|
|
40
|
+
# that links it to its parent.
|
|
41
|
+
type SubtreeNode {
|
|
42
|
+
id: String!
|
|
43
|
+
type: String!
|
|
44
|
+
title: String!
|
|
45
|
+
description: String
|
|
46
|
+
status: String!
|
|
47
|
+
metadata: String
|
|
48
|
+
createdAt: String!
|
|
49
|
+
updatedAt: String!
|
|
50
|
+
parentId: String!
|
|
51
|
+
depth: Int!
|
|
52
|
+
relation: String!
|
|
53
|
+
}
|
|
54
|
+
|
|
22
55
|
type Query {
|
|
23
56
|
node(id: String!): Node
|
|
24
57
|
nodes(type: String): [Node!]!
|
|
25
58
|
descendants(nodeId: String!, relation: String, maxDepth: Int): [Node!]!
|
|
26
|
-
subtree(nodeId: String!, maxDepth: Int): [
|
|
59
|
+
subtree(nodeId: String!, relation: String, maxDepth: Int): [SubtreeNode!]!
|
|
27
60
|
edges(nodeId: String!, relation: String!, direction: String): [Node!]!
|
|
28
61
|
readyTasks(projectId: String): [Node!]!
|
|
29
62
|
search(query: String!, type: String, status: String, limit: Int): [Node!]!
|
|
63
|
+
auditLog(nodeId: String!, limit: Int): [AuditEntry!]!
|
|
30
64
|
}
|
|
31
65
|
|
|
32
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
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
describe('tree command', () => {
|
|
4
4
|
test('exports a flat command with id argument and depth option', async () => {
|
|
@@ -6,4 +6,92 @@ describe('tree command', () => {
|
|
|
6
6
|
expect(treeCommand.name()).toBe('tree')
|
|
7
7
|
expect(treeCommand.commands).toHaveLength(0)
|
|
8
8
|
})
|
|
9
|
+
|
|
10
|
+
test('exposes a --relation option defaulting to part_of', async () => {
|
|
11
|
+
const { treeCommand } = await import('./tree.ts')
|
|
12
|
+
const relationOpt = treeCommand.options.find((o) => o.long === '--relation')
|
|
13
|
+
expect(relationOpt).toBeDefined()
|
|
14
|
+
expect(relationOpt?.defaultValue).toBe('part_of')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('action', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.resetModules()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks()
|
|
24
|
+
vi.unstubAllEnvs()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('sends the SUBTREE op with the relation filter and renders depth/relation', async () => {
|
|
28
|
+
const calls: Array<{ query: string; variables: unknown }> = []
|
|
29
|
+
vi.doMock('../util/client.ts', () => ({
|
|
30
|
+
graphql: vi.fn(async (query: string, variables: unknown) => {
|
|
31
|
+
calls.push({ query, variables })
|
|
32
|
+
return {
|
|
33
|
+
subtree: [
|
|
34
|
+
{
|
|
35
|
+
id: 'feat_1',
|
|
36
|
+
type: 'feature',
|
|
37
|
+
title: 'F1',
|
|
38
|
+
status: 'draft',
|
|
39
|
+
parentId: 'proj_1',
|
|
40
|
+
depth: 1,
|
|
41
|
+
relation: 'part_of',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
}),
|
|
46
|
+
}))
|
|
47
|
+
const outputs: unknown[] = []
|
|
48
|
+
vi.doMock('../util/format.ts', () => ({
|
|
49
|
+
output: vi.fn((data: unknown) => outputs.push(data)),
|
|
50
|
+
outputError: vi.fn(),
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
const { SUBTREE } = await import('../util/operations.ts')
|
|
54
|
+
const { treeCommand } = await import('./tree.ts')
|
|
55
|
+
await treeCommand.parseAsync(['proj_1'], { from: 'user' })
|
|
56
|
+
|
|
57
|
+
expect(calls).toHaveLength(1)
|
|
58
|
+
expect(calls[0]!.query).toBe(SUBTREE)
|
|
59
|
+
expect(calls[0]!.variables).toMatchObject({
|
|
60
|
+
nodeId: 'proj_1',
|
|
61
|
+
relation: 'part_of',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const rendered = outputs[0] as Array<Record<string, unknown>>
|
|
65
|
+
expect(rendered[0]).toMatchObject({
|
|
66
|
+
id: 'feat_1',
|
|
67
|
+
parentId: 'proj_1',
|
|
68
|
+
depth: 1,
|
|
69
|
+
relation: 'part_of',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('passes an overridden relation through to the op', async () => {
|
|
74
|
+
const calls: Array<{ variables: unknown }> = []
|
|
75
|
+
vi.doMock('../util/client.ts', () => ({
|
|
76
|
+
graphql: vi.fn(async (_query: string, variables: unknown) => {
|
|
77
|
+
calls.push({ variables })
|
|
78
|
+
return { subtree: [] }
|
|
79
|
+
}),
|
|
80
|
+
}))
|
|
81
|
+
vi.doMock('../util/format.ts', () => ({
|
|
82
|
+
output: vi.fn(),
|
|
83
|
+
outputError: vi.fn(),
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
const { treeCommand } = await import('./tree.ts')
|
|
87
|
+
await treeCommand.parseAsync(['task_1', '--relation', 'blocks'], {
|
|
88
|
+
from: 'user',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(calls[0]!.variables).toMatchObject({
|
|
92
|
+
nodeId: 'task_1',
|
|
93
|
+
relation: 'blocks',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
9
97
|
})
|
package/src/commands/tree.ts
CHANGED
|
@@ -7,10 +7,16 @@ export const treeCommand = new Command('tree')
|
|
|
7
7
|
.description('Show subtree from any entity')
|
|
8
8
|
.argument('<id>', 'Root node ID')
|
|
9
9
|
.option('--depth <n>', 'Max depth', '10')
|
|
10
|
+
.option(
|
|
11
|
+
'--relation <relation>',
|
|
12
|
+
'Edge relation to follow (e.g. part_of, blocks)',
|
|
13
|
+
'part_of',
|
|
14
|
+
)
|
|
10
15
|
.action(async (id: string, opts) => {
|
|
11
16
|
try {
|
|
12
17
|
const data = await graphql<{ subtree: unknown[] }>(SUBTREE, {
|
|
13
18
|
nodeId: id,
|
|
19
|
+
relation: opts.relation,
|
|
14
20
|
maxDepth: Number.parseInt(opts.depth, 10),
|
|
15
21
|
})
|
|
16
22
|
output(data.subtree)
|
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 --------------------------------------------------------------
|
|
@@ -76,10 +78,11 @@ export const LIST_TASKS = `query ListTasks($nodeId: String!, $relation: String!,
|
|
|
76
78
|
}
|
|
77
79
|
}`
|
|
78
80
|
|
|
79
|
-
/** tree.ts —
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
/** tree.ts — subtree from any root, filtered to one relation (default part_of),
|
|
82
|
+
* each node annotated with parentId/depth/relation. */
|
|
83
|
+
export const SUBTREE = `query Subtree($nodeId: String!, $relation: String, $maxDepth: Int) {
|
|
84
|
+
subtree(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
|
|
85
|
+
id type title status parentId depth relation
|
|
83
86
|
}
|
|
84
87
|
}`
|
|
85
88
|
|
|
@@ -120,6 +123,18 @@ export const SEARCH = `query Search($query: String!, $type: String, $status: Str
|
|
|
120
123
|
}
|
|
121
124
|
}`
|
|
122
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
|
+
|
|
123
138
|
// --- Nodes: write -------------------------------------------------------------
|
|
124
139
|
|
|
125
140
|
/** project.ts `create`, init.ts — create a node by type/title. */
|
|
@@ -317,6 +332,7 @@ export const LOCAL_CONTRACT_OPERATIONS = {
|
|
|
317
332
|
EXPORT_PROJECT,
|
|
318
333
|
EXPORT_DESCENDANTS,
|
|
319
334
|
EXPORT_EDGES,
|
|
335
|
+
AUDIT_LOG,
|
|
320
336
|
} as const
|
|
321
337
|
|
|
322
338
|
/**
|