@sqaoss/flowy 1.2.2 → 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -65,7 +65,6 @@
65
65
  "husky": "^9.1.7",
66
66
  "lint-staged": "^16.3.0",
67
67
  "semantic-release": "25.0.3",
68
- "tdd-guard-vitest": "^0.1.6",
69
68
  "typescript": "^5",
70
69
  "vitest": "^4.1.2"
71
70
  },
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { createDb } from './db.ts'
3
+ import { LATEST_VERSION } from './migrations.ts'
3
4
 
4
5
  describe('createDb', () => {
5
6
  it('creates nodes and edges tables', () => {
@@ -90,4 +91,51 @@ describe('createDb', () => {
90
91
 
91
92
  db.close()
92
93
  })
94
+
95
+ it('runs migrations to the latest user_version on a fresh DB', () => {
96
+ const db = createDb(':memory:')
97
+
98
+ const result = db.raw
99
+ .query<{ user_version: number }, []>('PRAGMA user_version')
100
+ .get()
101
+
102
+ expect(result?.user_version).toBe(LATEST_VERSION)
103
+
104
+ db.close()
105
+ })
106
+
107
+ it('has a writable metadata column on nodes', () => {
108
+ const db = createDb(':memory:')
109
+
110
+ const columns = db.raw
111
+ .query<{ name: string }, []>('PRAGMA table_info(nodes)')
112
+ .all()
113
+ .map((c) => c.name)
114
+ expect(columns).toContain('metadata')
115
+
116
+ expect(() =>
117
+ db.raw.run(
118
+ `INSERT INTO nodes (id, type, title, metadata) VALUES ('n1', 'task', 'T', '{"a":1}')`,
119
+ ),
120
+ ).not.toThrow()
121
+
122
+ db.close()
123
+ })
124
+
125
+ it('accepts the blocked and cancelled statuses', () => {
126
+ const db = createDb(':memory:')
127
+
128
+ expect(() =>
129
+ db.raw.run(
130
+ "INSERT INTO nodes (id, type, title, status) VALUES ('n1', 'task', 'T', 'blocked')",
131
+ ),
132
+ ).not.toThrow()
133
+ expect(() =>
134
+ db.raw.run(
135
+ "INSERT INTO nodes (id, type, title, status) VALUES ('n2', 'task', 'T', 'cancelled')",
136
+ ),
137
+ ).not.toThrow()
138
+
139
+ db.close()
140
+ })
93
141
  })
package/server/src/db.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Database } from 'bun:sqlite'
2
+ import { runMigrations } from './migrations.ts'
2
3
 
3
4
  export type FlowyDb = ReturnType<typeof createDb>
4
5
 
@@ -8,37 +9,7 @@ export function createDb(path: string) {
8
9
  db.run('PRAGMA journal_mode = WAL')
9
10
  db.run('PRAGMA foreign_keys = ON')
10
11
 
11
- db.run(`
12
- CREATE TABLE IF NOT EXISTS nodes (
13
- id TEXT PRIMARY KEY,
14
- type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
15
- title TEXT NOT NULL,
16
- description TEXT,
17
- status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done', 'blocked', 'cancelled')),
18
- metadata TEXT,
19
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
20
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
21
- )
22
- `)
23
-
24
- db.run(`
25
- CREATE TABLE IF NOT EXISTS edges (
26
- source_id TEXT NOT NULL REFERENCES nodes(id),
27
- target_id TEXT NOT NULL REFERENCES nodes(id),
28
- relation TEXT NOT NULL CHECK(relation IN ('part_of', 'blocks')),
29
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
30
- PRIMARY KEY (source_id, target_id, relation)
31
- )
32
- `)
33
-
34
- db.run('CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)')
35
- db.run('CREATE INDEX IF NOT EXISTS idx_nodes_status ON nodes(status)')
36
- db.run(
37
- 'CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id, relation)',
38
- )
39
- db.run(
40
- 'CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id, relation)',
41
- )
12
+ runMigrations(db)
42
13
 
43
14
  return {
44
15
  raw: db,
@@ -94,4 +94,109 @@ describe('server error masking', () => {
94
94
  expect(error.message).not.toBe('Unexpected error.')
95
95
  expect(error.extensions?.code).toBe('CONFLICT')
96
96
  })
97
+
98
+ it('round-trips metadata through createNode and the node query', async () => {
99
+ instance = createServer({ dbPath: ':memory:', port: 0 })
100
+
101
+ const created = (await gql(
102
+ 'mutation ($m: String!) { createNode(type: "task", title: "T", metadata: $m) { id metadata } }',
103
+ { m: '{"priority":"high"}' },
104
+ )) as { data: { createNode: { id: string; metadata: string } } }
105
+ expect(created.data.createNode.metadata).toBe('{"priority":"high"}')
106
+
107
+ const id = created.data.createNode.id
108
+ const fetched = (await gql(
109
+ 'query ($id: String!) { node(id: $id) { metadata } }',
110
+ { id },
111
+ )) as { data: { node: { metadata: string } } }
112
+ expect(JSON.parse(fetched.data.node.metadata)).toEqual({ priority: 'high' })
113
+ })
114
+
115
+ it('updates title/description/status/metadata via updateNode', async () => {
116
+ instance = createServer({ dbPath: ':memory:', port: 0 })
117
+
118
+ const created = (await gql(
119
+ 'mutation { createNode(type: "task", title: "Old") { id } }',
120
+ )) as { data: { createNode: { id: string } } }
121
+ const id = created.data.createNode.id
122
+
123
+ const updated = (await gql(
124
+ `mutation ($id: String!) {
125
+ updateNode(id: $id, title: "New", description: "d", status: "in_progress", metadata: "{\\"k\\":1}") {
126
+ title description status metadata
127
+ }
128
+ }`,
129
+ { id },
130
+ )) as {
131
+ data: {
132
+ updateNode: {
133
+ title: string
134
+ description: string
135
+ status: string
136
+ metadata: string
137
+ }
138
+ }
139
+ }
140
+ expect(updated.data.updateNode.title).toBe('New')
141
+ expect(updated.data.updateNode.description).toBe('d')
142
+ expect(updated.data.updateNode.status).toBe('in_progress')
143
+ expect(JSON.parse(updated.data.updateNode.metadata)).toEqual({ k: 1 })
144
+ })
145
+
146
+ it('rejects non-JSON metadata with VALIDATION_ERROR over the wire', async () => {
147
+ instance = createServer({ dbPath: ':memory:', port: 0 })
148
+
149
+ const json = await gql(
150
+ 'mutation { createNode(type: "task", title: "T", metadata: "not json") { id } }',
151
+ )
152
+ expect(json.errors).toBeDefined()
153
+ const error = json.errors![0]
154
+ expect(error.message).not.toBe('Unexpected error.')
155
+ expect(error.extensions?.code).toBe('VALIDATION_ERROR')
156
+ })
157
+
158
+ it('deletes a leaf node via deleteNode', async () => {
159
+ instance = createServer({ dbPath: ':memory:', port: 0 })
160
+
161
+ const created = (await gql(
162
+ 'mutation { createNode(type: "task", title: "T") { id } }',
163
+ )) as { data: { createNode: { id: string } } }
164
+ const id = created.data.createNode.id
165
+
166
+ const del = (await gql('mutation ($id: String!) { deleteNode(id: $id) }', {
167
+ id,
168
+ })) as { data: { deleteNode: boolean } }
169
+ expect(del.data.deleteNode).toBe(true)
170
+
171
+ const fetched = (await gql(
172
+ 'query ($id: String!) { node(id: $id) { id } }',
173
+ {
174
+ id,
175
+ },
176
+ )) as { data: { node: unknown } }
177
+ expect(fetched.data.node).toBeNull()
178
+ })
179
+
180
+ it('refuses to delete a parent with children (CONFLICT)', async () => {
181
+ instance = createServer({ dbPath: ':memory:', port: 0 })
182
+
183
+ const project = (await gql(
184
+ 'mutation { createNode(type: "project", title: "P") { id } }',
185
+ )) as { data: { createNode: { id: string } } }
186
+ const feature = (await gql(
187
+ 'mutation { createNode(type: "feature", title: "F") { id } }',
188
+ )) as { data: { createNode: { id: string } } }
189
+ await gql(
190
+ 'mutation ($s: String!, $t: String!) { createEdge(sourceId: $s, targetId: $t, relation: "part_of") { relation } }',
191
+ { s: feature.data.createNode.id, t: project.data.createNode.id },
192
+ )
193
+
194
+ const json = await gql('mutation ($id: String!) { deleteNode(id: $id) }', {
195
+ id: project.data.createNode.id,
196
+ })
197
+ expect(json.errors).toBeDefined()
198
+ const error = json.errors![0]
199
+ expect(error.message).not.toBe('Unexpected error.')
200
+ expect(error.extensions?.code).toBe('CONFLICT')
201
+ })
97
202
  })
@@ -0,0 +1,218 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { LATEST_VERSION, rebuildTable, runMigrations } from './migrations.ts'
4
+
5
+ function userVersion(db: Database): number {
6
+ const row = db
7
+ .query<{ user_version: number }, []>('PRAGMA user_version')
8
+ .get()
9
+ return row?.user_version ?? 0
10
+ }
11
+
12
+ function tableNames(db: Database): string[] {
13
+ return db
14
+ .query<{ name: string }, []>(
15
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
16
+ )
17
+ .all()
18
+ .map((r) => r.name)
19
+ }
20
+
21
+ function columnNames(db: Database, table: string): string[] {
22
+ return db
23
+ .query<{ name: string }, []>(`PRAGMA table_info(${table})`)
24
+ .all()
25
+ .map((r) => r.name)
26
+ }
27
+
28
+ describe('runMigrations', () => {
29
+ it('brings a fresh DB to the latest user_version', () => {
30
+ const db = new Database(':memory:')
31
+ runMigrations(db)
32
+ expect(userVersion(db)).toBe(LATEST_VERSION)
33
+ db.close()
34
+ })
35
+
36
+ it('creates the nodes and edges tables on a fresh DB', () => {
37
+ const db = new Database(':memory:')
38
+ runMigrations(db)
39
+ const tables = tableNames(db)
40
+ expect(tables).toContain('nodes')
41
+ expect(tables).toContain('edges')
42
+ db.close()
43
+ })
44
+
45
+ it('is a no-op when run twice (idempotent)', () => {
46
+ const db = new Database(':memory:')
47
+ runMigrations(db)
48
+ const versionAfterFirst = userVersion(db)
49
+ // second run must not throw and must not change the version
50
+ runMigrations(db)
51
+ expect(userVersion(db)).toBe(versionAfterFirst)
52
+ expect(userVersion(db)).toBe(LATEST_VERSION)
53
+ db.close()
54
+ })
55
+
56
+ it('upgrades an old-shaped DB forward without losing data', () => {
57
+ const db = new Database(':memory:')
58
+ // Simulate a March-era schema: nodes table WITHOUT a metadata column,
59
+ // and a CHECK constraint that does not yet allow newer vocabulary.
60
+ db.run(`
61
+ CREATE TABLE nodes (
62
+ id TEXT PRIMARY KEY,
63
+ type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
64
+ title TEXT NOT NULL,
65
+ description TEXT,
66
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done')),
67
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
68
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
69
+ )
70
+ `)
71
+ db.run(`
72
+ CREATE TABLE edges (
73
+ source_id TEXT NOT NULL REFERENCES nodes(id),
74
+ target_id TEXT NOT NULL REFERENCES nodes(id),
75
+ relation TEXT NOT NULL CHECK(relation IN ('part_of', 'blocks')),
76
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
77
+ PRIMARY KEY (source_id, target_id, relation)
78
+ )
79
+ `)
80
+ db.run(
81
+ "INSERT INTO nodes (id, type, title, description, status) VALUES ('proj_old', 'project', 'Legacy Project', 'from march', 'in_progress')",
82
+ )
83
+
84
+ runMigrations(db)
85
+
86
+ expect(userVersion(db)).toBe(LATEST_VERSION)
87
+ // metadata column now exists
88
+ expect(columnNames(db, 'nodes')).toContain('metadata')
89
+ // legacy row preserved
90
+ const row = db
91
+ .query<
92
+ { id: string; title: string; status: string; metadata: string | null },
93
+ []
94
+ >('SELECT id, title, status, metadata FROM nodes WHERE id = ?')
95
+ .get('proj_old') as {
96
+ id: string
97
+ title: string
98
+ status: string
99
+ metadata: string | null
100
+ }
101
+ expect(row.id).toBe('proj_old')
102
+ expect(row.title).toBe('Legacy Project')
103
+ expect(row.status).toBe('in_progress')
104
+ db.close()
105
+ })
106
+
107
+ it('after migration the new vocabulary (blocked, cancelled) is accepted', () => {
108
+ const db = new Database(':memory:')
109
+ // Old DB with a restrictive status CHECK (no blocked/cancelled)
110
+ db.run(`
111
+ CREATE TABLE nodes (
112
+ id TEXT PRIMARY KEY,
113
+ type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
114
+ title TEXT NOT NULL,
115
+ description TEXT,
116
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done')),
117
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
118
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
119
+ )
120
+ `)
121
+ db.run(`
122
+ CREATE TABLE edges (
123
+ source_id TEXT NOT NULL REFERENCES nodes(id),
124
+ target_id TEXT NOT NULL REFERENCES nodes(id),
125
+ relation TEXT NOT NULL CHECK(relation IN ('part_of', 'blocks')),
126
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
127
+ PRIMARY KEY (source_id, target_id, relation)
128
+ )
129
+ `)
130
+ db.run("INSERT INTO nodes (id, type, title) VALUES ('task_1', 'task', 'T')")
131
+
132
+ runMigrations(db)
133
+
134
+ // The CHECK rebuild should now permit 'cancelled'
135
+ expect(() =>
136
+ db.run("UPDATE nodes SET status = 'cancelled' WHERE id = 'task_1'"),
137
+ ).not.toThrow()
138
+ })
139
+
140
+ describe('rebuildTable helper', () => {
141
+ it('rebuilds a table with a new CHECK constraint, preserving rows', () => {
142
+ const db = new Database(':memory:')
143
+ db.run(`
144
+ CREATE TABLE widgets (
145
+ id TEXT PRIMARY KEY,
146
+ kind TEXT NOT NULL CHECK(kind IN ('a', 'b'))
147
+ )
148
+ `)
149
+ db.run("INSERT INTO widgets (id, kind) VALUES ('w1', 'a')")
150
+
151
+ rebuildTable(
152
+ db,
153
+ 'widgets',
154
+ (tmp) => `
155
+ CREATE TABLE ${tmp} (
156
+ id TEXT PRIMARY KEY,
157
+ kind TEXT NOT NULL CHECK(kind IN ('a', 'b', 'c'))
158
+ )
159
+ `,
160
+ ['id', 'kind'],
161
+ )
162
+
163
+ // existing row preserved
164
+ const row = db
165
+ .query<{ id: string; kind: string }, []>(
166
+ 'SELECT id, kind FROM widgets WHERE id = ?',
167
+ )
168
+ .get('w1') as { id: string; kind: string }
169
+ expect(row).toEqual({ id: 'w1', kind: 'a' })
170
+ // new value now allowed by the widened CHECK
171
+ expect(() =>
172
+ db.run("INSERT INTO widgets (id, kind) VALUES ('w2', 'c')"),
173
+ ).not.toThrow()
174
+ db.close()
175
+ })
176
+ })
177
+
178
+ it('preserves edges across a nodes CHECK-rebuild migration', () => {
179
+ const db = new Database(':memory:')
180
+ db.run(`
181
+ CREATE TABLE nodes (
182
+ id TEXT PRIMARY KEY,
183
+ type TEXT NOT NULL CHECK(type IN ('project', 'feature', 'task')),
184
+ title TEXT NOT NULL,
185
+ description TEXT,
186
+ status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'pending_review', 'approved', 'in_progress', 'done')),
187
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
188
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
189
+ )
190
+ `)
191
+ db.run(`
192
+ CREATE TABLE edges (
193
+ source_id TEXT NOT NULL REFERENCES nodes(id),
194
+ target_id TEXT NOT NULL REFERENCES nodes(id),
195
+ relation TEXT NOT NULL CHECK(relation IN ('part_of', 'blocks')),
196
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
197
+ PRIMARY KEY (source_id, target_id, relation)
198
+ )
199
+ `)
200
+ db.run(
201
+ "INSERT INTO nodes (id, type, title) VALUES ('proj_1', 'project', 'P')",
202
+ )
203
+ db.run(
204
+ "INSERT INTO nodes (id, type, title) VALUES ('feat_1', 'feature', 'F')",
205
+ )
206
+ db.run(
207
+ "INSERT INTO edges (source_id, target_id, relation) VALUES ('feat_1', 'proj_1', 'part_of')",
208
+ )
209
+
210
+ runMigrations(db)
211
+
212
+ const edgeCount = db
213
+ .query<{ c: number }, []>('SELECT COUNT(*) AS c FROM edges')
214
+ .get() as { c: number }
215
+ expect(edgeCount.c).toBe(1)
216
+ db.close()
217
+ })
218
+ })
@@ -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', () => {
@@ -161,6 +214,189 @@ describe('createResolvers', () => {
161
214
  }),
162
215
  ).toThrow('Node nonexistent not found')
