@openparachute/vault 0.4.3 → 0.4.4-rc.12
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 +58 -2
- package/core/src/core.test.ts +232 -0
- package/core/src/mcp.ts +104 -4
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +19 -1
- package/core/src/store.ts +13 -0
- package/core/src/types.ts +15 -0
- package/package.json +1 -1
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/routes.ts +75 -5
- package/src/vault.test.ts +215 -0
package/README.md
CHANGED
|
@@ -139,7 +139,7 @@ Password and 2FA secrets live in `~/.parachute/vault/config.yaml` at mode 0600 (
|
|
|
139
139
|
|
|
140
140
|
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.
|
|
141
141
|
|
|
142
|
-
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,
|
|
142
|
+
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.
|
|
143
143
|
|
|
144
144
|
### Claude Desktop (OAuth)
|
|
145
145
|
|
|
@@ -190,7 +190,13 @@ parachute-vault uninstall --yes --wipe # scripted destructive wipe (prints a
|
|
|
190
190
|
parachute-vault create work # create a new vault
|
|
191
191
|
parachute-vault list # list all vaults (alias: `ls`)
|
|
192
192
|
parachute-vault remove work --yes # delete a vault (alias: `rm`)
|
|
193
|
-
parachute-vault mcp-install # (re)write the
|
|
193
|
+
parachute-vault mcp-install # (re)write the MCP client entry; defaults to --mint (hub-issued JWT) at local scope
|
|
194
|
+
parachute-vault mcp-install --token <t> # paste an existing bearer instead of minting
|
|
195
|
+
parachute-vault mcp-install --legacy-pat # mint a vault-DB pvt_* (self-hosted-without-hub)
|
|
196
|
+
parachute-vault mcp-install --install-scope user # write ~/.claude.json top-level (every project)
|
|
197
|
+
parachute-vault mcp-install --install-scope local # write ~/.claude.json projects[<cwd>] (this directory only — default)
|
|
198
|
+
parachute-vault mcp-install --install-scope project # write ./.mcp.json (checked into the repo)
|
|
199
|
+
parachute-vault mcp-install --vault work # target the "work" vault (keyed as parachute-vault-work)
|
|
194
200
|
|
|
195
201
|
# OAuth — owner password + 2FA
|
|
196
202
|
parachute-vault set-password # set/change the owner password (OAuth consent page)
|
|
@@ -565,6 +571,56 @@ Atomic at the SQL layer: two concurrent appends both land in some order, never c
|
|
|
565
571
|
|
|
566
572
|
The shortest path to a public HTTPS URL for a vault you control — useful for SSG rebuilds running on GitHub Actions, Vercel, or any runner that isn't on your tailnet. See [Remote access via Tailscale Funnel](#remote-access-via-tailscale-funnel) below for the full setup.
|
|
567
573
|
|
|
574
|
+
### Install vault MCP into a client config
|
|
575
|
+
|
|
576
|
+
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.
|
|
577
|
+
|
|
578
|
+
#### Install scopes
|
|
579
|
+
|
|
580
|
+
`--install-scope` accepts three values, matching Claude Code's own `claude mcp add --scope`:
|
|
581
|
+
|
|
582
|
+
| Scope | Where the entry lives | Visibility |
|
|
583
|
+
|---|---|---|
|
|
584
|
+
| `local` *(default)* | `~/.claude.json` under `projects[<absolute-cwd>].mcpServers` | private to your machine, scoped to this directory |
|
|
585
|
+
| `user` | `~/.claude.json` top-level `mcpServers` | every project, every directory on this machine |
|
|
586
|
+
| `project` | `<cwd>/.mcp.json` | checked into the repo, shared with anyone who clones it |
|
|
587
|
+
|
|
588
|
+
The default is **`local`** — Claude Code only loads the entry when launched from the directory you ran the install from. Pick `user` for a global install (every project sees the vault); pick `project` to commit the entry to the repo so collaborators get it on `git pull`.
|
|
589
|
+
|
|
590
|
+
#### Cookbook
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
# 1. Default — mint a scope-narrow hub JWT (vault:<vault>:read) via your
|
|
594
|
+
# operator token; write it into ~/.claude.json under projects[<cwd>]
|
|
595
|
+
# (local scope, this directory only). Requires:
|
|
596
|
+
# - ~/.parachute/operator.token (run `parachute auth rotate-operator` if missing)
|
|
597
|
+
# - PARACHUTE_HUB_ORIGIN set OR an active `parachute expose` session
|
|
598
|
+
parachute-vault mcp-install --mint
|
|
599
|
+
|
|
600
|
+
# 2. Global install — write the entry at top-level ~/.claude.json so
|
|
601
|
+
# Claude Code loads it from every project, every directory.
|
|
602
|
+
parachute-vault mcp-install --install-scope user
|
|
603
|
+
|
|
604
|
+
# 3. Project-level install — write ./.mcp.json (committed to the repo,
|
|
605
|
+
# shared with the team). Pair with --scope vault:write when the
|
|
606
|
+
# project actually mutates the vault.
|
|
607
|
+
parachute-vault mcp-install --install-scope project --scope vault:write
|
|
608
|
+
|
|
609
|
+
# 4. Paste an existing token — useful when you already have a pvt_* in hand
|
|
610
|
+
# or want to re-use a long-lived bearer from another machine. Skips the
|
|
611
|
+
# mint step entirely.
|
|
612
|
+
parachute-vault mcp-install --token pvt_abc123...
|
|
613
|
+
|
|
614
|
+
# 5. Self-hosted-without-hub — mint a vault-DB pvt_* token (the legacy
|
|
615
|
+
# path; preserved so deployments without a hub keep working). Prints a
|
|
616
|
+
# deprecation notice.
|
|
617
|
+
parachute-vault mcp-install --legacy-pat
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
**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.
|
|
621
|
+
|
|
622
|
+
**Doctor.** `parachute-vault doctor` checks `~/.claude.json` (both top-level and `projects[<cwd>]`) and `./.mcp.json`, and reports which one holds the entry, plus port-match and reachability of the MCP URL.
|
|
623
|
+
|
|
568
624
|
## Data model
|
|
569
625
|
|
|
570
626
|
```
|
package/core/src/core.test.ts
CHANGED
|
@@ -3678,6 +3678,238 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
3678
3678
|
const result = await create.execute({ content: "x", tags: ["task"] }) as any;
|
|
3679
3679
|
expect(result.validation_status).toBeUndefined();
|
|
3680
3680
|
});
|
|
3681
|
+
|
|
3682
|
+
// vault#310 — integer type validation. JSON has no separate integer
|
|
3683
|
+
// type, so a JSON number with zero fractional part (`5`, `5.0`) must
|
|
3684
|
+
// pass an `integer`-typed field. Pre-fix, the validator had no
|
|
3685
|
+
// `"integer"` case at all — falling through the switch returned
|
|
3686
|
+
// undefined and every integer-typed field warned `type_mismatch` on
|
|
3687
|
+
// legitimate values. Gitcoin Brain's drift detector emits JSON for
|
|
3688
|
+
// diffs; every `kpi: 3` triggered the false-positive and buried the
|
|
3689
|
+
// real warnings.
|
|
3690
|
+
|
|
3691
|
+
it("integer-typed field: JSON integer (5) passes (vault#310)", async () => {
|
|
3692
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3693
|
+
const tools = generateMcpTools(store);
|
|
3694
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3695
|
+
const result = await create.execute({
|
|
3696
|
+
content: "x",
|
|
3697
|
+
tags: ["kpi"],
|
|
3698
|
+
metadata: { count: 5 },
|
|
3699
|
+
}) as any;
|
|
3700
|
+
expect(result.validation_status?.warnings ?? []).toEqual([]);
|
|
3701
|
+
});
|
|
3702
|
+
|
|
3703
|
+
it("integer-typed field: JSON `5.0` (zero-fractional) passes (vault#310)", async () => {
|
|
3704
|
+
// 5.0 is the canonical Gitcoin shape — JSON.parse decodes the
|
|
3705
|
+
// emitted JSON number as a JS Number; the JS Number for `5.0` is
|
|
3706
|
+
// identical to `5` so Number.isInteger reports true. This is the
|
|
3707
|
+
// load-bearing assertion for the Gitcoin drift-detector use case.
|
|
3708
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3709
|
+
const tools = generateMcpTools(store);
|
|
3710
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3711
|
+
const result = await create.execute({
|
|
3712
|
+
content: "x",
|
|
3713
|
+
tags: ["kpi"],
|
|
3714
|
+
metadata: { count: 5.0 },
|
|
3715
|
+
}) as any;
|
|
3716
|
+
expect(result.validation_status?.warnings ?? []).toEqual([]);
|
|
3717
|
+
});
|
|
3718
|
+
|
|
3719
|
+
it("integer-typed field: JSON `5.5` (non-zero fractional) warns type_mismatch (vault#310)", async () => {
|
|
3720
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3721
|
+
const tools = generateMcpTools(store);
|
|
3722
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3723
|
+
const result = await create.execute({
|
|
3724
|
+
content: "x",
|
|
3725
|
+
tags: ["kpi"],
|
|
3726
|
+
metadata: { count: 5.5 },
|
|
3727
|
+
}) as any;
|
|
3728
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3729
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3730
|
+
expect(result.validation_status.warnings[0].field).toBe("count");
|
|
3731
|
+
});
|
|
3732
|
+
|
|
3733
|
+
it("integer-typed field: string `\"5\"` warns type_mismatch (no string→number coercion) (vault#310)", async () => {
|
|
3734
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3735
|
+
const tools = generateMcpTools(store);
|
|
3736
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3737
|
+
const result = await create.execute({
|
|
3738
|
+
content: "x",
|
|
3739
|
+
tags: ["kpi"],
|
|
3740
|
+
metadata: { count: "5" },
|
|
3741
|
+
}) as any;
|
|
3742
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3743
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3744
|
+
});
|
|
3745
|
+
|
|
3746
|
+
it("integer-typed field: edge `5.0000000000001` warns type_mismatch (vault#310)", async () => {
|
|
3747
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3748
|
+
const tools = generateMcpTools(store);
|
|
3749
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3750
|
+
const result = await create.execute({
|
|
3751
|
+
content: "x",
|
|
3752
|
+
tags: ["kpi"],
|
|
3753
|
+
metadata: { count: 5.0000000000001 },
|
|
3754
|
+
}) as any;
|
|
3755
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3756
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3757
|
+
});
|
|
3758
|
+
|
|
3759
|
+
it("integer-typed field: boolean warns type_mismatch (vault#310)", async () => {
|
|
3760
|
+
// Pin that boolean is rejected (Number.isInteger(true) returns
|
|
3761
|
+
// false, but extra coverage in case anyone "improves" the check
|
|
3762
|
+
// with a looser predicate later).
|
|
3763
|
+
await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
|
|
3764
|
+
const tools = generateMcpTools(store);
|
|
3765
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3766
|
+
const result = await create.execute({
|
|
3767
|
+
content: "x",
|
|
3768
|
+
tags: ["kpi"],
|
|
3769
|
+
metadata: { count: true },
|
|
3770
|
+
}) as any;
|
|
3771
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3772
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3773
|
+
});
|
|
3774
|
+
|
|
3775
|
+
// Note on NaN/Infinity: those values pass through
|
|
3776
|
+
// JSON.stringify as `null`, then the validator's null short-circuit
|
|
3777
|
+
// (schema-defaults.ts:327 — "value === null → skip") filters them out
|
|
3778
|
+
// before reaching the type check. We can't observe them in
|
|
3779
|
+
// validation_status from this layer; a dedicated unit test against
|
|
3780
|
+
// `valueMatchesType` would catch the case at the inner boundary.
|
|
3781
|
+
// Pinned at the next layer down:
|
|
3782
|
+
|
|
3783
|
+
it("valueMatchesType('integer', ...) rejects NaN / Infinity (vault#310)", async () => {
|
|
3784
|
+
// Reach the unexported helper indirectly via validateNote on a
|
|
3785
|
+
// hand-built resolved-schemas + metadata where the value is the
|
|
3786
|
+
// actual NaN/Infinity (no JSON round-trip).
|
|
3787
|
+
const { validateNote, loadSchemaConfig } = await import("./schema-defaults.js");
|
|
3788
|
+
// Seed via the public surface, then load the resolved schemas
|
|
3789
|
+
// snapshot.
|
|
3790
|
+
await store.upsertTagSchema("k", { fields: { c: { type: "integer" } } });
|
|
3791
|
+
const resolved = loadSchemaConfig((store as any).db);
|
|
3792
|
+
expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.NaN } })?.warnings[0]?.reason)
|
|
3793
|
+
.toBe("type_mismatch");
|
|
3794
|
+
expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.POSITIVE_INFINITY } })?.warnings[0]?.reason)
|
|
3795
|
+
.toBe("type_mismatch");
|
|
3796
|
+
});
|
|
3797
|
+
});
|
|
3798
|
+
|
|
3799
|
+
// ---------------------------------------------------------------------------
|
|
3800
|
+
// update-note `if_missing: "create"` — idempotent upsert (vault#309)
|
|
3801
|
+
// ---------------------------------------------------------------------------
|
|
3802
|
+
|
|
3803
|
+
describe("update-note if_missing=create (vault#309)", async () => {
|
|
3804
|
+
let store: SqliteStore;
|
|
3805
|
+
beforeEach(() => {
|
|
3806
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
3807
|
+
});
|
|
3808
|
+
|
|
3809
|
+
it("creates the note when missing + carries created: true", async () => {
|
|
3810
|
+
const tools = generateMcpTools(store);
|
|
3811
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
3812
|
+
const result = await update.execute({
|
|
3813
|
+
id: "Inbox/2026-05-13-meeting",
|
|
3814
|
+
content: "agenda body",
|
|
3815
|
+
tags: ["meeting"],
|
|
3816
|
+
metadata: { priority: "high" },
|
|
3817
|
+
if_missing: "create",
|
|
3818
|
+
}) as any;
|
|
3819
|
+
expect(result.created).toBe(true);
|
|
3820
|
+
expect(result.path).toBe("Inbox/2026-05-13-meeting");
|
|
3821
|
+
expect(result.content).toBe("agenda body");
|
|
3822
|
+
expect(result.tags).toContain("meeting");
|
|
3823
|
+
expect(result.metadata?.priority).toBe("high");
|
|
3824
|
+
|
|
3825
|
+
// And the row landed.
|
|
3826
|
+
const fetched = await store.getNoteByPath("Inbox/2026-05-13-meeting");
|
|
3827
|
+
expect(fetched).not.toBeNull();
|
|
3828
|
+
});
|
|
3829
|
+
|
|
3830
|
+
it("updates the note when present + carries created: false", async () => {
|
|
3831
|
+
await store.createNote("original", { path: "p", metadata: { v: 1 } });
|
|
3832
|
+
const tools = generateMcpTools(store);
|
|
3833
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
3834
|
+
const result = await update.execute({
|
|
3835
|
+
id: "p",
|
|
3836
|
+
content: "updated body",
|
|
3837
|
+
metadata: { v: 2 },
|
|
3838
|
+
if_missing: "create",
|
|
3839
|
+
force: true, // bypass OC since this is an unconditional update
|
|
3840
|
+
}) as any;
|
|
3841
|
+
expect(result.created).toBe(false);
|
|
3842
|
+
expect(result.content).toBe("updated body");
|
|
3843
|
+
expect(result.metadata?.v).toBe(2);
|
|
3844
|
+
});
|
|
3845
|
+
|
|
3846
|
+
it("without if_missing, missing note errors (current behavior — back-compat)", async () => {
|
|
3847
|
+
const tools = generateMcpTools(store);
|
|
3848
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
3849
|
+
await expect(update.execute({
|
|
3850
|
+
id: "nope",
|
|
3851
|
+
content: "x",
|
|
3852
|
+
force: true,
|
|
3853
|
+
})).rejects.toThrow(/Note not found/);
|
|
3854
|
+
});
|
|
3855
|
+
|
|
3856
|
+
it("create branch applies tag-schema defaults when the new tag declares fields", async () => {
|
|
3857
|
+
await store.upsertTagSchema("task", {
|
|
3858
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
3859
|
+
});
|
|
3860
|
+
const tools = generateMcpTools(store);
|
|
3861
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
3862
|
+
const result = await update.execute({
|
|
3863
|
+
id: "Inbox/new-task",
|
|
3864
|
+
content: "do the thing",
|
|
3865
|
+
tags: ["task"],
|
|
3866
|
+
if_missing: "create",
|
|
3867
|
+
}) as any;
|
|
3868
|
+
expect(result.created).toBe(true);
|
|
3869
|
+
// Schema defaults populated metadata.priority on insert.
|
|
3870
|
+
expect(result.metadata?.priority).toBeDefined();
|
|
3871
|
+
});
|
|
3872
|
+
|
|
3873
|
+
it("create branch surfaces validation warnings just like create-note", async () => {
|
|
3874
|
+
await store.upsertTagSchema("task", {
|
|
3875
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
3876
|
+
});
|
|
3877
|
+
const tools = generateMcpTools(store);
|
|
3878
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
3879
|
+
const result = await update.execute({
|
|
3880
|
+
id: "Inbox/bad-task",
|
|
3881
|
+
content: "x",
|
|
3882
|
+
tags: ["task"],
|
|
3883
|
+
metadata: { priority: "ULTRA" },
|
|
3884
|
+
if_missing: "create",
|
|
3885
|
+
}) as any;
|
|
3886
|
+
expect(result.created).toBe(true);
|
|
3887
|
+
expect(result.validation_status?.warnings?.[0]?.reason).toBe("enum_mismatch");
|
|
3888
|
+
});
|
|
3889
|
+
|
|
3890
|
+
it("idempotent: second call with same id + same content updates without error", async () => {
|
|
3891
|
+
const tools = generateMcpTools(store);
|
|
3892
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
3893
|
+
const first = await update.execute({
|
|
3894
|
+
id: "Inbox/sync-target",
|
|
3895
|
+
content: "v1",
|
|
3896
|
+
if_missing: "create",
|
|
3897
|
+
}) as any;
|
|
3898
|
+
expect(first.created).toBe(true);
|
|
3899
|
+
|
|
3900
|
+
const second = await update.execute({
|
|
3901
|
+
id: "Inbox/sync-target",
|
|
3902
|
+
content: "v2",
|
|
3903
|
+
if_missing: "create",
|
|
3904
|
+
force: true,
|
|
3905
|
+
}) as any;
|
|
3906
|
+
expect(second.created).toBe(false);
|
|
3907
|
+
expect(second.content).toBe("v2");
|
|
3908
|
+
|
|
3909
|
+
// Only one row exists.
|
|
3910
|
+
const all = await store.queryNotes({ limit: 100 });
|
|
3911
|
+
expect(all.filter((n) => n.path === "Inbox/sync-target")).toHaveLength(1);
|
|
3912
|
+
});
|
|
3681
3913
|
});
|
|
3682
3914
|
|
|
3683
3915
|
// ---------------------------------------------------------------------------
|
package/core/src/mcp.ts
CHANGED
|
@@ -469,6 +469,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
469
469
|
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
470
470
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
471
471
|
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
|
|
472
|
+
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
472
473
|
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.`,
|
|
473
474
|
inputSchema: {
|
|
474
475
|
type: "object",
|
|
@@ -491,6 +492,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
491
492
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
492
493
|
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
493
494
|
force: { type: "boolean", description: "Override the required `if_updated_at` check and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe." },
|
|
495
|
+
if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
|
|
494
496
|
tags: {
|
|
495
497
|
type: "object",
|
|
496
498
|
properties: {
|
|
@@ -554,6 +556,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
554
556
|
created_at: { type: "string" },
|
|
555
557
|
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
|
|
556
558
|
force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
|
|
559
|
+
if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
|
|
557
560
|
tags: { type: "object" },
|
|
558
561
|
links: { type: "object" },
|
|
559
562
|
},
|
|
@@ -572,6 +575,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
572
575
|
}
|
|
573
576
|
|
|
574
577
|
const updated: Note[] = [];
|
|
578
|
+
// Track which note IDs were freshly created via `if_missing: "create"`
|
|
579
|
+
// so the response can carry `created: true|false` per-note. The
|
|
580
|
+
// sync-loop caller (Gitcoin Brain et al) reads this to know which
|
|
581
|
+
// path fired without doing a separate query. vault#309.
|
|
582
|
+
const createdIds = new Set<string>();
|
|
575
583
|
// Wrap multi-item batches in a SQLite transaction so any mid-batch
|
|
576
584
|
// failure (precondition error, content_edit miss, ConflictError, …)
|
|
577
585
|
// rolls back every prior mutation in the batch — see #236.
|
|
@@ -581,7 +589,87 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
581
589
|
if (batched) db.exec("BEGIN");
|
|
582
590
|
try {
|
|
583
591
|
for (const item of items) {
|
|
584
|
-
|
|
592
|
+
// Try ID-then-path resolve. If not found AND
|
|
593
|
+
// `if_missing: "create"` is set, fall through to the create
|
|
594
|
+
// branch using this same item's payload. Otherwise mirror the
|
|
595
|
+
// existing `requireNote` behavior (throw "Note not found").
|
|
596
|
+
// vault#309.
|
|
597
|
+
const resolved = resolveNote(db, item.id as string);
|
|
598
|
+
if (!resolved) {
|
|
599
|
+
if (item.if_missing === "create") {
|
|
600
|
+
// Treat the update payload as a create payload. Minimum:
|
|
601
|
+
// content OR a path/id (something the createNote-empty-row
|
|
602
|
+
// invariant accepts). createNote enforces its own
|
|
603
|
+
// not-both-empty check — we leave that to the Store and
|
|
604
|
+
// surface any error to the caller verbatim.
|
|
605
|
+
//
|
|
606
|
+
// Field mapping (mirrors the create-note tool surface):
|
|
607
|
+
// - `item.id` → both the note's `id` AND a fallback
|
|
608
|
+
// `path` when `item.path` isn't set. Treating `id` as
|
|
609
|
+
// the path-or-id lookup key matches Gitcoin's nightly
|
|
610
|
+
// sync shape where the canonical key is a path string
|
|
611
|
+
// like "Inbox/2026-05-13-meeting". If the caller
|
|
612
|
+
// supplied an opaque ULID as `id` and no `path`, we
|
|
613
|
+
// still create with that as `id` (path stays null).
|
|
614
|
+
// - `item.content` / `item.path` / `item.tags` /
|
|
615
|
+
// `item.metadata` / `item.created_at` → forwarded.
|
|
616
|
+
// - `if_updated_at` / `force` / `content_edit` /
|
|
617
|
+
// `append` / `prepend` are update-only — silently
|
|
618
|
+
// ignored on the create branch. (Content-edit on a
|
|
619
|
+
// non-existent note is a nonsense combination; the
|
|
620
|
+
// caller's intent on missing-note is "create the
|
|
621
|
+
// row", not "patch in this section".)
|
|
622
|
+
// - `links.remove` is also ignored on create (nothing
|
|
623
|
+
// to remove on a fresh note).
|
|
624
|
+
// - `links.add` IS applied below — the drift sync can
|
|
625
|
+
// declare typed links at upsert time and have them
|
|
626
|
+
// materialize alongside the create. See vault#320
|
|
627
|
+
// reviewer F1 — the prior comment claimed all
|
|
628
|
+
// `links` were ignored, but `links.add` was already
|
|
629
|
+
// processed and used by Gitcoin's sync; the
|
|
630
|
+
// misleading wording is fixed here so a future
|
|
631
|
+
// reader doesn't trust it and break the workflow.
|
|
632
|
+
const idOrPath = item.id as string;
|
|
633
|
+
// Heuristic: if `path` isn't set AND the `id` looks like a
|
|
634
|
+
// path (contains "/" or doesn't match a typical opaque-id
|
|
635
|
+
// shape), use it as the path too. Otherwise treat it as a
|
|
636
|
+
// pure id. The shared `id` field for update is ID-or-path
|
|
637
|
+
// already (see `resolveNote`), so this preserves the
|
|
638
|
+
// caller's intent.
|
|
639
|
+
const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
|
|
640
|
+
const explicitPath = typeof item.path === "string" ? item.path as string : undefined;
|
|
641
|
+
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
642
|
+
...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
643
|
+
...(item.tags && Array.isArray((item.tags as any).add)
|
|
644
|
+
? { tags: (item.tags as any).add as string[] }
|
|
645
|
+
: Array.isArray(item.tags)
|
|
646
|
+
? { tags: item.tags as string[] }
|
|
647
|
+
: {}),
|
|
648
|
+
...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
|
|
649
|
+
...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
|
|
650
|
+
};
|
|
651
|
+
const content = (item.content as string | undefined) ?? "";
|
|
652
|
+
const created = await store.createNote(content, createOpts);
|
|
653
|
+
await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
|
|
654
|
+
// Apply links.add if the caller declared any.
|
|
655
|
+
const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
656
|
+
if (linksAdd) {
|
|
657
|
+
for (const link of linksAdd) {
|
|
658
|
+
const target = resolveNote(db, link.target);
|
|
659
|
+
if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
663
|
+
updated.push(fresh);
|
|
664
|
+
createdIds.add(fresh.id);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
// Fallthrough: not-found + no if_missing → existing error
|
|
668
|
+
// contract. Match `requireNote`'s message shape so existing
|
|
669
|
+
// callers see no behavior change.
|
|
670
|
+
throw new Error(`Note not found: "${item.id}"`);
|
|
671
|
+
}
|
|
672
|
+
const note = resolved;
|
|
585
673
|
|
|
586
674
|
// --- Validate mutual exclusion of content modes ---
|
|
587
675
|
const hasContent = item.content !== undefined;
|
|
@@ -745,14 +833,21 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
745
833
|
// Response shape: full Note (back-compat default) or lean NoteIndex
|
|
746
834
|
// (#285 friction point 2.response — opt-out for callers making
|
|
747
835
|
// frequent small edits to large notes). `validation_status` from
|
|
748
|
-
// `tags.fields` is preserved across either shape.
|
|
836
|
+
// `tags.fields` is preserved across either shape. `created: true|false`
|
|
837
|
+
// (vault#309) is attached to every response so callers using
|
|
838
|
+
// `if_missing: "create"` can tell which branch fired without a
|
|
839
|
+
// separate query. `false` for the (overwhelmingly common) update
|
|
840
|
+
// path; `true` only when this call took the create-on-missing
|
|
841
|
+
// branch.
|
|
749
842
|
const includeContent = params.include_content !== false;
|
|
750
843
|
const final = updated.map((n) => {
|
|
751
844
|
const validated = attachValidationStatus(store, db, n);
|
|
752
|
-
|
|
845
|
+
const created = createdIds.has(n.id);
|
|
846
|
+
if (includeContent) return { ...validated, created } as Note & { created: boolean };
|
|
753
847
|
const lean: any = noteOps.toNoteIndex(validated);
|
|
754
848
|
const vs = (validated as any).validation_status;
|
|
755
849
|
if (vs !== undefined) lean.validation_status = vs;
|
|
850
|
+
lean.created = created;
|
|
756
851
|
return lean;
|
|
757
852
|
});
|
|
758
853
|
return batch ? final : final[0];
|
|
@@ -1127,8 +1222,13 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
|
1127
1222
|
*
|
|
1128
1223
|
* Returns the note unchanged when no tag declares fields, so callers
|
|
1129
1224
|
* without any tag schemas see no behavior change.
|
|
1225
|
+
*
|
|
1226
|
+
* Exported so both transports (MCP `update-note` here, HTTP `PATCH
|
|
1227
|
+
* /api/notes/:id` in `src/routes.ts`) attach the same status field by
|
|
1228
|
+
* the same recipe — see vault#287 for the asymmetry that motivated
|
|
1229
|
+
* exposing it.
|
|
1130
1230
|
*/
|
|
1131
|
-
function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
|
|
1231
|
+
export function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
|
|
1132
1232
|
// Short-circuit cheaply: when no tag declares fields, the resolver
|
|
1133
1233
|
// returns null without us paying a re-read of the note.
|
|
1134
1234
|
const status = store.validateNoteAgainstSchemas({
|