@sqaoss/flowy 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +5 -2
- package/server/src/contract.test.ts +53 -7
- package/server/src/index.errors.test.ts +6 -3
- package/server/src/index.ts +10 -1
- package/server/src/migrations.test.ts +61 -0
- package/server/src/migrations.ts +27 -0
- package/server/src/resolvers.test.ts +355 -16
- package/server/src/resolvers.ts +315 -38
- package/server/src/schema.ts +32 -1
- package/skills/using-flowy/SKILL.md +3 -0
- package/src/commands/history.test.ts +99 -0
- package/src/commands/history.ts +20 -0
- package/src/commands/search.test.ts +90 -0
- package/src/commands/search.ts +19 -3
- package/src/commands/status.test.ts +71 -0
- package/src/commands/status.ts +18 -4
- package/src/index.test.ts +16 -0
- package/src/index.ts +2 -0
- package/src/util/operations.test.ts +1 -0
- package/src/util/operations.ts +26 -3
package/server/src/resolvers.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
490
|
+
'SELECT * FROM audit_log WHERE node_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?',
|
|
329
491
|
)
|
|
330
|
-
.all(
|
|
331
|
-
return
|
|
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
|
-
|
|
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)
|
|
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.
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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(
|
|
427
|
-
.get(args.id) as
|
|
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.
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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.
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
}
|
package/server/src/schema.ts
CHANGED
|
@@ -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(
|
|
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
|
+
})
|