@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.
@@ -5,19 +5,24 @@ services:
5
5
  dockerfile_inline: |
6
6
  FROM oven/bun:1.3.11
7
7
  WORKDIR /app
8
- RUN bun init -y && bun add @sqaoss/flowy
8
+ RUN bun init -y && bun add @sqaoss/flowy@1.3.0
9
9
  WORKDIR /app/node_modules/@sqaoss/flowy/server
10
10
  RUN bun install --production
11
11
  EXPOSE 4000
12
12
  VOLUME /data
13
13
  CMD ["bun", "src/index.ts"]
14
+ # Publish on loopback only — the server is unauthenticated, so it must not
15
+ # be reachable from the LAN. Prefer the native `flowy serve` for local dev.
14
16
  ports:
15
- - "4000:4000"
17
+ - "127.0.0.1:4000:4000"
16
18
  volumes:
17
19
  - flowy-data:/data
18
20
  environment:
19
21
  - FLOWY_DB_PATH=/data/flowy.sqlite
20
22
  - PORT=4000
23
+ # Bind all interfaces *inside* the container so Docker's loopback-only
24
+ # host publish above can reach it; the host port stays 127.0.0.1.
25
+ - HOST=0.0.0.0
21
26
  healthcheck:
22
27
  test: ["CMD", "bun", "-e", "fetch('http://localhost:4000/health').then(r => r.ok ? process.exit(0) : process.exit(1))"]
23
28
  interval: 5s
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
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,
@@ -75,6 +75,37 @@ describe('server error masking', () => {
75
75
  expect(error.extensions?.code).toBe('VALIDATION_ERROR')
76
76
  })
77
77
 
