@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/src/routes.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import type { Store, Note } from "../core/src/types.ts";
15
15
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
16
  import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
17
+ import { attachValidationStatus } from "../core/src/mcp.ts";
17
18
  import * as linkOps from "../core/src/links.ts";
18
19
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
19
20
  import {
@@ -733,7 +734,13 @@ export async function handleNotes(
733
734
  }
734
735
  }
735
736
 
736
- return json(body.notes ? created : created[0], 201);
737
+ // Attach `validation_status` so HTTP create-note matches the MCP
738
+ // surface (vault#287). Mirrors the MCP create-note attach site at
739
+ // `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
740
+ // unchanged when no tag declares fields, so vaults without any tag
741
+ // schemas see no behavior change.
742
+ const final = created.map((n) => attachValidationStatus(store, db, n));
743
+ return json(body.notes ? final : final[0], 201);
737
744
  }
738
745
 
739
746
  return json({ error: "Method not allowed" }, 405);
@@ -847,15 +854,64 @@ export async function handleNotes(
847
854
  // PATCH /notes/:idOrPath — update (content, path, metadata, tags, links)
848
855
  if (method === "PATCH") {
849
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;
850
861
  const note = await resolveNote(store, idOrPath);
851
- 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
+ }
852
909
  // Tag-scope: existing note must be in scope. Mirror the read-side
853
910
  // 404-not-403 stance — a token can't see (and therefore can't
854
911
  // discover-then-modify) notes outside its allowlist.
855
912
  if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
856
913
  throw new NotFoundError(`Note not found: "${idOrPath}"`);
857
914
  }
858
- const body = await req.json() as any;
859
915
  // Tag-scope: post-update tag set must still satisfy scope. Compute
860
916
  // the prospective tag set (existing − removed + added) and reject
861
917
  // before any write if it would drift outside the allowlist. This
@@ -1019,11 +1075,25 @@ export async function handleNotes(
1019
1075
  // Response shape: full Note (back-compat default) or lean NoteIndex
1020
1076
  // (vault#285 friction point 2.response — opt-out for callers making
1021
1077
  // frequent small edits to large notes). Mirror the MCP `update-note`
1022
- // `include_content` knob exactly.
1078
+ // `include_content` knob exactly, *and* `validation_status` attachment
1079
+ // (vault#287) so HTTP and MCP consumers see the same schema-validation
1080
+ // signal. Recipe matches `core/src/mcp.ts:751` — attach to the full
1081
+ // Note first, then carry the field across the lean conversion (since
1082
+ // `toNoteIndex` drops unknown fields).
1023
1083
  const updatedNote = await store.getNote(note.id);
1024
1084
  if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
1085
+ const validated = attachValidationStatus(store, db, updatedNote);
1025
1086
  const includeContentResp = body.include_content !== false;
1026
- return json(includeContentResp ? updatedNote : toNoteIndex(updatedNote));
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 });
1092
+ const lean: any = toNoteIndex(validated);
1093
+ const vs = (validated as any).validation_status;
1094
+ if (vs !== undefined) lean.validation_status = vs;
1095
+ lean.created = false;
1096
+ return json(lean);
1027
1097
  } catch (e: any) {
1028
1098
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
1029
1099
  // Duck-type on `code` rather than `instanceof ConflictError`: this
package/src/vault.test.ts CHANGED
@@ -2783,6 +2783,221 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
2783
2783
  expect(body.id).toBe("big");
2784
2784
  expect(body.path).toBe("big");
2785
2785
  });
