diff --git a/.changeset/spec-define-x-factories.md b/.changeset/spec-define-x-factories.md new file mode 100644 index 0000000000..febe9cab57 --- /dev/null +++ b/.changeset/spec-define-x-factories.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": minor +--- + +spec: add `defineX` factories for the remaining 16 writable domains and the 6 +missing `XInput` aliases — one consistent, type-safe authoring entry per domain +(#2035). + +New factories: `defineDatasource`, `defineConnector`, `definePolicy`, +`defineSharingRule`, `defineRole`, `definePermissionSet`, +`defineEmailTemplateDefinition`, `defineReport`, `defineWebhook`, +`defineObjectExtension`, `defineCube`, `defineMapping`, `defineTheme`, +`defineTranslationBundle`, `definePage`, `defineAction`. Each mirrors the 19 +existing factories (`XSchema.parse(z.input<…>)`): input-shape ergonomics + +authoring-time validation. Because a factory is a *value* import, a broken +import hard-errors instead of silently degrading to `any` (the #2023 failure +mode), and errors surface at `.parse()` time with field-level messages. + +Also adds the previously-missing input aliases `PolicyInput`, `CubeInput`, +`MappingInput`, `ThemeInput`, `TranslationBundleInput`, `PageInput`. + +Purely additive: no existing exports change. diff --git a/eslint.config.mjs b/eslint.config.mjs index 533dbb164a..d05fe59e94 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,6 +31,22 @@ const SUBPATH_RULE_MESSAGE = 're-exports were removed because Node ESM cannot tree-shake them — see ' + 'packages/spec/src/index.ts.'; +// issue #2035 — the 16 writable domains that now have a `defineX` factory. In +// example/app metadata files these must be authored through the factory, never a +// bare `: DomainType` / `: DomainTypeInput` literal: the factory validates at +// `.parse()` time and is a *value* import that fails loudly on a broken import +// instead of silently degrading to `any` (the #2023 failure mode). +const DOMAIN_TYPES = [ + 'Datasource', 'Connector', 'Policy', 'SharingRule', 'Role', 'PermissionSet', + 'EmailTemplateDefinition', 'Report', 'Webhook', 'ObjectExtension', 'Cube', + 'Mapping', 'Theme', 'TranslationBundle', 'Page', 'Action', +].flatMap((t) => [t, t + 'Input']).join('|'); + +const DOMAIN_RULE_MESSAGE = + 'Author this metadata through its defineX factory (e.g. `definePage({ ... })`) ' + + 'instead of a bare `: Type` literal. The factory validates at parse time and a ' + + 'broken value import fails loudly instead of degrading to `any` — see issue #2035.'; + export default [ { files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], @@ -62,4 +78,29 @@ export default [ }], }, }, + // issue #2035 — authoring-entry guard. Flags exported consts in example + // metadata files that are annotated with a spec domain type (simple `Page` + // or qualified `UI.Page`) instead of being wrapped in the `defineX` factory. + // AST-only (no type info): matches the declaration shape, not local vars or + // function params. Scoped to examples — the reference corpus AI learns from. + { + files: ['examples/**/*.{ts,tsx,mts,cts}'], + ignores: ['**/node_modules/**', '**/dist/**'], + languageOptions: { + parser: tsParser, + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + }, + rules: { + 'no-restricted-syntax': ['error', + { + selector: `ExportNamedDeclaration VariableDeclarator[id.typeAnnotation.typeAnnotation.typeName.name=/^(${DOMAIN_TYPES})$/]`, + message: DOMAIN_RULE_MESSAGE, + }, + { + selector: `ExportNamedDeclaration VariableDeclarator[id.typeAnnotation.typeAnnotation.typeName.right.name=/^(${DOMAIN_TYPES})$/]`, + message: DOMAIN_RULE_MESSAGE, + }, + ], + }, + }, ]; diff --git a/examples/app-crm/src/actions/convert-lead.action.ts b/examples/app-crm/src/actions/convert-lead.action.ts index 970cecf78b..4671bb4c28 100644 --- a/examples/app-crm/src/actions/convert-lead.action.ts +++ b/examples/app-crm/src/actions/convert-lead.action.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type * as UI from '@objectstack/spec/ui'; +import { defineAction } from '@objectstack/spec/ui'; /** * Row-level action on crm_lead — launches the Convert Lead screen flow wizard. * Shown as a button in the lead list row menu and in the lead record header. */ -export const ConvertLeadAction: UI.ActionInput = { +export const ConvertLeadAction = defineAction({ name: 'crm_convert_lead', label: 'Convert Lead', icon: 'ArrowRightCircle', @@ -20,4 +20,4 @@ export const ConvertLeadAction: UI.ActionInput = { // hitting the flow's "already converted" guard screen. The flow keeps that // guard as a server-side backstop. visible: 'record.status != "converted"', -}; +}); diff --git a/examples/app-crm/src/actions/park-lead.action.ts b/examples/app-crm/src/actions/park-lead.action.ts index 62d35ab7b1..0f4a30ca04 100644 --- a/examples/app-crm/src/actions/park-lead.action.ts +++ b/examples/app-crm/src/actions/park-lead.action.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type * as UI from '@objectstack/spec/ui'; +import { defineAction } from '@objectstack/spec/ui'; /** * Row-level action on crm_lead — reassign the lead's owner. @@ -11,7 +11,7 @@ import type * as UI from '@objectstack/spec/ui'; * UndoManager — Ctrl+Z works too). Prompts for the new owner via one param * (pre-filled with "Triage Queue"). */ -export const ParkLeadAction: UI.ActionInput = { +export const ParkLeadAction = defineAction({ name: 'crm_park_lead', label: 'Reassign Lead', icon: 'UserPlus', @@ -32,4 +32,4 @@ export const ParkLeadAction: UI.ActionInput = { ], undoable: true, successMessage: 'Lead reassigned.', -}; +}); diff --git a/examples/app-crm/src/analytics/crm.cube.ts b/examples/app-crm/src/analytics/crm.cube.ts index cda2a47672..766f9cc5ab 100644 --- a/examples/app-crm/src/analytics/crm.cube.ts +++ b/examples/app-crm/src/analytics/crm.cube.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Cube } from '@objectstack/spec/data'; +import { defineCube } from '@objectstack/spec/data'; /** * Opportunity Pipeline Cube — revenue metrics broken down by stage, * owner, and account for the CRM sales dashboard. */ -export const PipelineCube: Cube = { +export const PipelineCube = defineCube({ name: 'crm_pipeline', title: 'CRM Pipeline', description: 'Revenue and deal-count analytics across the sales pipeline.', @@ -71,12 +71,12 @@ export const PipelineCube: Cube = { every: '1 hour', }, public: false, -}; +}); /** * Lead funnel cube — conversion metrics from lead to opportunity. */ -export const LeadFunnelCube: Cube = { +export const LeadFunnelCube = defineCube({ name: 'crm_lead_funnel', title: 'CRM Lead Funnel', description: 'Lead volume and conversion rate analytics.', @@ -119,4 +119,4 @@ export const LeadFunnelCube: Cube = { every: '30 minutes', }, public: false, -}; +}); diff --git a/examples/app-crm/src/connectors/crm-connectors.ts b/examples/app-crm/src/connectors/crm-connectors.ts index a732f63c26..3cf9b6c42f 100644 --- a/examples/app-crm/src/connectors/crm-connectors.ts +++ b/examples/app-crm/src/connectors/crm-connectors.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { ConnectorInput } from '@objectstack/spec/integration'; +import { defineConnector } from '@objectstack/spec/integration'; /** * HubSpot connector — sync contacts and deals bi-directionally. * Uses OAuth2 for authentication; actual credentials come from environment. */ -export const HubSpotConnector: ConnectorInput = { +export const HubSpotConnector = defineConnector({ name: 'hubspot_crm', label: 'HubSpot CRM', type: 'saas', @@ -86,12 +86,12 @@ export const HubSpotConnector: ConnectorInput = { requestTimeoutMs: 30000, status: 'inactive', enabled: true, -}; +}); /** * Slack connector — post notifications to channels. */ -export const SlackConnector: ConnectorInput = { +export const SlackConnector = defineConnector({ name: 'slack_notifications', label: 'Slack', type: 'api', @@ -123,4 +123,4 @@ export const SlackConnector: ConnectorInput = { }, status: 'inactive', enabled: true, -}; +}); diff --git a/examples/app-crm/src/data/crm-mappings.ts b/examples/app-crm/src/data/crm-mappings.ts index d92a182c3c..66a17731fd 100644 --- a/examples/app-crm/src/data/crm-mappings.ts +++ b/examples/app-crm/src/data/crm-mappings.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Mapping } from '@objectstack/spec/data'; +import { defineMapping } from '@objectstack/spec/data'; /** * CSV import mapping for bulk lead upload. * Maps common CRM export column names to crm_lead fields. */ -export const LeadCsvImportMapping: Mapping = { +export const LeadCsvImportMapping = defineMapping({ name: 'csv_import_leads', label: 'CSV Import: Leads', sourceFormat: 'csv', @@ -51,12 +51,12 @@ export const LeadCsvImportMapping: Mapping = { ], errorPolicy: 'skip', batchSize: 500, -}; +}); /** * JSON import mapping for contact sync from external systems (HubSpot, etc.). */ -export const ContactJsonSyncMapping: Mapping = { +export const ContactJsonSyncMapping = defineMapping({ name: 'json_sync_contacts', label: 'JSON Sync: Contacts from HubSpot', sourceFormat: 'json', @@ -72,4 +72,4 @@ export const ContactJsonSyncMapping: Mapping = { ], errorPolicy: 'skip', batchSize: 250, -}; +}); diff --git a/examples/app-crm/src/datasources/crm.datasource.ts b/examples/app-crm/src/datasources/crm.datasource.ts index 7c41686bf9..09c210a7f0 100644 --- a/examples/app-crm/src/datasources/crm.datasource.ts +++ b/examples/app-crm/src/datasources/crm.datasource.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { DatasourceInput } from '@objectstack/spec/data'; +import { defineDatasource } from '@objectstack/spec/data'; /** * Primary CRM datasource — in-memory SQLite for the example. * In production, swap `driver` to 'postgres' and supply real `config`. */ -export const CrmDatasource: DatasourceInput = { +export const CrmDatasource = defineDatasource({ name: 'crm_primary', label: 'CRM Primary Database', driver: 'sqlite', @@ -18,12 +18,12 @@ export const CrmDatasource: DatasourceInput = { max: 5, }, active: true, -}; +}); /** * Read-replica for analytics queries — demonstrates datasource routing. */ -export const CrmAnalyticsDatasource: DatasourceInput = { +export const CrmAnalyticsDatasource = defineDatasource({ name: 'crm_analytics', label: 'CRM Analytics Read Replica', driver: 'sqlite', @@ -32,4 +32,4 @@ export const CrmAnalyticsDatasource: DatasourceInput = { readOnly: true, }, active: true, -}; +}); diff --git a/examples/app-crm/src/emails/deal-won.email.ts b/examples/app-crm/src/emails/deal-won.email.ts index d24fbce3c3..39bac0b18a 100644 --- a/examples/app-crm/src/emails/deal-won.email.ts +++ b/examples/app-crm/src/emails/deal-won.email.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { EmailTemplateDefinitionInput } from '@objectstack/spec/system'; +import { defineEmailTemplateDefinition } from '@objectstack/spec/system'; /** * Sent when an opportunity moves to Closed Won. * Referenced by the `notify_owner_deal_won` workflow action. */ -export const DealWonEmail: EmailTemplateDefinitionInput = { +export const DealWonEmail = defineEmailTemplateDefinition({ name: 'crm.deal_won', label: 'Deal Won — Owner Congrats', category: 'workflow', @@ -33,4 +33,4 @@ Nice work!`, ], active: true, description: 'Internal congrats email fired by the High-Value Deal workflow when stage = Closed Won.', -}; +}); diff --git a/examples/app-crm/src/emails/lead-follow-up.email.ts b/examples/app-crm/src/emails/lead-follow-up.email.ts index c6e2fb2066..43d9fd94d2 100644 --- a/examples/app-crm/src/emails/lead-follow-up.email.ts +++ b/examples/app-crm/src/emails/lead-follow-up.email.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { EmailTemplateDefinitionInput } from '@objectstack/spec/system'; +import { defineEmailTemplateDefinition } from '@objectstack/spec/system'; /** * Follow-up nudge sent by the Stale Opportunity workflow when no * activity has been logged on a Lead for the configured threshold. */ -export const LeadFollowUpEmail: EmailTemplateDefinitionInput = { +export const LeadFollowUpEmail = defineEmailTemplateDefinition({ name: 'crm.lead_followup', label: 'Lead — Follow-Up Reminder', category: 'notification', @@ -35,4 +35,4 @@ Follow up: {{lead_url}}`, ], active: true, description: 'Internal reminder fired by the Stale Opportunity workflow.', -}; +}); diff --git a/examples/app-crm/src/emails/welcome.email.ts b/examples/app-crm/src/emails/welcome.email.ts index 923049fd94..77282fce5a 100644 --- a/examples/app-crm/src/emails/welcome.email.ts +++ b/examples/app-crm/src/emails/welcome.email.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { EmailTemplateDefinitionInput } from '@objectstack/spec/system'; +import { defineEmailTemplateDefinition } from '@objectstack/spec/system'; /** * Welcome email sent to a Contact after it's added to the CRM. * Demonstrates marketing-category templates with a clear CTA. */ -export const WelcomeEmail: EmailTemplateDefinitionInput = { +export const WelcomeEmail = defineEmailTemplateDefinition({ name: 'crm.welcome', label: 'Welcome — New Contact', category: 'marketing', @@ -36,4 +36,4 @@ Open your portal: {{portal_url}}`, replyTo: 'support@acme.example', active: true, description: 'Marketing welcome email sent on contact creation.', -}; +}); diff --git a/examples/app-crm/src/extensions/contact.extension.ts b/examples/app-crm/src/extensions/contact.extension.ts index 67e84e3d3e..1aa55ce5ed 100644 --- a/examples/app-crm/src/extensions/contact.extension.ts +++ b/examples/app-crm/src/extensions/contact.extension.ts @@ -1,13 +1,13 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { ObjectExtensionInput } from '@objectstack/spec/data'; +import { defineObjectExtension } from '@objectstack/spec/data'; /** * Extends the built-in crm_contact object with social-media fields. * Demonstrates ObjectExtension — additive fields without re-declaring the * whole object schema. */ -export const ContactExtension: ObjectExtensionInput = { +export const ContactExtension = defineObjectExtension({ extend: 'crm_contact', label: 'Contact (CRM Extended)', fields: { @@ -35,4 +35,4 @@ export const ContactExtension: ObjectExtensionInput = { }, }, priority: 210, -}; +}); diff --git a/examples/app-crm/src/pages/welcome.page.ts b/examples/app-crm/src/pages/welcome.page.ts index a1f9bd1e96..199c8fe94c 100644 --- a/examples/app-crm/src/pages/welcome.page.ts +++ b/examples/app-crm/src/pages/welcome.page.ts @@ -1,11 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type * as UI from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Example custom page — a CRM landing page. */ -export const CrmWelcomePage: UI.Page = { +export const CrmWelcomePage = definePage({ name: 'crm_welcome', label: 'CRM Welcome', type: 'home', @@ -40,4 +40,4 @@ export const CrmWelcomePage: UI.Page = { ], }, ], -}; +}); diff --git a/examples/app-crm/src/reports/sales-by-stage.report.ts b/examples/app-crm/src/reports/sales-by-stage.report.ts index e4f2a45ccb..14e75c7a73 100644 --- a/examples/app-crm/src/reports/sales-by-stage.report.ts +++ b/examples/app-crm/src/reports/sales-by-stage.report.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type * as UI from '@objectstack/spec/ui'; +import { defineReport } from '@objectstack/spec/ui'; /** * Example report — total opportunity amount grouped by stage. @@ -11,7 +11,7 @@ import type * as UI from '@objectstack/spec/ui'; * its "grouped by stage" label), so both forms compute the same number and the * reconciliation harness can verify them. */ -export const SalesByStageReport: UI.ReportInput = { +export const SalesByStageReport = defineReport({ name: 'crm_sales_by_stage', label: 'Sales by Stage', description: 'Total opportunity amount grouped by sales stage.', @@ -19,4 +19,4 @@ export const SalesByStageReport: UI.ReportInput = { dataset: 'opportunity_metrics', rows: ['stage'], values: ['total_amount'], -}; +}); diff --git a/examples/app-crm/src/security/crm-policy.ts b/examples/app-crm/src/security/crm-policy.ts index f5c1102e14..577c7d45ff 100644 --- a/examples/app-crm/src/security/crm-policy.ts +++ b/examples/app-crm/src/security/crm-policy.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Policy } from '@objectstack/spec/security'; +import { definePolicy } from '@objectstack/spec/security'; /** * Default CRM security policy applied to all users. * Enforces password complexity, session limits, and audit logging. */ -export const CrmDefaultPolicy: Policy = { +export const CrmDefaultPolicy = definePolicy({ name: 'crm_default_policy', password: { minLength: 12, @@ -28,12 +28,12 @@ export const CrmDefaultPolicy: Policy = { captureRead: false, }, isDefault: true, -}; +}); /** * Strict policy for Finance Approvers — requires MFA and shorter sessions. */ -export const CrmFinancePolicy: Policy = { +export const CrmFinancePolicy = definePolicy({ name: 'crm_finance_policy', password: { minLength: 16, @@ -56,4 +56,4 @@ export const CrmFinancePolicy: Policy = { }, isDefault: false, assignedProfiles: ['finance_approver'], -}; +}); diff --git a/examples/app-crm/src/security/sales-roles.ts b/examples/app-crm/src/security/sales-roles.ts index 4984005a94..de8c7d4ebe 100644 --- a/examples/app-crm/src/security/sales-roles.ts +++ b/examples/app-crm/src/security/sales-roles.ts @@ -1,37 +1,37 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type * as Identity from '@objectstack/spec/identity'; -import type * as Security from '@objectstack/spec/security'; +import { defineRole } from '@objectstack/spec/identity'; +import { definePermissionSet } from '@objectstack/spec/security'; /** * Example roles — a small sales hierarchy. */ -export const SalesRepRole: Identity.RoleInput = { +export const SalesRepRole = defineRole({ name: 'sales_rep', label: 'Sales Representative', description: 'Front-line sales representative.', -}; +}); -export const SalesManagerRole: Identity.RoleInput = { +export const SalesManagerRole = defineRole({ name: 'sales_manager', label: 'Sales Manager', description: 'Manages a team of sales reps.', parent: 'sales_rep', -}; +}); /** Referenced by the Discount Approval second step. */ -export const FinanceApproverRole: Identity.RoleInput = { +export const FinanceApproverRole = defineRole({ name: 'finance_approver', label: 'Finance Approver', description: 'Finance team member authorised to approve discounts above 30%.', -}; +}); /** * Example permission set — base permissions on CRM objects for sales users. * * Note: `objects` is a Record keyed by object name, not an array. */ -export const SalesUserPermissionSet: Security.PermissionSetInput = { +export const SalesUserPermissionSet = definePermissionSet({ name: 'crm_sales_user', label: 'CRM Sales User', isProfile: false, @@ -42,7 +42,7 @@ export const SalesUserPermissionSet: Security.PermissionSetInput = { crm_lead: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, crm_activity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, }, -}; +}); /** * Guest profile for the public Web-to-Lead form (lead.view.ts `web_to_lead`). @@ -52,11 +52,11 @@ export const SalesUserPermissionSet: Security.PermissionSetInput = { * `crm_lead` (not a short `lead`). INSERT-only — guests can never read, edit, or * delete any record. */ -export const GuestPortalProfile: Security.PermissionSetInput = { +export const GuestPortalProfile = definePermissionSet({ name: 'guest_portal', label: 'Guest (Public Forms)', isProfile: true, objects: { crm_lead: { allowRead: false, allowCreate: true, allowEdit: false, allowDelete: false }, }, -}; +}); diff --git a/examples/app-crm/src/security/sharing-rules.ts b/examples/app-crm/src/security/sharing-rules.ts index 2e49b44a2f..4d4407a9e3 100644 --- a/examples/app-crm/src/security/sharing-rules.ts +++ b/examples/app-crm/src/security/sharing-rules.ts @@ -1,12 +1,12 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { SharingRuleInput } from '@objectstack/spec/security'; +import { defineSharingRule } from '@objectstack/spec/security'; /** * Criteria-based sharing: share high-value opportunities (amount > 100000) * with the Sales Manager role so managers always have read access to big deals. */ -export const HighValueOpportunitySharingRule: SharingRuleInput = { +export const HighValueOpportunitySharingRule = defineSharingRule({ type: 'criteria', name: 'share_high_value_opps_with_managers', label: 'High-Value Deals → Sales Managers', @@ -19,13 +19,13 @@ export const HighValueOpportunitySharingRule: SharingRuleInput = { value: 'sales_manager', }, active: true, -}; +}); /** * Owner-based sharing: leads owned by a Sales Rep are shared read-only * with their manager so managers can coach on individual pipelines. */ -export const RepLeadSharingRule: SharingRuleInput = { +export const RepLeadSharingRule = defineSharingRule({ type: 'owner', name: 'share_rep_leads_with_manager', label: "Rep's Leads → Manager (read-only)", @@ -41,13 +41,13 @@ export const RepLeadSharingRule: SharingRuleInput = { value: 'sales_manager', }, active: true, -}; +}); /** * Criteria-based: share activities linked to won deals with the whole * Sales team so everyone can learn from successful engagement patterns. */ -export const WonDealActivitySharingRule: SharingRuleInput = { +export const WonDealActivitySharingRule = defineSharingRule({ type: 'criteria', name: 'share_won_deal_activities', label: 'Won-Deal Activities → All Sales', @@ -60,4 +60,4 @@ export const WonDealActivitySharingRule: SharingRuleInput = { value: 'sales_rep', }, active: true, -}; +}); diff --git a/examples/app-crm/src/themes/crm.theme.ts b/examples/app-crm/src/themes/crm.theme.ts index 925408fbba..c1ecfa8f0f 100644 --- a/examples/app-crm/src/themes/crm.theme.ts +++ b/examples/app-crm/src/themes/crm.theme.ts @@ -1,11 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Theme } from '@objectstack/spec/ui'; +import { defineTheme } from '@objectstack/spec/ui'; /** * Default CRM brand theme — light mode with professional blue palette. */ -export const CrmLightTheme: Theme = { +export const CrmLightTheme = defineTheme({ name: 'crm_light', label: 'CRM Light', description: 'Default CRM theme — professional blue, light mode.', @@ -54,12 +54,12 @@ export const CrmLightTheme: Theme = { }, density: 'regular', wcagContrast: 'AA', -}; +}); /** * Dark variant — same palette, dark surfaces. */ -export const CrmDarkTheme: Theme = { +export const CrmDarkTheme = defineTheme({ name: 'crm_dark', label: 'CRM Dark', description: 'CRM dark mode theme.', @@ -80,4 +80,4 @@ export const CrmDarkTheme: Theme = { info: '#3DD5F3', }, density: 'regular', -}; +}); diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index 6326f6447e..edacfce59c 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { TranslationBundle } from '@objectstack/spec/system'; +import { defineTranslationBundle } from '@objectstack/spec/system'; /** * CRM translation bundle — English + Simplified Chinese. @@ -8,7 +8,7 @@ import type { TranslationBundle } from '@objectstack/spec/system'; * Provides display labels for all CRM objects, apps, and common UI messages * so the Studio i18n pipeline has real data to render. */ -export const CrmTranslationBundle: TranslationBundle = { +export const CrmTranslationBundle = defineTranslationBundle({ en: { objects: { crm_account: { @@ -186,4 +186,4 @@ export const CrmTranslationBundle: TranslationBundle = { 'crm.activity.due_today': '您今天有 {count} 个活动待处理。', }, }, -}; +}); diff --git a/examples/app-crm/src/webhooks/crm-webhooks.ts b/examples/app-crm/src/webhooks/crm-webhooks.ts index 0499a8ef6b..33715d87de 100644 --- a/examples/app-crm/src/webhooks/crm-webhooks.ts +++ b/examples/app-crm/src/webhooks/crm-webhooks.ts @@ -1,11 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { WebhookInput } from '@objectstack/spec/automation'; +import { defineWebhook } from '@objectstack/spec/automation'; /** * Notify external CRM bus whenever an opportunity is created or updated. */ -export const OpportunityChangedWebhook: WebhookInput = { +export const OpportunityChangedWebhook = defineWebhook({ name: 'crm_opportunity_changed', label: 'Opportunity Created / Updated', object: 'crm_opportunity', @@ -32,12 +32,12 @@ export const OpportunityChangedWebhook: WebhookInput = { isActive: true, description: 'Fires on every opportunity create/update for downstream sync.', tags: ['crm', 'sync'], -}; +}); /** * Notify Slack channel when a deal is won. */ -export const DealWonSlackWebhook: WebhookInput = { +export const DealWonSlackWebhook = defineWebhook({ name: 'crm_deal_won_slack', label: 'Deal Won → Slack', object: 'crm_opportunity', @@ -49,4 +49,4 @@ export const DealWonSlackWebhook: WebhookInput = { isActive: true, description: 'Posts to #wins Slack channel when stage=closed_won.', tags: ['slack', 'notifications'], -}; +}); diff --git a/examples/app-showcase/src/actions/index.ts b/examples/app-showcase/src/actions/index.ts index 05bcdeefee..efc2cd4e3e 100644 --- a/examples/app-showcase/src/actions/index.ts +++ b/examples/app-showcase/src/actions/index.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Action } from '@objectstack/spec/ui'; +import { defineAction } from '@objectstack/spec/ui'; const task = 'showcase_task'; @@ -11,7 +11,7 @@ const task = 'showcase_task'; */ /** script — inline handler, shown on each row and the record header. */ -export const MarkDoneAction: Action = { +export const MarkDoneAction = defineAction({ name: 'showcase_mark_done', label: 'Mark Done', icon: 'check', @@ -22,10 +22,10 @@ export const MarkDoneAction: Action = { // explicitly-named actions, mirroring the platform's own sys-user pages. locations: ['list_item', 'record_header', 'record_section'], refreshAfter: true, -}; +}); /** url — navigate out, from the row overflow menu. */ -export const OpenDocsAction: Action = { +export const OpenDocsAction = defineAction({ name: 'showcase_open_docs', label: 'Open Docs', icon: 'book-open', @@ -34,7 +34,7 @@ export const OpenDocsAction: Action = { target: 'https://docs.objectstack.ai', locations: ['record_more'], refreshAfter: false, -}; +}); /** * flow — launch the Reassign screen-flow wizard. Row-level (`list_item`) so the @@ -42,7 +42,7 @@ export const OpenDocsAction: Action = { * `screen` node and writes it back with `update_record`. The objectui * FlowRunner renders the screen and resumes the run. */ -export const BulkReassignAction: Action = { +export const BulkReassignAction = defineAction({ name: 'showcase_bulk_reassign', label: 'Reassign…', icon: 'users', @@ -51,10 +51,10 @@ export const BulkReassignAction: Action = { target: 'showcase_reassign_wizard', locations: ['list_item', 'list_toolbar'], refreshAfter: true, -}; +}); /** modal — open a dialog/page. */ -export const QuickViewAction: Action = { +export const QuickViewAction = defineAction({ name: 'showcase_quick_view', label: 'Quick View', icon: 'eye', @@ -63,10 +63,10 @@ export const QuickViewAction: Action = { target: 'showcase_component_gallery', locations: ['list_item'], refreshAfter: false, -}; +}); /** api — call a custom endpoint. */ -export const RecalcEstimateAction: Action = { +export const RecalcEstimateAction = defineAction({ name: 'showcase_recalc_estimate', label: 'Recalculate Estimate', icon: 'calculator', @@ -75,10 +75,10 @@ export const RecalcEstimateAction: Action = { target: '/api/v1/showcase/recalc', locations: ['record_more', 'record_section'], refreshAfter: true, -}; +}); /** form — open a parameter form dialog. */ -export const LogTimeAction: Action = { +export const LogTimeAction = defineAction({ name: 'showcase_log_time', label: 'Log Time', icon: 'clock', @@ -88,10 +88,10 @@ export const LogTimeAction: Action = { // `record_section` so it surfaces in the Task Detail quick-actions bar too. locations: ['record_header', 'record_related', 'record_section'], refreshAfter: true, -}; +}); /** global nav command-palette action. */ -export const NewTaskAction: Action = { +export const NewTaskAction = defineAction({ name: 'showcase_new_task', label: 'New Task', icon: 'plus', @@ -100,7 +100,7 @@ export const NewTaskAction: Action = { target: 'showcase_component_gallery', locations: ['global_nav'], refreshAfter: true, -}; +}); export const allActions = [ MarkDoneAction, diff --git a/examples/app-showcase/src/pages/account-detail.page.ts b/examples/app-showcase/src/pages/account-detail.page.ts index 01b6943fc6..e8bc6bf0a9 100644 --- a/examples/app-showcase/src/pages/account-detail.page.ts +++ b/examples/app-showcase/src/pages/account-detail.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Account 360 — the flagship "object 360" record page every enterprise app has, @@ -20,7 +20,7 @@ import type { Page } from '@objectstack/spec/ui'; * slotted path; the same shape inside a full-page region does not — mirror the * working Project Detail page.) */ -export const AccountDetailPage: Page = { +export const AccountDetailPage = definePage({ name: 'showcase_account_detail', label: 'Account', type: 'record', @@ -103,4 +103,4 @@ export const AccountDetailPage: Page = { }, }, }, -}; +}); diff --git a/examples/app-showcase/src/pages/active-projects.page.ts b/examples/app-showcase/src/pages/active-projects.page.ts index a32e9223da..dea7a10f63 100644 --- a/examples/app-showcase/src/pages/active-projects.page.ts +++ b/examples/app-showcase/src/pages/active-projects.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Active Projects — interface page demonstrating the deeper list config: @@ -10,7 +10,7 @@ import type { Page } from '@objectstack/spec/ui'; * • `addRecord` — a toolbar "add" entry point that opens a FORM. * • dropdown user-filters layered on top of the base filter. */ -export const ActiveProjectsPage: Page = { +export const ActiveProjectsPage = definePage({ name: 'showcase_active_projects', label: 'Active Projects', type: 'list', @@ -36,4 +36,4 @@ export const ActiveProjectsPage: Page = { addRecord: { enabled: true, position: 'top', mode: 'form', formView: 'default' }, showRecordCount: true, }, -}; +}); diff --git a/examples/app-showcase/src/pages/index.ts b/examples/app-showcase/src/pages/index.ts index 763270f5da..379a4530b3 100644 --- a/examples/app-showcase/src/pages/index.ts +++ b/examples/app-showcase/src/pages/index.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; export { ProjectWorkspacePage } from './project-workspace.page.js'; export { ProjectDetailPage } from './project-detail.page.js'; @@ -30,7 +30,7 @@ export { * region (ai:input, oversized element:image, page:card body) so the first * impression is polished, not a debug canvas. */ -export const ComponentGalleryPage: Page = { +export const ComponentGalleryPage = definePage({ name: 'showcase_component_gallery', label: 'Component Gallery', type: 'home', @@ -85,4 +85,4 @@ export const ComponentGalleryPage: Page = { ], }, ], -}; +}); diff --git a/examples/app-showcase/src/pages/my-work.page.ts b/examples/app-showcase/src/pages/my-work.page.ts index 3cbf8afea7..df317aa692 100644 --- a/examples/app-showcase/src/pages/my-work.page.ts +++ b/examples/app-showcase/src/pages/my-work.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * My Work — a role-aware workspace home that *composes* live data the way a @@ -10,7 +10,7 @@ import type { Page } from '@objectstack/spec/ui'; * via the `{current_user_id}` token (records I own); * • sidebar shortcuts + a per-user `visible`-gated note on `user.email`. */ -export const MyWorkPage: Page = { +export const MyWorkPage = definePage({ name: 'showcase_my_work', label: 'My Work', type: 'home', @@ -86,4 +86,4 @@ export const MyWorkPage: Page = { ], }, ], -}; +}); diff --git a/examples/app-showcase/src/pages/new-project-wizard.page.ts b/examples/app-showcase/src/pages/new-project-wizard.page.ts index 548254bc0b..a158dbcd35 100644 --- a/examples/app-showcase/src/pages/new-project-wizard.page.ts +++ b/examples/app-showcase/src/pages/new-project-wizard.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * New Project Wizard — a multi-step (wizard) form surface. The showcase @@ -9,7 +9,7 @@ import type { Page } from '@objectstack/spec/ui'; * `formType: 'wizard'` directly: Basics → Status → Budget, with a step * indicator, over showcase_project. */ -export const NewProjectWizardPage: Page = { +export const NewProjectWizardPage = definePage({ name: 'showcase_new_project_wizard', label: 'New Project (Wizard)', type: 'app', @@ -40,4 +40,4 @@ export const NewProjectWizardPage: Page = { ], }, ], -}; +}); diff --git a/examples/app-showcase/src/pages/project-detail.page.ts b/examples/app-showcase/src/pages/project-detail.page.ts index 5cb1c0aa31..94837b93c3 100644 --- a/examples/app-showcase/src/pages/project-detail.page.ts +++ b/examples/app-showcase/src/pages/project-detail.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Project detail — a slotted record page that surfaces the project's Tasks as @@ -14,7 +14,7 @@ import type { Page } from '@objectstack/spec/ui'; * fills in the header / highlights / details / discussion; the Tasks tab below * replaces the synthesized related-list strip. */ -export const ProjectDetailPage: Page = { +export const ProjectDetailPage = definePage({ name: 'showcase_project_detail', label: 'Project', type: 'record', @@ -102,4 +102,4 @@ export const ProjectDetailPage: Page = { }, }, }, -}; +}); diff --git a/examples/app-showcase/src/pages/project-workspace.page.ts b/examples/app-showcase/src/pages/project-workspace.page.ts index e25a5136b7..b6ac8698c1 100644 --- a/examples/app-showcase/src/pages/project-workspace.page.ts +++ b/examples/app-showcase/src/pages/project-workspace.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Project Workspace — a master-detail (header + line items) entry scenario. @@ -10,7 +10,7 @@ import type { Page } from '@objectstack/spec/ui'; * `showcase_task.project` is a `master_detail` field, so the children are * created with the parent FK set in a single client-orchestrated transaction. */ -export const ProjectWorkspacePage: Page = { +export const ProjectWorkspacePage = definePage({ name: 'showcase_project_workspace', label: 'New Project + Tasks', type: 'app', @@ -63,4 +63,4 @@ export const ProjectWorkspacePage: Page = { ], }, ], -}; +}); diff --git a/examples/app-showcase/src/pages/review-queue.page.ts b/examples/app-showcase/src/pages/review-queue.page.ts index 6d2f2cf03f..756abc14b6 100644 --- a/examples/app-showcase/src/pages/review-queue.page.ts +++ b/examples/app-showcase/src/pages/review-queue.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Approvals · Review Queue — the human side of the approval / review flows. @@ -12,7 +12,7 @@ import type { Page } from '@objectstack/spec/ui'; * buttons (Mark done = approve & complete) and a drawer to inspect each item. * Tabs let the reviewer pivot to urgent or blocked work. */ -export const ReviewQueuePage: Page = { +export const ReviewQueuePage = definePage({ name: 'showcase_review_queue', label: 'Approvals', type: 'list', @@ -35,4 +35,4 @@ export const ReviewQueuePage: Page = { recordAction: 'drawer', showRecordCount: true, }, -}; +}); diff --git a/examples/app-showcase/src/pages/settings.page.ts b/examples/app-showcase/src/pages/settings.page.ts index 111eb95fe1..2353bd5c8a 100644 --- a/examples/app-showcase/src/pages/settings.page.ts +++ b/examples/app-showcase/src/pages/settings.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Settings — every enterprise app needs one. A record page over the singleton @@ -8,7 +8,7 @@ import type { Page } from '@objectstack/spec/ui'; * to change it), so this is a working preferences surface grouped into * Appearance / Notifications sections. */ -export const SettingsPage: Page = { +export const SettingsPage = definePage({ name: 'showcase_settings', label: 'Setting', type: 'record', @@ -33,4 +33,4 @@ export const SettingsPage: Page = { ], }, ], -}; +}); diff --git a/examples/app-showcase/src/pages/task-detail.page.ts b/examples/app-showcase/src/pages/task-detail.page.ts index 6af7976314..4022eae090 100644 --- a/examples/app-showcase/src/pages/task-detail.page.ts +++ b/examples/app-showcase/src/pages/task-detail.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Task Detail — a record page that exercises the record-layout component set @@ -15,7 +15,7 @@ import type { Page } from '@objectstack/spec/ui'; * `kind: 'full'` — this page fully owns the record layout (vs the slotted * Project page which only overrides the tabs slot). */ -export const TaskDetailPage: Page = { +export const TaskDetailPage = definePage({ name: 'showcase_task_detail', label: 'Task', type: 'record', @@ -77,4 +77,4 @@ export const TaskDetailPage: Page = { ], }, ], -}; +}); diff --git a/examples/app-showcase/src/pages/task-triage.page.ts b/examples/app-showcase/src/pages/task-triage.page.ts index f522b6034e..8e4b2583ef 100644 --- a/examples/app-showcase/src/pages/task-triage.page.ts +++ b/examples/app-showcase/src/pages/task-triage.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Task Triage — interface page demonstrating the **tabs** user-filter element @@ -13,7 +13,7 @@ import type { Page } from '@objectstack/spec/ui'; * (`{ name, label, filter }`) — it never switches the view form; that is the * separate "Visualizations" axis (locked to grid here). */ -export const TaskTriagePage: Page = { +export const TaskTriagePage = definePage({ name: 'showcase_task_triage', label: 'Task Triage', type: 'list', @@ -56,4 +56,4 @@ export const TaskTriagePage: Page = { showRecordCount: true, }, -}; +}); diff --git a/examples/app-showcase/src/pages/task-visualizations.pages.ts b/examples/app-showcase/src/pages/task-visualizations.pages.ts index fcce5d2cf1..20cccdeeef 100644 --- a/examples/app-showcase/src/pages/task-visualizations.pages.ts +++ b/examples/app-showcase/src/pages/task-visualizations.pages.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Visualization gallery — one interface page per record visualization, each @@ -25,7 +25,7 @@ const base = { const cols = ['title', 'assignee', 'status', 'priority', 'due_date']; -export const TaskBoardPage: Page = { +export const TaskBoardPage = definePage({ ...base, name: 'showcase_task_board', label: 'Task Board', @@ -36,9 +36,9 @@ export const TaskBoardPage: Page = { userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, -}; +}); -export const TaskCalendarPage: Page = { +export const TaskCalendarPage = definePage({ ...base, name: 'showcase_task_calendar', label: 'Task Calendar', @@ -49,9 +49,9 @@ export const TaskCalendarPage: Page = { userActions: { sort: false, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, -}; +}); -export const TaskGalleryPage: Page = { +export const TaskGalleryPage = definePage({ ...base, name: 'showcase_task_gallery', label: 'Task Gallery', @@ -62,9 +62,9 @@ export const TaskGalleryPage: Page = { userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, -}; +}); -export const TaskSchedulePage: Page = { +export const TaskSchedulePage = definePage({ ...base, name: 'showcase_task_schedule', label: 'Team Schedule (Gantt)', @@ -75,9 +75,9 @@ export const TaskSchedulePage: Page = { userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, -}; +}); -export const TaskTimelinePage: Page = { +export const TaskTimelinePage = definePage({ ...base, name: 'showcase_task_timeline', label: 'Activity Timeline', @@ -88,9 +88,9 @@ export const TaskTimelinePage: Page = { userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, -}; +}); -export const TaskMapPage: Page = { +export const TaskMapPage = definePage({ ...base, name: 'showcase_task_map', label: 'Work Map', @@ -101,9 +101,9 @@ export const TaskMapPage: Page = { userActions: { sort: false, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, -}; +}); -export const TaskAllViewsPage: Page = { +export const TaskAllViewsPage = definePage({ ...base, name: 'showcase_task_all_views', label: 'All Views', @@ -119,4 +119,4 @@ export const TaskAllViewsPage: Page = { userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, -}; +}); diff --git a/examples/app-showcase/src/pages/task-workbench.page.ts b/examples/app-showcase/src/pages/task-workbench.page.ts index 177aade487..cb4160559a 100644 --- a/examples/app-showcase/src/pages/task-workbench.page.ts +++ b/examples/app-showcase/src/pages/task-workbench.page.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Page } from '@objectstack/spec/ui'; +import { definePage } from '@objectstack/spec/ui'; /** * Task Workbench — the canonical **interface page** example (ADR-0047). @@ -20,7 +20,7 @@ import type { Page } from '@objectstack/spec/ui'; * Mirrors Airtable's Interfaces right panel: Data (source), User filters * (Elements: dropdowns), Appearance (Visualizations), User actions. */ -export const TaskWorkbenchPage: Page = { +export const TaskWorkbenchPage = definePage({ name: 'showcase_task_workbench', label: 'Task Workbench', type: 'list', @@ -67,4 +67,4 @@ export const TaskWorkbenchPage: Page = { showRecordCount: true, }, -}; +}); diff --git a/examples/app-showcase/src/reports/index.ts b/examples/app-showcase/src/reports/index.ts index c15e5b6a04..ab5df66044 100644 --- a/examples/app-showcase/src/reports/index.ts +++ b/examples/app-showcase/src/reports/index.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Report } from '@objectstack/spec/ui'; +import { defineReport } from '@objectstack/spec/ui'; const task = 'showcase_task'; @@ -10,7 +10,7 @@ const task = 'showcase_task'; // not analytics, so it is no longer a report. /** 2 ── Summary: grouped down by status with a sum. */ -export const HoursByStatusReport: Report = { +export const HoursByStatusReport = defineReport({ name: 'showcase_hours_by_status', label: 'Hours by Status (Summary)', description: 'Estimated hours grouped by task status.', @@ -20,10 +20,10 @@ export const HoursByStatusReport: Report = { dataset: 'showcase_task_metrics', rows: ['status'], values: ['est_hours'], -}; +}); /** 3 ── Matrix: status (down) × priority (across) cross-tab. */ -export const StatusPriorityMatrixReport: Report = { +export const StatusPriorityMatrixReport = defineReport({ name: 'showcase_status_priority_matrix', label: 'Status × Priority (Matrix)', description: 'Task counts cross-tabulated by status and priority.', @@ -34,10 +34,10 @@ export const StatusPriorityMatrixReport: Report = { rows: ['status'], columns: ['priority'], values: ['est_hours'], -}; +}); /** 4 ── Joined: multiple stacked blocks in one report. */ -export const TaskOverviewReport: Report = { +export const TaskOverviewReport = defineReport({ name: 'showcase_task_overview', label: 'Task Overview (Joined)', description: 'Multiple task sub-reports stacked into one joined view.', @@ -66,7 +66,7 @@ export const TaskOverviewReport: Report = { runtimeFilter: { done: true }, }, ], -}; +}); export const allReports = [ HoursByStatusReport, diff --git a/examples/app-todo/src/actions/task.actions.ts b/examples/app-todo/src/actions/task.actions.ts index bf5405cd75..2956f69bcf 100644 --- a/examples/app-todo/src/actions/task.actions.ts +++ b/examples/app-todo/src/actions/task.actions.ts @@ -1,9 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Action } from '@objectstack/spec/ui'; +import { defineAction } from '@objectstack/spec/ui'; /** Mark Task as Complete */ -export const CompleteTaskAction: Action = { +export const CompleteTaskAction = defineAction({ name: 'complete_task', label: 'Mark Complete', objectName: 'todo_task', @@ -17,10 +17,10 @@ export const CompleteTaskAction: Action = { exposed: true, description: 'Mark a todo task as complete. Use when the user says a task is done or finished.', }, -}; +}); /** Mark Task as In Progress */ -export const StartTaskAction: Action = { +export const StartTaskAction = defineAction({ name: 'start_task', label: 'Start Task', objectName: 'todo_task', @@ -34,10 +34,10 @@ export const StartTaskAction: Action = { exposed: true, description: 'Mark a todo task as in progress. Use when the user says they are starting or working on a task.', }, -}; +}); /** Defer Task */ -export const DeferTaskAction: Action = { +export const DeferTaskAction = defineAction({ name: 'defer_task', label: 'Defer Task', objectName: 'todo_task', @@ -61,10 +61,10 @@ export const DeferTaskAction: Action = { ], successMessage: 'Task deferred successfully!', refreshAfter: true, -}; +}); /** Set Reminder */ -export const SetReminderAction: Action = { +export const SetReminderAction = defineAction({ name: 'set_reminder', label: 'Set Reminder', objectName: 'todo_task', @@ -82,10 +82,10 @@ export const SetReminderAction: Action = { ], successMessage: 'Reminder set!', refreshAfter: true, -}; +}); /** Clone Task */ -export const CloneTaskAction: Action = { +export const CloneTaskAction = defineAction({ name: 'clone_task', label: 'Clone Task', objectName: 'todo_task', @@ -99,10 +99,10 @@ export const CloneTaskAction: Action = { exposed: true, description: 'Duplicate an existing todo task, copying its fields into a new task record.', }, -}; +}); /** Mass Complete Tasks */ -export const MassCompleteTasksAction: Action = { +export const MassCompleteTasksAction = defineAction({ name: 'mass_complete', label: 'Complete Selected', objectName: 'todo_task', @@ -116,10 +116,10 @@ export const MassCompleteTasksAction: Action = { exposed: true, description: 'Mark all currently selected todo tasks as complete in one bulk operation.', }, -}; +}); /** Delete Completed Tasks */ -export const DeleteCompletedAction: Action = { +export const DeleteCompletedAction = defineAction({ name: 'delete_completed', label: 'Delete Completed', objectName: 'todo_task', @@ -141,10 +141,10 @@ export const DeleteCompletedAction: Action = { // confirmText + variant:'danger' default this to requiring HITL approval; // it registers only when enableActionApproval is on, then routes to the queue. }, -}; +}); /** Export Tasks to CSV */ -export const ExportToCsvAction: Action = { +export const ExportToCsvAction = defineAction({ name: 'export_csv', label: 'Export to CSV', objectName: 'todo_task', @@ -158,4 +158,4 @@ export const ExportToCsvAction: Action = { exposed: true, description: 'Export the current list of todo tasks to a downloadable CSV file.', }, -}; +}); diff --git a/examples/app-todo/src/reports/task.report.ts b/examples/app-todo/src/reports/task.report.ts index 32809df873..d91bf53c11 100644 --- a/examples/app-todo/src/reports/task.report.ts +++ b/examples/app-todo/src/reports/task.report.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { ReportInput } from '@objectstack/spec/ui'; +import { defineReport } from '@objectstack/spec/ui'; // ADR-0021 Phase 2: each report below carries a `task_metrics` dataset binding // (`dataset` + `rows` + `values`, measures referenced BY NAME) alongside the @@ -10,7 +10,7 @@ import type { ReportInput } from '@objectstack/spec/ui'; // drilldown (per the migration decision); `overdue_tasks` becomes a ListView. /** Tasks by Status Report */ -export const TasksByStatusReport: ReportInput = { +export const TasksByStatusReport = defineReport({ name: 'tasks_by_status', label: 'Tasks by Status', description: 'Summary of tasks grouped by status', @@ -18,10 +18,10 @@ export const TasksByStatusReport: ReportInput = { dataset: 'task_metrics', rows: ['status'], values: ['task_count'], -}; +}); /** Tasks by Priority Report */ -export const TasksByPriorityReport: ReportInput = { +export const TasksByPriorityReport = defineReport({ name: 'tasks_by_priority', label: 'Tasks by Priority', description: 'Summary of tasks grouped by priority level', @@ -30,10 +30,10 @@ export const TasksByPriorityReport: ReportInput = { rows: ['priority'], values: ['task_count'], runtimeFilter: { is_completed: false }, -}; +}); /** Tasks by Owner Report */ -export const TasksByOwnerReport: ReportInput = { +export const TasksByOwnerReport = defineReport({ name: 'tasks_by_owner', label: 'Tasks by Owner', description: 'Task summary by assignee', @@ -42,7 +42,7 @@ export const TasksByOwnerReport: ReportInput = { rows: ['owner'], values: ['est_hours', 'actual_hours'], runtimeFilter: { is_completed: false }, -}; +}); // ADR-0021 Phase 2: the former `OverdueTasksReport` (a flat record list, no // grouping/aggregation) is now the `overdue` ListView on todo_task — see @@ -50,7 +50,7 @@ export const TasksByOwnerReport: ReportInput = { // (ADR-0017), not a dataset report. /** Completed Tasks Report */ -export const CompletedTasksReport: ReportInput = { +export const CompletedTasksReport = defineReport({ name: 'completed_tasks', label: 'Completed Tasks', description: 'All completed tasks with time tracking', @@ -59,10 +59,10 @@ export const CompletedTasksReport: ReportInput = { rows: ['category'], values: ['est_hours', 'actual_hours'], runtimeFilter: { is_completed: true }, -}; +}); /** Time Tracking Report */ -export const TimeTrackingReport: ReportInput = { +export const TimeTrackingReport = defineReport({ name: 'time_tracking', label: 'Time Tracking Report', description: 'Estimated vs actual hours analysis', @@ -74,4 +74,4 @@ export const TimeTrackingReport: ReportInput = { rows: ['owner', 'category'], values: ['est_hours', 'actual_hours'], runtimeFilter: { is_completed: true }, -}; +}); diff --git a/examples/app-todo/src/translations/todo.translation.ts b/examples/app-todo/src/translations/todo.translation.ts index 8bc885ced2..9355325246 100644 --- a/examples/app-todo/src/translations/todo.translation.ts +++ b/examples/app-todo/src/translations/todo.translation.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { TranslationBundle } from '@objectstack/spec/system'; +import { defineTranslationBundle } from '@objectstack/spec/system'; import { en } from './en'; import { zhCN } from './zh-CN'; import { jaJP } from './ja-JP'; @@ -17,8 +17,8 @@ import { jaJP } from './ja-JP'; * * Supported locales: en (English), zh-CN (Chinese), ja-JP (Japanese) */ -export const TodoTranslations: TranslationBundle = { +export const TodoTranslations = defineTranslationBundle({ en, 'zh-CN': zhCN, 'ja-JP': jaJP, -}; +}); diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts index d7681ac398..2b773180b3 100644 --- a/packages/spec/src/automation/webhook.zod.ts +++ b/packages/spec/src/automation/webhook.zod.ts @@ -144,3 +144,12 @@ export type Webhook = z.infer; /** Authoring input for {@link Webhook} — defaulted fields are optional. */ export type WebhookInput = z.input; export type WebhookReceiver = z.infer; + +/** + * Type-safe factory for an outbound webhook. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Webhook` literal. + */ +export function defineWebhook(config: z.input): Webhook { + return WebhookSchema.parse(config); +} diff --git a/packages/spec/src/data/analytics.zod.ts b/packages/spec/src/data/analytics.zod.ts index 1dbf2faf4d..8be3b647ef 100644 --- a/packages/spec/src/data/analytics.zod.ts +++ b/packages/spec/src/data/analytics.zod.ts @@ -174,4 +174,15 @@ export type Metric = z.infer; export type Dimension = z.infer; export type CubeJoin = z.infer; export type Cube = z.infer; +/** Authoring input for {@link Cube} — defaulted fields are optional. */ +export type CubeInput = z.input; + +/** + * Type-safe factory for an analytics semantic-layer cube. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Cube` literal. + */ +export function defineCube(config: z.input): Cube { + return CubeSchema.parse(config); +} export type AnalyticsQuery = z.infer; diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index 899127c886..2ef55ea3f6 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -260,3 +260,12 @@ export type Datasource = z.infer; /** Authoring input for {@link Datasource} — defaulted fields are optional. */ export type DatasourceInput = z.input; export type DatasourceCapabilitiesType = z.infer; + +/** + * Type-safe factory for an external data connection (datasource). Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Datasource` literal. + */ +export function defineDatasource(config: z.input): Datasource { + return DatasourceSchema.parse(config); +} diff --git a/packages/spec/src/data/mapping.zod.ts b/packages/spec/src/data/mapping.zod.ts index aee7521bd6..5610083af8 100644 --- a/packages/spec/src/data/mapping.zod.ts +++ b/packages/spec/src/data/mapping.zod.ts @@ -92,4 +92,15 @@ export const MappingSchema = lazySchema(() => z.object({ })); export type Mapping = z.infer; +/** Authoring input for {@link Mapping} — defaulted fields are optional. */ +export type MappingInput = z.input; + +/** + * Type-safe factory for a data import/export mapping. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Mapping` literal. + */ +export function defineMapping(config: z.input): Mapping { + return MappingSchema.parse(config); +} export type FieldMapping = z.infer; diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 8a9feb5f61..3eddbd8063 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1011,3 +1011,12 @@ export const ObjectExtensionSchema = lazySchema(() => z.object({ export type ObjectExtension = z.infer; /** Authoring input for {@link ObjectExtension} — defaulted fields are optional. */ export type ObjectExtensionInput = z.input; + +/** + * Type-safe factory for an extension to an object owned by another package. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: ObjectExtension` literal. + */ +export function defineObjectExtension(config: z.input): ObjectExtension { + return ObjectExtensionSchema.parse(config); +} diff --git a/packages/spec/src/identity/role.zod.ts b/packages/spec/src/identity/role.zod.ts index 2d481eb609..55605efde0 100644 --- a/packages/spec/src/identity/role.zod.ts +++ b/packages/spec/src/identity/role.zod.ts @@ -45,3 +45,12 @@ export const RoleSchema = lazySchema(() => z.object({ export type Role = z.infer; /** Authoring input for {@link Role} — defaulted fields are optional. */ export type RoleInput = z.input; + +/** + * Type-safe factory for a role in the role hierarchy. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Role` literal. + */ +export function defineRole(config: z.input): Role { + return RoleSchema.parse(config); +} diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index a6969ebfca..17c153b41f 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -80,6 +80,27 @@ export { defineBook } from './system/book.zod'; export { defineAgent } from './ai/agent.zod'; export { defineTool } from './ai/tool.zod'; export { defineSkill } from './ai/skill.zod'; + +// DX factories for the remaining authoring domains (issue #2035) — one type-safe +// entry per writable domain, mirroring the 19 factories above. `defineX` is a +// *value* import: a broken import hard-errors instead of silently degrading to +// `any` (the #2023 failure mode). Input-shape config + runtime `.parse()`. +export { defineDatasource } from './data/datasource.zod'; +export { defineConnector } from './integration/connector.zod'; +export { definePolicy } from './security/policy.zod'; +export { defineSharingRule } from './security/sharing.zod'; +export { defineRole } from './identity/role.zod'; +export { definePermissionSet } from './security/permission.zod'; +export { defineEmailTemplateDefinition } from './system/email-template.zod'; +export { defineReport } from './ui/report.zod'; +export { defineWebhook } from './automation/webhook.zod'; +export { defineObjectExtension } from './data/object.zod'; +export { defineCube } from './data/analytics.zod'; +export { defineMapping } from './data/mapping.zod'; +export { defineTheme } from './ui/theme.zod'; +export { defineTranslationBundle } from './system/translation.zod'; +export { definePage } from './ui/page.zod'; +export { defineAction } from './ui/action.zod'; export type { Agent } from './ai/agent.zod'; export type { Tool } from './ai/tool.zod'; export type { Skill } from './ai/skill.zod'; diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 8a33979d1f..630710b420 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -630,3 +630,12 @@ export const ConnectorSchema = lazySchema(() => z.object({ export type Connector = z.infer; /** Authoring input for {@link Connector} — defaulted fields are optional. */ export type ConnectorInput = z.input; + +/** + * Type-safe factory for an external-system connector. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Connector` literal. + */ +export function defineConnector(config: z.input): Connector { + return ConnectorSchema.parse(config); +} diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index fbd6137fbd..97fa5f2ed4 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -196,3 +196,12 @@ export type PermissionSet = z.infer; export type PermissionSetInput = z.input; export type ObjectPermission = z.infer; export type FieldPermission = z.infer; + +/** + * Type-safe factory for a permission set / profile. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: PermissionSet` literal. + */ +export function definePermissionSet(config: z.input): PermissionSet { + return PermissionSetSchema.parse(config); +} diff --git a/packages/spec/src/security/policy.zod.ts b/packages/spec/src/security/policy.zod.ts index 1ac0e1033a..565962d067 100644 --- a/packages/spec/src/security/policy.zod.ts +++ b/packages/spec/src/security/policy.zod.ts @@ -77,3 +77,14 @@ export const PolicySchema = lazySchema(() => z.object({ })); export type Policy = z.infer; +/** Authoring input for {@link Policy} — defaulted fields are optional. */ +export type PolicyInput = z.input; + +/** + * Type-safe factory for a security / compliance policy. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Policy` literal. + */ +export function definePolicy(config: z.input): Policy { + return PolicySchema.parse(config); +} diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts index 291158f026..f85b642072 100644 --- a/packages/spec/src/security/sharing.zod.ts +++ b/packages/spec/src/security/sharing.zod.ts @@ -112,3 +112,12 @@ export type SharingRule = z.infer; export type SharingRuleInput = z.input; export type CriteriaSharingRule = z.infer; export type OwnerSharingRule = z.infer; + +/** + * Type-safe factory for a record sharing rule. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: SharingRule` literal. + */ +export function defineSharingRule(config: z.input): SharingRule { + return SharingRuleSchema.parse(config); +} diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index 65dd0fe183..e0fcffb430 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -135,3 +135,12 @@ function EmailAddressInlineSchema() { export type EmailTemplateDefinition = z.infer; /** Authoring input for {@link EmailTemplateDefinition} — defaulted fields are optional. */ export type EmailTemplateDefinitionInput = z.input; + +/** + * Type-safe factory for an email template. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: EmailTemplateDefinition` literal. + */ +export function defineEmailTemplateDefinition(config: z.input): EmailTemplateDefinition { + return EmailTemplateDefinitionSchema.parse(config); +} diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 24a8d01853..a7d2ec11e8 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -295,6 +295,17 @@ export type TranslationData = z.infer; export const TranslationBundleSchema = lazySchema(() => z.record(LocaleSchema, TranslationDataSchema).describe('Map of locale codes to translation data')); export type TranslationBundle = z.infer; +/** Authoring input for {@link TranslationBundle} — defaulted fields are optional. */ +export type TranslationBundleInput = z.input; + +/** + * Type-safe factory for an i18n translation bundle (locale code → translations map). Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: TranslationBundle` literal. + */ +export function defineTranslationBundle(config: z.input): TranslationBundle { + return TranslationBundleSchema.parse(config); +} // ──────────────────────────────────────────────────────────────────────────── // File Organization Convention diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index d26cd40301..d1eb4a4c4e 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -502,3 +502,12 @@ export type ActionInput = z.input; export const Action = { create: (config: z.input): Action => ActionSchema.parse(config), } as const; + +/** + * Type-safe factory for a global or object action. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Action` literal. + */ +export function defineAction(config: z.input): Action { + return ActionSchema.parse(config); +} diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 42c7ecbb3c..a3539897ba 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -399,6 +399,17 @@ export const PageSchema = lazySchema(() => z.object({ })); export type Page = z.infer; +/** Authoring input for {@link Page} — defaulted fields are optional. */ +export type PageInput = z.input; + +/** + * Type-safe factory for a custom page. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Page` literal. + */ +export function definePage(config: z.input): Page { + return PageSchema.parse(config); +} export type PageType = z.infer; export type PageComponent = z.infer; export type PageRegion = z.infer; diff --git a/packages/spec/src/ui/report.zod.ts b/packages/spec/src/ui/report.zod.ts index 38cc05afae..bda5d20fe0 100644 --- a/packages/spec/src/ui/report.zod.ts +++ b/packages/spec/src/ui/report.zod.ts @@ -220,3 +220,12 @@ export type ReportChartInput = z.input; export const Report = { create: (config: ReportInput): Report => ReportSchema.parse(config), } as const; + +/** + * Type-safe factory for an analytics report. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Report` literal. + */ +export function defineReport(config: z.input): Report { + return ReportSchema.parse(config); +} diff --git a/packages/spec/src/ui/theme.zod.ts b/packages/spec/src/ui/theme.zod.ts index 14628bf090..7d74b987c5 100644 --- a/packages/spec/src/ui/theme.zod.ts +++ b/packages/spec/src/ui/theme.zod.ts @@ -270,6 +270,17 @@ export const ThemeSchema = lazySchema(() => z.object({ })); export type Theme = z.infer; +/** Authoring input for {@link Theme} — defaulted fields are optional. */ +export type ThemeInput = z.input; + +/** + * Type-safe factory for a UI theme. Validates at authoring time via + * `.parse()` and accepts input-shape config (optional defaults, CEL + * shorthand) — preferred over a bare `: Theme` literal. + */ +export function defineTheme(config: z.input): Theme { + return ThemeSchema.parse(config); +} export type ColorPalette = z.infer; export type Typography = z.infer; export type Spacing = z.infer;