@sqaoss/flowy 1.2.2 → 1.3.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.
@@ -0,0 +1,140 @@
1
+ import type { Database } from 'bun:sqlite'
2
+
3
+ /**
4
+ * Versioned schema migrations for the bundled local SQLite server.
5
+ *
6
+ * The schema version is tracked with SQLite's `PRAGMA user_version`. Each entry
7
+ * in `MIGRATIONS` is a step whose 1-based ordinal is the version it produces:
8
+ * step at index `i` upgrades the DB from `user_version = i` to `user_version = i + 1`.
9
+ * On startup we apply every step whose target version exceeds the current
10
+ * `user_version`, in order, each wrapped in its own transaction so a failure
11
+ * leaves the DB at a clean, consistent version. Re-running is a no-op.
12
+ */
13
+
14
+ type Migration = (db: Database) => void
15
+
16
+ /**
17
+ * Rebuild a table with a new schema, preserving its rows. SQLite cannot ALTER a
18
+ * CHECK constraint in place, so changing column constraints requires creating a
19
+ * new table, copying rows across the shared columns, dropping the old table, and
20
+ * renaming. The whole sequence runs inside a transaction.
21
+ */
22
+ export function rebuildTable(
23
+ db: Database,
24
+ table: string,
25
+ createNewTableSql: (tmpName: string) => string,
26
+ columns: string[],
27
+ ): void {
28
+ const tmp = `${table}__migrate_tmp`
29
+ const cols = columns.join(', ')
30
+ db.run('PRAGMA foreign_keys = OFF')
31
+ db.transaction(() => {
32
+ db.run(createNewTableSql(tmp))
33
+ db.run(`INSERT INTO ${tmp} (${cols}) SELECT ${cols} FROM ${table}`)
34
+ db.run(`DROP TABLE ${table}`)
35
+ db.run(`ALTER TABLE ${tmp} RENAME TO ${table}`)
36
+ })()
37
+ db.run('PRAGMA foreign_keys = ON')
38
+ }
39
+
40
+ const MIGRATIONS: Migration[] = [
41
+ // 0 -> 1: base schema (nodes + edges + indexes). Uses IF NOT EXISTS so this
42
+ // step is harmless on a DB that predates user_version tracking but already
43
+ // has the tables (created by an old `CREATE TABLE IF NOT EXISTS` startup).
44
+ (db) => {
45
+ db.run(`
46
+ CREATE TABLE IF NOT EXISTS nodes (
47
+ id TEXT PRIMARY KEY,
48
+ type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
49
+ title TEXT NOT NULL,
50
+ description TEXT,
51
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done')),
52
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
53
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
54
+ )
55
+ `)
56
+ db.run(`
57
+ CREATE TABLE IF NOT EXISTS edges (
58
+ source_id TEXT NOT NULL REFERENCES nodes(id),
59
+ target_id TEXT NOT NULL REFERENCES nodes(id),
60
+ relation TEXT NOT NULL CHECK(relation IN ('part_of', 'blocks')),
61
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
62
+ PRIMARY KEY (source_id, target_id, relation)
63
+ )
64
+ `)
65
+ db.run('CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)')
66
+ db.run('CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status)')
67
+ db.run(
68
+ 'CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id, relation)',
69
+ )
70
+ db.run(
71
+ 'CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id, relation)',
72
+ )
73
+ },
74
+
75
+ // 1 -> 2: (a) add a writable `metadata` TEXT column if it is missing, then
76
+ // (b) rebuild `nodes` to widen the status CHECK to include 'blocked' and
77
+ // 'cancelled'. Adding the column first lets the CHECK-rebuild copy a uniform
78
+ // set of columns regardless of the pre-existing shape.
79
+ (db) => {
80
+ const hasMetadata = db
81
+ .query<{ name: string }, []>('PRAGMA table_info(nodes)')
82
+ .all()
83
+ .some((c) => c.name === 'metadata')
84
+ if (!hasMetadata) {
85
+ db.run('ALTER TABLE nodes ADD COLUMN metadata TEXT')
86
+ }
87
+
88
+ rebuildTable(
89
+ db,
90
+ 'nodes',
91
+ (tmp) => `
92
+ CREATE TABLE ${tmp} (
93
+ id TEXT PRIMARY KEY,
94
+ type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
95
+ title TEXT NOT NULL,
96
+ description TEXT,
97
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done', 'blocked', 'cancelled')),
98
+ metadata TEXT,
99
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
100
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
101
+ )
102
+ `,
103
+ [
104
+ 'id',
105
+ 'type',
106
+ 'title',
107
+ 'description',
108
+ 'status',
109
+ 'metadata',
110
+ 'created_at',
111
+ 'updated_at',
112
+ ],
113
+ )
114
+
115
+ // Indexes are dropped with the old table; recreate them.
116
+ db.run('CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)')
117
+ db.run('CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status)')
118
+ },
119
+ ]
120
+
121
+ export const LATEST_VERSION = MIGRATIONS.length
122
+
123
+ function getUserVersion(db: Database): number {
124
+ const row = db
125
+ .query<{ user_version: number }, []>('PRAGMA user_version')
126
+ .get()
127
+ return row?.user_version ?? 0
128
+ }
129
+
130
+ export function runMigrations(db: Database): void {
131
+ const current = getUserVersion(db)
132
+ MIGRATIONS.forEach((migration, index) => {
133
+ const targetVersion = index + 1
134
+ if (targetVersion <= current) return
135
+ migration(db)
136
+ // PRAGMA user_version does not accept bound parameters; the value is an
137
+ // integer derived from our own loop index, never user input.
138
+ db.run(`PRAGMA user_version = ${targetVersion}`)
139
+ })
140
+ }
@@ -96,6 +96,59 @@ describe('createResolvers', () => {
96
96
  expect(typeof node.createdAt).toBe('string')
97
97
  expect(typeof node.updatedAt).toBe('string')
98
98
  })
