Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/spec-pin-17-1-0-5328.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
---
'@object-ui/data-objectstack': minor
'@object-ui/plugin-list': minor
'@object-ui/plugin-view': minor
'@object-ui/core': minor
'@object-ui/app-shell': minor
---

Pins `@objectstack/spec`, `@objectstack/client`, `@objectstack/formula` and `@objectstack/lint` to `17.1.0`, and adapts the two consumer surfaces the new build moves.

The pin itself is a lockfile refresh — every manifest already declared `^17.0.0`, which admits `17.1.0`, so no dependency range changed. All four move together: a split resolution is what produced the dual-version spec graph that reddened `check:spec-symbols` in this repo's history.

**A `icontains` filter now reaches the driver as a filter.** `icontains` is a canonical `VIEW_FILTER_OPERATORS` member as of `17.1.0`, so an author can declare it on a `ViewFilterRule` and the spec validates it — but `@object-ui/data-objectstack`'s alias table had no row for it, and an unmapped operator is how this adapter shipped an unfiltered query before (objectstack#3948). It is an identity row like `contains`: `icontains` is itself a member of `VALID_AST_OPERATORS`, so the spelling the author writes is the spelling the AST takes, and no case-sensitivity is translated away. Declared rather than left to the table's `?? op` fall-through, on the rule its own parity test states — the AST gate accepting a spelling is not the driver compiling it into a `WHERE` clause.

The same operator reaches the list view's own bridge: `@object-ui/plugin-list`'s `mapOperator` gains an explicit `icontains` arm. The emitted spelling is identical to the input, but the arm is written out rather than left to the `default` passthrough — `icontains` is its own member of `VALID_AST_OPERATORS`, so a raw passthrough is accepted *today*, and depending on that coincidence is what the bridge's own parity test records as how it once stopped discriminating.

`@object-ui/core` adds `onSuccess` to its spec key inventory, so an author writing the key `17.1.0` now declares is no longer warned that it is unknown. That is a diagnostic statement only — the four declared action surfaces still drop the key before it reaches the runner, which is tracked separately.

**A stored view filtering case-insensitively still shows that operator when it is reopened.** `@object-ui/plugin-view`'s canonical-to-builder table is keyed by `ViewFilterOperator`, so `17.1.0` adding `icontains` failed to compile rather than letting the operator reach the FilterBuilder as a raw spelling its dropdown cannot select. It maps to the builder's `containsCaseInsensitive` — the id that authors the spec's `$icontains` — and deliberately not to `contains`, which would quietly rewrite a case-insensitive filter into a case-sensitive one the next time the view was saved.

**The page-editor palette keeps one entry per renderer.** `17.1.0` retires `element:filter` from `PageComponentType` and adds `record:discussion`, leaving the member count at 34 either side — so the swap is invisible to any count-based reading. The stale `element:filter` exclusion is dropped, and `record:discussion` is excluded because it is the *same renderer* as the already-offered `record:chatter`, not because it is unauthorable. Nothing the palette offers changes.
106 changes: 86 additions & 20 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,37 @@ const GA_ONLY_BLOCKS = [
'object-metric',
];

/**
* The five `record:*` blocks `@objectstack/spec` 17.1.0 adds to
* `ComponentPropsMap` and `17.0.0` does not carry at all (objectui#5328;
* the map goes from 37 entries to 42, and these are the five).
*
* Exactly the same shape as `GA_ONLY_BLOCKS` above, and for the same reason:
* this repo has registered all five with `inputs` for far longer than the spec
* has described them — `plugin-detail/src/index.tsx` registers `alert` (:686),
* `history` (:662) and `reference_rail` (:675), and `quick_actions` /
* `discussion` alongside them — so what moved at the pin bump is the SPEC's
* side, not this repo's. They enter `covered` the moment the installed spec
* carries them, and the reverse direction then asks each for the keys it does
* not publish. Only `record:reference_rail` had one: `entries`, exempted below.
*/
const MINOR_17_1_BLOCKS = [
'record:alert',
'record:discussion',
'record:history',
'record:quick_actions',
'record:reference_rail',
];

/**
* Does the installed `@objectstack/spec` carry the 17.1.0 record set?
*
* Same observable-fact reasoning as `specCarriesGaBlocks` below, including the
* `every` rather than `some`: the five arrived in one release, so a
* half-carried state is a broken premise rather than an in-between pin.
*/
const specCarries171Blocks = MINOR_17_1_BLOCKS.every((type) => type in ComponentPropsMap);

