@sqaoss/flowy 1.11.0 → 1.13.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,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`, 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqaoss/flowy",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Agentic persistent planning",
5
5
  "type": "module",
6
6
  "bin": {
@@ -175,6 +175,48 @@ describe('CLI/local-server contract (P1-1)', () => {
175
175
  relation: 'part_of',
176
176
  })
177
177
 
178
+ // CREATE_NODE_WITH_PARENT (P1-4/F24): create a node AND its part_of edge to
179
+ // the feature in one atomic call. The returned node must be a real child of
180
+ // the feature (reachable via the part_of hierarchy), proving the edge was
181
+ // created in the same unit of work — no separate createEdge call needed.
182
+ const { createNode: parentedTask } = await ok<{
183
+ createNode: { id: string }
184
+ }>(LOCAL_CONTRACT_OPERATIONS.CREATE_NODE_WITH_PARENT, {
185
+ type: 'task',
186
+ title: 'Parented Task',
187
+ description: 'linked atomically',
188
+ parentId: feature.id,
189
+ })
190
+ expect(parentedTask.id).toMatch(/^task_/)
191
+ const parentedChildren = await ok<{ descendants: Array<{ id: string }> }>(
192
+ LOCAL_CONTRACT_OPERATIONS.LIST_TASKS,
193
+ { nodeId: feature.id, relation: 'part_of', maxDepth: 1 },
194
+ )
195
+ expect(parentedChildren.descendants.map((n) => n.id)).toContain(
196
+ parentedTask.id,
197
+ )
198
+
199
+ // A non-existent parent must be rejected (NOT_FOUND) and leave no orphan.
200
+ const beforeOrphan = await ok<{ nodes: Array<{ id: string }> }>(
201
+ LOCAL_CONTRACT_OPERATIONS.ALL_TASKS,
202
+ { type: 'task' },
203
+ )
204
+ const badParent = await run(
205
+ LOCAL_CONTRACT_OPERATIONS.CREATE_NODE_WITH_PARENT,
206
+ {
207
+ type: 'task',
208
+ title: 'Should Not Exist',
209
+ description: 'orphan attempt',
210
+ parentId: 'feat_missing',
211
+ },
212
+ )
213
+ expect(badParent.errors).toBeDefined()
214
+ const afterOrphan = await ok<{ nodes: Array<{ id: string }> }>(
215
+ LOCAL_CONTRACT_OPERATIONS.ALL_TASKS,
216
+ { type: 'task' },
217
+ )
218
+ expect(afterOrphan.nodes).toHaveLength(beforeOrphan.nodes.length)
219
+
178
220
  // blocker blocks task
179
221
  const { createEdge: blocksEdge } = await ok<{
180
222
  createEdge: { relation: string; createdAt: string }
@@ -443,6 +485,7 @@ describe('CLI/local-server contract (P1-1)', () => {
443
485
  'CREATE_PROJECT',
444
486
  'CREATE_NODE',
445
487
  'CREATE_TASK',
488
+ 'CREATE_NODE_WITH_PARENT',
446
489
  'UPDATE_NODE',
447
490
  'UPDATE_STATUS',
448
491
  'APPROVE_NODE',
@@ -151,6 +151,79 @@ describe('createResolvers', () => {
151
151
  })
152
152
  })
153
153
 
154
+ describe('Mutation.createNode with parentId (F24)', () => {
155
+ it('creates the node and a part_of edge to the parent atomically', () => {
156
+ const parent = resolvers.Mutation.createNode(null, {
157
+ type: 'feature',
158
+ title: 'Parent Feature',
159
+ })
160
+ const child = resolvers.Mutation.createNode(null, {
161
+ type: 'task',
162
+ title: 'Child Task',
163
+ parentId: parent.id,
164
+ })
165
+ expect(child.id).toMatch(/^task_/)
166
+ // the node exists
167
+ expect(resolvers.Query.node(null, { id: child.id })?.id).toBe(child.id)
168
+ // a part_of edge child -> parent exists (child reachable from parent)
169
+ const children = resolvers.Query.descendants(null, {
170
+ nodeId: parent.id,
171
+ relation: 'part_of',
172
+ maxDepth: 1,
173
+ })
174
+ expect(children.map((n) => n.id)).toContain(child.id)
175
+ })
176
+
177
+ it('writes a create audit row and a create_edge audit row for the link', () => {
178
+ const parent = resolvers.Mutation.createNode(null, {
179
+ type: 'feature',
180
+ title: 'Parent',
181
+ })
182
+ const child = resolvers.Mutation.createNode(null, {
183
+ type: 'task',
184
+ title: 'Child',
185
+ parentId: parent.id,
186
+ })
187
+ const childHistory = resolvers.Query.auditLog(null, { nodeId: child.id })
188
+ const actions = childHistory.map((e) => e.action)
189
+ expect(actions).toContain('create')
190
+ expect(actions).toContain('create_edge')
191
+ const edgeEntry = childHistory.find((e) => e.action === 'create_edge')
192
+ expect(edgeEntry?.field).toBe('part_of')
193
+ expect(edgeEntry?.newValue).toBe(parent.id)
194
+ })
195
+
196
+ it('rejects a non-existent parent with NOT_FOUND and creates no orphan', () => {
197
+ const before = resolvers.Query.nodes(null, {})
198
+ try {
199
+ resolvers.Mutation.createNode(null, {
200
+ type: 'task',
201
+ title: 'Orphan?',
202
+ parentId: 'feat_does_not_exist',
203
+ })
204
+ throw new Error('expected createNode to throw')
205
+ } catch (err) {
206
+ const e = err as { message: string; extensions?: { code?: string } }
207
+ expect(e.message).toContain('feat_does_not_exist')
208
+ expect(e.extensions?.code).toBe('NOT_FOUND')
209
+ }
210
+ // nothing was written: no new node, no dangling edge
211
+ const after = resolvers.Query.nodes(null, {})
212
+ expect(after).toHaveLength(before.length)
213
+ expect(after.some((n) => n.title === 'Orphan?')).toBe(false)
214
+ })
215
+
216
+ it('behaves exactly like a plain create when parentId is omitted', () => {
217
+ const node = resolvers.Mutation.createNode(null, {
218
+ type: 'task',
219
+ title: 'No parent',
220
+ })
221
+ expect(node.id).toMatch(/^task_/)
222
+ const history = resolvers.Query.auditLog(null, { nodeId: node.id })
223
+ expect(history.map((e) => e.action)).toEqual(['create'])
224
+ })
225
+ })
226
+
154
227
  describe('Query.node', () => {
155
228
  it('returns a node by id', () => {
156
229
  const created = resolvers.Mutation.createNode(null, {
@@ -495,6 +495,13 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
495
495
  },
496
496
 
497
497
  Mutation: {
498
+ // Create a node, optionally linking it under a parent in one atomic step
499
+ // (F24). When `parentId` is given we validate the parent exists FIRST —
500
+ // before any write — then, in a SINGLE transaction, insert the node, its
501
+ // `create` audit row, the `part_of` edge (child -> parent), and the
502
+ // edge's `create_edge` audit row. A failure anywhere rolls the whole unit
503
+ // back, so a bad link can never leave an orphaned node. With no
504
+ // `parentId`, behaviour is unchanged: just the node + its create audit.
498
505
  createNode: (
499
506
  _: unknown,
500
507
  args: {
@@ -503,6 +510,7 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
503
510
  description?: string
504
511
  status?: string
505
512
  metadata?: string
513
+ parentId?: string
506
514
  },
507
515
  ): NodeGql => {
508
516
  if (!args.title.trim()) throw validationError('Title is required')
@@ -510,6 +518,15 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
510
518
  throw validationError('Description cannot be empty')
511
519
  }
512
520
  if (args.status != null) assertValidStatus(args.status)
521
+ // Validate the parent up front so a bad link errors before any write.
522
+ if (args.parentId != null) {
523
+ const parentExists = db.raw
524
+ .query('SELECT id FROM nodes WHERE id = ?')
525
+ .get(args.parentId)
526
+ if (!parentExists) {
527
+ throw notFoundError(`Parent node ${args.parentId} not found`)
528
+ }
529
+ }
513
530
  const metadata =
514
531
  args.metadata != null ? normalizeMetadata(args.metadata) : null
515
532
  const id = generateId(args.type)
@@ -545,6 +562,18 @@ export function createResolvers(db: Db, opts: ResolverOptions = {}) {
545
562
  action: 'create',
546
563
  snapshot: node as unknown as Record<string, unknown>,
547
564
  })
565
+ if (args.parentId != null) {
566
+ db.raw.run(
567
+ 'INSERT INTO edges (source_id, target_id, relation, created_at) VALUES (?, ?, ?, ?)',
568
+ [id, args.parentId, 'part_of', now],
569
+ )
570
+ insertAudit(db, {
571
+ nodeId: id,
572
+ action: 'create_edge',
573
+ field: 'part_of',
574
+ newValue: args.parentId,
575
+ })
576
+ }
548
577
  })()
549
578
  return node
550
579
  },
@@ -85,6 +85,7 @@ export const typeDefs = /* GraphQL */ `
85
85
  description: String
