@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.
@@ -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` / `links` are
618
- // update-only — silently ignored on the create branch.
619
- // (Content-edit on a non-existent note is a nonsense
620
- // combination; the caller's intent on missing-note is
621
- // "create the row", not "patch in this section".)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.4-rc.11",
3
+ "version": "0.4.4-rc.12",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
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) throw new NotFoundError(`Note not found: "${idOrPath}"`);
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
- if (includeContentResp) return json(validated);
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"] });