@sqaoss/flowy 1.2.1 → 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.1",
3
+ "version": "1.3.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -54,18 +54,17 @@
54
54
  "prepare": "husky"
55
55
  },
56
56
  "dependencies": {
57
- "@semantic-release/changelog": "6.0.3",
58
- "@semantic-release/git": "10.0.1",
59
- "commander": "^14.0.3",
60
- "semantic-release": "25.0.3"
57
+ "commander": "^14.0.3"
61
58
  },
62
59
  "devDependencies": {
63
60
  "@biomejs/biome": "2.4.4",
64
61
  "@commitlint/cli": "^20.4.2",
65
62
  "@commitlint/config-conventional": "^20.4.2",
63
+ "@semantic-release/changelog": "6.0.3",
64
+ "@semantic-release/git": "10.0.1",
66
65
  "husky": "^9.1.7",
67
66
  "lint-staged": "^16.3.0",
68
- "tdd-guard-vitest": "^0.1.6",
67
+ "semantic-release": "25.0.3",
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,
@@ -0,0 +1,202 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { createServer } from './index.ts'
3
+
4
+ interface GraphQLResponse {
5
+ data?: unknown
6
+ errors?: Array<{ message: string; extensions?: { code?: string } }>
7
+ }
8
+
9
+ describe('server error masking', () => {
10
+ let instance: ReturnType<typeof createServer> | undefined
11
+
12
+ afterEach(() => {
13
+ instance?.close()
14
+ instance = undefined
15
+ })
16
+
17
+ async function gql(
18
+ query: string,
19
+ variables?: Record<string, unknown>,
20
+ ): Promise<GraphQLResponse> {
21
+ const res = await fetch(`http://localhost:${instance!.port}/graphql`, {
22
+ method: 'POST',
23
+ headers: { 'Content-Type': 'application/json' },
24
+ body: JSON.stringify({ query, variables }),
25
+ })
26
+ return (await res.json()) as GraphQLResponse
27
+ }
28
+
29
+ it('surfaces a too-short search error with real message and VALIDATION_ERROR code', async () => {
30
+ instance = createServer({ dbPath: ':memory:', port: 0 })
31
+
32
+ const json = await gql('query ($q: String!) { search(query: $q) { id } }', {
33
+ q: 'ab',
34
+ })
35
+
36
+ expect(json.errors).toBeDefined()
37
+ const error = json.errors![0]
38
+ expect(error.message).toBe('Search query must be at least 3 characters')
39
+ expect(error.message).not.toBe('Unexpected error.')
40
+ expect(error.extensions?.code).toBe('VALIDATION_ERROR')
41
+ })
42
+
43
+ it('surfaces a not-found update error with real message and NOT_FOUND code', async () => {
44
+ instance = createServer({ dbPath: ':memory:', port: 0 })
45
+
46
+ const json = await gql(
47
+ 'mutation ($id: String!) { updateNode(id: $id, status: "done") { id } }',
48
+ { id: 'nonexistent' },
49
+ )
50
+
51
+ expect(json.errors).toBeDefined()
52
+ const error = json.errors![0]
53
+ expect(error.message).toBe('Node nonexistent not found')
54
+ expect(error.message).not.toBe('Unexpected error.')
55
+ expect(error.extensions?.code).toBe('NOT_FOUND')
56
+ })
57
+
58
+ it('surfaces an invalid-status error with real message and VALIDATION_ERROR code', async () => {
59
+ instance = createServer({ dbPath: ':memory:', port: 0 })
60
+
61
+ const created = (await gql(
62
+ 'mutation { createNode(type: "task", title: "T") { id } }',
63
+ )) as { data: { createNode: { id: string } } }
64
+ const id = created.data.createNode.id
65
+
66
+ const json = await gql(
67
+ 'mutation ($id: String!) { updateNode(id: $id, status: "bogus") { id } }',
68
+ { id },
69
+ )
70
+
71
+ expect(json.errors).toBeDefined()
72
+ const error = json.errors![0]
73
+ expect(error.message).toContain('Invalid status: bogus')
74
+ expect(error.message).not.toBe('Unexpected error.')
75
+ expect(error.extensions?.code).toBe('VALIDATION_ERROR')
76
+ })
77
+
78
+ it('surfaces an approve-wrong-status error with CONFLICT code', async () => {
79
+ instance = createServer({ dbPath: ':memory:', port: 0 })
80
+
81
+ const created = (await gql(
82
+ 'mutation { createNode(type: "feature", title: "F") { id } }',
83
+ )) as { data: { createNode: { id: string } } }
84
+ const id = created.data.createNode.id
85
+
86
+ const json = await gql(
87
+ 'mutation ($id: String!) { approveNode(id: $id) { id } }',
88
+ { id },
89
+ )
90
+
91
+ expect(json.errors).toBeDefined()
92
+ const error = json.errors![0]
93
+ expect(error.message).toContain('Cannot approve node with status "draft"')
94
+ expect(error.message).not.toBe('Unexpected error.')
95
+ expect(error.extensions?.code).toBe('CONFLICT')
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
+ })
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
+ }