@openparachute/vault 0.7.0-rc.4 → 0.7.0-rc.6
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/attribution.test.ts +11 -3
- package/core/src/contract-taxonomy.test.ts +193 -17
- package/core/src/contract-typed-index.test.ts +228 -11
- package/core/src/core.test.ts +67 -14
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +360 -0
- package/core/src/mcp.ts +164 -28
- package/core/src/notes.ts +98 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/schema-defaults.ts +48 -7
- package/core/src/schema.ts +147 -2
- package/core/src/seed-packs.ts +20 -13
- package/core/src/store.ts +35 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +166 -6
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +90 -5
- package/src/mirror-import.ts +5 -0
- package/src/onboarding-seed.test.ts +5 -2
- package/src/routes.ts +91 -52
- package/src/routing.ts +29 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +43 -0
- package/src/vault.test.ts +58 -35
package/core/src/mcp.ts
CHANGED
|
@@ -190,9 +190,11 @@ export interface GenerateMcpToolsOpts {
|
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* Generate the consolidated MCP tools for a vault. Surface (
|
|
193
|
+
* Generate the consolidated MCP tools for a vault. Surface (13):
|
|
194
194
|
* query-notes, create-note, update-note, delete-note, list-tags, update-tag,
|
|
195
|
-
* delete-tag, find-path, vault-info, prune-schema
|
|
195
|
+
* delete-tag, rename-tag, merge-tags, find-path, vault-info, prune-schema
|
|
196
|
+
* (admin), doctor (admin). `manage-token` (admin) is appended by the SERVER
|
|
197
|
+
* layer (src/mcp-tools.ts), not here — see that file's doc comment.
|
|
196
198
|
*/
|
|
197
199
|
export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
|
|
198
200
|
const db: Database = store.db;
|
|
@@ -1586,15 +1588,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1586
1588
|
description: { type: "string", description: "Human-readable description of what this tag means" },
|
|
1587
1589
|
fields: {
|
|
1588
1590
|
type: "object",
|
|
1589
|
-
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299).',
|
|
1591
|
+
description: 'Metadata fields notes with this tag should have. E.g., { "status": { "type": "string", "enum": ["active", "archived"], "strict": true, "default": "active" } }. Constraints are ADVISORY by default (violations surface as validation_status warnings; the write still succeeds). Mark a field `strict: true` to ENFORCE all its constraints — type + enum + required + cardinality flip to hard write rejections (vault#299). Mark a field `indexed: true` to make it queryable — an indexed field\'s TYPE is ALWAYS enforced (a type-mismatched write is REJECTED, independent of `strict`) because a bad-typed value silently poisons range-query ordering (vault#553).',
|
|
1590
1592
|
additionalProperties: {
|
|
1591
1593
|
type: "object",
|
|
1592
1594
|
properties: {
|
|
1593
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object" },
|
|
1595
|
+
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation. Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
|
|
1594
1596
|
description: { type: "string" },
|
|
1595
|
-
enum: { type: "array", items: { type: "string" }, description: "Allowed values (first is default
|
|
1596
|
-
|
|
1597
|
-
|
|
1597
|
+
enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
|
|
1598
|
+
default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
|
|
1599
|
+
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
|
|
1600
|
+
strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off. Note: `indexed: true` fields enforce their TYPE constraint regardless of this flag (vault#553)." },
|
|
1598
1601
|
required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
|
|
1599
1602
|
cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
|
|
1600
1603
|
},
|
|
@@ -1716,11 +1719,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1716
1719
|
// mgmt + future config writes; deletes are write-tier mutations.
|
|
1717
1720
|
// See delete-note rationale.
|
|
1718
1721
|
requiredVerb: "write",
|
|
1719
|
-
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged.",
|
|
1722
|
+
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged. Refused with error_type \"tag_referenced_as_parent\" (vault#552) when another tag's parent_names still names this one — pass cascade OR detach (either — both mean the same thing: strip the stale reference from the referencing tag(s)' parent_names, never delete them) to proceed anyway.",
|
|
1720
1723
|
inputSchema: {
|
|
1721
1724
|
type: "object",
|
|
1722
1725
|
properties: {
|
|
1723
1726
|
tag: { type: "string", description: "Tag name to delete" },
|
|
1727
|
+
cascade: { type: "boolean", description: "Proceed even though another tag's parent_names references this one, stripping the reference. Synonym of detach." },
|
|
1728
|
+
detach: { type: "boolean", description: "Same as cascade — proceed and strip the stale parent_names reference from referencing tag(s)." },
|
|
1724
1729
|
},
|
|
1725
1730
|
required: ["tag"],
|
|
1726
1731
|
},
|
|
@@ -1731,8 +1736,107 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1731
1736
|
// Indexed-field release is handled inside store.deleteTag →
|
|
1732
1737
|
// noteOps.deleteTag so every entry point (MCP, REST, import sweep)
|
|
1733
1738
|
// releases consistently with the co-declaration guard. See the
|
|
1734
|
-
// gitcoin orphaned-fields bug report.
|
|
1735
|
-
|
|
1739
|
+
// gitcoin orphaned-fields bug report. The referential-integrity
|
|
1740
|
+
// guard (vault#552) is ALSO inside store.deleteTag, so it returns
|
|
1741
|
+
// (not throws) `{error: "tag_referenced_as_parent", ...}` when
|
|
1742
|
+
// refused — same in-band shape delete-tag has always used for its
|
|
1743
|
+
// token-reference guard (applyTagDependencyGuards, src/mcp-tools.ts).
|
|
1744
|
+
return await store.deleteTag(tag, {
|
|
1745
|
+
cascade: params.cascade === true,
|
|
1746
|
+
detach: params.detach === true,
|
|
1747
|
+
});
|
|
1748
|
+
},
|
|
1749
|
+
},
|
|
1750
|
+
|
|
1751
|
+
// =====================================================================
|
|
1752
|
+
// 7a. rename-tag — atomic cascading rename (vault#552 MCP parity)
|
|
1753
|
+
// =====================================================================
|
|
1754
|
+
{
|
|
1755
|
+
name: "rename-tag",
|
|
1756
|
+
requiredVerb: "write",
|
|
1757
|
+
description:
|
|
1758
|
+
"Atomically rename a tag across EVERY surface that references it: note memberships, OTHER tags' parent_names, tag-scoped tokens' allowlists, indexed-field declarer lists, inline #tag mentions in note bodies, and _tags/<name> config-note paths — all in one transaction. THIS is the fix for the manual retag→delete dance (create the new tag, retag notes, delete the old one): that dance silently orphans parent_names references (the renamed-away tag stays a live query surface via subtype expansion while list-tags reports it at count 0, and the new tag misses every child-tagged note) and leaves stale #tag mentions behind. Sub-tags rename recursively — renaming \"task\" to \"todo\" also renames \"task/work\" to \"todo/work\". Does NOT rewrite metadata values that happen to equal the old tag name (e.g. metadata.epic: \"task\") — that's a distinct drift class the doctor tool's dead_tag_metadata_reference finding flags heuristically; rename-tag's job is structural (tags/note_tags/parent_names/tokens/content), not a blind string search-and-replace over arbitrary metadata.",
|
|
1759
|
+
inputSchema: {
|
|
1760
|
+
type: "object",
|
|
1761
|
+
properties: {
|
|
1762
|
+
old_name: { type: "string", description: "The tag to rename. Aliases: from, tag." },
|
|
1763
|
+
new_name: { type: "string", description: "The new name. Alias: to." },
|
|
1764
|
+
from: { type: "string", description: "Alias for old_name." },
|
|
1765
|
+
to: { type: "string", description: "Alias for new_name." },
|
|
1766
|
+
tag: { type: "string", description: "Alias for old_name." },
|
|
1767
|
+
},
|
|
1768
|
+
},
|
|
1769
|
+
execute: async (params) => {
|
|
1770
|
+
const oldName = (params.old_name ?? params.from ?? params.tag) as string | undefined;
|
|
1771
|
+
const newName = (params.new_name ?? params.to) as string | undefined;
|
|
1772
|
+
if (typeof oldName !== "string" || oldName.length === 0) {
|
|
1773
|
+
throw structuredError("rename-tag: old_name (or from/tag) is required", {
|
|
1774
|
+
error_type: "invalid_request",
|
|
1775
|
+
field: "old_name",
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
if (typeof newName !== "string" || newName.length === 0) {
|
|
1779
|
+
throw structuredError("rename-tag: new_name (or to) is required", {
|
|
1780
|
+
error_type: "invalid_request",
|
|
1781
|
+
field: "new_name",
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
const result = await store.renameTag(oldName, newName);
|
|
1785
|
+
if ("error" in result) {
|
|
1786
|
+
if (result.error === "not_found") {
|
|
1787
|
+
throw structuredError(`rename-tag: tag "${oldName}" not found`, {
|
|
1788
|
+
error_type: "tag_not_found",
|
|
1789
|
+
field: "old_name",
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
throw Object.assign(
|
|
1793
|
+
new Error(
|
|
1794
|
+
`rename-tag: target "${newName}" (or one of its sub-tags) already exists — use merge-tags to combine them instead`,
|
|
1795
|
+
),
|
|
1796
|
+
{
|
|
1797
|
+
error_type: "target_exists" as const,
|
|
1798
|
+
target: newName,
|
|
1799
|
+
conflicting: result.conflicting,
|
|
1800
|
+
hint: "use merge-tags to combine the tags instead",
|
|
1801
|
+
},
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
return result;
|
|
1805
|
+
},
|
|
1806
|
+
},
|
|
1807
|
+
|
|
1808
|
+
// =====================================================================
|
|
1809
|
+
// 7b. merge-tags — same machinery, N sources → one target
|
|
1810
|
+
// =====================================================================
|
|
1811
|
+
{
|
|
1812
|
+
name: "merge-tags",
|
|
1813
|
+
requiredVerb: "write",
|
|
1814
|
+
description:
|
|
1815
|
+
"Atomically merge one or more source tags into a target tag: every note carrying any source is retagged with the target, then the source tags (and their identity rows — description/fields/relationships/parent_names) are dropped. target is created if it doesn't exist yet; target's own schema is preserved (sources' schemas are consumed, not merged field-by-field). Sources that don't exist are reported at count 0. Refused with error_type \"tag_in_use_by_tokens\" if a source is referenced by a tag-scoped token — revoke or re-mint it first.",
|
|
1816
|
+
inputSchema: {
|
|
1817
|
+
type: "object",
|
|
1818
|
+
properties: {
|
|
1819
|
+
sources: { type: "array", items: { type: "string" }, description: "Tag names to merge away into target." },
|
|
1820
|
+
target: { type: "string", description: "The tag that survives; sources are retagged onto it and dropped." },
|
|
1821
|
+
},
|
|
1822
|
+
required: ["sources", "target"],
|
|
1823
|
+
},
|
|
1824
|
+
execute: async (params) => {
|
|
1825
|
+
const sources = params.sources;
|
|
1826
|
+
const target = params.target;
|
|
1827
|
+
if (!Array.isArray(sources) || sources.length === 0 || !sources.every((s) => typeof s === "string" && s.length > 0)) {
|
|
1828
|
+
throw structuredError("merge-tags: sources must be a non-empty array of strings", {
|
|
1829
|
+
error_type: "invalid_request",
|
|
1830
|
+
field: "sources",
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
if (typeof target !== "string" || target.length === 0) {
|
|
1834
|
+
throw structuredError("merge-tags: target must be a non-empty string", {
|
|
1835
|
+
error_type: "invalid_request",
|
|
1836
|
+
field: "target",
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
return await store.mergeTags(sources as string[], target);
|
|
1736
1840
|
},
|
|
1737
1841
|
},
|
|
1738
1842
|
|
|
@@ -1822,6 +1926,22 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1822
1926
|
},
|
|
1823
1927
|
},
|
|
1824
1928
|
|
|
1929
|
+
// =====================================================================
|
|
1930
|
+
// 11. doctor — read-only taxonomy/metadata integrity scan (vault#552)
|
|
1931
|
+
// =====================================================================
|
|
1932
|
+
{
|
|
1933
|
+
name: "doctor",
|
|
1934
|
+
// `admin` — same tier as prune-schema: a diagnostic over the WHOLE
|
|
1935
|
+
// vault's taxonomy, not scoped to any one tag's write authority.
|
|
1936
|
+
requiredVerb: "admin",
|
|
1937
|
+
description:
|
|
1938
|
+
"Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Reports, per finding, {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
|
|
1939
|
+
inputSchema: { type: "object", properties: {} },
|
|
1940
|
+
execute: async () => {
|
|
1941
|
+
return await store.doctor();
|
|
1942
|
+
},
|
|
1943
|
+
},
|
|
1944
|
+
|
|
1825
1945
|
];
|
|
1826
1946
|
}
|
|
1827
1947
|
|
|
@@ -1834,18 +1954,33 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1834
1954
|
* for any field they omitted. Returns the IDs of the notes whose metadata was
|
|
1835
1955
|
* actually written — callers use this to re-read ONLY the mutated notes (and
|
|
1836
1956
|
* to skip the re-read entirely when nothing changed). The common no-schema /
|
|
1837
|
-
* no-defaults path returns an empty array.
|
|
1957
|
+
* no-declared-defaults path returns an empty array.
|
|
1958
|
+
*
|
|
1959
|
+
* vault#553 Decision B: backfill is EXPLICIT-`default`-only. A field with no
|
|
1960
|
+
* declared `default` is skipped entirely here — it stays genuinely absent on
|
|
1961
|
+
* the note, not silently filled with the first enum value or a type
|
|
1962
|
+
* zero-value (the pre-0.7.0 behavior, which made "never set" and
|
|
1963
|
+
* "explicitly set to the default" indistinguishable and broke `exists:false`).
|
|
1964
|
+
* Exported so `src/routes.ts` (REST create/PATCH) shares this ONE
|
|
1965
|
+
* implementation instead of carrying its own copy — the two had drifted into
|
|
1966
|
+
* a byte-identical duplicate pre-#553; centralizing here means the cloud
|
|
1967
|
+
* runtime (which imports core directly, not `src/routes.ts`) automatically
|
|
1968
|
+
* inherits this behavior with zero handler-side work.
|
|
1838
1969
|
*
|
|
1839
1970
|
* vault#299: this runs AFTER the create write (so AFTER the strict gate) and
|
|
1840
1971
|
* intentionally does NOT re-run `enforceStrict`. Defaults are always
|
|
1841
|
-
* conforming by construction — `
|
|
1842
|
-
*
|
|
1843
|
-
* `
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
1846
|
-
*
|
|
1972
|
+
* conforming by construction — `store.upsertTagRecord` validates a field's
|
|
1973
|
+
* `default` against its own `type`/`enum` BEFORE the schema can be persisted
|
|
1974
|
+
* (`InvalidFieldDefaultError` / `TagFieldConflictError` reason
|
|
1975
|
+
* `invalid_default`), so a default can never violate type/enum at read time
|
|
1976
|
+
* here. And a `required` strict field is already caught at the pre-write
|
|
1977
|
+
* gate, so a note that would need a default to satisfy `required` never
|
|
1978
|
+
* reaches this filler (the create was rejected first) — declaring a
|
|
1979
|
+
* `default` does NOT satisfy `required`; the caller must still set the field
|
|
1980
|
+
* explicitly. Don't add a defaults path that could inject a violating value
|
|
1981
|
+
* without re-gating.
|
|
1847
1982
|
*/
|
|
1848
|
-
async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
1983
|
+
export async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
1849
1984
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
1850
1985
|
if (Object.keys(schemas).length === 0) return [];
|
|
1851
1986
|
|
|
@@ -1854,9 +1989,10 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
1854
1989
|
const schema = schemas[tag];
|
|
1855
1990
|
if (!schema?.fields) continue;
|
|
1856
1991
|
for (const [field, fieldSchema] of Object.entries(schema.fields)) {
|
|
1857
|
-
if (
|
|
1858
|
-
|
|
1859
|
-
|
|
1992
|
+
if (field in defaults) continue; // first tag that declares a REAL default wins
|
|
1993
|
+
const value = defaultForField(fieldSchema);
|
|
1994
|
+
if (value === undefined) continue; // no `default` declared — leave the slot open for a later tag
|
|
1995
|
+
defaults[field] = value;
|
|
1860
1996
|
}
|
|
1861
1997
|
}
|
|
1862
1998
|
if (Object.keys(defaults).length === 0) return [];
|
|
@@ -1882,13 +2018,13 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
1882
2018
|
return mutated;
|
|
1883
2019
|
}
|
|
1884
2020
|
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
2021
|
+
/**
|
|
2022
|
+
* Resolve a field's backfill value — its declared `default` (vault#553
|
|
2023
|
+
* Decision B), or `undefined` when none was declared (the field stays
|
|
2024
|
+
* absent). Exported alongside `applySchemaDefaults` for `src/routes.ts`.
|
|
2025
|
+
*/
|
|
2026
|
+
export function defaultForField(field: { default?: unknown }): unknown {
|
|
2027
|
+
return field.default;
|
|
1892
2028
|
}
|
|
1893
2029
|
|
|
1894
2030
|
// ---------------------------------------------------------------------------
|
package/core/src/notes.ts
CHANGED
|
@@ -1512,40 +1512,114 @@ export function listTags(db: Database): { name: string; count: number; expanded_
|
|
|
1512
1512
|
return rows.map((r) => ({ ...r, expanded_count: expandedCounts.get(r.name) ?? 0 }));
|
|
1513
1513
|
}
|
|
1514
1514
|
|
|
1515
|
-
|
|
1515
|
+
/** Options accepted by {@link deleteTag} (vault#552). */
|
|
1516
|
+
export interface DeleteTagOpts {
|
|
1517
|
+
/**
|
|
1518
|
+
* Proceed with the delete even though another tag's `parent_names`
|
|
1519
|
+
* references this one — strip the reference from every referencing tag's
|
|
1520
|
+
* `parent_names` array as part of the same transaction. Accepted as a
|
|
1521
|
+
* synonym of `detach` (both name the identical repair: remove the stale
|
|
1522
|
+
* reference, never delete the referencing tag itself); offered as two
|
|
1523
|
+
* flags because operators reach for either word depending on whether
|
|
1524
|
+
* they're thinking "cascade the delete" or "detach the children."
|
|
1525
|
+
*/
|
|
1526
|
+
cascade?: boolean;
|
|
1527
|
+
/** Synonym of `cascade` — see above. */
|
|
1528
|
+
detach?: boolean;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
export type DeleteTagResult =
|
|
1532
|
+
| { deleted: boolean; notes_untagged: number; parent_refs_detached?: number }
|
|
1533
|
+
| { error: "tag_referenced_as_parent"; referencing_tags: string[] };
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Delete a tag: drop its identity row, untag every note carrying it, and
|
|
1537
|
+
* release any indexed fields it exclusively declared. Notes themselves are
|
|
1538
|
+
* NEVER deleted — only untagged.
|
|
1539
|
+
*
|
|
1540
|
+
* Referential integrity (vault#552): if another tag's `parent_names` array
|
|
1541
|
+
* names `name`, deleting it would silently orphan that reference — the
|
|
1542
|
+
* child tag's hierarchy edge would point at a name with no identity row
|
|
1543
|
+
* (the exact "renamed-away tag remains a live query surface" class of bug
|
|
1544
|
+
* the gardener found, one hop over from rename). Refuse by default with
|
|
1545
|
+
* `{ error: "tag_referenced_as_parent", referencing_tags }`; pass
|
|
1546
|
+
* `opts.cascade` or `opts.detach` (either — see {@link DeleteTagOpts}) to
|
|
1547
|
+
* proceed, stripping `name` from every referencing tag's `parent_names` in
|
|
1548
|
+
* the same transaction as the delete.
|
|
1549
|
+
*/
|
|
1550
|
+
export function deleteTag(db: Database, name: string, opts?: DeleteTagOpts): DeleteTagResult {
|
|
1516
1551
|
const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
|
|
1517
1552
|
| { fields: string | null }
|
|
1518
1553
|
| null;
|
|
1519
1554
|
if (!row) return { deleted: false, notes_untagged: 0 };
|
|
1520
1555
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
//
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1556
|
+
// Referential-integrity guard: who names `name` as a parent? Reuses the
|
|
1557
|
+
// SAME parent_names parsing loadTagHierarchy uses everywhere else in the
|
|
1558
|
+
// hierarchy (rather than a bespoke LIKE scan) so "who references this
|
|
1559
|
+
// tag" answers identically here as it does for query expansion.
|
|
1560
|
+
const hierarchy = loadTagHierarchy(db);
|
|
1561
|
+
const referencing = Array.from(hierarchy.childrenOf.get(name) ?? []);
|
|
1562
|
+
if (referencing.length > 0 && !opts?.cascade && !opts?.detach) {
|
|
1563
|
+
return { error: "tag_referenced_as_parent", referencing_tags: referencing.sort() };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
return transaction(db, (): DeleteTagResult => {
|
|
1567
|
+
// Strip the stale reference from every referencing tag's parent_names
|
|
1568
|
+
// BEFORE dropping this tag's own row — order doesn't matter for
|
|
1569
|
+
// correctness (different rows), but doing the repair first means a
|
|
1570
|
+
// mid-transaction failure never leaves the identity row gone with
|
|
1571
|
+
// dangling references still pointing at it.
|
|
1572
|
+
let parentRefsDetached = 0;
|
|
1573
|
+
if (referencing.length > 0) {
|
|
1574
|
+
const readStmt = db.prepare("SELECT parent_names FROM tags WHERE name = ?");
|
|
1575
|
+
const updateStmt = db.prepare("UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?");
|
|
1576
|
+
const now = new Date().toISOString();
|
|
1577
|
+
for (const refTag of referencing) {
|
|
1578
|
+
const r = readStmt.get(refTag) as { parent_names: string | null } | null;
|
|
1579
|
+
if (!r?.parent_names) continue;
|
|
1580
|
+
let parsed: unknown;
|
|
1581
|
+
try { parsed = JSON.parse(r.parent_names); } catch { continue; }
|
|
1582
|
+
if (!Array.isArray(parsed)) continue;
|
|
1583
|
+
const next = (parsed as unknown[]).filter((p) => p !== name);
|
|
1584
|
+
if (next.length === parsed.length) continue;
|
|
1585
|
+
updateStmt.run(next.length > 0 ? JSON.stringify(next) : null, now, refTag);
|
|
1586
|
+
parentRefsDetached++;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
|
|
1591
|
+
const notesUntagged = countRow.c;
|
|
1592
|
+
|
|
1593
|
+
// Release any indexed fields this tag declared BEFORE the row drops.
|
|
1594
|
+
// `releaseField` only drops the generated column + index when this tag is
|
|
1595
|
+
// the last live declarer (co-declaration guard) — a field co-declared by
|
|
1596
|
+
// another live tag keeps its column and just loses this tag from the set.
|
|
1597
|
+
// This lives in the store-level delete (not the MCP layer) so every caller
|
|
1598
|
+
// — MCP delete-tag, the REST DELETE /tags/:name route, the import
|
|
1599
|
+
// blow-away sweep — releases consistently. See the gitcoin orphaned-fields
|
|
1600
|
+
// bug report.
|
|
1601
|
+
if (row.fields) {
|
|
1602
|
+
try {
|
|
1603
|
+
const fields = JSON.parse(row.fields) as Record<string, { indexed?: boolean }>;
|
|
1604
|
+
for (const [fieldName, spec] of Object.entries(fields)) {
|
|
1605
|
+
if (spec?.indexed === true) {
|
|
1606
|
+
releaseField(db, fieldName, name);
|
|
1607
|
+
}
|
|
1538
1608
|
}
|
|
1609
|
+
} catch {
|
|
1610
|
+
// Malformed fields JSON — nothing to release; proceed with the delete.
|
|
1539
1611
|
}
|
|
1540
|
-
} catch {
|
|
1541
|
-
// Malformed fields JSON — nothing to release; proceed with the delete.
|
|
1542
1612
|
}
|
|
1543
|
-
}
|
|
1544
1613
|
|
|
1545
|
-
|
|
1546
|
-
|
|
1614
|
+
db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
|
|
1615
|
+
db.prepare("DELETE FROM tags WHERE name = ?").run(name);
|
|
1547
1616
|
|
|
1548
|
-
|
|
1617
|
+
return {
|
|
1618
|
+
deleted: true,
|
|
1619
|
+
notes_untagged: notesUntagged,
|
|
1620
|
+
...(parentRefsDetached > 0 ? { parent_refs_detached: parentRefsDetached } : {}),
|
|
1621
|
+
};
|
|
1622
|
+
});
|
|
1549
1623
|
}
|
|
1550
1624
|
|
|
1551
1625
|
// The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous:
|
|
@@ -1441,6 +1441,113 @@ describe("portable-md non-markdown round-trip (vault#328)", async () => {
|
|
|
1441
1441
|
// No sidecar → no DB row.
|
|
1442
1442
|
expect(stats.notes_created).toBe(0);
|
|
1443
1443
|
});
|
|
1444
|
+
|
|
1445
|
+
// -------------------------------------------------------------------------
|
|
1446
|
+
// vault#552 — the write-time parent_names cycle guard must NOT abort an
|
|
1447
|
+
// import of an OLD export that carries a mutual-cycle fixture (accepted
|
|
1448
|
+
// before the guard shipped). The offending parent_names is dropped +
|
|
1449
|
+
// warned, the tags + notes still land. On a blow-away replace this is
|
|
1450
|
+
// load-bearing: the vault was already wiped, so an uncaught throw here
|
|
1451
|
+
// would leave it EMPTY with no rollback.
|
|
1452
|
+
// -------------------------------------------------------------------------
|
|
1453
|
+
|
|
1454
|
+
/** Hand-build a portable-md export whose two tag schemas form an A↔B
|
|
1455
|
+
* parent_names cycle, plus one note tagged with `a`. */
|
|
1456
|
+
function writeCyclicSchemaExport(dir: string): void {
|
|
1457
|
+
mkdirSync(join(dir, SIDECAR_DIR, "schemas"), { recursive: true });
|
|
1458
|
+
writeFileSync(
|
|
1459
|
+
join(dir, SIDECAR_DIR, "vault.yaml"),
|
|
1460
|
+
"export_format_version: 1\nexported_at: '2026-07-09T00:00:00.000Z'\n",
|
|
1461
|
+
);
|
|
1462
|
+
writeFileSync(
|
|
1463
|
+
join(dir, SIDECAR_DIR, "schemas", "a.yaml"),
|
|
1464
|
+
"name: a\ndescription: tag a\nparent_names:\n - b\n",
|
|
1465
|
+
);
|
|
1466
|
+
writeFileSync(
|
|
1467
|
+
join(dir, SIDECAR_DIR, "schemas", "b.yaml"),
|
|
1468
|
+
"name: b\ndescription: tag b\nparent_names:\n - a\n",
|
|
1469
|
+
);
|
|
1470
|
+
writeFileSync(
|
|
1471
|
+
join(dir, "note1.md"),
|
|
1472
|
+
"---\nid: note1\npath: note1\ntags:\n - a\ncreated_at: '2026-07-09T00:00:00.000Z'\nupdated_at: '2026-07-09T00:00:00.000Z'\n---\nhello from note1\n",
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
it("imports an export carrying a parent_names cycle — the cycle is dropped + warned, not fatal (vault#552 MUST-FIX 1)", async () => {
|
|
1477
|
+
const outDir = join(tmpBase, "cyclic-schemas");
|
|
1478
|
+
writeCyclicSchemaExport(outDir);
|
|
1479
|
+
|
|
1480
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1481
|
+
// MUST complete, not throw.
|
|
1482
|
+
const stats = await importPortableVault(restored, { inDir: outDir });
|
|
1483
|
+
|
|
1484
|
+
// Both schemas + the note landed.
|
|
1485
|
+
expect(stats.schemas_restored).toBe(2);
|
|
1486
|
+
expect(stats.notes_created).toBe(1);
|
|
1487
|
+
expect((await restored.getNote("note1"))!.content.trimEnd()).toBe("hello from note1");
|
|
1488
|
+
expect(await restored.getTagRecord("a")).not.toBeNull();
|
|
1489
|
+
expect(await restored.getTagRecord("b")).not.toBeNull();
|
|
1490
|
+
|
|
1491
|
+
// Exactly one of the two had its parent_names dropped (whichever the
|
|
1492
|
+
// filesystem walk restored SECOND closes the cycle) and is recorded.
|
|
1493
|
+
expect(stats.skipped_schema_parents).toHaveLength(1);
|
|
1494
|
+
const skipped = stats.skipped_schema_parents[0]!;
|
|
1495
|
+
expect(["a", "b"]).toContain(skipped.tag);
|
|
1496
|
+
expect(skipped.reason).toMatch(/cycle/i);
|
|
1497
|
+
|
|
1498
|
+
// The surviving hierarchy is acyclic: at most one of a/b still carries
|
|
1499
|
+
// parent_names (the other was dropped). Never both.
|
|
1500
|
+
const aParents = (await restored.getTagRecord("a"))!.parent_names ?? null;
|
|
1501
|
+
const bParents = (await restored.getTagRecord("b"))!.parent_names ?? null;
|
|
1502
|
+
expect(Boolean(aParents) && Boolean(bParents)).toBe(false);
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
it("blow-away import of a cyclic-schema export leaves the vault populated, NOT empty (vault#552 MUST-FIX 1 — destructive path)", async () => {
|
|
1506
|
+
const outDir = join(tmpBase, "cyclic-blowaway");
|
|
1507
|
+
writeCyclicSchemaExport(outDir);
|
|
1508
|
+
|
|
1509
|
+
// Seed the target with a throwaway note so blow-away has something to
|
|
1510
|
+
// wipe — proving the wipe ran AND that the uncaught-throw regression
|
|
1511
|
+
// (wipe-then-abort → empty vault) is gone.
|
|
1512
|
+
const restored = new SqliteStore(new Database(":memory:"));
|
|
1513
|
+
await restored.createNote("doomed pre-existing note", { id: "old1", path: "old1" });
|
|
1514
|
+
|
|
1515
|
+
const stats = await importPortableVault(restored, { inDir: outDir, blowAway: true });
|
|
1516
|
+
|
|
1517
|
+
expect(stats.notes_wiped).toBe(1); // the pre-existing note was wiped
|
|
1518
|
+
expect(await restored.getNote("old1")).toBeNull(); // gone
|
|
1519
|
+
// Vault is NOT left empty — the fixture note landed despite the cycle.
|
|
1520
|
+
expect((await restored.getNote("note1"))!.content.trimEnd()).toBe("hello from note1");
|
|
1521
|
+
expect(stats.skipped_schema_parents).toHaveLength(1);
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
it("blow-away wipes a parent-before-child hierarchy with NO stale tag rows left (vault#552 MUST-FIX 2)", async () => {
|
|
1525
|
+
// The bug: blow-away's tag sweep called store.deleteTag(tag) with no
|
|
1526
|
+
// flag and ignored the return. listTagRecords is ORDER BY name, so the
|
|
1527
|
+
// namespace parent "task" is visited BEFORE its child "task/work" —
|
|
1528
|
+
// while the child's parent_names still references it — and deleteTag now
|
|
1529
|
+
// RETURNS {error:"tag_referenced_as_parent"} instead of throwing, so the
|
|
1530
|
+
// parent's row silently survived the "clean slate." Fixed by passing
|
|
1531
|
+
// {cascade:true} in the sweep.
|
|
1532
|
+
const target = new SqliteStore(new Database(":memory:"));
|
|
1533
|
+
await target.upsertTagRecord("task", { description: "parent" });
|
|
1534
|
+
await target.upsertTagRecord("task/work", { parent_names: ["task"], description: "child" });
|
|
1535
|
+
await target.createNote("stale note", { id: "stale1", path: "stale1", tags: ["task/work"] });
|
|
1536
|
+
|
|
1537
|
+
// Build a minimal UNRELATED export from a fresh store to replay.
|
|
1538
|
+
const sourceStore = new SqliteStore(new Database(":memory:"));
|
|
1539
|
+
await sourceStore.createNote("fresh body", { id: "fresh1", path: "fresh1", tags: ["fresh"] });
|
|
1540
|
+
const outDir = join(tmpBase, "blowaway-hierarchy-source");
|
|
1541
|
+
await exportVaultToDir(sourceStore, { outDir, exportedAt: "2026-07-09T00:00:00.000Z" });
|
|
1542
|
+
|
|
1543
|
+
await importPortableVault(target, { inDir: outDir, blowAway: true });
|
|
1544
|
+
|
|
1545
|
+
// After a full wipe, NEITHER old hierarchy tag row survives.
|
|
1546
|
+
const remaining = (await target.listTagRecords()).map((t) => t.tag);
|
|
1547
|
+
expect(remaining).not.toContain("task");
|
|
1548
|
+
expect(remaining).not.toContain("task/work");
|
|
1549
|
+
expect(await target.getNote("stale1")).toBeNull();
|
|
1550
|
+
});
|
|
1444
1551
|
});
|
|
1445
1552
|
|
|
1446
1553
|
// ---------------------------------------------------------------------------
|
package/core/src/portable-md.ts
CHANGED
|
@@ -76,6 +76,7 @@ import { readdirSync, readFileSync, realpathSync, statSync, mkdirSync, writeFile
|
|
|
76
76
|
import { basename, join, relative, extname, dirname, resolve as resolvePath, sep as pathSep } from "path";
|
|
77
77
|
import type { Store, Note, Link, Attachment } from "./types.js";
|
|
78
78
|
import type { TagRecord } from "./tag-schemas.js";
|
|
79
|
+
import { ParentCycleError } from "./tag-schemas.js";
|
|
79
80
|
|
|
80
81
|
// ---------------------------------------------------------------------------
|
|
81
82
|
// Format constants
|
|
@@ -1631,6 +1632,17 @@ export interface ImportStats {
|
|
|
1631
1632
|
* content file during the walk.
|
|
1632
1633
|
*/
|
|
1633
1634
|
skipped_sidecars: Array<{ sidecar_id: string; expected_path: string | null; expected_extension: string | null; reason: string }>;
|
|
1635
|
+
/**
|
|
1636
|
+
* Tag schemas whose `parent_names` was DROPPED on restore because it
|
|
1637
|
+
* would have created a hierarchy cycle (vault#552 — the write-time cycle
|
|
1638
|
+
* guard in `upsertTagRecord`). The tag itself is still restored (its
|
|
1639
|
+
* description/fields/relationships land); only the offending
|
|
1640
|
+
* `parent_names` is skipped, so the import completes instead of aborting
|
|
1641
|
+
* mid-flight on an uncaught `ParentCycleError`. An OLD export that carried
|
|
1642
|
+
* a mutual-cycle fixture (accepted before the guard shipped) still
|
|
1643
|
+
* imports — the cycle is warned, not fatal. Empty in the common case.
|
|
1644
|
+
*/
|
|
1645
|
+
skipped_schema_parents: Array<{ tag: string; parent_names: string[]; reason: string }>;
|
|
1634
1646
|
/** Set when the caller passed `blowAway: true`; counts notes removed. */
|
|
1635
1647
|
notes_wiped: number;
|
|
1636
1648
|
/**
|
|
@@ -1802,6 +1814,7 @@ export async function importVault(
|
|
|
1802
1814
|
skipped_links: [],
|
|
1803
1815
|
skipped_attachments: [],
|
|
1804
1816
|
skipped_sidecars: [],
|
|
1817
|
+
skipped_schema_parents: [],
|
|
1805
1818
|
notes_wiped: 0,
|
|
1806
1819
|
indexes_declared: 0,
|
|
1807
1820
|
};
|
|
@@ -1821,10 +1834,18 @@ export async function importVault(
|
|
|
1821
1834
|
}
|
|
1822
1835
|
}
|
|
1823
1836
|
// Clear tag rows too — `deleteNote` clears note_tags via FK cascade but
|
|
1824
|
-
// leaves the `tags` table rows in place (orphaned schemas).
|
|
1837
|
+
// leaves the `tags` table rows in place (orphaned schemas). `cascade:
|
|
1838
|
+
// true` is REQUIRED here (vault#552): a blow-away is a full wipe, and
|
|
1839
|
+
// `listTagRecords` returns rows `ORDER BY name`, so a namespace parent
|
|
1840
|
+
// (`task`) sorts before its child (`task/work`) and is visited while the
|
|
1841
|
+
// child's `parent_names` still references it — without `cascade`,
|
|
1842
|
+
// `deleteTag` would RETURN `{error: "tag_referenced_as_parent"}` (it no
|
|
1843
|
+
// longer throws) and silently leave the parent's row behind, defeating
|
|
1844
|
+
// the "clean slate." Since we're deleting every tag anyway, cascading the
|
|
1845
|
+
// parent_names strip is exactly right.
|
|
1825
1846
|
const tagRecords = await store.listTagRecords();
|
|
1826
1847
|
for (const tag of tagRecords) {
|
|
1827
|
-
await store.deleteTag(tag.tag);
|
|
1848
|
+
await store.deleteTag(tag.tag, { cascade: true });
|
|
1828
1849
|
}
|
|
1829
1850
|
}
|
|
1830
1851
|
|
|
@@ -1842,12 +1863,37 @@ export async function importVault(
|
|
|
1842
1863
|
stats.schemas_restored++;
|
|
1843
1864
|
continue;
|
|
1844
1865
|
}
|
|
1845
|
-
|
|
1866
|
+
const parentNames = (frontmatter.parent_names as string[] | null | undefined) ?? null;
|
|
1867
|
+
const patch = {
|
|
1846
1868
|
description: (frontmatter.description as string | null | undefined) ?? null,
|
|
1847
1869
|
fields: (frontmatter.fields as Record<string, unknown> | null | undefined) as any ?? null,
|
|
1848
1870
|
relationships: (frontmatter.relationships as Record<string, unknown> | null | undefined) as any ?? null,
|
|
1849
|
-
parent_names:
|
|
1850
|
-
}
|
|
1871
|
+
parent_names: parentNames,
|
|
1872
|
+
};
|
|
1873
|
+
try {
|
|
1874
|
+
await store.upsertTagRecord(tagName, patch);
|
|
1875
|
+
} catch (err) {
|
|
1876
|
+
// vault#552: the write-time cycle guard rejects a `parent_names` that
|
|
1877
|
+
// would close a hierarchy cycle. An OLD export carrying a mutual-cycle
|
|
1878
|
+
// fixture (accepted before the guard shipped) must still import —
|
|
1879
|
+
// dropping the offending `parent_names` and warning, not aborting the
|
|
1880
|
+
// whole import mid-flight (import isn't atomic; on a blow-away replace
|
|
1881
|
+
// the vault was already wiped in step 1, so an uncaught throw here
|
|
1882
|
+
// would leave it EMPTY). Retry WITHOUT parent_names so the tag's
|
|
1883
|
+
// description/fields/relationships still land; record a skip warning
|
|
1884
|
+
// mirroring the skipped_links/skipped_attachments/skipped_sidecars
|
|
1885
|
+
// precedent. Any OTHER error still propagates.
|
|
1886
|
+
if (err instanceof ParentCycleError) {
|
|
1887
|
+
stats.skipped_schema_parents.push({
|
|
1888
|
+
tag: tagName,
|
|
1889
|
+
parent_names: parentNames ?? [],
|
|
1890
|
+
reason: `parent_names would create a hierarchy cycle (${err.cycle.join(" → ")}); dropped on import`,
|
|
1891
|
+
});
|
|
1892
|
+
await store.upsertTagRecord(tagName, { ...patch, parent_names: null });
|
|
1893
|
+
} else {
|
|
1894
|
+
throw err;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1851
1897
|
stats.schemas_restored++;
|
|
1852
1898
|
}
|
|
1853
1899
|
|