99
+
100
+ it('persists metadata as JSON and reads it back unchanged', () => {
101
+ const created = resolvers.Mutation.createNode(null, {
102
+ type: 'task',
103
+ title: 'With meta',
104
+ metadata: '{"priority":"high","effort":3}',
105
+ })
106
+ expect(created.metadata).toBe('{"priority":"high","effort":3}')
107
+ const found = find(resolvers, created.id)
108
+ expect(JSON.parse(found.metadata as string)).toEqual({
109
+ priority: 'high',
110
+ effort: 3,
111
+ })
112
+ })
113
+
114
+ it('accepts an explicit initial status', () => {
115
+ const node = resolvers.Mutation.createNode(null, {
116
+ type: 'task',
117
+ title: 'Started',
118
+ status: 'in_progress',
119
+ })
120
+ expect(node.status).toBe('in_progress')
121
+ })
122
+
123
+ it('rejects an invalid initial status with VALIDATION_ERROR', () => {
124
+ try {
125
+ resolvers.Mutation.createNode(null, {
126
+ type: 'task',
127
+ title: 'Bad',
128
+ status: 'bogus',
129
+ })
130
+ throw new Error('expected createNode to throw')
131
+ } catch (err) {
132
+ const e = err as { message: string; extensions?: { code?: string } }
133
+ expect(e.message).toContain('Invalid status: bogus')
134
+ expect(e.extensions?.code).toBe('VALIDATION_ERROR')
135
+ }
136
+ })
137
+
138
+ it('rejects non-JSON metadata with VALIDATION_ERROR', () => {
139
+ try {
140
+ resolvers.Mutation.createNode(null, {
141
+ type: 'task',
142
+ title: 'Bad meta',
143
+ metadata: 'not json',
144
+ })
145
+ throw new Error('expected createNode to throw')
146
+ } catch (err) {
147
+ const e = err as { message: string; extensions?: { code?: string } }
148
+ expect(e.message).toContain('metadata')
149
+ expect(e.extensions?.code).toBe('VALIDATION_ERROR')
150
+ }
151
+ })
99
152
  })
