@openparachute/vault 0.6.4-rc.1 → 0.6.4-rc.3

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/core/src/notes.ts CHANGED
@@ -22,6 +22,36 @@ import { getIndexedField, releaseField } from "./indexed-fields.js";
22
22
 
23
23
  let idCounter = 0;
24
24
 
25
+ /**
26
+ * Write-attribution context (vault#298) — the two axes of provenance threaded
27
+ * from the authenticated request down into every note write.
28
+ *
29
+ * actor — WHO: the principal the write is attributed to (a JWT `sub`, or an
30
+ * operator / `token:<id>` label for non-JWT auth). Lands in
31
+ * `created_by` on the first write and `last_updated_by` on every
32
+ * write.
33
+ * via — VIA WHAT: the interface/channel the write arrived through
34
+ * (`mcp`, `surface:<name>`, `agent:<id>`, `operator`/`cli`, `api`).
35
+ * Lands in `created_via` / `last_updated_via` symmetrically.
36
+ *
37
+ * Both are independently optional — an internal/import write may carry
38
+ * neither, and a non-JWT operator write carries an `actor`/`via` pair without
39
+ * a `sub`. `undefined` (or a missing context) means "don't write this column,"
40
+ * so legacy callers and importers leave attribution NULL rather than
41
+ * fabricating it.
42
+ */
43
+ export interface WriteContext {
44
+ actor?: string | null;
45
+ via?: string | null;
46
+ }
47
+
48
+ /** Normalize an attribution value: empty/whitespace → null (never store ""). */
49
+ function attrValue(v: string | null | undefined): string | null {
50
+ if (typeof v !== "string") return null;
51
+ const t = v.trim();
52
+ return t.length === 0 ? null : t;
53
+ }
54
+
25
55
  /** Generate a timestamp-based ID: YYYY-MM-DD-HH-MM-SS-ffffff */
