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