@openparachute/vault 0.3.3 → 0.4.0

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 (79) hide show
  1. package/.parachute/module.json +15 -0
  2. package/core/src/core.test.ts +2252 -7
  3. package/core/src/links.ts +1 -1
  4. package/core/src/mcp.ts +801 -67
  5. package/core/src/note-schemas.ts +232 -0
  6. package/core/src/notes.ts +313 -35
  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 +287 -0
  11. package/core/src/schema.ts +393 -9
  12. package/core/src/store.ts +248 -6
  13. package/core/src/tag-hierarchy.ts +137 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +100 -6
  16. package/core/src/wikilinks.ts +3 -3
  17. package/package.json +13 -3
  18. package/src/admin-spa.test.ts +161 -0
  19. package/src/admin-spa.ts +161 -0
  20. package/src/auth-hub-jwt.test.ts +231 -0
  21. package/src/auth-status.ts +84 -0
  22. package/src/auth.test.ts +135 -23
  23. package/src/auth.ts +144 -15
  24. package/src/backup.ts +4 -7
  25. package/src/cli.ts +322 -57
  26. package/src/config.test.ts +44 -0
  27. package/src/config.ts +68 -40
  28. package/src/hub-jwt.test.ts +296 -0
  29. package/src/hub-jwt.ts +79 -0
  30. package/src/init.test.ts +216 -0
  31. package/src/mcp-http.ts +30 -28
  32. package/src/mcp-install.ts +1 -1
  33. package/src/mcp-tools.ts +294 -6
  34. package/src/module-config.ts +1 -1
  35. package/src/oauth.test.ts +345 -0
  36. package/src/oauth.ts +85 -14
  37. package/src/owner-auth.ts +57 -1
  38. package/src/prompt.ts +6 -5
  39. package/src/routes.ts +686 -58
  40. package/src/routing.test.ts +466 -1
  41. package/src/routing.ts +108 -24
  42. package/src/scopes.test.ts +66 -8
  43. package/src/scopes.ts +163 -37
  44. package/src/server.ts +24 -2
  45. package/src/services-manifest.test.ts +20 -0
  46. package/src/services-manifest.ts +9 -2
  47. package/src/stop-signal.test.ts +85 -0
  48. package/src/storage.test.ts +92 -0
  49. package/src/tag-scope.ts +118 -0
  50. package/src/token-store.test.ts +47 -0
  51. package/src/token-store.ts +128 -13
  52. package/src/tokens-routes.test.ts +720 -0
  53. package/src/tokens-routes.ts +392 -0
  54. package/src/transcription-worker.test.ts +5 -0
  55. package/src/triggers.ts +1 -1
  56. package/src/two-factor.ts +2 -2
  57. package/src/vault-create.test.ts +193 -0
  58. package/src/vault-name.test.ts +123 -0
  59. package/src/vault-name.ts +80 -0
  60. package/src/vault.test.ts +868 -3
  61. package/tsconfig.json +8 -1
  62. package/.claude/settings.local.json +0 -8
  63. package/.dockerignore +0 -8
  64. package/.env.example +0 -9
  65. package/CHANGELOG.md +0 -175
  66. package/CLAUDE.md +0 -125
  67. package/Caddyfile +0 -3
  68. package/Dockerfile +0 -22
  69. package/bun.lock +0 -219
  70. package/bunfig.toml +0 -2
  71. package/deploy/parachute-vault.service +0 -20
  72. package/docker-compose.yml +0 -50
  73. package/docs/HTTP_API.md +0 -434
  74. package/docs/auth-model.md +0 -340
  75. package/fly.toml +0 -24
  76. package/package/package.json +0 -32
  77. package/railway.json +0 -14
  78. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  79. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -1,9 +1,18 @@