100
153
 
101
154
  describe('Query.node', () => {
@@ -112,9 +165,17 @@ describe('createResolvers', () => {
112
165
  })
113
166
  })
114
167
 
115
- it('returns null for non-existent id', () => {
116
- const found = resolvers.Query.node(null, { id: 'nonexistent' })
117
- expect(found).toBeNull()
168
+ it('throws NOT_FOUND for a non-existent id (no silent null)', () => {
169
+ expect(() => resolvers.Query.node(null, { id: 'nonexistent' })).toThrow(
170
+ 'Node nonexistent not found',
171
+ )
172
+ try {
173
+ resolvers.Query.node(null, { id: 'nonexistent' })
174
+ } catch (error) {
175
+ expect(
176
+ (error as { extensions?: { code?: string } }).extensions?.code,
177
+ ).toBe('NOT_FOUND')
178
+ }
118
179
  })
119
180
  })
120
181
 
@@ -161,6 +222,192 @@ describe('createResolvers', () => {
161
222
  }),
162
223
  ).toThrow('Node nonexistent not found')
163
224
  })
225
+
226
+ it('updates the title, leaving other fields untouched', () => {
227
+ const node = create(resolvers, {
228
+ type: 'task',
229
+ title: 'Old title',
230
+ description: 'keep me',
231
+ })
232
+ const updated = resolvers.Mutation.updateNode(null, {
233
+ id: node.id,
234
+ title: 'New title',
235
+ })
236
+ expect(updated.title).toBe('New title')
237
+ expect(updated.description).toBe('keep me')
238
+ expect(updated.status).toBe('draft')
239
+ })
240
+
241
+ it('updates the description independently', () => {
242
+ const node = create(resolvers, {
243
+ type: 'task',
244
+ title: 'Title',
245
+ description: 'old desc',
246
+ })
247
+ const updated = resolvers.Mutation.updateNode(null, {
248
+ id: node.id,
249
+ description: 'new desc',
250
+ })
251
+ expect(updated.description).toBe('new desc')
252
+ expect(updated.title).toBe('Title')
253
+ })
254
+
255
+ it('updates metadata independently and round-trips', () => {
256
+ const node = create(resolvers, { type: 'task', title: 'Title' })
257
+ const updated = resolvers.Mutation.updateNode(null, {
258
+ id: node.id,
259
+ metadata: '{"source":"import","effort":5}',
260
+ })
261
+ expect(JSON.parse(updated.metadata as string)).toEqual({
262
+ source: 'import',
263
+ effort: 5,
264
+ })
265
+ const found = find(resolvers, node.id)
266
+ expect(JSON.parse(found.metadata as string)).toEqual({
267
+ source: 'import',
268
+ effort: 5,
269
+ })
270
+ })
271
+
272
+ it('updates several fields at once', () => {
273
+ const node = create(resolvers, { type: 'task', title: 'Title' })
274
+ const updated = resolvers.Mutation.updateNode(null, {
275
+ id: node.id,
276
+ title: 'Renamed',
277
+ description: 'desc',
278
+ status: 'in_progress',
279
+ metadata: '{"k":"v"}',
280
+ })
281
+ expect(updated.title).toBe('Renamed')
282
+ expect(updated.description).toBe('desc')
283
+ expect(updated.status).toBe('in_progress')
284
+ expect(JSON.parse(updated.metadata as string)).toEqual({ k: 'v' })
285
+ })
286
+
287
+ it('rejects non-JSON metadata on update with VALIDATION_ERROR', () => {
288
+ const node = create(resolvers, { type: 'task', title: 'Title' })
289
+ try {
290
+ resolvers.Mutation.updateNode(null, {
291
+ id: node.id,
292
+ metadata: 'nope',
293
+ })
294
+ throw new Error('expected updateNode to throw')
295
+ } catch (err) {
296
+ const e = err as { message: string; extensions?: { code?: string } }
297
+ expect(e.message).toContain('metadata')
298
+ expect(e.extensions?.code).toBe('VALIDATION_ERROR')
299
+ }
300
+ })
301
+
302
+ it('not-found error carries the NOT_FOUND code', () => {
303
+ try {
304
+ resolvers.Mutation.updateNode(null, {
305
+ id: 'missing',
306
+ title: 'x',
307
+ })
308
+ throw new Error('expected updateNode to throw')
309
+ } catch (err) {
310
+ const e = err as { extensions?: { code?: string } }
311
+ expect(e.extensions?.code).toBe('NOT_FOUND')
312
+ }
313
+ })
314
+
315
+ it('rejects an empty title with VALIDATION_ERROR', () => {
316
+ const node = create(resolvers, { type: 'task', title: 'Title' })
317
+ try {
318
+ resolvers.Mutation.updateNode(null, { id: node.id, title: ' ' })
319
+ throw new Error('expected updateNode to throw')
320
+ } catch (err) {
321
+ const e = err as { extensions?: { code?: string } }
322
+ expect(e.extensions?.code).toBe('VALIDATION_ERROR')
323
+ }
324
+ })
325
+ })
326
+
327
+ describe('Mutation.deleteNode', () => {
328
+ it('deletes a leaf node and returns true', () => {
329
+ const node = create(resolvers, { type: 'task', title: 'Leaf' })
330
+ const result = resolvers.Mutation.deleteNode(null, { id: node.id })
331
+ expect(result).toBe(true)
332
+ // After deletion the node is gone: Query.node now fails loud (NOT_FOUND).
333
+ expect(() => resolvers.Query.node(null, { id: node.id })).toThrow(
334
+ `Node ${node.id} not found`,
335
+ )
336
+ })
337
+
338
+ it('removes incident blocks edges when deleting a leaf', () => {
339
+ const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
340
+ const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
341
+ resolvers.Mutation.createEdge(null, {
342
+ sourceId: blocker.id,
343
+ targetId: blocked.id,
344
+ relation: 'blocks',
345
+ })
346
+
347
+ resolvers.Mutation.deleteNode(null, { id: blocked.id })
348
+
349
+ // the blocks edge that referenced the deleted node must be gone
350
+ const edges = db.raw
351
+ .query<{ c: number }, [string, string]>(
352
+ 'SELECT COUNT(*) AS c FROM edges WHERE source_id = ? OR target_id = ?',
353
+ )
354
+ .get(blocked.id, blocked.id) as { c: number }
355
+ expect(edges.c).toBe(0)
356
+ })
357
+
358
+ it('deletes a node together with its part_of edge to its parent', () => {
359
+ const project = create(resolvers, { type: 'project', title: 'P' })
360
+ const feature = create(resolvers, { type: 'feature', title: 'F' })
361
+ resolvers.Mutation.createEdge(null, {
362
+ sourceId: feature.id,
363
+ targetId: project.id,
364
+ relation: 'part_of',
365
+ })
366
+
367
+ const result = resolvers.Mutation.deleteNode(null, { id: feature.id })
368
+ expect(result).toBe(true)
369
+ // parent survives
370
+ expect(resolvers.Query.node(null, { id: project.id })).not.toBeNull()
371
+ // the part_of edge is gone
372
+ const edges = db.raw
373
+ .query<{ c: number }, [string]>(
374
+ 'SELECT COUNT(*) AS c FROM edges WHERE source_id = ?',
375
+ )
376
+ .get(feature.id) as { c: number }
377
+ expect(edges.c).toBe(0)
378
+ })
379
+
380
+ it('refuses to delete a node that has children (CONFLICT)', () => {
381
+ const project = create(resolvers, { type: 'project', title: 'P' })
382
+ const feature = create(resolvers, { type: 'feature', title: 'F' })
383
+ resolvers.Mutation.createEdge(null, {
384
+ sourceId: feature.id,
385
+ targetId: project.id,
386
+ relation: 'part_of',
387
+ })
388
+
389
+ try {
390
+ resolvers.Mutation.deleteNode(null, { id: project.id })
391
+ throw new Error('expected deleteNode to throw')
392
+ } catch (err) {
393
+ const e = err as { message: string; extensions?: { code?: string } }
394
+ expect(e.extensions?.code).toBe('CONFLICT')
395
+ expect(e.message).toContain('child')
396
+ }
397
+ // node not deleted
398
+ expect(resolvers.Query.node(null, { id: project.id })).not.toBeNull()
399
+ })
400
+
401
+ it('throws NOT_FOUND for a missing node', () => {
402
+ try {
403
+ resolvers.Mutation.deleteNode(null, { id: 'missing' })
404
+ throw new Error('expected deleteNode to throw')
405
+ } catch (err) {
406
+ const e = err as { message: string; extensions?: { code?: string } }
407
+ expect(e.extensions?.code).toBe('NOT_FOUND')
408
+ expect(e.message).toContain('missing')
409
+ }
410
+ })
164
411
  })