163
216
  })
217
+
218
+ it('updates the title, leaving other fields untouched', () => {
219
+ const node = create(resolvers, {
220
+ type: 'task',
221
+ title: 'Old title',
222
+ description: 'keep me',
223
+ })
224
+ const updated = resolvers.Mutation.updateNode(null, {
225
+ id: node.id,
226
+ title: 'New title',
227
+ })
228
+ expect(updated.title).toBe('New title')
229
+ expect(updated.description).toBe('keep me')
230
+ expect(updated.status).toBe('draft')
231
+ })
232
+
233
+ it('updates the description independently', () => {
234
+ const node = create(resolvers, {
235
+ type: 'task',
236
+ title: 'Title',
237
+ description: 'old desc',
238
+ })
239
+ const updated = resolvers.Mutation.updateNode(null, {
240
+ id: node.id,
241
+ description: 'new desc',
242
+ })
243
+ expect(updated.description).toBe('new desc')
244
+ expect(updated.title).toBe('Title')
245
+ })
246
+
247
+ it('updates metadata independently and round-trips', () => {
248
+ const node = create(resolvers, { type: 'task', title: 'Title' })
249
+ const updated = resolvers.Mutation.updateNode(null, {
250
+ id: node.id,
251
+ metadata: '{"source":"import","effort":5}',
252
+ })
253
+ expect(JSON.parse(updated.metadata as string)).toEqual({
254
+ source: 'import',
255
+ effort: 5,
256
+ })
257
+ const found = find(resolvers, node.id)
258
+ expect(JSON.parse(found.metadata as string)).toEqual({
259
+ source: 'import',
260
+ effort: 5,
261
+ })
262
+ })
263
+
264
+ it('updates several fields at once', () => {
265
+ const node = create(resolvers, { type: 'task', title: 'Title' })
266
+ const updated = resolvers.Mutation.updateNode(null, {
267
+ id: node.id,
268
+ title: 'Renamed',
269
+ description: 'desc',
270
+ status: 'in_progress',
271
+ metadata: '{"k":"v"}',
272
+ })
273
+ expect(updated.title).toBe('Renamed')
274
+ expect(updated.description).toBe('desc')
275
+ expect(updated.status).toBe('in_progress')
276
+ expect(JSON.parse(updated.metadata as string)).toEqual({ k: 'v' })
277
+ })
278
+
279
+ it('rejects non-JSON metadata on update with VALIDATION_ERROR', () => {
280
+ const node = create(resolvers, { type: 'task', title: 'Title' })
281
+ try {
282
+ resolvers.Mutation.updateNode(null, {
283
+ id: node.id,
284
+ metadata: 'nope',
285
+ })
286
+ throw new Error('expected updateNode to throw')
287
+ } catch (err) {
288
+ const e = err as { message: string; extensions?: { code?: string } }
289
+ expect(e.message).toContain('metadata')
290
+ expect(e.extensions?.code).toBe('VALIDATION_ERROR')
291
+ }
292
+ })
293
+
294
+ it('not-found error carries the NOT_FOUND code', () => {
295
+ try {
296
+ resolvers.Mutation.updateNode(null, {
297
+ id: 'missing',
298
+ title: 'x',
299
+ })
300
+ throw new Error('expected updateNode to throw')
301
+ } catch (err) {
302
+ const e = err as { extensions?: { code?: string } }
303
+ expect(e.extensions?.code).toBe('NOT_FOUND')
304
+ }
305
+ })
306
+
307
+ it('rejects an empty title with VALIDATION_ERROR', () => {
308
+ const node = create(resolvers, { type: 'task', title: 'Title' })
309
+ try {
310
+ resolvers.Mutation.updateNode(null, { id: node.id, title: ' ' })
311
+ throw new Error('expected updateNode to throw')
312
+ } catch (err) {
313
+ const e = err as { extensions?: { code?: string } }
314
+ expect(e.extensions?.code).toBe('VALIDATION_ERROR')
315
+ }
316
+ })
317
+ })
318
+
319
+ describe('Mutation.deleteNode', () => {
320
+ it('deletes a leaf node and returns true', () => {
321
+ const node = create(resolvers, { type: 'task', title: 'Leaf' })
322
+ const result = resolvers.Mutation.deleteNode(null, { id: node.id })
323
+ expect(result).toBe(true)
324
+ expect(resolvers.Query.node(null, { id: node.id })).toBeNull()
325
+ })
326
+
327
+ it('removes incident blocks edges when deleting a leaf', () => {
328
+ const blocker = create(resolvers, { type: 'task', title: 'Blocker' })
329
+ const blocked = create(resolvers, { type: 'task', title: 'Blocked' })
330
+ resolvers.Mutation.createEdge(null, {
331
+ sourceId: blocker.id,
332
+ targetId: blocked.id,
333
+ relation: 'blocks',
334
+ })
335
+
336
+ resolvers.Mutation.deleteNode(null, { id: blocked.id })
337
+
338
+ // the blocks edge that referenced the deleted node must be gone
339
+ const edges = db.raw
340
+ .query<{ c: number }, [string, string]>(
341
+ 'SELECT COUNT(*) AS c FROM edges WHERE source_id = ? OR target_id = ?',
342
+ )
343
+ .get(blocked.id, blocked.id) as { c: number }
344
+ expect(edges.c).toBe(0)
345
+ })
346
+
347
+ it('deletes a node together with its part_of edge to its parent', () => {
348
+ const project = create(resolvers, { type: 'project', title: 'P' })
349
+ const feature = create(resolvers, { type: 'feature', title: 'F' })
350
+ resolvers.Mutation.createEdge(null, {
351
+ sourceId: feature.id,
352
+ targetId: project.id,
353
+ relation: 'part_of',
354
+ })
355
+
356
+ const result = resolvers.Mutation.deleteNode(null, { id: feature.id })
357
+ expect(result).toBe(true)
358
+ // parent survives
359
+ expect(resolvers.Query.node(null, { id: project.id })).not.toBeNull()
360
+ // the part_of edge is gone
361
+ const edges = db.raw
362
+ .query<{ c: number }, [string]>(
363
+ 'SELECT COUNT(*) AS c FROM edges WHERE source_id = ?',
364
+ )
365
+ .get(feature.id) as { c: number }
366
+ expect(edges.c).toBe(0)
367
+ })
368
+
369
+ it('refuses to delete a node that has children (CONFLICT)', () => {
370
+ const project = create(resolvers, { type: 'project', title: 'P' })
371
+ const feature = create(resolvers, { type: 'feature', title: 'F' })
372
+ resolvers.Mutation.createEdge(null, {
373
+ sourceId: feature.id,
374
+ targetId: project.id,
375
+ relation: 'part_of',
376
+ })
377
+
378
+ try {
379
+ resolvers.Mutation.deleteNode(null, { id: project.id })
380
+ throw new Error('expected deleteNode to throw')
381
+ } catch (err) {
382
+ const e = err as { message: string; extensions?: { code?: string } }
383
+ expect(e.extensions?.code).toBe('CONFLICT')
384
+ expect(e.message).toContain('child')
385
+ }
386
+ // node not deleted
387
+ expect(resolvers.Query.node(null, { id: project.id })).not.toBeNull()
388
+ })
389
+
390
+ it('throws NOT_FOUND for a missing node', () => {
391
+ try {
392
+ resolvers.Mutation.deleteNode(null, { id: 'missing' })
393
+ throw new Error('expected deleteNode to throw')
394
+ } catch (err) {
395
+ const e = err as { message: string; extensions?: { code?: string } }
396
+ expect(e.extensions?.code).toBe('NOT_FOUND')
397
+ expect(e.message).toContain('missing')
398
+ }
399
+ })
164
400
  })
