Skip to content

Commit d7c0241

Browse files
feat(spec): a stored record-form filter at the rule-array doors converts to the rule array where lossless — D2 page-component-filter-record-to-rule-array (#17321) (#20175)
Part of #17321 Clause-②: no A stored record-form `filter` at the converged rule-array doors is now converted to the `ViewFilterRule` array wherever that mapping is lossless — a new ADR-0087 D2 conversion, `page-component-filter-record-to-rule-array` (toMajor 18, `retiredFromLoadPath: true`). A filter carrying `$and` / `$or` / `$not` is left byte-identical and is never flattened. `Part of`, not a closing keyword: ruling item 2's printed TODO list is **not** delivered here. It needs changes outside `packages/spec`, and this dispatch told me to stop at that line and report it (see *Stopped half* below). ## The ruling, item by item Ruling record 5644018752 (director seat, decision batch #121 item 4, maintainer 「同意」): | # | Ruled | Here | | --- | --- | --- | | 1 | 「A D2 conversion in the `element-data-source-and-object-block-filter-rule-array` family covering: flat record `{k: v}` → `[{field, operator: 'equals', value}]`; operator object `{k: {$op: v}}` → the mapped operator; several keys → several AND rules; single-level AST tuples where the seat's mapping is total.」 | **Delivered.** `packages/spec/src/conversions/registry.ts`, and replayed by id from `step18.conversionIds`. | | 2 | 「A record carrying `$and` / `$or` / `$not` is passed through unchanged and reported as a structured TODO naming the page/block and the combinator — `os migrate meta --stored` prints the list; …」 | **Pass-through: delivered and pinned. TODO list: stopped** (outside `packages/spec`, see below); plus every filter of an inline-row component (contract review 5853094156). | | 3 | 「The refusal message at the `filter` door gains the "how to fix" sentence (the separate message-gap card is folded in if it is still open; otherwise cross-referenced).」 | The message-gap card #17320 has state `completed`, so it is cross-referenced here. I measured all 12 doors, and each one already gives the "how to fix" sentence. **One gap was found and fixed.** For a record carrying a combinator, the sentence rendered the combinator as a field (`{ field: '$or', operator: …, value: … }`), which does not fix anything. That case is exactly what this conversion leaves at rest. | | 4 | 「`Clause-②: no` (no accept-set change; a conversion is added to the ledger).」 | Held **because** the conversion is `retiredFromLoadPath`. `normalizeStackInput` (`defineStack` / `os validate`) never replays it, so an author who writes the record form is still refused at the door. Pinned in §7 of the new test. | C (flattening) is excluded, as ruled: 「C converts combinators by flattening and silently changes what some pages select — excluded on the fault axis alone.」 ## Premises, re-measured on this tree (card measured on `501959b72`) - **The doors still refuse the record form**: yes. All 12 doors listed below answer `invalid_type` at the key, with the prescription. - **The stored read path still replays conversions without validating**: yes. `conversions/stored.ts` says 「Never throws, never validates」. `ObjectStackProtocolImplementation.convertStoredItem` is the item read path: `getMetaItem` for both draft and active rows calls it, and it replays the full chain, retired entries included. - **No D2 conversion existed for this shape**: yes. At base `49144fcc`, `conversions/registry.ts` held 103 `toMajor:` lines and not one mention of `dataSource`, `defaultFilters` or a rule-array conversion. The family id appeared only as a semantic entry. At head there are 104. - **The 「one table」**: the doors fold `operator` through `normalizeFilterOperator` (`VIEW_FILTER_OPERATOR_ALIASES`). The conversion introduces no new operator table. A record `$op` must be a declared `FILTER_OPERATORS` member, and its name without the `$` is folded through `normalizeFilterOperator` into `VIEW_FILTER_OPERATORS`. An AST infix spelling with no rule word (`=`, `!=`, `>=` …) is lowered through `parseFilterAST`, which is the AST's own single sink, and read back from the `$` operator it produces. - **Seam (objectui)**: read at the `.objectui-sha` pin `f8a9d0fb0596`. `toFilterNode` / `mergeFilterNodes` (`packages/core/src/utils/filter-converter.ts`) already lower all three shapes, including a spec `ViewFilterRule[]`, so a stored row served as the rule array renders — except where a block matches the filter against its OWN inline rows. `object-map`, `object-tree`, `object-calendar` and `object-gantt` hand `schema.filter` unlowered to an in-memory `ValueDataSource` (`ObjectMap.tsx:831-833`, `ObjectTree.tsx:835-837`, `ObjectCalendar.tsx:645-647`, `ObjectGantt.tsx:661,865` via `resolveDataSource.ts:70`), whose `find` reads an object `$filter` in the record dialect but an array one as an AST that refuses a rule object and excludes every row (`ValueDataSource.ts:1100-1101`, `:564`, `:600-603`, `:70-73`). So every filter of a component whose rows are inline (data provider `value`, a data array, `staticData`), the binding included, is left as stored (contract review `5853094156`, rework round 1). The same read **changed the mapping in one place**. `convertFiltersToAST` **skips a record key whose value is `null`**, so today that key constrains nothing, whereas an `equals null` rule would test IS NULL. So a `null` value is declined rather than converted. Combinators are lowered at that pin (`$and` / `$or`) or refused (`$not`), so leaving them as stored keeps what the page does today. ## Which doors 「the family」 covers — measured door by door Each door below refuses `{ status: 'active' }` with `invalid_type` at the key and the rule-array prescription, including the 「Write one rule per record key」 sentence. The conversion reaches exactly these doors. §6 of the test derives this set from `ComponentPropsMap` and `ElementDataSourceSchema` and holds the conversion's reach equal to it. | Door | Migration entry named by the refusal | | --- | --- | | `dataSource.filter` (any page component, `ElementDataSourceSchema`) | `element-data-source-and-object-block-filter-rule-array` | | `object-grid` / `object-metric` / `object-kanban` / `object-calendar` / `object-map` / `object-gantt` / `object-tree` / `object-timeline` `.properties.filter` | `element-data-source-and-object-block-filter-rule-array` | | `element:number.properties.filter` | `element-number-filter-rule-array` | | `element:record_picker.properties.filter` | `element-record-picker-filter-rule-array` | | `object-grid.properties.defaultFilters` | `object-grid-default-filters-rule-array` | The `filter` of any other component type is not touched (pinned). ## What converts, and what is left exactly as stored Converts: a flat record, where each scalar value becomes an `equals` rule; an operator object, where each `$op` becomes the mapped operator and several operators on one field become several rules; several keys, which AND; a single-level AST, meaning one comparison or a flat list of comparisons; and `{}`, which becomes `[]`. Values are carried verbatim. Every produced rule must parse against `ViewFilterRuleSchema`, which is the door's own check, so a converted row re-saves cleanly. Left byte-identical (identity by reference, no notice): - `$and` / `$or` / `$not`, or any other top-level `$` key. - An AST `and` / `or` group, or a flat list that nests one. - `$null` and `$exists`, whose meaning lives in the value and so is not in the one table. - `like` / `ilike`. - A `null` value. - An array or object comparand in equality position, a field reference, an empty operator object. - Any rule the door would refuse, such as `in` with a scalar or an empty `icontains`. - Every filter, the binding's `dataSource.filter` included, of a component whose rows are inline (`properties.data` with provider `value`, a bare `properties.data` array, or a truthy `properties.staticData`). The same filter on an object-bound block converts. Conversion is all-or-nothing per filter, because converting the mappable keys and dropping the rest would widen an AND list. **Lossless is measured, not asserted** (§4). For every `FILTER_OPERATORS` member, the mapped rule lowered through `parseFilterAST` equals the source FilterCondition. Only `$null` and `$exists` are declined. For every `VALID_AST_OPERATORS` spelling, the mapped rule lowers exactly as the source comparison did. Only `like` and `ilike` are declined. ## Changed published text **Refusal at every rule-array `filter` door, for a record carrying a combinator.** For every other value the text is byte-identical to base. I measured this by loading base `49144fcc`'s `filter-rule-array.ts` next to head's and comparing the full issue text (`code|path|message`) for 13 inputs: identical for 11 inputs (flat, `{}`, null value, operator object, more than 3 keys, array value, `$text`, string, number, AST array, `Date`), changed for the 2 combinator inputs. New text for `{ $or: [{ a: 1 }] }` at the binding, read from the built CJS `dist/ui`: ```text `filter` on this element data source takes the ViewFilterRule ARRAY form `[{ field, operator, value }, ...]`, and this value is the MongoDB-style record form this door took before the one-filter-orthography convergence. `$or` is a combinator, not a field, and the rule array has no spelling for it: its rules only AND, so `$or` and `$not` cannot be written as rules at all, and `$and` only as the separate rules it joins. Nothing rewrites a combinator for you — flattening one changes which rows the filter selects — so decide which rows this filter should select, and write the rules that select exactly those. Legacy operator shorthands (`eq`, `gt`, `notIn`, …) are accepted and normalized on parse. Full conversion table: migration `element-data-source-and-object-block-filter-rule-array`. ``` For a record that has field keys beside the combinator, the rewrite sentence becomes 「Write one rule per field key — they AND — so its field keys become `[…]`.」 and the combinators are named in the plural: 「`$and` and `$not` are combinators, not fields, …」. The code (`invalid_type`) and path (`filter`) do not change. Both cases are pinned in `filter-rule-array-guidance.test.ts` §1 (code + path + text). **ADR-0087 semantic entries whose prose this PR made false** (published in the upgrade guide): - `element-data-source-and-object-block-filter-rule-array`, `reason`. 「⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 conversion … so it has nothing to rewrite for this shape …」 is replaced with a paragraph that names the conversion and what it converts. It also names what it leaves as stored and why. It says in so many words that 「`os migrate meta --stored` does not list these rows yet: a row the conversion leaves as stored reports there as already on protocol.」 - `object-grid-default-filters-rule-array`, `reason`. 「Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion …」 is replaced to match. The two sibling entries (`element-number-…`, `element-record-picker-…`) made no at-rest claim and are unchanged. - The hand-written `step18.rationale` gains one closing sentence naming the conversion. The generated regions of `migrations/registry.ts` were regenerated with `gen:migration-registry`, not hand-edited. ## Stopped half — ruling item 2's structured TODO list (exact sites) Measured: the existing D2 machinery does **not** report pass-through rows. Ruled item 2's list needs all four of the following, and three of them are outside `packages/spec`: 1. `packages/spec/src/conversions/types.ts` + `apply.ts` + `index.ts`. There is no TODO channel. `ConversionContext` offers only `reportConflict`, and `ApplyConversionsOptions` only `onNotice` and `onConflict`. A TODO sink would be a new notice with a new stable code, next to `OS_METADATA_CONVERTED` / `OS_METADATA_CONVERSION_CONFLICT`. 2. `packages/runtime/src/dispatcher-error-vocabulary.ts`. `pnpm check:dispatcher-error-vocabulary` reds a new `code:` stamp in `apply.ts` until a `foreign-vocabulary` row is added. Its two twins are the `OS_METADATA_CONVERTED` and `OS_METADATA_CONVERSION_CONFLICT` rows. **This is why the spec half was not landed alone**: it would turn a gate red outside this lane. 3. `packages/metadata-protocol/src/protocol.ts`. `convertStoredItemDetailed` forwards only `onNotice` to `applyConversionsToStoredItem`. `migrateStoredMetadata` decides with `changed = … notices.length > 0`, so a row that emitted no notice is recorded `canonical` and is never itemised. 4. `packages/metadata-protocol/src/stored-migration.ts`. `StoredMigrationRow` has no TODO field, and `formatStoredMigrationReport` has no TODO section. `packages/cli/src/commands/migrate/meta.ts` `runStored` prints `formatStoredMigrationReport` and emits the whole report as JSON, so it needs no change once the report carries the list. Until that lands, a combinator row keeps loading unchanged, is counted canonical by `os migrate meta --stored`, and is refused at its door on its next save, now with the corrected prescription. The entry prose and the changeset say exactly this. ## Verification — all at head `a0360aad` (rework round 1; round 0 was `b1c9143f`) - `pnpm --filter @objectstack/spec build` passed (dts 36/36). - `check:generated`: all 15 generated artifacts up to date. `spec-changes.json` and `docs/protocol-upgrade-guide.md` do not move, because protocol 18 is the major in preparation (`PROTOCOL_VERSION` 17.0.0). - `pnpm --filter @objectstack/spec typecheck` passed, including `check:test-typecheck` with 142 pinned signatures and no new debt. - Full `@objectstack/spec` suite (`vitest run --project local`): **541 files, 15871 passed, 2 todo**. - New `conversions/page-component-filter-record-to-rule-array.test.ts`: 57 tests, §0–§7 (round 1 adds 6 inline-row cases, the inline-grid `defaultFilters` case and the object-bound control). The guidance test gains 2 combinator pins. - **Ablation** (`scripts/ablation-replace.mjs`, wrap mode, committed head): I deleted the door-acceptance gate (`return rules.every((rule) => ViewFilterRuleSchema.safeParse(rule).success) ? rules : undefined;` became `return rules;`), with anchor 1→0 and blob `17b8a6c1` → `188a52b8`. Expected direction: red. Observed: 3 red, namely a field-reference comparand, `in` with a scalar, and an empty `icontains`. The restore left blob == HEAD `17b8a6c1` and `git diff HEAD` empty. The fourth door-refused row, an AST scalar comparison with no value, stayed green, because `parseFilterAST` already declines it one step earlier. - **Second ablation (round 1)**: the inline-rows decline removed (`if (rendersInlineRows(component.properties)) return component;` → a comment), anchor 1→0, blob `7de8807d` → `9d28b85b`. Expected direction: red. Observed: 9 red (the 6 inline rows, the inline-grid `defaultFilters` row, the replay-no-op pin and the registry fixture pair); the object-bound control stayed green. The restore left blob == HEAD `7de8807d` and `git diff HEAD` empty. - Consumer suites: `@objectstack/metadata-core` `artifact-forward-conversion.test.ts` 25/25. `@objectstack/lint` `validate-component-props` + `validate-preset-comparands` + `authoring-rule-input-tier` 107/107. No test outside `packages/spec` seeds a page row with a record-form filter at these doors (swept every consumer test of `migrateStoredMetadata` / `applyConversionsToStoredItem`), and none pins the conversion list. - CJS and ESM smoke of the built `dist`: `applyConversionsToStoredItem('page', …)` converts in both. - Gates: `dispatch-gates.mjs --commands` re-derived at this head found 87 families. **84 exit 0; 3 exit 3 (prerequisite not met, NOT MEASURED)**: `check:dual-build-cjs-loads` and `check:type-check-debt` need a full `pnpm build`, and `check:lean-entry-closure` needs the `objectql` dist. `--ran` reconciliation: 87 accounted for, 84 run, 3 NOT MEASURED, 0 unrun. The roster gates whose roster sits under my paths were also run, all exit 0: `check:filter-alias-parity`, `check:error-code-casing`, `check:authz-resolver`, spec `check:meta-url-spelling`, spec `check:error-code-provenance`, and `check:nul-bytes`. - Lint, narrowed and shown to exclude nothing (the repo-wide `pnpm lint` is CI's): `eslint --no-inline-config --format json` over the 7 changed TS files reported 7 files, 0 errors, 0 warnings. `eslint.config.mjs` matches them under `packages/**/*.{ts,…}`. It has no `parserOptions.project` and no typed rules, so this diff cannot move a verdict in any untouched file. - `main` was merged in before these runs (it had moved `packages/spec/src/migrations/registry.ts` and `ui/report.zod.ts`). The merge was clean, and `check:migration-registry` passed after it. Main has moved again since (`e2c4e125`); the newer commits touch none of these 8 files. ## Acceptance notes - **Settled choices, each on the four axes.** - *Several operators on one field* (`{ $gte: 1, $lte: 9 }`) convert to one rule per operator. It is the same AND as several keys, so it is lossless. The alternative, leaving such a filter silent and unconverted, is worse on the fault axis. - *`null` values are declined.* Converting them would silently change what the page selects, per the pin read above. - *All-or-nothing per filter.* A partial conversion widens the filter. - *The family is every door the prescription helper serves.* The helper recognises the same record form, the four entries carry the same table, and the doors that joined later (`object-map` / `-gantt` / `-tree` / `-timeline`) name the same migration. - `.changeset/19514-…` (pending, another card's) says for `object-grid.defaultFilters` 「Stored metadata … keeps loading and keeps rendering exactly as it does today; what changes is that RE-SAVING it is refused」. After this PR that sentence is still true for what the conversion leaves as stored, but not for the converted forms. This PR's changeset states the new behaviour and ships in the same release. Not edited here (carrier: none). - `$null` / `$exists` operator objects stay unconverted, because the one-table rule excludes value-dependent operators. They are listed by nothing until the stopped half lands. - **objectui carrier**: `ValueDataSource` refuses a canonical `ViewFilterRule` array, so a spec-conformant filter on an inline-row map, tree, calendar or gantt already renders nothing today, independent of this PR (measured by probe at the pin, round 1 report on #17321). The seat files it in objectui; the inline-rows decline here can be dropped once it lands. - The `os migrate meta --from 17` sentence needs no `--to 18`: the CLI's default terminus is the highest major with a step (`packages/cli/src/commands/migrate/meta.ts:88`, `:393`; pinned by `packages/cli/test/migrate-meta-default-range.test.ts`). --- _Generated by [Claude Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ecbe0f commit d7c0241

8 files changed

Lines changed: 1209 additions & 38 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): a stored record-form `filter` at the converged rule-array doors is converted to the rule array wherever the mapping is lossless — the ADR-0087 D2 conversion `page-component-filter-record-to-rule-array` (#17321, ruling B)
6+
7+
The one-filter-orthography convergence moved every page-component `filter` door onto the
8+
`ViewFilterRule` array and refused the MongoDB-style record it used to take — but converted
9+
nothing at rest. A page saved with the old form kept rendering, and the next person to open it
10+
in the builder and press Save was refused. This release adds the mechanical half.
11+
12+
**What converts** — at `dataSource.filter` on any page component, at `properties.filter` on the
13+
`object-grid`, `object-metric`, `object-kanban`, `object-calendar`, `object-map`, `object-gantt`,
14+
`object-tree`, `object-timeline`, `element:number` and `element:record_picker` blocks, and at
15+
`properties.defaultFilters` on `object-grid`:
16+
17+
| Stored | Becomes |
18+
| --- | --- |
19+
| `{ status: 'active' }` | `[{ field: 'status', operator: 'equals', value: 'active' }]` |
20+
| `{ amount: { $gt: 100 } }` | `[{ field: 'amount', operator: 'greater_than', value: 100 }]` |
21+
| `{ amount: { $gte: 1, $lte: 9 }, owner_id: 'u1' }` | three rules — they AND |
22+
| `[['owner_id', '=', '{current_user_id}']]` | `[{ field: 'owner_id', operator: 'equals', value: '{current_user_id}' }]` |
23+
| `{}` | `[]` |
24+
25+
Values are carried verbatim, value placeholders and date macros included. The operator comes
26+
from the two tables the doors already use — a declared FilterCondition operator (`$gt`, `$nin`,
27+
`$notContains`, …) folded through `normalizeFilterOperator`, and an AST infix spelling (`=`,
28+
`!=`, `>=`, …) lowered through `parseFilterAST` — and every produced rule must parse at the
29+
door, so a converted page re-saves cleanly.
30+
31+
**Where it runs.** On every stored-row read (`applyConversionsToStoredItem` replays it), so
32+
the metadata API already serves such a page in the rule-array form; `os migrate meta --stored --apply`
33+
persists it; `os migrate meta --from 17` lists the edits for author sources. It is retired from
34+
the authoring load path, so `defineStack` / `os validate` still refuse an author who writes the
35+
record form and teach the rule array — no accept-set change.
36+
37+
**What is left exactly as stored.** A filter carrying `$and` / `$or` / `$not` is never
38+
flattened: the rule array only ANDs, and flattening `$or` or `$not` changes which rows the page
39+
selects. So is any filter with a part that has no lossless rule spelling — a `null` value (the
40+
renderer skips that key today, where a rule would test IS NULL), `$null` / `$exists`, an AST
41+
`like` / `ilike`, an array or object comparand in equality position, an AST `and` / `or` group.
42+
Conversion is all-or-nothing per filter: converting the mappable keys and dropping the rest
43+
would widen the filter. And every filter — the binding's included — of a component whose rows
44+
are **inline** (`data: { provider: 'value', … }`, a `data` array, or `staticData`) is left as
45+
stored: the `object-map`, `object-tree`, `object-calendar` and `object-gantt` renderers match
46+
that filter against their own rows in an in-memory data source that reads the record form but
47+
excludes every row for a rule array, so a rewrite there would empty the block. The same filter
48+
on a block that queries an object converts. Such a page keeps loading and rendering unchanged and is refused at its
49+
`filter` door on its next save — and for a combinator record that refusal no longer renders the
50+
combinator as a field (`{ field: '$or', … }`); it names the combinator and says why no rule
51+
spells it. `os migrate meta --stored` does not list these rows yet: a row the conversion leaves
52+
as stored reports there as already on protocol.

‎packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts‎

Lines changed: 500 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 490 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,29 @@ export const entry: SemanticMigration = {
5757
+ 'array on `object-grid`) and three lint fixtures — every one rewritten to the rule array '
5858
+ 'in the same change, and zero outside those files; this entry carries the prescription '
5959
+ 'for authors outside the repo. '
60-
+ '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 '
61-
+ 'conversion — a SemanticMigration converts nothing by its own type, and '
62-
+ '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) replays '
63-
+ 'D2 conversions only, so it has nothing to rewrite for this shape. The read path '
64-
+ 'does not re-validate stored rows '
65-
+ '(`applyConversionsToStoredItem` replays the full chain without validating, by its own '
66-
+ 'contract), so a stored page or block carrying the record form keeps loading unchanged '
67-
+ 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that '
68-
+ 'RE-SAVING it is refused at the `filter` door, on its next save and not before.',
60+
+ 'Metadata AT REST: the mappable part of the table above is a D2 conversion, '
61+
+ '`page-component-filter-record-to-rule-array` (#17321, ruling B), so '
62+
+ '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) rewrites '
63+
+ 'a stored page whose `filter` is a flat record, an operator object whose operators the '
64+
+ 'rule vocabulary spells, several such keys, or a single-level AST tuple array, and every '
65+
+ 'stored-row read replays the same rewrite until it does. It is retired from the load '
66+
+ 'path: an author writing the record form is still refused at the `filter` door. ⚠️ A '
67+
+ 'filter carrying `$and` / `$or` / `$not` is left exactly as stored — the rule array '
68+
+ 'only ANDs, and flattening a combinator changes which rows the page selects — and so is '
69+
+ 'any filter with a part that has no lossless rule spelling: a `null` value (the renderer '
70+
+ 'skips that key, so it constrains nothing today, where a rule would test IS NULL), an '
71+
+ 'operator such as `$null` / `$exists` or an AST `like`, an array or object comparand in '
72+
+ 'equality position, or an AST `and` / `or` group. So is every filter — the binding\'s '
73+
+ 'included — of a component whose rows are INLINE (`data: { provider: \'value\' }`, a '
74+
+ '`data` array, or `staticData`): at the pinned `.objectui-sha` the `object-map`, '
75+
+ '`object-tree`, `object-calendar` and `object-gantt` blocks match that filter against '
76+
+ 'their own rows in an in-memory data source that reads the record form and excludes '
77+
+ 'EVERY row for a rule array, so a rewrite there would empty the block. Such a row keeps loading unchanged '
78+
+ '(`applyConversionsToStoredItem` replays the chain without validating, by its own '
79+
+ 'contract) and is refused at the `filter` door on its next save — for a combinator '
80+
+ 'record, a refusal that names the combinator and says why no rule spells it. '
81+
+ '`os migrate meta --stored` does not list these rows yet: a row the conversion leaves '
82+
+ 'as stored reports there as already on protocol.',
6983
acceptanceCriteria:
7084
'`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: '
7185
+ '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule '

‎packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,17 @@ export const entry: SemanticMigration = {
4848
+ 'outright — the other arm the finding offered — removes an accepted shape and needs its '
4949
+ 'own ruling; the deprecation already stated in the description is unchanged and still '
5050
+ 'says to prefer filter. '
51-
+ 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion, '
52-
+ 'for the reason its sibling gives at length: a SemanticMigration converts nothing by '
53-
+ 'its own type, the stored-row pass replays D2 conversions only, and the read path does '
54-
+ 'not re-validate stored rows — so a stored page carrying the record form keeps loading '
55-
+ 'and keeps rendering as it does today. What changes is that RE-SAVING it is refused at '
56-
+ 'the defaultFilters path, with the same conversion table the filter door gives, '
57-
+ 'computed from the author\'s own keys. ADR-0049 / ADR-0087.',
51+
+ 'Metadata AT REST: the record form and the AST tuple array at this key are rewritten to '
52+
+ 'the rule array by the same D2 conversion as its sibling filter, '
53+
+ 'page-component-filter-record-to-rule-array, wherever the mapping is lossless — by '
54+
+ 'os migrate meta --stored, and on every stored-row read until it runs. What it cannot '
55+
+ 'map losslessly is left exactly as stored and keeps rendering as it does today — a '
56+
+ 'combinator, a null value, an operator the rule vocabulary does not spell, the bare '
57+
+ 'string or number this key also took, and any filter on a grid whose rows are inline '
58+
+ '(data with provider value, or staticData), for the reason its sibling gives — and '
59+
+ 'RE-SAVING such a node is refused at the '
60+
+ 'defaultFilters path, with the same conversion table the filter door gives, computed '
61+
+ 'from the author\'s own keys. ADR-0049 / ADR-0087.',
5862
acceptanceCriteria:
5963
'Every object-grid node in your pages either omits defaultFilters or carries a '
6064
+ 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is '

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5279,7 +5279,18 @@ const step18: MigrationStep = {
52795279
+ 'door persists the authored value and the stored-row rehydration seam is live for '
52805280
+ 'this type, both measured); and the withdrawn `ConnectorProviderContext` member, '
52815281
+ 'which is code and has no authored source to rewrite, leaves via the paired semantic '
5282-
+ 'entry instead.',
5282+
+ 'entry instead. '
5283+
+ 'Finally it gives the one-filter-orthography convergence (objectui#6206) its '
5284+
+ 'mechanical half at rest (#17321, ruling B): the D2 conversion '
5285+
+ '`page-component-filter-record-to-rule-array` rewrites a record-form or single-level '
5286+
+ 'AST `filter` at the converged rule-array doors — `dataSource.filter`, the '
5287+
+ '`object-*` / `element:number` / `element:record_picker` `filter` props and '
5288+
+ '`object-grid.defaultFilters` — to the rule array wherever the mapping is lossless, '
5289+
+ 'and leaves a filter carrying `$and` / `$or` / `$not` (or any part with no lossless '
5290+
+ 'rule spelling) exactly as stored, because flattening a combinator changes which rows '
5291+
+ 'a page selects — as it does every filter of a component whose rows are inline, which '
5292+
+ 'the renderer matches in the record dialect and would empty for a rule array. It is retired from the load path, so authors are still refused at '
5293+
+ 'the door and taught the array; the stored-row seams and this chain replay it.',
52835294
conversionIds: [
52845295
'field-malformed-scale-precision-removed',
52855296
'record-chatter-position-vocabulary',
@@ -5315,6 +5326,7 @@ const step18: MigrationStep = {
53155326
'dashboard-widget-chart-config-structure-removed',
53165327
'translation-per-app-settings-removed',
53175328
'object-tenancy-organization-field-removed',
5329+
'page-component-filter-record-to-rule-array',
53185330
],
53195331
semantic: [
53205332
// One file per entry under `entries/semantic/`, concatenated here sorted by
@@ -7863,15 +7875,29 @@ const step18: MigrationStep = {
78637875
+ 'array on `object-grid`) and three lint fixtures — every one rewritten to the rule array '
78647876
+ 'in the same change, and zero outside those files; this entry carries the prescription '
78657877
+ 'for authors outside the repo. '
7866-
+ '⚠️ Metadata AT REST is deliberately NOT rewritten, and this disposition adds no D2 '
7867-
+ 'conversion — a SemanticMigration converts nothing by its own type, and '
7868-
+ '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) replays '
7869-
+ 'D2 conversions only, so it has nothing to rewrite for this shape. The read path '
7870-
+ 'does not re-validate stored rows '
7871-
+ '(`applyConversionsToStoredItem` replays the full chain without validating, by its own '
7872-
+ 'contract), so a stored page or block carrying the record form keeps loading unchanged '
7873-
+ 'and is still rendered by objectui at the pinned `.objectui-sha`; what changes is that '
7874-
+ 'RE-SAVING it is refused at the `filter` door, on its next save and not before.',
7878+
+ 'Metadata AT REST: the mappable part of the table above is a D2 conversion, '
7879+
+ '`page-component-filter-record-to-rule-array` (#17321, ruling B), so '
7880+
+ '`os migrate meta --stored` (the pass over a deployment\'s `sys_metadata` rows) rewrites '
7881+
+ 'a stored page whose `filter` is a flat record, an operator object whose operators the '
7882+
+ 'rule vocabulary spells, several such keys, or a single-level AST tuple array, and every '
7883+
+ 'stored-row read replays the same rewrite until it does. It is retired from the load '
7884+
+ 'path: an author writing the record form is still refused at the `filter` door. ⚠️ A '
7885+
+ 'filter carrying `$and` / `$or` / `$not` is left exactly as stored — the rule array '
7886+
+ 'only ANDs, and flattening a combinator changes which rows the page selects — and so is '
7887+
+ 'any filter with a part that has no lossless rule spelling: a `null` value (the renderer '
7888+
+ 'skips that key, so it constrains nothing today, where a rule would test IS NULL), an '
7889+
+ 'operator such as `$null` / `$exists` or an AST `like`, an array or object comparand in '
7890+
+ 'equality position, or an AST `and` / `or` group. So is every filter — the binding\'s '
7891+
+ 'included — of a component whose rows are INLINE (`data: { provider: \'value\' }`, a '
7892+
+ '`data` array, or `staticData`): at the pinned `.objectui-sha` the `object-map`, '
7893+
+ '`object-tree`, `object-calendar` and `object-gantt` blocks match that filter against '
7894+
+ 'their own rows in an in-memory data source that reads the record form and excludes '
7895+
+ 'EVERY row for a rule array, so a rewrite there would empty the block. Such a row keeps loading unchanged '
7896+
+ '(`applyConversionsToStoredItem` replays the chain without validating, by its own '
7897+
+ 'contract) and is refused at the `filter` door on its next save — for a combinator '
7898+
+ 'record, a refusal that names the combinator and says why no rule spells it. '
7899+
+ '`os migrate meta --stored` does not list these rows yet: a row the conversion leaves '
7900+
+ 'as stored reports there as already on protocol.',
78757901
acceptanceCriteria:
78767902
'`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: '
78777903
+ '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule '
@@ -10900,13 +10926,17 @@ const step18: MigrationStep = {
1090010926
+ 'outright — the other arm the finding offered — removes an accepted shape and needs its '
1090110927
+ 'own ruling; the deprecation already stated in the description is unchanged and still '
1090210928
+ 'says to prefer filter. '
10903-
+ 'Metadata AT REST is deliberately NOT rewritten and this entry adds no D2 conversion, '
10904-
+ 'for the reason its sibling gives at length: a SemanticMigration converts nothing by '
10905-
+ 'its own type, the stored-row pass replays D2 conversions only, and the read path does '
10906-
+ 'not re-validate stored rows — so a stored page carrying the record form keeps loading '
10907-
+ 'and keeps rendering as it does today. What changes is that RE-SAVING it is refused at '
10908-
+ 'the defaultFilters path, with the same conversion table the filter door gives, '
10909-
+ 'computed from the author\'s own keys. ADR-0049 / ADR-0087.',
10929+
+ 'Metadata AT REST: the record form and the AST tuple array at this key are rewritten to '
10930+
+ 'the rule array by the same D2 conversion as its sibling filter, '
10931+
+ 'page-component-filter-record-to-rule-array, wherever the mapping is lossless — by '
10932+
+ 'os migrate meta --stored, and on every stored-row read until it runs. What it cannot '
10933+
+ 'map losslessly is left exactly as stored and keeps rendering as it does today — a '
10934+
+ 'combinator, a null value, an operator the rule vocabulary does not spell, the bare '
10935+
+ 'string or number this key also took, and any filter on a grid whose rows are inline '
10936+
+ '(data with provider value, or staticData), for the reason its sibling gives — and '
10937+
+ 'RE-SAVING such a node is refused at the '
10938+
+ 'defaultFilters path, with the same conversion table the filter door gives, computed '
10939+
+ 'from the author\'s own keys. ADR-0049 / ADR-0087.',
1091010940
acceptanceCriteria:
1091110941
'Every object-grid node in your pages either omits defaultFilters or carries a '
1091210942
+ 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is '

‎packages/spec/src/ui/filter-rule-array-guidance.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,46 @@ describe('§1 the record form is refused WITH the new spelling', () => {
160160
);
161161
});
162162

163+
// A combinator-carrying record is exactly what the D2 conversion
164+
// `page-component-filter-record-to-rule-array` leaves at rest, so it is what
165+
// still meets this door on its next save. The worked rewrite used to render
166+
// the combinator as a field — `{ field: '$or', operator: …, value: … }` — a
167+
// "fix" that is no fix.
168+
it('a lone combinator is not rendered as a field, and the refusal says why no rule spells it', () => {
169+
const issue = filterIssue(
170+
ElementDataSourceSchema.safeParse({
171+
object: 'task',
172+
filter: { $or: [{ status: 'open' }, { status: 'blocked' }] },
173+
}),
174+
);
175+
expect(issue.code).toBe('invalid_type');
176+
expect(issue.message).not.toContain("field: '$or'");
177+
expect(issue.message).toContain('`$or` is a combinator, not a field, and the rule array has no spelling for it');
178+
expect(issue.message).toContain('flattening one changes which rows the filter selects');
179+
expect(issue.message).toContain('decide which rows this filter should select');
180+
expect(issue.message).toContain('migration `element-data-source-and-object-block-filter-rule-array`');
181+
// No worked rewrite at all: "this filter becomes `[]`" would read as "no filter".
182+
expect(issue.message).not.toContain('becomes `[]`');
183+
expect(issue.message).not.toContain('Write one rule per record key');
184+
});
185+
186+
it('beside field keys, the rewrite covers only the field keys and every combinator is named', () => {
187+
const issue = filterIssue(
188+
ElementDataSourceSchema.safeParse({
189+
object: 'task',
190+
filter: { owner: 'me', $and: [{ a: 1 }], $not: { b: 2 } },
191+
}),
192+
);
193+
expect(issue.code).toBe('invalid_type');
194+
expect(issue.message).toContain(
195+
"Write one rule per field key — they AND — so its field keys become "
196+
+ "`[{ field: 'owner', operator: 'equals', value: 'me' }]`.",
197+
);
198+
expect(issue.message).toContain('`$and` and `$not` are combinators, not fields');
199+
expect(issue.message).not.toContain("field: '$and'");
200+
expect(issue.message).not.toContain("field: '$not'");
201+
});
202+
163203
it('an operator-object value is not mis-prescribed as an `equals` scalar', () => {
164204
const issue = filterIssue(
165205
ElementDataSourceSchema.safeParse({ object: 'task', filter: { amount: { $gt: 100 } } }),

0 commit comments

Comments
 (0)