@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 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,9 @@ 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`, and `import`/`export`. Account-only commands (`whoami`, `billing`, `key`) are remote-mode features and don't apply locally.
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.
184
+
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.
149
186
 
150
187
  ## Command Reference
151
188
 
@@ -179,10 +216,12 @@ The self-hosted server supports the full planning workflow — `init`, `project`
179
216
  | `task deps <id>` | Show what blocks a task and what it blocks |
180
217
  | `status <id> <status>` | Update status (shorthand) |
181
218
  | `approve <id>` | Approve (must be pending_review) |
182
- | `search <query> [--type] [--status] [--limit]` | Full-text search |
219
+ | `search <query> [--type] [--status] [--limit]` | Full-text search; prints `{ nodes, truncated, total }` and warns on stderr when results are capped at `--limit` |
183
220
  | `tree <id> [--depth N]` | Show subtree from any entity |
184
221
  | `import <manifest>` | Ingest a JSON manifest of nodes + edges (idempotent by client-key) |
185
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`) |
186
225
  | `whoami` | Show current user (remote mode) |
187
226
  | `billing checkout --tier <tier>` | Get a checkout URL for a subscription (remote mode) |
188
227
  | `key rotate` | Revoke all API keys and issue a new one (remote mode) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,7 @@
50
50
  "check": "biome check --write .",
51
51
  "test": "vitest run",
52
52
  "test:watch": "vitest",
53
+ "test:coverage": "vitest run --coverage",
53
54
  "test:e2e": "vitest run --config vitest.config.e2e.ts",
54
55
  "sdl": "bun scripts/print-sdl.ts",
55
56
  "typecheck": "tsc --noEmit",
@@ -64,11 +65,12 @@
64
65
  "@commitlint/config-conventional": "^20.4.2",
65
66
  "@semantic-release/changelog": "6.0.3",
66
67
  "@semantic-release/git": "10.0.1",
68
+ "@vitest/coverage-v8": "4.1.8",
67
69
  "husky": "^9.1.7",
68
70
  "lint-staged": "^16.3.0",
69
71
  "semantic-release": "25.0.3",
70
72
  "typescript": "^5",
71
- "vitest": "^4.1.2"
73
+ "vitest": "4.1.8"
72
74
  },
