@sqaoss/flowy 1.12.0 → 1.13.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 +1 -1
- package/server/src/contract.test.ts +43 -0
- package/server/src/resolvers.test.ts +73 -0
- package/server/src/resolvers.ts +29 -0
- package/server/src/schema.ts +1 -0
- package/src/commands/feature.test.ts +44 -0
- package/src/commands/feature.ts +10 -8
- package/src/commands/task.test.ts +44 -0
- package/src/commands/task.ts +5 -7
- package/src/util/operations.test.ts +0 -2
- package/src/util/operations.ts +31 -6
package/package.json
CHANGED
|
@@ -175,6 +175,48 @@ describe('CLI/local-server contract (P1-1)', () => {
|
|
|
175
175
|
relation: 'part_of',
|
|
176
176
|
})
|
|
177
177
|
|
|
178
|
+
// CREATE_NODE_WITH_PARENT (P1-4/F24): create a node AND its part_of edge to
|
|
179
|
+
// the feature in one atomic call. The returned node must be a real child of
|
|
180
|
+
// the feature (reachable via the part_of hierarchy), proving the edge was
|
|
181
|
+
// created in the same unit of work — no separate createEdge call needed.
|
|
182
|
+
const { createNode: parentedTask } = await ok<{
|
|
183
|
+
createNode: { id: string }
|
|
184
|
+
}>(LOCAL_CONTRACT_OPERATIONS.CREATE_NODE_WITH_PARENT, {
|
|
185
|
+
type: 'task',
|
|
186
|
+
title: 'Parented Task',
|
|
187
|
+
description: 'linked atomically',
|
|
188
|
+
parentId: feature.id,
|
|
189
|
+
})
|
|
190
|
+
expect(parentedTask.id).toMatch(/^task_/)
|
|
191
|
+
const parentedChildren = await ok<{ descendants: Array<{ id: string }> }>(
|
|
192
|
+
LOCAL_CONTRACT_OPERATIONS.LIST_TASKS,
|
|
193
|
+
{ nodeId: feature.id, relation: 'part_of', maxDepth: 1 },
|
|
194
|
+
)
|
|
195
|
+
expect(parentedChildren.descendants.map((n) => n.id)).toContain(
|
|
196
|
+
parentedTask.id,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
// A non-existent parent must be rejected (NOT_FOUND) and leave no orphan.
|
|
200
|
+
const beforeOrphan = await ok<{ nodes: Array<{ id: string }> }>(
|
|
201
|
+
LOCAL_CONTRACT_OPERATIONS.ALL_TASKS,
|
|
202
|
+
{ type: 'task' },
|
|
203
|
+
)
|
|
204
|
+
const badParent = await run(
|
|
205
|
+
LOCAL_CONTRACT_OPERATIONS.CREATE_NODE_WITH_PARENT,
|
|
206
|
+
{
|
|
207
|
+
type: 'task',
|
|
208
|
+
title: 'Should Not Exist',
|
|
209
|
+
description: 'orphan attempt',
|
|
210
|
+
parentId: 'feat_missing',
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
expect(badParent.errors).toBeDefined()
|
|
214
|
+
const afterOrphan = await ok<{ nodes: Array<{ id: string }> }>(
|
|
215
|
+
LOCAL_CONTRACT_OPERATIONS.ALL_TASKS,
|
|
216
|
+
{ type: 'task' },
|
|
217
|
+
)
|
|
218
|
+
expect(afterOrphan.nodes).toHaveLength(beforeOrphan.nodes.length)
|
|
219
|
+
|
|
178
220
|
// blocker blocks task
|
|
179
221
|
const { createEdge: blocksEdge } = await ok<{
|
|
180
222
|
createEdge: { relation: string; createdAt: string }
|
|
@@ -443,6 +485,7 @@ describe('CLI/local-server contract (P1-1)', () => {
|
|
|
443
485
|
'CREATE_PROJECT',
|
|
444
486
|
'CREATE_NODE',
|
|
445
487
|
'CREATE_TASK',
|
|
488
|
+
'CREATE_NODE_WITH_PARENT',
|
|
446
489
|
'UPDATE_NODE',
|
|
447
490
|
'UPDATE_STATUS',
|
|
448
491
|
'APPROVE_NODE',
|
|
@@ -151,6 +151,79 @@ describe('createResolvers', () => {
|
|
|
151
151
|
})
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
+
describe('Mutation.createNode with parentId (F24)', () => {
|
|
155
|
+
it('creates the node and a part_of edge to the parent atomically', () => {
|
|
156
|
+
const parent = resolvers.Mutation.createNode(null, {
|
|
157
|
+
type: 'feature',
|
|
158
|
+
title: 'Parent Feature',
|
|
159
|
+
})
|
|
160
|
+
const child = resolvers.Mutation.createNode(null, {
|
|
161
|
+
type: 'task',
|
|
162
|
+
title: 'Child Task',
|
|
163
|
+
parentId: parent.id,
|
|
164
|
+
})
|
|
165
|
+
expect(child.id).toMatch(/^task_/)
|
|
166
|
+
// the node exists
|
|
167
|
+
expect(resolvers.Query.node(null, { id: child.id })?.id).toBe(child.id)
|
|
168
|
+
// a part_of edge child -> parent exists (child reachable from parent)
|
|
169
|
+
const children = resolvers.Query.descendants(null, {
|
|
170
|
+
nodeId: parent.id,
|
|
171
|
+
relation: 'part_of',
|
|
172
|
+
maxDepth: 1,
|
|
173
|
+
})
|
|
174
|
+
expect(children.map((n) => n.id)).toContain(child.id)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('writes a create audit row and a create_edge audit row for the link', () => {
|
|
178
|
+
const parent = resolvers.Mutation.createNode(null, {
|
|
179
|
+
type: 'feature',
|
|
180
|
+
title: 'Parent',
|
|
181
|
+
})
|
|
182
|
+
const child = resolvers.Mutation.createNode(null, {
|
|
183
|
+
type: 'task',
|
|
184
|
+
title: 'Child',
|
|
185
|
+
parentId: parent.id,
|
|
186
|
+
})
|
|
187
|
+
const childHistory = resolvers.Query.auditLog(null, { nodeId: child.id })
|
|
188
|
+
const actions = childHistory.map((e) => e.action)
|
|
189
|
+
expect(actions).toContain('create')
|
|
190
|
+
expect(actions).toContain('create_edge')
|
|
191
|
+
const edgeEntry = childHistory.find((e) => e.action === 'create_edge')
|
|
192
|
+
expect(edgeEntry?.field).toBe('part_of')
|
|
193
|
+
expect(edgeEntry?.newValue).toBe(parent.id)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('rejects a non-existent parent with NOT_FOUND and creates no orphan', () => {
|
|
197
|
+
const before = resolvers.Query.nodes(null, {})
|
|
198
|
+
try {
|
|
199
|
+
resolvers.Mutation.createNode(null, {
|
|
200
|
+
type: 'task',
|
|
201
|
+
title: 'Orphan?',
|
|
202
|
+
parentId: 'feat_does_not_exist',
|
|
203
|
+
})
|
|
204
|
+
throw new Error('expected createNode to throw')
|
|
205
|
+
} catch (err) {
|
|
206
|
+
const e = err as { message: string; extensions?: { code?: string } }
|
|
207
|
+
expect(e.message).toContain('feat_does_not_exist')
|
|
208
|
+
expect(e.extensions?.code).toBe('NOT_FOUND')
|
|
209
|
+
}
|
|
210
|
+
// nothing was written: no new node, no dangling edge
|
|
211
|
+
const after = resolvers.Query.nodes(null, {})
|
|
212
|
+
expect(after).toHaveLength(before.length)
|
|
213
|
+
expect(after.some((n) => n.title === 'Orphan?')).toBe(false)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('behaves exactly like a plain create when parentId is omitted', () => {
|
|
217
|
+
const node = resolvers.Mutation.createNode(null, {
|
|
218
|
+
type: 'task',
|
|
219
|
+
title: 'No parent',
|
|
220
|
+
})
|
|
221
|
+
expect(node.id).toMatch(/^task_/)
|
|
222
|
+
const history = resolvers.Query.auditLog(null, { nodeId: node.id })
|
|
223
|
+
expect(history.map((e) => e.action)).toEqual(['create'])
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
154
227
|
describe('Query.node', () => {
|
|
155
228
|
it('returns a node by id', () => {
|
|
156
229
|
const created = resolvers.Mutation.createNode(null, {
|
package/server/src/resolvers.ts
CHANGED
|
@@ -495,6 +495,13 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
|
|
|
495
495
|
},
|
|
496
496
|
|
|
497
497
|
Mutation: {
|
|
498
|
+
// Create a node, optionally linking it under a parent in one atomic step
|
|
499
|
+
// (F24). When `parentId` is given we validate the parent exists FIRST —
|
|
500
|
+
// before any write — then, in a SINGLE transaction, insert the node, its
|
|
501
|
+
// `create` audit row, the `part_of` edge (child -> parent), and the
|
|
502
|
+
// edge's `create_edge` audit row. A failure anywhere rolls the whole unit
|
|
503
|
+
// back, so a bad link can never leave an orphaned node. With no
|
|
504
|
+
// `parentId`, behaviour is unchanged: just the node + its create audit.
|
|
498
505
|
createNode: (
|
|
499
506
|
_: unknown,
|
|
500
507
|
args: {
|
|
@@ -503,6 +510,7 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
|
|
|
503
510
|
description?: string
|
|
504
511
|
status?: string
|
|
505
512
|
metadata?: string
|
|
513
|
+
parentId?: string
|
|
506
514
|
},
|
|
507
515
|
): NodeGql => {
|
|
508
516
|
if (!args.title.trim()) throw validationError('Title is required')
|
|
@@ -510,6 +518,15 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
|
|
|
510
518
|
throw validationError('Description cannot be empty')
|
|
511
519
|
}
|
|
512
520
|
if (args.status != null) assertValidStatus(args.status)
|
|
521
|
+
// Validate the parent up front so a bad link errors before any write.
|
|
522
|
+
if (args.parentId != null) {
|
|
523
|
+
const parentExists = db.raw
|
|
524
|
+
.query('SELECT id FROM nodes WHERE id = ?')
|
|
525
|
+
.get(args.parentId)
|
|
526
|
+
if (!parentExists) {
|
|
527
|
+
throw notFoundError(`Parent node ${args.parentId} not found`)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
513
530
|
const metadata =
|
|
514
531
|
args.metadata != null ? normalizeMetadata(args.metadata) : null
|
|
515
532
|
const id = generateId(args.type)
|
|
@@ -545,6 +562,18 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
|
|
|
545
562
|
action: 'create',
|
|
546
563
|
snapshot: node as unknown as Record<string, unknown>,
|
|
547
564
|
})
|
|
565
|
+
if (args.parentId != null) {
|
|
566
|
+
db.raw.run(
|
|
567
|
+
'INSERT INTO edges (source_id, target_id, relation, created_at) VALUES (?, ?, ?, ?)',
|
|
568
|
+
[id, args.parentId, 'part_of', now],
|
|
569
|
+
)
|
|
570
|
+
insertAudit(db, {
|
|
571
|
+
nodeId: id,
|
|
572
|
+
action: 'create_edge',
|
|
573
|
+
field: 'part_of',
|
|
574
|
+
newValue: args.parentId,
|
|
575
|
+
})
|
|
576
|
+
}
|
|
548
577
|
})()
|
|
549
578
|
return node
|
|
550
579
|
},
|
package/server/src/schema.ts
CHANGED
|
@@ -56,6 +56,50 @@ describe('feature command', () => {
|
|
|
56
56
|
)
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
+
test('create issues ONE createNode-with-parent call under the active project', async () => {
|
|
60
|
+
const { graphql } = await import('../util/client.ts')
|
|
61
|
+
const { requireProject } = await import('../util/config.ts')
|
|
62
|
+
const { output } = await import('../util/format.ts')
|
|
63
|
+
const { featureCommand } = await import('./feature.ts')
|
|
64
|
+
|
|
65
|
+
vi.mocked(requireProject).mockReturnValueOnce({
|
|
66
|
+
id: 'proj_active',
|
|
67
|
+
name: 'active',
|
|
68
|
+
})
|
|
69
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
70
|
+
createNode: { id: 'feat_new', title: 'Test' },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const createCmd = featureCommand.commands.find((c) => c.name() === 'create')
|
|
74
|
+
await createCmd?.parseAsync(['--title', 'Test', '--description', 'Desc'], {
|
|
75
|
+
from: 'user',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(graphql).toHaveBeenCalledTimes(1)
|
|
79
|
+
const [query, variables] = vi.mocked(graphql).mock.calls[0]!
|
|
80
|
+
expect(query).toContain('createNode')
|
|
81
|
+
expect(query).toContain('parentId')
|
|
82
|
+
expect(variables).toMatchObject({
|
|
83
|
+
type: 'feature',
|
|
84
|
+
title: 'Test',
|
|
85
|
+
description: 'Desc',
|
|
86
|
+
parentId: 'proj_active',
|
|
87
|
+
})
|
|
88
|
+
expect(output).toHaveBeenCalledWith({ id: 'feat_new', title: 'Test' })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('create validates the project BEFORE any write (no createNode on bad project)', async () => {
|
|
92
|
+
const { graphql } = await import('../util/client.ts')
|
|
93
|
+
const { featureCommand } = await import('./feature.ts')
|
|
94
|
+
|
|
95
|
+
const createCmd = featureCommand.commands.find((c) => c.name() === 'create')
|
|
96
|
+
await createCmd?.parseAsync(['--title', 'Test', '--description', 'Desc'], {
|
|
97
|
+
from: 'user',
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(graphql).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
|
|
59
103
|
test('unset calls updateProjectConfig to delete activeFeature', async () => {
|
|
60
104
|
const { featureCommand } = await import('./feature.ts')
|
|
61
105
|
const { output } = await import('../util/format.ts')
|
package/src/commands/feature.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
import { resolveDescription } from '../util/description.ts'
|
|
9
9
|
import { output, outputError } from '../util/format.ts'
|
|
10
10
|
import {
|
|
11
|
-
CREATE_EDGE,
|
|
12
11
|
CREATE_NODE,
|
|
13
12
|
DELETE_NODE,
|
|
14
13
|
DESCENDANTS,
|
|
@@ -35,21 +34,24 @@ featureCommand
|
|
|
35
34
|
)
|
|
36
35
|
.action(async (opts) => {
|
|
37
36
|
try {
|
|
37
|
+
// Validate the active project BEFORE any write so a bad project context
|
|
38
|
+
// errors cleanly instead of orphaning a node (F24).
|
|
38
39
|
const project = requireProject()
|
|
39
40
|
const description = await resolveDescription({
|
|
40
41
|
description: opts.description,
|
|
41
42
|
descriptionFile: opts.descriptionFile,
|
|
42
43
|
})
|
|
44
|
+
// One atomic call: the server creates the feature and its `part_of` edge
|
|
45
|
+
// to the project together, so a failed link can never leave an orphan.
|
|
43
46
|
const nodeData = await graphql<{ createNode: { id: string } }>(
|
|
44
47
|
CREATE_NODE,
|
|
45
|
-
{
|
|
48
|
+
{
|
|
49
|
+
type: 'feature',
|
|
50
|
+
title: opts.title,
|
|
51
|
+
description,
|
|
52
|
+
parentId: project.id,
|
|
53
|
+
},
|
|
46
54
|
)
|
|
47
|
-
const featureId = nodeData.createNode.id
|
|
48
|
-
await graphql(CREATE_EDGE, {
|
|
49
|
-
sourceId: featureId,
|
|
50
|
-
targetId: project.id,
|
|
51
|
-
relation: 'part_of',
|
|
52
|
-
})
|
|
53
55
|
output(nodeData.createNode)
|
|
54
56
|
} catch (error) {
|
|
55
57
|
outputError(error)
|
|
@@ -63,6 +63,50 @@ describe('task command', () => {
|
|
|
63
63
|
)
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
test('create validates the feature BEFORE any write (no createNode on bad feature)', async () => {
|
|
67
|
+
const { graphql } = await import('../util/client.ts')
|
|
68
|
+
const { taskCommand } = await import('./task.ts')
|
|
69
|
+
|
|
70
|
+
// requireFeature() throws (default mock) — the command must bail out before
|
|
71
|
+
// issuing any mutation, so the node is never created.
|
|
72
|
+
const createCmd = taskCommand.commands.find((c) => c.name() === 'create')!
|
|
73
|
+
await createCmd.parseAsync(['--title', 'Test', '--description', 'desc'], {
|
|
74
|
+
from: 'user',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(graphql).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('create issues ONE createNode-with-parent call (not a create then a link)', async () => {
|
|
81
|
+
const { graphql } = await import('../util/client.ts')
|
|
82
|
+
const { requireFeature } = await import('../util/config.ts')
|
|
83
|
+
const { output } = await import('../util/format.ts')
|
|
84
|
+
const { taskCommand } = await import('./task.ts')
|
|
85
|
+
|
|
86
|
+
vi.mocked(requireFeature).mockReturnValueOnce('feat_active')
|
|
87
|
+
vi.mocked(graphql).mockResolvedValueOnce({
|
|
88
|
+
createNode: { id: 'task_new', title: 'Test' },
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const createCmd = taskCommand.commands.find((c) => c.name() === 'create')!
|
|
92
|
+
await createCmd.parseAsync(['--title', 'Test', '--description', 'desc'], {
|
|
93
|
+
from: 'user',
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// exactly one GraphQL call, and it is the parented createNode
|
|
97
|
+
expect(graphql).toHaveBeenCalledTimes(1)
|
|
98
|
+
const [query, variables] = vi.mocked(graphql).mock.calls[0]!
|
|
99
|
+
expect(query).toContain('createNode')
|
|
100
|
+
expect(query).toContain('parentId')
|
|
101
|
+
expect(variables).toMatchObject({
|
|
102
|
+
type: 'task',
|
|
103
|
+
title: 'Test',
|
|
104
|
+
description: 'desc',
|
|
105
|
+
parentId: 'feat_active',
|
|
106
|
+
})
|
|
107
|
+
expect(output).toHaveBeenCalledWith({ id: 'task_new', title: 'Test' })
|
|
108
|
+
})
|
|
109
|
+
|
|
66
110
|
test('show calls outputError when graphql throws network error', async () => {
|
|
67
111
|
const { graphql } = await import('../util/client.ts')
|
|
68
112
|
const { outputError } = await import('../util/format.ts')
|
package/src/commands/task.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
BLOCK_TASK,
|
|
9
9
|
CREATE_TASK,
|
|
10
10
|
DELETE_NODE,
|
|
11
|
-
LINK_TASK,
|
|
12
11
|
LIST_TASKS,
|
|
13
12
|
READY_TASKS,
|
|
14
13
|
SHOW_TASK,
|
|
@@ -35,21 +34,20 @@ taskCommand
|
|
|
35
34
|
)
|
|
36
35
|
.action(async (opts) => {
|
|
37
36
|
try {
|
|
37
|
+
// Validate the active feature BEFORE any write so a bad FLOWY_FEATURE
|
|
38
|
+
// errors cleanly instead of orphaning a node (F24).
|
|
38
39
|
const featureId = requireFeature()
|
|
39
40
|
const description = await resolveDescription({
|
|
40
41
|
description: opts.description,
|
|
41
42
|
descriptionFile: opts.descriptionFile,
|
|
42
43
|
})
|
|
44
|
+
// One atomic call: the server creates the task and its `part_of` edge to
|
|
45
|
+
// the feature together, so a failed link can never leave an orphan.
|
|
43
46
|
const data = await graphql<{ createNode: { id: string } }>(CREATE_TASK, {
|
|
44
47
|
type: 'task',
|
|
45
48
|
title: opts.title,
|
|
46
49
|
description,
|
|
47
|
-
|
|
48
|
-
const taskId = data.createNode.id
|
|
49
|
-
await graphql(LINK_TASK, {
|
|
50
|
-
sourceId: taskId,
|
|
51
|
-
targetId: featureId,
|
|
52
|
-
relation: 'part_of',
|
|
50
|
+
parentId: featureId,
|
|
53
51
|
})
|
|
54
52
|
output(data.createNode)
|
|
55
53
|
} catch (error) {
|
|
@@ -57,7 +57,6 @@ describe('commands send canonical operations (no re-inlined queries)', () => {
|
|
|
57
57
|
],
|
|
58
58
|
'feature.ts': [
|
|
59
59
|
'CREATE_NODE',
|
|
60
|
-
'CREATE_EDGE',
|
|
61
60
|
'DESCENDANTS',
|
|
62
61
|
'DESCENDANTS_BRIEF',
|
|
63
62
|
'UPDATE_NODE',
|
|
@@ -66,7 +65,6 @@ describe('commands send canonical operations (no re-inlined queries)', () => {
|
|
|
66
65
|
],
|
|
67
66
|
'task.ts': [
|
|
68
67
|
'CREATE_TASK',
|
|
69
|
-
'LINK_TASK',
|
|
70
68
|
'READY_TASKS',
|
|
71
69
|
'ALL_TASKS',
|
|
72
70
|
'LIST_TASKS',
|
package/src/util/operations.ts
CHANGED
|
@@ -152,20 +152,44 @@ export const CREATE_PROJECT = `mutation CreateProject($type: String!, $title: St
|
|
|
152
152
|
}
|
|
153
153
|
}`
|
|
154
154
|
|
|
155
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
/**
|
|
156
|
+
* feature.ts `create` — create a feature node linked under its parent project
|
|
157
|
+
* in ONE transactional call (F24). The optional `parentId` makes the server
|
|
158
|
+
* create the node and the `part_of` edge atomically, so a failed link can never
|
|
159
|
+
* orphan the node. Both backends accept `parentId` (bundled local since P1-4;
|
|
160
|
+
* SaaS since flowy-ai v33). When omitted, it is a plain create.
|
|
161
|
+
*/
|
|
162
|
+
export const CREATE_NODE = `mutation CreateNode($type: String!, $title: String!, $description: String, $parentId: String) {
|
|
163
|
+
createNode(type: $type, title: $title, description: $description, parentId: $parentId) {
|
|
158
164
|
id type title description status createdAt updatedAt
|
|
159
165
|
}
|
|
160
166
|
}`
|
|
161
167
|
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
168
|
+
/**
|
|
169
|
+
* task.ts `create` — create a task node linked under its parent feature in ONE
|
|
170
|
+
* transactional call (F24); see CREATE_NODE for the `parentId` semantics.
|
|
171
|
+
*/
|
|
172
|
+
export const CREATE_TASK = `mutation CreateTask($type: String!, $title: String!, $description: String, $parentId: String) {
|
|
173
|
+
createNode(type: $type, title: $title, description: $description, parentId: $parentId) {
|
|
165
174
|
id type title description status createdAt
|
|
166
175
|
}
|
|
167
176
|
}`
|
|
168
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Contract op (P1-4/F24): create a node AND its `part_of` edge to a parent in a
|
|
180
|
+
* single atomic `createNode(parentId:)` call. This is the operation `task
|
|
181
|
+
* create` / `feature create` issue at runtime (via CREATE_TASK / CREATE_NODE);
|
|
182
|
+
* exercised explicitly by the contract test to assert the bundled local server
|
|
183
|
+
* creates the node + edge atomically and rejects a non-existent parent without
|
|
184
|
+
* leaving an orphan. The SaaS contract guard mirrors this op so both backends
|
|
185
|
+
* stay aligned on the parented-create surface.
|
|
186
|
+
*/
|
|
187
|
+
export const CREATE_NODE_WITH_PARENT = `mutation CreateNodeWithParent($type: String!, $title: String!, $description: String, $parentId: String) {
|
|
188
|
+
createNode(type: $type, title: $title, description: $description, parentId: $parentId) {
|
|
189
|
+
id type title description status createdAt updatedAt
|
|
190
|
+
}
|
|
191
|
+
}`
|
|
192
|
+
|
|
169
193
|
/** project/feature/task `update` — title/description/metadata. */
|
|
170
194
|
export const UPDATE_NODE = `mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
|
|
171
195
|
updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
|
|
@@ -324,6 +348,7 @@ export const LOCAL_CONTRACT_OPERATIONS = {
|
|
|
324
348
|
CREATE_PROJECT,
|
|
325
349
|
CREATE_NODE,
|
|
326
350
|
CREATE_TASK,
|
|
351
|
+
CREATE_NODE_WITH_PARENT,
|
|
327
352
|
UPDATE_NODE,
|
|
328
353
|
UPDATE_STATUS,
|
|
329
354
|
APPROVE_NODE,
|