26
56
  export function generateId(): string {
27
57
  const now = new Date();
@@ -41,7 +71,7 @@ export function generateId(): string {
41
71
  export function createNote(
42
72
  db: Database,
43
73
  content: string,
44
- opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string },
74
+ opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string; actor?: string | null; via?: string | null },
45
75
  ): Note {
46
76
  const id = opts?.id ?? generateId();
47
77
  const createdAt = opts?.created_at ?? new Date().toISOString();
@@ -52,6 +82,14 @@ export function createNote(
52
82
  // whatever the caller passed; importer paths trust the export's shape.
53
83
  const extension = opts?.extension ?? "md";
54
84
 
85
+ // Write-attribution (vault#298). On CREATE both axes land in the
86
+ // `created_*` columns AND mirror into `last_updated_*` — the first write IS
87
+ // the most-recent write, so a never-updated note reports the same author on
88
+ // both. NULL when the caller passed no attribution (internal/import writes),
89
+ // never an empty string.
90
+ const actor = attrValue(opts?.actor);
91
+ const via = attrValue(opts?.via);
92
+
55
93
  // Empty content is a valid state (vault#323): skeleton notes, drafts
56
94
  // saved before content, organizing-only notes, capture-then-fill flows.
57
95
  // The earlier #213 guard rejected `content + path both absent`; we no
@@ -66,8 +104,8 @@ export function createNote(
66
104
  // "user-touched since creation."
67
105
  try {
68
106
  db.prepare(
69
- `INSERT INTO notes (id, content, path, metadata, created_at, updated_at, extension) VALUES (?, ?, ?, ?, ?, ?, ?)`,
70
- ).run(id, content, path, metadata, createdAt, createdAt, extension);
107
+ `INSERT INTO notes (id, content, path, metadata, created_at, updated_at, extension, created_by, created_via, last_updated_by, last_updated_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
108
+ ).run(id, content, path, metadata, createdAt, createdAt, extension, actor, via, actor, via);
71
109
  } catch (err) {
72
110
  if (path !== null && isPathUniqueError(err)) {
73
111
  throw new PathConflictError(path);
@@ -170,6 +208,50 @@ export class ConflictError extends Error {
170
208
  }
171
209
  }
172
210
 
211
+ /**
212
+ * Thrown by `updateNote` when a `state_transition` precondition fails: the
213
+ * named metadata field's current value does not equal `from` (a missing field
214
+ * counts as a mismatch). Distinct from `ConflictError` (vault#299 settled lead
215
+ * #3) — a state-transition conflict is about a VALUE not matching, not a
216
+ * stale `updated_at` token, so it carries its own `transition_conflict` code
217
+ * and the from/to/current triple rather than the if_updated_at vocabulary.
218
+ *
219
+ * The check + set is one atomic conditional UPDATE (`... WHERE
220
+ * json_extract(metadata,'$.field') IS ?`), so two racing transitioners can't
221
+ * both observe `from` and both commit — exactly one wins, the other gets this.
222
+ */
223
+ export class TransitionConflictError extends Error {
224
+ code = "TRANSITION_CONFLICT" as const;
225
+ note_id: string;
226
+ note_path: string | null;
227
+ field: string;
228
+ expected_from: unknown;
229
+ to: unknown;
230
+ current: unknown;
231
+
232
+ constructor(
233
+ noteId: string,
234
+ notePath: string | null,
235
+ field: string,
236
+ from: unknown,
237
+ to: unknown,
238
+ current: unknown,
239
+ ) {
240
+ super(
241
+ `transition_conflict: note "${noteId}" field "${field}" is ${JSON.stringify(
242
+ current ?? null,
243
+ )}, expected ${JSON.stringify(from)} to transition to ${JSON.stringify(to)}`,
244
+ );
245
+ this.name = "TransitionConflictError";
246
+ this.note_id = noteId;
247
+ this.note_path = notePath;
248
+ this.field = field;
249
+ this.expected_from = from;
250
+ this.to = to;
251
+ this.current = current;
252
+ }
253
+ }
254
+
173
255
  /**
174
256
  * Thrown by `createNote` / `updateNote` when the requested path is already
175
257
  * taken by another note. Surfaces as 409 at the HTTP layer so clients can
@@ -321,12 +403,34 @@ export function updateNote(
321
403
  metadata?: Record<string, unknown>;
322
404
  created_at?: string;
323
405
  skipUpdatedAt?: boolean;
406
+ /**
407
+ * Write-attribution (vault#298). When set, the most-recent-write columns
408
+ * `last_updated_by` / `last_updated_via` are bumped to this principal /
409
+ * channel as part of the same UPDATE. Gated on the same condition as
410
+ * `updated_at` — a `skipUpdatedAt` machine write doesn't claim authorship,
411
+ * symmetric with not bumping the timestamp. `created_*` is never touched by
412
+ * an update (it's set-once at create). Omitted → attribution left as-is.
413
+ */
414
+ actor?: string | null;
415
+ via?: string | null;
324
416
  /**
325
417
  * Optimistic concurrency token. When provided, the UPDATE runs with an
326
418
  * additional `AND updated_at IS ?` clause; if no row is affected and the
327
419
  * note still exists, a `ConflictError` is thrown.
328
420
  */
329
421
  if_updated_at?: string;
422
+ /**
423
+ * Compare-and-set state transition (vault#299 Part B). Atomically: if the
424
+ * named metadata field currently equals `from`, set it to `to` and commit;
425
+ * otherwise throw `TransitionConflictError` (a missing field is a
426
+ * conflict). The check rides the same conditional UPDATE — an additional
427
+ * `AND json_extract(metadata,'$.<field>') IS ?` clause — so two racing
428
+ * transitioners can't both pass. Combinable with `metadata` (other field
429
+ * updates merge alongside the transition) and `if_updated_at` (both
430
+ * preconditions must hold). `from`/`to` are JSON scalars (string / number /
431
+ * boolean / null).
432
+ */
433
+ state_transition?: { field: string; from: unknown; to: unknown };
330
434
  },
331
435
  ): Note {
332
436
  if (updates.content !== undefined && (updates.append !== undefined || updates.prepend !== undefined)) {
@@ -358,6 +462,17 @@ export function updateNote(
358
462
  }
359
463
  sets.push("updated_at = ?");
360
464
  values.push(now);
465
+
466
+ // Write-attribution (vault#298): the most-recent-write columns ride the
467
+ // SAME gate as `updated_at`. A `skipUpdatedAt` machine write doesn't bump
468
+ // the timestamp, so it doesn't claim authorship either. Set unconditionally
469
+ // within this branch (even to NULL) so a write that arrives without
470
+ // attribution honestly records "unknown author for the latest edit" rather
471
+ // than leaving a stale prior principal — the latest writer wasn't them.
472
+ sets.push("last_updated_by = ?");
473
+ values.push(attrValue(updates.actor));
474
+ sets.push("last_updated_via = ?");
475
+ values.push(attrValue(updates.via));
361
476
  }
362
477
 
363
478
  if (updates.content !== undefined) {
@@ -404,16 +519,37 @@ export function updateNote(
404
519
  sets.push("extension = ?");
405
520
  values.push(updates.extension);
406
521
  }
522
+ // State-transition (vault#299 Part B). The `to` value must land in the
523
+ // metadata field. Two sub-cases keep the metadata write consistent:
524
+ // - caller ALSO passed `metadata` (the MCP/REST layer merges before
525
+ // calling): fold `to` into that object so the single `metadata = ?`
526
+ // SET carries it. The transition value wins over a merged value for
527
+ // the same field — the transition is the authoritative state write.
528
+ // - caller passed NO `metadata`: use SQL `json_set` so the field is
529
+ // updated in place without a read-merge-write (keeps it atomic).
530
+ // The atomic GUARD (current value == from) is appended to the WHERE below.
531
+ const st = updates.state_transition;
532
+ if (st !== undefined && updates.metadata !== undefined) {
533
+ (updates.metadata as Record<string, unknown>)[st.field] = st.to;
534
+ }
407
535
  if (updates.metadata !== undefined) {
408
536
  sets.push("metadata = ?");
409
537
  values.push(JSON.stringify(updates.metadata));
538
+ } else if (st !== undefined) {
539
+ // No metadata payload — set the single field via json_set. The path is
540
+ // bound as a parameter (mirrors the json_extract pattern elsewhere); the
541
+ // value is bound as a JSON-typed argument via `json(?)` so booleans /
542
+ // numbers / null land as their JSON types, not stringified text.
543
+ sets.push(`metadata = json_set(COALESCE(metadata, '{}'), ?, json(?))`);
544
+ values.push(`$.${jsonPathKey(st.field)}`, JSON.stringify(st.to));
410
545
  }
411
546
  if (updates.created_at !== undefined) {
412
547
  sets.push("created_at = ?");
413
548
  values.push(updates.created_at);
414
549
  }
415
550
 
416
- // No-op: no SET fields. If a caller still passed `if_updated_at`, we
551
+ // No-op: no SET fields. If a caller still passed `if_updated_at` (and no
552
+ // state_transition — that always pushes a metadata SET above), we
417
553
  // need to validate the precondition; a conditional UPDATE that sets
418
554
  // updated_at to itself does exactly that atomically — even a no-net-
419
555
  // change UPDATE takes the write lock in WAL mode, so it still serializes
@@ -438,10 +574,23 @@ export function updateNote(
438
574
  sql += " AND updated_at IS ?";
439
575
  values.push(updates.if_updated_at);
440
576
  }
577
+ // State-transition guard (vault#299 Part B): the atomic compare. The UPDATE
578
+ // only fires when the stored field currently equals `from`; otherwise zero
579
+ // rows match and we raise TransitionConflictError below. `IS` (not `=`) so
580
+ // a `from: null` correctly matches a stored JSON null / missing field, and
581
+ // a non-null `from` never matches a NULL/missing field (= conflict).
582
+ if (st !== undefined) {
583
+ sql += ` AND json_extract(metadata, ?) IS json_extract(json(?), '$')`;
584
+ values.push(`$.${jsonPathKey(st.field)}`, JSON.stringify(st.from));
585
+ }
441
586
 
587
+ // A value-conditional WHERE (if_updated_at OR state_transition) needs
588
+ // RETURNING to distinguish "matched + updated" from "no match" — `.changes`
589
+ // is unreliable inside transactions (vault#261).
590
+ const conditional = updates.if_updated_at !== undefined || st !== undefined;
442
591
  let matched: { id: string } | null = null;
443
592
  try {
444
- if (updates.if_updated_at !== undefined) {
593
+ if (conditional) {
445
594
  matched = db.prepare(`${sql} RETURNING id`).get(...values) as
446
595
  | { id: string }
447
596
  | null;
@@ -461,13 +610,47 @@ export function updateNote(
461
610
  throw err;
462
611
  }
463
612
 
464
- if (updates.if_updated_at !== undefined && matched === null) {
465
- throwConflictOrMissing(db, id, updates.if_updated_at);
613
+ if (conditional && matched === null) {
614
+ // No row matched. Disambiguate the cause, checking the if_updated_at
615
+ // precondition first (it's the cheaper, pre-existing contract), then the
616
+ // state-transition value, then not-found.
617
+ const row = db.prepare("SELECT updated_at, path, metadata FROM notes WHERE id = ?").get(id) as
618
+ | { updated_at: string | null; path: string | null; metadata: string | null }
619
+ | null;
620
+ if (!row) throw new Error(`Note not found: "${id}"`);
621
+ if (updates.if_updated_at !== undefined && row.updated_at !== updates.if_updated_at) {
622
+ throw new ConflictError(id, row.path, row.updated_at, updates.if_updated_at);
623
+ }
624
+ if (st !== undefined) {
625
+ // if_updated_at (if any) matched, so the mismatch is the transition.
626
+ let current: unknown;
627
+ try {
628
+ const meta = row.metadata ? JSON.parse(row.metadata) : {};
629
+ current = (meta as Record<string, unknown>)[st.field];
630
+ } catch {
631
+ current = undefined;
632
+ }
633
+ throw new TransitionConflictError(id, row.path, st.field, st.from, st.to, current);
634
+ }
635
+ // if_updated_at-only path that fell through (timestamp matched but row
636
+ // vanished mid-flight): preserve the prior contract.
637
+ throwConflictOrMissing(db, id, updates.if_updated_at!);
466
638
  }
467
639
 
468
640
  return getNote(db, id)!;
469
641
  }
470
642
 
643
+ /**
644
+ * Build the dotted JSON-path key fragment for a metadata field name. Field
645
+ * names that contain characters JSON-path treats specially (`.`, `[`, `"`,
646
+ * etc.) are double-quoted; simple identifiers pass through bare. Mirrors the
647
+ * SQLite JSON1 path grammar.
648
+ */
649
+ function jsonPathKey(field: string): string {
650
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(field)) return field;
651
+ return `"${field.replace(/"/g, '\\"')}"`;
652
+ }
653
+
471
654
  function throwConflictOrMissing(db: Database, id: string, expected: string): never {
472
655
  const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
473
656
  | { updated_at: string | null; path: string | null }
@@ -601,6 +784,29 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
601
784
  }
602
785
  }
603
786
 
787
+ // Write-attribution filters (vault#298). Exact match on the indexed
788
+ // attribution columns. Each rides its own B-tree (idx_notes_<col>), so
789
+ // "everything Mathilda wrote" / "everything via the meeting-ingest surface"
790
+ // is a seek, not a scan. NULL columns never match a non-null filter value
791
+ // (SQL `=` is unknown against NULL), so legacy/unattributed rows are
792
+ // correctly excluded from an attribution query.
793
+ if (opts.createdBy !== undefined) {
794
+ conditions.push("n.created_by = ?");
795
+ params.push(opts.createdBy);
796
+ }
797
+ if (opts.lastUpdatedBy !== undefined) {
798
+ conditions.push("n.last_updated_by = ?");
799
+ params.push(opts.lastUpdatedBy);
800
+ }
801
+ if (opts.createdVia !== undefined) {
802
+ conditions.push("n.created_via = ?");
803
+ params.push(opts.createdVia);
804
+ }
805
+ if (opts.lastUpdatedVia !== undefined) {
806
+ conditions.push("n.last_updated_via = ?");
807
+ params.push(opts.lastUpdatedVia);
808
+ }
809
+
604
810
  // Metadata filters — operator objects route through the indexed generated
605
811
  // column (fast, loud errors on non-indexed fields); primitives keep the
606
812
  // existing JSON-scan exact-match behavior for backcompat.
@@ -898,6 +1104,10 @@ function toQueryHashInputs(opts: QueryOpts): QueryHashInputs {
898
1104
  extension: opts.extension,
899
1105
  ids: opts.ids,
900
1106
  metadata: opts.metadata,
1107
+ createdBy: opts.createdBy,
1108
+ lastUpdatedBy: opts.lastUpdatedBy,
1109
+ createdVia: opts.createdVia,
1110
+ lastUpdatedVia: opts.lastUpdatedVia,
901
1111
  dateFrom: opts.dateFrom,
902
1112
  dateTo: opts.dateTo,
903
1113
  dateFilter: opts.dateFilter,
@@ -1587,6 +1797,10 @@ export function toNoteIndex(note: Note): NoteIndex {
1587
1797
  extension: note.extension,
1588
1798
  createdAt: note.createdAt,
1589
1799
  updatedAt: note.updatedAt,
1800
+ createdBy: note.createdBy ?? null,
1801
+ createdVia: note.createdVia ?? null,
1802
+ lastUpdatedBy: note.lastUpdatedBy ?? null,
1803
+ lastUpdatedVia: note.lastUpdatedVia ?? null,
1590
1804
  tags: note.tags,
1591
1805
  metadata: note.metadata,
1592
1806
  byteSize,
@@ -1703,6 +1917,10 @@ export interface BulkNoteInput {
1703
1917
  metadata?: Record<string, unknown>;
1704
1918
  created_at?: string;
1705
1919
  extension?: string;
1920
+ /** Write-attribution (vault#298) — see WriteContext. Per-item so a batch can
1921
+ * carry the same actor/via on every row. */
1922
+ actor?: string | null;
1923
+ via?: string | null;
1706
1924
  }
1707
1925
 
1708
1926
  export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
@@ -1719,6 +1937,8 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
1719
1937
  metadata: input.metadata,
1720
1938
  created_at: input.created_at,
1721
1939
  extension: input.extension,
1940
+ actor: input.actor,
1941
+ via: input.via,
1722
1942
  }),
1723
1943
  );
1724
1944
  }
