@openparachute/vault 0.4.0 → 0.4.4-rc.11

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.
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
  import { rebuildIndexes } from "./indexed-fields.js";
4
4
 
5
- export const SCHEMA_VERSION = 16;
5
+ export const SCHEMA_VERSION = 17;
6
6
 
7
7
  export const SCHEMA_SQL = `
8
8
  -- Notes: the universal record
@@ -69,37 +69,12 @@ CREATE TABLE IF NOT EXISTS links (
69
69
  -- tags row directly. The CREATE TABLE was removed from SCHEMA_SQL after the
70
70
  -- v14 data migration drops the table; existing v6+ vaults pick up the
71
71
  -- migration on next boot. See migrateToV14.
72
-
73
- -- Note schemas (v15): schema definitions used to validate notes by path
74
- -- prefix or tag. Replaces the v6-era _schemas/NAME notes-as-config
75
- -- convention. Validation is non-blocking — schemas surface warnings on
76
- -- create/update responses, never reject the write. See
77
- -- core/src/schema-defaults.ts and patterns/tag-data-model.md §Note schemas.
78
72
  --
79
- -- name — primary key; the schema identifier referenced by mappings.
80
- -- description — human-readable blurb (markdown).
81
- -- fields JSON: { fieldName: { type?, enum?, description? } }.
82
- -- required — JSON: string[] of required field names.
83
- CREATE TABLE IF NOT EXISTS note_schemas (
84
- name TEXT PRIMARY KEY,
85
- description TEXT,
86
- fields TEXT,
87
- required TEXT,
88
- created_at TEXT,
89
- updated_at TEXT
90
- );
91
-
92
- -- Schema mappings (v15): replaces the singleton _schema_defaults note. One
93
- -- row per match rule; the resolver walks the table at note-write time.
94
- -- match_kind is constrained to 'path_prefix' or 'tag'. Composite PK so
95
- -- (schema, kind, value) is naturally unique without an extra surrogate id.
96
- -- ON DELETE CASCADE: dropping a schema cleans up its mappings.
97
- CREATE TABLE IF NOT EXISTS schema_mappings (
98
- schema_name TEXT NOT NULL REFERENCES note_schemas(name) ON DELETE CASCADE,
99
- match_kind TEXT NOT NULL CHECK (match_kind IN ('path_prefix', 'tag')),
100
- match_value TEXT NOT NULL,
101
- PRIMARY KEY (schema_name, match_kind, match_value)
102
- );
73
+ -- note_schemas + schema_mappings (v15) were retired in v17 (vault#267).
74
+ -- The two-table validation subsystem turned out to be a parallel path to
75
+ -- the per-tag fields column with zero operator usage; v17 drops both
76
+ -- tables and the six MCP tools that managed them. Validation now reads
77
+ -- tags.fields exclusively see core/src/schema-defaults.ts.
103
78
 
104
79
  -- Indexed fields: SSOT for generated columns and indexes on notes derived
105
80
  -- from tag-declared fields with indexed=true. One row per indexed metadata
@@ -209,7 +184,6 @@ CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_name, note_id);
209
184
  CREATE INDEX IF NOT EXISTS idx_attachments_note ON attachments(note_id);
210
185
  CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_id);
211
186
  CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_id);
212
- CREATE INDEX IF NOT EXISTS idx_schema_mappings_match ON schema_mappings(match_kind, match_value);
213
187
  -- idx_tokens_vault_name is created in migrateToV16, not here. SCHEMA_SQL
214
188
  -- runs BEFORE migrations; an upgrading v15 vault doesn't yet have the
215
189
  -- vault_name column when this section evaluates, so the index has to
@@ -285,6 +259,13 @@ export function initSchema(db: Database): void {
285
259
  // New mints via per-vault routes write the column explicitly. See vault#257.
286
260
  migrateToV16(db);
287
261
 
262
+ // Migrate v16 → v17: rip the standalone `note_schemas` + `schema_mappings`
263
+ // subsystem. Validation now reads `tags.fields` exclusively. The two
264
+ // tables are dropped wholesale; if a vault carried rows we log a warning
265
+ // naming the dropped schemas/mappings so the operator can recreate them
266
+ // as `tags.fields` if needed. See vault#267.
267
+ migrateToV17(db);
268
+
288
269
  // Rebuild any generated columns + indexes declared in indexed_fields.
289
270
  // No-op for a fresh vault; idempotent on existing vaults.
290
271
  rebuildIndexes(db);
@@ -572,6 +553,10 @@ function migrateToV14(db: Database): void {
572
553
  * leaves the DB in either pre-v15 or post-v15 state, never partial.
573
554
  */
574
555
  function migrateToV15(db: Database): void {
556
+ // note_schemas was dropped in v17; on any v17+ vault (or a v14→v17 skip),
557
+ // this guard returns immediately and the function is effectively dead code.
558
+ // Left in place rather than deleted because removing it would change initSchema's
559
+ // migration call ordering. Safe to delete in a future cleanup.
575
560
  if (!hasTable(db, "note_schemas") || !hasTable(db, "notes")) return;
576
561
 
577
562
  // Short-circuit: if either destination table already has data, the
@@ -721,6 +706,93 @@ function migrateToV16(db: Database): void {
721
706
  db.exec("CREATE INDEX IF NOT EXISTS idx_tokens_vault_name ON tokens(vault_name)");
722
707
  }
723
708
 
709
+ /**
710
+ * Migrate v16 → v17: rip `note_schemas` + `schema_mappings` (vault#267).
711
+ *
712
+ * The two-table validation subsystem from v15 turned out to be a parallel
713
+ * path to `tags.fields` with zero operator usage. v17 drops both tables
714
+ * outright. Fresh vaults never see them; upgrading vaults lose them.
715
+ *
716
+ * If an upgrading vault DID carry rows (which Aaron's didn't, but a future
717
+ * operator's might), the migration logs the dropped names + mapping rules
718
+ * so the operator can re-create them as `tags.fields` declarations on the
719
+ * relevant tag rows. We don't try to auto-migrate path_prefix mappings —
720
+ * the new validation surface is tag-axis only, and a path_prefix → tag
721
+ * translation has no faithful one-to-one shape.
722
+ *
723
+ * Wrapped in BEGIN IMMEDIATE / COMMIT / ROLLBACK per the v14/v15/v16
724
+ * pattern from vault#251 — DROP TABLE statements are individually atomic,
725
+ * but the wrap means a crash mid-migration leaves either pre-v17 or
726
+ * post-v17 state, never partial.
727
+ */
728
+ function migrateToV17(db: Database): void {
729
+ const hasNoteSchemas = hasTable(db, "note_schemas");
730
+ const hasSchemaMappings = hasTable(db, "schema_mappings");
731
+ if (!hasNoteSchemas && !hasSchemaMappings) return;
732
+
733
+ // Snapshot any data so the operator can recreate as `tags.fields` if
734
+ // needed. Read BEFORE the transaction so we don't lose the warning if
735
+ // the DROP fails (the COMMIT below atomically swaps state).
736
+ let droppedSchemas: { name: string; description: string | null }[] = [];
737
+ let droppedMappings: { schema_name: string; match_kind: string; match_value: string }[] = [];
738
+ if (hasNoteSchemas) {
739
+ droppedSchemas = db.prepare(
740
+ "SELECT name, description FROM note_schemas",
741
+ ).all() as { name: string; description: string | null }[];
742
+ }
743
+ if (hasSchemaMappings) {
744
+ droppedMappings = db.prepare(
745
+ "SELECT schema_name, match_kind, match_value FROM schema_mappings",
746
+ ).all() as { schema_name: string; match_kind: string; match_value: string }[];
747
+ }
748
+
749
+ db.exec("BEGIN IMMEDIATE");
750
+ try {
751
+ // Drop the index first — the index references the table; SQLite would
752
+ // tear it down on DROP TABLE but the explicit DROP keeps the order
753
+ // obvious if a future migration reads from sqlite_master mid-flight.
754
+ db.exec("DROP INDEX IF EXISTS idx_schema_mappings_match");
755
+ // schema_mappings has an FK to note_schemas — drop it first.
756
+ if (hasSchemaMappings) {
757
+ db.exec("DROP TABLE schema_mappings");
758
+ }
759
+ if (hasNoteSchemas) {
760
+ db.exec("DROP TABLE note_schemas");
761
+ }
762
+ db.exec("COMMIT");
763
+ } catch (err) {
764
+ db.exec("ROLLBACK");
765
+ throw err;
766
+ }
767
+
768
+ if (droppedSchemas.length > 0 || droppedMappings.length > 0) {
769
+ const schemaNames = droppedSchemas.map((s) => s.name).join(", ");
770
+ const tagMappings = droppedMappings.filter((m) => m.match_kind === "tag");
771
+ const pathMappings = droppedMappings.filter((m) => m.match_kind === "path_prefix");
772
+ const lines: string[] = [
773
+ `[vault] migrated to schema v17 (vault#267): note_schemas + schema_mappings retired.`,
774
+ ];
775
+ if (droppedSchemas.length > 0) {
776
+ lines.push(` dropped schemas (${droppedSchemas.length}): ${schemaNames}`);
777
+ }
778
+ if (tagMappings.length > 0) {
779
+ const list = tagMappings.map((m) => `${m.match_value}→${m.schema_name}`).join(", ");
780
+ lines.push(
781
+ ` dropped tag mappings (${tagMappings.length}): ${list}`,
782
+ ` recreate as \`tags.fields\` declarations on the relevant tag rows.`,
783
+ );
784
+ }
785
+ if (pathMappings.length > 0) {
786
+ const list = pathMappings.map((m) => `${m.match_value}→${m.schema_name}`).join(", ");
787
+ lines.push(
788
+ ` dropped path_prefix mappings (${pathMappings.length}): ${list}`,
789
+ ` no path-prefix-driven validation in v17 — file vault#267 if you need this.`,
790
+ );
791
+ }
792
+ console.log(lines.join("\n"));
793
+ }
794
+ }
795
+
724
796
  function hasTable(db: Database, name: string): boolean {
725
797
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
726
798
  return !!row;
package/core/src/store.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  loadTagHierarchy,
12
12
  getTagDescendants,
13
13
  TAG_CONFIG_PREFIX,
14
+ DEFAULT_TAG_NAME,
14
15
  type TagHierarchy,
15
16
  } from "./tag-hierarchy.js";
