@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.
- package/docker-compose.yml +7 -2
- package/package.json +1 -2
- package/server/src/db.test.ts +48 -0
- package/server/src/db.ts +2 -31
- package/server/src/index.errors.test.ts +135 -0
- package/server/src/index.test.ts +29 -0
- package/server/src/index.ts +15 -5
- package/server/src/migrations.test.ts +218 -0
- package/server/src/migrations.ts +140 -0
- package/server/src/resolvers.test.ts +250 -3
- package/server/src/resolvers.ts +114 -17
- package/server/src/schema.test.ts +39 -5
- package/server/src/schema.ts +15 -2
- package/src/commands/feature.ts +12 -2
- package/src/commands/serve.test.ts +89 -0
- package/src/commands/serve.ts +76 -0
- package/src/commands/setup.test.ts +12 -14
- package/src/commands/setup.ts +14 -73
- package/src/commands/task.test.ts +8 -0
- package/src/commands/task.ts +11 -4
- package/src/index.test.ts +16 -0
- package/src/index.ts +2 -0
- package/src/util/client.test.ts +194 -81
- package/src/util/client.ts +127 -27
- package/src/util/description.test.ts +59 -7
- package/src/util/description.ts +59 -4
- package/src/util/format.test.ts +65 -0
- package/src/util/format.ts +22 -1
package/docker-compose.yml
CHANGED
|
@@ -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.
|
|
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
|
},
|
package/server/src/db.test.ts
CHANGED
|
@@ -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
|
|
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
|
})
|
package/server/src/index.test.ts
CHANGED
|
@@ -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
|
})
|
package/server/src/index.ts
CHANGED
|
@@ -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?: {
|
|
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
|
-
|
|
43
|
-
console.log(`
|
|
44
|
-
console.log(`
|
|
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
|
+
})
|