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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/spec-define-x-factories.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 41 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}'],
Expand Down Expand Up @@ -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,
},
],
},
},
];
6 changes: 3 additions & 3 deletions examples/app-crm/src/actions/convert-lead.action.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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"',
};
});
6 changes: 3 additions & 3 deletions examples/app-crm/src/actions/park-lead.action.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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',
Expand All @@ -32,4 +32,4 @@ export const ParkLeadAction: UI.ActionInput = {
],
undoable: true,
successMessage: 'Lead reassigned.',
};
});
10 changes: 5 additions & 5 deletions examples/app-crm/src/analytics/crm.cube.ts
Original file line number Diff line number Diff line change
@@ -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.',
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -119,4 +119,4 @@ export const LeadFunnelCube: Cube = {
every: '30 minutes',
},
public: false,
};
});
10 changes: 5 additions & 5 deletions examples/app-crm/src/connectors/crm-connectors.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -123,4 +123,4 @@ export const SlackConnector: ConnectorInput = {
},
status: 'inactive',
enabled: true,
};
});
10 changes: 5 additions & 5 deletions examples/app-crm/src/data/crm-mappings.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -72,4 +72,4 @@ export const ContactJsonSyncMapping: Mapping = {
],
errorPolicy: 'skip',
batchSize: 250,
};
});
10 changes: 5 additions & 5 deletions examples/app-crm/src/datasources/crm.datasource.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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',
Expand All @@ -32,4 +32,4 @@ export const CrmAnalyticsDatasource: DatasourceInput = {
readOnly: true,
},
active: true,
};
});
6 changes: 3 additions & 3 deletions examples/app-crm/src/emails/deal-won.email.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -33,4 +33,4 @@ Nice work!`,
],
active: true,
description: 'Internal congrats email fired by the High-Value Deal workflow when stage = Closed Won.',
};
});
6 changes: 3 additions & 3 deletions examples/app-crm/src/emails/lead-follow-up.email.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -35,4 +35,4 @@ Follow up: {{lead_url}}`,
],
active: true,
description: 'Internal reminder fired by the Stale Opportunity workflow.',
};
});
6 changes: 3 additions & 3 deletions examples/app-crm/src/emails/welcome.email.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -36,4 +36,4 @@ Open your portal: {{portal_url}}`,
replyTo: 'support@acme.example',
active: true,
description: 'Marketing welcome email sent on contact creation.',
};
});
6 changes: 3 additions & 3 deletions examples/app-crm/src/extensions/contact.extension.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -35,4 +35,4 @@ export const ContactExtension: ObjectExtensionInput = {
},
},
priority: 210,
};
});
6 changes: 3 additions & 3 deletions examples/app-crm/src/pages/welcome.page.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -40,4 +40,4 @@ export const CrmWelcomePage: UI.Page = {
],
},
],
};
});
6 changes: 3 additions & 3 deletions examples/app-crm/src/reports/sales-by-stage.report.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,12 +11,12 @@ 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.',
type: 'summary',
dataset: 'opportunity_metrics',
rows: ['stage'],
values: ['total_amount'],
};
});
Loading
Loading