diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts
index 16a62f05cd..e4915e3000 100644
--- a/examples/app-crm/objectstack.config.ts
+++ b/examples/app-crm/objectstack.config.ts
@@ -7,10 +7,8 @@ import * as views from './src/views/index.js';
import * as apps from './src/apps/index.js';
import * as dashboards from './src/dashboards/index.js';
import * as datasets from './src/datasets/index.js';
-import * as reports from './src/reports/index.js';
import * as pages from './src/pages/index.js';
import * as actions from './src/actions/index.js';
-import * as emails from './src/emails/index.js';
import { allHooks } from './src/hooks/index.js';
import { allFlows } from './src/flows/index.js';
import {
@@ -24,29 +22,19 @@ import {
WonDealActivitySharingRule,
} from './src/security/index.js';
import { CrmSeedData } from './src/data/index.js';
-import { LeadCsvImportMapping, ContactJsonSyncMapping } from './src/data/crm-mappings.js';
import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js';
import { CrmTranslationBundle } from './src/translations/crm.translation.js';
-import { ContactExtension } from './src/extensions/contact.extension.js';
-import { CustomerPortal } from './src/portals/customer.portal.js';
-import { CrmLightTheme, CrmDarkTheme } from './src/themes/crm.theme.js';
-import { LeadScoringJob, PipelineReportJob, RenewalSweepJob } from './src/jobs/crm-jobs.js';
-import {
- PipelineSummaryEndpoint,
- LeadConvertEndpoint,
- MarketingWebhookEndpoint,
-} from './src/api/crm-endpoints.js';
-import { OpportunityChangedWebhook, DealWonSlackWebhook } from './src/webhooks/crm-webhooks.js';
-import { PipelineCube, LeadFunnelCube } from './src/analytics/crm.cube.js';
-import { HubSpotConnector, SlackConnector } from './src/connectors/crm-connectors.js';
/**
- * CRM example — exercises the full metadata loading pipeline with at
- * least one record of every form-bearing metadata type so the Studio
- * metadata-admin UI can be developed and validated against real data.
+ * CRM example — a MINIMAL, realistic relational bundle that smoke-tests the
+ * metadata application loading pipeline: objects/relationships → views →
+ * app → dashboard (dataset-backed) → hook → one screen-flow wizard → seed.
+ * Deliberately small so `pnpm dev:crm` boots fast for backend debugging.
*
- * For a full enterprise reference (10+ objects, RAG, sharing rules,
- * etc.) see https://github.com/objectstack-ai/hotcrm
+ * NOT a feature showcase: capability breadth (cubes, extensions, apis,
+ * webhooks, portals, themes, reports, jobs, emails, automation variety)
+ * lives in examples/app-showcase, whose coverage manifest enforces it.
+ * For a full enterprise reference see https://github.com/objectstack-ai/hotcrm
*/
export default defineStack({
manifest: {
@@ -58,12 +46,10 @@ export default defineStack({
description: 'Minimal CRM workspace used by the framework to validate the metadata loading pipeline end-to-end.',
},
- // Auto-resolved by the CLI; `ui` enables the Studio shell, `automation` loads
- // AutomationServicePlugin + node packs so screen flows can execute, and
- // `approvals` loads ApprovalsServicePlugin so the `approval` flow node is
- // contributed to the engine (ADR-0019) — required for the discount-approval
- // flow to compile/register and to surface the node in the designer palette.
- requires: ['ui', 'automation', 'approvals'],
+ // Auto-resolved by the CLI; `ui` enables the Studio shell, `automation`
+ // loads AutomationServicePlugin + node packs so the convert-lead screen
+ // flow can execute.
+ requires: ['ui', 'automation'],
// Infrastructure
datasources: [CrmDatasource, CrmAnalyticsDatasource],
@@ -85,28 +71,22 @@ export default defineStack({
// Data
objects: Object.values(objects),
- objectExtensions: [ContactExtension],
// UI
apps: Object.values(apps),
- portals: [CustomerPortal],
views: Object.values(views),
pages: Object.values(pages),
dashboards: Object.values(dashboards),
datasets: Object.values(datasets),
- reports: Object.values(reports),
actions: Object.values(actions),
- themes: [CrmLightTheme, CrmDarkTheme],
// Logic
hooks: allHooks,
- // ADR-0020: `workflows` retired — record state machines are now a
+ // ADR-0020: `workflows` retired — record state machines are a
// `state_machine` validation rule on the object (see
- // src/objects/opportunity.object.ts) and side-effecting automation is
- // modelled as Flows (high-value-deal, stale-opportunity in allFlows).
+ // src/objects/opportunity.object.ts). One flow only: the convert-lead
+ // screen wizard the smoke test drives.
flows: allFlows,
- jobs: [LeadScoringJob, PipelineReportJob, RenewalSweepJob],
- emailTemplates: Object.values(emails),
// Security
roles: [SalesRepRole, SalesManagerRole, FinanceApproverRole],
@@ -117,17 +97,6 @@ export default defineStack({
WonDealActivitySharingRule,
],
- // API
- apis: [PipelineSummaryEndpoint, LeadConvertEndpoint, MarketingWebhookEndpoint],
- webhooks: [OpportunityChangedWebhook, DealWonSlackWebhook],
-
- // Data Extensions
- mappings: [LeadCsvImportMapping, ContactJsonSyncMapping],
- analyticsCubes: [PipelineCube, LeadFunnelCube],
-
- // Integrations
- connectors: [HubSpotConnector, SlackConnector],
-
// Seed data
data: CrmSeedData,
});
diff --git a/examples/app-crm/src/actions/index.ts b/examples/app-crm/src/actions/index.ts
index 9490e28e5f..0957b456b8 100644
--- a/examples/app-crm/src/actions/index.ts
+++ b/examples/app-crm/src/actions/index.ts
@@ -1,4 +1,3 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
export { ConvertLeadAction } from './convert-lead.action.js';
-export { ParkLeadAction } from './park-lead.action.js';
diff --git a/examples/app-crm/src/actions/park-lead.action.ts b/examples/app-crm/src/actions/park-lead.action.ts
deleted file mode 100644
index 0f4a30ca04..0000000000
--- a/examples/app-crm/src/actions/park-lead.action.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { defineAction } from '@objectstack/spec/ui';
-
-/**
- * Row-level action on crm_lead — reassign the lead's owner.
- *
- * Demonstrates the `undoable` action affordance: it's a single-record field
- * update (`assigned_to`), so the runtime captures the prior owner and the
- * success toast offers an "Undo" that restores it (backed by the client
- * UndoManager — Ctrl+Z works too). Prompts for the new owner via one param
- * (pre-filled with "Triage Queue").
- */
-export const ParkLeadAction = defineAction({
- name: 'crm_park_lead',
- label: 'Reassign Lead',
- icon: 'UserPlus',
- objectName: 'crm_lead',
- // `type: 'api'` with a non-URL target routes to the console runtime's generic
- // `dataSource.update` path (the row id comes from the list_item row record).
- type: 'api',
- target: 'crm_lead',
- // From the list row AND the record header — both runtimes drive param
- // collection, the dataSource.update, and the Undo toast.
- locations: ['list_item', 'record_header'],
- // Conditional disable (CEL): a converted lead is locked — the action shows but
- // greys out, rather than disappearing. Demonstrates `disabled` (greys) vs
- // `visible` (hides). Evaluated against the record on every surface.
- disabled: 'record.status == "converted"',
- params: [
- { field: 'assigned_to', label: 'Reassign to', defaultValue: 'Triage Queue', required: true },
- ],
- 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
deleted file mode 100644
index 766f9cc5ab..0000000000
--- a/examples/app-crm/src/analytics/crm.cube.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-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 = defineCube({
- name: 'crm_pipeline',
- title: 'CRM Pipeline',
- description: 'Revenue and deal-count analytics across the sales pipeline.',
- sql: 'crm_opportunity',
- measures: {
- count: {
- name: 'count',
- label: 'Deal Count',
- type: 'count',
- sql: '*',
- },
- total_amount: {
- name: 'total_amount',
- label: 'Total Pipeline Value',
- type: 'sum',
- sql: 'amount',
- format: 'currency',
- },
- avg_amount: {
- name: 'avg_amount',
- label: 'Average Deal Size',
- type: 'avg',
- sql: 'amount',
- format: 'currency',
- },
- win_rate: {
- name: 'win_rate',
- label: 'Win Rate (%)',
- type: 'number',
- sql: "SUM(CASE WHEN stage = 'closed_won' THEN 1 ELSE 0 END) * 100.0 / COUNT(*)",
- format: 'percent',
- },
- },
- dimensions: {
- stage: {
- name: 'stage',
- label: 'Pipeline Stage',
- type: 'string',
- sql: 'stage',
- },
- close_date: {
- name: 'close_date',
- label: 'Close Date',
- type: 'time',
- sql: 'close_date',
- },
- owner: {
- name: 'owner',
- label: 'Owner',
- type: 'string',
- sql: 'owner_id',
- },
- },
- joins: {
- crm_account: {
- name: 'crm_account',
- relationship: 'many_to_one',
- sql: '${crm_pipeline}.account_id = ${crm_account}.id',
- },
- },
- refreshKey: {
- every: '1 hour',
- },
- public: false,
-});
-
-/**
- * Lead funnel cube — conversion metrics from lead to opportunity.
- */
-export const LeadFunnelCube = defineCube({
- name: 'crm_lead_funnel',
- title: 'CRM Lead Funnel',
- description: 'Lead volume and conversion rate analytics.',
- sql: 'crm_lead',
- measures: {
- count: {
- name: 'count',
- label: 'Lead Count',
- type: 'count',
- sql: '*',
- },
- converted_count: {
- name: 'converted_count',
- label: 'Converted Leads',
- type: 'count',
- sql: 'converted_opportunity_id',
- },
- },
- dimensions: {
- status: {
- name: 'status',
- label: 'Lead Status',
- type: 'string',
- sql: 'status',
- },
- source: {
- name: 'source',
- label: 'Lead Source',
- type: 'string',
- sql: 'source',
- },
- created_at: {
- name: 'created_at',
- label: 'Created At',
- type: 'time',
- sql: 'created_at',
- },
- },
- refreshKey: {
- every: '30 minutes',
- },
- public: false,
-});
diff --git a/examples/app-crm/src/api/crm-endpoints.ts b/examples/app-crm/src/api/crm-endpoints.ts
deleted file mode 100644
index fbd1797054..0000000000
--- a/examples/app-crm/src/api/crm-endpoints.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import type { ApiEndpoint } from '@objectstack/spec/api';
-
-/**
- * Custom REST endpoint — exposes pipeline summary metrics.
- * Backed by a flow (lead_qualification_conversion) that aggregates stage data.
- */
-export const PipelineSummaryEndpoint: ApiEndpoint = {
- name: 'crm_pipeline_summary',
- path: '/api/v1/crm/pipeline-summary',
- method: 'GET',
- summary: 'Pipeline summary by stage',
- description: 'Returns opportunity count and total value grouped by stage.',
- type: 'object_operation',
- target: 'crm_opportunity',
- objectParams: {
- object: 'crm_opportunity',
- operation: 'find',
- },
- authRequired: true,
- cacheTtl: 60,
-};
-
-/**
- * Lead conversion endpoint — triggers the qualification flow via HTTP.
- */
-export const LeadConvertEndpoint: ApiEndpoint = {
- name: 'crm_lead_convert',
- path: '/api/v1/crm/leads/:id/convert',
- method: 'POST',
- summary: 'Convert a lead to an opportunity',
- type: 'flow',
- target: 'lead_qualification_conversion',
- inputMapping: [
- { source: 'params.id', target: 'leadId' },
- { source: 'body.ownerId', target: 'ownerId' },
- ],
- authRequired: true,
-};
-
-/**
- * Public webhook receiver — inbound events from external marketing tools.
- * No auth required (HMAC verified at the webhook layer instead).
- */
-export const MarketingWebhookEndpoint: ApiEndpoint = {
- name: 'crm_marketing_webhook',
- path: '/api/v1/crm/marketing/events',
- method: 'POST',
- summary: 'Receive marketing automation events',
- type: 'flow',
- target: 'lead_qualification_conversion',
- authRequired: false,
-};
diff --git a/examples/app-crm/src/connectors/crm-connectors.ts b/examples/app-crm/src/connectors/crm-connectors.ts
deleted file mode 100644
index 3cf9b6c42f..0000000000
--- a/examples/app-crm/src/connectors/crm-connectors.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-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 = defineConnector({
- name: 'hubspot_crm',
- label: 'HubSpot CRM',
- type: 'saas',
- description: 'Bi-directional sync of contacts, companies, and deals with HubSpot.',
- icon: 'hubspot',
- authentication: {
- type: 'oauth2',
- authorizationUrl: 'https://app.hubspot.com/oauth/authorize',
- tokenUrl: 'https://api.hubapi.com/oauth/v1/token',
- clientId: 'env:HUBSPOT_CLIENT_ID',
- clientSecret: 'env:HUBSPOT_CLIENT_SECRET',
- scopes: ['contacts', 'crm.objects.deals.read', 'crm.objects.deals.write'],
- },
- actions: [
- {
- key: 'create_contact',
- label: 'Create Contact',
- description: 'Create a new contact in HubSpot',
- inputSchema: {
- type: 'object',
- properties: {
- email: { type: 'string' },
- firstname: { type: 'string' },
- lastname: { type: 'string' },
- },
- required: ['email'],
- },
- },
- {
- key: 'update_deal',
- label: 'Update Deal',
- description: 'Update an existing deal in HubSpot',
- inputSchema: {
- type: 'object',
- properties: {
- dealId: { type: 'string' },
- dealstage: { type: 'string' },
- amount: { type: 'number' },
- },
- required: ['dealId'],
- },
- },
- ],
- triggers: [
- {
- key: 'contact_created',
- label: 'Contact Created',
- type: 'webhook',
- description: 'Fires when a contact is created in HubSpot',
- },
- {
- key: 'deal_stage_changed',
- label: 'Deal Stage Changed',
- type: 'polling',
- interval: 300,
- description: 'Polls every 5 minutes for deal stage changes',
- },
- ],
- syncConfig: {
- direction: 'bidirectional',
- schedule: '0 * * * *',
- conflictResolution: 'source_wins',
- batchSize: 500,
- },
- rateLimitConfig: {
- maxRequests: 100,
- windowSeconds: 10,
- strategy: 'sliding_window',
- },
- retryConfig: {
- strategy: 'exponential_backoff',
- maxAttempts: 3,
- initialDelayMs: 1000,
- maxDelayMs: 30000,
- },
- connectionTimeoutMs: 10000,
- requestTimeoutMs: 30000,
- status: 'inactive',
- enabled: true,
-});
-
-/**
- * Slack connector — post notifications to channels.
- */
-export const SlackConnector = defineConnector({
- name: 'slack_notifications',
- label: 'Slack',
- type: 'api',
- description: 'Post deal-win and alert notifications to Slack channels.',
- icon: 'slack',
- authentication: {
- type: 'bearer',
- token: 'env:SLACK_BOT_TOKEN',
- },
- actions: [
- {
- key: 'post_message',
- label: 'Post Message',
- description: 'Post a message to a Slack channel',
- inputSchema: {
- type: 'object',
- properties: {
- channel: { type: 'string' },
- text: { type: 'string' },
- },
- required: ['channel', 'text'],
- },
- },
- ],
- rateLimitConfig: {
- maxRequests: 50,
- windowSeconds: 60,
- strategy: 'token_bucket',
- },
- status: 'inactive',
- enabled: true,
-});
diff --git a/examples/app-crm/src/data/crm-mappings.ts b/examples/app-crm/src/data/crm-mappings.ts
deleted file mode 100644
index 66a17731fd..0000000000
--- a/examples/app-crm/src/data/crm-mappings.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-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 = defineMapping({
- name: 'csv_import_leads',
- label: 'CSV Import: Leads',
- sourceFormat: 'csv',
- targetObject: 'crm_lead',
- mode: 'upsert',
- upsertKey: ['email'],
- fieldMapping: [
- { source: 'First Name', target: 'first_name', transform: 'none' },
- { source: 'Last Name', target: 'last_name', transform: 'none' },
- { source: 'Email', target: 'email', transform: 'none' },
- { source: 'Phone', target: 'phone', transform: 'none' },
- { source: 'Company', target: 'company', transform: 'none' },
- {
- source: 'Lead Status',
- target: 'status',
- transform: 'map',
- params: {
- valueMap: {
- New: 'new',
- 'Working': 'contacted',
- Qualified: 'qualified',
- 'Unqualified': 'disqualified',
- Converted: 'converted',
- },
- },
- },
- {
- source: 'Lead Source',
- target: 'source',
- transform: 'map',
- params: {
- valueMap: {
- 'Web': 'web',
- 'Email Campaign': 'email_campaign',
- 'Cold Call': 'cold_call',
- 'Referral': 'referral',
- 'Trade Show': 'trade_show',
- },
- },
- },
- { source: 'Lead Score', target: 'lead_score', transform: 'none' },
- ],
- errorPolicy: 'skip',
- batchSize: 500,
-});
-
-/**
- * JSON import mapping for contact sync from external systems (HubSpot, etc.).
- */
-export const ContactJsonSyncMapping = defineMapping({
- name: 'json_sync_contacts',
- label: 'JSON Sync: Contacts from HubSpot',
- sourceFormat: 'json',
- targetObject: 'crm_contact',
- mode: 'upsert',
- upsertKey: ['email'],
- fieldMapping: [
- { source: 'properties.firstname', target: 'first_name', transform: 'none' },
- { source: 'properties.lastname', target: 'last_name', transform: 'none' },
- { source: 'properties.email', target: 'email', transform: 'none' },
- { source: 'properties.phone', target: 'phone', transform: 'none' },
- { source: 'properties.jobtitle', target: 'title', transform: 'none' },
- ],
- errorPolicy: 'skip',
- batchSize: 250,
-});
diff --git a/examples/app-crm/src/emails/deal-won.email.ts b/examples/app-crm/src/emails/deal-won.email.ts
deleted file mode 100644
index 39bac0b18a..0000000000
--- a/examples/app-crm/src/emails/deal-won.email.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-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 = defineEmailTemplateDefinition({
- name: 'crm.deal_won',
- label: 'Deal Won — Owner Congrats',
- category: 'workflow',
- locale: 'en-US',
- subject: 'Congratulations — {{opportunity.name}} closed!',
- bodyHtml: `
-
-
Great news!
-
Hi {{user.name}},
-
The opportunity {{opportunity.name}} for {{account.name}} just closed at \${{opportunity.amount}}.
-
Nice work! 🎉
-
-`,
- bodyText: `Hi {{user.name}},
-
-The opportunity {{opportunity.name}} for {{account.name}} just closed at \${{opportunity.amount}}.
-
-Nice work!`,
- variables: [
- { name: 'user.name', type: 'string', required: true, description: 'Opportunity owner' },
- { name: 'opportunity.name', type: 'string', required: true },
- { name: 'opportunity.amount', type: 'number', required: true, description: 'Closed amount in USD' },
- { name: 'account.name', type: 'string', required: true },
- ],
- 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/index.ts b/examples/app-crm/src/emails/index.ts
deleted file mode 100644
index 2293a35685..0000000000
--- a/examples/app-crm/src/emails/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-export { DealWonEmail } from './deal-won.email.js';
-export { WelcomeEmail } from './welcome.email.js';
-export { LeadFollowUpEmail } from './lead-follow-up.email.js';
diff --git a/examples/app-crm/src/emails/lead-follow-up.email.ts b/examples/app-crm/src/emails/lead-follow-up.email.ts
deleted file mode 100644
index 43d9fd94d2..0000000000
--- a/examples/app-crm/src/emails/lead-follow-up.email.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-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 = defineEmailTemplateDefinition({
- name: 'crm.lead_followup',
- label: 'Lead — Follow-Up Reminder',
- category: 'notification',
- locale: 'en-US',
- subject: 'Reminder: follow up on {{lead.name}}',
- bodyHtml: `
-
-
Time to follow up
-
Hi {{owner.name}},
-
Lead {{lead.name}} ({{lead.company}}) has had no activity for {{days_idle}} days.
-
Open the lead in CRM and log the next action to keep your pipeline healthy.
-
View lead
-
-`,
- bodyText: `Hi {{owner.name}},
-
-Lead {{lead.name}} ({{lead.company}}) has had no activity for {{days_idle}} days.
-
-Follow up: {{lead_url}}`,
- variables: [
- { name: 'owner.name', type: 'string', required: true },
- { name: 'lead.name', type: 'string', required: true },
- { name: 'lead.company', type: 'string', required: false },
- { name: 'days_idle', type: 'number', required: true },
- { name: 'lead_url', type: 'url', required: true },
- ],
- 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
deleted file mode 100644
index 77282fce5a..0000000000
--- a/examples/app-crm/src/emails/welcome.email.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-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 = defineEmailTemplateDefinition({
- name: 'crm.welcome',
- label: 'Welcome — New Contact',
- category: 'marketing',
- locale: 'en-US',
- subject: 'Welcome to {{account.name}}, {{contact.first_name}}!',
- bodyHtml: `
-
-
Welcome aboard 👋
-
Hi {{contact.first_name}},
-
Thanks for connecting with {{account.name}}. Your account manager {{owner.name}} will be in touch shortly.
-
Open your customer portal
-
Or copy this link: {{portal_url}}
-
-`,
- bodyText: `Hi {{contact.first_name}},
-
-Thanks for connecting with {{account.name}}. Your account manager {{owner.name}} will be in touch shortly.
-
-Open your portal: {{portal_url}}`,
- variables: [
- { name: 'contact.first_name', type: 'string', required: true },
- { name: 'account.name', type: 'string', required: true },
- { name: 'owner.name', type: 'string', required: true, description: 'Assigned account manager' },
- { name: 'portal_url', type: 'url', required: true, description: 'Customer portal link' },
- ],
- fromOverride: { name: 'Acme Sales', address: 'sales@acme.example' },
- 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
deleted file mode 100644
index 1aa55ce5ed..0000000000
--- a/examples/app-crm/src/extensions/contact.extension.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-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 = defineObjectExtension({
- extend: 'crm_contact',
- label: 'Contact (CRM Extended)',
- fields: {
- linkedin_url: {
- name: 'linkedin_url',
- label: 'LinkedIn URL',
- type: 'url',
- },
- twitter_handle: {
- name: 'twitter_handle',
- label: 'Twitter / X Handle',
- type: 'text',
- maxLength: 64,
- },
- preferred_channel: {
- name: 'preferred_channel',
- label: 'Preferred Contact Channel',
- type: 'select',
- options: [
- { value: 'email', label: 'Email' },
- { value: 'phone', label: 'Phone' },
- { value: 'linkedin', label: 'LinkedIn' },
- { value: 'whatsapp', label: 'WhatsApp' },
- ],
- },
- },
- priority: 210,
-});
diff --git a/examples/app-crm/src/flows/discount-approval.flow.ts b/examples/app-crm/src/flows/discount-approval.flow.ts
deleted file mode 100644
index 41fd145012..0000000000
--- a/examples/app-crm/src/flows/discount-approval.flow.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import type { Flow } from '@objectstack/spec/automation';
-
-/**
- * Discount approval — ADR-0019 approval-as-flow-node.
- *
- * What used to be a standalone two-step approval *process* is now an ordinary
- * autolaunched flow with two `approval` nodes. The flow suspends on each
- * approval and resumes down the matching `approve` / `reject` edge:
- *
- * start → manager_review ──approve──▶ finance_review ──approve──▶ end
- * └─reject──▶ rejected └─reject──▶ rejected
- *
- * Finance only signs off when the discount exceeds 30% — that gate is just a
- * decision node on the approve edge out of the manager step.
- */
-export const DiscountApprovalFlow: Flow = {
- name: 'crm_discount_approval',
- label: 'Opportunity Discount Approval',
- description: 'Two-step approval for opportunities with significant discounts.',
- type: 'autolaunched',
-
- nodes: [
- {
- id: 'start',
- type: 'start',
- label: 'On Discount Above Threshold',
- config: {
- objectName: 'crm_opportunity',
- triggerType: 'record-after-update',
- condition: 'discount_percent > 20',
- },
- },
- {
- id: 'manager_review',
- type: 'approval',
- label: 'Manager Review',
- config: {
- approvers: [{ type: 'role', value: 'sales_manager' }],
- behavior: 'first_response',
- lockRecord: true,
- approvalStatusField: 'approval_status',
- },
- },
- {
- id: 'needs_finance',
- type: 'decision',
- label: 'Discount Above 30%?',
- config: { condition: 'discount_percent > 30' },
- },
- {
- id: 'finance_review',
- type: 'approval',
- label: 'Finance Review',
- config: {
- approvers: [{ type: 'role', value: 'finance_approver' }],
- behavior: 'unanimous',
- lockRecord: true,
- approvalStatusField: 'approval_status',
- },
- },
- { id: 'approved', type: 'end', label: 'Approved' },
- { id: 'rejected', type: 'end', label: 'Rejected' },
- ],
- edges: [
- { id: 'e1', source: 'start', target: 'manager_review' },
- { id: 'e2', source: 'manager_review', target: 'needs_finance', label: 'approve' },
- { id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' },
- { id: 'e4', source: 'needs_finance', target: 'finance_review', label: 'true' },
- { id: 'e5', source: 'needs_finance', target: 'approved', label: 'false' },
- { id: 'e6', source: 'finance_review', target: 'approved', label: 'approve' },
- { id: 'e7', source: 'finance_review', target: 'rejected', label: 'reject' },
- ],
-};
diff --git a/examples/app-crm/src/flows/high-value-deal.flow.ts b/examples/app-crm/src/flows/high-value-deal.flow.ts
deleted file mode 100644
index e355a09c45..0000000000
--- a/examples/app-crm/src/flows/high-value-deal.flow.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import type { Flow } from '@objectstack/spec/automation';
-
-/**
- * High-Value Deal alert — record-triggered flow.
- *
- * Migrated from the legacy `workflow` metadata type (retired in ADR-0020,
- * which reclaims the runtime FSM as the `state_machine` validation rule on
- * the object and folds side-effecting automation into Flow). The original
- * rule fired when a `crm_opportunity` crossed the $100k threshold and
- * notified sales managers; that side effect is exactly a record-triggered
- * Flow.
- *
- * The lifecycle/transition aspect (open → high_value → won/lost) is no
- * longer modelled here — record state transitions belong on the object as a
- * `state_machine` validation rule (see opportunity.object.ts). This Flow
- * carries only the notification side effect.
- */
-export const HighValueDealFlow: Flow = {
- name: 'high_value_deal_alert',
- label: 'Notify on High-Value Deal',
- description: 'Notifies sales managers when an opportunity amount crosses the $100k threshold.',
- type: 'autolaunched',
-
- nodes: [
- {
- id: 'start',
- type: 'start',
- label: 'On Opportunity Update',
- config: {
- objectName: 'crm_opportunity',
- triggerType: 'record-after-update',
- // Fire only on the upward crossing of the threshold, not on every
- // save of an already-high-value deal.
- condition: 'amount > 100000 && (previous.amount == null || previous.amount <= 100000)',
- },
- },
- {
- id: 'notify_managers',
- type: 'script',
- label: 'Notify Sales Managers',
- config: {
- actionType: 'email',
- inputs: {
- to: '{record.owner_manager_email}',
- subject: '💰 High-Value Deal: {record.name}',
- template: 'high_value_deal_alert',
- },
- },
- },
- { id: 'end', type: 'end', label: 'End' },
- ],
- edges: [
- { id: 'e1', source: 'start', target: 'notify_managers' },
- { id: 'e2', source: 'notify_managers', target: 'end' },
- ],
-};
diff --git a/examples/app-crm/src/flows/index.ts b/examples/app-crm/src/flows/index.ts
index 7421ebbaa2..0e0a33162c 100644
--- a/examples/app-crm/src/flows/index.ts
+++ b/examples/app-crm/src/flows/index.ts
@@ -1,23 +1,10 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-import { OpportunityWonFlow } from './opportunity-won.flow.js';
-import { LeadQualificationFlow } from './lead-qualification.flow.js';
-import { RenewalReminderFlow } from './renewal-reminder.flow.js';
+// One flow — the screen-flow wizard the smoke test drives. Automation
+// breadth (approvals, schedules, connectors, subflows, …) is the
+// showcase's job (examples/app-showcase/src/automation/flows/).
import { ConvertLeadScreenFlow } from './convert-lead.flow.js';
-import { DiscountApprovalFlow } from './discount-approval.flow.js';
-// ADR-0020: side-effecting automation migrated off the retired `workflow`
-// metadata type into record-triggered / scheduled Flows.
-import { HighValueDealFlow } from './high-value-deal.flow.js';
-import { StaleOpportunityFlow } from './stale-opportunity.flow.js';
export { ConvertLeadScreenFlow } from './convert-lead.flow.js';
-export const allFlows = [
- OpportunityWonFlow,
- LeadQualificationFlow,
- RenewalReminderFlow,
- ConvertLeadScreenFlow,
- DiscountApprovalFlow,
- HighValueDealFlow,
- StaleOpportunityFlow,
-];
+export const allFlows = [ConvertLeadScreenFlow];
diff --git a/examples/app-crm/src/flows/lead-qualification.flow.ts b/examples/app-crm/src/flows/lead-qualification.flow.ts
deleted file mode 100644
index c8e0ca5d10..0000000000
--- a/examples/app-crm/src/flows/lead-qualification.flow.ts
+++ /dev/null
@@ -1,375 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import type { Flow } from '@objectstack/spec/automation';
-
-/**
- * Lead Qualification & Conversion Flow
- *
- * Exercises the full breadth of the Flow node type repertoire:
- * • assignment — initialise scoring variables
- * • get_record — fetch the related Account
- * • http — external enrichment API (with fault edge → error handler)
- * • script — calculate a composite lead score
- * • decision — branch on score threshold (conditional + isDefault edges)
- * • parallel_gateway — AND-split: notify rep AND create Opportunity simultaneously
- * • join_gateway — AND-join: wait for both branches before continuing
- * • create_record — create the derived Opportunity
- * • update_record — mark lead converted / disqualified
- * • wait — pause 24 h for a prospect-response timer event
- * • subflow — escalate to manager if no follow-up after the wait
- * • end — multiple terminal nodes for distinct outcomes
- *
- * Flow-level config exercised:
- * • variables — typed input/output variable declarations
- * • errorHandling — retry with exponential back-off + fallback node
- * • status / runAs / version
- */
-export const LeadQualificationFlow: Flow = {
- name: 'lead_qualification_conversion',
- label: 'Lead Qualification & Conversion',
- description:
- 'Qualifies an inbound lead through scoring and enrichment, then converts it to an Opportunity via parallel processing, a timed wait, and optional manager escalation.',
- type: 'record_change',
- status: 'active',
- version: 2,
- runAs: 'system',
-
- // ── Typed variable declarations ──────────────────────────────────────────
- variables: [
- { name: 'lead_score', type: 'number', isInput: false, isOutput: false },
- { name: 'enrichment_data', type: 'object', isInput: false, isOutput: false },
- { name: 'account_data', type: 'object', isInput: false, isOutput: false },
- { name: 'new_opportunity', type: 'object', isInput: false, isOutput: false },
- { name: 'opportunity_id', type: 'text', isInput: false, isOutput: true },
- { name: 'qualified', type: 'boolean', isInput: false, isOutput: true },
- ],
-
- // ── Flow-level error handling (retry with exponential back-off) ──────────
- errorHandling: {
- strategy: 'retry',
- maxRetries: 3,
- retryDelayMs: 2000,
- backoffMultiplier: 2,
- maxRetryDelayMs: 30000,
- jitter: true,
- fallbackNodeId: 'error_handler',
- },
-
- // ── Node graph ───────────────────────────────────────────────────────────
- nodes: [
-
- // ── 1. Start — fires when lead status transitions to "qualifying" ─────
- {
- id: 'start',
- type: 'start',
- label: 'Lead Status → Qualifying',
- config: {
- objectName: 'crm_lead',
- triggerType: 'record-after-update',
- condition: 'status == "qualifying" && previous.status != "qualifying"',
- },
- position: { x: 400, y: 0 },
- },
-
- // ── 2. Assignment — initialise variables before any branching ─────────
- {
- id: 'init_vars',
- type: 'assignment',
- label: 'Initialise Scoring Variables',
- config: {
- assignments: [
- { variable: 'lead_score', value: 0 },
- { variable: 'qualified', value: false },
- { variable: 'enrichment_data', value: null },
- ],
- },
- position: { x: 400, y: 120 },
- },
-
- // ── 3. Get Record — fetch the linked Account for revenue scoring ───────
- {
- id: 'get_account',
- type: 'get_record',
- label: 'Fetch Account Details',
- config: {
- objectName: 'crm_account',
- filter: { id: '{record.account}' },
- outputVariable: 'account_data',
- },
- outputSchema: {
- account_data: { type: 'object', description: 'Account record' },
- },
- position: { x: 400, y: 240 },
- },
-
- // ── 4. HTTP Request — call an external enrichment service ─────────────
- // A fault edge connects this node to error_handler if it fails.
- {
- id: 'enrich_lead',
- type: 'http',
- label: 'Enrich Lead (External API)',
- config: {
- method: 'POST',
- url: 'https://api.enrichment-service.example/leads/enrich',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Api-Key': '{env.ENRICHMENT_API_KEY}',
- },
- body: {
- email: '{record.email}',
- company: '{record.company}',
- },
- outputVariable: 'enrichment_data',
- },
- outputSchema: {
- enrichment_data: { type: 'object', description: 'Enrichment API response' },
- },
- timeoutMs: 10000,
- position: { x: 400, y: 360 },
- },
-
- // ── 5. Script — composite lead score from enrichment + account data ────
- {
- id: 'score_lead',
- type: 'script',
- label: 'Calculate Lead Score',
- config: {
- script: `
- let score = 0;
- // Source weighting
- const sourceBonus = { referral: 30, event: 20, web: 10, partner: 25 };
- score += sourceBonus[record.source] ?? 5;
- // Company size from enrichment (may be undefined if enrichment failed)
- const employees = enrichment_data?.employee_count ?? 0;
- if (employees > 500) score += 25;
- else if (employees > 100) score += 15;
- else if (employees > 20) score += 8;
- // Existing account relationship bonus
- if (account_data?.annual_revenue > 5_000_000) score += 20;
- else if (account_data?.annual_revenue > 1_000_000) score += 10;
- // Title / seniority
- const title = (record.title ?? '').toLowerCase();
- if (title.includes('vp') || title.includes('director') || title.includes('chief')) score += 15;
- else if (title.includes('manager') || title.includes('head')) score += 10;
- variables.lead_score = Math.min(score, 100);
- variables.qualified = score >= 60;
- `,
- outputVariables: ['lead_score', 'qualified'],
- },
- outputSchema: {
- lead_score: { type: 'number', description: 'Calculated score 0–100' },
- qualified: { type: 'boolean', description: 'Whether score meets threshold' },
- },
- position: { x: 400, y: 480 },
- },
-
- // ── 6. Decision — route on qualification threshold ────────────────────
- {
- id: 'decide_qualification',
- type: 'decision',
- label: 'Lead Score ≥ 60?',
- config: {
- conditions: [
- { label: 'Qualified', expression: 'lead_score >= 60' },
- { label: 'Not Qualified', expression: 'true' },
- ],
- },
- position: { x: 400, y: 600 },
- },
-
- // ── 7a. Disqualification path ─────────────────────────────────────────
- {
- id: 'update_lead_disqualified',
- type: 'update_record',
- label: 'Mark Lead Disqualified',
- config: {
- objectName: 'crm_lead',
- recordId: '{record.id}',
- data: {
- status: 'disqualified',
- notes: 'Auto-disqualified: Lead score {lead_score}/100 below threshold. Enrichment source: {enrichment_data.company_size}.',
- },
- },
- position: { x: 0, y: 720 },
- },
-
- // ── 7b. Parallel Gateway — AND-split (notify + create simultaneously) ─
- {
- id: 'parallel_split',
- type: 'parallel_gateway',
- label: 'Notify & Create Opportunity (Parallel)',
- position: { x: 600, y: 720 },
- },
-
- // ── 8a. Branch A — email notification to the assigned sales rep ────────
- {
- id: 'notify_sales_rep',
- type: 'script',
- label: 'Send Qualification Alert to Rep',
- config: {
- actionType: 'email',
- inputs: {
- to: '{record.assigned_to}',
- subject: '🎯 Lead Qualified: {record.name} (Score: {lead_score}/100)',
- template: 'lead_qualified_email',
- context: {
- lead_name: '{record.name}',
- company: '{record.company}',
- lead_score: '{lead_score}',
- },
- },
- },
- position: { x: 400, y: 840 },
- },
-
- // ── 8b. Branch B — create Opportunity record ───────────────────────────
- {
- id: 'create_opportunity',
- type: 'create_record',
- label: 'Create Opportunity from Lead',
- config: {
- objectName: 'crm_opportunity',
- data: {
- name: '{record.company} — Converted Lead: {record.name}',
- account: '{record.account}',
- stage: 'prospecting',
- amount: 0,
- probability: 20,
- close_date: '{daysFromNow(90)}',
- },
- outputVariable: 'new_opportunity',
- },
- outputSchema: {
- new_opportunity: { type: 'object', description: 'Newly created opportunity' },
- },
- position: { x: 800, y: 840 },
- },
-
- // ── 9. Join Gateway — AND-join: wait for both branches ────────────────
- {
- id: 'parallel_join',
- type: 'join_gateway',
- label: 'Wait for Both Branches',
- position: { x: 600, y: 960 },
- },
-
- // ── 10. Update Lead — set converted state with opportunity link ────────
- {
- id: 'update_lead_converted',
- type: 'update_record',
- label: 'Mark Lead Converted',
- config: {
- objectName: 'crm_lead',
- recordId: '{record.id}',
- data: {
- status: 'converted',
- converted_opportunity: '{new_opportunity.id}',
- },
- },
- position: { x: 600, y: 1080 },
- },
-
- // ── 11. Wait — pause 24 h on a timer for prospect-response signal ──────
- {
- id: 'wait_prospect_response',
- type: 'wait',
- label: 'Wait 24 h for Prospect Response',
- waitEventConfig: {
- eventType: 'timer',
- timerDuration: 'PT24H',
- timeoutMs: 86_400_000,
- onTimeout: 'continue',
- },
- position: { x: 600, y: 1200 },
- },
-
- // ── 12. Decision — did the rep advance the opportunity? ────────────────
- {
- id: 'decide_followup',
- type: 'decision',
- label: 'Opportunity Advanced Beyond Prospecting?',
- config: {
- conditions: [
- { label: 'Follow-up complete', expression: 'new_opportunity.stage != "prospecting"' },
- { label: 'No follow-up', expression: 'true' },
- ],
- },
- position: { x: 600, y: 1320 },
- },
-
- // ── 13. Subflow — escalate to manager when follow-up is missing ────────
- {
- id: 'escalate_to_manager',
- type: 'subflow',
- label: 'Escalate to Manager (Subflow)',
- config: {
- flowName: 'notify_manager_subflow',
- inputs: {
- opportunity_id: '{new_opportunity.id}',
- reason: 'No rep follow-up within 24 h of lead conversion (score: {lead_score})',
- },
- },
- position: { x: 800, y: 1440 },
- },
-
- // ── 14. Error handler — catch enrichment / unexpected failures ─────────
- {
- id: 'error_handler',
- type: 'script',
- label: 'Log Error & Continue Scoring',
- config: {
- script: `
- // Enrichment failed — proceed with partial score (no enrichment bonus)
- console.warn('Enrichment failed for lead', record.id, '; scoring without enrichment data.');
- variables.enrichment_data = {};
- `,
- outputVariables: ['enrichment_data'],
- },
- position: { x: 700, y: 360 },
- },
-
- // ── End nodes ─────────────────────────────────────────────────────────
- { id: 'end_disqualified', type: 'end', label: 'Lead Disqualified', position: { x: 0, y: 840 } },
- { id: 'end_converted', type: 'end', label: 'Lead Converted Successfully', position: { x: 400, y: 1440 } },
- { id: 'end_escalated', type: 'end', label: 'Lead Escalated to Manager', position: { x: 800, y: 1560 } },
- ],
-
- // ── Edge graph ───────────────────────────────────────────────────────────
- edges: [
- // Linear setup path
- { id: 'e01', source: 'start', target: 'init_vars' },
- { id: 'e02', source: 'init_vars', target: 'get_account' },
- { id: 'e03', source: 'get_account', target: 'enrich_lead' },
-
- // Enrichment: success path → score; fault path → error_handler
- { id: 'e04', source: 'enrich_lead', target: 'score_lead', type: 'default' },
- { id: 'e05', source: 'enrich_lead', target: 'error_handler', type: 'fault', label: 'Enrichment Failed' },
- // Error handler rejoins the main path at score step
- { id: 'e06', source: 'error_handler', target: 'score_lead' },
-
- { id: 'e07', source: 'score_lead', target: 'decide_qualification' },
-
- // Decision: qualified branch → parallel split; default → disqualify
- { id: 'e08', source: 'decide_qualification', target: 'parallel_split', type: 'conditional', condition: 'lead_score >= 60', label: 'Qualified' },
- { id: 'e09', source: 'decide_qualification', target: 'update_lead_disqualified', type: 'conditional', isDefault: true, label: 'Not Qualified' },
- { id: 'e10', source: 'update_lead_disqualified', target: 'end_disqualified' },
-
- // Parallel split → two branches
- { id: 'e11', source: 'parallel_split', target: 'notify_sales_rep' },
- { id: 'e12', source: 'parallel_split', target: 'create_opportunity' },
-
- // Both branches → join
- { id: 'e13', source: 'notify_sales_rep', target: 'parallel_join' },
- { id: 'e14', source: 'create_opportunity', target: 'parallel_join' },
-
- // Post-join → update lead → wait → follow-up decision
- { id: 'e15', source: 'parallel_join', target: 'update_lead_converted' },
- { id: 'e16', source: 'update_lead_converted', target: 'wait_prospect_response' },
- { id: 'e17', source: 'wait_prospect_response', target: 'decide_followup' },
-
- // Follow-up decision
- { id: 'e18', source: 'decide_followup', target: 'end_converted', type: 'conditional', condition: 'new_opportunity.stage != "prospecting"', label: 'Follow-up Done' },
- { id: 'e19', source: 'decide_followup', target: 'escalate_to_manager', type: 'conditional', isDefault: true, label: 'No Follow-up' },
- { id: 'e20', source: 'escalate_to_manager', target: 'end_escalated' },
- ],
-};
diff --git a/examples/app-crm/src/flows/opportunity-won.flow.ts b/examples/app-crm/src/flows/opportunity-won.flow.ts
deleted file mode 100644
index ed90390ec7..0000000000
--- a/examples/app-crm/src/flows/opportunity-won.flow.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import type { Flow } from '@objectstack/spec/automation';
-
-/**
- * Auto-launched flow that fires when an opportunity transitions to
- * Closed Won. Demonstrates a record-triggered flow shape.
- */
-export const OpportunityWonFlow: Flow = {
- name: 'opportunity_won_notification',
- label: 'Notify on Opportunity Won',
- description: 'Sends a celebration email when an opportunity is closed-won.',
- type: 'autolaunched',
-
- nodes: [
- {
- id: 'start',
- type: 'start',
- label: 'On Opportunity Update',
- config: {
- objectName: 'crm_opportunity',
- triggerType: 'record-after-update',
- condition: 'stage == "closed_won" && previous.stage != "closed_won"',
- },
- },
- {
- id: 'send_email',
- type: 'script',
- label: 'Send Win Notification',
- config: {
- actionType: 'email',
- inputs: {
- to: '{record.owner.email}',
- subject: '🎉 Opportunity Won: {record.name}',
- template: 'opportunity_won_email',
- },
- },
- },
- { id: 'end', type: 'end', label: 'End' },
- ],
- edges: [
- { id: 'e1', source: 'start', target: 'send_email' },
- { id: 'e2', source: 'send_email', target: 'end' },
- ],
-};
diff --git a/examples/app-crm/src/flows/renewal-reminder.flow.ts b/examples/app-crm/src/flows/renewal-reminder.flow.ts
deleted file mode 100644
index 60fc56522b..0000000000
--- a/examples/app-crm/src/flows/renewal-reminder.flow.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import type { Flow } from '@objectstack/spec/automation';
-
-/**
- * Renewal Reminder Flow
- *
- * A schedule-triggered flow that runs daily. It queries opportunities closed
- * approximately 12 months ago and creates a renewal opportunity for each one
- * that does not already have a linked renewal.
- *
- * Demonstrates:
- * • schedule-type flow with cron config
- * • get_record for a multi-record query
- * • script node used as an inline loop (forEach over results)
- * • create_record inside conditional logic within a script
- * • assignment for accumulating a counter variable
- * • clean variables + errorHandling wiring
- */
-export const RenewalReminderFlow: Flow = {
- name: 'renewal_reminder_flow',
- label: 'Daily Renewal Opportunity Creator',
- description:
- 'Runs daily, finds closed-won opportunities with a close date ~12 months ago that have no renewal, and creates a renewal opportunity for each.',
- type: 'schedule',
- status: 'active',
- version: 1,
- runAs: 'system',
-
- variables: [
- { name: 'expiring_deals', type: 'array', isInput: false, isOutput: false },
- { name: 'renewals_created', type: 'number', isInput: false, isOutput: true },
- ],
-
- errorHandling: {
- strategy: 'continue',
- fallbackNodeId: 'log_errors',
- },
-
- nodes: [
- // ── Start — daily at 07:00 UTC ─────────────────────────────────────────
- {
- id: 'start',
- type: 'start',
- label: 'Daily 07:00 UTC',
- config: {
- triggerType: 'schedule',
- cron: '0 7 * * *',
- },
- position: { x: 400, y: 0 },
- },
-
- // ── Init counter ──────────────────────────────────────────────────────
- {
- id: 'init_counter',
- type: 'assignment',
- label: 'Reset Renewal Counter',
- config: {
- assignments: [{ variable: 'renewals_created', value: 0 }],
- },
- position: { x: 400, y: 120 },
- },
-
- // ── Fetch expiring deals (close_date between 11 and 13 months ago) ─────
- {
- id: 'fetch_expiring',
- type: 'get_record',
- label: 'Fetch Deals Expiring ~12 Months Ago',
- config: {
- objectName: 'crm_opportunity',
- filter: {
- stage: 'closed_won',
- close_date: { $gte: '{daysAgo(395)}', $lte: '{daysAgo(335)}' },
- renewal_of: null,
- },
- outputVariable: 'expiring_deals',
- limit: 200,
- },
- outputSchema: {
- expiring_deals: { type: 'array', description: 'Deals eligible for renewal' },
- },
- position: { x: 400, y: 240 },
- },
-
- // ── Decision — any results? ────────────────────────────────────────────
- {
- id: 'decide_has_results',
- type: 'decision',
- label: 'Any Expiring Deals?',
- config: {
- conditions: [
- { label: 'Has deals', expression: 'expiring_deals.length > 0' },
- { label: 'Nothing', expression: 'true' },
- ],
- },
- position: { x: 400, y: 360 },
- },
-
- // ── Script — iterate over deals and create renewals ────────────────────
- // (Script-as-loop avoids the partial loop executor gap noted in ADR.)
- {
- id: 'create_renewals',
- type: 'script',
- label: 'Create Renewal Opportunities',
- config: {
- script: `
- let count = 0;
- for (const deal of expiring_deals) {
- // Safety: skip if a renewal already exists (defensive; filter above should catch this)
- const existingRenewal = await objectql.findOne('crm_opportunity', {
- filter: { renewal_of: deal.id },
- });
- if (existingRenewal) continue;
-
- await objectql.insert('crm_opportunity', {
- name: deal.name + ' — Renewal',
- account: deal.account,
- stage: 'prospecting',
- amount: deal.amount,
- probability: 20,
- close_date: daysFromNow(90),
- renewal_of: deal.id,
- });
- count++;
- }
- variables.renewals_created = count;
- `,
- outputVariables: ['renewals_created'],
- },
- position: { x: 400, y: 480 },
- },
-
- // ── Log summary ───────────────────────────────────────────────────────
- {
- id: 'log_summary',
- type: 'script',
- label: 'Log Renewal Run Summary',
- config: {
- script: `console.info('Renewal run complete — created', renewals_created, 'renewal opportunities.');`,
- },
- position: { x: 400, y: 600 },
- },
-
- // ── Error logger fallback ─────────────────────────────────────────────
- {
- id: 'log_errors',
- type: 'script',
- label: 'Log Flow Errors',
- config: {
- script: `console.error('renewal_reminder_flow encountered an error:', error);`,
- },
- position: { x: 700, y: 360 },
- },
-
- // ── End nodes ─────────────────────────────────────────────────────────
- { id: 'end_nothing', type: 'end', label: 'No Expiring Deals Today', position: { x: 700, y: 480 } },
- { id: 'end_done', type: 'end', label: 'Renewals Created', position: { x: 400, y: 720 } },
- { id: 'end_error', type: 'end', label: 'Error Handled', position: { x: 700, y: 480 } },
- ],
-
- edges: [
- { id: 'e01', source: 'start', target: 'init_counter' },
- { id: 'e02', source: 'init_counter', target: 'fetch_expiring' },
- { id: 'e03', source: 'fetch_expiring', target: 'decide_has_results' },
- { id: 'e04', source: 'decide_has_results', target: 'create_renewals', type: 'conditional', condition: 'expiring_deals.length > 0', label: 'Has Deals' },
- { id: 'e05', source: 'decide_has_results', target: 'end_nothing', type: 'conditional', isDefault: true, label: 'Nothing to do' },
- { id: 'e06', source: 'create_renewals', target: 'log_summary' },
- { id: 'e07', source: 'log_summary', target: 'end_done' },
- // Fault paths
- { id: 'e08', source: 'fetch_expiring', target: 'log_errors', type: 'fault', label: 'Query Failed' },
- { id: 'e09', source: 'create_renewals', target: 'log_errors', type: 'fault', label: 'Create Failed' },
- { id: 'e10', source: 'log_errors', target: 'end_error' },
- ],
-};
diff --git a/examples/app-crm/src/flows/stale-opportunity.flow.ts b/examples/app-crm/src/flows/stale-opportunity.flow.ts
deleted file mode 100644
index 037c70d48a..0000000000
--- a/examples/app-crm/src/flows/stale-opportunity.flow.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import type { Flow } from '@objectstack/spec/automation';
-
-/**
- * Stale Opportunity sweep — scheduled flow.
- *
- * Migrated from the legacy time-based `workflow` metadata type (retired in
- * ADR-0020). The original rule ran on a schedule, found open
- * `crm_opportunity` records untouched for 30+ days, and (a) emailed the
- * owner and (b) created a follow-up task. A scheduled sweep with
- * side-effecting actions is precisely a schedule-triggered Flow, so it now
- * lives here rather than as a bespoke workflow metadata type.
- */
-export const StaleOpportunityFlow: Flow = {
- name: 'stale_opportunity_sweep',
- label: 'Stale Opportunity Sweep',
- description: 'Daily sweep that notifies owners of open opportunities untouched for 30+ days and opens a follow-up task.',
- type: 'schedule',
- // A scheduled run has no trigger user, so it must declare its elevation: this
- // sweep spans every owner's opportunities and opens follow-up tasks across
- // them. Without this it would run UNSCOPED by default. (ADR-0049 / #1888)
- runAs: 'system',
-
- nodes: [
- {
- id: 'start',
- type: 'start',
- label: 'Daily Schedule',
- config: {
- triggerType: 'schedule',
- // Every day at 08:00 — re-evaluate open opportunities for staleness.
- cron: '0 8 * * *',
- objectName: 'crm_opportunity',
- filter: "stage != 'closed_won' && stage != 'closed_lost' && last_modified < daysAgo(30)",
- },
- },
- {
- id: 'notify_owner',
- type: 'script',
- label: 'Notify Owner',
- config: {
- actionType: 'email',
- inputs: {
- to: '{record.owner_email}',
- subject: '⏰ Stale Opportunity: {record.name}',
- template: 'stale_opportunity_alert',
- },
- },
- },
- {
- id: 'open_followup_task',
- type: 'create_record',
- label: 'Open Follow-up Task',
- config: {
- objectName: 'crm_activity',
- inputs: {
- subject: 'Follow up on stale opportunity: {record.name}',
- description: 'This opportunity has not been updated in 30+ days. Please review and update.',
- due_date: '{daysFromNow(3)}',
- related_to: '{record.id}',
- },
- },
- },
- { id: 'end', type: 'end', label: 'End' },
- ],
- edges: [
- { id: 'e1', source: 'start', target: 'notify_owner' },
- { id: 'e2', source: 'notify_owner', target: 'open_followup_task' },
- { id: 'e3', source: 'open_followup_task', target: 'end' },
- ],
-};
diff --git a/examples/app-crm/src/jobs/crm-jobs.ts b/examples/app-crm/src/jobs/crm-jobs.ts
deleted file mode 100644
index 05fc33f8a8..0000000000
--- a/examples/app-crm/src/jobs/crm-jobs.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import type { JobInput } from '@objectstack/spec/system';
-
-/**
- * Nightly lead-scoring job — recomputes `lead_score` for all open leads.
- * Handler key 'scoreLeads' must be registered in defineStack({ functions }).
- */
-export const LeadScoringJob: JobInput = {
- name: 'crm_lead_scoring',
- label: 'Nightly Lead Score Refresh',
- description: 'Recalculates lead_score for all open leads using engagement signals.',
- schedule: {
- type: 'cron',
- expression: '0 2 * * *',
- timezone: 'UTC',
- },
- handler: 'scoreLeads',
- retryPolicy: {
- maxRetries: 2,
- backoffMs: 5000,
- backoffMultiplier: 2,
- },
- timeout: 300000,
- enabled: true,
-};
-
-/**
- * Weekly pipeline report — aggregates deal data and emails managers.
- */
-export const PipelineReportJob: JobInput = {
- name: 'crm_pipeline_report',
- label: 'Weekly Pipeline Report',
- description: 'Generates and emails weekly pipeline summary to sales managers.',
- schedule: {
- type: 'cron',
- expression: '0 8 * * 1',
- timezone: 'UTC',
- },
- handler: 'generatePipelineReport',
- retryPolicy: {
- maxRetries: 1,
- backoffMs: 10000,
- backoffMultiplier: 1,
- },
- timeout: 120000,
- enabled: true,
-};
-
-/**
- * Daily renewal reminder sweep — kicks off renewal_reminder_flow for
- * opportunities nearing contract expiry.
- */
-export const RenewalSweepJob: JobInput = {
- name: 'crm_renewal_sweep',
- label: 'Daily Renewal Reminder Sweep',
- description: 'Scans contracts expiring within 30 days and enqueues reminder flows.',
- schedule: {
- type: 'cron',
- expression: '0 9 * * *',
- timezone: 'UTC',
- },
- handler: 'sweepRenewals',
- timeout: 60000,
- enabled: true,
-};
diff --git a/examples/app-crm/src/portals/customer.portal.ts b/examples/app-crm/src/portals/customer.portal.ts
deleted file mode 100644
index 356f9e536e..0000000000
--- a/examples/app-crm/src/portals/customer.portal.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import type { Portal } from '@objectstack/spec/ui';
-
-/**
- * Customer Self-Service Portal — external users can view their account,
- * submit activities, and track open opportunities without a full Studio login.
- *
- * Demonstrates: kind discriminator, profiles, navigation items, anonymous
- * entry with rate-limiting, SEO metadata, and magic-link auth mode.
- */
-export const CustomerPortal: Portal = {
- kind: 'portal',
- id: 'customer_self_service',
- label: 'Customer Self-Service Portal',
- description: 'External portal for customers to track their account and support activities.',
- routePrefix: '/portal/customer',
- layout: 'minimal',
- authMode: 'magic-link',
- locale: 'auto',
- profiles: ['customer_portal_user'],
- seo: {
- title: 'Customer Portal — CRM Example',
- description: 'Track your account, opportunities, and support activities.',
- robots: 'noindex',
- },
- navigation: [
- {
- type: 'view',
- id: 'my_activities',
- label: 'My Activities',
- icon: 'calendar',
- order: 1,
- viewRef: 'crm_activity.activity_grid',
- },
- {
- type: 'view',
- id: 'my_account',
- label: 'My Account',
- icon: 'building',
- order: 2,
- viewRef: 'crm_account.account_list',
- },
- {
- type: 'url',
- id: 'knowledge_base',
- label: 'Knowledge Base',
- icon: 'book-open',
- order: 3,
- url: 'https://docs.example.com',
- target: '_blank',
- },
- ],
- // NOTE: `anonymousEntry` is a spec property with no runtime consumer yet — the
- // routes here would never mount (verified: 404). For a WORKING public capture
- // form, see the `web_to_lead` form view in `views/lead.view.ts`
- // (`sharing.allowAnonymous` → live `/api/v1/forms/contact-us` endpoint). Re-add
- // `anonymousEntry` here only once the runtime mounts it.
- defaultRoute: {
- viewRef: 'crm_activity.activity_grid',
- },
- embeddable: false,
- active: true,
-};
diff --git a/examples/app-crm/src/reports/index.ts b/examples/app-crm/src/reports/index.ts
deleted file mode 100644
index 257688117e..0000000000
--- a/examples/app-crm/src/reports/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-export { SalesByStageReport } from './sales-by-stage.report.js';
-export { SalesByAccountReport } from './sales-by-account.report.js';
diff --git a/examples/app-crm/src/reports/sales-by-account.report.ts b/examples/app-crm/src/reports/sales-by-account.report.ts
deleted file mode 100644
index 6584f097f2..0000000000
--- a/examples/app-crm/src/reports/sales-by-account.report.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { defineReport } from '@objectstack/spec/ui';
-
-/**
- * Example report — total opportunity amount grouped by ACCOUNT.
- *
- * Bound to `opportunity_metrics` with rows = `account`, a LOOKUP dimension.
- * The analytics layer resolves each account FK to its display name in `rows`,
- * but exposes the raw FK via `drillRawRows` + `dimensionFields` (ADR-0021 D2),
- * so drilling a row filters the opportunity list by the account's stored id —
- * not its (possibly non-unique) display name. Paired with the currency-aware
- * `total_amount` measure (USD), this exercises both render paths — Intl
- * currency formatting AND raw-value lookup drill — end to end.
- */
-export const SalesByAccountReport = defineReport({
- name: 'crm_sales_by_account',
- label: 'Sales by Account',
- description: 'Total opportunity amount grouped by account (lookup-dimension drill).',
- type: 'summary',
- dataset: 'opportunity_metrics',
- rows: ['account'],
- values: ['total_amount'],
-});
diff --git a/examples/app-crm/src/reports/sales-by-stage.report.ts b/examples/app-crm/src/reports/sales-by-stage.report.ts
deleted file mode 100644
index 14e75c7a73..0000000000
--- a/examples/app-crm/src/reports/sales-by-stage.report.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { defineReport } from '@objectstack/spec/ui';
-
-/**
- * Example report — total opportunity amount grouped by stage.
- *
- * ADR-0021 Phase 2: bound to the `opportunity_metrics` dataset (rows = stage,
- * values = total_amount) alongside the legacy inline query. The legacy form was
- * also corrected to actually group + sum (it previously listed rows flat despite
- * its "grouped by stage" label), so both forms compute the same number and the
- * reconciliation harness can verify them.
- */
-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'],
-});
diff --git a/examples/app-crm/src/themes/crm.theme.ts b/examples/app-crm/src/themes/crm.theme.ts
deleted file mode 100644
index c1ecfa8f0f..0000000000
--- a/examples/app-crm/src/themes/crm.theme.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { defineTheme } from '@objectstack/spec/ui';
-
-/**
- * Default CRM brand theme — light mode with professional blue palette.
- */
-export const CrmLightTheme = defineTheme({
- name: 'crm_light',
- label: 'CRM Light',
- description: 'Default CRM theme — professional blue, light mode.',
- mode: 'light',
- colors: {
- primary: '#1E6FD9',
- secondary: '#6C757D',
- accent: '#17A2B8',
- background: '#FFFFFF',
- surface: '#F8F9FA',
- text: '#212529',
- textSecondary: '#6C757D',
- border: '#DEE2E6',
- success: '#28A745',
- warning: '#FFC107',
- error: '#DC3545',
- info: '#17A2B8',
- },
- typography: {
- fontFamily: { base: "'Inter', 'Segoe UI', system-ui, sans-serif" },
- fontSize: {
- xs: '0.75rem',
- sm: '0.875rem',
- base: '1rem',
- lg: '1.125rem',
- xl: '1.25rem',
- },
- fontWeight: {
- normal: 400,
- medium: 500,
- semibold: 600,
- bold: 700,
- },
- lineHeight: {
- tight: '1.25',
- normal: '1.5',
- relaxed: '1.75',
- },
- },
- borderRadius: {
- sm: '4px',
- md: '6px',
- lg: '8px',
- xl: '12px',
- full: '9999px',
- },
- density: 'regular',
- wcagContrast: 'AA',
-});
-
-/**
- * Dark variant — same palette, dark surfaces.
- */
-export const CrmDarkTheme = defineTheme({
- name: 'crm_dark',
- label: 'CRM Dark',
- description: 'CRM dark mode theme.',
- mode: 'dark',
- extends: 'crm_light',
- colors: {
- primary: '#4D9EF5',
- secondary: '#ADB5BD',
- accent: '#3DD5F3',
- background: '#121212',
- surface: '#1E1E2E',
- text: '#E9ECEF',
- textSecondary: '#ADB5BD',
- border: '#343A40',
- success: '#40C057',
- warning: '#FFD43B',
- error: '#FA5252',
- info: '#3DD5F3',
- },
- density: 'regular',
-});
diff --git a/examples/app-crm/src/webhooks/crm-webhooks.ts b/examples/app-crm/src/webhooks/crm-webhooks.ts
deleted file mode 100644
index 33715d87de..0000000000
--- a/examples/app-crm/src/webhooks/crm-webhooks.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
-
-import { defineWebhook } from '@objectstack/spec/automation';
-
-/**
- * Notify external CRM bus whenever an opportunity is created or updated.
- */
-export const OpportunityChangedWebhook = defineWebhook({
- name: 'crm_opportunity_changed',
- label: 'Opportunity Created / Updated',
- object: 'crm_opportunity',
- triggers: ['create', 'update'],
- url: 'https://hooks.example.com/crm/opportunity',
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Source': 'objectstack-crm',
- },
- payloadFields: ['id', 'name', 'stage', 'amount', 'owner_id', 'updated_at'],
- authentication: {
- type: 'bearer',
- credentials: { token: 'env:CRM_WEBHOOK_TOKEN' },
- },
- retryPolicy: {
- maxRetries: 3,
- backoffStrategy: 'exponential',
- initialDelayMs: 1000,
- maxDelayMs: 30000,
- },
- timeoutMs: 10000,
- secret: 'env:CRM_WEBHOOK_SECRET',
- 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 = defineWebhook({
- name: 'crm_deal_won_slack',
- label: 'Deal Won → Slack',
- object: 'crm_opportunity',
- triggers: ['update'],
- url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXX',
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- payloadFields: ['id', 'name', 'amount', 'owner_id', 'close_date'],
- isActive: true,
- description: 'Posts to #wins Slack channel when stage=closed_won.',
- tags: ['slack', 'notifications'],
-});
diff --git a/examples/app-crm/test/smoke.test.ts b/examples/app-crm/test/smoke.test.ts
index 43c48c923a..2d4b52715a 100644
--- a/examples/app-crm/test/smoke.test.ts
+++ b/examples/app-crm/test/smoke.test.ts
@@ -23,11 +23,13 @@ describe('app-crm minimal metadata bundle', () => {
]);
});
- it('registers exactly one app, one dashboard, one hook, and at least 4 flows', () => {
+ it('registers exactly one app, one dashboard, one hook, and one flow', () => {
expect(stack.apps).toHaveLength(1);
expect(stack.dashboards).toHaveLength(1);
expect(stack.hooks).toHaveLength(1);
- expect((stack.flows ?? []).length).toBeGreaterThanOrEqual(4);
+ // One flow only — the convert-lead screen wizard. Automation breadth
+ // is the showcase's job.
+ expect(stack.flows).toHaveLength(1);
});
it('includes a screen flow with input/output variables, screen nodes, and guard decision', () => {
@@ -66,7 +68,9 @@ describe('app-crm minimal metadata bundle', () => {
expect((stack.data ?? []).length).toBeGreaterThanOrEqual(3);
});
- // Phase 2: full metadata coverage
+ // Infrastructure & security of the slim core (feature breadth lives in
+ // the showcase — its coverage manifest enforces it; see #2611/#2612 for
+ // the inert mappings/connectors this example used to demo):
it('has datasources', () => {
expect((stack.datasources ?? []).length).toBeGreaterThanOrEqual(1);
});
@@ -84,24 +88,6 @@ describe('app-crm minimal metadata bundle', () => {
expect(stack.i18n!.supportedLocales).toContain('zh-CN');
});
- it('has object extensions', () => {
- expect((stack.objectExtensions ?? []).length).toBeGreaterThanOrEqual(1);
- expect(stack.objectExtensions![0].extend).toBe('crm_contact');
- });
-
- it('has a portal', () => {
- expect((stack.portals ?? []).length).toBeGreaterThanOrEqual(1);
- expect(stack.portals![0].routePrefix).toBe('/portal/customer');
- });
-
- it('has themes (light + dark)', () => {
- expect((stack.themes ?? []).length).toBeGreaterThanOrEqual(2);
- });
-
- it('has jobs', () => {
- expect((stack.jobs ?? []).length).toBeGreaterThanOrEqual(2);
- });
-
it('has sharing rules (criteria + owner types)', () => {
const rules = stack.sharingRules ?? [];
expect(rules.length).toBeGreaterThanOrEqual(2);
@@ -109,26 +95,6 @@ describe('app-crm minimal metadata bundle', () => {
expect(rules.some((r) => r.type === 'owner')).toBe(true);
});
-
- it('has API endpoints', () => {
- expect((stack.apis ?? []).length).toBeGreaterThanOrEqual(2);
- });
-
- it('has webhooks', () => {
- expect((stack.webhooks ?? []).length).toBeGreaterThanOrEqual(1);
- });
-
- it('has import/export mappings', () => {
- expect((stack.mappings ?? []).length).toBeGreaterThanOrEqual(1);
- });
-
- it('has analytics cubes', () => {
- expect((stack.analyticsCubes ?? []).length).toBeGreaterThanOrEqual(1);
- });
-
- it('has connectors', () => {
- expect((stack.connectors ?? []).length).toBeGreaterThanOrEqual(1);
- });
});
describe('Pipeline dashboard', () => {
diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts
index 0cd7476b7e..34c8033750 100644
--- a/examples/app-showcase/objectstack.config.ts
+++ b/examples/app-showcase/objectstack.config.ts
@@ -29,6 +29,7 @@ import { allHooks } from './src/data/hooks/index.js';
import { allJobs } from './src/automation/jobs/index.js';
import { allEmails } from './src/system/emails/index.js';
import { allBooks } from './src/system/books/index.js';
+import { allApis } from './src/system/apis/index.js';
import {
allRoles,
allPermissionSets,
@@ -180,6 +181,9 @@ export default defineStack({
flows: allFlows,
jobs: allJobs,
emailTemplates: allEmails,
+ // Declarative REST endpoints (object_operation + flow) — the metadata
+ // counterpart of the code-mounted recalc endpoint (see src/system/apis/).
+ apis: allApis,
hooks: allHooks,
webhooks: allWebhooks,
diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts
index e55b711950..428ab895a3 100644
--- a/examples/app-showcase/src/coverage.ts
+++ b/examples/app-showcase/src/coverage.ts
@@ -181,6 +181,12 @@ export const STACK_COLLECTION_COVERAGE: Record = {
files: ['src/data/extensions/account.extension.ts'],
notes: 'Merged into showcase_account by the ObjectQL engine at registerApp (priority overlay).',
},
+ apis: {
+ status: 'demonstrated',
+ files: ['src/system/apis/index.ts'],
+ notes:
+ 'Declarative ApiEndpoint metadata (object_operation + flow targets), executed by the runtime dispatcher (handleApiEndpoint). Complements the code-mounted endpoint in src/system/server/ (router kind stays waived: code-only).',
+ },
mappings: {
status: 'waived',
reason:
diff --git a/examples/app-showcase/src/system/apis/index.ts b/examples/app-showcase/src/system/apis/index.ts
new file mode 100644
index 0000000000..5c7ffd5239
--- /dev/null
+++ b/examples/app-showcase/src/system/apis/index.ts
@@ -0,0 +1,43 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { ApiEndpoint } from '@objectstack/spec/api';
+
+/**
+ * Declarative API endpoints (`apis:`) — the metadata-authored counterpart of
+ * the code-mounted endpoint in src/system/server/recalc-endpoint.ts. The
+ * runtime dispatcher matches these by path+method and executes the target
+ * (`object_operation` → a data read; `flow` → a flow run) with no handler
+ * code. Migrated here from app-crm when that example was slimmed back to a
+ * pure loading-pipeline smoke fixture.
+ */
+
+/** Read-only data projection: GET a filtered task list through a stable URL. */
+export const TaskFeedEndpoint: ApiEndpoint = {
+ name: 'showcase_task_feed',
+ path: '/api/v1/showcase/tasks',
+ method: 'GET',
+ summary: 'Task feed',
+ description: 'Returns tasks via a declarative object_operation endpoint — no handler code.',
+ type: 'object_operation',
+ target: 'showcase_task',
+ objectParams: {
+ object: 'showcase_task',
+ operation: 'find',
+ },
+ authRequired: true,
+ cacheTtl: 30,
+};
+
+/** Flow-typed endpoint: POST triggers the janitor flow (get+delete demo). */
+export const InquiryPurgeEndpoint: ApiEndpoint = {
+ name: 'showcase_inquiry_purge_api',
+ path: '/api/v1/showcase/inquiries/purge',
+ method: 'POST',
+ summary: 'Purge closed inquiries',
+ description: 'Invokes the showcase_inquiry_purge flow (get_record + delete_record janitor) over HTTP.',
+ type: 'flow',
+ target: 'showcase_inquiry_purge',
+ authRequired: true,
+};
+
+export const allApis = [TaskFeedEndpoint, InquiryPurgeEndpoint];
diff --git a/examples/app-showcase/test/gap-fill.test.ts b/examples/app-showcase/test/gap-fill.test.ts
index 9b165d31ca..967609dd56 100644
--- a/examples/app-showcase/test/gap-fill.test.ts
+++ b/examples/app-showcase/test/gap-fill.test.ts
@@ -33,6 +33,31 @@ describe('showcase gap fill — analytics cube', () => {
});
});
+describe('showcase gap fill — declarative api endpoints', () => {
+ it('is wired into the stack definition', () => {
+ const apis = (stack as { apis?: Array<{ name: string }> }).apis ?? [];
+ expect(apis.map((a) => a.name)).toEqual(
+ expect.arrayContaining(['showcase_task_feed', 'showcase_inquiry_purge_api']),
+ );
+ });
+
+ it('flow-typed endpoints target flows that actually exist (no 500 at dispatch)', () => {
+ const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? [];
+ const flowNames = ((stack as { flows?: Array<{ name: string }> }).flows ?? []).map((f) => f.name);
+ for (const api of apis.filter((a) => a.type === 'flow')) {
+ expect(flowNames, `api endpoint targets missing flow '${api.target}'`).toContain(api.target);
+ }
+ });
+
+ it('object_operation endpoints target objects that exist', () => {
+ const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? [];
+ const objectNames = ((stack as { objects?: Array<{ name: string }> }).objects ?? []).map((o) => o.name);
+ for (const api of apis.filter((a) => a.type === 'object_operation')) {
+ expect(objectNames, `api endpoint targets missing object '${api.target}'`).toContain(api.target);
+ }
+ });
+});
+
describe('showcase gap fill — object extension (overlay merge)', () => {
it('is wired into the stack definition', () => {
const exts = (stack as { objectExtensions?: Array<{ extend: string }> }).objectExtensions ?? [];
diff --git a/examples/app-todo/README.md b/examples/app-todo/README.md
index e1052a5fac..f20a744af1 100644
--- a/examples/app-todo/README.md
+++ b/examples/app-todo/README.md
@@ -5,11 +5,15 @@ A comprehensive Todo application demonstrating the ObjectStack Protocol with tas
## 🎯 Purpose
This example serves as a **quick-start reference** for learning ObjectStack basics. It demonstrates:
-- Object definition with essential field types, validations, and workflows
+- Object definition with essential field types and validations (record
+ state lives in validation rules per ADR-0020 — there is no `workflow` type)
- Actions for task management (complete, defer, clone, etc.)
-- Dashboard with key metrics and visualizations
-- Reports for status, priority, owner, and time tracking analysis
+- Dashboard with key metrics and visualizations, backed by the dataset
+ semantic layer (ADR-0021)
+- Reports for status, priority, owner, and time-tracking analysis
- Automation flows for reminders, escalation, and recurring tasks
+- i18n bundles for **en / zh-CN / ja-JP** (the ja-JP bundle is unique
+ in-repo)
- Full configuration using `objectstack.config.ts` with the standard **by-type** layout
For a **comprehensive enterprise example** with advanced features (AI agents, security profiles, sharing rules), see the **[HotCRM reference app](https://github.com/objectstack-ai/hotcrm)** (separate repository).
@@ -22,20 +26,21 @@ Follows the **by-type** directory layout — the ObjectStack standard aligned wi
examples/app-todo/
├── src/
│ ├── objects/ # 📦 Data Models
-│ │ ├── task.object.ts # Task object definition (fields, validations, workflows)
-│ │ └── task.hook.ts # Data hooks / triggers
-│ ├── actions/ # ⚡ Buttons & Actions
-│ │ └── task.actions.ts # Complete, Start, Defer, Clone, Mass Complete, Export
-│ ├── apps/ # 🚀 App Configuration
-│ │ └── todo.app.ts # Navigation, branding
-│ ├── dashboards/ # 📊 BI Dashboards
-│ │ └── task.dashboard.ts # Metrics, charts, task lists
-│ ├── reports/ # 📈 Analytics Reports
-│ │ └── task.report.ts # By status, priority, owner, overdue, time tracking
-│ └── flows/ # 🔄 Automation Flows
-│ └── task.flow.ts # Reminder, escalation, completion, quick-add
+│ │ ├── task.object.ts # Task object (fields + validation rules)
+│ │ └── task.hook.ts # Data hooks
+│ ├── views/ # 👓 List-view lenses (incl. Overdue — ADR-0017)
+│ ├── datasets/ # 🧮 Semantic layer feeding dashboard/reports (ADR-0021)
+│ ├── actions/ # ⚡ Complete, Start, Defer, Clone, Mass Complete, Export
+│ ├── apps/ # 🚀 Navigation, branding
+│ ├── dashboards/ # 📊 Metrics, charts, task lists
+│ ├── reports/ # 📈 By status / priority / owner / completed / time tracking
+│ ├── flows/ # 🔄 Reminder, escalation, completion, quick-add
+│ ├── translations/ # 🌍 en · zh-CN · ja-JP bundles (+ completeness spec)
+│ ├── data/ # 🌱 Seed data
+│ └── docs/ # 📚 Package docs (ADR-0046)
├── test/
-│ └── seed.test.ts # 🧪 Seed data verification
+│ ├── seed-check.ts # 🧪 Seed/boot verification script (tsx)
+│ └── mcp-actions.e2e.ts # 🤖 MCP business-action E2E (pnpm test:mcp)
├── objectstack.config.ts # Application manifest
└── README.md
```
@@ -69,10 +74,12 @@ examples/app-todo/
- Charts (status pie, priority bar, weekly trend line, category donut)
- Task tables (overdue, due today)
-### Reports (6)
+### Reports (5)
- Tasks by Status / Priority / Owner
-- Overdue Tasks / Completed Tasks
+- Completed Tasks
- Time Tracking (estimated vs actual hours matrix)
+- (Overdue Tasks is deliberately **not** a report — a flat record list is a
+ ListView lens, ADR-0021; see `src/views/task.view.ts`)
### Automation Flows (4)
- **Task Reminder** — Daily scheduled reminder for tasks due tomorrow
@@ -80,11 +87,12 @@ examples/app-todo/
- **Task Completion** — Auto-create next occurrence for recurring tasks
- **Quick Add Task** — Screen flow for fast task creation
-### Validations & Workflows
-- Completed date required when status is "completed"
-- Recurrence type required for recurring tasks
-- Auto-set `is_completed`, `completed_date`, `progress_percent` on status change
-- Auto-detect overdue tasks and send urgent notifications
+### Validations & Automation
+- Completed date required when status is "completed" (validation rule)
+- Recurrence type required for recurring tasks (validation rule)
+- Auto-set `is_completed`, `completed_date`, `progress_percent` on status
+ change (data hook)
+- Auto-detect overdue tasks and send urgent notifications (flow)
## 💡 How to Run
@@ -108,43 +116,29 @@ pnpm --filter @objectstack/example-todo build
### Explore the Config
Open `objectstack.config.ts` to see how all pieces connect via `defineStack()`.
-## 🤖 AI Demo (NEW in v5)
+## 🤖 MCP Demo — business actions over the open AI surface
-This example also showcases the v1 AI capabilities. Run the end-to-end demo:
+The open framework exposes AI via **`@objectstack/mcp`** (BYO-AI; the
+in-product chat lives in the cloud distribution — ADR-0063). This example
+ships a real end-to-end proof, **no API key required**:
```bash
-pnpm --filter @objectstack/example-todo test:ai
+pnpm --filter @objectstack/example-todo test:mcp
```
-What it does — **no API key required**:
+What it does (`test/mcp-actions.e2e.ts`):
-1. Boots the Todo stack with `@objectstack/service-ai` and the in-memory `MemoryLLMAdapter`
-2. Registers a `memory` model in the runtime `ModelRegistry` for cost attribution
-3. Calls the built-in `query_data` tool with a natural-language request (`"list my todo_task records"`)
-4. The tool:
- - Retrieves the matching object schema (`SchemaRetriever`)
- - Generates an ObjectQL plan via `ai.generateObject()` (heuristic in memory mode)
- - Executes the plan against the data engine
- - Returns the records
-5. Verifies the call was auto-recorded as a row in the `ai_traces` object with `operation='generate_object'`, latency, status, and model
+1. Boots a self-host composition of ONLY the open framework —
+ `@objectstack/runtime` + ObjectQL + a driver + this seeded app + `@objectstack/mcp`
+2. Drives the real `MCPServerRuntime` over JSON-RPC — the same code path an
+ external MCP client (e.g. Claude) hits
+3. Lists the app's business actions as MCP tools, then executes one via
+ `run_action` → `engine.executeAction` → the registered handler → the
+ real driver, **permission-enforced** end to end
-### Agent Demo (`pnpm test:agent`)
-
-A higher-level demo that exercises the **`data_chat` built-in agent** end-to-end:
-
-```bash
-pnpm --filter @objectstack/example-todo test:agent
-```
-
-1. Sends a natural-language user message to `AIService.chatWithTools()` (the same path the REST endpoint `POST /api/v1/ai/agents/data_chat/chat` uses)
-2. `MemoryLLMAdapter` returns a `query_data` tool call
-3. The tool registry executes it, feeds the result back
-4. The adapter summarises: `"[memory] Found 8 records for ..."`
-5. Verifies a `chat_with_tools` row was persisted in `ai_traces`
-
-This is the canonical "ask in English, get real data" loop. Swap in a real LLM adapter and the loop carries `data_chat` directly to production — no code changes.
-
-To switch to a real LLM, replace `MemoryLLMAdapter` with the auto-detected `VercelLLMAdapter` and set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY` — everything else stays the same.
+Point any MCP client at the running server and the same tools are live —
+that is the community-edition "ask in natural language, act on real data"
+path.
## 📖 Learning Path
@@ -162,4 +156,4 @@ To switch to a real LLM, replace `MemoryLLMAdapter` with the auto-detected `Verc
## 📝 License
-MIT
+Apache-2.0
diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json
index 6c64071ba4..7844c9491b 100644
--- a/examples/app-todo/package.json
+++ b/examples/app-todo/package.json
@@ -17,7 +17,7 @@
"validate": "objectstack validate",
"typecheck": "tsc --noEmit",
"test": "objectstack test",
- "test:mcp": "tsx test/mcp-actions.test.ts"
+ "test:mcp": "tsx test/mcp-actions.e2e.ts"
},
"dependencies": {
"@objectstack/client": "workspace:*",
diff --git a/examples/app-todo/src/dashboards/task.dashboard.ts b/examples/app-todo/src/dashboards/task.dashboard.ts
index 826159db4f..1decd2bf0f 100644
--- a/examples/app-todo/src/dashboards/task.dashboard.ts
+++ b/examples/app-todo/src/dashboards/task.dashboard.ts
@@ -5,14 +5,12 @@ import type { Dashboard } from '@objectstack/spec/ui';
/**
* Task Overview dashboard.
*
- * ADR-0021 Phase 2: every widget is bound to the `task_metrics` dataset
- * (`dataset` + `dimensions` + `values`, measures/dimensions referenced BY NAME)
- * so the numbers stay consistent with every other surface. The legacy inline
- * query (`object` + `categoryField` + `valueField` + `aggregate` + `filter`) is
- * retained side-by-side during the dual-form window — the reconciliation
- * harness asserts the two forms return identical numbers before the inline
- * form is removed (see scripts/analytics-reconcile). The widget `filter`
- * doubles as the dataset-bound `runtimeFilter` (presentation scope).
+ * ADR-0021 single-form: every widget is bound to the `task_metrics` dataset
+ * (`dataset` + `dimensions` + `values`, measures/dimensions referenced BY
+ * NAME) so the numbers stay consistent with every other surface. The
+ * dual-form migration window is over — no widget carries the legacy inline
+ * query form. The widget `filter` doubles as the dataset-bound
+ * `runtimeFilter` (presentation scope).
*/
export const TaskDashboard: Dashboard = {
name: 'task_dashboard',
diff --git a/examples/app-todo/src/reports/task.report.ts b/examples/app-todo/src/reports/task.report.ts
index d91bf53c11..eb97541824 100644
--- a/examples/app-todo/src/reports/task.report.ts
+++ b/examples/app-todo/src/reports/task.report.ts
@@ -2,12 +2,11 @@
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
-// legacy inline query during the dual-form window. The reconciliation harness
-// asserts both forms return identical numbers (scripts/analytics-reconcile).
-// When the inline form is removed, the detail columns move to a click-through
-// drilldown (per the migration decision); `overdue_tasks` becomes a ListView.
+// ADR-0021 single-form: each report binds the `task_metrics` dataset
+// (`dataset` + `rows` + `values`, measures referenced BY NAME). The dual-form
+// migration window is over — the legacy inline query form is gone, and the
+// former `overdue_tasks` report now lives as a ListView lens on the task
+// object (src/views/task.view.ts).
/** Tasks by Status Report */
export const TasksByStatusReport = defineReport({
diff --git a/examples/app-todo/src/translations/translation-completeness.test.ts b/examples/app-todo/src/translations/translation-completeness.test.ts
index c81a7e72ee..b24959fcfe 100644
--- a/examples/app-todo/src/translations/translation-completeness.test.ts
+++ b/examples/app-todo/src/translations/translation-completeness.test.ts
@@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest';
import { Task } from '../objects/task.object';
import { en } from './en';
import { zhCN } from './zh-CN';
+import { jaJP } from './ja-JP';
import type { TranslationData } from '@objectstack/spec/system';
/**
@@ -25,6 +26,9 @@ const selectFields = Object.entries(Task.fields)
describe.each([
['en', en],
['zh-CN', zhCN],
+ // ja-JP is the repo's only Japanese bundle — keep it held to the same
+ // completeness bar as the other declared locales.
+ ['ja-JP', jaJP],
] as [string, TranslationData][])('%s translation completeness', (locale, t) => {
it('should have task object translation', () => {
diff --git a/examples/app-todo/test/mcp-actions.test.ts b/examples/app-todo/test/mcp-actions.e2e.ts
similarity index 100%
rename from examples/app-todo/test/mcp-actions.test.ts
rename to examples/app-todo/test/mcp-actions.e2e.ts
diff --git a/examples/app-todo/test/seed.test.ts b/examples/app-todo/test/seed-check.ts
similarity index 100%
rename from examples/app-todo/test/seed.test.ts
rename to examples/app-todo/test/seed-check.ts
diff --git a/examples/embed-objectql/README.md b/examples/embed-objectql/README.md
index fe9602b1a1..1e8adfbadf 100644
--- a/examples/embed-objectql/README.md
+++ b/examples/embed-objectql/README.md
@@ -5,7 +5,7 @@ plugins, no metadata-management protocol. This is the path for a thin host (e.g.
a gateway, an edge worker, a CLI tool) that wants the query/CRUD engine and the
*same* object definitions as a full ObjectStack backend, without the platform.
-## The point (ADR-0076)
+## The point (ADR-0076 core tiering — Proposed; the `/core` boundary itself has shipped)
Import from the **lean entry**: