@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2
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 +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- package/web/ui/dist/assets/index-Degr8snN.js +0 -60
package/README.md
CHANGED
|
@@ -93,13 +93,13 @@ The daemon binds `0.0.0.0:1940` (or whatever you set in `PORT`) and serves REST,
|
|
|
93
93
|
|
|
94
94
|
### `~/.claude.json`
|
|
95
95
|
|
|
96
|
-
`vault init` adds one entry — `mcpServers["parachute-vault"]` — pointing at `http://127.0.0.1:<port>/vault/<default-vault>/mcp` with a baked-in `Authorization: Bearer
|
|
96
|
+
`vault init` adds one entry — `mcpServers["parachute-vault"]` — pointing at `http://127.0.0.1:<port>/vault/<default-vault>/mcp` with a baked-in `Authorization: Bearer <hub-jwt>` header (a hub-minted JWT — vault#282 Stage 2). Next Claude Code session picks it up; there's no further wiring. See [Connecting a client](#connecting-a-client) for rotating that token or pointing it elsewhere.
|
|
97
97
|
|
|
98
98
|
### Your API token
|
|
99
99
|
|
|
100
|
-
`vault init` asks two explicit questions: (1) install vault as an MCP server in `~/.claude.json`? (2) also surface the
|
|
100
|
+
`vault init` asks two explicit questions: (1) install vault as an MCP server in `~/.claude.json`? (2) also surface the access token so you can paste it into other MCP clients (Codex, Goose, OpenCode, Cursor, Zed, Cline), scripts, or `curl`? Both default yes. Pass `--mcp` / `--no-mcp` and `--token` / `--no-token` for non-interactive installs.
|
|
101
101
|
|
|
102
|
-
If you said yes to (2), the
|
|
102
|
+
If you said yes to (2), the hub-issued JWT 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? Mint a fresh one with `parachute auth mint-token --scope vault:<name>:<verb>` (or rewire an MCP client with `parachute-vault mcp-install`, or use the admin SPA Tokens page). As of vault 0.5.0 (vault#282 Stage 2) vault no longer mints its own `pvt_*` tokens — minting is the hub's job.
|
|
103
103
|
|
|
104
104
|
### OAuth lives on the hub
|
|
105
105
|
|
|
@@ -120,13 +120,13 @@ Two ways to authenticate — pick based on the client, not the deployment:
|
|
|
120
120
|
| Path | When to use | User action |
|
|
121
121
|
|---|---|---|
|
|
122
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 |
|
|
123
|
-
| **Bearer token** | Claude Code (auto-wired by `vault init`), CLI scripts, cron jobs, any non-interactive caller | `curl -H "Authorization: Bearer
|
|
123
|
+
| **Bearer token (hub JWT)** | Claude Code (auto-wired by `vault init`), CLI scripts, cron jobs, any non-interactive caller | `curl -H "Authorization: Bearer <hub-jwt>"` — mint one with `parachute-vault mcp-install` (MCP clients) or `parachute auth mint-token --scope vault:<name>:<verb>` (scripts) |
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
As of 0.5.0 (vault#282 Stage 2) vault is a **pure hub resource-server**: both paths use a hub-signed JWT that vault validates against the hub's JWKS. (The OAuth path is the interactive browser handshake; the bearer path mints the same kind of JWT non-interactively.) The old vault-local `pvt_*` opaque token was dropped — vault no longer mints or accepts it. The server-wide `VAULT_AUTH_TOKEN` operator bearer remains for the no-granular-auth / cross-container path.
|
|
126
126
|
|
|
127
127
|
### Claude Code
|
|
128
128
|
|
|
129
|
-
`vault init` fully auto-configures `~/.claude.json` — there's nothing else to do. The entry it writes
|
|
129
|
+
`vault init` fully auto-configures `~/.claude.json` — there's nothing else to do. The entry it writes bakes in a hub-minted JWT rather than running the interactive OAuth browser flow:
|
|
130
130
|
|
|
131
131
|
```json
|
|
132
132
|
{
|
|
@@ -134,13 +134,13 @@ The OAuth path mints a hub-signed JWT that vault validates against the hub's JWK
|
|
|
134
134
|
"parachute-vault": {
|
|
135
135
|
"type": "http",
|
|
136
136
|
"url": "http://127.0.0.1:1940/vault/{name}/mcp",
|
|
137
|
-
"headers": { "Authorization": "Bearer
|
|
137
|
+
"headers": { "Authorization": "Bearer <hub-jwt>" }
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
Where `{name}` is `default` on a fresh install, or whatever vault you pointed `vault init` at. **First MCP call after `vault init` requires no browser handoff — Claude Code uses the baked-in token and the vault's tools show up in your next session.** This is intentional: for an owner connecting their own machine's vault to their own Claude Code, the token is already there and OAuth would add friction.
|
|
143
|
+
Where `{name}` is `default` on a fresh install, or whatever vault you pointed `vault init` at. **First MCP call after `vault init` requires no browser handoff — Claude Code uses the baked-in token and the vault's tools show up in your next session.** This is intentional: for an owner connecting their own machine's vault to their own Claude Code, the token is already there and the OAuth browser handshake would add friction.
|
|
144
144
|
|
|
145
145
|
To re-point Claude Code at a different vault, change `default_vault` in `~/.parachute/vault/config.yaml` and re-run `parachute-vault init` — which re-mints an API token and re-writes the `~/.claude.json` entry end-to-end. To rotate the token only, run `parachute-vault mcp-install` (defaults to `--mint`, which mints a fresh scope-narrow hub JWT via `~/.parachute/operator.token` and writes it into `~/.claude.json` with an `Authorization: Bearer …` header). See the [cookbook](#install-vault-mcp-into-a-client-config) section below for the full flag surface — token paste, scope narrowing, project-level install, multi-vault.
|
|
146
146
|
|
|
@@ -154,7 +154,7 @@ For Claude Desktop — or any install where the server is on a different machine
|
|
|
154
154
|
4. Sign in to the hub with your hub credentials, pick a scope (`full` or `read`), click Authorize.
|
|
155
155
|
5. Browser redirects back. The connection is live. The client now holds a hub-signed JWT scoped to this vault.
|
|
156
156
|
|
|
157
|
-
If you'd rather skip
|
|
157
|
+
If you'd rather skip the browser flow — e.g. you're scripting the setup — Claude Desktop also accepts a bearer token via the integration's auth header field. Mint a hub JWT with `parachute auth mint-token --scope vault:<name>:<verb>` (or the admin SPA Tokens page), or reuse the one from `vault init` if you still have it. This is the "manual bearer" fallback; the OAuth browser flow is the recommended path.
|
|
158
158
|
|
|
159
159
|
### Parachute Daily (mobile)
|
|
160
160
|
|
|
@@ -194,8 +194,7 @@ parachute-vault create work # create a new vault
|
|
|
194
194
|
parachute-vault list # list all vaults (alias: `ls`)
|
|
195
195
|
parachute-vault remove work --yes # delete a vault (alias: `rm`)
|
|
196
196
|
parachute-vault mcp-install # (re)write the MCP client entry; defaults to --mint (hub-issued JWT) at local scope
|
|
197
|
-
parachute-vault mcp-install --token <t> # paste an existing bearer instead of minting
|
|
198
|
-
parachute-vault mcp-install --legacy-pat # mint a vault-DB pvt_* (self-hosted-without-hub)
|
|
197
|
+
parachute-vault mcp-install --token <t> # paste an existing bearer (hub JWT or VAULT_AUTH_TOKEN) instead of minting
|
|
199
198
|
parachute-vault mcp-install --install-scope user # write ~/.claude.json top-level (every project)
|
|
200
199
|
parachute-vault mcp-install --install-scope local # write ~/.claude.json projects[<cwd>] (this directory only — default)
|
|
201
200
|
parachute-vault mcp-install --install-scope project # write ./.mcp.json (checked into the repo)
|
|
@@ -217,14 +216,11 @@ parachute-vault 2fa enroll # enroll TOTP (shows QR + prints one-
|
|
|
217
216
|
parachute-vault 2fa disable # disable 2FA
|
|
218
217
|
parachute-vault 2fa backup-codes # regenerate backup codes
|
|
219
218
|
|
|
220
|
-
# Tokens
|
|
221
|
-
parachute-vault
|
|
222
|
-
parachute
|
|
223
|
-
parachute-vault tokens
|
|
224
|
-
parachute-vault tokens
|
|
225
|
-
parachute-vault tokens create --expires 30d # expiring token (N{h|d|w|m|y})
|
|
226
|
-
parachute-vault tokens create --label mobile # labeled token
|
|
227
|
-
parachute-vault tokens revoke <token-id> # revoke (default vault; add --vault to target)
|
|
219
|
+
# Tokens — vault#282 Stage 2: vault no longer mints its own tokens. Mint a
|
|
220
|
+
# hub JWT with `parachute-vault mcp-install` (MCP clients) or
|
|
221
|
+
# `parachute auth mint-token --scope vault:<name>:<verb>` (scripts).
|
|
222
|
+
parachute-vault tokens # list any vestigial pre-0.5.0 token rows (all vaults)
|
|
223
|
+
parachute-vault tokens revoke <token-id> # revoke a vestigial row (default vault; add --vault to target)
|
|
228
224
|
|
|
229
225
|
# Obsidian
|
|
230
226
|
parachute-vault import ~/Obsidian/MyVault # import into default vault
|
|
@@ -445,7 +441,7 @@ On Linux, scheduled runs via systemd timers are a follow-up; for now `parachute-
|
|
|
445
441
|
Serve notes as clean HTML pages at `/view/:noteId`:
|
|
446
442
|
|
|
447
443
|
- **Without auth**: only serves notes tagged `published` (or with `metadata.published: true`). Returns 404 for unpublished notes.
|
|
448
|
-
- **With auth**: serves any note. Pass your token via `Authorization: Bearer
|
|
444
|
+
- **With auth**: serves any note. Pass your token via `Authorization: Bearer <hub-jwt>` header or `?key=<hub-jwt>` query param.
|
|
449
445
|
- **Custom tag**: set `published_tag` in vault.yaml to use a different tag name (default: `publish`).
|
|
450
446
|
|
|
451
447
|
```yaml
|
|
@@ -535,7 +531,7 @@ The SSG / sync pattern. Two equivalent forms — bracket-style is canonical goin
|
|
|
535
531
|
curl -H "Authorization: Bearer $VAULT_TOKEN" \
|
|
536
532
|
"http://localhost:1940/vault/default/api/notes?meta[updated_at][gte]=2026-04-01T00:00:00Z"
|
|
537
533
|
|
|
538
|
-
# Flat form (DEPRECATED in 0.4.3; planned removal 0.
|
|
534
|
+
# Flat form (DEPRECATED in 0.4.3; planned removal in a later 0.x per vault#288)
|
|
539
535
|
curl -H "Authorization: Bearer $VAULT_TOKEN" \
|
|
540
536
|
"http://localhost:1940/vault/default/api/notes?date_field=updated_at&date_from=2026-04-01T00:00:00Z"
|
|
541
537
|
```
|
|
@@ -613,7 +609,7 @@ The shortest path to a public HTTPS URL for a vault you control — useful for S
|
|
|
613
609
|
|
|
614
610
|
### Install vault MCP into a client config
|
|
615
611
|
|
|
616
|
-
Bare `parachute-vault mcp-install` from a terminal **walks you through a short contextual conversation** — picks defaults informed by your environment (how many vaults you have, whether the hub is reachable, whether you're in a project directory, whether vault is already installed somewhere), shows the JSON shape it will write before doing anything, and asks before each non-obvious choice. The patterns below are the non-interactive shapes — pass any flag (`--mint`, `--token`, `--scope`, `--install-scope`, `--vault
|
|
612
|
+
Bare `parachute-vault mcp-install` from a terminal **walks you through a short contextual conversation** — picks defaults informed by your environment (how many vaults you have, whether the hub is reachable, whether you're in a project directory, whether vault is already installed somewhere), shows the JSON shape it will write before doing anything, and asks before each non-obvious choice. The patterns below are the non-interactive shapes — pass any flag (`--mint`, `--token`, `--scope`, `--install-scope`, `--vault`) and the walkthrough is skipped.
|
|
617
613
|
|
|
618
614
|
#### Install scopes
|
|
619
615
|
|
|
@@ -646,15 +642,12 @@ parachute-vault mcp-install --install-scope user
|
|
|
646
642
|
# project actually mutates the vault.
|
|
647
643
|
parachute-vault mcp-install --install-scope project --scope vault:write
|
|
648
644
|
|
|
649
|
-
# 4. Paste an existing
|
|
650
|
-
# or want to re-use
|
|
651
|
-
# mint step entirely.
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
# path; preserved so deployments without a hub keep working). Prints a
|
|
656
|
-
# deprecation notice.
|
|
657
|
-
parachute-vault mcp-install --legacy-pat
|
|
645
|
+
# 4. Paste an existing bearer — useful when you already have a hub JWT in
|
|
646
|
+
# hand, or want to re-use the VAULT_AUTH_TOKEN operator bearer / a
|
|
647
|
+
# long-lived JWT from another machine. Skips the mint step entirely.
|
|
648
|
+
# (vault#282 Stage 2 removed the --legacy-pat pvt_* path — without a hub,
|
|
649
|
+
# paste a bearer here or set VAULT_AUTH_TOKEN.)
|
|
650
|
+
parachute-vault mcp-install --token <hub-jwt-or-operator-bearer>
|
|
658
651
|
```
|
|
659
652
|
|
|
660
653
|
**Multi-vault.** `--vault <name>` targets a specific vault and writes the entry under `parachute-vault-<name>` so multiple vaults coexist. Without `--vault`, the singular `parachute-vault` slot is used and one install clobbers another — that's intentional for the common single-vault case.
|
|
@@ -671,8 +664,9 @@ parachute-vault mcp-install --legacy-pat
|
|
|
671
664
|
2. **`--mcp-config` JSON is per-runner boilerplate.** Some runners prefer to inline the MCP config rather than mutate the user's Claude Code state. `parachute-vault mcp-config <vault-name>` emits exactly the JSON shape `--mcp-config` consumes:
|
|
672
665
|
|
|
673
666
|
```bash
|
|
674
|
-
# Mint
|
|
675
|
-
|
|
667
|
+
# Mint a vault-scoped hub JWT first (parachute auth mint-token --scope
|
|
668
|
+
# vault:gitcoin:read), then:
|
|
669
|
+
export PARACHUTE_VAULT_TOKEN=<hub-jwt>
|
|
676
670
|
claude -p --mcp-config "$(parachute-vault mcp-config gitcoin)" \
|
|
677
671
|
--strict-mcp-config \
|
|
678
672
|
"Summarize the latest notes under projects/gitcoin"
|
|
@@ -681,7 +675,7 @@ claude -p --mcp-config "$(parachute-vault mcp-config gitcoin)" \
|
|
|
681
675
|
parachute-vault mcp-config gitcoin --env-vars > .claude/mcp-gitcoin.json
|
|
682
676
|
# ...then later, when invoking claude:
|
|
683
677
|
export PARACHUTE_HUB_URL=http://127.0.0.1:1940
|
|
684
|
-
export PARACHUTE_VAULT_TOKEN
|
|
678
|
+
export PARACHUTE_VAULT_TOKEN=<hub-jwt>
|
|
685
679
|
claude -p --mcp-config "$(envsubst < .claude/mcp-gitcoin.json)" \
|
|
686
680
|
--strict-mcp-config ...
|
|
687
681
|
```
|
|
@@ -710,43 +704,46 @@ For wiring up an AI client (Claude Code, Claude Desktop, Parachute Daily), see [
|
|
|
710
704
|
|
|
711
705
|
### Passing the key
|
|
712
706
|
|
|
713
|
-
|
|
707
|
+
As of 0.5.0 (vault#282 Stage 2) vault accepts these bearers at every authenticated endpoint:
|
|
708
|
+
|
|
709
|
+
- **Hub-issued JWT** (`eyJ...`) — the user-credential path; what OAuth issues and what `parachute-vault mcp-install` / `parachute auth mint-token` produce. Audience-bound to `vault.<name>`, scope-narrowed (`vault:<name>:<verb>`).
|
|
710
|
+
- **`VAULT_AUTH_TOKEN`** — the server-wide operator bearer (env var; full-admin against any vault on the server).
|
|
711
|
+
- **`pvk_...`** — legacy global API keys from `config.yaml` / per-vault `vault.yaml` (still honored for existing deployments).
|
|
714
712
|
|
|
715
|
-
- `pvt_
|
|
716
|
-
- `pvk_...` — legacy global API keys from `config.yaml` (still honored for existing deployments)
|
|
713
|
+
The old vault-local `pvt_*` opaque token was **dropped at 0.5.0** — vault no longer mints or accepts it.
|
|
717
714
|
|
|
718
715
|
```bash
|
|
719
716
|
# Header (preferred)
|
|
720
|
-
curl -H "Authorization: Bearer
|
|
717
|
+
curl -H "Authorization: Bearer <hub-jwt>" http://localhost:1940/vault/default/api/notes
|
|
721
718
|
|
|
722
719
|
# Alternative header
|
|
723
|
-
curl -H "X-API-Key:
|
|
720
|
+
curl -H "X-API-Key: <hub-jwt>" http://localhost:1940/vault/default/api/notes
|
|
724
721
|
|
|
725
722
|
# Query param (for /view endpoint only — convenient for browsers)
|
|
726
|
-
curl http://localhost:1940/vault/default/view/noteId?key
|
|
723
|
+
curl http://localhost:1940/vault/default/view/noteId?key=<hub-jwt>
|
|
727
724
|
```
|
|
728
725
|
|
|
729
726
|
### Token management
|
|
730
727
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
| Permission | Can do |
|
|
734
|
-
|---|---|
|
|
735
|
-
| `full` | Everything (CRUD + delete + token management) |
|
|
736
|
-
| `read` | Query, list, find-path, vault-info only |
|
|
728
|
+
Tokens are hub-issued JWTs (vault#282 Stage 2 — vault no longer mints its own).
|
|
729
|
+
Mint and revoke happen on the hub:
|
|
737
730
|
|
|
738
731
|
```bash
|
|
739
|
-
parachute-vault
|
|
740
|
-
parachute
|
|
741
|
-
parachute
|
|
742
|
-
parachute-vault tokens create --vault work --expires 30d # with expiry
|
|
743
|
-
parachute-vault tokens create --vault work --label phone # labeled token
|
|
744
|
-
parachute-vault tokens revoke <token-id> --vault work # revoke
|
|
732
|
+
parachute-vault mcp-install --scope vault:write # mint + wire a hub JWT into an MCP client
|
|
733
|
+
parachute auth mint-token --scope vault:<name>:<verb> # mint a scoped JWT for scripts
|
|
734
|
+
parachute auth tokens # list / revoke hub JWTs (hub registry)
|
|
745
735
|
```
|
|
746
736
|
|
|
747
|
-
|
|
737
|
+
Two permission levels carry through the JWT scope verb:
|
|
738
|
+
|
|
739
|
+
| Verb | Can do |
|
|
740
|
+
|---|---|
|
|
741
|
+
| `admin` / `write` → `full` | Everything (CRUD + delete + token management) |
|
|
742
|
+
| `read` | Query, list, find-path, vault-info only |
|
|
748
743
|
|
|
749
|
-
|
|
744
|
+
`parachute-vault tokens list` / `tokens revoke` remain only to clean up any
|
|
745
|
+
vestigial pre-0.5.0 rows. Legacy `pvk_...` keys from config.yaml still work at
|
|
746
|
+
runtime; the `vault keys` CLI commands were removed long ago.
|
|
750
747
|
|
|
751
748
|
### Public endpoints
|
|
752
749
|
|
package/core/src/core.test.ts
CHANGED
|
@@ -1944,6 +1944,9 @@ describe("MCP tools", async () => {
|
|
|
1944
1944
|
expect(names).toContain("delete-tag");
|
|
1945
1945
|
expect(names).toContain("find-path");
|
|
1946
1946
|
expect(names).toContain("vault-info");
|
|
1947
|
+
// prune-schema (admin) — drops orphaned indexed-field columns whose
|
|
1948
|
+
// declaring tags are gone. The gitcoin orphaned-fields fix.
|
|
1949
|
+
expect(names).toContain("prune-schema");
|
|
1947
1950
|
// Six note-schema tools (list/update/delete-note-schema +
|
|
1948
1951
|
// list/set/delete-schema-mapping) retired in v17 — the standalone
|
|
1949
1952
|
// note_schemas + schema_mappings subsystem was a parallel path to
|
|
@@ -1957,7 +1960,7 @@ describe("MCP tools", async () => {
|
|
|
1957
1960
|
// synthesize-notes retired in v17 — replicable with query-notes(near=) +
|
|
1958
1961
|
// find-path + agent-side aggregation. See vault#268.
|
|
1959
1962
|
expect(names).not.toContain("synthesize-notes");
|
|
1960
|
-
expect(tools).toHaveLength(
|
|
1963
|
+
expect(tools).toHaveLength(10);
|
|
1961
1964
|
});
|
|
1962
1965
|
|
|
1963
1966
|
it("create-note tool works", async () => {
|
|
@@ -7,11 +7,13 @@ import {
|
|
|
7
7
|
declareField,
|
|
8
8
|
getIndexedField,
|
|
9
9
|
listIndexedFields,
|
|
10
|
+
pruneOrphanedIndexedFields,
|
|
10
11
|
rebuildIndexes,
|
|
11
12
|
releaseField,
|
|
12
13
|
TYPE_MAP,
|
|
13
14
|
validateFieldName,
|
|
14
15
|
} from "./indexed-fields.js";
|
|
16
|
+
import { buildVaultProjection } from "./vault-projection.js";
|
|
15
17
|
|
|
16
18
|
let db: Database;
|
|
17
19
|
let store: SqliteStore;
|
|
@@ -282,4 +284,153 @@ describe("delete-tag: indexed fields", () => {
|
|
|
282
284
|
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["ticket"]);
|
|
283
285
|
expect(notesColumns()).toContain("meta_status");
|
|
284
286
|
});
|
|
287
|
+
|
|
288
|
+
// Bug 1b — the release lives in store.deleteTag now (not the MCP layer), so
|
|
289
|
+
// every delete entry point releases. Co-declaration sequencing: deleting the
|
|
290
|
+
// FIRST co-declarer keeps the column; deleting the SECOND drops it.
|
|
291
|
+
it("co-declaration: delete A keeps column (B holds), then delete B drops it", async () => {
|
|
292
|
+
const update = findTool("update-tag");
|
|
293
|
+
const del = findTool("delete-tag");
|
|
294
|
+
await update.execute({ tag: "asset", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
295
|
+
await update.execute({ tag: "storyboard", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
296
|
+
|
|
297
|
+
await del.execute({ tag: "asset" });
|
|
298
|
+
expect(getIndexedField(db, "aspect_ratio")?.declarerTags).toEqual(["storyboard"]);
|
|
299
|
+
expect(notesColumns()).toContain("meta_aspect_ratio");
|
|
300
|
+
|
|
301
|
+
await del.execute({ tag: "storyboard" });
|
|
302
|
+
expect(getIndexedField(db, "aspect_ratio")).toBeNull();
|
|
303
|
+
expect(notesColumns()).not.toContain("meta_aspect_ratio");
|
|
304
|
+
expect(notesIndexes()).not.toContain("idx_meta_aspect_ratio");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ===========================================================================
|
|
309
|
+
// Bug 1a — update-tag {fields: null} clears all of this tag's field schemas,
|
|
310
|
+
// dropping the exclusively-declared columns + indexes. `null` (clear-all) must
|
|
311
|
+
// be distinguished from `undefined` (no change). The gitcoin orphaned-fields
|
|
312
|
+
// bug was that `?? {}` collapsed null to a no-op.
|
|
313
|
+
// ===========================================================================
|
|
314
|
+
describe("update-tag: fields null vs undefined", () => {
|
|
315
|
+
it("fields:null drops the tag's exclusively-declared columns + indexed_fields rows", async () => {
|
|
316
|
+
const update = findTool("update-tag");
|
|
317
|
+
await update.execute({
|
|
318
|
+
tag: "project",
|
|
319
|
+
fields: { status: { type: "string", indexed: true }, priority: { type: "integer", indexed: true } },
|
|
320
|
+
});
|
|
321
|
+
expect(notesColumns()).toContain("meta_status");
|
|
322
|
+
expect(notesColumns()).toContain("meta_priority");
|
|
323
|
+
|
|
324
|
+
await update.execute({ tag: "project", fields: null });
|
|
325
|
+
|
|
326
|
+
expect(getIndexedField(db, "status")).toBeNull();
|
|
327
|
+
expect(getIndexedField(db, "priority")).toBeNull();
|
|
328
|
+
expect(notesColumns()).not.toContain("meta_status");
|
|
329
|
+
expect(notesColumns()).not.toContain("meta_priority");
|
|
330
|
+
expect(notesIndexes()).not.toContain("idx_meta_status");
|
|
331
|
+
// The tag's fields column is cleared too.
|
|
332
|
+
const rec = await store.getTagRecord("project");
|
|
333
|
+
expect(rec?.fields ?? null).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("fields:null no longer lists the fields in the vault-info indexed_fields catalog", async () => {
|
|
337
|
+
const update = findTool("update-tag");
|
|
338
|
+
await update.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
339
|
+
expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).toContain("status");
|
|
340
|
+
|
|
341
|
+
await update.execute({ tag: "project", fields: null });
|
|
342
|
+
expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("status");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("fields:undefined is a no-op — preserves existing field schemas + columns", async () => {
|
|
346
|
+
const update = findTool("update-tag");
|
|
347
|
+
await update.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
348
|
+
// Update only the description; omit fields entirely.
|
|
349
|
+
await update.execute({ tag: "project", description: "a project" });
|
|
350
|
+
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["project"]);
|
|
351
|
+
expect(notesColumns()).toContain("meta_status");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("fields:null respects co-declaration — keeps a field another live tag still declares", async () => {
|
|
355
|
+
const update = findTool("update-tag");
|
|
356
|
+
await update.execute({ tag: "asset", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
357
|
+
await update.execute({ tag: "storyboard", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
358
|
+
|
|
359
|
+
await update.execute({ tag: "asset", fields: null });
|
|
360
|
+
expect(getIndexedField(db, "aspect_ratio")?.declarerTags).toEqual(["storyboard"]);
|
|
361
|
+
expect(notesColumns()).toContain("meta_aspect_ratio");
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ===========================================================================
|
|
366
|
+
// Bug 1c — prune for ALREADY-orphaned fields. The gitcoin case: an
|
|
367
|
+
// indexed_fields row whose every declarer tag has no `tags` row (orphaned by a
|
|
368
|
+
// pre-fix delete/clear that never released). prune finds + drops them; it must
|
|
369
|
+
// NOT touch fields with a live declarer.
|
|
370
|
+
// ===========================================================================
|
|
371
|
+
describe("pruneOrphanedIndexedFields", () => {
|
|
372
|
+
// Seed an orphaned field directly: declare via the API (creates the tag
|
|
373
|
+
// row + column), then delete the tag row out from under it WITHOUT going
|
|
374
|
+
// through the release path — exactly the pre-fix orphaned state.
|
|
375
|
+
function orphanField(field: string, type: "TEXT" | "INTEGER", tag: string) {
|
|
376
|
+
declareField(db, field, type, tag);
|
|
377
|
+
// Drop the tags row directly — simulating the pre-fix delete-tag that
|
|
378
|
+
// never released. The indexed_fields row + column survive (orphaned).
|
|
379
|
+
db.prepare("DELETE FROM tags WHERE name = ?").run(tag);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
it("dry-run reports the orphan without mutating", async () => {
|
|
383
|
+
orphanField("legacy_status", "TEXT", "ghost");
|
|
384
|
+
expect(notesColumns()).toContain("meta_legacy_status");
|
|
385
|
+
|
|
386
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: true });
|
|
387
|
+
expect(plan).toEqual([{ field: "legacy_status", deadDeclarers: ["ghost"], dropped: true }]);
|
|
388
|
+
// Nothing changed.
|
|
389
|
+
expect(getIndexedField(db, "legacy_status")).not.toBeNull();
|
|
390
|
+
expect(notesColumns()).toContain("meta_legacy_status");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("apply drops the orphaned column + index + row", async () => {
|
|
394
|
+
orphanField("legacy_status", "TEXT", "ghost");
|
|
395
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: false });
|
|
396
|
+
expect(plan).toEqual([{ field: "legacy_status", deadDeclarers: ["ghost"], dropped: true }]);
|
|
397
|
+
expect(getIndexedField(db, "legacy_status")).toBeNull();
|
|
398
|
+
expect(notesColumns()).not.toContain("meta_legacy_status");
|
|
399
|
+
expect(notesIndexes()).not.toContain("idx_meta_legacy_status");
|
|
400
|
+
// No longer advertised by vault-info.
|
|
401
|
+
expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("legacy_status");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("does NOT touch a field with a live declarer", async () => {
|
|
405
|
+
const update = findTool("update-tag");
|
|
406
|
+
await update.execute({ tag: "project", fields: { status: { type: "string", indexed: true } } });
|
|
407
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: false });
|
|
408
|
+
expect(plan).toEqual([]);
|
|
409
|
+
expect(getIndexedField(db, "status")?.declarerTags).toEqual(["project"]);
|
|
410
|
+
expect(notesColumns()).toContain("meta_status");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("trims dead declarers but keeps the column when a live co-declarer remains", async () => {
|
|
414
|
+
const update = findTool("update-tag");
|
|
415
|
+
// Two declarers, then orphan only one by deleting its tag row directly.
|
|
416
|
+
await update.execute({ tag: "asset", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
417
|
+
await update.execute({ tag: "storyboard", fields: { aspect_ratio: { type: "string", indexed: true } } });
|
|
418
|
+
db.prepare("DELETE FROM tags WHERE name = ?").run("asset"); // orphan one declarer
|
|
419
|
+
|
|
420
|
+
const plan = pruneOrphanedIndexedFields(db, { dryRun: false });
|
|
421
|
+
expect(plan).toEqual([{ field: "aspect_ratio", deadDeclarers: ["asset"], dropped: false }]);
|
|
422
|
+
// Column kept; storyboard still declares it; asset trimmed from the set.
|
|
423
|
+
expect(getIndexedField(db, "aspect_ratio")?.declarerTags).toEqual(["storyboard"]);
|
|
424
|
+
expect(notesColumns()).toContain("meta_aspect_ratio");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("store.pruneIndexedFields surfaces the same plan", async () => {
|
|
428
|
+
orphanField("legacy_status", "TEXT", "ghost");
|
|
429
|
+
const dry = await store.pruneIndexedFields({ dryRun: true });
|
|
430
|
+
expect(dry).toEqual([{ field: "legacy_status", deadDeclarers: ["ghost"], dropped: true }]);
|
|
431
|
+
expect(getIndexedField(db, "legacy_status")).not.toBeNull(); // dry-run didn't mutate
|
|
432
|
+
const applied = await store.pruneIndexedFields({ dryRun: false });
|
|
433
|
+
expect(applied).toEqual([{ field: "legacy_status", deadDeclarers: ["ghost"], dropped: true }]);
|
|
434
|
+
expect(getIndexedField(db, "legacy_status")).toBeNull();
|
|
435
|
+
});
|
|
285
436
|
});
|
|
@@ -236,3 +236,101 @@ export function rebuildIndexes(db: Database): void {
|
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
|
+
|
|
240
|
+
export interface PrunedField {
|
|
241
|
+
/** The field whose `indexed_fields` row was affected. */
|
|
242
|
+
field: string;
|
|
243
|
+
/** Declarer tags that no longer have a `tags` row (removed from the set). */
|
|
244
|
+
deadDeclarers: string[];
|
|
245
|
+
/**
|
|
246
|
+
* True when the field had NO surviving live declarer and was fully dropped
|
|
247
|
+
* (row + generated column + index). False when at least one live declarer
|
|
248
|
+
* remained and only the dead declarers were pruned from the set.
|
|
249
|
+
*/
|
|
250
|
+
dropped: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Prune orphaned `indexed_fields` declarers — the gitcoin defect.
|
|
255
|
+
*
|
|
256
|
+
* A declarer tag is "dead" when no `tags` row carries that name (the tag was
|
|
257
|
+
* deleted, or its schema was cleared, without releasing the field). For every
|
|
258
|
+
* `indexed_fields` row:
|
|
259
|
+
*
|
|
260
|
+
* - Drop dead declarers from the set.
|
|
261
|
+
* - If NO live declarer remains, drop the whole field — row + generated
|
|
262
|
+
* column + index. This is the only data-loss-free drop: the generated
|
|
263
|
+
* column is `json_extract(metadata, …)` so the source values stay in
|
|
264
|
+
* `notes.metadata`; only the (now-dead) index is lost.
|
|
265
|
+
* - If at least one live declarer remains (co-declaration), keep the column
|
|
266
|
+
* and just trim the dead names from the declarer set.
|
|
267
|
+
*
|
|
268
|
+
* `dryRun` (default) computes the plan without mutating; pass `dryRun: false`
|
|
269
|
+
* to apply. Returns the per-field plan either way so the CLI / MCP surface can
|
|
270
|
+
* print what it would (or did) drop.
|
|
271
|
+
*/
|
|
272
|
+
export function pruneOrphanedIndexedFields(
|
|
273
|
+
db: Database,
|
|
274
|
+
opts: { dryRun?: boolean } = {},
|
|
275
|
+
): PrunedField[] {
|
|
276
|
+
const dryRun = opts.dryRun ?? true;
|
|
277
|
+
const liveTags = new Set(
|
|
278
|
+
(db.prepare("SELECT name FROM tags").all() as { name: string }[]).map((r) => r.name),
|
|
279
|
+
);
|
|
280
|
+
const plan: PrunedField[] = [];
|
|
281
|
+
for (const f of listIndexedFields(db)) {
|
|
282
|
+
const deadDeclarers = f.declarerTags.filter((t) => !liveTags.has(t));
|
|
283
|
+
if (deadDeclarers.length === 0) continue; // every declarer is live — leave it
|
|
284
|
+
const liveDeclarers = f.declarerTags.filter((t) => liveTags.has(t));
|
|
285
|
+
const dropped = liveDeclarers.length === 0;
|
|
286
|
+
plan.push({ field: f.field, deadDeclarers, dropped });
|
|
287
|
+
if (dryRun) continue;
|
|
288
|
+
if (dropped) {
|
|
289
|
+
db.prepare("DELETE FROM indexed_fields WHERE field = ?").run(f.field);
|
|
290
|
+
dropColumnAndIndex(db, f.field);
|
|
291
|
+
} else {
|
|
292
|
+
db.prepare("UPDATE indexed_fields SET declarer_tags = ? WHERE field = ?").run(
|
|
293
|
+
JSON.stringify(liveDeclarers),
|
|
294
|
+
f.field,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return plan;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Replay `declareField` for every field a tag schema marks `indexed: true`.
|
|
303
|
+
* Idempotent — used by the portable-md import path so a fresh import ends with
|
|
304
|
+
* the same generated columns a live vault would have (the import writes
|
|
305
|
+
* `tags.fields` via `upsertTagRecord` but never materializes the backing
|
|
306
|
+
* columns). Without this, an imported vault's schemas say `indexed: true` but
|
|
307
|
+
* queries fall back to full scans until each tag is next `update-tag`'d.
|
|
308
|
+
*
|
|
309
|
+
* `tagSchemas` is the post-import set of (tag, fields) pairs. Returns the
|
|
310
|
+
* number of (tag, field) declarations replayed.
|
|
311
|
+
*/
|
|
312
|
+
export function reconcileDeclaredIndexes(
|
|
313
|
+
db: Database,
|
|
314
|
+
tagSchemas: { tag: string; fields?: Record<string, { type: string; indexed?: boolean }> }[],
|
|
315
|
+
): number {
|
|
316
|
+
let declared = 0;
|
|
317
|
+
for (const schema of tagSchemas) {
|
|
318
|
+
if (!schema.fields) continue;
|
|
319
|
+
for (const [fieldName, spec] of Object.entries(schema.fields)) {
|
|
320
|
+
if (spec.indexed !== true) continue;
|
|
321
|
+
const mapped = mapFieldType(spec.type);
|
|
322
|
+
if (!mapped) continue; // unsupported type for indexing — skip, don't throw
|
|
323
|
+
try {
|
|
324
|
+
validateFieldName(fieldName);
|
|
325
|
+
declareField(db, fieldName, mapped, schema.tag);
|
|
326
|
+
declared++;
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.error(
|
|
329
|
+
`[indexed-fields] could not re-declare "${fieldName}" for tag "${schema.tag}" on import:`,
|
|
330
|
+
err,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return declared;
|
|
336
|
+
}
|