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
7 changes: 4 additions & 3 deletions .changeset/previews-read-only-spec-declared-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ a valid app showed generic badges, no targets and an invented `Landing: /`. Now
(`objectName`/`pageName`/`dashboardName`/`url`/`reportName`/`componentRef`/
`actionDef.actionName`), and the route comes from `resolveHref`, the shell's own
nav → URL mapping that `useNavPins` and `SearchResultsPage` already share — so a
link in the preview is the link the runtime follows. `homePageId` is rendered as
the nav item **id** it is, resolved to the entry it selects; it is never printed
as a path.
link in the preview is the link the runtime follows. The landing entry is
DERIVED, never read off the draft — it is the first navigation item that yields
a route, the same rule the console shell applies; `homePageId` was retired in
`@objectstack/spec` 17.0.0 and is not consulted.

**`DatasourcePreview`** — `capabilities` is a `DatasourceCapabilities` object of
boolean flags, and the preview tested `Array.isArray`, lighting the block up only
Expand Down
90 changes: 90 additions & 0 deletions .changeset/spec-17-rc2-pin-bump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
"@object-ui/types": minor
"@object-ui/core": minor
"@object-ui/layout": minor
"@object-ui/app-shell": minor
"@object-ui/data-objectstack": minor
"@object-ui/console": minor
---

