@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/src/init-summary.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ const baseInput = {
|
|
|
13
13
|
bindHost: "127.0.0.1",
|
|
14
14
|
port: 1940,
|
|
15
15
|
mcpUrl: "http://127.0.0.1:1940/vault/default/mcp",
|
|
16
|
+
vaultName: "default",
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
function lines(addMcp: boolean, addToken: boolean, apiKey: string | undefined) {
|
|
@@ -96,16 +97,70 @@ describe("buildInitSummaryLines", () => {
|
|
|
96
97
|
});
|
|
97
98
|
});
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
// vault#442: the DEFAULT init path — MCP wired, NO token minted (per-user
|
|
101
|
+
// OAuth). The summary must LEAD with the OAuth connect path, never mint, and
|
|
102
|
+
// never surface the old "no token issued" failure copy.
|
|
103
|
+
describe("MCP=Y + no token (vault#442 OAuth default)", () => {
|
|
104
|
+
const out = lines(true, false, undefined).join("\n");
|
|
105
|
+
|
|
106
|
+
test("leads with the OAuth connect message — no token needed", () => {
|
|
107
|
+
expect(out).toContain("no token needed, you'll sign in on first use");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("tells the user Claude Code is already wired in", () => {
|
|
111
|
+
expect(out).toContain("Claude Code is already wired in");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("shows the OAuth `claude mcp add` command for other clients", () => {
|
|
115
|
+
expect(out).toContain(
|
|
116
|
+
"claude mcp add --transport http parachute-vault http://127.0.0.1:1940/vault/default/mcp",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("offers the scope-narrow opt-in mint for scripts (full vault:<name>:read, never admin)", () => {
|
|
121
|
+
// Must be the three-segment named-resource form the hub mint-token model
|
|
122
|
+
// requires — a bare `vault:read` would mint a malformed scope (vault#443).
|
|
123
|
+
expect(out).toContain("parachute auth mint-token --scope vault:default:read");
|
|
124
|
+
expect(out).not.toContain("--scope vault:read ");
|
|
125
|
+
expect(out).not.toMatch(/--scope vault:read$/m);
|
|
126
|
+
expect(out).not.toContain("vault:admin");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("does NOT print or imply any minted token", () => {
|
|
130
|
+
expect(out).not.toContain("Your API token:");
|
|
131
|
+
expect(out).not.toContain("Baked into ~/.claude.json");
|
|
132
|
+
expect(out).not.toContain("Authorization: Bearer");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("does NOT surface the old no-token-issued failure copy", () => {
|
|
136
|
+
expect(out).not.toContain("No token issued");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("threads a non-default vault name into the mint-token scope", () => {
|
|
140
|
+
const out2 = buildInitSummaryLines({
|
|
141
|
+
...baseInput,
|
|
142
|
+
vaultName: "journal",
|
|
143
|
+
mcpUrl: "http://127.0.0.1:1940/vault/journal/mcp",
|
|
144
|
+
addMcp: true,
|
|
145
|
+
addToken: false,
|
|
146
|
+
apiKey: undefined,
|
|
147
|
+
}).join("\n");
|
|
148
|
+
expect(out2).toContain("parachute auth mint-token --scope vault:journal:read");
|
|
149
|
+
expect(out2).not.toContain("vault:default:read");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("MCP=N + token=N (OAuth default, Claude Code not wired)", () => {
|
|
100
154
|
const out = lines(false, false, undefined).join("\n");
|
|
101
155
|
|
|
102
|
-
test("
|
|
103
|
-
expect(out).toContain("
|
|
156
|
+
test("frames skipping the MCP entry as OAuth-first, not 'unreachable'", () => {
|
|
157
|
+
expect(out).toContain("uses per-user OAuth, no token needed");
|
|
158
|
+
expect(out).not.toContain("your vault isn't reachable by any client");
|
|
104
159
|
});
|
|
105
160
|
|
|
106
|
-
test("points to
|
|
161
|
+
test("points to mcp-install (no token-minting framing)", () => {
|
|
107
162
|
expect(out).toContain("parachute-vault mcp-install");
|
|
108
|
-
expect(out).toContain("mints a hub JWT");
|
|
163
|
+
expect(out).not.toContain("mints a hub JWT");
|
|
109
164
|
});
|
|
110
165
|
|
|
111
166
|
test("does not print any token", () => {
|
|
@@ -118,6 +173,66 @@ describe("buildInitSummaryLines", () => {
|
|
|
118
173
|
});
|
|
119
174
|
});
|
|
120
175
|
|
|
176
|
+
// Explicit opt-in but no hub reachable to mint (vault#282 Stage 2 path,
|
|
177
|
+
// reached only when the operator passes --token without a hub).
|
|
178
|
+
describe("MCP=N + token=Y but no hub (opt-in mint failed, standalone)", () => {
|
|
179
|
+
const out = buildInitSummaryLines({
|
|
180
|
+
...baseInput,
|
|
181
|
+
addMcp: false,
|
|
182
|
+
addToken: true,
|
|
183
|
+
apiKey: undefined,
|
|
184
|
+
noTokenGuidance: "No token issued — hub unreachable.",
|
|
185
|
+
hubPresent: false,
|
|
186
|
+
}).join("\n");
|
|
187
|
+
|
|
188
|
+
test("surfaces the no-token-issued guidance + recovery", () => {
|
|
189
|
+
expect(out).toContain("No token issued");
|
|
190
|
+
expect(out).toContain("parachute-vault mcp-install");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("standalone framing — points at bringing a hub up / VAULT_AUTH_TOKEN", () => {
|
|
194
|
+
expect(out).toContain("Once a hub is running");
|
|
195
|
+
expect(out).toContain("VAULT_AUTH_TOKEN");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("does NOT claim the vault is reachable (no hub present)", () => {
|
|
199
|
+
expect(out).not.toContain("Your vault is still reachable");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// #445: opted into a token, none minted, but a HUB IS PRESENT. The vault is
|
|
204
|
+
// reachable via the hub's browser OAuth flow even with no header-auth token,
|
|
205
|
+
// so the standalone "isn't reachable" framing would be false here.
|
|
206
|
+
describe("MCP=N + token=Y, no token minted, but hub present (#445)", () => {
|
|
207
|
+
const out = buildInitSummaryLines({
|
|
208
|
+
...baseInput,
|
|
209
|
+
addMcp: false,
|
|
210
|
+
addToken: true,
|
|
211
|
+
apiKey: undefined,
|
|
212
|
+
noTokenGuidance: "No token yet — the hub's admin wizard mints it.",
|
|
213
|
+
hubPresent: true,
|
|
214
|
+
}).join("\n");
|
|
215
|
+
|
|
216
|
+
test("affirms the vault is still reachable via the hub's OAuth flow", () => {
|
|
217
|
+
expect(out).toContain("Your vault is still reachable");
|
|
218
|
+
expect(out).toContain("sign-in (OAuth)");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("frames a header-auth token as optional (scripts / non-OAuth clients)", () => {
|
|
222
|
+
expect(out).toContain("only needed for scripts");
|
|
223
|
+
expect(out).toContain("parachute-vault mcp-install");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("does NOT print the standalone 'Once a hub is running' / VAULT_AUTH_TOKEN copy", () => {
|
|
227
|
+
expect(out).not.toContain("Once a hub is running");
|
|
228
|
+
expect(out).not.toContain("VAULT_AUTH_TOKEN");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("never claims the vault isn't reachable by any client", () => {
|
|
232
|
+
expect(out).not.toContain("isn't reachable by any client");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
121
236
|
test("always prints Config: and Server: lines", () => {
|
|
122
237
|
for (const [addMcp, addToken] of [
|
|
123
238
|
[true, true],
|
package/src/init-summary.ts
CHANGED
|
@@ -12,6 +12,13 @@ export type InitSummaryInput = {
|
|
|
12
12
|
bindHost: string;
|
|
13
13
|
port: number;
|
|
14
14
|
mcpUrl: string;
|
|
15
|
+
/**
|
|
16
|
+
* The default vault's name — used to emit the three-segment
|
|
17
|
+
* `vault:<vaultName>:read` scope in the OAuth-first mint-token suggestion
|
|
18
|
+
* (the hub mint-token model requires the named-resource form;
|
|
19
|
+
* a bare `vault:read` would mint a malformed scope). vault#442/#443.
|
|
20
|
+
*/
|
|
21
|
+
vaultName: string;
|
|
15
22
|
/**
|
|
16
23
|
* Guidance from the bootstrap-credential step when no token could be issued
|
|
17
24
|
* (standalone install, no hub reachable — vault#282 Stage 2). Surfaced when
|
|
@@ -19,66 +26,101 @@ export type InitSummaryInput = {
|
|
|
19
26
|
* undefined, so they know why and how to make the vault reachable.
|
|
20
27
|
*/
|
|
21
28
|
noTokenGuidance?: string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a hub is present on this host (live `/health` probe or a
|
|
31
|
+
* configured hub origin — see `detectHubPresence`). Branches the
|
|
32
|
+
* opted-into-a-token-but-none-minted copy: under a hub the vault is reachable
|
|
33
|
+
* via the hub's browser OAuth flow even with no header-auth token, so the
|
|
34
|
+
* old "your vault isn't reachable by any client" framing is false. #445.
|
|
35
|
+
* Undefined → treat as the conservative standalone case.
|
|
36
|
+
*/
|
|
37
|
+
hubPresent?: boolean | undefined;
|
|
22
38
|
};
|
|
23
39
|
|
|
24
40
|
/**
|
|
25
41
|
* Build the post-install summary lines for `vault init`, branched on the
|
|
26
|
-
* (addMcp, addToken, apiKey) decision matrix.
|
|
27
|
-
*
|
|
28
|
-
* is
|
|
42
|
+
* (addMcp, addToken, apiKey) decision matrix.
|
|
43
|
+
*
|
|
44
|
+
* vault#442: the DEFAULT is per-user OAuth — no token is minted, and the
|
|
45
|
+
* Claude Code MCP entry is written without a baked bearer (browser sign-in on
|
|
46
|
+
* first connect). A token is minted only on explicit opt-in (`addToken`), and
|
|
47
|
+
* then scope-narrow. Branches:
|
|
29
48
|
*
|
|
30
|
-
* addMcp,
|
|
31
|
-
* addMcp,
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* !addMcp, !
|
|
49
|
+
* addMcp, !apiKey → OAuth-first: connect, sign in on first use
|
|
50
|
+
* addMcp, addToken, apiKey → token baked into claude.json + printed
|
|
51
|
+
* addMcp, !addToken, apiKey → token baked into claude.json, hint
|
|
52
|
+
* !addMcp, addToken, apiKey → token printed prominently
|
|
53
|
+
* !addMcp, addToken, !apiKey → opted into a token but no hub reachable
|
|
54
|
+
* !addMcp, !addToken → OAuth-first: add Claude Code later
|
|
35
55
|
*/
|
|
36
56
|
export function buildInitSummaryLines(input: InitSummaryInput): string[] {
|
|
37
|
-
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, noTokenGuidance } = input;
|
|
57
|
+
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, vaultName, noTokenGuidance, hubPresent } = input;
|
|
38
58
|
const lines: string[] = [];
|
|
39
59
|
lines.push("");
|
|
40
60
|
lines.push("---");
|
|
41
61
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (addMcp && addToken && apiKey) {
|
|
62
|
+
if (addMcp && apiKey && addToken) {
|
|
45
63
|
lines.push("");
|
|
46
64
|
lines.push(`Your API token: ${apiKey}`);
|
|
47
65
|
lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
|
|
48
66
|
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
49
67
|
lines.push(` - Won't be shown again — save it now.`);
|
|
50
|
-
} else if (addMcp &&
|
|
68
|
+
} else if (addMcp && apiKey && !addToken) {
|
|
51
69
|
lines.push("");
|
|
52
70
|
lines.push(
|
|
53
71
|
"Token in ~/.claude.json; run `parachute-vault mcp-install` later if you need one for other clients.",
|
|
54
72
|
);
|
|
73
|
+
} else if (addMcp && !apiKey) {
|
|
74
|
+
// vault#442 default: OAuth-first. The MCP entry is wired without a bearer —
|
|
75
|
+
// Claude Code signs in via browser OAuth on first connect. No token needed.
|
|
76
|
+
lines.push("");
|
|
77
|
+
lines.push("Connect your AI — no token needed, you'll sign in on first use:");
|
|
78
|
+
lines.push(` Claude Code is already wired in (~/.claude.json) — just start a session.`);
|
|
79
|
+
lines.push(` Other clients: claude mcp add --transport http parachute-vault ${mcpUrl}`);
|
|
80
|
+
lines.push(` Need a header-auth token for a script? parachute auth mint-token --scope vault:${vaultName}:read`);
|
|
55
81
|
} else if (!addMcp && addToken && apiKey) {
|
|
56
82
|
lines.push("");
|
|
57
83
|
lines.push(`Your API token: ${apiKey}`);
|
|
58
84
|
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
59
85
|
lines.push(` - Won't be shown again — save it now.`);
|
|
60
|
-
} else if (
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// the recovery paths.
|
|
86
|
+
} else if (!addMcp && addToken && !apiKey) {
|
|
87
|
+
// Explicitly opted into a token but none was minted (vault#282 Stage 2 —
|
|
88
|
+
// vault no longer mints local pvt_* tokens). Surface why + recovery.
|
|
64
89
|
lines.push("");
|
|
65
90
|
lines.push(
|
|
66
91
|
noTokenGuidance ??
|
|
67
92
|
"No token issued — no hub was reachable to mint a hub JWT.",
|
|
68
93
|
);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
if (hubPresent) {
|
|
95
|
+
// A hub IS present — the vault is already reachable via the hub's
|
|
96
|
+
// browser OAuth flow / web UI. A header-auth token is optional, only for
|
|
97
|
+
// non-OAuth clients + scripts. The "isn't reachable" framing is false
|
|
98
|
+
// here (#445).
|
|
99
|
+
lines.push(
|
|
100
|
+
" Your vault is still reachable — clients connect through the hub's browser",
|
|
101
|
+
);
|
|
102
|
+
lines.push(
|
|
103
|
+
" sign-in (OAuth); a header-auth token is only needed for scripts / non-OAuth",
|
|
104
|
+
);
|
|
105
|
+
lines.push(
|
|
106
|
+
" clients. Run `parachute-vault mcp-install` to mint + wire one when you want it.",
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
lines.push(
|
|
110
|
+
" Once a hub is running, run `parachute-vault mcp-install` to mint + wire a token,",
|
|
111
|
+
);
|
|
112
|
+
lines.push(
|
|
113
|
+
" or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
75
116
|
} else if (!addMcp && !addToken) {
|
|
117
|
+
// OAuth-first, but the operator skipped wiring Claude Code too.
|
|
76
118
|
lines.push("");
|
|
77
119
|
lines.push(
|
|
78
|
-
"
|
|
120
|
+
"Skipped the Claude Code MCP entry. Add it anytime — it uses per-user OAuth, no token needed:",
|
|
79
121
|
);
|
|
80
122
|
lines.push(
|
|
81
|
-
"
|
|
123
|
+
" parachute-vault mcp-install",
|
|
82
124
|
);
|
|
83
125
|
}
|
|
84
126
|
|
|
@@ -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
|
+
});
|