diff --git a/.changeset/7632-shared-record-source-config.md b/.changeset/7632-shared-record-source-config.md index b455fd38be..73151cc6c1 100644 --- a/.changeset/7632-shared-record-source-config.md +++ b/.changeset/7632-shared-record-source-config.md @@ -31,8 +31,11 @@ input matrix, so a later edit to the shared reader that moves any site turns red **Two divergences were measured rather than assumed, and both are preserved.** -`ObjectCalendar`'s `'data' in schema && schema.data` guards exist because its parameter is -the union `ObjectGridSchema | CalendarSchema` and `CalendarSchema` declares neither key. +`ObjectCalendar`'s `'data' in schema && schema.data` guards existed because its parameter was +at that time the union `ObjectGridSchema | CalendarSchema`, whose `CalendarSchema` arm declared +neither key. (objectui#8651 has since re-pointed that parameter at the published +`ObjectCalendarSchema`, so the union is gone; the CONCLUSION below — that the guard had no +runtime effect and removing it is behaviour-neutral — is unaffected.) That is a TypeScript narrowing device with no runtime effect — an absent property reads `undefined`, falsy either way — so the guard could never change which rung is taken. The shared reader's optional-property parameter accepts the union directly, and the diff --git a/.changeset/olive-buckets-scream.md b/.changeset/olive-buckets-scream.md new file mode 100644 index 0000000000..2114d5add7 --- /dev/null +++ b/.changeset/olive-buckets-scream.md @@ -0,0 +1,13 @@ +--- +"@object-ui/plugin-calendar": minor +"@object-ui/types": minor +--- + +fix(plugin-calendar): type `ObjectCalendar` at the published `object-calendar` schema, and declare the `calendar` container + +`ObjectCalendarComponentProps.schema` was the union `ObjectGridSchema | CalendarSchema` — a grid's schema, plus a plugin-local interface absent from this package's barrel. Neither arm is the schema of the element this renderer is registered as. Measured with the TypeScript checker: of the fifteen keys the renderer reads off the node, seven were undeclared on that union and had to be read through a cast, while five more resolved only through `ObjectGridSchema`'s index signature and were therefore typed `any` — admitted, never examined. `ObjectCalendarSchema` already declared eleven of the fifteen. + +- `ObjectCalendarComponentProps.schema` is now `ObjectCalendarSchema`. **Breaking for a React host that passed an `object-grid` node or a `type: 'calendar'` literal to `ObjectCalendar`** — neither was ever a node this renderer is registered for. `ObjectCalendarProps`, the deprecated alias, follows it. +- `ObjectCalendarSchema.calendar` is declared on both published faces. The spec declares the KEY; it does **not** declare its shape — `ComponentPropsMap['object-calendar'].calendar` is `z.unknown().optional()` and accepts anything at that position — so the member list is objectui's own: the four `CalendarConfigSchema` names plus objectui's `allDayField`, which is what the renderer reads out of the block. The container keeps `.passthrough()`, so no KEY that parsed before is refused — an unexamined key inside the block still parses, and so do `calendar.dateField` and `calendar.defaultView`. What is new is VALUE validation: `calendar: 42` and `calendar: { startDateField: 42 }` are refused where both were admitted unexamined. +- `@object-ui/types` now exports the type `ObjectCalendarBlockConfig`, so that published member has a name an importer can write. +- ⚠️ The `dateField` / `endField` alias rungs in `getCalendarConfig` are **kept**, and routed to the producer. An earlier revision of this change retired them on a census that was false: `ListView` flattens an authored `calendar` block onto the node it emits, and `resolveTimelineDateBinding` in that same file documents `dateField` as the pre-#2231 alias for `startDateField` and honours it — so a view authored that way renders today and would have drawn "Calendar configuration required". (objectui's own `ListViewSchema` also accepts `calendar.dateField`, but only weakly: that block is `.passthrough()` and admits nonsense too, so the load-bearing half is the producer and the read site, not the accept.) No behaviour changes for either spelling. The alias question already has a carrier — objectui#8355, open and undecided — and the producer-side remedy belongs in `ListView`'s calendar branch. diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index b892cff599..14013b17bd 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -518,7 +518,7 @@ const taskCalendar: ObjectCalendarSchema = { You can also import and use the components directly in React: -{/* doc-snippet: fragment — the `ObjectCalendar` half cannot compile against the SHIPPED prop type: `ObjectCalendarComponentProps.schema` is declared `ObjectGridSchema | CalendarSchema`, and neither admits an `object-calendar` node — `ObjectGridSchema.type` is the literal `'object-grid'` and `CalendarSchema` is the FORM date picker (`type: 'calendar'`). The renderer registered for `object-calendar` passes exactly this shape (`index.tsx`), and `ObjectCalendar.tsx` reads `objectName` / `calendar` / `staticData` off it, so the runtime path is real and the declaration is the stale half — filed as objectui#7311 rather than papered over with a cast (measured: TS2322 x1) */} +{/* doc-snippet: fragment — the `ObjectCalendar` half cannot compile as written, because the `schema` literal below omits the required `type: 'object-calendar'` discriminant. The SHIPPED prop type is `ObjectCalendarComponentProps.schema: ObjectCalendarSchema` (`plugin-calendar/dist/ObjectCalendar.d.ts`), whose `type` member is the literal `'object-calendar'`. Compiling this exact block against the built workspace measures TS2741 x1 (Property 'type' is missing) and TS2322 x0. ⚠️ This reason was rewritten by objectui#8651: it previously said the prop was declared a grid/date-picker union and that neither arm admitted an `object-calendar` node, with a measurement of TS2322 x1. Every clause of that reason is false at this head, because objectui#8651 re-pointed the prop at the published element schema — which is also why the objectui#7311 'stale declaration' framing is gone. ⛔ Whether the old TS2322 x1 held when it was written is NOT asserted here: that would need a build of the old tree, which was not done. The fix for the block itself is to add the discriminant, not a cast. */} ```tsx import { CalendarView, ObjectCalendar } from '@object-ui/plugin-calendar'; import type { DataSource } from '@object-ui/types'; diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 771ce5c749..57b39d0d43 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -23,7 +23,7 @@ */ import React, { useEffect, useState, useCallback, useMemo } from 'react'; -import type { ObjectGridSchema, DataSource, CalendarConfig } from '@object-ui/types'; +import type { ObjectCalendarSchema, DataSource, CalendarConfig, ViewData } from '@object-ui/types'; import { CalendarView, type CalendarViewEvent } from './CalendarView'; import { usePullToRefresh } from '@object-ui/mobile'; import { @@ -74,18 +74,52 @@ import { ValueDataSource, } from '@object-ui/core'; -export interface CalendarSchema { - type: 'calendar'; - objectName?: string; - dateField?: string; - endField?: string; - titleField?: string; - colorField?: string; - filter?: any; - sort?: any; - /** Initial view mode */ - defaultView?: 'month' | 'week' | 'day'; -} +/** + * ⛔ RETIRED (objectui#8651) — a plugin-local `CalendarSchema` used to sit here, + * and `ObjectCalendarComponentProps.schema` below was the union of it with + * `ObjectGridSchema`. Both arms are gone; the prop is now the published schema + * of the element this file is registered as (`index.tsx`, both tags). + * + * What the union cost, measured with the TypeScript checker on the merge-base + * (`getPropertyOfType`, never a grep — objectui#8410). Of the fifteen keys this + * renderer reads off the node, FOUR were declared on neither arm and EIGHT on + * exactly one, while `ObjectCalendarSchema` already declared ELEVEN of the + * fifteen. + * + * ⚠️ But the cast count is NOT twelve, and an earlier cut of this comment said + * it was. `ObjectGridSchema` carries `BaseSchema`'s `[key: string]: any`, so on + * the UNION the five `CalendarSchema`-only keys resolved through that index + * signature and compiled with no cast at all. The checker on the union itself: + * DECLARED 8 of 15 (`colorField` `dateField` `defaultView` `endField` `filter` + * `objectName` `sort` `titleField`), NOT declared 7 (`allDayField` `calendar` + * `data` `endDateField` `navigation` `startDateField` `staticData`), nonsense + * control `false`. ⇒ SEVEN reads needed a cast, not twelve. + * + * ⭐ That reading is worse for the old shape, not better: those five compiled + * silently as `any` — admitted through an index signature, never examined — + * which is the objectui#6914 defect itself rather than an absence of one. A + * cast at least announces the gap. The union's own index signature is `false`, + * which is why the other seven had to be cast. + * + * The two arms failed differently and neither was salvageable: + * + * - `ObjectGridSchema` is `type: 'object-grid'`. No producer hands this + * component one: the single call site is this package's `index.tsx`, whose + * two registrations both publish `OBJECT_CALENDAR_INPUTS`. + * - the local interface was absent from this package's barrel, so no importer + * could name it, and it SHADOWED `@object-ui/types`' own published + * `CalendarSchema` — the date-picker primitive reachable at `ui:calendar` + * only (objectui#8499). Its two distinctive members are the alias spellings + * `getCalendarConfig` below ROUTES to the producer (carrier objectui#8355); + * they are still read, and they have a producer. + * + * The shape this leaves is the family's: `ObjectKanban` takes + * `ObjectKanbanSchema`, `ObjectGantt` takes `ObjectGanttSchema`, `ObjectMap` + * takes `ObjectMapSchema` — and `plugin-map` is registered under two tags with + * one published props type, exactly as this package is. + * + * Pinned in `__tests__/calendarUnionReads-8651.test.tsx`. + */ /** * Props of the `ObjectCalendar` React component. @@ -104,7 +138,7 @@ export interface CalendarSchema { * no importer breaks. Tripwire: `__tests__/spec-symbol-4650.test.ts`. */ export interface ObjectCalendarComponentProps { - schema: ObjectGridSchema | CalendarSchema; + schema: ObjectCalendarSchema; dataSource?: DataSource; className?: string; /** Pre-fetched records passed by a parent (e.g. ObjectView). When provided, skips internal data fetching. */ @@ -205,20 +239,98 @@ type ObjectCalendarConfig = CalendarConfig & { allDayField?: string; }; -function getCalendarConfig(schema: ObjectGridSchema | CalendarSchema): ObjectCalendarConfig | null { - // The declared configuration container. - if ((schema as any).calendar) { - return (schema as any).calendar as ObjectCalendarConfig; +/** + * The two PRE-#2231 alias spellings `getCalendarConfig` still honours, declared + * ONLY as a cast target and deliberately NOT as schema members — see the + * routing note on `getCalendarConfig`. + * + * ⚠️ The ground is NOT that the spec singles these two out. MEASURED on + * installed `@objectstack/spec` 17.4.0: `ComponentPropsMap['object-calendar']` + * is STRICT and declares exactly nine flat members — `calendar` `data` + * `defaultView` `filter` `loading` `locale` `objectName` `sort` `staticData` — + * so it refuses every undeclared flat key with the same `unrecognized_keys` + * diagnostic: these two aliases, a nonsense key, AND the five canonical field + * keys `ObjectCalendarSchema` already declares and this renderer reads (`startDateField` + * `endDateField` `titleField` `colorField` `allDayField`). ⛔ Blanket strictness cannot be the reason these + * two stay undeclared — applied as a reason it would require undeclaring those + * canonical five, and this repo's mirror being stricter than the protocol is + * the SANCTIONED direction anyway (see `zod/objectql.zod.ts`). + * + * The real ground is narrower: they are deprecated pre-#2231 ALIASES of keys + * this schema already declares, and the alias question has an open carrier — + * objectui#8355 — which has not ruled. Declaring an alias would settle that + * card by accretion; routing it to the producer leaves it open. + */ +type CalendarAliasRungs = { dateField?: string; endField?: string }; + +/** + * ⚠️ TWO ALIAS RUNGS SURVIVE HERE, and objectui#8651 ROUTES THEM TO THE + * PRODUCER rather than retiring them. An earlier cut of that card DID retire + * them, on a census that was FALSE; the census was the defect, so the reasoning + * is recorded here rather than the conclusion it produced. + * + * What the false census said: zero producers anywhere in this repo write either + * spelling onto a calendar node. What it could not see: the producer does not + * write the key LITERALLY, it SPREADS it. `plugin-list/src/ListView.tsx`'s + * `case 'calendar':` ends by flattening the authored block onto the node it + * emits — `...(schema.options?.calendar || {})` then `...(schema.calendar || {})` + * — objectui's own published `ListViewSchema` accepts `calendar.dateField`, and + * `resolveTimelineDateBinding` in that same file documents it in terms: + * *"`dateField` is the pre-#2231 alias for `startDateField`"*, and honours it. + * A word-boundary text census is structurally blind to a key that arrives + * through a spread. + * + * MEASURED by mounting the producer on `calendar: { dateField, titleField }`, + * capturing the `object-calendar` node it really emits — a `titleField` and a + * flat `dateField`, NO `startDateField` — and rendering that exact node on both + * trees: the merge-base draws a calendar, the retiring tree drew "Calendar + * configuration required". A live, published authoring path stopped rendering + * with every gate green. `endField` degraded more quietly: the node still + * resolved through `startDateField` and the end binding was silently dropped. + * + * ⇒ AGENTS.md #0.1 points these at the PRODUCER — the one translation point in + * `ListView.tsx`'s calendar branch, which already lifts `startDateField`, + * `endDateField`, `titleField` and `defaultView` and should normalise these two + * the same way. That file is outside objectui#8651's declared file surface, so + * the card ROUTES rather than retires: the rungs stay, and both keys are + * LEDGERED BY NAME in `__tests__/calendarUnionReads-8651.test.tsx`, which + * asserts they are still read AND that the producer still flattens — so when + * the producer is fixed the ledger reddens and the rungs can go. + * + * ⭐ THE CARRIER IS objectui#8355, which already asks exactly this — *"the + * renderer carries a lenient alias ladder that no published declaration spells + * — decide whether the aliases stay, are declared, or are refused"* — and is + * OPEN and UNDECIDED. ⛔ Do not open a second card for it. Two notes for + * whoever takes it: its key list says `dateField` / `startField`, and the + * ladder measured here is `dateField` / `endField`; and its three options do + * not include the fourth this card takes, NORMALISE AT THE PRODUCER, which is + * the only one that refuses the alias without breaking the authoring path. + * + * ⛔ Do not re-retire these on a text census. The only census that can answer + * this question runs the producer. + */ +function getCalendarConfig(schema: ObjectCalendarSchema): ObjectCalendarConfig | null { + // The declared configuration container — read FIRST, as the spec declares it. + if (schema.calendar) { + return schema.calendar as ObjectCalendarConfig; } - - // Check for flat properties (used by ObjectView) - if ((schema as any).startDateField || (schema as any).dateField) { + + // The flat spelling, which `ObjectView` and `ListView` emit. + // + // ⛔ The two alias rungs are spelled as a cast ON `schema` at each read site, + // never through a renamed local. The repo's read census (objectui#6576's + // `schemaReads`, used by + // `types/src/__tests__/calendar-flat-color-allday-8466.test.ts` and by this + // card's own pin) matches `schema.KEY` and `(schema as T).KEY` — a local alias + // would hide these two reads from the very instrument meant to enumerate them, + // which is the same class of blindness that produced the false census above. + if (schema.startDateField || (schema as CalendarAliasRungs).dateField) { return { - startDateField: (schema as any).startDateField || (schema as any).dateField, - endDateField: (schema as any).endDateField || (schema as any).endField, - titleField: (schema as any).titleField, - colorField: (schema as any).colorField, - allDayField: (schema as any).allDayField + startDateField: schema.startDateField || (schema as CalendarAliasRungs).dateField, + endDateField: schema.endDateField || (schema as CalendarAliasRungs).endField, + titleField: schema.titleField, + colorField: schema.colorField, + allDayField: schema.allDayField } as ObjectCalendarConfig; } @@ -281,7 +393,7 @@ export const ObjectCalendar: React.FC = ({ // this component rather than remounting it. const [unscheduledOpen, setUnscheduledOpen] = useState(false); const isMobile = useIsMobile(); - const schemaDefaultView = (schema as any).defaultView as 'month' | 'week' | 'day' | undefined; + const schemaDefaultView = schema.defaultView; // Lazy initializer: read window.innerWidth synchronously so SSR-friendly // useIsMobile (which returns false on first render) doesn't lock us into // a 24-hour day grid on phones. @@ -336,9 +448,33 @@ export const ObjectCalendar: React.FC = ({ // the registration's description promises) AND reaches the component as the // `data` PROP through `index.tsx`'s `resolveExternalData`, which is what // actually draws it. - const dataConfig = useMemo(() => resolveRecordSourceConfig(schema, 'array'), [ - (schema as any).data, - (schema as any).staticData, + // + // ⚠️ NAMED SEAM, and the reason the three members are passed one by one + // instead of handing the whole node over (objectui#8651). The shared + // resolver's PARAMETER declares `data?: ViewData`, but its own `dataArm` + // contract — and its `authoredDataIsOnTheDeclaredArm` predicate, which takes + // `unknown` — admit an ARRAY on the `'array'` arm, which is exactly the arm + // this block declares (`ObjectCalendarSchema.data`, objectui#9239/#8348). So + // the signature contradicts the function's own documented contract, and the + // retired union hid it: `ObjectGridSchema.data` is `ViewData`, so the call + // type-checked while saying something this block does not mean. + // + // ⛔ That is an upstream defect in `@object-ui/core`, NOT a licence to widen + // anything here (AGENTS.md #0.1) — and `packages/core/` is outside this + // card's file surface. Reported rather than patched. This spelling passes + // only the three members the resolver documents itself as reading, with the + // `data` member named at the arm this block declares, so the RUNTIME value + // reaching the predicate is byte-for-byte the one `schema.data` held before. + const dataConfig = useMemo(() => resolveRecordSourceConfig( + { + objectName: schema.objectName, + data: schema.data as ViewData | undefined, + staticData: schema.staticData, + }, + 'array', + ), [ + schema.data, + schema.staticData, schema.objectName, ]); // Every key `getCalendarConfig` reads, and nothing it does not. @@ -350,12 +486,16 @@ export const ObjectCalendar: React.FC = ({ // filter change for nothing. // // Dropping it also removes an accidental co-trigger, so the three flat keys - // the function reads but this list never named are added in the same edit: - // `startDateField` and `endDateField` (the canonical half of the - // `dateField` / `endField` pairs below, which WERE named) and `allDayField`. - // Before this change a simultaneous `filter` change could recompute the memo - // and pick those up by luck; that luck is now gone, so the list has to be - // honest. Pinned in `__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx`. + // the function reads but this list never named were added in the same edit: + // `startDateField`, `endDateField` and `allDayField`. Before that change a + // simultaneous `filter` change could recompute the memo and pick those up by + // luck; that luck is now gone, so the list has to be honest. Pinned in + // `__tests__/ObjectCalendar.filterIsNotAConfigSlot-7711.test.tsx`. + // + // ⚠️ The two alias rungs are listed too, and they have to be: this list is + // exactly what `getCalendarConfig` reads, in BOTH directions. A cut of + // objectui#8651 dropped them alongside a retirement that broke a live + // authoring path; the rungs came back, so these came back with them. // // ⭐ objectui#8026 — `allDayField` is now LOAD-BEARING here, not merely // honest. When #7711 named it, nothing read the key, so the dependency could @@ -368,14 +508,14 @@ export const ObjectCalendar: React.FC = ({ // now depend on this entry live in // `__tests__/ObjectCalendar.allDayFieldIsHonoured-8026.test.tsx`. const calendarConfig = useMemo(() => getCalendarConfig(schema), [ - (schema as any).calendar, - (schema as any).startDateField, - (schema as any).dateField, - (schema as any).endDateField, - (schema as any).endField, - (schema as any).titleField, - (schema as any).colorField, - (schema as any).allDayField + schema.calendar, + schema.startDateField, + (schema as CalendarAliasRungs).dateField, + schema.endDateField, + (schema as CalendarAliasRungs).endField, + schema.titleField, + schema.colorField, + schema.allDayField ]); const hasInlineData = dataConfig?.provider === 'value'; /** @@ -800,6 +940,21 @@ export const ObjectCalendar: React.FC = ({ // CLOSED, not open — do not re-open it as a cleanup. If bucket-vocabulary // unification ever becomes a product direction that is a fresh ruling, // with visual-regression evidence across all four surfaces in one stroke. + // ⛔ The ONE cast objectui#8651 left standing, deliberately. `navigation` is + // objectui#8652's key: the maintainer ruled B there — declare it on the + // platform element schemas first, then mirror — and that card is `pm:blocked` + // on objectstack#17987. Its declaredness verdict at this read site is + // UNCHANGED by this card: through the retired union it was undeclared too, + // and it is undeclared on `ObjectCalendarSchema`. The rule that makes that + // come out right is NOT "declared on every arm". In the checker reading + // recorded above, five keys ride the union although only ONE arm declares + // them — `colorField`, `dateField`, `defaultView`, `endField` and + // `titleField` — because `ObjectGridSchema`'s index signature supplies them. + // The rule is: a union + // member is available only when EVERY arm supplies it — by its own + // declaration OR through an applicable index signature. `CalendarSchema` has + // neither for `navigation`, so the union does not carry it. Ledgered by name, and + // asserted to be STILL READ, in `__tests__/calendarUnionReads-8651.test.tsx`. const navConfig = (schema as any).navigation ?? { mode: 'drawer' }; const navIsOverlay = navConfig.mode === 'drawer' || navConfig.mode === 'modal' || navConfig.mode === 'split' || navConfig.mode === 'popover'; const navigation = useNavigationOverlay({ diff --git a/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx new file mode 100644 index 0000000000..95769f7b8b --- /dev/null +++ b/packages/plugin-calendar/src/__tests__/calendarUnionReads-8651.test.tsx @@ -0,0 +1,528 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#8651 — `ObjectCalendar` is typed at the PUBLISHED schema of the + * element it is registered as, and every key it reads off the node is declared + * there. + * + * ## What was measured, and why the card's three-way split collapsed to two + * + * The card filed twelve keys in two classes — four undeclared on both arms of + * `ObjectGridSchema | CalendarSchema`, eight declared on exactly ONE arm — and + * asked, as its FIRST question, whether that union is right at all. Measured + * with the TypeScript checker on the merge-base (`getPropertyOfType`, never a + * grep — objectui#8410), the answer decides eleven of the twelve at once: + * + * ⚠️ Note what the union itself answers, because it is easy to get backwards + * and this file's first cut did: on the UNION only SEVEN of the fifteen reads + * were undeclared. `ObjectGridSchema` carries `BaseSchema`'s index signature, + * so the five `CalendarSchema`-only keys resolved through it and compiled with + * no cast — silently typed `any`, which is the defect rather than the absence + * of one. Per-ARM is the reading the card tabled; per-UNION is the reading the + * compiler acts on; they are different numbers and both are measured here. + * + * `ObjectCalendarSchema` — this repo's published interface for the + * `object-calendar` element, and its zod mirror — ALREADY declares eleven of + * the fifteen keys this renderer reads, `allDayField`, `startDateField`, + * `endDateField`, `titleField`, `colorField` and `defaultView` among them + * (objectui#8466, #8174, #8314, #9239). Neither arm of the union it was + * actually annotated with is that interface. + * + * ⇒ the reads were never undeclared for want of a declaration; they were + * undeclared because the props type pointed somewhere else. Both arms go: + * + * - `ObjectGridSchema` is `type: 'object-grid'`. Nothing hands this component + * one — the single production call site is this package's own + * `index.tsx`, and both of its registrations (`object-calendar` and + * `calendar`) publish `OBJECT_CALENDAR_INPUTS`, the `object-calendar` + * surface. + * - `CalendarSchema` was declared LOCALLY in `ObjectCalendar.tsx`, absent + * from this package's barrel — so no importer could ever name it — and it + * SHADOWED `@object-ui/types`' own published `CalendarSchema`, which is the + * date-picker primitive (`form.ts`, reachable at `ui:calendar` only, + * objectui#8499). Two layers, one word. + * + * The shape this leaves is the one every sibling widget in the family already + * has: `ObjectKanban` takes `ObjectKanbanSchema`, `ObjectGantt` takes + * `ObjectGanttSchema`, `ObjectMap` takes `ObjectMapSchema` — and `plugin-map` + * is registered under TWO tags (`object-map` and `map`) with a single published + * props type, exactly as this package is. + * + * ## The two keys that do NOT come along, and their separate exits + * + * - `calendar` is declared by NO face of this repo, while + * `@objectstack/spec`'s `ComponentPropsMap['object-calendar']` declares it + * as the configuration container AND this package's registration `inputs` + * publishes it AND `getCalendarConfig` reads it FIRST. Triage's ruling for + * the family (objectui#8327, comment `5619610246`) makes that exit + * mechanical: declared in spec ⇒ align the mirror. Declared here. + * - `dateField` / `endField` are ROUTED TO THE PRODUCER, the third exit. + * ⛔ An earlier cut of this card RETIRED them on a census that was FALSE, + * and the false census is recorded here because it is the transferable + * part. It said: zero producers write either spelling onto a calendar node. + * It could not see the producer because the producer does not write the key + * LITERALLY — `plugin-list/src/ListView.tsx`'s `case 'calendar':` SPREADS + * the authored block flat onto the node it emits, objectui's own published + * `ListViewSchema` accepts `calendar.dateField`, and + * `resolveTimelineDateBinding` in that file documents it as *"the pre-#2231 + * alias for `startDateField`"* and honours it. A word-boundary text census + * is structurally blind to a key arriving through a spread. Measured by + * mounting the producer: `calendar: { dateField, titleField }` emits a node + * with a flat `dateField` and NO `startDateField`, which the merge-base + * draws and the retiring tree refused. ⇒ the remedy is at the producer, + * which is outside this card's file surface, so the rungs STAY and both + * keys are ledgered below with the producer named. The standing carrier for + * the alias question is objectui#8355 (open, undecided); ⛔ this card does + * not open a second one and does not decide it. + * + * ## ⛔ `navigation` is NOT ruled here, and its verdict is INVARIANT + * + * objectui#8652 carries the `navigation` family; the maintainer ruled B + * (declare on the platform element schemas first, then mirror), the spec half + * is objectstack#17987, and #8652 is `pm:blocked` on it. This card must not + * rule, declare, retire or touch it. + * + * ⭐ It does not, and that is measurable rather than asserted: through the + * UNION the key was already undeclared, and on `ObjectCalendarSchema` it is + * undeclared too. ⚠️ The rule is NOT "declared on every arm" — the reading + * recorded at the top of this file carries five keys on the union that only ONE + * arm declares (`colorField` `dateField` `defaultView` `endField` `titleField`). + * It is: a union member is available only when EVERY arm supplies it — by its + * own declaration OR through an applicable index signature. `ObjectGridSchema` + * declares `navigation`; `CalendarSchema` neither declares it nor has an index + * signature to supply it; so the union does not carry it. Same + * verdict at the read site before and after; the read itself is untouched. It + * is ledgered BY NAME below, with an assertion that it is STILL READ — a stale + * exception is a hole (the objectui#8885 ledger discipline). + * + * ## What the population assertion is, and why it is not a written-down list + * + * AGENTS.md #9: a claim that needs a live population is defended by an + * instrument that re-derives it every run, never by a figure in prose. So the + * central row below takes BOTH sides from the tree at run time — the keys read + * come from a cast-aware census over `ObjectCalendar.tsx` (objectui#6576's + * `schemaReads`, comment-masked so a key that appears only in prose cannot + * move a verdict), and the keys declared come from the zod mirror's own + * `.shape`, never from parse acceptance, which under `.passthrough()` cannot + * tell "declared" from "admitted unexamined" (objectui#8466's instrument). + * + * The TS face is held to the same set by the standing `zod-mirror-parity` + * ratchet plus the compile-time pins in this file, so one row covers both + * published faces without this file re-implementing a second checker. + * + * ## The ceiling, stated rather than assumed (objectui#5155 / #7927) + * + * `BaseSchema` ends in `[key: string]: any` and its mirror is `.passthrough()`, + * so declaring a key buys VALUE validation and never buys rejection of a + * MISSPELLING. The counter-probe below pins that honestly, so nobody reads this + * file as claiming more than it does. + */ + +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { ComponentPropsMap } from '@objectstack/spec/ui'; +import { ObjectCalendarSchema as ObjectCalendarMirror } from '@object-ui/types/zod'; +import type { ObjectCalendarSchema } from '@object-ui/types'; + +import { ObjectCalendar, type ObjectCalendarComponentProps } from '../ObjectCalendar'; + +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + +/** Local annotation, since the import above is untyped — the call site stays checked. */ +const mask: (source: string) => string = maskComments; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const CALENDAR_READER = 'packages/plugin-calendar/src/ObjectCalendar.tsx'; + +/** + * The ONE key this card deliberately does not rule on — objectui#8652's remit, + * maintainer-ruled B and blocked on objectstack#17987. Every entry must still + * be READ; see the header for why the ledger asserts that and nothing else. + */ +const LEDGERED_OTHER_CARD_READS = ['navigation'] as const; + +/** + * The two PRE-#2231 alias spellings this card ROUTES TO THE PRODUCER rather + * than declaring or retiring. Both are genuinely undeclared on + * `ObjectCalendarSchema` and must stay so. + * + * ⚠️ The ground is NOT that the spec singles these two out. MEASURED on + * installed `@objectstack/spec` 17.4.0: `ComponentPropsMap['object-calendar']` + * is STRICT and declares exactly nine flat members — `calendar` `data` + * `defaultView` `filter` `loading` `locale` `objectName` `sort` `staticData` — + * so it refuses every undeclared flat key with the same `unrecognized_keys` + * diagnostic: these two aliases, a nonsense key, AND the five canonical field + * keys `ObjectCalendarSchema` already declares and this renderer reads (`startDateField` + * `endDateField` `titleField` `colorField` `allDayField`). ⛔ Blanket strictness cannot be the reason these + * two stay undeclared — applied as a reason it would require undeclaring those + * canonical five, and this repo's mirror being stricter than the protocol is + * the SANCTIONED direction anyway (see `zod/objectql.zod.ts`). + * + * The real ground is narrower: they are deprecated pre-#2231 ALIASES of keys + * this schema already declares, and the alias question has an open carrier — + * objectui#8355 — which has not ruled. Declaring an alias would settle that + * card by accretion; routing it to the producer leaves it open. + * + * Each entry is asserted to be STILL READ, and the + * PRODUCER is asserted to still flatten its block, so when `ListView` is fixed + * this ledger reddens rather than rotting into a permanent exemption. + */ +const ROUTED_TO_PRODUCER = ['dateField', 'endField'] as const; + +/** Their canonical twins — the control that makes any zero above a reading. */ +const CANONICAL_TWINS = ['startDateField', 'endDateField'] as const; + +/** The producer that makes the two aliases reachable, and the branch that does it. */ +const PRODUCER = 'packages/plugin-list/src/ListView.tsx'; + +/** A key nothing reads and nothing declares: the both-ways control. */ +const CONTROL_KEY = 'zzqxNoSuchField'; + +/* ── Instruments ──────────────────────────────────────────────────────────── */ + +function readRepo(rel: string): string { + return readFileSync(join(REPO_ROOT, rel), 'utf8'); +} + +/** + * Every key read off the schema node, in EITHER form: the bare `schema.KEY` and + * the cast `(schema as any).KEY`. Both spell the same FACT — the renderer reads + * this key — and only one spells it through a cast that this card's cleanup + * deletes. Reading the fact rather than the spelling is what stops these + * verdicts reddening on that cleanup (objectui#8832). Borrowed verbatim from + * `types/src/__tests__/calendar-flat-color-allday-8466.test.ts`. + */ +const SCHEMA_READ = /(?:\bschema\b|\(\s*schema\s+as\s+[^)]*\))\s*\.\s*([A-Za-z_$][\w$]*)/g; + +function schemaReads(source: string): Set { + return new Set([...source.matchAll(SCHEMA_READ)].map((m) => m[1])); +} + +/** The renderer's reads, with prose masked so a key named only in a comment cannot vote. */ +function rendererReads(): Set { + return schemaReads(mask(readRepo(CALENDAR_READER))); +} + +/** Declared membership, off the mirror's OWN shape — never off parse acceptance. */ +function shapeKeys(schema: unknown): string[] { + return Object.keys((schema as { shape: Record }).shape); +} + +/** + * ⚠️ `ComponentPropsMap` entries are read through `Record` on + * purpose: `_def` is a zod INTERNAL for which the spec publishes no type, so a + * hand-written shape for it would be a local assertion about a third-party + * runtime that nothing re-checks. The same reading, and the same decision, as + * `calendar-flat-color-allday-8466.test.ts`. + */ +function specShapeKeys(type: string): string[] { + const entry = (ComponentPropsMap as unknown as Record)[type]; + const def = entry._def; + const shape = typeof def.shape === 'function' ? def.shape() : def.shape; + return Object.keys(shape); +} + +/* ── Compile-time pins (compiled by `tsc -p tsconfig.test.json`) ───────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** + * `Equal`, not `extends`: a union arm carrying `BaseSchema`'s index signature + * makes an UNDECLARED member read `any`, and a one-way check accepts `any` on + * both sides — which is precisely the before-state this card removes. + */ +export type assertionSchemaPropIsThePublishedElementSchema = + Expect>; +/** The helper can FAIL — synthetic control. */ +export type assertionEqualCanFail = Expect, false>>; + +/* ── 1. The props type ────────────────────────────────────────────────────── */ + +describe('objectui#8651 — the props type is the published element schema', () => { + it('accepts the flat calendar vocabulary contextually typed, with NO cast', () => { + const schema: ObjectCalendarComponentProps['schema'] = { + type: 'object-calendar', + objectName: 'duly_task', + startDateField: 'kickoff', + endDateField: 'wrapup', + titleField: 'nickname', + colorField: 'status_colour', + allDayField: 'is_all_day', + defaultView: 'week', + }; + expect(schema.startDateField).toBe('kickoff'); + expect(schema.defaultView).toBe('week'); + }); + + it('accepts the nested `calendar` container the spec names, contextually typed', () => { + const schema: ObjectCalendarComponentProps['schema'] = { + type: 'object-calendar', + objectName: 'duly_task', + calendar: { startDateField: 'kickoff', endDateField: 'wrapup', titleField: 'nickname' }, + }; + expect(schema.calendar?.startDateField).toBe('kickoff'); + }); +}); + +/* ── 2. The population: every read is declared, or ledgered by name ────────── */ + +describe('objectui#8651 — every key read off the node is declared on that schema', () => { + it('no read is undeclared, with `navigation` ledgered by name', () => { + const reads = rendererReads(); + const declared = new Set(shapeKeys(ObjectCalendarMirror)); + const exempt = new Set([...LEDGERED_OTHER_CARD_READS, ...ROUTED_TO_PRODUCER]); + const undeclared = [...reads] + .filter((key) => !declared.has(key)) + .filter((key) => !exempt.has(key)) + .sort(); + expect(undeclared, `undeclared reads on ObjectCalendarSchema: ${undeclared.join(', ')}`) + .toEqual([]); + }); + + it('the ledger is not stale: every carve-out is STILL read', () => { + const reads = rendererReads(); + for (const key of LEDGERED_OTHER_CARD_READS) { + expect([...reads], `${key} is ledgered but no longer read — the exception is a hole`) + .toContain(key); + } + }); + + it('⛔ and `navigation` stays UNDECLARED here — objectui#8652 owns it, not this card', () => { + expect(shapeKeys(ObjectCalendarMirror)).not.toContain('navigation'); + }); + + it('CONTROL: both halves of the row above can fail', () => { + // The census sees a read that is there… + expect([...rendererReads()]).toContain('objectName'); + // …and does not invent one that is not. + expect(rendererReads().has(CONTROL_KEY)).toBe(false); + // The declared set is a real set, not everything. + expect(shapeKeys(ObjectCalendarMirror)).toContain('startDateField'); + expect(shapeKeys(ObjectCalendarMirror)).not.toContain(CONTROL_KEY); + // And the census fires on both spellings, so the cast cleanup cannot mute it. + const probe = schemaReads('const a = schema.alphaKey; const b = (schema as any).betaKey;'); + expect([...probe].sort()).toEqual(['alphaKey', 'betaKey']); + }); +}); + +/* ── 3. `calendar`, the container the spec names ───────────────────────────── */ + +describe('objectui#8651 — `calendar` is declared; the spec sets the KEY, objectui sets the SHAPE', () => { + it('⚠️ the spec declares the KEY but NOT its shape — the slot refuses nothing', () => { + // The grounds, stated the way the instrument returns them. An earlier cut + // of this card said the spec declares the container and that "no conforming + // author could write" the alias spellings inside it. The first half is + // true; the second is false, and this row is why. + const oc = (ComponentPropsMap as unknown as Record)['object-calendar']; + const inside = (value: unknown) => oc.safeParse({ objectName: 'duly_task', calendar: value }).success; + expect(inside({ startDateField: 'kickoff' })).toBe(true); // known-accepted control + expect(inside({ [CONTROL_KEY]: 'x' })).toBe(true); // ⇒ the slot is not strict + expect(inside({ dateField: 'kickoff' })).toBe(true); // so the alias IS writable + expect(inside(42)).toBe(true); // it is `z.unknown()` + // CONTROL, same instrument, one level out: the element's props schema IS + // strict, so the reading above is about this SLOT and not a dead parser. + expect(oc.safeParse({ objectName: 'duly_task', [CONTROL_KEY]: 'x' }).success).toBe(false); + }); + + it('the spec declares it at the flat position (control: a nonsense key on the same call)', () => { + expect(specShapeKeys('object-calendar')).toContain('calendar'); + const oc = (ComponentPropsMap as unknown as Record)['object-calendar']; + expect(oc.safeParse({ objectName: 'duly_task', calendar: { startDateField: 'kickoff' } }).success) + .toBe(true); + expect(oc.safeParse({ objectName: 'duly_task', [CONTROL_KEY]: 'x' }).success).toBe(false); + }); + + it('the mirror declares it — read off the shape, not off acceptance', () => { + expect(shapeKeys(ObjectCalendarMirror)).toContain('calendar'); + }); + + it('…and now VALUE-validates it, which is the whole of what declaring buys', () => { + const node = { type: 'object-calendar', objectName: 'duly_task' }; + expect(ObjectCalendarMirror.safeParse({ ...node, calendar: 42 }).success).toBe(false); + const ok = ObjectCalendarMirror.safeParse({ + ...node, + calendar: { startDateField: 'kickoff', titleField: 'nickname', allDayField: 'is_all_day' }, + }); + expect(ok.success, JSON.stringify(ok.error?.issues)).toBe(true); + }); + + it('the objectui#7927 ceiling is UNCHANGED: a misspelling is still admitted', () => { + const node = { type: 'object-calendar', objectName: 'duly_task' }; + expect(ObjectCalendarMirror.safeParse({ ...node, calender: { startDateField: 'k' } }).success) + .toBe(true); + }); +}); + +/* ── 4. The routed spellings ───────────────────────────────────────────────── */ + +describe('objectui#8651 — the `dateField` / `endField` rungs are ROUTED, not retired', () => { + it('both are STILL READ — the rungs stay until the producer is fixed', () => { + const reads = rendererReads(); + for (const key of ROUTED_TO_PRODUCER) { + expect([...reads], `${key} is ledgered as routed but is no longer read`).toContain(key); + } + }); + + it('CONTROL: the same census sees the canonical twins, and invents nothing', () => { + const reads = rendererReads(); + for (const key of CANONICAL_TWINS) expect([...reads]).toContain(key); + expect(reads.has(CONTROL_KEY)).toBe(false); + }); + + it('⛔ neither is DECLARED — declaring would accept what the platform refuses', () => { + const declared = shapeKeys(ObjectCalendarMirror); + for (const key of ROUTED_TO_PRODUCER) expect(declared).not.toContain(key); + // …and that is the spec's verdict, not an assumption. + // + // ⭐ THE CONTROL IS `startDateField`, NOT a nonsense key. A nonsense key is + // refused for exactly the same reason the subject is — the props object is + // strict — so it shares the suspect part of the instrument and cannot + // separate "the spec singles these two out" from "the spec refuses every + // undeclared flat key". `startDateField` varies only the claim: it is a key + // this very card DECLARES on the mirror, and the spec refuses it flat too. + // Its `false` is the finding, not a failure: it shows the refusal is + // blanket, which is why blanket strictness is not the routing's ground. + const oc = (ComponentPropsMap as unknown as Record)['object-calendar']; + expect(oc.safeParse({ objectName: 'duly_task' }).success).toBe(true); + expect(oc.safeParse({ objectName: 'duly_task', startDateField: 'kickoff' }).success, + 'the spec now accepts a flat startDateField — the refusal is no longer blanket, so re-read the routing note') + .toBe(false); + for (const key of ROUTED_TO_PRODUCER) { + const r = oc.safeParse({ objectName: 'duly_task', [key]: 'x' }); + expect(r.success, `the spec now accepts a flat ${key}; revisit this routing`).toBe(false); + } + }); + + it('the LEDGER IS NOT STALE: the producer still flattens its block onto the node', () => { + // The carrier assertion. `ListView`'s calendar branch ends by spreading the + // authored `calendar` block FLAT onto the `object-calendar` node it emits, + // which is the whole reason an authored `calendar.dateField` reaches this + // renderer as a flat key. When that branch normalises the aliases instead, + // this row reddens and the two rungs above can finally go. + const producer = mask(readRepo(PRODUCER)); + const at = producer.indexOf("case 'calendar':"); + expect(at, `${PRODUCER}: the calendar branch is gone; re-derive this ledger`).toBeGreaterThan(-1); + // Bound the slice STRUCTURALLY, at the next `case` label, rather than by a + // character count — a magic window either overshoots into the sibling + // branch (measured: 2000 chars reaches `case 'gallery':`) or silently + // undershoots past the spread this row is about. + const nextCase = producer.indexOf("case '", at + 1); + expect(nextCase, `${PRODUCER}: no branch follows the calendar one; the bound is unsafe`) + .toBeGreaterThan(at); + const branch = producer.slice(at, nextCase); + expect(branch, `${PRODUCER} no longer flattens the authored calendar block`) + .toContain('...(schema.calendar || {})'); + // CONTROLS: the slice really is just this branch — it carries this branch's + // own content and none of the next one's. + expect(branch).toContain('startDateField'); + expect(branch).not.toContain("case 'gallery':"); + expect(branch.length).toBeLessThan(producer.length); + }); +}); + +/* ── 5. …and the regression is observable on screen ──────────────────────── */ + +vi.mock('@object-ui/plugin-detail', async (importOriginal) => ({ + ...(await importOriginal()), + RecordDetailPanel: () => null, + deriveRecordPageHref: () => null, +})); + +const OBJECT_SCHEMA = { + name: 'duly_task', + nameField: 'subject', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text' }, + nickname: { name: 'nickname', type: 'text' }, + kickoff: { name: 'kickoff', type: 'datetime' }, + }, +}; + +const ROW = { + id: 'r1', + subject: 'Default display name', + nickname: 'Authored event title', + kickoff: '2026-03-01T09:00:00.000Z', +}; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [ROW], total: 1 })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => OBJECT_SCHEMA), + } as any; +} + +const REFUSAL = /Calendar configuration required/i; + +/** + * ⭐ THE REGRESSION ROW. An earlier cut of this card retired the two alias rungs + * and this suite was GREEN, the whole farm was GREEN, and a live authoring path + * had stopped rendering. What was missing was not a stricter assertion anywhere + * in it — it was THIS node. + * + * The shape is not invented: it is what `ListView` really emits for a view + * authored `calendar: { dateField, titleField }` — captured by mounting the + * producer with a spy registration — a flat `dateField`, a `titleField`, and NO + * `startDateField`. Measured on both trees with the identical node: the + * merge-base draws, the retiring tree drew "Calendar configuration required". + * + * ⛔ Do not relax this row to make a retirement pass. The retirement is + * available only once the PRODUCER normalises the alias, and the ledger row + * above is what reports that. + */ +describe('objectui#8651 — the node the producer really emits still draws', () => { + it('a node carrying ONLY the aliased date binding renders, and does not refuse', async () => { + render( + , + ); + await waitFor(() => expect(screen.queryByTestId('object-calendar-shell') ?? document.body).toBeTruthy()); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(screen.queryByText(REFUSAL), 'the aliased binding stopped resolving — this is the objectui#8651 regression').toBeNull(); + }); + + it('CONTROL: the canonical spelling on the same rows also draws', async () => { + render( + , + ); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(screen.queryByText(REFUSAL)).toBeNull(); + }); + + it('CONTROL: the refusal is REACHABLE, so the two rows above are not vacuous', async () => { + // A node with no date binding at all must still refuse — otherwise + // `queryByText(REFUSAL)` returning null above would mean nothing. + render( + , + ); + await waitFor(() => expect(screen.getByText(REFUSAL)).toBeTruthy()); + }); +}); diff --git a/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts b/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts index 6843829bd8..cb69a04da3 100644 --- a/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts +++ b/packages/types/src/__tests__/object-calendar-record-source-7313.test.ts @@ -49,6 +49,11 @@ import { dirname, join } from 'node:path'; // measure it rather than restate it. `@objectstack/spec` is a declared // dependency of this package; `ComponentPropsMap` is its published UI surface. import { ComponentPropsMap } from '@objectstack/spec/ui'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + +/** Local annotation, since the import above is untyped — the call site stays checked. */ +const mask: (source: string) => string = maskComments; import { ObjectCalendarSchema, ObjectGanttSchema, safeValidateSchema } from '../zod/index.zod'; import { BaseSchema } from '../zod/base.zod'; @@ -377,20 +382,84 @@ describe('objectui#7313 — `data` and `staticData` are DECLARED, not passthroug describe('objectui#7313 — the declaration names a live read, in the declared order', () => { it('the renderer resolves its records through the shared ladder, on the ARRAY arm', () => { - const src = readFileSync(join(REPO_ROOT, RENDERER), 'utf8'); - // ⭐ objectui#8348 — the arm is part of the call now, and asserting it here - // is what keeps this row honest. The previous spelling looked for the bare - // `resolveRecordSourceConfig(schema)`, which this file's own renderer - // satisfies from a DOCBLOCK line that merely names the function — so it - // would have stayed green through a call site that had stopped existing. - expect(src, `${RENDERER} no longer calls the shared ladder with its declared arm`).toContain( - "resolveRecordSourceConfig(schema, 'array')", - ); + // ⭐ objectui#8348 — the arm is part of the call, and asserting it here is + // what keeps this row honest. The spelling before that card looked for the + // bare `resolveRecordSourceConfig(schema)`, which this renderer satisfies + // from a DOCBLOCK line that merely names the function — so it would have + // stayed green through a call site that had stopped existing. + // + // ⭐ objectui#8651 re-anchored it a second time, for the objectui#8832 + // reason: the literal `resolveRecordSourceConfig(schema, 'array')` pinned + // how the FIRST ARGUMENT is written, and that card had to change it — the + // ladder's parameter declares `data?: ViewData` while this block's + // published `data` row is the ARRAY arm, so the three members it documents + // itself as reading are now passed one by one. The call, the arm and the + // refusal of the other arm are the FACTS; the argument's shape is + // formatting. So the arms are read out of the call's own argument list, + // located by paren matching, with comments masked FIRST — which retires the + // docblock false green structurally rather than by wording. + const src = mask(readFileSync(join(REPO_ROOT, RENDERER), 'utf8')); + const at = src.indexOf('resolveRecordSourceConfig('); + expect(at, `${RENDERER} no longer calls the shared ladder at all`).toBeGreaterThan(-1); + let depth = 0; + let end = at + 'resolveRecordSourceConfig'.length; + let closed = false; + for (; end < src.length; end += 1) { + if (src[end] === '(') depth += 1; + else if (src[end] === ')') { + depth -= 1; + if (depth === 0) { + closed = true; + break; + } + } + } + const call = src.slice(at, end + 1); + expect(closed, 'the paren match ran away — every assertion below is void').toBe(true); + + // ⭐ READ THE ARGUMENT LIST, do not search the slice for the arm's TEXT. + // + // This row shipped with `call.length < src.length` and `call.endsWith(')')` + // as its control, and objectui#8651's contract review showed both are + // satisfied by a paren match that RAN AWAY — a runaway slice is shorter + // than the file and ends in a paren. The control passed in exactly the case + // it existed to catch. + // + // ⚠️ And the obvious repairs do not close it either, which is worth writing + // down so the next person does not re-derive it: delete this call's own + // closing paren and the matcher simply closes on `useMemo`'s instead, + // swallowing the dependency array. That runaway slice still reports + // `closed`, is still balanced on every bracket kind, and still contains no + // declaration — a dependency array is a legal call argument, so NO + // structural test on the slice can separate the two. Measured: 179 chars + // genuine, 249 runaway. + // + // What DOES separate them is the thing this row actually claims — the arm + // is the call's LAST ARGUMENT, not a string that appears somewhere inside + // it. Splitting the argument list at depth 0 gives `['{…}', "'array'"]` for + // the real call and a third `[…]` argument for the runaway, so the arity + // assertion fires. It is also reformat-stable, which a length ceiling is + // not. + const inner = call.slice(call.indexOf('(') + 1, -1); + const args: string[] = []; + let buf = ''; + let d = 0; + for (const ch of inner) { + if ('([{'.includes(ch)) d += 1; + else if (')]}'.includes(ch)) d -= 1; + if (ch === ',' && d === 0) { args.push(buf.trim()); buf = ''; continue; } + buf += ch; + } + args.push(buf.trim()); + const positional = args.filter((a) => a.length > 0); + expect(positional, `${RENDERER}: the ladder call no longer takes exactly (schema, arm)`) + .toHaveLength(2); // The arm is the one `ComponentPropsMap['object-calendar'].data` declares // (`z.array(z.unknown())`, "Pre-fetched records"), which is why it is // `'array'` here and `'view-data'` on `object-grid` / `object-map` / // `object-gantt`. - expect(src).not.toContain("resolveRecordSourceConfig(schema, 'view-data')"); + expect(positional[1], `${RENDERER} no longer calls the shared ladder with its declared arm`) + .toBe("'array'"); }); it('the ladder reads `data`, then `staticData`, then `objectName` — the order the refinement rests on', () => { diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 9a316eef6a..dcf8b1961e 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3181,6 +3181,13 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // i18n label union, or the widget's comparison directive moves ONE side too. // Either way it is exactly what this list exists to make legible rather than // mysterious. + // objectui#8651: the `calendar` CONTAINER is the spec's own + // `SpecCalendarConfigSchema` — spec-derived, extended with objectui's single + // local knob (`allDayField`, the lane objectui#8466 took for the flat spelling + // of the same vocabulary) and kept `.passthrough()`. So a spec bump that moves + // the four-key calendar config vocabulary moves ONE side of this pair, which + // is exactly what this list exists to make legible rather than mysterious. + 'objectql.zod.ts#ObjectCalendarSchema', 'objectql.zod.ts#ObjectChartSchema', 'objectql.zod.ts#ObjectGallerySchema', 'objectql.zod.ts#ObjectGanttSchema', diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6b1e2cefda..da51bd68cf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -446,6 +446,7 @@ export type { ViewNavigationConfig, ViewTabBarConfig, ObjectQLComponentSchema, + ObjectCalendarBlockConfig, BulkActionDef, BulkActionParam, BulkActionOperation, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index cc68e92401..941cc00d04 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -27,7 +27,22 @@ import type { DrillDownConfig } from './data-display.js'; import type { BulkActionOperation } from '@objectstack/spec/ui'; import type { FormField } from './form.js'; // ListView type is now derived from the zod schema (issue #2231) — see ListViewSchema below. -import type { ListViewInferred } from './zod/objectql.zod.js'; +import type { ListViewInferred, ObjectCalendarBlockConfig } from './zod/objectql.zod.js'; + +/** + * The type of {@link ObjectCalendarSchema.calendar}, re-exported so the + * published member has a NAME an importer can write (objectui#8651). + * + * ⛔ Spelled with its `from` clause deliberately: a re-export with no module + * specifier is judged as its own declaration by + * `scripts/check-spec-symbol-derivation.mjs`, which is the same reason + * `plugin-calendar`'s deprecated aliases carry theirs. + * + * Without this the member was typed by a name no consumer could reach — exactly + * the "measurably unreachable" property objectui#8651 removed from that + * plugin's local `CalendarSchema`, and it must not come back one layer over. + */ +export type { ObjectCalendarBlockConfig } from './zod/objectql.zod.js'; // ============================================================================ // Spec-Canonical Types — imported from @objectstack/spec/ui @@ -2803,9 +2818,11 @@ export interface ObjectCalendarSchema extends BaseSchema { objectName?: string; /** * PRE-FETCHED RECORDS — an ARRAY, drawn in place of the calendar's own query. - * Read FIRST by the shared record-source ladder - * (`resolveRecordSourceConfig(schema, 'array')` in `@object-ui/core`), ahead - * of `staticData` / `objectName`. + * Read FIRST by the shared record-source ladder in `@object-ui/core`, on the + * `'array'` arm, ahead of `staticData` / `objectName`. ⛔ The ladder's ARM is + * the citation; its first argument is not, because that is a call SHAPE and + * it has already moved once — objectui#8651 now passes the three members the + * ladder documents itself as reading, one by one. * * Declared by objectui#7313, in the same stroke as the mirror's `data`: until * then the read landed on `BaseSchema`'s index signature on this side and @@ -2843,6 +2860,39 @@ export interface ObjectCalendarSchema extends BaseSchema { data?: SpecObjectCalendarProps['data']; /** Inline records, wrapped into a `{ provider: 'value' }` config by `getDataConfig`. */ staticData?: any[]; + /** + * The configuration container, and the FIRST thing this element's renderer + * reads: `plugin-calendar/src/ObjectCalendar.tsx`'s `getCalendarConfig` + * returns this block whole when it is present, and only falls through to the + * flat members below when it is not. + * + * `@objectstack/spec` declares the KEY — + * `ComponentPropsMap['object-calendar'].calendar` — and this package's + * registration `inputs` publishes it, so authors are offered it. ⚠️ The spec + * does NOT declare its SHAPE: measured on 17.4.0 that slot is + * `z.unknown().optional()`, not `CalendarConfigSchema`, so the protocol + * accepts any value there at all. The member list below is objectui's own — + * see the mirror for the grounds. Both published faces of THIS package stayed + * silent about the key until objectui#8651, + * which is the objectui#6914 class: the value rode {@link BaseSchema}'s + * `[key: string]: any` here and `.passthrough()` on the mirror, admitted and + * never examined. `calendar: 42` type-checked, parsed green, and drew an + * empty calendar. + * + * DERIVED from the mirror rather than re-spelled, so the two faces cannot + * fork — the same construction {@link ListViewSchema} uses through + * `ListViewInferred`. What the mirror declares is the five members + * `ObjectCalendar`'s events pass destructures out of the resolved config: the + * spec's four plus objectui's own `allDayField`, on the lane objectui#8466 + * took for the flat spelling of the same vocabulary. + * + * ⛔ `defaultView` is deliberately NOT a member of this container even though + * a list VIEW's calendar block carries one: this renderer seeds its view state + * from {@link ObjectCalendarSchema.defaultView}, the FLAT member below, and + * never looks inside here. The container stays `.passthrough()`, so a block + * carrying it still parses — it is simply not advertised. + */ + calendar?: ObjectCalendarBlockConfig; /** Field for event start */ startDateField?: string; /** Field for event end */ @@ -2877,8 +2927,11 @@ export interface ObjectCalendarSchema extends BaseSchema { * * objectui-LOCAL, and the one member here with no {@link CalendarConfig} twin * to derive from: `@objectstack/spec`'s `CalendarConfigSchema` is a - * `strictObject` of four keys and refuses this one BY NAME with an - * `unrecognized_keys` diagnostic. That is the class this package's mirror + * `strictObject` of exactly `startDateField`, `endDateField`, `titleField` + * and `colorField`, so it refuses this key as UNDECLARED — ⚠️ not "by name". + * Measured on 17.4.0: it answers `allDayField` and a nonsense key with the + * identical `unrecognized_keys` diagnostic, so the refusal is blanket + * strictness and says nothing about this key in particular (objectui#8651). That is the class this package's mirror * already names out loud, where `.passthrough()` is kept explicitly for this * key — "the renderers grow config knobs ahead of the protocol (calendar's * `allDayField`, for one), and stripping them here would silently disable a diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 428c92716f..ba4907a33c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -671,6 +671,76 @@ const CalendarConfig = stripImportedDefaults(SpecCalendarConfigSchema).partial() defaultView: z.enum(['month', 'week', 'day']).optional().describe("Initial calendar view mode — 'month' | 'week' | 'day' ('agenda' was retired: objectui#5784)"), }).passthrough(); +/** + * The `object-calendar` ELEMENT's configuration container — a DIFFERENT contract + * from {@link CalendarConfig} above, which is a list VIEW's calendar block, and + * the reason the two are not one const (objectui#8651). + * + * Both derive from the same spec object. They differ on exactly one member, and + * the difference is a read site, not a preference: + * + * - a VIEW's block carries `defaultView`, and `ListView` LIFTS it onto the + * node it builds (`plugin-list/src/ListView.tsx`, the `calendar` branch), + * so the key is honoured from there. + * - this ELEMENT's block does not, because `ObjectCalendar` seeds its view + * state from the FLAT `defaultView` member of this schema and never looks + * inside the container. Declaring it here would advertise a write this + * renderer drops — `plugin-calendar/src/index.tsx` names that as this + * gate's own failure mode one layer in. + * + * ⚠️ THE MEMBER LIST IS objectui's OWN, and the spec does NOT supply it. + * MEASURED on the installed `@objectstack/spec` 17.4.0: + * `ComponentPropsMap['object-calendar'].calendar` is NOT `CalendarConfigSchema` + * — it is `z.unknown().optional()` (wrapper chain `["optional","unknown"]`, and + * not the same object reference), so at THIS position the protocol accepts + * everything: a nonsense key, a wrong-typed member, even `calendar: 42` all + * parse. `CalendarConfigSchema` is the strict four-key object the spec uses for + * a LIST VIEW's calendar block, which is a different position. + * + * ⇒ what the protocol settles here is the KEY, not its SHAPE. The shape below + * is objectui's, chosen as exactly the five members `ObjectCalendar.tsx`'s + * events pass destructures out of the resolved config — the spec's four plus + * objectui's own `allDayField`, the same objectui-local lane objectui#8466 took + * for the FLAT spelling of this vocabulary, on this same interface, for the same + * renderer. + * + * That makes this mirror STRICTER than the protocol at this position, which is + * the sanctioned direction and not the forbidden one: objectui#8327's triage + * ruling forbids accepting what the platform REFUSES, and under `BaseSchema`'s + * `.passthrough()` — which already admitted this key unexamined — a declaration + * can only narrow. The same asymmetry `filter` and `sort` already carry on this + * block. + * + * ⛔ `.passthrough()` is kept, so this declaration refuses no KEY that parses + * today: a `calendar` block carrying `defaultView`, or any other unexamined + * key, still parses exactly as it did through `BaseSchema`'s own + * `.passthrough()`. It does refuse VALUES, which is the whole of what declaring + * buys — `calendar: 42` and `calendar: { startDateField: 42 }` are refused + * where both were admitted unexamined before. + * + * ⚠️ The key/value split is stated that way on purpose. An earlier cut wrote + * "REFUSES NOTHING that parses today", which is literally false for + * `calendar: 42`: it parsed at the merge-base and is refused here. The colon + * scoped it to keys and the next sentence gave the value narrowing, so it was + * defensible — but a sentence that needs its own punctuation to stay true is + * one reader away from being wrong, and the narrowing is the point of the + * declaration rather than a footnote to it. + */ +const ObjectCalendarBlockConfigSchema = stripImportedDefaults(SpecCalendarConfigSchema).partial().extend({ + // objectui-local, no spec counterpart — see objectui#8466 for the measurement + // and the lane. The renderer honours it in BOTH positions: this container and + // the flat member of the node. + allDayField: z.string().optional().describe("Field carrying the all-day flag — objectui-local: the spec's CalendarConfigSchema is a strict object of startDateField, endDateField, titleField and colorField, so it refuses this key as undeclared, exactly as it refuses any other. LOAD-BEARING since objectui#8026"), +}).passthrough(); + +/** + * The inferred twin of {@link ObjectCalendarBlockConfigSchema}, exported so the + * TS face of `ObjectCalendarSchema.calendar` can DERIVE from this mirror rather + * than re-spell it. Two faces, one declaration — the same construction + * `ListViewSchema` already uses through `ListViewInferred`. + */ +export type ObjectCalendarBlockConfig = z.infer; + const GalleryConfig = stripImportedDefaults(SpecGalleryConfigSchema).partial().extend({ /** @deprecated legacy alias for the spec's `coverField` */ imageField: z.string().optional().describe('Deprecated alias for coverField'), @@ -986,8 +1056,9 @@ export const ObjectMapConfigSchema = z.object({ * place of the block's own query, NOT a source to fetch from. * `ComponentPropsMap['object-calendar'].data` is `z.array(z.unknown()) * .optional()` on `@objectstack/spec` 17.4.0 and the renderer honours that - * arm alone since objectui#8348 (`resolveRecordSourceConfig(schema, - * 'array')`); objectui#9239 brought this file's member onto it. + * arm alone since objectui#8348 (the shared ladder, called on the `'array'` + * arm — ⛔ the arm is the citation, not the call shape, which has moved); + * objectui#9239 brought this file's member onto it. * * ⛔ So do not read the message below as promising a fetchable source: on the * calendar, declaring `data` means handing the block rows it already has. @@ -1232,6 +1303,23 @@ export const ObjectCalendarSchema = BaseSchema.extend({ // `unknown[]`, which is exactly what `z.array(z.unknown())` infers here. data: z.array(z.unknown()).optional().describe('Pre-fetched records — an ARRAY, drawn in place of the calendar\'s own query; read FIRST by the record-source ladder. Mirrors ComponentPropsMap[\'object-calendar\'].data — the { provider, items } config object is refused by kind on this block (objectui#9239, ruling objectui#8348)'), staticData: z.array(z.any()).optional().describe('Inline records, wrapped into a { provider: value } data config — read SECOND by getDataConfig'), + // objectui#8651 — the configuration container the SPEC declares for this + // element (`ComponentPropsMap['object-calendar'].calendar`), which this + // package's registration `inputs` already publishes and which + // `ObjectCalendar.tsx`'s `getCalendarConfig` reads FIRST, ahead of the flat + // members below. Neither published face of this package named it: it rode + // `BaseSchema`'s `.passthrough()` here and its `[key: string]: any` on the TS + // side — admitted, never examined — so `calendar: 42` and + // `calendar: { startDateField: 42 }` both parsed green and then produced a + // calendar that drew nothing. + // + // The exit is the mechanical one triage ruled for this family (objectui#8327, + // comment 5619610246): the key IS declared by `@objectstack/spec`, so this + // mirror aligns to it rather than forking the contract. Mirrored at the SAME + // requiredness as `../objectql.ts` (both optional) so the zod-mirror-parity + // ratchet stays at zero drift for this pair, exactly as the `filter`/`sort` + // and `colorField`/`allDayField` pairs below. + calendar: ObjectCalendarBlockConfigSchema.optional().describe('Calendar configuration container — startDateField, endDateField, titleField, colorField (plus objectui\'s allDayField); read FIRST by getCalendarConfig, ahead of the flat spelling'), startDateField: z.string().optional().describe('Start date field'), endDateField: z.string().optional().describe('End date field'), titleField: z.string().optional().describe('Title field'), @@ -1255,7 +1343,7 @@ export const ObjectCalendarSchema = BaseSchema.extend({ // face is objectui's own lane — `titleField`/`startDateField`/`endDateField` // have shipped declared here, and absent from `inputs`, for releases. colorField: z.string().optional().describe('Field carrying the per-record event colour — a CSS colour or a semantic palette name'), - allDayField: z.string().optional().describe("Field carrying the all-day flag — objectui-local (the spec's CalendarConfigSchema refuses it by name); LOAD-BEARING since objectui#8026"), + allDayField: z.string().optional().describe("Field carrying the all-day flag — objectui-local: the spec's CalendarConfigSchema is a strict object of startDateField, endDateField, titleField and colorField, so it refuses this key as undeclared, exactly as it refuses any other. LOAD-BEARING since objectui#8026"), defaultView: z.enum(['month', 'week', 'day']).optional().describe("Default view — 'month' | 'week' | 'day', the renderer's rendered set ('agenda' was retired: objectui#5784)"), // objectui#8174 — the two query keys `ObjectCalendar.tsx` lowers onto its own // `dataSource.find` (`$filter: schema.filter`, diff --git a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts index a8ff6027ff..ef937e811a 100644 --- a/scripts/__tests__/one-authority-per-exported-name-6273.test.ts +++ b/scripts/__tests__/one-authority-per-exported-name-6273.test.ts @@ -364,7 +364,15 @@ const KNOWN_COLLISIONS: ReadonlyMap = new Map([ // `packages/types/src/navigation.ts`; data-display's were a strict SUBSET copy, so // that file re-points at navigation's one authority. ['CalendarEvent', ['packages/plugin-calendar/src/index.tsx', 'packages/types/src/complex.ts']], // the ruled-on objectui#5044 alias — see the header - ['CalendarSchema', ['packages/plugin-calendar/src/ObjectCalendar.tsx', 'packages/types/src/form.ts']], + // `CalendarSchema` sat here, colliding between + // `packages/plugin-calendar/src/ObjectCalendar.tsx` and + // `packages/types/src/form.ts`. Two unrelated meanings behind one word: the + // plugin's was the calendar VIEW's props schema, `@object-ui/types`' is the + // date-picker primitive reachable at `ui:calendar` only (objectui#8499). The + // remedy was the DELETE branch — the plugin-local one was absent from that + // package's barrel, so no importer could name it, and objectui#8651 measured + // that `ObjectCalendar`'s props belong at the published `ObjectCalendarSchema` + // instead. One authority remains, in `@object-ui/types`. ['ChatMessage', ['packages/plugin-chatbot/src/ChatbotEnhanced.tsx', 'packages/types/src/complex.ts']], ['ChatToolInvocation', ['packages/plugin-chatbot/src/ChatbotEnhanced.tsx', 'packages/types/src/complex.ts']], // `ComboboxOption` sat here, colliding between