165
412
 
166
413
  describe('Mutation.approveNode', () => {
@@ -59,6 +59,30 @@ const VALID_STATUSES = new Set([
59
59
  'cancelled',
60
60
  ])
61
61
 
62
+ function assertValidStatus(status: string): void {
63
+ if (!VALID_STATUSES.has(status)) {
64
+ throw validationError(
65
+ `Invalid status: ${status}. Must be one of: ${[...VALID_STATUSES].join(', ')}`,
66
+ )
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Validate that `metadata` is a JSON string and return its canonical form.
72
+ * Metadata is stored as a JSON string (the column and the GraphQL field are
73
+ * both String); callers pass a JSON-encoded string. Non-JSON input is rejected
74
+ * with a VALIDATION_ERROR so agents can self-correct.
75
+ */
76
+ function normalizeMetadata(metadata: string): string {
77
+ let parsed: unknown
78
+ try {
79
+ parsed = JSON.parse(metadata)
80
+ } catch {
81
+ throw validationError('Invalid metadata: must be a valid JSON string')
82
+ }
83
+ return JSON.stringify(parsed)
84
+ }
85
+
62
86
  function generateId(type: string): string {
63
87
  const prefix = PREFIX_MAP[type] ?? type
64
88
  return `${prefix}_${nanoid(12)}`
@@ -98,7 +122,9 @@ export function createResolvers(db: Db) {
98
122
  return {
99
123
  Query: {
100
124
  node: (_: unknown, args: { id: string }) => {
101
- return selectNode(db, args.id)
125
+ const node = selectNode(db, args.id)
126
+ if (!node) throw notFoundError(`Node ${args.id} not found`)
127
+ return node
102
128
  },
103
129
 
104
130
  nodes: (_: unknown, args: { type?: string }) => {
@@ -206,49 +232,120 @@ export function createResolvers(db: Db) {
206
232
  Mutation: {
207
233
  createNode: (
208
234
  _: unknown,
209
- args: { type: string; title: string; description?: string },
235
+ args: {
236
+ type: string
237
+ title: string
238
+ description?: string
239
+ status?: string
240
+ metadata?: string
241
+ },
210
242
  ): NodeGql => {
211
243
  if (!args.title.trim()) throw validationError('Title is required')
212
244
  if (args.description != null && !args.description.trim()) {
213
245
  throw validationError('Description cannot be empty')
214
246
  }
247
+ if (args.status != null) assertValidStatus(args.status)
248
+ const metadata =
249
+ args.metadata != null ? normalizeMetadata(args.metadata) : null
215
250
  const id = generateId(args.type)
216
251
  const now = new Date().toISOString()
217
252
  const description = args.description ?? null
253
+ const status = args.status ?? 'draft'
218
254
  db.raw.run(
219
255
  'INSERT INTO nodes (id, type, title, description, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
220
- [id, args.type, args.title, description, 'draft', null, now, now],
256
+ [id, args.type, args.title, description, status, metadata, now, now],
221
257
  )
222
258
  return {
223
259
  id,
224
260
  type: args.type,
225
261
  title: args.title,
226
262
  description,
227
- status: 'draft',
228
- metadata: null,
263
+ status,
264
+ metadata,
229
265
  createdAt: now,
230
266
  updatedAt: now,
231
267
  }
232
268
  },
233
269
 
234
- updateNode: (_: unknown, args: { id: string; status?: string }) => {
270
+ updateNode: (
271
+ _: unknown,
272
+ args: {
273
+ id: string
274
+ title?: string
275
+ description?: string
276
+ status?: string
277
+ metadata?: string
278
+ },
279
+ ) => {
235
280
  const existing = db.raw
236
281
  .query('SELECT * FROM nodes WHERE id = ?')
237
282
  .get(args.id) as NodeRow | null
238
283
  if (!existing) throw notFoundError(`Node ${args.id} not found`)
239
- const newStatus = args.status ?? existing.status
240
- if (args.status && !VALID_STATUSES.has(args.status)) {
241
- throw validationError(
242
- `Invalid status: ${args.status}. Must be one of: ${[...VALID_STATUSES].join(', ')}`,
243
- )
284
+
285
+ if (args.title != null && !args.title.trim()) {
286
+ throw validationError('Title cannot be empty')
287
+ }
288
+ if (args.description != null && !args.description.trim()) {
289
+ throw validationError('Description cannot be empty')
290
+ }
291
+ if (args.status != null) assertValidStatus(args.status)
292
+
293
+ const next: NodeRow = {
294
+ ...existing,
295
+ title: args.title ?? existing.title,
296
+ description:
297
+ args.description !== undefined
298
+ ? args.description
299
+ : existing.description,
300
+ status: args.status ?? existing.status,
301
+ metadata:
302
+ args.metadata != null
303
+ ? normalizeMetadata(args.metadata)
304
+ : existing.metadata,
244
305
  }
245
306
  const now = new Date().toISOString()
246
- db.raw.run('UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?', [
247
- newStatus,
248
- now,
249
- args.id,
250
- ])
251
- return rowToNode({ ...existing, status: newStatus, updated_at: now })
307
+ db.raw.run(
308
+ 'UPDATE nodes SET title = ?, description = ?, status = ?, metadata = ?, updated_at = ? WHERE id = ?',
309
+ [
310
+ next.title,
311
+ next.description,
312
+ next.status,
313
+ next.metadata,
314
+ now,
315
+ args.id,
316
+ ],
317
+ )
318
+ return rowToNode({ ...next, updated_at: now })
319
+ },
320
+
321
+ deleteNode: (_: unknown, args: { id: string }): boolean => {
322
+ const existing = db.raw
323
+ .query('SELECT id FROM nodes WHERE id = ?')
324
+ .get(args.id) as { id: string } | null
325
+ if (!existing) throw notFoundError(`Node ${args.id} not found`)
326
+
327
+ // The hierarchy is client -> project -> feature -> task via `part_of`
328
+ // edges (source = child, target = parent). A node with children must
329
+ // not be orphaned; reject rather than cascade-delete the subtree.
330
+ const childCount = db.raw
331
+ .query(
332
+ 'SELECT COUNT(*) AS c FROM edges WHERE target_id = ? AND relation = ?',
333
+ )
334
+ .get(args.id, 'part_of') as { c: number }
335
+ if (childCount.c > 0) {
336
+ throw conflictError(
337
+ `Cannot delete node ${args.id}: it has ${childCount.c} child node(s). Delete or re-link them first.`,
338
+ )
339
+ }
340
+
341
+ db.raw.transaction(() => {
342
+ db.raw.run('DELETE FROM edges WHERE source_id = ? OR target_id = ?', [
343
+ args.id,
344
+ args.id,
345
+ ])
346
+ db.raw.run('DELETE FROM nodes WHERE id = ?', [args.id])
347
+ })()
348
+ return true
252
349
  },
253
350
 
254
351
  approveNode: (_: unknown, args: { id: string }) => {
@@ -70,7 +70,7 @@ describe('schema', () => {
70
70
  expect(fields.tree).toBeUndefined()
71
71
  })
72
72
 
73
- it('Mutation type has createNode, updateNode, approveNode, createEdge, removeEdge', () => {
73
+ it('Mutation type has createNode, updateNode, approveNode, deleteNode, createEdge, removeEdge', () => {
74
74
  const mutationType = schema.getType(
75
75
  'Mutation',
76
76
  ) as import('graphql').GraphQLObjectType
@@ -78,16 +78,50 @@ describe('schema', () => {
78
78
  expect(fields.createNode).toBeDefined()
79
79
  expect(fields.updateNode).toBeDefined()
80
80
  expect(fields.approveNode).toBeDefined()
81
+ expect(fields.deleteNode).toBeDefined()
81
82
  expect(fields.createEdge).toBeDefined()
82
83
  expect(fields.removeEdge).toBeDefined()
83
84
  })
84
85
 
85
- it('Mutation type does not have deleteNode or deleteEdge', () => {
86
+ it('createNode accepts type, title, description, status, metadata args', () => {
86
87
  const mutationType = schema.getType(
87
88
  'Mutation',
88
89
  ) as import('graphql').GraphQLObjectType
89
- const fields = mutationType.getFields()
90
- expect(fields.deleteNode).toBeUndefined()
91
- expect(fields.deleteEdge).toBeUndefined()
90
+ const args = mutationType.getFields().createNode.args.map((a) => a.name)
91
+ expect(args).toEqual(
92
+ expect.arrayContaining([
93
+ 'type',
94
+ 'title',
95
+ 'description',
96
+ 'status',
97
+ 'metadata',
98
+ ]),
99
+ )
100
+ })
101
+
102
+ it('updateNode accepts id, title, description, status, metadata args', () => {
103
+ const mutationType = schema.getType(
104
+ 'Mutation',
105
+ ) as import('graphql').GraphQLObjectType
106
+ const args = mutationType.getFields().updateNode.args.map((a) => a.name)
107
+ expect(args).toEqual(
108
+ expect.arrayContaining([
109
+ 'id',
110
+ 'title',
111
+ 'description',
112
+ 'status',
113
+ 'metadata',
114
+ ]),
115
+ )
116
+ })
117
+
118
+ it('deleteNode returns Boolean and takes an id arg', () => {
119
+ const mutationType = schema.getType(
120
+ 'Mutation',
121
+ ) as import('graphql').GraphQLObjectType
122
+ const deleteNode = mutationType.getFields().deleteNode
123
+ const args = deleteNode.args.map((a) => a.name)
124
+ expect(args).toEqual(expect.arrayContaining(['id']))
125
+ expect(String(deleteNode.type)).toContain('Boolean')
92
126
  })
93
127
  })
@@ -28,9 +28,22 @@ export const typeDefs = /* GraphQL */ `
28
28
  }
29
29
 
30
30
  type Mutation {
31
- createNode(type: String!, title: String!, description: String): Node!
32
- updateNode(id: String!, status: String): Node!
31
+ createNode(
32
+ type: String!
33
+ title: String!
34
+ description: String
35
+ status: String
36
+ metadata: String
37
+ ): Node!
38
+ updateNode(
39
+ id: String!
40
+ title: String
41
+ description: String
42
+ status: String
43
+ metadata: String
44
+ ): Node!
33
45
  approveNode(id: String!): Node!
46
+ deleteNode(id: String!): Boolean!
34
47
  createEdge(sourceId: String!, targetId: String!, relation: String!): Edge!
35
48
  removeEdge(sourceId: String!, targetId: String!, relation: String!): Boolean!
36
49
  }