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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .changeset/recursive-schema-input-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
"@objectstack/spec": minor
"@objectstack/platform-objects": patch
---

fix(spec): the remaining six recursive schemas name both type parameters, and the authoring artifacts stop spelling out defaults (#4195)

#4221 fixed `NavigationItemSchema` — the worst instance, and the one with a
reproducible "`defineApp` compiles `navigation: [42, 'nonsense']`" demo. This
finishes the sweep: **six more schemas** had the same shape, and the authoring
artifacts that #4171 had to work around can now be typed honestly.

`z.ZodType` takes `<Output, Input>` and `Input` defaults to `unknown`, so naming
only the first parameter leaves `z.input` of anything embedding that schema at
`unknown`. Measured with a type probe:

| | was | now |
|---|---|---|
| `QueryInput['joins']` | `unknown[]` | `JoinNodeInput[]` |
| `QueryInput['fields']` | `unknown[]` | `FieldNode[]` |
| `z.input<typeof FormFieldSchema>` | `unknown` | `FormFieldInput` |
| `z.input<typeof QuerySchema>` | `unknown` | `QueryInput` |
| `z.input<typeof StateNodeSchema>` | `unknown` | `StateNodeConfig` |
| `z.input<typeof ValidationRuleSchema>` | `unknown` | `BaseValidationRuleShape` |

New exported types: `FormFieldInput`, `JoinNodeInput`, `NavigationContributionInput`.
`FilterCondition`, `NormalizedFilter` and `FieldNode` carry no `.default()` or
`.transform()`, so their input is their output and the second parameter is the
first.

**The `z.ZodType<T>` single-parameter form is now absent from the codebase.**

## 26 hand-written defaults deleted

This is the half #4221 left on the table. #4171 had to spell out
`expanded: false` (×16) and `target: '_self'` (×10) across `setup.app.ts`,
`studio.app.ts` and `setup-nav.contributions.ts`, because those artifacts are
annotated with the PARSED type where a `.default()`ed key is required — and
retyping them to the input surface would have traded eight loud errors for no
checking at all.

With `NavigationItemInput` landed (#4221) and `NavigationContributionInput`
added here, they are annotated `AppInput` / `NavigationContributionInput`, the
defaults are defaults again, and the literals are checked for the first time.
Net across those four files: 21 lines added, 54 removed.

Verified live, not nominal: a literal omitting `expanded`/`target` compiles, and
one writing `defaultOpen` — the non-spec key #4171 found in `account.app.ts` —
is a compile error whose suggestion list names `expanded`.

## Two typed with a documented caveat

`StateNodeSchema` and `ValidationRuleSchema` reuse their hand-written type for
both parameters: exact on the input side, loose on the output side.
`StateNodeConfig` marks `type` optional though `.default('atomic')` makes it
always present; `BaseValidationRuleShape` carries a `[key: string]: unknown`
index signature. Both were already that loose — input went from `unknown` (types
nothing) to a real type, output is untouched. Making them exact means deriving
those types from their schemas instead of maintaining them beside one, which is
separate work; the caveat is written at each declaration rather than left for a
reader to find.

## Why there is still no CI gate for this

Worth recording, since #4195 proposed one: extend `check:exported-any` to fail on
"output precise but input `unknown`". Measured after this change — exactly two
schemas match, `TranslationItemSchema` and `InlineActionSchema`, and **both are
correct**: they are `z.preprocess(...)`, where an `unknown` input is zod's
semantics rather than a missing annotation. Separating those from a genuinely
missing parameter needs heuristics on emitted type names, and per the rule in
that script's own header — zero false positives, so red keeps meaning broken — a
gate that cannot be made reliable is worse than none. #4221's
`app.nav-type-assertions.ts` is the better pattern where it applies: pin the
contract at compile level rather than infer intent from shape.
13 changes: 5 additions & 8 deletions packages/platform-objects/src/apps/account.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
* self-service view, platform admins get the browsable tables.
*/

import type { App } from '@objectstack/spec/ui';
import type { AppInput } from '@objectstack/spec/ui';

export const ACCOUNT_APP: App = {
export const ACCOUNT_APP: AppInput = {
name: 'account',
label: 'Account',
description: 'Personal security and identity settings',
Expand All @@ -55,12 +55,9 @@ export const ACCOUNT_APP: App = {
// manage their own linked accounts / personal OAuth apps. RLS on each
// object scopes rows to the caller.
//
// Group open-state is `expanded` — the spec key. These entries said
// `defaultOpen`, objectui's legacy alias, which the spec has never declared
// and `.strict()` would reject; it worked only because objectui's
// NavigationRenderer still falls back to it, and because `NavigationItem`
// resolved to `any` so nothing checked the key here (#4171). Per Prime
// Directive #12 the producer is the place to fix that, not the renderer.
// Group open-state is `expanded` — the spec key. These said `defaultOpen`,
// objectui's legacy alias the spec never declared; #4171 fixed that at the
// producer per Prime Directive #12.
navigation: [
// Profile is the canonical landing — a hand-written React settings card
// (Vercel/Linear style) registered in the Console SPA as
Expand Down
29 changes: 12 additions & 17 deletions packages/platform-objects/src/apps/setup-nav.contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,14 @@
* contributions in the same group (mirrors object owner priority).
*/

import type { NavigationContribution } from '@objectstack/spec/ui';
import type { NavigationContributionInput } from '@objectstack/spec/ui';

const BASE_PRIORITY = 100;

// `target: '_self'` on every `url` entry is the schema's own default, spelled
// out: this array is annotated with the PARSED type, where a `.default()`ed key
// is required. Omitting it was only possible while `NavigationItem` resolved to
// `any` and nothing checked these literals at all (#4171).

// Marketplace entries (browse / installed) moved to
// @objectstack/cloud-connection's marketplace plugins (cloud ADR-0009:
// the nav lives and dies with the capability — no plugin, no entry).
export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
export const SETUP_NAV_CONTRIBUTIONS: NavigationContributionInput[] = [
{
app: 'setup',
group: 'group_overview',
Expand Down Expand Up @@ -102,7 +97,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
group: 'group_configuration',
priority: BASE_PRIORITY,
items: [
{ id: 'nav_settings_hub', type: 'url', target: '_self', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] },
// Workspace identity first — Localization (order 2) and Company (order 3)
// are the lowest-`order` settings manifests and the first thing a new
// company admin configures. They ship as `service-settings` manifests
Expand All @@ -111,15 +106,15 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
// admin consoles (Salesforce "Company Information", ServiceNow) surface
// both directly. No `requiredPermissions` — matches Branding (read perm is
// the app's base `setup.access`).
{ id: 'nav_settings_localization', type: 'url', target: '_self', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' },
{ id: 'nav_settings_company', type: 'url', target: '_self', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' },
{ id: 'nav_settings_branding', type: 'url', target: '_self', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' },
{ id: 'nav_settings_auth', type: 'url', target: '_self', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_mail', type: 'url', target: '_self', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_storage', type: 'url', target: '_self', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_ai', type: 'url', target: '_self', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_knowledge', type: 'url', target: '_self', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_feature_flags', type: 'url', target: '_self', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' },
{ id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' },
{ id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' },
{ id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' },
{ id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_storage', type: 'url', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_ai', type: 'url', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_knowledge', type: 'url', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] },
{ id: 'nav_settings_feature_flags', type: 'url', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' },
],
},
{
Expand Down
17 changes: 2 additions & 15 deletions packages/platform-objects/src/apps/setup.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
* matching the convention used by the HotCRM reference app.
*/

import type { App } from '@objectstack/spec/ui';
import type { AppInput } from '@objectstack/spec/ui';

export const SETUP_APP: App = {
export const SETUP_APP: AppInput = {
name: 'setup',
label: 'Setup',
description: 'Platform settings and administration',
Expand All @@ -47,18 +47,12 @@ export const SETUP_APP: App = {
requiredPermissions: ['setup.access'],
// Shell only — the stable group anchors. Children are supplied by
// `navigationContributions` from the packages that own the objects.
//
// `expanded: false` is the schema's own default, spelled out: this literal is
// annotated with the PARSED `App` type, where a `.default()`ed key is
// required. Omitting it was only possible while `NavigationItem` resolved to
// `any` and nothing checked these entries at all (#4171).
navigation: [
{
id: 'group_overview',
type: 'group',
label: 'Overview',
icon: 'layout-dashboard',
expanded: false,
requiredPermissions: ['manage_platform_settings'],
children: [],
},
Expand All @@ -67,31 +61,27 @@ export const SETUP_APP: App = {
type: 'group',
label: 'Apps',
icon: 'package',
expanded: false,
children: [],
},
{
id: 'group_people_org',
type: 'group',
label: 'People & Organization',
icon: 'users',
expanded: false,
children: [],
},
{
id: 'group_access_control',
type: 'group',
label: 'Access Control',
icon: 'shield',
expanded: false,
children: [],
},
{
id: 'group_approvals',
type: 'group',
label: 'Approvals',
icon: 'check-circle',
expanded: false,
requiredPermissions: ['manage_platform_settings'],
children: [],
},
Expand All @@ -100,15 +90,13 @@ export const SETUP_APP: App = {
type: 'group',
label: 'Configuration',
icon: 'sliders-horizontal',
expanded: false,
children: [],
},
{
id: 'group_diagnostics',
type: 'group',
label: 'Diagnostics',
icon: 'stethoscope',
expanded: false,
requiredPermissions: ['manage_platform_settings'],
children: [],
},
Expand All @@ -117,7 +105,6 @@ export const SETUP_APP: App = {
type: 'group',
label: 'Integrations',
icon: 'plug',
expanded: false,
requiredPermissions: ['manage_platform_settings'],
children: [],
},
Expand Down
16 changes: 2 additions & 14 deletions packages/platform-objects/src/apps/studio.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
* the box whenever the auth/security trio is loaded.
*/

import type { App } from '@objectstack/spec/ui';
import type { AppInput } from '@objectstack/spec/ui';

export const STUDIO_APP: App = {
export const STUDIO_APP: AppInput = {
name: 'studio',
label: 'Studio',
description: 'Metadata workbench for developers, analysts, and implementers',
Expand Down Expand Up @@ -77,17 +77,12 @@ export const STUDIO_APP: App = {
placement: 'sidebar_header',
},
],
// `expanded: false` on each group is the schema's own default, spelled out:
// this literal is annotated with the PARSED `App` type, where a `.default()`ed
// key is required. Omitting it was only possible while `NavigationItem`
// resolved to `any` and nothing checked these entries at all (#4171).
navigation: [
{
id: 'group_overview',
type: 'group',
label: 'Overview',
icon: 'layout-dashboard',
expanded: false,
children: [
{
// The application builder's front door (ADR-0080/0084): pick or
Expand Down Expand Up @@ -126,7 +121,6 @@ export const STUDIO_APP: App = {
type: 'group',
label: 'Data Model',
icon: 'database',
expanded: false,
children: [
{
id: 'nav_objects',
Expand All @@ -152,7 +146,6 @@ export const STUDIO_APP: App = {
type: 'group',
label: 'User Experience',
icon: 'layout',
expanded: false,
children: [
{
id: 'nav_apps',
Expand Down Expand Up @@ -211,7 +204,6 @@ export const STUDIO_APP: App = {
type: 'group',
label: 'Logic',
icon: 'function-square',
expanded: false,
children: [
{
id: 'nav_actions',
Expand All @@ -237,7 +229,6 @@ export const STUDIO_APP: App = {
type: 'group',
label: 'Automation',
icon: 'workflow',
expanded: false,
children: [
{
id: 'nav_flows',
Expand All @@ -262,7 +253,6 @@ export const STUDIO_APP: App = {
type: 'group',
label: 'AI',
icon: 'sparkles',
expanded: false,
children: [
{
id: 'nav_agents',
Expand Down Expand Up @@ -299,7 +289,6 @@ export const STUDIO_APP: App = {
type: 'group',
label: 'Developer',
icon: 'terminal',
expanded: false,
children: [
{
id: 'nav_api_console',
Expand Down Expand Up @@ -333,7 +322,6 @@ export const STUDIO_APP: App = {
type: 'group',
label: 'Integration',
icon: 'plug',
expanded: false,
children: [
{
id: 'nav_email_templates',
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@
"JSONValidation (type)",
"JSONValidationSchema (const)",
"JoinNode (type)",
"JoinNodeInput (type)",
"JoinNodeSchema (const)",
"JoinStrategy (const)",
"JoinType (const)",
Expand Down Expand Up @@ -3290,6 +3291,7 @@
"FormButtonConfig (type)",
"FormButtonConfigSchema (const)",
"FormField (type)",
"FormFieldInput (type)",
"FormFieldSchema (const)",
"FormSection (type)",
"FormSectionSchema (const)",
Expand Down Expand Up @@ -3350,6 +3352,7 @@
"NavigationConfig (type)",
"NavigationConfigSchema (const)",
"NavigationContribution (type)",
"NavigationContributionInput (type)",
"NavigationContributionSchema (const)",
"NavigationItem (type)",
"NavigationItemInput (type)",
Expand Down
14 changes: 13 additions & 1 deletion packages/spec/src/automation/state-machine.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,20 @@ export type StateNodeConfig = {

/**
* State Node Definition
*
* Both type arguments are given (#4195) so `z.input` is not `unknown` — a state
* machine is hand-authored, and an `unknown` input type means nothing checks
* what an author writes into `states`.
*
* Caveat worth knowing before trusting it: {@link StateNodeConfig} is written by
* hand in the AUTHORING shape (`type?` is optional), while `type` is
* `.default('atomic')` and so always present once parsed. Using it for both
* arguments is therefore exact on the input side and slightly loose on the
* output side — which is what this schema already claimed before, so nothing
* regressed. Making the output exact means re-deriving `StateNodeConfig` from
* the schema rather than maintaining it beside one; that is a separate change.
*/
export const StateNodeSchema: z.ZodType<StateNodeConfig> = z.lazy(() => z.object({
export const StateNodeSchema: z.ZodType<StateNodeConfig, StateNodeConfig> = z.lazy(() => z.object({
/** Type of state */
type: z.enum(['atomic', 'compound', 'parallel', 'final', 'history']).default('atomic'),

Expand Down
Loading
Loading