78
+ it('surfaces a not-found node query with real message and NOT_FOUND code (no silent null)', async () => {
79
+ instance = createServer({ dbPath: ':memory:', port: 0 })
80
+
81
+ const json = await gql('query ($id: String!) { node(id: $id) { id } }', {
82
+ id: 'task_nonexistent',
83
+ })
84
+
85
+ expect(json.errors).toBeDefined()
86
+ const error = json.errors![0]
87
+ expect(error.message).toBe('Node task_nonexistent not found')
88
+ expect(error.extensions?.code).toBe('NOT_FOUND')
89
+ const data = json.data as { node: unknown } | null | undefined
90
+ expect(data?.node ?? null).toBeNull()
91
+ })
92
+
93
+ it('still returns a node when it exists', async () => {
94
+ instance = createServer({ dbPath: ':memory:', port: 0 })
95
+
96
+ const created = (await gql(
97
+ 'mutation { createNode(type: "task", title: "T") { id } }',
98
+ )) as { data: { createNode: { id: string } } }
99
+ const id = created.data.createNode.id
100
+
101
+ const json = (await gql('query ($id: String!) { node(id: $id) { id } }', {
102
+ id,
103
+ })) as { data: { node: { id: string } }; errors?: unknown[] }
104
+
105
+ expect(json.errors).toBeUndefined()
106
+ expect(json.data.node.id).toBe(id)
107
+ })
108
+
78
109
  it('surfaces an approve-wrong-status error with CONFLICT code', async () => {
79
110
  instance = createServer({ dbPath: ':memory:', port: 0 })
80
111
 
@@ -94,4 +125,108 @@ describe('server error masking', () => {
94
125
  expect(error.message).not.toBe('Unexpected error.')
95
126
  expect(error.extensions?.code).toBe('CONFLICT')
96
127
  })
128
+
129
+ it('round-trips metadata through createNode and the node query', async () => {
130
+ instance = createServer({ dbPath: ':memory:', port: 0 })
131
+
132
+ const created = (await gql(
133
+ 'mutation ($m: String!) { createNode(type: "task", title: "T", metadata: $m) { id metadata } }',
134
+ { m: '{"priority":"high"}' },
135
+ )) as { data: { createNode: { id: string; metadata: string } } }
136
+ expect(created.data.createNode.metadata).toBe('{"priority":"high"}')
137
+
138
+ const id = created.data.createNode.id
139
+ const fetched = (await gql(
140
+ 'query ($id: String!) { node(id: $id) { metadata } }',
141
+ { id },
142
+ )) as { data: { node: { metadata: string } } }
143
+ expect(JSON.parse(fetched.data.node.metadata)).toEqual({ priority: 'high' })
144
+ })
145
+
146
+ it('updates title/description/status/metadata via updateNode', async () => {
147
+ instance = createServer({ dbPath: ':memory:', port: 0 })
148
+
149
+ const created = (await gql(
150
+ 'mutation { createNode(type: "task", title: "Old") { id } }',
151
+ )) as { data: { createNode: { id: string } } }
152
+ const id = created.data.createNode.id
153
+
154
+ const updated = (await gql(
155
+ `mutation ($id: String!) {
156
+ updateNode(id: $id, title: "New", description: "d", status: "in_progress", metadata: "{\\"k\\":1}") {
157
+ title description status metadata
158
+ }
159
+ }`,
160
+ { id },
161
+ )) as {
162
+ data: {
163
+ updateNode: {
164
+ title: string
165
+ description: string
166
+ status: string
167
+ metadata: string
168
+ }
169
+ }
170
+ }
171
+ expect(updated.data.updateNode.title).toBe('New')
172
+ expect(updated.data.updateNode.description).toBe('d')
173
+ expect(updated.data.updateNode.status).toBe('in_progress')
174
+ expect(JSON.parse(updated.data.updateNode.metadata)).toEqual({ k: 1 })
175
+ })
176
+
177
+ it('rejects non-JSON metadata with VALIDATION_ERROR over the wire', async () => {
178
+ instance = createServer({ dbPath: ':memory:', port: 0 })
179
+
180
+ const json = await gql(
181
+ 'mutation { createNode(type: "task", title: "T", metadata: "not json") { id } }',
182
+ )
183
+ expect(json.errors).toBeDefined()
184
+ const error = json.errors![0]
185
+ expect(error.message).not.toBe('Unexpected error.')
186
+ expect(error.extensions?.code).toBe('VALIDATION_ERROR')
187
+ })
188
+
189
+ it('deletes a leaf node via deleteNode', async () => {
190
+ instance = createServer({ dbPath: ':memory:', port: 0 })
191
+
192
+ const created = (await gql(
193
+ 'mutation { createNode(type: "task", title: "T") { id } }',
194
+ )) as { data: { createNode: { id: string } } }
195
+ const id = created.data.createNode.id
196
+
197
+ const del = (await gql('mutation ($id: String!) { deleteNode(id: $id) }', {
198
+ id,
199
+ })) as { data: { deleteNode: boolean } }
200
+ expect(del.data.deleteNode).toBe(true)
201
+
202
+ const fetched = await gql('query ($id: String!) { node(id: $id) { id } }', {
203
+ id,
204
+ })
205
+ // The node is gone: querying it now fails loud with NOT_FOUND.
206
+ expect(fetched.errors).toBeDefined()
207
+ expect(fetched.errors![0].extensions?.code).toBe('NOT_FOUND')
208
+ })
209
+
210
+ it('refuses to delete a parent with children (CONFLICT)', async () => {
211
+ instance = createServer({ dbPath: ':memory:', port: 0 })
212
+
213
+ const project = (await gql(
214
+ 'mutation { createNode(type: "project", title: "P") { id } }',
215
+ )) as { data: { createNode: { id: string } } }
216
+ const feature = (await gql(
217
+ 'mutation { createNode(type: "feature", title: "F") { id } }',
218
+ )) as { data: { createNode: { id: string } } }
219
+ await gql(
220
+ 'mutation ($s: String!, $t: String!) { createEdge(sourceId: $s, targetId: $t, relation: "part_of") { relation } }',
221
+ { s: feature.data.createNode.id, t: project.data.createNode.id },
222
+ )
223
+
224
+ const json = await gql('mutation ($id: String!) { deleteNode(id: $id) }', {
225
+ id: project.data.createNode.id,
226
+ })
227
+ expect(json.errors).toBeDefined()
228
+ const error = json.errors![0]
229
+ expect(error.message).not.toBe('Unexpected error.')
230
+ expect(error.extensions?.code).toBe('CONFLICT')
231
+ })
97
232
  })
