@openparachute/vault 0.4.4-rc.11 → 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/core/src/core.test.ts +116 -0
- package/core/src/mcp.ts +15 -5
- package/package.json +1 -1
- package/src/routes.ts +57 -3
- package/src/vault.test.ts +74 -0
package/core/src/core.test.ts
CHANGED
|
@@ -3796,6 +3796,122 @@ describe("schema validation (tags.fields)", async () => {
|
|
|
3796
3796
|
});
|
|
3797
3797
|
});
|
|
3798
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
|
+
});
|
|
3913
|
+
});
|
|
3914
|
+
|
|
3799
3915
|
// ---------------------------------------------------------------------------
|
|
3800
3916
|
// Schema inheritance via parent_names + `_default` universal parent — vault#270
|
|
3801
3917
|
// ---------------------------------------------------------------------------
|
package/core/src/mcp.ts
CHANGED
|
@@ -614,11 +614,21 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
614
614
|
// - `item.content` / `item.path` / `item.tags` /
|
|
615
615
|
// `item.metadata` / `item.created_at` → forwarded.
|
|
616
616
|
// - `if_updated_at` / `force` / `content_edit` /
|
|
617
|
-
// `append` / `prepend`
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
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.
|
|
622
632
|
const idOrPath = item.id as string;
|
|
623
633
|
// Heuristic: if `path` isn't set AND the `id` looks like a
|
|
624
634
|
// path (contains "/" or doesn't match a typical opaque-id
|
package/package.json
CHANGED
package/src/routes.ts
CHANGED
|
@@ -854,15 +854,64 @@ export async function handleNotes(
|
|
|
854
854
|
// PATCH /notes/:idOrPath — update (content, path, metadata, tags, links)
|
|
855
855
|
if (method === "PATCH") {
|
|
856
856
|
try {
|
|
857
|
+
// Body is parsed up front so the `if_missing: "create"` branch
|
|
858
|
+
// (vault#309) can fire when the note doesn't exist. Pre-#309
|
|
859
|
+
// shape parsed the body only after the not-found check.
|
|
860
|
+
const body = await req.json() as any;
|
|
857
861
|
const note = await resolveNote(store, idOrPath);
|
|
858
|
-
if (!note)
|
|
862
|
+
if (!note) {
|
|
863
|
+
// vault#309 — `if_missing: "create"` turns this PATCH into a
|
|
864
|
+
// create using the same payload. POST /notes is the canonical
|
|
865
|
+
// create surface, but supporting it inline on PATCH lets sync
|
|
866
|
+
// loops use one endpoint for both branches. Returns the
|
|
867
|
+
// created note with `created: true` and HTTP 200 (not 201 —
|
|
868
|
+
// the response shape is "the note as it now exists," same
|
|
869
|
+
// contract as the update path; `created: true` carries the
|
|
870
|
+
// signal).
|
|
871
|
+
if (body.if_missing === "create") {
|
|
872
|
+
const idOrPathStr = idOrPath;
|
|
873
|
+
// Tag-scope check on the create branch: the prospective
|
|
874
|
+
// tag set must still satisfy scope. Compute from body.tags
|
|
875
|
+
// (create-shape: an array, not the {add,remove} dict).
|
|
876
|
+
const tagsArr = Array.isArray(body.tags)
|
|
877
|
+
? body.tags as string[]
|
|
878
|
+
: Array.isArray(body.tags?.add) ? body.tags.add as string[] : [];
|
|
879
|
+
if (tagScope.allowed && !tagsWithinScope(tagsArr, tagScope.allowed, tagScope.raw)) {
|
|
880
|
+
return tagScopeForbidden(tagScope.raw ?? []);
|
|
881
|
+
}
|
|
882
|
+
const idLooksLikePath = idOrPathStr.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPathStr);
|
|
883
|
+
const explicitPath = typeof body.path === "string" ? body.path as string : undefined;
|
|
884
|
+
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
885
|
+
...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
886
|
+
...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
|
|
887
|
+
...(body.metadata !== undefined ? { metadata: body.metadata as Record<string, unknown> } : {}),
|
|
888
|
+
...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
|
|
889
|
+
...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
|
|
890
|
+
};
|
|
891
|
+
const content = (body.content as string | undefined) ?? "";
|
|
892
|
+
const created = await store.createNote(content, createOpts);
|
|
893
|
+
if (tagsArr.length > 0) {
|
|
894
|
+
await applySchemaDefaults(store, db, [created.id], tagsArr);
|
|
895
|
+
}
|
|
896
|
+
const final = await store.getNote(created.id);
|
|
897
|
+
if (!final) return json({ error: "Note disappeared" }, 500);
|
|
898
|
+
const validated = attachValidationStatus(store, db, final);
|
|
899
|
+
const includeContentResp = body.include_content !== false;
|
|
900
|
+
if (includeContentResp) return json({ ...validated, created: true });
|
|
901
|
+
const lean: any = toNoteIndex(validated);
|
|
902
|
+
const vs = (validated as any).validation_status;
|
|
903
|
+
if (vs !== undefined) lean.validation_status = vs;
|
|
904
|
+
lean.created = true;
|
|
905
|
+
return json(lean);
|
|
906
|
+
}
|
|
907
|
+
throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
908
|
+
}
|
|
859
909
|
// Tag-scope: existing note must be in scope. Mirror the read-side
|
|
860
910
|
// 404-not-403 stance — a token can't see (and therefore can't
|
|
861
911
|
// discover-then-modify) notes outside its allowlist.
|
|
862
912
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
863
913
|
throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
864
914
|
}
|
|
865
|
-
const body = await req.json() as any;
|
|
866
915
|
// Tag-scope: post-update tag set must still satisfy scope. Compute
|
|
867
916
|
// the prospective tag set (existing − removed + added) and reject
|
|
868
917
|
// before any write if it would drift outside the allowlist. This
|
|
@@ -1035,10 +1084,15 @@ export async function handleNotes(
|
|
|
1035
1084
|
if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
|
|
1036
1085
|
const validated = attachValidationStatus(store, db, updatedNote);
|
|
1037
1086
|
const includeContentResp = body.include_content !== false;
|
|
1038
|
-
|
|
1087
|
+
// `created: false` is appended to every update-path response so
|
|
1088
|
+
// sync-loop callers using `if_missing: "create"` can distinguish
|
|
1089
|
+
// the two branches without a separate query (vault#309). The
|
|
1090
|
+
// create-branch response above carries `created: true`.
|
|
1091
|
+
if (includeContentResp) return json({ ...validated, created: false });
|
|
1039
1092
|
const lean: any = toNoteIndex(validated);
|
|
1040
1093
|
const vs = (validated as any).validation_status;
|
|
1041
1094
|
if (vs !== undefined) lean.validation_status = vs;
|
|
1095
|
+
lean.created = false;
|
|
1042
1096
|
return json(lean);
|
|
1043
1097
|
} catch (e: any) {
|
|
1044
1098
|
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
package/src/vault.test.ts
CHANGED
|
@@ -2926,6 +2926,80 @@ describe("HTTP POST /notes — validation_status attachment (vault#287)", async
|
|
|
2926
2926
|
});
|
|
2927
2927
|
});
|
|
2928
2928
|
|
|
2929
|
+
// vault#309 — HTTP PATCH /notes/:id with if_missing: "create" mirrors
|
|
2930
|
+
// the MCP update-note path. Sync loops (Gitcoin Brain et al) use this
|
|
2931
|
+
// for idempotent upsert without a separate query-first round trip.
|
|
2932
|
+
|
|
2933
|
+
describe("HTTP PATCH /notes/:idOrPath if_missing=create (vault#309)", async () => {
|
|
2934
|
+
test("missing note + if_missing=create creates and returns created: true", async () => {
|
|
2935
|
+
const res = await handleNotes(
|
|
2936
|
+
mkReq("PATCH", `/notes/${encodeURIComponent("Inbox/m309a")}`, {
|
|
2937
|
+
content: "agenda body",
|
|
2938
|
+
tags: ["meeting309"],
|
|
2939
|
+
metadata: { priority: "high" },
|
|
2940
|
+
if_missing: "create",
|
|
2941
|
+
}),
|
|
2942
|
+
store,
|
|
2943
|
+
`/${encodeURIComponent("Inbox/m309a")}`,
|
|
2944
|
+
);
|
|
2945
|
+
expect(res.status).toBe(200);
|
|
2946
|
+
const body = await res.json() as any;
|
|
2947
|
+
expect(body.created).toBe(true);
|
|
2948
|
+
expect(body.path).toBe("Inbox/m309a");
|
|
2949
|
+
expect(body.content).toBe("agenda body");
|
|
2950
|
+
expect(body.tags).toContain("meeting309");
|
|
2951
|
+
expect(body.metadata.priority).toBe("high");
|
|
2952
|
+
// And the row landed.
|
|
2953
|
+
expect(await store.getNoteByPath("Inbox/m309a")).not.toBeNull();
|
|
2954
|
+
});
|
|
2955
|
+
|
|
2956
|
+
test("existing note + if_missing=create updates and returns created: false", async () => {
|
|
2957
|
+
await store.createNote("original", { path: "m309b", metadata: { v: 1 } });
|
|
2958
|
+
const res = await handleNotes(
|
|
2959
|
+
mkReq("PATCH", "/notes/m309b", {
|
|
2960
|
+
content: "updated body",
|
|
2961
|
+
metadata: { v: 2 },
|
|
2962
|
+
if_missing: "create",
|
|
2963
|
+
force: true,
|
|
2964
|
+
}),
|
|
2965
|
+
store,
|
|
2966
|
+
"/m309b",
|
|
2967
|
+
);
|
|
2968
|
+
expect(res.status).toBe(200);
|
|
2969
|
+
const body = await res.json() as any;
|
|
2970
|
+
expect(body.created).toBe(false);
|
|
2971
|
+
expect(body.content).toBe("updated body");
|
|
2972
|
+
expect(body.metadata.v).toBe(2);
|
|
2973
|
+
});
|
|
2974
|
+
|
|
2975
|
+
test("missing note without if_missing returns 404 (back-compat)", async () => {
|
|
2976
|
+
const res = await handleNotes(
|
|
2977
|
+
mkReq("PATCH", "/notes/m309c-nope", {
|
|
2978
|
+
content: "x",
|
|
2979
|
+
force: true,
|
|
2980
|
+
}),
|
|
2981
|
+
store,
|
|
2982
|
+
"/m309c-nope",
|
|
2983
|
+
);
|
|
2984
|
+
expect(res.status).toBe(404);
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2987
|
+
test("regular update path now also carries created: false (response shape extended)", async () => {
|
|
2988
|
+
await store.createNote("body", { path: "m309d" });
|
|
2989
|
+
const res = await handleNotes(
|
|
2990
|
+
mkReq("PATCH", "/notes/m309d", {
|
|
2991
|
+
content: "new body",
|
|
2992
|
+
force: true,
|
|
2993
|
+
}),
|
|
2994
|
+
store,
|
|
2995
|
+
"/m309d",
|
|
2996
|
+
);
|
|
2997
|
+
expect(res.status).toBe(200);
|
|
2998
|
+
const body = await res.json() as any;
|
|
2999
|
+
expect(body.created).toBe(false);
|
|
3000
|
+
});
|
|
3001
|
+
});
|
|
3002
|
+
|
|
2929
3003
|
describe("HTTP /tags", async () => {
|
|
2930
3004
|
test("GET /tags lists all tags", async () => {
|
|
2931
3005
|
await store.createNote("A", { tags: ["daily"] });
|