86
86
  status: String
87
87
  metadata: String
88
+ parentId: String
88
89
  ): Node!
89
90
  updateNode(
90
91
  id: String!
@@ -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
+ })
@@ -56,6 +56,50 @@ describe('feature command', () => {
56
56
  )
57
57
  })
58
58
 
59
+ test('create issues ONE createNode-with-parent call under the active project', async () => {
60
+ const { graphql } = await import('../util/client.ts')
61
+ const { requireProject } = await import('../util/config.ts')
62
+ const { output } = await import('../util/format.ts')
63
+ const { featureCommand } = await import('./feature.ts')
64
+
65
+ vi.mocked(requireProject).mockReturnValueOnce({
66
+ id: 'proj_active',
67
+ name: 'active',
68
+ })
69
+ vi.mocked(graphql).mockResolvedValueOnce({
70
+ createNode: { id: 'feat_new', title: 'Test' },
71
+ })
72
+
73
+ const createCmd = featureCommand.commands.find((c) => c.name() === 'create')
74
+ await createCmd?.parseAsync(['--title', 'Test', '--description', 'Desc'], {
75
+ from: 'user',
76
+ })
77
+
78
+ expect(graphql).toHaveBeenCalledTimes(1)
79
+ const [query, variables] = vi.mocked(graphql).mock.calls[0]!
80
+ expect(query).toContain('createNode')
81
+ expect(query).toContain('parentId')
82
+ expect(variables).toMatchObject({
83
+ type: 'feature',
84
+ title: 'Test',
85
+ description: 'Desc',
86
+ parentId: 'proj_active',
87
+ })
88
+ expect(output).toHaveBeenCalledWith({ id: 'feat_new', title: 'Test' })
89
+ })
90
+
91
+ test('create validates the project BEFORE any write (no createNode on bad project)', async () => {
92
+ const { graphql } = await import('../util/client.ts')
93
+ const { featureCommand } = await import('./feature.ts')
94
+
95
+ const createCmd = featureCommand.commands.find((c) => c.name() === 'create')
96
+ await createCmd?.parseAsync(['--title', 'Test', '--description', 'Desc'], {
97
+ from: 'user',
98
+ })
99
+
100
+ expect(graphql).not.toHaveBeenCalled()
101
+ })
102
+
59
103
  test('unset calls updateProjectConfig to delete activeFeature', async () => {
60
104
  const { featureCommand } = await import('./feature.ts')
61
105
  const { output } = await import('../util/format.ts')
@@ -8,7 +8,6 @@ import {
8
8
  import { resolveDescription } from '../util/description.ts'
9
9
  import { output, outputError } from '../util/format.ts'
10
10
  import {
11
- CREATE_EDGE,
12
11
  CREATE_NODE,
13
12
  DELETE_NODE,
14
13
  DESCENDANTS,
@@ -35,21 +34,24 @@ featureCommand
35
34
  )
36
35
  .action(async (opts) => {
37
36
  try {
37
+ // Validate the active project BEFORE any write so a bad project context
38
+ // errors cleanly instead of orphaning a node (F24).
38
39
  const project = requireProject()
39
40
  const description = await resolveDescription({
40
41
  description: opts.description,
41
42
  descriptionFile: opts.descriptionFile,
42
43
  })
44
+ // One atomic call: the server creates the feature and its `part_of` edge
45
+ // to the project together, so a failed link can never leave an orphan.
43
46
  const nodeData = await graphql<{ createNode: { id: string } }>(
44
47
  CREATE_NODE,
45
- { type: 'feature', title: opts.title, description },
48
+ {
49
+ type: 'feature',
50
+ title: opts.title,
51
+ description,
52
+ parentId: project.id,
53
+ },
46
54
  )
47
- const featureId = nodeData.createNode.id
48
- await graphql(CREATE_EDGE, {
49
- sourceId: featureId,
50
- targetId: project.id,
51
- relation: 'part_of',
52
- })
53
55
  output(nodeData.createNode)
54
56
  } catch (error) {
55
57
  outputError(error)
@@ -63,6 +63,50 @@ describe('task command', () => {
63
63
  )
64
64
  })
65
65
 
66
+ test('create validates the feature BEFORE any write (no createNode on bad feature)', async () => {
67
+ const { graphql } = await import('../util/client.ts')
68
+ const { taskCommand } = await import('./task.ts')
69
+
70
+ // requireFeature() throws (default mock) — the command must bail out before
71
+ // issuing any mutation, so the node is never created.
72
+ const createCmd = taskCommand.commands.find((c) => c.name() === 'create')!
73
+ await createCmd.parseAsync(['--title', 'Test', '--description', 'desc'], {
74
+ from: 'user',
75
+ })
76
+
77
+ expect(graphql).not.toHaveBeenCalled()
78
+ })
79
+
80
+ test('create issues ONE createNode-with-parent call (not a create then a link)', async () => {
81
+ const { graphql } = await import('../util/client.ts')
82
+ const { requireFeature } = await import('../util/config.ts')
83
+ const { output } = await import('../util/format.ts')
84
+ const { taskCommand } = await import('./task.ts')
85
+
86
+ vi.mocked(requireFeature).mockReturnValueOnce('feat_active')
87
+ vi.mocked(graphql).mockResolvedValueOnce({
88
+ createNode: { id: 'task_new', title: 'Test' },
89
+ })
90
+
91
+ const createCmd = taskCommand.commands.find((c) => c.name() === 'create')!
92
+ await createCmd.parseAsync(['--title', 'Test', '--description', 'desc'], {
93
+ from: 'user',
94
+ })
95
+
96
+ // exactly one GraphQL call, and it is the parented createNode
97
+ expect(graphql).toHaveBeenCalledTimes(1)
98
+ const [query, variables] = vi.mocked(graphql).mock.calls[0]!
99
+ expect(query).toContain('createNode')
100
+ expect(query).toContain('parentId')
101
+ expect(variables).toMatchObject({
102
+ type: 'task',
103
+ title: 'Test',
104
+ description: 'desc',
105
+ parentId: 'feat_active',
106
+ })
107
+ expect(output).toHaveBeenCalledWith({ id: 'task_new', title: 'Test' })
108
+ })
109
+
66
110
  test('show calls outputError when graphql throws network error', async () => {
67
111
  const { graphql } = await import('../util/client.ts')
68
112
  const { outputError } = await import('../util/format.ts')
@@ -8,7 +8,6 @@ import {
8
8
  BLOCK_TASK,
9
9
  CREATE_TASK,
10
10
  DELETE_NODE,
11
- LINK_TASK,
12
11
  LIST_TASKS,
13
12
  READY_TASKS,
14
13
  SHOW_TASK,
@@ -35,21 +34,20 @@ taskCommand
35
34
  )
36
35
  .action(async (opts) => {
37
36
  try {
37
+ // Validate the active feature BEFORE any write so a bad FLOWY_FEATURE
38
+ // errors cleanly instead of orphaning a node (F24).
38
39
  const featureId = requireFeature()
39
40
  const description = await resolveDescription({
40
41
  description: opts.description,
41
42
  descriptionFile: opts.descriptionFile,
42
43
  })
44
+ // One atomic call: the server creates the task and its `part_of` edge to
45
+ // the feature together, so a failed link can never leave an orphan.
43
46
  const data = await graphql<{ createNode: { id: string } }>(CREATE_TASK, {
44
47
  type: 'task',
45
48
  title: opts.title,
46
49
  description,
47
- })
48
- const taskId = data.createNode.id
49
- await graphql(LINK_TASK, {
50
- sourceId: taskId,
51
- targetId: featureId,
52
- relation: 'part_of',
50
+ parentId: featureId,
53
51
  })
54
52
  output(data.createNode)
55
53
  } catch (error) {
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()
@@ -57,7 +57,6 @@ describe('commands send canonical operations (no re-inlined queries)', () => {
57
57
  ],
58
58
  'feature.ts': [
59
59
  'CREATE_NODE',
60
- 'CREATE_EDGE',
61
60
  'DESCENDANTS',
62
61
  'DESCENDANTS_BRIEF',
63
62
  'UPDATE_NODE',
@@ -66,7 +65,6 @@ describe('commands send canonical operations (no re-inlined queries)', () => {
66
65
  ],
67
66
  'task.ts': [
68
67
  'CREATE_TASK',
69
- 'LINK_TASK',
70
68
  'READY_TASKS',
71
69
  'ALL_TASKS',
72
70
  'LIST_TASKS',
@@ -152,20 +152,44 @@ export const CREATE_PROJECT = `mutation CreateProject($type: String!, $title: St
152
152
  }
153
153
  }`
154
154
 
155
- /** feature.ts `create` — create a node with a description. */
156
- export const CREATE_NODE = `mutation CreateNode($type: String!, $title: String!, $description: String) {
157
- createNode(type: $type, title: $title, description: $description) {
155
+ /**
156
+ * feature.ts `create` create a feature node linked under its parent project
157
+ * in ONE transactional call (F24). The optional `parentId` makes the server
158
+ * create the node and the `part_of` edge atomically, so a failed link can never
159
+ * orphan the node. Both backends accept `parentId` (bundled local since P1-4;
160
+ * SaaS since flowy-ai v33). When omitted, it is a plain create.
161
+ */
162
+ export const CREATE_NODE = `mutation CreateNode($type: String!, $title: String!, $description: String, $parentId: String) {
163
+ createNode(type: $type, title: $title, description: $description, parentId: $parentId) {
158
164
  id type title description status createdAt updatedAt
159
165
  }
160
166
  }`
161
167
 
162
- /** task.ts `create` — create a task node. */
163
- export const CREATE_TASK = `mutation CreateTask($type: String!, $title: String!, $description: String) {
164
- createNode(type: $type, title: $title, description: $description) {
168
+ /**
169
+ * task.ts `create` create a task node linked under its parent feature in ONE
170
+ * transactional call (F24); see CREATE_NODE for the `parentId` semantics.
171
+ */
172
+ export const CREATE_TASK = `mutation CreateTask($type: String!, $title: String!, $description: String, $parentId: String) {
173
+ createNode(type: $type, title: $title, description: $description, parentId: $parentId) {
165
174
  id type title description status createdAt
166
175
  }
167
176
  }`
168
177
 
178
+ /**
179
+ * Contract op (P1-4/F24): create a node AND its `part_of` edge to a parent in a
180
+ * single atomic `createNode(parentId:)` call. This is the operation `task
181
+ * create` / `feature create` issue at runtime (via CREATE_TASK / CREATE_NODE);
182
+ * exercised explicitly by the contract test to assert the bundled local server
183
+ * creates the node + edge atomically and rejects a non-existent parent without
184
+ * leaving an orphan. The SaaS contract guard mirrors this op so both backends
185
+ * stay aligned on the parented-create surface.
186
+ */
187
+ export const CREATE_NODE_WITH_PARENT = `mutation CreateNodeWithParent($type: String!, $title: String!, $description: String, $parentId: String) {
188
+ createNode(type: $type, title: $title, description: $description, parentId: $parentId) {
189
+ id type title description status createdAt updatedAt
190
+ }
191
+ }`
192
+
169
193
  /** project/feature/task `update` — title/description/metadata. */
170
194
  export const UPDATE_NODE = `mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
171
195
  updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
@@ -324,6 +348,7 @@ export const LOCAL_CONTRACT_OPERATIONS = {
324
348
  CREATE_PROJECT,
325
349
  CREATE_NODE,
326
350
  CREATE_TASK,
351
+ CREATE_NODE_WITH_PARENT,
327
352
  UPDATE_NODE,
328
353
  UPDATE_STATUS,
329
354
  APPROVE_NODE,