2786
+
2787
+ // vault#287 — HTTP must match MCP on validation_status attachment.
2788
+ // Pre-#287 fix: MCP `update-note` attached validation_status; HTTP
2789
+ // PATCH didn't. HTTP consumers using schema-validated vaults had no
2790
+ // way to see schema warnings without re-reading + replaying validation
2791
+ // client-side. These tests pin the symmetry on both response shapes
2792
+ // (`include_content: true` and `false`) and confirm the no-schema
2793
+ // case still returns no validation_status (advisory only — never
2794
+ // forced onto vaults that don't declare fields).
2795
+
2796
+ test("PATCH attaches validation_status with enum_mismatch warning when tag schema is violated", async () => {
2797
+ await store.upsertTagSchema("task287patch", {
2798
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
2799
+ });
2800
+ const note = await store.createNote("body", {
2801
+ id: "p287a",
2802
+ tags: ["task287patch"],
2803
+ metadata: { priority: "high" },
2804
+ });
2805
+ const res = await handleNotes(
2806
+ mkReq("PATCH", "/notes/p287a", {
2807
+ metadata: { priority: "ULTRA" },
2808
+ if_updated_at: note.updatedAt,
2809
+ }),
2810
+ store,
2811
+ "/p287a",
2812
+ );
2813
+ expect(res.status).toBe(200);
2814
+ const body = await res.json() as any;
2815
+ // The write still lands — validation is advisory.
2816
+ expect(body.metadata.priority).toBe("ULTRA");
2817
+ // …but the response carries the warning so the HTTP caller knows.
2818
+ expect(body.validation_status).toBeTruthy();
2819
+ expect(body.validation_status.schemas).toContain("task287patch");
2820
+ expect(body.validation_status.warnings.length).toBeGreaterThan(0);
2821
+ expect(body.validation_status.warnings[0].reason).toBe("enum_mismatch");
2822
+ expect(body.validation_status.warnings[0].field).toBe("priority");
2823
+ });
2824
+
2825
+ test("PATCH preserves validation_status on the lean (include_content: false) response", async () => {
2826
+ await store.upsertTagSchema("task287lean", {
2827
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
2828
+ });
2829
+ const note = await store.createNote("body", {
2830
+ id: "p287b",
2831
+ tags: ["task287lean"],
2832
+ metadata: { priority: "high" },
2833
+ });
2834
+ const res = await handleNotes(
2835
+ mkReq("PATCH", "/notes/p287b", {
2836
+ metadata: { priority: "ULTRA" },
2837
+ include_content: false,
2838
+ if_updated_at: note.updatedAt,
2839
+ }),
2840
+ store,
2841
+ "/p287b",
2842
+ );
2843
+ expect(res.status).toBe(200);
2844
+ const body = await res.json() as any;
2845
+ // Lean shape: no `content`, has `byteSize` + `preview`.
2846
+ expect(body.content).toBeUndefined();
2847
+ expect(typeof body.byteSize).toBe("number");
2848
+ // …and validation_status survives the lean conversion.
2849
+ expect(body.validation_status).toBeTruthy();
2850
+ expect(body.validation_status.warnings[0].reason).toBe("enum_mismatch");
2851
+ });
2852
+
2853
+ test("PATCH omits validation_status when no tag on the note declares fields", async () => {
2854
+ // No tag schemas configured for this note — the response should look
2855
+ // exactly like the pre-#287 shape (no validation_status). The behavior-
2856
+ // unchanged guarantee for callers that don't use tag schemas.
2857
+ await store.createNote("body", { id: "p287c", tags: ["plain"] });
2858
+ const res = await handleNotes(
2859
+ mkReq("PATCH", "/notes/p287c", { content: "updated", force: true }),
2860
+ store,
2861
+ "/p287c",
2862
+ );
2863
+ expect(res.status).toBe(200);
2864
+ const body = await res.json() as any;
2865
+ expect(body.content).toBe("updated");
2866
+ expect(body.validation_status).toBeUndefined();
2867
+ });
2868
+ });
2869
+
2870
+ describe("HTTP POST /notes — validation_status attachment (vault#287)", async () => {
2871
+ // Mirror of the PATCH cases for create. The MCP create-note path
2872
+ // attaches validation_status; HTTP POST must match (vault#287).
2873
+
2874
+ test("POST attaches validation_status with type_mismatch warning", async () => {
2875
+ await store.upsertTagSchema("task287post", {
2876
+ fields: { done: { type: "boolean" } },
2877
+ });
2878
+ const res = await handleNotes(
2879
+ mkReq("POST", "/notes", {
2880
+ content: "x",
2881
+ tags: ["task287post"],
2882
+ metadata: { done: "yes" }, // wrong type
2883
+ }),
2884
+ store,
2885
+ "",
2886
+ );
2887
+ expect(res.status).toBe(201);
2888
+ const body = await res.json() as any;
2889
+ expect(body.id).toBeTruthy();
2890
+ expect(body.validation_status).toBeTruthy();
2891
+ expect(body.validation_status.warnings[0].reason).toBe("type_mismatch");
2892
+ expect(body.validation_status.warnings[0].field).toBe("done");
2893
+ });
2894
+
2895
+ test("POST batch attaches validation_status per-note", async () => {
2896
+ await store.upsertTagSchema("task287batch", {
2897
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
2898
+ });
2899
+ const res = await handleNotes(
2900
+ mkReq("POST", "/notes", {
2901
+ notes: [
2902
+ { content: "good", tags: ["task287batch"], metadata: { priority: "high" } },
2903
+ { content: "bad", tags: ["task287batch"], metadata: { priority: "ULTRA" } },
2904
+ ],
2905
+ }),
2906
+ store,
2907
+ "",
2908
+ );
2909
+ expect(res.status).toBe(201);
2910
+ const body = await res.json() as any[];
2911
+ expect(body).toHaveLength(2);
2912
+ expect(body[0].validation_status.warnings).toEqual([]);
2913
+ expect(body[1].validation_status.warnings[0].reason).toBe("enum_mismatch");
2914
+ });
2915
+
2916
+ test("POST omits validation_status when no tag declares fields (back-compat)", async () => {
2917
+ const res = await handleNotes(
2918
+ mkReq("POST", "/notes", { content: "no schema here", tags: ["plain287"] }),
2919
+ store,
2920
+ "",
2921
+ );
2922
+ expect(res.status).toBe(201);
2923
+ const body = await res.json() as any;
2924
+ expect(body.id).toBeTruthy();
2925
+ expect(body.validation_status).toBeUndefined();
2926
+ });
2927
+ });
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
+ });
2786
3001
  });
2787
3002
 
2788
3003
  describe("HTTP /tags", async () => {