From 5eaf57993799e211a8d0a3c079bd2ff7953cca83 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 17 Jun 2026 13:19:54 -0700 Subject: [PATCH] =?UTF-8?q?fix(8.0):=20real=20bugs=20surfaced=20by=20integ?= =?UTF-8?q?ration=20hardening=20=E2=80=94=20where-intersect,=20related()?= =?UTF-8?q?=20offset,=20relate()=20updatedAt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the five bugs the rot pass surfaced (correct inputs → wrong output), fixed: 1. where-filter dropped all but the last operator on a field. getIdsForFilter()'s per-operator loop overwrote fieldResults each iteration, so { year: { greaterThan: 2009, lessThan: 2020 } } kept only lessThan. Now each operator's match set is AND-intersected (multi-operator-per-field works). (metadataIndex.ts) 2. related({ offset }) ignored the offset — getVerbs() zeroed it and smuggled it via an unimplemented cursor, so every page returned items [0, limit). Now the real offset is passed through to getVerbsWithPagination (which slices [offset,+limit)). (baseStorage.ts) — verified: get-relations-fix pagination test passes. 3. relate() never persisted updatedAt, so reads fabricated a fresh Date.now() each call (non-idempotent). Now createdAt and updatedAt share one timestamp at create. (brainy.ts) — verified: get-relations-fix equivalence test passes. Follow-ups (precisely diagnosed, not papered over): exclusive range bounds — ColumnStore.rangeQuery(field,min,max) is inclusive-only, so getIdsForRange() drops includeMin/includeMax (metadataIndex.ts:996) and lessThan/greaterThan behave as lte/gte when the column store is active (the dual-bound test's popularity:95 boundary stays red); counts not rehydrating after restart; unscoped VFS path-cache. --- src/brainy.ts | 6 +++++- src/storage/baseStorage.ts | 19 +++++++------------ src/utils/metadataIndex.ts | 14 ++++++++++++++ 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/brainy.ts b/src/brainy.ts index bd24e4e4..845b4dcf 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -2583,6 +2583,9 @@ export class Brainy implements BrainyInterface { // Prepare verb metadata // User metadata spread FIRST, then system fields ALWAYS win (prevents collision) + // One timestamp for both createdAt and updatedAt so a never-updated edge reports a + // stable updatedAt (=== createdAt) instead of a fresh Date.now() fabricated per read. + const relateTs = Date.now() const verbMetadata = { ...(params.metadata || {}), verb: params.type, @@ -2593,7 +2596,8 @@ export class Brainy implements BrainyInterface { weight: params.weight ?? 1.0, ...(params.confidence !== undefined && { confidence: params.confidence }), ...(params.service !== undefined && { service: params.service }), - createdAt: Date.now(), + createdAt: relateTs, + updatedAt: relateTs, ...(params.data !== undefined && { data: params.data }) } diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts index 1deb6746..0e7a444a 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -2043,15 +2043,11 @@ export abstract class BaseStorage extends BaseStorageAdapter { // Check if the adapter has a paginated method for getting verbs if (typeof this.getVerbsWithPagination === 'function') { - // Use the adapter's paginated method - // Convert offset to cursor if no cursor provided (adapters use cursor for offset) - const effectiveCursor = cursor || (offset > 0 ? offset.toString() : undefined) - - // offset is carried via effectiveCursor for adapters with cursor-based - // pagination; 0 here matches the long-standing destructure default in - // getVerbsWithPagination (this call never passed offset). The - // annotation widens totalCount to optional: adapter overrides follow - // BaseStorageAdapter's contract, where totalCount may be absent. + // getVerbsWithPagination honors `offset` directly (it slices the + // [offset, offset+limit) window; `cursor` is not yet implemented there). + // Pass the real offset through. Previously offset was zeroed and smuggled + // via an ignored `cursor`, so every page returned items [0, limit) and + // offset was silently dropped (related({ offset }) paginated incorrectly). const result: { items: HNSWVerbWithMetadata[] totalCount?: number @@ -2059,12 +2055,11 @@ export abstract class BaseStorage extends BaseStorageAdapter { nextCursor?: string } = await this.getVerbsWithPagination({ limit, - offset: 0, - cursor: effectiveCursor, + offset, + cursor, filter: options?.filter }) - // Items are already offset by the adapter via cursor, no need to slice const items = result.items // CRITICAL SAFETY CHECK: Prevent infinite loops diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts index 10607768..39de77a6 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -1820,7 +1820,14 @@ export class MetadataIndexManager implements MetadataIndexProvider { if (condition && typeof condition === 'object' && !Array.isArray(condition)) { // Handle Brainy Field Operators (canonical operators defined) // See docs/api/README.md for complete operator reference + // + // Multiple operators on ONE field are AND-combined (intersected): e.g. + // { greaterThan: 2009, lessThan: 2020 } requires BOTH bounds to hold. Each + // operator computes its own match set, then intersects with the running set. + let opIndex = 0 for (const [op, operand] of Object.entries(condition)) { + const prevOpResults = fieldResults + fieldResults = [] switch (op) { // ===== EQUALITY OPERATORS ===== // Canonical: 'eq' | Alias: 'equals' | Deprecated: 'is' @@ -1950,6 +1957,13 @@ export class MetadataIndexManager implements MetadataIndexProvider { break } } + // Intersect this operator's matches with the running set (AND semantics + // for multiple operators on the same field). + if (opIndex > 0) { + const prevSet = new Set(prevOpResults) + fieldResults = fieldResults.filter((id) => prevSet.has(id)) + } + opIndex++ } } else { // Direct value match (shorthand for 'eq' operator)