@@ -22,4 +22,33 @@ describe('createServer', () => {
22
22
  expect(res.status).toBe(200)
23
23
  expect(json).toEqual({ status: 'ok' })
24
24
  })
25
+
26
+ it('binds to 127.0.0.1 by default, not all interfaces', () => {
27
+ instance = createServer({ dbPath: ':memory:', port: 0 })
28
+
29
+ expect(instance.hostname).toBe('127.0.0.1')
30
+ expect(instance.server.hostname).toBe('127.0.0.1')
31
+ })
32
+
33
+ it('allows the bind hostname to be overridden via opts', () => {
34
+ instance = createServer({
35
+ dbPath: ':memory:',
36
+ port: 0,
37
+ hostname: '0.0.0.0',
38
+ })
39
+
40
+ expect(instance.hostname).toBe('0.0.0.0')
41
+ })
42
+
43
+ it('allows the bind hostname to be overridden via HOST env', () => {
44
+ const prev = process.env.HOST
45
+ process.env.HOST = '0.0.0.0'
46
+ try {
47
+ instance = createServer({ dbPath: ':memory:', port: 0 })
48
+ expect(instance.hostname).toBe('0.0.0.0')
49
+ } finally {
50
+ if (prev === undefined) delete process.env.HOST
51
+ else process.env.HOST = prev
52
+ }
53
+ })
25
54
  })
@@ -3,9 +3,16 @@ import { createDb } from './db.ts'
3
3
  import { createResolvers } from './resolvers.ts'
4
4
  import { typeDefs } from './schema.ts'
5
5
 
6
- export function createServer(opts?: { dbPath?: string; port?: number }) {
6
+ export function createServer(opts?: {
7
+ dbPath?: string
8
+ port?: number
9
+ hostname?: string
10
+ }) {
7
11
  const dbPath = opts?.dbPath ?? process.env.FLOWY_DB_PATH ?? './flowy.sqlite'
8
12
  const port = opts?.port ?? Number(process.env.PORT ?? 4000)
13
+ // Bind loopback by default so the unauthenticated dev server is not exposed
14
+ // on the LAN. Override with the `hostname` opt or the HOST env var.
15
+ const hostname = opts?.hostname ?? process.env.HOST ?? '127.0.0.1'
9
16
 
10
17
  const db = createDb(dbPath)
11
18
  const resolvers = createResolvers(db)
@@ -17,6 +24,7 @@ export function createServer(opts?: { dbPath?: string; port?: number }) {
17
24
 
18
25
  const server = Bun.serve({
19
26
  port,
27
+ hostname,
20
28
  fetch(req) {
21
29
  const url = new URL(req.url)
22
30
  if (url.pathname === '/health' && req.method === 'GET') {
@@ -29,6 +37,7 @@ export function createServer(opts?: { dbPath?: string; port?: number }) {
29
37
  return {
30
38
  server,
31
39
  port: server.port,
40
+ hostname: server.hostname,
32
41
  db,
33
42
  close() {
34
43
  server.stop()
@@ -38,8 +47,9 @@ export function createServer(opts?: { dbPath?: string; port?: number }) {
38
47
  }
39
48
 
40
49
  if (import.meta.main) {
41
- const { port } = createServer()
42
- console.log(`Flowy local server running on http://localhost:${port}`)
43
- console.log(` GraphQL: http://localhost:${port}/graphql`)
44
- console.log(` Health: http://localhost:${port}/health`)
50
+ const { port, hostname } = createServer()
51
+ const host = hostname === '0.0.0.0' ? 'localhost' : hostname
52
+ console.log(`Flowy local server running on http://${host}:${port}`)
53
+ console.log(` GraphQL: http://${host}:${port}/graphql`)
54
+ console.log(` Health: http://${host}:${port}/health`)
45
55
  }
@@ -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
+ })