@openparachute/vault 0.5.3-rc.3 → 0.6.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.
- package/.parachute/module.json +14 -3
- package/core/src/mcp.ts +20 -0
- package/core/src/schema.ts +45 -1
- package/core/src/store.ts +66 -19
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +27 -1
- package/package.json +1 -1
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/cli.ts +45 -18
- package/src/config.test.ts +27 -0
- package/src/config.ts +87 -0
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/routes.ts +192 -78
- package/src/routing.test.ts +64 -0
- package/src/routing.ts +48 -1
- package/src/server.ts +49 -3
- 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/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/vault-create.test.ts +35 -1
- 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 +194 -0
- package/web/ui/dist/assets/index-CGL256oe.js +60 -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-DJL6Az--.js +0 -60
|
@@ -118,4 +118,55 @@ describe("shouldAutoTranscribe", () => {
|
|
|
118
118
|
enabledOverride: false,
|
|
119
119
|
})).toBe(false);
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
describe("per-vault precedence (per-vault → global → true)", () => {
|
|
123
|
+
test("per-vault true wins even when global is false", () => {
|
|
124
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
125
|
+
readGlobalConfigImpl: readGlobalConfig(false),
|
|
126
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
127
|
+
perVaultEnabled: true,
|
|
128
|
+
})).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("per-vault false wins even when global is true", () => {
|
|
132
|
+
// The whole point: linking scribe to vault X (perVault true) elsewhere
|
|
133
|
+
// must not force-on a vault that set its own false.
|
|
134
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
135
|
+
readGlobalConfigImpl: readGlobalConfig(true),
|
|
136
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
137
|
+
perVaultEnabled: false,
|
|
138
|
+
})).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("per-vault unset falls back to global", () => {
|
|
142
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
143
|
+
readGlobalConfigImpl: readGlobalConfig(true),
|
|
144
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
145
|
+
perVaultEnabled: undefined,
|
|
146
|
+
})).toBe(true);
|
|
147
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
148
|
+
readGlobalConfigImpl: readGlobalConfig(false),
|
|
149
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
150
|
+
perVaultEnabled: undefined,
|
|
151
|
+
})).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("both per-vault and global unset falls back to true (no regression)", () => {
|
|
155
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
156
|
+
readGlobalConfigImpl: readGlobalConfig(undefined),
|
|
157
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
158
|
+
perVaultEnabled: undefined,
|
|
159
|
+
})).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("enabledOverride still hard-overrides the per-vault value", () => {
|
|
163
|
+
// The explicit caller-opt-in path beats everything.
|
|
164
|
+
expect(shouldAutoTranscribe("audio/wav", {
|
|
165
|
+
readGlobalConfigImpl: readGlobalConfig(true),
|
|
166
|
+
getCachedScribeUrlImpl: scribePresent,
|
|
167
|
+
perVaultEnabled: false,
|
|
168
|
+
enabledOverride: true,
|
|
169
|
+
})).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
121
172
|
});
|
package/src/auto-transcribe.ts
CHANGED
|
@@ -19,11 +19,18 @@ import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
|
19
19
|
*
|
|
20
20
|
* Returns `true` only when ALL three conditions hold:
|
|
21
21
|
* 1. mime-type starts with `audio/` (case-insensitive).
|
|
22
|
-
* 2.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* 2. The resolved auto-transcribe toggle is not `false`. Resolution is
|
|
23
|
+
* **per-vault → global → true**:
|
|
24
|
+
* - `perVaultEnabled` (the owning vault's own `auto_transcribe.enabled`)
|
|
25
|
+
* wins when set — this is what makes scribe's "link to vault X" affect
|
|
26
|
+
* only X, not the whole server.
|
|
27
|
+
* - else the server-wide `globalConfig.auto_transcribe?.enabled`.
|
|
28
|
+
* - else `true` (default ON — once scribe is reachable, audio
|
|
29
|
+
* transcribes without a separate config step). Operators who want it
|
|
30
|
+
* OFF set `auto_transcribe.enabled: false` explicitly (per-vault or
|
|
31
|
+
* globally).
|
|
32
|
+
* `enabledOverride`, when present, hard-overrides the whole chain (used
|
|
33
|
+
* by the explicit caller-opt-in path).
|
|
27
34
|
* 3. Scribe is discoverable (services.json entry OR SCRIBE_URL env).
|
|
28
35
|
*
|
|
29
36
|
* The three conditions are independent guards: a single `false` is sufficient
|
|
@@ -35,7 +42,17 @@ export function shouldAutoTranscribe(
|
|
|
35
42
|
/** Injection seam for tests — defaults to live globals. */
|
|
36
43
|
readGlobalConfigImpl?: typeof readGlobalConfig;
|
|
37
44
|
getCachedScribeUrlImpl?: () => string | undefined;
|
|
38
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The owning vault's per-vault `auto_transcribe.enabled` (vault.yaml).
|
|
47
|
+
* Takes precedence over the global toggle when set, so enabling/disabling
|
|
48
|
+
* one vault doesn't move the rest. `undefined` (the vault left it unset)
|
|
49
|
+
* falls through to the global toggle.
|
|
50
|
+
*/
|
|
51
|
+
perVaultEnabled?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Hard override of the entire per-vault→global→true chain. Used by the
|
|
54
|
+
* explicit caller-opt-in path; not part of the normal precedence ladder.
|
|
55
|
+
*/
|
|
39
56
|
enabledOverride?: boolean;
|
|
40
57
|
} = {},
|
|
41
58
|
): boolean {
|
|
@@ -43,6 +60,7 @@ export function shouldAutoTranscribe(
|
|
|
43
60
|
return false;
|
|
44
61
|
}
|
|
45
62
|
const enabled = opts.enabledOverride
|
|
63
|
+
?? opts.perVaultEnabled
|
|
46
64
|
?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
|
|
47
65
|
?? true;
|
|
48
66
|
if (!enabled) return false;
|
package/src/cli.ts
CHANGED
|
@@ -933,21 +933,16 @@ async function cmdCreate(args: string[]) {
|
|
|
933
933
|
process.exit(1);
|
|
934
934
|
}
|
|
935
935
|
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
if (name === "list") {
|
|
947
|
-
// Reserved — keeps the "list" vault name out of play even though per-vault
|
|
948
|
-
// routes now live under /vault/<name>/ and no longer collide with the
|
|
949
|
-
// /vaults/list discovery endpoint.
|
|
950
|
-
console.error(`"list" is a reserved vault name.`);
|
|
936
|
+
// One validator for every name-minting edge (2026-06-09 hub-module-boundary
|
|
937
|
+
// migration B2). cmdCreate used to carry its own inline charset check plus a
|
|
938
|
+
// hardcoded `"list"` reservation that had drifted from `validateVaultName`'s
|
|
939
|
+
// set — a vault named `admin`/`new`/`assets` could enter through `create`
|
|
940
|
+
// and capture a reserved route (`/vault/admin` is the daemon-level admin
|
|
941
|
+
// mount as of B3). Consuming the shared validator also picks up its 2–32
|
|
942
|
+
// length rule, aligning `create` with `init`, the env var, and hub's wizard.
|
|
943
|
+
const nameValidation = validateVaultName(name);
|
|
944
|
+
if (!nameValidation.ok) {
|
|
945
|
+
console.error(nameValidation.error);
|
|
951
946
|
process.exit(1);
|
|
952
947
|
}
|
|
953
948
|
|
|
@@ -1575,20 +1570,52 @@ function cmdRemove(args: string[]) {
|
|
|
1575
1570
|
// Keep default_vault in sync. If the removed vault was the default, either
|
|
1576
1571
|
// promote the remaining vault (if exactly one) or clear the setting.
|
|
1577
1572
|
const globalConfig = readGlobalConfig();
|
|
1573
|
+
const remaining = listVaults();
|
|
1574
|
+
let configDirty = false;
|
|
1578
1575
|
if (globalConfig.default_vault === name) {
|
|
1579
|
-
const remaining = listVaults();
|
|
1580
1576
|
if (remaining.length === 1) {
|
|
1581
1577
|
globalConfig.default_vault = remaining[0];
|
|
1582
|
-
writeGlobalConfig(globalConfig);
|
|
1583
1578
|
console.log(` Default vault is now "${remaining[0]}".`);
|
|
1584
1579
|
} else {
|
|
1585
1580
|
delete globalConfig.default_vault;
|
|
1586
|
-
writeGlobalConfig(globalConfig);
|
|
1587
1581
|
if (remaining.length > 1) {
|
|
1588
1582
|
console.log(` Cleared default_vault — set one with: editor ${CONFIG_DIR}/config.yaml`);
|
|
1589
1583
|
}
|
|
1590
1584
|
}
|
|
1585
|
+
configDirty = true;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Last-vault marker (2026-06-09 hub-module-boundary migration, B1's
|
|
1589
|
+
// CLI-side improvement). Server boot auto-creates `default` when zero
|
|
1590
|
+
// vaults exist — without the marker, an operator who explicitly emptied
|
|
1591
|
+
// the server would find a freshly-credentialed `default` resurrected on
|
|
1592
|
+
// the next restart. Fresh installs never carry the marker (no config.yaml
|
|
1593
|
+
// at all), so Docker / hub-install first-run auto-create is preserved.
|
|
1594
|
+
if (remaining.length === 0 && globalConfig.auto_create !== false) {
|
|
1595
|
+
globalConfig.auto_create = false;
|
|
1596
|
+
configDirty = true;
|
|
1597
|
+
console.log(
|
|
1598
|
+
` Last vault removed — wrote auto_create: false to ${GLOBAL_CONFIG_PATH} so the` +
|
|
1599
|
+
` server won't auto-recreate "default" on next boot. Create a vault with:` +
|
|
1600
|
+
` parachute-vault create <name>`,
|
|
1601
|
+
);
|
|
1591
1602
|
}
|
|
1603
|
+
if (configDirty) writeGlobalConfig(globalConfig);
|
|
1604
|
+
|
|
1605
|
+
// Refresh services.json so the removed vault's /vault/<name> path drops
|
|
1606
|
+
// out of the parachute-vault row immediately — the same selfRegister
|
|
1607
|
+
// refresh cmdCreate does (#208). Without this, the hub's well-known
|
|
1608
|
+
// fan-out kept advertising the deleted vault until the next server boot.
|
|
1609
|
+
// Note: with zero vaults remaining, selfRegister falls back to the
|
|
1610
|
+
// manifest's canonical paths (`/vault/default`) — the same row a
|
|
1611
|
+
// subsequent boot would write — so CLI-remove and boot agree on the
|
|
1612
|
+
// zero-vault registration shape. Warnings go to stderr; status lines stay
|
|
1613
|
+
// ours.
|
|
1614
|
+
selfRegister({
|
|
1615
|
+
version: pkg.version,
|
|
1616
|
+
warn: (msg) => console.error(`Warning: ${msg}`),
|
|
1617
|
+
log: () => {},
|
|
1618
|
+
});
|
|
1592
1619
|
}
|
|
1593
1620
|
|
|
1594
1621
|
async function cmdConfig(args: string[]) {
|
package/src/config.test.ts
CHANGED
|
@@ -169,6 +169,33 @@ describe("config", () => {
|
|
|
169
169
|
expect(loaded!.transcription).toBeUndefined();
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
test("round-trips per-vault auto_transcribe.enabled (true + false)", () => {
|
|
173
|
+
// The PATCH /api/vault handler persists the per-vault toggle via
|
|
174
|
+
// writeVaultConfig; confirm it survives a read-back — this is the exact
|
|
175
|
+
// field shouldAutoTranscribe reads per-vault (per-vault → global → true).
|
|
176
|
+
const base: VaultConfig = {
|
|
177
|
+
name: "testvault",
|
|
178
|
+
api_keys: [],
|
|
179
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
writeVaultConfig({ ...base, auto_transcribe: { enabled: true } });
|
|
183
|
+
expect(readVaultConfig("testvault")!.auto_transcribe?.enabled).toBe(true);
|
|
184
|
+
|
|
185
|
+
writeVaultConfig({ ...base, auto_transcribe: { enabled: false } });
|
|
186
|
+
expect(readVaultConfig("testvault")!.auto_transcribe?.enabled).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("vault config without auto_transcribe loads as undefined (falls back to global)", () => {
|
|
190
|
+
const config: VaultConfig = {
|
|
191
|
+
name: "testvault",
|
|
192
|
+
api_keys: [],
|
|
193
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
194
|
+
};
|
|
195
|
+
writeVaultConfig(config);
|
|
196
|
+
expect(readVaultConfig("testvault")!.auto_transcribe).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
172
199
|
test("round-trips discovery: enabled|disabled", () => {
|
|
173
200
|
// Default: absent means enabled (endpoint serves names).
|
|
174
201
|
writeGlobalConfig({ port: 1940 });
|
package/src/config.ts
CHANGED
|
@@ -183,6 +183,23 @@ export interface VaultConfig {
|
|
|
183
183
|
transcription?: {
|
|
184
184
|
context?: TriggerIncludeContext[];
|
|
185
185
|
};
|
|
186
|
+
/**
|
|
187
|
+
* Per-vault auto-transcribe override (vault#353 follow-up). When set, this
|
|
188
|
+
* vault's value takes precedence over the server-wide
|
|
189
|
+
* `GlobalConfig.auto_transcribe.enabled`. Resolution at the decision point
|
|
190
|
+
* (`shouldAutoTranscribe`) is **per-vault → global → true**: a vault that
|
|
191
|
+
* sets `enabled` here uses it; a vault that leaves it unset falls back to
|
|
192
|
+
* the global toggle, which itself defaults ON.
|
|
193
|
+
*
|
|
194
|
+
* This is what makes scribe's "link to vault X" genuinely per-vault —
|
|
195
|
+
* `PATCH /vault/X/api/vault {auto_transcribe:{enabled:true}}` flips only
|
|
196
|
+
* vault X, never the whole server. URL + bearer are still resolved per-
|
|
197
|
+
* process (services.json / SCRIBE_AUTH_TOKEN); only the on/off toggle is
|
|
198
|
+
* per-vault.
|
|
199
|
+
*/
|
|
200
|
+
auto_transcribe?: {
|
|
201
|
+
enabled?: boolean;
|
|
202
|
+
};
|
|
186
203
|
}
|
|
187
204
|
|
|
188
205
|
// ---------------------------------------------------------------------------
|
|
@@ -240,6 +257,20 @@ export interface TriggerAction {
|
|
|
240
257
|
* top-level `context` field (send=json). send=content ignores this.
|
|
241
258
|
*/
|
|
242
259
|
include_context?: TriggerIncludeContext[];
|
|
260
|
+
/**
|
|
261
|
+
* Optional webhook auth. When `auth.bearer` is set, the trigger sends
|
|
262
|
+
* `Authorization: Bearer <bearer>` on the webhook POST — the JWT path that
|
|
263
|
+
* retires the shared `?secret=` query param. Back-compat: a webhook URL
|
|
264
|
+
* carrying its own `?secret=` still works; `auth` is purely additive.
|
|
265
|
+
* Runtime triggers (registered via the /api/triggers REST surface) are the
|
|
266
|
+
* primary users; config.yaml triggers may also carry it.
|
|
267
|
+
*/
|
|
268
|
+
auth?: TriggerAuth;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface TriggerAuth {
|
|
272
|
+
/** Bearer token (typically a hub-issued JWT) for the webhook Authorization header. */
|
|
273
|
+
bearer?: string;
|
|
243
274
|
}
|
|
244
275
|
|
|
245
276
|
export interface TriggerConfig {
|
|
@@ -281,6 +312,19 @@ export interface GlobalConfig {
|
|
|
281
312
|
* point their own supervisor at it.
|
|
282
313
|
*/
|
|
283
314
|
autostart?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* Boot auto-create marker (2026-06-09 hub-module-boundary migration, the
|
|
317
|
+
* vault wave's `cmdRemove` improvement). Server boot auto-creates a
|
|
318
|
+
* `default` vault when `listVaults()` is empty — the Docker / hub-install
|
|
319
|
+
* first-run path. When the operator EXPLICITLY deletes their last vault
|
|
320
|
+
* via `parachute-vault remove`, that auto-create would silently resurrect
|
|
321
|
+
* a fresh `default` (with fresh credentials) on the next boot. `cmdRemove`
|
|
322
|
+
* writes `auto_create: false` when it removes the last vault; boot skips
|
|
323
|
+
* the auto-create while the marker is present. Fresh installs (no
|
|
324
|
+
* config.yaml at all) never carry the marker, so the Docker first-run
|
|
325
|
+
* behavior is preserved. See `bootAutoCreateAllowed`.
|
|
326
|
+
*/
|
|
327
|
+
auto_create?: boolean;
|
|
284
328
|
/** Backup configuration: schedule, retention, destinations. */
|
|
285
329
|
backup?: BackupConfig;
|
|
286
330
|
/**
|
|
@@ -430,6 +474,14 @@ function serializeVaultConfig(config: VaultConfig): string {
|
|
|
430
474
|
lines.push(`audio_retention: ${config.audio_retention}`);
|
|
431
475
|
}
|
|
432
476
|
|
|
477
|
+
// Per-vault auto-transcribe override. Serialized as a nested block so future
|
|
478
|
+
// fields can grow under it (mirrors the GlobalConfig shape). Only emitted
|
|
479
|
+
// when `enabled` is explicitly set — an unset vault falls back to global.
|
|
480
|
+
if (config.auto_transcribe?.enabled !== undefined) {
|
|
481
|
+
lines.push("auto_transcribe:");
|
|
482
|
+
lines.push(` enabled: ${config.auto_transcribe.enabled}`);
|
|
483
|
+
}
|
|
484
|
+
|
|
433
485
|
if (config.transcription?.context?.length) {
|
|
434
486
|
lines.push("transcription:");
|
|
435
487
|
lines.push(" context:");
|
|
@@ -566,6 +618,22 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
|
|
|
566
618
|
config.transcription = { context: transcriptionContext };
|
|
567
619
|
}
|
|
568
620
|
|
|
621
|
+
// Parse the per-vault auto_transcribe block — currently single boolean
|
|
622
|
+
// `enabled`. Nested 2-space-indent block (mirrors the GlobalConfig parser)
|
|
623
|
+
// so future fields can grow under it without breaking the regex.
|
|
624
|
+
const autoTranscribeStart = yaml.match(/^auto_transcribe:\s*$/m);
|
|
625
|
+
if (autoTranscribeStart) {
|
|
626
|
+
const after = yaml.slice((autoTranscribeStart.index ?? 0) + autoTranscribeStart[0].length);
|
|
627
|
+
for (const line of after.split("\n")) {
|
|
628
|
+
if (line.match(/^\S/) && line.trim().length > 0) break; // next top-level key
|
|
629
|
+
const m = line.match(/^\s+enabled:\s*(true|false)/);
|
|
630
|
+
if (m) {
|
|
631
|
+
config.auto_transcribe = { enabled: m[1]! === "true" };
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
569
637
|
return config;
|
|
570
638
|
}
|
|
571
639
|
|
|
@@ -1203,6 +1271,20 @@ export function migrateVaultInternalLayout(): void {
|
|
|
1203
1271
|
// Global config
|
|
1204
1272
|
// ---------------------------------------------------------------------------
|
|
1205
1273
|
|
|
1274
|
+
/**
|
|
1275
|
+
* Whether server boot may auto-create the first vault when none exist.
|
|
1276
|
+
*
|
|
1277
|
+
* Only the explicit `auto_create: false` marker (written by `cmdRemove`
|
|
1278
|
+
* when it deletes the LAST vault) blocks the auto-create. A fresh install
|
|
1279
|
+
* has no config.yaml — `readGlobalConfig()` returns defaults with
|
|
1280
|
+
* `auto_create` unset — so Docker / hub-install first-run still
|
|
1281
|
+
* auto-creates `default`. Pure + exported so the boot gate is testable
|
|
1282
|
+
* without booting a server.
|
|
1283
|
+
*/
|
|
1284
|
+
export function bootAutoCreateAllowed(config: Pick<GlobalConfig, "auto_create">): boolean {
|
|
1285
|
+
return config.auto_create !== false;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1206
1288
|
export function readGlobalConfig(): GlobalConfig {
|
|
1207
1289
|
try {
|
|
1208
1290
|
const gcPath = globalConfigPath();
|
|
@@ -1214,6 +1296,7 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1214
1296
|
const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
|
|
1215
1297
|
const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
|
|
1216
1298
|
const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
|
|
1299
|
+
const autoCreateMatch = yaml.match(/^auto_create:\s*(true|false)/m);
|
|
1217
1300
|
const defaultMirrorMatch = yaml.match(/^default_mirror:\s*(internal|off)/m);
|
|
1218
1301
|
// auto_transcribe block — currently single boolean `enabled` (vault#353).
|
|
1219
1302
|
// Parsed as a nested 2-space-indent block so future fields can grow under
|
|
@@ -1243,6 +1326,9 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
1243
1326
|
if (autostartMatch) {
|
|
1244
1327
|
config.autostart = autostartMatch[1]! === "true";
|
|
1245
1328
|
}
|
|
1329
|
+
if (autoCreateMatch) {
|
|
1330
|
+
config.auto_create = autoCreateMatch[1]! === "true";
|
|
1331
|
+
}
|
|
1246
1332
|
if (defaultMirrorMatch) {
|
|
1247
1333
|
config.default_mirror = defaultMirrorMatch[1]! as "internal" | "off";
|
|
1248
1334
|
}
|
|
@@ -1318,6 +1404,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
|
|
|
1318
1404
|
if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
|
|
1319
1405
|
if (config.discovery) lines.push(`discovery: ${config.discovery}`);
|
|
1320
1406
|
if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
|
|
1407
|
+
if (config.auto_create !== undefined) lines.push(`auto_create: ${config.auto_create}`);
|
|
1321
1408
|
if (config.default_mirror) lines.push(`default_mirror: ${config.default_mirror}`);
|
|
1322
1409
|
if (config.owner_password_hash) {
|
|
1323
1410
|
lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicate-parity tests for the live matcher (live-query SSE).
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing invariant: for any supported query, the set of notes the
|
|
5
|
+
* snapshot SQL (`store.queryNotes`) returns MUST equal the set the in-process
|
|
6
|
+
* `buildLiveMatcher` accepts over the same corpus. Each `it` seeds a corpus,
|
|
7
|
+
* runs both evaluators for a query shape, and asserts the id-sets are equal.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
13
|
+
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
14
|
+
import type { QueryOpts } from "../core/src/types.ts";
|
|
15
|
+
import { buildLiveMatcher } from "./live-match.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Declare metadata fields as `indexed: true` via the update-tag MCP tool —
|
|
19
|
+
* the only path that populates the `indexed_fields` table + reconciles the
|
|
20
|
+
* generated `meta_<field>` columns the snapshot operator queries need.
|
|
21
|
+
* `store.upsertTagSchema` alone records the schema but NOT the index.
|
|
22
|
+
*/
|
|
23
|
+
async function declareIndexed(tag: string, fields: Record<string, { type: string; indexed: boolean }>) {
|
|
24
|
+
const tools = generateMcpTools(store);
|
|
25
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
26
|
+
await updateTag.execute({ tag, fields });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let db: Database;
|
|
30
|
+
let store: SqliteStore;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
db = new Database(":memory:");
|
|
34
|
+
store = new SqliteStore(db);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
db.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Build the parity assertion: snapshot id-set === live-matcher id-set. */
|
|
42
|
+
async function assertParity(opts: QueryOpts): Promise<Set<string>> {
|
|
43
|
+
const snapshot = await store.queryNotes(opts);
|
|
44
|
+
const snapshotIds = new Set(snapshot.map((n) => n.id));
|
|
45
|
+
|
|
46
|
+
const all = await store.queryNotes({ limit: 100000 });
|
|
47
|
+
const matcher = await buildLiveMatcher(store, opts);
|
|
48
|
+
const liveIds = new Set(all.filter((n) => matcher.match(n)).map((n) => n.id));
|
|
49
|
+
|
|
50
|
+
expect([...liveIds].sort()).toEqual([...snapshotIds].sort());
|
|
51
|
+
return snapshotIds;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("live-match — predicate parity with the query engine", () => {
|
|
55
|
+
it("tags (single, no hierarchy)", async () => {
|
|
56
|
+
await store.createNote("a", { tags: ["chat"] });
|
|
57
|
+
await store.createNote("b", { tags: ["chat", "x"] });
|
|
58
|
+
await store.createNote("c", { tags: ["other"] });
|
|
59
|
+
await store.createNote("d", {});
|
|
60
|
+
const ids = await assertParity({ tags: ["chat"] });
|
|
61
|
+
expect(ids.size).toBe(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("tags 'all' (AND) across multiple tags", async () => {
|
|
65
|
+
await store.createNote("a", { tags: ["chat", "x"] });
|
|
66
|
+
await store.createNote("b", { tags: ["chat"] });
|
|
67
|
+
await store.createNote("c", { tags: ["x"] });
|
|
68
|
+
const ids = await assertParity({ tags: ["chat", "x"], tagMatch: "all" });
|
|
69
|
+
expect(ids.size).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("tags 'any' (OR) across multiple tags", async () => {
|
|
73
|
+
await store.createNote("a", { tags: ["chat"] });
|
|
74
|
+
await store.createNote("b", { tags: ["x"] });
|
|
75
|
+
await store.createNote("c", { tags: ["other"] });
|
|
76
|
+
const ids = await assertParity({ tags: ["chat", "x"], tagMatch: "any" });
|
|
77
|
+
expect(ids.size).toBe(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("tags with descendant/hierarchy expansion", async () => {
|
|
81
|
+
// Declare voice as a child of manual via parent_names.
|
|
82
|
+
await store.upsertTagRecord("manual", { description: "manual root" });
|
|
83
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
84
|
+
await store.createNote("root", { tags: ["manual"] });
|
|
85
|
+
await store.createNote("child", { tags: ["voice"] });
|
|
86
|
+
await store.createNote("unrelated", { tags: ["other"] });
|
|
87
|
+
// Query for #manual should match notes tagged #voice (a descendant).
|
|
88
|
+
const ids = await assertParity({ tags: ["manual"] });
|
|
89
|
+
expect(ids.has("nonexistent")).toBe(false);
|
|
90
|
+
// Both root + child match.
|
|
91
|
+
const notes = await store.queryNotes({ tags: ["manual"] });
|
|
92
|
+
expect(notes.length).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("excludeTags (raw, no expansion — mirrors engine)", async () => {
|
|
96
|
+
await store.createNote("a", { tags: ["chat"] });
|
|
97
|
+
await store.createNote("b", { tags: ["chat", "muted"] });
|
|
98
|
+
await assertParity({ tags: ["chat"], excludeTags: ["muted"] });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("path (case-insensitive exact)", async () => {
|
|
102
|
+
await store.createNote("a", { path: "Channels/general" });
|
|
103
|
+
await store.createNote("b", { path: "Channels/random" });
|
|
104
|
+
await assertParity({ path: "channels/general" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("pathPrefix", async () => {
|
|
108
|
+
await store.createNote("a", { path: "Channels/general" });
|
|
109
|
+
await store.createNote("b", { path: "Channels/random" });
|
|
110
|
+
await store.createNote("c", { path: "Other/thing" });
|
|
111
|
+
const ids = await assertParity({ pathPrefix: "Channels/" });
|
|
112
|
+
expect(ids.size).toBe(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("pathPrefix (mixed-case — parity with the engine's CI LIKE) (N1)", async () => {
|
|
116
|
+
await store.createNote("a", { path: "Channels/general" });
|
|
117
|
+
await store.createNote("b", { path: "Channels/random" });
|
|
118
|
+
await store.createNote("c", { path: "Other/thing" });
|
|
119
|
+
// Lower-case prefix must still match the title-case paths, same as the
|
|
120
|
+
// engine's `LIKE 'channels/%'` (ASCII case-insensitive).
|
|
121
|
+
const ids = await assertParity({ pathPrefix: "channels/" });
|
|
122
|
+
expect(ids.size).toBe(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("hasTags true/false (M1 — presence parity)", async () => {
|
|
126
|
+
await store.createNote("tagged", { tags: ["x"] });
|
|
127
|
+
await store.createNote("bare", {});
|
|
128
|
+
const has = await assertParity({ hasTags: true });
|
|
129
|
+
expect(has.size).toBe(1);
|
|
130
|
+
const none = await assertParity({ hasTags: false });
|
|
131
|
+
expect(none.size).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("hasTags is ignored when a tag filter is also present (engine parity)", async () => {
|
|
135
|
+
// queryNotes drops hasTags when `tags` is set (the tag filter already
|
|
136
|
+
// constrains to tagged notes); the matcher must mirror that exactly.
|
|
137
|
+
await store.createNote("a", { tags: ["chat"] });
|
|
138
|
+
await store.createNote("b", { tags: ["other"] });
|
|
139
|
+
await assertParity({ tags: ["chat"], hasTags: false });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("extension (default md + explicit)", async () => {
|
|
143
|
+
await store.createNote("md1", { path: "n1" });
|
|
144
|
+
await store.createNote("csv1", { path: "n2", extension: "csv" });
|
|
145
|
+
await assertParity({ extension: "csv" });
|
|
146
|
+
await assertParity({ extension: "md" });
|
|
147
|
+
await assertParity({ extension: ["csv", "yaml"] });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("metadata operators (indexed field)", () => {
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
// Declare `channel` + `count` indexed so the snapshot operator path works.
|
|
153
|
+
await declareIndexed("msg", {
|
|
154
|
+
channel: { type: "string", indexed: true },
|
|
155
|
+
count: { type: "integer", indexed: true },
|
|
156
|
+
});
|
|
157
|
+
await store.createNote("g1", { tags: ["msg"], metadata: { channel: "general", count: 5 } });
|
|
158
|
+
await store.createNote("g2", { tags: ["msg"], metadata: { channel: "general", count: 10 } });
|
|
159
|
+
await store.createNote("r1", { tags: ["msg"], metadata: { channel: "random", count: 1 } });
|
|
160
|
+
await store.createNote("n1", { tags: ["msg"] }); // no channel/count
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("eq", async () => {
|
|
164
|
+
const ids = await assertParity({ tags: ["msg"], metadata: { channel: { eq: "general" } } });
|
|
165
|
+
expect(ids.size).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
it("ne (absent field passes)", async () => {
|
|
168
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { ne: "general" } } });
|
|
169
|
+
});
|
|
170
|
+
it("gt / gte / lt / lte", async () => {
|
|
171
|
+
await assertParity({ tags: ["msg"], metadata: { count: { gt: 5 } } });
|
|
172
|
+
await assertParity({ tags: ["msg"], metadata: { count: { gte: 5 } } });
|
|
173
|
+
await assertParity({ tags: ["msg"], metadata: { count: { lt: 5 } } });
|
|
174
|
+
await assertParity({ tags: ["msg"], metadata: { count: { lte: 5 } } });
|
|
175
|
+
});
|
|
176
|
+
it("in / not_in", async () => {
|
|
177
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { in: ["general", "random"] } } });
|
|
178
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { not_in: ["random"] } } });
|
|
179
|
+
});
|
|
180
|
+
it("exists true/false", async () => {
|
|
181
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { exists: true } } });
|
|
182
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { exists: false } } });
|
|
183
|
+
});
|
|
184
|
+
it("combined: tag + metadata operator (the channel case)", async () => {
|
|
185
|
+
const ids = await assertParity({
|
|
186
|
+
tags: ["msg"],
|
|
187
|
+
metadata: { channel: { eq: "general" }, count: { gte: 10 } },
|
|
188
|
+
});
|
|
189
|
+
expect(ids.size).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("metadata primitive exact-match (shorthand)", async () => {
|
|
194
|
+
await store.createNote("a", { tags: ["t"], metadata: { kind: "note" } });
|
|
195
|
+
await store.createNote("b", { tags: ["t"], metadata: { kind: "task" } });
|
|
196
|
+
await assertParity({ tags: ["t"], metadata: { kind: "note" } });
|
|
197
|
+
});
|
|
198
|
+
});
|