Extending Objects

Add or change HotCRM objects, fields, hooks, flows, and sharing metadata.

Extending Objects

HotCRM objects live in the objects/ directory of the package that owns them — src/sales/, src/service/, src/revenue/ or src/marketing/ — and each package registers its own from that directory's index.ts. src/ has no top-level objects/ directory: a directory under src/ is a package (ADR-0130). The warranty below goes in the revenue package, beside crm_product and crm_contract, the objects a warranty sits next to; put yours wherever its own domain lives.

Add an object

// src/revenue/objects/warranty.object.ts
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Warranty = ObjectSchema.create({
  name: 'crm_warranty',
  label: 'Warranty',
  pluralLabel: 'Warranties',
  icon: 'shield',
  fields: {
    warranty_number: Field.autonumber({ label: 'Warranty Number', format: 'WR-{000000}' }),
    crm_account: Field.lookup('crm_account', { label: 'Account', required: true }),
    crm_product: Field.lookup('crm_product', { label: 'Product' }),
    status: Field.select({
      label: 'Status',
      defaultValue: 'active',
      options: [
        { label: 'Active', value: 'active', default: true },
        { label: 'Expired', value: 'expired' },
      ],
    }),
  },
  enable: {
    apiEnabled: true,
    searchable: true,
    trackHistory: true,
  },
});

Then export it:

// src/revenue/objects/index.ts
export { Warranty } from './warranty.object.js';

Add lifecycle logic

A *.hook.ts sits beside the *.object.ts it names, in the same package, and is registered from that package's own objects/hooks.ts — src/revenue/objects/hooks.ts here — and from there into its package's hook list: appHooks in objectstack.composition.ts for the app package (sales, revenue and marketing), serviceHooks in src/service/index.ts for the service module. A hook that reaches neither is silently ignored: the build stays green and the handler never runs.

// src/revenue/objects/warranty.hook.ts
import type { Hook } from '@objectstack/spec/data';

const warrantyHook: Hook = {
  name: 'crm_warranty_hook',
  object: 'crm_warranty',
  events: ['beforeInsert', 'beforeUpdate'],
  handler: async (ctx) => {
    const doc = ctx.input.doc as Record<string, unknown>;
    if (doc.status === 'expired' && !doc.end_date) {
      throw new Error('Expired warranties need an end date.');
    }
  },
};

export default warrantyHook;

Add automation

Multi-step automation goes in the flows/ directory of the package that owns its trigger object — src/sales/flows/, src/service/flows/, src/revenue/flows/ or src/marketing/flows/. A warranty alert fires on crm_warranty, so it belongs to revenue, beside the object above.

// src/revenue/flows/warranty-alert.flow.ts
import type * as Automation from '@objectstack/spec/automation';

type Flow = Automation.Flow;

export const WarrantyAlertFlow: Flow = {
  name: 'warranty_alert',
  label: 'Warranty Alert',
  type: 'record_change',
  status: 'active',
  variables: [],
  nodes: [
    { id: 'start', type: 'start', label: 'Warranty updated', config: { objectName: 'crm_warranty', triggerType: 'record-after-update' } },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [{ id: 'e1', source: 'start', target: 'end', type: 'default' }],
};

Registration is two steps, and skipping the second one loses the flow in silence. Re-export it from that package's barrel, then add it to its package's flow list — appFlows in objectstack.composition.ts for sales, revenue and marketing (the app package), serviceFlows in src/service/index.ts for the service module. That list, not the barrel, is what the package's own defineStack({ flows }) receives:

// src/revenue/flows/index.ts — the package barrel: a re-export, nothing else
export { WarrantyAlertFlow } from './warranty-alert.flow.js';
// objectstack.composition.ts — the app package's list, in the order flows are registered
import { WarrantyAlertFlow } from './src/revenue/flows/index.js';

export const appFlows = [
  // …existing flows
  WarrantyAlertFlow,
];

The barrel on its own is not enough. appFlows is an explicit ordered list because the registration order interleaves three directories, so no per-directory barrel can carry it. A flow that reaches the barrel but never its package's list is registered by nothing, and pnpm validate still exits 0 and names it nowhere — pnpm test is what fails, naming any flow a package exports that neither list carries.

Verify

pnpm validate
pnpm typecheck
pnpm test

On this page