73
75
  "lint-staged": {
74
76
  "*.{ts,tsx,js,jsx}": [
@@ -360,11 +360,22 @@ describe('CLI/local-server contract (P1-1)', () => {
360
360
  })
361
361
  expect(Array.isArray(exportEdges.edges)).toBe(true)
362
362
 
363
- const search = await ok<{ search: Array<{ id: string }> }>(
364
- LOCAL_CONTRACT_OPERATIONS.SEARCH,
365
- { query: 'Contract', type: 'project', status: null, limit: 50 },
366
- )
367
- expect(search.search.some((n) => n.id === project.id)).toBe(true)
363
+ // SEARCH returns a SearchResult envelope (F32): nodes + truncation meta.
364
+ const search = await ok<{
365
+ search: {
366
+ nodes: Array<{ id: string }>
367
+ truncated: boolean
368
+ total: number
369
+ }
370
+ }>(LOCAL_CONTRACT_OPERATIONS.SEARCH, {
371
+ query: 'Contract',
372
+ type: 'project',
373
+ status: null,
374
+ limit: 50,
375
+ })
376
+ expect(search.search.nodes.some((n) => n.id === project.id)).toBe(true)
377
+ expect(search.search.truncated).toBe(false)
378
+ expect(typeof search.search.total).toBe('number')
368
379
 
369
380
  // auditLog (P1-2/F27): the task has accumulated a trail — a `create` on
370
381
  // insert, a `status_change` to pending_review, an `approve`, plus the
@@ -29,9 +29,12 @@ describe('server error masking', () => {
29
29
  it('surfaces a too-short search error with real message and VALIDATION_ERROR code', async () => {
30
30
  instance = createServer({ dbPath: ':memory:', port: 0 })
31
31
 
32
- const json = await gql('query ($q: String!) { search(query: $q) { id } }', {
33
- q: 'ab',
34
- })
32
+ const json = await gql(
33
+ 'query ($q: String!) { search(query: $q) { nodes { id } } }',
34
+ {
35
+ q: 'ab',
36
+ },
37
+ )
35
38
 
36
39
  expect(json.errors).toBeDefined()
37
40
  const error = json.errors![0]
@@ -7,15 +7,24 @@ export function createServer(opts?: {
7
7
  dbPath?: string
8
8
  port?: number
9
9
  hostname?: string
10
+ enforceStatusLifecycle?: boolean
10
11
  }) {
11
12
  const dbPath = opts?.dbPath ?? process.env.FLOWY_DB_PATH ?? './flowy.sqlite'
12
13
  const port = opts?.port ?? Number(process.env.PORT ?? 4000)
13
14
  // Bind loopback by default so the unauthenticated dev server is not exposed
14
15
  // on the LAN. Override with the `hostname` opt or the HOST env var.
15
16
  const hostname = opts?.hostname ?? process.env.HOST ?? '127.0.0.1'
17
+ // Status-lifecycle enforcement is OPT-IN (F32). Off unless explicitly enabled
18
+ // via the `enforceStatusLifecycle` opt or FLOWY_ENFORCE_STATUS_LIFECYCLE=1
19
+ // (also accepts "true"). When off, any vocabulary-valid status is accepted.
20
+ const enforceStatusLifecycle =
21
+ opts?.enforceStatusLifecycle ??
22
+ ['1', 'true'].includes(
23
+ (process.env.FLOWY_ENFORCE_STATUS_LIFECYCLE ?? '').toLowerCase(),
24
+ )
16
25
 
17
26
  const db = createDb(dbPath)
18
- const resolvers = createResolvers(db)
27
+ const resolvers = createResolvers(db, { enforceStatusLifecycle })
19
28
 
20
29
  const yoga = createYoga({
21
30
  schema: createSchema({ typeDefs, resolvers }),
@@ -773,8 +773,10 @@ describe('createResolvers', () => {
773
773
  })
774
774
 
775
775
  const results = resolvers.Query.search(null, { query: 'Auth' })
776
- expect(results).toHaveLength(1)
777
- expect(results[0].title).toBe('Authentication')
776
+ expect(results.nodes).toHaveLength(1)
777
+ expect(results.nodes[0].title).toBe('Authentication')
778
+ expect(results.truncated).toBe(false)
779
+ expect(results.total).toBe(1)
778
780
  })
779
781
 
780
782
  it('finds nodes by description', () => {
@@ -785,8 +787,8 @@ describe('createResolvers', () => {
785
787
  })
786
788
 
787
789
  const results = resolvers.Query.search(null, { query: 'OAuth' })
788
- expect(results).toHaveLength(1)
789
- expect(results[0].title).toBe('Login')
790
+ expect(results.nodes).toHaveLength(1)
791
+ expect(results.nodes[0].title).toBe('Login')
790
792
  })
791
793
 
792
794
  it('filters by type', () => {
@@ -803,8 +805,8 @@ describe('createResolvers', () => {
803
805
  query: 'Auth',
804
806
  type: 'project',
805
807
  })
806
- expect(results).toHaveLength(1)
807
- expect(results[0].type).toBe('project')
808
+ expect(results.nodes).toHaveLength(1)
809
+ expect(results.nodes[0].type).toBe('project')
808
810
  })
809
811
 
810
812
  it('filters by status', () => {
@@ -825,8 +827,8 @@ describe('createResolvers', () => {
825
827
  query: 'Auth',
826
828
  status: 'in_progress',
827
829
  })
828
- expect(results).toHaveLength(1)
829
- expect(results[0].title).toBe('Auth')
830
+ expect(results.nodes).toHaveLength(1)
831
+ expect(results.nodes[0].title).toBe('Auth')
830
832
  })
831
833
 
832
834
  it('respects limit', () => {
@@ -841,7 +843,58 @@ describe('createResolvers', () => {
841
843
  query: 'Task',
842
844
  limit: 2,
843
845
  })
844
- expect(results).toHaveLength(2)
846
+ expect(results.nodes).toHaveLength(2)
847
+ })
848
+
849
+ it('signals truncation when results are capped at the limit', () => {
850
+ for (let i = 0; i < 5; i++) {
851
+ resolvers.Mutation.createNode(null, {
852
+ type: 'task',
853
+ title: `Task ${i}`,
854
+ })
855
+ }
856
+
857
+ const results = resolvers.Query.search(null, {
858
+ query: 'Task',
859
+ limit: 2,
860
+ })
861
+ expect(results.nodes).toHaveLength(2)
862
+ expect(results.truncated).toBe(true)
863
+ expect(results.total).toBe(5)
864
+ })
865
+
866
+ it('does not signal truncation when all results fit under the limit', () => {
867
+ for (let i = 0; i < 3; i++) {
868
+ resolvers.Mutation.createNode(null, {
869
+ type: 'task',
870
+ title: `Task ${i}`,
871
+ })
872
+ }
873
+
874
+ const results = resolvers.Query.search(null, {
875
+ query: 'Task',
876
+ limit: 50,
877
+ })
878
+ expect(results.nodes).toHaveLength(3)
879
+ expect(results.truncated).toBe(false)
880
+ expect(results.total).toBe(3)
881
+ })
882
+
883
+ it('does not signal truncation when results exactly equal the limit', () => {
884
+ for (let i = 0; i < 2; i++) {
885
+ resolvers.Mutation.createNode(null, {
886
+ type: 'task',
887
+ title: `Task ${i}`,
888
+ })
889
+ }
890
+
891
+ const results = resolvers.Query.search(null, {
892
+ query: 'Task',
893
+ limit: 2,
894
+ })
895
+ expect(results.nodes).toHaveLength(2)
896
+ expect(results.truncated).toBe(false)
897
+ expect(results.total).toBe(2)
845
898
  })
846
899
  })
847
900
 
@@ -958,7 +1011,9 @@ describe('createResolvers', () => {
958
1011
  const results = resolvers.Query.search(null, {
959
1012
  query: 'zzz_no_match',
960
1013
  })
961
- expect(results).toEqual([])
1014
+ expect(results.nodes).toEqual([])
1015
+ expect(results.truncated).toBe(false)
1016
+ expect(results.total).toBe(0)
962
1017
  })
963
1018
 
964
1019
  it('throws when query is shorter than 3 characters', () => {
@@ -976,23 +1031,23 @@ describe('createResolvers', () => {
976
1031
  it('succeeds with 3-character query', () => {
977
1032
  create(resolvers, { type: 'project', title: 'abc match' })
978
1033
  const results = resolvers.Query.search(null, { query: 'abc' })
979
- expect(results).toHaveLength(1)
1034
+ expect(results.nodes).toHaveLength(1)
980
1035
  })
981
1036
 
982
1037
  it('does not treat % as LIKE wildcard', () => {
983
1038
  create(resolvers, { type: 'project', title: '100% done' })
984
1039
  create(resolvers, { type: 'project', title: '100 things' })
985
1040
  const results = resolvers.Query.search(null, { query: '100%' })
986
- expect(results).toHaveLength(1)
987
- expect(results[0].title).toBe('100% done')
1041
+ expect(results.nodes).toHaveLength(1)
1042
+ expect(results.nodes[0].title).toBe('100% done')
988
1043
  })
989
1044
 
990
1045
  it('does not treat _ as LIKE wildcard', () => {
991
1046
  create(resolvers, { type: 'project', title: '_est something' })
992
1047
  create(resolvers, { type: 'project', title: 'Test something' })
993
1048
  const results = resolvers.Query.search(null, { query: '_est' })
994
- expect(results).toHaveLength(1)
995
- expect(results[0].title).toBe('_est something')
1049
+ expect(results.nodes).toHaveLength(1)
1050
+ expect(results.nodes[0].title).toBe('_est something')
996
1051
  })
997
1052
  })
998
1053
 
@@ -1071,6 +1126,150 @@ describe('createResolvers', () => {
1071
1126
  })
1072
1127
  })
1073
1128
 
1129
+ describe('status lifecycle enforcement (opt-in)', () => {
1130
+ let strict: ReturnType<typeof createResolvers>
1131
+
1132
+ beforeEach(() => {
1133
+ strict = createResolvers(db, { enforceStatusLifecycle: true })
1134
+ })
1135
+
1136
+ function strictCreate(title: string): NodeGql {
1137
+ return strict.Mutation.createNode(null, { type: 'task', title })
1138
+ }
1139
+
1140
+ it('allows the canonical forward flow draft -> ... -> done', () => {
1141
+ const node = strictCreate('Flow')
1142
+ expect(node.status).toBe('draft')
1143
+ let cur = strict.Mutation.updateNode(null, {
1144
+ id: node.id,
1145
+ status: 'pending_review',
1146
+ })
1147
+ expect(cur.status).toBe('pending_review')
1148
+ cur = strict.Mutation.updateNode(null, {
1149
+ id: node.id,
1150
+ status: 'approved',
1151
+ })
1152
+ expect(cur.status).toBe('approved')
1153
+ cur = strict.Mutation.updateNode(null, {
1154
+ id: node.id,
1155
+ status: 'in_progress',
1156
+ })
1157
+ expect(cur.status).toBe('in_progress')
1158
+ cur = strict.Mutation.updateNode(null, { id: node.id, status: 'done' })
1159
+ expect(cur.status).toBe('done')
1160
+ })
1161
+
1162
+ it('rejects an illegal skip (draft -> done) with VALIDATION_ERROR', () => {
1163
+ const node = strictCreate('Skip')
1164
+ expect(() =>
1165
+ strict.Mutation.updateNode(null, { id: node.id, status: 'done' }),
1166
+ ).toThrow(/transition/i)
1167
+ try {
1168
+ strict.Mutation.updateNode(null, { id: node.id, status: 'done' })
1169
+ } catch (e) {
1170
+ expect((e as { extensions?: { code?: string } }).extensions?.code).toBe(
1171
+ 'VALIDATION_ERROR',
1172
+ )
1173
+ }
1174
+ })
1175
+
1176
+ it('rejects skipping pending_review -> in_progress', () => {
1177
+ const node = strictCreate('Skip2')
1178
+ strict.Mutation.updateNode(null, {
1179
+ id: node.id,
1180
+ status: 'pending_review',
1181
+ })
1182
+ expect(() =>
1183
+ strict.Mutation.updateNode(null, {
1184
+ id: node.id,
1185
+ status: 'in_progress',
1186
+ }),
1187
+ ).toThrow(/transition/i)
1188
+ })
1189
+
1190
+ it('allows cancelling from an active state', () => {
1191
+ const node = strictCreate('Cancel')
1192
+ strict.Mutation.updateNode(null, {
1193
+ id: node.id,
1194
+ status: 'pending_review',
1195
+ })
1196
+ const cur = strict.Mutation.updateNode(null, {
1197
+ id: node.id,
1198
+ status: 'cancelled',
1199
+ })
1200
+ expect(cur.status).toBe('cancelled')
1201
+ })
1202
+
1203
+ it('allows blocking from in_progress and resuming', () => {
1204
+ const node = strictCreate('Block')
1205
+ strict.Mutation.updateNode(null, {
1206
+ id: node.id,
1207
+ status: 'pending_review',
1208
+ })
1209
+ strict.Mutation.updateNode(null, { id: node.id, status: 'approved' })
1210
+ strict.Mutation.updateNode(null, { id: node.id, status: 'in_progress' })
1211
+ let cur = strict.Mutation.updateNode(null, {
1212
+ id: node.id,
1213
+ status: 'blocked',
1214
+ })
1215
+ expect(cur.status).toBe('blocked')
1216
+ cur = strict.Mutation.updateNode(null, {
1217
+ id: node.id,
1218
+ status: 'in_progress',
1219
+ })
1220
+ expect(cur.status).toBe('in_progress')
1221
+ })
1222
+
1223
+ it('rejects blocking directly from draft', () => {
1224
+ const node = strictCreate('BlockEarly')
1225
+ expect(() =>
1226
+ strict.Mutation.updateNode(null, { id: node.id, status: 'blocked' }),
1227
+ ).toThrow(/transition/i)
1228
+ })
1229
+
1230
+ it('allows reopening done -> in_progress', () => {
1231
+ const node = strictCreate('Reopen')
1232
+ strict.Mutation.updateNode(null, {
1233
+ id: node.id,
1234
+ status: 'pending_review',
1235
+ })
1236
+ strict.Mutation.updateNode(null, { id: node.id, status: 'approved' })
1237
+ strict.Mutation.updateNode(null, { id: node.id, status: 'in_progress' })
1238
+ strict.Mutation.updateNode(null, { id: node.id, status: 'done' })
1239
+ const cur = strict.Mutation.updateNode(null, {
1240
+ id: node.id,
1241
+ status: 'in_progress',
1242
+ })
1243
+ expect(cur.status).toBe('in_progress')
1244
+ })
1245
+
1246
+ it('allows a same-status no-op even under enforcement', () => {
1247
+ const node = strictCreate('NoOp')
1248
+ const cur = strict.Mutation.updateNode(null, {
1249
+ id: node.id,
1250
+ status: 'draft',
1251
+ })
1252
+ expect(cur.status).toBe('draft')
1253
+ })
1254
+
1255
+ it('still validates the status vocabulary', () => {
1256
+ const node = strictCreate('Vocab')
1257
+ expect(() =>
1258
+ strict.Mutation.updateNode(null, { id: node.id, status: 'bogus' }),
1259
+ ).toThrow(/Invalid status/)
1260
+ })
1261
+
1262
+ it('does NOT enforce transitions when the flag is off (default)', () => {
1263
+ // default `resolvers` (no enforcement) permits the illegal skip
1264
+ const node = create(resolvers, { type: 'task', title: 'Default' })
1265
+ const updated = resolvers.Mutation.updateNode(null, {
1266
+ id: node.id,
1267
+ status: 'done',
1268
+ })
1269
+ expect(updated.status).toBe('done')
1270
+ })
1271
+ })
1272
+
1074
1273
  describe('Query.descendants — edge cases', () => {
1075
1274
  it('returns empty array for leaf node', () => {
1076
1275
  const leaf = create(resolvers, { type: 'task', title: 'Leaf' })
@@ -1216,7 +1415,7 @@ describe('createResolvers', () => {
1216
1415
  query: 'Test',
1217
1416
  limit: 0,
1218
1417
  })
1219
- expect(results).toEqual([])
1418
+ expect(results.nodes).toEqual([])
1220
1419
  })
1221
1420
  })
1222
1421
 
@@ -81,6 +81,18 @@ export interface SubtreeNodeGql extends NodeGql {
81
81
  relation: string
82
82
  }
83
83
 
84
+ /**
85
+ * Search results plus truncation metadata (F32). `nodes` is capped at `limit`;
86
+ * `total` is the unbounded match count and `truncated` is true when `total`
87
+ * exceeds the returned page — letting the CLI show a clear "results truncated"
88
+ * marker instead of silently dropping matches at the default cap.
89
+ */
90
+ export interface SearchResultGql {
91
+ nodes: NodeGql[]
92
+ truncated: boolean
93
+ total: number
94
+ }
95
+
84
96
  interface SubtreeRow extends NodeRow {
85
97
  parent_id: string
86
98
  depth: number
@@ -114,6 +126,36 @@ function assertValidStatus(status: string): void {
114
126
  }
115
127
  }
116
128
 
129
+ /**
130
+ * Allowed status transitions for the OPT-IN lifecycle enforcement
131
+ * (`FLOWY_ENFORCE_STATUS_LIFECYCLE`). The canonical forward flow is
132
+ * `draft → pending_review → approved → in_progress → done`; `blocked` and
133
+ * `cancelled` are reachable from active states and recoverable back into the
134
+ * flow. A same-status update is always a no-op and never checked here. When
135
+ * enforcement is OFF (the default) this map is unused and any vocabulary-valid
136
+ * status is accepted, preserving the prior behaviour.
137
+ */
138
+ const ALLOWED_TRANSITIONS: Record<string, Set<string>> = {
139
+ draft: new Set(['pending_review', 'cancelled']),
140
+ pending_review: new Set(['approved', 'draft', 'cancelled']),
141
+ approved: new Set(['in_progress', 'pending_review', 'cancelled']),
142
+ in_progress: new Set(['done', 'blocked', 'cancelled']),
143
+ done: new Set(['in_progress']),
144
+ blocked: new Set(['in_progress', 'cancelled']),
145
+ cancelled: new Set(['draft']),
146
+ }
147
+
148
+ function assertValidTransition(from: string, to: string): void {
149
+ if (from === to) return
150
+ if (!ALLOWED_TRANSITIONS[from]?.has(to)) {
151
+ throw validationError(
152
+ `Illegal status transition: ${from} → ${to}. Allowed from "${from}": ${
153
+ [...(ALLOWED_TRANSITIONS[from] ?? [])].join(', ') || '(none)'
154
+ }`,
155
+ )
156
+ }
157
+ }
158
+
117
159
  /**
118
160
  * Validate that `metadata` is a JSON string and return its canonical form.
119
161
  * Metadata is stored as a JSON string (the column and the GraphQL field are
@@ -216,7 +258,18 @@ function prefixedCols() {
216
258
  .join(', ')
217
259
  }
218
260
 
219
- export function createResolvers(db: Db) {
261
+ export interface ResolverOptions {
262
+ /**
263
+ * Enforce the canonical status lifecycle on `updateNode` status changes.
264
+ * OFF by default — when false (the default) any vocabulary-valid status is
265
+ * accepted, matching pre-F32 behaviour. Wired from
266
+ * `FLOWY_ENFORCE_STATUS_LIFECYCLE` in `index.ts`.
267
+ */
268
+ enforceStatusLifecycle?: boolean
269
+ }
270
+
271
+ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
272
+ const enforceStatusLifecycle = opts.enforceStatusLifecycle ?? false
220
273
  return {
221
274
  Query: {
222
275
  node: (_: unknown, args: { id: string }) => {
@@ -386,7 +439,7 @@ export function createResolvers(db: Db) {
386
439
  status?: string
387
440
  limit?: number
388
441
  },
389
- ) => {
442
+ ): SearchResultGql => {
390
443
  if (args.query.trim().length < 3) {
391
444
  throw validationError('Search query must be at least 3 characters')
392
445
  }
@@ -403,13 +456,23 @@ export function createResolvers(db: Db) {
403
456
  conditions.push('status = ?')
404
457
  params.push(args.status)
405
458
  }
459
+ const where = conditions.join(' AND ')
406
460
  const limit = args.limit ?? 50
461
+ // Fetch one extra row so we can tell "exactly at the cap" from
462
+ // "more matches exist" without a second COUNT for the common case.
407
463
  const rows = db.raw
408
- .query(
409
- `SELECT ${NODE_COLS} FROM nodes WHERE ${conditions.join(' AND ')} LIMIT ?`,
410
- )
411
- .all(...params, limit) as NodeRow[]
412
- return selectNodes(rows)
464
+ .query(`SELECT ${NODE_COLS} FROM nodes WHERE ${where} LIMIT ?`)
465
+ .all(...params, limit + 1) as NodeRow[]
466
+ const truncated = rows.length > limit
467
+ const page = truncated ? rows.slice(0, limit) : rows
468
+ const total = truncated
469
+ ? (
470
+ db.raw
471
+ .query(`SELECT COUNT(*) AS c FROM nodes WHERE ${where}`)
472
+ .get(...params) as { c: number }
473
+ ).c
474
+ : page.length
475
+ return { nodes: selectNodes(page), truncated, total }
413
476
  },
414
477
 
415
478
  // Audit history for a node, newest first. Shaped to match the SaaS
@@ -507,7 +570,12 @@ export function createResolvers(db: Db) {
507
570
  if (args.description != null && !args.description.trim()) {
508
571
  throw validationError('Description cannot be empty')
509
572
  }
510
- if (args.status != null) assertValidStatus(args.status)
573
+ if (args.status != null) {
574
+ assertValidStatus(args.status)
575
+ if (enforceStatusLifecycle) {
576
+ assertValidTransition(existing.status, args.status)
577
+ }
578
+ }
511
579
 
512
580
  const next: NodeRow = {
513
581
  ...existing,
@@ -52,6 +52,16 @@ export const typeDefs = /* GraphQL */ `
52
52
  relation: String!
53
53
  }
54
54
 
55
+ # Search results plus truncation metadata (F32). \`nodes\` is the page capped
56
+ # at the requested \`limit\`; \`total\` is the unbounded match count and
57
+ # \`truncated\` is true when more matches exist than were returned, so the CLI
58
+ # can surface a "results truncated" marker instead of silently dropping rows.
59
+ type SearchResult {
60
+ nodes: [Node!]!
61
+ truncated: Boolean!
62
+ total: Int!
63
+ }
64
+
55
65
  type Query {
56
66
  node(id: String!): Node
57
67
  nodes(type: String): [Node!]!
@@ -59,7 +69,12 @@ export const typeDefs = /* GraphQL */ `
59
69
  subtree(nodeId: String!, relation: String, maxDepth: Int): [SubtreeNode!]!
60
70
  edges(nodeId: String!, relation: String!, direction: String): [Node!]!
61
71
  readyTasks(projectId: String): [Node!]!
62
- search(query: String!, type: String, status: String, limit: Int): [Node!]!
72
+ search(
73
+ query: String!
74
+ type: String
75
+ status: String
76
+ limit: Int
77
+ ): SearchResult!
63
78
  auditLog(nodeId: String!, limit: Int): [AuditEntry!]!
64
79
  }
65
80
 
@@ -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.
@@ -154,6 +155,9 @@ flowy status <id> done
154
155
  flowy search "query" --type task --status draft --limit 10
155
156
  flowy tree <id> --depth 3 # show subtree from any entity
156
157
  ```
158
+ `search` prints `{ nodes, truncated, total }`. When `truncated` is `true` the
159
+ results were capped at `--limit` (default 50) and `total` more matches exist —
160
+ raise `--limit` to see them.
157
161
 
158
162
  ### Import and Export
159
163
  ```bash
@@ -180,6 +184,19 @@ A manifest is a single JSON document describing a backlog: `nodes` (projects, fe
180
184
 
181
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.
182
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
+
183
200
  ### Remote-only (hosted mode)
184
201
  These hit account/billing resolvers that do **not** exist on the local server; they fail in local mode.
185
202
  ```bash