@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.10
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/.parachute/module.json +1 -1
- package/README.md +78 -41
- package/core/src/connection-pragmas.test.ts +232 -0
- package/core/src/core.test.ts +257 -0
- package/core/src/cursor.test.ts +160 -0
- package/core/src/cursor.ts +272 -0
- package/core/src/mcp.ts +51 -7
- package/core/src/notes.ts +164 -2
- package/core/src/schema.ts +106 -5
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +7 -3
- package/src/auth-status.ts +4 -0
- package/src/auth.test.ts +5 -112
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/backup.ts +17 -3
- package/src/cli.ts +95 -66
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- package/src/export-watch.test.ts +21 -0
- package/src/mcp-install-interactive.test.ts +23 -2
- package/src/mcp-install-interactive.ts +21 -2
- package/src/mcp-install.test.ts +40 -0
- package/src/mcp-tools.ts +17 -1
- package/src/module-config.ts +70 -14
- package/src/module-manifest.test.ts +114 -0
- package/src/module-manifest.ts +104 -0
- package/src/oauth-discovery.ts +95 -0
- package/src/owner-auth.ts +22 -149
- package/src/routes.ts +268 -51
- package/src/routing.test.ts +102 -99
- package/src/routing.ts +33 -47
- package/src/scribe-discovery.test.ts +77 -0
- package/src/scribe-discovery.ts +91 -0
- package/src/scribe-env.test.ts +66 -1
- package/src/scribe-env.ts +42 -1
- package/src/self-register.test.ts +412 -0
- package/src/self-register.ts +247 -0
- package/src/server.ts +47 -23
- package/src/transcript-note.test.ts +171 -0
- package/src/transcript-note.ts +189 -0
- package/src/transcription-registry.ts +22 -0
- package/src/transcription-worker.test.ts +250 -0
- package/src/transcription-worker.ts +186 -27
- package/src/vault-name.ts +3 -2
- package/src/vault.test.ts +347 -0
- package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
- package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
- package/web/ui/dist/index.html +14 -0
- package/web/ui/tsconfig.json +21 -0
- package/src/oauth.test.ts +0 -2156
- package/src/oauth.ts +0 -973
package/.parachute/module.json
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
"manifestName": "parachute-vault",
|
|
4
4
|
"displayName": "Vault",
|
|
5
5
|
"tagline": "Your owner-authenticated MCP knowledge store.",
|
|
6
|
-
"kind": "api",
|
|
7
6
|
"port": 1940,
|
|
8
7
|
"paths": ["/vault/default"],
|
|
9
8
|
"health": "/vault/default/health",
|
|
10
9
|
"managementUrl": "/admin/",
|
|
10
|
+
"uiUrl": "/admin/",
|
|
11
11
|
"startCmd": ["parachute-vault", "serve"],
|
|
12
12
|
"scopes": {
|
|
13
13
|
"defines": ["vault:read", "vault:write", "vault:admin"]
|
package/README.md
CHANGED
|
@@ -66,7 +66,12 @@ A mental model for "where is my data?" and "what can I poke at?" after the one-c
|
|
|
66
66
|
vaults/ # one subdirectory per vault
|
|
67
67
|
default/
|
|
68
68
|
vault.db # the SQLite database — notes, tags, links, attachments,
|
|
69
|
-
# per-vault tokens,
|
|
69
|
+
# per-vault tokens, tag schemas (oauth_clients +
|
|
70
|
+
# oauth_codes tables persist post-workstream-E but
|
|
71
|
+
# are vestigial — no issuer writes to them anymore)
|
|
72
|
+
vault.db-wal # WAL journal (write-ahead log) — transient, recreated
|
|
73
|
+
# on demand. Carries pending writes between checkpoints.
|
|
74
|
+
vault.db-shm # WAL shared-memory index — transient, recreated on demand.
|
|
70
75
|
vault.yaml # per-vault config — description (sent as MCP session
|
|
71
76
|
# instruction), published_tag override, legacy api_keys
|
|
72
77
|
assets/ # per-vault uploaded attachments (audio, images)
|
|
@@ -74,7 +79,9 @@ A mental model for "where is my data?" and "what can I poke at?" after the one-c
|
|
|
74
79
|
|
|
75
80
|
`~/.parachute/` itself is the ecosystem root shared across sibling services — `services.json` and `well-known/` live at the root and are managed by the top-level CLI. Everything vault owns is scoped under `~/.parachute/vault/`. Pre-0.3 installs kept vault state directly at the root; any legacy paths still there are auto-migrated into `vault/` on first post-upgrade run (see CHANGELOG).
|
|
76
81
|
|
|
77
|
-
`config.yaml` is the one file written at 0600 because it holds the bcrypt owner-password hash and the plaintext TOTP secret. `.env` is written with your umask default (typically 0644); if you add webhook API keys there, tighten the mode yourself. SQLite DBs follow your umask.
|
|
82
|
+
`config.yaml` is the one file written at 0600 because it holds the bcrypt owner-password hash and the plaintext TOTP secret (legacy fields kept for hub's expose-posture-check; the standalone consent flow they used to gate was retired in 0.4.x — workstream E). `.env` is written with your umask default (typically 0644); if you add webhook API keys there, tighten the mode yourself. SQLite DBs follow your umask.
|
|
83
|
+
|
|
84
|
+
The vault SQLite database runs in **WAL** (write-ahead logging) journal mode for multi-process concurrent access — the daemon, CLI tools, and out-of-process consumers (e.g. `parachute-runner` polling `tag:job`) can read concurrently while the daemon writes, without lock contention. WAL adds two sidecar files alongside `vault.db`: `vault.db-wal` (the journal) and `vault.db-shm` (shared-memory index). Both are recreated on demand; **don't back them up separately** — `parachute-vault backup` snapshots only `vault.db` via `VACUUM INTO`, which produces a consistent full-DB copy without needing the sidecars. If you copy a vault by hand, `vault.db` alone is sufficient on a checkpointed database; if you must capture an in-flight write, copy all three files. If WAL can't be enabled (NFS, some FUSE / Docker volume drivers don't support the `-shm` region), vault logs `[vault] WAL mode could not be enabled` on startup and falls back to the legacy single-writer mode.
|
|
78
85
|
|
|
79
86
|
### Registered externally
|
|
80
87
|
|
|
@@ -82,7 +89,7 @@ A mental model for "where is my data?" and "what can I poke at?" after the one-c
|
|
|
82
89
|
- **Linux + systemd**: a user service named `parachute-vault.service` (managed via `systemctl --user`).
|
|
83
90
|
- **Neither of the above**: `vault init` prints a reminder to start the server yourself (`bun src/server.ts` or Docker). No service registration.
|
|
84
91
|
|
|
85
|
-
The daemon binds `0.0.0.0:1940` (or whatever you set in `PORT`) and serves REST, MCP, and OAuth routes. `parachute-vault status` is the fast check; `parachute-vault url` prints just the URL for use in scripts.
|
|
92
|
+
The daemon binds `0.0.0.0:1940` (or whatever you set in `PORT`) and serves REST, MCP, and OAuth-discovery routes (the issuer itself lives on the hub — see "OAuth lives on the hub" below). `parachute-vault status` is the fast check; `parachute-vault url` prints just the URL for use in scripts.
|
|
86
93
|
|
|
87
94
|
### `~/.claude.json`
|
|
88
95
|
|
|
@@ -94,9 +101,17 @@ The daemon binds `0.0.0.0:1940` (or whatever you set in `PORT`) and serves REST,
|
|
|
94
101
|
|
|
95
102
|
If you said yes to (2), the `pvt_...` token is printed prominently at the end — it's the same token baked into `~/.claude.json` (if you also said yes to (1)). It's not stored anywhere retrievable — save it if you need it for `curl`, cron, or any other script. Lost it? Just mint a new one: `parachute-vault tokens create`. Tokens are SHA-256 hashed at rest in each vault's `vault.db`.
|
|
96
103
|
|
|
97
|
-
###
|
|
104
|
+
### OAuth lives on the hub
|
|
105
|
+
|
|
106
|
+
Vault is OAuth resource-server-only. The authorization flow — DCR, consent page, code-for-token exchange — lives on the [hub](https://github.com/ParachuteComputer/parachute-hub). Install it once you want browser-based clients (Claude Desktop, Parachute Daily, etc.) to connect:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
parachute install hub
|
|
110
|
+
```
|
|
98
111
|
|
|
99
|
-
|
|
112
|
+
The hub fronts every vault on the host with a single consent surface, signs JWTs that vault validates against the hub's JWKS, and renders the operator UI. Vault still serves OAuth discovery (`/.well-known/oauth-*`) but the metadata documents forward clients to the hub. See [`docs/auth-model.md`](docs/auth-model.md) for the validation contract.
|
|
113
|
+
|
|
114
|
+
> Vault shipped its own standalone OAuth issuer through 0.4.7. It was retired in 0.4.x — workstream E. If you're upgrading from a standalone-vault posture, see [`UPGRADING.md`](UPGRADING.md#workstream-e--standalone-oauth-retired).
|
|
100
115
|
|
|
101
116
|
## Connecting a client
|
|
102
117
|
|
|
@@ -104,22 +119,10 @@ Two ways to authenticate — pick based on the client, not the deployment:
|
|
|
104
119
|
|
|
105
120
|
| Path | When to use | User action |
|
|
106
121
|
|---|---|---|
|
|
107
|
-
| **OAuth 2.1 + PKCE (browser flow)** | Claude Desktop, Parachute Daily, any third-party MCP client set up interactively | Click "Add integration", enter
|
|
122
|
+
| **OAuth 2.1 + PKCE (browser flow, via hub)** | Claude Desktop, Parachute Daily, any third-party MCP client set up interactively | Click "Add integration", enter the vault MCP URL, a browser opens to the **hub's** consent page, sign in with hub credentials, done — no token ever touches your clipboard |
|
|
108
123
|
| **Bearer token** | Claude Code (auto-wired by `vault init`), CLI scripts, cron jobs, any non-interactive caller | `curl -H "Authorization: Bearer pvt_..."` — the token is printed once at `vault init` (save it) or minted on demand with `parachute-vault tokens create` |
|
|
109
124
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
### Owner password (needed for OAuth)
|
|
113
|
-
|
|
114
|
-
`vault init` prompts you to set an owner password (minimum 12 characters). This is what the OAuth consent page asks for when a client requests access. If you skip the prompt, OAuth still works but the consent page falls back to asking for a vault token instead — functional but clunky. Set it later with:
|
|
115
|
-
|
|
116
|
-
```bash
|
|
117
|
-
parachute-vault set-password # set / change
|
|
118
|
-
parachute-vault set-password --clear # remove (reverts to token fallback)
|
|
119
|
-
parachute-vault 2fa enroll # optional: add TOTP 2FA on top
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
Password and 2FA secrets live in `~/.parachute/vault/config.yaml` at mode 0600 (bcrypt hash + base32 TOTP secret).
|
|
125
|
+
The OAuth path mints a hub-signed JWT that vault validates against the hub's JWKS. The bearer-token path mints a `pvt_` opaque token straight from vault's local DB. Either works for any client; pick based on whether you want a human-driven consent step.
|
|
123
126
|
|
|
124
127
|
### Claude Code
|
|
125
128
|
|
|
@@ -143,19 +146,19 @@ To re-point Claude Code at a different vault, change `default_vault` in `~/.para
|
|
|
143
146
|
|
|
144
147
|
### Claude Desktop (OAuth)
|
|
145
148
|
|
|
146
|
-
For Claude Desktop — or any install where the server is on a different machine from the client — use the browser-based OAuth flow:
|
|
149
|
+
For Claude Desktop — or any install where the server is on a different machine from the client — use the browser-based OAuth flow. **The flow runs against the hub**, not vault, so make sure you've run `parachute install hub` first:
|
|
147
150
|
|
|
148
151
|
1. Claude Desktop → Settings → Integrations → Add MCP server.
|
|
149
152
|
2. Enter the URL: `https://vault.yourdomain.com/vault/{name}/mcp` (replace `{name}` with your vault name — e.g. `default`). **Do not paste a bearer token** — leave the auth field empty.
|
|
150
|
-
3.
|
|
151
|
-
4.
|
|
152
|
-
5. Browser redirects back. The connection is live. The client now holds a
|
|
153
|
+
3. The MCP client discovers vault's protected-resource metadata at `/vault/{name}/.well-known/oauth-protected-resource`, which names the **hub** as the authorization server. It then drives the OAuth flow against the hub: DCR, browser-opened consent page, code exchange.
|
|
154
|
+
4. Sign in to the hub with your hub credentials, pick a scope (`full` or `read`), click Authorize.
|
|
155
|
+
5. Browser redirects back. The connection is live. The client now holds a hub-signed JWT scoped to this vault.
|
|
153
156
|
|
|
154
157
|
If you'd rather skip OAuth — e.g. you're scripting the setup — Claude Desktop also accepts a bearer token via the integration's auth header field. Use a token from `parachute-vault tokens create` (or the one from `vault init` if you still have it). This is the "manual bearer" fallback; OAuth is the recommended path.
|
|
155
158
|
|
|
156
159
|
### Parachute Daily (mobile)
|
|
157
160
|
|
|
158
|
-
Daily uses the same OAuth flow. On first launch: enter the server URL, pick the vault from the drop-down (populated from the public `GET /vaults/list` endpoint), tap **Connect to Vault**. The
|
|
161
|
+
Daily uses the same OAuth flow (hub-fronted). On first launch: enter the server URL, pick the vault from the drop-down (populated from the public `GET /vaults/list` endpoint), tap **Connect to Vault**. The consent handoff runs in your phone's browser against the hub, then redirects back to the app via the `parachute://oauth/callback` deep link. The app stores the hub-signed JWT in platform secure storage.
|
|
159
162
|
|
|
160
163
|
### Multi-vault
|
|
161
164
|
|
|
@@ -169,7 +172,7 @@ parachute-vault remove work --yes
|
|
|
169
172
|
|
|
170
173
|
**The default vault is managed for you.** `vault init` creates `default` on first install and records it as `default_vault` in `~/.parachute/config.yaml`. `vault create <name>` promotes the newly-created vault to default when no default exists or when the configured default points at a missing vault. `vault remove <name>` promotes the sole survivor when you delete the default and one vault remains; if multiple remain after removing the default, it clears the setting and tells you to edit `config.yaml` yourself. There is no `vault set-default` subcommand — to point the server at a different existing vault, edit the `default_vault:` line in `~/.parachute/config.yaml` and `parachute-vault restart`.
|
|
171
174
|
|
|
172
|
-
**URL shape.** Every vault-touching route lives under `/vault/{name}/...`: `/vault/{name}/mcp`, `/vault/{name}/
|
|
175
|
+
**URL shape.** Every vault-touching route lives under `/vault/{name}/...`: `/vault/{name}/mcp`, `/vault/{name}/api/notes`, `/vault/{name}/view/{id}`, `/vault/{name}/.well-known/oauth-*` (discovery forwarders). There is no unscoped fallback — pick the vault in the URL even if you only have one. OAuth tokens are scoped to the vault named in the audience claim (`aud=vault.<name>`); cross-vault substitution is rejected at the auth layer.
|
|
173
176
|
|
|
174
177
|
**Listing vaults from a client.** The authenticated `GET /vaults` endpoint returns full vault metadata. The public `GET /vaults/list` endpoint returns names only, no metadata, no auth required — this is what Parachute Daily's vault picker calls before the user authenticates. Operators who want to hide the vault list from unauthenticated callers can set `discovery: disabled` in `~/.parachute/vault/config.yaml` to make `/vaults/list` return 404.
|
|
175
178
|
|
|
@@ -201,13 +204,18 @@ parachute-vault mcp-install --dry-run # describe the write without touching
|
|
|
201
204
|
parachute-vault mcp-config gitcoin # emit JSON for `claude -p --mcp-config "$(...)"`
|
|
202
205
|
parachute-vault mcp-config gitcoin --env-vars # template form with ${PARACHUTE_HUB_URL}/${PARACHUTE_VAULT_TOKEN}
|
|
203
206
|
|
|
204
|
-
# OAuth — owner password + 2FA
|
|
205
|
-
|
|
206
|
-
|
|
207
|
+
# OAuth — owner password + 2FA (legacy, no longer wired up)
|
|
208
|
+
# vault's standalone OAuth consent page was retired in 0.4.x (workstream E);
|
|
209
|
+
# OAuth now runs on the hub. These commands still write to config.yaml for
|
|
210
|
+
# hub's `expose public` posture-check (which reads the same fields), but
|
|
211
|
+
# they don't gate any consent flow inside vault. Set hub credentials with
|
|
212
|
+
# `parachute auth set-password` instead.
|
|
213
|
+
parachute-vault set-password # set/change owner password (legacy YAML field)
|
|
214
|
+
parachute-vault set-password --clear # remove the owner password
|
|
207
215
|
parachute-vault 2fa status # show 2FA state + remaining backup codes
|
|
208
216
|
parachute-vault 2fa enroll # enroll TOTP (shows QR + prints one-time backup codes)
|
|
209
|
-
parachute-vault 2fa disable # disable 2FA
|
|
210
|
-
parachute-vault 2fa backup-codes # regenerate backup codes
|
|
217
|
+
parachute-vault 2fa disable # disable 2FA
|
|
218
|
+
parachute-vault 2fa backup-codes # regenerate backup codes
|
|
211
219
|
|
|
212
220
|
# Tokens
|
|
213
221
|
parachute-vault tokens # list all tokens across all vaults
|
|
@@ -339,16 +347,45 @@ Webhook servers (scribe, narrate) are stateless — they don't need vault's API
|
|
|
339
347
|
|
|
340
348
|
### On-upload transcription
|
|
341
349
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
350
|
+
Two paths feed the same transcription worker; they differ in how the
|
|
351
|
+
result surfaces.
|
|
352
|
+
|
|
353
|
+
**Auto-transcribe (vault#353).** When the operator flips
|
|
354
|
+
`auto_transcribe.enabled: true` in vault's config AND scribe is
|
|
355
|
+
reachable (registered in `~/.parachute/services.json`, or pointed at
|
|
356
|
+
via `SCRIBE_URL`), any audio attachment uploaded to the vault is
|
|
357
|
+
automatically queued for transcription. The worker writes a sibling
|
|
358
|
+
`<attachment-path>.transcript.md` note with the transcript text +
|
|
359
|
+
frontmatter linking back to the audio (`transcript_of`,
|
|
360
|
+
`transcript_status`, `transcript_duration_ms`, etc.). Failures land as
|
|
361
|
+
the same note with `transcript_status: failed` and the cause in
|
|
362
|
+
`transcript_error`; the original audio is preserved. Operators can
|
|
363
|
+
retry a failed transcript via `POST /vault/<name>/api/notes/<note-id>/retry-transcription`.
|
|
364
|
+
|
|
365
|
+
**Explicit `transcribe: true` (legacy, Lens flow).** Callers that already
|
|
366
|
+
know at upload time that an audio file should be transcribed pass
|
|
367
|
+
`transcribe: true` to `POST /api/notes/{id}/attachments`. The vault
|
|
368
|
+
stamps the attachment with `transcribe_status: "pending"` and the note
|
|
369
|
+
with `transcribe_stub: true`. The worker replaces the literal
|
|
370
|
+
`_Transcript pending._` placeholder in the note body with the transcript
|
|
371
|
+
on success (or the whole body if no placeholder is present).
|
|
372
|
+
|
|
373
|
+
Service discovery is automatic — when scribe lands in
|
|
374
|
+
`~/.parachute/services.json` (the canonical hub-maintained registry),
|
|
375
|
+
vault picks up its URL on next restart. The `SCRIBE_URL` env var still
|
|
376
|
+
wins when set. The shared bearer for vault→scribe auth is generated
|
|
377
|
+
once at first boot and persisted to `~/.parachute/vault/.env` as
|
|
378
|
+
`SCRIBE_AUTH_TOKEN`; mirror that value into scribe's
|
|
379
|
+
`SCRIBE_AUTH_TOKEN` env so scribe accepts the Authorization header.
|
|
380
|
+
|
|
381
|
+
The worker POSTs audio as multipart to
|
|
382
|
+
`${SCRIBE_URL}/v1/audio/transcriptions` and expects `{ text: string }`
|
|
383
|
+
back. On success it records `transcript` + `transcribe_done_at` +
|
|
384
|
+
`transcribe_duration_ms` on the attachment row regardless of the
|
|
385
|
+
result-surface path. Failures retry with exponential backoff up to three
|
|
386
|
+
attempts before flipping `transcribe_status` to `"failed"`; 4xx
|
|
387
|
+
responses carrying `error_code` (e.g. `missing_provider`) are treated as
|
|
388
|
+
terminal on the first failure.
|
|
352
389
|
|
|
353
390
|
Per-vault `audio_retention` controls what happens to the audio file on disk once the worker reaches a terminal state. It's readable and mutable at runtime via `GET` / `PATCH /api/vault` (under `config.audio_retention`), or by hand-editing `vault.yaml`.
|
|
354
391
|
|
|
@@ -759,7 +796,7 @@ The checks, in the order they're emitted:
|
|
|
759
796
|
- **Daemon won't start after a port change.** `~/.parachute/vault/.env` has the new `PORT=...` but the daemon is still trying to bind the old one, or something else already holds the new port. `parachute-vault doctor` surfaces both conditions. Fix the holder (or pick a different port) and `parachute-vault restart`.
|
|
760
797
|
- **MCP entry is stale after moving the repo.** launchd/systemd keeps pointing at the old path. `doctor` flags this as a failed `server.ts at pointer target` check; `parachute-vault init` from the new location rewrites the pointer, wrapper, and daemon registration.
|
|
761
798
|
- **Claude Code shows no vault tools.** Check in order: (1) is the daemon up (`parachute-vault status`)? (2) does `~/.claude.json` have a `parachute-vault` entry with both `url` and a valid `Authorization` header? (3) does the URL's vault name match an existing vault? `parachute-vault doctor` catches the first two. A missing or stale `Authorization` header after a bare `vault mcp-install` is the usual culprit for #2 — see the Claude Code section of [Connecting a client](#connecting-a-client) for how to rewrite it.
|
|
762
|
-
- **Claude Desktop / Daily won't connect via OAuth.**
|
|
799
|
+
- **Claude Desktop / Daily won't connect via OAuth.** Check the hub is installed and running (`parachute status hub`) — OAuth runs there, not on vault. If you're upgrading from a standalone-vault install where vault used to render its own consent page, run `parachute install hub` to bring up the issuer. See [`UPGRADING.md`](UPGRADING.md#workstream-e--standalone-oauth-retired).
|
|
763
800
|
- **Scheduled backups aren't running.** On macOS: `doctor` flags `backup agent: not loaded` when `schedule` isn't `manual` but the launchd agent is missing — rerun `parachute-vault backup --schedule <freq>` to reinstall it. On Linux: systemd-timer support for backup isn't shipped yet, so `--schedule daily` silently skips the scheduler. Run `parachute-vault backup` from cron (or similar) until that lands.
|
|
764
801
|
- **Manual `curl` against `/vault/<name>/mcp` returns `406 Not Acceptable`.** The MCP HTTP transport requires both `application/json` and `text/event-stream` in the `Accept` header (it negotiates between the JSON response and the SSE streaming variant). Claude Code's `--mcp-config` http transport sets this automatically — the symptom only shows up when you probe the endpoint by hand. The fix:
|
|
765
802
|
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for connection-level pragmas — WAL mode + synchronous=NORMAL +
|
|
3
|
+
* foreign_keys=ON. See applyConnectionPragmas in schema.ts and vault#326.
|
|
4
|
+
*
|
|
5
|
+
* `:memory:` databases land in journal_mode=memory and DO NOT support WAL
|
|
6
|
+
* (the WAL/shm sidecars need a real file). On-disk DBs are the realistic
|
|
7
|
+
* path; we exercise both so the readonly + filesystem-unsupported branches
|
|
8
|
+
* are covered.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { tmpdir } from "os";
|
|
16
|
+
import { applyConnectionPragmas, initSchema } from "./schema.ts";
|
|
17
|
+
|
|
18
|
+
let dir: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), "vault-pragma-"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function pragma(db: Database, name: string): string | number {
|
|
29
|
+
const row = db.prepare(`PRAGMA ${name}`).get() as Record<string, string | number> | null;
|
|
30
|
+
if (!row) return "";
|
|
31
|
+
// PRAGMA xxx returns a one-key object whose key matches the pragma name.
|
|
32
|
+
const v = Object.values(row)[0];
|
|
33
|
+
return typeof v === "string" ? v.toLowerCase() : (v as number);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("applyConnectionPragmas — on-disk DB", () => {
|
|
37
|
+
it("enables WAL mode on a fresh on-disk database", () => {
|
|
38
|
+
const db = new Database(join(dir, "fresh.db"));
|
|
39
|
+
const result = applyConnectionPragmas(db);
|
|
40
|
+
expect(result.wal).toBe(true);
|
|
41
|
+
expect(result.journalMode).toBe("wal");
|
|
42
|
+
expect(pragma(db, "journal_mode")).toBe("wal");
|
|
43
|
+
db.close();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("sets synchronous=NORMAL when WAL succeeds", () => {
|
|
47
|
+
const db = new Database(join(dir, "sync.db"));
|
|
48
|
+
applyConnectionPragmas(db);
|
|
49
|
+
// synchronous values: 0=off, 1=normal, 2=full, 3=extra. NORMAL is 1.
|
|
50
|
+
expect(pragma(db, "synchronous")).toBe(1);
|
|
51
|
+
db.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("sets wal_autocheckpoint=1000 when WAL succeeds", () => {
|
|
55
|
+
const db = new Database(join(dir, "checkpoint.db"));
|
|
56
|
+
applyConnectionPragmas(db);
|
|
57
|
+
expect(pragma(db, "wal_autocheckpoint")).toBe(1000);
|
|
58
|
+
db.close();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("enables foreign_keys", () => {
|
|
62
|
+
const db = new Database(join(dir, "fk.db"));
|
|
63
|
+
applyConnectionPragmas(db);
|
|
64
|
+
expect(pragma(db, "foreign_keys")).toBe(1);
|
|
65
|
+
db.close();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("is idempotent — applying twice yields the same result", () => {
|
|
69
|
+
const dbPath = join(dir, "idem.db");
|
|
70
|
+
const db = new Database(dbPath);
|
|
71
|
+
const a = applyConnectionPragmas(db);
|
|
72
|
+
const b = applyConnectionPragmas(db);
|
|
73
|
+
expect(a).toEqual(b);
|
|
74
|
+
expect(b.wal).toBe(true);
|
|
75
|
+
db.close();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("a DB created in DELETE mode is migrated to WAL on next open", () => {
|
|
79
|
+
const dbPath = join(dir, "migrate.db");
|
|
80
|
+
|
|
81
|
+
// First connection — manually force DELETE journal mode (the legacy
|
|
82
|
+
// shape that pre-WAL vaults shipped with). Write some data so we can
|
|
83
|
+
// verify it survives the WAL flip.
|
|
84
|
+
{
|
|
85
|
+
const db = new Database(dbPath);
|
|
86
|
+
db.exec("PRAGMA journal_mode = DELETE");
|
|
87
|
+
db.exec("CREATE TABLE legacy (k TEXT PRIMARY KEY, v TEXT)");
|
|
88
|
+
db.prepare("INSERT INTO legacy (k, v) VALUES (?, ?)").run("hello", "world");
|
|
89
|
+
expect(pragma(db, "journal_mode")).toBe("delete");
|
|
90
|
+
db.close();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Second connection — apply pragmas. WAL takes effect, existing data
|
|
94
|
+
// intact.
|
|
95
|
+
{
|
|
96
|
+
const db = new Database(dbPath);
|
|
97
|
+
const result = applyConnectionPragmas(db);
|
|
98
|
+
expect(result.wal).toBe(true);
|
|
99
|
+
const row = db.prepare("SELECT v FROM legacy WHERE k = ?").get("hello") as { v: string };
|
|
100
|
+
expect(row.v).toBe("world");
|
|
101
|
+
db.close();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("applyConnectionPragmas — :memory: DB", () => {
|
|
107
|
+
it("returns wal:false, journalMode='memory' WITHOUT warning", () => {
|
|
108
|
+
// :memory: is bun:sqlite's ephemeral mode — journal_mode comes back as
|
|
109
|
+
// "memory" and WAL can't be enabled. This is an explicit caller choice
|
|
110
|
+
// (test fixtures, throwaway probes), not an operator-visible filesystem
|
|
111
|
+
// limitation, so applyConnectionPragmas suppresses the warning for it
|
|
112
|
+
// to keep test output clean.
|
|
113
|
+
const db = new Database(":memory:");
|
|
114
|
+
|
|
115
|
+
const warnings: string[] = [];
|
|
116
|
+
const origWarn = console.warn;
|
|
117
|
+
console.warn = (msg: string) => warnings.push(msg);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const result = applyConnectionPragmas(db);
|
|
121
|
+
expect(result.wal).toBe(false);
|
|
122
|
+
expect(result.journalMode).toBe("memory");
|
|
123
|
+
expect(warnings.length).toBe(0);
|
|
124
|
+
} finally {
|
|
125
|
+
console.warn = origWarn;
|
|
126
|
+
db.close();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("still enables foreign_keys on the :memory: branch", () => {
|
|
131
|
+
const db = new Database(":memory:");
|
|
132
|
+
applyConnectionPragmas(db);
|
|
133
|
+
expect(pragma(db, "foreign_keys")).toBe(1);
|
|
134
|
+
db.close();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("applyConnectionPragmas — WAL-unsupported on-disk filesystem (simulated)", () => {
|
|
139
|
+
it("warns once when an on-disk DB lands in a non-WAL, non-memory mode", () => {
|
|
140
|
+
// We can't easily mount an NFS volume in CI, so simulate the
|
|
141
|
+
// unsupported-FS branch: open an on-disk DB and immediately force
|
|
142
|
+
// journal_mode to a non-WAL mode that sticks. PRAGMA journal_mode=WAL
|
|
143
|
+
// will then return that mode (because WAL silently fell back), which
|
|
144
|
+
// is the exact shape the unsupported-FS detection triggers on.
|
|
145
|
+
//
|
|
146
|
+
// We do this by stubbing prepare("PRAGMA journal_mode = WAL") to
|
|
147
|
+
// return { journal_mode: "delete" } — what bun:sqlite returns when
|
|
148
|
+
// SQLite refuses the WAL flip.
|
|
149
|
+
const db = new Database(join(dir, "stub.db"));
|
|
150
|
+
|
|
151
|
+
const origPrepare = db.prepare.bind(db);
|
|
152
|
+
// @ts-expect-error — narrow override for the one PRAGMA we care about
|
|
153
|
+
db.prepare = (sql: string) => {
|
|
154
|
+
if (sql === "PRAGMA journal_mode = WAL") {
|
|
155
|
+
return {
|
|
156
|
+
get: () => ({ journal_mode: "delete" }),
|
|
157
|
+
all: () => [{ journal_mode: "delete" }],
|
|
158
|
+
run: () => ({ changes: 0, lastInsertRowid: 0 }),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return origPrepare(sql);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const warnings: string[] = [];
|
|
165
|
+
const origWarn = console.warn;
|
|
166
|
+
console.warn = (msg: string) => warnings.push(msg);
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const result = applyConnectionPragmas(db);
|
|
170
|
+
expect(result.wal).toBe(false);
|
|
171
|
+
expect(result.journalMode).toBe("delete");
|
|
172
|
+
expect(warnings.length).toBe(1);
|
|
173
|
+
expect(warnings[0]).toContain("WAL mode could not be enabled");
|
|
174
|
+
expect(warnings[0]).toContain("journal_mode=delete");
|
|
175
|
+
|
|
176
|
+
// Second call on the same handle: dedupe by WeakSet, no second warn.
|
|
177
|
+
applyConnectionPragmas(db);
|
|
178
|
+
expect(warnings.length).toBe(1);
|
|
179
|
+
} finally {
|
|
180
|
+
console.warn = origWarn;
|
|
181
|
+
db.close();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("initSchema integration", () => {
|
|
187
|
+
it("leaves a fresh vault DB in WAL mode after full initialization", () => {
|
|
188
|
+
const db = new Database(join(dir, "vault.db"));
|
|
189
|
+
initSchema(db);
|
|
190
|
+
expect(pragma(db, "journal_mode")).toBe("wal");
|
|
191
|
+
expect(pragma(db, "foreign_keys")).toBe(1);
|
|
192
|
+
expect(pragma(db, "synchronous")).toBe(1);
|
|
193
|
+
db.close();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("multi-connection concurrency under WAL", () => {
|
|
198
|
+
it("allows a second connection to read while a first holds an open write txn", () => {
|
|
199
|
+
// The whole point of WAL: a reader running concurrently with a writer
|
|
200
|
+
// does NOT block. Under the legacy DELETE journal mode an open write
|
|
201
|
+
// txn locks out readers and a `BEGIN IMMEDIATE` from a sibling
|
|
202
|
+
// connection on the same file errors with SQLITE_BUSY.
|
|
203
|
+
//
|
|
204
|
+
// Setup: writer opens, applies pragmas (flips DB to WAL), inserts a
|
|
205
|
+
// row, BEGINS another txn (uncommitted). Reader opens a second
|
|
206
|
+
// connection to the same file and SELECTs — should succeed instantly
|
|
207
|
+
// with the pre-txn committed state.
|
|
208
|
+
const dbPath = join(dir, "concurrent.db");
|
|
209
|
+
const writer = new Database(dbPath);
|
|
210
|
+
initSchema(writer);
|
|
211
|
+
|
|
212
|
+
writer.exec(`CREATE TABLE k (id INTEGER PRIMARY KEY, v TEXT)`);
|
|
213
|
+
writer.prepare(`INSERT INTO k (id, v) VALUES (?, ?)`).run(1, "committed");
|
|
214
|
+
|
|
215
|
+
// Open a long-running write txn that doesn't commit.
|
|
216
|
+
writer.exec("BEGIN IMMEDIATE");
|
|
217
|
+
writer.prepare(`INSERT INTO k (id, v) VALUES (?, ?)`).run(2, "uncommitted");
|
|
218
|
+
|
|
219
|
+
// Reader: a separate connection (simulating a separate process).
|
|
220
|
+
// Under WAL this read should succeed and see only the committed row.
|
|
221
|
+
const reader = new Database(dbPath, { readonly: true });
|
|
222
|
+
try {
|
|
223
|
+
const rows = reader.prepare("SELECT id, v FROM k ORDER BY id").all() as { id: number; v: string }[];
|
|
224
|
+
expect(rows).toEqual([{ id: 1, v: "committed" }]);
|
|
225
|
+
} finally {
|
|
226
|
+
reader.close();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
writer.exec("ROLLBACK");
|
|
230
|
+
writer.close();
|
|
231
|
+
});
|
|
232
|
+
});
|