diff --git a/.changeset/11068-grid-declared-keys.md b/.changeset/11068-grid-declared-keys.md index 0645008bf5..90a0ee96d4 100644 --- a/.changeset/11068-grid-declared-keys.md +++ b/.changeset/11068-grid-declared-keys.md @@ -40,3 +40,9 @@ now authors only keys the grid reads. **Unchanged.** An `object-view`'s `table` slot still withholds `description` and `emptyState`. Write them on the `object-grid` node itself. `keyboardNavigation` is still declared and still not read. + +**Correction, 2026-10-03 (objectui#11227).** The Zod twin's `emptyState` is no longer three +optional strings. It is the spec's `EmptyStateSchema` by reference, which `@objectstack/spec` +17.6.0 declares on its `object-grid` row: `title` and `message` are `string | I18nLabel` (a +plain string or an inline locale map, which the grid resolves against the display locale), and +an unknown member is still refused by name (`.changeset/11227-object-grid-17-6-keys.md`). diff --git a/.changeset/11068-row1-booking-17-6.md b/.changeset/11068-row1-booking-17-6.md new file mode 100644 index 0000000000..3af92c9bdb --- /dev/null +++ b/.changeset/11068-row1-booking-17-6.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change in `@object-ui/console`; no published behaviour changes. Under objectui#11438 ruling A″, row 1 of the `@objectstack/spec` 17.6.0 bump, `object-grid.keyboardNavigation`, is booked as owed to objectui#11068 in `registry-inputs-spec-parity.test.ts`, with an expiry (2026-11-02, or when objectui#11068's build lands). The entry joins the file's objectui#11111 ledger, whose `unpublishedKeys` cap rises by exactly this one entry. The GA-block split row admits this one id by name and refuses every other exemption on the four GA blocks as before. The file sits under `apps/console/src/__tests__/`, which nothing outside `__tests__/` imports, so neither the console bundle nor `plugin.*` carries it. diff --git a/.changeset/11094-value-source-empty-operator.md b/.changeset/11094-value-source-empty-operator.md new file mode 100644 index 0000000000..71456cc58d --- /dev/null +++ b/.changeset/11094-value-source-empty-operator.md @@ -0,0 +1,13 @@ +--- +'@object-ui/core': minor +--- + +`ValueDataSource` executes the empty pair `is_empty` / `is_not_empty` and the `$empty` operator, and `convertFiltersToAST` lowers `$empty` (objectui#11094). + +`@objectstack/spec` 17.6.0 admits `$empty` to `FILTER_OPERATORS` (objectstack#20446). It also stops folding `is_empty` / `isempty` onto `is_null`, and `is_not_empty` / `isnotempty` onto `is_not_null`, and lowers them to `$empty: true` / `$empty: false` instead (objectstack#20570). `ValueDataSource` canonicalises AST operators through the spec's `canonicalAstOperator` and had arms only for the null pair. So on 17.6.0 a stored `is_empty` or `is_not_empty` rule on a `provider: 'value'` source reached the refusal arm: it selected no row and logged one refusal per `find()`. + +**After.** `is_empty` and `$empty: true` select a row whose value is null, absent, `''` or `[]`. `is_not_empty` and `$empty: false` select exactly the other rows. The answer is the spec's own `isEmptyFilterValue`, called without a field declaration, because this adapter holds none. That is the by-value reading the spec gives a face with no declarations, the same call `@objectstack/formula`'s matcher makes. A `$empty` flag that is not `true` or `false` is refused like every other refusal in this adapter: the row is excluded and the reason is logged once. + +**What moves for a stored rule.** On 17.5.0 the empty pair was a null test here. A row whose value is `''` or `[]` is now selected by `is_empty` and no longer by `is_not_empty`. Rows holding a value, null or no key answer as before. + +`convertFiltersToAST` lowers `{ f: { $empty: true } }` to `['f', 'is_empty', true]` and `$empty: false` to `['f', 'is_not_empty', true]`, the inverse of the spec's `parseFilterAST`. A flag that is not a boolean throws a `FilterOperatorError` (`code: 'INVALID_FILTER'`, `httpStatus: 400`). Before this, the operator reached the unknown-operator throw. The unknown-operator message now lists `$empty` among the supported operators. `@object-ui/data-objectstack`'s `find()` lowers an object `$filter` through this function, so an object filter carrying `$empty` reaches the wire there instead of throwing. diff --git a/.changeset/11227-object-grid-17-6-keys.md b/.changeset/11227-object-grid-17-6-keys.md new file mode 100644 index 0000000000..a1bc3f18b8 --- /dev/null +++ b/.changeset/11227-object-grid-17-6-keys.md @@ -0,0 +1,39 @@ +--- +'@object-ui/plugin-grid': minor +'@object-ui/plugin-list': patch +'@object-ui/types': minor +--- + +An `object-grid` publishes `description` and `emptyState` now that `@objectstack/spec` 17.6.0 +declares them on its `object-grid` row, and `emptyState.title` / `.message` take an inline +locale map on both the grid and the list view (objectui#11227). + +**`@object-ui/plugin-grid` (feature and fix).** + +- `description` and `emptyState` are in the grid's declared inputs, so the designer panel, the + component manifest and the generated `sdui-intrinsics.d.ts` offer them, and the SDUI parser + no longer reports an authored `emptyState` as `unknown-prop`. `description` declares both + arms of the `I18nLabel` union, so a locale map on it is not a `type-mismatch`. +- `emptyState.title` and `emptyState.message` are resolved against the display locale, as + `label` and `description` are. Before, a locale map on either reached the empty-state + component as an object, and the grid failed to render. A map with no usable entry keeps + that member's default: the table's "No results found" heading, and no message line. +- `keyboardNavigation`, the row's third 17.6.0 key, is not published. The spec marks it + `[EXPERIMENTAL — not enforced]`, and nothing in the grid reads it yet. + +**`@object-ui/plugin-list` (fix).** A list view's authored `emptyState.title` and +`emptyState.message` are resolved against the display locale. Before, a locale map on either +was treated as absent, and the empty state drew the default copy in every locale. A plain +string is drawn as before. + +**`@object-ui/types` (breaking for a reader of the text members, hence `minor`).** + +- `ObjectGridSchema.emptyState` is the spec's `EmptyState`, and the Zod twin takes the spec's + `EmptyStateSchema` by reference. `title` and `message` are `string | I18nLabel`, `icon` stays + a string, and an unknown member is still refused by name. A locale map on `title` or + `message` now parses, and a value that is neither a string nor a map is refused at that + member. +- `NamedListView.emptyState` is `ListViewSchema['emptyState']`, the same spec type. +- TypeScript code that reads `emptyState.title` or `.message` as a `string` no longer + type-checks. Resolve the value first, for example with `resolveI18nLabel` from + `@objectstack/spec/ui`. diff --git a/.changeset/11227-types-spec-floor-17-6.md b/.changeset/11227-types-spec-floor-17-6.md new file mode 100644 index 0000000000..dadcec7b2b --- /dev/null +++ b/.changeset/11227-types-spec-floor-17-6.md @@ -0,0 +1,18 @@ +--- +'@object-ui/types': patch +--- + +Raise `@object-ui/types`' declared `@objectstack/spec` floor from `^17.5.0` to +`^17.6.0` (objectui#11227). The package's published types now read +`EmptyState` from `@objectstack/spec/ui`, because the `object-grid` +`emptyState` member is typed by reference to the protocol's own shape, and its +published zod mirror reads `EmptyStateSchema` from the same subpath. Neither +export exists in `@objectstack/spec` 17.5.0, which the old range admitted; both +ship from 17.6.0. A consumer resolution landing on 17.5.0 therefore got types +and a mirror that name exports their declared spec dependency does not have. + +Nothing moves at runtime in this repository: the lockfile already resolves +`@objectstack/spec` 17.6.0 for this specifier, so only the declared range was +stale. For a consumer, the range now refuses `@objectstack/spec` 17.5.0. The +instrument that re-derives this floor is `pnpm check:spec-floors`, read over a +full workspace build. diff --git a/.changeset/11276-object-grid-properties-bag.md b/.changeset/11276-object-grid-properties-bag.md index defd40bcce..0e5378dac3 100644 --- a/.changeset/11276-object-grid-properties-bag.md +++ b/.changeset/11276-object-grid-properties-bag.md @@ -29,3 +29,10 @@ Nothing changes at render time: `SchemaRenderer` hoists every `properties` key o **What did not move.** The TypeScript `ObjectGridSchema` and its zod mirror `ObjectGridSchema` stay published and unchanged in shape. They are the node as `ObjectGrid` reads it after the hoist, and as code composes it, and the `object-view` `table` slot is still built from the mirror. **Correction, 2026-10-02 (objectui#6152, round 6).** The list above of keys "the row declares and the flat mirror does not" is no longer true for ten of them: the flat `ObjectGridSchema` mirror now declares `rowHeight`, `grouping`, `aggregations`, `conditionalFormatting`, `rowColor`, `bulkActionDefs`, `navigation`, `singleClickEdit`, `reorderableColumns` and `operations`, each typed as its TypeScript twin types it (`.changeset/6152-objectgrid-round6-mirrored.md`). `resizableColumns` is still the row's alone. The bag is unchanged: the spec row still judges these keys inside `properties`. + +**Correction, 2026-10-03 (objectui#11227).** At `@objectstack/spec` 17.6.0 the row declares +`description`, `emptyState` and `keyboardNavigation`, so the sentence above saying it does not +declare `emptyState` or `keyboardNavigation` no longer holds. All three parse in the bag, and +each written flat on the node is refused by name toward its bag member, like the row's other +members. `emptyState` is authorable in a document, and so is `description`, which no longer +stays on the node beside the bag (`.changeset/11227-object-grid-17-6-keys.md`). diff --git a/.changeset/11276-plugin-grid-properties-bag-docs.md b/.changeset/11276-plugin-grid-properties-bag-docs.md index d20d56288d..64fafe006b 100644 --- a/.changeset/11276-plugin-grid-properties-bag-docs.md +++ b/.changeset/11276-plugin-grid-properties-bag-docs.md @@ -5,3 +5,8 @@ docs(plugin-grid): authored `object-grid` examples write their props in the `properties` bag (objectui#11276) The README's authored `object-grid` examples now write `{ "type": "object-grid", "properties": { … } }`, the spelling `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row declares and `objectui validate` now requires, each bag checked with `satisfies ObjectGridProps` (the spec's row type). `emptyState` is described as what it is today: a key `ObjectGrid` reads that the spec's row does not declare, so not authorable in a document. The examples that mount `ObjectGrid` directly keep the flat `schema` prop: a component mounted without `SchemaRenderer` receives the node as it reads it, and nothing hoists a bag there. Under `src/`, a new render pin holds that the bag and a stored flat node draw the same grid, and the CRUD-guide render pin reads the guide's bag. No runtime change. + +**Correction, 2026-10-03 (objectui#11227).** The README no longer describes `emptyState` as +not authorable: `@objectstack/spec` 17.6.0 declares it, with `description`, on the `object-grid` +row, so both are written in the `properties` bag, and the README's example shows them there +(`.changeset/11227-object-grid-17-6-keys.md`). diff --git a/.changeset/11334-row3-booking-17-6.md b/.changeset/11334-row3-booking-17-6.md new file mode 100644 index 0000000000..fe7f5c1618 --- /dev/null +++ b/.changeset/11334-row3-booking-17-6.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change in `@object-ui/types` and `@object-ui/plugin-dashboard`; no published behaviour changes. Under objectui#11438 ruling A″, the spec's dimensionless measure-arity check is booked as owed to objectui#11334, with an expiry, in three tests. In `spec-object-refinements-7715.test.ts` it goes on the census's `DashboardWidgetSchema (complex.zod.ts)` row. In the two objectui#8894 measure-door tests, it is a bounded ledger of the seven types objectui's door does not refuse yet, and a cap row requires that set to equal the ledger. All three files sit under `__tests__/`, which each package's `tsconfig.json` excludes from the published `dist`. diff --git a/.changeset/11344-row5-booking-17-6.md b/.changeset/11344-row5-booking-17-6.md new file mode 100644 index 0000000000..02e0740a51 --- /dev/null +++ b/.changeset/11344-row5-booking-17-6.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change in `@object-ui/core`; no published behaviour changes. Under objectui#11438 ruling A″, row 5 of the `@objectstack/spec` 17.6.0 bump, `ActionSchema.outcomeMessages`, is booked as owed to objectui#11344 in `actionKeys.pin.test.ts`, with an expiry (2026-11-02, or when objectui#11344's slice lands, whichever is first). The file's spec-inventory row now requires its `missing` set to equal that one-entry ledger exactly, and a new row asserts today's difference: the spec declares the key, and neither `SPEC_ACTION_KEYS` nor `KNOWN_ACTION_KEYS` knows it. The file sits under `packages/core/src/actions/__tests__/`, which the package's `tsconfig.json` excludes from the build program, so the published `dist` does not carry it. diff --git a/.changeset/11438-objectstack-17-6-bump.md b/.changeset/11438-objectstack-17-6-bump.md new file mode 100644 index 0000000000..e018cd16ae --- /dev/null +++ b/.changeset/11438-objectstack-17-6-bump.md @@ -0,0 +1,11 @@ +--- +'@object-ui/components': patch +'@object-ui/i18n': patch +'@object-ui/console': patch +--- + +objectui now resolves `@objectstack/*` 17.6.0 (objectui#11438). One declared range moves: `@object-ui/types` raises its `@objectstack/spec` floor to `^17.6.0` in objectui#11227's own changeset, because its published types read `EmptyState` / `EmptyStateSchema`, which 17.5.0 does not export. Every other `@objectstack/*` range already admits 17.6.0 and stays as it was, and `check:spec-floors` names no other floor that has to rise. Everything below is a contract move 17.6.0 made, and it already reaches any consumer that resolves `@objectstack/spec ^17.6`. + +- `@object-ui/components`: the `page:header` registration no longer publishes a `breadcrumb` input. 17.6.0 retires the key (objectstack#20758) and refuses it by name; the renderer has ignored it since objectui#11166. +- `@object-ui/i18n`: a served translation document is recognised as a spec payload when it carries only the `picklists` group, which 17.6.0's `GetTranslationsResponseSchema` adds. Such a bundle used to be returned untransformed, so nothing read it. The group is now namespaced under `app` like every other group. +- `@object-ui/console`: the bundle inlines the 17.6.0 packages, so its client-side validation answers as a 17.6.0 server does. The first screen is smaller: 17.6.0's spec root no longer carries the migration chain, so the eager closure is 250,096 gzipped bytes lighter (measured against a `main` build), and the bundle budget comes down by that amount. diff --git a/apps/console/src/__tests__/i18nLabelInputsManifest-10993.test.ts b/apps/console/src/__tests__/i18nLabelInputsManifest-10993.test.ts index a228dcfabf..e0c35d472e 100644 --- a/apps/console/src/__tests__/i18nLabelInputsManifest-10993.test.ts +++ b/apps/console/src/__tests__/i18nLabelInputsManifest-10993.test.ts @@ -15,9 +15,11 @@ * (`ObjectMetric.i18nLabel-10993.test.tsx` in `plugin-dashboard` pins the * render); * - `object-grid`: `label` (`ObjectGrid.labelI18nLabel-10993.test.tsx` in - * `plugin-grid`). `view:grid` registers the same inputs, so its declaration - * carries the same two arms, but it is not in the public manifest, so it has - * no row here: this door does not reach it; + * `plugin-grid`), and `description` since objectui#11227 published it at + * `@objectstack/spec` 17.6.0 (`ObjectGrid.declaredKeys-11068.test.tsx`). + * `view:grid` registers the same inputs, so its declaration carries the same + * two arms, but it is not in the public manifest, so it has no row here: this + * door does not reach it; * - `record:related_list`: `title` * (`record-related-list.titleI18nLabel-10993.test.tsx` in `plugin-detail`). * @@ -45,7 +47,7 @@ const manifest = manifestFromConfigs( /** Each block, a minimal node the gate reads as clean, and its `I18nLabel` inputs. */ const BLOCKS: ReadonlyArray<{ block: string; base: Record; keys: readonly string[] }> = [ { block: 'object-metric', base: { objectName: 'deal' }, keys: ['label', 'description', 'title'] }, - { block: 'object-grid', base: { objectName: 'account' }, keys: ['label'] }, + { block: 'object-grid', base: { objectName: 'account' }, keys: ['label', 'description'] }, { block: 'record:related_list', base: { objectName: 'task', relationshipField: 'account_id', columns: ['name'] }, diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index a7941be355..1c88395bfd 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -643,6 +643,23 @@ const MINOR_17_5_LOADED_BLOCKS = ['object-gantt', 'object-map', 'object-timeline /** Does the installed `@objectstack/spec` carry the loaded 17.5.0 blocks? `every`, for the same reason. */ const specCarries175LoadedBlocks = MINOR_17_5_LOADED_BLOCKS.every((type) => type in ComponentPropsMap); +/** + * The one block `@objectstack/spec` 17.6.0 newly carries (objectui#11438): the + * map went from 55 entries to 56 when `record:line_items` gained its row + * (objectstack `24c554da`, #21142 / #21244). `@object-ui/plugin-form` + * registers it eagerly with `inputs`, so this file JUDGES it from that pin on. + * Its differences are booked to objectui#11536 under objectui#11438 ruling A′ + * (see `OWED_TO` above), not declared in the bump. + * + * Same shape as `MINOR_17_5_BLOCKS`: this repo registered the block with + * `inputs` before the spec described it, so what moved at the bump is the + * SPEC's side. It enters `covered` the moment the installed spec carries it. + */ +const MINOR_17_6_BLOCKS = ['record:line_items']; + +/** Does the spec this tree resolves carry the 17.6.0 set? `every`, for the reason `specCarriesGaBlocks` gives. */ +const specCarries176Blocks = MINOR_17_6_BLOCKS.every((type) => type in ComponentPropsMap); + const EXPECTED_COVERED = [ ...PINNED_EXPECTED_COVERED, ...LAZY_REGISTERED_BLOCKS, @@ -650,6 +667,7 @@ const EXPECTED_COVERED = [ ...(specCarries171Blocks ? MINOR_17_1_BLOCKS : []), ...(specCarries175Blocks ? MINOR_17_5_BLOCKS : []), ...(specCarries175LoadedBlocks ? MINOR_17_5_LOADED_BLOCKS : []), + ...(specCarries176Blocks ? MINOR_17_6_BLOCKS : []), ].sort(); /** @@ -722,6 +740,30 @@ function isRetiredUpstream(type: string): boolean { * - objectui#8649 — `enforceFieldSecurity`, `redactFields` and * `requiredPermissions` on the three `record:*` blocks. * + * The 17.6.0 bump (objectui#11438) books under the same decision, with a + * fourth owner. objectui#11438 was ruled A′ (record 5965062249): its one group + * with no owner card, `record:line_items` — which 17.6.0 gives a + * `ComponentPropsMap` row (objectstack `24c554da`, #21142 / #21244), so this + * file judges it from that pin on — "follows objectui#11111 decision three B + * (5902351047): an owner card is filed and ledgered on the trunk with an + * expiry". The card filed for it is: + * + * - objectui#11536 — every entry on `record:line_items`: the keys its spec + * row declares that `inputs` does not publish, and the member pins its + * array/object-armed inputs owe. + * + * A fifth owner, under the same bump: objectui#11438 ruling A″ (record + * 5968177777, the maintainer's order to land 17.6.0 first) books a row whose + * owner card's slice is not accepted when the row-3 booking is pushed "the + * same way, to their own cards". Row 1 is such a row: + * + * - objectui#11068 — `object-grid.keyboardNavigation`, the one key on a GA + * block that 17.6.0's row declares and `inputs` does not publish. That card + * is BUILDING the key's reader and publishes the key with it; this is the + * only OWED entry on any of the four GA blocks, pinned by name in `the four + * GA blocks resolve their ruled split`, because objectui#4648's split + * otherwise refuses any exemption on them (see THE FOUR GA BLOCKS below). + * * Each owner card decides every key by its own measurement: declare what the * renderer honours, refuse or retire what it does not. ⛔ Batch-declaring an * input a renderer does not honour is the failure decision 3 = B exists to @@ -733,21 +775,68 @@ function isRetiredUpstream(type: string): boolean { * new difference beyond the listed entries is still red: every direction below * subtracts exact ids, never a block or a prefix. Each owner card's landing * strikes its entries and lowers the matching cap in the same change; the last - * of the three to land restores the empty ledgers and the zero caps this file - * held before the bump. + * of the five to land restores the empty ledgers and the zero caps this file + * held before the 17.5.0 bump. * * EXPIRY. None of these ledgers has a date field, and this repo's SUNSET * convention is a condition re-derived from the installed spec, not a date, so * nothing here can enforce a calendar deadline. The date is stated in every * entry's reason and asserted PRESENT; it is read by the owner cards, not by a - * clock. + * clock. The 17.6.0 entries carry their own date, set the same distance from + * their ruling as the 17.5.0 date is from its own: record 5902351047 was ruled + * 2026-09-30 and its entries expire 2026-10-30; record 5965062249 was ruled + * 2026-10-03, so objectui#11536's entries expire 2026-11-02. Record 5968177777 + * (ruling A″) was ruled the same day, so objectui#11068's entry shares that + * date: it expires 2026-11-02, or when that card's build lands. */ const OBJECTUI_11111_EXPIRES = '2026-10-30'; -/** The three owner cards the ruling names, and nothing else may own an entry. */ -const OBJECTUI_11111_OWNERS = ['objectui#11168', 'objectui#8652', 'objectui#8649'] as const; +/** The 17.6.0 bookings' date: objectui#11438 ruling A′ (record 5965062249), plus thirty days; ruling A″ (record 5968177777) is of the same day. */ +const OBJECTUI_11438_EXPIRES = '2026-11-02'; + +/** + * The owner cards, and nothing else may own an entry: the three decision 3 = B + * names, objectui#11536, which objectui#11438 ruling A′ filed for the 17.6.0 + * bump's one group with no owner, and objectui#11068, whose row ruling A″ + * booked to it. + */ +const OBJECTUI_11111_OWNERS = [ + 'objectui#11168', + 'objectui#8652', + 'objectui#8649', + 'objectui#11536', + 'objectui#11068', +] as const; type Objectui11111Owner = (typeof OBJECTUI_11111_OWNERS)[number]; +/** The ruling every entry cites, whichever bump booked it. */ +const DECISION_3_B = 'objectui#11111 decision 3 = B (record 5902351047)'; + +/** + * Where each owner's entries were booked: the bump that surfaced them, the + * record that booked them, and their expiry. The 17.5.0 owners were booked by + * decision 3 = B itself; objectui#11536 by the ruling that applies it to 17.6.0; + * objectui#11068 by the amendment of that ruling that books row 1. + */ +const OBJECTUI_11111_BOOKINGS: Record< + Objectui11111Owner, + { bump: string; bookedBy: string; expires: string } +> = { + 'objectui#11168': { bump: '17.5.0', bookedBy: DECISION_3_B, expires: OBJECTUI_11111_EXPIRES }, + 'objectui#8652': { bump: '17.5.0', bookedBy: DECISION_3_B, expires: OBJECTUI_11111_EXPIRES }, + 'objectui#8649': { bump: '17.5.0', bookedBy: DECISION_3_B, expires: OBJECTUI_11111_EXPIRES }, + 'objectui#11536': { + bump: '17.6.0', + bookedBy: `objectui#11438 ruling A′ (record 5965062249), which applies ${DECISION_3_B} to the 17.6.0 bump`, + expires: OBJECTUI_11438_EXPIRES, + }, + 'objectui#11068': { + bump: '17.6.0', + bookedBy: `objectui#11438 ruling A″ (record 5968177777), which books row 1 of the 17.6.0 bump in the shape of ${DECISION_3_B}`, + expires: OBJECTUI_11438_EXPIRES, + }, +}; + /** The security triple 17.5.0 declares on exactly the three record blocks (objectui#8649's). */ const FIELD_SECURITY_TRIPLE = ['enforceFieldSecurity', 'redactFields', 'requiredPermissions']; @@ -755,10 +844,14 @@ const FIELD_SECURITY_TRIPLE = ['enforceFieldSecurity', 'redactFields', 'required const OWED_PREFIX = 'OWED TO '; /** One ruled entry's reason: owner first, then what is owed, then the ruling and the expiry. */ -const OWED_TO = (owner: Objectui11111Owner, what: string): string => - `${OWED_PREFIX}${owner}. ${what} Booked by objectui#11111 decision 3 = B (record 5902351047): ` + - `the 17.5.0 bump re-pins and declares nothing; ${owner} decides it by its own measurement. ` + - `Expires ${OBJECTUI_11111_EXPIRES}, or when ${owner} lands, whichever is first.`; +const OWED_TO = (owner: Objectui11111Owner, what: string): string => { + const { bump, bookedBy, expires } = OBJECTUI_11111_BOOKINGS[owner]; + return ( + `${OWED_PREFIX}${owner}. ${what} Booked by ${bookedBy}: ` + + `the ${bump} bump re-pins and declares nothing; ${owner} decides it by its own measurement. ` + + `Expires ${expires}, or when ${owner} lands, whichever is first.` + ); +}; /** `BLOCK.KEY` entries for every listed key of one block, all with the same owner and reason. */ const owedEntries = ( @@ -771,6 +864,10 @@ const owedEntries = ( /** Which owner the ruling routes an entry id to — asserted against every entry's reason. */ function objectui11111OwnerOf(id: string): Objectui11111Owner { + // objectui#11438 ruling A′ routes by BLOCK: every entry on `record:line_items` is objectui#11536's. + if (id.slice(0, id.indexOf('.')) === 'record:line_items') return 'objectui#11536'; + // Ruling A″ routes by exact ID: row 1 is this one key, and objectui#11068 owns nothing else. + if (id === 'object-grid.keyboardNavigation') return 'objectui#11068'; const key = id.slice(id.indexOf('.') + 1).split(':')[0]; if (FIELD_SECURITY_TRIPLE.includes(key)) return 'objectui#8649'; if (key === 'navigation') return 'objectui#8652'; @@ -792,9 +889,9 @@ const owedIdsOf = (ledger: Record): string[] => const OBJECTUI_11111_LEDGER_CAPS = { unjudgedBlocks: 0, // objectui#11168 loaded and judged all four: slice 3 object-map and object-tree, slice 4 object-gantt, slice 5 object-timeline offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name - unpublishedKeys: 3, // objectui#11168: 3 (action:button endpoint + undoable, action:icon endpoint); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing) + unpublishedKeys: 12, // objectui#11168: 1 (action:button undoable; the two `endpoint` entries left at the 17.6.0 bump, objectui#11438, when the spec stopped declaring the key); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing); objectui#11536: 10 (record:line_items, booked by objectui#11438 ruling A′); objectui#11068: 1 (object-grid keyboardNavigation, booked by objectui#11438 ruling A″) refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout - memberPins: 0, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3 + memberPins: 2, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3; objectui#11536: 2 (record:line_items columns and dataSource, booked by objectui#11438 ruling A′) } as const; /** @@ -1408,28 +1505,20 @@ const UNPUBLISHED_EXEMPTIONS: Record = { */ // objectui#11168 slice 1 measured every `action:button` / `action:icon` key // below through the real `SchemaRenderer` and `ActionRunner` and DECLARED the - // ones honoured on the block path — all but the three still listed here, - // which the slice held back with its measurement and a question for the - // card. The pins are + // ones honoured on the block path — all but three, which the slice held + // back with its measurement and a question for the card. The pins are // `packages/components/src/renderers/action/__tests__/action-button-icon-inputs-11168.test.tsx`. - ...owedEntries( - 'action:button', - ['endpoint'], - 'objectui#11168', - 'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `endpoint` and the runner\'s built-in `api` executor reads it, but the console registers its own `api` handler, which reads `target` and never `endpoint` — so on the console an `api` action with an `endpoint` sends nothing there.', - ), + // Two of the three, `endpoint` on `action:button` and on `action:icon`, left + // at the 17.6.0 bump (objectui#11438): 17.6.0 refuses `endpoint` on both + // blocks (objectstack `b3917d90`, the rename to `target`), so the entries no + // longer named a key the spec declares and `every unpublished-key exemption + // names a key the spec really declares` went red on them. One is left. ...owedEntries( 'action:button', ['undoable'], 'objectui#11168', 'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `undoable`, but the runner\'s `operation: update` path and the console runtime offer Undo only with a host `_rowRecord` stash this block never writes; only the record page\'s own `api` handler honours it.', ), - ...owedEntries( - 'action:icon', - ['endpoint'], - 'objectui#11168', - 'A SPEC KEY HELD UNPUBLISHED AFTER MEASUREMENT (slice 1): the block forwards `endpoint` and the runner\'s built-in `api` executor reads it, but the console registers its own `api` handler, which reads `target` and never `endpoint` — so on the console an `api` action with an `endpoint` sends nothing there.', - ), // `action:group`'s `location` / `visible` and `action:menu`'s `size` / // `visible` stood here until objectui#11168 slice 1 measured each against its // renderer through the real `SchemaRenderer` and DECLARED all four — the @@ -1444,6 +1533,49 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // declared all three keys on the three blocks' `inputs`, with the contract's // types and describe text, so each entry went stale and `carries no stale // unpublished-key exemption` would refuse it. + + /* + * ⚠️ THE 17.6.0 BOOKINGS — objectui#11438 ruling A′ (record 5965062249), + * which applies objectui#11111 decision 3 = B to the 17.6.0 bump. + * + * The spec keys `record:line_items` declares from 17.6.0 on that its + * `inputs` do not publish. The same bar and the same ruling as the 17.5.0 + * bookings above: the bump declares nothing, and objectui#11536 measures + * each key against the line-items renderer, declares what is honoured and + * leaves the rest out. Capped with the 17.5.0 entries at + * `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys`. + */ + ...owedEntries( + 'record:line_items', + [ + 'parentObject', 'parentId', 'recordId', 'title', 'readonly', 'minRows', 'maxRows', 'filter', + 'sort', 'limit', + ], + 'objectui#11536', + 'A SPEC KEY NOT PUBLISHED: `record:line_items` entered `covered` with 17.6.0 and its `inputs` omit this key its spec row declares.', + ), + + /* + * ⚠️ ROW 1 OF THE 17.6.0 BUMP — objectui#11438 ruling A″ (record 5968177777), + * which books a row whose owner card's slice is not accepted when the row-3 + * booking is pushed, "the same way, to their own cards". + * + * `object-grid.keyboardNavigation`: the 17.6.0 row declares it and the + * grid's `inputs` do not publish it. objectui#11068 is building its reader + * (arrow-key cell navigation on the WAI-ARIA grid pattern) and publishes the + * key in `GRID_QUERY_INPUTS` together with that reader, never ahead of it. + * `object-grid` is a GA block, where objectui#4648's split admits no + * exemption but the ruled carve-out, so this entry is the one exception and + * `the four GA blocks resolve their ruled split` pins it by name. Capped + * with the entries above at `OBJECTUI_11111_LEDGER_CAPS.unpublishedKeys`; + * the build's landing makes it stale, and strikes it and lowers the cap. + */ + ...owedEntries( + 'object-grid', + ['keyboardNavigation'], + 'objectui#11068', + 'A SPEC KEY NOT PUBLISHED, ITS READER IN FLIGHT: `object-grid`\'s 17.6.0 row declares `keyboardNavigation` and its `inputs` omit it; objectui#11068 publishes it with the reader it is building.', + ), }; /** @@ -1623,6 +1755,15 @@ const isDormantOnThisPin = (exemptionKey: string): boolean => { * The exemption list is therefore the carve-out and nothing else. A future key * these blocks gain is a plain A-class defect: declare it at the registration * site. Do not add an entry here to silence one. + * + * ⚠️ ONE BOOKED EXCEPTION, BY RULING AND WITH AN EXPIRY — not a silenced key. + * objectui#11438 ruling A″ (record 5968177777) books row 1 of the 17.6.0 bump, + * `object-grid.keyboardNavigation`, OWED TO objectui#11068, which declares it + * at the registration site together with the reader it is building. The entry + * expires 2026-11-02 or at that card's landing, whichever is first. + * `the four GA blocks resolve their ruled split` admits exactly that id and no + * other OWED entry on these four blocks, so the rule above still holds for + * every other key. */ const exemptedFor = (type: string): string[] => @@ -2868,6 +3009,14 @@ const MEMBER_PINS: Record = { file: 'packages/plugin-grid/src/__tests__/ObjectGrid.labelI18nLabel-10993.test.tsx', pins: 'The I18nLabel object arm on the grid\'s identity key: through the real `SchemaRenderer`, the `{ type, properties }` document, and the table caption `ObjectGrid` builds from `resolveI18nLabel(schema.label, displayLocale)`. With no regional locale mounted the display locale is the UI language, so a `properties.label` map paints its `zh-CN` entry under `zh` and its `en` entry under `en`, every map listing `en` FIRST so an `en` or first-entry fallback fails the `zh` row; a plain-string control renders as authored. The record-detail overlay heading, the third read, is `ObjectGrid.overlayTitleInlineLocale-9092.test.tsx`\'s pin; the manifest half (a map is not a `type-mismatch`) is `i18nLabelInputsManifest-10993.test.ts` in this directory. ⛔ Not the deprecated `title`, whose caption fallback still reads a map raw and is held to a later batch (objectui#10993).', }, + 'object-grid.description': { + file: 'packages/plugin-grid/src/__tests__/ObjectGrid.declaredKeys-11068.test.tsx', + pins: 'The I18nLabel object arm on the grid\'s help-text line, published at `@objectstack/spec` 17.6.0 once the row declared it (objectui#11227): through the real `SchemaRenderer` and the `{ type, properties }` document, a `properties.description` map paints its `fr` entry under a `fr` UI language, with `en` listed FIRST so an `en` or first-entry fallback fails the row; a plain string draws as authored; and a map with no usable entry draws no strip at all, the same as an absent key, because the guard reads the RESOLVED text. Pre-existing file (objectui#11068), read end to end before being cited; the manifest half (a map is not a `type-mismatch`) is `i18nLabelInputsManifest-10993.test.ts` in this directory.', + }, + 'object-grid.emptyState': { + file: 'packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx', + pins: 'All three members the spec\'s strict `EmptyStateSchema` declares, at the empty state the grid draws in place of an empty table, through the real `SchemaRenderer` and the `{ type, properties }` document (objectui#11227). `title` and `message` are I18nLabel: a map paints its `zh-CN` entry under `zh` and its `en` entry under `en`, with `en` listed FIRST so a fallback fails the `zh` row, and the two members resolve independently (a map title beside a string message). A map with no usable entry keeps that member\'s default: the table\'s own "No results found" heading, and no message line. `icon` is a plain Lucide name, read off the same object. The render-failure signature is asserted absent on every row, because the pre-fix renderer handed a map to `DataEmptyState` as an object child, which React refuses. The string arm, the empty-search case and the not-drawn-with-rows case are `ObjectGrid.declaredKeys-11068.test.tsx`\'s rows. New file.', + }, 'object-kanban.cardFields': { file: 'packages/plugin-kanban/src/__tests__/ObjectKanban.structuredMembersReachTheirSinks-8313.test.tsx', pins: 'Members are BARE FIELD NAMES, and the pin is explicit about WHICH question it answers (the objectui#8269 trap): `resolveKanbanCardFields` answers which names the AUTHOR chose — authored order preserved, and NOT filtered against the object definition, which is the one behaviour that separates the explicit list from the `highlightFields` fallback it overrides (that fallback IS filtered). Which cells a card ends up carrying is a SECOND and narrower question, measured separately at the render, because the card loop further drops a name duplicating the title and one whose value is empty. An empty array reading as omitted is the control that keeps the fallback rows from being vacuous. The spec side is `z.array(z.string())`, so it constrains the member KIND but says nothing about either read — the sinks are the whole of the member contract (objectui#8313).', @@ -3392,6 +3541,19 @@ const MEMBER_PIN_EXEMPTIONS: Record = { // slice 1, and `element:definition-list.items` and `element:repeater`'s // `fields`, `filter` and `sort` by slice 2, each registered in // `MEMBER_PINS`. Header kept as a landmark for a future grep. + + // ⚠️ 17.6.0 BOOKINGS — objectui#11438 ruling A′ (record 5965062249), which + // applies objectui#11111 decision 3 = B to the 17.6.0 bump. The + // array/object-armed inputs of `record:line_items`, the block 17.6.0 newly + // judges, owed a member pin by objectui#11536. Counted into + // `MEMBER_PIN_EXEMPTION_CEILING` (0 -> 2) and capped at these two; that + // card's pins strike them. + ...owedEntries( + 'record:line_items', + ['columns', 'dataSource'], + 'objectui#11536', + 'A MEMBER PIN OWED: this `record:line_items` input is array/object-armed on a block 17.6.0 newly judges, and no pin states its members.', + ), }; /** @@ -4206,8 +4368,19 @@ const NEWLY_JUDGED_UNPINNED_MEMBERS: string[] = []; * real renderer, and deleted their four booked entries. Nothing else stood in * the list, so the ceiling follows it to 0 and this direction now runs with no * exemption at all. A new array/object-armed key is answered with a pin. + * + * ## 0 -> 2 (objectui#11438, the @objectstack/spec 17.6.0 bump) — a RULING, + * not room, the same rise the 17.5.0 bump took + * + * 17.6.0 newly judges `record:line_items`, which brings two array/object-armed + * inputs with no pin: `columns` and `dataSource`. objectui#11438 ruling A′ + * (record 5965062249) applies objectui#11111 decision 3 = B to the bump, so + * both are booked OWED to objectui#11536 rather than pinned in it. 0 + 2 booked + * = 2, the exact count, and `the objectui#11111 ledger is capped at exactly the + * entries it lists` caps the two and routes each to objectui#11536. + * objectui#11536's pins take this back to 0. */ -const MEMBER_PIN_EXEMPTION_CEILING = 0; +const MEMBER_PIN_EXEMPTION_CEILING = 2; /** * Every test file a member pin can live in, as LAZY `?raw` loaders. @@ -4374,8 +4547,15 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // `object-gantt`: 37 -> 38 judged, 11 -> 10 ledgered. 38 + 7 + 10 = 55. // Slice 5 loads `object-timeline`, the last of the four: 38 -> 39 judged, // 10 -> 9 ledgered. 39 + 7 + 9 = 55. - specCarried: 55, - judged: 39, + // + // 55 -> 56 on the `@objectstack/spec` 17.6.0 pin (objectui#11438): one + // new block, `record:line_items`, registered eagerly with `inputs`, so it + // is judged (`MINOR_17_6_BLOCKS`, 39 -> 40) and its differences are + // booked to objectui#11536 under objectui#11438 ruling A′. + // `registeredPropless` and `ledgeredUnjudgeable` do not move. + // 40 + 7 + 9 = 56. + specCarried: 56, + judged: 40, registeredPropless: 7, ledgeredUnjudgeable: 9, }); @@ -4779,21 +4959,42 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) ).toContain(`object-grid.${key}`); } + // ⚠️ THE ONE BOOKED KEY — objectui#11438 ruling A″ (record 5968177777). + // Row 1 of the 17.6.0 bump, `object-grid.keyboardNavigation`, is OWED TO + // objectui#11068, which publishes it together with the reader it is + // building. It is neither declared nor carved out, so it is admitted BY + // NAME: the OWED entries on the four blocks must be exactly this list, and a + // second one is red here whatever the ledger cap says. It expires 2026-11-02 + // or at that card's landing; the landing declares the key, which turns the + // first assertion in the loop below red until this list and the entry are + // struck together. + const BOOKED_GA_KEYS = ['object-grid.keyboardNavigation']; + expect( + owedIdsOf(UNPUBLISHED_EXEMPTIONS).filter((id) => GA_ONLY_BLOCKS.includes(splitExemptionKey(id)[0])), + 'an OWED entry on a GA block that objectui#11438 ruling A″ did not book — declare the key instead', + ).toEqual(BOOKED_GA_KEYS); + // Everything else the four blocks' spec schemas declare is DECLARED, and // carries no exemption. Stated as an exact set difference rather than a // spot-check so a key added by a later GA cannot slip through as neither. for (const type of GA_ONLY_BLOCKS) { const carved = type === 'object-grid' ? CARVED_OUT_GRID_KEYS : []; + const booked = BOOKED_GA_KEYS.filter((id) => splitExemptionKey(id)[0] === type).map( + (id) => splitExemptionKey(id)[1], + ); const shouldPublish = specTopLevelKeys(type) .filter((key) => !(key in GLOBALLY_UNPUBLISHED_SPEC_KEYS)) .filter((key) => !carved.includes(key)); const declared = new Set(declaredInputs(type) ?? []); expect( shouldPublish.filter((key) => !declared.has(key)), - `${type} does not publish these spec keys, and they are not the ruled carve-out`, - ).toEqual([]); + `${type} does not publish these spec keys, and they are not the ruled carve-out or the ` + + 'booked key — or a booked key is declared now, so strike its booking', + ).toEqual(booked); expect( - shouldPublish.filter((key) => Object.keys(UNPUBLISHED_EXEMPTIONS).includes(`${type}.${key}`)), + shouldPublish + .filter((key) => !booked.includes(key)) + .filter((key) => Object.keys(UNPUBLISHED_EXEMPTIONS).includes(`${type}.${key}`)), `${type} exempts a key it declares — an exemption may not stand in for a declaration here`, ).toEqual([]); } @@ -5756,6 +5957,10 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // entries must number EXACTLY its cap (so one more is red, and a struck one // demands the cap come down with it), each must be routed to the card the // ruling names for it, and each must carry the ruling and its expiry. + // objectui#11438 ruling A′ (record 5965062249) books the 17.6.0 bump's + // `record:line_items` entries the same way, to objectui#11536, and ruling + // A″ (record 5968177777) books row 1, `object-grid.keyboardNavigation`, to + // objectui#11068. const ledgers: Array<[keyof typeof OBJECTUI_11111_LEDGER_CAPS, Record]> = [ ['unjudgedBlocks', UNJUDGED_SPEC_BLOCKS], ['offSpecInputs', OFF_SPEC_EXEMPTIONS], @@ -5781,8 +5986,14 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) expect(reason, `${id} does not cite the ruling`).toContain( 'objectui#11111 decision 3 = B (record 5902351047)', ); + // The record that booked it: decision 3 = B itself for the 17.5.0 + // owners, objectui#11438 ruling A′ for objectui#11536, ruling A″ for + // objectui#11068. + expect(reason, `${id} does not cite the record that booked it`).toContain( + `Booked by ${OBJECTUI_11111_BOOKINGS[owner].bookedBy}: `, + ); expect(reason, `${id} carries no expiry`).toContain( - `Expires ${OBJECTUI_11111_EXPIRES}, or when ${owner} lands`, + `Expires ${OBJECTUI_11111_BOOKINGS[owner].expires}, or when ${owner} lands`, ); reasons.push(reason); } @@ -5795,6 +6006,12 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length, ]), ), - ).toEqual({ 'objectui#11168': 3, 'objectui#8652': 0, 'objectui#8649': 0 }); + ).toEqual({ + 'objectui#11168': 1, + 'objectui#8652': 0, + 'objectui#8649': 0, + 'objectui#11536': 12, + 'objectui#11068': 1, + }); }); }); diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 67aa8d3342..8a47e5a890 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -751,14 +751,14 @@ These schemas integrate with [ObjectStack](https://objectstack.ai) for automatic A data grid that auto-fetches from an ObjectQL object definition. Includes search, filters, pagination, grouping, and inline editing. -An authored `object-grid` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#11276). `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` runs, so `ObjectGridSchema` is the node as the renderer reads it, and the table below lists its members. Base props such as `description` and `className` stay on the node, beside the bag. +An authored `object-grid` node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming its bag member, as the spec's own page component does (objectui#11276). `SchemaRenderer` hoists the bag onto the node before `ObjectGrid` runs, so `ObjectGridSchema` is the node as the renderer reads it, and the table below lists its members. Base props such as `className` stay on the node, beside the bag. `description` is a bag member: the spec's row declares it (objectstack#20694), so written on the node it is refused by name and pointed at `properties.description` (objectui#11227). ```json { "type": "object-grid", - "description": "Manage your contacts", "properties": { "objectName": "Contact", + "description": "Manage your contacts", "title": "All Contacts", "searchableFields": ["name", "email", "company"], "resizable": true, @@ -791,8 +791,8 @@ also author `showFilters` (now retired on this node, below), `striped` and `pagination.enabled`, which the validator refuses: selection is spelled `selection: { type: 'multiple' }` (`'none'`, `'single'` or `'multiple'`), and `pagination` declares no on switch — its presence enables paging. It also authored `emptyState`, which the grid reads but -the spec's row does not declare, so it is not authorable in a document today (see -its row below). +the spec's row did not declare until objectstack#20694; it is authorable in the +`properties` bag now (see its row below). | Property | Type | Description | |----------|------|-------------| @@ -801,7 +801,7 @@ its row below). | `columns` | `string[] \| ListColumn[]` | Columns to display. Either a plain array of field names (`["name", "email"]`), which auto-resolve from object metadata, or an array of `ListColumn` objects whose identity key is `field` (`{ "field": "status", "label": "Status" }`) — never `name`. **Do not mix the two forms in one array:** the array is dispatched on its first entry, so column objects sitting behind a bare string are dropped. | | `filter` | `any[]` | Pre-applied filter conditions. | | `sort` | `SortConfig[]` | Default sort configuration. The string clause (`"name desc"`) was retired in objectui#8221 and now fails validation. | -| `description` | `string \| I18nLabel` | One line of help text drawn above the grid; a per-locale map resolves like `label` (objectui#11068). | +| `description` | `string \| I18nLabel` | One line of help text drawn above the grid; a per-locale map resolves like `label` (objectui#11068). Authored in the `properties` bag: the spec's row declares it (objectstack#20694, objectui#11227). | | `searchableFields` | `string[]` | Fields included in search. | | `selection` | `SelectionConfig` | Row selection configuration. | | `pagination` | `PaginationConfig` | Pagination settings. | @@ -812,7 +812,7 @@ its row below). | `frozenColumns` | `number` | Number of columns frozen on scroll. | | `conditionalFormatting` | `ConditionalFormattingRule[]` | Row styling rules, each `{ condition, style }` — a CEL `condition` over the row's `record.*` and a CSS `style` map, the rule a list view declares; the first matching rule styles the row. The native `{ field, operator, value }` rule, its `expression`, and a colour written beside `condition` (rather than inside `style`) are retired (objectui#11533): `@object-ui/types` refuses them by name on `ObjectGridSchema`, the `object-view` `table` slot and `list-view`. `{ field: 'priority', operator: 'equals', value: 'high', backgroundColor: '#fee2e2' }` is `{ condition: "record.priority == 'high'", style: { backgroundColor: '#fee2e2' } }`. In an authored node's `properties` bag the member is `@objectstack/spec`'s row member, judged by the installed spec. A grid stored with a retired rule still paints it. | | `navigation` | `ViewNavigationConfig` | SPA navigation configuration. | -| `emptyState` | `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). **Not authorable in a document today:** the spec's `object-grid` row does not declare it, so `objectui validate` refuses it in the `properties` bag, and the strict face refuses it on the node; a host mounting `` or composing the node in code can set it. | +| `emptyState` | `EmptyState` — `{ title?, message?, icon? }` | Drawn in place of an empty table: a Lucide `icon`, a `title` (default: the table's "No results found") and a `message` (default: none). `title` and `message` are `string \| I18nLabel`, each resolved against the display locale; a map with no usable entry keeps that member's default. Not drawn when a term in the grid's own server-side search box emptied it — the table and its search box stay (objectui#11068). Authored in the `properties` bag: the spec's row declares it as the list view's own `EmptyStateSchema` (objectstack#20694, objectui#11227). | > **`name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are retired on > this node (objectui#11068).** Nothing ever read them: `rowSpecActions` / diff --git a/content/docs/guide/slotted-pages.md b/content/docs/guide/slotted-pages.md index 7b75df583f..42d20aa459 100644 --- a/content/docs/guide/slotted-pages.md +++ b/content/docs/guide/slotted-pages.md @@ -111,7 +111,6 @@ export const AccountDetailPage: Page = { properties: { title: '{name}', subtitle: '{industry} · {type}', - breadcrumb: true, }, }, }, diff --git a/content/docs/layout/page-header.mdx b/content/docs/layout/page-header.mdx index d2ebcd3115..bb7d561b11 100644 --- a/content/docs/layout/page-header.mdx +++ b/content/docs/layout/page-header.mdx @@ -67,8 +67,9 @@ renders none of them and says so once per id in the console. That is why the dem carries no actions. Placement (`locations: ['record_header']` or `record_more`), ordering and the overflow menu are covered in [Slotted pages](/docs/guide/slotted-pages). -**`breadcrumb` draws nothing:** the contract accepts it until objectstack#20758 retires it, -and the renderer ignores it, so `true`, `false` and an absent key render the same header. +**`breadcrumb` is not a key of this node.** The contract retired it (objectstack#20758) and +refuses it by name. It never drew anything: the renderer +ignores it, so a stored `true` or `false` renders the same header as an absent key. **`maxVisible` / `mobileMaxVisible`** must be positive integers: the contract rejects `0` and fractional values, and the renderer falls back to its default for a value the contract diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 9169e8321a..62cce3d0d4 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -93,6 +93,8 @@ below for why this plugin deliberately does not claim it. | `objectName` | `string` (**required**) | The object queried. There is no `object`. | | `columns` | `string[] \| ListColumn[]` | Field names or column objects — see below. | | `label` | `I18nLabel` | Table caption and export file title. | +| `description` | `I18nLabel` | One line of help text above the grid — see `description` and `emptyState` below. | +| `emptyState` | `EmptyState` (`@objectstack/spec/ui`) | `{ title?, message?, icon? }`, drawn in place of an empty table — see below. | | `filter` | `ViewFilterRule[]` | Baked into the query, lowered to `$filter`. | | `sort` | `[{ field, order }]` | Initial order; a header click replaces it. | | `pagination` | `PaginationConfig` | `{ pageSize?, pageSizeOptions? }` — **strict**, and its presence is what enables paging. | @@ -114,35 +116,46 @@ booleans do not exist at all; the five `on*` names are **component props** #### `description` and `emptyState` -Two more keys the grid honours on the node it reads (objectui#11068): +Two keys the grid honours (objectui#11068). The upstream protocol's `object-grid` +row declares both (objectstack#20694), so both are bag members and both are in +`GRID_QUERY_INPUTS` (objectui#11227): - `description` — one line of help text drawn above the grid. A string, or an inline locale map resolved against the display locale the way `label` is. -- `emptyState: { title?, message?, icon? }` — drawn **in place of** an empty - table: a Lucide `icon`, a `title` (default: the table's own "No results found") - and a `message` (default: none). It is not drawn when a term typed into the - grid's own server-side search box is what emptied it — the table and its - search box stay, so the term can be cleared. Leave the key out and an empty - grid draws the table's own empty row, as before. - -`description` is a base prop, so it sits on the node beside the bag: +- `emptyState: { title?, message?, icon? }` — the list view's own empty-state + shape (`EmptyState`), drawn **in place of** an empty table: a Lucide `icon`, a + `title` (default: the table's own "No results found") and a `message` + (default: none). `title` and `message` each take a string or an inline locale + map, resolved against the display locale; a map with no usable entry keeps + that member's default. It is not drawn when a term typed into the grid's own + server-side search box is what emptied it — the table and its search box stay, + so the term can be cleared. Leave the key out and an empty grid draws the + table's own empty row, as before. ```json { "type": "object-grid", - "description": "Everyone you work with", - "properties": { "objectName": "contacts" } + "properties": { + "objectName": "contacts", + "description": "Everyone you work with", + "emptyState": { + "title": { "en": "No contacts yet", "fr": "Aucun contact" }, + "message": "Add one to get started", + "icon": "users" + } + } } ``` -`emptyState` is **not authorable in a document today**. The upstream protocol's -`object-grid` row does not declare it, so `objectui validate` refuses it inside -the `properties` bag (the row's own refusal) and, on the node, its strict face -refuses it as an unknown key — as `os validate` does. It stays a key of the node -the grid reads: a host that mounts `` itself, or composes -the node in code, can set it. For the same reason it is not in -`GRID_QUERY_INPUTS` yet: that list may only declare keys the row accepts, so the -SDUI parser reports an authored `emptyState` as `unknown-prop` (a warning). +Written flat on the node, either key is refused by name and pointed at the bag +(`description` → `properties.description`), as the spec's own page component +refuses it. Until the row declared `description`, this page told you to write it +on the node; that is now the refused spelling. + +`keyboardNavigation`, the third key objectstack#20694 added to the row, is marked +`[EXPERIMENTAL — not enforced]` there, and nothing in this plugin reads it yet, +so it is not in `GRID_QUERY_INPUTS`. The bag accepts it, as the spec row does, +and it changes nothing. `name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are **retired** on this node (objectui#11068): nothing ever read them, and both faces of diff --git a/content/docs/utilities/cli.mdx b/content/docs/utilities/cli.mdx index aaea3ab1d4..6fd3c2529a 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -141,9 +141,7 @@ reported at the nested node's real path: Nearest of the … accepted types: element:text, element:button, … ``` -A slot that walk does not descend is not judged here. One is live today: -`page:card`'s `properties.footer`, which the card draws — a component written -there is accepted unexamined until the spec's walk reaches it. +A slot that walk does not descend is not judged here. #### Reading a failure diff --git a/e2e/live/ci/backend.env b/e2e/live/ci/backend.env index 95c134de73..790433aa58 100644 --- a/e2e/live/ci/backend.env +++ b/e2e/live/ci/backend.env @@ -14,7 +14,7 @@ # # This file declares exactly ONE @objectstack pin, and that is the whole # design: the commit is derived from it, see below. -OBJECTSTACK_VERSION=17.5.0 +OBJECTSTACK_VERSION=17.6.0 # There is deliberately no OBJECTSTACK_REF key here. The objectstack-ai/objectstack # commit the showcase app metadata is sparse-checked-out from is DERIVED at diff --git a/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts b/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts index a89f6bafd9..eb9b5d9ae6 100644 --- a/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts +++ b/examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts @@ -46,9 +46,10 @@ * exactly those two keys. Without it, a judge that stopped judging would * pass 1 and 2 as well. * - * `breadcrumb: true` stays in the example: its fate is a separate card, and - * this file only asks that every key the example writes is one the contract - * accepts today. + * The example carried `breadcrumb: true` until the `@objectstack/spec` 17.6.0 + * bump (objectui#11438), when the contract began refusing it by name + * (objectstack#20758); it left the example then. This file only asks that + * every key the example writes is one the contract accepts today. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/app-shell/src/providers/writeWarningToast.test.ts b/packages/app-shell/src/providers/writeWarningToast.test.ts index 9bbf4d4d97..3918d9a57e 100644 --- a/packages/app-shell/src/providers/writeWarningToast.test.ts +++ b/packages/app-shell/src/providers/writeWarningToast.test.ts @@ -179,10 +179,6 @@ describe('emitWriteWarning (#3484)', () => { }); it('uses the formula wording for reason `computed`, which is NOT read-only (objectui#11206)', async () => { - // Spelled through `string`: the pinned spec (17.5.0) does not name this arm - // yet, while objectstack `main` does (`b2805465`), and this file compiles - // against both. - const computed: string = 'computed'; const keys: string[] = []; const recordingT = (key: string, opts?: Record) => { keys.push(key); @@ -194,7 +190,7 @@ describe('emitWriteWarning (#3484)', () => { { ...EVENT, droppedFields: [ - { object: 'andon', fields: ['type'], reason: computed as DroppedFieldsEvent['reason'] }, + { object: 'andon', fields: ['type'], reason: 'computed' }, ], }, recordingT, @@ -209,21 +205,6 @@ describe('emitWriteWarning (#3484)', () => { expect(calls[0].description).not.toMatch(/^Not applied by the server/); }); - /** - * The tripwire for the `| 'computed'` widening on `STRIPPED_LINE` - * (objectui#11206). That widening exists only because the published pin - * predates objectstack `b2805465`; on the bump to a release that carries the - * arm it becomes a silent no-op, which no compiler reports. This makes that - * bump say so instead, in the suite a bump is run through. - */ - it('reminds the pin bump that carries `computed` to retire the widening (objectui#11206)', () => { - expect( - DroppedFieldsEventSchema.shape.reason.options, - "the spec pin now declares `computed`: delete `| 'computed'` from STRIPPED_LINE's key type " + - "and make spec-symbol-batch6.test.ts's `_ReasonIsTheEnum` pin the four-member union", - ).not.toContain('computed'); - }); - it('keeps one line per reason when a save was stripped for several', async () => { const { sink, calls } = makeSink(); @@ -252,8 +233,8 @@ describe('emitWriteWarning (#3484)', () => { /** * The exhaustiveness pin (objectui#3935), read off the SPEC rather than a hand * list that would drift: `STRIPPED_LINE` is keyed by - * `DroppedFieldsEvent['reason']` (widened by `'computed'` until the pin - * carries it, objectui#11206), so a missing key is a type error — but + * `DroppedFieldsEvent['reason']` (widened by `'computed'` from objectui#11206 + * until the 17.6.0 bump, objectui#11438), so a missing key is a type error — but * `type-check` and `vitest` are different gates, * and the reason the ternary this replaced survived so long is that nothing in * the test suite could see the gap at all. diff --git a/packages/app-shell/src/providers/writeWarningToast.ts b/packages/app-shell/src/providers/writeWarningToast.ts index d6cc9914cd..3e2bd2c947 100644 --- a/packages/app-shell/src/providers/writeWarningToast.ts +++ b/packages/app-shell/src/providers/writeWarningToast.ts @@ -116,23 +116,15 @@ type StrippedLine = (t: TranslateFn, fields: string) => string; * `DROPPED_REASON_LABEL`), and the shape the spec's own schema comment asks every * consumer that branches on `reason` to use. * - * ⭐ Keyed by the spec union WIDENED BY `'computed'`, and the widening is what - * lets one spelling compile against both specs this repository is checked - * against (objectui#11206). objectstack `b2805465` added the `computed` arm (a - * `formula` field's caller-supplied value, stripped on every write path); the - * published pin, `@objectstack/spec` 17.5.0, predates it, while the - * `Spec Main Shape Gate` compiles against objectstack `main`, which carries it. - * A bare `Record` is exact in both directions, - * so the row is a missing key against `main` and an excess key (TS2353) against - * the pin; `satisfies` does not help, because it runs the same excess-property - * check on an object literal. The union with the one literal instead makes the - * required key set IDENTICAL on both sides — the pin's three plus `computed` — - * and still names the spec's union as the thing being covered, so the promise - * above holds on both: the NEXT arm upstream adds is a missing key here. When - * the pin reaches a release carrying `computed`, the `| 'computed'` is a no-op - * and is deleted; `writeWarningToast.test.ts` fails on that bump to say so. + * The `computed` row (a `formula` field's caller-supplied value, stripped on + * every write path) was added by objectui#11206 ahead of the published pin: + * objectstack `b2805465` declared the arm while the pin of the day predated it, + * so the key type was widened by `| 'computed'` to compile against both. Since + * `@objectstack/spec` 17.6.0 the pin's own union carries `computed`, so the + * widening was a no-op and was deleted at that bump (objectui#11438), as its + * tripwire asked. The key type is the spec union again, exact in both directions. */ -const STRIPPED_LINE: Record = { +const STRIPPED_LINE: Record = { readonly: (t: TranslateFn, fields: string) => t('detail.writeStrippedReadonly', { fields, diff --git a/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts b/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts index 88c7aca355..856c4d5eaa 100644 --- a/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts +++ b/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts @@ -143,8 +143,11 @@ describe('objectui#6238 · the schema oracle — both tombstone keys are refused // `packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts`; // the count rides here only as the corpus guard for the membership claims // below, so it is re-pointed, not weakened. + // 44 at `@objectstack/spec` 17.6.0, which adopted `imageField` (measured against + // a 17.5.0 control: gained set exactly `['imageField']`, lost set empty; + // objectui#11438). Re-pointed as the 17.3.0 adoption was, not weakened. const accept = new Set(Object.keys(ObjectSchema.shape as Record)); - expect(accept.size).toBe(43); + expect(accept.size).toBe(44); expect(accept.has('enable')).toBe(true); expect(accept.has('enabled')).toBe(false); expect(accept.has('_deleted')).toBe(false); diff --git a/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts b/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts index 15d7eeec33..c1633d2aad 100644 --- a/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts +++ b/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts @@ -157,8 +157,11 @@ describe('the instrument', () => { // gained set exactly `['editMode']`, lost set empty). The count is this // file's corpus guard, not its subject; every claim it guards is below and // unchanged. + // 44 at `@objectstack/spec` 17.6.0, which adopted `imageField` (measured against + // a 17.5.0 control: gained set exactly `['imageField']`, lost set empty; + // objectui#11438). Re-pointed as the 17.3.0 adoption was, not weakened. const accept = new Set(Object.keys(ObjectSchema.shape as Record)); - expect(accept.size).toBe(43); + expect(accept.size).toBe(44); // `fieldGroups` is the only grouping key on the object, and it groups the // FIELDS INSIDE one object — it is not a category for objects themselves, // so `group` has no mapping target here. diff --git a/packages/app-shell/src/views/metadata-admin/celAuthoring.ts b/packages/app-shell/src/views/metadata-admin/celAuthoring.ts index 761ee036ae..d62c47f26b 100644 --- a/packages/app-shell/src/views/metadata-admin/celAuthoring.ts +++ b/packages/app-shell/src/views/metadata-admin/celAuthoring.ts @@ -428,7 +428,7 @@ function loadRowCanon(): Promise { * not re-derived — and the engine's own message ships with it. * * ⚠️ The reason is NOT that the engine's message refuses a `record.` - * rewrite. Measured against the installed 17.5.0, it mostly PRESCRIBES one: the + * rewrite. Measured against the installed 17.6.0, it mostly PRESCRIBES one: the * `current_user` text CONTAINS "To gate on record state, rewrite the predicate * against `record`." — one of the three remedies it offers, and NOT where it * ends (measured: `includes` true, `endsWith` false; every covered slot's text diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx index 02da178e8f..768719ba0e 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx @@ -381,7 +381,7 @@ describe('boolean: a declared defaultValue seeds the control (objectui#8451, arm // ⇒ `screen.waitForInput` is the offline undeclared boolean that sentence // says does not exist — and it is undeclared CORRECTLY, so ⛔ do not read // this as an objectui#9277-class omission waiting to be declared. Measured - // on the installed `@objectstack/spec` (17.5.0): `waitForInput` is typed + // on the installed `@objectstack/spec` (17.6.0): `waitForInput` is typed // `z.boolean().optional()` with no `.default(...)`, so an omitted key // materialises nothing and there is no spec answer for a declaration to // mirror. Declaring one here would invent a default the runtime does not diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.saveErrors-10948.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.saveErrors-10948.test.tsx index 72955a5a9d..54cea89d58 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.saveErrors-10948.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.saveErrors-10948.test.tsx @@ -2,7 +2,7 @@ /** * objectui#10948 — what the designer's live `FlowSchema` pass says about a node - * the author has ADDED but not yet configured, on the installed spec (17.5.0). + * the author has ADDED but not yet configured, on the installed spec (17.6.0). * * Spec 17.5.0 carries objectstack#20316 (a node config key its executor * contract requires, left out, and a decision branch with no `label`, are diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index 6fe937ae63..9b88e6b28f 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -1507,9 +1507,10 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // `breadcrumb` is deliberately NOT read (objectui#11166, ruling RETIRE). All // it ever drew was an empty `data-page-breadcrumb-slot` div that nothing // filled, and the console's app header already draws the trail. The spec - // still accepts `PageHeaderProps.breadcrumb` until objectstack#20758 retires - // it, so an authored value renders exactly as an absent one: ignored, no - // error. ⛔ Do not add a trail here; that is an ENFORCE ruling, not a fix. + // refuses `PageHeaderProps.breadcrumb` by name since 17.6.0 + // (objectstack#20758); a value that still reaches this renderer renders + // exactly as an absent one: ignored, no error. ⛔ Do not add a trail here; + // that is an ENFORCE ruling, not a fix. // Schema-level opt-outs let authors keep the historic "bare h1" header // when they don't want a record chip (e.g. a non-record landing page). @@ -2419,14 +2420,10 @@ ComponentRegistry.register('header', PageHeaderRenderer, { { name: 'title', type: ['string', 'object'], description: 'Supports {field} interpolation and inline translation maps; falls back to the record title' }, { name: 'subtitle', type: ['string', 'object'], description: 'Same interpolation as Title' }, { name: 'actions', type: 'array', of: 'string', description: "Action IDS — the names of actions declared on the object's own metadata — rendered in the header before any host-injected system actions. An id whose action declares neither record_header nor record_more in its locations renders nowhere." }, - // Declared but NOT read (objectui#11166). It stays in `inputs` only while - // the installed spec still accepts the key: the reverse direction of - // `registry-inputs-spec-parity.test.ts` asks for every accepted spec key, - // and the manifest would otherwise warn `unknown-prop` on a value the - // contract accepts. It leaves with the spec retirement (objectstack#20758): - // once the pin carries the tombstone, this entry fails the forward - // direction. The description tells an author reading the manifest it is inert. - { name: 'breadcrumb', type: 'boolean', description: 'Ignored: the header draws no breadcrumb, and the key is being retired from the contract. Leave it out.' }, + // No `breadcrumb`: it was declared but NOT read (objectui#11166), and stayed + // here only while the spec accepted the key. `@objectstack/spec` 17.6.0 + // carries the retirement (objectstack#20758, a tombstone refused by name), + // so the entry left at that bump (objectui#11438), as this comment asked. { name: 'recordChrome', type: 'boolean', description: 'Set false for the bare h1 header on non-record pages' }, { name: 'showStar', type: 'boolean' }, { name: 'showCopyId', type: 'boolean' }, diff --git a/packages/core/src/actions/__tests__/actionKeys.pin.test.ts b/packages/core/src/actions/__tests__/actionKeys.pin.test.ts index 3445b86f1a..1b510b8449 100644 --- a/packages/core/src/actions/__tests__/actionKeys.pin.test.ts +++ b/packages/core/src/actions/__tests__/actionKeys.pin.test.ts @@ -13,6 +13,15 @@ * Discrimination proof for the guard below: with `ACTION_DEF_KEYS` complete these * pass; dropping any single entry (e.g. `target`) fails with * "ActionDef declares keys the inventory is missing: target". + * + * ## OWED TO objectui#11344 — a bounded ledger, not a skip + * + * `@objectstack/spec` 17.6.0's `ActionSchema` declares `outcomeMessages`, and + * objectui does not read it yet: objectui#11344 builds the reader and lists the + * key with it. Until then the key is booked in `OWED_TO_OBJECTUI_11344` below. + * Its row asserts today's difference, and "lists every key the spec + * ActionSchema declares" requires `missing` to EQUAL the ledger exactly, so a + * new spec key is red, and so is the booked key once objectui lists it. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -87,6 +96,38 @@ function specActionKeys(): string[] { return keys; } +/** + * ⚠️ OWED TO objectui#11344 — the spec `ActionSchema` keys objectui's inventory + * does NOT list yet, booked rather than skipped. + * + * `@objectstack/spec` 17.6.0 declares `outcomeMessages` on `ActionSchema` + * (objectstack#21095): success copy per handler outcome, and the spec's own + * landing order names objectui#11344 as its console reader. objectui does not + * read it yet, so `SPEC_ACTION_KEYS` does not list it and the runner's + * unknown-key warning still names it. objectui#11344 owns the reader, and lists + * the key in the same change. + * + * Booked by objectui#11438 ruling A″ (record 5968177777), which applies + * objectui#11111 decision 3 = B (record 5902351047) to the bump. The listed + * key's row asserts TODAY's difference (the spec declares it; objectui's + * inventory does not know it), and "lists every key the spec ActionSchema + * declares" requires `missing` to EQUAL this list, with `stale` still empty. A + * new spec key is red, and so is a listed key objectui starts knowing: the + * entry goes stale, by name, when objectui#11344's reader lands, and that change + * strikes it. + * + * EXPIRES when objectui#11344's slice lands, or 2026-11-02, whichever is first. + * The landing is caught by the rows below going red; the date is read by + * objectui#11344, not by a clock. + */ +const OWED_TO_OBJECTUI_11344 = ['outcomeMessages']; + +/** The reason every owed row prints when it fails. */ +const OWED_REASON = + 'OWED TO objectui#11344: objectui does not read the spec ActionSchema key yet, so the inventory does not list it. ' + + 'Booked by objectui#11438 ruling A″ (record 5968177777), applying objectui#11111 decision 3 = B ' + + '(record 5902351047). Expires when objectui#11344\'s slice lands, or 2026-11-02, whichever is first.'; + describe('action key inventory (objectstack#4075 step 1)', () => { it('ActionDef has NO index signature, and ActionContext still does', () => { // The successor to step 1's inverted pin. That one asserted the index @@ -124,8 +165,13 @@ describe('action key inventory (objectstack#4075 step 1)', () => { const stale = (SPEC_ACTION_KEYS as readonly string[]).filter((k) => !spec.includes(k)); // `missing` means the spec grew a key objectui does not know about; `stale` // means it dropped one. Either way the inventory has to be re-derived, and - // the diff names exactly which key moved. - expect({ missing, stale }).toEqual({ missing: [], stale: [] }); + // the diff names exactly which key moved. The one exception is the booked + // ledger: `missing` must EQUAL `OWED_TO_OBJECTUI_11344`, not merely contain + // it, so the booking cannot outlive the reader that strikes it. + expect({ missing: [...missing].sort(), stale }, OWED_REASON).toEqual({ + missing: [...OWED_TO_OBJECTUI_11344].sort(), + stale: [], + }); }); it('`execute` is still a live spec tombstone, so it must not count as known', () => { @@ -181,6 +227,24 @@ describe('action key inventory (objectstack#4075 step 1)', () => { }); }); +describe('objectui#11344 — OWED: the spec ActionSchema declares these keys and objectui does not know them today', () => { + it.each(OWED_TO_OBJECTUI_11344)('OWED TO objectui#11344: `%s` is declared by the spec ActionSchema, and neither the inventory nor the runner knows it', (key) => { + // All three halves are asserted together. The first keeps the ledger + // honest: a key the spec does not declare is not owed to anyone. The other + // two are the difference: `SPEC_ACTION_KEYS` is the inventory the cap row + // reads, and `KNOWN_ACTION_KEYS` is what the unknown-key warning reads, so a + // reader that lists the key on either side turns this row red. + expect( + { + declaredBySpec: specActionKeys().includes(key), + inSpecActionKeys: (SPEC_ACTION_KEYS as readonly string[]).includes(key), + knownToTheRunner: KNOWN_ACTION_KEYS.has(key), + }, + OWED_REASON, + ).toEqual({ declaredBySpec: true, inSpecActionKeys: false, knownToTheRunner: false }); + }); +}); + describe('unknown-key warning', () => { let warn: ReturnType; diff --git a/packages/core/src/adapters/README.md b/packages/core/src/adapters/README.md index 09f5c80122..21fe105944 100644 --- a/packages/core/src/adapters/README.md +++ b/packages/core/src/adapters/README.md @@ -117,10 +117,13 @@ The object dialect executes one arm per member of the spec's `FILTER_OPERATORS`: ```text $eq $ne $gt $gte $lt $lte $in $nin $between -$contains $icontains $notContains $startsWith $endsWith $null $exists +$contains $icontains $notContains $startsWith $endsWith $null $exists $empty ``` -That is **all sixteen** — nothing the spec declares is refused by name. +That is **every member** — nothing the spec declares is refused by name. What is held +to the spec is the matcher's case table, not this list: +`ValueDataSource.dollarFilterVocabulary.test.ts` reddens when a release adds an +operator it has no case for. Nothing re-checks the list above. `$contains`, `$notContains`, `$startsWith` and `$endsWith` are **case-sensitive**; `$icontains` is the one case-insensitive member, its fold is **ASCII-only**, and its @@ -130,6 +133,17 @@ table below rather than folded (objectui#8748). is IS NOT NULL. `$exists` is its exact inverse — `$exists: true` is IS NOT NULL — which is the lowering `convertFiltersToAST` already performs, not a reading invented here. +`$empty` (objectui#11094) is not a null test. `$empty: true` selects a row whose value +is null, absent, `''` or `[]`, and `$empty: false` is its exact complement; a flag that +is not a boolean is refused. The AST twins are `is_empty` / `is_not_empty`, which the +spec stopped folding onto `is_null` / `is_not_null` (objectstack#20570). The answer is +the spec's own `isEmptyFilterValue`, called without a field declaration, because this +adapter holds none: the spec's ruled table is keyed on a field's declared type (a +text-like field is empty when null or `''`, a multi-value field when null or `[]`, +every other type when null), and a face with no declarations judges by value, as +`@objectstack/formula`'s matcher does. The two readings differ only on a value the +declared type does not predict, such as `''` stored in a number column. + A stored value that is **not a string** never satisfies `$contains`, `$icontains`, `$startsWith` or `$endsWith`, and always satisfies `$notContains` — the number `5` does not contain the substring `"5"`, so it is on the negation side. That is diff --git a/packages/core/src/adapters/ValueDataSource.ts b/packages/core/src/adapters/ValueDataSource.ts index 0b8eef295c..904c94d02b 100644 --- a/packages/core/src/adapters/ValueDataSource.ts +++ b/packages/core/src/adapters/ValueDataSource.ts @@ -22,6 +22,7 @@ import { asciiCaseInsensitiveContains, canonicalAstOperator, isAcceptedFilterComparand, + isEmptyFilterValue, RETIRED_FILTER_OPERATORS, } from '@objectstack/spec/data'; import { emulateBatchTransaction } from './batchTransaction.js'; @@ -111,11 +112,44 @@ const AST_LIST_COMPARAND_OPERATORS = new Set(['in', 'nin', 'between']); /** * The AST operators that never read the value slot at all, so nothing sitting - * there is a comparand. `matchesComparisonNode`'s null arms take their - * direction from the operator NAME and the ObjectUI client sends a truthy + * there is a comparand. `matchesComparisonNode`'s null and emptiness arms take + * their direction from the operator NAME and the ObjectUI client sends a truthy * placeholder in the third position, so the array guard must not judge it. + * The spec discards that slot on all four: `parseFilterAST(['x', 'is_empty', + * ['a']])` is `{ x: { $empty: true } }`. */ -const AST_NO_COMPARAND_OPERATORS = new Set(['is_null', 'is_not_null']); +const AST_NO_COMPARAND_OPERATORS = new Set(['is_null', 'is_not_null', 'is_empty', 'is_not_empty']); + +/** + * Is this stored value EMPTY, in the sense the spec's `$empty` operator gives + * the word (objectui#11094)? + * + * The answer is the spec's own `isEmptyFilterValue` (`@objectstack/spec/data`), + * called WITHOUT an expansion. That is the by-value reading: null, `undefined` + * (an absent key reads as `undefined` here), `''` and `[]` are empty, and + * nothing else is. + * + * ## Why no field type is read + * + * The spec's ruled 「is empty」 table (ruling B on objectstack#20311) is keyed + * on a field's DECLARED type and `multiple`: a text-like field is empty when + * null or `''`, a multi-value field when null or `[]`, every other type when + * null. A face that holds the declaration turns it into a row with + * `expandEmptyOperator` and passes that row to `isEmptyFilterValue`. This face + * holds none. `ValueDataSourceConfig` carries rows and an id field, and the + * spec's `value` data source declares only `items`. So this face judges by + * value, as ruling A on objectstack#20399 gives the faces with no field + * declarations. `@objectstack/formula`'s matcher and `driver-memory`'s + * reference matcher make the same call to the same function. + * + * ⛔ No per-type table is kept here, and none is inferred from the rows. The + * by-value reading differs from a declared face only on a stored state the + * declaration does not predict: `''` in a non-text column, or `[]` in a scalar + * one. The spec calls the first a write-door defect rather than a stored state. + */ +function isEmptyStoredValue(value: unknown): boolean { + return isEmptyFilterValue(value); +} /** * An ARRAY where a single-value comparand belongs — `{ tags: ['a', 'b'] }`, @@ -503,14 +537,28 @@ function matchesComparisonNode( // -- Null-ness. Direction comes from the operator NAME; the value slot is // never read, so the 2-tuple `['x', 'is_not_null']` and the 3-tuple // `['x', 'isnotnull', null]` are the same predicate. `canonicalAstOperator` - // folds all eight spellings (`is_null` / `isnull` / `is_empty` / `isempty` - // and their four negatives) onto these two arms — including `is_empty`, - // which the spec lowers to `$null` rather than to an emptiness test. + // folds `is_null` / `isnull` onto the first arm and their two negatives + // onto the second. case 'is_null': return value === null || value === undefined; case 'is_not_null': return value !== null && value !== undefined; + // -- Emptiness (objectui#11094). The spec stopped folding `is_empty` / + // `isempty` onto `is_null` (objectstack#20570, in `@objectstack/spec` + // 17.6.0): `canonicalAstOperator` now answers `is_empty` for those two + // spellings and `is_not_empty` for their two negatives, and the spec + // lowers them to `$empty: true` / `$empty: false`. So they get their own + // arms, the twins of the `$empty` arm in `matchesDollarOperator`. Without + // these arms, on that release a stored `is_empty` rule reached the refusal + // below and selected no row. Direction comes from the operator NAME, as + // for null-ness, and the meaning is the spec's emptiness test, not a null + // test: see {@link isEmptyStoredValue}. + case 'is_empty': + return isEmptyStoredValue(value); + case 'is_not_empty': + return !isEmptyStoredValue(value); + // objectui#10829 — equality and membership through `comparandEquals`, so // two `Date`s compare their instant; the ordering arms below are untouched. case '=': @@ -686,8 +734,11 @@ function matchesASTFilter(record: any, filterNode: any, refusals: Set): * The vocabulary is the spec's own `FILTER_OPERATORS` (`@objectstack/spec/data`) * and each arm answers the same question its AST twin answers in * {@link matchesComparisonNode} — `$eq`/`=`, `$nin`/`nin`, `$startsWith`/ - * `starts_with`, and so on, one-to-one across ALL SIXTEEN: every declared - * operator is executed here, none is refused by name. That pairing IS the + * `starts_with`, `$empty`/`is_empty`, and so on, one-to-one across EVERY + * member: every declared operator is executed here, none is refused by name. + * How many members there are is not written here: the case table in + * `ValueDataSource.dollarFilterVocabulary.test.ts` is held equal to + * `FILTER_OPERATORS`, and it is what re-derives that. That pairing IS the * fix for objectui#8447: `find()` picks between the two matchers on nothing * more than whether `$filter` arrived as an array or an object, so any operator * one of them executes and the other waves through is a result that changes @@ -829,6 +880,25 @@ function matchesDollarOperator( ? value !== null && value !== undefined : value === null || value === undefined; + // objectui#11094 — `$empty`, which `@objectstack/spec` 17.6.0 admitted to + // `FILTER_OPERATORS` (objectstack#20446) and to which it lowers `is_empty` / + // `is_not_empty`. `true` selects the empty rows and `false` is the exact + // complement, by value (see {@link isEmptyStoredValue}); the AST twins are + // the `is_empty` / `is_not_empty` arms in `matchesComparisonNode`. + // + // The flag is a BOOLEAN by declaration (`SpecialOperatorSchema`), so + // anything else is refused here rather than read for its truthiness. That + // is the answer the spec gives at its save door and on every query face, + // and `@objectstack/formula`'s matcher denies a non-boolean flag too. + case '$empty': + if (target === true) return isEmptyStoredValue(value); + if (target === false) return !isEmptyStoredValue(value); + return refuseFilterNode( + refusals, + `filter operator '$empty' on field '${field}' takes a boolean flag (true or false); ` + + `received ${describeComparand(target)}`, + ); + // objectui#8515 — the hand-authored IMPLICIT form `{ amount: { $field: 'x' } }`. // It is not a reference comparand: an object whose only key starts with `$` // reads as an OPERATOR SPEC named `$field`, which is why this arrives here diff --git a/packages/core/src/adapters/__tests__/ValueDataSource.astFilterVocabulary.test.ts b/packages/core/src/adapters/__tests__/ValueDataSource.astFilterVocabulary.test.ts index bcb254979a..00ddd40558 100644 --- a/packages/core/src/adapters/__tests__/ValueDataSource.astFilterVocabulary.test.ts +++ b/packages/core/src/adapters/__tests__/ValueDataSource.astFilterVocabulary.test.ts @@ -33,10 +33,28 @@ * never read the value slot, in every spelling the spec folds onto them. * 3. An operator or shape the matcher cannot execute excludes the row and says * so, instead of passing every row silently. + * + * ## The fold moved under §2 (objectui#11094) + * + * Until `@objectstack/spec` 17.6.0, `canonicalAstOperator` folded `is_empty` / + * `isempty` onto `is_null` and their negatives onto `is_not_null`, and §2 pinned + * all eight spellings as null tests. objectstack#20570 gave the empty pair its + * own names and lowers them to the `$empty` operator, whose meaning is an + * emptiness test: `''` and `[]` count as empty beside null. On that release this + * matcher had no arm for them and refused every stored `is_empty` rule, which + * selected no row. §2 is rewritten to the new fold: the null pair stays a null + * test, and the empty pair is pinned in §2b over text, multi-value and number + * columns, `''` and `[]` included. */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { VIEW_FILTER_OPERATORS } from '@objectstack/spec/ui'; +import { + canonicalAstOperator, + EMPTY_OPERATOR_ARMS, + isEmptyFilterValue, + parseFilterAST, +} from '@objectstack/spec/data'; import { ValueDataSource } from '../ValueDataSource'; import { mergeFilterNodes, toFilterNode } from '../../utils/filter-converter'; @@ -127,9 +145,34 @@ const NULL_ROWS = [ ]; /** Every spelling the spec's `canonicalAstOperator` folds onto `is_null`. */ -const IS_NULL_SPELLINGS = ['is_null', 'isnull', 'is_empty', 'isempty']; -/** …and onto `is_not_null`. `is_empty` folds to `$null` in the spec too. */ -const IS_NOT_NULL_SPELLINGS = ['is_not_null', 'isnotnull', 'is_not_empty', 'isnotempty']; +const IS_NULL_SPELLINGS = ['is_null', 'isnull']; +/** …and onto `is_not_null`. */ +const IS_NOT_NULL_SPELLINGS = ['is_not_null', 'isnotnull']; +/** + * The two spellings it folds onto `is_empty`, and the two onto `is_not_empty`. + * They folded onto the null pair above until `@objectstack/spec` 17.6.0. + */ +const IS_EMPTY_SPELLINGS = ['is_empty', 'isempty']; +const IS_NOT_EMPTY_SPELLINGS = ['is_not_empty', 'isnotempty']; + +describe('objectui#11094 — the fold this file is written against', () => { + it('the null pair and the empty pair are four canonical operators, not two', () => { + // The spec's answer, read rather than restated, so a release that moves the + // fold again reddens HERE first, beside the reason, instead of only in the + // row sets below. + for (const op of IS_NULL_SPELLINGS) expect(canonicalAstOperator(op)).toBe('is_null'); + for (const op of IS_NOT_NULL_SPELLINGS) expect(canonicalAstOperator(op)).toBe('is_not_null'); + for (const op of IS_EMPTY_SPELLINGS) expect(canonicalAstOperator(op)).toBe('is_empty'); + for (const op of IS_NOT_EMPTY_SPELLINGS) expect(canonicalAstOperator(op)).toBe('is_not_empty'); + }); + + it('and the spec lowers the empty pair to `$empty`, not to `$null`', () => { + expect(parseFilterAST(['x', 'is_empty'])).toEqual({ x: { $empty: true } }); + expect(parseFilterAST(['x', 'is_not_empty'])).toEqual({ x: { $empty: false } }); + // Lit control: the null pair still lowers to `$null`. + expect(parseFilterAST(['x', 'is_null'])).toEqual({ x: { $null: true } }); + }); +}); describe('objectui#7349 — null-ness takes direction from the operator NAME', () => { it.each(IS_NULL_SPELLINGS)('`%s` selects null, undefined and the absent key', async (op) => { @@ -164,6 +207,111 @@ describe('objectui#7349 — null-ness takes direction from the operator NAME', ( }); }); +// --------------------------------------------------------------------------- +// 2b. Emptiness — the empty pair is its own test since spec 17.6.0 +// --------------------------------------------------------------------------- + +/** + * A text column (`name`), a multi-value column (`tags`) and a number column + * (`score`), each holding a value, its type's "blank" (`''`, `[]`, `0`), null, + * and no key at all. The spec's ruled table makes `''` empty on a text field and + * `[]` empty on a multi-value field, and leaves `0` a value, so the three + * columns give three different answers to one operator — and the text column's + * answer differs from `is_null`'s, which is the flip itself. + */ +const EMPTY_ROWS = [ + { id: 'full', name: 'ada', tags: ['x'], score: 7 }, + { id: 'blank', name: '', tags: [], score: 0 }, + { id: 'null', name: null, tags: null, score: null }, + { id: 'missing' }, +]; +const EMPTY_ROW_IDS = EMPTY_ROWS.map((r) => r.id); + +/** Per column: the rows `is_empty` selects. `is_not_empty` selects the rest. */ +const EMPTY_BY_COLUMN: Record = { + name: ['blank', 'null', 'missing'], + tags: ['blank', 'null', 'missing'], + score: ['null', 'missing'], +}; + +const complementOf = (ids: string[]) => EMPTY_ROW_IDS.filter((id) => !ids.includes(id)); + +describe('objectui#11094 — `is_empty` / `is_not_empty` are an emptiness test', () => { + describe.each(Object.entries(EMPTY_BY_COLUMN))('on the `%s` column', (column, empty) => { + it.each(IS_EMPTY_SPELLINGS)('`%s` selects the empty rows', async (op) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(await selectedIds([[column, op]], EMPTY_ROWS)).toEqual(empty); + // Executed, not refused: on 17.6.0 without the arm this was `[]` plus + // one logged refusal. + expect(warn).not.toHaveBeenCalled(); + }); + + it.each(IS_NOT_EMPTY_SPELLINGS)('`%s` selects exactly the rest', async (op) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(await selectedIds([[column, op]], EMPTY_ROWS)).toEqual(complementOf(empty)); + expect(warn).not.toHaveBeenCalled(); + }); + + it('answers what the spec\'s own `isEmptyFilterValue` answers, row by row', async () => { + // The by-value reading, called with no declared row: the function itself + // is the reference, so this table cannot drift into a second copy of it. + const expected = EMPTY_ROWS + .filter((row) => isEmptyFilterValue((row as Record)[column])) + .map((row) => row.id); + expect(await selectedIds([column, 'is_empty'], EMPTY_ROWS)).toEqual(expected); + }); + + it('the object dialect\'s `$empty` gives the same rows, both directions', async () => { + expect(await selectedIds({ [column]: { $empty: true } }, EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds({ [column]: { $empty: false } }, EMPTY_ROWS)) + .toEqual(complementOf(empty)); + }); + }); + + it('is not a null test any more — the flip, on the text column', async () => { + // `''` is the row that moves. Before 17.6.0 both operators were one arm. + expect(await selectedIds([['name', 'is_null']], EMPTY_ROWS)).toEqual(['null', 'missing']); + expect(await selectedIds([['name', 'is_empty']], EMPTY_ROWS)).toEqual(['blank', 'null', 'missing']); + expect(await selectedIds([['name', 'is_not_null']], EMPTY_ROWS)).toEqual(['full', 'blank']); + expect(await selectedIds([['name', 'is_not_empty']], EMPTY_ROWS)).toEqual(['full']); + }); + + it('with no declared type, a value is judged by value — `\'\'` in a number column is empty', async () => { + // This adapter holds no field declarations (its config is rows and an id + // field), so it takes the by-value reading the spec gives such faces. That + // differs from a face that holds the declaration on exactly this kind of + // value: a number field is the `null_only` row of the ruled table, which + // would leave `''` (and `[]`) a value. The control below is the spec's own + // function asked both ways, so the divergence this case pins is the + // spec's, not this file's. + expect(isEmptyFilterValue('')).toBe(true); + expect(isEmptyFilterValue('', EMPTY_OPERATOR_ARMS.null_only)).toBe(false); + expect(isEmptyFilterValue([], EMPTY_OPERATOR_ARMS.null_only)).toBe(false); + const rows = [ + { id: 'number', score: 3 }, + { id: 'zero', score: 0 }, + { id: 'blank-string', score: '' }, + { id: 'blank-list', score: [] }, + ]; + expect(await selectedIds([['score', 'is_empty']], rows)).toEqual(['blank-string', 'blank-list']); + expect(await selectedIds([['score', 'is_not_empty']], rows)).toEqual(['number', 'zero']); + }); + + it('never reads the value slot — filler, null, or a stray array', async () => { + // Same rule as the null pair: direction from the NAME. An array in the slot + // is not an array comparand here (the spec discards it), so it is neither + // refused nor read. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const empty = EMPTY_BY_COLUMN.tags; + expect(await selectedIds(['tags', 'is_empty'], EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds(['tags', 'is_empty', null], EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds(['tags', 'is_empty', true], EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds(['tags', 'isempty', ['x']], EMPTY_ROWS)).toEqual(empty); + expect(await selectedIds(['tags', 'is_not_empty', 'FILLER'], EMPTY_ROWS)).toEqual(complementOf(empty)); + expect(warn).not.toHaveBeenCalled(); + }); +}); + // --------------------------------------------------------------------------- // 3. Refusal — the arm that used to be `return true` // --------------------------------------------------------------------------- diff --git a/packages/core/src/adapters/__tests__/ValueDataSource.dollarFilterVocabulary.test.ts b/packages/core/src/adapters/__tests__/ValueDataSource.dollarFilterVocabulary.test.ts index 5a6bdb0443..fd3e651efc 100644 --- a/packages/core/src/adapters/__tests__/ValueDataSource.dollarFilterVocabulary.test.ts +++ b/packages/core/src/adapters/__tests__/ValueDataSource.dollarFilterVocabulary.test.ts @@ -200,6 +200,15 @@ const EXECUTED_CASES: Record< ast: ['nickname', 'is_not_null'], expected: ['a'], }, + // objectui#11094 — admitted to `FILTER_OPERATORS` by `@objectstack/spec` + // 17.6.0, which lowers `is_empty` to it. On these rows (no `''`, no `[]`) + // it selects what `$null: true` selects; the case that tells them apart is + // below, and the per-type cases are in `ValueDataSource.astFilterVocabulary`. + $empty: { + filter: { nickname: { $empty: true } }, + ast: ['nickname', 'is_empty'], + expected: ['b', 'c'], + }, }; /** @@ -235,6 +244,37 @@ describe('objectui#8447 — every executed operator constrains', () => { expect(warn).not.toHaveBeenCalled(); }); + it('`$empty: false` is the other direction of ITS operator', async () => { + const warn = spyWarn(); + expect(await selectedIds({ nickname: { $empty: false } })).toEqual(['a']); + expect(warn).not.toHaveBeenCalled(); + }); + + it('`$empty` is not `$null`: `\'\'` and `[]` are empty, not values (objectui#11094)', async () => { + // The row set that separates the two operators the fixture above cannot. + const rows = [ + { id: 'a', nickname: 'ace' }, + { id: 'blank', nickname: '' }, + { id: 'list', nickname: [] }, + { id: 'null', nickname: null }, + ]; + expect(await selectedIds({ nickname: { $null: true } }, rows)).toEqual(['null']); + expect(await selectedIds({ nickname: { $empty: true } }, rows)).toEqual(['blank', 'list', 'null']); + expect(await selectedIds({ nickname: { $empty: false } }, rows)).toEqual(['a']); + }); + + it.each([['yes'], [1], [0], [null]])( + '`$empty: %j` — a flag that is not a boolean — is refused, not read for truthiness', + async (flag) => { + // `SpecialOperatorSchema` declares the flag `boolean`. Reading `'yes'` as + // true and `0` as false would answer a filter the spec refuses. + const warn = spyWarn(); + expect(await selectedIds({ nickname: { $empty: flag } })).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain('$empty'); + }, + ); + it('`$exists` and `$null` are one predicate read from opposite ends', async () => { // Not a restatement of the two cases above: it pins the INVERSION, so an // arm that got the boolean the wrong way round fails here even if each diff --git a/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts b/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts index 927b394a07..861393aad1 100644 --- a/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts +++ b/packages/core/src/adapters/__tests__/ValueDataSource.filterLogicConformance-8513.test.ts @@ -47,10 +47,16 @@ * * `@objectstack/spec` 17.5.0 added a second partition of the same kind: the * STAGED `$empty` flag (objectstack#20444), enrolled in the table while the spec - * keeps it out of `FILTER_OPERATORS` and says the executors refuse it until each - * face has its arm. It is pinned the same way — by name, by direction, and with + * kept it out of `FILTER_OPERATORS` and said the executors refuse it until each + * face has its arm. It was pinned the same way — by name, by direction, and with * an expiry row that reddens when the staging ends (objectui#11073). * + * That row reddened on `@objectstack/spec` 17.6.0, which admitted `$empty` to + * `FILTER_OPERATORS` (objectstack#20446). This face owed its arm, and has it + * (objectui#11094), so the partition moved back into the executed table as the + * expiry row instructed. The by-name pin stays, now over the executed `$empty` + * cases, so a new `$empty` case landing upstream is still a decision. + * * That last one is why this file asserts the refusal instead of `it.skip`: * `$not` failed in the OPPOSITE direction from `$and` / `$or` before #8447, and * a fix that flattened the three combinators into one arm is the specific @@ -84,19 +90,20 @@ import { toFilterNode } from '../../utils/filter-converter'; const carriesNot = (filter: unknown): boolean => JSON.stringify(filter).includes('"$not"'); /** - * Cases whose filter mentions the STAGED `$empty` flag (objectstack#20444), - * which `@objectstack/spec` 17.5.0 enrolled in this table while keeping it OUT - * of `FILTER_OPERATORS`: "Until each face has its arm, the query executors - * refuse it". This matcher has no arm, so it refuses — the staged behaviour — - * and that partition is pinned below by name and direction, exactly as `$not`'s - * is (objectui#11073). A `$not` over `$empty` stays in the `$not` partition: - * the outer refusal is the one this matcher gives. + * Cases whose filter mentions the `$empty` flag (objectstack#20444). + * `@objectstack/spec` 17.5.0 enrolled them in this table while keeping the flag + * OUT of `FILTER_OPERATORS`, and this matcher refused them as staged + * (objectui#11073). 17.6.0 admitted the flag (objectstack#20446) and this + * matcher executes it (objectui#11094), so they are now a SUBSET of the + * executed cases, named below so that the subset is a decision rather than an + * accident. A `$not` over `$empty` stays in the `$not` partition: the outer + * refusal is the one this matcher gives. */ const carriesEmpty = (filter: unknown): boolean => JSON.stringify(filter).includes('"$empty"'); const NOT_CASES = FILTER_LOGIC_CASES.filter((c) => carriesNot(c.filter)); -const STAGED_EMPTY_CASES = FILTER_LOGIC_CASES.filter((c) => !carriesNot(c.filter) && carriesEmpty(c.filter)); -const EXECUTED_CASES = FILTER_LOGIC_CASES.filter((c) => !carriesNot(c.filter) && !carriesEmpty(c.filter)); +const EXECUTED_CASES = FILTER_LOGIC_CASES.filter((c) => !carriesNot(c.filter)); +const EMPTY_CASES = EXECUTED_CASES.filter((c) => carriesEmpty(c.filter)); async function selectedIds(filter: unknown): Promise { const ds = new ValueDataSource({ items: FILTER_LOGIC_ROWS as any[] }); @@ -117,16 +124,16 @@ afterEach(() => { // --------------------------------------------------------------------------- describe('objectui#8513 — the conformance harness discriminates', () => { - it('the table is present, non-trivial, and split into three non-empty parts', () => { + it('the table is present, non-trivial, and split into two non-empty parts', () => { // A table that failed to import would make every `it.each` below vacuous. expect(FILTER_LOGIC_CASES.length).toBeGreaterThan(20); expect(FILTER_LOGIC_ROWS.length).toBe(4); expect(EXECUTED_CASES.length).toBeGreaterThan(0); expect(NOT_CASES.length).toBeGreaterThan(0); - expect(STAGED_EMPTY_CASES.length).toBeGreaterThan(0); - expect(EXECUTED_CASES.length + NOT_CASES.length + STAGED_EMPTY_CASES.length).toBe( - FILTER_LOGIC_CASES.length, - ); + expect(EXECUTED_CASES.length + NOT_CASES.length).toBe(FILTER_LOGIC_CASES.length); + // …and the executed part really carries the `$empty` cases, so the run + // below exercises that arm rather than passing around it. + expect(EMPTY_CASES.length).toBeGreaterThan(0); }); it('the fixture discriminates: no expectation is the whole table AND none is empty', () => { @@ -203,13 +210,24 @@ describe('objectui#8513 — `$not` is out of scope, and stays refused', () => { }); // --------------------------------------------------------------------------- -// 2b. The STAGED `$empty` partition — refused, as the spec stages it +// 2b. The `$empty` cases — staged until 17.6.0, executed since (objectui#11094) // --------------------------------------------------------------------------- -describe('objectui#11073 — the staged `$empty` flag is refused by this matcher, as the spec stages it', () => { - it('the staged partition is exactly these cases', () => { - // By NAME, so another staged case landing upstream is a decision. - expect(STAGED_EMPTY_CASES.map((c) => c.name).sort()).toEqual( +describe('objectui#11094 — the `$empty` flag is executed now that the spec admits it', () => { + it('the staging is over upstream — `$empty` is in FILTER_OPERATORS', () => { + // The expiry row objectui#11073 left here, flipped. It read "not in" and + // reddened on `@objectstack/spec` 17.6.0 (objectstack#20446), which is the + // day this face owed its arm; the partition it guarded moved into the + // executed table above. Lit control: a flag operator that was always in. + expect(FILTER_OPERATORS as readonly string[]).toContain('$empty'); + expect(FILTER_OPERATORS as readonly string[]).toContain('$null'); + }); + + it('the formerly staged partition is exactly these cases, and every one is executed', () => { + // By NAME, so another `$empty` case landing upstream is a decision. They + // run in §1 with the rest of the executed table: the expected row set, and + // no logged refusal. + expect(EMPTY_CASES.map((c) => c.name).sort()).toEqual( [ '$empty true selects exactly the no-value rows', '$empty false selects exactly the valued rows', @@ -218,28 +236,8 @@ describe('objectui#11073 — the staged `$empty` flag is refused by this matcher '$empty ANDs with a sibling operator on the same field', ].sort(), ); + for (const testCase of EMPTY_CASES) expect(EXECUTED_CASES).toContain(testCase); }); - - it('the staging still holds upstream — `$empty` is not in FILTER_OPERATORS', () => { - // The expiry of this partition. The day the flip card adds `$empty` to - // `FILTER_OPERATORS`, the query executors stop refusing it and this face - // owes its arm: this row reddens, and the partition above moves back into - // the executed table. Lit control: a flag operator that IS in the list. - expect(FILTER_OPERATORS as readonly string[]).not.toContain('$empty'); - expect(FILTER_OPERATORS as readonly string[]).toContain('$null'); - }); - - it.each(STAGED_EMPTY_CASES.map((c) => [c.name, c] as const))( - 'refused, not answered: %s', - async (_name, testCase) => { - const warn = spyWarn(); - // A visibly different answer from the table's, AND a logged reason that - // names the operator — never a silent partial answer. - expect(await selectedIds(testCase.filter)).not.toEqual(testCase.expected); - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0]?.[0])).toContain('$empty'); - }, - ); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/evaluator/optionRules.ts b/packages/core/src/evaluator/optionRules.ts index 9713ef7661..0d0aa7f4ea 100644 --- a/packages/core/src/evaluator/optionRules.ts +++ b/packages/core/src/evaluator/optionRules.ts @@ -118,7 +118,7 @@ export function resolveVisibleOptions( * This is deliberately NOT the lenient renderer fallback AGENTS.md #0.1 * forbids. That rule governs metadata the contract REFUSES ("if the metadata * is off-spec, fix it at the producer"); an empty label is metadata the - * contract ACCEPTS. Measured on the installed `@objectstack/spec` 17.5.0, + * contract ACCEPTS. Measured on the installed `@objectstack/spec` 17.6.0, * `SelectOptionSchema`: * * { value: 'low', label: 'Low' } -> ACCEPT (lit control) diff --git a/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts b/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts new file mode 100644 index 0000000000..cd95d7fb82 --- /dev/null +++ b/packages/core/src/utils/__tests__/filter-empty-operator-11094.test.ts @@ -0,0 +1,138 @@ +/** + * 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#11094 — the `$empty` operator through this repo's `$`-dialect + * lowering, and the same filter through both arms of `ValueDataSource`. + * + * `@objectstack/spec` 17.6.0 admitted `$empty` to `FILTER_OPERATORS` + * (objectstack#20446) and lowers the view operators `is_empty` / + * `is_not_empty` to it (objectstack#20570). Before this card, + * `convertFiltersToAST` had no arm for it and threw "Unknown filter operator", + * so an object filter carrying the canonical operator never reached the wire, + * while `ValueDataSource` was about to execute it. That is the + * objectui#8976 shape (`$icontains`), one operator over. + * + * Three facts are pinned here: + * + * 1. the lowering is the inverse of the spec's own `parseFilterAST`, so the + * node round-trips through the server's reader to the filter the author + * wrote; + * 2. a flag that is not a boolean is refused in the `INVALID_FILTER` / 400 + * envelope rather than read for its truthiness; + * 3. one authored filter selects one row set, whether it reaches + * `ValueDataSource` as the object, as the lowered node, or as a stored + * view rule. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { isFilterAST, parseFilterAST } from '@objectstack/spec/data'; +import type { QueryParams } from '@object-ui/types'; +import { convertFiltersToAST, toFilterNode, FilterOperatorError } from '../filter-converter'; +import { ValueDataSource } from '../../adapters/ValueDataSource'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('objectui#11094 — convertFiltersToAST lowers `$empty`', () => { + it('`$empty: true` lowers to `is_empty`, and `false` to `is_not_empty`', () => { + // The `true` in the value slot is the placeholder `$null` and `$exists` + // already carry: direction comes from the operator NAME. + expect(convertFiltersToAST({ a: { $empty: true } })).toEqual(['a', 'is_empty', true]); + expect(convertFiltersToAST({ a: { $empty: false } })).toEqual(['a', 'is_not_empty', true]); + }); + + it('round-trips through the spec\'s own reader to the filter that was written', () => { + for (const flag of [true, false]) { + const node = convertFiltersToAST({ a: { $empty: flag } }); + expect(isFilterAST(node)).toBe(true); + expect(parseFilterAST(node)).toEqual({ a: { $empty: flag } }); + } + }); + + it('composes with a sibling key like every other operator', () => { + expect(convertFiltersToAST({ a: { $empty: true }, b: 1 })).toEqual([ + 'and', + ['a', 'is_empty', true], + ['b', '=', 1], + ]); + }); + + it.each([['yes'], [1], [0], [null], [[]]])( + 'refuses a flag that is not a boolean — `$empty: %j`', + (flag) => { + let thrown: unknown; + try { + convertFiltersToAST({ a: { $empty: flag } }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(FilterOperatorError); + // The envelope a refusal is read by, not merely "it threw". + expect(thrown).toMatchObject({ + code: 'INVALID_FILTER', + httpStatus: 400, + operator: '$empty', + field: 'a', + }); + expect((thrown as Error).message).toContain('$empty'); + }, + ); + + it('the unknown-operator message lists it among the supported operators', () => { + let message = ''; + try { + convertFiltersToAST({ a: { $definitelyNotAnOperator: 1 } }); + } catch (error) { + message = (error as Error).message; + } + expect(message).toMatch(/Supported operators:[^.]*\$empty/); + }); +}); + +/** + * Rows on which `$empty` and `$null` disagree: a `''` and a `[]`. On a fixture + * without them the two operators select the same rows, and nothing here could + * tell an emptiness test from a null test. + */ +const ROWS = [ + { id: 'full', v: 'x' }, + { id: 'blank', v: '' }, + { id: 'list', v: [] }, + { id: 'null', v: null }, + { id: 'missing' }, +]; + +async function selectedIds(filter: unknown): Promise { + const ds = new ValueDataSource({ items: ROWS }); + const result = await ds.find('rows', { $filter: filter as QueryParams['$filter'] }); + return result.data.map((r) => r.id as string); +} + +describe('objectui#11094 — one `$empty` filter, one row set, on every path into ValueDataSource', () => { + it.each([ + [true, ['blank', 'list', 'null', 'missing']], + [false, ['full']], + ] as const)('`$empty: %s`', async (flag, expected) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const object = { v: { $empty: flag } }; + const lowered = toFilterNode(object); + const storedRule = toFilterNode([{ field: 'v', operator: flag ? 'is_empty' : 'is_not_empty' }]); + + expect(await selectedIds(object)).toEqual(expected); + expect(await selectedIds(lowered)).toEqual(expected); + expect(await selectedIds(storedRule)).toEqual(expected); + expect(warn).not.toHaveBeenCalled(); + }); + + it('and none of the three is the null test it used to be', async () => { + expect(await selectedIds({ v: { $null: true } })).toEqual(['null', 'missing']); + expect(await selectedIds({ v: { $empty: true } })).not.toEqual(['null', 'missing']); + }); +}); diff --git a/packages/core/src/utils/__tests__/filter-refusal-subject-9050.test.ts b/packages/core/src/utils/__tests__/filter-refusal-subject-9050.test.ts index ca00670243..4375a40256 100644 --- a/packages/core/src/utils/__tests__/filter-refusal-subject-9050.test.ts +++ b/packages/core/src/utils/__tests__/filter-refusal-subject-9050.test.ts @@ -32,6 +32,8 @@ * ⚠️ UPDATED. It did: objectui#9164 added the empty-operator-map refusal * (`{ a: {} }`), and that case went red until its row was added below. Like * the two comparand arms it names no operator, so `field` is its subject. + * objectui#11094 added the `$empty` flag refusal (a flag that is not a + * boolean) the same way; it names its operator, so `$empty` is its subject. * * ⚠️ Each input is driven through `toFilterNodeSafely` — the RENDER-time entry, * not `convertFiltersToAST` directly — because the question is what a renderer @@ -73,6 +75,7 @@ const REFUSALS: Array<{ { site: 'bare array equality comparand', input: { tags: ['a', 'b'] }, operator: undefined, field: 'tags', subject: 'tags' }, { site: 'exotic comparand', input: { created: /abc/ }, operator: undefined, field: 'created', subject: 'created' }, { site: '$regex operator', input: { name: { $regex: 'a.c' } }, operator: '$regex', field: 'name', subject: '$regex' }, + { site: '$empty flag is not a boolean', input: { a: { $empty: 'yes' } }, operator: '$empty', field: 'a', subject: '$empty' }, { site: 'retired lowercase alias', input: { name: { $startswith: 'x' } }, operator: '$startswith', field: 'name', subject: '$startswith' }, { site: 'unknown operator', input: { name: { $bogus: 1 } }, operator: '$bogus', field: 'name', subject: '$bogus' }, { site: 'view rule: array on a single-value operator', input: [{ field: 'tags', operator: 'equals', value: ['a'] }], operator: 'equals', field: 'tags', subject: 'equals' }, diff --git a/packages/core/src/utils/__tests__/filter-source-merge.test.ts b/packages/core/src/utils/__tests__/filter-source-merge.test.ts index 74cb546ca9..054e8328eb 100644 --- a/packages/core/src/utils/__tests__/filter-source-merge.test.ts +++ b/packages/core/src/utils/__tests__/filter-source-merge.test.ts @@ -101,7 +101,12 @@ describe('lowering a ViewFilterRule — the operator vocabulary (objectui#3431)' const node = toFilterNode([{ field: 'a', operator: 'is_empty' }]); expect(node).toEqual([['a', 'is_empty']]); expect(isFilterAST(node)).toBe(true); - expect(parseFilterAST(node)).toEqual({ a: { $null: true } }); + // What the server reads the 2-tuple AS. It read `$null: true` until + // `@objectstack/spec` 17.6.0, whose `parseFilterAST` lowers `is_empty` to + // the emptiness operator instead (objectstack#20570, objectui#11094). The + // assertion's job is unchanged: the server reads the rule's own predicate, + // keyed on the operator NAME, and not an equality on a `null` nobody wrote. + expect(parseFilterAST(node)).toEqual({ a: { $empty: true } }); }); it('passes an operator the spec does not know through VERBATIM', () => { diff --git a/packages/core/src/utils/filter-converter.ts b/packages/core/src/utils/filter-converter.ts index 5d04847cc4..ff63bb7dc4 100644 --- a/packages/core/src/utils/filter-converter.ts +++ b/packages/core/src/utils/filter-converter.ts @@ -578,7 +578,9 @@ function refuseEmptyOperatorMap(field: string): never { * `ValueDataSource` has refused since objectui#8748; see * {@link refuseTextComparand} (objectui#9001), or if a field is an EMPTY * operator map (`{ a: {} }`) — alone (objectui#9164) or - * beside a key that lowers (objectui#10788): see {@link refuseEmptyOperatorMap}. + * beside a key that lowers (objectui#10788): see {@link refuseEmptyOperatorMap}, + * or if a `$empty` flag is not a boolean (`{ a: { $empty: 'yes' } }`, + * objectui#11094). * * @example * // A field with NO operator (objectui#9164) @@ -852,6 +854,32 @@ export function convertFiltersToAST( conditions.push([field, operatorValue ? 'is_not_null' : 'is_null', true]); continue; } + // objectui#11094 — `$empty`, admitted to the spec's `FILTER_OPERATORS` + // in `@objectstack/spec` 17.6.0 (objectstack#20446). The spec's own + // `parseFilterAST` reads `is_empty` as `$empty: true` and `is_not_empty` + // as `$empty: false`, so this is the inverse of that lowering, written + // the way `$null` is written above: direction from the flag, a `true` + // placeholder in the value slot, which the spec discards. + // + // The flag is a BOOLEAN by declaration (`SpecialOperatorSchema`), so a + // non-boolean is refused rather than read for its truthiness. That is + // the answer the spec's save door and every query face give, and + // `ValueDataSource`'s `$empty` arm refuses the same values, so one + // authored filter gets one answer whichever data source is behind it. + // Until this arm the operator reached the unknown-operator throw below. + if (operator === '$empty') { + if (operatorValue !== true && operatorValue !== false) { + throw new FilterOperatorError( + `[ObjectUI] The '$empty' filter operator on field '${field}' takes a boolean flag ` + + `(true or false); received ${JSON.stringify(operatorValue) ?? String(operatorValue)}. ` + + `Write { ${field}: { $empty: true } } for an empty value, or ` + + `{ ${field}: { $empty: false } } for a non-empty one.`, + { operator: '$empty', field }, + ); + } + conditions.push([field, operatorValue ? 'is_empty' : 'is_not_empty', true]); + continue; + } const astOperator = convertOperatorToAST(operator); @@ -892,7 +920,7 @@ export function convertFiltersToAST( throw new FilterOperatorError( `[ObjectUI] Unknown filter operator '${operator}' for field '${field}'. ` + `Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $between, ` + - `$contains, $notContains, $startsWith, $endsWith, $icontains, $null, $exists. ` + + `$contains, $notContains, $startsWith, $endsWith, $icontains, $null, $exists, $empty. ` + `If you need exact object matching, use the value directly without an operator.`, { operator, field }, ); diff --git a/packages/data-objectstack/README.md b/packages/data-objectstack/README.md index 9b409e9cb1..5ea45642ba 100644 --- a/packages/data-objectstack/README.md +++ b/packages/data-objectstack/README.md @@ -160,11 +160,23 @@ of going unnoticed. | `$icontains` | `icontains` | `{ name: { $icontains: 'john' } }` → `['name', 'icontains', 'john']` | | `$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]` | - -`$null` and `$exists` read their boolean: `$null: false` lowers to -`is_not_null` and `$exists: false` to `is_null`. The lowered node's value slot -is always `true` — the direction comes from the operator name, which is how the -spec's `data/filter.zod.ts` reads it. +| `$empty` | `is_empty` / `is_not_empty` | `{ email: { $empty: true } }` → `['email', 'is_empty', true]` | + +`$null`, `$exists` and `$empty` read their boolean: `$null: false` lowers to +`is_not_null`, `$exists: false` to `is_null` and `$empty: false` to +`is_not_empty`. The lowered node's value slot is always `true` — the direction +comes from the operator name, which is how the spec's `data/filter.zod.ts` +reads it. `$empty` takes ONLY a boolean: any other flag throws `INVALID_FILTER` +/ 400 at lowering time, which is how the spec's own doors treat it. + +`$empty` is not `$null`. objectstack#20446 admitted it to the spec's +`FILTER_OPERATORS` and flipped the view operators `is_empty` / `is_not_empty` +to lower to it; they lowered to `$null` before. Its meaning is the spec's ruled +per-type table: a text-like field is empty when null or `''`, a multi-value +field when null or `[]`, and any other field when null. Which backend applies +that table, and how, is listed in the docblock of the spec's +`FILTER_OPERATORS`, not here. Until objectui#11094 `convertFiltersToAST` +refused `$empty` as an unknown operator. `$icontains` constrains its **comparand**, which no other row in this table does: `@objectstack/spec`'s `FILTER_TEXT_CASES` declares an empty or non-string diff --git a/packages/data-objectstack/src/spec-symbol-batch6.test.ts b/packages/data-objectstack/src/spec-symbol-batch6.test.ts index aa21dd3fe6..8b34eae160 100644 --- a/packages/data-objectstack/src/spec-symbol-batch6.test.ts +++ b/packages/data-objectstack/src/spec-symbol-batch6.test.ts @@ -242,16 +242,15 @@ describe('DroppedFieldsEvent IS the spec type, not a mirror of it', () => { // IT FIRED AGAIN at objectstack `b2805465`, which added `computed` (a // formula field's caller-supplied value, stripped on every write path) — // this time in the `Spec Main Shape Gate`, before any pin moved - // (objectui#11206). This file compiles against BOTH the published pin - // (17.5.0, three arms) and objectstack `main` (four), so the member list is - // stated with `computed` subtracted. That is still exact on each side: - // `computed` is the only member the `Exclude` can absorb, so any other new - // arm is a remainder `Equal` refuses, and `_ReasonIsNotString` below still - // refuses the widening. When the pin moves past 17.5.0 to a release - // carrying `computed`, this becomes the four-member union with no `Exclude` - // (`writeWarningToast.test.ts` fails on that bump to say so). + // (objectui#11206). Until the pin carried it, this file compiled against + // BOTH a three-arm pin and objectstack `main` (four), so the member list + // was stated with `computed` subtracted. `@objectstack/spec` 17.6.0 carries + // `computed`, so at that bump (objectui#11438) the pin and `main` agree and + // this is the exact four-member union again, with no `Exclude`: any new arm + // is a remainder `Equal` refuses, and `_ReasonIsNotString` below still + // refuses the widening. type _ReasonIsTheEnum = Assert< - Equal, 'readonly' | 'readonly_when' | 'primary_key'> + Equal >; type _ReasonIsNotString = Assert, false>>; diff --git a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts index b650e9c74f..e7ef01cc84 100644 --- a/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts +++ b/packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts @@ -90,13 +90,23 @@ const noTypes = () => undefined; * not a run. */ /* - * `$empty` arrived with `@objectstack/spec` 17.5.0 (objectui#11073) and is a - * different kind of entry: a STAGED operator, not a builder decision. The spec - * declares it "ahead of its backends and absent from FILTER_OPERATORS. Until - * each face has its arm, the query executors refuse it … the view operators - * is_empty / is_not_empty still lower to $null." A dropdown row emitting it - * would therefore author a filter every executor refuses. It leaves this set - * the day `FILTER_OPERATORS` admits it — the row below the ratchet reddens then. + * `$empty` arrived with `@objectstack/spec` 17.5.0 (objectui#11073) as a STAGED + * operator: declared, absent from `FILTER_OPERATORS`, refused by every query + * executor, so a dropdown row emitting it would have authored a filter nothing + * could run. The entry was to leave this set the day `FILTER_OPERATORS` admitted + * it, and the expiry row below reddened on `@objectstack/spec` 17.6.0 + * (objectstack#20446), which admitted it and lowers `is_empty` / `is_not_empty` + * to it. + * + * ⚠️ It has NOT left, and the reason changed rather than expired. The staging + * reason is gone; what remains is which builder row should author `$empty`. + * This widget's `is_empty` writes "no value OR `''`" on every field type + * (objectui#10790), one of the three meanings of 「is empty」 objectui#10813 + * reconciles. Moving that row onto `$empty` changes what a stored sharing rule + * or roll-up filter selects, so it is that card's decision, not the 17.6.0 + * bump's (objectui#11094 executed the operator and left the builders alone). + * The entry now leaves the day a builder operator emits `$empty`: the second + * row below the ratchet reddens then. */ const KNOWN_UNREACHABLE = new Set(['$eq', '$between', '$like', '$ilike', '$empty']); @@ -179,12 +189,31 @@ describe('every spec field operator is reachable from the builder (#2942)', () = ).toEqual([]); }); - it('the staged `$empty` exclusion expires when FILTER_OPERATORS admits it', () => { - // Lit control: a flag operator that IS in the list. - expect(FILTER_OPERATORS as readonly string[]).not.toContain('$empty'); + it('the staged `$empty` exclusion expired: FILTER_OPERATORS admits it', () => { + // The objectui#11073 expiry row, flipped. It read "not in" and reddened on + // `@objectstack/spec` 17.6.0 (objectstack#20446). Lit control: a flag + // operator that was always in the list. + expect(FILTER_OPERATORS as readonly string[]).toContain('$empty'); expect(FILTER_OPERATORS as readonly string[]).toContain('$null'); }); + it('the `$empty` exclusion now expires when a builder operator emits it (objectui#10813)', () => { + // The entry's remaining reason, held mechanically: no drawable builder id + // writes `$empty` today. The day objectui#10813 moves a row onto it, this + // reddens and `$empty` leaves KNOWN_UNREACHABLE, so the sweep above starts + // holding that row to the spec like every other. Derived from the same + // drawable vocabulary the sweep feeds `condToMongo`. + const emitted = new Set(); + for (const operator of FILTER_BUILDER_OPERATORS) { + const value = operator === 'in' || operator === 'not_in' ? ['a'] : operator === 'between' ? [1, 5] : 'a'; + const frag = condToMongo({ id: 'c1', field: 'f', operator, value }, noTypes); + for (const op of operatorsOf(frag)) emitted.add(op); + } + // Lit control: the sweep really reads emitted operators. + expect(emitted.has('$null')).toBe(true); + expect(emitted.has('$empty')).toBe(false); + }); + it('every KNOWN_UNREACHABLE token is still a spec operator (the exclusion ratchet)', () => { // A stale exclusion is how a parity test rots into a tautology: if the spec // ever drops one of these, the entry must go too rather than sit there diff --git a/packages/i18n/src/utils/spec-translations.ts b/packages/i18n/src/utils/spec-translations.ts index d0e0fe3476..960eff7287 100644 --- a/packages/i18n/src/utils/spec-translations.ts +++ b/packages/i18n/src/utils/spec-translations.ts @@ -85,6 +85,10 @@ const SPEC_TRANSLATION_GROUPS = [ 'settings', 'metadataForms', 'settingsCommon', + // `@objectstack/spec` 17.6.0 serves `picklists` (objectstack `addbbf02`, the + // `picklist` metadata kind); added at that bump (objectui#11438). The + // transform below copies it through onto `app` like any other group. + 'picklists', ] as const; function isPlainObject(value: unknown): value is Record { diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index cc05ab395a..b7f1e6f997 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -1247,7 +1247,7 @@ export const ObjectCalendar: React.FC = ({ * * `@objectstack/spec`'s `CalendarConfigSchema` is a `strictObject` whose ONE * required key is `startDateField`; `titleField` is optional. Re-measured on - * the installed 17.5.0, three legs: `{}` and `{ titleField: 't' }` both fail + * the installed 17.6.0, three legs: `{}` and `{ titleField: 't' }` both fail * `invalid_type` at `startDateField`, and `{ startDateField: 'd' }` parses * CLEAN. The spec's own note on that schema names THIS renderer as the * reason — `resolveTitle` above takes an explicit `titleField` when present diff --git a/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx index cdeae8716c..34e64ac512 100644 --- a/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx +++ b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx @@ -177,7 +177,7 @@ const LEDGERED_OTHER_CARD_READS: readonly string[] = []; * `ObjectCalendarSchema` and must stay so. * * ⚠️ The ground is NOT that the spec singles these two out. MEASURED on - * installed `@objectstack/spec` 17.5.0: `ComponentPropsMap['object-calendar']` + * installed `@objectstack/spec` 17.6.0: `ComponentPropsMap['object-calendar']` * is STRICT and declares exactly ten flat members — `calendar` `data` * `defaultView` `filter` `loading` `locale` `navigation` `objectName` `sort` * `staticData` (nine through 17.4.0; 17.5.0 added `navigation`) — diff --git a/packages/plugin-dashboard/src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx b/packages/plugin-dashboard/src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx index eef06d77a1..c735d45482 100644 --- a/packages/plugin-dashboard/src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DatasetWidget.unrenderedMeasures-8894.test.tsx @@ -54,6 +54,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { MIGRATIONS_BY_MAJOR } from '@objectstack/spec/migrations'; import { ChartTypeSchema as SpecChartTypeSchema } from '@objectstack/spec/ui'; import { DashboardWidgetSchema } from '@object-ui/types/zod'; import { DatasetWidget } from '../DatasetWidget'; @@ -91,20 +92,17 @@ type SemanticEntry = { id: string; replacement: string }; type MigrationRegistry = Readonly>; /** - * The installed spec's ADR-0087 registry, read off the package ROOT, where the - * pinned `@objectstack/spec` 17.5.0 exports `MIGRATIONS_BY_MAJOR` (it has no - * `./migrations` subpath). objectstack `main` moved the chain to - * `@objectstack/spec/migrations` (its `migrations-entry-split` entry: same - * names, new path), and the Spec Main Shape Gate compiles this file against - * `main` — so the root is imported dynamically and read as a plain record, - * which type-checks against both shapes without naming a module either lacks. - * ⛔ No fallback to the subpath: on a spec whose root no longer carries the - * registry the lookup below fails by name, and the pull request that moves the - * pin past the split replaces this with a static import from the subpath. + * The installed spec's ADR-0087 registry, read off the `./migrations` subpath. + * Through 17.5.0 the chain was exported from the package ROOT and this file + * read it there dynamically, so it would compile against both that and + * objectstack `main`, which had moved it (its `migrations-entry-split` entry: + * same names, new path). `@objectstack/spec` 17.6.0 carries the split and its + * root no longer exports the registry, so the bump that moved the pin past it + * (objectui#11438) replaced the dynamic root read with this static import, as + * the previous docblock asked. ⛔ No fallback to the root. */ -async function installedMigrationRegistry(): Promise { - const root = (await import('@objectstack/spec')) as Record; - return root.MIGRATIONS_BY_MAJOR as MigrationRegistry | undefined; +function installedMigrationRegistry(): MigrationRegistry { + return MIGRATIONS_BY_MAJOR; } /** The duly#109 tile, verbatim from the card's repro block — a stored three-measure metric. */ @@ -172,13 +170,8 @@ describe('objectui#8894 — the dropped measures speak', () => { // The pointer cannot dangle: the id is read back out of the installed // spec's migration registry, and the entry carries the `replacement` the // message sends the reader to. - const registry = await installedMigrationRegistry(); - expect( - registry, - 'the installed spec no longer exports MIGRATIONS_BY_MAJOR from its root — the pin has passed the ' - + 'migrations-entry-split: import it from the `/migrations` subpath here', - ).toBeDefined(); - const entry = Object.values(registry!) + const registry = installedMigrationRegistry(); + const entry = Object.values(registry) .flatMap((step) => step.semantic) .find((e) => e.id === ENTRY_ID); expect(entry, `${ENTRY_ID} is not in the installed spec's ADR-0087 registry`).toBeDefined(); diff --git a/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx b/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx index 21c8e47552..a13d1e049b 100644 --- a/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/WidgetConfigPanel.measureDoor-8894.test.tsx @@ -30,6 +30,14 @@ * A refusal shown in the panel is asserted to EQUAL the spec's message for the * same document, read off the spec's own parse: equal text proves the panel * surfaces the door's verdict rather than a restatement of it. + * + * ## OWED TO objectui#11334 — a bounded ledger, not a skip + * + * The spec's dimensionless measure-arity check puts more types into FAMILY than + * objectui's door refuses, because the mirror does not re-attach it yet. Those + * types are booked in `OWED_TO_OBJECTUI_11334` below, as in the types package's + * test: their rows assert what the panel does today, and a cap row requires the + * set the panel fails to refuse to equal the ledger exactly. */ import * as React from 'react'; @@ -76,6 +84,46 @@ const FAMILY = SPEC_TYPES.filter((t) => specArityIssue(t, TWO) !== undefined); /** Every other spec widget type. */ const OTHERS = SPEC_TYPES.filter((t) => !FAMILY.includes(t)); +/** + * ⚠️ OWED TO objectui#11334 — the FAMILY types the panel does NOT refuse yet, + * booked rather than skipped. + * + * The spec this repository resolves refuses two or more measures on these types + * when the widget declares no `dimensions` + * (`checkDashboardWidgetDimensionlessMeasureArity`, objectstack `11d28c17`, + * #21053). The panel asks objectui's door (`measureRefusal`), and the door's + * mirror does not re-attach that check: objectstack `main` renamed its export + * (`32d57690`), and a static import of the name the resolved spec ships fails + * the `Spec Main Shape Gate`. objectui#11334 owns the mirror; the panel follows + * it with no edit of its own. + * + * Booked by objectui#11438 ruling A″ (record 5968177777), which applies + * objectui#11111 decision 3 = B (record 5902351047) to the bump. Each listed + * type's rows assert TODAY's behaviour (the add control is offered, and no + * refusal is shown under a stored pair the spec refuses), and the cap row + * requires the FAMILY types the panel fails to refuse to EQUAL this list. A new + * difference is red, and so is a listed type the panel starts refusing: the + * entries go stale, by name, when the mirror attaches, and objectui#11334 + * strikes them in that change. + * + * EXPIRES when objectui resolves an `@objectstack/spec` carrying `32d57690`, or + * 2026-11-02, whichever is first. That resolution is caught by name in + * `packages/types/src/__tests__/spec-object-refinements-7715.test.ts`, whose + * census books the same check; the date is read by objectui#11334, not by a + * clock. + */ +const OWED_TO_OBJECTUI_11334 = ['pie', 'donut', 'funnel', 'scatter', 'treemap', 'sankey', 'radar']; + +/** The reason every owed row prints when it fails. */ +const OWED_REASON = + 'OWED TO objectui#11334: the dimensionless measure-arity check is not re-attached by the mirror yet. ' + + 'Booked by objectui#11438 ruling A″ (record 5968177777), applying objectui#11111 decision 3 = B ' + + '(record 5902351047). Expires when objectui resolves an @objectstack/spec carrying 32d57690, or ' + + '2026-11-02, whichever is first.'; + +/** The FAMILY types the panel refuses today: FAMILY minus the ledger. */ +const MIRRORED = FAMILY.filter((t) => !OWED_TO_OBJECTUI_11334.includes(t)); + const renderPanel = (config: Record, datasets: WidgetDatasetCatalogEntry[] | undefined = catalog) => render(); @@ -94,7 +142,7 @@ describe('objectui#8894 — the family is read off the spec\'s rule, and it is n }); describe('objectui#8894 — no second measure is offered on a metric-family widget', () => { - it.each(FAMILY)('SUBJECT: `%s` with one measure chosen offers no add control (catalog)', (type) => { + it.each(MIRRORED)('SUBJECT: `%s` with one measure chosen offers no add control (catalog)', (type) => { renderPanel({ id: 'rev_tile', type, dataset: 'sales_pipeline', values: ONE }); expect(valuesField().getByTestId('dataset-name-chip-revenue')).toBeInTheDocument(); expect(addControl()).toBeNull(); @@ -102,7 +150,7 @@ describe('objectui#8894 — no second measure is offered on a metric-family widg expect(valuesField().queryByTestId('dataset-names-refusal')).toBeNull(); }); - it.each(FAMILY)('SUBJECT: `%s` with one measure chosen offers no add control (free text, no catalog)', (type) => { + it.each(MIRRORED)('SUBJECT: `%s` with one measure chosen offers no add control (free text, no catalog)', (type) => { renderPanel({ id: 'rev_tile', type, dataset: 'sales_pipeline', values: ONE }, undefined); expect(addControl()).toBeNull(); }); @@ -120,7 +168,7 @@ describe('objectui#8894 — no second measure is offered on a metric-family widg }); describe('objectui#8894 — measures the door refuses are reported at once, in the door\'s words', () => { - it.each(FAMILY)('SUBJECT: a stored `%s` with two measures shows the spec\'s own refusal under them', (type) => { + it.each(MIRRORED)('SUBJECT: a stored `%s` with two measures shows the spec\'s own refusal under them', (type) => { renderPanel({ id: 'sales_tile', type, dataset: 'sales_pipeline', values: TWO }); const shown = valuesField().getByTestId('dataset-names-refusal'); expect(shown).toHaveAttribute('role', 'alert'); @@ -143,3 +191,44 @@ describe('objectui#8894 — measures the door refuses are reported at once, in t expect(addControl()).not.toBeNull(); }); }); + +describe('objectui#11334 — OWED: what the panel does today for the types the spec refuses and the door accepts', () => { + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: `%s` with one measure chosen still offers the add control (catalog)', (type) => { + renderPanel({ id: 'rev_tile', type, dataset: 'sales_pipeline', values: ONE }); + expect(valuesField().getByTestId('dataset-name-chip-revenue')).toBeInTheDocument(); + expect(addControl(), OWED_REASON).not.toBeNull(); + expect(valuesField().queryByTestId('dataset-names-refusal'), OWED_REASON).toBeNull(); + }); + + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: `%s` with one measure chosen still offers the add control (free text, no catalog)', (type) => { + renderPanel({ id: 'rev_tile', type, dataset: 'sales_pipeline', values: ONE }, undefined); + expect(addControl(), OWED_REASON).not.toBeNull(); + }); + + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: a stored `%s` with two measures and no dimension shows no refusal, though the spec refuses it', (type) => { + expect(specArityIssue(type, TWO), OWED_REASON).toBeDefined(); + renderPanel({ id: 'sales_tile', type, dataset: 'sales_pipeline', values: TWO }); + expect(valuesField().queryByTestId('dataset-names-refusal'), OWED_REASON).toBeNull(); + expect(valuesField().getByTestId('dataset-name-chip-revenue')).toBeInTheDocument(); + expect(valuesField().getByTestId('dataset-name-chip-deal_count')).toBeInTheDocument(); + expect(addControl(), OWED_REASON).not.toBeNull(); + }); +}); + +describe('objectui#11334 — the cap: the panel fails to refuse EXACTLY the ledgered FAMILY types', () => { + it('the FAMILY types whose stored two-measure widget shows no spec refusal equal the ledger', () => { + const unrefused = FAMILY.filter((type) => { + const { unmount } = renderPanel({ id: 'sales_tile', type, dataset: 'sales_pipeline', values: TWO }); + const shown = valuesField().queryByTestId('dataset-names-refusal'); + const refused = shown !== null && shown.textContent === specArityIssue(type, TWO)?.message; + unmount(); + return !refused; + }); + expect([...unrefused].sort(), OWED_REASON).toEqual([...OWED_TO_OBJECTUI_11334].sort()); + }); + + it('the ledger leaves the metric family judged: the mirrored rows are not vacuous', () => { + expect(MIRRORED.length).toBeGreaterThan(0); + expect(MIRRORED).toContain('kpi'); + }); +}); diff --git a/packages/plugin-designer/src/MetadataFieldsPage.refusalIssues-11302.test.tsx b/packages/plugin-designer/src/MetadataFieldsPage.refusalIssues-11302.test.tsx index 9e23acd7ad..74366910dd 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.refusalIssues-11302.test.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.refusalIssues-11302.test.tsx @@ -204,12 +204,13 @@ describe('the instrument — what the installed spec says about each probe name' expect(issues[0].message).toMatch(/Rename the key/); }); - it('`constructor`: refused today with a FIELD-LESS issue (objectstack#20997 names the key)', () => { - // The upstream pair: until objectstack#20997 lands, the reserved-name - // refusal is raised on the map, not on the key. The page renders whatever - // path it is given, so only this reading moves when that lands. + it('`constructor`: refused at its key since objectstack#20997', () => { + // The upstream pair: through 17.5.0 the reserved-name refusal was raised on + // the map, not on the key (`custom @ fields`). objectstack#20997 names the + // key, and `@objectstack/spec` 17.6.0 carries it, so this reading moved at + // that bump (objectui#11438). The page renders whatever path it is given. const issues = specIssuesFor('constructor'); - expect(issues.map((i) => `${i.code} @ ${i.path}`)).toEqual(['custom @ fields']); + expect(issues.map((i) => `${i.code} @ ${i.path}`)).toEqual(['custom @ fields.constructor']); }); }); @@ -235,13 +236,13 @@ describe('objectui#11302 · a spec-refused save shows the per-field prescription expect(shown).toMatch(/^• fields\.__proto__ — .*Rename the key\.$/); }); - it('`constructor`: today\'s field-less issue shows at the map, with its prescription', async () => { + it('`constructor`: the banner names the key, with its prescription', async () => { await addFieldThroughDrawer('constructor'); const shown = await banner(); expect(refusal).not.toBeNull(); expect(shown).toBe(refusal!.issues.map(formatMetadataIssue).join('\n')); - expect(shown).toMatch(/^• fields — .*constructor/); + expect(shown).toMatch(/^• fields\.constructor — .*constructor/); }); it('the multi-line list keeps its newlines on screen', async () => { diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index e91b206437..8accb079d8 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -183,7 +183,7 @@ function isDesignerAuthorableType(raw: unknown): raw is DesignerFieldType { * ## Why reading the retired spelling is a RENAME here and not a laundering * * `@objectstack/spec` treats the two as one key under two spellings and says - * so in the refusal itself. Measured on the installed 17.5.0: + * so in the refusal itself. Measured on the installed 17.6.0: * * FieldSchema.safeParse({ type: 'lookup', label: 'L', referenceTo: 'account' }) * => success = false diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 3af75c0f1e..2f4fd90089 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -942,7 +942,7 @@ ComponentRegistry.register('alert', RecordAlertRenderer, { // through `pickLocalized`, which is exactly what these descriptions teach. // Declaring the map arm therefore adds no shape the block does not already // honour; it stops the manifest gate warning `type-mismatch` on the - // recommended write. (The row DOES exist as of the installed 17.5.0 — read + // recommended write. (The row DOES exist as of the installed 17.6.0 — read // for `visible` below, objectui#9100 — so the "no entry" reading is stale; // these two arms are unaffected either way.) { name: 'title', type: ['string', 'object'], description: 'Accepts an inline translation map ({ en, "zh-CN", … })' }, @@ -950,7 +950,7 @@ ComponentRegistry.register('alert', RecordAlertRenderer, { // objectui#9100 — the spec accepts three arms here and the renderer now // resolves all three, so a single `'string'` was the declaration-narrower- // than-the-contract family of objectui#4581, one layer up. Measured on the - // INSTALLED `@objectstack/spec` 17.5.0 (`dist/ui/index.d.ts`, the + // INSTALLED `@objectstack/spec` 17.6.0 (`dist/ui/index.d.ts`, the // `ComponentPropsMap['record:alert']` row): `visible` is // `boolean | string | { dialect: 'cel'|'cron'|'template', source?, … }`, // and `renderers/record-alert.tsx` hands whichever arrives to diff --git a/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx b/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx index f4cea796bd..2456fc3714 100644 --- a/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx +++ b/packages/plugin-form/src/__tests__/formSectionGroupReference-7051.test.tsx @@ -448,7 +448,7 @@ describe('objectui#7051 — resolveSectionGroupReferences', () => { /** * ⭐ The second axis. The card asks that group-owned presentation keys beside * `group`, and `group` on a wizard step, not be SILENTLY accepted. Measured - * against the installed `@objectstack/spec` (17.5.0): both are refused at PARSE + * against the installed `@objectstack/spec` (17.6.0): both are refused at PARSE * — a stronger door than the lint that objectui#8497 concluded owns EXISTENCE * checking, and a different one. So authored metadata carrying either never * reaches this renderer at all; the renderer's own reports above exist for the diff --git a/packages/plugin-gantt/src/readme-navigation-example.test.ts b/packages/plugin-gantt/src/readme-navigation-example.test.ts index 659fdb8198..9060060c41 100644 --- a/packages/plugin-gantt/src/readme-navigation-example.test.ts +++ b/packages/plugin-gantt/src/readme-navigation-example.test.ts @@ -79,8 +79,9 @@ * * WARNING: `declaredMembers()` cannot derive that key's retirement. When this * was written the spec in the lockfile (17.4.0) still DECLARED `view` as a live - * member. The installed `@objectstack/spec` 17.5.0 lands it as a tombstone - * (objectui#11073 re-measured: typed `never`, and `{ view: 'detail' }` is + * member. The installed `@objectstack/spec` 17.6.0 lands it as a tombstone, + * as 17.5.0 did (objectui#11073 measured it, objectui#11438 again: a `never` + * schema, and `{ view: 'detail' }` is * refused at `view` with "`view.list.navigation.view` was removed in * @objectstack/spec 17.5.0") -- but a tombstone is still a KEY of the shape, * so the key-set reading below lists it exactly as it listed the live member. diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 6e9220cd28..21ff6cd8e9 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -63,8 +63,8 @@ two calls in `src/index.tsx` claim exactly these keys: | `register(…)` call | Namespaced key | Bare fallback | | --- | --- | --- | -| `('object-grid', ObjectGridRenderer, { namespace: 'plugin-grid' })` — `src/index.tsx:202` | `plugin-grid:object-grid` | `object-grid` | -| `('grid', ObjectGridRenderer, { namespace: 'view', skipFallback: true })` — `src/index.tsx:214` | `view:grid` | **none** — `skipFallback: true` | +| `('object-grid', ObjectGridRenderer, { namespace: 'plugin-grid' })` | `plugin-grid:object-grid` | `object-grid` | +| `('grid', ObjectGridRenderer, { namespace: 'view', skipFallback: true })` | `view:grid` | **none** — `skipFallback: true` | The `import-wizard` node key is RETIRED (objectui#10859 batch 8): no schema produced it, and `objectui validate` refused it at `type`. `ImportWizard` is still @@ -169,7 +169,7 @@ An authored grid node takes its props in its `properties` bag, whose members are `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row (`ObjectGridProps`): one required `objectName` — or the node's `dataSource` binding naming the object — and keys drawn from the list this package **declares** as its authoring surface -(`GRID_QUERY_INPUTS`, `src/index.tsx:166`) — the same list that feeds the designer +(`GRID_QUERY_INPUTS` in `src/index.tsx`) — the same list that feeds the designer panel and the generated `sdui-intrinsics.d.ts`, so what is authorable here is what the renderer reads. `objectui validate` judges the bag against that row and refuses a prop written flat on the node by name, naming where it goes @@ -199,6 +199,8 @@ const grid: ObjectGridBlockNode = { | `objectName` | `string` (**required**) | The object queried. There is no `object`. | | `columns` | `string[] \| ListColumn[]` | Field names or column objects — see below. | | `label` | `I18nLabel` | Table caption and export title. | +| `description` | `I18nLabel` | One line of help text above the grid — see `description` and `emptyState` below. | +| `emptyState` | `EmptyState` (`@objectstack/spec/ui`) | `{ title?, message?, icon? }`, drawn in place of an empty table — see below. | | `filter` | `ViewFilterRule[]` | Lowered to `$filter`. | | `sort` | `[{ field, order }]` | Initial order; a header click replaces it. | | `pagination` | `PaginationConfig` | `{ pageSize?, pageSizeOptions? }` — **strict**, and its presence is what enables paging. | @@ -220,35 +222,46 @@ booleans do not exist at all; the five `on*` names are **component props** #### `description` and `emptyState` -Two more keys the grid honours on the node it reads (objectui#11068): +Two keys the grid honours (objectui#11068). The upstream protocol's `object-grid` +row declares both (objectstack#20694), so both are bag members and both are in +`GRID_QUERY_INPUTS` (objectui#11227): - `description` — one line of help text drawn above the grid. A string, or an inline locale map resolved against the display locale the way `label` is. -- `emptyState: { title?, message?, icon? }` — drawn **in place of** an empty - table: a Lucide `icon`, a `title` (default: the table's own "No results found") - and a `message` (default: none). It is not drawn when a term typed into the - grid's own server-side search box is what emptied it — the table and its - search box stay, so the term can be cleared. Leave the key out and an empty - grid draws the table's own empty row, as before. - -`description` is a base prop, so it sits on the node beside the bag: +- `emptyState: { title?, message?, icon? }` — the list view's own empty-state + shape (`EmptyState`), drawn **in place of** an empty table: a Lucide `icon`, a + `title` (default: the table's own "No results found") and a `message` + (default: none). `title` and `message` each take a string or an inline locale + map, resolved against the display locale; a map with no usable entry keeps + that member's default. It is not drawn when a term typed into the grid's own + server-side search box is what emptied it — the table and its search box stay, + so the term can be cleared. Leave the key out and an empty grid draws the + table's own empty row, as before. ```json { "type": "object-grid", - "description": "Everyone you work with", - "properties": { "objectName": "contacts" } + "properties": { + "objectName": "contacts", + "description": "Everyone you work with", + "emptyState": { + "title": { "en": "No contacts yet", "fr": "Aucun contact" }, + "message": "Add one to get started", + "icon": "users" + } + } } ``` -`emptyState` is **not authorable in a document today**. The upstream protocol's -`object-grid` row does not declare it, so `objectui validate` refuses it inside -the `properties` bag (the row's own refusal) and, on the node, its strict face -refuses it as an unknown key — as `os validate` does. It stays a key of the node -the grid reads: a host that mounts `` itself, or composes -the node in code, can set it. For the same reason it is not in -`GRID_QUERY_INPUTS` yet: that list may only declare keys the row accepts, so the -SDUI parser reports an authored `emptyState` as `unknown-prop` (a warning). +Written flat on the node, either key is refused by name and pointed at the bag +(`description` → `properties.description`), as the spec's own page component +refuses it. Until the row declared `description`, this page told you to write it +on the node; that is now the refused spelling. + +`keyboardNavigation`, the third key objectstack#20694 added to the row, is marked +`[EXPERIMENTAL — not enforced]` there, and nothing in this package reads it yet, +so it is not in `GRID_QUERY_INPUTS`. The bag accepts it, as the spec row does, +and it changes nothing. `name`, `placeholder`, `rowSpecActions` and `bulkSpecActions` are **retired** on this node (objectui#11068): nothing ever read them, and both faces of diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index ce823cdcf6..d0327129fe 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -6439,7 +6439,15 @@ export const ObjectGrid: React.FC = ({ // (an icon name that resolves to nothing is the same as none), the table's // "No results found" heading, and no message line. Text only, as the table's // empty row is — an empty table draws no add-record row either. + // + // objectui#11227 — `title` and `message` are the spec's `I18nLabel`: a plain + // string or an inline locale map. Each is resolved against the display locale + // the way `description` above is, so a map draws its locale's entry instead of + // reaching `DataEmptyState` as an object (React refuses an object child). A map + // with no usable entry resolves to nothing and keeps that member's default. const authoredEmptyState = schema.emptyState; + const authoredEmptyTitle = resolveInlineI18nLabel(authoredEmptyState?.title, displayLocale); + const authoredEmptyMessage = resolveInlineI18nLabel(authoredEmptyState?.message, displayLocale); const searchEmptiedRows = manualSearchOn && manualSearch.trim() !== ''; const drawsAuthoredEmptyState = authoredEmptyState != null && !loading @@ -6455,8 +6463,8 @@ export const ObjectGrid: React.FC = ({ // render. The rule cannot see that through a call. // eslint-disable-next-line react-hooks/static-components icon={AuthoredEmptyIcon ? : undefined} - title={authoredEmptyState?.title || t('table.noResults')} - description={authoredEmptyState?.message || undefined} + title={authoredEmptyTitle || t('table.noResults')} + description={authoredEmptyMessage || undefined} /> ) : gridContent; diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx new file mode 100644 index 0000000000..370761f4c9 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.emptyStateI18nLabel-11227.test.tsx @@ -0,0 +1,126 @@ +/** + * 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. + * + * `object-grid`'s `emptyState` members, through the registry, with the + * `I18nLabel` arm on `title` and `message` (objectui#11227). + * + * `@objectstack/spec` 17.6.0 declares `emptyState` on its + * `ComponentPropsMap['object-grid']` row as the list view's own + * `EmptyStateSchema`: strict over `{ title, message, icon }`, with `title` and + * `message` typed `I18nLabel` (a plain string or an inline per-locale map) and + * `icon` a string. `ObjectGrid` drew the two text members raw. A map reached + * `DataEmptyState` as an object CHILD, which React refuses ("Objects are not + * valid as a React child"), so the block failed to render. Each member is now + * resolved against the display locale, as `label` and `description` are. + * + * This file is the member pin the console parity gate registers for + * `object-grid.emptyState` (`MEMBER_PINS` in + * `registry-inputs-spec-parity.test.ts`): it mounts the `{ type, properties }` + * document a page carries through the real `SchemaRenderer` and the registered + * block, over a grid with no record, and reads what each of the three members + * draws. The string arm, the empty-search case and the not-drawn-with-rows case + * are `ObjectGrid.declaredKeys-11068.test.tsx`'s rows and are not repeated here. + * + * No regional locale is provided, so the display locale is the UI language. + * Every map lists `en` FIRST, so under `zh` a resolver that fell back to `en` + * or to the first entry would paint English and fail the row. + */ + +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { ActionProvider, SchemaRenderer } from '@object-ui/react'; +// Registers `object-grid`, the block under test. +import '../index'; + +afterEach(() => cleanup()); + +/** `en` first on purpose; see the file header. */ +const TITLE = { en: 'No contacts yet', 'zh-CN': '还没有联系人' }; +const MESSAGE = { en: 'Add one to get started', 'zh-CN': '添加一个联系人开始使用' }; + +/** The table's own empty-row heading: the default an authored `title` replaces. */ +const TABLE_EMPTY_HEADING = 'No results found'; + +/** A JSON document: the node and its `properties` bag over no record at all. */ +const doc = (emptyState: unknown) => ({ + type: 'object-grid', + properties: { + objectName: 'contacts', + columns: [{ field: 'name', label: 'Name' }], + data: { provider: 'value', items: [] }, + emptyState, + }, +}); + +function mountIn(language: string, emptyState: unknown) { + return render( + + + + + , + ); +} + +/** The drawn empty state, once it is on screen; a render failure fails here. */ +async function emptyStateOf(container: HTMLElement): Promise { + await waitFor(() => expect(container.querySelector('[data-testid="object-grid-empty-state"]')).not.toBeNull()); + // The defect's own signature: an object child throws, and the block is + // replaced by its error boundary instead of drawing anything. + expect(container.textContent ?? '').not.toContain('failed to render'); + expect(container.textContent ?? '').not.toContain('[object Object]'); + return container.querySelector('[data-testid="object-grid-empty-state"]') as HTMLElement; +} + +const headingOf = (state: HTMLElement) => state.querySelector('h3')?.textContent ?? null; +const messageOf = (state: HTMLElement) => state.querySelector('p')?.textContent ?? null; + +describe('object-grid `emptyState.title` / `.message` resolve an inline locale map (objectui#11227)', () => { + it('zh: the heading and the line below it paint the zh-CN entries, with the authored icon', async () => { + const { container } = mountIn('zh', { title: TITLE, message: MESSAGE, icon: 'users' }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe('还没有联系人'); + expect(messageOf(state)).toBe('添加一个联系人开始使用'); + // `icon` stays a plain Lucide name, read off the same object. + expect(state.querySelector('svg.lucide-users')).not.toBeNull(); + // It replaces the table, as the string arm does. + expect(container.querySelector('table')).toBeNull(); + }); + + it('en: the same maps paint the en entries', async () => { + const { container } = mountIn('en', { title: TITLE, message: MESSAGE }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe('No contacts yet'); + expect(messageOf(state)).toBe('Add one to get started'); + }); + + it('the members resolve independently: a map title beside a plain-string message', async () => { + const { container } = mountIn('zh', { title: TITLE, message: 'Add one to get started' }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe('还没有联系人'); + expect(messageOf(state)).toBe('Add one to get started'); + }); + + it('a map with no usable entry keeps that member’s default: the table’s heading, and no line', async () => { + // Under `en`, where the default heading is the English one this file names + // (under `zh` the locale pack translates it). + const { container } = mountIn('en', { title: {}, message: {} }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe(TABLE_EMPTY_HEADING); + expect(state.querySelector('p')).toBeNull(); + }); + + it('CONTROL: plain-string members render exactly as authored, under zh', async () => { + const { container } = mountIn('zh', { title: 'No contacts yet', message: 'Add one to get started' }); + const state = await emptyStateOf(container); + expect(headingOf(state)).toBe('No contacts yet'); + expect(messageOf(state)).toBe('Add one to get started'); + }); +}); diff --git a/packages/plugin-grid/src/index.tsx b/packages/plugin-grid/src/index.tsx index bbd04c82f6..2297da709a 100644 --- a/packages/plugin-grid/src/index.tsx +++ b/packages/plugin-grid/src/index.tsx @@ -212,6 +212,17 @@ export const ObjectGridRenderer: React.FC<{ schema: any; [key: string]: any }> = * objectui#5861 removed every renderer read of it (ADR-0049 enforce-or-remove). * It stays off this list because the contract refuses it, not by exemption. * + * ## `description`, `emptyState`, and the one 17.6.0 key that is not here + * + * `@objectstack/spec` 17.6.0 adds three keys to the `object-grid` row + * (objectstack#20694). `description` and `emptyState` are published below + * (objectui#11227): `ObjectGrid` has read both since objectui#11068, and until + * the row declared them this list could not. `keyboardNavigation` is NOT here. + * The row marks it `[EXPERIMENTAL — not enforced]`, nothing in this repo reads it + * yet, and this list is what the renderer reads: the key joins it with its + * reader (objectui#11068's build). Until then the console parity gate's reverse + * direction reports it as the one unpublished `object-grid` key. + * * ## `data` declares the CONTRACT's shape, not the shortcut's (objectui#5090) * * The key landed above with `type: 'array'`, labelled "Static Data" and described @@ -253,6 +264,18 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ description: 'Grid label, used as the table caption, as the export file title and in the record-detail overlay heading. The canonical spelling — the deprecated `title` is only read when this is absent. Accepts either a plain string or an inline per-locale map (`{ en: "Accounts", "zh-CN": "客户" }`) — the `I18nLabel` union the contract admits on this key — and the grid resolves the map against the display locale (the workspace\'s regional default when one is configured, otherwise the active UI language), falling back through base language, a region-qualified sibling, `default`, then `en`, and finally to any remaining entry.', }, + // `description` (objectui#11068 honoured it; objectui#11227 publishes it now + // that the spec row declares it, at 17.6.0) is an `I18nLabel` in the row too, + // and `ObjectGrid` resolves it with the same resolver as `label`, so both arms + // are declared for the same reason. The render is pinned by + // `ObjectGrid.declaredKeys-11068.test.tsx`, the manifest by the console's + // `i18nLabelInputsManifest-10993.test.ts`. + { + name: 'description', + type: ['string', 'object'], + description: + 'One line of help text drawn above the grid\'s rows, in muted type. Accepts either a plain string or an inline per-locale map (`{ en: "Everyone you work with", "zh-CN": "你的所有联系人" }`) — the `I18nLabel` union the contract admits on this key — resolved against the display locale the way `label` is. A map with no usable entry draws no line.', + }, // ── query shaping ───────────────────────────────────────────────────────── { name: 'sort', type: 'array', description: 'Initial sort order, `[{ field, order }]`. The only sort spelling this block reads — the retired single-sort `defaultSort` is refused by the protocol and ignored by the renderer.' }, { name: 'pagination', type: 'object', description: 'Pagination config, `{ pageSize, pageSizeOptions, … }`. Presence enables paging with the object\'s settings, and an explicit off wins — the deprecated flat `showPagination: false` turns paging off even beside this object, because this object declares no off switch of its own. Prefer it over the deprecated flat `pageSize` / `showPagination` pair.' }, @@ -266,6 +289,12 @@ const GRID_QUERY_INPUTS: ComponentInput[] = [ { name: 'showColumnTypeIcons', type: 'boolean', description: 'Show a field-type icon in each column header. Off by default — the type is usually obvious from the cell content, and the icons compete with the column labels.' }, { name: 'rowColor', type: 'object', description: 'Rules that colour whole rows from a field value.' }, { name: 'conditionalFormatting', type: 'array', description: 'Row style rules, each `{ condition, style }` — a CEL `condition` over the row’s own `record.*` and a CSS `style` map, the rule a list view declares. The first matching rule styles that row. The native `{ field, operator, value }` rule, its `expression`, and a colour written beside `condition` instead of inside `style` are retired (objectui#11533).' }, + // `emptyState` (objectui#11068 honoured it; objectui#11227 publishes it now + // that the spec row declares it, at 17.6.0, as the list view's own + // `EmptyStateSchema`). Its `title` and `message` are `I18nLabel` members that + // `ObjectGrid` resolves against the display locale. The members are pinned by + // `ObjectGrid.emptyStateI18nLabel-11227.test.tsx`. + { name: 'emptyState', type: 'object', description: 'What the grid draws in place of an empty table, `{ title, message, icon }`: a Lucide `icon` name, a `title` (default: the table\'s own "No results found") and a `message` (default: none). `title` and `message` each accept a plain string or an inline per-locale map, resolved against the display locale. Not drawn when a term typed into the grid\'s own server-side search box is what emptied it — the table and its search box stay, so the term can be cleared. Leave it out and an empty grid draws the table\'s own empty row.' }, // ── grouping and roll-ups ───────────────────────────────────────────────── { name: 'grouping', type: 'object', description: 'Group rows by one or more fields into collapsible sections.' }, { name: 'aggregations', type: 'array', description: 'Per-group roll-ups shown in group headers, `[{ field, type: "sum" | "count" | "avg" | "min" | "max" | "count_distinct" }]`. Needs `grouping` to have anything to roll up.' }, diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index 7a241642ec..a2fee7368f 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -530,7 +530,8 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> * ## Why these keys were added * * `@objectstack/spec`'s `ComponentPropsMap['object-kanban']` carries FIFTEEN - * top-level keys on the installed 17.5.0 pin, fourteen of them live: 17.5.0 + * top-level keys on the installed 17.6.0 pin, fourteen of them live, the + * same set as 17.5.0: that release * added `navigation` and turned `quickAdd` into a tombstone (typed `never`, * refused by name — objectui#8285's ruled retirement, landed). This list * published three until objectui#8186 added `filter`. ⚠️ The shape carried diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 7bcdeea4c9..5cb221ca85 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -5198,9 +5198,17 @@ export const ListView = React.forwardRef(({ hasBaseFilter || (Array.isArray(appliedUserFilterConditions) && appliedUserFilterConditions.length > 0) || (Array.isArray(appliedFilters?.conditions) && appliedFilters.conditions.length > 0); - const title = (typeof schema.emptyState?.title === 'string' ? schema.emptyState.title : undefined) + // objectui#11227 — `title` and `message` are the spec's `I18nLabel` + // (`EmptyStateSchema`): a plain string or an inline locale map. They + // are RESOLVED against the display locale, as the view's `label` + // is. A `typeof === 'string'` test stood here, which is not a + // resolution: it answered "absent" for every map an author may write, + // so a localised empty state silently drew the default copy in every + // locale. A string still passes through unchanged, and a map with no + // usable entry still falls to the default. + const title = resolveInlineI18nLabel(schema.emptyState?.title, displayLocale) ?? (hasActiveQuery ? t('list.noMatches') : t('list.firstRunTitle')); - const description = (typeof schema.emptyState?.message === 'string' ? schema.emptyState.message : undefined) + const description = resolveInlineI18nLabel(schema.emptyState?.message, displayLocale) ?? (hasActiveQuery ? t('list.noMatchesMessage') : t('list.firstRunMessage')); return ( ({ + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}); + +/** `en` first on purpose; see the file header. */ +const TITLE = { en: 'No open work', 'zh-CN': '没有未完成的工作' }; +const MESSAGE = { en: 'Everything is done.', 'zh-CN': '全部完成。' }; + +async function emptyStateFor(emptyState: unknown, locale: string): Promise { + const ds = emptyDataSource(); + const schema = { + type: 'list-view', + objectName: 'tasks', + viewType: 'grid', + columns: ['name'], + emptyState, + } as ListViewSchema; + const { container } = render( + + + + + , + ); + await waitFor(() => expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull()); + return container.querySelector('[data-testid="empty-state"]') as HTMLElement; +} + +const headingOf = (state: HTMLElement) => state.querySelector('h3')?.textContent ?? null; +const messageOf = (state: HTMLElement) => state.querySelector('p')?.textContent ?? null; + +afterEach(() => cleanup()); + +describe('ListView `emptyState.title` / `.message` resolve the inline locale map (objectui#11227)', () => { + it('zh-CN: the heading and the message paint the zh-CN entries, not the default copy', async () => { + const state = await emptyStateFor({ title: TITLE, message: MESSAGE }, 'zh-CN'); + expect(headingOf(state)).toBe('没有未完成的工作'); + expect(messageOf(state)).toBe('全部完成。'); + // The exact thing the `typeof` else-arm produced for a map. + expect(state.textContent).not.toMatch(/Nothing here yet/i); + }); + + it('en: the same maps paint the en entries', async () => { + const state = await emptyStateFor({ title: TITLE, message: MESSAGE }, 'en'); + expect(headingOf(state)).toBe('No open work'); + expect(messageOf(state)).toBe('Everything is done.'); + }); + + it('a map with no usable entry keeps the default copy, as an absent member does', async () => { + const absent = await emptyStateFor({}, 'en'); + const defaults = [headingOf(absent), messageOf(absent)]; + cleanup(); + const unusable = await emptyStateFor({ title: {}, message: {} }, 'en'); + expect([headingOf(unusable), messageOf(unusable)]).toEqual(defaults); + // Non-vacuity: the default is real copy, not an empty node. + expect(defaults[0]).toMatch(/Nothing here yet/i); + }); + + it('CONTROL: plain-string members pass through unchanged, under zh-CN', async () => { + const state = await emptyStateFor({ title: 'No open work', message: 'Everything is done.' }, 'zh-CN'); + expect(headingOf(state)).toBe('No open work'); + expect(messageOf(state)).toBe('Everything is done.'); + }); +}); diff --git a/packages/types/package.json b/packages/types/package.json index 2b4c9ce0f7..cd87cc8019 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -93,7 +93,7 @@ "directory": "packages/types" }, "dependencies": { - "@objectstack/spec": "^17.5.0", + "@objectstack/spec": "^17.6.0", "zod": "^4.6.1" }, "devDependencies": { diff --git a/packages/types/src/__tests__/chartconfig-aria-retired-4044.test.ts b/packages/types/src/__tests__/chartconfig-aria-retired-4044.test.ts index d57757b7b5..5d1b453ebb 100644 --- a/packages/types/src/__tests__/chartconfig-aria-retired-4044.test.ts +++ b/packages/types/src/__tests__/chartconfig-aria-retired-4044.test.ts @@ -29,8 +29,9 @@ * copied here. * - **TypeScript.** `complex.ts` re-typed `chartConfig` as `any`, so an * authored `aria` compiled. The member now takes `aria` from the spec's own - * input type, which is the spec's retired-key type (`undefined` at the - * pinned 17.5.0, the branded `[REMOVED]` mark on objectstack `main`; both + * input type, which is the spec's retired-key type (the branded `[REMOVED]` + * mark at the pinned 17.6.0 and on objectstack `main`, bare `undefined` + * through 17.5.0; both * spellings are `retired-key-type.ts`'s), so an authored value is a compile * error. * The `@ts-expect-error` below is the real enforcement: this package's diff --git a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts index a03e2eff95..c8ac799e20 100644 --- a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts +++ b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts @@ -29,8 +29,8 @@ * objectui#9736 closed that half. The interface now extends the spec's own * `Dashboard` input type (`Omit< Dashboard, … >` over the exclusion list its * mirror reads), so it inherits the spec's tombstone as a DECLARED member typed - * as the spec's retired key (`undefined` at the pinned 17.5.0, the branded - * `[REMOVED]` mark on objectstack `main`) — `aria` is back in the declared key + * as the spec's retired key (the branded `[REMOVED]` mark at the pinned 17.6.0 + * and on objectstack `main`, bare `undefined` through 17.5.0) — `aria` is back in the declared key * set, but only as a refusal, * and an authored value is a compile error: the same verdict the Zod twin gives * at parse. The pins below assert exactly that, with the former neighbours as @@ -52,8 +52,9 @@ describe('the TS interface declares `aria` only as the spec tombstone (objectui# it('`aria` is declared, typed as the spec\'s retired key; the neighbours it stood beside still are', () => { // Type-level pins, erased at runtime. `aria` is a DECLARED key again — the // spec projection carries the tombstone — and its type admits no value: - // `undefined` at the pinned 17.5.0, the branded `[REMOVED]` mark on - // objectstack `main` (both spellings are `retired-key-type.ts`'s, objectui#11330). + // the branded `[REMOVED]` mark at the pinned 17.6.0 and on objectstack + // `main`, bare `undefined` through 17.5.0 (both spellings are + // `retired-key-type.ts`'s, objectui#11330). const ariaDeclared: 'aria' extends Declared ? true : false = true; const ariaAdmitsNoValue: IsRetiredKeyType extends true ? true : false = true; // The control for the line above, through the same helper: a live diff --git a/packages/types/src/__tests__/dashboard-widget-metric-measure-door-8894.test.ts b/packages/types/src/__tests__/dashboard-widget-metric-measure-door-8894.test.ts index b6677803ca..0bcd31844a 100644 --- a/packages/types/src/__tests__/dashboard-widget-metric-measure-door-8894.test.ts +++ b/packages/types/src/__tests__/dashboard-widget-metric-measure-door-8894.test.ts @@ -43,6 +43,15 @@ * dimension, the shape the spec's refusal points authors at), and the family * derivation itself is checked for being non-vacuous in both directions. * + * ## OWED TO objectui#11334 — a bounded ledger, not a skip + * + * FAMILY is read by effect, so a second spec rule at `values` joins it: the + * dimensionless measure-arity check (objectstack `11d28c17`, #21053) refuses two + * measures with no `dimensions` on more types than the metric family, and + * objectui's mirror does not re-attach it yet. Those types are booked in + * `OWED_TO_OBJECTUI_11334` below: their rows assert today's difference, and a cap + * row requires the set objectui fails to refuse to equal the ledger exactly. + * * ⛔ Not pinned here, deliberately: the TypeScript face. `values` stays * `string[]` on the type, as it does on the spec's own inferred type — the * card rules out a TypeScript narrowing. @@ -88,6 +97,55 @@ const FAMILY = SPEC_TYPES.filter((t) => specArityIssue(widget(t, TWO)) !== undef /** Every other spec chart family. */ const OTHERS = SPEC_TYPES.filter((t) => !FAMILY.includes(t)); +/** + * ⚠️ OWED TO objectui#11334 — the FAMILY types whose refusal objectui's doors do + * NOT mirror yet, booked rather than skipped. + * + * The spec this repository resolves refuses two or more measures on these types + * when the widget declares no `dimensions` + * (`checkDashboardWidgetDimensionlessMeasureArity`, objectstack `11d28c17`, + * #21053). objectui's mirror does not re-attach that check: objectstack `main` + * renamed its export (`32d57690`), and a static import of the name the resolved + * spec ships fails the `Spec Main Shape Gate`. objectui#11334 owns the mirror. + * + * Booked by objectui#11438 ruling A″ (record 5968177777), which applies + * objectui#11111 decision 3 = B (record 5902351047) to the bump. Each listed + * type's rows assert TODAY's difference (the spec refuses; objectui's door + * accepts), and the cap rows require the FAMILY types objectui fails to refuse + * to EQUAL this list on both faces. A new difference is red, and so is a listed + * type objectui starts refusing: the entries go stale, by name, when the mirror + * attaches, and objectui#11334 strikes them in that change. + * + * EXPIRES when objectui resolves an `@objectstack/spec` carrying `32d57690`, or + * 2026-11-02, whichever is first. That resolution is caught by name in + * `spec-object-refinements-7715.test.ts`, whose census books the same check; + * the date is read by objectui#11334, not by a clock. + */ +const OWED_TO_OBJECTUI_11334 = ['pie', 'donut', 'funnel', 'scatter', 'treemap', 'sankey', 'radar']; + +/** The reason every owed row prints when it fails. */ +const OWED_REASON = + 'OWED TO objectui#11334: the dimensionless measure-arity check is not re-attached by the mirror yet. ' + + 'Booked by objectui#11438 ruling A″ (record 5968177777), applying objectui#11111 decision 3 = B ' + + '(record 5902351047). Expires when objectui resolves an @objectstack/spec carrying 32d57690, or ' + + '2026-11-02, whichever is first.'; + +/** The FAMILY types whose refusal objectui's doors mirror today: FAMILY minus the ledger. */ +const MIRRORED = FAMILY.filter((t) => !OWED_TO_OBJECTUI_11334.includes(t)); + +/** Does an objectui verdict carry the spec's own arity issue for `doc` at `path`? */ +const refusesAsSpec = ( + r: { success: boolean; error?: { issues: readonly unknown[] } }, + doc: Record, + path: string, +): boolean => { + const specIssue = specArityIssue(doc); + if (r.success || specIssue === undefined) return false; + return envelope(r.error!.issues as Issue[]).some( + (i) => i.code === 'custom' && i.path === path && i.message === specIssue.message, + ); +}; + describe('objectui#8894 — the family is read off the spec\'s own rule, and it is not vacuous', () => { it('the spec refuses a second measure on SOME types and not on others', () => { // Both halves must be populated: an empty family would make every @@ -106,7 +164,7 @@ describe('objectui#8894 — the family is read off the spec\'s own rule, and it }); describe('objectui#8894 — face 1: `DashboardWidgetSchema` refuses a second measure on the metric family', () => { - it.each(FAMILY)('`%s` with two measures is refused at `values`, with the spec\'s own issue', (type) => { + it.each(MIRRORED)('`%s` with two measures is refused at `values`, with the spec\'s own issue', (type) => { const doc = widget(type, TWO); const specIssue = specArityIssue(doc)!; const r = DashboardWidgetSchema.safeParse(doc); @@ -145,7 +203,7 @@ describe('objectui#8894 — face 1: `DashboardWidgetSchema` refuses a second mea }); describe('objectui#8894 — face 2: the published door (`safeValidateSchema`) refuses it inside a dashboard', () => { - it.each(FAMILY)('`%s` with two measures is refused at `widgets.0.values`, with the spec\'s own issue', (type) => { + it.each(MIRRORED)('`%s` with two measures is refused at `widgets.0.values`, with the spec\'s own issue', (type) => { const w = widget(type, TWO); const specIssue = specArityIssue(w)!; const r = safeValidateSchema(dashboardNode(w)); @@ -161,3 +219,39 @@ describe('objectui#8894 — face 2: the published door (`safeValidateSchema`) re expect(safeValidateSchema(dashboardNode(widget(type, ONE))).success).toBe(true); }); }); + +describe('objectui#11334 — OWED: the spec refuses these widgets and objectui\'s doors accept them today', () => { + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: `%s` with two measures and no dimension: the spec refuses at `values`, `DashboardWidgetSchema` accepts', (type) => { + const doc = widget(type, TWO); + expect(specArityIssue(doc), OWED_REASON).toBeDefined(); + expect(DashboardWidgetSchema.safeParse(doc).success, OWED_REASON).toBe(true); + }); + + it.each(OWED_TO_OBJECTUI_11334)('OWED TO objectui#11334: `%s` with two measures and no dimension: the published door accepts it inside a dashboard', (type) => { + const w = widget(type, TWO); + expect(specArityIssue(w), OWED_REASON).toBeDefined(); + expect(safeValidateSchema(dashboardNode(w)).success, OWED_REASON).toBe(true); + }); +}); + +describe('objectui#11334 — the cap: objectui fails to refuse EXACTLY the ledgered FAMILY types', () => { + it('face 1: the FAMILY types `DashboardWidgetSchema` does not refuse with the spec\'s own issue equal the ledger', () => { + const unrefused = FAMILY.filter((t) => { + const doc = widget(t, TWO); + return !refusesAsSpec(DashboardWidgetSchema.safeParse(doc), doc, 'values'); + }); + expect([...unrefused].sort(), OWED_REASON).toEqual([...OWED_TO_OBJECTUI_11334].sort()); + }); + + it('face 2: the FAMILY types the published door does not refuse with the spec\'s own issue equal the ledger', () => { + const unrefused = FAMILY.filter((t) => { + const w = widget(t, TWO); + return !refusesAsSpec(safeValidateSchema(dashboardNode(w)), w, 'widgets.0.values'); + }); + expect([...unrefused].sort(), OWED_REASON).toEqual([...OWED_TO_OBJECTUI_11334].sort()); + }); + + it('the ledger leaves the metric family judged: the mirrored rows are not vacuous', () => { + expect(MIRRORED.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts index 7498d201a5..94d9aa4357 100644 --- a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts +++ b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts @@ -66,6 +66,7 @@ import { GroupingFieldSchema as SpecGroupingFieldSchema } from '@objectstack/spe import { AnyComponentSchema, ListViewSchema, + ObjectGridBlockSchema, ObjectGallerySchema, ObjectViewSchema, StrictAnyComponentSchema, @@ -135,6 +136,18 @@ const DECLARING: Readonly> = { doc: (fields) => ({ type: 'object-view', objectName: 'account', table: { grouping: { fields } } }), fieldPath: (i) => ['table', 'grouping', 'fields', i, 'field'], }, + // `@objectstack/spec` 17.6.0 types `ComponentPropsMap['object-grid'].grouping` as + // the grouping config instead of `z.unknown()`, and the authored arm's `properties` + // is that row by reference, so the bag now judges the shape: a padded name is + // refused at its entry on every face. It moved here from `NOT_COVERED` at that + // bump (objectui#11438), when the row below stopped holding. + 'object-grid properties': { + type: 'object-grid', + declaredAt: 'properties.grouping', + arm: ObjectGridBlockSchema, + doc: (fields) => ({ type: 'object-grid', properties: { objectName: 'account', grouping: { fields } } }), + fieldPath: (i) => ['properties', 'grouping', 'fields', i, 'field'], + }, }; /** @@ -142,16 +155,15 @@ const DECLARING: Readonly> = { * with the reason and the paths the census records — so an arm cannot slip in * here silently, and a reason that stops holding turns the rows below red. * - * `object-grid` (objectui#11276's `object-grid` batch): the authored arm's - * `properties` is `@objectstack/spec`'s `ComponentPropsMap['object-grid']` row - * by reference, and the row types its `grouping` member as `z.unknown()`, so no - * grouping shape is judged in the bag and a padded field name is not refused - * there — the row's own reading, recorded rather than restated here. The - * node-level `grouping` is no declaration at all: it is the flat spelling's - * by-name refusal (`flatPropRefusals`), pointing at `properties.grouping`. + * `object-grid` (objectui#11276's `object-grid` batch): the node-level `grouping` + * is no declaration at all: it is the flat spelling's by-name refusal + * (`flatPropRefusals`), pointing at `properties.grouping`. The bag member + * `properties.grouping` stood here too while the spec row typed it `z.unknown()`; + * since `@objectstack/spec` 17.6.0 the row judges it, so it is a `DECLARING` row + * above (objectui#11438). */ const NOT_COVERED: Readonly> = { - 'object-grid': ['grouping', 'properties.grouping'], + 'object-grid': ['grouping'], }; /** The faces an authored document meets. `objectui validate` runs `safeValidateSchema`. */ @@ -277,16 +289,10 @@ describe('objectui#7347 — census: the table above is every arm that declares ` expect(found).toEqual(expected); }); - it('`object-grid` is not covered for the recorded reasons: a flat refusal on the node, the row\'s `unknown` in the bag', () => { - const doc = (grouping: unknown, at: 'node' | 'bag') => (at === 'node' - ? { type: 'object-grid', properties: { objectName: 'account' }, grouping } - : { type: 'object-grid', properties: { objectName: 'account', grouping } }); - const padded = { fields: [{ field: PADDED[0], order: 'asc' }] }; + it('`object-grid` node-level `grouping` is not covered for the recorded reason: a flat refusal on the node', () => { + const node = { type: 'object-grid', properties: { objectName: 'account' }, grouping: { fields: [{ field: CLEAN, order: 'asc' }] } }; // On the node, every value is refused by name, toward the bag member. - const flat = issuesOf(safeValidateSchema(doc({ fields: [{ field: CLEAN, order: 'asc' }] }, 'node'))); + const flat = issuesOf(safeValidateSchema(node)); expect(flat.map((i) => i.path.join('.'))).toEqual(['grouping']); - // In the bag the row judges nothing about the shape: a padded name parses. - expect(safeValidateSchema(doc(padded, 'bag')).success).toBe(true); - expect(StrictAnyComponentSchema.safeParse(doc(padded, 'bag')).success).toBe(true); }); }); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index d4386841f1..69f7e04f9b 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -97,6 +97,8 @@ import { RowHeightSchema as SpecRowHeightSchema, BulkActionDefSchema as SpecBulkActionDefSchema, BulkActionOperationSchema as SpecBulkActionOperationSchema, + // objectui#11227 — the list view's empty-state shape `ObjectGridSchema.emptyState` reads. + EmptyStateSchema as SpecEmptyStateSchema, // objectui#10872 — the `ComponentPropsMap` rows the public-block arms read. PageHeaderProps as SpecPageHeaderProps, PageTabsProps as SpecPageTabsProps, @@ -303,6 +305,10 @@ const IMPORTED: Array = [ ['RowHeightSchema', SpecRowHeightSchema], ['BulkActionDefSchema', SpecBulkActionDefSchema], ['BulkActionOperationSchema', SpecBulkActionOperationSchema], + // objectui#11227: the flat mirror's `emptyState` is the spec's `EmptyStateSchema` + // by reference (the same schema the `object-grid` row holds since 17.6.0), + // crossed through this boundary. + ['EmptyStateSchema', SpecEmptyStateSchema], // objectui#10872: each ADR-0080 public-block arm's `properties` is the // block's `ComponentPropsMap` row, crossed through this boundary, so every // row is measured here like every other crossing (`page:section`, diff --git a/packages/types/src/__tests__/nested-page-children-11223.test.ts b/packages/types/src/__tests__/nested-page-children-11223.test.ts index 7f7c92c50a..d6236cb78d 100644 --- a/packages/types/src/__tests__/nested-page-children-11223.test.ts +++ b/packages/types/src/__tests__/nested-page-children-11223.test.ts @@ -33,8 +33,9 @@ * (d) parity: the positions are read off the installed spec's walk, here, * without naming one; objectui's walk reaches every one of them, and * each is a position a row of (a) judges on both faces; - * (e) the one slot the walks disagree on (`page:card`'s `footer`), held as a - * row that turns red the day the exported walk descends it; + * (e) retired: it held `page:card`'s `footer`, the one slot the walks + * disagreed on, until `@objectstack/spec` 17.6.0's walk descended it + * (objectui#11438); the footer is a row of (a) since; * (f) a JavaScript value that nests a component inside itself still gets a * verdict. * @@ -45,7 +46,6 @@ import { describe, expect, it } from 'vitest'; import { walkAddressedPageComponents } from '@objectstack/spec/system'; -import { PageCardProps } from '@objectstack/spec/ui'; import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; import { nestedComponentsOf } from '../zod/nested-component-walk.js'; @@ -92,6 +92,14 @@ const CONTAINERS: ReadonlyArray ({ type: 'page:card', properties: { footer: [text('first'), child] } }), + ['properties', 'footer', 1], + ] as const, ]; const ROWS = CONTAINERS.flatMap(([type, , place, path]) => @@ -100,11 +108,13 @@ const ROWS = CONTAINERS.flatMap(([type, , place, path]) => /* ── (a) the refusals ───────────────────────────────────────────────────── */ describe('objectui#11223 (a) — a bag child is judged by the node union, at its real path', () => { - it('the population is the six containers on both faces — a row dropped from the table fails here', () => { - expect(CONTAINERS.map(([type]) => type)).toEqual([ - 'page:card', 'page:section', 'page:footer', 'page:sidebar', 'page:tabs', 'page:accordion', + it('the population is the seven child positions on both faces — a row dropped from the table fails here', () => { + expect(CONTAINERS.map(([type, position]) => `${type} ${position}`)).toEqual([ + 'page:card properties.children[]', 'page:section properties.children[]', 'page:footer properties.children[]', + 'page:sidebar properties.children[]', 'page:tabs properties.items[].children[]', + 'page:accordion properties.items[].children[]', 'page:card properties.footer[]', ]); - expect(ROWS).toHaveLength(12); + expect(ROWS).toHaveLength(14); }); it.each(ROWS)('%s refuses a malformed child, each issue under the child', (label, place, path, judge) => { @@ -293,23 +303,6 @@ describe('objectui#11223 (d) — objectui walks every position the spec\'s walk }); }); -/* ── (e) the slot the walks disagree on ─────────────────────────────────── */ - -describe('objectui#11223 (e) — `page:card`\'s `properties.footer`: declared, drawn, and not yet walked', () => { - it('the row declares it and the exported walk does not descend it — turn this red, and the gap closes itself', () => { - expect(Object.keys(PageCardProps.shape)).toContain('footer'); - // ⚠️ When this goes red, the spec's walk has started descending the card's - // footer, and every row of (d) now judges it: delete this block and record - // the close on objectui#11223, with the `page:card` footer note in - // `content/docs/utilities/cli.mdx`. Nothing else needs to change. - expect(POSITIONS).not.toContain('properties.footer[]'); - }); - - it.each(FACES)('so a malformed footer child still parses on the %s face', (_face, judge) => { - expect(judge({ type: 'page:card', properties: { footer: [MALFORMED] } }).success).toBe(true); - }); -}); - /* ── (f) a self-nesting value ───────────────────────────────────────────── */ describe('objectui#11223 (f) — a value that nests a component inside itself gets a verdict', () => { diff --git a/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts b/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts index eaca0f823f..6615d2788e 100644 --- a/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts +++ b/packages/types/src/__tests__/object-grid-declared-keys-11068.test.ts @@ -113,10 +113,24 @@ describe('objectql.zod.ts#ObjectGridSchema — `emptyState` is mirrored member f if (result.success) expect(result.data.emptyState).toEqual(emptyState); }); - it('refuses a non-string member AT that member', () => { + it('refuses a member that is neither a string nor a locale map AT that member', () => { + // objectui#11227 — the member is the spec's `EmptyStateSchema` by reference + // since 17.6.0, so `title` is `I18nLabel`: a union of a string and an inline + // locale map. A number fails both arms, so the refusal at the member is the + // union's (`invalid_union`); it read `invalid_type` while the twin restated + // `title` as a plain string. const result = ObjectGridSchema.safeParse({ ...NODE, emptyState: { title: 42 } }); expect(result.success).toBe(false); - expect(issueAt(result, 'emptyState.title')?.code).toBe('invalid_type'); + expect(issueAt(result, 'emptyState.title')?.code).toBe('invalid_union'); + }); + + it('accepts an inline locale map on `title` and `message`, as the spec row does (objectui#11227)', () => { + const emptyState = { title: { en: 'No contacts yet', fr: 'Aucun contact' }, message: { en: 'Add one' } }; + const result = ObjectGridSchema.safeParse({ ...NODE, emptyState }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.emptyState).toEqual(emptyState); + // The twin follows the spec, ⛔ not a second shape: the member IS the spec's schema. + expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'probe', emptyState }).success).toBe(true); }); it('refuses an unknown member by name instead of keeping it — `description` is not `message`', () => { diff --git a/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts index 610e27e085..e9ac476b2c 100644 --- a/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts +++ b/packages/types/src/__tests__/object-grid-properties-bag-11276.test.ts @@ -367,9 +367,6 @@ describe('the flat spelling is refused by name, with the bag member as the remed it.each([ ['an invented key', 'inventedKey11276'], - // Mirror members the row does not declare: not authored keys of this node. - ['the mirror\'s `emptyState`', 'emptyState'], - ['the mirror\'s `keyboardNavigation`', 'keyboardNavigation'], ] as const)('%s written flat stays unjudged on the tolerant face and is refused on the strict face', (_label, key) => { // The strictness control: this arm is `BaseSchema`, whose `.passthrough()` // every arm keeps, so a key the arm does not declare is not refused by the @@ -382,6 +379,28 @@ describe('the flat spelling is refused by name, with the bag member as the remed const issue = strict.error.issues.find((i) => i.code === 'unrecognized_keys'); expect((issue as { keys?: string[] } | undefined)?.keys).toEqual([key]); }); + // ⭐ THE READING FLIPPED at `@objectstack/spec` 17.6.0 (objectstack#20694, read + // on objectui#11227). These two mirror members used to sit in the table above, + // as keys the row did not declare: written flat, the tolerant face kept them + // unjudged and only the strict face refused them as unknown. The row now + // declares both, so each is a row member like any other: written flat, it is + // refused on BOTH faces, by name, toward the bag. Recorded by name here, not + // left to the derived `REFUSED_TO_BAG` loop, so the flip itself stays visible. + it.each(['emptyState', 'keyboardNavigation'] as const)( + 'the mirror\'s `%s`, which the row declares since 17.6.0, written flat is refused on both faces toward the bag (objectui#11227)', + (key) => { + // The spec's reading first: the installed row lists the key and does not retire it. + expect(ROW_KEYS).toContain(key); + expect(isNeverMember(ROW_SHAPE[key])).toBe(false); + expect(REFUSED_TO_BAG).toContain(key); + const doc = { ...SHOWCASE_MY_WORK, [key]: { title: 'x' } }; + for (const [face, parse] of FACES) { + const issue = issuesOf(parse(doc)).find((i) => i.path.join('.') === key); + expect(issue, face).toBeDefined(); + expect(issue!.message, face).toContain(`\`${key}\` → \`properties.${key}\``); + } + }, + ); }); describe('the bag is judged by the spec row (objectui#11276)', () => { @@ -414,7 +433,7 @@ describe('the bag is judged by the spec row (objectui#11276)', () => { expect(issue.path).toEqual(path); }); - it.each(['emptyState', 'keyboardNavigation', 'showFilters', 'name'] as const)( + it.each(['showFilters', 'name'] as const)( 'the row does not declare `%s`, so the bag refuses it on both faces (the spec\'s reading, recorded)', (key) => { for (const [face, parse] of FACES) { @@ -425,6 +444,37 @@ describe('the bag is judged by the spec row (objectui#11276)', () => { }, ); + // ⭐ THE READING FLIPPED at `@objectstack/spec` 17.6.0 (objectstack#20694, read + // on objectui#11227). `emptyState` and `keyboardNavigation` used to be rows of + // the table above: the row did not declare them, so the bag refused each as an + // unknown key. The row now declares both, so the bag ACCEPTS each on both faces + // and judges its value — `emptyState` as the list view's `EmptyStateSchema`, + // whose `title` and `message` take an inline locale map, and + // `keyboardNavigation` as a boolean. Accepting `keyboardNavigation` here is the + // spec row's reading on the authoring face, nothing more: the row marks it + // `[EXPERIMENTAL — not enforced]`, and whether `object-grid` PUBLISHES it in + // `GRID_QUERY_INPUTS` is the console parity gate's question, not this file's. + it.each([ + ['emptyState', { title: { en: 'No tasks yet', 'zh-CN': '暂无任务' }, message: 'Create one to start', icon: 'inbox' }, { title: 7 }], + ['keyboardNavigation', true, 'yes'], + ] as const)( + 'the row declares `%s` since 17.6.0, so the bag accepts it on both faces and judges its value (objectui#11227)', + (key, valid, invalid) => { + // The spec's own reading of the same values, so the recorded flip is the row's. + expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'task', [key]: valid }).success).toBe(true); + expect(SpecObjectGridPropsSchema.safeParse({ objectName: 'task', [key]: invalid }).success).toBe(false); + for (const [face, parse] of FACES) { + const accepted = parse(bag({ [key]: valid })); + expect(accepted.success, `${face}: ${JSON.stringify(accepted.success ? null : accepted.error.issues)}`).toBe(true); + // Lit control: the member is JUDGED, not merely admitted — a wrong value + // is refused at the member, and never as an unknown key. + const issues = issuesOf(parse(bag({ [key]: invalid }))); + expect(issues.some((i) => i.path[0] === 'properties' && i.path[1] === key), `${face}: ${JSON.stringify(issues)}`).toBe(true); + expect(issues.some((i) => i.code === 'unrecognized_keys'), face).toBe(false); + } + }, + ); + it('the bag is the spec row by reference, through the import boundary', () => { const member = ObjectGridBlockSchema.shape.properties.unwrap(); const keysOf = (schema: unknown) => Object.keys((schema as { shape: Record }).shape).sort(); diff --git a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts index 667e9822a2..612fc16368 100644 --- a/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts +++ b/packages/types/src/__tests__/object-view-unmirrored-keys-7779.test.ts @@ -337,7 +337,7 @@ const NAMED_VIEW_ABSENT_CONTROL = 'stickyHeader'; /** * The protocol's own member set for a named list view — `ObjectListViewSchema`'s - * shape, off `@objectstack/spec` as installed (17.5.0). ⛔ Not a copy of the + * shape, off `@objectstack/spec` as installed (17.6.0). ⛔ Not a copy of the * objectui face and ⛔ not a hand list: it is read off the schema object, so a * spec bump that moves a key moves this. */ @@ -1379,13 +1379,16 @@ describe('objectui#7924 — the per-member liveness census on `NamedListView`, r // replacing the regex without re-opening the number. expect(ast).toBe(64); expect(namedListViewMemberCount()).toBe(ast); - // …and the loose regex does NOT, by 12, because it also counts nested - // object-literal lines. The gap is still exactly 12 after objectui#8980: + // …and the loose regex does NOT, by 9, because it also counts nested + // object-literal lines. The gap stayed exactly 12 through objectui#8980: // every one of the seventeen new members is a single-line type reference, // so none of them adds a nested object literal for the loose instrument to - // over-count. Pinned so "a figure between two instruments is neither" stays - // a reading rather than a remembered sentence. - expect(namedListViewLooseMemberCount()).toBe(76); + // over-count. objectui#11227 took it from 12 to 9: `emptyState` stopped + // restating `{ title, message, icon }` as a nested literal and indexes + // `ListViewSchema['emptyState']` instead, so its three nested lines left + // and the member count did not move. Pinned so "a figure between two + // instruments is neither" stays a reading rather than a remembered sentence. + expect(namedListViewLooseMemberCount()).toBe(73); expect(namedListViewLooseMemberCount()).toBeGreaterThan(ast); }); diff --git a/packages/types/src/__tests__/record-details-top-level-9040.test.ts b/packages/types/src/__tests__/record-details-top-level-9040.test.ts index 648f175a07..222bdf1a85 100644 --- a/packages/types/src/__tests__/record-details-top-level-9040.test.ts +++ b/packages/types/src/__tests__/record-details-top-level-9040.test.ts @@ -173,8 +173,9 @@ type _KeyofStillResolvesOnThisInterface = Expect< /** * The contract's face, untouched by this removal: the tombstone accepts nothing. - * Its type is the spec's retired-key type — `undefined` at the pinned 17.5.0, - * the branded `[REMOVED]` mark on objectstack `main` — and the helper accepts + * Its type is the spec's retired-key type — the branded `[REMOVED]` mark at the + * pinned 17.6.0 and on objectstack `main`, + * bare `undefined` through 17.5.0 — and the helper accepts * exactly those two spellings (objectui#11330). */ type _LayoutOnTheContractFace = Expect>; diff --git a/packages/types/src/__tests__/retired-key-type.ts b/packages/types/src/__tests__/retired-key-type.ts index 1e4c433200..ecab582f8d 100644 --- a/packages/types/src/__tests__/retired-key-type.ts +++ b/packages/types/src/__tests__/retired-key-type.ts @@ -12,13 +12,15 @@ * package's build, and nothing re-exports this module. * * `@objectstack/spec` declares a removed key with `retiredKey()`, and the type - * that tombstone gives the key has two spellings, both of which these pins - * must compile against — the pinned spec in every ordinary job, and the spec - * built from objectstack `main` in `Spec Main Shape Gate`: + * that tombstone gives the key has had two spellings. These pins were written + * to compile against both — the pinned spec in every ordinary job, and the + * spec built from objectstack `main` in `Spec Main Shape Gate` — while the two + * disagreed: * - * - **The pinned 17.5.0 spelling: bare `undefined`** — the input type of + * - **Through 17.5.0: bare `undefined`** — the input type of * `z.never().optional()`. The key admits nothing but absence. - * - **objectstack `main` since `d830d71f` (objectstack#21023): a branded + * - **objectstack `main` since `d830d71f` (objectstack#21023), and the pinned + * 17.6.0 (objectui#11438): a branded * mark** — an inline, anonymous object type whose ONE property is named by * the retirement sentence and typed `never`, under the optional wrapper: * MARK or `undefined`, where MARK reads @@ -38,8 +40,10 @@ * prefix). The direction proofs at the foot of this module are what hold that * line: loosen the helper toward "anything" and one of them stops compiling. * - * When the pin moves past a release that carries the mark, the `undefined` - * arm describes no installed spec any more and can be dropped. + * The pin moved past a release that carries the mark at 17.6.0, so the bare + * `undefined` arm describes no installed spec any more and can be dropped. The + * bump that moved the pin (objectui#11438) kept it: dropping it tightens the + * helper every retirement pin reads, which is a change of its own. */ /** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */ diff --git a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts index be5bad7967..57d18b3205 100644 --- a/packages/types/src/__tests__/spec-object-refinements-7715.test.ts +++ b/packages/types/src/__tests__/spec-object-refinements-7715.test.ts @@ -133,7 +133,27 @@ const SITES = [ // Both added to the spec object at 17.5.0 and attached by objectui#11073: each reads only // `type`, `options.stageOrder` / `values` and `id`, the spec's own fields on this node. attached: ['checkDashboardWidgetStageOrder', 'checkDashboardWidgetMetricMeasureArity'], - notAttachable: [], + // OWED TO objectui#11334. A BOOKING, not a measured structural refusal like the + // `checkListViewPageMount` entry the `ListViewSchema` row once held: objectstack + // `11d28c17` (#21053) added this check to the spec object, and objectui's mirror + // does not re-attach it yet. A static import of it compiles against the spec this + // repository resolves and fails the `Spec Main Shape Gate`, because objectstack + // `main` renamed the export (`32d57690`, #21293 / #21425) and no longer exports + // this name; objectui#11531 measured that attach on the bump's trunk and reverted + // it (its row 3). Nothing in this file measures that reason, for the Shape Gate + // compiles against objectstack `main`, which no run of this file sees. + // Booked by objectui#11438 ruling A″ (record 5968177777), which applies + // objectui#11111 decision 3 = B (record 5902351047) to the bump; group 5's + // booking of objectui#11536 has the same shape. + // Expires when objectui resolves an `@objectstack/spec` carrying `32d57690`, or + // 2026-11-02, whichever is first. The condition is enforced here and the date + // is read by objectui#11334, not by a clock: that resolution drops this export + // name, so the census test that matches every exported `check*` name against + // these lists turns red by name, and objectui#11334 strikes this entry as it + // re-attaches the renamed check. Capped by this row's own count equality and + // that census's exact name match: no check beyond the entries listed here is + // admitted. + notAttachable: ['checkDashboardWidgetDimensionlessMeasureArity'], }, { site: 'SpecDashboardFields → DashboardComponentSchema (complex.zod.ts)', spec: SpecDashboardSchema, attached: [], notAttachable: [] }, { site: 'SpecPageFields → PageNodeSchema (layout.zod.ts)', spec: SpecPageSchema, attached: ['checkPageSourceCompleteness'], notAttachable: [] }, diff --git a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts index 588fd8b009..5f31bf2698 100644 --- a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts +++ b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -123,8 +123,9 @@ describe('spec tombstones surface on the twin as a refusal — the verdict the m // (ADR-0090 D2 deleted the Profile concept). Both faces take it by // reference, so the twin's member IS the spec's, and it admits no value. const isSpecMember: Equal = true; - // The spec's retired-key type: `undefined` at the pinned 17.5.0, the branded - // `[REMOVED]` mark on objectstack `main` (objectui#11330). + // The spec's retired-key type: the branded `[REMOVED]` mark at the pinned + // 17.6.0 and on objectstack `main`, bare `undefined` through 17.5.0 + // (objectui#11330). const admitsNoValue: Equal, true> = true; // The control for the line above, through the same helper: an admitted // spec key on the same twin is NOT a retired-key type. diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index 00c46b2e92..64be46b213 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -25,7 +25,7 @@ import type { Field as SpecField } from '@objectstack/spec/data'; * sibling field names, or `{ field, param }` entries mapping a sibling onto the * remote query parameter a dependent lookup filters by. * - * Measured on the installed `@objectstack/spec` (17.5.0), `FieldSchema` declares + * Measured on the installed `@objectstack/spec` (17.6.0), `FieldSchema` declares * `dependsOn` as an OPTIONAL ARRAY of `string | { field, param? }` — never a * bare string. That is deliberately narrower than `DependsOnInput` (`form.ts`), * the shape the widget prop `FieldWidgetComponentProps.dependsOn` and @@ -288,7 +288,7 @@ export interface MarkdownFieldMetadata extends BaseFieldMetadata { * annotated literal rejected. Follows the `TextareaFieldMetadata` precedent. * * A DECLARED spec key as of `@objectstack/spec` 17.3.0, which implements that - * same ruling. Measured on the installed `@objectstack/spec` 17.5.0: + * same ruling. Measured on the installed `@objectstack/spec` 17.6.0: * `FieldSchema` ACCEPTS `rows` on all four of textarea/markdown/html/ * richtext, as an integer of at least 1 — a non-integer answers * `invalid_type` and 0 answers `too_small`, so "declared" does not mean "any @@ -572,7 +572,7 @@ export interface SelectOptionMetadata extends SelectOptionBase { * * A DECLARED `SelectOptionSchema` key as of `@objectstack/spec` 17.3.0, * which implements the objectui#6153 half of that ruling. Measured on the - * installed `@objectstack/spec` 17.5.0: an option carrying `description` + * installed `@objectstack/spec` 17.6.0: an option carrying `description` * is ACCEPTED, as a string (a non-string answers `invalid_type`; the empty * string is valid), and a field whose `options` carry it parses whole. ⇒ it * may now be written into authored object metadata, which is the point of diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index d5c1398d36..37bd4fa5b6 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -161,6 +161,11 @@ import type { // objectui#11355 — `ComponentPropsMap['object-kanban']`'s author state, so // `ObjectKanbanSchema.swimlaneField` takes the row's own type by reference. ObjectKanbanProps as SpecObjectKanbanProps, + // objectui#11227 — the list view's empty-state shape, which the spec's + // `object-grid` row holds by reference since 17.6.0 (`EmptyStateSchema`): + // `ObjectGridSchema.emptyState` below takes the protocol's own authored type. + // Aliased for the reason `SpecObjectCalendarProps` above is. + EmptyState as SpecEmptyState, ChartDrillDown, I18nLabel, DashboardWidget as SpecDashboardWidget, @@ -1424,18 +1429,17 @@ export interface ObjectGridSchema extends BaseSchema { * own "no results" row, so the search box that emptied it stays reachable. * Leave the key out and nothing changes — the table draws its own empty row. * - * The same three members, with the same meanings, as `ListViewSchema`'s - * `emptyState`. Mirrored member for member by the Zod twin, which refuses an - * unknown member. + * The protocol's own empty-state type, ⛔ not a second shape (objectui#11227): + * `@objectstack/spec` 17.6.0 declares `emptyState` on its `object-grid` row + * by reference to the list view's `EmptyStateSchema`, so the grid and + * `ListViewSchema` share one shape. `title` and `message` are `I18nLabel` — + * a plain string or an inline locale map — and `ObjectGrid` resolves each + * against the display locale, as it resolves `label` and `description`; a + * map with no usable entry keeps that member's default. `icon` stays a + * string. The Zod twin takes the same schema by reference, so it refuses an + * unknown member as the spec does. */ - emptyState?: { - /** Title text for the empty state */ - title?: string; - /** Message/description for the empty state */ - message?: string; - /** Icon name (Lucide icon identifier) */ - icon?: string; - }; + emptyState?: SpecEmptyState; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-grid` reads NEITHER * content channel; see `children` below for the measurement. @@ -2905,12 +2909,16 @@ export interface NamedListView { /** Allow printing the view @default false */ allowPrinting?: boolean; - /** Empty state configuration */ - emptyState?: { - title?: string; - message?: string; - icon?: string; - }; + /** + * Empty state configuration — the type of {@link ListViewSchema}'s own + * `emptyState`, indexed rather than restated, as the objectui#8980 members + * below are (objectui#11227). That member is the protocol's `EmptyStateSchema` + * by reference, so `title` and `message` are `I18nLabel` — a plain string or + * an inline locale map — and `ListView`, which this view is forwarded into, + * resolves each against the display locale. This member said plain `string` + * until then, narrower than the protocol on both. + */ + emptyState?: ListViewSchema['emptyState']; /** ARIA attributes for accessibility */ aria?: { @@ -4058,7 +4066,7 @@ export interface ObjectCalendarSchema extends BaseSchema { * (the spec's own `ObjectCalendarPropsSchema`) declares * `z.array(z.unknown()).optional()`, described *"Pre-fetched records — skips * the internal fetch"*. - * MEASURED on the installed artifact at `@objectstack/spec` 17.5.0 — the + * MEASURED on the installed artifact at `@objectstack/spec` 17.6.0 — the * version this repository's `pnpm-lock.yaml` resolves — through the published * `@objectstack/spec/ui` entry point: the provider block returns * `success=false` with `expected: 'array'` at `path: ['data']`, the array diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index 53e50e9845..3e43567ed4 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -107,7 +107,7 @@ export interface RecordDetailsComponentProps { * It was `number` here until objectui#8604, which is the wrong PRIMITIVE * TYPE, not merely a wider range: `{ columns: 2 }` compiled locally and the * contract refused it at publish with `invalid_value` at `columns` (measured - * on the installed pin, 17.5.0, against a control — `columns: '2'` — that + * on the installed pin, 17.6.0, against a control — `columns: '2'` — that * parses green on the same instrument). Contract-first (Commandment #0.1): * the code moves to the contract's spelling, and today's `columns: 2` * authors are the defect surfacing rather than collateral damage. @@ -334,7 +334,7 @@ export interface RecordHighlightsComponentProps { * `RecordHighlightsProps.fields[]`'s object arm declares exactly * `name`/`label`/`type`/`readonly` and carries a `never` catchall, i.e. it is * `$strict`: an unlisted key is REFUSED, not stripped, and the refusal takes - * the WHOLE document with it. Measured on the installed pin, 17.5.0, + * the WHOLE document with it. Measured on the installed pin, 17.6.0, * `RecordHighlightsProps.safeParse({ fields: [{ name: 'x', icon: 'star' }] })` * is RED with `invalid_union` at `fields.0`. So `{ name: 'amount', icon: * 'dollar-sign' }` type-checked here and was refused at the door — a green @@ -366,7 +366,7 @@ export interface RecordHighlightsComponentProps { * `z.enum(['horizontal','vertical'])` behind a `.default('horizontal')`). * * It offered a third value, `grid`, until objectui#9187, and the contract - * never accepted it: measured on the installed pin, 17.5.0, + * never accepted it: measured on the installed pin, 17.6.0, * `RecordHighlightsProps.safeParse({ fields: ['name'], layout: 'grid' })` is * RED with `invalid_value` at `layout`. So `{ layout: 'grid' }` type-checked * here and was refused at the door — a green local build and a rejection at diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 8af435c095..360b2e65b1 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -71,6 +71,10 @@ import { // objectui#11070 — the per-element data binding (`PageComponentSchema.dataSource`) // the object-bound arms below declare as `dataSource`, by reference. ElementDataSourceSchema as SpecElementDataSourceSchema, + // objectui#11227 — the list view's empty-state shape, which the spec's + // `object-grid` row holds by reference since 17.6.0; `ObjectGridSchema.emptyState` + // below takes it by reference too. + EmptyStateSchema as SpecEmptyStateSchema, checkListViewCalendarVisualization, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; @@ -698,17 +702,16 @@ export const ObjectGridSchema = BaseSchema.extend({ placeholder: retirementTombstone(OBJECT_GRID_PLACEHOLDER_RETIRED), showFilters: retirementTombstone(OBJECT_GRID_SHOW_FILTERS_RETIRED), // objectui#11068 — read by `ObjectGrid`, which draws it in place of an empty - // table. Mirrored member for member with the interface: three optional - // strings, and an unknown member refused rather than kept, so a misspelt - // `description` / `text` for `message` is named instead of drawing nothing. - emptyState: z - .strictObject({ - title: z.string().optional().describe('Heading of the empty state; absent, the table’s own "No results found"'), - message: z.string().optional().describe('Line of text below the heading; absent, no line'), - icon: z.string().optional().describe('Lucide icon name; absent or unknown, the shared empty-state glyph'), - }) + // table. objectui#11227 — the spec's `EmptyStateSchema` BY REFERENCE, ⛔ not a + // second shape: the spec's `object-grid` row (17.6.0) declares this member as + // that very schema, so the twin follows the spec. It is strict, so a misspelt + // `description` / `text` for `message` is still named instead of drawing + // nothing; `title` and `message` are `I18nLabel` (a plain string or an inline + // locale map, which `ObjectGrid` resolves against the display locale), and + // `icon` is a string. Mirrored by the interface's `SpecEmptyState`. + emptyState: stripImportedDefaults(SpecEmptyStateSchema) .optional() - .describe('What the grid draws instead of an empty table: `{ title, message, icon }`'), + .describe('What the grid draws instead of an empty table: `{ title, message, icon }`, the spec EmptyState by reference'), editable: z.boolean().optional(), keyboardNavigation: z.boolean().optional(), frozenColumns: z.number().optional(), @@ -1592,8 +1595,9 @@ const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().ext * alias table points this spelling at the END of the event" and that was WRONG * about the protocol; the corrected mechanism, re-derived by RUNNING * `@objectstack/spec` 17.4.0 (the version in the lockfile then) rather than - * reading it — and RE-RUN on the installed 17.5.0 (objectui#11073), whose - * answer moved, as the last bullet says: + * reading it — RE-RUN on 17.5.0 (objectui#11073), whose answer moved, as the + * last bullet says, and RE-RUN again on the installed 17.6.0 (objectui#11438), + * which answers every bullet below as 17.5.0 did: * * - `CalendarConfigSchema`'s `strictObject` options carry `surface` and * `history` and NOTHING ELSE. There is no `aliases` entry, so upstream holds @@ -1616,7 +1620,8 @@ const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().ext * not say which end of the range it binds … Write the one you mean") and * prescribes neither. `endField` still draws no hint, and a one-char typo * still resolves by distance (`titleFeld` → `titleField`) — all three - * measured on the installed 17.5.0. + * measured on 17.5.0, and again on the + * installed 17.6.0 (objectui#11438). * * ⇒ through 17.4.0 a generic typo-distance suggester picked the wrong sibling. * It was not a declaration, it contradicted no declaration, and ⛔ no upstream @@ -1813,8 +1818,8 @@ const CalendarConfig = stripImportedDefaults(SpecCalendarConfigSchema).partial() * gate's own failure mode one layer in. * * ⚠️ THE MEMBER LIST IS objectui's OWN, and the spec does NOT supply it. - * MEASURED on the installed `@objectstack/spec` 17.5.0 (the same answer - * 17.4.0 gave): + * MEASURED on the installed `@objectstack/spec` 17.6.0 (the same answer + * 17.5.0 and 17.4.0 gave): * `ComponentPropsMap['object-calendar'].calendar` is NOT `CalendarConfigSchema` * — it is `z.unknown().optional()` (wrapper chain `["optional","unknown"]`, and * not the same object reference), so at THIS position the protocol accepts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea9b7ea5e3..73b5b94e76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: link:packages/test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -273,13 +273,13 @@ importers: version: link:../../packages/types '@objectstack/client': specifier: ^17.3.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -417,7 +417,7 @@ importers: version: link:../../packages/types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) fumadocs-core: specifier: 16.15.4 version: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.5) @@ -775,13 +775,13 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@sentry/react': specifier: ^10.70.0 version: 10.70.0(react@19.2.8) @@ -872,7 +872,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) better-auth: specifier: ^1.7.2 version: 1.7.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0(socks@2.8.9))(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -903,7 +903,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.1.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -971,7 +971,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1007,7 +1007,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1191,10 +1191,10 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1250,10 +1250,10 @@ importers: version: link:../types '@objectstack/client': specifier: ^17.3.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1293,7 +1293,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1397,7 +1397,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1438,7 +1438,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1619,7 +1619,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1661,7 +1661,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1752,7 +1752,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1822,7 +1822,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.4.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1874,7 +1874,7 @@ importers: version: link:../i18n '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1999,7 +1999,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2060,7 +2060,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2130,7 +2130,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2304,7 +2304,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2481,7 +2481,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2529,7 +2529,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2596,7 +2596,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2669,7 +2669,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2734,7 +2734,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.1.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) react: specifier: 19.2.8 version: 19.2.8 @@ -2799,7 +2799,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2838,13 +2838,13 @@ importers: version: link:../react '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) packages/test-support: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + version: 17.6.0(ai@7.0.65(zod@4.6.5)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2858,8 +2858,8 @@ importers: packages/types: dependencies: '@objectstack/spec': - specifier: ^17.5.0 - version: 17.5.0(ai@7.0.65(zod@4.6.5)) + specifier: ^17.6.0 + version: 17.6.0(ai@7.0.65(zod@4.6.5)) zod: specifier: ^4.6.1 version: 4.6.5 @@ -4220,26 +4220,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.5.0': - resolution: {integrity: sha512-RT8CoPw7v+ejADcK1NJRD/1rHOQJMKC+rL7SjTvEqI+Cfxulz3K4NSgILpyPZMaic72O6DWiWSNueNiFp/7W1g==} + '@objectstack/client@17.6.0': + resolution: {integrity: sha512-ZvhtjximG+nQNeC8v0Ed/FRdI5xcGQLJzxUtKlqvJJ6I3ueqBmOs8zHSoCa1vyMbctjBuzLRKxUeM9cUn9LTuA==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.5.0': - resolution: {integrity: sha512-KYVVHoqDQZzqvCD7B1REAnQc80ZeimrkSryaGvPfQc2bdurvTOjtubGXw2IkQsh3g2Gh0RD9QzohteEU7C1DFg==} + '@objectstack/core@17.6.0': + resolution: {integrity: sha512-mLKGKYnbmp6ZNVZ26sC1ftDOIpKqiPjujJ+9avVDM06eQhL+O9r9nPFFjx+4RNfH7z+6r7T2LOu6nPdpRPaUWA==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.5.0': - resolution: {integrity: sha512-2vot7CS7++4vuxOJ3fyWVUPOut+/oejJT1YbLwyyyVDxj4WjZ3tJqEk+WGRqNunwYbd3CTk7jrtMo7SRzF7+Xg==} + '@objectstack/formula@17.6.0': + resolution: {integrity: sha512-5RpDNHLYJxqaivpDtM+QpltC5WlLRzwm58G9Y1us+6waZNnrV61eCDwV8JvaCsluneugcCVxp7oSDfjM8xZjAA==} - '@objectstack/lint@17.5.0': - resolution: {integrity: sha512-sXNgX4PFAMwo5q7xRqUfTEBUE5OKStYQwmFqdwDDN5Zir8JRJi9z2cN1D42h9PciR4Y9+Qum0z9gOKgS6o0jsw==} + '@objectstack/lint@17.6.0': + resolution: {integrity: sha512-72LncQPEk1BnO3HWdtp+3Q9tik2lntQ8/WNfxtB5jqPo/9XQqDLjYaxCXmN1+mLtEkjk1ph/g8mHaSGORq9/4g==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.5.0': - resolution: {integrity: sha512-GBn5BONhKuWfI/wmI+X4P0mh4a/cKx6Zo0rgZojWVBVC00fPpxuMb5obO74Do509Nm6o0h7m7OAq65Rb0td7TQ==} + '@objectstack/sdui-parser@17.6.0': + resolution: {integrity: sha512-mMJxAXscqVTruSdRdzBfC1EufAXfX2jNDuyo1k0XNLkClLdJAgjmQBZtyMARUSEbGg33PC30gPx9IiBt7qYSFA==} - '@objectstack/spec@17.5.0': - resolution: {integrity: sha512-G9IPkAnxbgk90wKTqyrIHsPm+kWyYDn2cUfmNnt5xwewdpvRtd5HqaHSjjn5wBCzO0OACerZULNR/f3gPtQu1w==} + '@objectstack/spec@17.6.0': + resolution: {integrity: sha512-i5il5q0vIYjYrkHznsCXuO8C6Ftrs2RU4MxRHWrW3TsR7t/VUoe3sSfVLHDaudV+LfVVUDD9QHsWex1MCdckrA==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -4247,8 +4247,8 @@ packages: ai: optional: true - '@objectstack/types@17.5.0': - resolution: {integrity: sha512-QSwGeHxGmDd5SQbZFkHdoct9J6kOh7tijsU6BDE1k+zgTHqOnqUGNQkp6T8KggJTdPz2MHvRDBpmstHT8Rsgng==} + '@objectstack/types@17.6.0': + resolution: {integrity: sha512-yH4B4YZt0cdSkenjifKY5o8svaV+PMPCdV0Tm0FMo3w1o6ShhF5gQK/PvU9lwFaRsEAB9SuJ70nCURfIAVoLsw==} engines: {node: '>=22.0.0'} '@open-draft/deferred-promise@2.2.0': @@ -12322,33 +12322,33 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/client@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/core': 17.5.0(ai@7.0.65(zod@4.6.5)) - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/core': 17.6.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) transitivePeerDependencies: - ai - '@objectstack/core@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/core@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) - '@objectstack/types': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/types': 17.6.0(ai@7.0.65(zod@4.6.5)) zod: 4.6.5 transitivePeerDependencies: - ai - '@objectstack/formula@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/formula@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) transitivePeerDependencies: - ai - '@objectstack/lint@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/lint@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/formula': 17.5.0(ai@7.0.65(zod@4.6.5)) - '@objectstack/sdui-parser': 17.5.0 - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/formula': 17.6.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/sdui-parser': 17.6.0 + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12356,18 +12356,18 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.5.0': {} + '@objectstack/sdui-parser@17.6.0': {} - '@objectstack/spec@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/spec@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: pg-connection-string: 2.14.0 zod: 4.6.5 optionalDependencies: ai: 7.0.65(zod@4.6.5) - '@objectstack/types@17.5.0(ai@7.0.65(zod@4.6.5))': + '@objectstack/types@17.6.0(ai@7.0.65(zod@4.6.5))': dependencies: - '@objectstack/spec': 17.5.0(ai@7.0.65(zod@4.6.5)) + '@objectstack/spec': 17.6.0(ai@7.0.65(zod@4.6.5)) transitivePeerDependencies: - ai diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index baa67b7390..d2aa8e66cb 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -807,9 +807,11 @@ describe('ceiling sensitivity, judged live (objectui#5924)', () => { // to 3104.5 on the authorised re-pin over `main`'s own drift, objectui#11073 // UP to 3563.2 on the ruled raise for `@objectstack/*` 17.5.0, // objectui#11088 decision 1 = A, objectui#11101 down to 3552.8 on the first - // payback of that raise) — a rendering derived in the test would agree with - // the renderer by construction and pin nothing. - expect(result.message).toContain('3552.8'); + // payback of that raise, objectui#11438 down to 3306.8 on the second, when + // `@objectstack/*` 17.6.0's spec root shed its migration chain) — a rendering + // derived in the test would agree with the renderer by construction and pin + // nothing. + expect(result.message).toContain('3306.8'); }); it('is exactly one regression wide, from either side of the line', () => { @@ -1639,7 +1641,7 @@ describe('main', () => { // about the FIXTURE while the gate under test behaved correctly. The number // this case is actually about is "the report's chunk count, echoed". expect(outputs.closure_chunks).toBe(String(fixture.files.length)); - expect(outputs.closure_gzip_kb).toBe('3552.8'); + expect(outputs.closure_gzip_kb).toBe('3306.8'); }); it('exits 1 — a verdict about the BUNDLE — when over budget', () => { @@ -2617,8 +2619,8 @@ describe('the prose attached to the baselines (objectui#7046)', () => { * * objectui#10996 is the first re-baseline to meet it, and re-pinned it to the * shape it left: `squashMerge` null, one commit carried. objectui#11073 and - * objectui#11101 each re-pinned the constant onto a branch tip of their own - * and left the same shape. ⛔ Still exact and positional — a back-fill of the + * objectui#11101, and objectui#11438 after them, each re-pinned the constant + * onto a branch tip of their own and left the same shape. ⛔ Still exact and positional — a back-fill of the * squash reds here again, and is re-pinned to two strings the same way. */ it('records what each baseline carries as data, so the pin cannot go vacuous', () => { diff --git a/scripts/__tests__/check-lockfile-integrity.test.ts b/scripts/__tests__/check-lockfile-integrity.test.ts index 23774c4575..08f41d675e 100644 --- a/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/scripts/__tests__/check-lockfile-integrity.test.ts @@ -89,17 +89,17 @@ describe('green on main — the negative control', () => { * * ⛔ It cannot be produced by running the resolver, and that impossibility is * the card's central claim: every workspace range on `@objectstack/*` is a - * floating `^17.x` whose floor is at or above the resolved 17.5.0 (objectui#11073; - * 17.4.0 when this sample was written), so no fresh resolve can pick 17.2.0. The sample is therefore a transformation of the real lockfile + * floating `^17.x` that a fresh resolve satisfies with the newest release, 17.6.0 today + * (objectui#11438; 17.4.0 when this sample was written), so no fresh resolve can pick 17.2.0. The sample is therefore a transformation of the real lockfile * reproducing the identity facts measured on PRs #7053 / #7058 — the family * down to 17.2.0, and `@objectstack/spec` resolved at BOTH versions. */ function sampleA(): string { let out = realLock; for (const pkg of ['client', 'core', 'formula', 'lint', 'sdui-parser']) { - out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.5\\.0`, 'g'), '$117.2.0'); + out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.6\\.0`, 'g'), '$117.2.0'); } - const specKey = " '@objectstack/spec@17.5.0(ai@7.0.65(zod@4.6.5))':"; + const specKey = " '@objectstack/spec@17.6.0(ai@7.0.65(zod@4.6.5))':"; expect(out, 'the spec snapshot key moved — rebuild this sample before trusting it').toContain( specKey, ); @@ -123,8 +123,8 @@ function sampleA(): string { function sampleB(): string { let out = realLock; const forked = [ - " '@objectstack/spec@17.5.0(ai@7.0.65(zod@4.6.5))':", - " '@objectstack/formula@17.5.0(ai@7.0.65(zod@4.6.5))':", + " '@objectstack/spec@17.6.0(ai@7.0.65(zod@4.6.5))':", + " '@objectstack/formula@17.6.0(ai@7.0.65(zod@4.6.5))':", " ai@7.0.65(zod@4.6.5):", ]; for (const key of forked) { @@ -190,7 +190,7 @@ describe('cause 2 — a declared dependency forks with no version moving (object expect(spec?.headCount).toBe(2); // This is #8326's exact bundling mechanism — two real paths, nothing // dedupes them — arriving with no version change anywhere. - expect(spec?.head.every((k) => k.includes('@17.5.0'))).toBe(true); + expect(spec?.head.every((k) => k.includes('@17.6.0'))).toBe(true); }); }); diff --git a/scripts/__tests__/vite-objectstack-spec-dist.test.ts b/scripts/__tests__/vite-objectstack-spec-dist.test.ts index 086e214c1a..e8d937a04a 100644 --- a/scripts/__tests__/vite-objectstack-spec-dist.test.ts +++ b/scripts/__tests__/vite-objectstack-spec-dist.test.ts @@ -30,7 +30,7 @@ import { * * 1. **Set → every subpath is mapped.** The client hook this mirrors is one * prefix alias, which is safe only because `@objectstack/client` exports a - * single entry. The spec's map has 20 and redirects each into `dist/`, so a + * single entry. The spec's map has 21 and redirects each into `dist/`, so a * copied client line rewrites `@objectstack/spec/ui` to a path that does not * exist — measured as 214 broken import sites for `/ui` alone. The * reconciliation case below therefore checks the derivation against Node's @@ -260,7 +260,7 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { ) as { exports: Record }; const declared = Object.keys(manifest.exports); - // Anti-vacuity: the map this is reconciled against is the measured 20-entry + // Anti-vacuity: the map this is reconciled against is the measured 21-entry // one, not an empty object a silently-changed reader would also "cover". // 18 -> 19 on the @objectstack/spec 17.2.0 refresh (objectui#5668): the // one added subpath, measured by diffing 17.1.0's exports map against @@ -272,7 +272,11 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { // REMOVED (the last step of the objectstack#16325 chain objectui#8225 names // below), `./api-assembled` and `./marketplace` were ADDED. The pin did its // job again: the un-updated 19 turned this red on the bump's CI. - expect(declared.length).toBe(20); + // 20 -> 21 on the @objectstack/spec 17.6.0 refresh (objectui#11438), + // measured by diffing 17.5.0's exports map against 17.6.0's: `./migrations` + // was ADDED (the spec's migrations-entry-split; it declares no `browser` + // arm), nothing was removed. The un-updated 20 turned this red on the bump. + expect(declared.length).toBe(21); expect(Object.keys(injection!.aliases).length).toBe(declared.length); // objectui#9408 — the population splits, and WHICH oracle applies is the @@ -294,7 +298,7 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { // `./system`, `./kernel`, `./api-assembled`). Still 5 after the 17.5.0 // refresh (objectui#11073) but NOT the same 5: 17.4.0's set carried // `./cloud`, which left with its subpath, and the new `./api-assembled` - // arrived browser-first. Pinned exactly, like the 20 above, + // arrived browser-first. Pinned exactly, like the 21 above, // because an unpinned browser set is precisely how this went unnoticed: // objectui#9408 was FILED naming `./api` as browser-first, and by the time // it was worked `./api` had lost its browser arm upstream with nothing @@ -338,8 +342,8 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { it('agrees with a REAL Vite build on every entry, `browser` arm included', async () => { // The oracle that actually counts. Vite is the resolver this hook MODELS, - // and unlike Node it satisfies `browser`, so it can answer for all 20 - // entries where Node can only answer for 15. Run through a real build + // and unlike Node it satisfies `browser`, so it can answer for all 21 + // entries where Node can only answer for 16. Run through a real build // rather than a transcription, for the same reason the alias-matcher cases // below bundle for real: a transcribed algorithm agrees with its own // transcription, not with Vite. @@ -365,7 +369,7 @@ describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { expect(disagreed, 'entries where the hook and Vite pick different files').toEqual([]); // Anti-vacuity: the sweep really did exercise the browser arm, i.e. the - // agreement above is not agreement about 20 Node-arm files. + // agreement above is not agreement about 21 Node-arm files. const browserArm = specifiers.filter((s) => resolved.get(s)?.includes(`${path.sep}browser${path.sep}`)); expect(browserArm.length).toBe(5); }); @@ -861,7 +865,7 @@ describe('objectui#9408: the exports map declares precedence, not this module', // would surface as a bundler error at a pin bump nobody connects to this // file. A report that omitted the decided entries would leave that intact. const injection = inject(installedSpecDir)!; - expect(injection.resolutions).toHaveLength(20); + expect(injection.resolutions).toHaveLength(21); const report = formatConditionReport(injection).join('\n'); expect(report).toContain(injection.packageDir); @@ -890,7 +894,7 @@ describe('objectui#9408: the exports map declares precedence, not this module', // Anti-vacuity on the other half: entries with no choice to make are still // reported, with their arm, rather than silently dropped. const forced = injection.resolutions.filter((r) => r.passedOver.length === 0); - expect(forced.length).toBe(20 - decided.length); + expect(forced.length).toBe(21 - decided.length); expect(forced.length).toBeGreaterThan(0); }); @@ -967,7 +971,7 @@ describe('objectui#4854: the four flagged surfaces in the console config', () => const injectedSpecKeys = Object.keys(injected.resolve.alias).filter((k: string) => k === SPEC_PACKAGE_NAME || k.startsWith(`${SPEC_PACKAGE_NAME}/`) ); - expect(injectedSpecKeys).toHaveLength(20); + expect(injectedSpecKeys).toHaveLength(21); expect(injectedSpecKeys[injectedSpecKeys.length - 1]).toBe(SPEC_PACKAGE_NAME); expect(injected.resolve.alias[`${SPEC_PACKAGE_NAME}/ui`]).toBe( path.join(fs.realpathSync(installedSpecDir), 'dist/ui/index.mjs') @@ -1214,7 +1218,7 @@ function consoleShapedBundle( * (`/…/objectstack/packages/spec`, or `/home/runner/work/objectstack/objectstack/ * packages/spec` on CI) has no `@objectstack` segment anywhere, and that is the * one property this fixture has to reproduce. It stays minimal on purpose: the - * exports-map derivation is covered above against the real 20-entry map, and + * exports-map derivation is covered above against the real 21-entry map, and * what these cases need is a legal package at a path of the wrong SHAPE. */ function makeOutOfTreeSpecPackage(): string { diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 2806b18f44..425381a5c1 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -417,10 +417,12 @@ import { isEntrypoint } from './invoked-as.mjs'; * header for both builds and the three control rows that show the bytes * LEFT rather than moved. * - * Headroom above {@link BASELINE} is 22,276 bytes — 0.24x - * {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}, on the pair objectui#11101 - * re-pinned when it paid back part of objectui#11073's raise; the header's - * objectui#11101 entry says why that is under the 0.50x design point. ⚠️ That + * Headroom above {@link BASELINE} is 24,091 bytes — 0.26x + * {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}, on the pair objectui#11438 + * re-pinned when the `@objectstack/*` 17.6.0 bump paid back the rest of the + * spec-root part of objectui#11073's raise; the header's objectui#11101 entry + * says why that band is under the 0.50x design point, and objectui#11438's + * entry kept it to the byte. ⚠️ That * is arithmetic on two * constants in this file, so it stays true while they do — it is NOT what the * closure has left today, which is smaller by every byte the payload has @@ -741,8 +743,47 @@ import { isEntrypoint } from './invoked-as.mjs'; * and neither did {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}. * objectstack#20646 is the second payback, and it lowers both lines again by * what it recovers. + * + * ## ⭐ LOWERED BY THE SECOND PAYBACK (objectstack#20646, carried by objectui#11438) + * + * From 3,660,380 to 3,410,284: down by 250,096 bytes, exactly what the + * `@objectstack/*` 17.6.0 bump took out of the closure. 17.6.0 carries the + * spec's migrations-entry-split: the root entry no longer exports the + * migration chain (`MIGRATIONS_BY_MAJOR` moved to `@objectstack/spec/migrations`, + * which nothing on the first screen imports). Lowered by the amount recovered, + * as objectui#11101's payback was: ⛔ not re-derived as the new reading plus + * half a regression, and ⛔ not rounded. + * + * WHAT LEFT — two console builds, one container, one instrument, one hold of + * `scripts/pm/os-verify-lock.sh`: `pnpm turbo run build --filter='./packages/*'`, + * then `CI=true pnpm exec vite build` in `apps/console`, reading + * `apps/console/dist/eager-closure.json`. `2d576e46` is `main` with + * `@objectstack/*` 17.5.0 resolved; `2ba091c` is objectui#11438's branch, the + * same tree with 17.6.0 resolved and the bump's own edits. + * + * | chunk | `2d576e46` | `2ba091c` | delta | + * |-----------------------------|------------:|-----------:|---------:| + * | `vendor-objectstack` | 1,671,066 | 1,420,958 | -250,108 | + * | the other 329 eager chunks | 1,965,223 | 1,965,235 | +12 | + * | ⇒ aggregate | 3,636,289 | 3,386,193 | -250,096 | + * + * Both builds weigh 330 of 2450 chunks. The +12 is spread over the other + * chunks, ⛔ not attributed here. Left at 3,660,380, the ceiling would have + * stood 274,187 bytes (3.01x the regression) above this payload, and + * {@link evaluateHeadroomSensitivity} calls that a blind gauge (exit 2). + * + * Headroom 24,091 bytes = 0.26x {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES} + * over {@link BASELINE}: exactly the headroom `main` had on `2d576e46` before + * the payback (3,660,380 − 3,636,289), because the ceiling came down by the + * aggregate's own delta. No build that passed before this edit and measures + * under 3,410,284 fails after it. + * + * ⛔ What moved with it, and what did not. `vendor-objectstack`'s per-chunk + * ceiling came down the same way, by the 250,108 bytes that chunk lost, with + * {@link PER_CHUNK_BASELINE}. The other three per-chunk rows did not move, + * and neither did {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES}. */ -export const MAX_EAGER_CLOSURE_GZIP_BYTES = 3_660_380; +export const MAX_EAGER_CLOSURE_GZIP_BYTES = 3_410_284; /** * The measurement the ceiling above was derived from. Exported so the two @@ -755,6 +796,21 @@ export const BASELINE = Object.freeze({ /** * `emitEagerClosureReport`'s `eagerGzipBytes` on this commit. * + * `2ba091c` is objectui#11438's branch tip: `main` at `2d576e46` with + * `@objectstack/*` 17.6.0 resolved and the bump's own edits, the second + * payback recorded on {@link MAX_EAGER_CLOSURE_GZIP_BYTES} (its table and its + * `2d576e46` control are there, ⛔ not restated here). It is a branch tip for + * the reason the paragraph below gives for `4acbea07`: the pull request's own + * diff is what moved the figure. Measured by `CI=true pnpm exec vite build` + * in `apps/console` (exit 0) after `pnpm turbo run build --filter='./packages/*'`, + * reading `apps/console/dist/eager-closure.json`, under + * `scripts/pm/os-verify-lock.sh`, in the same container and the same lock + * hold as the `2d576e46` control build. The commit that re-pins this file + * reaches no bundler input, so it cannot move the figure it pins. + * + * ⚠️ The paragraphs below describe the reading this one superseded, `4acbea07` + * (3,638,104 across 330 of 2450 chunks), kept as its provenance. + * * `4acbea07` is the payback commit of objectui#11101's branch: `main` at * `5262f7d` plus the one line in `apps/console/vite.config.ts` that keeps an * `import()`ed `@objectstack/*` module out of `vendor-objectstack`, under the @@ -786,10 +842,10 @@ export const BASELINE = Object.freeze({ * and not from `main` once it is squash-merged — the dead end objectui#9355 * added `squashMerge` below to route around. */ - gzipBytes: 3_638_104, + gzipBytes: 3_386_193, chunks: 330, totalChunks: 2450, - commit: '4acbea07', + commit: '2ba091c', /** * The squash merge that carried the reading above onto `main`, recorded when @@ -797,8 +853,8 @@ export const BASELINE = Object.freeze({ * (objectui#9355): a sha that DOES resolve, so a reader re-checking the * measurement gets a handle rather than a dead end. * - * `null` since objectui#10996, and `null` again after objectui#11101, - * deliberately. This field can only ever be BACK-FILLED: a squash sha does + * `null` since objectui#10996, and `null` again after objectui#11101 and + * objectui#11438, deliberately. This field can only ever be BACK-FILLED: a squash sha does * not exist until the pull request merges, so the change that re-pins the * field above ⛔ cannot write its own here. ⛔ Do not guess one: a wrong sha in * this position is worse than an absent one, because it RESOLVES, and a @@ -1314,7 +1370,13 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ // key had on `main` at `5262f7d` before the payback, kept to the byte — and // it comes down again with the aggregate when objectstack#20646 pays back the // rest. - 'vendor-objectstack': 1_689_265, + // + // ⭐ LOWERED again by that second payback, carried by objectui#11438's + // `@objectstack/*` 17.6.0 bump, from 1,689,265 by exactly the 250,108 bytes + // this chunk lost (the table under objectui#11438's entry on + // MAX_EAGER_CLOSURE_GZIP_BYTES). Headroom 18,199 bytes = 0.20x + // REGRESSION_THIS_GATE_MUST_CATCH_BYTES over the baseline below. + 'vendor-objectstack': 1_439_157, // ⭐ LOWERED, and RE-KEYED, by objectui#7479 — this line used to read // `'i18n-locales': 465_000` and budget TEN catalogues. Nine of them are // `import()`ed on demand now, so the chunk that name pointed at no longer @@ -1385,10 +1447,12 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * per file, and saying so is the point — a comment that names one commit for * three numbers taken on two is the drift objectui#6631 is open about: * - * - `vendor-objectstack` — `4acbea07` (objectui#11101), the payback build - * the aggregate also reads, re-measured when this chunk's group stopped - * claiming `import()`ed modules and its ceiling came down with the - * aggregate's. It supersedes `048e7f6` (objectui#11073, the `@objectstack/*` + * - `vendor-objectstack` — `2ba091c` (objectui#11438), the 17.6.0 bump's + * build the aggregate also reads, re-measured when the spec's root entry + * shed its migration chain and this chunk's ceiling came down with the + * aggregate's. It supersedes `4acbea07` (objectui#11101), the payback build + * re-measured when this chunk's group stopped claiming `import()`ed + * modules, which superseded `048e7f6` (objectui#11073, the `@objectstack/*` * 17.5.0 raise), which superseded `34a1578ef` (objectui#7122, the 17.3.0 * family bump) and `2c8474c04` (objectui#5490) before that. This bullet * still named `34a1578ef` after objectui#11073 had re-pinned the key, and @@ -1450,10 +1514,10 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * objectui#9251, and `ui-components` from objectui#9251 until objectui#10996 * re-pinned the aggregate onto a `main` commit; today neither does — and * WHICH ONE IS LATER flips every time either side is re-baselined, so read the - * commit names, never a direction asserted here. As of objectui#11101, - * `vendor-objectstack` SHARES the aggregate's reading: BASELINE's `4acbea07`, - * the payback build, re-pinned both, as objectui#11073's `048e7f6` had before - * it. The aggregate is the later reading for the + * commit names, never a direction asserted here. As of objectui#11438, + * `vendor-objectstack` SHARES the aggregate's reading: BASELINE's `2ba091c`, + * the 17.6.0 bump's build, re-pinned both, as objectui#11101's `4acbea07` and + * objectui#11073's `048e7f6` had before it. The aggregate is the later reading for the * other keys: `bbf6b02d9` (2026-09-13, objectui#9251) for `ui-components`, * and `3f775eeb8` for `framework`. ⚠️ `i18n-locale-en`'s commit was `755d34a5f` when it * was taken and the aggregate has moved on since, which is exactly why the two @@ -1514,11 +1578,12 @@ export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ * measurement with no ceiling weighs nothing. */ export const PER_CHUNK_BASELINE = Object.freeze({ - // `4acbea07`, the same build as BASELINE above (objectui#11101), re-pinned - // with its ceiling by the payback. It supersedes objectui#11073's `048e7f6` - // reading of 1,703,690 (landed on `main` as `81f8498`), which superseded - // objectui#7122's `34a1578ef` reading of 1,235,029. - 'vendor-objectstack': 1_670_964, + // `2ba091c`, the same build as BASELINE above (objectui#11438), re-pinned + // with its ceiling by the second payback. It supersedes objectui#11101's + // `4acbea07` reading of 1,670,964, which superseded objectui#11073's + // `048e7f6` reading of 1,703,690 (landed on `main` as `81f8498`), which + // superseded objectui#7122's `34a1578ef` reading of 1,235,029. + 'vendor-objectstack': 1_420_958, // `755d34a5f` (objectui#7479) — the SAME console build as // BASELINE above, so the two are directly comparable, and the same instrument // and container as the control build it is subtracted from. It supersedes diff --git a/scripts/check-installed-spec-pin-claims.mjs b/scripts/check-installed-spec-pin-claims.mjs index 90ba27f32c..ea9e11c178 100644 --- a/scripts/check-installed-spec-pin-claims.mjs +++ b/scripts/check-installed-spec-pin-claims.mjs @@ -470,6 +470,14 @@ export const LEDGER = [ class: "stale", why: "\"MEASURED on the installed @objectstack/spec 17.3.0 artifact\" \u2014 re-parse `record:activity.types` against the resolved artifact before moving the stamp.", }, + { + file: "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", + package: "@objectstack/spec", + version: "17.5.0", + sites: 4, + class: "historical", + why: "Four lines about the release, not the install. Two ask whether the installed spec carries \"the 17.5.0 set\" / \"the loaded 17.5.0 blocks\" (the version names a record SET, as the 17.1.0 entry above does). Two are `pins` prose whose `pinned` is an unrelated verb (\"pinned narrowly\", \"pinned as THE RESIDUAL LEG\") beside a WHEN sentence (`allDayField` \"since `@objectstack/spec` 17.5.0\"; `redactFields` \"until `@objectstack/spec` 17.5.0 declared it\"). None restamps (objectui#11438).", + }, { file: "apps/console/src/components/FormPage.tsx", package: "@objectstack/spec", @@ -574,6 +582,14 @@ export const LEDGER = [ class: "historical", why: "Same line, the other version: a verbatim quotation of the justification this code REPLACED \u2014 \"`I18nLabel` was plain `string` through 17.0.0-rc.5\". Quoting a retired justification is history.", }, + { + file: "packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx", + package: "@objectstack/spec", + version: "17.5.0", + sites: 1, + class: "historical", + why: "\"the nine the installed spec applied; `@objectstack/spec` 17.5.0 began applying `screen.mode`'s\" says WHEN the tenth declaration returned (objectui#11073). The installed-spec reading on the same line has no version. Never restamp (objectui#11438).", + }, { file: "packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx", package: "@objectstack/spec", @@ -750,6 +766,22 @@ export const LEDGER = [ class: "historical", why: "\"`publicPicker` arrives in @objectstack/spec 17.0.0 GA\" \u2014 the arrival release. \u26a0\ufe0f The stale claim in the SAME docblock (\"this repo is pinned to `^17.0.0-rc.6`\") is a RANGE and therefore outside this gate's predicate by construction; it is recorded in objectui#8924 rather than silently covered here.", }, + { + file: "packages/plugin-grid/src/__tests__/gridNavigationMembers-8071.test.tsx", + package: "@objectstack/spec", + version: "17.5.0", + sites: 1, + class: "historical", + why: "\"the row that pinned this**: `@objectstack/spec` 17.5.0 retired `view.list.navigation.view`\" names the release that DID the retirement (objectui#9874); `pinned` is the row's verb, not a claim about the install. Never restamp (objectui#11438).", + }, + { + file: "packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts", + package: "@objectstack/spec", + version: "17.5.0", + sites: 1, + class: "historical", + why: "\"Re-measured on the installed 17.5.0:\" sits under the dated heading \"UPDATE at `@objectstack/spec` 17.5.0 (objectui#11073)\" and records that bump's re-measurement; the rows below it re-derive the readings from the installed spec at run time. Never restamp (objectui#11438).", + }, { file: "packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts", package: "@objectstack/spec", diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 7ba715ce6f..1e7223641b 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -476,6 +476,19 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); // // Key format: ":". const ALLOW = { + "@object-ui/components:EmptyState": { + reason: + "Two LAYERS under one name, measured when `@objectstack/spec` 17.6.0 began exporting " + + "`EmptyState` from `@objectstack/spec/ui` (objectui#11438). The spec's type is a list " + + "view's AUTHORED empty-state config (`z.input` of `EmptyStateSchema`: title, message, icon); " + + "this one is a React COMPONENT, the published alias `DataEmptyState as EmptyState` in " + + "`view-states.tsx`, which draws an empty state from host props (title, description, icon, " + + "illustration, action). A component is not a config shape, so neither import nor derivation fits. " + + "It is a PUBLISHED export of the package, so retiring or renaming the alias changes a public " + + "name and belongs to its own card, not to the dependency bump that surfaced the collision " + + "(the same disposition objectui#11073 gave the four `Object*Props` collisions below).", + issue: 11438, + }, "@object-ui/plugin-gantt:ObjectGanttProps": { reason: "Two LAYERS under one name, measured when `@objectstack/spec` 17.5.0 began exporting " +