@openparachute/vault 0.4.0 → 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.
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 =>
@@ -219,16 +218,45 @@ export class BunSqliteStore implements Store {
219
218
  * each input tag is replaced with `{tag} ∪ descendants(tag)`. The SQL
220
219
  * builder uses this to widen the tag join from `name = ?` to
221
220
  * `name IN (...)`, so a query for `#manual` matches notes tagged with
222
- * any descendant declared via `_tags/*` config notes.
221
+ * any descendant declared via `tags.parent_names`.
223
222
  *
224
- * No-op when no `_tags/*` notes exist (empty hierarchy → each tag
225
- * expands to just itself, identical to the pre-expansion behavior).
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.
226
238
  */
227
239
  private expandQueryTags(opts: QueryOpts): QueryOpts {
228
240
  if (!opts.tags || opts.tags.length === 0) return opts;
229
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
+
230
258
  if (hierarchy.childrenOf.size === 0) return opts;
231
- const expanded = opts.tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
259
+ const expanded = tags.map((t) => Array.from(getTagDescendants(hierarchy, t)));
232
260
  return { ...opts, _tagsExpanded: expanded } as QueryOpts;
233
261
  }
234
262
 
@@ -237,9 +265,16 @@ export class BunSqliteStore implements Store {
237
265
  // should match notes tagged with any descendant tag. The underlying
238
266
  // FTS path already uses `IN (...)` for tags, so we flatten the
239
267
  // per-input expansions into a single union (search semantics are
240
- // "any tag matches").
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.
241
272
  if (opts?.tags && opts.tags.length > 0) {
242
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
+ }
243
278
  if (hierarchy.childrenOf.size > 0) {
244
279
  const expanded = new Set<string>();
245
280
  for (const t of opts.tags) {
@@ -277,17 +312,22 @@ export class BunSqliteStore implements Store {
277
312
 
278
313
  async deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }> {
279
314
  const result = noteOps.deleteTag(this.db, name);
280
- // The deleted tag may have been a parent or child in the hierarchy.
315
+ // The deleted tag may have been a parent or child in the hierarchy
316
+ // and may have declared `fields` powering schema validation.
281
317
  this._tagHierarchy = null;
318
+ this._schemaConfig = null;
282
319
  return result;
283
320
  }
284
321
 
285
322
  async renameTag(oldName: string, newName: string): Promise<noteOps.RenameTagResult> {
286
323
  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.
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.
290
329
  this._tagHierarchy = null;
330
+ this._schemaConfig = null;
291
331
  return result;
292
332
  }
293
333
 
@@ -297,8 +337,10 @@ export class BunSqliteStore implements Store {
297
337
  ): Promise<{ merged: Record<string, number>; target: string }> {
298
338
  const result = noteOps.mergeTags(this.db, sources, target);
299
339
  // Source tags drop out of the hierarchy; downstream callers asking
300
- // for descendants of target should pick up any merged children.
340
+ // for descendants of target should pick up any merged children. Also
341
+ // bust the schema cache — `fields` declarations follow tag identity.
301
342
  this._tagHierarchy = null;
343
+ this._schemaConfig = null;
302
344
  return result;
303
345
  }
304
346
 
@@ -332,9 +374,9 @@ export class BunSqliteStore implements Store {
332
374
  const notes = noteOps.createNotes(this.db, inputs);
333
375
  for (const note of notes) {
334
376
  // 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.
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.
338
380
  this.invalidateConfigCachesForPath(note.path);
339
381
  this.hooks.dispatch("created", note, this);
340
382
  }
@@ -370,11 +412,17 @@ export class BunSqliteStore implements Store {
370
412
  }
371
413
 
372
414
  async upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, tagSchemaOps.TagFieldSchema> }) {
373
- 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;
374
420
  }
375
421
 
376
422
  async deleteTagSchema(tag: string) {
377
- return tagSchemaOps.deleteTagSchema(this.db, tag);
423
+ const result = tagSchemaOps.deleteTagSchema(this.db, tag);
424
+ if (result) this._schemaConfig = null;
425
+ return result;
378
426
  }
379
427
 
380
428
  async getTagSchemaMap() {
@@ -407,7 +455,20 @@ export class BunSqliteStore implements Store {
407
455
  ) {
408
456
  const result = tagSchemaOps.upsertTagRecord(this.db, tag, patch);
409
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") {
410
470
  this._tagHierarchy = null;
471
+ this._schemaConfig = null;
411
472
  }
412
473
  return result;
413
474
  }
@@ -431,63 +492,14 @@ export class BunSqliteStore implements Store {
431
492
  /**
432
493
  * Drop the config caches unconditionally. Used by bulk-import paths that
433
494
  * skip per-note invalidation for throughput, and by importers that
434
- * directly populate `note_schemas` / `schema_mappings`.
495
+ * directly mutate `tags` / `tags.fields` outside the singleton write
496
+ * methods.
435
497
  */
436
498
  rebuildConfigCaches(): void {
437
499
  this._tagHierarchy = null;
438
500
  this._schemaConfig = null;
439
501
  }
440
502
 
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
503
  /**
492
504
  * Sync wikilinks for all notes in the vault.
493
505
  * 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) {
package/core/src/types.ts CHANGED
@@ -1,23 +1,8 @@
1
1
  import type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
2
- import type {
3
- NoteSchemaField,
4
- NoteSchemaRecord,
5
- NoteSchemaPatch,
6
- SchemaMappingKind,
7
- SchemaMappingRecord,
8
- ListMappingsOpts,
9
- } from "./note-schemas.js";
10
2
 
11
3
  // ---- Re-exports ----
12
4
 
13
5
  export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
14
- export type {
15
- NoteSchemaField,
16
- NoteSchemaRecord,
17
- NoteSchemaPatch,
18
- SchemaMappingKind,
19
- SchemaMappingRecord,
20
- } from "./note-schemas.js";
21
6
 
22
7
  // ---- Note ----
23
8
 
@@ -95,12 +80,14 @@ export interface QueryOpts {
95
80
  // as the common path; specifying both this and `dateFilter` rejects.
96
81
  dateFrom?: string; // ISO date
97
82
  dateTo?: string; // ISO date
98
- // Generalized date range. `field` defaults to `created_at`; any other
99
- // field must be declared `indexed: true` in a tag schema (so the SQL
100
- // hits a real B-tree index, same contract as `metadata` operator
101
- // queries and `orderBy`). Use this to filter on a *content* date an
102
- // email's received date, a meeting's scheduled date rather than the
103
- // ingestion timestamp.
83
+ // Generalized date range. `field` defaults to `created_at`; `updated_at`
84
+ // is also a recognized real column (the incremental-rebuild path
85
+ // vault#285 1.5). Any other field must be declared `indexed: true` in a
86
+ // tag schema (so the SQL hits a real B-tree index, same contract as
87
+ // `metadata` operator queries and `orderBy`). Use this to filter on a
88
+ // *content* date — an email's received date, a meeting's scheduled
89
+ // date — rather than the ingestion timestamp, or on `updated_at` to ask
90
+ // "what changed since X."
104
91
  dateFilter?: {
105
92
  field?: string;
106
93
  from?: string;
@@ -175,7 +162,19 @@ export interface Store {
175
162
  renameTag(
176
163
  oldName: string,
177
164
  newName: string,
178
- ): Promise<{ renamed: number } | { error: "not_found" } | { error: "target_exists" }>;
165
+ ): Promise<
166
+ | {
167
+ renamed: number;
168
+ sub_tags_renamed: number;
169
+ parent_refs_updated: number;
170
+ tokens_updated: number;
171
+ indexed_field_declarers_updated: number;
172
+ notes_rewritten: number;
173
+ paths_renamed: number;
174
+ }
175
+ | { error: "not_found" }
176
+ | { error: "target_exists"; conflicting: string[] }
177
+ >;
179
178
  mergeTags(
180
179
  sources: string[],
181
180
  target: string,
@@ -228,30 +227,26 @@ export interface Store {
228
227
  },
229
228
  ): Promise<TagRecord>;
230
229
 
231
- // Schema validation (post-v15: backed by `note_schemas` + `schema_mappings`
232
- // tables). Returns null when no schema applies to the given note. The
233
- // underlying resolver is in-memory after the first lazy load.
230
+ // Schema validation (post-v17: backed by `tags.fields` only — the
231
+ // standalone note_schemas + schema_mappings subsystem retired in v17, see
232
+ // vault#267). Post vault#270 the resolver walks `parent_names` so a note's
233
+ // effective fields include all ancestors' declarations (first-in-walk wins
234
+ // on conflict, surfaced as `schema_conflict` warnings); a tag named
235
+ // `_default` is the implicit universal parent. Returns null when no
236
+ // ancestor declares any fields. The underlying resolver is in-memory after
237
+ // the first lazy load.
234
238
  validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): {
235
239
  schemas: string[];
236
- warnings: { field: string; schema: string; reason: "missing_required" | "type_mismatch" | "enum_mismatch"; message: string }[];
240
+ warnings: {
241
+ field: string;
242
+ schema: string;
243
+ reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
244
+ message: string;
245
+ /** Set only on `schema_conflict` — the tag whose declaration was overridden. */
246
+ loser_schema?: string;
247
+ }[];
237
248
  } | null;
238
249
 
239
- // Note schemas (post-v15 — the writable surface that drives validation).
240
- listNoteSchemas(): Promise<NoteSchemaRecord[]>;
241
- getNoteSchema(name: string): Promise<NoteSchemaRecord | null>;
242
- /**
243
- * Partial-upsert. Auto-creates the row if missing. Any patch field left
244
- * undefined is preserved; pass null to clear. Empty `required: []`
245
- * collapses to null.
246
- */
247
- upsertNoteSchema(name: string, patch: NoteSchemaPatch): Promise<NoteSchemaRecord>;
248
- deleteNoteSchema(name: string): Promise<boolean>;
249
-
250
- // Schema mappings (post-v15 — replaces the singleton `_schema_defaults`).
251
- listSchemaMappings(opts?: ListMappingsOpts): Promise<SchemaMappingRecord[]>;
252
- setSchemaMapping(schema_name: string, match_kind: SchemaMappingKind, match_value: string): Promise<void>;
253
- deleteSchemaMapping(schema_name: string, match_kind: SchemaMappingKind, match_value: string): Promise<boolean>;
254
-
255
250
  // Attachments
256
251
  addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;
257
252
  getAttachments(noteId: string): Promise<Attachment[]>;