@openparachute/vault 0.3.3 → 0.4.3

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.
Files changed (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/store.ts CHANGED
@@ -7,6 +7,19 @@ import * as tagSchemaOps from "./tag-schemas.js";
7
7
  import { syncWikilinks, resolveUnresolvedWikilinks } from "./wikilinks.js";
8
8
  import { pathTitle } from "./paths.js";
9
9
  import { HookRegistry } from "./hooks.js";
10
+ import {
11
+ loadTagHierarchy,
12
+ getTagDescendants,
13
+ TAG_CONFIG_PREFIX,
14
+ DEFAULT_TAG_NAME,
15
+ type TagHierarchy,
16
+ } from "./tag-hierarchy.js";
17
+ import {
18
+ loadSchemaConfig,
19
+ validateNote as runValidateNote,
20
+ type ResolvedSchemas,
21
+ type ValidationStatus,
22
+ } from "./schema-defaults.js";
10
23
 
11
24
  /**
12
25
  * bun:sqlite-backed Store implementation. Internally everything is
@@ -16,11 +29,67 @@ import { HookRegistry } from "./hooks.js";
16
29
  export class BunSqliteStore implements Store {
17
30
  public readonly hooks: HookRegistry;
18
31
 
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.
37
+ private _tagHierarchy: TagHierarchy | null = null;
38
+ private _schemaConfig: ResolvedSchemas | null = null;
39
+
19
40
  constructor(public readonly db: Database, opts?: { hooks?: HookRegistry }) {
20
41
  initSchema(db);
21
42
  this.hooks = opts?.hooks ?? new HookRegistry();
22
43
  }
23
44
 
45
+ /**
46
+ * Lazy accessor for the `_tags/*` config-note hierarchy. First call after
47
+ * boot or after an invalidation does the scan; subsequent calls hit the
48
+ * cache. Returns the same object until invalidated, so callers can rely
49
+ * on identity for memoizing per-tag descendant sets.
50
+ */
51
+ private getTagHierarchy(): TagHierarchy {
52
+ if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
53
+ return this._tagHierarchy;
54
+ }
55
+
56
+ /**
57
+ * Lazy accessor for the per-tag `fields` resolution. Same lifecycle as
58
+ * the tag hierarchy cache.
59
+ */
60
+ private getSchemaConfig(): ResolvedSchemas {
61
+ if (!this._schemaConfig) this._schemaConfig = loadSchemaConfig(this.db);
62
+ return this._schemaConfig;
63
+ }
64
+
65
+ /**
66
+ * Run the resolved schemas against a note and return the resulting
67
+ * validation status, or null when no schema applies. Public so the MCP
68
+ * layer can surface `validation_status` on create/update responses
69
+ * without re-importing the config loader.
70
+ */
71
+ validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): ValidationStatus | null {
72
+ return runValidateNote(this.getSchemaConfig(), note);
73
+ }
74
+
75
+ /**
76
+ * Drop the tag-hierarchy cache if the mutated path is in the `_tags/*`
77
+ * namespace. Called from create/update/delete — old path is passed
78
+ * alongside new for rename cases (a note moved out of `_tags/` should
79
+ * still invalidate).
80
+ *
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`).
84
+ */
85
+ private invalidateConfigCachesForPath(path: string | null | undefined, oldPath?: string | null): void {
86
+ const isTagConfig = (p: string | null | undefined): boolean =>
87
+ typeof p === "string" && p.startsWith(TAG_CONFIG_PREFIX);
88
+ if (isTagConfig(path) || isTagConfig(oldPath)) {
89
+ this._tagHierarchy = null;
90
+ }
91
+ }
92
+
24
93
  // ---- Notes ----
25
94
 
26
95
  async createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
@@ -34,6 +103,7 @@ export class BunSqliteStore implements Store {
34
103
  resolveUnresolvedWikilinks(this.db, note.path, note.id);
35
104
  }
36
105
 
