@openparachute/vault 0.6.0-rc.1 β 0.6.1
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/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/core/src/tag-schemas.ts
CHANGED
|
@@ -33,10 +33,13 @@ export interface TagFieldSchema {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Cardinality vocabulary for typed
|
|
37
|
-
* algebra so AI clients reading `list-tags` can reason
|
|
38
|
-
* directly.
|
|
39
|
-
*
|
|
36
|
+
* Cardinality vocabulary for the historical typed-relationship shape.
|
|
37
|
+
* Names rather than algebra so AI clients reading `list-tags` can reason
|
|
38
|
+
* about intent directly. Retained for callers that still want the typed
|
|
39
|
+
* `{ target_tag, cardinality }` declaration β but `relationships` is now an
|
|
40
|
+
* opaque vocabulary map (see `TagRelationshipMap` / `validateRelationships`),
|
|
41
|
+
* so this is one valid value shape among many, not a required one.
|
|
42
|
+
* See patterns/tag-data-model.md Β§Relationships.
|
|
40
43
|
*/
|
|
41
44
|
export type TagRelCardinality = "one" | "optional" | "many" | "many-required";
|
|
42
45
|
|
|
@@ -47,12 +50,24 @@ export const TAG_REL_CARDINALITIES: readonly TagRelCardinality[] = [
|
|
|
47
50
|
"many-required",
|
|
48
51
|
] as const;
|
|
49
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The historical typed-relationship declaration. Still a valid opaque-map
|
|
55
|
+
* value β vault no longer enforces it. New apps (the Weaver / structural-link
|
|
56
|
+
* picker) declare their own freeform vocabulary instead.
|
|
57
|
+
*/
|
|
50
58
|
export interface TagRelationship {
|
|
51
59
|
target_tag: string;
|
|
52
60
|
cardinality: TagRelCardinality;
|
|
53
61
|
description?: string;
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
/**
|
|
65
|
+
* `relationships` is an opaque vocabulary map: relationship-name β arbitrary
|
|
66
|
+
* JSON value the declaring app interprets. Vault stores and returns the values
|
|
67
|
+
* verbatim and enforces only that the top-level value is a JSON object (a map).
|
|
68
|
+
*/
|
|
69
|
+
export type TagRelationshipMap = Record<string, unknown>;
|
|
70
|
+
|
|
56
71
|
/**
|
|
57
72
|
* Schema-only view of a tag β the historical shape. Backwards-compatible
|
|
58
73
|
* with v13-and-earlier callers.
|
|
@@ -67,7 +82,7 @@ export interface TagSchema {
|
|
|
67
82
|
* Full tag record β schema + typed relationships + hierarchy parents.
|
|
68
83
|
*/
|
|
69
84
|
export interface TagRecord extends TagSchema {
|
|
70
|
-
relationships?:
|
|
85
|
+
relationships?: TagRelationshipMap;
|
|
71
86
|
parent_names?: string[];
|
|
72
87
|
created_at?: string;
|
|
73
88
|
updated_at?: string;
|
|
@@ -100,7 +115,7 @@ export function listTagRecords(db: Database): TagRecord[] {
|
|
|
100
115
|
export function getTagRecord(db: Database, tag: string): TagRecord | null {
|
|
101
116
|
const row = db.prepare(
|
|
102
117
|
"SELECT name, description, fields, relationships, parent_names, created_at, updated_at FROM tags WHERE name = ?",
|
|
103
|
-
).get(tag) as TagRow |
|
|
118
|
+
).get(tag) as TagRow | null;
|
|
104
119
|
return row ? rowToRecord(row) : null;
|
|
105
120
|
}
|
|
106
121
|
|
|
@@ -115,7 +130,7 @@ export function upsertTagRecord(
|
|
|
115
130
|
patch: {
|
|
116
131
|
description?: string | null;
|
|
117
132
|
fields?: Record<string, TagFieldSchema> | null;
|
|
118
|
-
relationships?:
|
|
133
|
+
relationships?: TagRelationshipMap | null;
|
|
119
134
|
parent_names?: string[] | null;
|
|
120
135
|
},
|
|
121
136
|
): TagRecord {
|
|
@@ -174,7 +189,7 @@ export function listTagSchemas(db: Database): TagSchema[] {
|
|
|
174
189
|
export function getTagSchema(db: Database, tag: string): TagSchema | null {
|
|
175
190
|
const row = db.prepare(
|
|
176
191
|
"SELECT name, description, fields FROM tags WHERE name = ?",
|
|
177
|
-
).get(tag) as { name: string; description: string | null; fields: string | null } |
|
|
192
|
+
).get(tag) as { name: string; description: string | null; fields: string | null } | null;
|
|
178
193
|
if (!row) return null;
|
|
179
194
|
if (row.description === null && row.fields === null) return null;
|
|
180
195
|
return {
|
|
@@ -226,56 +241,56 @@ export function deleteTagSchema(db: Database, tag: string): boolean {
|
|
|
226
241
|
}
|
|
227
242
|
|
|
228
243
|
// ---------------------------------------------------------------------------
|
|
229
|
-
// Validation β
|
|
244
|
+
// Validation β relationships (opaque vocabulary map)
|
|
230
245
|
// ---------------------------------------------------------------------------
|
|
231
246
|
|
|
232
247
|
/**
|
|
233
|
-
* Validate a `relationships` payload before persisting.
|
|
234
|
-
*
|
|
235
|
-
*
|
|
248
|
+
* Validate a `relationships` payload before persisting. `relationships` is
|
|
249
|
+
* an **opaque vocabulary map**: a JSON object whose keys are relationship
|
|
250
|
+
* names and whose values are arbitrary JSON the declaring app interprets
|
|
251
|
+
* (e.g. the Weaver / structural-link picker's `{ "works-on": { from, to } }`
|
|
252
|
+
* shape). Vault does not enforce any inner structure β it stores and returns
|
|
253
|
+
* the values verbatim.
|
|
254
|
+
*
|
|
255
|
+
* Rules (the only ones):
|
|
256
|
+
* - The top-level value must be a plain JSON object (a map). A top-level
|
|
257
|
+
* array or primitive is rejected β relationships is a map, not a list.
|
|
258
|
+
* - The payload must be JSON-serializable (no circular refs / functions /
|
|
259
|
+
* bigints), since it's persisted as a JSON column.
|
|
236
260
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
261
|
+
* Returns the value verbatim (round-trips through JSON.parse(JSON.stringify)
|
|
262
|
+
* to both prove serializability and strip anything non-serializable). The
|
|
263
|
+
* historical typed shape `{ target_tag, cardinality }` is a valid opaque map,
|
|
264
|
+
* so this is a backwards-compatible superset β existing typed declarations
|
|
265
|
+
* and callers keep working unchanged.
|
|
266
|
+
*
|
|
267
|
+
* Phase 1 was already informational ("declarations are not enforced at write
|
|
268
|
+
* time"); dropping the inner-shape gate is consistent with that intent.
|
|
241
269
|
*/
|
|
242
|
-
export function validateRelationships(
|
|
243
|
-
raw: unknown,
|
|
244
|
-
): Record<string, TagRelationship> {
|
|
270
|
+
export function validateRelationships(raw: unknown): Record<string, unknown> {
|
|
245
271
|
if (raw === null || raw === undefined) {
|
|
246
272
|
throw new Error("relationships: expected an object, got null/undefined");
|
|
247
273
|
}
|
|
248
274
|
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
249
|
-
throw new Error(
|
|
275
|
+
throw new Error(
|
|
276
|
+
"relationships: expected an object mapping relationship name β value (got an array or primitive)",
|
|
277
|
+
);
|
|
250
278
|
}
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
if (!rel || typeof rel !== "string") {
|
|
279
|
+
for (const rel of Object.keys(raw as Record<string, unknown>)) {
|
|
280
|
+
if (!rel) {
|
|
254
281
|
throw new Error("relationships: keys must be non-empty strings");
|
|
255
282
|
}
|
|
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
283
|
}
|
|
278
|
-
|
|
284
|
+
// Round-trip through JSON to (a) confirm the payload is serializable β
|
|
285
|
+
// the column is stored as JSON β and (b) return a clean, owned copy with
|
|
286
|
+
// no non-JSON values lingering. Throws on circular refs / bigint / etc.
|
|
287
|
+
let serialized: string;
|
|
288
|
+
try {
|
|
289
|
+
serialized = JSON.stringify(raw);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
throw new Error(`relationships: value must be JSON-serializable (${(err as Error).message})`);
|
|
292
|
+
}
|
|
293
|
+
return JSON.parse(serialized) as Record<string, unknown>;
|
|
279
294
|
}
|
|
280
295
|
|
|
281
296
|
// ---------------------------------------------------------------------------
|
|
@@ -287,7 +302,7 @@ function rowToRecord(row: TagRow): TagRecord {
|
|
|
287
302
|
tag: row.name,
|
|
288
303
|
description: row.description ?? undefined,
|
|
289
304
|
fields: parseJson<Record<string, TagFieldSchema>>(row.fields),
|
|
290
|
-
relationships: parseJson<
|
|
305
|
+
relationships: parseJson<TagRelationshipMap>(row.relationships),
|
|
291
306
|
parent_names: parseJson<string[]>(row.parent_names),
|
|
292
307
|
created_at: row.created_at ?? undefined,
|
|
293
308
|
updated_at: row.updated_at ?? undefined,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the persisted-triggers store (core/src/triggers-store.ts) β
|
|
3
|
+
* JSON encode/decode round-trip + upsert/list/get/delete semantics over an
|
|
4
|
+
* in-memory SQLite DB (schema v21).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { initSchema } from "./schema.js";
|
|
10
|
+
import {
|
|
11
|
+
upsertTrigger,
|
|
12
|
+
listTriggers,
|
|
13
|
+
getTrigger,
|
|
14
|
+
deleteTrigger,
|
|
15
|
+
loadAllTriggers,
|
|
16
|
+
} from "./triggers-store.js";
|
|
17
|
+
|
|
18
|
+
let db: Database;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
db = new Database(":memory:");
|
|
22
|
+
initSchema(db);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function sample(name: string) {
|
|
26
|
+
return {
|
|
27
|
+
name,
|
|
28
|
+
events: ["created", "updated"] as Array<"created" | "updated">,
|
|
29
|
+
when: { tags: ["channel-message"], has_content: true },
|
|
30
|
+
action: {
|
|
31
|
+
webhook: "https://example.test/hook",
|
|
32
|
+
send: "json" as const,
|
|
33
|
+
timeout: 30000,
|
|
34
|
+
auth: { bearer: "jwt-token" },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("triggers-store", () => {
|
|
40
|
+
test("the triggers table exists after initSchema (v21)", () => {
|
|
41
|
+
const row = db
|
|
42
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='triggers'")
|
|
43
|
+
.get();
|
|
44
|
+
expect(row).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("upsert β get round-trips the structured JSON columns", () => {
|
|
48
|
+
upsertTrigger(db, sample("inbound"));
|
|
49
|
+
const got = getTrigger(db, "inbound");
|
|
50
|
+
expect(got).not.toBeNull();
|
|
51
|
+
expect(got!.when).toEqual({ tags: ["channel-message"], has_content: true });
|
|
52
|
+
expect(got!.action.webhook).toBe("https://example.test/hook");
|
|
53
|
+
expect(got!.action.auth).toEqual({ bearer: "jwt-token" });
|
|
54
|
+
expect(got!.events).toEqual(["created", "updated"]);
|
|
55
|
+
expect(got!.created_at).toBeTruthy();
|
|
56
|
+
expect(got!.updated_at).toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("upsert by name replaces + preserves created_at, bumps updated_at", async () => {
|
|
60
|
+
const first = upsertTrigger(db, sample("inbound"));
|
|
61
|
+
// Ensure clock advances so updated_at differs.
|
|
62
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
63
|
+
const second = upsertTrigger(db, {
|
|
64
|
+
...sample("inbound"),
|
|
65
|
+
action: { webhook: "https://example.test/v2" },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(listTriggers(db)).toHaveLength(1);
|
|
69
|
+
expect(second.created_at).toBe(first.created_at);
|
|
70
|
+
expect(second.updated_at >= first.updated_at).toBe(true);
|
|
71
|
+
expect(getTrigger(db, "inbound")!.action.webhook).toBe("https://example.test/v2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("events defaults to [created, updated] when omitted", () => {
|
|
75
|
+
upsertTrigger(db, {
|
|
76
|
+
name: "no-events",
|
|
77
|
+
when: { tags: ["x"] },
|
|
78
|
+
action: { webhook: "https://example.test/hook" },
|
|
79
|
+
});
|
|
80
|
+
expect(getTrigger(db, "no-events")!.events).toEqual(["created", "updated"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("list / loadAllTriggers return all rows ordered by name", () => {
|
|
84
|
+
upsertTrigger(db, sample("zebra"));
|
|
85
|
+
upsertTrigger(db, sample("alpha"));
|
|
86
|
+
expect(listTriggers(db).map((t) => t.name)).toEqual(["alpha", "zebra"]);
|
|
87
|
+
expect(loadAllTriggers(db).map((t) => t.name)).toEqual(["alpha", "zebra"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("delete removes the row; returns false on a missing name", () => {
|
|
91
|
+
upsertTrigger(db, sample("inbound"));
|
|
92
|
+
expect(deleteTrigger(db, "inbound")).toBe(true);
|
|
93
|
+
expect(getTrigger(db, "inbound")).toBeNull();
|
|
94
|
+
expect(deleteTrigger(db, "inbound")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("getTrigger returns null for an unknown name", () => {
|
|
98
|
+
expect(getTrigger(db, "nope")).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted runtime triggers β per-vault CRUD over the `triggers` table
|
|
3
|
+
* (schema v21).
|
|
4
|
+
*
|
|
5
|
+
* Complements the static `config.yaml` trigger system: config.yaml triggers
|
|
6
|
+
* are loaded at boot and fire globally (all vaults); rows in this table live
|
|
7
|
+
* in a single vault's SQLite DB and are re-registered at boot scoped to that
|
|
8
|
+
* vault (they fire ONLY for events on the vault they were stored under).
|
|
9
|
+
*
|
|
10
|
+
* The structured columns (`events`, `when`, `action`) are JSON-encoded on
|
|
11
|
+
* write and parsed on read. `name` is the primary key, so `upsertTrigger`
|
|
12
|
+
* is a true upsert (insert-or-replace by name). The shape here is kept
|
|
13
|
+
* structurally compatible with `src/config.ts`'s `TriggerConfig` /
|
|
14
|
+
* `TriggerWhen` / `TriggerAction` without importing from `src/` β core stays
|
|
15
|
+
* dependency-free of the server layer.
|
|
16
|
+
*
|
|
17
|
+
* `action.auth.bearer`, when present, becomes an `Authorization: Bearer`
|
|
18
|
+
* header on the webhook POST (the JWT webhook-auth path that retires the
|
|
19
|
+
* old `?secret=` query param). It is stored verbatim in the JSON column.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Database } from "bun:sqlite";
|
|
23
|
+
|
|
24
|
+
/** Predicate shape β mirrors src/config.ts:TriggerWhen. */
|
|
25
|
+
export interface StoredTriggerWhen {
|
|
26
|
+
tags?: string[];
|
|
27
|
+
has_content?: boolean;
|
|
28
|
+
missing_metadata?: string[];
|
|
29
|
+
has_metadata?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Webhook auth β only the bearer-JWT path for now. */
|
|
33
|
+
export interface StoredTriggerAuth {
|
|
34
|
+
bearer?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Action shape β mirrors src/config.ts:TriggerAction plus `auth`. */
|
|
38
|
+
export interface StoredTriggerAction {
|
|
39
|
+
webhook: string;
|
|
40
|
+
timeout?: number;
|
|
41
|
+
send?: "json" | "attachment" | "content";
|
|
42
|
+
auth?: StoredTriggerAuth;
|
|
43
|
+
// Forward-compat: include_context and any other action fields round-trip
|
|
44
|
+
// through the JSON column verbatim even though core doesn't interpret them.
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A persisted trigger row, decoded. */
|
|
49
|
+
export interface StoredTrigger {
|
|
50
|
+
name: string;
|
|
51
|
+
events: Array<"created" | "updated">;
|
|
52
|
+
when: StoredTriggerWhen;
|
|
53
|
+
action: StoredTriggerAction;
|
|
54
|
+
created_at: string;
|
|
55
|
+
updated_at: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The writable subset callers pass to upsert. */
|
|
59
|
+
export interface TriggerInput {
|
|
60
|
+
name: string;
|
|
61
|
+
events?: Array<"created" | "updated">;
|
|
62
|
+
when: StoredTriggerWhen;
|
|
63
|
+
action: StoredTriggerAction;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface TriggerRow {
|
|
67
|
+
name: string;
|
|
68
|
+
events: string;
|
|
69
|
+
when: string;
|
|
70
|
+
action: string;
|
|
71
|
+
created_at: string;
|
|
72
|
+
updated_at: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decodeRow(row: TriggerRow): StoredTrigger {
|
|
76
|
+
return {
|
|
77
|
+
name: row.name,
|
|
78
|
+
events: safeParse(row.events, ["created", "updated"]) as Array<"created" | "updated">,
|
|
79
|
+
when: safeParse(row.when, {}) as StoredTriggerWhen,
|
|
80
|
+
action: safeParse(row.action, { webhook: "" }) as StoredTriggerAction,
|
|
81
|
+
created_at: row.created_at,
|
|
82
|
+
updated_at: row.updated_at,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function safeParse(json: string, fallback: unknown): unknown {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(json);
|
|
89
|
+
} catch {
|
|
90
|
+
return fallback;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Insert or replace a trigger by name. Preserves `created_at` on update
|
|
96
|
+
* (re-fetches the existing row's timestamp) and stamps a fresh `updated_at`.
|
|
97
|
+
* Returns the stored shape.
|
|
98
|
+
*/
|
|
99
|
+
export function upsertTrigger(db: Database, input: TriggerInput): StoredTrigger {
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
const existing = db
|
|
102
|
+
.prepare("SELECT created_at FROM triggers WHERE name = ?")
|
|
103
|
+
.get(input.name) as { created_at: string } | null;
|
|
104
|
+
const createdAt = existing?.created_at ?? now;
|
|
105
|
+
const events = input.events ?? ["created", "updated"];
|
|
106
|
+
|
|
107
|
+
db.prepare(
|
|
108
|
+
`INSERT INTO triggers (name, events, "when", action, created_at, updated_at)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
110
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
111
|
+
events = excluded.events,
|
|
112
|
+
"when" = excluded."when",
|
|
113
|
+
action = excluded.action,
|
|
114
|
+
updated_at = excluded.updated_at`,
|
|
115
|
+
).run(
|
|
116
|
+
input.name,
|
|
117
|
+
JSON.stringify(events),
|
|
118
|
+
JSON.stringify(input.when),
|
|
119
|
+
JSON.stringify(input.action),
|
|
120
|
+
createdAt,
|
|
121
|
+
now,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
name: input.name,
|
|
126
|
+
events,
|
|
127
|
+
when: input.when,
|
|
128
|
+
action: input.action,
|
|
129
|
+
created_at: createdAt,
|
|
130
|
+
updated_at: now,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** List all persisted triggers for this vault, ordered by name. */
|
|
135
|
+
export function listTriggers(db: Database): StoredTrigger[] {
|
|
136
|
+
const rows = db
|
|
137
|
+
.prepare(
|
|
138
|
+
`SELECT name, events, "when", action, created_at, updated_at
|
|
139
|
+
FROM triggers ORDER BY name`,
|
|
140
|
+
)
|
|
141
|
+
.all() as TriggerRow[];
|
|
142
|
+
return rows.map(decodeRow);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Fetch a single trigger by name, or null. */
|
|
146
|
+
export function getTrigger(db: Database, name: string): StoredTrigger | null {
|
|
147
|
+
const row = db
|
|
148
|
+
.prepare(
|
|
149
|
+
`SELECT name, events, "when", action, created_at, updated_at
|
|
150
|
+
FROM triggers WHERE name = ?`,
|
|
151
|
+
)
|
|
152
|
+
.get(name) as TriggerRow | null;
|
|
153
|
+
return row ? decodeRow(row) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Delete a trigger by name. Returns true if a row was removed. */
|
|
157
|
+
export function deleteTrigger(db: Database, name: string): boolean {
|
|
158
|
+
const res = db.prepare("DELETE FROM triggers WHERE name = ?").run(name);
|
|
159
|
+
return res.changes > 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Load all persisted triggers (boot path). Alias of listTriggers for clarity. */
|
|
163
|
+
export function loadAllTriggers(db: Database): StoredTrigger[] {
|
|
164
|
+
return listTriggers(db);
|
|
165
|
+
}
|
package/core/src/types.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
2
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
4
|
+
import type { TagExpandMode } from "./tag-hierarchy.js";
|
|
3
5
|
|
|
4
6
|
// ---- Re-exports ----
|
|
5
7
|
|
|
6
|
-
export type { TagFieldSchema, TagRelationship, TagRecord } from "./tag-schemas.js";
|
|
8
|
+
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
7
9
|
export type { PrunedField } from "./indexed-fields.js";
|
|
10
|
+
export type { TagExpandMode } from "./tag-hierarchy.js";
|
|
8
11
|
|
|
9
12
|
// ---- Note ----
|
|
10
13
|
|
|
@@ -25,6 +28,14 @@ export interface Note {
|
|
|
25
28
|
updatedAt?: string;
|
|
26
29
|
tags?: string[];
|
|
27
30
|
links?: Link[];
|
|
31
|
+
/**
|
|
32
|
+
* Opt-in link degree (raw row count, both directions by default). Present
|
|
33
|
+
* only when the caller requests it via `include_link_count` (REST/MCP).
|
|
34
|
+
* Surfaced the same way `links`/`attachments` are β an extra key injected
|
|
35
|
+
* onto the response after the base shape. See `getLinkCounts` in links.ts
|
|
36
|
+
* for the exact degree semantics (self-loop = 2 under `both`).
|
|
37
|
+
*/
|
|
38
|
+
linkCount?: number;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
// ---- Link ----
|
|
@@ -59,6 +70,16 @@ export interface VaultStats {
|
|
|
59
70
|
tagCount: number;
|
|
60
71
|
attachmentCount: number;
|
|
61
72
|
linkCount: number;
|
|
73
|
+
/**
|
|
74
|
+
* Total bytes of all note content, computed as the sum of the UTF-8 byte
|
|
75
|
+
* length of every note's `content`. The SQL uses `LENGTH(CAST(content AS
|
|
76
|
+
* BLOB))` deliberately: SQLite's bare `LENGTH(text)` returns the number of
|
|
77
|
+
* *characters*, not bytes, so a note full of multibyte UTF-8 (emoji, CJK,
|
|
78
|
+
* accents) would undercount its true on-disk/on-wire footprint. Casting to
|
|
79
|
+
* BLOB forces `LENGTH` to count raw bytes. This is the logical content size,
|
|
80
|
+
* not the physical DB-file size (see `usage.ts:dbBytes` for the latter).
|
|
81
|
+
*/
|
|
82
|
+
contentBytes: number;
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
// ---- Query Options ----
|
|
@@ -66,6 +87,18 @@ export interface VaultStats {
|
|
|
66
87
|
export interface QueryOpts {
|
|
67
88
|
tags?: string[];
|
|
68
89
|
tagMatch?: "all" | "any"; // "all" = must have ALL tags (default), "any" = must have ANY tag
|
|
90
|
+
/**
|
|
91
|
+
* Tag-expansion axis (vault tag `expand` axis β design
|
|
92
|
+
* `design/2026-06-09-tag-expand-axis.md`). Selects how each `tags` entry
|
|
93
|
+
* expands:
|
|
94
|
+
* - `"subtypes"` (DEFAULT): tag βͺ `parent_names` descendants. Today's
|
|
95
|
+
* semantic is-a behavior, unchanged. `_default` universal magic fires here.
|
|
96
|
+
* - `"namespace"`: tag βͺ lexically name-prefixed `tag/*` (the filing axis).
|
|
97
|
+
* - `"both"`: union of subtypes + namespace.
|
|
98
|
+
* - `"exact"`: the literal tag only, no expansion.
|
|
99
|
+
* Absent β `"subtypes"` β byte-identical to pre-axis behavior.
|
|
100
|
+
*/
|
|
101
|
+
expand?: TagExpandMode;
|
|
69
102
|
excludeTags?: string[];
|
|
70
103
|
// Presence filters. `true` β has at least one; `false` β has none.
|
|
71
104
|
// When `tags` is also set, `hasTags` is ignored (the tag filter already constrains the set).
|
|
@@ -115,6 +148,12 @@ export interface QueryOpts {
|
|
|
115
148
|
// declared `indexed: true`; errors loudly otherwise. Direction is taken
|
|
116
149
|
// from `sort` (default "asc") and `created_at` is appended as a stable
|
|
117
150
|
// tiebreaker.
|
|
151
|
+
//
|
|
152
|
+
// The pseudo-field `link_count` is special-cased (no indexed-field
|
|
153
|
+
// declaration needed): it sorts by link DEGREE β the both-directions
|
|
154
|
+
// raw row count β using the same directional-sum definition as the
|
|
155
|
+
// `linkCount` response field, so the sort key equals the field value for
|
|
156
|
+
// every note (self-loops included). See `queryNotes`/`getLinkCounts`.
|
|
118
157
|
orderBy?: string;
|
|
119
158
|
limit?: number;
|
|
120
159
|
offset?: number;
|
|
@@ -153,6 +192,8 @@ export interface NoteSummary {
|
|
|
153
192
|
createdAt: string;
|
|
154
193
|
updatedAt?: string;
|
|
155
194
|
tags?: string[];
|
|
195
|
+
/** Opt-in link degree (see `Note.linkCount`). */
|
|
196
|
+
linkCount?: number;
|
|
156
197
|
}
|
|
157
198
|
|
|
158
199
|
/**
|
|
@@ -169,6 +210,8 @@ export interface NoteIndex {
|
|
|
169
210
|
metadata?: Record<string, unknown>;
|
|
170
211
|
byteSize: number;
|
|
171
212
|
preview: string;
|
|
213
|
+
/** Opt-in link degree (see `Note.linkCount`). */
|
|
214
|
+
linkCount?: number;
|
|
172
215
|
}
|
|
173
216
|
|
|
174
217
|
/** Link with hydrated note summaries. */
|
|
@@ -180,6 +223,15 @@ export interface HydratedLink extends Link {
|
|
|
180
223
|
// ---- Store Interface ----
|
|
181
224
|
|
|
182
225
|
export interface Store {
|
|
226
|
+
/**
|
|
227
|
+
* The underlying `bun:sqlite` handle. Exposed (read-only) so callers that
|
|
228
|
+
* need to run a raw query the Store interface doesn't surface β e.g. the
|
|
229
|
+
* token-table reverse-lookups in routes.ts and MCP tool generation in
|
|
230
|
+
* mcp.ts β can reach it without an `(store as any).db` cast. The concrete
|
|
231
|
+
* `Store` class declares this as `public readonly db: Database`. See vault#242.
|
|
232
|
+
*/
|
|
233
|
+
readonly db: Database;
|
|
234
|
+
|
|
183
235
|
// Notes
|
|
184
236
|
createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note>;
|
|
185
237
|
getNote(id: string): Promise<Note | null>;
|
|
@@ -218,7 +270,7 @@ export interface Store {
|
|
|
218
270
|
* agent loop can persist a single watermark and keep polling.
|
|
219
271
|
*/
|
|
220
272
|
queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage>;
|
|
221
|
-
searchNotes(query: string, opts?: { tags?: string[]; limit?: number }): Promise<Note[]>;
|
|
273
|
+
searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]>;
|
|
222
274
|
|
|
223
275
|
// Tags
|
|
224
276
|
tagNote(noteId: string, tags: string[]): Promise<void>;
|
|
@@ -230,6 +282,18 @@ export interface Store {
|
|
|
230
282
|
* compute the effective allowlisted tag-set at auth time.
|
|
231
283
|
*/
|
|
232
284
|
expandTagsWithDescendants(tags: string[]): Promise<Set<string>>;
|
|
285
|
+
/**
|
|
286
|
+
* Mode-aware tag expansion (vault tag `expand` axis). Expands each input tag
|
|
287
|
+
* along the selected axis and returns the union:
|
|
288
|
+
* - `"subtypes"` (default): `{tag} βͺ parent_names-descendants` β identical to
|
|
289
|
+
* `expandTagsWithDescendants` (which is a thin shim over this).
|
|
290
|
+
* - `"namespace"`: `{tag} βͺ lexically name-prefixed tag/*`.
|
|
291
|
+
* - `"both"`: union of the two.
|
|
292
|
+
* - `"exact"`: `{tag}` only.
|
|
293
|
+
* Always includes each input tag. Used by the live-query matcher to lower the
|
|
294
|
+
* IDENTICAL expansion the snapshot query engine uses for the same `expand`.
|
|
295
|
+
*/
|
|
296
|
+
expandTags(tags: string[], mode?: TagExpandMode): Promise<Set<string>>;
|
|
233
297
|
listTags(): Promise<{ name: string; count: number }[]>;
|
|
234
298
|
deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
|
|
235
299
|
renameTag(
|
|
@@ -313,7 +377,7 @@ export interface Store {
|
|
|
313
377
|
patch: {
|
|
314
378
|
description?: string | null;
|
|
315
379
|
fields?: Record<string, TagFieldSchema> | null;
|
|
316
|
-
relationships?:
|
|
380
|
+
relationships?: TagRelationshipMap | null;
|
|
317
381
|
parent_names?: string[] | null;
|
|
318
382
|
},
|
|
319
383
|
): Promise<TagRecord>;
|
|
@@ -305,5 +305,25 @@ export function projectionToMarkdown(args: {
|
|
|
305
305
|
lines.push("");
|
|
306
306
|
lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
|
|
307
307
|
|
|
308
|
+
// Scripting pointer block: the connect-time brief used to dead-end on
|
|
309
|
+
// querying β an agent had no path to "how do I script/automate against this
|
|
310
|
+
// vault." Point at the guide rather than inlining it, to keep this brief
|
|
311
|
+
// lean (token-budget note above). Uses the concrete vault name so the mint
|
|
312
|
+
// command is copy-paste ready.
|
|
313
|
+
lines.push("");
|
|
314
|
+
lines.push("## Scripting & automation (beyond this session)");
|
|
315
|
+
lines.push("");
|
|
316
|
+
lines.push(
|
|
317
|
+
"This vault is also a plain HTTP API β reach for it when the user wants a script, cron job, or CI step rather than an interactive session:",
|
|
318
|
+
);
|
|
319
|
+
lines.push(
|
|
320
|
+
`- Mint a scoped credential: \`parachute auth mint-token --scope vault:${vaultName}:read --ephemeral\` (\`--ephemeral\` = short-lived, ideal for scripts; use \`:write\` to create/edit).`,
|
|
321
|
+
);
|
|
322
|
+
lines.push(`- Call the REST API at \`<hub-origin>/vault/${vaultName}/api/...\`.`);
|
|
323
|
+
lines.push(
|
|
324
|
+
"- Full guide β copy-paste bash/Python/JS examples, plus how to design tags vs paths vs schemas: https://parachute.computer/scripting/",
|
|
325
|
+
);
|
|
326
|
+
lines.push("- For a prompt on a schedule with no code, see Parachute Runner.");
|
|
327
|
+
|
|
308
328
|
return lines.join("\n");
|
|
309
329
|
}
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -137,7 +137,7 @@ export function resolveWikilink(db: Database, target: string): string | null {
|
|
|
137
137
|
const extPart = extMatch[2]!.toLowerCase();
|
|
138
138
|
const explicit = db.prepare(
|
|
139
139
|
"SELECT id FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
140
|
-
).get(pathPart, extPart) as { id: string } |
|
|
140
|
+
).get(pathPart, extPart) as { id: string } | null;
|
|
141
141
|
if (explicit) return explicit.id;
|
|
142
142
|
// No match for explicit (path, ext) β fall through to the looser
|
|
143
143
|
// rules so a literal note named `Recipe.v2` (where `v2` isn't an
|
|
@@ -199,7 +199,7 @@ export function resolveWikilinkDetailed(db: Database, target: string): WikilinkR
|
|
|
199
199
|
const extPart = extMatch[2]!.toLowerCase();
|
|
200
200
|
const explicit = db.prepare(
|
|
201
201
|
"SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
|
|
202
|
-
).get(pathPart, extPart) as { id: string; path: string } |
|
|
202
|
+
).get(pathPart, extPart) as { id: string; path: string } | null;
|
|
203
203
|
if (explicit) {
|
|
204
204
|
return { resolved: true, note_id: explicit.id, path: explicit.path, candidates: [] };
|
|
205
205
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
5
|
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -22,12 +22,11 @@
|
|
|
22
22
|
"test:core": "cd core && node --experimental-vm-modules node_modules/vitest/dist/cli.js run",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
|
|
25
|
-
"postinstall": "if [ -d web/ui ]; then bun run build:spa; fi",
|
|
26
25
|
"prepack": "bun run build:spa"
|
|
27
26
|
},
|
|
28
27
|
"dependencies": {
|
|
29
28
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
30
|
-
"@openparachute/scope-guard": "^0.4.
|
|
29
|
+
"@openparachute/scope-guard": "^0.4.1-rc.1",
|
|
31
30
|
"jose": "^6.2.2",
|
|
32
31
|
"otpauth": "^9.5.0",
|
|
33
32
|
"qrcode-terminal": "^0.12.0"
|