16
17
  import {
@@ -19,7 +20,6 @@ import {
19
20
  type ResolvedSchemas,
20
21
  type ValidationStatus,
21
22
  } from "./schema-defaults.js";
22
- import * as noteSchemaOps from "./note-schemas.js";
23
23
 
24
24
  /**
25
25
  * bun:sqlite-backed Store implementation. Internally everything is
@@ -29,12 +29,11 @@ import * as noteSchemaOps from "./note-schemas.js";
29
29
  export class BunSqliteStore implements Store {
30
30
  public readonly hooks: HookRegistry;
31
31
 
32
- // Lazy-built caches over the post-v14 `tags` table (hierarchy via
33
- // parent_names) and the post-v15 `note_schemas` + `schema_mappings`
34
- // tables (validation). Null means "not yet loaded or invalidated"; the
35
- // next read rebuilds. We invalidate synchronously inside the writers
36
- // that mutate the source tables so reads after writes always see the
37
- // post-write state.
32
+ // Lazy-built caches over the post-v14 `tags` table hierarchy via
33
+ // parent_names, schema validation via `fields`. Null means "not yet
34
+ // loaded or invalidated"; the next read rebuilds. We invalidate
35
+ // synchronously inside the writers that mutate the source rows so reads
36
+ // after writes always see the post-write state.
38
37
  private _tagHierarchy: TagHierarchy | null = null;
39
38
  private _schemaConfig: ResolvedSchemas | null = null;
40
39
 
@@ -55,8 +54,8 @@ export class BunSqliteStore implements Store {
55
54
  }
56
55
 
57
56
  /**
58
- * Lazy accessor for the `note_schemas` + `schema_mappings` resolution.
59
- * Same lifecycle as the tag hierarchy cache.
57
+ * Lazy accessor for the per-tag `fields` resolution. Same lifecycle as
58
+ * the tag hierarchy cache.
60
59
  */
61
60
  private getSchemaConfig(): ResolvedSchemas {
62
61
  if (!this._schemaConfig) this._schemaConfig = loadSchemaConfig(this.db);
@@ -79,9 +78,9 @@ export class BunSqliteStore implements Store {
79
78
  * alongside new for rename cases (a note moved out of `_tags/` should
80
79
  * still invalidate).
81
80
  *
82
- * Post-v15 the schema-config cache is no longer note-driven — its
83
- * invalidation hook is on `upsertNoteSchema` / `setSchemaMapping` /
84
- * `deleteNoteSchema` / `deleteSchemaMapping` instead.
81
+ * Post-v17 the schema-config cache is purely tag-driven — its
82
+ * invalidation hook is on `upsertTagSchema` / `upsertTagRecord` /
83
+ * `deleteTagSchema` / `deleteTag` (mutations of `tags.fields`).
85
84
  */
86
85
  private invalidateConfigCachesForPath(path: string | null | undefined, oldPath?: string | null): void {
87
86
  const isTagConfig = (p: string | null | undefined): boolean =>
@@ -203,6 +202,19 @@ export class BunSqliteStore implements Store {
203
202
  }
204
203
  }
205
204
 
205
+ async restoreNoteTimestamps(id: string, createdAt: string, updatedAt: string): Promise<void> {
206
+ // Import-only: direct UPDATE so the importer can restore a note's
207
+ // historical `created_at`/`updated_at` from the portable-md export
208
+ // bytes. `updateNote` either bumps `updated_at` to wall-clock-now or
209
+ // (with `skipUpdatedAt: true`) leaves it untouched — neither lets
210
+ // the importer write a specific historical timestamp. Skips hooks
211
+ // by design: this isn't a user-edit, it's a state restoration.
212
+ // See vault#308 PR 2.
213
+ this.db
214
+ .prepare("UPDATE notes SET created_at = ?, updated_at = ? WHERE id = ?")
215
+ .run(createdAt, updatedAt, id);
216
+ }
217
+
206
218
  async deleteNote(id: string): Promise<void> {
207
219
  // Read before delete so we can invalidate config caches on the way out.
208
220
  const existing = noteOps.getNote(this.db, id);
@@ -219,16 +231,45 @@ export class BunSqliteStore implements Store {
219
231
  * each input tag is replaced with `{tag} ∪ descendants(tag)`. The SQL
220
232
  * builder uses this to widen the tag join from `name = ?` to
221
233
  * `name IN (...)`, so a query for `#manual` matches notes tagged with
222
- * any descendant declared via `_tags/*` config notes.
234
+ * any descendant declared via `tags.parent_names`.
235
+ *
236
+ * `_default` magic (vault#270): when a `_default` tag row exists in the
237
+ * vault, it's the implicit parent of every note (tagged or not). A query
238
+ * filter that names `_default` is therefore equivalent to "no tag filter
239
+ * at all" — but the precise treatment depends on `tagMatch`:
223
240
  *
224
- * No-op when no `_tags/*` notes exist (empty hierarchy each tag
225
- * expands to just itself, identical to the pre-expansion behavior).
241
+ * - `all` (default, AND-semantics): `_default` is universally satisfied,
242
+ * so it can be dropped from the tag list. Other tags' AND-semantics
243
+ * still apply. If `_default` was the only entry, drop the filter
244
+ * entirely so untagged notes match.
245
+ * - `any` (OR-semantics): `_default` matches every note, so the disjunction
246
+ * collapses to "every note." Drop the filter entirely regardless of
247
+ * what else was in the list (otherwise we'd narrow to the union of
248
+ * the other tags' notes — wrong).
249
+ *
250
+ * Other filters (path, metadata, dates) still apply in both cases.
226
251
  */
227
252
  private expandQueryTags(opts: QueryOpts): QueryOpts {
228
253
  if (!opts.tags || opts.tags.length === 0) return opts;
229
254
  const hierarchy = this.getTagHierarchy();
255
+
256
+ let tags = opts.tags;
257
+ if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && tags.includes(DEFAULT_TAG_NAME)) {
258
+ const match = opts.tagMatch ?? "all";
259
+ if (match === "any") {
260
+ const { tags: _drop, ..._rest } = opts;
261
+ return _rest as QueryOpts;
262
+ }
263
+ tags = tags.filter((t) => t !== DEFAULT_TAG_NAME);
264
+ if (tags.length === 0) {
265
+ const { tags: _drop, ..._rest } = opts;
266
+ return _rest as QueryOpts;
267
+ }
268
+ opts = { ...opts, tags };
269
+ }
270
+
230
271
  if (hierarchy.childrenOf.size === 0) return opts;
231
- const expanded = opts.tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
272
+ const expanded = tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
232
273
  return { ...opts, _tagsExpanded: expanded } as QueryOpts;
233
274
  }
234
275
 
@@ -237,9 +278,16 @@ export class BunSqliteStore implements Store {
237
278
  // should match notes tagged with any descendant tag. The underlying
238
279
  // FTS path already uses `IN (...)` for tags, so we flatten the
239
280
  // per-input expansions into a single union (search semantics are
240
- // "any tag matches").
281
+ // "any tag matches"). When `_default` is among the requested tags
282
+ // (and a `_default` row exists), the OR collapses to "every note" —
283
+ // drop the tag filter entirely so the search hits the full corpus
284
+ // and untagged notes are reachable.
241
285
  if (opts?.tags && opts.tags.length > 0) {
242
286
  const hierarchy = this.getTagHierarchy();
287
+ if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
288
+ const { tags: _drop, ..._rest } = opts;
289
+ return noteOps.searchNotes(this.db, query, _rest);
290
+ }
243
291
  if (hierarchy.childrenOf.size > 0) {
244
292
  const expanded = new Set<string>();
245
293
  for (const t of opts.tags) {
@@ -277,17 +325,22 @@ export class BunSqliteStore implements Store {
277
325
 
278
326
  async deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }> {
279
327
  const result = noteOps.deleteTag(this.db, name);
280
- // The deleted tag may have been a parent or child in the hierarchy.
328
+ // The deleted tag may have been a parent or child in the hierarchy
329
+ // and may have declared `fields` powering schema validation.
281
330
  this._tagHierarchy = null;
331
+ this._schemaConfig = null;
282
332
  return result;
283
333
  }
284
334
 
285
335
  async renameTag(oldName: string, newName: string): Promise<noteOps.RenameTagResult> {
286
336
  const result = noteOps.renameTag(this.db, oldName, newName);
287
- // Other tags' parent_names may reference oldName though we don't
288
- // rewrite those, the hierarchy cache should be rebuilt to pick up the
289
- // new row identity.
337
+ // Vault#240: the cascade rewrites parent_names in OTHER tag rows as
338
+ // part of the same transaction, plus tokens.scoped_tags and
339
+ // indexed_fields.declarer_tags. Both caches are tag-keyed, so they
340
+ // must be rebuilt regardless — the hierarchy by tag-set identity,
341
+ // the schema-config by parent_names + fields content.
290
342
  this._tagHierarchy = null;
343
+ this._schemaConfig = null;
291
344
  return result;
292
345
  }
293
346
 
@@ -297,8 +350,10 @@ export class BunSqliteStore implements Store {
297
350
  ): Promise<{ merged: Record<string, number>; target: string }> {
298
351
  const result = noteOps.mergeTags(this.db, sources, target);
299
352
  // Source tags drop out of the hierarchy; downstream callers asking
300
- // for descendants of target should pick up any merged children.
353
+ // for descendants of target should pick up any merged children. Also
354
+ // bust the schema cache — `fields` declarations follow tag identity.
301
355
  this._tagHierarchy = null;
356
+ this._schemaConfig = null;
302
357
  return result;
303
358
  }
304
359
 
@@ -332,9 +387,9 @@ export class BunSqliteStore implements Store {
332
387
  const notes = noteOps.createNotes(this.db, inputs);
333
388
  for (const note of notes) {
334
389
  // Bulk path needs the same config-cache invalidation as singleton
335
- // createNote — without it, a batch that includes `_tags/*` or
336
- // `_schemas/*` notes would leave the cache stale until the next
337
- // singleton write happened to bust it.
390
+ // createNote — without it, a batch that includes `_tags/*` notes
391
+ // would leave the hierarchy cache stale until the next singleton
392
+ // write happened to bust it.
338
393
  this.invalidateConfigCachesForPath(note.path);
339
394
  this.hooks.dispatch("created", note, this);
340
395
  }
@@ -370,11 +425,17 @@ export class BunSqliteStore implements Store {
370
425
  }
371
426
 
372
427
  async upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, tagSchemaOps.TagFieldSchema> }) {
373
- return tagSchemaOps.upsertTagSchema(this.db, tag, schema);
428
+ const result = tagSchemaOps.upsertTagSchema(this.db, tag, schema);
429
+ // `fields` drives validation — bust the schema cache so the next
430
+ // create/update sees the new declarations.
431
+ this._schemaConfig = null;
432
+ return result;
374
433
  }
375
434
 
376
435
  async deleteTagSchema(tag: string) {
377
- return tagSchemaOps.deleteTagSchema(this.db, tag);
436
+ const result = tagSchemaOps.deleteTagSchema(this.db, tag);
437
+ if (result) this._schemaConfig = null;
438
+ return result;
378
439
  }
379
440
 
380
441
  async getTagSchemaMap() {
@@ -407,7 +468,20 @@ export class BunSqliteStore implements Store {
407
468
  ) {
408
469
  const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
409
470
  if (patch.parent_names !== undefined) {
471
+ // parent_names drives both query expansion (tag hierarchy) AND, post
472
+ // vault#270, schema inheritance — bust both caches.
410
473
  this._tagHierarchy = null;
474
+ this._schemaConfig = null;
475
+ }
476
+ if (patch.fields !== undefined) {
477
+ this._schemaConfig = null;
478
+ }
479
+ // First-time creation of a tag row (e.g. an empty `_default` placeholder)
480
+ // changes the `_default` universal-parent gate even when no fields or
481
+ // parent_names are touched. Cheap to bust: caches rebuild on next read.
482
+ if (tag === "_default") {
483
+ this._tagHierarchy = null;
484
+ this._schemaConfig = null;
411
485
  }
412
486
  return result;
413
487
  }
@@ -431,63 +505,14 @@ export class BunSqliteStore implements Store {
431
505
  /**
432
506
  * Drop the config caches unconditionally. Used by bulk-import paths that
433
507
  * skip per-note invalidation for throughput, and by importers that
434
- * directly populate `note_schemas` / `schema_mappings`.
508
+ * directly mutate `tags` / `tags.fields` outside the singleton write
509
+ * methods.
435
510
  */
436
511
  rebuildConfigCaches(): void {
437
512
  this._tagHierarchy = null;
438
513
  this._schemaConfig = null;
439
514
  }
440
515
 
441
- // ---- Note schemas (post-v15: validation by path-prefix or tag) ----
442
-
443
- async listNoteSchemas() {
444
- return noteSchemaOps.listNoteSchemas(this.db);
445
- }
446
-
447
- async getNoteSchema(name: string) {
448
- return noteSchemaOps.getNoteSchema(this.db, name);
449
- }
450
-
451
- /**
452
- * Partial-upsert a note schema. Auto-creates the row if missing. Any
453
- * patch field left undefined is preserved; pass null to clear. Empty
454
- * `required: []` collapses to null. Invalidates the schema-config cache.
455
- */
456
- async upsertNoteSchema(name: string, patch: noteSchemaOps.NoteSchemaPatch) {
457
- const result = noteSchemaOps.upsertNoteSchema(this.db, name, patch);
458
- this._schemaConfig = null;
459
- return result;
460
- }
461
-
462
- async deleteNoteSchema(name: string) {
463
- const removed = noteSchemaOps.deleteNoteSchema(this.db, name);
464
- if (removed) this._schemaConfig = null;
465
- return removed;
466
- }
467
-
468
- async listSchemaMappings(opts?: noteSchemaOps.ListMappingsOpts) {
469
- return noteSchemaOps.listSchemaMappings(this.db, opts ?? {});
470
- }
471
-
472
- async setSchemaMapping(
473
- schema_name: string,
474
- match_kind: noteSchemaOps.SchemaMappingKind,
475
- match_value: string,
476
- ) {
477
- noteSchemaOps.setSchemaMapping(this.db, schema_name, match_kind, match_value);
478
- this._schemaConfig = null;
479
- }
480
-
481
- async deleteSchemaMapping(
482
- schema_name: string,
483
- match_kind: noteSchemaOps.SchemaMappingKind,
484
- match_value: string,
485
- ) {
486
- const removed = noteSchemaOps.deleteSchemaMapping(this.db, schema_name, match_kind, match_value);
487
- if (removed) this._schemaConfig = null;
488
- return removed;
489
- }
490
-
491
516
  /**
492
517
  * Sync wikilinks for all notes in the vault.
493
518
  * Efficient for bulk imports — call once after importing all notes.
@@ -32,8 +32,23 @@ export interface TagHierarchy {
32
32
  childrenOf: Map<string, Set<string>>;
33
33
  /** Memoization cache: tag → set including the tag itself plus all transitive descendants. */
34
34
  descendantsCache: Map<string, Set<string>>;
35
+ /**
36
+ * Every known tag name in the vault. Loaded once at hierarchy build so the
37
+ * `_default` universal-parent magic in `getTagDescendants` can answer
38
+ * "expand `_default` → every tag" without a second DB scan.
39
+ */
40
+ allTags: Set<string>;
35
41
  }
36
42
 
43
+ /**
44
+ * Reserved tag name treated as the implicit universal parent of every other
45
+ * tag (vault#270). When a row named `_default` exists in the `tags` table,
46
+ * its schema applies to all notes — tagged or not — and tag-hierarchy queries
47
+ * for `_default` expand to the full tag list. The `tags.parent_names` column
48
+ * is never auto-mutated; the universal-parent property lives at resolve time.
49
+ */
50
+ export const DEFAULT_TAG_NAME = "_default";
51
+
37
52
  /**
38
53
  * Pre-v14 path prefix that marked a note as a tag-hierarchy declaration.
39
54
  * Retained as an exported constant so call-sites that still need to know
@@ -59,16 +74,21 @@ function readParentNames(raw: string | null): string[] {
59
74
  /**
60
75
  * Scan the `tags` table and build the parent→children adjacency map.
61
76
  * Each row's `parent_names` JSON array contributes one edge per parent.
77
+ * Also collects every known tag name in `allTags` so the `_default`
78
+ * universal-parent leg in `getTagDescendants` can return the full tag list
79
+ * without a second scan.
62
80
  */
63
81
  export function loadTagHierarchy(db: Database): TagHierarchy {
64
82
  const rows = db.prepare(
65
- `SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL`,
83
+ `SELECT name, parent_names FROM tags`,
66
84
  ).all() as { name: string; parent_names: string | null }[];
67
85
 
68
86
  const childrenOf = new Map<string, Set<string>>();
87
+ const allTags = new Set<string>();
69
88
 
70
89
  for (const row of rows) {
71
90
  if (!row.name) continue;
91
+ allTags.add(row.name);
72
92
  const parents = readParentNames(row.parent_names);
73
93
  for (const parent of parents) {
74
94
  let children = childrenOf.get(parent);
@@ -80,18 +100,32 @@ export function loadTagHierarchy(db: Database): TagHierarchy {
80
100
  }
81
101
  }
82
102
 
83
- return { childrenOf, descendantsCache: new Map() };
103
+ return { childrenOf, descendantsCache: new Map(), allTags };
84
104
  }
85
105
 
86
106
  /**
87
107
  * Return the tag plus all transitive descendants. Always includes the tag
88
108
  * itself, so callers can use the result as a drop-in replacement for the
89
109
  * input tag when expanding queries.
110
+ *
111
+ * Special case: `_default` is treated as the implicit parent of every tag
112
+ * (vault#270). When a row named `_default` exists in the `tags` table, this
113
+ * function returns the full set of known tag names — symmetric with the
114
+ * schema-inheritance model where `_default`'s fields apply to every note.
115
+ * When `_default` is *not* declared as a tag row, the call falls through to
116
+ * normal descendant traversal (which yields just `{_default}` since nothing
117
+ * lists it as a parent).
90
118
  */
91
119
  export function getTagDescendants(h: TagHierarchy, tag: string): Set<string> {
92
120
  const cached = h.descendantsCache.get(tag);
93
121
  if (cached) return cached;
94
122
 
123
+ if (tag === DEFAULT_TAG_NAME && h.allTags.has(DEFAULT_TAG_NAME)) {
124
+ const universal = new Set<string>(h.allTags);
125
+ h.descendantsCache.set(tag, universal);
126
+ return universal;
127
+ }
128
+
95
129
  const result = new Set<string>([tag]);
96
130
  const stack = [tag];
97
131
  while (stack.length > 0) {