/**
* Does the installed `@objectstack/spec` carry the GA element set?
*
Expand DownExpand Up@@ -335,6 +366,7 @@ const PINNED_EXPECTED_COVERED = [
const EXPECTED_COVERED = [
...PINNED_EXPECTED_COVERED,
...(specCarriesGaBlocks ? GA_ONLY_BLOCKS : []),
...(specCarries171Blocks ? MINOR_17_1_BLOCKS : []),
].sort();

/**
Expand DownExpand Up@@ -522,14 +554,17 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
* regression: the mechanism is now self-clearing. A key upstream retires after
* this change enters the shape as a tombstone, leaves the accepted set on
* arrival, and any exemption covering it goes dangling-and-stale in the same
* run — no issue needed, no filter to remember. Two of the entries still below
* are already queued for it: objectstack `origin/main` tombstones
* run — no issue needed, no filter to remember.
*
* THAT PREDICTION HAS NOW RUN ONCE, AND IT HELD. The paragraph used to say two
* entries below were queued for it: objectstack `origin/main` tombstoned
* `targetVariable` on BOTH `element:text_input` and `element:record_picker`
* (measured on `main` @ `23abe2782`; both keys are still LIVE in the installed
* 17.0.0, whose tombstone set is the same eight rc.6 carried), so the pin that
* carries those retirements will name both entries here. Deleting them is
* the fix — objectui#3834's "should we publish an intent-only key" question is
* answered upstream by then, in the negative.
* while the installed 17.0.0 still carried both as live. The 17.1.0 pin
* (objectui#5328) delivered those retirements, all three directions named the
* two entries in the same run, and deleting them was the entire fix —
* objectui#3834's "should we publish an intent-only key" question having been
* answered upstream, in the negative. The mechanism needed no maintenance to
* do that, which is the property worth keeping.
*
* DO NOT resolve a tombstone red by declaring the input. That publishes a key
* the contract rejects by name and fails the forward direction immediately;
Expand All@@ -544,19 +579,22 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
// exemption` demanded its deletion. It is now pinned as DECLARED, by name,
// alongside #3808's four at the bottom of this file.

// ── targetVariable — the spec's own "declarative hint" (2 keys) ────────────
// Zero read points repo-wide (`grep -rn targetVariable packages/ apps/` is
// empty), and that is by design, not drift: the spec's describe says the live
// binding resolves via the variable whose `source` equals the component id,
// which is exactly what `usePageVariableBinding(schema?.id)` does
// (`text-input.tsx:60`). So publishing it is neither a fix nor a defect — it
// is a judgement about whether to publish an intent-only key, with a concrete
// risk on the publish side (an author who writes only `targetVariable` and no
// variable `source` gets an input that writes nowhere, silently).
'element:text_input.targetVariable':
"Spec's own declarative hint with zero read points repo-wide; the live binding is the reverse lookup in usePageVariableBinding(schema.id) (text-input.tsx:60). Whether to publish an intent-only key is an open judgement: objectui#3834.",
'element:record_picker.targetVariable':
"Spec's own declarative hint with zero read points repo-wide; the live binding is the reverse lookup by component id, as on element:text_input. Whether to publish an intent-only key is an open judgement: objectui#3834.",
// TWO targetVariable ENTRIES DELETED HERE — objectui#5328, and they died
// exactly the way the docblock above said they would.
//
// `element:text_input.targetVariable` and `element:record_picker.targetVariable`
// were exempted as the spec's own intent-only "declarative hint" with zero read
// points repo-wide, pending objectui#3834's question of whether to publish such
// a key at all. The `@objectstack/spec` 17.1.0 pin answered it upstream, in the
// negative: both keys arrived as ADR-0087 D2 tombstones, so they left the
// accepted set and the exemptions covering them went dangling-and-stale in the
// same run — named by `every unpublished-key exemption names a key the spec
// really declares`, `carries no stale unpublished-key exemption` and `the
// tombstoned keys are recognised, not exempted`, all three at once.
//
// Deleting them is the whole fix. The tombstone judge recognises both keys now,
// which is a stronger statement than an exemption ever was: the contract itself
// rejects them by name.

// FIVE GA-PENDING ENTRIES DELETED HERE — objectui#4668, and they too were
// designed to die exactly this way.
Expand DownExpand Up@@ -655,6 +693,34 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
'@deprecated in ObjectGridSchema ("Moved to top-level resizable"); GA describes it as the "Alternate spelling of `resizable`". Read as back-compat, deliberately not published — the canonical `resizable` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).',
'object-grid.title':
'@deprecated in ObjectGridSchema ("Use label instead"); GA describes it as the "Fallback for `label` (the renderer reads `label || title`)". Read as back-compat, deliberately not published — the canonical `label` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).',

// ── record:reference_rail.entries — a nested collection, newly JUDGED ──────
// (1 key)
// The gap is not new; being GATED is. `@objectstack/spec` 17.1.0 added
// `record:reference_rail` to `ComponentPropsMap` (37 entries to 42), so this
// file began judging a block it had never covered — the registration in
// `plugin-detail/src/index.tsx:675` has always published `hideEmpty` and only
// `hideEmpty`. Nothing about the renderer or its inputs changed on the pin
// (objectui#5328).
//
// `entries` is an ARRAY OF OBJECTS — `{objectName, relationshipField, title,
// limit, displayField}` per item — and `inputs` is a flat carrier of scalar
// fields (`type: 'string' | 'number' | 'boolean' | 'enum'`). The same
// "unpublishable in a flat carrier" reading `page:tabs.type` carried, except
// here the carrier cannot express the SHAPE rather than colliding on a name.
//
// DO NOT resolve this by declaring a scalar input for it: a string field
// standing in for a list of related-object bindings recommends a write the
// renderer cannot honour, which is this gate's own failure mode one layer in
// (the `page:tabs.alwaysShowStrip` note above).
//
// Also blocked on a real contract question, so it is not merely unbuilt: the
// item shape is `ReferenceRailEntrySchema`, whose `$strict` object REFUSES the
// `icon` key this repo's own local `ReferenceRailEntry` declares and the
// renderer reads. Publishing an entries editor means first deciding whether
// `icon` survives — objectui#5494.
'record:reference_rail.entries':
'An array of {objectName, relationshipField, title, limit, displayField} objects; `inputs` is a flat scalar carrier and cannot express it. Newly judged rather than newly missing — @objectstack/spec 17.1.0 added record:reference_rail to ComponentPropsMap, and the registration (plugin-detail/src/index.tsx:675) has always published only `hideEmpty`. An entries editor also needs the `icon` divergence settled first: objectui#5494.',
};

/**
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,10 +153,24 @@ export const PALETTE_EXCLUSIONS: Record<string, string> = {
'user:profile': 'shell singleton — lives in the app shell header',
// No renderer, by decision.
'ai:chat_window': 'no inline renderer — the floating chat overlay (plugin-chatbot) is canonical',
'element:filter': 'no renderer — list surfaces own filtering (userFilters / filter builder)',
'element:form': 'no renderer — use the object-bound `object-form` block',
'element:record_picker': 'no renderer — record picking is a field widget, not a page block',
'element:text_input': 'no renderer — bare inputs belong to a form, not a page block',
// Renders fine — excluded to keep ONE palette entry per renderer, not because
// it is unauthorable. `record:chatter` and `record:discussion` are the same
// renderer under two names (plugin-detail/src/index.tsx:589), and the palette
// already offers it above as 'Chatter feed'. Entered when `@objectstack/spec`
// 17.1.0 added `record:discussion` to `PageComponentType` (objectui#5328);
// objectui itself has emitted the type all along
// (synth/buildDefaultPageSchema.ts:731), so nothing new became authorable —
// only the spec's enum caught up.
//
// ⚠️ The pair is offered under the LEGACY name: `public-blocks.ts` records
// `record:chatter` as "`record:discussion` under a Salesforce-familiar name",
// i.e. this exclusion hides the canonical spelling and offers the alias.
// Flipping which one the palette offers changes what authors drag, so it is a
// decision rather than a pin-bump edit — filed as objectui#5495.
'record:discussion': 'same renderer as the offered `record:chatter` — one palette entry per renderer',
};

export const CATEGORY_LABEL_EN: Record<BlockCategory, string> = {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/actions/actionKeys.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,14 @@ export const SPEC_ACTION_KEYS = [
'name',
'newTabUrl',
'objectName',
// Declared by `ActionSchema` as of @objectstack/spec 17.1.0 (objectui#5328).
// Listing it here is a DIAGNOSTIC statement only — `KNOWN_ACTION_KEYS` feeds
// `warnOnUnknownActionKeys`, so without this row an author writing the key the
// spec now accepts would be warned it is unknown. It says nothing about the
// key being forwarded: the four declared action surfaces still drop it before
// the runner, tracked as KNOWN_GAPS in check-action-forward-parity.mjs and
// filed as objectui#5493.
'onSuccess',
'openIn',
'opensInNewTab',
'order',
Expand Down
9 changes: 9 additions & 0 deletions packages/data-objectstack/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,6 +99,15 @@ export const FILTER_OPERATOR_ALIASES: Record<string, string> = {
notin: 'nin',
nin: 'nin',
contains: 'contains',
// Case-insensitive contains. A canonical `VIEW_FILTER_OPERATORS` member that
// arrived with `@objectstack/spec` 17.1.0 (objectui#5328), and an IDENTITY row
// like `contains` above: `icontains` is itself a member of
// `VALID_AST_OPERATORS`, so the spelling the author writes is the spelling the
// AST takes — nothing is translated and no case-sensitivity is lost.
// Declared rather than left to the `?? op` fall-through on the rule this
// table's test states: the AST gate accepting a spelling is not the driver
// compiling it into a WHERE clause.
icontains: 'icontains',
not_contains: 'notcontains',
notcontains: 'notcontains',
starts_with: 'startswith',
Expand Down
7 changes: 7 additions & 0 deletions packages/plugin-list/src/ListView.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -181,6 +181,13 @@ export function mapOperator(op: string) {
case 'equals': case 'eq': return '=';
case 'notequals': case 'ne': case 'neq': return '!=';
case 'contains': return 'contains';
// Canonical in `VIEW_FILTER_OPERATORS` as of @objectstack/spec 17.1.0
// (objectui#5328), and an explicit arm rather than a `default` fall-through
// even though the emitted spelling is identical: `icontains` is its own
// member of `VALID_AST_OPERATORS`, so the raw passthrough happens to be
// accepted today, and relying on that is the exact slack this file's own
// header records as how it stopped discriminating in #3641.
case 'icontains': return 'icontains';
case 'notcontains': return 'notcontains';
case 'startswith': return 'startswith';
case 'endswith': return 'endswith';
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,7 +122,9 @@ describe('UserFilters tab presets — the reported defect (#3470)', () => {
describe('UserFilters tab presets — lowering is structural, not translated', () => {
it('reads both vocabularies from the spec', () => {
// Guards every it.each below against silently passing on an empty list.
expect(VIEW_FILTER_OPERATORS.length).toBe(19);
// 19 through @objectstack/spec 17.0.0; 20 as of 17.1.0, which adds the
// canonical `icontains` (objectui#5328).
expect(VIEW_FILTER_OPERATORS.length).toBe(20);
expect(VALID_AST_OPERATORS.size).toBeGreaterThan(0);
});

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,6 +81,7 @@ const EXPECTED_AST_TARGET: Record<string, string> = {
equals: '=', // case 'equals'
not_equals: '!=', // case 'notequals'
contains: 'contains', // case 'contains'
icontains: 'icontains', // case 'icontains'
not_contains: 'notcontains', // case 'notcontains'
starts_with: 'startswith', // case 'startswith'
ends_with: 'endswith', // case 'endswith'
Expand Down
8 changes: 8 additions & 0 deletions packages/plugin-view/src/config/view-config-utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,6 +45,14 @@ const CANONICAL_TO_BUILDER: Record<ViewFilterOperator, string | null> = {
'equals': 'equals',
'not_equals': 'notEquals',
'contains': 'contains',
// Case-insensitive contains, canonical in `VIEW_FILTER_OPERATORS` as of
// `@objectstack/spec` 17.1.0 (objectui#5328). The builder HAS an equivalent
// — `containsCaseInsensitive`, which authors the spec's `$icontains`
// (filter-builder.tsx:160, objectui#4023) — so this is a real row and not a
// `null`: mapping it to `contains` would quietly rewrite a case-insensitive
// filter into a case-sensitive one the next time the view was saved, the
// same folding the `is_null` note below refuses.
'icontains': 'containsCaseInsensitive',
'not_contains': 'notContains',
'starts_with': 'startsWith',
'ends_with': 'endsWith',
Expand Down
Loading
Loading