@sqaoss/flowy 1.11.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 +38 -1
- package/package.json +1 -1
- package/skills/using-flowy/SKILL.md +14 -0
- package/src/commands/backup.test.ts +252 -0
- package/src/commands/backup.ts +145 -0
- package/src/index.test.ts +18 -0
- package/src/index.ts +3 -0
package/README.md
CHANGED
|
@@ -108,6 +108,41 @@ A manifest looks like:
|
|
|
108
108
|
|
|
109
109
|
Each node's `parent` implies a `part_of` edge, so the simplest manifests need no explicit `edges`. `blocks` dependencies go in `edges`. The reserved `__flowyKey` metadata field stores the client-key; your own `metadata` is preserved alongside it and stripped back out on export.
|
|
110
110
|
|
|
111
|
+
### Backup and restore (local SQLite)
|
|
112
|
+
|
|
113
|
+
In self-hosted mode your backlog lives in a single SQLite file (see [Where your data lives](#where-your-data-lives)). `flowy backup` takes a consistent, file-level snapshot of that database, and `flowy restore` reinstates one.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
flowy backup flowy-backup.sqlite # snapshot the local DB (./flowy.sqlite by default)
|
|
117
|
+
flowy backup ~/snapshots/flowy.sqlite --db ~/flowy.sqlite # back up a DB at a custom path
|
|
118
|
+
flowy restore flowy-backup.sqlite # restore into a fresh DB (refuses to clobber)
|
|
119
|
+
flowy restore flowy-backup.sqlite --force # overwrite an existing DB
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The snapshot is taken with SQLite's `VACUUM INTO`, so it is transactionally consistent **even while the server is running** and writes a single self-contained file (no `-wal`/`-shm` sidecars). `restore` validates the source is a real SQLite database before touching the target, and **refuses to overwrite an existing database** unless you pass `--force`.
|
|
123
|
+
|
|
124
|
+
Both commands resolve the database path the same way the server does: `--db <path>`, then `$FLOWY_DB_PATH`, then `./flowy.sqlite`.
|
|
125
|
+
|
|
126
|
+
**`backup` vs. `export` — they're complementary, not redundant:**
|
|
127
|
+
|
|
128
|
+
| | `flowy export` (logical) | `flowy backup` (raw) |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| Format | Portable JSON manifest | Exact SQLite file |
|
|
131
|
+
| Scope | The active project's subtree | The entire database (all projects) |
|
|
132
|
+
| Re-importable | Yes — `flowy import` (idempotent, cross-backend) | No — restore only, local server |
|
|
133
|
+
| Use it for | Migrating between machines/backends, re-importing, diffing in git | Point-in-time disaster recovery, an exact byte-faithful snapshot |
|
|
134
|
+
|
|
135
|
+
Use `export`/`import` to move a backlog around or seed another backend; use `backup`/`restore` for a true snapshot of your local server's data.
|
|
136
|
+
|
|
137
|
+
### Where your data lives
|
|
138
|
+
|
|
139
|
+
The self-hosted server persists everything in one SQLite file. Its location depends on how you run the server:
|
|
140
|
+
|
|
141
|
+
- **`flowy serve`** (native): `./flowy.sqlite` in the current directory by default, or wherever `--db`/`$FLOWY_DB_PATH` points.
|
|
142
|
+
- **Docker (`docker compose up`)**: inside the named volume `flowy-data`, mounted at `/data`, with `FLOWY_DB_PATH=/data/flowy.sqlite`. The data outlives the container — but `docker compose down -v` deletes the volume **and your backlog with it**. Take a `flowy backup` first.
|
|
143
|
+
|
|
144
|
+
To back up the Docker volume's database, point `--db` at the in-container path while running `flowy backup` from inside the container, or restore into a fresh `flowy serve` directory and snapshot there.
|
|
145
|
+
|
|
111
146
|
## Agent Skill
|
|
112
147
|
|
|
113
148
|
`flowy setup` installs an agent skill so your AI agent automatically knows every command. If that install step fails (offline, no `npx`, registry hiccup), setup prints a warning telling you to install it manually:
|
|
@@ -145,7 +180,7 @@ flowy serve # bind 127.0.0.1:4000, store data in ./flowy.sqlite
|
|
|
145
180
|
flowy serve --port 5000 --host 0.0.0.0 --db ~/flowy.sqlite # override defaults
|
|
146
181
|
```
|
|
147
182
|
|
|
148
|
-
The self-hosted server supports the full planning workflow — `init`, `project`/`feature`/`task` CRUD, `status`, `approve`, `search`, `tree`, `task deps`, `task list --ready/--all`,
|
|
183
|
+
The self-hosted server supports the full planning workflow — `init`, `project`/`feature`/`task` CRUD, `status`, `approve`, `search`, `tree`, `task deps`, `task list --ready/--all`, `import`/`export`, and `backup`/`restore` (raw SQLite snapshots). Account-only commands (`whoami`, `billing`, `key`) are remote-mode features and don't apply locally.
|
|
149
184
|
|
|
150
185
|
The canonical status flow is `draft → pending_review → approved → in_progress → done`, plus `blocked` and `cancelled`. By default any status change is allowed (and the `status` command validates the value client-side). To make the local server *enforce* legal transitions — rejecting illegal jumps like `draft → done` with a `VALIDATION_ERROR` — start it with `FLOWY_ENFORCE_STATUS_LIFECYCLE=1`. Enforcement is opt-in and off by default.
|
|
151
186
|
|
|
@@ -185,6 +220,8 @@ The canonical status flow is `draft → pending_review → approved → in_progr
|
|
|
185
220
|
| `tree <id> [--depth N]` | Show subtree from any entity |
|
|
186
221
|
| `import <manifest>` | Ingest a JSON manifest of nodes + edges (idempotent by client-key) |
|
|
187
222
|
| `export [output]` | Dump the active project as a manifest (stdout or file) |
|
|
223
|
+
| `backup <dest> [--db <path>]` | Consistent file-level snapshot of the local SQLite database |
|
|
224
|
+
| `restore <src> [--db <path>] [--force]` | Restore the local SQLite database from a backup (refuses to clobber without `--force`) |
|
|
188
225
|
| `whoami` | Show current user (remote mode) |
|
|
189
226
|
| `billing checkout --tier <tier>` | Get a checkout URL for a subscription (remote mode) |
|
|
190
227
|
| `key rotate` | Revoke all API keys and issue a new one (remote mode) |
|
package/package.json
CHANGED
|
@@ -22,6 +22,7 @@ Flowy runs against one of two backends. Which one you're in determines which com
|
|
|
22
22
|
| Account / API key | None | Email registration; API key stored in config |
|
|
23
23
|
| Subscription | None — fully free | Data operations require an active subscription |
|
|
24
24
|
| Planning workflow | Full (`init`, project/feature/task CRUD, status, approve, search, tree, `task deps`, `task list --ready/--all`, `import`/`export`) | Full, same commands |
|
|
25
|
+
| `backup` / `restore` | Available (raw SQLite snapshot of the local DB) | **Not available** — local-only |
|
|
25
26
|
| `whoami` / `billing` / `key` | **Not available** — these hard-fail locally | Available |
|
|
26
27
|
|
|
27
28
|
The planning workflow is **identical** in both modes. Only the account/billing commands differ.
|
|
@@ -183,6 +184,19 @@ A manifest is a single JSON document describing a backlog: `nodes` (projects, fe
|
|
|
183
184
|
|
|
184
185
|
**Idempotency:** import upserts by client-key — re-importing the same manifest updates the matching nodes in place instead of creating duplicates. The key is stored in node metadata under the reserved `__flowyKey` field (your own `metadata` is preserved alongside it and stripped back out on export). A node's `parent` implies a `part_of` edge, so simple manifests need no explicit `edges`; `blocks` dependencies are listed in `edges`. Edges live in the real edge model (`createEdge` / `Query.edges`), so a `blocks` edge created by hand with `task block` is captured on export and never re-created on the next import. Works in both local and remote modes.
|
|
185
186
|
|
|
187
|
+
### Backup and Restore (local mode only)
|
|
188
|
+
```bash
|
|
189
|
+
flowy backup flowy-backup.sqlite # consistent snapshot of the local SQLite DB
|
|
190
|
+
flowy restore flowy-backup.sqlite # restore into a fresh DB (refuses to clobber)
|
|
191
|
+
flowy restore flowy-backup.sqlite --force # overwrite an existing DB
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`backup`/`restore` operate on the **raw SQLite file** the self-hosted server uses, resolving its path from `--db`, then `$FLOWY_DB_PATH`, then `./flowy.sqlite`. The snapshot is taken with SQLite `VACUUM INTO`, so it is consistent even while the server is running. `restore` refuses to overwrite an existing database without `--force`.
|
|
195
|
+
|
|
196
|
+
This is **complementary to** `export`/`import`, not a replacement: `export` produces a portable, re-importable JSON manifest of one project (works in both modes); `backup` produces an exact, byte-faithful snapshot of the whole local database for disaster recovery (local mode only). Use `export` to move or seed a backlog; use `backup` for a true point-in-time snapshot.
|
|
197
|
+
|
|
198
|
+
**Where the data lives:** in self-hosted mode the entire backlog is one SQLite file — `./flowy.sqlite` by default (or `$FLOWY_DB_PATH`). Under Docker it lives in the named volume `flowy-data` (mounted at `/data`, `FLOWY_DB_PATH=/data/flowy.sqlite`); `docker compose down -v` deletes that volume and the backlog with it, so take a `flowy backup` first.
|
|
199
|
+
|
|
186
200
|
### Remote-only (hosted mode)
|
|
187
201
|
These hit account/billing resolvers that do **not** exist on the local server; they fail in local mode.
|
|
188
202
|
```bash
|
|
@@ -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
|
+
})
|
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()
|