@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
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`,
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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(
|
|
33
|
-
q: '
|
|
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]
|
package/server/src/index.ts
CHANGED
|
@@ -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
|
|
package/server/src/resolvers.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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)
|
|
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,
|
package/server/src/schema.ts
CHANGED
|
@@ -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(
|
|
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
|