diff --git a/.changeset/19778-preset-entry-carriers.md b/.changeset/19778-preset-entry-carriers.md index 7e6b178d473..8f4a207dddb 100644 --- a/.changeset/19778-preset-entry-carriers.md +++ b/.changeset/19778-preset-entry-carriers.md @@ -8,14 +8,12 @@ Clause-②: no No behaviour moves. No schema, accept set or lint rule is touched, and no export is added, removed or retyped. Every line this change edits in `registry.ts` is a string literal inside that one step-18 entry, which the exported `MIGRATIONS_BY_MAJOR` carries, so what moves in `dist` is prose. -- **The three groups the entry now draws.** They list the carriers measured, not a closed partition; the entry's grep sentence is the catch-all. Each was measured against the built `dist` with a preset comparand (`last_30_days`, and `today` as a `between` endpoint), and an ISO-date dark control reads green in every cell. +- **The groups the entry now draws.** They list the carriers measured, not a closed partition; the entry's grep sentence is the catch-all. Each was measured against the built `dist` with a preset comparand (`last_30_days`, and `today` as a `between` endpoint), and an ISO-date dark control reads green in every cell. 1. Slots typed `FilterConditionSchema`: `DashboardWidgetSchema.filter`, `GlobalFilterOptionsFromSchema.filter`, `DatasetSchema.filter`, `DatasetMeasureSchema.filter`, `ReportSchema.runtimeFilter`, `JoinedReportBlockSchema.runtimeFilter`, `FieldSchema.relatedListFilter` and `FieldSchema.summaryOperations.filter`. A parse of the declaring schema refuses each one at the comparand's own path, and the lint rule reports each one as well. 2. Filters under a key the lint walks whose declared type carries no preset check. These are `ViewFilterRuleSchema` rule arrays (a view's `filter`, a page element's `dataSource.filter`, a page component's `filter` prop) and a Mongo-shape record typed as a loose record rather than `FilterConditionSchema` (a flow `get_record` / `update_record` / `delete_record` node's `config.filter`). These parse green, and the lint rule alone refuses them. - 3. A page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters` parse green and also lint green, because the lint's filter walk descends neither key. Neither door refuses them at publish, so the entry now tells the upgrader to sweep both by hand. Two controls back the `filterBy` reading. A malformed `filterBy` value is refused at `interfaceConfig.filterBy.0.value`, so the slot is parsed. The same rule under `interfaceConfig.filter` is refused by the lint, so the key name is what decides. `lookupFilters` takes `gt`, `gte`, `lt` and `lte` as its only ordering spellings (`greater_than`, `after`, `between`, `>` and `$gt` are refused at `lookupFilters.0.operator`), and the by-hand clause names those four. - **Two more false sentences are narrowed.** - The `replacement` called the dashboard date-filter positions "the only place any layer ever resolved" a preset name. An analytics query's `timeDimensions[].dateRange` accepts and resolves the names too. - The `reason` said equality and membership "are NOT judged". That holds for the schema door only. The lint rule refuses a preset in an equality or membership position on a field it can resolve to a declared `date` or `datetime`, while `this_quarter` on a `select` field stays green. Where the filter binds to no object, such as a widget whose `dataset` names no dataset, that arm does not fire. - **Reach.** Counted over `dist/index.js`, `dist/index.mjs`, `dist/browser/index.js` and `dist/browser/index.mjs`: - The removed carrier list `page filter, component filter, rollup filter` and each of the four other removed claims read 4 before and 0 after. - - Each of nine sentences unique to the corrected text reads 0 before and 4 after. - The unchanged dark control `compared false against every row: HTTP 200` reads 4 on both sides. diff --git a/.changeset/19791-filter-walk-rule-array-carriers.md b/.changeset/19791-filter-walk-rule-array-carriers.md new file mode 100644 index 00000000000..7a410167723 --- /dev/null +++ b/.changeset/19791-filter-walk-rule-array-carriers.md @@ -0,0 +1,11 @@ +--- +"@objectstack/lint": patch +--- + +`filter-preset-comparand` now judges a list page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters`, consumed filter carriers the shared filter walk never entered (#19791). + +Both carriers are rule arrays (`{ field, operator, value }`) whose values reach the engine's `where` verbatim, and neither schema carries a preset check. So `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one parsed green and linted green, then the engine refused it at query time (`INVALID_FILTER` / 400). The same rule on a component `dataSource.filter` or a view `filter` was already refused. `filterBy` and `lookupFilters` join `FILTER_KEYS`, so `os lint`, `os validate` and the runtime publish gate (for `page` and `object` writes) now refuse it where it is written. Each finding carries its path (`pages[0].interfaceConfig.filterBy[0].value`, `objects[2].fields.account.lookupFilters[0].value`). + +- **Which object a condition addresses.** The field-typed arm, which refuses a preset under equality or membership on a `date` / `datetime` field, binds `filterBy` to `interfaceConfig.source`. Without a `source` it falls back to the page's `object`. It binds `lookupFilters` to the field's `reference` and never to the object that owns the field, because the picker queries the referenced object. A `relatedListFilter` on the same field still binds to the owner. +- **`filter-token-unknown` reaches the same two carriers.** An unresolvable placeholder such as `{current_user}` in `filterBy` or `lookupFilters` is now reported, as it already is in a view's `filter`. `{current_user_id}` and the date macros stay clean. +- **What you do:** in a `filterBy` or `lookupFilters` rule, replace a preset name with the `{date-macro}` window the message names (`{ operator: 'gte', value: '{30_days_ago}' }`) or with an ISO date. diff --git a/.changeset/19791-preset-entry-filterby-lookupfilters.md b/.changeset/19791-preset-entry-filterby-lookupfilters.md new file mode 100644 index 00000000000..00e24952074 --- /dev/null +++ b/.changeset/19791-preset-entry-filterby-lookupfilters.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +The shipped ADR-0087 semantic entry `filter-preset-ordering-comparand-refused` drops, from its `surface` and its `reason`, the text that said a page's `interfaceConfig.filterBy` and a lookup field's `lookupFilters` are refused by neither door at publish, and drops, from its `acceptanceCriteria`, the by-hand search it prescribed for those two keys. The `@objectstack/lint` `filter-preset-comparand` rule now walks both keys, so `os lint`, `os validate` and the runtime publish gate (on `page` and `object` writes) refuse `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` in either one, while the schema parse still accepts it. + +Clause-②: no diff --git a/packages/lint/src/filter-walk.ts b/packages/lint/src/filter-walk.ts index 6848404f34a..9bdbdcbfa61 100644 --- a/packages/lint/src/filter-walk.ts +++ b/packages/lint/src/filter-walk.ts @@ -57,15 +57,27 @@ type AnyRec = Record; /** * Keys whose subtree is a filter. The one place a filter is authored. * - * `relatedListFilter` (#8704) is the one member that does not spell the key - * `filter`: it sits flat on a FIELD beside its `relatedList`/`relatedListTitle`/ + * `relatedListFilter` (#8704) sits flat on a FIELD beside its `relatedList`/`relatedListTitle`/ * `relatedListColumns` family, so the family naming wins over the filter-key * convention. It carries a canonical Query-DSL `FilterCondition` (the schema * door already judges it at parse), and listing it here is what extends the * three walking rules — tokens, empty combinators, preset comparands — to the * new position instead of leaving a per-rule hole. + * + * `filterBy` and `lookupFilters` (#19791) are consumed RULE-ARRAY + * carriers that do not spell the key `filter`: a list page's always-on + * base filter (`interfaceConfig.filterBy`, `z.array(ViewFilterRuleSchema)`, + * which the console spreads into the list query beside the view's own + * `filter`) and a lookup field's picker filter (`lookupFilters`, lowered by the + * console to a Mongo `$filter` on the REFERENCED object). Both values reach + * the engine's `where` verbatim. Their schemas carry no preset check, so + * before this entry an ordering preset in either parsed green, linted green. + * The binding half — which object a condition on each carrier + * addresses — is `validate-preset-comparands.ts`'s, not this walk's. */ -export const FILTER_KEYS: ReadonlySet = new Set(['filter', 'filters', 'runtimeFilter', 'relatedListFilter']); +export const FILTER_KEYS: ReadonlySet = new Set([ + 'filter', 'filters', 'runtimeFilter', 'relatedListFilter', 'filterBy', 'lookupFilters', +]); /** One stack collection a caller wants walked. */ export interface FilterSurface { diff --git a/packages/lint/src/validate-filter-tokens.test.ts b/packages/lint/src/validate-filter-tokens.test.ts index 068004f41dc..b5ebe29b8da 100644 --- a/packages/lint/src/validate-filter-tokens.test.ts +++ b/packages/lint/src/validate-filter-tokens.test.ts @@ -180,6 +180,43 @@ describe('validateFilterTokens', () => { ).toEqual([]); }); + // [#19791] A list page's `interfaceConfig.filterBy` and a lookup field's + // `lookupFilters` reach the engine's `where` verbatim, where the same two + // placeholder vocabularies resolve. + it('reaches a page filterBy and a lookup field lookupFilters', () => { + const findings = validateFilterTokens({ + objects: [{ + name: 'invoice', + fields: { + account: { + type: 'lookup', + reference: 'account', + lookupFilters: [ + { field: 'owner', operator: 'eq', value: '{current_user}' }, + { field: 'owner', operator: 'ne', value: '{current_user_id}' }, + ], + }, + }, + }], + pages: [{ + name: 'deals', + type: 'list', + interfaceConfig: { + source: 'deal', + filterBy: [ + { field: 'owner', operator: 'equals', value: '{user_id}' }, + { field: 'created_at', operator: 'greater_than', value: '{30_days_ago}' }, + ], + }, + }], + }); + expect(findings.map((f) => f.path).sort()).toEqual([ + 'objects[0].fields.account.lookupFilters[0].value', + 'pages[0].interfaceConfig.filterBy[0].value', + ]); + for (const f of findings) expect(f.rule).toBe(FILTER_TOKEN_UNKNOWN); + }); + it('survives a cyclic metadata graph', () => { const dash: Record = { name: 'd', widgets: [] }; dash.self = dash; diff --git a/packages/lint/src/validate-preset-comparands.test.ts b/packages/lint/src/validate-preset-comparands.test.ts index 0c78a34a91a..2eb709a7cd5 100644 --- a/packages/lint/src/validate-preset-comparands.test.ts +++ b/packages/lint/src/validate-preset-comparands.test.ts @@ -672,3 +672,116 @@ describe('validatePresetComparands — arm 2, the FIELD-TYPED equality / members ]); }); }); + +// ── [#19791] consumed rule-array carriers the walk never entered ──── +// +// A list page's `interfaceConfig.filterBy` and a lookup field's +// `lookupFilters` both reach the engine's `where` verbatim, and neither schema +// carries a preset check. Before `FILTER_KEYS` named them, the card's rule +// `{ field: 'close_date', operator: 'gt', value: 'last_30_days' }` parsed +// green AND linted green on both, while the identical rule on a component +// `dataSource.filter` was refused. +describe('validatePresetComparands — page filterBy and lookup-field lookupFilters (#19791)', () => { + const card = { field: 'close_date', operator: 'gt', value: 'last_30_days' }; + // `close_date` is a DATE on `crm_deal` and a SELECT on `crm_region`, whose + // option value collides with a preset name — the pair that tells a binding + // to the right object from a binding to the wrong one. + const carrierObjects = [ + { name: 'crm_deal', fields: { close_date: { type: 'date' }, name: { type: 'text' } } }, + { name: 'crm_region', fields: { close_date: { type: 'select', options: [{ label: 'This Quarter', value: 'this_quarter' }] } } }, + ]; + const listPage = (interfaceConfig: Record, over: Record = {}) => ({ + objects: carrierObjects, + pages: [{ name: 'deals', type: 'list', interfaceConfig, ...over }], + }); + const lookupHolder = (account: Record, owner: Record = {}) => ({ + objects: [ + ...carrierObjects, + { name: 'crm_invoice', fields: { ...owner, account: { type: 'lookup', ...account } } }, + ], + }); + + it("refuses the card's rule in a page's filterBy, beside the lit control on the same page", () => { + const findings = validatePresetComparands({ + objects: carrierObjects, + pages: [{ + name: 'deals', type: 'list', + interfaceConfig: { source: 'crm_deal', filterBy: [card, { field: 'close_date', operator: 'between', value: ['this_week', '2026-01-01'] }] }, + components: [{ type: 'list', dataSource: { object: 'crm_deal', filter: [card] } }], + }], + }); + expect(findings.map((f) => f.path).sort()).toEqual([ + 'pages[0].components[0].dataSource.filter[0].value', + 'pages[0].interfaceConfig.filterBy[0].value', + 'pages[0].interfaceConfig.filterBy[1].value[0]', + ]); + const onFilterBy = findings.find((f) => f.path === 'pages[0].interfaceConfig.filterBy[0].value')!; + expect(onFilterBy.severity).toBe('error'); + expect(onFilterBy.rule).toBe(FILTER_PRESET_COMPARAND); + expect(onFilterBy.where).toBe('page "deals"'); + expect(onFilterBy.message).toContain('"last_30_days" is a dashboard date-range PRESET name'); + }); + + it("refuses the card's rule in a lookup field's lookupFilters, map-form and array-form fields alike", () => { + expect(validatePresetComparands(lookupHolder({ reference: 'crm_deal', lookupFilters: [card] })) + .map((f) => f.path)).toEqual(['objects[2].fields.account.lookupFilters[0].value']); + expect(validatePresetComparands({ + objects: [{ name: 'crm_invoice', fields: [{ name: 'account', type: 'lookup', reference: 'crm_deal', lookupFilters: [card] }] }], + }).map((f) => f.path)).toEqual(['objects[0].fields[0].lookupFilters[0].value']); + }); + + it('judges equality on filterBy against the object interfaceConfig.source names, falling back to the page object', () => { + const eq = { field: 'close_date', operator: 'equals', value: 'this_quarter' }; + // `source` is the date object: refused. + expect(validatePresetComparands(listPage({ source: 'crm_deal', filterBy: [eq] })).map((f) => f.path)) + .toEqual(['pages[0].interfaceConfig.filterBy[0].value']); + // `source` is the select object while the page's own `object` is the date + // one: the list queries `source`, so the equality is the picklist case. + expect(validatePresetComparands(listPage({ source: 'crm_region', filterBy: [eq] }, { object: 'crm_deal' }))).toEqual([]); + // No `source`: the page's `object`, as validate-page-field-bindings reads it. + expect(validatePresetComparands(listPage({ filterBy: [eq] }, { object: 'crm_deal' })).map((f) => f.path)) + .toEqual(['pages[0].interfaceConfig.filterBy[0].value']); + }); + + it("judges equality on lookupFilters against the field's reference, never the owning object", () => { + const eq = { field: 'close_date', operator: 'eq', value: 'this_quarter' }; + // The referenced object declares `close_date` as a date: refused. + expect(validatePresetComparands(lookupHolder({ reference: 'crm_deal', lookupFilters: [eq] })).map((f) => f.path)) + .toEqual(['objects[2].fields.account.lookupFilters[0].value']); + // The OWNING object declares a date `close_date`, the referenced one a + // select: binding to the owner would be the #16106 B1 false refusal. + const owner = { close_date: { type: 'date' } }; + expect(validatePresetComparands(lookupHolder({ reference: 'crm_region', lookupFilters: [eq] }, owner))).toEqual([]); + // No `reference` to follow: unjudged by arm 2, never the owner. + expect(validatePresetComparands(lookupHolder({ lookupFilters: [eq] }, owner))).toEqual([]); + // A `relatedListFilter` on the SAME field keeps binding to the owner, + // whose rows it filters — one field, two filters, two objects. + const both = validatePresetComparands(lookupHolder( + { reference: 'crm_region', lookupFilters: [eq], relatedListFilter: { close_date: 'this_quarter' } }, + owner, + )); + expect(both.map((f) => f.path)).toEqual(['objects[2].fields.account.relatedListFilter.close_date']); + }); + + it('stays quiet on every legal comparand in both carriers', () => { + const legal = [ + { field: 'close_date', operator: 'gt', value: '{30_days_ago}' }, + { field: 'close_date', operator: 'gte', value: '2026-01-15' }, + { field: 'close_date', operator: 'between', value: ['{week_start}', '{week_end}'] }, + { field: 'name', operator: 'equals', value: 'last_30_days' }, + ]; + // The lookup picker's own flat operator vocabulary (`FieldSchema.lookupFilters`). + const legalLookup = [ + { field: 'close_date', operator: 'gt', value: '{30_days_ago}' }, + { field: 'close_date', operator: 'gte', value: '2026-01-15' }, + { field: 'name', operator: 'eq', value: 'last_30_days' }, + ]; + expect(validatePresetComparands({ + objects: [ + ...carrierObjects, + { name: 'crm_invoice', fields: { account: { type: 'lookup', reference: 'crm_deal', lookupFilters: legalLookup } } }, + ], + pages: [{ name: 'deals', type: 'list', interfaceConfig: { source: 'crm_deal', filterBy: legal } }], + })).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index ed5acbe83af..614554f5776 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -115,6 +115,16 @@ import { indexObjectGraph, recordsOf, resolveFieldPath, type ObjectGraph } from * (`validate-flow-node-writes`), a templated `{…}` value skipped; * - `dataSource.object`, then `properties.object` / `properties.objectName` * — page components (`validate-page-field-bindings`); + * - `interfaceConfig.source` — a list page's config, which names its object + * `source` (the console queries exactly that object); read only at that + * position, the page's own `interfaceConfig`, and falling through to the + * page's `object` when absent — the binding `validate-page-field-bindings` + * already makes there (#19791); + * - a lookup field's `lookupFilters` → that field's `reference`, else NOTHING: + * the picker queries the REFERENCED object, so like the public-lookup picker + * below it must never fall through to the object that owns the field (a + * `relatedListFilter` on the same field keeps binding to the owner, whose + * rows it filters) (#19791); * - `publicPicker.object`, else the enclosing form field's `reference` * resolved on the view's object, else NOTHING — a form field's public-lookup * picker (`FormFieldPublicPickerSchema`) queries the REFERENCED object, so @@ -354,7 +364,10 @@ interface Ancestor { * the SAME `recordsOf` coercion `walkAuthoredFilters` applied to the * collection, so a map-form collection's injected `name` is visible here too. */ -function ancestorsOf(stack: AnyRec, path: string): { collection: string; chain: Ancestor[] } | null { +function ancestorsOf( + stack: AnyRec, + path: string, +): { collection: string; chain: Ancestor[]; filterKey: string | number } | null { const segments = pathSegments(path); if (!segments || segments.length < 3) return null; const [collection, index, ...rest] = segments; @@ -373,7 +386,7 @@ function ancestorsOf(stack: AnyRec, path: string): { collection: string; chain: if (isPlainObject(node)) chain.push({ key, node }); else if (!Array.isArray(node)) break; } - return { collection, chain }; + return { collection, chain, filterKey: rest[rest.length - 1] }; } /** @@ -385,6 +398,22 @@ function ancestorsOf(stack: AnyRec, path: string): { collection: string; chain: */ const PUBLIC_PICKER_KEY = 'publicPicker'; +/** + * [#19791] The filter key of a lookup field's picker filter + * (`FieldSchema.lookupFilters`). The console lowers each `{ field, operator, + * value }` entry to a Mongo `$filter` on the REFERENCED object — the field's + * `reference` — and never on the object that owns the field. Every spelling of + * this key in the platform means that same picker filter, so the reader claims + * it by the filter key itself. + */ +const LOOKUP_FILTERS_KEY = 'lookupFilters'; + +/** + * [#19791] The page key holding a list page's interface config + * (`InterfacePageConfigSchema`), which names its object `source`. + */ +const INTERFACE_CONFIG_KEY = 'interfaceConfig'; + /** * Bind one authored filter to the object its conditions address — the NEAREST * ancestor that declares one, in the carriers' own spellings (module note, @@ -401,6 +430,21 @@ function boundObjectOf( ): string | undefined { const located = ancestorsOf(stack, path); if (!located) return undefined; + + // [#19791] A lookup field's picker filter is a CLAIMING reader, for the + // #16106 B1 reason the public-lookup picker below is one: its conditions + // address the REFERENCED object, so the position binds to the enclosing + // field's literal `reference` and to NOTHING otherwise. Falling through to + // the owning object would be a false refusal wherever the two objects share + // a field name with differing types. Unbound leaves arm 2 silent on this + // subtree only; arm 1 still judges it. + if (located.filterKey === LOOKUP_FILTERS_KEY) { + const field = located.chain[located.chain.length - 1].node; + return Object.prototype.hasOwnProperty.call(field, LOOKUP_FILTERS_KEY) + ? literalObjectName(field.reference) + : undefined; + } + return bindAncestors(located.collection, located.chain, located.chain.length - 1, datasets, graph); } @@ -450,6 +494,16 @@ function bindAncestors( return verdict?.kind === 'ok' ? strName(verdict.meta?.reference) : undefined; } + // [#19791] A list page's `interfaceConfig` names its object `source`. Read + // at that one position — the page's own config, directly under the + // `pages` item — and NOT claiming: without a `source` the search goes on + // outward to the page's `object`, the fallback + // `validate-page-field-bindings` applies to the same config. + if (collection === 'pages' && i === 1 && key === INTERFACE_CONFIG_KEY) { + const viaSource = literalObjectName(r.source); + if (viaSource) return viaSource; + } + const direct = literalObjectName(r.object) ?? literalObjectName(r.objectName); if (direct) return direct; diff --git a/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts b/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts index 114ae5029b4..5df45c9f062 100644 --- a/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.filter-preset-ordering-comparand-refused.ts @@ -13,7 +13,7 @@ export const entry: SemanticMigration = { + '$gt / $gte / $lt / $lte value or a $between endpoint, a greater_than / less_than / ' + 'before / after / between view filter rule value, or an ordering [field, op, value] ' + 'filter triple. WHICH DOOR refuses it at publish is decided by the carrier\'s declared ' - + 'type and by its key. The carriers measured fall in three groups, and the groups are a ' + + 'type and by its key. The carriers measured fall in groups, and the groups are a ' + 'list of what was measured, not a closed partition: the grep in the acceptance criteria ' + 'is the catch-all. (1) A slot typed FilterConditionSchema — a ' + 'dashboard widget filter, a dashboard global-filter options-source filter ' @@ -26,11 +26,7 @@ export const entry: SemanticMigration = { + 'filter, a page element\'s dataSource.filter, a page component\'s filter prop), and a ' + 'Mongo-shape filter record typed as a loose record rather than FilterConditionSchema (a ' + 'flow CRUD node\'s config.filter). The lint is likewise what refuses a preset in an ' - + 'ordering filter triple wherever its walk meets one. (3) A filter under a key the lint ' - + 'does NOT walk parses GREEN and lints GREEN, so neither door refuses it at publish and ' - + 'only a search of the authored and stored metadata finds it: a page\'s ' - + 'interfaceConfig.filterBy rule array, and a lookup field\'s lookupFilters, whose ordering ' - + 'operators are spelled gt / gte / lt / lte', + + 'ordering filter triple wherever its walk meets one', replacement: 'the date-macro window the preset already means — { $gte: "{30_days_ago}" } for ' + 'last_30_days, { $between: ["{week_start}", "{week_end}"] } for this_week, and so on ' @@ -55,8 +51,7 @@ export const entry: SemanticMigration = { + 'shape on the slots typed that way, and the @objectstack/lint filter-preset-comparand ' + 'rule refuses it on every filter its walk reaches, which makes it the only door for a ' + 'walked filter whose declared type carries no preset check. Both answer at publish, where ' - + 'the author — an AI author in particular — can still act on the message; a page\'s ' - + 'interfaceConfig.filterBy and a lookup field\'s lookupFilters are reached by neither, and ' + + 'the author — an AI author in particular — can still act on the message; ' + 'the surface\'s groups say which measured carrier sits under which door. Ordering ' + 'positions only at the schema door, deliberately: it judges no equality or membership, ' + 'because a select/picklist column legitimately stores values that collide with preset ' @@ -81,14 +76,7 @@ export const entry: SemanticMigration = { + 'grep is the catch-all; the surface\'s groups are the carriers measured. The sweep is ' + 'mechanical for groups (1) and (2): `os validate` / `os lint` report each one by path, and ' + 'a group (1) slot is also refused by a `safeParse` of the schema that declares it, at the ' - + 'comparand\'s own path. Group (3) is BY HAND, because nothing reports it. Search every ' - + 'page for an `interfaceConfig.filterBy` rule whose operator is an ordering one ' - + '(greater_than, greater_than_or_equal, less_than, less_than_or_equal, before, after, ' - + 'between, or an alias of one) and whose value — or either `between` endpoint — is one of ' - + 'the thirteen names. Search every lookup field for a `lookupFilters` entry whose operator ' - + 'is `gt`, `gte`, `lt` or `lte` — the only ordering spellings that key accepts; it has no ' - + '`between` — and whose value is one of the thirteen names. Take each window from ' - + '`DATE_RANGE_PRESET_MACRO_WINDOWS`, since no rejection names it. Leave presets in dashboard ' + + 'comparand\'s own path. Leave presets in dashboard ' + 'date-filter positions (dateRange.defaultRange, date global filter defaultValue) ' + 'untouched — they remain the declared vocabulary there. A filter that carried one of ' + 'these shapes was never returning the window it named (silent zero before the engine ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 8430b24a9f3..1d58dae3981 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8703,7 +8703,7 @@ const step18: MigrationStep = { + '$gt / $gte / $lt / $lte value or a $between endpoint, a greater_than / less_than / ' + 'before / after / between view filter rule value, or an ordering [field, op, value] ' + 'filter triple. WHICH DOOR refuses it at publish is decided by the carrier\'s declared ' - + 'type and by its key. The carriers measured fall in three groups, and the groups are a ' + + 'type and by its key. The carriers measured fall in groups, and the groups are a ' + 'list of what was measured, not a closed partition: the grep in the acceptance criteria ' + 'is the catch-all. (1) A slot typed FilterConditionSchema — a ' + 'dashboard widget filter, a dashboard global-filter options-source filter ' @@ -8716,11 +8716,7 @@ const step18: MigrationStep = { + 'filter, a page element\'s dataSource.filter, a page component\'s filter prop), and a ' + 'Mongo-shape filter record typed as a loose record rather than FilterConditionSchema (a ' + 'flow CRUD node\'s config.filter). The lint is likewise what refuses a preset in an ' - + 'ordering filter triple wherever its walk meets one. (3) A filter under a key the lint ' - + 'does NOT walk parses GREEN and lints GREEN, so neither door refuses it at publish and ' - + 'only a search of the authored and stored metadata finds it: a page\'s ' - + 'interfaceConfig.filterBy rule array, and a lookup field\'s lookupFilters, whose ordering ' - + 'operators are spelled gt / gte / lt / lte', + + 'ordering filter triple wherever its walk meets one', replacement: 'the date-macro window the preset already means — { $gte: "{30_days_ago}" } for ' + 'last_30_days, { $between: ["{week_start}", "{week_end}"] } for this_week, and so on ' @@ -8745,8 +8741,7 @@ const step18: MigrationStep = { + 'shape on the slots typed that way, and the @objectstack/lint filter-preset-comparand ' + 'rule refuses it on every filter its walk reaches, which makes it the only door for a ' + 'walked filter whose declared type carries no preset check. Both answer at publish, where ' - + 'the author — an AI author in particular — can still act on the message; a page\'s ' - + 'interfaceConfig.filterBy and a lookup field\'s lookupFilters are reached by neither, and ' + + 'the author — an AI author in particular — can still act on the message; ' + 'the surface\'s groups say which measured carrier sits under which door. Ordering ' + 'positions only at the schema door, deliberately: it judges no equality or membership, ' + 'because a select/picklist column legitimately stores values that collide with preset ' @@ -8771,14 +8766,7 @@ const step18: MigrationStep = { + 'grep is the catch-all; the surface\'s groups are the carriers measured. The sweep is ' + 'mechanical for groups (1) and (2): `os validate` / `os lint` report each one by path, and ' + 'a group (1) slot is also refused by a `safeParse` of the schema that declares it, at the ' - + 'comparand\'s own path. Group (3) is BY HAND, because nothing reports it. Search every ' - + 'page for an `interfaceConfig.filterBy` rule whose operator is an ordering one ' - + '(greater_than, greater_than_or_equal, less_than, less_than_or_equal, before, after, ' - + 'between, or an alias of one) and whose value — or either `between` endpoint — is one of ' - + 'the thirteen names. Search every lookup field for a `lookupFilters` entry whose operator ' - + 'is `gt`, `gte`, `lt` or `lte` — the only ordering spellings that key accepts; it has no ' - + '`between` — and whose value is one of the thirteen names. Take each window from ' - + '`DATE_RANGE_PRESET_MACRO_WINDOWS`, since no rejection names it. Leave presets in dashboard ' + + 'comparand\'s own path. Leave presets in dashboard ' + 'date-filter positions (dateRange.defaultRange, date global filter defaultValue) ' + 'untouched — they remain the declared vocabulary there. A filter that carried one of ' + 'these shapes was never returning the window it named (silent zero before the engine '