@openparachute/vault 0.7.0-rc.7 → 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 +895 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp.ts +439 -49
- package/core/src/notes.ts +39 -4
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +23 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +9 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +230 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-tools.ts +36 -3
- package/src/routes.ts +363 -39
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +745 -4
package/core/src/notes.ts
CHANGED
|
@@ -851,6 +851,32 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
851
851
|
);
|
|
852
852
|
}
|
|
853
853
|
|
|
854
|
+
// Presence: has_broken_links (vault#555) — a dangling outbound wikilink or
|
|
855
|
+
// structured `links` target that never resolved. The `unresolved_wikilinks`
|
|
856
|
+
// table is created lazily (see wikilinks.ts:ensureUnresolvedTable) only when
|
|
857
|
+
// a link actually goes unresolved — a vault where nothing ever has won't
|
|
858
|
+
// have the table at all. Check existence first rather than reference it
|
|
859
|
+
// unconditionally: a read-only query filter shouldn't have the side effect
|
|
860
|
+
// of creating a table, and a bare `EXISTS`/`NOT EXISTS` against a missing
|
|
861
|
+
// table would throw "no such table" instead of the correct empty answer.
|
|
862
|
+
if (opts.hasBrokenLinks !== undefined) {
|
|
863
|
+
const unresolvedTableExists = db.prepare(
|
|
864
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'unresolved_wikilinks'",
|
|
865
|
+
).get() !== null;
|
|
866
|
+
if (!unresolvedTableExists) {
|
|
867
|
+
// No table yet means no note has ever had a broken link:
|
|
868
|
+
// hasBrokenLinks:true matches nothing; hasBrokenLinks:false is a
|
|
869
|
+
// no-op (every note already qualifies) — add no condition at all.
|
|
870
|
+
if (opts.hasBrokenLinks) conditions.push("0 = 1");
|
|
871
|
+
} else {
|
|
872
|
+
conditions.push(
|
|
873
|
+
opts.hasBrokenLinks
|
|
874
|
+
? `EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`
|
|
875
|
+
: `NOT EXISTS (SELECT 1 FROM unresolved_wikilinks ubl WHERE ubl.source_id = n.id)`,
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
854
880
|
// ID set filter — used by `near` to push neighborhood scoping into SQL so
|
|
855
881
|
// that LIMIT applies to the neighborhood, not the whole notes table.
|
|
856
882
|
if (opts.ids !== undefined) {
|
|
@@ -1238,6 +1264,7 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
|
|
|
1238
1264
|
excludeTags: opts.excludeTags,
|
|
1239
1265
|
hasTags: opts.hasTags,
|
|
1240
1266
|
hasLinks: opts.hasLinks,
|
|
1267
|
+
hasBrokenLinks: opts.hasBrokenLinks,
|
|
1241
1268
|
path: opts.path,
|
|
1242
1269
|
pathPrefix: opts.pathPrefix,
|
|
1243
1270
|
extension: opts.extension,
|
|
@@ -1926,11 +1953,17 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1926
1953
|
const candidates = db
|
|
1927
1954
|
.prepare(`SELECT id, content FROM notes WHERE content IS NOT NULL AND content != '' AND (${orClauses})`)
|
|
1928
1955
|
.all(...params) as { id: string; content: string }[];
|
|
1929
|
-
|
|
1956
|
+
// vault#555 fix 2 — this content rewrite is a real persisted-state
|
|
1957
|
+
// change (the note's `#oldtag` references literally became
|
|
1958
|
+
// `#newtag`) and must bump `updated_at` like any other content
|
|
1959
|
+
// write, or a cursor/sync-poll loop never sees it. Shares the one
|
|
1960
|
+
// `now` timestamp for the whole cascade (same convention as the
|
|
1961
|
+
// tag-row rename pass above).
|
|
1962
|
+
const updateStmt = db.prepare("UPDATE notes SET content = ?, updated_at = ? WHERE id = ?");
|
|
1930
1963
|
for (const row of candidates) {
|
|
1931
1964
|
const next = rewriteNoteBody(row.content, renames);
|
|
1932
1965
|
if (next === row.content) continue;
|
|
1933
|
-
updateStmt.run(next, row.id);
|
|
1966
|
+
updateStmt.run(next, now, row.id);
|
|
1934
1967
|
notesRewritten++;
|
|
1935
1968
|
}
|
|
1936
1969
|
}
|
|
@@ -1945,11 +1978,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
|
|
|
1945
1978
|
const candidates = db
|
|
1946
1979
|
.prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
|
|
1947
1980
|
.all(...params) as { id: string; path: string }[];
|
|
1948
|
-
|
|
1981
|
+
// vault#555 fix 2 — same reasoning as the content rewrite above: a
|
|
1982
|
+
// path rewrite is a real persisted-state change.
|
|
1983
|
+
const updateStmt = db.prepare("UPDATE notes SET path = ?, updated_at = ? WHERE id = ?");
|
|
1949
1984
|
for (const row of candidates) {
|
|
1950
1985
|
const next = rewriteTagConfigPath(row.path, renames);
|
|
1951
1986
|
if (next === row.path) continue;
|
|
1952
|
-
updateStmt.run(next, row.id);
|
|
1987
|
+
updateStmt.run(next, now, row.id);
|
|
1953
1988
|
pathsRenamed++;
|
|
1954
1989
|
}
|
|
1955
1990
|
}
|
|
@@ -291,6 +291,20 @@ describe("getting-started pack", () => {
|
|
|
291
291
|
expect(GETTING_STARTED_CONTENT).toContain("over MCP");
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
+
test("Getting Started carries the journaling / agent-memory / search conventions (vault#555)", () => {
|
|
295
|
+
expect(GETTING_STARTED_CONTENT).toContain("## A few shapes worth reusing");
|
|
296
|
+
// Journaling: indexed entry_date + mood enum.
|
|
297
|
+
expect(GETTING_STARTED_CONTENT).toContain("entry_date");
|
|
298
|
+
expect(GETTING_STARTED_CONTENT).toContain("mood");
|
|
299
|
+
// Agent-memory pattern: thread + messages + the if_exists:"ignore" crash-replay recipe.
|
|
300
|
+
expect(GETTING_STARTED_CONTENT).toContain("#thread");
|
|
301
|
+
expect(GETTING_STARTED_CONTENT).toContain("#message");
|
|
302
|
+
expect(GETTING_STARTED_CONTENT).toContain('if_exists: "ignore"');
|
|
303
|
+
// Search one-liner: literal by default, search_mode:"advanced" for FTS syntax.
|
|
304
|
+
expect(GETTING_STARTED_CONTENT).toContain("literal by default");
|
|
305
|
+
expect(GETTING_STARTED_CONTENT).toContain('search_mode: "advanced"');
|
|
306
|
+
});
|
|
307
|
+
|
|
294
308
|
test("default vault-description constants orient + tell the AI to self-replace them", () => {
|
|
295
309
|
for (const d of [DEFAULT_VAULT_DESCRIPTION, IMPORTED_VAULT_DESCRIPTION]) {
|
|
296
310
|
expect(d.length).toBeGreaterThan(100);
|
package/core/src/seed-packs.ts
CHANGED
|
@@ -481,6 +481,29 @@ support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
|
|
|
481
481
|
\`order_by\`; all tags declaring the same field must agree on its \`type\` and
|
|
482
482
|
\`indexed\` flag.
|
|
483
483
|
|
|
484
|
+
## A few shapes worth reusing
|
|
485
|
+
|
|
486
|
+
Testers of this vault independently reinvented these — start from them instead:
|
|
487
|
+
|
|
488
|
+
- **Journaling.** One tag (\`#journal\`) with an indexed \`entry_date\`
|
|
489
|
+
(\`{ type: "string", indexed: true }\`, ISO date) and a \`mood\` enum
|
|
490
|
+
(\`{ type: "string", enum: [...] }\`). Indexing \`entry_date\` (see "Only
|
|
491
|
+
\`indexed: true\` fields support operator queries" above) is what makes
|
|
492
|
+
date-range queries (\`metadata: { entry_date: { gte: "2026-01-01" } }\`) and
|
|
493
|
+
\`order_by: "entry_date"\` work.
|
|
494
|
+
- **Agent memory (thread + messages, crash-replay-safe).** Model a
|
|
495
|
+
conversation as one \`#thread\` note (metadata: \`status\`, \`cursor\`) plus many
|
|
496
|
+
\`#message\` notes linked to it (\`relationship: "in-thread"\`), each carrying
|
|
497
|
+
its own \`status\`. If your write loop can crash and replay the same message,
|
|
498
|
+
give it a stable \`path\` (e.g. \`Threads/<thread-id>/msg-<n>\`) and create it
|
|
499
|
+
with \`if_exists: "ignore"\` — a retry after a crash safely returns the
|
|
500
|
+
already-written message instead of creating a duplicate, no
|
|
501
|
+
query-before-write round trip needed.
|
|
502
|
+
- **Search.** \`query-notes { search: "..." }\` is literal by default — your
|
|
503
|
+
text is escaped, not parsed as FTS5 syntax, so punctuation like "didn't" or
|
|
504
|
+
"18.6" just works. Pass \`search_mode: "advanced"\` only when you actually
|
|
505
|
+
want FTS5 boolean/phrase/prefix syntax.
|
|
506
|
+
|
|
484
507
|
## Write gotchas
|
|
485
508
|
|
|
486
509
|
A few behaviors worth knowing before you write at scale:
|
package/core/src/store.ts
CHANGED
|
@@ -741,22 +741,29 @@ export class BunSqliteStore implements Store {
|
|
|
741
741
|
// Throws IndexedFieldError on an invalid identifier (e.g. kebab-case).
|
|
742
742
|
indexedFieldOps.validateFieldName(fieldName);
|
|
743
743
|
}
|
|
744
|
-
// Default-conformance pre-validate (vault#553
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// regardless of queryability. This is
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
// pre-
|
|
755
|
-
//
|
|
744
|
+
// Default-conformance + type-vocabulary pre-validate (vault#553
|
|
745
|
+
// Decision B; vault#555 fix 4/5) — mirrors the indexed-type/name
|
|
746
|
+
// checks above: fail BEFORE any persistence so a bad `default` or an
|
|
747
|
+
// unrecognized `type` never gets written. Runs over EVERY field in the
|
|
748
|
+
// full (already-merged) `nextFields` map, not just indexed ones — both
|
|
749
|
+
// are tag-schema errors regardless of queryability. This is a
|
|
750
|
+
// DEFENSE-IN-DEPTH backstop, not the primary user-facing gate: both
|
|
751
|
+
// REST's `PUT /api/tags/:name` (`collectCrossTagFieldViolations` +
|
|
752
|
+
// `collectOwnFieldDefaultAndTypeViolations`, bundled 422) and MCP's
|
|
753
|
+
// `update-tag` (`collectTagFieldViolations`, same bundle) now
|
|
754
|
+
// pre-validate every field and report ALL violations together BEFORE
|
|
755
|
+
// ever reaching this chokepoint — a conforming call never trips this
|
|
756
|
+
// fail-fast loop. It stays here so any OTHER caller of
|
|
757
|
+
// `store.upsertTagRecord` (imports, migrations, scripts) still fails
|
|
758
|
+
// closed rather than persisting a lying schema.
|
|
756
759
|
for (const [fieldName, spec] of Object.entries(nextFields ?? {})) {
|
|
757
|
-
const
|
|
758
|
-
if (
|
|
759
|
-
throw new tagSchemaOps.
|
|
760
|
+
const typeViolation = tagSchemaOps.validateFieldType(fieldName, spec);
|
|
761
|
+
if (typeViolation) {
|
|
762
|
+
throw new tagSchemaOps.InvalidFieldTypeError(fieldName, spec.type);
|
|
763
|
+
}
|
|
764
|
+
const defaultViolation = tagSchemaOps.validateFieldDefault(fieldName, spec);
|
|
765
|
+
if (defaultViolation) {
|
|
766
|
+
throw new tagSchemaOps.InvalidFieldDefaultError(fieldName, defaultViolation.message);
|
|
760
767
|
}
|
|
761
768
|
}
|
|
762
769
|
}
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -190,6 +190,33 @@ export class InvalidFieldDefaultError extends Error {
|
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Thrown by `upsertTagRecord` (core/src/store.ts's chokepoint) when a
|
|
195
|
+
* field's declared `type` isn't one of the six recognized values (vault#555
|
|
196
|
+
* — `update-tag{fields:{weird:{type:"frobnicator"}}}` used to be accepted
|
|
197
|
+
* and persisted verbatim, no error, for any NON-indexed field: the only
|
|
198
|
+
* existing type check, `mapFieldType`, ran solely on `indexed: true` fields
|
|
199
|
+
* — see {@link validateFieldType}'s doc comment). Own-field validation leaf,
|
|
200
|
+
* same posture as `InvalidFieldDefaultError`.
|
|
201
|
+
*/
|
|
202
|
+
export class InvalidFieldTypeError extends Error {
|
|
203
|
+
code = "INVALID_FIELD_TYPE" as const;
|
|
204
|
+
error_type = "invalid_field_type" as const;
|
|
205
|
+
field: string;
|
|
206
|
+
type: string;
|
|
207
|
+
valid_types: readonly string[];
|
|
208
|
+
|
|
209
|
+
constructor(field: string, type: string) {
|
|
210
|
+
super(
|
|
211
|
+
`field "${field}" declares unknown type "${type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
|
|
212
|
+
);
|
|
213
|
+
this.name = "InvalidFieldTypeError";
|
|
214
|
+
this.field = field;
|
|
215
|
+
this.type = type;
|
|
216
|
+
this.valid_types = VALID_FIELD_TYPES;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
193
220
|
/**
|
|
194
221
|
* Validate that a field's declared `default` (vault#553 Decision B) conforms
|
|
195
222
|
* to its own `type` and (when declared) `enum`. Returns `null` when the
|
|
@@ -226,6 +253,73 @@ export function validateFieldDefault(field: string, spec: TagFieldSchema): TagFi
|
|
|
226
253
|
return null;
|
|
227
254
|
}
|
|
228
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The full recognized vocabulary for `TagFieldSchema.type` — storage/
|
|
258
|
+
* advisory validation accepts all six; only `string`/`integer`/`boolean`
|
|
259
|
+
* are INDEXABLE (that narrower subset is `indexed-fields.ts`'s `TYPE_MAP`,
|
|
260
|
+
* enforced separately via `mapFieldType` for `indexed: true` fields).
|
|
261
|
+
* Matches `defaultMatchesType`'s switch and `schema-defaults.ts`'s
|
|
262
|
+
* `SchemaField.type` union — kept in lockstep by hand across the two
|
|
263
|
+
* deliberately-decoupled modules (see `validateFieldDefault`'s doc comment
|
|
264
|
+
* for why they don't cross-import).
|
|
265
|
+
*/
|
|
266
|
+
export const VALID_FIELD_TYPES = ["string", "number", "integer", "boolean", "array", "object"] as const;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Validate that a field's declared `type` is one of the six recognized
|
|
270
|
+
* values (vault#555). Returns `null` when `type` is unset (own-field checks
|
|
271
|
+
* elsewhere already treat an unset type as "nothing to check against") or
|
|
272
|
+
* recognized; otherwise a {@link TagFieldViolation} with `reason:
|
|
273
|
+
* "invalid_type"`.
|
|
274
|
+
*
|
|
275
|
+
* Before this, a non-indexed field's `type` was NEVER validated anywhere —
|
|
276
|
+
* `mapFieldType` (the only existing type check) runs solely on fields
|
|
277
|
+
* declaring `indexed: true`. `update-tag{fields:{weird:{type:"frobnicator"}}}`
|
|
278
|
+
* on a plain (non-indexed) field silently persisted the bogus type
|
|
279
|
+
* verbatim; every later read of that field just skipped validation
|
|
280
|
+
* entirely (`valueMatchesType` in schema-defaults.ts has no vocabulary
|
|
281
|
+
* entry for it either). This is now the SINGLE gate — indexed fields still
|
|
282
|
+
* get their own, narrower `unsupported_indexed_type` check (which also
|
|
283
|
+
* covers a RECOGNIZED-but-unindexable type like `"array"`, a different
|
|
284
|
+
* case from a genuinely unknown token).
|
|
285
|
+
*
|
|
286
|
+
* Pure — never throws; callers ({@link collectTagFieldViolations}'s bundled
|
|
287
|
+
* MCP/REST report, and the store chokepoint's defense-in-depth pre-validate)
|
|
288
|
+
* each decide how to surface it.
|
|
289
|
+
*/
|
|
290
|
+
export function validateFieldType(field: string, spec: TagFieldSchema): TagFieldViolation | null {
|
|
291
|
+
if (!spec.type) return null;
|
|
292
|
+
if ((VALID_FIELD_TYPES as readonly string[]).includes(spec.type)) return null;
|
|
293
|
+
return {
|
|
294
|
+
field,
|
|
295
|
+
reason: "invalid_type",
|
|
296
|
+
message: `field "${field}" declares unknown type "${spec.type}" — must be one of [${VALID_FIELD_TYPES.join(", ")}]`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Combined own-field `default` + `type` violation collection — no cross-tag
|
|
302
|
+
* lookups needed, so no `db` parameter (unlike
|
|
303
|
+
* {@link collectCrossTagFieldViolations}). Used by REST's `PUT
|
|
304
|
+
* /api/tags/:name` (vault#555 fix — folds these into the SAME bundled
|
|
305
|
+
* `tag_field_conflict` 422 report as the cross-tag checks, replacing the
|
|
306
|
+
* old fail-fast single-violation `invalid_field_default` 400) and reused by
|
|
307
|
+
* {@link collectTagFieldViolations} (MCP) so the two surfaces can't drift on
|
|
308
|
+
* what counts as an own-field violation.
|
|
309
|
+
*/
|
|
310
|
+
export function collectOwnFieldDefaultAndTypeViolations(
|
|
311
|
+
incomingFields: Record<string, TagFieldSchema>,
|
|
312
|
+
): TagFieldViolation[] {
|
|
313
|
+
const violations: TagFieldViolation[] = [];
|
|
314
|
+
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
315
|
+
const typeViolation = validateFieldType(fieldName, spec);
|
|
316
|
+
if (typeViolation) violations.push(typeViolation);
|
|
317
|
+
const defaultViolation = validateFieldDefault(fieldName, spec);
|
|
318
|
+
if (defaultViolation) violations.push(defaultViolation);
|
|
319
|
+
}
|
|
320
|
+
return violations;
|
|
321
|
+
}
|
|
322
|
+
|
|
229
323
|
/** Same six-type vocabulary as `TagFieldSchema.type`'s doc comment. Unknown/unset types pass (nothing to check against). */
|
|
230
324
|
function defaultMatchesType(value: unknown, type: string): boolean {
|
|
231
325
|
switch (type) {
|
|
@@ -507,7 +601,8 @@ export interface TagFieldViolation {
|
|
|
507
601
|
| "indexed_flag_conflict"
|
|
508
602
|
| "unsupported_indexed_type"
|
|
509
603
|
| "invalid_field_name"
|
|
510
|
-
| "invalid_default"
|
|
604
|
+
| "invalid_default"
|
|
605
|
+
| "invalid_type";
|
|
511
606
|
message: string;
|
|
512
607
|
/**
|
|
513
608
|
* The conflicting declarer tag — present on the cross-tag reasons
|
|
@@ -625,18 +720,24 @@ export function collectCrossTagFieldViolations(
|
|
|
625
720
|
|
|
626
721
|
/**
|
|
627
722
|
* Full field-violation collection: {@link collectCrossTagFieldViolations}
|
|
628
|
-
* PLUS
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
* `
|
|
723
|
+
* PLUS EVERY own-field check — {@link collectOwnFieldDefaultAndTypeViolations}
|
|
724
|
+
* (a non-conforming `default`, vault#553 Decision B; an unrecognized `type`,
|
|
725
|
+
* vault#555) AND the indexed-only checks (unsupported type for indexing,
|
|
726
|
+
* invalid identifier). Used by the MCP `update-tag` tool, which — unlike
|
|
727
|
+
* REST's indexed-type/name path — had no prior single-violation
|
|
728
|
+
* status-code contract to preserve for those two (its old inline loop threw
|
|
729
|
+
* an unstructured `Error` for them, same as everything else pre-#554);
|
|
730
|
+
* collecting everything here is a strict improvement. See
|
|
731
|
+
* {@link collectCrossTagFieldViolations}'s doc comment for why REST calls
|
|
732
|
+
* that narrower function directly for the CROSS-tag checks, then ALSO calls
|
|
733
|
+
* {@link collectOwnFieldDefaultAndTypeViolations} itself (vault#555 fix 5 —
|
|
734
|
+
* REST used to get `invalid_default` coverage only via
|
|
735
|
+
* `store.upsertTagRecord`'s fail-fast pre-validate, a single-violation
|
|
736
|
+
* `InvalidFieldDefaultError` → 400 that silently dropped every OTHER bad
|
|
737
|
+
* field in the same call; both surfaces now report every own-field default/
|
|
738
|
+
* type violation together, bundled with the cross-tag ones) — the
|
|
739
|
+
* indexed-type/name pair stays REST's own single-violation `400
|
|
740
|
+
* invalid_indexed_field` path (unchanged wire contract, vault#478).
|
|
640
741
|
*/
|
|
641
742
|
export function collectTagFieldViolations(
|
|
642
743
|
db: Database,
|
|
@@ -644,14 +745,9 @@ export function collectTagFieldViolations(
|
|
|
644
745
|
incomingFields: Record<string, TagFieldSchema>,
|
|
645
746
|
): TagFieldViolation[] {
|
|
646
747
|
const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
|
|
748
|
+
violations.push(...collectOwnFieldDefaultAndTypeViolations(incomingFields));
|
|
647
749
|
|
|
648
750
|
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
649
|
-
// Own-field default-conformance check (vault#553 Decision B) — applies
|
|
650
|
-
// to EVERY field (not just indexed ones); a bad default is a tag-schema
|
|
651
|
-
// error regardless of whether the field is queryable.
|
|
652
|
-
const defaultViolation = validateFieldDefault(fieldName, spec);
|
|
653
|
-
if (defaultViolation) violations.push(defaultViolation);
|
|
654
|
-
|
|
655
751
|
if (spec.indexed === true) {
|
|
656
752
|
const mapped = mapFieldType(spec.type);
|
|
657
753
|
if (!mapped) {
|
package/core/src/types.ts
CHANGED
|
@@ -141,6 +141,15 @@ export interface QueryOpts {
|
|
|
141
141
|
// `hasLinks` checks both directions — inbound or outbound counts as "has links".
|
|
142
142
|
hasTags?: boolean;
|
|
143
143
|
hasLinks?: boolean;
|
|
144
|
+
/**
|
|
145
|
+
* Presence filter on the `unresolved_wikilinks` table (vault#555):
|
|
146
|
+
* `true` → only notes with at least one dangling outbound link (a
|
|
147
|
+
* `[[wikilink]]` or structured `links` target that never resolved to a
|
|
148
|
+
* note); `false` → only notes with none. Safe on a vault where the
|
|
149
|
+
* `unresolved_wikilinks` table has never been created (no note has ever
|
|
150
|
+
* had a broken link) — `true` matches nothing, `false` is a no-op.
|
|
151
|
+
*/
|
|
152
|
+
hasBrokenLinks?: boolean;
|
|
144
153
|
path?: string; // exact path match (case-insensitive)
|
|
145
154
|
pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
|
|
146
155
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import { SqliteStore } from "./store.js";
|
|
4
|
-
import { parseWikilinks, syncWikilinks, resolveWikilink, resolveUnresolvedWikilinks } from "./wikilinks.js";
|
|
4
|
+
import { parseWikilinks, syncWikilinks, resolveWikilink, resolveUnresolvedWikilinks, listUnresolvedWikilinks } from "./wikilinks.js";
|
|
5
5
|
|
|
6
6
|
let store: SqliteStore;
|
|
7
7
|
let db: Database;
|
|
@@ -275,3 +275,115 @@ describe("path-based resolution", async () => {
|
|
|
275
275
|
expect(links[0].targetId).toBe(target.id);
|
|
276
276
|
});
|
|
277
277
|
});
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// unresolved_wikilinks relationship-column migration — atomicity (vault#555
|
|
281
|
+
// wire+generalist must-fix; W7's migrateToV25-interruption lesson applied).
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
describe("ensureRelationshipColumn — crash-safe rebuild", () => {
|
|
285
|
+
/**
|
|
286
|
+
* Build a legacy (pre-vault#555) 2-column `unresolved_wikilinks` table with
|
|
287
|
+
* pending rows, on a store whose other tables already exist. Returns the
|
|
288
|
+
* source note's id (a real row so the FK is satisfiable when
|
|
289
|
+
* foreign_keys is on).
|
|
290
|
+
*/
|
|
291
|
+
async function seedLegacyTable(): Promise<string> {
|
|
292
|
+
// A plain note (no wikilinks) doesn't create the v555 table.
|
|
293
|
+
const src = await store.createNote("plain body, no wikilinks", { path: "src-note" });
|
|
294
|
+
const tgt = await store.createNote("plain target", { path: "Target A" });
|
|
295
|
+
// Hand-build the pre-v555 shape (2-column PK, no `relationship`).
|
|
296
|
+
db.exec(`
|
|
297
|
+
CREATE TABLE unresolved_wikilinks (
|
|
298
|
+
source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
|
299
|
+
target_path TEXT NOT NULL COLLATE NOCASE,
|
|
300
|
+
PRIMARY KEY (source_id, target_path)
|
|
301
|
+
)
|
|
302
|
+
`);
|
|
303
|
+
db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)")
|
|
304
|
+
.run(src.id, "Target B");
|
|
305
|
+
db.prepare("INSERT INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)")
|
|
306
|
+
.run(src.id, "Target A"); // this one resolves to tgt after the migration
|
|
307
|
+
return src.id;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function hasRelationshipColumn(): boolean {
|
|
311
|
+
const cols = db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as { name: string }[];
|
|
312
|
+
return cols.some((c) => c.name === "relationship");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function tableExists(name: string): boolean {
|
|
316
|
+
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name);
|
|
317
|
+
return row !== null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
it("a crash mid-rebuild (between RENAME and CREATE) rolls back — original table + pending rows intact, no orphan _pre_v555", async () => {
|
|
321
|
+
await seedLegacyTable();
|
|
322
|
+
expect(hasRelationshipColumn()).toBe(false); // legacy shape confirmed
|
|
323
|
+
const rowsBefore = (db.prepare("SELECT COUNT(*) AS c FROM unresolved_wikilinks").get() as { c: number }).c;
|
|
324
|
+
expect(rowsBefore).toBe(2);
|
|
325
|
+
|
|
326
|
+
// Monkey-patch db.exec to throw on the migration's CREATE — i.e. AFTER
|
|
327
|
+
// the RENAME has moved the table to _pre_v555 but BEFORE the new table
|
|
328
|
+
// exists. This is the exact interruption window the transaction wrapper
|
|
329
|
+
// must survive. BEGIN/RENAME/ROLLBACK all pass through untouched.
|
|
330
|
+
const origExec = db.exec.bind(db);
|
|
331
|
+
let crashed = false;
|
|
332
|
+
(db as unknown as { exec: (sql: string) => unknown }).exec = (sql: string) => {
|
|
333
|
+
if (!crashed && /CREATE TABLE unresolved_wikilinks \(/.test(sql)) {
|
|
334
|
+
crashed = true;
|
|
335
|
+
throw new Error("simulated crash between RENAME and CREATE");
|
|
336
|
+
}
|
|
337
|
+
return origExec(sql);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
let thrown: unknown;
|
|
341
|
+
try {
|
|
342
|
+
// resolveUnresolvedWikilinks calls ensureRelationshipColumn first —
|
|
343
|
+
// the REAL heal path, not a hand-rolled copy.
|
|
344
|
+
resolveUnresolvedWikilinks(db, "Target A", "irrelevant-id");
|
|
345
|
+
} catch (e) {
|
|
346
|
+
thrown = e;
|
|
347
|
+
} finally {
|
|
348
|
+
(db as unknown as { exec: (sql: string) => unknown }).exec = origExec;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// The crash propagated (not swallowed).
|
|
352
|
+
expect(crashed).toBe(true);
|
|
353
|
+
expect((thrown as Error)?.message).toContain("simulated crash");
|
|
354
|
+
|
|
355
|
+
// ROLLBACK restored the ORIGINAL table exactly:
|
|
356
|
+
expect(tableExists("unresolved_wikilinks")).toBe(true); // NOT renamed away
|
|
357
|
+
expect(tableExists("unresolved_wikilinks_pre_v555")).toBe(false); // no orphan
|
|
358
|
+
expect(hasRelationshipColumn()).toBe(false); // still the legacy 2-col shape
|
|
359
|
+
const rowsAfter = (db.prepare("SELECT COUNT(*) AS c FROM unresolved_wikilinks").get() as { c: number }).c;
|
|
360
|
+
expect(rowsAfter).toBe(2); // pending rows NOT lost
|
|
361
|
+
|
|
362
|
+
// And a clean retry (no crash) fully recovers: migration runs, column
|
|
363
|
+
// added, rows preserved and backfilled as "wikilink".
|
|
364
|
+
resolveUnresolvedWikilinks(db, "nothing-matches-here", "irrelevant-id-2");
|
|
365
|
+
expect(hasRelationshipColumn()).toBe(true);
|
|
366
|
+
expect(tableExists("unresolved_wikilinks_pre_v555")).toBe(false);
|
|
367
|
+
const migratedRows = db.prepare("SELECT relationship FROM unresolved_wikilinks").all() as { relationship: string }[];
|
|
368
|
+
expect(migratedRows).toHaveLength(2);
|
|
369
|
+
expect(migratedRows.every((r) => r.relationship === "wikilink")).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("a successful (uninterrupted) rebuild migrates + backfills as wikilink, and a pending forward-ref then resolves", async () => {
|
|
373
|
+
const srcId = await seedLegacyTable();
|
|
374
|
+
|
|
375
|
+
// Drive the heal through the real read path.
|
|
376
|
+
const before = listUnresolvedWikilinks(db);
|
|
377
|
+
expect(before.count).toBe(2);
|
|
378
|
+
expect(hasRelationshipColumn()).toBe(true); // listUnresolvedWikilinks healed it
|
|
379
|
+
expect(before.unresolved.every((u) => u.relationship === "wikilink")).toBe(true);
|
|
380
|
+
|
|
381
|
+
// "Target A" already exists (seedLegacyTable created it) — resolving now
|
|
382
|
+
// backfills the edge from the migrated pending row.
|
|
383
|
+
const targetA = await store.getNoteByPath("Target A");
|
|
384
|
+
const resolved = resolveUnresolvedWikilinks(db, "Target A", targetA!.id);
|
|
385
|
+
expect(resolved).toBe(1);
|
|
386
|
+
const links = await store.getLinks(srcId, { direction: "outbound" });
|
|
387
|
+
expect(links.some((l) => l.targetId === targetA!.id && l.relationship === "wikilink")).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
});
|