@@ -1787,6 +2007,14 @@ interface NoteRow {
1787
2007
  created_at: string;
1788
2008
  updated_at: string | null;
1789
2009
  extension: string | null;
2010
+ // Write-attribution (vault#298). All four nullable — NULL on legacy rows and
2011
+ // on writes that carried no attribution context. A v22 vault reading these
2012
+ // before its migration runs would see them absent on the row object
2013
+ // (`SELECT *` simply omits non-existent columns); `?? null` normalizes.
2014
+ created_by?: string | null;
2015
+ created_via?: string | null;
2016
+ last_updated_by?: string | null;
2017
+ last_updated_via?: string | null;
1790
2018
  }
1791
2019
 
1792
2020
  function rowToNote(row: NoteRow): Note {
@@ -1807,5 +2035,13 @@ function rowToNote(row: NoteRow): Note {
1807
2035
  // Legacy notes (pre-#70) may have NULL updated_at. Fall back to created_at
1808
2036
  // so the optimistic-concurrency contract always has a real token to echo.
1809
2037
  updatedAt: row.updated_at ?? row.created_at,
2038
+ // Write-attribution (vault#298). NULL passes through verbatim — a missing
2039
+ // author is meaningful ("pre-attribution / unknown"), so we don't coerce
2040
+ // to a placeholder. `?? null` collapses both SQL NULL and an absent column
2041
+ // (pre-migration read) to the same `null`.
2042
+ createdBy: row.created_by ?? null,
2043
+ createdVia: row.created_via ?? null,
2044
+ lastUpdatedBy: row.last_updated_by ?? null,
2045
+ lastUpdatedVia: row.last_updated_via ?? null,
1810
2046
  };
1811
2047
  }