Track `@objectstack/spec` 17.0.0-rc.2 (objectui#3235, #3208, #3287, #3264).

The pin moves from `^17.0.0-rc.1` to `^17.0.0-rc.2` across the workspace, and
the sibling `@objectstack/*` packages (`client` / `core` / `formula` / `lint`)
move with it — they pin `@objectstack/spec` **exactly**, so leaving them behind
kept a second copy of the spec in the tree and would have had `@objectstack/lint`
validating against rc.1 schemas that still accept keys rc.2 retires.

Breaking semantics, in FROM → TO form:

- **`app.homePageId` is retired — an app's landing page is now its first
navigation item.** An app that pinned a landing page with `homePageId` will
open on the first reachable navigation entry (by `order`) instead; the root
landing still follows `isDefault`. To restore a specific landing page, reorder
`navigation` so the intended entry comes first. Stored metadata is migrated by
`os migrate meta --from 16`. The key is a hard error now, not a stripped one:
the spec ships a tombstone that names the migration.
Upstream retired it because of its SHAPE, not its usage — it was an ID
cross-reference with no referential integrity, so a `homePageId` that pointed
at nothing silently fell back to the first navigation item anyway
(objectstack#4667, premise corrected in #4709). If the capability returns, it
returns as a flag on the navigation item itself, which cannot dangle.
- **`@object-ui/types`' `HttpMethod` now resolves to the spec's
`HttpMethodType`.** Shape is verbatim identical — the same 5-value UI subset —
and `@object-ui/types` still exports it as `HttpMethod`, so no consumer
changes. The spec renamed its `./ui` export because `HttpMethod` named two
different types depending on the import path (`./shared` / `./api` carry a
7-value enum including `HEAD` / `OPTIONS`); objectui deliberately keeps the
5-value one (objectstack#4691).
- **`AppContextSelector.includeAll` / `placement` are gone.** Neither ever did
anything in this renderer: context selectors are mandatory-scope, so no "All"
row was ever rendered, and `placement: 'topbar'` put nothing in the topbar.
Both carried schema defaults, which is why the liveness lint structurally
could not flag them — removal was the only channel that reaches an author
(framework#4509).
- **`NavigationArea.visible` / `order` / `requiredPermissions` are gone.** An
area is a layout grouping, not an access boundary. Gating moved down to the
navigation ITEM, where `visible` and `requiredPermissions` are unchanged and
still enforced. `AppSchemaRenderer`'s area switcher no longer hides an area, so
an area whose items are all gated away renders as visible-but-empty rather
than disappearing.
- **`@object-ui/core` no longer exports `NotificationProtocol`**
(`resolveNotificationConfig`, `specNotificationToToast`, `mapSeverityToVariant`,
`mapPosition`, `ToastNotification`). It bridged `@objectstack/spec/ui`'s
`Notification` / `NotificationConfig`, which objectstack#4610 deleted with no
successor. Use `resolveNotificationConfig` from `@object-ui/react`
(`NotificationContext`), which owns the live `NotificationSystemConfig` and is
what every notification surface already read. Note that the spec's *other*
`Notification` — `@objectstack/spec/api` — is the REST inbox row, a different
contract, and is deliberately NOT aliased in as a replacement.
- **The `email_template` client-side validator now uses
`EmailTemplateDefinitionSchema`.** It was pointing at the removed
`EmailTemplateSchema`, so authored templates were being checked against the
wrong contract: the live one is keyed `name` + `locale` (not `id`) and splits
the body into `bodyHtml` / `bodyText` (not `body` + `bodyType`)
(objectstack#4616 / #4807).

Fixes that are not breaking, but were only found because rc.2 stopped being
lenient — each had been passing vacuously:

- **`view` drafts are actually validated now.** The client validator named the
aggregated container schema while this admin authors first-class `ViewItem`s,
and the container used to strip `viewKind` / `config` in silence — so no view
draft ever had one of its own keys checked. It now validates each shape
against its own schema (objectui#3312).
- **The console's worked examples were wrong**, and being stripped rather than
refused: `view.list.object` (the container root already declares it),
`job.concurrency` / `job.timeoutMs` (no such keys; the spelling is `timeout`,
already in ms), `email_template.from` / `.to` (a template is not a send —
the sender override is `fromOverride`, an object), and
`datasource.capabilities` / `.healthCheck` (objectstack#4583 removed the
former; the latter was never a datasource key). These are the drafts an
author — or a model generating metadata — copies.
- Action key inventory re-derived: `ActionSchema` gained the package-lock
envelope (`_lock*` / `_package*` / `_provenance`), so a packaged action no
longer reports them as unknown keys.
- The schema-diff panel labels the new `default_mismatch` finding.
- Test fixtures pinning the retired `managedBy: 'system'` bucket now use
`engine-owned`. Protocol 17 split that value (objectstack#3355), so it
resolved to the default-writable fallback and a batch of "stays locked"
assertions had quietly stopped asserting anything.
1 change: 0 additions & 1 deletion apps/console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ Additional backend requirements for cross-origin deployments:
### AppSchema Support
- ✅ `name`, `label`, `icon` — Basic app metadata
- ✅ `description`, `version` — Optional app information
- ✅ `homePageId` — Custom landing page configuration
- ✅ `requiredPermissions` — Permission-based access control
- ✅ `branding.logo`, `branding.primaryColor`, `branding.favicon` — App branding

Expand Down
4 changes: 2 additions & 2 deletions apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@
"@object-ui/providers": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/client": "^17.0.0-rc.1",
"@objectstack/spec": "^17.0.0-rc.1",
"@objectstack/client": "^17.0.0-rc.2",
"@objectstack/spec": "^17.0.0-rc.2",
"@tailwindcss/postcss": "^4.3.3",
"@tailwindcss/typography": "^0.5.20",
"@testing-library/jest-dom": "^7.0.0",
Expand Down
32 changes: 21 additions & 11 deletions apps/console/src/__tests__/preview-samples-spec-valid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,21 @@
* asks precisely the question that matters: would this sample survive being
* published in a real stack?
*
* LIMIT — worth knowing before trusting a pass. Only some element schemas are
* `.strict()` (`tools`, `apps`, `flows`, `permissions`, `positions`,
* `datasources` are; `views`, `jobs`, `emailTemplates` are not). For the
* non-strict ones an unknown or retired key is stripped rather than rejected,
* so a PASS there proves the sample is structurally sound, NOT that it is free
* of retired keys. The guard is exactly as strict as the spec is. `RETIRED_KEYS`
* below covers the named retirements regardless, which is the only reason the
* `tool` / `report` rows mean anything: `report` reached SPEC_CLEAN by binding a
* dataset, and its stripped pre-9.0 `object` / `groupBy` keys would not have
* failed anything on their own.
* LIMIT — worth knowing before trusting a pass. This used to read "only some
* element schemas are `.strict()`", and it named `views` / `jobs` /
* `emailTemplates` as the lenient ones. Spec 17.0.0 closed that gap: those
* three now reject unknown keys by name too, which is how the 17.0.0-rc.2 pin
* bump caught this file's `view.list.object`, `job.concurrency` /
* `job.timeoutMs` and `email_template.from` / `.to` — four stale samples that
* had been passing precisely because the key was stripped instead of refused.
*
* Some element schemas are still non-strict, and for those a PASS proves the
* sample is structurally sound, NOT that it is free of retired keys — the guard
* is exactly as strict as the spec is. `RETIRED_KEYS` below covers the named
* retirements regardless, which is the only reason the `tool` / `report` rows
* mean anything: `report` reached SPEC_CLEAN by binding a dataset, and its
* stripped pre-9.0 `object` / `groupBy` keys would not have failed anything on
* their own.
*/

import { describe, it, expect } from 'vitest';
Expand Down Expand Up @@ -159,7 +164,12 @@ const RETIRED_KEYS: Array<[type: string, key: string, adjudication: string]> = [
['agent', 'knowledge', 'objectstack#3896'],
['skill', 'triggerPhrases', 'objectstack#3896'],
['flow', 'onTimeout', 'objectstack#4158'],
['app', 'landing', 'objectstack#4001 — use `homePageId`'],
// `landing` was replaced by `homePageId` in objectstack#4001, and
// `homePageId` was itself retired in objectstack#4667 / #4709. There is no
// authorable landing key any more: the landing page IS the first navigation
// item (by `order`), and the root landing follows `isDefault`.
['app', 'landing', 'objectstack#4001 — landing is now the first nav item'],
['app', 'homePageId', 'objectstack#4667 / #4709 — landing is now the first nav item'],
];

/** Every key name appearing anywhere in `value`, at any depth. */
Expand Down
6 changes: 4 additions & 2 deletions apps/console/src/components/RootLandingRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
* The landing is a build/dev-time PRODUCT decision, declared in metadata — not a
* runtime, per-tenant, Settings-UI preference. Resolution order (see
* {@link resolveLandingPath}):
* 1. the App marked `isDefault: true` → `/apps/<it>` — and that App's own
* `homePageId` then selects the landing page within it;
* 1. the App marked `isDefault: true` → `/apps/<it>` — and the landing page
* WITHIN that app is its first navigation item (by `order`); it used to
* be selectable with `homePageId`, retired in spec 17.0.0
* (objectstack#4667 / #4709);
* 2. else the single visible App (`active !== false && hidden !== true`)
* → `/apps/<it>` (a one-app deployment shouldn't show a one-tile launcher);
* 3. else `/home` — the multi-app workspace launcher (the legacy default).
Expand Down
39 changes: 26 additions & 13 deletions apps/console/src/preview-samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
object: 'sales_order',
list: {
type: 'grid',
object: 'sales_order',
// No `object` here: the view root above declares it. The list view is
// `.strict()` since spec 17.0.0, so repeating it one level down is a
// hard error rather than a silently dropped key.
columns: ['name', 'amount', 'status', 'close_date'],
},
},
Expand Down Expand Up @@ -77,13 +79,16 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
// `.strict()`: an app does not route by hand-written `path`, it names the
// metadata record to open (`objectName` / `pageName` / `dashboardName` /
// `url`) and the shell builds the route. `id` is required so a nav entry can
// be addressed — by a patch, and by `homePageId`, which replaced the removed
// top-level `landing` route (objectstack#4001).
// be addressed by a patch.
//
// There is no landing-page key: `landing` was removed in objectstack#4001,
// its replacement `homePageId` was retired in objectstack#4667 / #4709, and
// the app now opens on the FIRST navigation item that yields a route — here
// `home`. To change where an app opens, reorder `navigation`.
app: {
name: 'crm',
label: 'CRM',
icon: 'briefcase',
homePageId: 'home',
navigation: [
{ id: 'home', type: 'page', label: 'Home', pageName: 'crm_welcome' },
{ id: 'accounts', type: 'object', label: 'Accounts', objectName: 'account' },
Expand Down Expand Up @@ -237,9 +242,11 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
enabled: true,
schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'UTC' },
handler: 'syncOrders',
concurrency: 1,
retryPolicy: { maxRetries: 3 },
timeoutMs: 600000,
// `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,
},

agent: {
Expand Down Expand Up @@ -320,14 +327,16 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
// declared at stack level via `datasourceMapping`. Both were rejected.
driver: 'postgres',
active: true,
// `ssl` and `capabilities` are config OBJECTS, not a boolean / token list.
// `ssl` is a config OBJECT, not a boolean.
ssl: { enabled: true, rejectUnauthorized: true },
config: { host: 'db.internal', port: 5432, database: 'analytics' },
pool: { min: 2, max: 10 },
capabilities: { readOnly: true, queryAggregations: true },
// `interval` → `intervalMs`, and the unit is MILLISECONDS: the old `60`
// was read as 60ms once the key was spelled correctly, not 60 seconds.
healthCheck: { enabled: true, intervalMs: 60000 },
// No `capabilities` block: spec 17.0.0 removed it (objectstack#4583,
// ADR-0049). All eleven flags were declared, strict-guarded and read by
// nobody — pushdown is decided by the runtime driver's own `supports.*`,
// never by datasource metadata, so `readOnly: true` here made nothing
// read-only. `healthCheck` is likewise not a datasource key.
// `os migrate meta --from 16` rewrites stored copies.
},

// `condition` is the FAILURE predicate, not the invariant: `ScriptValidationSchema`
Expand Down Expand Up @@ -366,8 +375,12 @@ export const SAMPLES: Record<string, Record<string, unknown>> = {
name: 'order_confirmation',
label: 'Order Confirmation',
subject: 'Your order ${order.name} is confirmed',
from: 'sales@example.com',
to: '${contact.email}',
// An email TEMPLATE is not a send: there is no `to` — the recipient is
// supplied when the template is rendered and dispatched. The sender
// override is `fromOverride`, and it is an OBJECT (`name` + `address`),
// not a bare address string. Both `from` and `to` are rejected by name now
// that the authorable record is `.strict()` (spec 17.0.0).
fromOverride: { name: 'Sales Team', address: 'sales@example.com' },
bodyHtml:
'<html><body style="font-family:sans-serif;padding:24px"><h1 style="color:#4f46e5">Thanks for your order!</h1><p>Hi ${contact.name},</p><p>Your order <strong>${order.name}</strong> for <strong>${order.amount}</strong> is confirmed.</p><p>— The Sales Team</p></body></html>',
},
Expand Down
2 changes: 1 addition & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"@object-ui/plugin-view": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0-rc.1",
"@objectstack/spec": "^17.0.0-rc.2",
"fumadocs-core": "16.13.0",
"fumadocs-mdx": "15.2.1",
"fumadocs-ui": "16.14.0",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"devDependencies": {
"@changesets/cli": "^2.31.1",
"@eslint/js": "^10.0.1",
"@objectstack/spec": "^17.0.0-rc.1",
"@objectstack/spec": "^17.0.0-rc.2",
"@playwright/test": "^1.62.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.0",
Expand Down
6 changes: 3 additions & 3 deletions packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@
"@object-ui/providers": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/formula": "^17.0.0-rc.1",
"@objectstack/lint": "^17.0.0-rc.1",
"@objectstack/spec": "^17.0.0-rc.1",
"@objectstack/formula": "^17.0.0-rc.2",
"@objectstack/lint": "^17.0.0-rc.2",
"@objectstack/spec": "^17.0.0-rc.2",
"@sentry/react": "^10.69.0",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.25.0",
Expand Down
48 changes: 18 additions & 30 deletions packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -836,11 +836,11 @@ function findFirstRoute(items: any[], ctx?: NavTemplateContext): string {
return '';
}

// Build the per-item route segment without recursing through groups —
// used when `homePageId` resolved to an exact match and we just need to
// know how to address it. Delegates to the layout package's resolveHref()
// so `recordId`/`recordMode`/`componentRef` semantics stay consistent
// with the sidebar.
// Build the per-item route segment without recursing through groups — the
// caller has already picked the item and just needs to know how to address
// it. Delegates to the layout package's resolveHref() so
// `recordId`/`recordMode`/`componentRef` semantics stay consistent with the
// sidebar.
function buildItemRoute(item: any, ctx?: NavTemplateContext): string {
if (!item) return '';
if (item.type === 'url' || item.type === 'action' || item.type === 'separator' || item.type === 'group') return '';
Expand All @@ -852,35 +852,23 @@ function buildItemRoute(item: any, ctx?: NavTemplateContext): string {
return href.replace(/^\//, '');
}

function findNavItemById(items: any[], id: string): any | undefined {
if (!items) return undefined;
for (const item of items) {
if (item.id === id) return item;
if (item.type === 'group' && item.children) {
const hit = findNavItemById(item.children, id);
if (hit) return hit;
}
}
return undefined;
}

/**
* Resolves the route to navigate to when the user lands on the bare
* `/console/apps/:appName` URL. Honors the app's explicit
* `homePageId` (Salesforce-style "Default Landing"); falls back to the
* first reachable nav item only when no homePageId is set or it points
* at something that doesn't yield a route. This is what lets the CRM
* example open on the Sales Dashboard instead of the Lead list.
* `/console/apps/:appName` URL: the app's landing page is its FIRST reachable
* navigation item, in `order` — `findFirstRoute` walks groups and skips the
* item types that address nothing (`url`, `separator`, `action`). Which app
* the ROOT lands on is a separate question, decided by `isDefault`.
*
* This used to honour an explicit `app.homePageId`. Spec 17.0.0 retired that
* key (objectstack#4667, premise corrected in #4709 / objectui#3287): it was
* an ID cross-reference with no referential integrity, so a dangling id fell
* back to the first item *silently*. If the capability returns, the correct
* encoding is a flag on the navigation item itself (`navigation[].landing`),
* which cannot dangle — hence this function is kept as the seam even though it
* now has a single expression.
*/
function resolveLandingRoute(activeApp: any, ctx?: NavTemplateContext): string {
const homePageId: string | undefined = activeApp?.homePageId;
const navigation = activeApp?.navigation || [];
if (homePageId) {
const item = findNavItemById(navigation, homePageId);
const route = buildItemRoute(item, ctx);
if (route) return route;
}
return findFirstRoute(navigation, ctx);
return findFirstRoute(activeApp?.navigation || [], ctx);
}

/**
Expand Down
Loading
Loading