From 15d59f7a1a0383232b5246aa8d42ec9c905b1faa Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:59:02 +0800 Subject: [PATCH] fix(skills,docs): align skills/docs with published spec; guard flat view containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the five skill/doc-vs-spec drifts found by the 2026-07-17 third-party evaluation of the 15.1.0 release (AI following the official skills produced metadata that failed or silently broke): 1. objectstack-data skill taught `defineDataset()` for seeds — the actual export is `defineSeed()` (`dataset` is reserved for the ADR-0021 analytics layer). Renamed the whole section incl. imports, type table, zod path. 2. Same skill taught a `type: 'unique'` validation — removed from the spec in #1475. Now teaches `indexes: [{ fields, unique: true }]`; also purged the fictional `async`/`custom` types from rules/validation.md, fixed `format.pattern`→`regex` (no `uuid` builtin), `conditional` to its real `when`/`then`/`otherwise` shape, converted all predicates to record-rooted CEL (`P` tag), and documented that `cross_field` is failure-condition (inverted) like `script`, per the runtime rule-validator. 3. objectstack-ui skill kanban example used a top-level `groupBy` — the real shape is nested `kanban: { groupByField, summarizeField, columns }` with top-level `columns` still required. Gantt examples had the same flat disease with wrong key names (`startField`/`dependencyField` → `gantt.startDateField`/`dependenciesField`); `timeSegments` moved inside the `gantt:` block; column `summary` is a plain enum, not an object. Added a `defineView` container primer to the skill. 4. docs ui/views taught flat view objects that `os validate` passed but the Console silently never rendered (ViewSchema strips unknown keys → empty container). Page rewritten around the `defineView({ list, listViews, formViews })` container + stack registration; property tables corrected (`columns` required; `name`/`label` optional; `span` over legacy `colSpan`; `visibleWhen` is record-rooted CEL). 5. README quickstart curl path `/api/v1/todo_task` → `/api/v1/data/todo_task`. Root-cause guard for #4: `defineView()` now throws on a container with zero views, and `os validate` gains a `view-container-shape` check (`validateViewContainers` in @objectstack/lint, run PRE-parse like the ADR-0053 check) reporting flat/empty `views: []` entries with a fix hint. The runtime view type-schema mapping is deliberately untouched (ViewItem + container + #2555 personalization shapes need their own change). Regenerated skills/README.md + content/docs/ai/skills-reference.mdx via gen:skill-docs. Co-Authored-By: Claude Fable 5 --- .changeset/tidy-views-guard.md | 7 + README.md | 2 +- content/docs/ai/skills-reference.mdx | 6 +- content/docs/ui/views.mdx | 286 +++++++++++------- packages/cli/src/commands/validate.ts | 29 ++ packages/lint/src/index.ts | 3 + .../lint/src/validate-view-containers.test.ts | 84 +++++ packages/lint/src/validate-view-containers.ts | 101 +++++++ packages/spec/src/ui/view.test.ts | 13 + packages/spec/src/ui/view.zod.ts | 22 +- skills/README.md | 2 +- skills/objectstack-data/SKILL.md | 48 +-- .../objectstack-data/rules/relationships.md | 11 +- skills/objectstack-data/rules/validation.md | 236 +++++++++------ skills/objectstack-platform/SKILL.md | 2 +- skills/objectstack-ui/SKILL.md | 86 ++++-- 16 files changed, 679 insertions(+), 259 deletions(-) create mode 100644 .changeset/tidy-views-guard.md create mode 100644 packages/lint/src/validate-view-containers.test.ts create mode 100644 packages/lint/src/validate-view-containers.ts diff --git a/.changeset/tidy-views-guard.md b/.changeset/tidy-views-guard.md new file mode 100644 index 0000000000..29236abdd2 --- /dev/null +++ b/.changeset/tidy-views-guard.md @@ -0,0 +1,7 @@ +--- +"@objectstack/spec": patch +"@objectstack/lint": patch +"@objectstack/cli": patch +--- + +Reject view containers that define no views. A flat list-view object (`{ name, label, type, columns, ... }`) parses to an empty `ViewSchema` container because Zod strips unknown keys — zero views register and the Console silently renders nothing. `defineView()` now throws on a zero-view container, and `os validate` gains a `view-container-shape` check (`validateViewContainers` in `@objectstack/lint`) that reports flat or empty `views: []` entries pre-parse with a wrap-it fix hint. diff --git a/README.md b/README.md index 1faabec0d6..0eaa16ee9c 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Every object ships a REST API automatically — no controllers to write: ```bash # CRUD endpoints for the `todo_task` object you defined above -curl http://localhost:3000/api/v1/todo_task +curl http://localhost:3000/api/v1/data/todo_task ``` For the browser, the typed client SDK and React hooks (`useQuery` / `useMutation` / `usePagination`) live in [`@objectstack/client-react`](packages/client-react). Need a new capability? Write a plugin, driver, or service against the same kernel APIs — every built-in is one (see below). diff --git a/content/docs/ai/skills-reference.mdx b/content/docs/ai/skills-reference.mdx index 7cd5a23f9c..39be9bab6d 100644 --- a/content/docs/ai/skills-reference.mdx +++ b/content/docs/ai/skills-reference.mdx @@ -46,7 +46,7 @@ ObjectStack ships **9 domain-specific skills**. Each is self-contained — an AI | # | Skill | Domain | Path | What it covers | | :--- | :--- | :--- | :--- | :--- | | 1 | [Platform](#platform) | `platform` | `skills/objectstack-platform/` | Bootstrap, configure, extend, and operate ObjectStack runtimes. Covers project setup (`defineStack`, drivers, adapters, scaffolding), plugin and service development (PluginContext, DI, kernel hooks like `kernel:ready` and `data:*`), and operations (CLI commands, migrations, deployment, test harnesses via LiteKernel). | -| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed datasets (`defineDataset()`) that load fixtures and reference data alongside them. | +| 2 | [Data](#data) | `data` | `skills/objectstack-data/` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. | | 3 | [Query](#query) | `query` | `skills/objectstack-query/` | Construct ObjectQL queries — filters, sorting, pagination, aggregation, joins/expansion, window functions, and full-text search. | | 4 | [UI](#ui) | `ui` | `skills/objectstack-ui/` | Author ObjectStack UI metadata — Views (list/form/kanban/calendar/gantt), Apps (navigation), Pages (structured plus the HTML and React source-authoring tiers, ADR-0080/0081), Dashboards, Reports, Charts, Actions, and package Docs (`src/docs/*.md`). | | 5 | [Automation](#automation) | `automation` | `skills/objectstack-automation/` | Design ObjectStack automation — Flows (visual logic), Workflows (declarative rules), Triggers, Approvals, scheduled jobs, and webhooks. | @@ -75,13 +75,13 @@ Do not use for data schema design (see objectstack-data) or query patterns (see **Domain** `data` · **Path** `skills/objectstack-data/` -Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed datasets (`defineDataset()`) that load fixtures and reference data alongside them. +Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. Use when the user is creating or modifying `*.object.ts` / `*.seed.ts` files, picking field types, modelling relationships, writing `beforeInsert`/`afterUpdate` hooks, configuring per-object access control, or authoring bootstrap / demo data. Use for `visibleWhen` / `readonlyWhen` / `requiredWhen` rules that belong on fields. Do not use for querying data (see objectstack-query) or for plugin / kernel hooks (see objectstack-platform). CEL expressions in formulas / validations / sharing rules / dynamic seed values: load objectstack-formula alongside. -**Tags:** `object`, `field`, `validation`, `index`, `relationship`, `hook`, `schema`, `permission`, `rls`, `security`, `seed`, `dataset`, `fixture` +**Tags:** `object`, `field`, `validation`, `index`, `relationship`, `hook`, `schema`, `permission`, `rls`, `security`, `seed`, `fixture` --- diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 2f2ae43eb2..cb18e40e61 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -7,32 +7,77 @@ description: Configure list views and form views — grid, kanban, calendar, gan A **View** defines how records of an Object are displayed to users. ObjectStack supports two main view categories: **List Views** for browsing records and **Form Views** for editing individual records. -## List View +## The `defineView` Container -A List View controls how a collection of records is presented. It supports multiple visualization types. - -### Basic Structure +Views are authored **per object** inside a `defineView({ ... })` container — the default `list`, named `listViews`, and named `formViews` all live in one document. The loader expands the container into independently addressable `.` view items that power the view switcher. ```typescript -const taskListView = { - name: 'all_tasks', - label: 'All Tasks', - type: 'grid', - data: { - provider: 'object', - object: 'task', +// src/ui/views/task.view.ts +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'task' }; + +export const TaskViews = defineView({ + // Default list shown when the object is opened + list: { + label: 'All Tasks', + type: 'grid', + data, + columns: [ + { field: 'title', width: 300 }, + { field: 'status', width: 120 }, + { field: 'assignee', width: 200 }, + { field: 'due_date', width: 150 }, + ], + sort: [{ field: 'created_at', order: 'desc' }], + }, + + // Named saved views — entries in the view switcher + listViews: { + urgent: { + label: 'Urgent', + type: 'grid', + data, + columns: [{ field: 'title' }, { field: 'assignee' }, { field: 'due_date' }], + filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }], + }, }, - columns: [ - { field: 'title', label: 'Title', width: 300 }, - { field: 'status', label: 'Status', width: 120 }, - { field: 'assignee', label: 'Assignee', width: 200 }, - { field: 'due_date', label: 'Due Date', width: 150 }, - ], - filter: [], - sort: [{ field: 'created_at', order: 'desc' }], -}; + + // Named form views + formViews: { + edit: { + type: 'simple', + data, + sections: [ + { label: 'Task', columns: 2, fields: ['title', 'status', 'assignee', 'due_date'] }, + ], + }, + }, +}); +``` + +Register the container in your stack config: + +```typescript +// objectstack.config.ts +export default defineStack({ + // ... + views: [TaskViews], +}); ``` + + **Do not author a flat view object** — `{ name: 'all_tasks', label: 'All + Tasks', type: 'grid', columns: [...] }` at the top level is *not* a view + container. Nothing registers from it and no view appears in the switcher. + Every view must live under `list`, `listViews`, or `formViews`, and each + view binds its object via `data: { provider: 'object', object: '...' }`. + + +## List View + +A List View controls how a collection of records is presented. It supports multiple visualization types. + ### View Types | Type | Description | Use Case | @@ -51,15 +96,13 @@ const taskListView = { | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| `name` | `string` | ✅ | Machine name (`snake_case`) | -| `label` | `string` | ✅ | Display label | +| `columns` | `string[] \| ListColumn[]` | ✅ | Column definitions (required on **every** list view, including kanban/calendar/…) | +| `label` | `string` | optional | Display label shown in the view switcher | | `type` | `enum` | optional | View type (see table above; default `'grid'`) | | `data` | `ViewData` | optional | Data source configuration (defaults to the `object` provider) | -| `columns` | `ListColumn[]` | optional | Column definitions | -| `filter` | `array` | optional | Filter criteria | +| `filter` | `array` | optional | Base filter criteria | | `sort` | `array` | optional | Sort configuration | | `searchableFields` | `string[]` | optional | Fields included in search | -| `filterableFields` | `string[]` | optional | Fields available for filtering | | `grouping` | `object` | optional | Row grouping configuration | | `pagination` | `object` | optional | Pagination settings | | `selection` | `object` | optional | Row selection mode | @@ -69,6 +112,10 @@ const taskListView = { | `inlineEdit` | `boolean` | optional | Enable inline editing | | `exportOptions` | `string[]` | optional | Enabled export formats (`csv`, `xlsx`, `pdf`, `json`) | +The view's machine name is its **key** in the container (`listViews.urgent` on +object `task` becomes `task.urgent`); the default `list` claims `task.default`. +List and form views share that one namespace — don't reuse a key. + ### Column Configuration ```typescript @@ -101,6 +148,7 @@ columns: [ | `pinned` | `'left' \| 'right'` | Freeze column position | | `sortable` | `boolean` | Allow column sorting | | `resizable` | `boolean` | Allow column resize | +| `wrap` | `boolean` | Allow text wrapping | | `summary` | `enum` | Footer aggregation: `none`, `count`, `count_empty`, `count_filled`, `count_unique`, `percent_empty`, `percent_filled`, `sum`, `avg`, `min`, `max` | | `align` | `'left' \| 'center' \| 'right'` | Text alignment | | `link` | `boolean` | Cell functions as the primary navigation link | @@ -128,10 +176,15 @@ data: { ### Type-Specific Configuration +Each non-grid visualization reads its settings from a **nested config block +named after the type** (`kanban`, `calendar`, `gantt`, `gallery`, `timeline`, +`chart`, `tree`) — not from top-level keys. + #### Kanban ```typescript type: 'kanban', +columns: ['title', 'assignee', 'priority'], // still required at the top level kanban: { groupByField: 'status', // Field to group columns by (usually status/select) summarizeField: 'amount', // Optional numeric field summed at top of each column @@ -166,36 +219,38 @@ gantt: { ## Form View -A Form View defines how a single record is displayed for viewing or editing. +A Form View defines how a single record is displayed for viewing or editing. Form views live under `formViews` in the same `defineView` container as the object's list views. ### Basic Structure ```typescript -const taskFormView = { - type: 'simple', - data: { provider: 'object', object: 'task' }, - sections: [ - { - label: 'Basic Information', - columns: 2, - fields: [ - { field: 'title', colSpan: 2 }, - { field: 'status' }, - { field: 'priority' }, - { field: 'assignee' }, - { field: 'due_date' }, - ], - }, - { - label: 'Details', - collapsible: true, - columns: 1, - fields: [ - { field: 'description', colSpan: 1 }, - ], - }, - ], -}; +formViews: { + edit: { + type: 'simple', + data: { provider: 'object', object: 'task' }, + sections: [ + { + label: 'Basic Information', + columns: 2, + fields: [ + { field: 'title', span: 'full' }, + { field: 'status' }, + { field: 'priority' }, + { field: 'assignee' }, + { field: 'due_date' }, + ], + }, + { + label: 'Details', + collapsible: true, + columns: 1, + fields: [ + { field: 'description' }, + ], + }, + ], + }, +}, ``` ### Form Types @@ -213,11 +268,13 @@ const taskFormView = { | Property | Type | Description | | :--- | :--- | :--- | +| `name` | `string` | Stable identifier (snake_case) for i18n lookup | | `label` | `string` | Section header | | `columns` | `1-4` | Grid column count | | `collapsible` | `boolean` | Can section be collapsed | | `collapsed` | `boolean` | Initially collapsed | -| `fields` | `FormField[]` | Fields in the section | +| `visibleWhen` | `string` | CEL predicate — section shown only when TRUE | +| `fields` | `(string \| FormField)[]` | Fields in the section | ### Form Field Configuration @@ -231,9 +288,8 @@ fields: [ placeholder: 'Enter title', helpText: 'A brief description of the task', required: true, // Override required - colSpan: 2, // Span 2 grid columns - widget: 'custom-editor', // Custom widget component - visibleWhen: "status != 'cancelled'", + span: 'full', // Take the whole row at any column count + visibleWhen: "record.status != 'cancelled'", }, ] ``` @@ -247,71 +303,81 @@ fields: [ | `readonly` | `boolean` | Read-only override | | `required` | `boolean` | Required override | | `hidden` | `boolean` | Hidden override | -| `colSpan` | `1-4` | Column span in grid layout | +| `span` | `'auto' \| 'full'` | Relative width — `'full'` takes the whole row at any column count (preferred) | +| `colSpan` | `1-4` | Legacy absolute column span — prefer `span` | | `widget` | `string` | Custom widget/component name | | `dependsOn` | `string` | Parent field for cascading | -| `visibleWhen` | `string` | Visibility condition expression (was `visibleOn`, ADR-0089) | - -### Default Sort for Related Lists - -```typescript -// Sort related records -defaultSort: [ - { field: 'created_at', order: 'desc' }, -] -``` +| `visibleWhen` | `string` | Visibility predicate (CEL); runtime forms bind `record` + `current_user` (was `visibleOn`, ADR-0089) | ## Complete Example +One container covering a default grid, a kanban saved view, and a tabbed edit form — mirroring `examples/app-showcase/src/ui/views/task.view.ts`: + ```typescript -// List view: Kanban board for tasks -const taskKanban = { - name: 'task_board', - label: 'Task Board', - type: 'kanban', - data: { provider: 'object', object: 'task' }, - kanban: { - groupByField: 'status', - columns: ['title', 'assignee', 'priority', 'due_date'], +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'task' }; + +export const TaskViews = defineView({ + list: { + label: 'All Tasks', + type: 'grid', + data, + columns: [ + { field: 'title', label: 'Title' }, + { field: 'assignee', label: 'Assignee' }, + { field: 'priority', label: 'Priority' }, + { field: 'due_date', label: 'Due Date' }, + ], + sort: [{ field: 'priority', order: 'desc' }], }, - columns: [ - { field: 'title', label: 'Title' }, - { field: 'assignee', label: 'Assignee' }, - { field: 'priority', label: 'Priority' }, - { field: 'due_date', label: 'Due Date' }, - ], - sort: [{ field: 'priority', order: 'desc' }], -}; - -// Form view: Task edit form -const taskForm = { - type: 'tabbed', - data: { provider: 'object', object: 'task' }, - sections: [ - { - label: 'Details', - columns: 2, - fields: [ - { field: 'title', colSpan: 2, required: true }, - { field: 'status' }, - { field: 'priority' }, - { field: 'assignee' }, - { field: 'due_date' }, - { field: 'description', colSpan: 2 }, - ], + + listViews: { + board: { + label: 'Task Board', + type: 'kanban', + data, + columns: ['title', 'assignee', 'priority', 'due_date'], + kanban: { + groupByField: 'status', + columns: ['title', 'assignee', 'priority', 'due_date'], + }, }, - { - label: 'System', - collapsible: true, - collapsed: true, - columns: 2, - fields: [ - { field: 'created_at', readonly: true }, - { field: 'updated_at', readonly: true }, + }, + + formViews: { + edit: { + type: 'tabbed', + data, + sections: [ + { + name: 'details', + label: 'Details', + columns: 2, + fields: [ + { field: 'title', span: 'full', required: true }, + { field: 'status' }, + { field: 'priority' }, + { field: 'assignee' }, + { field: 'due_date' }, + { field: 'description', span: 'full' }, + ], + }, + { + name: 'system', + label: 'System', + collapsible: true, + collapsed: true, + columns: 2, + fields: [ + { field: 'created_at', readonly: true }, + { field: 'updated_at', readonly: true }, + ], + }, ], }, - ], -}; + }, +}); ``` ## Related diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index d7ebf63686..7d9bcbe9c1 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -10,6 +10,7 @@ import { ObjectStackDefinitionSchema, normalizeStackInput, type ConversionNotice import { loadConfig } from '../utils/config.js'; import { validateStackExpressions } from '@objectstack/lint'; import { validateListViewMode } from '@objectstack/lint'; +import { validateViewContainers } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint'; @@ -149,6 +150,34 @@ export default class Validate extends Command { this.exit(1); } + // 2d. View container shape — a flat list-view object in `views: []` + // parses to an EMPTY container (ViewSchema strips unknown keys), so + // the schema step passes while zero views register and the Console + // silently renders nothing. Checked on `normalized` (PRE-parse) — + // `result.data` has already had the flat keys stripped. + if (!flags.json) printStep('Checking view container shape...'); + const viewContainerFindings = validateViewContainers(normalized as Record); + const viewContainerErrors = viewContainerFindings.filter((f) => f.severity === 'error'); + + if (viewContainerErrors.length > 0) { + if (flags.json) { + console.log(JSON.stringify({ + valid: false, + errors: viewContainerErrors, + duration: timer.elapsed(), + }, null, 2)); + this.exit(1); + } + console.log(''); + printError(`View container check failed (${viewContainerErrors.length} issue${viewContainerErrors.length > 1 ? 's' : ''})`); + for (const f of viewContainerErrors.slice(0, 50)) { + console.log(` • ${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); + } + this.exit(1); + } + // 3. Dashboard widget reference integrity (issue #1721) — a semantic // cross-reference pass the protocol schema cannot express: every // widget's `dataset`/`dimensions`/`values` and chartConfig diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 68880ebf34..136c3e702c 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -29,6 +29,9 @@ export type { ExprIssue } from './validate-expressions.js'; export { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js'; export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js'; +export { validateViewContainers, VIEW_CONTAINER_SHAPE } from './validate-view-containers.js'; +export type { ViewContainerFinding, ViewContainerSeverity } from './validate-view-containers.js'; + export { validateResponsiveStyles, STYLE_NODE_MISSING_ID, diff --git a/packages/lint/src/validate-view-containers.test.ts b/packages/lint/src/validate-view-containers.test.ts new file mode 100644 index 0000000000..1fc4f073cc --- /dev/null +++ b/packages/lint/src/validate-view-containers.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { validateViewContainers, VIEW_CONTAINER_SHAPE } from './validate-view-containers.js'; + +describe('validateViewContainers (defineView container shape guardrail)', () => { + it('passes a proper container with a default list', () => { + const findings = validateViewContainers({ + views: [ + { list: { type: 'grid', data: { provider: 'object', object: 'task' }, columns: ['title'] } }, + ], + }); + expect(findings).toHaveLength(0); + }); + + it('passes a container with only named listViews / formViews', () => { + const findings = validateViewContainers({ + views: [ + { listViews: { urgent: { type: 'grid', columns: ['title'] } } }, + { formViews: { edit: { type: 'simple', sections: [{ fields: ['title'] }] } } }, + ], + }); + expect(findings).toHaveLength(0); + }); + + it('passes an independent ViewItem (viewKind discriminator)', () => { + const findings = validateViewContainers({ + views: [ + { + name: 'task.pipeline', + object: 'task', + viewKind: 'list', + config: { type: 'kanban', columns: ['title'] }, + }, + ], + }); + expect(findings).toHaveLength(0); + }); + + it('flags a flat list-view object with the wrap-it hint', () => { + const findings = validateViewContainers({ + views: [ + { + name: 'all_tasks', + label: 'All Tasks', + type: 'grid', + data: { provider: 'object', object: 'task' }, + columns: ['title', 'status'], + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + path: 'views[0]', + }); + expect(findings[0].where).toContain('all_tasks'); + expect(findings[0].message).toContain('Flat list-view object'); + expect(findings[0].hint).toContain('defineView'); + }); + + it('flags a container whose slots are all empty', () => { + const findings = validateViewContainers({ + views: [{ listViews: {}, formViews: {} }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('defines no views'); + }); + + it('handles a name-keyed views map', () => { + const findings = validateViewContainers({ + views: { + task: { list: { type: 'grid', columns: ['title'] } }, + broken: { type: 'grid', columns: ['title'] }, + }, + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('views.broken'); + }); + + it('ignores non-object entries and stacks without views', () => { + expect(validateViewContainers({})).toHaveLength(0); + expect(validateViewContainers({ views: [null, 42, 'x'] as unknown as [] })).toHaveLength(0); + }); +}); diff --git a/packages/lint/src/validate-view-containers.ts b/packages/lint/src/validate-view-containers.ts new file mode 100644 index 0000000000..de93796070 --- /dev/null +++ b/packages/lint/src/validate-view-containers.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Build-time guardrail for the `defineView` container shape. +// +// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate`. It +// catches the "flat view object" authoring mistake the schema alone cannot +// surface: `ViewSchema` is a container (`{ list, form, listViews, formViews }`) +// whose slots are all optional, and Zod strips unknown keys — so a flat list +// view (`{ name: 'all_tasks', label, type: 'grid', columns: [...] }`) parses +// to an EMPTY container. The stack validates, the loader finds nothing to +// expand, and the Console silently renders no view (no switcher entry). The +// third-party 15.1 evaluation hit exactly this via the old docs. +// +// Runs PRE-parse (on the normalizeStackInput output, before the +// ObjectStackDefinition parse): post-parse the flat keys are already stripped +// and the mistake is indistinguishable from an intentionally empty container. +// +// Independent ViewItems (`viewKind` + `config`) are legal `views: []` entries +// (the loader registers them as-is) and are not flagged. + +export type ViewContainerSeverity = 'error' | 'warning'; + +export interface ViewContainerFinding { + severity: ViewContainerSeverity; + rule: string; + /** Human-readable location, e.g. `views[0] ("all_tasks")`. */ + where: string; + /** Config path, e.g. `views[0]`. */ + path: string; + message: string; + hint: string; +} + +// Rule id (registry entry). +export const VIEW_CONTAINER_SHAPE = 'view-container-shape'; + +type AnyRec = Record; + +const CONTAINER_SLOT_KEYS = ['list', 'form', 'listViews', 'formViews'] as const; + +/** Coerce an array-or-name-keyed-map collection to indexed entries. */ +function asEntries(v: unknown): Array<{ key: string; value: unknown }> { + if (Array.isArray(v)) return v.map((value, i) => ({ key: `[${i}]`, value })); + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, value]) => ({ key: `.${name}`, value })); + } + return []; +} + +/** Number of views a parsed-or-raw container actually carries. */ +function containerViewCount(rec: AnyRec): number { + const named = (slot: unknown): number => + slot && typeof slot === 'object' && !Array.isArray(slot) ? Object.keys(slot as AnyRec).length : 0; + return (rec.list ? 1 : 0) + (rec.form ? 1 : 0) + named(rec.listViews) + named(rec.formViews); +} + +/** + * Validate that every stack-level `views` entry is a real view container (or + * an independent ViewItem). Flat list-view objects and view-less containers + * are reported as errors with a wrap-it fix hint. + */ +export function validateViewContainers(stack: Record): ViewContainerFinding[] { + const out: ViewContainerFinding[] = []; + if (!stack || typeof stack !== 'object') return out; + + for (const { key, value } of asEntries((stack as AnyRec).views)) { + // Non-object entries are the schema step's problem, not this rule's. + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const rec = value as AnyRec; + + // Independent ViewItem (`viewKind` discriminator) — registered as-is. + if (rec.viewKind != null) continue; + + if (containerViewCount(rec) > 0) continue; + + const label = typeof rec.name === 'string' ? ` ("${rec.name}")` : ''; + const hasContainerSlot = CONTAINER_SLOT_KEYS.some((k) => k in rec); + // Flat list-view fingerprint: view-ish keys at the top level where the + // container slots should be. + const looksFlat = !hasContainerSlot + && ['type', 'columns', 'data', 'filter', 'sort'].some((k) => k in rec); + + out.push({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + where: `views${key}${label}`, + path: `views${key}`, + message: looksFlat + ? 'Flat list-view object is not a view container: `ViewSchema` strips its keys, ' + + 'so it parses to an EMPTY container — zero views register and the Console ' + + 'renders no view for it.' + : 'View container defines no views — all of `list` / `form` / `listViews` / ' + + '`formViews` are absent or empty, so nothing registers.', + hint: 'Wrap every view in a defineView container: defineView({ list: { type, data, ' + + 'columns, ... }, listViews: { ... }, formViews: { ... } }). See ' + + 'examples/app-showcase/src/ui/views/task.view.ts.', + }); + } + + return out; +} diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index e6d5ec1170..b1aa3ab651 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2043,6 +2043,19 @@ describe('defineView', () => { list: { type: 'invalid_type' as 'grid', columns: ['name'] }, })).toThrow(); }); + + it('should throw on an empty container (zero views)', () => { + expect(() => defineView({})).toThrow(/defines no views/); + }); + + it('should throw on a flat list view — stripped keys must not silently produce an empty container', () => { + expect(() => defineView({ + name: 'all_tasks', + label: 'All Tasks', + type: 'grid', + columns: ['name', 'status'], + } as never)).toThrow(/defines no views/); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 8512fa94a8..9911185fa5 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1037,7 +1037,11 @@ export const ViewSchema = lazySchema(() => z.object({ /** * Type-safe factory for creating view definitions. * - * Validates the config at creation time using Zod `.parse()`. + * Validates the config at creation time using Zod `.parse()`, and rejects a + * container that defines no views: `ViewSchema` strips unknown top-level keys, + * so a *flat* list view (`{ name, label, type, columns, ... }`) would parse to + * an empty container — zero views register and the Console silently renders + * nothing. Failing here surfaces that authoring mistake at build time. * * @example * ```ts @@ -1055,7 +1059,21 @@ export const ViewSchema = lazySchema(() => z.object({ * ``` */ export function defineView(config: z.input): View { - return ViewSchema.parse(config); + const parsed = ViewSchema.parse(config); + const viewCount = + (parsed.list ? 1 : 0) + + (parsed.form ? 1 : 0) + + Object.keys(parsed.listViews ?? {}).length + + Object.keys(parsed.formViews ?? {}).length; + if (viewCount === 0) { + throw new Error( + 'defineView: the container defines no views — nothing registers and no view appears in the Console. ' + + 'Declare at least one of `list`, `form`, `listViews`, `formViews`. ' + + 'If you passed a flat list view ({ name, label, type, columns, ... }), wrap it: ' + + 'defineView({ list: { type, data, columns, ... } }).' + ); + } + return parsed; } // ─────────────────────────────────────────────────────────────────────────── diff --git a/skills/README.md b/skills/README.md index 9cd2884b35..deb203eccc 100644 --- a/skills/README.md +++ b/skills/README.md @@ -17,7 +17,7 @@ authoritative Zod sources in `node_modules/@objectstack/spec/src/...`. | Skill | Domain | What it covers | |:------|:-------|:---------------| | [Platform](./objectstack-platform/SKILL.md) | `platform` | Bootstrap, configure, extend, and operate ObjectStack runtimes. Covers project setup (`defineStack`, drivers, adapters, scaffolding), plugin and service development (PluginContext, DI, kernel hooks like `kernel:ready` and `data:*`), and operations (CLI commands, migrations, deployment, test harnesses via LiteKernel). | -| [Data](./objectstack-data/SKILL.md) | `data` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seed datasets (`defineDataset()`) that load fixtures and reference data alongside them. | +| [Data](./objectstack-data/SKILL.md) | `data` | Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. | | [Query](./objectstack-query/SKILL.md) | `query` | Construct ObjectQL queries — filters, sorting, pagination, aggregation, joins/expansion, window functions, and full-text search. | | [UI](./objectstack-ui/SKILL.md) | `ui` | Author ObjectStack UI metadata — Views (list/form/kanban/calendar/gantt), Apps (navigation), Pages (structured plus the HTML and React source-authoring tiers, ADR-0080/0081), Dashboards, Reports, Charts, Actions, and package Docs (`src/docs/*.md`). | | [Automation](./objectstack-automation/SKILL.md) | `automation` | Design ObjectStack automation — Flows (visual logic), Workflows (declarative rules), Triggers, Approvals, scheduled jobs, and webhooks. | diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 8517c62aaf..c7a47f1651 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -4,7 +4,7 @@ description: > Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security — - and the seed datasets (`defineDataset()`) that load fixtures and + and the seeds (`defineSeed()`) that load fixtures and reference data alongside them. Use when the user is creating or modifying `*.object.ts` / `*.seed.ts` files, picking field types, modelling relationships, writing `beforeInsert`/`afterUpdate` hooks, @@ -20,7 +20,7 @@ metadata: author: objectstack-ai version: "4.2" domain: data - tags: object, field, validation, index, relationship, hook, schema, permission, rls, security, seed, dataset, fixture + tags: object, field, validation, index, relationship, hook, schema, permission, rls, security, seed, fixture --- # Data Modeling — ObjectStack Data Protocol @@ -47,7 +47,7 @@ relationship modelling, validation rules, index strategy, and lifecycle hooks. - You are creating a **new business object** (e.g., `account`, `project_task`) - You need to **choose the right field type** from the 48 supported types - You are configuring **lookup / master-detail relationships** between objects -- You need to add **validation rules** (uniqueness, cross-field, state machine, etc.) +- You need to add **validation rules** (cross-field, state machine, format, etc.) - You are optimising **query performance with indexes** - You are extending an existing object with new fields or capabilities - You need to **implement data lifecycle hooks** for business logic @@ -348,12 +348,17 @@ See [rules/relationships.md](./rules/relationships.md) for detailed examples. > validation predicate — so `record.due_date == null` matches an omitted field the > same as an explicit `null` (#1871). (On update, the prior record supplies it.) -Common validation types: +The **complete** set of validation types (`ValidationRuleSchema` discriminators): - `script` — Formula expression (inverted logic) -- `unique` — Composite uniqueness - `state_machine` — Legal state transitions - `format` — Regex or built-in format - `cross_field` — Compare values across fields +- `json_schema` — Validate a JSON field against a JSON Schema +- `conditional` — Apply a nested rule only `when` a predicate holds + +> **There is NO `unique` validation type** (removed from the spec in #1475). +> Enforce uniqueness — including composite — with a **unique index**: +> `indexes: [{ fields: ['tenant_id', 'email'], unique: true }]`. See [rules/validation.md](./rules/validation.md) for all types and examples. @@ -760,23 +765,27 @@ export const SetupApp = defineApp({ --- -## Seed Data & Fixtures (`defineDataset()`) +## Seed Data & Fixtures (`defineSeed()`) Object definition and its seed data live together — writing a `*.object.ts` almost always goes with a `*.seed.ts` (test fixtures, reference rows, -bootstrap data). `defineDataset()` is type-safe: pass the object definition +bootstrap data). `defineSeed()` is type-safe: pass the object definition and TypeScript checks every record's field keys at compile time. +> The factory is named `defineSeed` — **not** `defineDataset`. The `dataset` +> name is reserved for the unrelated ADR-0021 analytics semantic layer +> (`defineDataset` from `@objectstack/spec/ui`), which is not a seed factory. + ### Quick start ```typescript // src/data/index.ts -import { defineDataset } from '@objectstack/spec/data'; +import { defineSeed } from '@objectstack/spec/data'; import { Status } from '../objects/status.object'; import { Category } from '../objects/category.object'; // Reference data — every environment -export const statusSeed = defineDataset(Status, { +export const statusSeed = defineSeed(Status, { externalId: 'code', mode: 'upsert', records: [ @@ -786,7 +795,7 @@ export const statusSeed = defineDataset(Status, { }); // Demo data — dev/test only -export const categorySeed = defineDataset(Category, { +export const categorySeed = defineSeed(Category, { externalId: 'slug', mode: 'upsert', env: ['dev', 'test'], @@ -798,17 +807,17 @@ export const categorySeed = defineDataset(Category, { export const SeedData = [statusSeed, categorySeed]; // parents first ``` -### `Dataset` fields +### `Seed` fields | Field | Default | Purpose | |:------|:--------|:--------| | `object` | derived | Auto-set from `objectDef.name` — never write manually | | `externalId` | `'name'` | Stable business key used for upsert / update lookup | | `mode` | `'upsert'` | Import strategy (see below) | -| `env` | `['prod','dev','test']` | Environments where the dataset loads | +| `env` | `['prod','dev','test']` | Environments where the seed loads | | `records` | — | `Partial>[]` | -Full Zod shape: `node_modules/@objectstack/spec/src/data/dataset.zod.ts`. +Full Zod shape: `node_modules/@objectstack/spec/src/data/seed.zod.ts`. ### Import modes @@ -835,7 +844,7 @@ across environments. ### Relationship references For `lookup` fields, supply the **natural key** of the target record (not -its UUID). The seed runner resolves at load time. Order datasets so parents +its UUID). The seed runner resolves at load time. Order seeds so parents appear before children in the exported array: > If a lookup value matches no natural key, the loader now falls back to @@ -845,7 +854,7 @@ appear before children in the exported array: > didn't seed (e.g. a system user). ```typescript -const contacts = defineDataset(Contact, { +const contacts = defineSeed(Contact, { externalId: 'email', records: [{ email: 'john@acme.example.com', @@ -863,9 +872,10 @@ time-based or identity-derived seed values — `new Date()` ships the package author's clock to every customer and breaks build determinism. ```typescript -import { defineDataset, cel } from '@objectstack/spec'; +import { defineSeed } from '@objectstack/spec/data'; +import { cel } from '@objectstack/spec'; -defineDataset(Opportunity, { +defineSeed(Opportunity, { records: [{ name: 'Acme Q3 Renewal', close_date: cel`daysFromNow(45)`, @@ -888,11 +898,11 @@ changes must produce byte-identical `dist/objectstack.json`. CEL + pinned | Practice | Why | |:---------|:----| -| Always use `defineDataset()`, never `DatasetSchema.parse()` | Lose compile-time field checking otherwise | +| Always use `defineSeed()`, never `SeedSchema.parse()` | Lose compile-time field checking otherwise | | Prefer natural keys (`code` / `email` / `slug`) | Portable across environments | | Default to `upsert` | Idempotent re-runs | | Scope demo data with `env: ['dev','test']` | Keep noise out of prod | -| Order datasets parent → child in the exported array | References resolve at load time | +| Order seeds parent → child in the exported array | References resolve at load time | | Use `replace` only on cache/lookup tables, with comments | Data-loss footgun | | One `{object}.seed.ts` file per object | Readability at scale | diff --git a/skills/objectstack-data/rules/relationships.md b/skills/objectstack-data/rules/relationships.md index 369d08fc00..11d970d041 100644 --- a/skills/objectstack-data/rules/relationships.md +++ b/skills/objectstack-data/rules/relationships.md @@ -122,13 +122,10 @@ export default ObjectSchema.create({ role: { type: 'text' }, hours_allocated: { type: 'number' }, }, - validations: [ - { - name: 'unique_assignment', - type: 'unique', - fields: ['project_id', 'employee_id'], - message: 'Employee already assigned to this project', - }, + indexes: [ + // One assignment per (project, employee) pair — uniqueness is an index + // concern; there is no 'unique' validation type. + { fields: ['project_id', 'employee_id'], unique: true }, ], }); ``` diff --git a/skills/objectstack-data/rules/validation.md b/skills/objectstack-data/rules/validation.md index 8fd38cfceb..6204732cf2 100644 --- a/skills/objectstack-data/rules/validation.md +++ b/skills/objectstack-data/rules/validation.md @@ -4,28 +4,46 @@ Comprehensive guide for implementing validation rules in ObjectStack. ## Available Rule Types +The **complete** set of `type` discriminators accepted by `ValidationRuleSchema`: + | Type | Purpose | When Validation Fails | |:-----|:--------|:---------------------| -| `script` | Formula expression | When expression evaluates to `true` | -| `unique` | Composite uniqueness | When duplicate found | +| `script` | CEL predicate over the record | When predicate evaluates to `true` | | `state_machine` | Legal state transitions | When transition not allowed | | `format` | Regex or built-in format | When format doesn't match | -| `cross_field` | Compare values across fields | When comparison fails | +| `cross_field` | CEL predicate comparing fields | When predicate evaluates to `true` | | `json_schema` | Validate JSON field | When JSON doesn't match schema | -| `async` | External API validation | When API returns error | -| `custom` | Registered validator function | When function returns false | -| `conditional` | Apply rule conditionally | When nested rule fails | +| `conditional` | Apply nested rule when a predicate holds | When nested rule fails | -## Script Validation +There is no other type. In particular: + +- **No `unique` type** (removed from the spec in #1475) — enforce uniqueness + with a **unique index** ([see below](#uniqueness--use-unique-indexes)). +- **No `async` / `custom` type** — external checks and arbitrary validation + code belong in a `beforeInsert` / `beforeUpdate` **lifecycle hook** + (see [hooks.md](./hooks.md)). + +## Expression Syntax -**⚠️ CRITICAL:** Script condition is **inverted** — validation **fails** when expression is `true`. +`condition` / `when` are **CEL predicates** (ADR-0032). Author them with the +`P` tag from `@objectstack/spec`; a plain string is also accepted and parsed +as CEL. Record fields are addressed as `record.`; on update the prior +row is available as `previous.`. CEL uses `==`, `!=`, `&&`, `||`, +`!` — not SQL's `=`, `AND`, `IS NULL`. + +**⚠️ CRITICAL:** For `script` **and** `cross_field`, the predicate expresses +the **failure** condition — validation **fails** when it evaluates to `true`. + +## Script Validation ```typescript +import { P } from '@objectstack/spec'; + validations: [ { name: 'prevent_past_dates', type: 'script', - condition: 'due_date < TODAY()', // ❌ Fails when this is TRUE + condition: P`record.due_date < today()`, // ❌ Fails when this is TRUE message: 'Due date cannot be in the past', severity: 'error', events: ['insert', 'update'], @@ -37,39 +55,38 @@ validations: [ ```typescript // Prevent negative values -condition: 'amount < 0' +condition: P`record.amount < 0` // Require field when another field has value -condition: 'status = "approved" AND approver_id IS NULL' +condition: P`record.status == 'approved' && isBlank(record.approver_id)` // Date range validation -condition: 'end_date < start_date' +condition: P`record.end_date < record.start_date` // Conditional required field -condition: 'type = "enterprise" AND account_manager IS NULL' +condition: P`record.type == 'enterprise' && isBlank(record.account_manager)` ``` -## Unique Validation +> On **insert**, an optional field omitted from the payload reads as `null` +> in the predicate — `record.due_date == null` matches an omitted field the +> same as an explicit `null` (#1871). Use `isBlank(v)` to catch `null` and +> empty strings together. + +## Uniqueness — Use Unique Indexes + +There is **no `unique` validation type**. Uniqueness — including composite +uniqueness — is declared as a unique **index** on the object: ```typescript -validations: [ - { - name: 'unique_email', - type: 'unique', - fields: ['email'], - caseSensitive: false, - message: 'Email address already exists', - }, - { - name: 'unique_tenant_email', - type: 'unique', - fields: ['tenant_id', 'email'], // Composite uniqueness - caseSensitive: false, - message: 'Email already exists in this tenant', - }, +indexes: [ + { fields: ['email'], unique: true }, // single-field uniqueness + { fields: ['tenant_id', 'email'], unique: true }, // composite uniqueness ] ``` +The database enforces the constraint; duplicate writes are rejected at the +driver layer. See [indexing.md](./indexing.md) for index options. + ## State Machine Validation ```typescript @@ -102,16 +119,16 @@ validations: [ name: 'email_format', type: 'format', field: 'email', - format: 'email', // Built-in: email, url, phone, json, uuid + format: 'email', // Built-in: email, url, phone, json message: 'Invalid email format', }, - // Custom regex + // Custom regex — the key is `regex`, not `pattern` { name: 'sku_format', type: 'format', field: 'sku', - pattern: '^[A-Z]{3}-\\d{4}$', // e.g., ABC-1234 + regex: '^[A-Z]{3}-\\d{4}$', // e.g., ABC-1234 message: 'SKU must be format: XXX-0000', }, ] @@ -119,19 +136,22 @@ validations: [ ## Cross-Field Validation +Same inverted semantics as `script` — the predicate is the **failure** +condition. `fields` lists the fields involved (used for error targeting). + ```typescript validations: [ { name: 'date_range', type: 'cross_field', - condition: 'end_date > start_date', + condition: P`record.end_date <= record.start_date`, // ❌ TRUE = invalid message: 'End date must be after start date', fields: ['start_date', 'end_date'], }, { name: 'discount_limit', type: 'cross_field', - condition: 'discount_amount <= subtotal * 0.5', + condition: P`record.discount_amount > record.subtotal * 0.5`, message: 'Discount cannot exceed 50% of subtotal', fields: ['discount_amount', 'subtotal'], }, @@ -161,41 +181,72 @@ validations: [ ] ``` -## Async Validation +## Conditional Validation + +Shape is `when` / `then` / `otherwise` — `when` is a CEL predicate, `then` +is a **single** nested rule applied when it holds, `otherwise` (optional) a +single rule applied when it doesn't. There is no `validations: []` array — +compose multiple checks as multiple top-level rules or nested conditionals. ```typescript validations: [ { - name: 'external_api_check', - type: 'async', - field: 'tax_id', - endpoint: 'https://api.example.com/validate/tax-id', - method: 'POST', - timeout: 5000, - debounce: 500, // Delay validation by 500ms - message: 'Invalid tax ID', + name: 'enterprise_requires_manager', + type: 'conditional', + when: P`record.type == 'enterprise'`, + message: 'Enterprise account validation', + then: { + name: 'manager_required', + type: 'script', + condition: P`isBlank(record.account_manager)`, + message: 'Enterprise accounts must have an account manager', + }, }, ] ``` -## Conditional Validation +With an `otherwise` branch: ```typescript -validations: [ - { - name: 'enterprise_requires_manager', - type: 'conditional', - condition: "type = 'enterprise'", - validations: [ - { - name: 'manager_required', - type: 'script', - condition: 'account_manager IS NULL', - message: 'Enterprise accounts must have an account manager', - }, - ], +{ + name: 'payment_validation', + type: 'conditional', + when: P`record.order_total > 10000`, + message: 'Order validation', + then: { + name: 'manager_approval_required', + type: 'script', + condition: P`isBlank(record.manager_approval_id)`, + message: 'Orders over $10,000 require manager approval', }, -] + otherwise: { + name: 'payment_method_required', + type: 'script', + condition: P`isBlank(record.payment_method)`, + message: 'Payment method is required', + }, +} +``` + +## External / Custom Validation → Lifecycle Hooks + +Calling an external API, hitting another object, or running arbitrary code is +**not** a validation type. Implement it as a `beforeInsert` / `beforeUpdate` +lifecycle hook and throw on failure — the typed, supported extension point: + +```typescript +import { Hook, HookContext } from '@objectstack/spec/data'; + +const taxIdCheck: Hook = { + name: 'tax_id_external_check', + object: 'account', + events: ['beforeInsert', 'beforeUpdate'], + handler: async (ctx: HookContext) => { + if (ctx.input.tax_id && !(await verifyTaxId(ctx.input.tax_id))) { + throw new Error('Invalid tax ID'); + } + }, +}; ``` ## Validation Properties @@ -234,7 +285,7 @@ Lower numbers execute **first**. ```typescript { type: 'script', - condition: 'amount > 0', // ❌ Fails when amount > 0 (inverted!) + condition: P`record.amount > 0`, // ❌ Fails when amount > 0 (inverted!) message: 'Amount must be positive', } ``` @@ -244,30 +295,28 @@ Lower numbers execute **first**. ```typescript { type: 'script', - condition: 'amount <= 0', // ✅ Fails when amount <= 0 + condition: P`record.amount <= 0`, // ✅ Fails when amount <= 0 message: 'Amount must be positive', } ``` -### ❌ Incorrect — Missing Severity +### ❌ Incorrect — SQL Syntax in a CEL Predicate ```typescript { type: 'script', - condition: 'end_date < start_date', - message: 'End date must be after start date', - // ❌ No severity — defaults to 'error' which may be too strict + condition: "status = 'approved' AND approver_id IS NULL", // ❌ not CEL + message: 'Approved records need an approver', } ``` -### ✅ Correct — Explicit Severity +### ✅ Correct — CEL Predicate ```typescript { type: 'script', - condition: 'end_date < start_date', - message: 'End date must be after start date', - severity: 'warning', // ✅ Allow save but warn user + condition: P`record.status == 'approved' && isBlank(record.approver_id)`, + message: 'Approved records need an approver', } ``` @@ -276,7 +325,7 @@ Lower numbers execute **first**. ```typescript { type: 'script', - condition: 'status = "draft"', + condition: P`record.status == 'draft'`, message: 'Record is still in draft', // ❌ No events — runs on all operations } @@ -287,7 +336,7 @@ Lower numbers execute **first**. ```typescript { type: 'script', - condition: 'status = "draft"', + condition: P`record.status == 'draft'`, message: 'Cannot publish draft records', events: ['update'], // ✅ Only validate on update } @@ -301,8 +350,8 @@ Lower numbers execute **first**. { name: 'no_backdate', type: 'script', - condition: 'created_at < TODAY()', - message: 'Cannot create records with past dates', + condition: P`record.effective_date < today()`, + message: 'Effective date cannot be in the past', events: ['insert'], } ``` @@ -313,14 +362,14 @@ Lower numbers execute **first**. { name: 'high_value_approval', type: 'conditional', - condition: 'amount > 10000', - validations: [ - { - type: 'script', - condition: 'approved_by IS NULL', - message: 'High-value transactions require approval', - }, - ], + when: P`record.amount > 10000`, + message: 'High-value transaction validation', + then: { + name: 'approval_required', + type: 'script', + condition: P`isBlank(record.approved_by)`, + message: 'High-value transactions require approval', + }, } ``` @@ -331,7 +380,7 @@ Lower numbers execute **first**. name: 'email_domain', type: 'format', field: 'email', - pattern: '^[a-zA-Z0-9._%+-]+@(company\\.com|partner\\.com)$', + regex: '^[a-zA-Z0-9._%+-]+@(company\\.com|partner\\.com)$', message: 'Email must be from company.com or partner.com', } ``` @@ -343,21 +392,19 @@ Lower numbers execute **first**. name: 'phone_format', type: 'format', field: 'phone', - pattern: '^\\+?[1-9]\\d{1,14}$', // E.164 format + regex: '^\\+?[1-9]\\d{1,14}$', // E.164 format message: 'Phone must be in international format (+1234567890)', } ``` -### Composite Unique (Tenant + Email) +### Composite Uniqueness (Tenant + Email) + +Not a validation — declare a unique index on the object: ```typescript -{ - name: 'tenant_email_unique', - type: 'unique', - fields: ['tenant_id', 'email'], - caseSensitive: false, - message: 'Email already exists in this tenant', -} +indexes: [ + { fields: ['tenant_id', 'email'], unique: true }, +] ``` ## Best Practices @@ -367,16 +414,15 @@ Lower numbers execute **first**. 3. **Events scope** — Only validate on relevant operations to avoid overhead 4. **Priority order** — System validations first (0-99), app validations second (100-999), user validations last (1000+) 5. **Clear error messages** — Tell users exactly what's wrong and how to fix it -6. **Async validation debounce** — Use debounce to reduce API calls on fast typing -7. **State machine for workflows** — Use state_machine instead of complex script logic -8. **Unique constraints** — Always use unique validation, not script-based checks +6. **State machine for workflows** — Use state_machine instead of complex script logic +7. **Uniqueness is an index concern** — Declare `indexes: [{ fields, unique: true }]`, never a script-based existence check +8. **External checks are hooks** — Call APIs from `beforeInsert`/`beforeUpdate` hooks, not validations 9. **Cross-field for comparisons** — More efficient than script validation 10. **Test thoroughly** — Validate edge cases, nulls, empty strings ## Performance Considerations - **Script validations are expensive** — Use sparingly, prefer declarative rules -- **Async validations add latency** — Use debounce and appropriate timeouts - **Priority affects order** — Lower priority = runs first -- **Unique checks hit database** — Index the unique fields for performance +- **Unique indexes are enforced by the database** — no per-write query cost beyond index maintenance - **State machine is optimized** — Better than complex conditional logic diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 6634608857..efc150b4cd 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -1154,7 +1154,7 @@ Port resolution is the same for `os dev` and `os start` (both spawn `os serve`): | Command | What it does | |:--------|:-------------| -| `os data seed` | Run all `defineDataset()` entries scoped to current env | +| `os data seed` | Run all `defineSeed()` entries scoped to current env | | `os data export` / `import` | Bulk import / export records as JSONL | | `os diff` | Show schema diff between local and target environment | | `os meta apply` | Apply metadata + data migrations to the target | diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index aa78498e07..1943ce586c 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -186,6 +186,35 @@ back-compat alias. Load **objectstack-formula** when authoring non-trivial CEL. ## Configuring a List View +### The `defineView` container (`*.view.ts` file shape) + +Views ship **inside a `defineView` container** — one per object, aggregating +the default `list`, named `listViews`, and `formViews`. The loader expands it +into `.` view items that power the view switcher. + +```typescript +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'support_case' }; + +export const CaseViews = defineView({ + list: { label: 'All Cases', type: 'grid', data, columns: ['subject', 'status'] }, + listViews: { + open: { label: 'Open', type: 'grid', data, columns: ['subject', 'status'], + filter: [{ field: 'status', operator: 'equals', value: 'open' }] }, + }, + formViews: { + edit: { type: 'simple', data, sections: [{ label: 'Case', fields: ['subject', 'status'] }] }, + }, +}); +``` + +> **Never export a bare flat view object** (`{ name, label, type, columns }` +> at top level). It is not a valid view container — nothing registers and no +> view appears in the switcher. Every view lives under `list` / `listViews` / +> `formViews`. Full canonical reference: +> `examples/app-showcase/src/ui/views/task.view.ts`. + ### Data Source (`data`) Every view connects to data via one of three providers: @@ -221,7 +250,7 @@ columns: [ { field: 'assigned_to', label: 'Owner' }, { field: 'due_date', - summary: { function: 'min' }, + summary: 'min', // footer aggregation — plain enum value, not an object sortable: true, }, ] @@ -328,33 +357,47 @@ sort: [ ## Configuring Kanban Views +Board settings nest under `kanban:` (`KanbanConfigSchema`) — there is **no +top-level `groupBy`**. The top-level `columns` is required on every list view +(including kanban), while `kanban.columns` picks the fields shown on each card. + ```typescript { type: 'kanban', - data: { provider: 'object', object: 'support_case' }, - columns: ['subject', 'priority', 'assigned_to'], - groupBy: 'status', + data: { provider: 'object', object: 'project_task' }, + columns: ['title', 'assignee', 'priority'], // required on every list view + kanban: { + groupByField: 'status', // one board column per select option + summarizeField: 'estimate_hours', // optional — summed at the top of each column + columns: ['title', 'assignee', 'priority'], // fields shown on each card + }, sort: 'priority desc', } ``` -> **Key rule:** The `groupBy` field should be a `select` type with well-defined -> options. Each option becomes a column on the board. +> **Key rule:** `kanban.groupByField` should be a `select` type with +> well-defined options. Each option becomes a column on the board. --- ## Configuring Gantt Views +Timeline settings nest under `gantt:` (`GanttConfigSchema`); +`startDateField` / `endDateField` / `titleField` are required in it. + ```typescript { type: 'gantt', data: { provider: 'object', object: 'project_task' }, columns: ['name', 'assigned_to', 'status'], // left-pane tree columns - startField: 'start_date', // task bar start - endField: 'end_date', // task bar end - progressField: 'progress', // 0–100 fill - dependencyField: 'depends_on', // FS dependency arrows - parentField: 'parent', // builds the summary-bar tree + gantt: { + startDateField: 'start_date', // task bar start + endDateField: 'end_date', // task bar end + titleField: 'name', // bar label + progressField: 'progress', // 0–100 fill + dependenciesField: 'depends_on', // FS dependency arrows + parentField: 'parent', // builds the summary-bar tree + }, } ``` @@ -366,20 +409,23 @@ freely unless `locked: true`. `timeSegments` splits each day column into ordered **bands** (e.g. 白班 / 夜班) for shift-based scheduling. It is an **ObjectUI display extension**, *not* part -of the upstream `GanttConfigSchema` in `@objectstack/spec` — it lives only in -the gantt view config and is read by the ObjectUI gantt runtime. +of the upstream `GanttConfigSchema` in `@objectstack/spec` — it lives inside +the nested `gantt: {}` view config and is read by the ObjectUI gantt runtime. ```typescript { type: 'gantt', data: { provider: 'object', object: 'work_order' }, - startField: 'start', endField: 'end', - timeSegments: { - dayStart: '08:00', // clock time the 排班日 begins (default '00:00') - bands: [ - { key: 'day', label: '白班', start: '08:00', end: '20:00' }, - { key: 'night', label: '夜班', start: '20:00', end: '08:00', color: '#6366f1' }, - ], + columns: ['name', 'assignee'], + gantt: { + startDateField: 'start', endDateField: 'end', titleField: 'name', + timeSegments: { + dayStart: '08:00', // clock time the 排班日 begins (default '00:00') + bands: [ + { key: 'day', label: '白班', start: '08:00', end: '20:00' }, + { key: 'night', label: '夜班', start: '20:00', end: '08:00', color: '#6366f1' }, + ], + }, }, } ```