106
+ this.invalidateConfigCachesForPath(note.path);
37
107
  this.hooks.dispatch("created", note, this);
38
108
 
39
109
  return note;
@@ -55,6 +125,8 @@ export class BunSqliteStore implements Store {
55
125
  id: string,
56
126
  updates: {
57
127
  content?: string;
128
+ append?: string;
129
+ prepend?: string;
58
130
  path?: string;
59
131
  metadata?: Record<string, unknown>;
60
132
  created_at?: string;
@@ -70,8 +142,11 @@ export class BunSqliteStore implements Store {
70
142
 
71
143
  const note = noteOps.updateNote(this.db, id, updates);
72
144
 
73
- if (updates.content !== undefined) {
74
- syncWikilinks(this.db, id, updates.content);
145
+ // Wikilink sync runs against the *resulting* content. For append/prepend
146
+ // we don't have the new value pre-write — read it back off the returned
147
+ // note so a `[[Foo]]` introduced via append still creates the link.
148
+ if (updates.content !== undefined || updates.append !== undefined || updates.prepend !== undefined) {
149
+ syncWikilinks(this.db, id, note.content);
75
150
  }
76
151
 
77
152
  if (updates.path !== undefined && note.path) {
@@ -81,6 +156,12 @@ export class BunSqliteStore implements Store {
81
156
  resolveUnresolvedWikilinks(this.db, note.path, id);
82
157
  }
83
158
 
159
+ // Invalidate before the hook dispatch so any handler that re-queries
160
+ // the hierarchy from inside its own logic sees post-write state.
161
+ // `metadata` updates can change the `parents` field on a config note
162
+ // even when the path didn't change, so always invalidate when the
163
+ // current path is in a config namespace.
164
+ this.invalidateConfigCachesForPath(note.path, oldPath);
84
165
  this.hooks.dispatch("updated", note, this);
85
166
 
86
167
  return note;
@@ -122,14 +203,86 @@ export class BunSqliteStore implements Store {
122
203
  }
123
204
 
124
205
  async deleteNote(id: string): Promise<void> {
206
+ // Read before delete so we can invalidate config caches on the way out.
207
+ const existing = noteOps.getNote(this.db, id);
125
208
  noteOps.deleteNote(this.db, id);
209
+ if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
126
210
  }
127
211
 
128
212
  async queryNotes(opts: QueryOpts): Promise<Note[]> {
129
- return noteOps.queryNotes(this.db, opts);
213
+ return noteOps.queryNotes(this.db, this.expandQueryTags(opts));
214
+ }
215
+
216
+ /**
217
+ * If `tags` are present, attach a parallel `_tagsExpanded` array where
218
+ * each input tag is replaced with `{tag} ∪ descendants(tag)`. The SQL
219
+ * builder uses this to widen the tag join from `name = ?` to
220
+ * `name IN (...)`, so a query for `#manual` matches notes tagged with
221
+ * any descendant declared via `tags.parent_names`.
222
+ *
223
+ * `_default` magic (vault#270): when a `_default` tag row exists in the
224
+ * vault, it's the implicit parent of every note (tagged or not). A query
225
+ * filter that names `_default` is therefore equivalent to "no tag filter
226
+ * at all" — but the precise treatment depends on `tagMatch`:
227
+ *
228
+ * - `all` (default, AND-semantics): `_default` is universally satisfied,
229
+ * so it can be dropped from the tag list. Other tags' AND-semantics
230
+ * still apply. If `_default` was the only entry, drop the filter
231
+ * entirely so untagged notes match.
232
+ * - `any` (OR-semantics): `_default` matches every note, so the disjunction
233
+ * collapses to "every note." Drop the filter entirely regardless of
234
+ * what else was in the list (otherwise we'd narrow to the union of
235
+ * the other tags' notes — wrong).
236
+ *
237
+ * Other filters (path, metadata, dates) still apply in both cases.
238
+ */
239
+ private expandQueryTags(opts: QueryOpts): QueryOpts {
240
+ if (!opts.tags || opts.tags.length === 0) return opts;
241
+ const hierarchy = this.getTagHierarchy();
242
+
243
+ let tags = opts.tags;
244
+ if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && tags.includes(DEFAULT_TAG_NAME)) {
245
+ const match = opts.tagMatch ?? "all";
246
+ if (match === "any") {
247
+ const { tags: _drop, ..._rest } = opts;
248
+ return _rest as QueryOpts;
249
+ }
250
+ tags = tags.filter((t) => t !== DEFAULT_TAG_NAME);
251
+ if (tags.length === 0) {
252
+ const { tags: _drop, ..._rest } = opts;
253
+ return _rest as QueryOpts;
254
+ }
255
+ opts = { ...opts, tags };
256
+ }
257
+
258
+ if (hierarchy.childrenOf.size === 0) return opts;
259
+ const expanded = tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
260
+ return { ...opts, _tagsExpanded: expanded } as QueryOpts;
130
261
  }
131
262
 
132
263
  async searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]> {
264
+ // Same hierarchy-expansion treatment as queryNotes — searching `#manual`
265
+ // should match notes tagged with any descendant tag. The underlying
266
+ // FTS path already uses `IN (...)` for tags, so we flatten the
267
+ // per-input expansions into a single union (search semantics are
268
+ // "any tag matches"). When `_default` is among the requested tags
269
+ // (and a `_default` row exists), the OR collapses to "every note" —
270
+ // drop the tag filter entirely so the search hits the full corpus
271
+ // and untagged notes are reachable.
272
+ if (opts?.tags && opts.tags.length > 0) {
273
+ const hierarchy = this.getTagHierarchy();
274
+ if (hierarchy.allTags.has(DEFAULT_TAG_NAME) && opts.tags.includes(DEFAULT_TAG_NAME)) {
275
+ const { tags: _drop, ..._rest } = opts;
276
+ return noteOps.searchNotes(this.db, query, _rest);
277
+ }
278
+ if (hierarchy.childrenOf.size > 0) {
279
+ const expanded = new Set<string>();
280
+ for (const t of opts.tags) {
281
+ for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
282
+ }
283
+ return noteOps.searchNotes(this.db, query, { ...opts, tags: Array.from(expanded) });
284
+ }
285
+ }
133
286
  return noteOps.searchNotes(this.db, query, opts);
134
287
  }
135
288
 
@@ -143,23 +296,52 @@ export class BunSqliteStore implements Store {
143
296
  noteOps.untagNote(this.db, noteId, tags);
144
297
  }
145
298
 
299
+ async expandTagsWithDescendants(tags: string[]): Promise<Set<string>> {
300
+ const expanded = new Set<string>();
301
+ if (tags.length === 0) return expanded;
302
+ const hierarchy = this.getTagHierarchy();
303
+ for (const t of tags) {
304
+ for (const x of getTagDescendants(hierarchy, t)) expanded.add(x);
305
+ }
306
+ return expanded;
307
+ }
308
+
146
309
  async listTags(): Promise<{ name: string; count: number }[]> {
147
310
  return noteOps.listTags(this.db);
148
311
  }
149
312
 
150
313
  async deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }> {
151
- return noteOps.deleteTag(this.db, name);
314
+ const result = noteOps.deleteTag(this.db, name);
315
+ // The deleted tag may have been a parent or child in the hierarchy
316
+ // and may have declared `fields` powering schema validation.
317
+ this._tagHierarchy = null;
318
+ this._schemaConfig = null;
319
+ return result;
152
320
  }
153
321
 
154
322
  async renameTag(oldName: string, newName: string): Promise<noteOps.RenameTagResult> {
155
- return noteOps.renameTag(this.db, oldName, newName);
323
+ const result = noteOps.renameTag(this.db, oldName, newName);
324
+ // Vault#240: the cascade rewrites parent_names in OTHER tag rows as
325
+ // part of the same transaction, plus tokens.scoped_tags and
326
+ // indexed_fields.declarer_tags. Both caches are tag-keyed, so they
327
+ // must be rebuilt regardless — the hierarchy by tag-set identity,
328
+ // the schema-config by parent_names + fields content.
329
+ this._tagHierarchy = null;
330
+ this._schemaConfig = null;
331
+ return result;
156
332
  }
157
333
 
158
334
  async mergeTags(
159
335
  sources: string[],
160
336
  target: string,
161
337
  ): Promise<{ merged: Record<string, number>; target: string }> {
162
- return noteOps.mergeTags(this.db, sources, target);
338
+ const result = noteOps.mergeTags(this.db, sources, target);
339
+ // Source tags drop out of the hierarchy; downstream callers asking
340
+ // for descendants of target should pick up any merged children. Also
341
+ // bust the schema cache — `fields` declarations follow tag identity.
342
+ this._tagHierarchy = null;
343
+ this._schemaConfig = null;
344
+ return result;
163
345
  }
164
346
 
165
347
  // ---- Vault Stats ----
@@ -191,6 +373,11 @@ export class BunSqliteStore implements Store {
191
373
  async createNotes(inputs: noteOps.BulkNoteInput[]): Promise<Note[]> {
192
374
  const notes = noteOps.createNotes(this.db, inputs);
193
375
  for (const note of notes) {
376
+ // Bulk path needs the same config-cache invalidation as singleton
377
+ // createNote — without it, a batch that includes `_tags/*` notes
378
+ // would leave the hierarchy cache stale until the next singleton
379
+ // write happened to bust it.
380
+ this.invalidateConfigCachesForPath(note.path);
194
381
  this.hooks.dispatch("created", note, this);
195
382
  }
196
383
  return notes;
@@ -225,27 +412,94 @@ export class BunSqliteStore implements Store {
225
412
  }
226
413
 
227
414
  async upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, tagSchemaOps.TagFieldSchema> }) {
228
- return tagSchemaOps.upsertTagSchema(this.db, tag, schema);
415
+ const result = tagSchemaOps.upsertTagSchema(this.db, tag, schema);
416
+ // `fields` drives validation — bust the schema cache so the next
417
+ // create/update sees the new declarations.
418
+ this._schemaConfig = null;
419
+ return result;
229
420
  }
230
421
 
231
422
  async deleteTagSchema(tag: string) {
232
- return tagSchemaOps.deleteTagSchema(this.db, tag);
423
+ const result = tagSchemaOps.deleteTagSchema(this.db, tag);
424
+ if (result) this._schemaConfig = null;
425
+ return result;
233
426
  }
234
427
 
235
428
  async getTagSchemaMap() {
236
429
  return tagSchemaOps.getTagSchemaMap(this.db);
237
430
  }
238
431
 
432
+ // ---- Tag Records (post-v14: full identity row) ----
433
+
434
+ async listTagRecords() {
435
+ return tagSchemaOps.listTagRecords(this.db);
436
+ }
437
+
438
+ async getTagRecord(tag: string) {
439
+ return tagSchemaOps.getTagRecord(this.db, tag);
440
+ }
441
+
442
+ /**
443
+ * Partial upsert of the full tag record. Any patch field left undefined
444
+ * is preserved; pass null to clear. Invalidates the tag-hierarchy cache
445
+ * when `parent_names` is touched.
446
+ */
447
+ async upsertTagRecord(
448
+ tag: string,
449
+ patch: {
450
+ description?: string | null;
451
+ fields?: Record<string, tagSchemaOps.TagFieldSchema> | null;
452
+ relationships?: Record<string, tagSchemaOps.TagRelationship> | null;
453
+ parent_names?: string[] | null;
454
+ },
455
+ ) {
456
+ const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
457
+ if (patch.parent_names !== undefined) {
458
+ // parent_names drives both query expansion (tag hierarchy) AND, post
459
+ // vault#270, schema inheritance — bust both caches.
460
+ this._tagHierarchy = null;
461
+ this._schemaConfig = null;
462
+ }
463
+ if (patch.fields !== undefined) {
464
+ this._schemaConfig = null;
465
+ }
466
+ // First-time creation of a tag row (e.g. an empty `_default` placeholder)
467
+ // changes the `_default` universal-parent gate even when no fields or
468
+ // parent_names are touched. Cheap to bust: caches rebuild on next read.
469
+ if (tag === "_default") {
470
+ this._tagHierarchy = null;
471
+ this._schemaConfig = null;
472
+ }
473
+ return result;
474
+ }
475
+
239
476
  // ---- Batch Wikilink Sync ----
240
477
 
241
478
  /**
242
479
  * Create a note without triggering wikilink sync.
243
480
  * Use this during bulk imports, then call syncAllWikilinks() after.
481
+ *
482
+ * Does **not** invalidate the `_tags/*` config cache — importers writing
483
+ * tag-hierarchy notes through this path must call `rebuildConfigCaches()`
484
+ * once the import is done. (Default importers follow `createNoteRaw` with
485
+ * `syncAllWikilinks`, so adding the cache rebuild there is the natural
486
+ * place.)
244
487
  */
245
488
  async createNoteRaw(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string }): Promise<Note> {
246
489
  return noteOps.createNote(this.db, content, opts);
247
490
  }
248
491
 
492
+ /**
493
+ * Drop the config caches unconditionally. Used by bulk-import paths that
494
+ * skip per-note invalidation for throughput, and by importers that
495
+ * directly mutate `tags` / `tags.fields` outside the singleton write
496
+ * methods.
497
+ */
498
+ rebuildConfigCaches(): void {
499
+ this._tagHierarchy = null;
500
+ this._schemaConfig = null;
501
+ }
502
+
249
503
  /**
250
504
  * Sync wikilinks for all notes in the vault.
251
505
  * Efficient for bulk imports — call once after importing all notes.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Tag hierarchy resolution from the `tags.parent_names` column.
3
+ *
4
+ * A `tags` row named `voice` with `parent_names = ["manual", "note"]`
5
+ * registers `voice` as a child of `manual` and `note`. Queries that ask for
6
+ * `tags: ["manual"]` then transparently match notes tagged `#voice` (or any
7
+ * other transitive descendant of `#manual`).
8
+ *
9
+ * History: pre-v14 vaults stored hierarchy in notes-as-config at
10
+ * `_tags/<name>`. The v14 migration (see core/src/schema.ts:migrateToV14)
11
+ * lifts those parent declarations onto the tags row and the resolver here
12
+ * was swapped accordingly. See parachute-patterns/patterns/tag-data-model.md.
13
+ *
14
+ * Resolution model:
15
+ * - Lazy: built on first access, cached on the store.
16
+ * - Invalidated synchronously when a tag's parent_names changes (see
17
+ * `BunSqliteStore.invalidateTagCaches`).
18
+ * - Tags without parent_names are treated as root-level (no parents, no
19
+ * children). They still match queries by their own name.
20
+ *
21
+ * Cycle handling:
22
+ * - Cycles in declared parents are tolerated at load — we don't reject the
23
+ * config (we don't have a "fail loud" signal at boot from inside a query).
24
+ * Descendant traversal uses a visited-set so a cycle can't loop forever;
25
+ * the resolved descendant set is well-defined regardless.
26
+ */
27
+
28
+ import { Database } from "bun:sqlite";
29
+
30
+ export interface TagHierarchy {
31
+ /** tag → set of immediate child tags (those that declared `tag` as a parent). */
32
+ childrenOf: Map<string, Set<string>>;
33
+ /** Memoization cache: tag → set including the tag itself plus all transitive descendants. */
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>;
41
+ }
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
+
52
+ /**
53
+ * Pre-v14 path prefix that marked a note as a tag-hierarchy declaration.
54
+ * Retained as an exported constant so call-sites that still need to know
55
+ * about historical `_tags/*` notes (cache-invalidation, importers) can
56
+ * reference a single source of truth.
57
+ */
58
+ export const TAG_CONFIG_PREFIX = "_tags/";
59
+
60
+ /**
61
+ * Decode a JSON-encoded `parent_names` column value, defending against
62
+ * malformed input. Non-string entries are dropped silently — the column
63
+ * is expected to be well-formed (we control all writers) but a single bad
64
+ * row shouldn't break the whole hierarchy resolution.
65
+ */
66
+ function readParentNames(raw: string | null): string[] {
67
+ if (!raw) return [];
68
+ let parsed: unknown;
69
+ try { parsed = JSON.parse(raw); } catch { return []; }
70
+ if (!Array.isArray(parsed)) return [];
71
+ return parsed.filter((x): x is string => typeof x === "string" && x.length > 0);
72
+ }
73
+
74
+ /**
75
+ * Scan the `tags` table and build the parent→children adjacency map.
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.
80
+ */
81
+ export function loadTagHierarchy(db: Database): TagHierarchy {
82
+ const rows = db.prepare(
83
+ `SELECT name, parent_names FROM tags`,
84
+ ).all() as { name: string; parent_names: string | null }[];
85
+
86
+ const childrenOf = new Map<string, Set<string>>();
87
+ const allTags = new Set<string>();
88
+
89
+ for (const row of rows) {
90
+ if (!row.name) continue;
91
+ allTags.add(row.name);
92
+ const parents = readParentNames(row.parent_names);
93
+ for (const parent of parents) {
94
+ let children = childrenOf.get(parent);
95
+ if (!children) {
96
+ children = new Set();
97
+ childrenOf.set(parent, children);
98
+ }
99
+ children.add(row.name);
100
+ }
101
+ }
102
+
103
+ return { childrenOf, descendantsCache: new Map(), allTags };
104
+ }
105
+
106
+ /**
107
+ * Return the tag plus all transitive descendants. Always includes the tag
108
+ * itself, so callers can use the result as a drop-in replacement for the
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).
118
+ */
119
+ export function getTagDescendants(h: TagHierarchy, tag: string): Set<string> {
120
+ const cached = h.descendantsCache.get(tag);
121
+ if (cached) return cached;
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
+
129
+ const result = new Set<string>([tag]);
130
+ const stack = [tag];
131
+ while (stack.length > 0) {
132
+ const current = stack.pop()!;
133
+ const children = h.childrenOf.get(current);
134
+ if (!children) continue;
135
+ for (const child of children) {
136
+ if (result.has(child)) continue;
137
+ result.add(child);
138
+ stack.push(child);
139
+ }
140
+ }
141
+
142
+ h.descendantsCache.set(tag, result);
143
+ return result;
144
+ }
145
+
146
+ /**
147
+ * Detect cycles in the declared hierarchy. Returns the list of tags
148
+ * reachable from themselves via parent declarations. Used by
149
+ * `update-tag` write paths to surface a warning to the caller without
150
+ * blocking the write — cycles are tolerated at runtime (descendant
151
+ * traversal uses a visited set), but they're almost always a config bug.
152
+ */
153
+ export function findHierarchyCycles(h: TagHierarchy): string[] {
154
+ const cycles: string[] = [];
155
+ for (const tag of h.childrenOf.keys()) {
156
+ const descendants = getTagDescendants(h, tag);
157
+ if (descendants.has(tag) && descendants.size > 1) {
158
+ // tag reaches itself through a non-trivial path
159
+ const ownChildren = h.childrenOf.get(tag);
160
+ if (ownChildren) {
161
+ for (const child of ownChildren) {
162
+ if (getTagDescendants(h, child).has(tag)) {
163
+ cycles.push(tag);
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+ return cycles;
171
+ }