@openparachute/vault 0.7.0-rc.8 → 0.7.0-rc.9
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 +482 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp.ts +278 -29
- package/core/src/notes.ts +27 -0
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +23 -0
- package/core/src/types.ts +9 -0
- package/core/src/wikilinks.ts +44 -0
- package/package.json +1 -1
- package/src/mcp-tools.ts +25 -3
- package/src/routes.ts +198 -17
- package/src/vault.test.ts +454 -0
package/core/src/wikilinks.ts
CHANGED
|
@@ -306,6 +306,50 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
|
|
|
306
306
|
return { unresolved, count: total };
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/** One note's dangling outbound link, as surfaced on a note read (vault#555). */
|
|
310
|
+
export interface BrokenLink {
|
|
311
|
+
target: string;
|
|
312
|
+
relationship: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Batch-fetch each note's pending `unresolved_wikilinks` rows — the
|
|
317
|
+
* `include_broken_links` surfacing on `query-notes` / `GET /notes`. ONE
|
|
318
|
+
* query for the whole page (mirrors `getLinksHydratedForNotes`'s batching),
|
|
319
|
+
* not one per note. Returns a map with an entry (possibly `[]`) for every
|
|
320
|
+
* requested id whenever the table exists; when the table has never been
|
|
321
|
+
* created (no note in this vault has ever had a broken link) every id maps
|
|
322
|
+
* to `[]` without a query attempt.
|
|
323
|
+
*/
|
|
324
|
+
export function getUnresolvedLinksForNotes(db: Database, noteIds: string[]): Map<string, BrokenLink[]> {
|
|
325
|
+
const result = new Map<string, BrokenLink[]>(noteIds.map((id) => [id, []]));
|
|
326
|
+
if (noteIds.length === 0) return result;
|
|
327
|
+
|
|
328
|
+
const rows: { source_id: string; target_path: string; relationship: string }[] = [];
|
|
329
|
+
try {
|
|
330
|
+
ensureRelationshipColumn(db);
|
|
331
|
+
for (const chunk of chunkForInClause(noteIds)) {
|
|
332
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
333
|
+
rows.push(...db.prepare(
|
|
334
|
+
`SELECT source_id, target_path, relationship FROM unresolved_wikilinks WHERE source_id IN (${placeholders})`,
|
|
335
|
+
).all(...chunk) as typeof rows);
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
// Table doesn't exist — every id already maps to [] above.
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const row of rows) {
|
|
343
|
+
result.get(row.source_id)?.push({ target: row.target_path, relationship: row.relationship || WIKILINK_REL });
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Single-note convenience wrapper around {@link getUnresolvedLinksForNotes}. */
|
|
349
|
+
export function getUnresolvedLinksForNote(db: Database, noteId: string): BrokenLink[] {
|
|
350
|
+
return getUnresolvedLinksForNotes(db, [noteId]).get(noteId) ?? [];
|
|
351
|
+
}
|
|
352
|
+
|
|
309
353
|
// ---------------------------------------------------------------------------
|
|
310
354
|
// Sync — maintain wikilink-based links for a note
|
|
311
355
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
package/src/mcp-tools.ts
CHANGED
|
@@ -174,6 +174,20 @@ export function generateScopedMcpTools(
|
|
|
174
174
|
)
|
|
175
175
|
: undefined;
|
|
176
176
|
|
|
177
|
+
// Tag-scope guard for `create-note` `if_exists` (vault#555 auth-review
|
|
178
|
+
// must-fix): the core `if_exists` upsert resolves the target path VAULT-WIDE
|
|
179
|
+
// and returns/updates/replaces the found note, so a scoped caller could
|
|
180
|
+
// read/overwrite an out-of-scope note by naming its path. Inject a
|
|
181
|
+
// visibility predicate the core `applyExistingNote` consults on the RESOLVED
|
|
182
|
+
// existing note — covering BOTH the proactive check AND the concurrent-INSERT
|
|
183
|
+
// race backstop (a wrapper-only pre-check misses the latter). Reads from the
|
|
184
|
+
// SAME shared `allowedHolder` the create-note wrapper's `getAllowed()`
|
|
185
|
+
// populates before core's execute runs. Unscoped sessions install no
|
|
186
|
+
// predicate (unchanged). Same closure as `expandVisibility`.
|
|
187
|
+
const ifExistsVisible = scoped
|
|
188
|
+
? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
|
|
189
|
+
: undefined;
|
|
190
|
+
|
|
177
191
|
// Write-attribution (vault#298). Every write through an MCP session arrives
|
|
178
192
|
// on the `mcp` channel — so we REFINE the auth's base `via` (the generic
|
|
179
193
|
// credential class) to `mcp` here, where the path/channel is known. The
|
|
@@ -197,10 +211,11 @@ export function generateScopedMcpTools(
|
|
|
197
211
|
|
|
198
212
|
const tools = generateMcpTools(
|
|
199
213
|
store,
|
|
200
|
-
expandVisibility || nearTraversable || writeContext || strictBypass
|
|
214
|
+
expandVisibility || nearTraversable || ifExistsVisible || writeContext || strictBypass
|
|
201
215
|
? {
|
|
202
216
|
...(expandVisibility ? { expandVisibility } : {}),
|
|
203
217
|
...(nearTraversable ? { nearTraversable } : {}),
|
|
218
|
+
...(ifExistsVisible ? { ifExistsVisible } : {}),
|
|
204
219
|
...(writeContext ? { writeContext } : {}),
|
|
205
220
|
...(strictBypass ? { strictBypass } : {}),
|
|
206
221
|
...(onStrictBypass ? { onStrictBypass } : {}),
|
|
@@ -498,6 +513,13 @@ function applyTagScopeWrappers(
|
|
|
498
513
|
return forbidden("create-note: every note must carry at least one tag in the token's allowlist");
|
|
499
514
|
}
|
|
500
515
|
}
|
|
516
|
+
// `if_exists` scope enforcement (vault#555 auth-review must-fix) is NOT
|
|
517
|
+
// done here as a wrapper pre-check — that would miss core's concurrent-
|
|
518
|
+
// INSERT race backstop. Instead the `ifExistsVisible` predicate wired into
|
|
519
|
+
// generateMcpTools above fires INSIDE core's `applyExistingNote`, covering
|
|
520
|
+
// both the proactive site and the race-backstop site with one guard. The
|
|
521
|
+
// `await getAllowed()` at the top of this wrapper populates the shared
|
|
522
|
+
// `allowedHolder` the predicate reads, before core's execute runs.
|
|
501
523
|
return await orig(params);
|
|
502
524
|
});
|
|
503
525
|
|
|
@@ -825,8 +847,8 @@ function buildManageTokenTool(
|
|
|
825
847
|
":admin'. List + revoke are scoped to tokens this session minted; " +
|
|
826
848
|
"CLI/REST-minted tokens are not surfaced here.\n\n" +
|
|
827
849
|
"Actions (discriminator: `action`):\n" +
|
|
828
|
-
"- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
|
|
829
|
-
"- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }
|
|
850
|
+
"- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at, scopes, scoped_tags, vault_name } (vault#555: scopes/scoped_tags/vault_name were previously undocumented here)\n" +
|
|
851
|
+
"- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean, already_revoked?: boolean } — idempotent; a jti not in this session's ledger, or already revoked, still returns ok:true. A genuine failure additionally carries error/message (and, for a hub-side rejection, hub_status).\n" +
|
|
830
852
|
"- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
|
|
831
853
|
inputSchema: {
|
|
832
854
|
type: "object",
|
package/src/routes.ts
CHANGED
|
@@ -22,9 +22,16 @@ import {
|
|
|
22
22
|
type QueryWarning,
|
|
23
23
|
} from "../core/src/query-warnings.ts";
|
|
24
24
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
listUnresolvedWikilinks,
|
|
27
|
+
resolveOrQueueLink,
|
|
28
|
+
resolveStructuredLinkNote,
|
|
29
|
+
getUnresolvedLinksForNote,
|
|
30
|
+
getUnresolvedLinksForNotes,
|
|
31
|
+
} from "../core/src/wikilinks.ts";
|
|
26
32
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
27
|
-
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
33
|
+
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError } from "../core/src/notes.ts";
|
|
34
|
+
import { normalizePath } from "../core/src/paths.ts";
|
|
28
35
|
import {
|
|
29
36
|
parseContentRange,
|
|
30
37
|
applyContentRange,
|
|
@@ -812,6 +819,9 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
812
819
|
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
813
820
|
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
814
821
|
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
822
|
+
// Presence filter on dangling outbound wikilinks/structured links
|
|
823
|
+
// (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
|
|
824
|
+
hasBrokenLinks: parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
815
825
|
path: parseQuery(url, "path") ?? undefined,
|
|
816
826
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
817
827
|
extension: parseExtensionFilter(url),
|
|
@@ -1017,6 +1027,13 @@ async function handleNotesInner(
|
|
|
1017
1027
|
tagScope.raw,
|
|
1018
1028
|
);
|
|
1019
1029
|
}
|
|
1030
|
+
if (parseBool(parseQuery(url, "include_broken_links"), false)) {
|
|
1031
|
+
// No tag-scope filtering needed (unlike include_links above): a
|
|
1032
|
+
// broken-link `target` never resolved to a note, so there's no
|
|
1033
|
+
// neighbor identity/content to leak — just the string this note's
|
|
1034
|
+
// own [[wikilink]]/structured link already named.
|
|
1035
|
+
result.broken_links = getUnresolvedLinksForNote(db, note.id);
|
|
1036
|
+
}
|
|
1020
1037
|
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
1021
1038
|
result.attachments = await store.getAttachments(note.id);
|
|
1022
1039
|
}
|
|
@@ -1336,6 +1353,7 @@ async function handleNotesInner(
|
|
|
1336
1353
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1337
1354
|
if (contentRange.error) return contentRange.error;
|
|
1338
1355
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
1356
|
+
const includeBrokenLinks = parseBool(parseQuery(url, "include_broken_links"), false);
|
|
1339
1357
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
1340
1358
|
const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
|
|
1341
1359
|
const inclMeta = parseIncludeMetadata(url);
|
|
@@ -1427,13 +1445,19 @@ async function handleNotesInner(
|
|
|
1427
1445
|
);
|
|
1428
1446
|
}
|
|
1429
1447
|
|
|
1430
|
-
if (includeLinks || includeAttachments) {
|
|
1448
|
+
if (includeLinks || includeBrokenLinks || includeAttachments) {
|
|
1431
1449
|
// Whole-page link hydration in a constant number of queries — the
|
|
1432
1450
|
// per-note variant cost (1 link query + 1 summary query + N tag
|
|
1433
1451
|
// queries) × page size. 2026-06-10 perf measurements.
|
|
1434
1452
|
const linksByNote = includeLinks
|
|
1435
1453
|
? linkOps.getLinksHydratedForNotes(db, output.map((n: any) => n.id))
|
|
1436
1454
|
: null;
|
|
1455
|
+
// Same batched-per-page shape as links (vault#555). No tag-scope
|
|
1456
|
+
// filtering needed — a broken-link `target` never resolved to a
|
|
1457
|
+
// note, so there's no neighbor identity to leak.
|
|
1458
|
+
const brokenLinksByNote = includeBrokenLinks
|
|
1459
|
+
? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id))
|
|
1460
|
+
: null;
|
|
1437
1461
|
const enrichedOut: any[] = [];
|
|
1438
1462
|
for (const n of output) {
|
|
1439
1463
|
const enriched: any = { ...n };
|
|
@@ -1445,6 +1469,7 @@ async function handleNotesInner(
|
|
|
1445
1469
|
tagScope.raw,
|
|
1446
1470
|
);
|
|
1447
1471
|
}
|
|
1472
|
+
if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
|
|
1448
1473
|
if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
|
|
1449
1474
|
enrichedOut.push(enriched);
|
|
1450
1475
|
}
|
|
@@ -1512,13 +1537,103 @@ async function handleNotesInner(
|
|
|
1512
1537
|
// layers share `resolveOrQueueLink` from core/wikilinks.ts).
|
|
1513
1538
|
const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
|
|
1514
1539
|
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1540
|
+
// `if_exists` bookkeeping (vault#555) — mirrors core/src/mcp.ts's
|
|
1541
|
+
// create-note tool exactly (same contract, independent REST-layer
|
|
1542
|
+
// reimplementation sharing the same core primitives). See that file's
|
|
1543
|
+
// doc comments for the full rationale.
|
|
1544
|
+
const existedMap = new Map<string, boolean>();
|
|
1545
|
+
const noMutateIds = new Set<string>();
|
|
1546
|
+
const recordExistedBranch = (resultNote: Note, noMutate: boolean): void => {
|
|
1547
|
+
existedMap.set(resultNote.id, true);
|
|
1548
|
+
if (noMutate) noMutateIds.add(resultNote.id);
|
|
1549
|
+
created.push(resultNote);
|
|
1550
|
+
};
|
|
1551
|
+
const applyExistingNote = async (
|
|
1552
|
+
item: any,
|
|
1553
|
+
existingNote: Note,
|
|
1554
|
+
mode: "ignore" | "update" | "replace",
|
|
1555
|
+
): Promise<{ note: Note; noMutate: boolean }> => {
|
|
1556
|
+
// CRITICAL scope guard (vault#555 auth-review must-fix): `existingNote`
|
|
1557
|
+
// was resolved VAULT-WIDE by path (store.getNoteByPath) at BOTH call
|
|
1558
|
+
// sites below (proactive + race-backstop). The batch's incoming-tag
|
|
1559
|
+
// pre-validation guards only each item's OWN tags — NOT this resolved
|
|
1560
|
+
// note — so without this a token scoped to `public` could READ (ignore)
|
|
1561
|
+
// or OVERWRITE (update/replace) a note carrying only an out-of-scope
|
|
1562
|
+
// tag `secret` just by naming its path. When the note is outside the
|
|
1563
|
+
// caller's tag scope, treat it as a path conflict — the path is taken,
|
|
1564
|
+
// but invisible to this caller — throwing BEFORE any read/mutation so
|
|
1565
|
+
// we never expose content or write. `noteWithinTagScope` is a no-op for
|
|
1566
|
+
// unscoped tokens (tagScope.raw === null → always true). The throw
|
|
1567
|
+
// propagates to runBatch's PATH_CONFLICT catch → 409 path_conflict,
|
|
1568
|
+
// byte-identical to a genuine conflict (leaks nothing about WHY).
|
|
1569
|
+
if (!noteWithinTagScope(existingNote, tagScope.allowed, tagScope.raw)) {
|
|
1570
|
+
throw new PathConflictError(existingNote.path ?? "");
|
|
1571
|
+
}
|
|
1572
|
+
if (mode === "ignore") {
|
|
1573
|
+
return { note: existingNote, noMutate: true };
|
|
1574
|
+
}
|
|
1575
|
+
const updates: { content?: string; metadata?: Record<string, unknown> } = {};
|
|
1576
|
+
if (mode === "update") {
|
|
1577
|
+
if (item.content !== undefined) updates.content = item.content;
|
|
1578
|
+
if (item.metadata !== undefined) {
|
|
1579
|
+
updates.metadata = mergeMetadata(
|
|
1580
|
+
existingNote.metadata as Record<string, unknown> | null | undefined,
|
|
1581
|
+
item.metadata,
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
} else {
|
|
1585
|
+
// "replace" — PUT semantics: content/metadata become exactly this
|
|
1586
|
+
// payload (empty default when omitted), never merged.
|
|
1587
|
+
updates.content = item.content ?? "";
|
|
1588
|
+
updates.metadata = item.metadata ?? {};
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const incomingTags: string[] = item.tags ?? [];
|
|
1592
|
+
const projectedTags = new Set<string>([...(existingNote.tags ?? []), ...incomingTags]);
|
|
1593
|
+
const projectedMetadata = updates.metadata ?? ((existingNote.metadata as Record<string, unknown>) ?? {});
|
|
1594
|
+
gateStrictWrite(store, writeCtx, { path: existingNote.path, tags: [...projectedTags], metadata: projectedMetadata });
|
|
1595
|
+
|
|
1596
|
+
// vault#555 fix 2 (W8 fix-2 bug class, generalist must-fix): gate the
|
|
1597
|
+
// core UPDATE on the tag/link mutation too, not just `updates` — a
|
|
1598
|
+
// tags-only/links-only "update" leaves `updates` empty, and skipping
|
|
1599
|
+
// store.updateNote would freeze `updated_at` even though store.tagNote
|
|
1600
|
+
// genuinely mutates the note (breaking cursor polling + updated_at
|
|
1601
|
+
// sync). Mirrors the update-note/PATCH hasTagMutation||hasLinkMutation
|
|
1602
|
+
// gate. "replace" always sets content+metadata, so it was unaffected.
|
|
1603
|
+
const hasLinkMutation = item.links !== undefined;
|
|
1604
|
+
let result: Note = existingNote;
|
|
1605
|
+
if (Object.keys(updates).length > 0 || incomingTags.length > 0 || hasLinkMutation) {
|
|
1606
|
+
result = await store.updateNote(existingNote.id, {
|
|
1607
|
+
...updates,
|
|
1608
|
+
actor: writeCtx.actor,
|
|
1609
|
+
via: writeCtx.via,
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
if (incomingTags.length > 0) {
|
|
1614
|
+
await store.tagNote(result.id, incomingTags);
|
|
1615
|
+
// Redundant with the outer batch loop's applySchemaDefaults (this
|
|
1616
|
+
// note isn't in `noMutateIds` — only "ignore" hits are), but harmless
|
|
1617
|
+
// (idempotent) — kept so the update/replace branch is self-contained.
|
|
1618
|
+
await applySchemaDefaults(store, db, [result.id], incomingTags);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (item.links) {
|
|
1622
|
+
pendingLinks.push({ sourceId: result.id, links: item.links as { target: string; relationship: string }[] });
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
return { note: (await store.getNote(result.id)) ?? result, noMutate: false };
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1515
1628
|
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
1516
1629
|
// failure (path conflict, etc.) rolls back every prior insert. Without
|
|
1517
1630
|
// this, callers got half-applied batches where the prefix landed and
|
|
1518
1631
|
// the offending entry surfaced the 409 — see #236. Single-item posts
|
|
1519
1632
|
// are already atomic at the store layer and skip the wrap so they
|
|
1520
1633
|
// don't collide with concurrent single-item callers on the shared
|
|
1521
|
-
// bun:sqlite connection.
|
|
1634
|
+
// bun:sqlite connection. A caught PATH_CONFLICT (the if_exists race
|
|
1635
|
+
// backstop below) does NOT roll back the transaction — only a throw
|
|
1636
|
+
// that escapes `runBatch` does (see core/src/txn.ts).
|
|
1522
1637
|
const batched = items.length > 1;
|
|
1523
1638
|
const runBatch = async (): Promise<void> => {
|
|
1524
1639
|
for (const item of items) {
|
|
@@ -1528,6 +1643,26 @@ async function handleNotesInner(
|
|
|
1528
1643
|
const extension = item.extension !== undefined
|
|
1529
1644
|
? validateExtension(item.extension)
|
|
1530
1645
|
: undefined;
|
|
1646
|
+
const effectiveExtension = extension ?? "md";
|
|
1647
|
+
const ifExists: string = item.if_exists ?? "error";
|
|
1648
|
+
const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
|
|
1649
|
+
|
|
1650
|
+
// Proactive existence check (vault#555) — only possible with a
|
|
1651
|
+
// path. Handles the common, non-racing case cleanly: skips the
|
|
1652
|
+
// raw-item strict gate below (it would validate the wrong shape —
|
|
1653
|
+
// see `applyExistingNote`) and goes straight to the collision
|
|
1654
|
+
// branch. The catch below is the backstop for a true race.
|
|
1655
|
+
let existingNote: Note | null = null;
|
|
1656
|
+
if (upsertMode && item.path) {
|
|
1657
|
+
const normalized = normalizePath(item.path);
|
|
1658
|
+
existingNote = normalized ? await store.getNoteByPath(normalized, effectiveExtension) : null;
|
|
1659
|
+
}
|
|
1660
|
+
if (existingNote) {
|
|
1661
|
+
const { note: resultNote, noMutate } = await applyExistingNote(item, existingNote, ifExists as "ignore" | "update" | "replace");
|
|
1662
|
+
recordExistedBranch(resultNote, noMutate);
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1531
1666
|
// Strict-schema gate (vault#299) — reject before any write so a
|
|
1532
1667
|
// mid-batch violation rolls back the batch transaction.
|
|
1533
1668
|
gateStrictWrite(store, writeCtx, {
|
|
@@ -1535,17 +1670,36 @@ async function handleNotesInner(
|
|
|
1535
1670
|
tags: item.tags,
|
|
1536
1671
|
metadata: item.metadata,
|
|
1537
1672
|
});
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1673
|
+
let note: Note;
|
|
1674
|
+
try {
|
|
1675
|
+
note = await store.createNote(item.content ?? "", {
|
|
1676
|
+
id: item.id,
|
|
1677
|
+
path: item.path,
|
|
1678
|
+
tags: item.tags,
|
|
1679
|
+
metadata: item.metadata,
|
|
1680
|
+
created_at: item.createdAt ?? item.created_at,
|
|
1681
|
+
...(extension !== undefined ? { extension } : {}),
|
|
1682
|
+
// Write-attribution (vault#298) — REST batch create.
|
|
1683
|
+
actor: writeCtx.actor,
|
|
1684
|
+
via: writeCtx.via,
|
|
1685
|
+
});
|
|
1686
|
+
} catch (err: any) {
|
|
1687
|
+
// Race backstop (vault#555): a concurrent writer's INSERT
|
|
1688
|
+
// committed between our proactive check and this one.
|
|
1689
|
+
// Re-resolve the now-existing row and fall through to the SAME
|
|
1690
|
+
// branch a proactive hit would have taken.
|
|
1691
|
+
if (upsertMode && err?.code === "PATH_CONFLICT") {
|
|
1692
|
+
const winner = await store.getNoteByPath(err.path, effectiveExtension);
|
|
1693
|
+
if (winner) {
|
|
1694
|
+
const { note: resultNote, noMutate } = await applyExistingNote(item, winner, ifExists as "ignore" | "update" | "replace");
|
|
1695
|
+
recordExistedBranch(resultNote, noMutate);
|
|
1696
|
+
continue;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
throw err;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
if (upsertMode) existedMap.set(note.id, false);
|
|
1549
1703
|
|
|
1550
1704
|
if (item.links) {
|
|
1551
1705
|
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
@@ -1624,6 +1778,10 @@ async function handleNotesInner(
|
|
|
1624
1778
|
// applied so the common no-defaults path adds zero extra reads.
|
|
1625
1779
|
const mutatedIds = new Set<string>();
|
|
1626
1780
|
for (const note of created) {
|
|
1781
|
+
// "ignore" hits (vault#555) promise ZERO mutation, including
|
|
1782
|
+
// schema-default backfill of a since-added default the existing
|
|
1783
|
+
// note predates — skip them entirely.
|
|
1784
|
+
if (noMutateIds.has(note.id)) continue;
|
|
1627
1785
|
if (note.tags?.length) {
|
|
1628
1786
|
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
1629
1787
|
mutatedIds.add(id);
|
|
@@ -1643,12 +1801,35 @@ async function handleNotesInner(
|
|
|
1643
1801
|
// unchanged when no tag declares fields, so vaults without any tag
|
|
1644
1802
|
// schemas see no behavior change. Fold in any `unresolved_link`
|
|
1645
1803
|
// warnings (vault#555) — additive, present only when this note's
|
|
1646
|
-
// `links` had a target that didn't resolve.
|
|
1804
|
+
// `links` had a target that didn't resolve. `existed` (vault#555) is
|
|
1805
|
+
// attached ONLY for items whose `if_exists` actually engaged — the
|
|
1806
|
+
// default "error" path's response shape is untouched.
|
|
1647
1807
|
const final = refreshed.map((n) => {
|
|
1648
1808
|
const validated = attachValidationStatus(store, db, n);
|
|
1649
1809
|
const warnings = linkWarningsByNote.get(n.id);
|
|
1650
|
-
|
|
1810
|
+
const existed = existedMap.get(n.id);
|
|
1811
|
+
let out: any = validated;
|
|
1812
|
+
if (warnings && warnings.length > 0) out = { ...out, warnings };
|
|
1813
|
+
if (existed !== undefined) out = { ...out, existed };
|
|
1814
|
+
return out;
|
|
1651
1815
|
});
|
|
1816
|
+
|
|
1817
|
+
// Batch summary (vault#555) — compact shape instead of N full note
|
|
1818
|
+
// objects. Batch-only (a `notes` array); `summary` is ignored on a
|
|
1819
|
+
// single-note POST. Mirrors the MCP create-note tool exactly.
|
|
1820
|
+
if (body.notes && body.summary === true) {
|
|
1821
|
+
return json(
|
|
1822
|
+
{
|
|
1823
|
+
created: final.filter((n: any) => n.existed !== true).length,
|
|
1824
|
+
ids: final.map((n: any) => n.id),
|
|
1825
|
+
// Reserved for future partial-batch-failure reporting — a batch
|
|
1826
|
+
// create is all-or-nothing today, so this is always empty.
|
|
1827
|
+
failed: [] as unknown[],
|
|
1828
|
+
},
|
|
1829
|
+
201,
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1652
1833
|
return json(body.notes ? final : final[0], 201);
|
|
1653
1834
|
}
|
|
1654
1835
|
|