@sqaoss/flowy 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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, {
@@ -1322,21 +1395,33 @@ describe('createResolvers', () => {
1322
1395
  })
1323
1396
 
1324
1397
  describe('Mutation.createEdge — edge cases', () => {
1325
- it('throws on duplicate edge', () => {
1398
+ it('is idempotent: creating the same edge twice succeeds and yields one edge', () => {
1326
1399
  const p = create(resolvers, { type: 'project', title: 'P' })
1327
1400
  const f = create(resolvers, { type: 'feature', title: 'F' })
1328
- resolvers.Mutation.createEdge(null, {
1401
+ const first = resolvers.Mutation.createEdge(null, {
1329
1402
  sourceId: f.id,
1330
1403
  targetId: p.id,
1331
1404
  relation: 'part_of',
1332
1405
  })
1333
- expect(() =>
1334
- resolvers.Mutation.createEdge(null, {
1335
- sourceId: f.id,
1336
- targetId: p.id,
1337
- relation: 'part_of',
1338
- }),
1339
- ).toThrow()
1406
+ const second = resolvers.Mutation.createEdge(null, {
1407
+ sourceId: f.id,
1408
+ targetId: p.id,
1409
+ relation: 'part_of',
1410
+ })
1411
+ // Both calls succeed and return the same canonical edge (same created_at).
1412
+ expect(second).toMatchObject({
1413
+ sourceId: f.id,
1414
+ targetId: p.id,
1415
+ relation: 'part_of',
1416
+ })
1417
+ expect(second.createdAt).toBe(first.createdAt)
1418
+ // Exactly one row exists for the triple — no duplicate.
1419
+ const { n } = db.raw
1420
+ .query(
1421
+ 'SELECT COUNT(*) AS n FROM edges WHERE source_id = ? AND target_id = ? AND relation = ?',
1422
+ )
1423
+ .get(f.id, p.id, 'part_of') as { n: number }
1424
+ expect(n).toBe(1)
1340
1425
  })
1341
1426
 
1342
1427
  it('throws when source node does not exist', () => {
@@ -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
  },
@@ -749,25 +778,39 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
749
778
  throw validationError('A node cannot block itself')
750
779
  }
751
780
  const now = new Date().toISOString()
781
+ // Idempotent (F29): re-creating an existing edge is a no-op success, not
782
+ // a PK-violation error. ON CONFLICT DO NOTHING leaves the original row
783
+ // (and its created_at) untouched; we only audit a genuinely new edge.
752
784
  db.raw.transaction(() => {
753
- db.raw.run(
754
- 'INSERT INTO edges (source_id, target_id, relation, created_at) VALUES (?, ?, ?, ?)',
785
+ const result = db.raw.run(
786
+ 'INSERT INTO edges (source_id, target_id, relation, created_at) VALUES (?, ?, ?, ?) ON CONFLICT (source_id, target_id, relation) DO NOTHING',
755
787
  [args.sourceId, args.targetId, args.relation, now],
756
788
  )
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
- })
789
+ if (result.changes > 0) {
790
+ // Record the edge against its source node so `auditLog(sourceId)`
791
+ // surfaces it. field = relation; newValue = the target it now links.
792
+ insertAudit(db, {
793
+ nodeId: args.sourceId,
794
+ action: 'create_edge',
795
+ field: args.relation,
796
+ newValue: args.targetId,
797
+ })
798
+ }
765
799
  })()
800
+ // Read back the canonical row so the returned createdAt reflects the
801
+ // existing edge on a duplicate call, not the discarded `now`.
802
+ const edge = db.raw
803
+ .query(
804
+ 'SELECT created_at FROM edges WHERE source_id = ? AND target_id = ? AND relation = ?',
805
+ )
806
+ .get(args.sourceId, args.targetId, args.relation) as {
807
+ created_at: string
808
+ }
766
809
  return {
767
810
  sourceId: args.sourceId,
768
811
  targetId: args.targetId,
769
812
  relation: args.relation,
770
- createdAt: now,
813
+ createdAt: edge.created_at,
771
814
  }
772
815
  },
773
816
 
@@ -85,6 +85,7 @@ export const typeDefs = /* GraphQL */ `
85
85
  description: String
86
86
  status: String
87
87
  metadata: String
88
+ parentId: String
88
89
  ): Node!
89
90
  updateNode(
90
91
  id: String!
@@ -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')
@@ -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
- { type: 'feature', title: opts.title, description },
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')
@@ -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',
@@ -152,20 +152,44 @@ export const CREATE_PROJECT = `mutation CreateProject($type: String!, $title: St
152
152
  }
153
153
  }`
154
154
 
155
- /** feature.ts `create` — create a node with a description. */
156
- export const CREATE_NODE = `mutation CreateNode($type: String!, $title: String!, $description: String) {
157
- createNode(type: $type, title: $title, description: $description) {
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
- /** task.ts `create` — create a task node. */
163
- export const CREATE_TASK = `mutation CreateTask($type: String!, $title: String!, $description: String) {
164
- createNode(type: $type, title: $title, description: $description) {
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,