165
401
 
166
402
  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)}`
@@ -206,49 +230,120 @@ export function createResolvers(db: Db) {
206
230
  Mutation: {
207
231
  createNode: (
208
232
  _: unknown,
209
- args: { type: string; title: string; description?: string },
233
+ args: {
234
+ type: string
235
+ title: string
236
+ description?: string
237
+ status?: string
238
+ metadata?: string
239
+ },
210
240
  ): NodeGql => {
211
241
  if (!args.title.trim()) throw validationError('Title is required')
212
242
  if (args.description != null && !args.description.trim()) {
213
243
  throw validationError('Description cannot be empty')
214
244
  }
245
+ if (args.status != null) assertValidStatus(args.status)
246
+ const metadata =
247
+ args.metadata != null ? normalizeMetadata(args.metadata) : null
215
248
  const id = generateId(args.type)
216
249
  const now = new Date().toISOString()
217
250
  const description = args.description ?? null
251
+ const status = args.status ?? 'draft'
218
252
  db.raw.run(
219
253
  '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],
254
+ [id, args.type, args.title, description, status, metadata, now, now],
221
255
  )
222
256
  return {
223
257
  id,
224
258
  type: args.type,
225
259
  title: args.title,
226
260
  description,
227
- status: 'draft',
228
- metadata: null,
261
+ status,
262
+ metadata,
229
263
  createdAt: now,
230
264
  updatedAt: now,
231
265
  }
232
266
  },
233
267
 
234
- updateNode: (_: unknown, args: { id: string; status?: string }) => {
268
+ updateNode: (
269
+ _: unknown,
270
+ args: {
271
+ id: string
272
+ title?: string
273
+ description?: string
274
+ status?: string
275
+ metadata?: string
276
+ },
277
+ ) => {
235
278
  const existing = db.raw
236
279
  .query('SELECT * FROM nodes WHERE id = ?')
237
280
  .get(args.id) as NodeRow | null
238
281
  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
- )
282
+
283
+ if (args.title != null && !args.title.trim()) {
284
+ throw validationError('Title cannot be empty')
285
+ }
286
+ if (args.description != null && !args.description.trim()) {
287
+ throw validationError('Description cannot be empty')
288
+ }
289
+ if (args.status != null) assertValidStatus(args.status)
290
+
291
+ const next: NodeRow = {
292
+ ...existing,
293
+ title: args.title ?? existing.title,
294
+ description:
295
+ args.description !== undefined
296
+ ? args.description
297
+ : existing.description,
298
+ status: args.status ?? existing.status,
299
+ metadata:
300
+ args.metadata != null
301
+ ? normalizeMetadata(args.metadata)
302
+ : existing.metadata,
244
303
  }
245
304
  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 })
305
+ db.raw.run(
306
+ 'UPDATE nodes SET title = ?, description = ?, status = ?, metadata = ?, updated_at = ? WHERE id = ?',
307
+ [
308
+ next.title,
309
+ next.description,
310
+ next.status,
311
+ next.metadata,
312
+ now,
313
+ args.id,
314
+ ],
315
+ )
316
+ return rowToNode({ ...next, updated_at: now })
317
+ },
318
+
319
+ deleteNode: (_: unknown, args: { id: string }): boolean => {
320
+ const existing = db.raw
321
+ .query('SELECT id FROM nodes WHERE id = ?')
322
+ .get(args.id) as { id: string } | null
323
+ if (!existing) throw notFoundError(`Node ${args.id} not found`)
324
+
325
+ // The hierarchy is client -> project -> feature -> task via `part_of`
326
+ // edges (source = child, target = parent). A node with children must
327
+ // not be orphaned; reject rather than cascade-delete the subtree.
328
+ const childCount = db.raw
329
+ .query(
330
+ 'SELECT COUNT(*) AS c FROM edges WHERE target_id = ? AND relation = ?',
331
+ )
332
+ .get(args.id, 'part_of') as { c: number }
333
+ if (childCount.c > 0) {
334
+ throw conflictError(
335
+ `Cannot delete node ${args.id}: it has ${childCount.c} child node(s). Delete or re-link them first.`,
336
+ )
337
+ }
338
+
339
+ db.raw.transaction(() => {
340
+ db.raw.run('DELETE FROM edges WHERE source_id = ? OR target_id = ?', [
341
+ args.id,
342
+ args.id,
343
+ ])
344
+ db.raw.run('DELETE FROM nodes WHERE id = ?', [args.id])
345
+ })()
346
+ return true
252
347
  },
253
348
 
254
349
  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
  }