diff --git a/.changeset/memory-matcher-no-value-negated-operators.md b/.changeset/memory-matcher-no-value-negated-operators.md new file mode 100644 index 0000000000..9e2f258537 --- /dev/null +++ b/.changeset/memory-matcher-no-value-negated-operators.md @@ -0,0 +1,36 @@ +--- +"@objectstack/driver-memory": patch +--- + +fix(driver-memory): a no-value row satisfies `$nin` / `$notContains` in the reference matcher (#13166) + +`memory-matcher`'s `match()` — the reference face `driver-sql` was aligned TO for +#5146 — diverged from the platform's settled answer in 3 of 6 measured cells. The +ruling is the INCLUDE direction: a row whose field has no value SATISFIES a +negation-carrying operator (`$ne` / `$nin` / `$notContains`). That is #5146 +extended by #5298 option A, re-affirmed on 2026-08-10 after the reversal was +priced and withdrawn. + +"No value" has two readings and the divergence had two INDEPENDENT causes, one +reachable from each: + +- a MISSING key short-circuited to "no match" in `checkCondition`'s pre-switch + guard, whose allowlist named `$ne` but not `$nin` / `$notContains`; +- a `null` value failed the `$notContains` arm on its `typeof value !== 'string'` + TYPE test rather than on the predicate — which the guard above cannot reach. + +Both are fixed, and both now answer one named predicate rather than two spellings +of one ruling. + +**Grading — what does NOT change.** `InMemoryDriver.find()` is unaffected: +`match()` is not part of this package's export surface, and the live mingo query +path users actually reach already answered the include direction (measured on the +card's fixture: `['2','3']` for all three operators, before and after). Nothing +moved on the SQL side either — `driver-sql` (2241 tests) and `formula` (643) are +untouched and green, because this change moves `driver-memory` TO the answer they +already gave. The observable effect is on the reference face itself: the +package's two filter faces now agree for these operators where they used to +disagree. + +`$exists` is deliberately NOT included — it is the neighbouring cell (#13195), +with a different backend list, and remains a pinned divergence. diff --git a/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts b/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts index 1aba67f48d..2687f90764 100644 --- a/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts +++ b/packages/drivers/driver-memory/src/memory-driver-document-not.test.ts @@ -208,28 +208,41 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => { * * `$exists` REFERENCE is correct. `$exists` means "has a value" * (#5298 ③ / #5369, PR #5962), so mingo's key-presence - * reading is the divergent one. - * `$nin` LIVE is correct. Negative operators MATCH no-value rows — + * reading is the divergent one. STILL OPEN — #13195. + * `$nin` LIVE was correct. Negative operators MATCH no-value rows — * #5146, extended by #5298, re-affirmed 2026-08-10 — so a * missing key satisfying `$nin` is the affirmed answer, and - * the reference matcher's early-exit guard is the divergence. - * `$notContains` LIVE is correct, for the same reason. + * the reference matcher's early-exit guard was the + * divergence. CLOSED by #13166: the guard now exempts the + * negation-carrying operators, and the two faces agree. + * `$notContains` LIVE was correct, for the same reason. CLOSED by #13166, + * through a SECOND and independent cause — the arm's + * `typeof value !== 'string'` test, which rejected a `null` + * on its type rather than on the predicate. * * A ruling that morning (07:33Z) would have made the REFERENCE column the * target on all three rows. Cells 1 and 3 of it were WITHDRAWN the same day, * once the reversal's cross-backend cost had been measured, and the include - * direction was re-affirmed — which leaves the split above. + * direction was re-affirmed — which left the split above. * - * ⛔ Nothing is flipped in either direction. The #5499 investment freeze was - * the reason while it stood; it dissolved 2026-08-11 (head note of - * `@objectstack/spec`'s `aggregation-conformance.ts`), so that excuse has - * lapsed and the direction is now #13166's and #13195's to settle — ⛔ not - * this pin's, and ⛔ not a sweep's. What the round trip confirmed is exactly - * why this pin exists — this package answers with two different faces, so a - * statement like "driver-memory already reads has-value" is true of the - * reference matcher and FALSE of the live query path users actually reach. + * ⚠️ Two of the three cells are no longer a divergence, and this block was + * built for exactly that edit. It used to say "⛔ Nothing is flipped in either + * direction. The #5499 investment freeze was the reason while it stood; it + * dissolved 2026-08-11 …, so that excuse has lapsed and the direction is now + * #13166's and #13195's to settle." #13166 settled its two: the `$nin` and + * `$notContains` rows below now assert live and reference AGREEING, on the + * affirmed include answer. They were not re-baselined onto whatever the + * matcher began printing — the target was the live path's pre-existing + * answer, named as correct in this very note before the fix existed. + * + * ⛔ The `$exists` row is untouched and stays a pinned divergence. It is a + * different cell with a different backend list (`driver-mongodb` reads + * key-presence too), and it belongs to #13195. What that row still shows is + * why this pin exists — this package answers with two faces, so a statement + * like "driver-memory already reads has-value" is true of the reference + * matcher and FALSE of the live query path users actually reach. */ - describe('[#5299] the settled cells, live vs reference — divergence pinned, disposition open (#13166/#13195)', () => { + describe('[#5299] the settled cells, live vs reference — $nin / $notContains converged (#13166), $exists still open (#13195)', () => { const liveVsReference = async (where: unknown) => ({ live: await idsFrom(nulled, where), reference: NULLED.filter((r) => match(r, where)).map((r) => r.id), @@ -242,16 +255,22 @@ describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => { }); }); - it('$nin on an ABSENT field', async () => { + it('$nin on an ABSENT field: the two faces now AGREE (#13166)', async () => { + // Was `reference: ['2']` — the matcher's `value === undefined` guard + // short-circuited before the `$nin` arm ran. The LIVE column is unchanged, + // and it is the column this note already named correct. const live = await idsFrom(missing, { stage: { $nin: ['won'] } }); const reference = MISSING.filter((r) => match(r, { stage: { $nin: ['won'] } })).map((r) => r.id); - expect({ live, reference }).toEqual({ live: ['2', '3', '4'], reference: ['2'] }); + expect({ live, reference }).toEqual({ live: ['2', '3', '4'], reference: ['2', '3', '4'] }); }); - it('$notContains on a null field', async () => { + it('$notContains on a null field: the two faces now AGREE (#13166)', async () => { + // Was `reference: ['1', '3', '4']` — `typeof null !== 'string'` failed the + // TYPE test, so the negation readmitted the null rows. The LIVE column is + // unchanged here too. expect(await liveVsReference({ $not: { stage: { $notContains: 'w' } } })).toEqual({ live: ['1'], - reference: ['1', '3', '4'], + reference: ['1'], }); }); }); diff --git a/packages/drivers/driver-memory/src/memory-matcher-no-value-negated-operators.test.ts b/packages/drivers/driver-memory/src/memory-matcher-no-value-negated-operators.test.ts new file mode 100644 index 0000000000..5fdd189c33 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-matcher-no-value-negated-operators.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13166] A row with NO VALUE satisfies a negation-carrying operator — the + * six cells, on this matcher, stated directly rather than through a `$not`. + * + * ## The ruling this file enforces + * + * `$ne` / `$nin` / `$notContains` MATCH a row whose field has no value. That is + * the INCLUDE direction: #5146 made `$not` NULL-safe, #5298 option A extended it + * to the non-negated negative operators, and the 2026-08-10 reversal that would + * have taken SQL's three-valued answer as the common denominator was WITHDRAWN + * the same day once its cross-backend cost had been measured. Ten other surfaces + * already answer this way — `formula` (`matches-filter-not-null-safe.test.ts`), + * the four SQL compilers via `nullSafeNegative` / `nullValueSatisfiesOperator`, + * and `driver-mongodb`, which passes `$nin` through and compiles `$notContains` + * to `{ $not: { $regex } }`, both of which match a missing or null field. + * + * ## Why the table has SIX cells and not three + * + * "No value" has two readings that this matcher reaches through DIFFERENT code, + * so a three-cell table cannot see the difference between them: + * + * - `name: null` — the shape a SQL NULL round-trips into a record. + * - the key absent — the shape a partial write leaves. + * + * Collapsing them is how the divergence stayed invisible: two INDEPENDENT causes + * produced it, each reachable from only one of the two readings, so a fixture + * carrying one reading measures at most one of them. Keep both columns. + * + * ⚠️ Do not collapse this into a single case, and do not add `$exists` to it. + * `$exists` is the neighbouring cell (#13195): this package's live mingo path + * and `driver-mongodb` still read it as key-presence rather than has-value, so + * it is a different, still-open divergence with a different backend list. + * + * The complementary POSITIVE operators are asserted beside each negative one, so + * a matcher that started answering "every row" to everything cannot pass this + * file: the ruling is that a no-value row joins the negative answer, not that + * predicates stop discriminating. + */ + +import { describe, it, expect } from 'vitest'; + +import { match } from './memory-matcher.js'; + +/** `name` present but null — how a SQL NULL round-trips into a record. */ +const NULLED: Array> = [ + { id: '1', name: 'alpha-one' }, + { id: '2', name: 'beta' }, + { id: '3', name: null }, +]; + +/** The same rows with `name` ABSENT — the shape a partial write leaves. */ +const MISSING: Array> = [ + { id: '1', name: 'alpha-one' }, + { id: '2', name: 'beta' }, + { id: '3' }, +]; + +const ids = (rows: Array>, filter: unknown): string[] => + rows.filter((r) => match(r, filter)).map((r) => String(r.id)); + +/** The ruling's answer: row 2 has a different value, row 3 has none. */ +const NO_VALUE_INCLUDED = ['2', '3']; + +describe('[#13166] no-value rows and the negation-carrying operators', () => { + describe('the six cells — three operators x two readings of "no value"', () => { + const OPERATORS: Array<[name: string, filter: unknown]> = [ + ['$ne', { name: { $ne: 'alpha-one' } }], + ['$nin', { name: { $nin: ['alpha-one'] } }], + ['$notContains', { name: { $notContains: 'one' } }], + ]; + + for (const [op, filter] of OPERATORS) { + it(`${op}: a null value satisfies it`, () => { + expect(ids(NULLED, filter)).toEqual(NO_VALUE_INCLUDED); + }); + + it(`${op}: an absent key satisfies it`, () => { + expect(ids(MISSING, filter)).toEqual(NO_VALUE_INCLUDED); + }); + + it(`${op}: both readings of "no value" answer alike`, () => { + expect(ids(MISSING, filter)).toEqual(ids(NULLED, filter)); + }); + } + }); + + describe('the operators still discriminate — this is not "match everything"', () => { + it('each negation excludes the row that DOES carry the comparand', () => { + expect(ids(NULLED, { name: { $ne: 'alpha-one' } })).not.toContain('1'); + expect(ids(NULLED, { name: { $nin: ['alpha-one'] } })).not.toContain('1'); + expect(ids(NULLED, { name: { $notContains: 'one' } })).not.toContain('1'); + }); + + it('the positive twins keep answering the complement over the VALUED rows', () => { + // A no-value row is in NEITHER answer for the positive operators: the + // ruling moved the negative cells only. + expect(ids(NULLED, { name: { $eq: 'alpha-one' } })).toEqual(['1']); + expect(ids(MISSING, { name: { $in: ['alpha-one'] } })).toEqual(['1']); + expect(ids(NULLED, { name: { $contains: 'one' } })).toEqual(['1']); + expect(ids(MISSING, { name: { $contains: 'one' } })).toEqual(['1']); + }); + + it('a present non-string value still fails $notContains on the type test', () => { + // Out of scope for this card, and stated so a later reader does not read + // the fix above as "any non-string satisfies the negation". Only the + // no-value readings moved; a value that is there and is not a string + // keeps the answer it had. + expect(ids([{ id: '9', name: 42 }], { name: { $notContains: 'one' } })).toEqual([]); + }); + }); + + describe('cross-operator agreement, the invariant that outlives the fixture', () => { + it('$nin answers exactly what $ne answers — it is the list form of it', () => { + // `formula`'s suite states the same identity over its own fixture. `$ne` + // is the operator ENROLLED in `FILTER_LOGIC_CASES`, so this is the link + // between the enrolled cell and the two that are not enrolled yet. + for (const rows of [NULLED, MISSING]) { + expect(ids(rows, { name: { $nin: ['alpha-one'] } })) + .toEqual(ids(rows, { name: { $ne: 'alpha-one' } })); + } + }); + + it('$contains and $notContains partition the VALUED rows and both keep the no-value row out of the positive side', () => { + for (const rows of [NULLED, MISSING]) { + const inside = ids(rows, { name: { $contains: 'one' } }); + const outside = ids(rows, { name: { $notContains: 'one' } }); + expect(inside).toEqual(['1']); + expect(outside).toEqual(NO_VALUE_INCLUDED); + expect(inside.filter((id) => outside.includes(id))).toEqual([]); + expect([...inside, ...outside].sort()).toEqual(['1', '2', '3']); + } + }); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts b/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts index e893037b9b..c1dbfe79c1 100644 --- a/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts +++ b/packages/drivers/driver-memory/src/memory-matcher-not-null-safe.test.ts @@ -128,32 +128,43 @@ describe('[#5146] memory-matcher — $not over records with no value', () => { }); }); - // ── The three #5299 cells — behaviour FROZEN, annotation current ─────────── + // ── The three #5299 cells — two CONVERGED (#13166), one still open ───────── /** - * [#5299, settled 2026-08-10] These three cells were filed as "known - * disagreements with `formula`, not ruled on by #5146". They are settled now, - * and the settled direction is INCLUDE: `$ne` / `$nin` / `$notContains` MATCH - * a no-value row (#5146, extended by #5298, shipped across eleven surfaces), - * and `$exists` means "has a value" (#5298 ③ / #5369, PR #5962). + * [#5299, settled 2026-08-10; the `$nin` / `$notContains` cells closed by + * #13166] These three cells were filed as "known disagreements with + * `formula`, not ruled on by #5146". They are settled, and the settled + * direction is INCLUDE: `$ne` / `$nin` / `$notContains` MATCH a no-value row + * (#5146, extended by #5298, shipped across eleven surfaces), and `$exists` + * means "has a value" (#5298 ③ / #5369, PR #5962). * * A ruling on 2026-08-10 07:33Z briefly went the other way — SQL's native * three-valued logic as the common denominator, negative operators never - * matching no-value rows — which would have made this matcher's answers below - * the canonical ones. Cells 1 and 3 of it were WITHDRAWN the same day, once - * the reversal's cross-backend cost had been measured, and include was - * re-affirmed. `$exists` = has-value is the leg that stands. + * matching no-value rows — which would have made this matcher's FORMER + * answers the canonical ones. Cells 1 and 3 of it were WITHDRAWN the same + * day, once the reversal's cross-backend cost had been measured, and include + * was re-affirmed. `$exists` = has-value is the leg that stands. * - * ⛔ Nothing below is flipped, and the reason is not inertia: this package is - * inside the #5499 investment freeze, and both rulings leave `checkCondition`'s - * early-exit guard exactly as it is. So this section is what it always was — a - * measured divergence from the platform answer, pinned rather than harmonised - * — with its wording brought current. + * ⚠️ The `$nin` and `$notContains` assertions below are INVERTED as of + * #13166, and what stood here before was the stated reason they had not been: + * "⛔ Nothing below is flipped, and the reason is not inertia: this package is + * inside the #5499 investment freeze, and both rulings leave + * `checkCondition`'s early-exit guard exactly as it is." That reason expired. + * The freeze dissolved on 2026-08-11 (head note of `@objectstack/spec`'s + * `aggregation-conformance.ts`), which left the divergence unexcused AND + * untracked — the DEBT ledger in `scripts/check-driver-conformance.mjs` never + * carried it, its granularity being per (driver × case-set). That is what + * #13166 was raised on, and it moved the guard rather than the ruling. * - * Re-measured on `60f0dd8`, because the old wording had gone stale in a way - * that mattered — it named `formula` as the key-presence reader on `$exists`, - * and `formula` stopped being that in PR #5962. Where each cell stands against - * the affirmed include direction: + * ⛔ These lines were NOT re-baselined to whatever the matcher started + * printing. They are inverted deliberately, ONTO the answer `formula` and all + * four SQL compilers already gave — which is this file's whole point: it is + * the reference `driver-sql` was aligned to, so an edit here has to be a + * decision. `sql-driver-not-null-safe.test.ts` is unchanged by #13166 and + * still asserts `['1']` for both filters below; the two files agree again + * because this one moved TO the SQL family, not the family to it. + * + * Where each cell stands against the affirmed include direction: * * `$exists` CONVERGED, and this matcher was already right. Both * evaluators read "has a value"; the formula-side assertion @@ -161,32 +172,40 @@ describe('[#5146] memory-matcher — $not over records with no value', () => { * because the two OTHER faces of this package — the live * mingo query path and the analytics face — still read * key-presence, so the package disagrees with itself and this - * test is the face that is correct. - * `$notContains` DIVERGES: a value-less field does not satisfy it here, - * while `formula` and all four SQL compilers say it does — - * deliberately, via #5298's `nullSafeNegative`. This matcher - * is the odd one out among the eleven surfaces, and frozen. - * `$nin` HALF converged: a present-but-null value already matches, - * as the affirmed direction requires; a MISSING key still - * does not, because the early-exit guard does not exempt - * `$nin`. Frozen at this state. + * test is the face that is correct. ⛔ That remaining gap is + * the neighbouring cell (#13195), not this one: it has a + * different backend list and was deliberately left alone. + * `$notContains` CONVERGED by #13166. The arm's `typeof value !== 'string'` + * test rejected a `null` on its TYPE rather than on the + * predicate, so a value-less field failed a negation it + * should satisfy. The arm now answers the no-value readings + * from `noValueSatisfiesNegation` first. + * `$nin` CONVERGED by #13166. A present-but-null value already + * matched; a MISSING key did not, because the early-exit + * guard's allowlist named `$ne` and not `$nin`. A SECOND and + * independent cause from the one above, reachable only from + * the other reading of "no value" — which is why the fixture + * carries both columns and why one cause could be fixed + * while the other stood. */ - describe('[#5299] the settled no-value cells — divergence measured, behaviour frozen (#5499)', () => { - it('$nin: an ABSENT field is treated differently from a null one', () => { - // The early `value === undefined` guard in `checkCondition` exempts only - // `$exists` / `$ne` / `$null`, so an absent field fails `$nin` outright - // while a null field passes it. The guard stays; the MISSING half is the - // part still short of the affirmed include answer, and it is frozen. + describe('[#5299] the settled no-value cells — $nin / $notContains converged (#13166)', () => { + it('$nin: an ABSENT field and a null one are now treated ALIKE', () => { + // Was: NULLED answered `['1']` and MISSING answered `['1', '3', '4']`. + // The divergence was never in the null column — it was the guard turning + // an absent key into "no match" before the `$nin` arm ran. Both columns + // are asserted separately rather than only through `matched()`, because + // the whole content of this cell is that the two readings agree. expect(ids(NULLED, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); - expect(ids(MISSING, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1', '3', '4']); + expect(ids(MISSING, { $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); + expect(matched({ $not: { stage: { $nin: ['won'] } } })).toEqual(['1']); }); - it('$notContains: a value-less field does NOT satisfy it here — DIVERGENT', () => { - // `typeof null !== 'string'` → false, so the negation matches. The - // affirmed direction is the opposite: `formula` and all four SQL compilers - // say a value-less field DOES satisfy `$notContains`, and #5299 re-affirmed - // that on 2026-08-10. This matcher stays where it is under #5499. - expect(matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1', '3', '4']); + it('$notContains: a value-less field DOES satisfy it — converged', () => { + // Was `['1', '3', '4']`: `typeof null !== 'string'` answered false, so the + // negation readmitted the value-less rows. `formula` and all four SQL + // compilers answer `['1']` — this matcher was the odd one out among the + // eleven surfaces, and is not any more. + expect(matched({ $not: { stage: { $notContains: 'w' } } })).toEqual(['1']); }); it('$exists: a present-but-null field counts as NOT existing here — CONVERGED', () => { diff --git a/packages/drivers/driver-memory/src/memory-matcher.ts b/packages/drivers/driver-memory/src/memory-matcher.ts index a4952cfd0f..aa5e508712 100644 --- a/packages/drivers/driver-memory/src/memory-matcher.ts +++ b/packages/drivers/driver-memory/src/memory-matcher.ts @@ -128,6 +128,40 @@ export function getValueByPath(obj: any, path: string): any { return path.split('.').reduce((o, i) => (o ? o[i] : undefined), obj); } +/** + * [#13166] Does a row whose field has NO VALUE satisfy this operator? + * + * The platform's settled answer for the negation-carrying operators is YES — + * the INCLUDE direction. #5146 made `$not` NULL-safe and ruled THIS matcher's + * answer canonical (`driver-sql` was rewritten to match it, not the other way + * round); #5298 option A extended the same answer to the non-negated `$ne` / + * `$nin` / `$notContains`. A ruling on 2026-08-10 briefly reversed it in favour + * of SQL's native three-valued logic and was WITHDRAWN the same day, once the + * cross-backend cost had been priced; include was re-affirmed. + * + * Ten other surfaces already answer this way, which is why aligning this one + * moves nothing on the SQL side: the four SQL compilers reach it through + * `nullSafeNegative` and their four copies of `nullValueSatisfiesOperator` + * (`$nin` → true, `$notContains` → true), `formula`'s `matchesFilterCondition` + * answers it directly, and `driver-mongodb` passes `$nin` through and compiles + * `$notContains` to `{ $not: { $regex } }` — both of which match a missing or + * null field in MongoDB. + * + * ⚠️ `$exists` is deliberately NOT here. It is not tolerant of an absent value, + * it is a question ABOUT one, and its own cell is a different, still-open + * divergence (#13195): this package's live mingo path and `driver-mongodb` read + * it as key-presence where this matcher and `formula` read it as has-value. + * `$null` is excluded for the same reason. + * + * Named rather than inlined because BOTH causes of the #13166 divergence have + * to answer the same question, and they sit in different places — a + * pre-switch guard that only sees `undefined`, and the `$notContains` arm that + * only sees `null`. Two spellings of one ruling is how they came apart. + */ +function noValueSatisfiesNegation(op: string): boolean { + return op === '$ne' || op === '$nin' || op === '$notContains'; +} + /** * Evaluate a specific condition against a value */ @@ -165,7 +199,15 @@ function checkCondition(value: any, condition: any): boolean { const target = condition[op]; // Handle undefined values - if (value === undefined && op !== '$exists' && op !== '$ne' && op !== '$null') { + // + // [#13166] The allowlist is {@link noValueSatisfiesNegation} plus the + // two operators that are ABOUT the absence rather than tolerant of it + // (`$exists` / `$null`), and it used to name `$ne` alone out of the + // three negative ones. That is why a MISSING key short-circuited to "no + // match" for `$nin` and `$notContains` before their arms ever ran — one + // of the two independent causes of the divergence #13166 measured, and + // the only one this guard can reach. + if (value === undefined && op !== '$exists' && op !== '$null' && !noValueSatisfiesNegation(op)) { return false; } @@ -219,6 +261,20 @@ function checkCondition(value: any, condition: any): boolean { if (typeof value !== 'string' || !value.includes(target)) return false; break; case '$notContains': + // [#13166] A row with NO VALUE satisfies this — the second and + // INDEPENDENT cause of the same divergence. The guard above + // cannot reach this one: it only fires for `undefined`, so a + // field that is present and `null` arrived here and failed on + // `typeof value !== 'string'` — the TYPE test, not the + // predicate. Answering it from {@link noValueSatisfiesNegation} + // states which question is being answered. + // + // A present, non-string value keeps the answer it had: the + // ruling moved the no-value cells, and only those. + if (value == null) { + if (noValueSatisfiesNegation(op)) break; + return false; + } if (typeof value !== 'string' || value.includes(target)) return false; break; case '$startsWith': diff --git a/packages/objectql/src/having-filter.ts b/packages/objectql/src/having-filter.ts index afd1278a07..f1c295222a 100644 --- a/packages/objectql/src/having-filter.ts +++ b/packages/objectql/src/having-filter.ts @@ -37,9 +37,27 @@ // the same vocabulary and was not in that PR's inventory, which left this // file as the lone holdout (#5905) — and the only face no conformance table // covers, since `FILTER_LOGIC_CASES` does not drive the HAVING path. -// driver-memory / driver-mongodb still answer the old way only because -// #5499 freezes them; the divergence is against a frozen face, not against -// the ruling. +// ⚠️ [#13166] This paragraph used to end "driver-memory / driver-mongodb +// still answer the old way only because #5499 freezes them; the divergence +// is against a frozen face, not against the ruling." That sentence was wrong +// TWICE, and #13166 settled both halves rather than re-tensing them — a +// tense-only rewrite would have turned an actionable defect into +// settled-looking prose. +// (a) The #5499 freeze DISSOLVED on 2026-08-11 (head note of +// `@objectstack/spec`'s `aggregation-conformance.ts`). From that date +// it excused nothing, and the divergence was neither excused nor +// tracked — the DEBT ledger in `scripts/check-driver-conformance.mjs` +// is per (driver × case-set) and never carried it. +// (b) `driver-mongodb` was never part of this divergence for THIS operator +// family. `translateFieldOperators` passes `$nin` straight through and +// compiles `$notContains` to `{ $not: { $regex } }`; both match a +// missing or null field in MongoDB, so it has always answered the way +// the ruling requires. +// The one real holdout was `driver-memory`'s REFERENCE matcher — its own +// live mingo path already agreed — and #13166 aligned it. So there is no +// frozen face left for this file to be divergent against: every evaluation +// face of the vocabulary now gives the answer this section states, and this +// one is held to it by agreement rather than by exemption. // // [#7158] A THIRD divergence has been REMOVED rather than added: this face had // no comparand-shape gate, which is what the five sibling faces refuse an diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index bf7c24ce54..2c6c3de660 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -178,11 +178,27 @@ import { * deliberately left alone — deciding it here would settle #5299's * key-missing-vs-value-null question as a side effect — and `driver-memory` / * `driver-mongodb` were pin-only under the #5499 freeze when #6125 scoped this, - * so this compiler and those two answer the cell differently on purpose — a debt - * #6125 recorded as owed AT THAW. The thaw has arrived: the freeze dissolved + * so this compiler and those two answered the cell differently — a debt #6125 + * recorded as owed AT THAW. The thaw has arrived: the freeze dissolved * 2026-08-11 (head note of `@objectstack/spec`'s `aggregation-conformance.ts`), - * so that debt is now DUE rather than deferred, and nothing has been triaged - * against it yet. + * so that debt became DUE rather than deferred. + * + * ⚠️ [#13166] That debt has since been triaged, and it was SMALLER than "those + * two" makes it sound, because the clause was also wrong on its second half — + * which is why #13166 settled the wording together with the divergence instead + * of re-tensing it. A tense-only rewrite would have carried the false half + * forward as settled-looking prose. + * (a) `driver-mongodb` was never a holdout for the negation-carrying operator + * family. `translateFieldOperators` passes `$nin` straight through and + * compiles `$notContains` to `{ $not: { $regex } }`, and both match a + * missing or null field — so it has always answered as this compiler does + * through {@link nullValueSatisfiesOperator}. + * (b) `driver-memory` was the one real holdout, and only on its REFERENCE + * matcher; its live mingo query path already agreed. #13166 aligned that + * matcher, so on the null SEMANTICS cell nothing answers differently now. + * ⛔ That does NOT discharge the whole debt. This section is about the + * `undefined`-COMPARAND question, a DIFFERENT cell from the null-semantics one, + * and it remains open on both drivers. Do not read #13166 as having closed it. * * The eleventh message was measured against `looksLikeInternalErrorLeak` before * being added, because the section above turns on that predicate answering FALSE