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..4bb6178457 --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-alias-retirement-8568.test.ts @@ -0,0 +1,270 @@ +/** + * 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 }); + }); + + // ⚠️ 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) => { + // 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( 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); }); });