@sqaoss/flowy 1.10.0 → 1.12.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/README.md +41 -2
- package/package.json +4 -2
- package/server/src/contract.test.ts +16 -5
- package/server/src/index.errors.test.ts +6 -3
- package/server/src/index.ts +10 -1
- package/server/src/resolvers.test.ts +215 -16
- package/server/src/resolvers.ts +76 -8
- package/server/src/schema.ts +16 -1
- package/skills/using-flowy/SKILL.md +17 -0
- package/src/commands/backup.test.ts +252 -0
- package/src/commands/backup.ts +145 -0
- package/src/commands/search.test.ts +90 -0
- package/src/commands/search.ts +19 -3
- package/src/commands/status.test.ts +71 -0
- package/src/commands/status.ts +18 -4
- package/src/index.test.ts +18 -0
- package/src/index.ts +3 -0
- package/src/util/operations.ts +10 -2
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
8
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
9
|
+
let workDir: string
|
|
10
|
+
|
|
11
|
+
// `bun:sqlite` is unavailable under the Node-based root test runner, so these
|
|
12
|
+
// tests drive a real `bun` subprocess to seed and read SQLite files. That keeps
|
|
13
|
+
// the row-level assertions genuine (open the backup, compare rows) without
|
|
14
|
+
// importing `bun:sqlite` into Node — exactly how the CLI runs at runtime.
|
|
15
|
+
|
|
16
|
+
/** Create a populated SQLite DB at `path` via a real bun subprocess. */
|
|
17
|
+
function seedDb(
|
|
18
|
+
path: string,
|
|
19
|
+
rows: Array<{ id: string; title: string }> = [
|
|
20
|
+
{ id: 'n1', title: 'Alpha' },
|
|
21
|
+
{ id: 'n2', title: 'Beta' },
|
|
22
|
+
{ id: 'n3', title: 'Gamma' },
|
|
23
|
+
],
|
|
24
|
+
): Array<{ id: string; title: string }> {
|
|
25
|
+
const script = `
|
|
26
|
+
const { Database } = require('bun:sqlite')
|
|
27
|
+
const db = new Database(process.argv[1])
|
|
28
|
+
db.run('PRAGMA journal_mode = WAL')
|
|
29
|
+
db.run('CREATE TABLE nodes (id TEXT PRIMARY KEY, type TEXT NOT NULL, title TEXT NOT NULL)')
|
|
30
|
+
const rows = JSON.parse(process.argv[2])
|
|
31
|
+
for (const r of rows) db.run('INSERT INTO nodes (id, type, title) VALUES (?, ?, ?)', [r.id, 'task', r.title])
|
|
32
|
+
db.close()
|
|
33
|
+
`
|
|
34
|
+
const res = spawnSync('bun', ['-e', script, path, JSON.stringify(rows)], {
|
|
35
|
+
encoding: 'utf-8',
|
|
36
|
+
})
|
|
37
|
+
if (res.status !== 0) throw new Error(`seedDb failed: ${res.stderr}`)
|
|
38
|
+
return rows
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read all node {id,title} rows from a SQLite file via a real bun subprocess. */
|
|
42
|
+
function readNodes(path: string): Array<{ id: string; title: string }> {
|
|
43
|
+
const script = `
|
|
44
|
+
const { Database } = require('bun:sqlite')
|
|
45
|
+
const db = new Database(process.argv[1], { readonly: true })
|
|
46
|
+
console.log(JSON.stringify(db.query('SELECT id, title FROM nodes ORDER BY id').all()))
|
|
47
|
+
db.close()
|
|
48
|
+
`
|
|
49
|
+
const res = spawnSync('bun', ['-e', script, path], { encoding: 'utf-8' })
|
|
50
|
+
if (res.status !== 0) throw new Error(`readNodes failed: ${res.stderr}`)
|
|
51
|
+
return JSON.parse(res.stdout.trim())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
mockOutput = vi.fn()
|
|
56
|
+
mockOutputError = vi.fn()
|
|
57
|
+
vi.doMock('../util/format.ts', () => ({
|
|
58
|
+
output: mockOutput,
|
|
59
|
+
outputError: mockOutputError,
|
|
60
|
+
}))
|
|
61
|
+
workDir = mkdtempSync(join(tmpdir(), 'flowy-backup-'))
|
|
62
|
+
delete process.env.FLOWY_DB_PATH
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
rmSync(workDir, { recursive: true, force: true })
|
|
67
|
+
delete process.env.FLOWY_DB_PATH
|
|
68
|
+
vi.resetModules()
|
|
69
|
+
vi.restoreAllMocks()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('backup command', () => {
|
|
73
|
+
test('exports a command named "backup"', async () => {
|
|
74
|
+
const { backupCommand } = await import('./backup.ts')
|
|
75
|
+
expect(backupCommand.name()).toBe('backup')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('declares a --db option', async () => {
|
|
79
|
+
const { backupCommand } = await import('./backup.ts')
|
|
80
|
+
const flags = backupCommand.options.map((o) => o.long)
|
|
81
|
+
expect(flags).toContain('--db')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('writes a valid SQLite snapshot whose rows match the source DB', async () => {
|
|
85
|
+
const src = join(workDir, 'flowy.sqlite')
|
|
86
|
+
const dest = join(workDir, 'backup.sqlite')
|
|
87
|
+
const expected = seedDb(src)
|
|
88
|
+
|
|
89
|
+
const { backupCommand } = await import('./backup.ts')
|
|
90
|
+
await backupCommand.parseAsync([dest, '--db', src], { from: 'user' })
|
|
91
|
+
|
|
92
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
93
|
+
expect(existsSync(dest)).toBe(true)
|
|
94
|
+
// The backup is a real, openable SQLite database with identical rows.
|
|
95
|
+
expect(readNodes(dest)).toEqual(expected)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('resolves the DB path from FLOWY_DB_PATH when --db is omitted', async () => {
|
|
99
|
+
const src = join(workDir, 'env.sqlite')
|
|
100
|
+
const dest = join(workDir, 'backup.sqlite')
|
|
101
|
+
const expected = seedDb(src)
|
|
102
|
+
process.env.FLOWY_DB_PATH = src
|
|
103
|
+
|
|
104
|
+
const { backupCommand } = await import('./backup.ts')
|
|
105
|
+
await backupCommand.parseAsync([dest], { from: 'user' })
|
|
106
|
+
|
|
107
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
108
|
+
expect(readNodes(dest)).toEqual(expected)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('produces a consistent snapshot while the source DB is open (WAL)', async () => {
|
|
112
|
+
const src = join(workDir, 'flowy.sqlite')
|
|
113
|
+
const dest = join(workDir, 'backup.sqlite')
|
|
114
|
+
const expected = seedDb(src)
|
|
115
|
+
|
|
116
|
+
// A long-lived bun process holds the DB open to mimic a running server.
|
|
117
|
+
const holder = spawnSync(
|
|
118
|
+
'bun',
|
|
119
|
+
[
|
|
120
|
+
'-e',
|
|
121
|
+
`const { Database } = require('bun:sqlite'); const db = new Database(process.argv[1]); db.query('SELECT 1').get(); Bun.sleepSync(50); db.close()`,
|
|
122
|
+
src,
|
|
123
|
+
],
|
|
124
|
+
{ encoding: 'utf-8' },
|
|
125
|
+
)
|
|
126
|
+
expect(holder.status).toBe(0)
|
|
127
|
+
|
|
128
|
+
const { backupCommand } = await import('./backup.ts')
|
|
129
|
+
await backupCommand.parseAsync([dest, '--db', src], { from: 'user' })
|
|
130
|
+
|
|
131
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
132
|
+
expect(readNodes(dest)).toEqual(expected)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('errors when the source DB does not exist', async () => {
|
|
136
|
+
const dest = join(workDir, 'backup.sqlite')
|
|
137
|
+
const missing = join(workDir, 'nope.sqlite')
|
|
138
|
+
|
|
139
|
+
const { backupCommand } = await import('./backup.ts')
|
|
140
|
+
await backupCommand.parseAsync([dest, '--db', missing], { from: 'user' })
|
|
141
|
+
|
|
142
|
+
expect(mockOutputError).toHaveBeenCalled()
|
|
143
|
+
expect(existsSync(dest)).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('reports the source and destination on success', async () => {
|
|
147
|
+
const src = join(workDir, 'flowy.sqlite')
|
|
148
|
+
const dest = join(workDir, 'backup.sqlite')
|
|
149
|
+
seedDb(src)
|
|
150
|
+
|
|
151
|
+
const { backupCommand } = await import('./backup.ts')
|
|
152
|
+
await backupCommand.parseAsync([dest, '--db', src], { from: 'user' })
|
|
153
|
+
|
|
154
|
+
expect(mockOutput).toHaveBeenCalledWith(
|
|
155
|
+
expect.objectContaining({ source: src, file: dest }),
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('restore command', () => {
|
|
161
|
+
test('exports a command named "restore"', async () => {
|
|
162
|
+
const { restoreCommand } = await import('./backup.ts')
|
|
163
|
+
expect(restoreCommand.name()).toBe('restore')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('declares --db and --force options', async () => {
|
|
167
|
+
const { restoreCommand } = await import('./backup.ts')
|
|
168
|
+
const flags = restoreCommand.options.map((o) => o.long)
|
|
169
|
+
expect(flags).toContain('--db')
|
|
170
|
+
expect(flags).toContain('--force')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('round-trips: restoring a backup reproduces the original rows', async () => {
|
|
174
|
+
const src = join(workDir, 'flowy.sqlite')
|
|
175
|
+
const backup = join(workDir, 'backup.sqlite')
|
|
176
|
+
const target = join(workDir, 'restored.sqlite')
|
|
177
|
+
const expected = seedDb(src)
|
|
178
|
+
|
|
179
|
+
const { backupCommand, restoreCommand } = await import('./backup.ts')
|
|
180
|
+
await backupCommand.parseAsync([backup, '--db', src], { from: 'user' })
|
|
181
|
+
await restoreCommand.parseAsync([backup, '--db', target], { from: 'user' })
|
|
182
|
+
|
|
183
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
184
|
+
expect(readNodes(target)).toEqual(expected)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('refuses to clobber an existing DB without --force', async () => {
|
|
188
|
+
const src = join(workDir, 'flowy.sqlite')
|
|
189
|
+
const backup = join(workDir, 'backup.sqlite')
|
|
190
|
+
const target = join(workDir, 'existing.sqlite')
|
|
191
|
+
// Pre-existing target whose content must NOT be overwritten.
|
|
192
|
+
const original = seedDb(target, [{ id: 'keep', title: 'Original' }])
|
|
193
|
+
// Source (and thus backup) differs from the existing target.
|
|
194
|
+
seedDb(src, [{ id: 'new', title: 'Incoming' }])
|
|
195
|
+
|
|
196
|
+
const { backupCommand, restoreCommand } = await import('./backup.ts')
|
|
197
|
+
await backupCommand.parseAsync([backup, '--db', src], { from: 'user' })
|
|
198
|
+
|
|
199
|
+
mockOutputError.mockClear()
|
|
200
|
+
await restoreCommand.parseAsync([backup, '--db', target], { from: 'user' })
|
|
201
|
+
|
|
202
|
+
expect(mockOutputError).toHaveBeenCalled()
|
|
203
|
+
// The existing DB is untouched.
|
|
204
|
+
expect(readNodes(target)).toEqual(original)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('overwrites an existing DB when --force is given', async () => {
|
|
208
|
+
const src = join(workDir, 'flowy.sqlite')
|
|
209
|
+
const backup = join(workDir, 'backup.sqlite')
|
|
210
|
+
const target = join(workDir, 'existing.sqlite')
|
|
211
|
+
seedDb(target, [{ id: 'old', title: 'Stale' }]) // pre-existing, replaced
|
|
212
|
+
seedDb(src, [{ id: 'z9', title: 'New' }])
|
|
213
|
+
|
|
214
|
+
const { backupCommand, restoreCommand } = await import('./backup.ts')
|
|
215
|
+
await backupCommand.parseAsync([backup, '--db', src], { from: 'user' })
|
|
216
|
+
await restoreCommand.parseAsync([backup, '--db', target, '--force'], {
|
|
217
|
+
from: 'user',
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(mockOutputError).not.toHaveBeenCalled()
|
|
221
|
+
expect(readNodes(target)).toEqual([{ id: 'z9', title: 'New' }])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('errors when the backup source does not exist', async () => {
|
|
225
|
+
const target = join(workDir, 'restored.sqlite')
|
|
226
|
+
const missing = join(workDir, 'nope.sqlite')
|
|
227
|
+
|
|
228
|
+
const { restoreCommand } = await import('./backup.ts')
|
|
229
|
+
await restoreCommand.parseAsync([missing, '--db', target], { from: 'user' })
|
|
230
|
+
|
|
231
|
+
expect(mockOutputError).toHaveBeenCalled()
|
|
232
|
+
expect(existsSync(target)).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('errors when the backup source is not a valid SQLite database', async () => {
|
|
236
|
+
const target = join(workDir, 'restored.sqlite')
|
|
237
|
+
const garbage = join(workDir, 'garbage.bin')
|
|
238
|
+
writeFileSync(garbage, 'this is not a sqlite database')
|
|
239
|
+
|
|
240
|
+
const { restoreCommand } = await import('./backup.ts')
|
|
241
|
+
await restoreCommand.parseAsync([garbage, '--db', target], { from: 'user' })
|
|
242
|
+
|
|
243
|
+
// A corrupt source must surface as a clean snapshot failure, not an
|
|
244
|
+
// incidental downstream error (e.g. a rename ENOENT).
|
|
245
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
246
|
+
expect.objectContaining({ code: 'BACKUP_ERROR' }),
|
|
247
|
+
)
|
|
248
|
+
expect(existsSync(target)).toBe(false)
|
|
249
|
+
// No staged temp file is left behind.
|
|
250
|
+
expect(existsSync(`${target}.restore-tmp`)).toBe(false)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, renameSync, rmSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { Command } from 'commander'
|
|
5
|
+
import { output, outputError } from '../util/format.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the local SQLite database path the bundled server uses, from the same
|
|
9
|
+
* sources `server/src/index.ts` and `flowy serve` honour:
|
|
10
|
+
* 1. an explicit `--db <path>` flag
|
|
11
|
+
* 2. the `FLOWY_DB_PATH` env var
|
|
12
|
+
* 3. the default `./flowy.sqlite`
|
|
13
|
+
*/
|
|
14
|
+
export function resolveDbPath(dbOpt?: string): string {
|
|
15
|
+
return dbOpt ?? process.env.FLOWY_DB_PATH ?? './flowy.sqlite'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Snapshot one SQLite database into another file with `VACUUM INTO`.
|
|
20
|
+
*
|
|
21
|
+
* `VACUUM INTO` takes a transactionally-consistent snapshot even while the
|
|
22
|
+
* source is open/being written by a running server (it reads under a read
|
|
23
|
+
* transaction), and writes a single fully-checkpointed file with no `-wal`/
|
|
24
|
+
* `-shm` sidecars — strictly safer than copying the file bytes. It also fails
|
|
25
|
+
* cleanly if `src` is not a valid SQLite database, which doubles as validation.
|
|
26
|
+
*
|
|
27
|
+
* Runs in a `bun` subprocess so this module never imports `bun:sqlite`
|
|
28
|
+
* (unavailable under the Node-based unit-test runner); the CLI itself always
|
|
29
|
+
* runs under bun, so the subprocess shares the same runtime.
|
|
30
|
+
*/
|
|
31
|
+
function vacuumInto(src: string, dest: string): void {
|
|
32
|
+
// Note: `bun -e` does not reliably exit non-zero on an uncaught throw, so the
|
|
33
|
+
// script explicitly catches, writes the message to stderr, and exits 1 — that
|
|
34
|
+
// is what lets the parent distinguish a corrupt source from a clean snapshot.
|
|
35
|
+
const script = `
|
|
36
|
+
const { Database } = require('bun:sqlite')
|
|
37
|
+
try {
|
|
38
|
+
const db = new Database(process.argv[1], { readonly: true })
|
|
39
|
+
try {
|
|
40
|
+
db.run('VACUUM INTO ?', [process.argv[2]])
|
|
41
|
+
} finally {
|
|
42
|
+
db.close()
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error(e && e.message ? e.message : String(e))
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
`
|
|
49
|
+
const result = spawnSync('bun', ['-e', script, src, dest], {
|
|
50
|
+
encoding: 'utf-8',
|
|
51
|
+
})
|
|
52
|
+
if (result.status !== 0) {
|
|
53
|
+
const detail = (result.stderr || result.stdout || '').trim()
|
|
54
|
+
const err = new Error(
|
|
55
|
+
`Failed to snapshot SQLite database ${src}${detail ? `: ${detail}` : '.'}`,
|
|
56
|
+
) as Error & { code?: string }
|
|
57
|
+
err.code = 'BACKUP_ERROR'
|
|
58
|
+
throw err
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Remove a SQLite file together with its `-wal`/`-shm` sidecars. */
|
|
63
|
+
function removeDbFiles(path: string): void {
|
|
64
|
+
for (const suffix of ['', '-wal', '-shm', '-journal']) {
|
|
65
|
+
rmSync(`${path}${suffix}`, { force: true })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const backupCommand = new Command('backup')
|
|
70
|
+
.description(
|
|
71
|
+
'Write a consistent file-level snapshot of the local SQLite database',
|
|
72
|
+
)
|
|
73
|
+
.argument('<dest>', 'Path to write the backup file to')
|
|
74
|
+
.option(
|
|
75
|
+
'-d, --db <path>',
|
|
76
|
+
'SQLite database to back up (default: $FLOWY_DB_PATH or ./flowy.sqlite)',
|
|
77
|
+
)
|
|
78
|
+
.action((dest: string, opts: { db?: string }) => {
|
|
79
|
+
try {
|
|
80
|
+
const src = resolveDbPath(opts.db)
|
|
81
|
+
if (!existsSync(src)) {
|
|
82
|
+
const err = new Error(
|
|
83
|
+
`No SQLite database at ${resolve(src)}. ` +
|
|
84
|
+
`Set --db or FLOWY_DB_PATH, or start the server first.`,
|
|
85
|
+
) as Error & { code?: string }
|
|
86
|
+
err.code = 'NOT_FOUND'
|
|
87
|
+
throw err
|
|
88
|
+
}
|
|
89
|
+
// VACUUM INTO refuses to write an existing file, so clear a stale dest.
|
|
90
|
+
removeDbFiles(dest)
|
|
91
|
+
vacuumInto(src, dest)
|
|
92
|
+
output({ source: src, file: dest })
|
|
93
|
+
} catch (error) {
|
|
94
|
+
outputError(error)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
export const restoreCommand = new Command('restore')
|
|
99
|
+
.description('Restore the local SQLite database from a backup file')
|
|
100
|
+
.argument('<src>', 'Path to a backup file produced by "flowy backup"')
|
|
101
|
+
.option(
|
|
102
|
+
'-d, --db <path>',
|
|
103
|
+
'SQLite database to restore into (default: $FLOWY_DB_PATH or ./flowy.sqlite)',
|
|
104
|
+
)
|
|
105
|
+
.option('-f, --force', 'Overwrite the target database if it already exists')
|
|
106
|
+
.action((src: string, opts: { db?: string; force?: boolean }) => {
|
|
107
|
+
try {
|
|
108
|
+
if (!existsSync(src)) {
|
|
109
|
+
const err = new Error(`No backup file at ${resolve(src)}.`) as Error & {
|
|
110
|
+
code?: string
|
|
111
|
+
}
|
|
112
|
+
err.code = 'NOT_FOUND'
|
|
113
|
+
throw err
|
|
114
|
+
}
|
|
115
|
+
const target = resolveDbPath(opts.db)
|
|
116
|
+
if (existsSync(target) && !opts.force) {
|
|
117
|
+
const err = new Error(
|
|
118
|
+
`Target database ${resolve(target)} already exists. ` +
|
|
119
|
+
`Re-run with --force to overwrite it (the current data will be lost).`,
|
|
120
|
+
) as Error & { code?: string }
|
|
121
|
+
err.code = 'TARGET_EXISTS'
|
|
122
|
+
throw err
|
|
123
|
+
}
|
|
124
|
+
// VACUUM INTO validates that `src` is a real SQLite database and writes a
|
|
125
|
+
// clean single-file copy. Stage it next to the target first, so a corrupt
|
|
126
|
+
// backup never destroys the existing DB before validation succeeds.
|
|
127
|
+
const staged = `${target}.restore-tmp`
|
|
128
|
+
removeDbFiles(staged)
|
|
129
|
+
try {
|
|
130
|
+
vacuumInto(src, staged)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
removeDbFiles(staged)
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
// Validation passed — swap the staged copy into place. `staged` sits in
|
|
136
|
+
// the same directory as `target`, so this rename stays on one filesystem.
|
|
137
|
+
// VACUUM INTO never creates `-wal`/`-shm` sidecars, so only the main file
|
|
138
|
+
// needs moving.
|
|
139
|
+
removeDbFiles(target)
|
|
140
|
+
renameSync(staged, target)
|
|
141
|
+
output({ restored: target, source: src })
|
|
142
|
+
} catch (error) {
|
|
143
|
+
outputError(error)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
5
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
6
|
+
let stderr: ReturnType<typeof vi.spyOn>
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockOutput = vi.fn()
|
|
10
|
+
mockOutputError = vi.fn()
|
|
11
|
+
mockGraphql = vi.fn()
|
|
12
|
+
stderr = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
13
|
+
|
|
14
|
+
vi.doMock('../util/format.ts', () => ({
|
|
15
|
+
output: mockOutput,
|
|
16
|
+
outputError: mockOutputError,
|
|
17
|
+
}))
|
|
18
|
+
vi.doMock('../util/client.ts', () => ({
|
|
19
|
+
graphql: mockGraphql,
|
|
20
|
+
}))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.resetModules()
|
|
25
|
+
vi.restoreAllMocks()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('search command', () => {
|
|
29
|
+
test('sends the SearchResult envelope query and outputs nodes + meta', async () => {
|
|
30
|
+
mockGraphql.mockResolvedValue({
|
|
31
|
+
search: {
|
|
32
|
+
nodes: [{ id: 'proj_1', title: 'Auth' }],
|
|
33
|
+
truncated: false,
|
|
34
|
+
total: 1,
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const { searchCommand } = await import('./search.ts')
|
|
39
|
+
await searchCommand.parseAsync(['Auth'], { from: 'user' })
|
|
40
|
+
|
|
41
|
+
const query = mockGraphql.mock.calls[0]?.[0] as string
|
|
42
|
+
expect(query).toContain('nodes')
|
|
43
|
+
expect(query).toContain('truncated')
|
|
44
|
+
expect(query).toContain('total')
|
|
45
|
+
|
|
46
|
+
expect(mockOutput).toHaveBeenCalledWith({
|
|
47
|
+
nodes: [{ id: 'proj_1', title: 'Auth' }],
|
|
48
|
+
truncated: false,
|
|
49
|
+
total: 1,
|
|
50
|
+
})
|
|
51
|
+
// no truncation warning when not truncated
|
|
52
|
+
expect(stderr).not.toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('renders a clear truncation marker when results are capped', async () => {
|
|
56
|
+
mockGraphql.mockResolvedValue({
|
|
57
|
+
search: {
|
|
58
|
+
nodes: new Array(50).fill({ id: 'x' }),
|
|
59
|
+
truncated: true,
|
|
60
|
+
total: 137,
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const { searchCommand } = await import('./search.ts')
|
|
65
|
+
await searchCommand.parseAsync(['Task'], { from: 'user' })
|
|
66
|
+
|
|
67
|
+
const outputArg = mockOutput.mock.calls[0]?.[0] as {
|
|
68
|
+
truncated: boolean
|
|
69
|
+
total: number
|
|
70
|
+
}
|
|
71
|
+
expect(outputArg.truncated).toBe(true)
|
|
72
|
+
expect(outputArg.total).toBe(137)
|
|
73
|
+
|
|
74
|
+
expect(stderr).toHaveBeenCalledOnce()
|
|
75
|
+
const warning = stderr.mock.calls[0]?.[0] as string
|
|
76
|
+
expect(warning).toMatch(/truncated/i)
|
|
77
|
+
expect(warning).toContain('50')
|
|
78
|
+
expect(warning).toContain('137')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('outputs error when the query fails', async () => {
|
|
82
|
+
mockGraphql.mockRejectedValue(new Error('boom'))
|
|
83
|
+
const { searchCommand } = await import('./search.ts')
|
|
84
|
+
await searchCommand.parseAsync(['Auth'], { from: 'user' })
|
|
85
|
+
expect(mockOutputError).toHaveBeenCalledWith(
|
|
86
|
+
expect.objectContaining({ message: 'boom' }),
|
|
87
|
+
)
|
|
88
|
+
expect(mockOutput).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
})
|
package/src/commands/search.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { graphql } from '../util/client.ts'
|
|
|
3
3
|
import { output, outputError } from '../util/format.ts'
|
|
4
4
|
import { SEARCH } from '../util/operations.ts'
|
|
5
5
|
|
|
6
|
+
interface SearchResult {
|
|
7
|
+
nodes: unknown[]
|
|
8
|
+
truncated: boolean
|
|
9
|
+
total: number
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export const searchCommand = new Command('search')
|
|
7
13
|
.description('Search nodes by text')
|
|
8
14
|
.argument('<query>', 'Search query')
|
|
@@ -11,13 +17,23 @@ export const searchCommand = new Command('search')
|
|
|
11
17
|
.option('--limit <n>', 'Limit results', '50')
|
|
12
18
|
.action(async (query: string, opts) => {
|
|
13
19
|
try {
|
|
14
|
-
const
|
|
20
|
+
const limit = opts.limit ? Number.parseInt(opts.limit, 10) : undefined
|
|
21
|
+
const data = await graphql<{ search: SearchResult }>(SEARCH, {
|
|
15
22
|
query,
|
|
16
23
|
type: opts.type,
|
|
17
24
|
status: opts.status,
|
|
18
|
-
limit
|
|
25
|
+
limit,
|
|
19
26
|
})
|
|
20
|
-
|
|
27
|
+
const { nodes, truncated, total } = data.search
|
|
28
|
+
// The truncation marker rides in the JSON envelope so callers can detect
|
|
29
|
+
// it programmatically; when capped we also warn on stderr so it is not
|
|
30
|
+
// silently lost in a human-read terminal.
|
|
31
|
+
output({ nodes, truncated, total })
|
|
32
|
+
if (truncated) {
|
|
33
|
+
console.error(
|
|
34
|
+
`Results truncated: showing ${nodes.length} of ${total} matches. Raise --limit to see more.`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
21
37
|
} catch (error) {
|
|
22
38
|
outputError(error)
|
|
23
39
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let mockGraphql: ReturnType<typeof vi.fn>
|
|
4
|
+
let mockOutput: ReturnType<typeof vi.fn>
|
|
5
|
+
let mockOutputError: ReturnType<typeof vi.fn>
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockOutput = vi.fn()
|
|
9
|
+
mockOutputError = vi.fn()
|
|
10
|
+
mockGraphql = vi.fn().mockResolvedValue({
|
|
11
|
+
updateNode: { id: 'task_1', type: 'task', title: 'T', status: 'done' },
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
vi.doMock('../util/format.ts', () => ({
|
|
15
|
+
output: mockOutput,
|
|
16
|
+
outputError: mockOutputError,
|
|
17
|
+
}))
|
|
18
|
+
vi.doMock('../util/client.ts', () => ({
|
|
19
|
+
graphql: mockGraphql,
|
|
20
|
+
}))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.resetModules()
|
|
25
|
+
vi.restoreAllMocks()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('status command', () => {
|
|
29
|
+
test('accepts a valid status and sends the update', async () => {
|
|
30
|
+
const { statusCommand } = await import('./status.ts')
|
|
31
|
+
await statusCommand.parseAsync(['task_1', 'in_progress'], { from: 'user' })
|
|
32
|
+
|
|
33
|
+
expect(mockGraphql).toHaveBeenCalledOnce()
|
|
34
|
+
expect(mockGraphql.mock.calls[0]?.[1]).toEqual({
|
|
35
|
+
id: 'task_1',
|
|
36
|
+
status: 'in_progress',
|
|
37
|
+
})
|
|
38
|
+
expect(mockOutput).toHaveBeenCalledOnce()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('rejects an invalid status client-side (.choices) without a request', async () => {
|
|
42
|
+
const { statusCommand } = await import('./status.ts')
|
|
43
|
+
// Commander throws on an invalid choice; configure it not to exit the
|
|
44
|
+
// process so the test can assert.
|
|
45
|
+
statusCommand.exitOverride()
|
|
46
|
+
statusCommand.configureOutput({ writeErr: () => {}, writeOut: () => {} })
|
|
47
|
+
|
|
48
|
+
await expect(
|
|
49
|
+
statusCommand.parseAsync(['task_1', 'bogus'], { from: 'user' }),
|
|
50
|
+
).rejects.toThrow(/Allowed choices|bogus/i)
|
|
51
|
+
|
|
52
|
+
expect(mockGraphql).not.toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('exposes the full canonical status vocabulary as choices', async () => {
|
|
56
|
+
const { statusCommand, STATUS_CHOICES } = await import('./status.ts')
|
|
57
|
+
expect(STATUS_CHOICES).toEqual([
|
|
58
|
+
'draft',
|
|
59
|
+
'pending_review',
|
|
60
|
+
'approved',
|
|
61
|
+
'in_progress',
|
|
62
|
+
'done',
|
|
63
|
+
'blocked',
|
|
64
|
+
'cancelled',
|
|
65
|
+
])
|
|
66
|
+
const statusArg = statusCommand.registeredArguments.find(
|
|
67
|
+
(a) => a.name() === 'status',
|
|
68
|
+
)
|
|
69
|
+
expect(statusArg?.argChoices).toEqual([...STATUS_CHOICES])
|
|
70
|
+
})
|
|
71
|
+
})
|
package/src/commands/status.ts
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
|
-
import { Command } from 'commander'
|
|
1
|
+
import { Argument, Command } from 'commander'
|
|
2
2
|
import { graphql } from '../util/client.ts'
|
|
3
3
|
import { output, outputError } from '../util/format.ts'
|
|
4
4
|
import { UPDATE_STATUS } from '../util/operations.ts'
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The canonical Flowy status vocabulary, mirrored from the server's
|
|
8
|
+
* VALID_STATUSES. Used for client-side `.choices()` validation so an invalid
|
|
9
|
+
* status is rejected before a request is ever sent.
|
|
10
|
+
*/
|
|
11
|
+
export const STATUS_CHOICES = [
|
|
12
|
+
'draft',
|
|
13
|
+
'pending_review',
|
|
14
|
+
'approved',
|
|
15
|
+
'in_progress',
|
|
16
|
+
'done',
|
|
17
|
+
'blocked',
|
|
18
|
+
'cancelled',
|
|
19
|
+
] as const
|
|
20
|
+
|
|
6
21
|
export const statusCommand = new Command('status')
|
|
7
22
|
.description('Update a node status (shorthand)')
|
|
8
23
|
.argument('<id>', 'Node ID')
|
|
9
|
-
.
|
|
10
|
-
'<status>',
|
|
11
|
-
'New status (draft, pending_review, approved, in_progress, done, blocked, cancelled)',
|
|
24
|
+
.addArgument(
|
|
25
|
+
new Argument('<status>', 'New status').choices([...STATUS_CHOICES]),
|
|
12
26
|
)
|
|
13
27
|
.action(async (id: string, status: string) => {
|
|
14
28
|
try {
|
package/src/index.test.ts
CHANGED
|
@@ -51,6 +51,10 @@ vi.mock('./commands/import.ts', () => ({
|
|
|
51
51
|
vi.mock('./commands/export.ts', () => ({
|
|
52
52
|
exportCommand: { name: () => 'export' },
|
|
53
53
|
}))
|
|
54
|
+
vi.mock('./commands/backup.ts', () => ({
|
|
55
|
+
backupCommand: { name: () => 'backup' },
|
|
56
|
+
restoreCommand: { name: () => 'restore' },
|
|
57
|
+
}))
|
|
54
58
|
|
|
55
59
|
describe('index.ts command registration', () => {
|
|
56
60
|
test('registers billing and key commands', async () => {
|
|
@@ -112,4 +116,18 @@ describe('index.ts command registration', () => {
|
|
|
112
116
|
)
|
|
113
117
|
expect(indexSource).toContain('program.addCommand(historyCommand)')
|
|
114
118
|
})
|
|
119
|
+
|
|
120
|
+
test('registers the backup and restore commands', async () => {
|
|
121
|
+
const { readFileSync } = await import('node:fs')
|
|
122
|
+
const indexSource = readFileSync(
|
|
123
|
+
new URL('./index.ts', import.meta.url).pathname,
|
|
124
|
+
'utf-8',
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(indexSource).toContain(
|
|
128
|
+
"import { backupCommand, restoreCommand } from './commands/backup.ts'",
|
|
129
|
+
)
|
|
130
|
+
expect(indexSource).toContain('program.addCommand(backupCommand)')
|
|
131
|
+
expect(indexSource).toContain('program.addCommand(restoreCommand)')
|
|
132
|
+
})
|
|
115
133
|
})
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ const pkgPath = resolve(
|
|
|
12
12
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
13
13
|
|
|
14
14
|
import { approveCommand } from './commands/approve.ts'
|
|
15
|
+
import { backupCommand, restoreCommand } from './commands/backup.ts'
|
|
15
16
|
import { billingCommand } from './commands/billing.ts'
|
|
16
17
|
import { clientCommand } from './commands/client.ts'
|
|
17
18
|
import { exportCommand } from './commands/export.ts'
|
|
@@ -51,5 +52,7 @@ program.addCommand(historyCommand)
|
|
|
51
52
|
program.addCommand(whoamiCommand)
|
|
52
53
|
program.addCommand(importCommand)
|
|
53
54
|
program.addCommand(exportCommand)
|
|
55
|
+
program.addCommand(backupCommand)
|
|
56
|
+
program.addCommand(restoreCommand)
|
|
54
57
|
|
|
55
58
|
program.parse()
|