1
1
  /**
2
- * Tag schema CRUD — DB-backed storage for tag metadata schemas.
2
+ * Tag record CRUD — DB-backed storage for the per-tag identity row.
3
3
  *
4
- * Each tag can optionally have a schema that describes expected metadata
5
- * fields for notes with that tag. Schemas drive auto-population of defaults
6
- * and soft warnings on create/tag operations.
4
+ * Each tag row carries: human-readable description, indexed metadata field
5
+ * declarations (`fields`), typed-link relationship declarations
6
+ * (`relationships`), and the hierarchy parent list (`parent_names`).
7
+ * See parachute-patterns/patterns/tag-data-model.md.
8
+ *
9
+ * This module retains the historical `tag-schemas` filename and exports
10
+ * (`TagSchema`, `listTagSchemas`, `getTagSchema`, `upsertTagSchema`,
11
+ * `deleteTagSchema`, `getTagSchemaMap`) as a thin schema-only facade —
12
+ * callers that only care about `description + fields` keep working without
13
+ * change. New surface (`TagRecord`, `listTagRecords`, `getTagRecord`,
14
+ * `upsertTagRecord`) returns the full row including relationships and
15
+ * parent_names.
7
16
  */
8
17
 
9
18
  import { Database } from "bun:sqlite";
@@ -23,87 +32,278 @@ export interface TagFieldSchema {
23
32
  indexed?: boolean;
24
33
  }
25
34
 
35
+ /**
36
+ * Cardinality vocabulary for typed relationships. Names rather than
37
+ * algebra so AI clients reading `list-tags` can reason about intent
38
+ * directly. Phase 1 is informational — declarations are not enforced
39
+ * at write time. See patterns/tag-data-model.md §Typed relationships.
40
+ */
41
+ export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
42
+
43
+ export const TAG_REL_CARDINALITIES: readonly TagRelCardinality[] = [
44
+ "one",
45
+ "optional",
46
+ "many",
47
+ "many-required",
48
+ ] as const;
49
+
50
+ export interface TagRelationship {
51
+ target_tag: string;
52
+ cardinality: TagRelCardinality;
53
+ description?: string;
54
+ }
55
+
56
+ /**
57
+ * Schema-only view of a tag — the historical shape. Backwards-compatible
58
+ * with v13-and-earlier callers.
59
+ */
26
60
  export interface TagSchema {
27
61
  tag: string;
28
62
  description?: string;
29
63
  fields?: Record<string, TagFieldSchema>;
30
64
  }
31
65
 
