@openparachute/vault 0.1.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.
Files changed (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -0,0 +1,50 @@
1
+ # Parachute Vault — self-hosted deployment
2
+ #
3
+ # Quick start:
4
+ # 1. Copy .env.example to .env and fill in your values
5
+ # 2. docker compose up -d
6
+ #
7
+ # For HTTPS (required for MCP from Claude Desktop):
8
+ # Set VAULT_DOMAIN in .env, ensure DNS points to this server.
9
+ # Caddy handles Let's Encrypt automatically.
10
+ #
11
+ # Without HTTPS (local/development):
12
+ # Leave VAULT_DOMAIN empty — Caddy proxies on port 80 only.
13
+
14
+ services:
15
+ vault:
16
+ build: .
17
+ restart: unless-stopped
18
+ volumes:
19
+ - vault-data:/data
20
+ env_file: .env
21
+ environment:
22
+ - PARACHUTE_HOME=/data
23
+ expose:
24
+ - "1940"
25
+ healthcheck:
26
+ test: ["CMD", "wget", "-qO-", "http://localhost:1940/health"]
27
+ interval: 30s
28
+ timeout: 3s
29
+ start_period: 5s
30
+
31
+ caddy:
32
+ image: caddy:2-alpine
33
+ restart: unless-stopped
34
+ ports:
35
+ - "80:80"
36
+ - "443:443"
37
+ volumes:
38
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
39
+ - caddy-data:/data
40
+ - caddy-config:/config
41
+ environment:
42
+ - VAULT_DOMAIN=${VAULT_DOMAIN:-localhost}
43
+ depends_on:
44
+ vault:
45
+ condition: service_healthy
46
+
47
+ volumes:
48
+ vault-data:
49
+ caddy-data:
50
+ caddy-config:
@@ -0,0 +1,328 @@
1
+ # Parachute Vault HTTP API
2
+
3
+ A flat reference for the Parachute Vault HTTP surface. Intended for humans *and*
4
+ agents building tools that read or write a vault over HTTP.
5
+
6
+ All endpoints serve JSON. The same vault is reachable at two roots:
7
+
8
+ - `/api/...` — the server's default vault
9
+ - `/vaults/{name}/api/...` — any named vault on this server
10
+
11
+ Use whichever is convenient. Examples below use the default `/api` root.
12
+
13
+ ## Quick start — render a graph in 5 lines
14
+
15
+ ```js
16
+ const res = await fetch("http://localhost:1940/api/graph", {
17
+ headers: { Authorization: `Bearer ${apiKey}` },
18
+ });
19
+ const { notes, links, tags, meta } = await res.json();
20
+ // notes: lightweight NoteIndex[] — id, path, tags, createdAt, byteSize, preview
21
+ // links: Link[] — sourceId, targetId, relationship, metadata
22
+ // Hand this to d3-force, cytoscape, sigma.js, etc.
23
+ ```
24
+
25
+ That's the whole happy path. Everything else in this doc is detail.
26
+
27
+ ## Conventions
28
+
29
+ - **Response payloads are camelCase**: `createdAt`, `sourceId`, `mimeType`,
30
+ `totalNotes`.
31
+ - **Request payloads are camelCase**: you `POST {sourceId, targetId, ...}` and
32
+ get the same shape back.
33
+ - **Query params are snake_case**: `?include_content=true`, `?tag_match=any`,
34
+ `?date_from=2025-01-01`. This matches the MCP tool-arg convention, so one
35
+ concept ports cleanly between HTTP and MCP.
36
+ - **Timestamps are ISO-8601** UTC strings (e.g. `2026-04-07T15:30:00.000Z`).
37
+ - **No envelope**. Responses are the data itself (`{...}` or `[...]`), not
38
+ wrapped in `{data: ...}`. Error responses are `{error: "...", message?: "..."}`.
39
+
40
+ ## Authentication
41
+
42
+ Pass your API key as either:
43
+
44
+ ```
45
+ Authorization: Bearer <key>
46
+ X-API-Key: <key>
47
+ ```
48
+
49
+ Requests from localhost bypass auth (you can hit the server directly without a
50
+ key for local dev).
51
+
52
+ Keys have a **scope**:
53
+
54
+ - `write` — full access
55
+ - `read` — `GET`/`HEAD`/`OPTIONS` only; writes return `403 Forbidden`
56
+
57
+ A read-only key is the right thing to hand to a visualizer or static-site
58
+ generator.
59
+
60
+ ## The shapes
61
+
62
+ ### `Note`
63
+
64
+ ```ts
65
+ {
66
+ id: string;
67
+ content: string;
68
+ path?: string;
69
+ metadata?: Record<string, unknown>;
70
+ createdAt: string;
71
+ updatedAt?: string;
72
+ tags?: string[];
73
+ }
74
+ ```
75
+
76
+ ### `NoteIndex` (lean shape)
77
+
78
+ Returned by list endpoints by default. Same as `Note` minus `content`, plus
79
+ `byteSize` and a one-line `preview` (120 code points, whitespace collapsed).
80
+
81
+ ```ts
82
+ {
83
+ id: string;
84
+ path?: string;
85
+ createdAt: string;
86
+ updatedAt?: string;
87
+ tags?: string[];
88
+ metadata?: Record<string, unknown>;
89
+ byteSize: number; // UTF-8 bytes of the full content
90
+ preview: string; // first ~120 chars, single line
91
+ }
92
+ ```
93
+
94
+ ### `Link`
95
+
96
+ ```ts
97
+ {
98
+ sourceId: string;
99
+ targetId: string;
100
+ relationship: string;
101
+ metadata?: Record<string, unknown>;
102
+ createdAt: string;
103
+ }
104
+ ```
105
+
106
+ ### `VaultStats`
107
+
108
+ ```ts
109
+ {
110
+ totalNotes: number;
111
+ earliestNote: { id: string; createdAt: string } | null;
112
+ latestNote: { id: string; createdAt: string } | null;
113
+ notesByMonth: { month: string; count: number }[]; // e.g. "2026-04"
114
+ topTags: { tag: string; count: number }[];
115
+ tagCount: number;
116
+ }
117
+ ```
118
+
119
+ ## Defaults: lean lists, fat point reads
120
+
121
+ - **List endpoints** (`GET /notes`, `GET /graph`) default to `NoteIndex`. The
122
+ common case is viz/listing, which doesn't need the full body of every note.
123
+ - **Point reads** (`GET /notes/:id`) default to the full `Note`. If you asked
124
+ for one specific thing by ID, you probably want its content.
125
+
126
+ Both shapes can be forced either way with `?include_content=true|false`.
127
+
128
+ ## Endpoints
129
+
130
+ ### Server-level
131
+
132
+ #### `GET /health`
133
+ Returns `{status: "ok", vaults: string[]}`. No auth required.
134
+
135
+ #### `GET /vaults`
136
+ List all vaults on the server.
137
+ ```json
138
+ {
139
+ "vaults": [
140
+ { "name": "default", "description": "...", "created_at": "..." }
141
+ ]
142
+ }
143
+ ```
144
+
145
+ #### `GET /vaults/{name}`
146
+ Single-vault landing payload — name, description, createdAt, and stats in one
147
+ round trip. Useful for a viz site's home page.
148
+ ```json
149
+ {
150
+ "name": "default",
151
+ "description": "My knowledge graph",
152
+ "createdAt": "2026-01-01T00:00:00.000Z",
153
+ "stats": { "totalNotes": 617, "topTags": [...], "notesByMonth": [...], ... }
154
+ }
155
+ ```
156
+
157
+ ### Notes
158
+
159
+ #### `GET /notes`
160
+ Query notes. Returns `NoteIndex[]` by default.
161
+
162
+ Query params:
163
+ - `include_content=true` — return full `Note[]` instead.
164
+ - `ids=a,b,c` — fetch specific notes by ID. Practical limit ~50 IDs due to
165
+ URL length; for larger batches call multiple times.
166
+ - `tag=foo&tag=bar` — filter by tags (repeat param to pass multiple).
167
+ - `tag_match=all|any` — default `all`.
168
+ - `exclude_tag=foo` — exclude notes with this tag.
169
+ - `date_from=ISO` — inclusive lower bound on `createdAt`.
170
+ - `date_to=ISO` — exclusive upper bound.
171
+ - `sort=asc|desc` — by `createdAt`. Default `asc`.
172
+ - `limit=N` — default 100.
173
+ - `offset=N` — default 0.
174
+
175
+ #### `POST /notes`
176
+ Create a note. Body:
177
+ ```json
178
+ {
179
+ "content": "...", // required
180
+ "id": "optional-client-id",
181
+ "path": "Projects/Foo",
182
+ "tags": ["a", "b"],
183
+ "metadata": { "status": "draft" },
184
+ "createdAt": "2026-04-07T..."
185
+ }
186
+ ```
187
+ Returns the created `Note`, `201 Created`.
188
+
189
+ #### `GET /notes/{id}`
190
+ Returns the full `Note`. `?include_content=false` returns a `NoteIndex`.
191
+
192
+ #### `PATCH /notes/{id}`
193
+ Update content, path, or metadata. Body:
194
+ ```json
195
+ { "content": "new body", "path": "new/path", "metadata": {...} }
196
+ ```
197
+
198
+ #### `DELETE /notes/{id}`
199
+ Returns `{deleted: true}`.
200
+
201
+ #### `POST /notes/{id}/tags`, `DELETE /notes/{id}/tags`
202
+ Body: `{"tags": ["a", "b"]}`.
203
+
204
+ #### `POST /notes/{id}/attachments`
205
+ Body: `{"path": "files/a.png", "mimeType": "image/png"}`.
206
+
207
+ #### `GET /notes/{id}/attachments`
208
+ Returns `Attachment[]`.
209
+
210
+ ### Links
211
+
212
+ #### `GET /links`
213
+ List edges. Polymorphic — filters compose freely.
214
+
215
+ Query params:
216
+ - `note_id=abc` — only edges touching this note.
217
+ - `direction=outbound|inbound|both` — only meaningful with `note_id`.
218
+ Default `both`.
219
+ - `relationship=cites` — only edges of this type.
220
+
221
+ Returns bare `Link[]` — no hydration. If you need the connected notes'
222
+ details, pair the result with `GET /notes?ids=...`.
223
+
224
+ Examples:
225
+ ```
226
+ GET /links # everything
227
+ GET /links?note_id=abc # all edges touching note abc
228
+ GET /links?note_id=abc&direction=outbound
229
+ GET /links?relationship=cites # vault-wide, by type
230
+ ```
231
+
232
+ #### `POST /links`
233
+ Body:
234
+ ```json
235
+ { "sourceId": "a", "targetId": "b", "relationship": "cites", "metadata": {...} }
236
+ ```
237
+
238
+ #### `DELETE /links`
239
+ Body:
240
+ ```json
241
+ { "sourceId": "a", "targetId": "b", "relationship": "cites" }
242
+ ```
243
+
244
+ ### Graph
245
+
246
+ #### `GET /graph`
247
+ One-shot knowledge graph payload for visualization.
248
+
249
+ ```json
250
+ {
251
+ "notes": [ /* NoteIndex[] by default, Note[] if include_content=true */ ],
252
+ "links": [ /* Link[] */ ],
253
+ "tags": [ { "name": "...", "count": 12 } ],
254
+ "meta": {
255
+ "totalNotes": 617,
256
+ "totalLinks": 1234,
257
+ "filteredNotes": 617,
258
+ "filteredLinks": 1234,
259
+ "includeContent": false
260
+ }
261
+ }
262
+ ```
263
+
264
+ Query params:
265
+ - `include_content=true` — fatten each note to include full content.
266
+ - `tag=foo&tag=bar` — filter to a subgraph (only notes with these tags, and
267
+ only links where **both** endpoints are in the subset).
268
+ - `tag_match=all|any` — default `all`.
269
+ - `exclude_tag=foo`.
270
+
271
+ `meta.totalNotes` / `meta.totalLinks` always reflect the full vault;
272
+ `filteredNotes` / `filteredLinks` reflect the response.
273
+
274
+ ### Search
275
+
276
+ #### `GET /search?q=query`
277
+ Full-text search. Returns `Note[]` (full shape).
278
+
279
+ Query params:
280
+ - `q=...` — required.
281
+ - `tag=foo` — optional tag filter (repeatable).
282
+ - `limit=N` — default 50.
283
+
284
+ ### Tags
285
+
286
+ #### `GET /tags`
287
+ Returns `[{name, count}]`.
288
+
289
+ ### Vault stats
290
+
291
+ You usually want `GET /vaults/{name}` which bundles stats with vault metadata.
292
+ If you only need the stats, call `GET /vaults/{name}` and read `.stats`.
293
+
294
+ ### Storage
295
+
296
+ #### `POST /storage/upload`
297
+ Multipart form:
298
+ - `file` — required, audio/image, ≤100MB
299
+
300
+ Returns `{path, size, mimeType}`.
301
+
302
+ #### `GET /storage/{date}/{filename}`
303
+ Serves the uploaded file.
304
+
305
+ ## CORS
306
+
307
+ The server sends permissive CORS headers (`Access-Control-Allow-Origin: *`)
308
+ so a static site on any origin can fetch the API. Writes still require a
309
+ valid API key.
310
+
311
+ ## Pairing with MCP
312
+
313
+ Every read endpoint here has a matching MCP tool over `/mcp`
314
+ (unified) or `/vaults/{name}/mcp` (scoped):
315
+
316
+ | HTTP | MCP tool |
317
+ |---------------------------|---------------------|
318
+ | `GET /notes` | `read-notes` |
319
+ | `GET /notes?ids=...` | `get-note` (ids) |
320
+ | `GET /notes/{id}` | `get-note` |
321
+ | `GET /links` | `get-links` |
322
+ | `GET /graph` | `get-graph` |
323
+ | `GET /vaults/{name}` | `get-vault-stats` + `get-vault-description` |
324
+ | `GET /tags` | `list-tags` |
325
+ | `GET /search?q=` | `search-notes` |
326
+
327
+ The MCP tools use the same lean-vs-fat convention (`include_content: true|false`)
328
+ and the same snake_case arg names as the HTTP query params.
package/fly.toml ADDED
@@ -0,0 +1,24 @@
1
+ app = "parachute-vault"
2
+ primary_region = "iad"
3
+
4
+ [build]
5
+ dockerfile = "Dockerfile"
6
+
7
+ [env]
8
+ PORT = "1940"
9
+ PARACHUTE_HOME = "/data"
10
+
11
+ [http_service]
12
+ internal_port = 1940
13
+ force_https = true
14
+ auto_stop_machines = "stop"
15
+ auto_start_machines = true
16
+ min_machines_running = 0
17
+
18
+ [[vm]]
19
+ size = "shared-cpu-1x"
20
+ memory = "512mb"
21
+
22
+ [[mounts]]
23
+ source = "vault_data"
24
+ destination = "/data"
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@openparachute/vault",
3
+ "version": "0.1.0",
4
+ "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
+ "module": "src/cli.ts",
6
+ "type": "module",
7
+ "bin": {
8
+ "parachute": "src/cli.ts"
9
+ },
10
+ "scripts": {
11
+ "start": "bun src/server.ts",
12
+ "cli": "bun src/cli.ts",
13
+ "test": "bun test src/",
14
+ "test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.12.1",
18
+ "otpauth": "^9.5.0",
19
+ "qrcode-terminal": "^0.12.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "latest"
23
+ },
24
+ "peerDependencies": {
25
+ "typescript": "^5"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/ParachuteComputer/parachute-vault.git"
30
+ },
31
+ "license": "AGPL-3.0"
32
+ }
package/railway.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://railway.com/railway.schema.json",
3
+ "build": {
4
+ "builder": "DOCKERFILE",
5
+ "dockerfilePath": "Dockerfile"
6
+ },
7
+ "deploy": {
8
+ "startCommand": "bun src/server.ts",
9
+ "healthcheckPath": "/health",
10
+ "healthcheckTimeout": 5,
11
+ "restartPolicyType": "ON_FAILURE",
12
+ "restartPolicyMaxRetries": 3
13
+ }
14
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Tests for scripts/migrate-audio-to-opus.ts.
3
+ *
4
+ * Spins up a temp vault layout under a fresh PARACHUTE_HOME, seeds a WAV
5
+ * attachment, runs the script in dry-run then real mode, asserts the DB
6
+ * row was rewritten, the .ogg file exists, and the original was unlinked.
7
+ *
8
+ * Uses the real ffmpeg binary (matches src/audio-encoding.test.ts).
9
+ *
10
+ * Requires `@openparachute/narrate` which is not a vault dependency.
11
+ * Install manually (`bun add @openparachute/narrate`) to run these tests.
12
+ */
13
+
14
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
15
+ import { Database } from "bun:sqlite";
16
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
17
+ import { join } from "path";
18
+ import { tmpdir } from "os";
19
+ import { SCHEMA_SQL } from "../core/src/schema.ts";
20
+
21
+ // @openparachute/narrate is not a vault dependency — dynamically import
22
+ // so the test file can at least be parsed without the optional package.
23
+ let runMigration: typeof import("./migrate-audio-to-opus.ts").runMigration;
24
+ let hasDep = false;
25
+ try {
26
+ ({ runMigration } = await import("./migrate-audio-to-opus.ts"));
27
+ hasDep = true;
28
+ } catch {
29
+ // dependency missing — tests will be skipped below
30
+ }
31
+
32
+ function buildSilentWav(samples: number): Buffer {
33
+ const sampleRate = 8000;
34
+ const numChannels = 1;
35
+ const bitsPerSample = 16;
36
+ const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
37
+ const blockAlign = (numChannels * bitsPerSample) / 8;
38
+ const dataSize = samples * blockAlign;
39
+ const chunkSize = 36 + dataSize;
40
+ const buf = Buffer.alloc(44 + dataSize);
41
+ let off = 0;
42
+ buf.write("RIFF", off); off += 4;
43
+ buf.writeUInt32LE(chunkSize, off); off += 4;
44
+ buf.write("WAVE", off); off += 4;
45
+ buf.write("fmt ", off); off += 4;
46
+ buf.writeUInt32LE(16, off); off += 4;
47
+ buf.writeUInt16LE(1, off); off += 2;
48
+ buf.writeUInt16LE(numChannels, off); off += 2;
49
+ buf.writeUInt32LE(sampleRate, off); off += 4;
50
+ buf.writeUInt32LE(byteRate, off); off += 4;
51
+ buf.writeUInt16LE(blockAlign, off); off += 2;
52
+ buf.writeUInt16LE(bitsPerSample, off); off += 2;
53
+ buf.write("data", off); off += 4;
54
+ buf.writeUInt32LE(dataSize, off); off += 4;
55
+ return buf;
56
+ }
57
+
58
+ let tmpHome: string;
59
+ let prevHome: string | undefined;
60
+ let prevAssets: string | undefined;
61
+
62
+ beforeEach(() => {
63
+ tmpHome = join(
64
+ tmpdir(),
65
+ `migrate-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
66
+ );
67
+ mkdirSync(tmpHome, { recursive: true });
68
+ prevHome = process.env.PARACHUTE_HOME;
69
+ prevAssets = process.env.ASSETS_DIR;
70
+ process.env.PARACHUTE_HOME = tmpHome;
71
+ delete process.env.ASSETS_DIR; // use default per-vault assets dir
72
+ });
73
+
74
+ afterEach(() => {
75
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
76
+ else process.env.PARACHUTE_HOME = prevHome;
77
+ if (prevAssets === undefined) delete process.env.ASSETS_DIR;
78
+ else process.env.ASSETS_DIR = prevAssets;
79
+ try {
80
+ rmSync(tmpHome, { recursive: true, force: true });
81
+ } catch {
82
+ // ignore
83
+ }
84
+ });
85
+
86
+ interface SeedResult {
87
+ vault: string;
88
+ dbPath: string;
89
+ assetsBase: string;
90
+ noteId: string;
91
+ attachmentId: string;
92
+ relWavPath: string;
93
+ absWavPath: string;
94
+ relOggPath: string;
95
+ absOggPath: string;
96
+ noteUpdatedAt: string;
97
+ }
98
+
99
+ function seedVaultWithWav(vaultName: string): SeedResult {
100
+ const vaultDir = join(tmpHome, "vaults", vaultName);
101
+ mkdirSync(vaultDir, { recursive: true });
102
+ const dbPath = join(vaultDir, "vault.db");
103
+ const assetsBase = join(vaultDir, "assets");
104
+ mkdirSync(assetsBase, { recursive: true });
105
+
106
+ const db = new Database(dbPath);
107
+ db.exec(SCHEMA_SQL);
108
+
109
+ const noteId = "n_" + Math.random().toString(36).slice(2, 10);
110
+ const attachmentId = "a_" + Math.random().toString(36).slice(2, 10);
111
+ const now = new Date().toISOString();
112
+
113
+ db.prepare(
114
+ "INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
115
+ ).run(noteId, "hello reader", null, "{}", now, now);
116
+
117
+ const relWavPath = `tts/2026-04-08/${noteId}-123.wav`;
118
+ const absWavPath = join(assetsBase, relWavPath);
119
+ mkdirSync(join(assetsBase, "tts", "2026-04-08"), { recursive: true });
120
+ // Write ~1s of silence WAV. ffmpeg handles this fine.
121
+ writeFileSync(absWavPath, buildSilentWav(8000));
122
+
123
+ db.prepare(
124
+ "INSERT INTO attachments (id, note_id, path, mime_type, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?)",
125
+ ).run(attachmentId, noteId, relWavPath, "audio/wav", "{}", now);
126
+
127
+ db.close();
128
+
129
+ return {
130
+ vault: vaultName,
131
+ dbPath,
132
+ assetsBase,
133
+ noteId,
134
+ attachmentId,
135
+ relWavPath,
136
+ absWavPath,
137
+ relOggPath: relWavPath.replace(/\.wav$/, ".ogg"),
138
+ absOggPath: absWavPath.replace(/\.wav$/, ".ogg"),
139
+ noteUpdatedAt: now,
140
+ };
141
+ }
142
+
143
+ describe.skipIf(!hasDep)("migrate-audio-to-opus", () => {
144
+ test("dry-run reports candidates without touching anything", async () => {
145
+ const seed = seedVaultWithWav("default");
146
+
147
+ const logs: string[] = [];
148
+ const origLog = console.log;
149
+ console.log = (...args: unknown[]) => {
150
+ logs.push(args.map((a) => String(a)).join(" "));
151
+ };
152
+ try {
153
+ const summaries = await runMigration(["--vault", "default", "--dry-run"]);
154
+ expect(summaries.length).toBe(1);
155
+ expect(summaries[0].dryRunCandidates).toBe(1);
156
+ expect(summaries[0].converted).toBe(0);
157
+ expect(summaries[0].errors).toBe(0);
158
+ } finally {
159
+ console.log = origLog;
160
+ }
161
+
162
+ expect(logs.some((l) => l.includes("DRY-RUN convert") && l.includes(seed.relWavPath))).toBe(
163
+ true,
164
+ );
165
+
166
+ // Nothing moved.
167
+ expect(existsSync(seed.absWavPath)).toBe(true);
168
+ expect(existsSync(seed.absOggPath)).toBe(false);
169
+
170
+ const db = new Database(seed.dbPath);
171
+ try {
172
+ const row = db
173
+ .prepare("SELECT path, mime_type FROM attachments WHERE id = ?")
174
+ .get(seed.attachmentId) as { path: string; mime_type: string };
175
+ expect(row.path).toBe(seed.relWavPath);
176
+ expect(row.mime_type).toBe("audio/wav");
177
+ } finally {
178
+ db.close();
179
+ }
180
+ });
181
+
182
+ test("full run converts WAV to Opus, updates DB, unlinks original, no updated_at bump", async () => {
183
+ const seed = seedVaultWithWav("default");
184
+
185
+ const origLog = console.log;
186
+ console.log = () => {};
187
+ try {
188
+ const summaries = await runMigration(["--vault", "default"]);
189
+ expect(summaries[0].converted).toBe(1);
190
+ expect(summaries[0].errors).toBe(0);
191
+ expect(summaries[0].bytesAfter).toBeGreaterThan(0);
192
+ } finally {
193
+ console.log = origLog;
194
+ }
195
+
196
+ // Original .wav removed, .ogg exists and has OggS magic bytes.
197
+ expect(existsSync(seed.absWavPath)).toBe(false);
198
+ expect(existsSync(seed.absOggPath)).toBe(true);
199
+ const oggBytes = readFileSync(seed.absOggPath);
200
+ expect(oggBytes.toString("ascii", 0, 4)).toBe("OggS");
201
+
202
+ // DB row rewritten.
203
+ const db = new Database(seed.dbPath);
204
+ try {
205
+ const row = db
206
+ .prepare("SELECT path, mime_type FROM attachments WHERE id = ?")
207
+ .get(seed.attachmentId) as { path: string; mime_type: string };
208
+ expect(row.path).toBe(seed.relOggPath);
209
+ expect(row.mime_type).toBe("audio/ogg");
210
+
211
+ // Note's updated_at must NOT have changed — this is a storage
212
+ // migration, not a content edit.
213
+ const note = db
214
+ .prepare("SELECT updated_at FROM notes WHERE id = ?")
215
+ .get(seed.noteId) as { updated_at: string };
216
+ expect(note.updated_at).toBe(seed.noteUpdatedAt);
217
+ } finally {
218
+ db.close();
219
+ }
220
+ });
221
+
222
+ test("re-running after a successful migration is a no-op (idempotent)", async () => {
223
+ seedVaultWithWav("default");
224
+
225
+ const origLog = console.log;
226
+ console.log = () => {};
227
+ try {
228
+ await runMigration(["--vault", "default"]);
229
+ const second = await runMigration(["--vault", "default"]);
230
+ expect(second[0].converted).toBe(0);
231
+ expect(second[0].skipped).toBe(1);
232
+ expect(second[0].errors).toBe(0);
233
+ } finally {
234
+ console.log = origLog;
235
+ }
236
+ });
237
+ });