diff --git a/.changeset/8772-objectstack-17-4-0-line.md b/.changeset/8772-objectstack-17-4-0-line.md new file mode 100644 index 0000000000..fa86c9753a --- /dev/null +++ b/.changeset/8772-objectstack-17-4-0-line.md @@ -0,0 +1,43 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-dashboard': minor +'@object-ui/plugin-gantt': minor +'@object-ui/plugin-kanban': minor +'@object-ui/components': minor +'@object-ui/app-shell': minor +'@object-ui/console': minor +--- + +Take the `@objectstack/*` line to 17.4.0 and follow every contract it moved +(objectui#8772, with #7783 · #7663 · #8172 · #7845 · #8785). + +**Breaking on one authored key, deliberately.** A dashboard node's +`refreshInterval` is now `refreshIntervalSeconds` — the value is unchanged +(seconds), and the spec refuses the old spelling by name with a message naming +the new one, so an existing document fails loudly at parse rather than silently +losing its auto-refresh. `os migrate meta --from 17` lists the mechanical edits. +The report component's own `refreshInterval` is a different key and is NOT +affected. (Scored `minor`, not `major`: this repo's fixed group tracks the +`@objectstack` major — AGENTS.md §版本号策略.) + +Also authored-surface changes, all following a published spec move rather than a +local decision: + +- `element:record_picker`'s `filter` input is now the `ViewFilterRule` array + form `[{ field, operator, value }, …]`. The MongoDB-style record form is + refused by the contract (objectstack#14406 converged the last record-form + `filter` in the map), so a JSX page writing the array form no longer draws a + false `type-mismatch` and one writing the record form is told. +- `object-kanban` now publishes `limit`, the row cap its renderer has always + lowered to `$top`. The spec declares it as of objectstack#16503, so the key + the docs teach is finally one the save gate stores. +- `object-gantt`'s ten extension keys (`timeSegments`, `interactions`, + `lockField`, …) are derived from the spec's `GanttConfigSchema` instead of + being re-declared locally. Same accept set on the flat face; the nested + `gantt` block narrows to the spec's, which now refuses an undeclared sub-key + by name where its `.passthrough()` window used to admit one. +- The console's preview-gallery samples — the worked examples an author copies — + move with two spec changes of their own: a job's `timeout` is now `timeoutMs` + (same unit-in-the-key-name ruling as the dashboard key above), and a flow's + end node writes `outcome: 'completed'`, the enum having narrowed to + `completed | refused`. diff --git a/.changeset/dashboard-declare-honoured-inputs.md b/.changeset/dashboard-declare-honoured-inputs.md index c0129d95e5..ed25c5a84b 100644 --- a/.changeset/dashboard-declare-honoured-inputs.md +++ b/.changeset/dashboard-declare-honoured-inputs.md @@ -2,4 +2,4 @@ '@object-ui/plugin-dashboard': minor --- -`dashboard` now publishes the authoring inputs its renderer already honours — `widgets`, `label`, `description`, `header`, `globalFilters`, `dateRange`, `refreshInterval` — so `validateTree`, the generated `sdui.manifest.json` and `sdui-intrinsics.d.ts` stop warning authors off keys that work (previously only `columns`/`gap`/`className` were published, and every other honoured key drew `unknown-prop`). Each declared key is accepted by the spec's strict `DashboardSchema`, so the manifest never offers a key the save gate refuses. The legacy `title` spelling and the retired `aria` key stay deliberately unpublished and are pinned as such; the `schema.title || schema.label` fallback read is unchanged, so documents in the wild keep rendering their header title. +`dashboard` now publishes the authoring inputs its renderer already honours — `widgets`, `label`, `description`, `header`, `globalFilters`, `dateRange`, `refreshIntervalSeconds` — so `validateTree`, the generated `sdui.manifest.json` and `sdui-intrinsics.d.ts` stop warning authors off keys that work (previously only `columns`/`gap`/`className` were published, and every other honoured key drew `unknown-prop`). Each declared key is accepted by the spec's strict `DashboardSchema`, so the manifest never offers a key the save gate refuses. The legacy `title` spelling and the retired `aria` key stay deliberately unpublished and are pinned as such; the `schema.title || schema.label` fallback read is unchanged, so documents in the wild keep rendering their header title. 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 92e91dd127..d82d6a528a 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2163,7 +2163,7 @@ const MEMBER_PINS: Record = { }, 'element:record_picker.filter': { file: 'packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts', - pins: 'The `object` arm is `FilterConditionSchema` — field keys plus `$and`/`$or`/`$not` — with the rule-ARRAY spelling every sibling `filter` uses rejected outright, and the description pinned to the renderer\'s own precedence read (`composed?.filter ?? props.filter`), objectui#3830.', + pins: 'The `array` arm is `ViewFilterRuleSchema[]` — the one filter orthography this map\'s array-declared `filter` doors share — with the MongoDB-style RECORD spelling (`{ status }`, `{ $and }`) refused by kind at `filter` and a bare tuple refused at `filter.0`, and the description pinned to the renderer\'s own precedence read (`composed?.filter ?? props.filter`). objectui#3830 declared the key; objectstack#14406 converged it off the record form and objectui#7663 moved this side onto the array arm.', }, 'element:record_picker.label': { file: 'packages/components/src/renderers/basic/__tests__/record-picker-label-placeholder-i18n.test.tsx', diff --git a/apps/console/src/preview-samples.ts b/apps/console/src/preview-samples.ts index 18c6e3c34c..eb06a5d371 100644 --- a/apps/console/src/preview-samples.ts +++ b/apps/console/src/preview-samples.ts @@ -251,7 +251,12 @@ export const SAMPLES: Record> = { { id: 'review', type: 'screen', label: 'CSM review', config: { fields: [ { name: 'discount', label: 'Discount %', type: 'number', required: false }, { name: 'note', label: 'Note', type: 'text', required: true, visibleWhen: 'discount > 0' } ] } }, { id: 'notify', type: 'script', label: 'Email the owner', config: { actionType: 'email', template: 'renewal_reminder', recipients: ['owner.email', 'csm@example.com'], variables: { contractId: '{contractId}' } } }, { id: 'enrich', type: 'script', label: 'Score (code)', config: { script: "variables.score = 42;\nreturn variables;", outputVariables: ['score'] } }, - { id: 'end', type: 'end', label: 'End', config: { outcome: 'success' } }, + // `completed`, not `success`: @objectstack/spec 17.4.0 narrowed the end + // node's outcome enum to `completed | refused` (objectui#8785). `refused` + // is not interchangeable — the spec additionally REQUIRES a `message` + // beside it, because a refusal with no text is the shape that contract + // exists to replace. This sample ends successfully, so it is `completed`. + { id: 'end', type: 'end', label: 'End', config: { outcome: 'completed' } }, ], // `FlowEdgeSchema.id` is REQUIRED — the canvas already keys off it when // present (splitting an edge, adding a back-edge), and an id-less edge @@ -315,10 +320,15 @@ export const SAMPLES: Record> = { schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'UTC' }, handler: 'syncOrders', retryPolicy: { maxRetries: 3 }, - // `timeout`, not `timeoutMs` — the unit is already milliseconds. There is - // no `concurrency` key on a job; `JobSchema` is `.strict()` since spec - // 17.0.0, so both spellings are now rejected by name instead of dropped. - timeout: 600000, + // `timeoutMs`, not `timeout` — @objectstack/spec 17.4.0 RETIRED the bare + // spelling and put the unit in the key name, the same ruling that renamed + // `dashboard.refreshInterval` (ruling B on objectstack#14478): the sibling + // `retryPolicy.backoffMs` already spelled its own unit, so one surface was + // teaching two conventions. The VALUE is unchanged — still milliseconds. + // There is no `concurrency` key on a job either; `JobSchema` is `.strict()` + // since spec 17.0.0, so both retired spellings are rejected by name rather + // than dropped (objectui#8785). + timeoutMs: 600000, }, agent: { diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index fbd5518ddb..c1fa9d7ec6 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -997,7 +997,7 @@ A widget-based dashboard with configurable grid layout and auto-refresh. "type": "dashboard", "columns": 4, "gap": 6, - "refreshInterval": 30000, + "refreshIntervalSeconds": 30, "widgets": [ { "id": "revenue", @@ -1050,7 +1050,7 @@ A widget-based dashboard with configurable grid layout and auto-refresh. | `columns` | `number` | Number of grid columns. | | `gap` | `number` | Gap between widgets (Tailwind spacing scale). | | `widgets` | `DashboardWidgetSchema[]` | **Required.** Widget definitions with `id`, `title`, `colSpan`, `rowSpan`, and `body`. | -| `refreshInterval` | `number` | Auto-refresh interval in milliseconds. | +| `refreshIntervalSeconds` | `number` | Auto-refresh interval in **seconds** — the renderer multiplies by 1000. Renamed from `refreshInterval`, which this table documented as milliseconds and which it never was (objectui#7783). | Each widget supports `colSpan` and `rowSpan` to control its size in the grid. The `body` can be any `SchemaNode`. diff --git a/content/docs/plugins/plugin-dashboard.mdx b/content/docs/plugins/plugin-dashboard.mdx index 323ccf3654..799d0c5117 100644 --- a/content/docs/plugins/plugin-dashboard.mdx +++ b/content/docs/plugins/plugin-dashboard.mdx @@ -51,7 +51,7 @@ npm install @object-ui/plugin-dashboard defaultRange?: string, // a spec date preset, or 'custom' allowCustomRange?: boolean }, - refreshInterval?: number, // Auto-refresh period in seconds; runs only when the host wires onRefresh + refreshIntervalSeconds?: number, // Auto-refresh period in seconds; runs only when the host wires onRefresh columns?: number, // Grid columns (default: 3) gap?: number, // Gap between widgets className?: string diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index c5329f0768..e44f073414 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -166,8 +166,9 @@ const board: ObjectKanbanSchema = { } ``` -The `dataSource` binding sets it too — its own `limit`, or the -`pagination.pageSize` of the view it names (see +The `dataSource` binding sets it too, and the two do not rank the same way: the +binding's OWN `limit` wins outright, while the `pagination.pageSize` of a view it +names fills the cap only when the board leaves `limit` unset (see [Data source](/docs/guide/data-source)). diff --git a/e2e/live/ci/backend.env b/e2e/live/ci/backend.env index 9646375756..0ebb0bced9 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.3.0 +OBJECTSTACK_VERSION=17.4.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/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts b/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts index 67e017efd3..08ad6018f0 100644 --- a/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts +++ b/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts @@ -268,23 +268,26 @@ describe('objectui#6041 · saveFields PUTs the relationship target as `reference * to be a declared divergence. * * ⚠️ Read the instrument note before adding a spec assertion here. This repo's - * pin is `@objectstack/spec` **17.3.0** (`pnpm-lock.yaml`; the 17.2.0 this note - * used to name was superseded by objectui#7122). At 17.3.0 the #13632 - * refinement refuses an absent and an EMPTY `reference` but spells its - * emptiness test as an equality against `''`, so `' '` still parses green. - * An assertion of the shape "the spec accepts `' '`" therefore measures the - * VERSION SKEW below, not a standing opinion of this repo's. + * pin is `@objectstack/spec` **17.4.0** (`pnpm-lock.yaml`; 17.3.0 before + * objectui#8772, 17.2.0 before objectui#7122). * - * The divergence itself is retired (objectui#8621): objectstack#16920 applies - * that emptiness test to the trimmed value, so upstream now refuses the same - * shape under the same `custom` issue at the same `reference` path, and this - * writer's predicate mirrors the contract instead of exceeding it. The fix is - * an unreleased `minor` at the time of writing, which is why the spec half - * below still reads `success = true`: it is pinned against the INSTALLED spec, - * and it is the tripwire that reddens when the pin reaches the fix. ⛔ The - * writer's refusal is asserted separately and does not depend on the installed - * spec at all — that separation is what makes the bump a one-line edit here and - * no behaviour change at all in `MetadataService.ts`. + * ⭐ THE SKEW IS CLOSED, AND THE TRIPWIRE THAT SAID SO HAS FIRED. Until 17.3.0 + * the #13632 refinement refused an absent and an EMPTY `reference` but spelled + * its emptiness test as an equality against `''`, so `' '` parsed green while + * this writer refused it — a real divergence, declared rather than hidden. + * objectstack#16920 (reported as objectstack#16126, retired here under + * objectui#8621) applies that test to the TRIMMED value, and it shipped in + * 17.4.0. Measured at this pin: `' '`, `''` and an absent target are all + * refused upstream under the same `custom` issue at the same `reference` path + * the writer names, so the writer's predicate now MIRRORS the contract rather + * than exceeding it. + * + * The row below was written as a tripwire against exactly this event and said + * what to do when it fired — "the reading becomes `false` and nothing else here + * moves" — which is what happened: the spec half flipped, and `MetadataService.ts` + * did not change at all. ⛔ The writer's refusal is still asserted separately and + * still does not depend on the installed spec; that separation is what kept this + * a prose-and-one-verdict edit. */ describe('objectui#7714 · the target states, and the retired divergence', () => { const puttable = async (reference: unknown) => { @@ -327,33 +330,45 @@ describe('objectui#7714 · the target states, and the retired divergence', () => expect(savedFields(puts)[0].reference).toBe(' account '); }); - it('the VERSION SKEW is real at THIS pin: 17.3.0 accepts `\' \'`, this writer refuses it', async () => { + it('the VERSION SKEW is CLOSED at THIS pin: 17.4.0 refuses `\' \'` too, exactly as this writer does', async () => { // The writer half — true regardless of which spec is installed, and // unchanged by objectstack#16920. This is the row that must never move. const { refused } = await puttable(' '); expect(refused).toBe(true); - // The spec half, stated honestly for the INSTALLED 17.3.0, where #13632's - // emptiness test is an equality against `''`. Discriminating on its own at - // this pin — an absent target IS refused here (asserted below), so a green - // `' '` isolates blankness rather than reflecting a schema that accepts - // everything. ⚠️ This is a TRIPWIRE, not an endorsement: objectstack#16920 - // trims before the test, so this line reddens when the pin reaches it — at - // which point the reading becomes `false` and nothing else here moves. - expect(FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: ' ' }).success).toBe(true); - - // Falsifying control for the line above: the refinement IS attached and IS - // consulted at this pin — an absent target is refused, as `custom` at path - // `reference`. A passthrough schema would have made the green above - // meaningless. + // The spec half. This line read `.toBe(true)` while the pin was 17.3.0, + // where #13632's emptiness test was an equality against `''`; it was written + // as a TRIPWIRE for the day objectstack#16920 reached the pin, and + // objectui#8772 is that day. Asserted as an ENVELOPE rather than a bare + // `success === false`, because the two halves of the claim are separable: a + // blank target must be refused, and refused AS a blank target — the same + // `custom` issue at the same `reference` path the absent case draws, not an + // `invalid_type` or some unrelated failure that would satisfy a bare false. + const blank = FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: ' ' }); + expect(blank.success).toBe(false); + expect(blank.error!.issues.map((i) => i.code)).toContain('custom'); + expect(blank.error!.issues.map((i) => i.path.join('.'))).toContain('reference'); + + // Non-vacuity for the refusal above: the schema has NOT simply become + // hostile to every `reference`. A real name still parses green, and a name + // that merely carries surrounding whitespace still does too — the trim is + // for the TEST only, which is the same reading the writer's own + // pass-it-through row above depends on. + expect(FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: 'account' }).success).toBe(true); + expect(FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: ' account ' }).success).toBe(true); + + // The absent case, which the blank case above now matches exactly — the + // convergence stated as a measurement rather than asserted in prose. const absent = FieldSchema.safeParse({ type: 'lookup', label: 'L' }); expect(absent.success).toBe(false); expect(absent.error!.issues.map((i) => i.code)).toContain('custom'); - // Second control, at value level rather than presence level: a non-string - // is refused as `invalid_type` by the base schema before the refinement - // runs. Blankness was simply not among the things the 17.3.0 test covered — - // the gap objectstack#16126 reported and objectstack#16920 closed. + // Value level rather than presence level: a non-string is refused as + // `invalid_type` by the base schema before the refinement runs — a + // different issue code from the blank case above, which is what makes + // "refused as a blank target" a distinguishable verdict at all. Blankness + // was simply not among the things the 17.3.0 test covered — the gap + // objectstack#16126 reported and objectstack#16920 closed. const nonString = FieldSchema.safeParse({ type: 'lookup', label: 'L', reference: 42 }); expect(nonString.success).toBe(false); expect(nonString.error!.issues.map((i) => i.code)).toContain('invalid_type'); diff --git a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx index 1b1a5054e8..a0c50561f4 100644 --- a/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx +++ b/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx @@ -1781,7 +1781,7 @@ function FieldControl({ // "pin it to today's default" in the saved metadata. // // ⚠️ Scope of the second half at the current spec pin: `columns` / `gap` / - // `refreshInterval` carry NO `default` in `DashboardSchema` — "(default 12)" + // `refreshIntervalSeconds` carry NO `default` in `DashboardSchema` — "(default 12)" // exists only as prose in the description, and the 12 is applied by the // renderer. Those three boxes therefore stay empty until the spec declares // the defaults it documents; filed upstream rather than hard-coded here, diff --git a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts index 47d9b31ad8..0a344079e3 100644 --- a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts +++ b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.test.ts @@ -86,7 +86,7 @@ describe('localizeMetadataForm — the spec form, in the author’s language', ( 'zh-CN', ); expect(fieldByName(zh, 'columns')).toMatchObject({ label: '列数', helpText: '栅格列数(默认 12)' }); - expect(fieldByName(zh, 'refreshInterval')?.label).toBe('自动刷新'); + expect(fieldByName(zh, 'refreshIntervalSeconds')?.label).toBe('自动刷新'); expect(fieldByName(zh, 'header')?.label).toBe('页眉'); }); @@ -98,10 +98,30 @@ describe('localizeMetadataForm — the spec form, in the author’s language', ( ); const gap = fieldByName(zh, 'gap'); expect(gap?.label).toBe('间距'); - expect(gap?.helpText).not.toMatch(/Tailwind/i); - // The English source still says it — that copy is the spec's to fix, and - // this overlay deliberately does not rewrite the producer's own text. - expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(/Tailwind/i); + + // ⚠️ THE CONTROL IS NOT A SENTENCE PIN, and it used to be one. This asserted + // that the English producer copy still said "Tailwind" — true while the spec + // shipped `Grid gap (Tailwind units)`, and false from @objectstack/spec + // 17.4.0, which rewrote it to "Space between widgets, in steps of 0.25rem + // (4 = 1rem)" (objectui#8785). The producer was right to fix its own copy; + // the pin was wrong to name a sentence it does not own, and re-pointing it + // at the new sentence would buy exactly one release before rotting again. + // + // What this test actually claims is narrower and durable: the overlay hands + // the author text they can act on, where the producer's copy reaches for + // DEVELOPER vocabulary — a framework name, a CSS unit — to say the same + // thing. So the control asserts the English carries some of that vocabulary + // and the zh carries none of it. It stays non-vacuous (the English leg must + // fire for the zh leg to mean anything) without pinning any one wording, and + // if the spec ever writes this hint in plain author language the control + // reddens and someone re-reads the overlay — which is the correct outcome, + // not a false alarm: the overlay would have nothing left to do here. + // A framework name, or a CSS length written as an author never would — + // digits welded to a unit. ⚠️ `\brem\b` does NOT work here: the boundary + // before `rem` never matches in `0.25rem`, because `5` is a word character. + const DEVELOPER_UNITS = /tailwind|\d\s*(rem|px)\b/i; + expect(fieldByName(dashboardForm, 'gap')?.helpText).toMatch(DEVELOPER_UNITS); + expect(gap?.helpText).not.toMatch(DEVELOPER_UNITS); }); it('synthesizes ALL of the `header` composite’s sub-rows — a partial list would hide the rest', () => { diff --git a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts index bb8e5d1fe0..c5ebc39c82 100644 --- a/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/metadata-form-i18n.ts @@ -84,11 +84,13 @@ const METADATA_FORM_BUNDLE: TranslationBundle = { label: { label: '显示名称', helpText: '展示给使用者的名称' }, description: { label: '描述' }, columns: { label: '列数', helpText: '栅格列数(默认 12)' }, - // The spec's own hint here is `Grid gap (Tailwind units)` — a unit - // only a developer can act on. Reported upstream rather than - // "translated" literally; this says what the author can decide. + // The spec's own hint reaches for developer vocabulary to say this — + // `Grid gap (Tailwind units)` before 17.4.0, and "Space between + // widgets, in steps of 0.25rem (4 = 1rem)" since. Either way it names + // a unit only a developer can act on, so this row says what the author + // can decide rather than transliterating it (objectui#8785). gap: { label: '间距', helpText: '组件之间的间距,数值越大越松' }, - refreshInterval: { label: '自动刷新', helpText: '自动刷新间隔(秒),0 表示不自动刷新' }, + refreshIntervalSeconds: { label: '自动刷新', helpText: '自动刷新间隔(秒),0 表示不自动刷新' }, header: { label: '页眉', helpText: '页眉设置:标题、描述与操作按钮' }, // All three children of the `header` composite — see the ⛔ note above. 'header.showTitle': { label: '显示标题', helpText: '在页眉中显示仪表板名称' }, diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts index 40c4a29ed1..d00bf42516 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts @@ -179,14 +179,15 @@ const EXEMPT: Readonly> = { * would absorb a future spec-face violation on the same name. */ const LEDGER: ReadonlyArray<{ block: string; path: string; face: OracleFace; card: string; why: string }> = [ - { - block: 'object-kanban', - path: 'limit', - face: 'spec', - card: 'objectui#8172', - why: - 'The renderer honours it — ObjectKanban.tsx sends `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` — and both @object-ui/types faces declare it, but ComponentPropsMap[object-kanban] does not. The producer side is upstream, so removing the control would delete a working affordance to satisfy a schema that is behind it.', - }, + // `object-kanban::limit@spec` stood here until @objectstack/spec 17.4.0. It was + // the ledger working exactly as designed: the renderer honoured the key, both + // `@object-ui/types` faces declared it and the docs taught it, while + // `ComponentPropsMap['object-kanban']` refused it by name — so the row said + // "upstream owes this" instead of deleting a working affordance to satisfy a + // schema that was behind it. objectstack#16503 (landed as objectstack#16562) + // declared `limit` upstream, which is the maintainer's option-A ruling on + // objectui#8172, and the row went stale. Deleted rather than kept: a row that + // no longer describes a violation is as red here as a violation with no row. { block: 'object-form', path: 'formType', @@ -389,17 +390,27 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the ratchet (objectui#8216)', // being ledgered with no probe behind it. `page:tabs::items[].key` was the // second row until objectui#8278 renamed that control to `value`; its // measurement moved WITH it, to `page-tabs-item-value-8278.test.tsx`. - expect(LEDGER.filter((r) => r.face === 'spec').map(ledgerId)).toEqual([ - 'object-kanban::limit@spec', - ]); - + expect(LEDGER.filter((r) => r.face === 'spec').map(ledgerId)).toEqual([]); + + // ⚠️ The spec-face ledger is EMPTY, so the self-deleting probe list above is + // empty too — and an empty list of probes is exactly what a broken oracle + // lookup also produces. The live control below is what separates the two: it + // re-measures the row this test used to carry (`object-kanban::limit@spec`, + // objectui#8172) and asserts the verdict that RETIRED it, so "no spec-face + // violations" stays a reading rather than a silence. const kanban = SPEC_ORACLES['object-kanban'] as { safeParse: (v: unknown) => any }; - const withLimit = kanban.safeParse({ objectName: 'opportunity', groupBy: 'stage', limit: 50 }); - expect(withLimit.success).toBe(false); - expect(withLimit.error.issues.flatMap((i: any) => i.keys ?? [])).toContain('limit'); + const base = { objectName: 'opportunity', groupBy: 'stage' }; expect( - kanban.safeParse({ objectName: 'opportunity', groupBy: 'stage' }).success, - 'the base kanban must parse clean, or the refusal above proves nothing', + kanban.safeParse(base).success, + 'the base kanban must parse clean, or neither verdict below proves anything', ).toBe(true); + // The former violation, now declared upstream (objectstack#16503). + expect(kanban.safeParse({ ...base, limit: 50 }).success).toBe(true); + // The oracle still refuses an undeclared sibling BY NAME — without this the + // line above would be satisfied just as well by an oracle that stopped + // refusing anything at all. + const bogus = kanban.safeParse({ ...base, notAKanbanKey: 1 }); + expect(bogus.success).toBe(false); + expect(bogus.error.issues.flatMap((i: any) => i.keys ?? [])).toContain('notAKanbanKey'); }); }); diff --git a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts index 913bc41210..60f64db1fb 100644 --- a/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts +++ b/packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts @@ -69,6 +69,19 @@ const filterDescription = () => input('filter')?.description ?? ''; */ const withFilter = (filter: unknown) => ({ object: 'account', filter }); +/** + * The accepted `filter` value, in ONE place because five assertions read it. + * + * A `ViewFilterRule` list — what objectstack#14406 converged this key onto + * (objectui#7663), and the orthography every array-declared `filter` door in + * `ComponentPropsMap` shares. ⚠️ `operator` is spelled `equals` rather than the + * `eq` alias on purpose: the spec NORMALISES `eq` to `equals` on parse, so a + * fixture written with the alias would make the round-trip assertion below + * (`parsed.data?.filter` equals what went in) fail for a reason that has + * nothing to do with the key being reachable. + */ +const RULE_ARRAY = [{ field: 'status', operator: 'equals', value: 'open' }]; + /** * Does the installed spec REFUSE an undeclared top-level key, or drop it in * silence? (objectui#4910, measured on both pins.) @@ -114,9 +127,9 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // parse only reports that no undeclared key was present. Survival is the // claim on both pins. expect(specTopLevelKeys()).toContain('filter'); - const parsed = ElementRecordPickerPropsSchema.safeParse(withFilter({ status: 'open' })); + const parsed = ElementRecordPickerPropsSchema.safeParse(withFilter(RULE_ARRAY)); expect(parsed.success).toBe(true); - expect(parsed.data?.filter).toEqual({ status: 'open' }); + expect(parsed.data?.filter).toEqual(RULE_ARRAY); // The contrast that makes the criterion meaningful: the SAME payload plus a // key the spec does not declare. Two contract spellings, one verdict — the @@ -126,7 +139,7 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // to `notASpecKey` instead of to the declared key having gone bad, and the // green parse asserted just above is what proves the base is valid. const undeclared = ElementRecordPickerPropsSchema.safeParse({ - ...withFilter({ status: 'open' }), + ...withFilter(RULE_ARRAY), notASpecKey: 1, } as never); @@ -150,49 +163,85 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // retired. expect(undeclared.success).toBe(true); expect(Object.keys(undeclared.data ?? {})).not.toContain('notASpecKey'); - expect(undeclared.data?.filter).toEqual({ status: 'open' }); + expect(undeclared.data?.filter).toEqual(RULE_ARRAY); } expect(inputNames()).toContain('filter'); expect(filterDescription()).not.toBe(''); }); - it('declares `object` as the type the spec actually accepts, not `array`', () => { - // objectui#3830's landing sketch guessed `'array'` and flagged the guess as - // needing checking against the resolved pin. It is wrong, and this is why: - // `ElementRecordPickerProps.filter` is `FilterConditionSchema`, which is - // `z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))` — - // an OBJECT. A rule array (an ObjectQL AST, a view's `ViewFilterRule[]`) is - // rejected outright. - expect(ElementRecordPickerPropsSchema.safeParse(withFilter({ status: 'open' })).success).toBe(true); - expect(ElementRecordPickerPropsSchema.safeParse(withFilter({ $and: [{ a: 1 }] })).success).toBe(true); - expect(ElementRecordPickerPropsSchema.safeParse(withFilter([['a', '=', 1]])).success).toBe(false); + it('declares `array` as the type the spec actually accepts, not `object`', () => { + // ⚠️ This pin was the mirror image of itself until objectui#7663. + // objectui#3830's landing sketch guessed `'array'`, flagged the guess as + // needing checking against the resolved pin, and the check said `'object'`: + // `ElementRecordPickerProps.filter` was `FilterConditionSchema` + // (`z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))`), + // so the rule ARRAY was the shape rejected outright. + // + // objectstack#14406 CONVERGED the key onto `z.array(ViewFilterRuleSchema)` + // — the last record-form `filter` in `ComponentPropsMap`, under the + // maintainer's one-orthography ruling (objectui#6206-B, 2026-08-25). The + // sketch's guess is the answer now, and the verdicts below simply swap + // sides. ⛔ The refusals are asserted as an ENVELOPE (kind and path), not as + // a bare `success === false`, so a parse that fails for some unrelated + // reason cannot stand in for the contract refusing the record form. + const accepted = ElementRecordPickerPropsSchema.safeParse(withFilter(RULE_ARRAY)); + expect(accepted.success).toBe(true); + + // The RECORD form — every spelling of it — is now refused BY KIND, at the + // key itself. + for (const recordForm of [{ status: 'open' }, { $and: [{ a: 1 }] }]) { + const refused = ElementRecordPickerPropsSchema.safeParse(withFilter(recordForm)); + expect(refused.success, JSON.stringify(recordForm)).toBe(false); + expect(refused.error?.issues.map((i) => i.code)).toContain('invalid_type'); + expect(refused.error?.issues.map((i) => i.path.join('.'))).toContain('filter'); + } + + // Still refused, and for the same reason as before: neither is an array. expect(ElementRecordPickerPropsSchema.safeParse(withFilter('a = 1')).success).toBe(false); expect(ElementRecordPickerPropsSchema.safeParse(withFilter(42)).success).toBe(false); - expect(input('filter')?.type).toBe('object'); + // An array whose MEMBERS are not rule objects is refused too — at + // `filter.0`, not at `filter`. The coarse `'array'` declaration cannot say + // this, which is why the input's description spells the member shape out. + const tuples = ElementRecordPickerPropsSchema.safeParse(withFilter([['a', '=', 1]])); + expect(tuples.success).toBe(false); + expect(tuples.error?.issues.map((i) => i.path.join('.'))).toContain('filter.0'); + + expect(input('filter')?.type).toBe('array'); }); - it('the coarse `object` type costs nothing here — it accepts exactly what the spec accepts', () => { + it('the coarse `array` type agrees with the spec at the KIND boundary, and stops there', () => { // The `element:text_input.defaultValue` sibling declares TWO arms, because // the spec's type there is the union `string | number` (objectui#3832). This - // key is the case where one arm agrees with the contract exactly: - // `checkType`'s `'object'` arm in - // `sdui-parser/src/validate.ts` passes a non-null non-array object and warns - // `type-mismatch` on everything else — the same partition `safeParse` draws - // above. Asserted through the real validator, not by reading its source, so - // a future widening of either side shows up here as a disagreement. + // key still needs only one — `checkType`'s `'array'` arm in + // `sdui-parser/src/validate.ts` passes an array and warns `type-mismatch` on + // everything else, which is the same KIND partition `safeParse` draws above + // now that objectstack#14406 converged the key onto a rule array + // (objectui#7663). Asserted through the real validator, not by reading its + // source, so a future widening of either side shows up here as a + // disagreement. const manifest = manifestFromConfigs([ - { type: TYPE, namespace: 'element', inputs: [{ name: 'filter', type: 'object' }] }, + { type: TYPE, namespace: 'element', inputs: [{ name: 'filter', type: 'array' }] }, ]); const codesFor = (literal: string) => compile(`<${TYPE} filter={${literal}} />`, manifest).diagnostics.map((d) => d.code); - expect(codesFor('{"status":"open"}')).toEqual([]); - expect(codesFor('{"$and":[{"a":1}]}')).toEqual([]); - expect(codesFor('[["a","=",1]]')).toContain('type-mismatch'); + expect(codesFor('[{"field":"status","operator":"equals","value":"open"}]')).toEqual([]); + // The record form is now the refused kind on BOTH authorities — this is the + // assertion that flipped, and the reason the declaration had to move with + // the spec rather than stay `'object'` and disagree with it. + expect(codesFor('{"status":"open"}')).toContain('type-mismatch'); + expect(codesFor('{"$and":[{"a":1}]}')).toContain('type-mismatch'); expect(codesFor('42')).toContain('type-mismatch'); expect(codesFor('null')).toContain('type-mismatch'); + + // ⚠️ WHERE THE AGREEMENT ENDS, stated rather than left to be discovered. + // The coarse vocabulary has no member arm, so a tuple list passes the + // manifest check and is refused by the spec at `filter.0` (asserted above). + // That is a KNOWN gap in the declaration's resolution, not a disagreement to + // repair here: the description carries what the type cannot say. + expect(codesFor('[["a","=",1]]')).toEqual([]); }); it('the `filter` description says which of the two filters an author writes wins', () => { @@ -242,7 +291,7 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // unknown-prop for filter" would also be what a broken manifest, an // unregistered tag or a silent parse failure looks like. const r = compile( - `<${TYPE} object="account" filter={{"status":"open"}} searchFields={["name"]} />`, + `<${TYPE} object="account" filter={[{"field":"status","operator":"equals","value":"open"}]} searchFields={["name"]} />`, manifest, ); @@ -255,7 +304,7 @@ describe('element:record_picker — registry inputs vs @objectstack/spec', () => // And the key survives into the compiled tree as itself — the whole point of // publishing it is that the author's `filter` reaches the renderer, which // turns it into the picker query's `$filter`. - expect(r.tree).toMatchObject({ type: TYPE, filter: { status: 'open' } }); + expect(r.tree).toMatchObject({ type: TYPE, filter: RULE_ARRAY }); expect(r.diagnostics.some((d) => d.severity === 'error')).toBe(false); }); }); diff --git a/packages/components/src/renderers/basic/record-picker.tsx b/packages/components/src/renderers/basic/record-picker.tsx index c44925d5a0..d38d9e0369 100644 --- a/packages/components/src/renderers/basic/record-picker.tsx +++ b/packages/components/src/renderers/basic/record-picker.tsx @@ -345,27 +345,32 @@ ComponentRegistry.register('record_picker', elementDataSourceBlock(ElementRecord { name: 'object', type: 'string' }, { name: 'filter', - // `'object'` is the spec's shape, not a chosen arm. `filter` is - // `FilterConditionSchema.optional()`, and that schema is - // `z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))` - // — a plain object. `checkType`'s `'object'` case in - // `sdui-parser/src/validate.ts` accepts exactly what the spec accepts - // here (a non-null non-array object) and rejects exactly what it rejects - // (arrays, strings, numbers, booleans — all verified against - // `ElementRecordPickerPropsSchema.safeParse` in the parity test next to - // this file). So this is the one case in the family where the coarse - // vocabulary lines up with the contract exactly as declared: one arm, no - // union to spell and nothing for the description to make up for. Contrast - // `element:text_input.defaultValue`, whose `string | number` needs two - // arms (objectui#3832), and `emptyText` below, which declares two for the - // same reason once its render site learned to resolve both + // `'array'` is the spec's shape, not a chosen arm. objectstack#14406 + // CONVERGED this key onto `z.array(ViewFilterRuleSchema)` — the last + // record-form `filter` in `ComponentPropsMap`, closing the maintainer's + // 2026-08-25 ruling that the platform carries one filter orthography + // (objectui#6206-B). It was `FilterConditionSchema` (the MongoDB-style + // `z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not }))`) + // until then, and this entry said `'object'` for exactly that reason. + // `checkType`'s `'array'` case in `sdui-parser/src/validate.ts` accepts + // what the spec now accepts here and rejects what it rejects — the record + // form included, which the spec refuses by KIND. Both halves are verified + // against `ElementRecordPickerPropsSchema.safeParse` in the parity test + // next to this file. So this stays the case in the family where the + // coarse vocabulary lines up with the contract exactly as declared: one + // arm, no union to spell. ⚠️ The array is a rule-OBJECT list, not a tuple + // list: `[['a', '=', 1]]` is refused at `filter.0`, and the coarse + // `'array'` cannot say so — which is what the description below is for. + // Contrast `element:text_input.defaultValue`, whose `string | number` + // needs two arms (objectui#3832), and `emptyText` below, which declares + // two for the same reason once its render site learned to resolve both // (objectui#5590). - type: 'object', + type: 'array', // Taken from what the renderer DOES with the key, because the one thing // an author cannot read off the spec is which of the two places they may // write a filter actually wins. description: - 'Filter criteria narrowing which records the picker offers, as a spec FilterCondition object — `{ status: "open" }`, or `{ $and: [ … ] }` for a group. It becomes the `$filter` of the picker\'s own query, so it decides which records exist for the user, not merely how they are shown. PRECEDENCE: a node-level `dataSource` binding wins outright. The renderer reads `dataSource.filter ?? filter`, so when the binding — or the saved view its `view` names, which AND-combine with each other because the spec calls the binding\'s filter *additional* — supplies a filter, THIS key is dropped entirely rather than merged into it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `filter` unset. A rule ARRAY (an ObjectQL AST, or a view\'s rule list) is not a FilterCondition and the spec rejects it here.', + 'Filter rules narrowing which records the picker offers, as a ViewFilterRule ARRAY — `[{ field: "status", operator: "equals", value: "open" }, …]`, the one filter orthography this map\'s array-declared `filter` doors share. It becomes the `$filter` of the picker\'s own query, so it decides which records exist for the user, not merely how they are shown. PRECEDENCE: a node-level `dataSource` binding wins outright. The renderer reads `dataSource.filter ?? filter`, so when the binding — or the saved view its `view` names, which AND-combine with each other because the spec calls the binding\'s filter *additional* — supplies a filter, THIS key is dropped entirely rather than merged into it; it applies only when the node carries no `dataSource`, or that `dataSource` and its view both leave `filter` unset. MEMBERS ARE RULE OBJECTS: the MongoDB-style record form (`{ status: "open" }`, `{ $and: [ … ] }`) was retired by objectstack#14406 and the spec now refuses it by kind, and a bare tuple (`["a", "=", 1]`) is refused as a member because each entry must be an object.', }, { name: 'labelField', type: 'string' }, { name: 'valueField', type: 'string' }, diff --git a/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts b/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts index 7d20898219..0c4df376e5 100644 --- a/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts +++ b/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts @@ -14,14 +14,23 @@ * cloud AI packages register a tool. * * The registry (`@objectstack/spec`'s `PLATFORM_TOOLS_BY_PACKAGE`) is the - * contract, so it is what this suite reads. But the pinned spec LAGS the cloud - * runtime — the five names in {@link AHEAD_OF_PIN} are registered by - * `service-ai-studio` today and are not in the snapshot — and a step label the - * user reads must not wait on a pin bump. So the coverage set is - * `registry ∪ AHEAD_OF_PIN`, and a member of `AHEAD_OF_PIN` that the registry - * has caught up with must be REMOVED from that list, which the last case here - * enforces. That keeps the hand-held half shrinking rather than growing into a - * second, permanent registry. + * contract, so it is what this suite reads. The pinned spec could LAG the cloud + * runtime, and a step label the user reads must not wait on a pin bump, so the + * coverage set is `registry ∪ AHEAD_OF_PIN` — a hand-held escape hatch for names + * the snapshot has not listed yet. A member the registry has caught up with must + * be REMOVED from that list, which the last case here enforces, so the hand-held + * half shrinks rather than growing into a second, permanent registry. + * + * ⭐ IT HAS SHRUNK TO NOTHING, AND THAT IS THE MECHANISM WORKING. The five names + * this file was written around — `get_authoring_rules` (cloud#1837), + * `load_tools`, `open_record`, `test_flow`, `toggle_flow` — are all in + * `PLATFORM_TOOLS_BY_PACKAGE` as of `@objectstack/spec` 17.4.0, so + * {@link AHEAD_OF_PIN} is empty and coverage is the registry alone + * (objectui#8772 took the pin there). ⛔ The list is kept, not deleted: the next + * tool the cloud registers ahead of a snapshot needs the same hatch, and + * rebuilding it under pressure is how a permanent second registry starts. + * {@link GRADUATED} keeps this file's guards honest while the list is empty — + * see its note. * * The English pin is the other half. The packs' own comment states the rule — * "the `en` values are deliberately EQUAL to what that title-caser produces: @@ -43,11 +52,25 @@ const REGISTRY = ( /** * Tools the cloud AI runtime registers that the PINNED spec snapshot does not - * list yet. Sourced from cloud `service-ai-studio`'s `plugin.ts` tool - * definitions (`AUTHORING_RULE_`, `LOAD_TOOLS_`, `OPEN_RECORD_`, `FLOW_`). - * Shrink this list on a pin bump — never grow it as a habit. + * list yet — sourced from cloud `service-ai-studio`'s `plugin.ts` tool + * definitions. Shrink this list on a pin bump — never grow it as a habit. + * + * EMPTY at this pin: every name it carried has graduated (see {@link GRADUATED}). */ -const AHEAD_OF_PIN = ['get_authoring_rules', 'load_tools', 'open_record', 'test_flow', 'toggle_flow'] as const; +const AHEAD_OF_PIN: readonly string[] = []; + +/** + * The names that HAVE graduated from {@link AHEAD_OF_PIN} into the registry. + * + * ⛔ Not a record for its own sake — it is the live instrument this file needs + * while `AHEAD_OF_PIN` is empty. Every guard below was anchored on that list + * being non-empty, so with it emptied they would read green against a + * `PLATFORM_TOOLS_BY_PACKAGE` that had vanished, gone empty, or lost these very + * names: `[].filter(…)` is `[]` and `arrayContaining([])` passes on anything. + * Asserting these five ARE in the registry is the reading that cannot be + * satisfied by an absent export, and it is the same fact the emptying rests on. + */ +const GRADUATED = ['get_authoring_rules', 'load_tools', 'open_record', 'test_flow', 'toggle_flow'] as const; /** Every tool name that must carry a step label in every pack. */ const COVERED: string[] = [ @@ -73,9 +96,13 @@ describe('chatbot.tool.* covers every tool the runtime can show (objectui#7481)' it('reads a non-empty coverage set', () => { // Guards against the whole suite going vacuously green if the spec export - // disappears AND the hand-held list is emptied. - expect(COVERED.length).toBeGreaterThanOrEqual(AHEAD_OF_PIN.length); - expect(COVERED).toEqual(expect.arrayContaining([...AHEAD_OF_PIN])); + // disappears. With `AHEAD_OF_PIN` empty the coverage set is the REGISTRY + // alone, so the floor is read off the registry rather than off the + // hand-held list — the two `AHEAD_OF_PIN` assertions that used to stand here + // are both tautologies on an empty list. + expect(REGISTRY, 'PLATFORM_TOOLS_BY_PACKAGE did not resolve').toBeTruthy(); + expect(COVERED.length).toBeGreaterThan(GRADUATED.length); + expect(COVERED).toEqual(expect.arrayContaining([...GRADUATED, ...AHEAD_OF_PIN])); }); it.each(LANGS)('%s defines a non-empty label for every covered tool', (lang) => { @@ -96,12 +123,19 @@ describe('chatbot.tool.* covers every tool the runtime can show (objectui#7481)' }); it('AHEAD_OF_PIN carries only names the pinned registry still lacks', () => { - // The moment `.objectstack-sha` advances past a tool's registry entry, the - // hand-held list must lose it — otherwise this file becomes a second - // registry that nobody keeps in sync. + // The moment the pin advances past a tool's registry entry, the hand-held + // list must lose it — otherwise this file becomes a second registry that + // nobody keeps in sync. This is the case that fired on the 17.4.0 bump and + // named all five; they were removed rather than the assertion relaxed. const inRegistry = new Set(Object.values(REGISTRY ?? {}).flat()); const caughtUp = AHEAD_OF_PIN.filter((name) => inRegistry.has(name)); expect(caughtUp, 'remove these from AHEAD_OF_PIN — the pinned spec now lists them').toEqual([]); + + // ⚠️ The line above is a tautology while the list is empty, so it is not + // left standing alone: the graduation it records is asserted directly. If + // the registry ever stops listing one of these, THIS is what reddens — + // and the repair would be to put that name back on `AHEAD_OF_PIN`. + expect(GRADUATED.filter((name) => !inRegistry.has(name))).toEqual([]); }); }); diff --git a/packages/plugin-dashboard/README.md b/packages/plugin-dashboard/README.md index fab0a7bc6f..5148d00a88 100644 --- a/packages/plugin-dashboard/README.md +++ b/packages/plugin-dashboard/README.md @@ -458,7 +458,7 @@ The authored shape is typed by `@object-ui/types`: | Import from `@object-ui/types` | What it types | | --- | --- | -| `DashboardComponentSchema` | the whole `type: 'dashboard'` node — `columns`, `gap`, `widgets`, `header`, `globalFilters`, `dateRange`, `refreshInterval`, … | +| `DashboardComponentSchema` | the whole `type: 'dashboard'` node — `columns`, `gap`, `widgets`, `header`, `globalFilters`, `dateRange`, `refreshIntervalSeconds`, … | | `DashboardWidgetSchema` | one entry of `widgets[]` — the spec's `DashboardWidget` keys, plus objectui's own (`component`, `layout`, `options`, …) | | `DashboardWidgetSlotComponentSchema` | the other kind of `widgets[]` entry — a component node placed directly in the slot, `type` one of the closed component set (`metric-card`); every other key is that component's own prop | | `DashboardWidgetLayout` | a widget's `{ x, y, w, h }` grid box | diff --git a/packages/plugin-dashboard/src/DashboardConfigPanel.tsx b/packages/plugin-dashboard/src/DashboardConfigPanel.tsx index 8cb0946ccc..1b629d0ffa 100644 --- a/packages/plugin-dashboard/src/DashboardConfigPanel.tsx +++ b/packages/plugin-dashboard/src/DashboardConfigPanel.tsx @@ -105,7 +105,7 @@ export function buildDashboardSchema(t: ConfigPanelTranslate): ConfigPanelSchema collapsible: true, fields: [ { - key: 'refreshInterval', + key: 'refreshIntervalSeconds', label: t('dashboard.config.field.autoRefresh'), type: 'select', defaultValue: '0', diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx index c69fd67dcc..7276f44e5e 100644 --- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx +++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx @@ -142,14 +142,16 @@ export const DashboardGridLayout: React.FC = ({ setTimeout(() => setRefreshing(false), 600); }, [onRefresh]); - // Auto-refresh interval + // Auto-refresh interval — seconds → milliseconds, as the key now says + // (objectui#7783; the spec renamed `refreshInterval` to + // `refreshIntervalSeconds`, value unchanged). React.useEffect(() => { - if (!schema.refreshInterval || schema.refreshInterval <= 0 || !onRefresh) return; - intervalRef.current = setInterval(handleRefresh, schema.refreshInterval * 1000); + if (!schema.refreshIntervalSeconds || schema.refreshIntervalSeconds <= 0 || !onRefresh) return; + intervalRef.current = setInterval(handleRefresh, schema.refreshIntervalSeconds * 1000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [schema.refreshInterval, onRefresh, handleRefresh]); + }, [schema.refreshIntervalSeconds, onRefresh, handleRefresh]); const [layouts, setLayouts] = React.useState<{ lg: RGLLayout[] }>( () => buildDefaultLayouts(schema), ); diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx index d42a37a445..14fa791317 100644 --- a/packages/plugin-dashboard/src/DashboardRenderer.tsx +++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx @@ -445,14 +445,19 @@ const DashboardRendererInner = forwardRef setRefreshing(false), 600); }, [onRefresh]); - // Auto-refresh interval + // Auto-refresh interval. The `* 1000` is seconds → milliseconds, and the + // key now says so itself: @objectstack/spec 17.4.0 renamed + // `refreshInterval` to `refreshIntervalSeconds` precisely because a reader + // multiplying by 1000 was the tell that the unit lived out of band + // (objectstack#15680, objectui#7783). The arithmetic is unchanged — the + // value is still seconds. useEffect(() => { - if (!schema.refreshInterval || schema.refreshInterval <= 0 || !onRefresh) return; - intervalRef.current = setInterval(handleRefresh, schema.refreshInterval * 1000); + if (!schema.refreshIntervalSeconds || schema.refreshIntervalSeconds <= 0 || !onRefresh) return; + intervalRef.current = setInterval(handleRefresh, schema.refreshIntervalSeconds * 1000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [schema.refreshInterval, onRefresh, handleRefresh]); + }, [schema.refreshIntervalSeconds, onRefresh, handleRefresh]); const handleWidgetClick = useCallback((e: React.MouseEvent, widgetId: string | undefined) => { if (!designMode || !onWidgetClick || !widgetId) return; diff --git a/packages/plugin-dashboard/src/__tests__/ConfigPanel.i18nWiring.test.tsx b/packages/plugin-dashboard/src/__tests__/ConfigPanel.i18nWiring.test.tsx index 561f150293..d1141c0749 100644 --- a/packages/plugin-dashboard/src/__tests__/ConfigPanel.i18nWiring.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/ConfigPanel.i18nWiring.test.tsx @@ -239,7 +239,7 @@ describe('leg 2 — each key sits where it belongs', () => { collapsible: true, fields: [ expect.objectContaining({ - key: 'refreshInterval', + key: 'refreshIntervalSeconds', label: 'dashboard.config.field.autoRefresh', options: [ { value: '0', label: 'dashboard.config.refresh.off' }, diff --git a/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx b/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx index bd77df2dcb..97a0c8058e 100644 --- a/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx @@ -13,7 +13,8 @@ * The `dashboard` registration published exactly three inputs (`columns`, * `gap`, `className`) while `DashboardRenderer` honoured `widgets`, `label` / * legacy `title`, `description`, `header`, `globalFilters`, `dateRange` and - * `refreshInterval`. `inputs` is not documentation: it is the published + * `refreshIntervalSeconds` (spelled `refreshInterval` until @objectstack/spec + * 17.4.0 renamed it — objectui#7783). `inputs` is not documentation: it is the published * authoring surface (`gen-manifest.ts` serializes it into * `sdui.manifest.json` — the save gate and parser whitelist — and into * `sdui-intrinsics.d.ts`, and `dashboard` is in `PUBLIC_BLOCKS`). So @@ -156,7 +157,7 @@ const DECLARED: Array<[string, unknown]> = [ ['header', { showTitle: false, actions: [{ label: 'Open', actionUrl: '/x' }] }], ['globalFilters', [{ field: 'region', label: 'Region', type: 'select' }]], ['dateRange', { field: 'created_at', defaultRange: 'last_30_days' }], - ['refreshInterval', 30], + ['refreshIntervalSeconds', 30], ]; /** Values matching NO declared arm — each must still be reported. */ @@ -167,7 +168,7 @@ const OFF_ARM: Array<[string, unknown]> = [ ['header', true], ['globalFilters', 'region'], ['dateRange', []], - ['refreshInterval', '30'], + ['refreshIntervalSeconds', '30'], ]; /** The two keys ruled OUT, with the evidence a reader can re-check. */ @@ -218,7 +219,7 @@ describe('the honoured keys now validate clean on an inline dashboard node (obje header: { showTitle: true }, columns: 4, gap: 6, - refreshInterval: 30, + refreshIntervalSeconds: 30, dateRange: { field: 'created_at', defaultRange: 'last_30_days' }, globalFilters: [{ field: 'region', label: 'Region', type: 'select' }], }); @@ -330,7 +331,7 @@ describe('the published artifacts carry the change — same generators as gen-ma 'header', 'globalFilters', 'dateRange', - 'refreshInterval', + 'refreshIntervalSeconds', 'columns', 'gap', 'className', @@ -350,7 +351,7 @@ describe('the published artifacts carry the change — same generators as gen-ma expect(block).toContain('header?: Record;'); expect(block).toContain('globalFilters?: unknown[];'); expect(block).toContain('dateRange?: Record;'); - expect(block).toContain('refreshInterval?: number;'); + expect(block).toContain('refreshIntervalSeconds?: number;'); // The exclusions stay out of the type surface an author compiles against. expect(block).not.toMatch(/\btitle\b/); expect(block).not.toMatch(/\baria\b/); diff --git a/packages/plugin-dashboard/src/index.tsx b/packages/plugin-dashboard/src/index.tsx index 94342cafd8..1479e50aab 100644 --- a/packages/plugin-dashboard/src/index.tsx +++ b/packages/plugin-dashboard/src/index.tsx @@ -91,7 +91,7 @@ ComponentRegistry.register( { name: 'header', type: 'object', description: 'Header block: { showTitle?, showDescription?, actions? }. Strict — the contract rejects any other key. Renders nothing (zero pixels) when everything it would show is suppressed.' }, { name: 'globalFilters', type: 'array', description: 'Dashboard-level filter bar — the spec’s GlobalFilter[]. Filter values live as dashboard variables (readable in widget expressions as page.) and are AND-merged into each bound widget’s query per its filterBindings.' }, { name: 'dateRange', type: 'object', description: 'Built-in date-range filter: { field?, defaultRange?, allowCustomRange? }. `defaultRange` takes the spec’s date presets plus "custom"; the bound field defaults to created_at.' }, - { name: 'refreshInterval', type: 'number', description: 'Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler.' }, + { name: 'refreshIntervalSeconds', type: 'number', description: 'Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler.' }, { name: 'columns', type: 'number' }, { name: 'gap', type: 'number' }, { name: 'className', type: 'string' } diff --git a/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx index c7dc5bb3a4..62490f91f6 100644 --- a/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.blockPrecedence.test.tsx @@ -258,7 +258,10 @@ describe('the named key set cannot drift from `GanttConfig` (objectui#6469)', () // added to the spec arrives here without a second edit. expect(FLAT_GANTT_CONFIG_KEYS).toContain('startDateField'); expect(FLAT_GANTT_CONFIG_KEYS).toContain('quickFilters'); - // objectui's own members, lifted into `GanttConfig` by objectui#6472. + // The ten that used to be named by a second literal here: objectui read them + // through the schema's `.passthrough()` window, objectstack#15469 closed the + // window and DECLARED them, and objectui#7845 retired the literal. They must + // keep arriving — now by the same derivation as the line above, not by list. expect(FLAT_GANTT_CONFIG_KEYS).toContain('lockField'); expect(FLAT_GANTT_CONFIG_KEYS).toContain('timeSegments'); // The legacy singular alias the flat branch still reads. @@ -272,8 +275,10 @@ describe('the named key set cannot drift from `GanttConfig` (objectui#6469)', () * Compile-time coverage pin (`tsc -p tsconfig.test.json` type-checks this file). * `never` exactly while every `GanttConfig` key — the one declaration both faces * derive from — appears in `FLAT_GANTT_CONFIG_KEYS`. A `GanttConfig` key that - * neither `GanttConfigSchema.shape` nor `GANTT_CONFIG_EXTENSION_KEYS` models - * makes this line fail to compile, NAMING the missing key. + * `GanttConfigSchema.shape` does not model makes this line fail to compile, + * NAMING the missing key. Before objectui#7845 a second literal + * (`GANTT_CONFIG_EXTENSION_KEYS`) could also satisfy it; the spec now declares + * those ten, so the shape is the only source that can. */ type AssertNever = T; export type UncoveredGanttConfigKey = AssertNever< diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index e01d65ff9d..123180c93b 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -102,10 +102,10 @@ export interface QuickFilterDef { /** * The gantt config as THIS renderer consumes it: `GanttConfig` from - * `@object-ui/types` — the spec's `GanttConfigSchema` plus objectui's own - * extensions — with `quickFilters` and `timeSegments` narrowed to the plugin's - * runtime types, and the spec-declared members re-documented with the behaviour - * this renderer gives them. + * `@object-ui/types` — the spec's `GanttConfigSchema`, which since + * objectstack#15469 declares the whole vocabulary — with `quickFilters` and + * `timeSegments` narrowed to the plugin's runtime types, and the spec-declared + * members re-documented with the behaviour this renderer gives them. * * ⚠️ Nothing here may declare a key `GanttConfig` does not (objectui#6051). Nine * members that lived ONLY here — `lockField`, `objectField`, `summaryExtent`, @@ -122,9 +122,10 @@ type GanttConfigEx = GanttConfig & GanttConfigRestated; * measured against the shipped `GanttConfig` on `main` (objectui#6471; the card * counted eleven before objectui#6051/#6472 landed part of the lift). * - * All twelve RESTATE a key `GanttConfig` already declares — eleven arrive from - * the spec's `GanttConfigSchema` (19 keys), `timeSegments` is objectui's own — - * and every one is mutually assignable with its twin. That includes + * All twelve RESTATE a key `GanttConfig` already declares, and since + * objectstack#15469 closed the schema all twelve — `timeSegments` included — + * arrive from the spec's `GanttConfigSchema` (29 keys); every one is mutually + * assignable with its twin. That includes * `quickFilters` and `timeSegments`, which objectui#6471 called load-bearing * NARROWINGS: measured on `main` they narrow nothing. What those two still do is * NAME this plugin's runtime types (`QuickFilterDef[]` / @@ -367,31 +368,6 @@ type KnownKeys = keyof { /** The DECLARED members of `GanttConfig` — its index signature removed. */ export type KnownGanttConfigKey = KnownKeys; -/** - * objectui's own `GanttConfig` members — the ten the spec's `GanttConfigSchema` - * does not model. They lived in this file's private `GanttConfigEx` until - * objectui#6472 lifted them into `@object-ui/types`, which is what makes - * `GanttConfig` the single declaration BOTH faces derive from. - * - * Listed here rather than derived because the runtime object that models them - * (`GanttConfigExtensionFields` in `@object-ui/types/zod`) is module-private - * there. `satisfies` keeps every entry a real `GanttConfig` key, and the - * coverage pin in `ObjectGantt.blockPrecedence.test.tsx` fails to compile if - * `GanttConfig` grows a key that neither source models. - */ -const GANTT_CONFIG_EXTENSION_KEYS = [ - 'borderColorField', - 'lockField', - 'objectField', - 'summaryExtent', - 'defaultCollapsedDepth', - 'dependencyTypes', - 'timeZone', - 'exportFileName', - 'interactions', - 'timeSegments', -] as const satisfies readonly KnownGanttConfigKey[]; - /** * The FLAT spelling of `GanttConfig`'s keys — what `getGanttConfig`'s flat * branch reads, and what `ObjectView` / `ListView` EMIT. @@ -403,16 +379,21 @@ const GANTT_CONFIG_EXTENSION_KEYS = [ * authoring surface — and it is why the precedence flip below strands neither * producer. * - * The spec-modelled half is DERIVED from `GanttConfigSchema` — the same zod - * object the block branch validates against — so a key added to the spec reaches - * the shadow diagnostic without a second edit (the discipline - * `FLAT_MAP_CONFIG_KEYS` set in objectui#5177). `dependencyField` is the legacy - * singular alias the flat branch still reads beside `dependenciesField`; it is - * not a `GanttConfig` key, so it is named on its own. + * DERIVED from `GanttConfigSchema` in full — the same zod object the block + * branch validates against — so a key added to the spec reaches the shadow + * diagnostic without a second edit (the discipline `FLAT_MAP_CONFIG_KEYS` set in + * objectui#5177). Until objectstack#15469 that derivation covered only part of + * the vocabulary and a second literal, `GANTT_CONFIG_EXTENSION_KEYS`, named the + * ten keys the renderer read through the schema's then-open `.passthrough()` + * window. The spec now declares all ten, so the list is one source again and the + * literal is retired (objectui#7845) — keeping it would have made every one of + * the ten a DUPLICATE entry here, which is what the no-duplicates pin in + * `ObjectGantt.blockPrecedence.test.tsx` measured. `dependencyField` is the + * legacy singular alias the flat branch still reads beside `dependenciesField`; + * it is not a `GanttConfig` key, so it is named on its own. */ export const FLAT_GANTT_CONFIG_KEYS = [ ...(Object.keys(GanttConfigSchema.shape) as (keyof typeof GanttConfigSchema.shape)[]), - ...GANTT_CONFIG_EXTENSION_KEYS, 'dependencyField' as const, ]; diff --git a/packages/plugin-kanban/src/index.tsx b/packages/plugin-kanban/src/index.tsx index c33616d641..6d3e73909d 100644 --- a/packages/plugin-kanban/src/index.tsx +++ b/packages/plugin-kanban/src/index.tsx @@ -535,10 +535,22 @@ export const ObjectKanbanRenderer: React.FC<{ schema: any; [key: string]: any }> * objectui#8223 (`sort`) cleared on: the SPEC already declares all five and the * RENDERER already honours all five, so this restores `declared = enforced` * instead of publishing anything new. Measured with a control on the same - * `safeParse` call — because "the spec declares it" is exactly the assumption - * objectui#8172 falsified for `limit`, which four faces teach and the strict - * `ComponentPropsMap` refuses BY NAME. An unrecognised probe key draws - * `unrecognized_keys` on these calls while none of these five does. + * `safeParse` call — because "the spec declares it" is an assumption + * objectui#8172 once falsified for `limit`: four faces taught the key and the + * strict `ComponentPropsMap` refused it BY NAME. ⚠️ That reading is HISTORY as of + * @objectstack/spec 17.4.0. objectstack#16503 (landed as objectstack#16562) + * added `limit: z.number().int().positive().optional()` to + * `ComponentPropsMap['object-kanban']` — the maintainer's option-A ruling on + * objectui#8172, the contract catching up with a capability that was already + * implemented, typed, mapped and documented — so `limit` is DECLARED below with + * the others. Re-measured here rather than inherited: all four faces now agree + * (renderer `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT`; BOTH `@object-ui/types` + * faces, TS and zod; `content/docs/plugins/plugin-kanban.mdx`; the spec). + * ⛔ The declaration carries NO default: a materialised `limit` would defeat the + * gate's `readLimit(base) === undefined` branch, and a bound view's + * `pagination.pageSize` would then never fill it. `DEFAULT_KANBAN_LIMIT = 100` + * stays documented rather than declared. An unrecognised probe key draws + * `unrecognized_keys` on these calls while none of the declared keys does. * * ## What is deliberately NOT here yet * @@ -565,6 +577,7 @@ const OBJECT_KANBAN_INPUTS: ComponentInput[] = [ { name: 'objectName', type: 'string', required: true }, { name: 'columns', type: 'array' }, { name: 'filter', type: 'array', description: 'Filter criteria in JSON-rules form, narrowing the records the board fetches. Lowered to `$filter` on the query.' }, + { name: 'limit', type: 'number', description: 'Row cap — the most records the board fetches, lowered to the query’s top-level `$top` (renderer default 100). The board renders every fetched record into a lane and offers no pagination, so this is the author’s window on the object rather than a page size. PRECEDENCE: a node-level `dataSource` binding’s own `limit` wins outright; the `pagination.pageSize` of a view that binding names fills this key only when the node leaves it unset.' }, { name: 'groupBy', type: 'string', description: 'Record field whose value buckets cards into lanes. Its picklist options become the lanes when `columns` is absent, and a drag between lanes writes the target lane’s value back to the record. A value matching no lane lands in the trailing “Uncategorized” lane rather than disappearing.' }, { name: 'cardTitle', type: 'string', description: 'Record field rendered as the card title. Read AHEAD of `titleField`, which is the legacy spelling of the same choice; when neither yields a value the shared record-display resolver names the card.' }, { name: 'titleField', type: 'string', description: 'Legacy spelling of `cardTitle` — the record field rendered as the card title. `cardTitle` wins when both are authored.' }, diff --git a/packages/types/src/__tests__/dashboard-config.test.ts b/packages/types/src/__tests__/dashboard-config.test.ts index 48ab5f4abd..06fb07b74a 100644 --- a/packages/types/src/__tests__/dashboard-config.test.ts +++ b/packages/types/src/__tests__/dashboard-config.test.ts @@ -34,7 +34,7 @@ describe('DashboardConfig TypeScript Types', () => { description: 'Overview of sales pipeline', columns: 12, gap: 16, - refreshInterval: 30, + refreshIntervalSeconds: 30, widgets: [ { id: 'w1', @@ -119,7 +119,7 @@ describe('DashboardConfig Zod Validation', () => { description: 'Overview of sales pipeline', columns: 12, gap: 16, - refreshInterval: 30, + refreshIntervalSeconds: 30, widgets: [ { id: 'w1', diff --git a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts index 370c41ffe7..eab3e487f7 100644 --- a/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts @@ -165,9 +165,12 @@ describe('spec-only keys are now VALIDATED, not passed through (objectstack#4115 expect(OuiAppSchema.safeParse({ type: 'app', branding: 'blue' }).success).toBe(false); }); - it('Dashboard declares header/refreshInterval/performance', () => { + it('Dashboard declares header/refreshIntervalSeconds/performance', () => { const keys = Object.keys(shapeOf(OuiDashboardSchema)); - for (const key of ['header', 'refreshInterval', 'performance', 'protection']) { + // `refreshIntervalSeconds` is the post-rename spelling (objectui#7783); the + // spec still carries `refreshInterval` as a `retiredKey` tombstone, so + // asserting the OLD name here would pass on the tombstone and prove nothing. + for (const key of ['header', 'refreshIntervalSeconds', 'performance', 'protection']) { expect(keys, `'${key}' is still undeclared`).toContain(key); } }); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index 8c83a05d11..6be16e8d86 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -2099,8 +2099,18 @@ export interface DashboardComponentSchema extends BaseSchema { * every `BaseSchema` slot. */ widgets: Array; - /** Auto-refresh interval in seconds. When set, the dashboard will periodically trigger onRefresh. */ - refreshInterval?: number; + /** + * Auto-refresh interval in seconds. When set, the dashboard will periodically + * trigger onRefresh. + * + * Renamed from `refreshInterval` in @objectstack/spec 17.4.0 (objectstack#15680, + * ruling B on objectstack#14478: a duration-shaped number carries its unit in + * the key name, never only in the describe prose). The spec's `DashboardSchema` + * — which this node derives its spec-owned half from — now REFUSES the old + * spelling with a `retiredKey` tombstone naming this one, so the two faces + * would disagree at parse if this declaration had stayed (objectui#7783). + */ + refreshIntervalSeconds?: number; /** * Dashboard header configuration. * Aligned with @objectstack/spec DashboardHeaderSchema. diff --git a/packages/types/src/designer.ts b/packages/types/src/designer.ts index 776f6aa86f..3c890e3a7c 100644 --- a/packages/types/src/designer.ts +++ b/packages/types/src/designer.ts @@ -597,8 +597,8 @@ export interface DashboardConfig { columns?: number; /** Grid gap in pixels */ gap?: number; - /** Auto-refresh interval in seconds */ - refreshInterval?: number; + /** Auto-refresh interval in seconds (renamed from `refreshInterval`, objectui#7783) */ + refreshIntervalSeconds?: number; /** Dashboard widgets */ widgets?: DashboardWidgetConfig[]; diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 525afff22d..9b32d3525d 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -164,13 +164,18 @@ export type { CalendarConfig } from '@objectstack/spec/ui'; * GanttConfigSchema". It is, as of rc.6, so the key now arrives from the * spec and the note is gone with it. * - * `timeSegments` is the one key the spec genuinely does not model, and it stays - * here declared as objectui's own. That is legal metadata rather than a second - * dialect: `GanttConfigSchema` is `$loose` upstream (see the note at - * `zod/objectql.zod.ts` — "the renderers grow config knobs"), so a key the spec - * does not declare passes its parse instead of being rejected. The intersection - * inherits that looseness, which is the spec's own decision for this vocabulary - * and not a widening taken here. + * ⚠️ THE LOOSENESS THIS PARAGRAPH USED TO REST ON IS GONE. `GanttConfigSchema` + * was `$loose` upstream, so a key the spec did not declare passed its parse + * instead of being rejected, and the members intersected below were legal + * metadata riding that window. objectstack#15469 CLOSED it: the schema is a + * `strictObject`, an undeclared key is refused by name, and the ten keys objectui + * read through the window — `timeSegments` among them — are DECLARED upstream + * with describes. So the members below no longer extend the spec's vocabulary; + * they restate part of it, and `SpecGanttConfig` already carries every one. + * + * ⛔ Do not read this block as an extension point. Writing an undeclared sub-key + * into `timeSegments` (or any member here) is refused at parse now, where it + * used to pass — the prose said the opposite until objectui#7845 corrected it. */ export type GanttConfig = SpecGanttConfig & { /** @@ -216,9 +221,10 @@ export type GanttConfig = SpecGanttConfig & { // referenced by neither declaration — so the vocabulary is lifted here rather // than restated, and the two faces derive from ONE source that cannot fork. // - // Like `timeSegments` above, each is legal metadata rather than a second - // dialect: `GanttConfigSchema` is `$loose` upstream, so a key the spec does not - // model passes its parse instead of being rejected. + // Like `timeSegments` above, each of the nine is now DECLARED by the spec — + // objectstack#15469 closed `GanttConfigSchema` and modelled them, so they are + // no longer objectui-only metadata riding a `$loose` parse. They stay written + // out here for their prose; the vocabulary itself is the spec's. /** * Record field marking a node as view-only (truthy → locked). A locked * row's bar can't be dragged/resized, its progress can't be dragged, no diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index eb96c3dd84..0fd1e17a36 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1062,9 +1062,12 @@ export const GlobalFilterSchema = z.object({ * * `BaseSchema` is `.passthrough()` while the spec's `DashboardSchema` is * strict, so before this derivation every spec-only key rode through objectui - * unvalidated — `header`, `refreshInterval`, `performance`, `aria`, + * unvalidated — `header`, `refreshIntervalSeconds`, `performance`, `aria`, * `protection` and the `_lock*`/`_package*`/`_provenance` package-lock - * envelope were neither checked nor declared. + * envelope were neither checked nor declared. (That key was spelled + * `refreshInterval` until @objectstack/spec 17.4.0 renamed it — objectui#7783; + * the spec now carries a `retiredKey` tombstone under the old spelling, and it + * flows in here by reference like every other member of this set.) * * Omitted, each for a stated reason: * - `name`/`label`/`description` — component-envelope keys owned by BaseSchema; @@ -1151,7 +1154,7 @@ export const DashboardConfigSchema = z.object({ description: z.string().optional().describe('Dashboard description'), columns: z.number().min(1).max(24).optional().describe('Grid columns (1-24)'), gap: z.number().min(0).optional().describe('Grid gap in pixels'), - refreshInterval: z.number().min(0).optional().describe('Auto-refresh interval in seconds'), + refreshIntervalSeconds: z.number().min(0).optional().describe('Auto-refresh interval in seconds'), widgets: z.array(DashboardWidgetConfigSchema).optional().describe('Dashboard widgets'), globalFilters: z.array(z.any()).optional().describe('Global filter conditions'), dateRange: z.object({ diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index e4a7f8f901..c6b4330c61 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -535,17 +535,26 @@ const LIST_VIEW_LOCAL_OVERRIDES = [ // trade-off the spec-field import on `ListViewSchema` makes. // // `gantt` needs no local schema at all: the spec config already covers every field -// the renderer reads and is `.passthrough()` for renderer-ahead knobs, so it flows -// in with the rest of the imported spec fields. +// the renderer reads, so it flows in with the rest of the imported spec fields. +// It used to cover them by being `.passthrough()` for renderer-ahead knobs; +// objectstack#15469 closed that window and DECLARED the ten it was carrying, so +// today the coverage is by declaration (objectui#7845). // // The deprecated aliases below are the pre-#2231 objectui vocabulary. They stay // accepted so stored view metadata keeps validating, but the spec key is canonical // and wins at every read-site. // -// `.passthrough()` is kept from the pre-#2231 shapes for the same reason the spec -// puts it on `GanttConfigSchema`/`TreeConfigSchema`: the renderers grow config knobs -// ahead of the protocol (calendar's `allDayField`, for one), and stripping them here -// would silently disable a shipped capability. +// `.passthrough()` is kept from the pre-#2231 shapes because the renderers grow +// config knobs ahead of the protocol (calendar's `allDayField`, for one), and +// stripping them here would silently disable a shipped capability. ⚠️ It is NOT +// kept "for the same reason the spec puts it on +// `GanttConfigSchema`/`TreeConfigSchema`", which is what this note used to say: +// objectstack#15469 closed both of those upstream, so the spec-side precedent is +// gone and only the local reason survives (objectui#7845). Measured for the two +// shapes below: `swimlaneField` (kanban) and `endField` (timeline) are still +// absent from the spec's `KanbanConfigSchema` / `TimelineConfigSchema`, so these +// `.passthrough()`s still carry real authored values — +// `core/src/utils/__tests__/normalize-list-view.test.ts` pins exactly those two. const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().extend({ /** @deprecated legacy alias for the spec's `groupByField` */ groupField: z.string().optional().describe('Deprecated alias for groupByField'), @@ -876,62 +885,6 @@ export const ObjectTreeSchema = BaseSchema.extend({ defaultExpandedDepth: z.number().optional().describe('Default expansion depth (0 = roots only)'), }); -/** - * objectui's own `GanttConfig` extensions — everything `../objectql.ts` declares - * on {@link GanttConfig} beyond the spec's `SpecGanttConfigSchema` (objectui#6051 - * lifted nine of them out of `plugin-gantt`'s package-private `GanttConfigEx`; - * `timeSegments` was already there). - * - * Held as ONE field map rather than inlined, so the flattened top-level spelling - * below is built from a single source — the same way the TS side derives its - * flattened members from `GanttConfig`. It is also the shape the nested `gantt` - * block is built from (objectui#6475): one line extending `SpecGanttConfigSchema` - * with this map — so both authoring faces share one vocabulary and cannot fork - * from each other. - * - * Not exported: the parity census in `__tests__/zod-mirror-parity.test.ts` reads - * `^export const` out of this directory and would require a registered TS - * counterpart for it. It has none of its own — it is a fragment of `GanttConfig`, - * and `GanttConfig` is checked through the two faces that carry it. - */ -const GanttConfigExtensionFields = { - borderColorField: z.string().optional().describe('Record field carrying a per-task alert stroke colour'), - lockField: z.string().optional().describe('Record field marking a row view-only (truthy → locked)'), - objectField: z.string().optional().describe("Record field carrying the row's own object API name"), - summaryExtent: z.enum(['children', 'self']).optional().describe("How a summary bar's span is computed"), - defaultCollapsedDepth: z.number().optional().describe('Auto-collapse tree nodes at/below this 0-indexed depth'), - dependencyTypes: z.boolean().optional().describe('Whether the store persists dependency link TYPES (fs/ss/ff/sf)'), - timeZone: z.string().optional().describe("Business time zone (IANA name) the chart's calendar renders in"), - exportFileName: z.string().optional().describe('Base name for exported PNG/PDF files'), - interactions: z - .object({ - move: z.boolean().optional().describe('Bar / subtree dragging'), - resize: z.boolean().optional().describe('Edge resize grips'), - progress: z.boolean().optional().describe('The progress drag handle'), - link: z.boolean().optional().describe('Dependency UI: drag-to-link dots and the create/delete menu'), - }) - .optional() - .describe('Per-interaction switches, each defaulting to true'), - timeSegments: z - .object({ - dayStart: z.string().optional().describe("Clock time the shift-day begins, 'HH:mm'"), - bands: z - .array( - z.object({ - key: z.string().optional().describe('Stable band id'), - label: z.string().describe('Display label'), - start: z.string().describe("Band start, 'HH:mm'"), - end: z.string().describe("Band end, 'HH:mm'"), - color: z.string().optional().describe('Accent colour for the column tint'), - }) - ) - .describe('Ordered bands covering the 24h shift-day'), - showMidnight: z.boolean().optional().describe('Draw the dashed calendar-midnight cue'), - }) - .optional() - .describe('Shift segmentation for the day-mode timeline'), -}; - /** * ObjectGantt Schema * @@ -1023,12 +976,32 @@ export const ObjectGanttSchema = BaseSchema.extend({ capacity: stripImportedDefaults(SpecGanttConfigSchema).shape.capacity, quickFilters: stripImportedDefaults(SpecGanttConfigSchema).shape.quickFilters, autoZoomToFilter: stripImportedDefaults(SpecGanttConfigSchema).shape.autoZoomToFilter, - // …and objectui's own ten, from the one field map above. - ...GanttConfigExtensionFields, + // …and the ten that used to be a SECOND declaration here. objectui read them + // through `GanttConfigSchema`'s then-open `.passthrough()` window and had to + // model them locally (`GanttConfigExtensionFields`, objectui#6051/#6475); + // objectstack#15469 closed the window and declared all ten upstream, so they + // arrive by reference like every member above and the local field map is + // retired (objectui#7845). Compared member for member before deleting: the + // spec's declarations cover the same keys, the same `interactions` + // (move/resize/progress/link) and `timeSegments` (dayStart/bands/showMidnight) + // sub-shapes and the same band members, with strictly fuller describes — the + // local copy carried no describe, alias or member the spec's lacks. + borderColorField: stripImportedDefaults(SpecGanttConfigSchema).shape.borderColorField, + lockField: stripImportedDefaults(SpecGanttConfigSchema).shape.lockField, + objectField: stripImportedDefaults(SpecGanttConfigSchema).shape.objectField, + summaryExtent: stripImportedDefaults(SpecGanttConfigSchema).shape.summaryExtent, + defaultCollapsedDepth: stripImportedDefaults(SpecGanttConfigSchema).shape.defaultCollapsedDepth, + dependencyTypes: stripImportedDefaults(SpecGanttConfigSchema).shape.dependencyTypes, + timeZone: stripImportedDefaults(SpecGanttConfigSchema).shape.timeZone, + exportFileName: stripImportedDefaults(SpecGanttConfigSchema).shape.exportFileName, + interactions: stripImportedDefaults(SpecGanttConfigSchema).shape.interactions, + timeSegments: stripImportedDefaults(SpecGanttConfigSchema).shape.timeSegments, // `gantt` — the BLOCK face `getGanttConfig`'s FIRST branch reads and prefers - // (objectui#6469 ruled block-over-flat) — objectui#6475. Built from the same - // field map as the flat face above, `SpecGanttConfigSchema` extended with - // `GanttConfigExtensionFields`, so the two authoring faces cannot fork. + // (objectui#6469 ruled block-over-flat) — objectui#6475. It is now + // `SpecGanttConfigSchema` itself, the same vocabulary the flat face above + // derives from key by key, so the two authoring faces cannot fork. The + // `.extend(GanttConfigExtensionFields)` this used to carry is gone with the + // field map (objectui#7845): the spec declares those ten itself. // // This is the one entry among the 28 that NARROWS rather than merely names: a // `gantt` block previously rode through `.passthrough()` entirely unvalidated; @@ -1042,7 +1015,7 @@ export const ObjectGanttSchema = BaseSchema.extend({ // configuration` on failure. Maintainer ruling, objectui#6475 (2026-08-27), // Option A: enforce as-is, no warning window (excluded by the startup-stage // no-gradualism rule, objectstack#12668 — no named external-user evidence). - gantt: stripImportedDefaults(SpecGanttConfigSchema).extend(GanttConfigExtensionFields).optional().describe( + gantt: stripImportedDefaults(SpecGanttConfigSchema).optional().describe( 'Nested gantt config block — the authoring face, and the winner over the flattened top-level keys whenever present' ), // The query/data keys the fetch path reads. They were declared on diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb2b342b8f..8555436611 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.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -273,13 +273,13 @@ importers: version: link:../../packages/types '@objectstack/client': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -414,7 +414,7 @@ importers: version: link:../../packages/types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) 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.31.0(react@19.2.8))(next@16.3.1(@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.4.3) @@ -772,13 +772,13 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@sentry/react': specifier: ^10.70.0 version: 10.70.0(react@19.2.8) @@ -869,7 +869,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) 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.1(@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) @@ -965,7 +965,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1001,7 +1001,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@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) @@ -1182,10 +1182,10 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1241,10 +1241,10 @@ importers: version: link:../types '@objectstack/client': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1281,7 +1281,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -1385,7 +1385,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -1426,7 +1426,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1598,7 +1598,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1640,7 +1640,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1750,7 +1750,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1847,7 +1847,7 @@ importers: version: link:../i18n '@objectstack/spec': specifier: ^17.1.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -1972,7 +1972,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2033,7 +2033,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2094,7 +2094,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2271,7 +2271,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2445,7 +2445,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2493,7 +2493,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2554,7 +2554,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2621,7 +2621,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2686,7 +2686,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.1.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2751,7 +2751,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2790,13 +2790,13 @@ importers: version: link:../react '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) packages/test-support: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2811,7 +2811,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.3.0 - version: 17.3.0(ai@7.0.65(zod@4.4.3)) + version: 17.4.0(ai@7.0.65(zod@4.4.3)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -4174,26 +4174,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.3.0': - resolution: {integrity: sha512-EoxDSZPxdqiInaEQ2eZlRAL0LES73sT2xVUqbUW84pXcB2f+s2uztOZmdkE/IDS65oBrMe8NnJ++q5RrwgI+wg==} + '@objectstack/client@17.4.0': + resolution: {integrity: sha512-4Zsov0iRcZhaYAGiabX2XJPhDsE6XmRhlfYikxLECZ1cxbHhXjVpsNL3rl56wkTHuNV1jMzZZwzj2rJJUbVYcQ==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.3.0': - resolution: {integrity: sha512-MgH+HYLEl7dCQJ9XW4Wi/mBX32+WMUSf5X65DplUvHvfOeLQHnDg/GoXjnNVFBI+82A0tgkBPpvsvn7haduY8A==} + '@objectstack/core@17.4.0': + resolution: {integrity: sha512-e/br10MaIZAJGgD7pb9AnW8zn9I18aJyiUYswMQGdjGheZYpp98ZV3WlAVpZFhnKkfd42VDMltB+X494oB8/1Q==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.3.0': - resolution: {integrity: sha512-zLR5DdFshJOvNMX9G4CPBPbWeVrWa/b1GxcMFhyNldtJCb92YlGsgd7vw8OWUkoxE+k9AnbfbxbDaxSxY2Ecgw==} + '@objectstack/formula@17.4.0': + resolution: {integrity: sha512-pWVkvXKXJxjnsx1i7mYIteltc6kjn/qS6swJ6bw5XG5B7bf8GjjBsuvcJR86nuTg+z8B9+snruaD9i9v8uw0LQ==} - '@objectstack/lint@17.3.0': - resolution: {integrity: sha512-pRFjIZ+sQmlPT2N24aM9szwHK50P3AjiwmHEFi6DUHJGtDq/Tt4HtZ+89nF7WP0MfT1AK8nlNadxsDrHPF8UCg==} + '@objectstack/lint@17.4.0': + resolution: {integrity: sha512-9Lst+TDusPVh3Fb1gxDPSsXpAFrkEIlusqLzBsCKQ0ebLaHxG2GrfMXQofDTox9vr7jOVSAKCW5jkHUmg5XpoA==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.3.0': - resolution: {integrity: sha512-LbzxlLBoDBd0yxoIYMhYuXJ+j3iEsqvvBtrB0JWWda8Iy6mhPqbpyjj11Y5bZ2A0yGf20z0b/vXLDi1ISNIwpA==} + '@objectstack/sdui-parser@17.4.0': + resolution: {integrity: sha512-pKKTqP6siwsLtcO7tDxMufHoCWuUeWjoRBv8NRGlptB+a/uhO0WKXHzZ3+zWVqz9a/Ac9N2Xwp+hBPoGtG4Z1w==} - '@objectstack/spec@17.3.0': - resolution: {integrity: sha512-mceFp9swHHISMycZk8TjdqjBhi0GboQoapRSNeFyaI8GBIpw5cRc37GrWrIFVTZLaVCc7TF3XHSi1qqjaBNmzA==} + '@objectstack/spec@17.4.0': + resolution: {integrity: sha512-bOtSFih0Ed67JZMGuLmlHuIv2zPN62W8NL2qENQCYjZB174NMlIIvHC6Jlz2SIy5EwsTnTGBF5EPF6C2/TmqoA==} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -4201,8 +4201,8 @@ packages: ai: optional: true - '@objectstack/types@17.3.0': - resolution: {integrity: sha512-G5c2+V0gzR5co5XcZ0Zs4uTOp42421Yj6fWEWVV6kEpKfIzaDYUzv1HfmwdOfLklrbtNiF3loBg6jB00BVge/g==} + '@objectstack/types@17.4.0': + resolution: {integrity: sha512-z7eCTBlGqAX8ChKifkItGg+woONgAMBbWtu8OmTqI6Fzn8z7JDCm07+K0gg0hivD1uoUUL253s8mshdiqpZnnQ==} engines: {node: '>=22.0.0'} '@open-draft/deferred-promise@2.2.0': @@ -12569,33 +12569,33 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/client@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/core': 17.3.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/core': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/core@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/core@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/types': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/types': 17.4.0(ai@7.0.65(zod@4.4.3)) zod: 4.4.3 transitivePeerDependencies: - ai - '@objectstack/formula@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/formula@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/lint@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/lint@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/formula': 17.3.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/sdui-parser': 17.3.0 - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/formula': 17.4.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/sdui-parser': 17.4.0 + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12603,18 +12603,18 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.3.0': {} + '@objectstack/sdui-parser@17.4.0': {} - '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: pg-connection-string: 2.14.0 zod: 4.4.3 optionalDependencies: ai: 7.0.65(zod@4.4.3) - '@objectstack/types@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/types@17.4.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.4.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai diff --git a/scripts/__tests__/check-lockfile-integrity.test.ts b/scripts/__tests__/check-lockfile-integrity.test.ts index 7e9236aff7..6a20c4fb4d 100644 --- a/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/scripts/__tests__/check-lockfile-integrity.test.ts @@ -89,7 +89,7 @@ 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` and the registry's latest is 17.3.0, so no fresh resolve can + * floating `^17.x` and the registry's latest is 17.4.0, 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. @@ -97,9 +97,9 @@ describe('green on main — the negative control', () => { function sampleA(): string { let out = realLock; for (const pkg of ['client', 'core', 'formula', 'lint', 'sdui-parser']) { - out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.3\\.0`, 'g'), '$117.2.0'); + out = out.replace(new RegExp(`(@objectstack/${pkg}@)17\\.4\\.0`, 'g'), '$117.2.0'); } - const specKey = " '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))':"; + const specKey = " '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))':"; 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.3.0(ai@7.0.65(zod@4.4.3))':", - " '@objectstack/formula@17.3.0(ai@7.0.65(zod@4.4.3))':", + " '@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))':", + " '@objectstack/formula@17.4.0(ai@7.0.65(zod@4.4.3))':", " ai@7.0.65(zod@4.4.3):", ]; for (const key of forked) { @@ -189,7 +189,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.3.0'))).toBe(true); + expect(spec?.head.every((k) => k.includes('@17.4.0'))).toBe(true); }); }); diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index 6860518346..c506be9611 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -1015,7 +1015,7 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `save`, `storedPage`, which the example never declares', }, - 'packages/types/src/objectql.ts:1608 ObjectFormSchema': { + 'packages/types/src/objectql.ts:1614 ObjectFormSchema': { card: null, codes: [1005, 1109], reason: