@openparachute/vault 0.4.9-rc.8 → 0.5.0-rc.1

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 (60) hide show
  1. package/README.md +51 -54
  2. package/core/src/core.test.ts +4 -1
  3. package/core/src/indexed-fields.test.ts +151 -0
  4. package/core/src/indexed-fields.ts +98 -0
  5. package/core/src/mcp.ts +66 -43
  6. package/core/src/notes.ts +26 -2
  7. package/core/src/portable-md.test.ts +52 -0
  8. package/core/src/portable-md.ts +48 -0
  9. package/core/src/schema.ts +87 -14
  10. package/core/src/store.ts +117 -0
  11. package/core/src/types.ts +28 -0
  12. package/package.json +2 -2
  13. package/src/auth-hub-jwt.test.ts +191 -11
  14. package/src/auth-status.ts +12 -5
  15. package/src/auth.test.ts +135 -219
  16. package/src/auth.ts +158 -107
  17. package/src/cli.ts +306 -224
  18. package/src/config.ts +12 -4
  19. package/src/export-watch.test.ts +74 -0
  20. package/src/export-watch.ts +108 -7
  21. package/src/hub-jwt.test.ts +27 -2
  22. package/src/hub-jwt.ts +10 -0
  23. package/src/init-summary.test.ts +4 -4
  24. package/src/init-summary.ts +36 -10
  25. package/src/mcp-config.test.ts +4 -2
  26. package/src/mcp-http.ts +24 -3
  27. package/src/mcp-install-interactive.test.ts +33 -71
  28. package/src/mcp-install-interactive.ts +23 -76
  29. package/src/mcp-install.test.ts +156 -55
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +249 -74
  32. package/src/mirror-config.test.ts +162 -10
  33. package/src/mirror-config.ts +328 -24
  34. package/src/mirror-credentials.test.ts +168 -17
  35. package/src/mirror-credentials.ts +155 -32
  36. package/src/mirror-deps.ts +25 -16
  37. package/src/mirror-import.test.ts +16 -16
  38. package/src/mirror-import.ts +6 -3
  39. package/src/mirror-manager.test.ts +104 -0
  40. package/src/mirror-manager.ts +172 -11
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +187 -19
  44. package/src/mirror-routes.ts +183 -18
  45. package/src/routes.ts +39 -2
  46. package/src/routing.test.ts +203 -118
  47. package/src/routing.ts +73 -53
  48. package/src/scopes.test.ts +0 -86
  49. package/src/scopes.ts +9 -97
  50. package/src/server.ts +102 -34
  51. package/src/storage.test.ts +132 -7
  52. package/src/token-store.test.ts +88 -169
  53. package/src/token-store.ts +123 -249
  54. package/src/vault-create.test.ts +12 -4
  55. package/src/vault.test.ts +408 -103
  56. package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
  57. package/web/ui/dist/index.html +1 -1
  58. package/src/tokens-routes.test.ts +0 -727
  59. package/src/tokens-routes.ts +0 -392
  60. package/web/ui/dist/assets/index-CudVv0Mv.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 pvt_...` header. 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.
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 API 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.
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 `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`.
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 pvt_..."` — the token is printed once at `vault init` (save it) or minted on demand with `parachute-vault tokens create` |
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
- 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.
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 uses a baked-in `pvt_` token rather than OAuth:
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 pvt_..." }
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 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.
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 tokens # list all tokens across all vaults
222
- parachute-vault tokens create # full-access token in the default vault
223
- parachute-vault tokens create --vault work # ...in a specific vault
224
- parachute-vault tokens create --read # read-only token
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 pvt_...` header or `?key=pvt_...` query param.
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.6.0 per vault#288)
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`, `--legacy-pat`) and the walkthrough is skipped.
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 token — useful when you already have a pvt_* in hand
650
- # or want to re-use a long-lived bearer from another machine. Skips the
651
- # mint step entirely.
652
- parachute-vault mcp-install --token pvt_abc123...
653
-
654
- # 5. Self-hosted-without-hub — mint a vault-DB pvt_* token (the legacy
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 or fetch a vault-scoped token first, then:
675
- export PARACHUTE_VAULT_TOKEN=pvt_...
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=pvt_...
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
- Tokens come in two shapes. Both work interchangeably at every authenticated endpoint:
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_...` per-vault scoped tokens (the modern format; what `vault init` mints, what OAuth issues, what `parachute-vault tokens create` produces)
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 pvt_..." http://localhost:1940/vault/default/api/notes
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: pvt_..." http://localhost:1940/vault/default/api/notes
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=pvt_...
723
+ curl http://localhost:1940/vault/default/view/noteId?key=<hub-jwt>
727
724
  ```
728
725
 
729
726
  ### Token management
730
727
 
731
- Per-vault tokens with two permission levels:
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 tokens # list all tokens
740
- parachute-vault tokens create --vault work # full-access token
741
- parachute-vault tokens create --vault work --read # read-only
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
- Tokens are shown once at creation save them immediately. SHA-256 hashed at rest.
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
- Legacy API keys (`pvk_...`) from config.yaml still work at runtime but the `vault keys` CLI commands have been removed. Use `vault tokens` for all new keys.
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
 
@@ -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(9);
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
+ }