From c8d1de14b37d63922cb035fe00d4a72e82e00a8a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 14:05:13 +0000 Subject: [PATCH 1/3] fix(core)!: retire the four lowercase $-dialect aliases (objectui#8568) `convertOperatorToAST`'s `operatorMap` carried `$notin`, `$notcontains`, `$startswith` and `$endswith` "for tolerance" while `ValueDataSource` refused the same four by design (objectui#8447: alias arms there "would fossilise a second dialect"). One authored filter therefore had two fates depending on which data source was behind the view. The maintainer ruled the tolerant side out: the `$` dialect follows `@objectstack/spec`'s spellings, with no deprecation window, and the documentation follows the implementation. The four are refused BY NAME and the refusal names the canonical spelling, so the repair is a key rename rather than a diff of two operator lists. A generic unknown operator still gets the generic message; that asymmetry is pinned. `packages/data-objectstack/README.md`'s tables and PR #8565's reconciliation pin follow the code: the pin reddened on exactly the four rows that had to change, which is the signal it was built to give. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- ...retire-dollar-dialect-lowercase-aliases.md | 45 +++ .../filter-alias-retirement-8568.test.ts | 261 ++++++++++++++++++ .../filter-array-comparand-8530.test.ts | 20 +- .../utils/__tests__/filter-converter.test.ts | 16 +- packages/core/src/utils/filter-converter.ts | 57 +++- packages/data-objectstack/README.md | 18 +- .../src/readme-filter-operator-table.test.ts | 25 +- 7 files changed, 412 insertions(+), 30 deletions(-) create mode 100644 .changeset/8568-retire-dollar-dialect-lowercase-aliases.md create mode 100644 packages/core/src/utils/__tests__/filter-alias-retirement-8568.test.ts diff --git a/.changeset/8568-retire-dollar-dialect-lowercase-aliases.md b/.changeset/8568-retire-dollar-dialect-lowercase-aliases.md new file mode 100644 index 0000000000..740fe2317f --- /dev/null +++ b/.changeset/8568-retire-dollar-dialect-lowercase-aliases.md @@ -0,0 +1,45 @@ +--- +'@object-ui/core': minor +--- + +`convertFiltersToAST` follows `@objectstack/spec`'s `$`-dialect spellings and nothing +else: the four lowercase aliases `$notin`, `$notcontains`, `$startswith` and `$endswith` +are retired from `convertOperatorToAST`'s `operatorMap` (objectui#8568). + +**BREAKING for anyone spelling those four in lowercase, and there is no deprecation +window.** A filter that carries one used to lower silently — `{ email: { $startswith: +'a' } }` became `['email', 'startswith', 'a']` — and now throws a `FilterOperatorError` +(`code: 'INVALID_FILTER'`, `httpStatus: 400`) at the call site. This repo forbids a +`major`, so the break ships as a `minor` and is spelled out here instead. The repair is +a key rename: `$notin` to `$nin`, `$notcontains` to `$notContains`, `$startswith` to +`$startsWith`, `$endswith` to `$endsWith`. The operator itself is unchanged, the lowered +node is unchanged, and no result set moves for a filter that was already spelled +canonically. + +**Why the tolerance had to go.** `ValueDataSource` refuses these same four by design — +objectui#8447 declined to grow alias arms there because they "would fossilise a second +dialect" — so one authored filter had two fates depending on which data source was +behind the view: rows through the ObjectStack adapter, nothing through the in-memory +matcher. One dialect with two acceptance sets is the second de-facto contract AGENTS.md +commandment 0.1 exists to refuse, and the decision had only ever reached one of the two +files. `ValueDataSource` is untouched by this change; the converter is the side that +moved. + +**The refusal names the canonical spelling for the alias you wrote** rather than +printing the generic "unknown operator, here are the supported ones". With no +deprecation window that message is the whole migration aid, so it is pinned as a +property, not left as a nicety. + +**Measured before landing, and it bounds the blast radius from the inside.** The in-repo +authored corpus (examples, docs, apps, e2e, fixtures) carries **zero** occurrences of the +four aliases in operator-key position — every tree-wide hit is the map that defined them +or something pointing at it — so no in-repo caller had to be repaired. That zero is +consumer-local, not seam-wide (objectui#6839): stored view / list / sharing-rule +criteria, producer-side metadata and published consumers of `@object-ui/core` are all +invisible from here. What is measurable about that population is that it is already half +broken: `kvToCondition`, the reader that loads stored `$`-criteria back into the filter +builder, has arms for fifteen spellings and none of these four, so a stored lowercase +criterion already failed to round-trip and dropped the admin into the raw-JSON editor. + +`packages/data-objectstack/README.md`'s operator tables follow the implementation, as +does the reconciliation test that holds them to it. diff --git a/packages/core/src/utils/__tests__/filter-alias-retirement-8568.test.ts b/packages/core/src/utils/__tests__/filter-alias-retirement-8568.test.ts new file mode 100644 index 0000000000..d0262ab1b0 --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-alias-retirement-8568.test.ts @@ -0,0 +1,261 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8568 — the `$` dialect has ONE acceptance set, and it is the spec's. + * + * ## What was wrong + * + * `convertOperatorToAST`'s `operatorMap` carried four lowercase aliases + * (`$notin`, `$notcontains`, `$startswith`, `$endswith`) "for tolerance", while + * the in-memory matcher `ValueDataSource` refused the same four by design + * (objectui#8447, whose changeset states the reason: they "would fossilise a + * second dialect"). So `{ email: { $startswith: 'a' } }` selected rows through + * the ObjectStack adapter and NOTHING through `ValueDataSource` — one authored + * filter, two fates, decided by which data source happened to be behind the + * view. That is the second de-facto contract AGENTS.md #0.1 exists to refuse. + * + * The maintainer ruled the tolerant side out (2026-09-10): the project follows + * the ObjectStack protocol, and the documentation follows the implementation. + * No deprecation window (2026-08-27). + * + * ## What this file holds, and why each half is here + * + * - **the aliases are gone** — refused, and `convertOperatorToAST` answers + * `null` for them; + * - **the refusal names the canonical spelling.** A refusal that only said + * "unknown operator, here is the supported list" would leave the author + * diffing two lists to find which entry they meant. With no deprecation + * window the message IS the migration aid, so "names `$startsWith`" is a + * pinned property, not a nicety. A live control below asserts a genuinely + * unknown operator still gets the GENERIC message, so "named" is a real + * distinction rather than every path printing the same paragraph; + * - **the prescriptions are DERIVED, never restated.** The alias table is + * read out of `filter-converter.ts`'s source (it is not exported) and both + * directions are executed against `@objectstack/spec`: every KEY must be + * absent from `FILTER_OPERATORS`, every VALUE present in it. A fifth alias + * added later, or a prescription naming a spelling the spec does not + * declare, fails here — the map cannot quietly become a lowering table; + * - **a spec-derived invariant that outlives this card**: for EVERY camelCase + * member of `FILTER_OPERATORS`, the all-lowercase spelling of it is not + * accepted. That is the contract-first rule itself, read off the spec + * rather than off the four names this card happened to retire; + * - **the reconciliation** — both consumers are exercised in one place, since + * "the two disagree" was the defect. `ValueDataSource` is unchanged by this + * card (objectui#8447's direction stands); it is here as the other half of + * the agreement. + * + * Refusals assert the `INVALID_FILTER` / 400 envelope, not a bare `toThrow()`: + * a driver that threw a plain `Error` would satisfy `toThrow` and still render + * "check your connection" instead of "the filter is malformed". + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { FILTER_OPERATORS } from '@objectstack/spec/data'; +import { convertFiltersToAST, convertOperatorToAST } from '../filter-converter'; +import { ValueDataSource } from '../../adapters/ValueDataSource'; + +const CONVERTER_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'filter-converter.ts'); +const CONVERTER_SOURCE = readFileSync(CONVERTER_PATH, 'utf8'); + +/** + * `RETIRED_OPERATOR_ALIASES`, read out of the source: it is module-local and + * deliberately not exported (nothing outside the refusal has any business + * reading a retired spelling). Anchored on the declaration and slice-terminated + * at its closing brace, and a control below proves the read found rows, so a + * moved or renamed table fails loudly instead of reading empty and passing. + */ +function retiredAliasesFromSource(): Map { + const start = CONVERTER_SOURCE.indexOf('const RETIRED_OPERATOR_ALIASES'); + const end = start === -1 ? -1 : CONVERTER_SOURCE.indexOf('};', start); + if (start === -1 || end === -1) { + throw new Error( + 'filter-converter.ts no longer declares `const RETIRED_OPERATOR_ALIASES` ' + + '(objectui#8568): re-point this reader at wherever the retired spellings now live, ' + + 'and do not delete it — the derivation below is what stops the table from ' + + 'prescribing a spelling @objectstack/spec does not declare', + ); + } + const table = new Map(); + for (const match of CONVERTER_SOURCE.slice(start, end).matchAll(/'(\$[A-Za-z]+)':\s*'(\$[A-Za-z]+)'/g)) { + table.set(match[1], match[2]); + } + return table; +} + +const RETIRED = retiredAliasesFromSource(); + +/** The refusal an author sees for `spelling` in operator position. */ +function refusalFor(spelling: string): { code?: unknown; httpStatus?: unknown; message: string } { + try { + const node = convertFiltersToAST({ email: { [spelling]: 'a' } }); + throw new Error( + `${spelling} lowered to ${JSON.stringify(node)} instead of being refused`, + ); + } catch (error) { + const thrown = error as { code?: unknown; httpStatus?: unknown; message?: unknown }; + if (thrown.code !== 'INVALID_FILTER') { + throw error; + } + return { code: thrown.code, httpStatus: thrown.httpStatus, message: String(thrown.message) }; + } +} + +const ROWS = [ + { id: 'a', role: 'admin' }, + { id: 'b', role: 'user' }, + { id: 'c', role: 'admin' }, +]; + +async function selectedIds(filter: unknown): Promise { + const ds = new ValueDataSource({ items: ROWS }); + const result = await ds.find('rows', { $filter: filter as any }); + return result.data.map((r) => r.id as string); +} + +function spyWarn() { + return vi.spyOn(console, 'warn').mockImplementation(() => {}); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// 0. Controls — the instrument is connected to both populations +// --------------------------------------------------------------------------- + +describe('objectui#8568 — controls', () => { + it('read the retired-alias table out of the source, and it has rows', () => { + expect(RETIRED.size).toBeGreaterThanOrEqual(4); + expect(RETIRED.get('$startswith')).toBe('$startsWith'); + }); + + it('the spec module is the live one, not an empty import', () => { + expect(FILTER_OPERATORS).toContain('$startsWith'); + expect(FILTER_OPERATORS).toContain('$nin'); + expect(FILTER_OPERATORS.length).toBeGreaterThanOrEqual(10); + }); + + it('a canonical spelling still lowers, so a green refusal below is not "everything throws"', () => { + expect(convertFiltersToAST({ email: { $startsWith: 'a' } })).toEqual(['email', 'startswith', 'a']); + expect(convertFiltersToAST({ status: { $nin: ['archived'] } })).toEqual(['status', 'nin', ['archived']]); + expect(convertFiltersToAST({ name: { $notContains: 'x' } })).toEqual(['name', 'notcontains', 'x']); + expect(convertFiltersToAST({ email: { $endsWith: 'z' } })).toEqual(['email', 'endswith', 'z']); + }); +}); + +// --------------------------------------------------------------------------- +// 1. The prescriptions are the spec's, in both directions +// --------------------------------------------------------------------------- + +describe('objectui#8568 — the retired table is derived from @objectstack/spec', () => { + it('every retired KEY is absent from FILTER_OPERATORS', () => { + const declared = [...RETIRED.keys()].filter((alias) => (FILTER_OPERATORS as readonly string[]).includes(alias)); + expect( + declared, + 'a spelling the spec declares is not an alias to retire — it is an operator to support', + ).toEqual([]); + }); + + it('every prescribed VALUE is a member of FILTER_OPERATORS', () => { + const invented = [...RETIRED.values()].filter((canonical) => !(FILTER_OPERATORS as readonly string[]).includes(canonical)); + expect( + invented, + 'the refusal would be telling an author to write a spelling @objectstack/spec does not declare', + ).toEqual([]); + }); + + it('every prescribed VALUE is one this converter actually lowers', () => { + const dead = [...RETIRED.values()].filter((canonical) => convertOperatorToAST(canonical) === null); + expect(dead, 'the prescription must name a spelling that works here, not just one the spec lists').toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// 2. The refusal, and the fact that it is a NAMED one +// --------------------------------------------------------------------------- + +describe('objectui#8568 — the four lowercase aliases are refused by name', () => { + it.each([...RETIRED])('`%s` is refused with the INVALID_FILTER / 400 envelope', (alias) => { + const refusal = refusalFor(alias); + expect(refusal).toMatchObject({ code: 'INVALID_FILTER', httpStatus: 400 }); + }); + + it.each([...RETIRED])('`%s` names `%s` in the refusal', (alias, canonical) => { + expect(refusalFor(alias).message).toContain(canonical); + }); + + it.each([...RETIRED])('`%s` does not fall through to the generic unknown-operator message', (alias) => { + // The whole value of the named arm is that it is NOT this paragraph. + expect(refusalFor(alias).message).not.toContain('Unknown filter operator'); + }); + + it.each([...RETIRED.keys()])('`convertOperatorToAST` answers null for `%s`', (alias) => { + expect(convertOperatorToAST(alias)).toBe(null); + }); + + it('a genuinely unknown operator still gets the GENERIC message', () => { + // The discriminating control for the three cases above: if every refusal + // printed the same paragraph, "named" would be an empty claim. + const refusal = refusalFor('$definitelyNotAnOperator'); + expect(refusal).toMatchObject({ code: 'INVALID_FILTER', httpStatus: 400 }); + expect(refusal.message).toContain('Unknown filter operator'); + expect(refusal.message).toContain('Supported operators'); + }); +}); + +// --------------------------------------------------------------------------- +// 3. The invariant behind the card, read off the spec rather than off the four +// --------------------------------------------------------------------------- + +describe('objectui#8568 — no camelCase spec operator has a lowercase second spelling', () => { + const CAMEL_CASE_OPERATORS = (FILTER_OPERATORS as readonly string[]) + .filter((operator) => operator !== operator.toLowerCase()); + + it('the derived population is non-empty, so the case below can fail', () => { + // `$icontains` is already all-lowercase and is correctly NOT in this set. + expect(CAMEL_CASE_OPERATORS.length).toBeGreaterThanOrEqual(3); + expect(CAMEL_CASE_OPERATORS).toContain('$startsWith'); + expect(CAMEL_CASE_OPERATORS).not.toContain('$icontains'); + }); + + it('the all-lowercase spelling of each is not accepted', () => { + const accepted = CAMEL_CASE_OPERATORS + .map((operator) => operator.toLowerCase()) + .filter((lowered) => convertOperatorToAST(lowered) !== null); + expect( + accepted, + 'a lowercase second spelling is a second dialect (AGENTS.md #0.1) — refuse it and name the spec spelling', + ).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Reconciliation — the two consumers that used to disagree now agree +// --------------------------------------------------------------------------- + +describe('objectui#8568 — one dialect across both data sources', () => { + it('the matcher still selects a non-empty proper subset for the canonical spelling', async () => { + // The control that makes the empty answers below mean something: a matcher + // that refused everything would also return [] for the aliases. + const warn = spyWarn(); + expect(await selectedIds({ role: { $startsWith: 'adm' } })).toEqual(['a', 'c']); + expect(warn).not.toHaveBeenCalled(); + }); + + it.each([...RETIRED.keys()])('`%s` is refused by BOTH the converter and the matcher', async (alias) => { + expect(refusalFor(alias)).toMatchObject({ code: 'INVALID_FILTER', httpStatus: 400 }); + const warn = spyWarn(); + expect(await selectedIds({ role: { [alias]: 'adm' } })).toEqual([]); + expect(warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/utils/__tests__/filter-array-comparand-8530.test.ts b/packages/core/src/utils/__tests__/filter-array-comparand-8530.test.ts index 8159842364..c9245944f8 100644 --- a/packages/core/src/utils/__tests__/filter-array-comparand-8530.test.ts +++ b/packages/core/src/utils/__tests__/filter-array-comparand-8530.test.ts @@ -183,14 +183,30 @@ describe('objectui#8530 — legitimate array positions are untouched', () => { expect(parseFilterAST(node)).toEqual({ status: { $in: ['active', 'pending'] } }); }); - it('$nin (and its $notin alias) still lower to `nin`', () => { + it('$nin still lowers to `nin`', () => { expect(convertFiltersToAST({ status: { $nin: ['archived'] } })) .toEqual(['status', 'nin', ['archived']]); - expect(convertFiltersToAST({ status: { $notin: ['archived', 'deleted'] } })) + expect(convertFiltersToAST({ status: { $nin: ['archived', 'deleted'] } })) .toEqual(['status', 'nin', ['archived', 'deleted']]); expect(parseFilterAST(['status', 'nin', ['archived']])).toEqual({ status: { $nin: ['archived'] } }); }); + // The `$notin` alias this case used to carry alongside `$nin` was RETIRED by + // objectui#8568 — it is now refused by name. Asserted here so the retirement + // is visible from the array-comparand axis too: a refusal is not the silent + // "condition dropped" this file exists to rule out, and the array member + // survives into the message rather than into a widened result set. + it('the retired $notin alias is refused, not lowered (objectui#8568)', () => { + expect(() => convertFiltersToAST({ status: { $notin: ['archived', 'deleted'] } })) + .toThrow(/\$nin/); + try { + convertFiltersToAST({ status: { $notin: ['archived'] } }); + throw new Error('expected a refusal'); + } catch (error) { + expect(error).toMatchObject({ code: 'INVALID_FILTER', httpStatus: 400 }); + } + }); + it('$between still lowers with its [min, max] pair', () => { const node = convertFiltersToAST({ age: { $between: [18, 65] } }); expect(node).toEqual(['age', 'between', [18, 65]]); diff --git a/packages/core/src/utils/__tests__/filter-converter.test.ts b/packages/core/src/utils/__tests__/filter-converter.test.ts index f66cedef9d..8134e949bb 100644 --- a/packages/core/src/utils/__tests__/filter-converter.test.ts +++ b/packages/core/src/utils/__tests__/filter-converter.test.ts @@ -20,9 +20,10 @@ describe('Filter Converter Utilities', () => { expect(convertOperatorToAST('$lte')).toBe('<='); expect(convertOperatorToAST('$in')).toBe('in'); expect(convertOperatorToAST('$nin')).toBe('nin'); - expect(convertOperatorToAST('$notin')).toBe('nin'); expect(convertOperatorToAST('$contains')).toBe('contains'); - expect(convertOperatorToAST('$startswith')).toBe('startswith'); + expect(convertOperatorToAST('$notContains')).toBe('notcontains'); + expect(convertOperatorToAST('$startsWith')).toBe('startswith'); + expect(convertOperatorToAST('$endsWith')).toBe('endswith'); expect(convertOperatorToAST('$between')).toBe('between'); }); @@ -30,6 +31,17 @@ describe('Filter Converter Utilities', () => { expect(convertOperatorToAST('$unknown')).toBe(null); expect(convertOperatorToAST('$exists')).toBe(null); }); + + // objectui#8568 retired the four lowercase aliases. They are answered by + // name one layer up, in `convertFiltersToAST` — this function has no error + // channel, so `null` is all it can say. The named refusal and the spec + // derivation behind it are pinned in filter-alias-retirement-8568.test.ts. + it('should return null for the retired lowercase aliases (objectui#8568)', () => { + expect(convertOperatorToAST('$notin')).toBe(null); + expect(convertOperatorToAST('$notcontains')).toBe(null); + expect(convertOperatorToAST('$startswith')).toBe(null); + expect(convertOperatorToAST('$endswith')).toBe(null); + }); }); describe('convertFiltersToAST', () => { diff --git a/packages/core/src/utils/filter-converter.ts b/packages/core/src/utils/filter-converter.ts index 5ba58fb752..21e1707478 100644 --- a/packages/core/src/utils/filter-converter.ts +++ b/packages/core/src/utils/filter-converter.ts @@ -59,10 +59,45 @@ export class FilterOperatorError extends Error { } } +/** + * The four lowercase `$`-dialect spellings this file used to accept "for + * tolerance", and the canonical spelling each author meant. + * + * RETIRED by objectui#8568, on the maintainer's ruling that this project follows + * the ObjectStack protocol and its documentation follows the implementation. + * They were a renderer-side second dialect — exactly what AGENTS.md #0.1 + * refuses, and the same ground objectui#8447 stood on when it declined to teach + * the in-memory matcher the same arms ("would fossilise a second dialect"). + * Until now `convertFiltersToAST` accepted them while `ValueDataSource` refused + * them, so ONE authored filter had two fates depending on which data source was + * behind the view. One dialect, one spelling. + * + * Refused BY NAME rather than dropped into the unknown-operator arm below, + * because the two answers cost an author very different amounts: "unknown + * operator, here is the supported list" makes them diff two lists and guess + * which entry they meant, while "write `$startsWith`" IS the repair. There is no + * deprecation window (maintainer, 2026-08-27), so this message is the entire + * migration aid — the reason it is worth spelling out. + * + * ⛔ Not a lowering table in disguise: nothing reads the value as an operator. + * Every VALUE is a member of the spec's `FILTER_OPERATORS` and every KEY is + * absent from it, and both directions are EXECUTED against + * `@objectstack/spec` in `filter-alias-retirement-8568.test.ts` rather than + * restated here, so this map cannot drift into prescribing a spelling the spec + * does not declare. + */ +const RETIRED_OPERATOR_ALIASES: Record = { + '$notin': '$nin', + '$notcontains': '$notContains', + '$startswith': '$startsWith', + '$endswith': '$endsWith', +}; + export function convertOperatorToAST(operator: string): string | null { // Spec reference: framework/packages/spec/src/data/filter.zod.ts - // Canonical MongoDB-style keys are camelCase ($startsWith, $endsWith, $notContains). - // Lowercase aliases are accepted for tolerance. + // Every key below is a canonical `FILTER_OPERATORS` spelling and nothing else. + // The four lowercase aliases that used to sit here are retired and refused by + // name (objectui#8568) — see RETIRED_OPERATOR_ALIASES above. const operatorMap: Record = { '$eq': '=', '$ne': '!=', @@ -72,15 +107,11 @@ export function convertOperatorToAST(operator: string): string | null { '$lte': '<=', '$in': 'in', '$nin': 'nin', - '$notin': 'nin', '$between': 'between', '$contains': 'contains', '$notContains': 'notcontains', - '$notcontains': 'notcontains', '$startsWith': 'startswith', - '$startswith': 'startswith', '$endsWith': 'endswith', - '$endswith': 'endswith', }; return operatorMap[operator] || null; @@ -516,6 +547,20 @@ export function convertFiltersToAST(filter: Record): FilterNode | R if (astOperator) { conditions.push([field, astOperator, operatorValue]); } else { + // A RETIRED lowercase alias is answered by name, before the generic + // arm below can swallow it into "unknown operator" (objectui#8568). + const canonical = RETIRED_OPERATOR_ALIASES[operator]; + if (canonical) { + throw new FilterOperatorError( + `[ObjectUI] The '${operator}' filter operator is retired. Write ` + + `'${canonical}' instead — the canonical spelling '@objectstack/spec' declares ` + + `in FILTER_OPERATORS (data/filter.zod.ts). The operator itself is unchanged; ` + + `only the key is renamed. Field: '${field}', Value: ${JSON.stringify(operatorValue)}. ` + + `It used to be accepted here as a lowercase alias while the in-memory matcher ` + + `refused it, so the same filter selected rows through one data source and none ` + + `through the other (objectui#8568).` + ); + } // Unknown operator - throw error to avoid silent failure throw new FilterOperatorError( `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` + diff --git a/packages/data-objectstack/README.md b/packages/data-objectstack/README.md index 32c63052ee..866f0a16db 100644 --- a/packages/data-objectstack/README.md +++ b/packages/data-objectstack/README.md @@ -125,10 +125,11 @@ AST format**. This is what keeps it compatible with the ObjectStack Protocol Every row below is decided by `@object-ui/core`'s `convertFiltersToAST`, and this package's `src/readme-filter-operator-table.test.ts` runs each worked example through it on every test run, so a row cannot drift from the code -unnoticed again (objectui#8558). Where a row lists two spellings, the camelCase -one is the spec's (`FILTER_OPERATORS` in `@objectstack/spec`'s -`data/filter.zod.ts`) and the lowercase one is an alias the converter also -accepts; both lower to the same node. +unnoticed again (objectui#8558). Every spelling below is the spec's own +(`FILTER_OPERATORS` in `@objectstack/spec`'s `data/filter.zod.ts`) and there are +no aliases: the four lowercase spellings this table used to list beside the +camelCase keys — `$notin`, `$notcontains`, `$startswith`, `$endswith` — were +retired by objectui#8568 and moved to the refused table below. | MongoDB Operator | ObjectStack Operator | Example | |------------------|---------------------|---------| @@ -140,12 +141,12 @@ accepts; both lower to the same node. | `$lt` | `<` | `{ age: { $lt: 65 } }` → `['age', '<', 65]` | | `$lte` | `<=` | `{ age: { $lte: 65 } }` → `['age', '<=', 65]` | | `$in` | `in` | `{ status: { $in: ['active', 'pending'] } }` → `['status', 'in', ['active', 'pending']]` | -| `$nin` / `$notin` | `nin` | `{ status: { $nin: ['archived'] } }` → `['status', 'nin', ['archived']]` | +| `$nin` | `nin` | `{ status: { $nin: ['archived'] } }` → `['status', 'nin', ['archived']]` | | `$between` | `between` | `{ age: { $between: [18, 65] } }` → `['age', 'between', [18, 65]]` | | `$contains` | `contains` | `{ name: { $contains: 'John' } }` → `['name', 'contains', 'John']` | -| `$notContains` / `$notcontains` | `notcontains` | `{ name: { $notContains: 'test' } }` → `['name', 'notcontains', 'test']` | -| `$startsWith` / `$startswith` | `startswith` | `{ email: { $startsWith: 'admin' } }` → `['email', 'startswith', 'admin']` | -| `$endsWith` / `$endswith` | `endswith` | `{ email: { $endsWith: '@example.com' } }` → `['email', 'endswith', '@example.com']` | +| `$notContains` | `notcontains` | `{ name: { $notContains: 'test' } }` → `['name', 'notcontains', 'test']` | +| `$startsWith` | `startswith` | `{ email: { $startsWith: 'admin' } }` → `['email', 'startswith', 'admin']` | +| `$endsWith` | `endswith` | `{ email: { $endsWith: '@example.com' } }` → `['email', 'endswith', '@example.com']` | | `$null` | `is_null` / `is_not_null` | `{ email: { $null: true } }` → `['email', 'is_null', true]` | | `$exists` | `is_not_null` / `is_null` | `{ email: { $exists: true } }` → `['email', 'is_not_null', true]` | @@ -180,6 +181,7 @@ the call site rather than as a `400` from the server or as an empty list. | `$regex` | The spec has no `$regex`, and it is not downgraded to `contains`: a pattern match and a substring match are different questions, not stronger and weaker forms of one. Use `$contains`, `$startsWith` or `$endsWith`. | `{ name: { $regex: '^J' } }` → throws `INVALID_FILTER` | | `$not` | The AST has no negation keyword, and rewriting the negation inward would be silently partial. Use a negated operator instead: `$ne`, `$nin`, `$notContains`. | `{ $not: { status: 'open' } }` → throws `INVALID_FILTER` | | a bare array as a field's value | The AST has no array-equality node, and the array is deliberately not read as `$in` (see below). | `{ tags: ['a', 'b'] }` → throws `INVALID_FILTER` | +| `$notin` / `$notcontains` / `$startswith` / `$endswith` | Retired lowercase aliases (objectui#8568). The `$` dialect follows `@objectstack/spec`'s spellings, and this repo's in-memory matcher already refused these; accepting them here made one authored filter behave differently depending on the data source behind the view. The refusal names the canonical spelling for the alias you wrote — rename the key, the operator is unchanged. | `{ email: { $startswith: 'a' } }` → throws `INVALID_FILTER` | | any other `$` key in operator position | Unknown operator; the error message lists the supported ones. | `{ age: { $foo: 1 } }` → throws `INVALID_FILTER` | A bare array as a field's value — `{ tags: ['a', 'b'] }` — is **refused** at diff --git a/packages/data-objectstack/src/readme-filter-operator-table.test.ts b/packages/data-objectstack/src/readme-filter-operator-table.test.ts index eae50ca22d..f82bb0a6c8 100644 --- a/packages/data-objectstack/src/readme-filter-operator-table.test.ts +++ b/packages/data-objectstack/src/readme-filter-operator-table.test.ts @@ -25,8 +25,10 @@ * retired (objectui#8447 / PR #8512): `$regex` is REFUSED with a * `FilterOperatorError`, because a substring match is a different * question, not a weaker version of the same one; - * - only `$startswith` was listed, while the map carries both spellings and + * - only `$startswith` was listed, while the map carried both spellings and * `$startsWith` is the spec's (`FILTER_OPERATORS`, `data/filter.zod.ts`). + * The lowercase half was retired outright by objectui#8568; this pin is + * what named the four rows that had to change with it. * * Re-deriving the whole table for that card found the rest of the drift: four * operators the code's own "Supported operators:" message enumerates — @@ -53,8 +55,7 @@ * `$exists` are probed with both booleans, so their rows must name both * directions; * - the supported table must carry a row for EVERY key of - * `convertOperatorToAST`'s `operatorMap` (alias spellings included — the - * omitted `$startsWith` was an alias row) and for every operator the + * `convertOperatorToAST`'s `operatorMap` and for every operator the * unknown-operator error message calls supported (`$null` / `$exists` * live outside the map); * - every `$`-spelling in the refused table must be refused, and every @@ -65,10 +66,10 @@ * Hardcoding `nin` below would rebuild the defect one level up: a second * hand-maintained copy, in a test, that would then have to be edited whenever * the map changed — the edit everybody makes without reading. So the map is - * read from `filter-converter.ts`'s source (the only way to see its lowercase - * aliases without exporting it; a control below proves the read agrees with - * the runtime), the supported list is read from the error the function throws - * for an unknown operator, and every lowering comes from calling the function. + * read from `filter-converter.ts`'s source (it is not exported, and a control + * below proves the read agrees with the runtime), the supported list is read + * from the error the function throws for an unknown operator, and every + * lowering comes from calling the function. * A red here is always "fix the README (or the code)", never "update the test". * * ## Exhaustiveness IS asserted, deliberately @@ -261,10 +262,10 @@ function lowerings(spelling: string): Lowering { } /** - * `convertOperatorToAST`'s `operatorMap`, read out of the source so the - * lowercase aliases are visible without exporting the map. The slice is - * anchored on the declaration, and a control below holds every entry to the - * runtime, so a moved or reshaped map fails loudly rather than reading empty. + * `convertOperatorToAST`'s `operatorMap`, read out of the source because the map + * is local to that function and not exported. The slice is anchored on the + * declaration, and a control below holds every entry to the runtime, so a moved + * or reshaped map fails loudly rather than reading empty. */ function operatorMapFromSource(): Map { const start = CONVERTER_SOURCE.indexOf('const operatorMap'); @@ -421,7 +422,7 @@ describe('README filter-operator tables are decided by convertFiltersToAST (obje ).toEqual([]); }); - it('carries a row for every key of the operator map, alias spellings included', () => { + it('carries a row for every key of the operator map', () => { const listed = new Set(supported.flatMap((row) => operatorSpellings(row.cells[0] ?? ''))); const missing = [...operatorMap.keys()].filter((spelling) => !listed.has(spelling)); expect( From 3ee36720a72b0340d4bcfd93faa9f84d75eca258 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 14:38:44 +0000 Subject: [PATCH 2/3] test(core): make the named-refusal assertion discriminate (objectui#8568) Ablating the named arm left `names the canonical spelling` GREEN: the generic unknown-operator message's own "Supported operators:" list already spells `$nin`, `$notContains`, `$startsWith` and `$endsWith`, so `toContain(canonical)` was a case that passed while witnessing nothing. The prescription phrase is what only the named arm can produce, so that is what is asserted now, with the measurement recorded beside it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .../__tests__/filter-alias-retirement-8568.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/__tests__/filter-alias-retirement-8568.test.ts b/packages/core/src/utils/__tests__/filter-alias-retirement-8568.test.ts index d0262ab1b0..4bb6178457 100644 --- a/packages/core/src/utils/__tests__/filter-alias-retirement-8568.test.ts +++ b/packages/core/src/utils/__tests__/filter-alias-retirement-8568.test.ts @@ -190,8 +190,17 @@ describe('objectui#8568 — the four lowercase aliases are refused by name', () expect(refusal).toMatchObject({ code: 'INVALID_FILTER', httpStatus: 400 }); }); - it.each([...RETIRED])('`%s` names `%s` in the refusal', (alias, canonical) => { - expect(refusalFor(alias).message).toContain(canonical); + // ⚠️ MEASURED, not assumed: `toContain(canonical)` ALONE does not discriminate. + // Ablating the named arm — so all four fall through to the generic message — + // left this case GREEN, because the generic message's own "Supported + // operators:" list already spells `$nin`, `$notContains`, `$startsWith` and + // `$endsWith`. A weaker assertion here would have been a case that passes + // while witnessing nothing. The PRESCRIPTION phrase is what only the named arm + // can produce, so that is what is asserted; the ablation moves it now. + it.each([...RETIRED])('`%s` prescribes `%s` by name in the refusal', (alias, canonical) => { + const message = refusalFor(alias).message; + expect(message).toContain(canonical); + expect(message).toContain(`Write '${canonical}' instead`); }); it.each([...RETIRED])('`%s` does not fall through to the generic unknown-operator message', (alias) => { From 151b9d453404e5b5c42fbc14145c26988857c5fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 14:49:07 +0000 Subject: [PATCH 3/3] test(scripts): keep the alias census a reading once the alias set is empty (objectui#8568) PR #8977's live-tree case asserted `aliases.length > 0`, `rows.length > 0` and `selfCarved > 0` unconditionally. Retiring the four aliases makes all three false at once, by construction: the census derives its subject set as operatorMap-minus-spec, and that set is now empty. The census script already models the empty tree and a synthetic unit case already pins it; only the live-tree case had the pre-ruling shape baked in. The blindness guard those three carried is real, so it is re-expressed rather than deleted. On an empty tree the instrument's reach is proved by the `authored-reach` control, which counts canonical `$`-operator payloads in non-test authored files and cannot be satisfied by a scanner that never opened the authored corpus; the empty set is then held to being DERIVED rather than the residue of a failed read on either side. The pre-retirement branch is kept intact, so the case still holds on any tree that carries an alias again. The subset check is recomputed in the test rather than by calling `deriveAliases` again: measured under ablation, a stubbed `deriveAliases` returning `[]` satisfies its own output, so reusing it would have made this branch pass while witnessing nothing. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB --- .../dollar-dialect-alias-census.test.ts | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/scripts/__tests__/dollar-dialect-alias-census.test.ts b/scripts/__tests__/dollar-dialect-alias-census.test.ts index b15f872897..67827c0075 100644 --- a/scripts/__tests__/dollar-dialect-alias-census.test.ts +++ b/scripts/__tests__/dollar-dialect-alias-census.test.ts @@ -308,6 +308,8 @@ describe('the tree as it stands today', () => { it('passes every control on a real run, so the printed number is a reading', async () => { const r = await runCensus(REPO_ROOT) as { + mapKeys: string[]; + specOperators: string[]; aliases: string[]; controls: Array<{ id: string; ok: boolean; detail: string }>; rows: Array<{ role: string }>; @@ -315,13 +317,47 @@ describe('the tree as it stands today', () => { }; const failed = r.controls.filter((c) => !c.ok).map((c) => `${c.id}: ${c.detail}`); expect(failed, 'a census run whose controls fail is not a reading').toEqual([]); - // Not an exhaustiveness pin on the count — objectui#8568 reserves the - // ruling and the count is expected to move. What is pinned is that the - // instrument SAW something, because a zero everywhere is blindness. + + // objectui#8568 has since been RULED (option 1) and the four aliases are + // retired, so on this tree the derived set is legitimately EMPTY. That is + // the DESIGNED post-ruling reading, not blindness: the census models it (its + // canonical-twin arm), and the synthetic case above pins the same thing. + // + // ⚠️ This case used to assert `aliases.length > 0`, `rows.length > 0` and + // `selfCarved > 0` unconditionally, and the retirement made all three false + // at once. The blindness guard they carried is real and is re-expressed + // rather than deleted: on an empty tree the instrument's reach is proved by + // `authored-reach`, which counts CANONICAL `$`-operator payloads in + // non-test authored files and therefore cannot be satisfied by a scanner + // that never opened the authored corpus. + const reach = r.controls.find((c) => c.id === 'authored-reach'); + expect(reach?.ok, 'the scanner must be shown to reach the authored corpus on either tree').toBe(true); + + if (r.aliases.length === 0) { + // An empty set has to be DERIVED, not the residue of a failed read on + // either side — that is the one way this branch could pass vacuously. + expect(r.mapKeys.length, 'an empty alias set read off an EMPTY operatorMap is a read failure').toBeGreaterThan(0); + expect(r.specOperators.length, 'an empty alias set read against an EMPTY spec list is a read failure').toBeGreaterThan(0); + // Recomputed HERE rather than by calling `deriveAliases` again: measured, + // a stubbed `deriveAliases` that returns `[]` satisfies its own output and + // this branch would pass while witnessing nothing. Asking the question + // independently — every accepted key must be a canonical one — is what + // makes an alias re-added to the map red here even if the census's own + // derivation is what broke. + expect( + r.mapKeys.filter((key) => !r.specOperators.includes(key)), + 'the alias set is accepted-minus-canonical; a non-canonical accepted key means the empty set is wrong', + ).toEqual([]); + // No alias exists, so no occurrence of one can, and there is nothing for + // the self carve-out to remove. + expect(r.rows.length).toBe(0); + expect(r.selfCarved).toBe(0); + return; + } + + // The pre-retirement shape, kept intact so this case still holds on any + // tree that carries an alias again — including a revert of the retirement. expect(r.rows.length).toBeGreaterThan(0); - expect(r.aliases.length).toBeGreaterThan(0); - // The self carve-out really carved something out; a zero here would mean - // the fixture spellings had quietly moved back into the population. expect(r.selfCarved).toBeGreaterThan(0); }); });