32
- // DB row shape
33
- interface TagSchemaRow {
34
- tag_name: string;
66
+ /**
67
+ * Full tag record — schema + typed relationships + hierarchy parents.
68
+ */
69
+ export interface TagRecord extends TagSchema {
70
+ relationships?: Record<string, TagRelationship>;
71
+ parent_names?: string[];
72
+ created_at?: string;
73
+ updated_at?: string;
74
+ }
75
+
76
+ // DB row shape (post-v14 `tags` table).
77
+ interface TagRow {
78
+ name: string;
35
79
  description: string | null;
36
80
  fields: string | null;
81
+ relationships: string | null;
82
+ parent_names: string | null;
83
+ created_at: string | null;
84
+ updated_at: string | null;
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // CRUD — full record
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /** List all tag records, sorted by name. */
92
+ export function listTagRecords(db: Database): TagRecord[] {
93
+ const rows = db.prepare(
94
+ "SELECT name, description, fields, relationships, parent_names, created_at, updated_at FROM tags ORDER BY name",
95
+ ).all() as TagRow[];
96
+ return rows.map(rowToRecord);
97
+ }
98
+
99
+ /** Get a single tag record, or null if the tag doesn't exist. */
100
+ export function getTagRecord(db: Database, tag: string): TagRecord | null {
101
+ const row = db.prepare(
102
+ "SELECT name, description, fields, relationships, parent_names, created_at, updated_at FROM tags WHERE name = ?",
103
+ ).get(tag) as TagRow | undefined;
104
+ return row ? rowToRecord(row) : null;
105
+ }
106
+
107
+ /**
108
+ * Upsert a tag record — partial update. Any field left `undefined` is
109
+ * preserved. Pass `null` explicitly to clear a column. Always touches
110
+ * `updated_at`; sets `created_at` on first insert.
111
+ */
112
+ export function upsertTagRecord(
113
+ db: Database,
114
+ tag: string,
115
+ patch: {
116
+ description?: string | null;
117
+ fields?: Record<string, TagFieldSchema> | null;
118
+ relationships?: Record<string, TagRelationship> | null;
119
+ parent_names?: string[] | null;
120
+ },
121
+ ): TagRecord {
122
+ const now = new Date().toISOString();
123
+ db.prepare(
124
+ "INSERT OR IGNORE INTO tags (name, created_at, updated_at) VALUES (?, ?, ?)",
125
+ ).run(tag, now, now);
126
+
127
+ const existing = getTagRecord(db, tag);
128
+
129
+ const description =
130
+ patch.description === undefined ? (existing?.description ?? null) : patch.description;
131
+ const fields =
132
+ patch.fields === undefined
133
+ ? jsonOrNull(existing?.fields)
134
+ : jsonOrNull(patch.fields);
135
+ const relationships =
136
+ patch.relationships === undefined
137
+ ? jsonOrNull(existing?.relationships)
138
+ : jsonOrNull(patch.relationships);
139
+ const parent_names =
140
+ patch.parent_names === undefined
141
+ ? jsonOrNull(existing?.parent_names)
142
+ : jsonOrNull(patch.parent_names);
143
+
144
+ db.prepare(
145
+ `UPDATE tags
146
+ SET description = ?, fields = ?, relationships = ?, parent_names = ?, updated_at = ?
147
+ WHERE name = ?`,
148
+ ).run(description, fields, relationships, parent_names, now, tag);
149
+
150
+ return getTagRecord(db, tag)!;
37
151
  }
38
152
 
39
153
  // ---------------------------------------------------------------------------
40
- // CRUD
154
+ // CRUD — schema-only facade (back-compat)
41
155
  // ---------------------------------------------------------------------------
42
156
 
43
- /** List all tag schemas. */
157
+ /** List schema-only views for tags that have a description or fields set. */
44
158
  export function listTagSchemas(db: Database): TagSchema[] {
45
- const rows = db.prepare("SELECT * FROM tag_schemas ORDER BY tag_name").all() as TagSchemaRow[];
46
- return rows.map(rowToSchema);
159
+ const rows = db.prepare(
160
+ "SELECT name, description, fields FROM tags WHERE description IS NOT NULL OR fields IS NOT NULL ORDER BY name",
161
+ ).all() as { name: string; description: string | null; fields: string | null }[];
162
+ return rows.map((r) => ({
163
+ tag: r.name,
164
+ description: r.description ?? undefined,
165
+ fields: parseJson<Record<string, TagFieldSchema>>(r.fields),
166
+ }));
47
167
  }
48
168
 
49
- /** Get a single tag's schema, or null if none defined. */
169
+ /**
170
+ * Schema-only view for a single tag. Returns null if the tag has neither
171
+ * a description nor fields (matches v13 behavior, where the absence of a
172
+ * `tag_schemas` row meant "no schema").
173
+ */
50
174
  export function getTagSchema(db: Database, tag: string): TagSchema | null {
51
- const row = db.prepare("SELECT * FROM tag_schemas WHERE tag_name = ?").get(tag) as TagSchemaRow | undefined;
52
- return row ? rowToSchema(row) : null;
175
+ const row = db.prepare(
176
+ "SELECT name, description, fields FROM tags WHERE name = ?",
177
+ ).get(tag) as { name: string; description: string | null; fields: string | null } | undefined;
178
+ if (!row) return null;
179
+ if (row.description === null && row.fields === null) return null;
180
+ return {
181
+ tag: row.name,
182
+ description: row.description ?? undefined,
183
+ fields: parseJson<Record<string, TagFieldSchema>>(row.fields),
184
+ };
53
185
  }
54
186
 
55
187
  /** Get all schemas as a lookup map (tag → schema). Used by schema effects. */
56
- export function getTagSchemaMap(db: Database): Record<string, { description?: string; fields?: Record<string, TagFieldSchema> }> {
57
- const schemas = listTagSchemas(db);
188
+ export function getTagSchemaMap(
189
+ db: Database,
190
+ ): Record<string, { description?: string; fields?: Record<string, TagFieldSchema> }> {
58
191
  const map: Record<string, { description?: string; fields?: Record<string, TagFieldSchema> }> = {};
59
- for (const s of schemas) {
192
+ for (const s of listTagSchemas(db)) {
60
193
  map[s.tag] = { description: s.description, fields: s.fields };
61
194
  }
62
195
  return map;
63
196
  }
64
197
 
65
198
  /**
66
- * Create or replace a tag schema (upsert).
67
- * Ensures the tag exists in the tags table first.
199
+ * Set or replace a tag's schema (description + fields). Other columns
200
+ * (`relationships`, `parent_names`) are left untouched. Idempotent.
68
201
  */
69
202
  export function upsertTagSchema(
70
203
  db: Database,
71
204
  tag: string,
72
205
  schema: { description?: string; fields?: Record<string, TagFieldSchema> },
73
206
  ): TagSchema {
74
- // Ensure tag exists
75
- db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)").run(tag);
76
-
77
- const fieldsJson = schema.fields ? JSON.stringify(schema.fields) : null;
78
- db.prepare(`
79
- INSERT INTO tag_schemas (tag_name, description, fields)
80
- VALUES (?, ?, ?)
81
- ON CONFLICT(tag_name) DO UPDATE SET
82
- description = excluded.description,
83
- fields = excluded.fields
84
- `).run(tag, schema.description ?? null, fieldsJson);
85
-
86
- return getTagSchema(db, tag)!;
207
+ upsertTagRecord(db, tag, {
208
+ description: schema.description ?? null,
209
+ fields: schema.fields ?? null,
210
+ });
211
+ return getTagSchema(db, tag) ?? { tag, description: schema.description, fields: schema.fields };
87
212
  }
88
213
 
89
- /** Delete a tag's schema. Returns true if a schema was deleted. */
214
+ /**
215
+ * Clear a tag's schema (description + fields). Returns true if anything
216
+ * was cleared. Other columns and the tag row itself are preserved — to
217
+ * delete the tag entirely, use `noteOps.deleteTag`.
218
+ */
90
219
  export function deleteTagSchema(db: Database, tag: string): boolean {
91
- const result = db.prepare("DELETE FROM tag_schemas WHERE tag_name = ?").run(tag);
92
- return result.changes > 0;
220
+ const before = getTagSchema(db, tag);
221
+ if (!before) return false;
222
+ db.prepare(
223
+ "UPDATE tags SET description = NULL, fields = NULL, updated_at = ? WHERE name = ?",
224
+ ).run(new Date().toISOString(), tag);
225
+ return true;
93
226
  }
94
227
 
95
228
  // ---------------------------------------------------------------------------
96
- // Helpers
229
+ // Validation — typed relationships
97
230
  // ---------------------------------------------------------------------------
98
231
 
99
- function rowToSchema(row: TagSchemaRow): TagSchema {
100
- let fields: Record<string, TagFieldSchema> | undefined;
101
- if (row.fields) {
102
- try { fields = JSON.parse(row.fields); } catch {}
232
+ /**
233
+ * Validate a `relationships` payload before persisting. Returns the
234
+ * canonicalized object on success; throws Error with a user-readable
235
+ * message on the first violation. Rules:
236
+ *
237
+ * - Each value must declare `target_tag` (non-empty string) and
238
+ * `cardinality` from the named vocabulary.
239
+ * - `description` is optional, must be a string when present.
240
+ * - Relationship keys must be non-empty strings.
241
+ */
242
+ export function validateRelationships(
243
+ raw: unknown,
244
+ ): Record<string, TagRelationship> {
245
+ if (raw === null || raw === undefined) {
246
+ throw new Error("relationships: expected an object, got null/undefined");
247
+ }
248
+ if (typeof raw !== "object" || Array.isArray(raw)) {
249
+ throw new Error("relationships: expected an object mapping rel name → declaration");
103
250
  }
251
+ const out: Record<string, TagRelationship> = {};
252
+ for (const [rel, decl] of Object.entries(raw as Record<string, unknown>)) {
253
+ if (!rel || typeof rel !== "string") {
254
+ throw new Error("relationships: keys must be non-empty strings");
255
+ }
256
+ if (!decl || typeof decl !== "object" || Array.isArray(decl)) {
257
+ throw new Error(`relationships["${rel}"]: declaration must be an object`);
258
+ }
259
+ const d = decl as Record<string, unknown>;
260
+ if (typeof d.target_tag !== "string" || d.target_tag.length === 0) {
261
+ throw new Error(`relationships["${rel}"]: target_tag must be a non-empty string`);
262
+ }
263
+ const card = d.cardinality;
264
+ if (typeof card !== "string" || !TAG_REL_CARDINALITIES.includes(card as TagRelCardinality)) {
265
+ throw new Error(
266
+ `relationships["${rel}"]: cardinality must be one of ${TAG_REL_CARDINALITIES.join(" | ")}; got ${JSON.stringify(card)}`,
267
+ );
268
+ }
269
+ if (d.description !== undefined && typeof d.description !== "string") {
270
+ throw new Error(`relationships["${rel}"]: description must be a string when set`);
271
+ }
272
+ out[rel] = {
273
+ target_tag: d.target_tag,
274
+ cardinality: card as TagRelCardinality,
275
+ ...(d.description !== undefined ? { description: d.description as string } : {}),
276
+ };
277
+ }
278
+ return out;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Helpers
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function rowToRecord(row: TagRow): TagRecord {
104
286
  return {
105
- tag: row.tag_name,
287
+ tag: row.name,
106
288
  description: row.description ?? undefined,
107
- fields,
289
+ fields: parseJson<Record<string, TagFieldSchema>>(row.fields),
290
+ relationships: parseJson<Record<string, TagRelationship>>(row.relationships),
291
+ parent_names: parseJson<string[]>(row.parent_names),
292
+ created_at: row.created_at ?? undefined,
293
+ updated_at: row.updated_at ?? undefined,
108
294
  };
109
295
  }
296
+
297
+ function parseJson<T>(raw: string | null): T | undefined {
298
+ if (raw === null || raw === undefined) return undefined;
299
+ try { return JSON.parse(raw) as T; } catch { return undefined; }
300
+ }
301
+
302
+ function jsonOrNull(value: unknown): string | null {
303
+ if (value === null || value === undefined) return null;
304
+ if (typeof value === "string") {
305
+ // Already-encoded payload (e.g. when copying from an existing row).
306
+ return value;
307
+ }
308
+ return JSON.stringify(value);
309
+ }
package/core/src/types.ts CHANGED
@@ -1,3 +1,24 @@
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
+
11
+ // ---- Re-exports ----
12
+
13
+ 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
+
1
22
  // ---- Note ----
2
23
 
3
24
  export interface Note {
@@ -41,6 +62,7 @@ export interface VaultStats {
41
62
  notesByMonth: { month: string; count: number }[];
42
63
  topTags: { tag: string; count: number }[];
43
64
  tagCount: number;
65
+ attachmentCount: number;
44
66
  linkCount: number;
45
67
  }
46
68
 
@@ -57,14 +79,33 @@ export interface QueryOpts {
57
79
  hasLinks?: boolean;
58
80
  path?: string; // exact path match (case-insensitive)
59
81
  pathPrefix?: string; // e.g., "Projects/Parachute" matches "Projects/Parachute/README"
82
+ // Restrict results to a specific set of note IDs. The MCP `near` query uses
83
+ // this to push graph-neighborhood scoping into the SQL WHERE clause so that
84
+ // LIMIT and ORDER BY apply to the filtered set, not the whole notes table.
85
+ // Empty array → no rows match (avoids `IN ()` syntax error).
86
+ ids?: string[];
60
87
  // Per-field metadata filter. Each value is either a primitive (exact
61
88
  // match, today's behavior) or an operator object — `{ eq, ne, gt, gte, lt,
62
89
  // lte, in, not_in, exists }` — which routes through the generated column
63
90
  // for the field. Operator queries require the field to be declared
64
91
  // `indexed: true` in a tag schema; undeclared fields error loudly.
65
92
  metadata?: Record<string, unknown>;
93
+ // Legacy shorthand: filters on `n.created_at` (vault ingestion time).
94
+ // Equivalent to `dateFilter: { field: "created_at", from, to }`. Kept
95
+ // as the common path; specifying both this and `dateFilter` rejects.
66
96
  dateFrom?: string; // ISO date
67
97
  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.
104
+ dateFilter?: {
105
+ field?: string;
106
+ from?: string;
107
+ to?: string;
108
+ };
68
109
  sort?: "asc" | "desc";
69
110
  // Sort by an indexed metadata field instead of `created_at`. Must be
70
111
  // declared `indexed: true`; errors loudly otherwise. Direction is taken
@@ -114,7 +155,7 @@ export interface Store {
114
155
  getNote(id: string): Promise<Note | null>;
115
156
  getNoteByPath(path: string): Promise<Note | null>;
116
157
  getNotes(ids: string[]): Promise<Note[]>;
117
- updateNote(id: string, updates: { content?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
158
+ updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; if_updated_at?: string }): Promise<Note>;
118
159
  deleteNote(id: string): Promise<void>;
119
160
  queryNotes(opts: QueryOpts): Promise<Note[]>;
120
161
  searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]>;
@@ -122,6 +163,13 @@ export interface Store {
122
163
  // Tags
123
164
  tagNote(noteId: string, tags: string[]): Promise<void>;
124
165
  untagNote(noteId: string, tags: string[]): Promise<void>;
166
+ /**
167
+ * Expand a set of tag names to the union of `{tag} ∪ descendants(tag)` for
168
+ * each input, using the `_tags/<name>` config-note hierarchy. Always
169
+ * includes each input tag in the result. Used by tag-scoped tokens to
170
+ * compute the effective allowlisted tag-set at auth time.
171
+ */
172
+ expandTagsWithDescendants(tags: string[]): Promise<Set<string>>;
125
173
  listTags(): Promise<{ name: string; count: number }[]>;
126
174
  deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
127
175
  renameTag(
@@ -151,12 +199,58 @@ export interface Store {
151
199
  traverseLinks(noteId: string, opts?: { max_depth?: number; relationship?: string }): Promise<{ noteId: string; depth: number; relationship: string; direction: "outbound" | "inbound" }[]>;
152
200
  findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<{ path: string[]; relationships: string[] } | null>;
153
201
 
154
- // Tag schemas
155
- listTagSchemas(): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }[]>;
156
- getTagSchema(tag: string): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> } | null>;
157
- upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }>;
202
+ // Tag schemas — schema-only facade (description + fields). Back-compat
203
+ // surface for v13-and-earlier callers; reads/writes route through the
204
+ // post-v14 `tags` row directly.
205
+ listTagSchemas(): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }[]>;
206
+ getTagSchema(tag: string): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> } | null>;
207
+ upsertTagSchema(tag: string, schema: { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }): Promise<{ tag: string; description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }>;
158
208
  deleteTagSchema(tag: string): Promise<boolean>;
159
- getTagSchemaMap(): Promise<Record<string, { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[] }> }>>;
209
+ getTagSchemaMap(): Promise<Record<string, { description?: string; fields?: Record<string, { type: string; description?: string; enum?: string[]; indexed?: boolean }> }>>;
210
+
211
+ // Tag records — full v14 identity row (description + fields + typed
212
+ // relationships + parent_names + timestamps). See
213
+ // parachute-patterns/patterns/tag-data-model.md.
214
+ listTagRecords(): Promise<TagRecord[]>;
215
+ getTagRecord(tag: string): Promise<TagRecord | null>;
216
+ /**
217
+ * Partial upsert. Any patch field left undefined is preserved; pass
218
+ * null to clear. Touching `parent_names` invalidates the tag-hierarchy
219
+ * cache. Returns the post-write row.
220
+ */
221
+ upsertTagRecord(
222
+ tag: string,
223
+ patch: {
224
+ description?: string | null;
225
+ fields?: Record<string, TagFieldSchema> | null;
226
+ relationships?: Record<string, TagRelationship> | null;
227
+ parent_names?: string[] | null;
228
+ },
229
+ ): Promise<TagRecord>;
230
+
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.
234
+ validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): {
235
+ schemas: string[];
236
+ warnings: { field: string; schema: string; reason: "missing_required" | "type_mismatch" | "enum_mismatch"; message: string }[];
237
+ } | null;
238
+
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>;
160
254
 
161
255
  // Attachments
162
256
  addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;
@@ -44,7 +44,7 @@ export function parseWikilinks(content: string): ParsedWikilink[] {
44
44
 
45
45
  while ((match = regex.exec(stripped)) !== null) {
46
46
  const embed = match[1] === "!";
47
- const inner = match[2];
47
+ const inner = match[2]!;
48
48
 
49
49
  // Split on | for display text: [[target|display]]
50
50
  const pipeIdx = inner.indexOf("|");
@@ -133,7 +133,7 @@ export function resolveWikilink(db: Database, target: string): string | null {
133
133
  )
134
134
  `).all(target, `%/${target}`) as { id: string }[];
135
135
 
136
- if (basename.length === 1) return basename[0].id;
136
+ if (basename.length === 1) return basename[0]!.id;
137
137
 
138
138
  // Ambiguous or no match
139
139
  return null;
@@ -171,7 +171,7 @@ export function resolveWikilinkDetailed(db: Database, target: string): WikilinkR
171
171
  `).all(target, `%/${target}`) as { id: string; path: string }[];
172
172
 
173
173
  if (basename.length === 1) {
174
- return { resolved: true, note_id: basename[0].id, path: basename[0].path, candidates: [] };
174
+ return { resolved: true, note_id: basename[0]!.id, path: basename[0]!.path, candidates: [] };
175
175
  }
176
176
 
177
177
  if (basename.length > 1) {
package/package.json CHANGED
@@ -1,20 +1,30 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "parachute-vault": "src/cli.ts"
9
9
  },
10
+ "files": [
11
+ "src",
12
+ "core/src",
13
+ "core/package.json",
14
+ ".parachute",
15
+ "tsconfig.json"
16
+ ],
10
17
  "scripts": {
11
18
  "start": "bun src/server.ts",
12
19
  "cli": "bun src/cli.ts",
13
- "test": "bun test src/",
14
- "test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run"
20
+ "test": "bun test ./src/",
21
+ "test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run",
22
+ "typecheck": "tsc --noEmit"
15
23
  },
16
24
  "dependencies": {
17
25
  "@modelcontextprotocol/sdk": "^1.12.1",
26
+ "@openparachute/scope-guard": "^0.1.0",
27
+ "jose": "^6.2.2",
18
28
  "otpauth": "^9.5.0",
19
29
  "qrcode-terminal": "^0.12.0"
20
30
  },