@@ -188,3 +188,120 @@ export function buildOperatorClause(
188
188
  params,
189
189
  };
190
190
  }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // In-memory operator evaluation (vault#299 — value-matched triggers)
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Evaluate an operator object against a single in-memory value — the
198
+ * non-SQL twin of {@link buildOperatorClause}. The trigger engine fires on a
199
+ * live `note.metadata` object (not a DB row), so it can't route through the
200
+ * indexed generated columns; this gives it the SAME operator vocabulary +
201
+ * semantics without re-implementing them. An object with multiple operators
202
+ * is AND-composed (`{ gt: 5, lt: 10 }`), matching the SQL clause builder.
203
+ *
204
+ * `value` is the field's current value (`undefined` when the field is
205
+ * absent). Semantics mirror the SQL builder, including the NULL/missing
206
+ * handling: `ne`/`not_in` treat an absent/null value as "matches" (the SQL
207
+ * `(col IS NULL OR ...)` shape); `eq: null` matches an absent/null value.
208
+ *
209
+ * Throws `QueryError` on an unknown operator or a malformed operand — the
210
+ * trigger validator surfaces it as a 400 at registration time.
211
+ */
212
+ export function matchesOperator(
213
+ field: string,
214
+ value: unknown,
215
+ opObj: Record<string, unknown>,
216
+ ): boolean {
217
+ validateOperatorObject(field, opObj);
218
+ const absent = value === undefined || value === null;
219
+
220
+ for (const [op, operand] of Object.entries(opObj)) {
221
+ switch (op as QueryOp) {
222
+ case "eq":
223
+ if (operand === null) {
224
+ if (!absent) return false;
225
+ } else if (absent || !looseEquals(value, operand)) {
226
+ return false;
227
+ }
228
+ break;
229
+ case "ne":
230
+ if (operand === null) {
231
+ if (absent) return false;
232
+ } else if (!absent && looseEquals(value, operand)) {
233
+ return false;
234
+ }
235
+ break;
236
+ case "gt":
237
+ case "gte":
238
+ case "lt":
239
+ case "lte": {
240
+ if (absent) return false;
241
+ const cmp = compareScalar(value, operand);
242
+ if (cmp === null) return false;
243
+ if (op === "gt" && !(cmp > 0)) return false;
244
+ if (op === "gte" && !(cmp >= 0)) return false;
245
+ if (op === "lt" && !(cmp < 0)) return false;
246
+ if (op === "lte" && !(cmp <= 0)) return false;
247
+ break;
248
+ }
249
+ case "in":
250
+ case "not_in": {
251
+ if (!Array.isArray(operand)) {
252
+ throw new QueryError(
253
+ `operator "${op}" on metadata field "${field}" expects an array`,
254
+ "INVALID_OPERATOR_VALUE",
255
+ );
256
+ }
257
+ const present = !absent && operand.some((o) => looseEquals(value, o));
258
+ if (op === "in" && !present) return false;
259
+ // not_in: absent/null matches (mirrors SQL `col IS NULL OR ...`).
260
+ if (op === "not_in" && present) return false;
261
+ break;
262
+ }
263
+ case "exists":
264
+ if (typeof operand !== "boolean") {
265
+ throw new QueryError(
266
+ `operator "exists" on metadata field "${field}" expects a boolean`,
267
+ "INVALID_OPERATOR_VALUE",
268
+ );
269
+ }
270
+ if (operand === !absent) break;
271
+ return false;
272
+ }
273
+ }
274
+ return true;
275
+ }
276
+
277
+ /**
278
+ * Loose scalar equality used by in-memory `eq`/`ne`/`in`. Matches SQLite's
279
+ * affinity-tolerant comparison closely enough for trigger value-matching:
280
+ * exact for same-type scalars; cross-type number/string compares by string
281
+ * form (`5` matches `"5"`), since metadata round-trips through JSON and a
282
+ * note may carry either shape.
283
+ */
284
+ function looseEquals(a: unknown, b: unknown): boolean {
285
+ if (a === b) return true;
286
+ if (
287
+ (typeof a === "number" || typeof a === "string") &&
288
+ (typeof b === "number" || typeof b === "string")
289
+ ) {
290
+ return String(a) === String(b);
291
+ }
292
+ return false;
293
+ }
294
+
295
+ /**
296
+ * Three-way comparison for ordered operators (gt/gte/lt/lte). Returns a
297
+ * negative/zero/positive number, or `null` when the operands aren't
298
+ * order-comparable (so the caller treats it as "no match"). Numbers compare
299
+ * numerically; strings lexicographically; mixed types don't compare.
300
+ */
301
+ function compareScalar(a: unknown, b: unknown): number | null {
302
+ if (typeof a === "number" && typeof b === "number") return a - b;
303
+ if (typeof a === "string" && typeof b === "string") {
304
+ return a < b ? -1 : a > b ? 1 : 0;
305
+ }
306
+ return null;
307
+ }