@openparachute/vault 0.6.4-rc.1 → 0.6.4-rc.11
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 +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/conformance.test.ts +112 -0
- package/core/src/conformance.ts +214 -0
- package/core/src/core.test.ts +126 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +235 -9
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +280 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +70 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +50 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/cli.ts +345 -9
- package/src/config.ts +11 -0
- package/src/first-boot-create.test.ts +155 -0
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +164 -0
- package/src/mirror-routes.ts +182 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +295 -48
- package/src/routing.test.ts +240 -25
- package/src/routing.ts +74 -4
- package/src/scale.bench.test.ts +82 -0
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/server.ts +58 -38
- package/src/subscribe.test.ts +23 -2
- package/src/test-support/spawn.ts +85 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
- package/src/vault-remove.test.ts +4 -5
- package/src/vault.test.ts +188 -17
- package/web/ui/dist/assets/{index-UlvD8KHr.css → index-C5XqQM-c.css} +1 -1
- package/web/ui/dist/assets/index-TJ-XN4OZ.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DwYo23aY.js +0 -61
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema conformance check — count how many EXISTING notes would violate a
|
|
3
|
+
* PROPOSED field spec for a tag, before the operator commits a tightening
|
|
4
|
+
* (vault#283, the MW2/#314 tie-in).
|
|
5
|
+
*
|
|
6
|
+
* Motivation: marking a field `strict`/`required`, narrowing an `enum`, or
|
|
7
|
+
* changing a `type` is a backwards-incompatible move — notes written under
|
|
8
|
+
* the old contract may now reject on their next write
|
|
9
|
+
* (`enforceStrictSchema` → `SchemaValidationError`). The Schema editor in
|
|
10
|
+
* the admin SPA calls this BEFORE save so the operator sees "N existing
|
|
11
|
+
* notes violate this — they'll reject on next write" and is pointed at the
|
|
12
|
+
* `parachute-vault schema migrate-field` CLI (#314) rather than silently
|
|
13
|
+
* shipping a contract their own data breaks.
|
|
14
|
+
*
|
|
15
|
+
* What "violate" means here: we overlay the PROPOSED `fields` for `tag` on
|
|
16
|
+
* top of the live schema config, treating every proposed field as if it
|
|
17
|
+
* were `strict:true` for the purpose of the count (so the count is "notes
|
|
18
|
+
* that would hard-reject IF this field were strict" — the worst case the
|
|
19
|
+
* operator is being warned about). We then walk every note carrying `tag`
|
|
20
|
+
* (descendants included, per the hierarchy) and run the SAME `validateNote`
|
|
21
|
+
* resolver the write path uses, counting notes with ≥1 violation on a field
|
|
22
|
+
* the proposal touches.
|
|
23
|
+
*
|
|
24
|
+
* This is a READ — no mutation. It's deliberately scoped to the proposed
|
|
25
|
+
* tag's own fields (the columns the operator is editing); inherited fields
|
|
26
|
+
* from ancestors are already enforced and aren't what the operator is
|
|
27
|
+
* tightening in this edit.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Database } from "bun:sqlite";
|
|
31
|
+
import {
|
|
32
|
+
loadSchemaConfig,
|
|
33
|
+
validateNote,
|
|
34
|
+
type SchemaField,
|
|
35
|
+
type ResolvedSchemas,
|
|
36
|
+
} from "./schema-defaults.ts";
|
|
37
|
+
import type { TagFieldSchema } from "./tag-schemas.ts";
|
|
38
|
+
import { getTagDescendants, loadTagHierarchy } from "./tag-hierarchy.ts";
|
|
39
|
+
import { queryNotes, getNoteTagsForNotes } from "./notes.ts";
|
|
40
|
+
|
|
41
|
+
export interface ConformanceViolation {
|
|
42
|
+
/** Note id of a non-conforming note. */
|
|
43
|
+
id: string;
|
|
44
|
+
/** Note path, when set (helps the operator find it). */
|
|
45
|
+
path?: string | null;
|
|
46
|
+
/** The proposed-field violations on this note (reasons + messages). */
|
|
47
|
+
fields: { field: string; reason: string; message: string }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConformanceReport {
|
|
51
|
+
tag: string;
|
|
52
|
+
/** Total notes carrying the tag (descendants included). */
|
|
53
|
+
total_notes: number;
|
|
54
|
+
/** Count of notes that would violate the proposed spec. */
|
|
55
|
+
violating_notes: number;
|
|
56
|
+
/** The field names the proposal touches (the ones checked). */
|
|
57
|
+
checked_fields: string[];
|
|
58
|
+
/**
|
|
59
|
+
* A bounded sample of violating notes (id + path + which fields), so the
|
|
60
|
+
* SPA can show examples without paging the whole vault. Capped at
|
|
61
|
+
* `sampleLimit` (default 20).
|
|
62
|
+
*/
|
|
63
|
+
sample: ConformanceViolation[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Convert the on-disk `TagFieldSchema` shape to the resolver's `SchemaField`
|
|
68
|
+
* shape, FORCING `strict:true` so `validateNote` flags every constraint as a
|
|
69
|
+
* (strict) violation we can count. `indexed` is dropped — it's not a
|
|
70
|
+
* note-data constraint. `type` is narrowed to the resolver's union; an
|
|
71
|
+
* unknown type string is dropped (no type check) rather than throwing.
|
|
72
|
+
*/
|
|
73
|
+
function toStrictSchemaField(spec: TagFieldSchema): SchemaField {
|
|
74
|
+
const out: SchemaField = { strict: true };
|
|
75
|
+
if (
|
|
76
|
+
spec.type === "string" ||
|
|
77
|
+
spec.type === "number" ||
|
|
78
|
+
spec.type === "integer" ||
|
|
79
|
+
spec.type === "boolean" ||
|
|
80
|
+
spec.type === "array" ||
|
|
81
|
+
spec.type === "object"
|
|
82
|
+
) {
|
|
83
|
+
out.type = spec.type;
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(spec.enum)) out.enum = spec.enum;
|
|
86
|
+
if (spec.required === true) out.required = true;
|
|
87
|
+
if (spec.cardinality === "one" || spec.cardinality === "many") {
|
|
88
|
+
out.cardinality = spec.cardinality;
|
|
89
|
+
}
|
|
90
|
+
if (typeof spec.description === "string") out.description = spec.description;
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a `ResolvedSchemas` snapshot with the proposed fields overlaid on
|
|
96
|
+
* `tag`'s OWN field map. We start from the live config (so inheritance +
|
|
97
|
+
* other tags are intact) and replace just `tag`'s field declarations with
|
|
98
|
+
* the strict-forced proposed set.
|
|
99
|
+
*/
|
|
100
|
+
function overlayProposedSchema(
|
|
101
|
+
base: ResolvedSchemas,
|
|
102
|
+
tag: string,
|
|
103
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
104
|
+
): ResolvedSchemas {
|
|
105
|
+
const tagToFields = new Map(base.tagToFields);
|
|
106
|
+
const forced: Record<string, SchemaField> = {};
|
|
107
|
+
for (const [name, spec] of Object.entries(proposedFields)) {
|
|
108
|
+
forced[name] = toStrictSchemaField(spec);
|
|
109
|
+
}
|
|
110
|
+
if (Object.keys(forced).length > 0) {
|
|
111
|
+
tagToFields.set(tag, forced);
|
|
112
|
+
} else {
|
|
113
|
+
tagToFields.delete(tag);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
allTags: new Set(base.allTags).add(tag),
|
|
117
|
+
tagToFields,
|
|
118
|
+
tagToParents: base.tagToParents,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Count existing notes that would violate the PROPOSED field spec for `tag`.
|
|
124
|
+
*
|
|
125
|
+
* `proposedFields` is the full merged field map the operator intends to save
|
|
126
|
+
* (the same object the PUT body carries). Only the fields in this map are
|
|
127
|
+
* checked — the violation count answers "if I tighten THESE fields, how many
|
|
128
|
+
* notes break?" The check runs against every note carrying `tag` or any of
|
|
129
|
+
* its descendants.
|
|
130
|
+
*
|
|
131
|
+
* Pure read; no mutation. Returns 0 violating notes when `proposedFields` is
|
|
132
|
+
* empty (nothing to enforce).
|
|
133
|
+
*/
|
|
134
|
+
export function countConformanceViolations(
|
|
135
|
+
db: Database,
|
|
136
|
+
tag: string,
|
|
137
|
+
proposedFields: Record<string, TagFieldSchema>,
|
|
138
|
+
opts: { sampleLimit?: number } = {},
|
|
139
|
+
): ConformanceReport {
|
|
140
|
+
const sampleLimit = opts.sampleLimit ?? 20;
|
|
141
|
+
const checkedFields = Object.keys(proposedFields);
|
|
142
|
+
|
|
143
|
+
const empty: ConformanceReport = {
|
|
144
|
+
tag,
|
|
145
|
+
total_notes: 0,
|
|
146
|
+
violating_notes: 0,
|
|
147
|
+
checked_fields: checkedFields,
|
|
148
|
+
sample: [],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Resolve the tag's descendant set so we count notes on subtype tags too
|
|
152
|
+
// (a strict field on `#dev` applies to `#dev/log` notes via inheritance).
|
|
153
|
+
const hierarchy = loadTagHierarchy(db);
|
|
154
|
+
const tagSet = Array.from(getTagDescendants(hierarchy, tag));
|
|
155
|
+
if (tagSet.length === 0) return empty;
|
|
156
|
+
|
|
157
|
+
// All notes carrying the tag (or a descendant). `tagMatch: "any"` over the
|
|
158
|
+
// expanded set — a note on ANY of these tags is in scope. We hydrate tags
|
|
159
|
+
// ourselves (batched) so the resolver sees the full ancestor set per note.
|
|
160
|
+
const notes = queryNotes(db, { tags: tagSet, tagMatch: "any" });
|
|
161
|
+
if (notes.length === 0) return empty;
|
|
162
|
+
|
|
163
|
+
if (checkedFields.length === 0) {
|
|
164
|
+
return { ...empty, total_notes: notes.length };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const base = loadSchemaConfig(db);
|
|
168
|
+
const resolved = overlayProposedSchema(base, tag, proposedFields);
|
|
169
|
+
const checkedSet = new Set(checkedFields);
|
|
170
|
+
|
|
171
|
+
const tagsById = getNoteTagsForNotes(
|
|
172
|
+
db,
|
|
173
|
+
notes.map((n) => n.id),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
let violating = 0;
|
|
177
|
+
const sample: ConformanceViolation[] = [];
|
|
178
|
+
|
|
179
|
+
for (const note of notes) {
|
|
180
|
+
const tags = tagsById.get(note.id) ?? note.tags ?? [];
|
|
181
|
+
const status = validateNote(resolved, {
|
|
182
|
+
path: note.path ?? null,
|
|
183
|
+
tags,
|
|
184
|
+
metadata: note.metadata ?? {},
|
|
185
|
+
});
|
|
186
|
+
if (!status) continue;
|
|
187
|
+
// Keep only violations on a field the PROPOSAL touches (ignore conflicts
|
|
188
|
+
// + violations on inherited/other fields the operator isn't editing).
|
|
189
|
+
const relevant = status.warnings.filter(
|
|
190
|
+
(w) => w.reason !== "schema_conflict" && checkedSet.has(w.field),
|
|
191
|
+
);
|
|
192
|
+
if (relevant.length === 0) continue;
|
|
193
|
+
violating++;
|
|
194
|
+
if (sample.length < sampleLimit) {
|
|
195
|
+
sample.push({
|
|
196
|
+
id: note.id,
|
|
197
|
+
path: note.path ?? null,
|
|
198
|
+
fields: relevant.map((w) => ({
|
|
199
|
+
field: w.field,
|
|
200
|
+
reason: w.reason,
|
|
201
|
+
message: w.message,
|
|
202
|
+
})),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
tag,
|
|
209
|
+
total_notes: notes.length,
|
|
210
|
+
violating_notes: violating,
|
|
211
|
+
checked_fields: checkedFields,
|
|
212
|
+
sample,
|
|
213
|
+
};
|
|
214
|
+
}
|
package/core/src/core.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { generateMcpTools } from "./mcp.js";
|
|
|
5
5
|
import { initSchema } from "./schema.js";
|
|
6
6
|
import { decodeCursor } from "./cursor.js";
|
|
7
7
|
import { traverseLinks } from "./links.js";
|
|
8
|
+
import * as indexedFieldOps from "./indexed-fields.js";
|
|
8
9
|
|
|
9
10
|
let store: SqliteStore;
|
|
10
11
|
let db: Database;
|
|
@@ -2169,6 +2170,50 @@ describe("MCP tools", async () => {
|
|
|
2169
2170
|
expect(result.extension).toBe("csv");
|
|
2170
2171
|
});
|
|
2171
2172
|
|
|
2173
|
+
// ---- Metadata key-deletion via null tombstone (vault#478 / #479) ---------
|
|
2174
|
+
|
|
2175
|
+
it("update-note deletes a metadata key when its value is null (RFC 7386)", async () => {
|
|
2176
|
+
const note = await store.createNote("m", {
|
|
2177
|
+
metadata: { keep: "yes", drop: "old", other: 1 },
|
|
2178
|
+
});
|
|
2179
|
+
const tools = generateMcpTools(store);
|
|
2180
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2181
|
+
await updateNote.execute({ id: note.id, metadata: { drop: null }, force: true });
|
|
2182
|
+
|
|
2183
|
+
const fresh = await store.getNote(note.id);
|
|
2184
|
+
// The deleted key is GONE — not a literal JSON null.
|
|
2185
|
+
expect(fresh!.metadata).not.toHaveProperty("drop");
|
|
2186
|
+
// Other keys are untouched.
|
|
2187
|
+
expect(fresh!.metadata).toMatchObject({ keep: "yes", other: 1 });
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
it("update-note metadata key-rename in one call (new key set, old key deleted)", async () => {
|
|
2191
|
+
const note = await store.createNote("m", {
|
|
2192
|
+
metadata: { "old-key": "v", stable: true },
|
|
2193
|
+
});
|
|
2194
|
+
const tools = generateMcpTools(store);
|
|
2195
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2196
|
+
await updateNote.execute({
|
|
2197
|
+
id: note.id,
|
|
2198
|
+
metadata: { new_key: "v", "old-key": null },
|
|
2199
|
+
force: true,
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
const fresh = await store.getNote(note.id);
|
|
2203
|
+
expect(fresh!.metadata).not.toHaveProperty("old-key");
|
|
2204
|
+
expect(fresh!.metadata).toMatchObject({ new_key: "v", stable: true });
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
it("deleting an absent metadata key is a no-op (round-trips cleanly)", async () => {
|
|
2208
|
+
const note = await store.createNote("m", { metadata: { a: 1 } });
|
|
2209
|
+
const tools = generateMcpTools(store);
|
|
2210
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2211
|
+
await updateNote.execute({ id: note.id, metadata: { ghost: null }, force: true });
|
|
2212
|
+
const fresh = await store.getNote(note.id);
|
|
2213
|
+
expect(fresh!.metadata).toEqual({ a: 1 });
|
|
2214
|
+
expect(fresh!.metadata).not.toHaveProperty("ghost");
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2172
2217
|
it("query-notes filters by extension (vault#328)", async () => {
|
|
2173
2218
|
await store.createNote("md note", { path: "a" });
|
|
2174
2219
|
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
@@ -5301,6 +5346,87 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
|
5301
5346
|
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
5302
5347
|
expect(await store.getTagRecord("voice")).toBeNull();
|
|
5303
5348
|
});
|
|
5349
|
+
|
|
5350
|
+
// ---- Indexed-field atomicity (vault#478) ----------------------------------
|
|
5351
|
+
|
|
5352
|
+
it("upsertTagRecord with a bad indexed-field name throws IndexedFieldError and leaves the schema unchanged", async () => {
|
|
5353
|
+
// kebab-case field name violates [A-Za-z0-9_] / no-leading-digit. The
|
|
5354
|
+
// declaration must NOT persist, and no orphan/lying index may be created.
|
|
5355
|
+
await expect(
|
|
5356
|
+
store.upsertTagRecord("meeting", {
|
|
5357
|
+
description: "meetings",
|
|
5358
|
+
fields: { "meeting-type": { type: "string", indexed: true } },
|
|
5359
|
+
}),
|
|
5360
|
+
).rejects.toThrow(indexedFieldOps.IndexedFieldError);
|
|
5361
|
+
|
|
5362
|
+
// Schema is untouched — the tag row may not even exist, and definitely
|
|
5363
|
+
// doesn't claim the poisoned field.
|
|
5364
|
+
const record = await store.getTagRecord("meeting");
|
|
5365
|
+
expect(record?.fields?.["meeting-type"]).toBeUndefined();
|
|
5366
|
+
|
|
5367
|
+
// No backing index row was created for the bad field.
|
|
5368
|
+
expect(indexedFieldOps.getIndexedField(db, "meeting-type")).toBeNull();
|
|
5369
|
+
// And the generated column doesn't exist either.
|
|
5370
|
+
const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
|
|
5371
|
+
(c) => c.name,
|
|
5372
|
+
);
|
|
5373
|
+
expect(cols).not.toContain("meta_meeting-type");
|
|
5374
|
+
});
|
|
5375
|
+
|
|
5376
|
+
it("upsertTagRecord with an unindexable type throws and leaves the schema unchanged", async () => {
|
|
5377
|
+
await expect(
|
|
5378
|
+
store.upsertTagRecord("meeting", {
|
|
5379
|
+
fields: { attendees: { type: "json", indexed: true } },
|
|
5380
|
+
}),
|
|
5381
|
+
).rejects.toThrow(indexedFieldOps.IndexedFieldError);
|
|
5382
|
+
const record = await store.getTagRecord("meeting");
|
|
5383
|
+
expect(record?.fields?.attendees).toBeUndefined();
|
|
5384
|
+
expect(indexedFieldOps.getIndexedField(db, "attendees")).toBeNull();
|
|
5385
|
+
});
|
|
5386
|
+
|
|
5387
|
+
it("a bad indexed field does NOT corrupt a prior valid declaration on the same tag", async () => {
|
|
5388
|
+
// First, a clean valid indexed field.
|
|
5389
|
+
await store.upsertTagRecord("meeting", {
|
|
5390
|
+
fields: { held_on: { type: "string", indexed: true } },
|
|
5391
|
+
});
|
|
5392
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
|
|
5393
|
+
|
|
5394
|
+
// Now an update that adds a poisoned field. The whole write must roll
|
|
5395
|
+
// back — the prior valid field survives untouched.
|
|
5396
|
+
await expect(
|
|
5397
|
+
store.upsertTagRecord("meeting", {
|
|
5398
|
+
fields: {
|
|
5399
|
+
held_on: { type: "string", indexed: true },
|
|
5400
|
+
"bad-field": { type: "string", indexed: true },
|
|
5401
|
+
},
|
|
5402
|
+
}),
|
|
5403
|
+
).rejects.toThrow(indexedFieldOps.IndexedFieldError);
|
|
5404
|
+
|
|
5405
|
+
const record = await store.getTagRecord("meeting");
|
|
5406
|
+
expect(record?.fields?.held_on?.indexed).toBe(true);
|
|
5407
|
+
expect(record?.fields?.["bad-field"]).toBeUndefined();
|
|
5408
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
|
|
5409
|
+
expect(indexedFieldOps.getIndexedField(db, "bad-field")).toBeNull();
|
|
5410
|
+
});
|
|
5411
|
+
|
|
5412
|
+
it("upsertTagRecord with fields:null releases indexed fields (transaction commits the release path)", async () => {
|
|
5413
|
+
// Declare an indexed field, then clear all fields — the release path runs
|
|
5414
|
+
// inside the same transaction as the persist. It must commit cleanly:
|
|
5415
|
+
// the row, generated column, and index all drop.
|
|
5416
|
+
await store.upsertTagRecord("meeting", {
|
|
5417
|
+
fields: { held_on: { type: "string", indexed: true } },
|
|
5418
|
+
});
|
|
5419
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")?.declarerTags).toContain("meeting");
|
|
5420
|
+
|
|
5421
|
+
await store.upsertTagRecord("meeting", { fields: null });
|
|
5422
|
+
const record = await store.getTagRecord("meeting");
|
|
5423
|
+
expect(record?.fields).toBeUndefined();
|
|
5424
|
+
expect(indexedFieldOps.getIndexedField(db, "held_on")).toBeNull();
|
|
5425
|
+
const cols = (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[]).map(
|
|
5426
|
+
(c) => c.name,
|
|
5427
|
+
);
|
|
5428
|
+
expect(cols).not.toContain("meta_held_on");
|
|
5429
|
+
});
|
|
5304
5430
|
});
|
|
5305
5431
|
|
|
5306
5432
|
// ---------------------------------------------------------------------------
|
package/core/src/cursor.ts
CHANGED
|
@@ -160,6 +160,14 @@ export interface QueryHashInputs {
|
|
|
160
160
|
extension?: string | string[];
|
|
161
161
|
ids?: string[];
|
|
162
162
|
metadata?: Record<string, unknown>;
|
|
163
|
+
// Write-attribution filters (vault#298) — part of the result-set-affecting
|
|
164
|
+
// opts, so they're bound into the cursor: changing "who/via" between polls
|
|
165
|
+
// invalidates the cursor with cursor_query_mismatch rather than silently
|
|
166
|
+
// continuing the prior filter's watermark.
|
|
167
|
+
createdBy?: string;
|
|
168
|
+
lastUpdatedBy?: string;
|
|
169
|
+
createdVia?: string;
|
|
170
|
+
lastUpdatedVia?: string;
|
|
163
171
|
dateFrom?: string;
|
|
164
172
|
dateTo?: string;
|
|
165
173
|
dateFilter?: { field?: string; from?: string; to?: string };
|