@openparachute/vault 0.7.6 → 0.7.7
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/README.md +16 -16
- package/core/src/attachment/policy.test.ts +7 -0
- package/core/src/attachment/policy.ts +9 -0
- package/core/src/attachment-tickets-tool.test.ts +15 -0
- package/core/src/conformance.test.ts +78 -0
- package/core/src/conformance.ts +34 -5
- package/core/src/connection-pragmas.test.ts +27 -1
- package/core/src/core.test.ts +88 -3
- package/core/src/cursor.ts +2 -0
- package/core/src/do-param-cap.test.ts +167 -0
- package/core/src/lede.test.ts +60 -0
- package/core/src/mcp-manifest.ts +15 -1
- package/core/src/mcp.ts +37 -5
- package/core/src/notes.ts +120 -48
- package/core/src/query-operators.ts +87 -5
- package/core/src/query-warnings.ts +11 -3
- package/core/src/schema.ts +32 -0
- package/core/src/seed-packs.ts +74 -5
- package/core/src/sql-in.ts +32 -2
- package/core/src/store.ts +16 -49
- package/core/src/test-preload.ts +48 -3
- package/core/src/types.ts +13 -1
- package/core/src/wikilinks.test.ts +57 -0
- package/core/src/wikilinks.ts +63 -29
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +62 -0
- package/src/attachment-tickets.ts +2 -2
- package/src/cli.ts +36 -8
- package/src/config.ts +34 -1
- package/src/contract-honest-queries.test.ts +33 -1
- package/src/contract-search.test.ts +47 -0
- package/src/embedding/select.ts +16 -3
- package/src/live-match.test.ts +8 -0
- package/src/live-match.ts +15 -0
- package/src/mcp-http.test.ts +12 -0
- package/src/mcp-http.ts +1 -0
- package/src/mcp-tools.ts +51 -17
- package/src/mirror-routes.test.ts +22 -31
- package/src/onboarding-seed.test.ts +68 -0
- package/src/routes.ts +88 -25
- package/src/routing.test.ts +24 -0
- package/src/routing.ts +2 -0
- package/src/subscriptions.ts +18 -2
- package/src/tag-scope-note-tags.test.ts +476 -0
- package/src/tag-scope.ts +73 -5
- package/src/test-home-isolation.test.ts +137 -0
- package/src/test-support/spawn.ts +12 -0
- package/src/transcription/download.test.ts +187 -1
- package/src/transcription/download.ts +149 -2
- package/src/transcription/install-python.test.ts +23 -2
- package/src/transcription/install-python.ts +13 -4
- package/src/vault.test.ts +39 -4
- package/src/version.test.ts +8 -0
- package/src/ws-server.ts +12 -2
package/src/routes.ts
CHANGED
|
@@ -68,6 +68,8 @@ import {
|
|
|
68
68
|
filterNotesByTagScope,
|
|
69
69
|
noteWithinTagScope,
|
|
70
70
|
scrubIndexedFieldConflictError,
|
|
71
|
+
scrubNotesTagsByScope,
|
|
72
|
+
scrubNoteTagsByScope,
|
|
71
73
|
scrubParentCycleError,
|
|
72
74
|
scrubReferencingTagsByScope,
|
|
73
75
|
scrubTagFieldViolationsByScope,
|
|
@@ -804,8 +806,7 @@ function parseMetadataJsonAlias(url: URL): {
|
|
|
804
806
|
|
|
805
807
|
/**
|
|
806
808
|
* Parse + validate the `?expand=` tag-expansion axis (vault tag `expand` axis).
|
|
807
|
-
* Shared by `parseNotesQueryOpts` (structured
|
|
808
|
-
* search branch of `handleNotes` (which bypasses `parseNotesQueryOpts`), so the
|
|
809
|
+
* Shared by `parseNotesQueryOpts` (structured, subscribe, and search) so the
|
|
809
810
|
* enum lives in exactly one place and `GET /notes?search=...&expand=bogus` is
|
|
810
811
|
* validated identically to the structured path.
|
|
811
812
|
*
|
|
@@ -875,8 +876,10 @@ function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response }
|
|
|
875
876
|
* Factored out of `handleNotesInner`'s structured-query branch so the
|
|
876
877
|
* `/subscribe` route evaluates the SAME predicate the snapshot query does —
|
|
877
878
|
* predicate parity by construction, not copy-paste. `handleNotesInner` keeps
|
|
878
|
-
* its own inline parsing for the single-note (`id`)
|
|
879
|
-
*
|
|
879
|
+
* its own inline parsing for the single-note (`id`) branch; the full-text
|
|
880
|
+
* (`search`) branch now reuses this helper for every structured filter
|
|
881
|
+
* (vault#647) so `exclude_tag` / date / path / metadata compose with
|
|
882
|
+
* `?search=` instead of being silently dropped.
|
|
880
883
|
*
|
|
881
884
|
* Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
|
|
882
885
|
* as the inline code did. `hasSearch` is surfaced from the raw `search` param
|
|
@@ -942,6 +945,7 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
942
945
|
hasBrokenLinks: parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
943
946
|
path: parseQuery(url, "path") ?? undefined,
|
|
944
947
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
948
|
+
excludePathPrefix: parseQueryList(url, "exclude_path_prefix"),
|
|
945
949
|
extension: parseExtensionFilter(url),
|
|
946
950
|
metadata: bracket.metadata ?? metadataAlias.metadata,
|
|
947
951
|
// Write-attribution filters (vault#298) — symmetric with the MCP
|
|
@@ -1191,6 +1195,10 @@ async function handleNotesInner(
|
|
|
1191
1195
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1192
1196
|
if (contentRange.error) return contentRange.error;
|
|
1193
1197
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
1198
|
+
// Tag-scope (vault#568): the note is visible via at least one
|
|
1199
|
+
// in-scope tag, but its `.tags` array would otherwise name every
|
|
1200
|
+
// out-of-scope co-tag it carries. No-op unscoped.
|
|
1201
|
+
result = scrubNoteTagsByScope(result, tagScope.allowed, tagScope.raw);
|
|
1194
1202
|
const expand = parseExpandParams(url, db, tagScope);
|
|
1195
1203
|
if (expand && includeContent && typeof result.content === "string") {
|
|
1196
1204
|
expand.ctx.expanded.add(note.id);
|
|
@@ -1291,6 +1299,10 @@ async function handleNotesInner(
|
|
|
1291
1299
|
if (contentRange.error) return contentRange.error;
|
|
1292
1300
|
const inclMeta = parseIncludeMetadata(url);
|
|
1293
1301
|
let output: any[] = includeContent ? filtered.map((n) => ({ ...n })) : filtered.map(toNoteIndex);
|
|
1302
|
+
// Tag-scope (vault#568): filter each surviving note's own `.tags`
|
|
1303
|
+
// to the in-scope subset — being visible via one tag must not
|
|
1304
|
+
// disclose the NAMES of its out-of-scope co-tags. No-op unscoped.
|
|
1305
|
+
output = scrubNotesTagsByScope(output, tagScope.allowed, tagScope.raw);
|
|
1294
1306
|
const expand = parseExpandParams(url, db, tagScope);
|
|
1295
1307
|
if (expand && includeContent) {
|
|
1296
1308
|
for (const n of output) expand.ctx.expanded.add(n.id);
|
|
@@ -1364,23 +1376,17 @@ async function handleNotesInner(
|
|
|
1364
1376
|
|
|
1365
1377
|
// Full-text search
|
|
1366
1378
|
if (search) {
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
const
|
|
1374
|
-
if (
|
|
1375
|
-
// `search_mode` (vault#551) — same loud-validation policy as
|
|
1376
|
-
// `expand` above; also bypasses `parseNotesQueryOpts` so it needs
|
|
1377
|
-
// its own check here.
|
|
1379
|
+
// vault#647: parse the structured filter grammar here too. Pre-fix
|
|
1380
|
+
// this branch bypassed `parseNotesQueryOpts` and only forwarded
|
|
1381
|
+
// tag/limit/expand/mode/sort, so `exclude_tag` and date/path/metadata
|
|
1382
|
+
// were silently dropped — a well-formed result set answering a
|
|
1383
|
+
// different question. `search_mode` is still parsed here because it
|
|
1384
|
+
// is search-specific (the helper does not know about it).
|
|
1385
|
+
const parsed = parseNotesQueryOpts(url);
|
|
1386
|
+
if (parsed.error) return parsed.error;
|
|
1378
1387
|
const searchModeParsed = parseSearchModeParam(url);
|
|
1379
1388
|
if (searchModeParsed.error) return searchModeParsed.error;
|
|
1380
1389
|
const mode: SearchMode = searchModeParsed.mode ?? "literal";
|
|
1381
|
-
// `sort` under search (vault#551 item 3): omit for FTS5 relevance
|
|
1382
|
-
// (default, unchanged); explicit asc/desc switches to created_at.
|
|
1383
|
-
const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
|
|
1384
1390
|
|
|
1385
1391
|
const searchWarnings: QueryWarning[] = [...nearTextIgnored];
|
|
1386
1392
|
// `offset` under full-text search (vault contracts-brief V1.2):
|
|
@@ -1414,11 +1420,13 @@ async function handleNotesInner(
|
|
|
1414
1420
|
// the pre-#551 silent `[]` — caught below and formatted the
|
|
1415
1421
|
// same way the structured-query path formats a `QueryError`.
|
|
1416
1422
|
rawResults = await store.searchNotes(search, {
|
|
1417
|
-
|
|
1418
|
-
limit,
|
|
1419
|
-
expand: tagExpand.expand,
|
|
1423
|
+
...parsed.queryOpts,
|
|
1420
1424
|
mode,
|
|
1421
|
-
|
|
1425
|
+
// Search has no offset/cursor/orderBy contract (offset is
|
|
1426
|
+
// warned above; cursor is rejected before this branch).
|
|
1427
|
+
offset: undefined,
|
|
1428
|
+
cursor: undefined,
|
|
1429
|
+
orderBy: undefined,
|
|
1422
1430
|
});
|
|
1423
1431
|
} catch (e: any) {
|
|
1424
1432
|
if (e && e.name === "QueryError") {
|
|
@@ -1467,6 +1475,9 @@ async function handleNotesInner(
|
|
|
1467
1475
|
if (contentRange.error) return contentRange.error;
|
|
1468
1476
|
const inclMeta = parseIncludeMetadata(url);
|
|
1469
1477
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
1478
|
+
// Tag-scope (vault#568): filter each surviving note's own `.tags` to
|
|
1479
|
+
// the in-scope subset — see the structured-query branch below.
|
|
1480
|
+
output = scrubNotesTagsByScope(output, tagScope.allowed, tagScope.raw);
|
|
1470
1481
|
const expand = parseExpandParams(url, db, tagScope);
|
|
1471
1482
|
if (expand && includeContent) {
|
|
1472
1483
|
for (const n of output) expand.ctx.expanded.add(n.id);
|
|
@@ -1711,7 +1722,9 @@ async function handleNotesInner(
|
|
|
1711
1722
|
// `limit` (i.e. there may be more rows the caller never saw). Cursor
|
|
1712
1723
|
// mode is exempt — it already carries `next_cursor` as the honest
|
|
1713
1724
|
// "more may follow" signal, so a second warning would be redundant.
|
|
1714
|
-
|
|
1725
|
+
// Explicit offset is also exempt (vault#601): the caller is already
|
|
1726
|
+
// paging, not hitting an accidental full default page.
|
|
1727
|
+
if (!cursorMode && queryOpts.offset === undefined && results.length === queryOpts.limit) {
|
|
1715
1728
|
queryWarnings.push(truncatedResultsWarning(queryOpts.limit));
|
|
1716
1729
|
}
|
|
1717
1730
|
|
|
@@ -1762,6 +1775,15 @@ async function handleNotesInner(
|
|
|
1762
1775
|
const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
|
|
1763
1776
|
const inclMeta = parseIncludeMetadata(url);
|
|
1764
1777
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
|
|
1778
|
+
// Tag-scope (vault#568): filter each surviving note's own `.tags` to
|
|
1779
|
+
// the in-scope subset. Deliberately applied to `output`, NOT `results`:
|
|
1780
|
+
// `results` still feeds `store.validateNoteAgainstSchemas` below (which
|
|
1781
|
+
// needs the FULL tag set to compute the status that then gets its own
|
|
1782
|
+
// scope scrub) and the graph-edge walk. Because `nodes` (graph format),
|
|
1783
|
+
// `enrichedOut`, and the cursor/plain envelopes are all derived from
|
|
1784
|
+
// `output`, this one call covers every shape this branch can return.
|
|
1785
|
+
// No-op unscoped.
|
|
1786
|
+
output = scrubNotesTagsByScope(output, tagScope.allowed, tagScope.raw);
|
|
1765
1787
|
const expand = parseExpandParams(url, db, tagScope);
|
|
1766
1788
|
if (expand && includeContent) {
|
|
1767
1789
|
for (const n of output) expand.ctx.expanded.add(n.id);
|
|
@@ -2298,6 +2320,19 @@ async function handleNotesInner(
|
|
|
2298
2320
|
let out: any = validated;
|
|
2299
2321
|
if (warnings && warnings.length > 0) out = { ...out, warnings };
|
|
2300
2322
|
if (existed !== undefined) out = { ...out, existed };
|
|
2323
|
+
// Tag-scope (vault#568): a create response echoes the STORED note,
|
|
2324
|
+
// and under `if_exists: ignore|update|replace` that's a note that
|
|
2325
|
+
// already existed with tags this token never supplied. Without the
|
|
2326
|
+
// scrub a scoped caller could recover any co-tagged note's full tag
|
|
2327
|
+
// set through the write door. Same treatment on the validation_status
|
|
2328
|
+
// this response carries — the #555 scrub was only wired to the read
|
|
2329
|
+
// paths, so the write door still named out-of-scope schemas.
|
|
2330
|
+
out = scrubNoteTagsByScope(out, tagScope.allowed, tagScope.raw);
|
|
2331
|
+
if (out.validation_status) {
|
|
2332
|
+
const vs = scrubValidationStatusByScope(out.validation_status, tagScope.allowed, tagScope.raw);
|
|
2333
|
+
if (vs === undefined) { const { validation_status: _d, ...rest } = out; out = rest; }
|
|
2334
|
+
else out = { ...out, validation_status: vs };
|
|
2335
|
+
}
|
|
2301
2336
|
return out;
|
|
2302
2337
|
});
|
|
2303
2338
|
|
|
@@ -2538,6 +2573,11 @@ async function handleNotesInner(
|
|
|
2538
2573
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
2539
2574
|
if (contentRange.error) return contentRange.error;
|
|
2540
2575
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
2576
|
+
// Tag-scope (vault#568): filter `.tags` to the in-scope subset. Placed
|
|
2577
|
+
// before the validation_status block on purpose — that block reads
|
|
2578
|
+
// `note.tags` (the full set) to COMPUTE the status and then scrubs the
|
|
2579
|
+
// status itself, so the two scrubs are independent.
|
|
2580
|
+
result = scrubNoteTagsByScope(result, tagScope.allowed, tagScope.raw);
|
|
2541
2581
|
// vault#555 fix 3 — mirror the MCP query-notes fix: attach
|
|
2542
2582
|
// validation_status on reads too, not just on the one-time create/update
|
|
2543
2583
|
// write response. See core/src/mcp.ts's query-notes handler for the
|
|
@@ -2692,8 +2732,20 @@ async function handleNotesInner(
|
|
|
2692
2732
|
}
|
|
2693
2733
|
const final = await store.getNote(created.id);
|
|
2694
2734
|
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
2695
|
-
|
|
2735
|
+
let validated: any = attachValidationStatus(store, db, final);
|
|
2696
2736
|
if (createWarnings.length > 0) validated.warnings = createWarnings;
|
|
2737
|
+
// Tag-scope (vault#568): scrub the echoed note so create-then-read
|
|
2738
|
+
// returns the SAME shape. A scoped token may legitimately attach an
|
|
2739
|
+
// out-of-scope co-tag on write (`tagsWithinScope` only requires ONE
|
|
2740
|
+
// in-scope tag), so this isn't a leak of anything it didn't send —
|
|
2741
|
+
// but leaving it unscrubbed would make the write door the one place
|
|
2742
|
+
// the full tag set is observable. Same for validation_status.
|
|
2743
|
+
validated = scrubNoteTagsByScope(validated, tagScope.allowed, tagScope.raw);
|
|
2744
|
+
if (validated.validation_status) {
|
|
2745
|
+
const vs = scrubValidationStatusByScope(validated.validation_status, tagScope.allowed, tagScope.raw);
|
|
2746
|
+
if (vs === undefined) { const { validation_status: _d, ...rest } = validated; validated = rest; }
|
|
2747
|
+
else validated = { ...validated, validation_status: vs };
|
|
2748
|
+
}
|
|
2697
2749
|
const includeContentResp = body.include_content !== false;
|
|
2698
2750
|
if (includeContentResp) return json({ ...validated, created: true });
|
|
2699
2751
|
const lean: any = toNoteIndex(validated);
|
|
@@ -3013,7 +3065,18 @@ async function handleNotesInner(
|
|
|
3013
3065
|
if (contentChanged) {
|
|
3014
3066
|
linkWarnings.push(...getContentWikilinkWarnings(db, note.id, updatedNote.content));
|
|
3015
3067
|
}
|
|
3016
|
-
|
|
3068
|
+
let validated: any = attachValidationStatus(store, db, updatedNote);
|
|
3069
|
+
// Tag-scope (vault#568): the update response echoes the STORED note, so
|
|
3070
|
+
// without this a scoped caller could recover a co-tagged note's full tag
|
|
3071
|
+
// set with a no-op PATCH — the read-path scrub would be trivially
|
|
3072
|
+
// bypassable. The validation_status scrub rides along for the same
|
|
3073
|
+
// reason (#555 only wired it to the read paths).
|
|
3074
|
+
validated = scrubNoteTagsByScope(validated, tagScope.allowed, tagScope.raw);
|
|
3075
|
+
if (validated.validation_status) {
|
|
3076
|
+
const vs = scrubValidationStatusByScope(validated.validation_status, tagScope.allowed, tagScope.raw);
|
|
3077
|
+
if (vs === undefined) { const { validation_status: _d, ...rest } = validated; validated = rest; }
|
|
3078
|
+
else validated = { ...validated, validation_status: vs };
|
|
3079
|
+
}
|
|
3017
3080
|
// Echo hydrated links when a link mutation was part of this request,
|
|
3018
3081
|
// OR the caller explicitly asked for them via `?include_links=true`
|
|
3019
3082
|
// (vault feedback #8). Previously the update response omitted links
|
package/src/routing.test.ts
CHANGED
|
@@ -678,6 +678,30 @@ describe("per-vault routing under /vault/<name>/", () => {
|
|
|
678
678
|
expect(res.status).toBe(401);
|
|
679
679
|
});
|
|
680
680
|
|
|
681
|
+
test("authed GET /api/subscribe without Upgrade → 410 SSE_TRANSPORT_REMOVED (vault#543)", async () => {
|
|
682
|
+
createVault("journal");
|
|
683
|
+
const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
|
|
684
|
+
const path = "/vault/journal/api/subscribe";
|
|
685
|
+
const res = await route(
|
|
686
|
+
new Request(`http://localhost:1940${path}`, {
|
|
687
|
+
headers: { authorization: `Bearer ${token}` },
|
|
688
|
+
}),
|
|
689
|
+
path,
|
|
690
|
+
);
|
|
691
|
+
expect(res.status).toBe(410);
|
|
692
|
+
const body = (await res.json()) as { code?: string };
|
|
693
|
+
expect(body.code).toBe("SSE_TRANSPORT_REMOVED");
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("anon GET /api/subscribe → 401, not the SSE tombstone (vault#543)", async () => {
|
|
697
|
+
createVault("journal");
|
|
698
|
+
const path = "/vault/journal/api/subscribe";
|
|
699
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
700
|
+
expect(res.status).toBe(401);
|
|
701
|
+
const body = (await res.json()) as { code?: string };
|
|
702
|
+
expect(body.code).not.toBe("SSE_TRANSPORT_REMOVED");
|
|
703
|
+
});
|
|
704
|
+
|
|
681
705
|
test("/vault/<name>/oauth/* returns 410 Gone (standalone issuer retired — workstream E)", async () => {
|
|
682
706
|
// The standalone OAuth issuer on vault was removed in vault#366 once hub
|
|
683
707
|
// became required. The 410 carries a pointer to the protected-resource
|
package/src/routing.ts
CHANGED
|
@@ -1156,6 +1156,8 @@ export async function route(
|
|
|
1156
1156
|
// straggler on a cached pre-WS notes-ui bundle — gets a clean 410 Gone
|
|
1157
1157
|
// pointing at the WS binding so its consumer degrades to polling gracefully;
|
|
1158
1158
|
// NEVER a 500 or an unhandled path.
|
|
1159
|
+
// Not method-gated: a POST here is still a straggler on the removed SSE
|
|
1160
|
+
// transport, not a missing verb on a live resource, so 410 (not 405).
|
|
1159
1161
|
if (apiPath === "/subscribe") {
|
|
1160
1162
|
return Response.json(
|
|
1161
1163
|
{
|
package/src/subscriptions.ts
CHANGED
|
@@ -65,7 +65,7 @@ import type { DeletedNoteRef, HookEvent, NoteHookPayload } from "../core/src/hoo
|
|
|
65
65
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
66
66
|
import { toNoteIndex } from "../core/src/notes.ts";
|
|
67
67
|
import { getVaultNameForStore } from "./vault-store.ts";
|
|
68
|
-
import { noteWithinTagScope } from "./tag-scope.ts";
|
|
68
|
+
import { noteWithinTagScope, scrubNoteTagsByScope } from "./tag-scope.ts";
|
|
69
69
|
import type { LiveMatcher } from "./live-match.ts";
|
|
70
70
|
|
|
71
71
|
/** Default per-vault concurrent-subscription cap. Over it → 503. */
|
|
@@ -332,7 +332,23 @@ export class SubscriptionManager {
|
|
|
332
332
|
if (matches) {
|
|
333
333
|
// A lean subscription (list view, `include_content=false`) carries the
|
|
334
334
|
// same `NoteIndex` projection REST lists return — never the full body.
|
|
335
|
-
|
|
335
|
+
//
|
|
336
|
+
// vault#568 — then scrub `.tags` to this subscriber's in-scope subset.
|
|
337
|
+
// A live event is a read: a `mine`-scoped socket watching a co-tagged
|
|
338
|
+
// note must not receive `project-manhattan` in the payload any more
|
|
339
|
+
// than `GET /api/notes/:id` would hand it over. Order matters twice:
|
|
340
|
+
// the matcher and the scope gate above both read the FULL tag set (a
|
|
341
|
+
// live predicate must evaluate against what's actually stored), and
|
|
342
|
+
// the scrub is NON-MUTATING — `note` is ONE payload fanned out to
|
|
343
|
+
// every subscriber in this loop, each with its own allowlist, so
|
|
344
|
+
// mutating it would cross-contaminate the next subscriber's frame.
|
|
345
|
+
this.emit(sub, "upsert", {
|
|
346
|
+
note: scrubNoteTagsByScope(
|
|
347
|
+
sub.lean ? toNoteIndex(note) : note,
|
|
348
|
+
sub.tagScopeAllowed,
|
|
349
|
+
sub.tagScopeRaw,
|
|
350
|
+
),
|
|
351
|
+
});
|
|
336
352
|
} else if (event === "updated" && inScope) {
|
|
337
353
|
// Left the set (predicate no longer true) BUT still within this
|
|
338
354
|
// token's scope, so the sub could have held it — idempotent remove
|