Backend services
Beyond pages and auth, the DMS exposes a handful of cross-cutting services a module can use.
Lifecycle hooks
Hooks let a module react to platform events, and contribute to some of them. Register a handler with RegisterHook(hook, handler).
- The handler's signature is checked against the hook at the TypeScript level only —
HookHandler<H>types the args and result from the hook — so a mismatch is a compile error, not a runtime one. - At runtime
RegisterHooksimply pushes your callback onto the hook's list; it does no validation.
import { Hook, RegisterHook } from "@antelopejs-private/cms/interfaces/cms/hooks";
RegisterHook(Hook.DATABASE_INITIALIZED, async () => {
// one-time setup once the DB schema is ready
});
RegisterHook(Hook.MEMBER_ADDED, async (payload) => {
// payload = { tenantId, userId, isTenantOwner } — the user who joined a tenant
});
RegisterHook(Hook.TENANT_DATA_EXPORT, async (tenantId: string) => {
// contribute this module's data to a tenant export
return { moduleId: "my-module", data: /* … */ };
});
RegisterHook(hook, handler, { moduleId }) names the module a registration belongs to; without it the runtime infers it from the call site. The id namespaces the module's entries in a tenant export archive.
The current Hook enum:
| Hook | Fires |
|---|---|
DATABASE_INITIALIZED | once, when the database schema is ready |
TENANT_BEING_PROVISIONED | a SaaS signup is provisioning a workspace — enrich it atomically (see note below) |
TENANT_DELETED | a tenant was deleted (clean up per-tenant data) |
TENANT_DATA_EXPORT | a tenant requests an export of their data (SaaS — see note below) |
INVITE_BEING_CREATED / INVITE_CREATED / INVITE_DELETED | invite lifecycle |
MEMBER_BEING_ADDED / MEMBER_ADDED / MEMBER_REMOVED | tenant-membership lifecycle |
USER_REGISTERED | a user completed registration — { tenantId, userId, email, name } |
UnregisterHook(hook, handler) removes a handler.
- The
*_BEING_*variants fire before the action commits — use them to veto or prepare — while the past-tense variants fire after. - Payload shapes differ per hook and are type-checked against the hook:
MEMBER_ADDEDandMEMBER_BEING_ADDEDcarry an object{ tenantId, userId, isTenantOwner }for the single member added, whileMEMBER_REMOVEDcarries{ tenantId, userIds }— auserIdsarray, since a removal can cover several members at once. - Let the handler's inferred parameter type guide you rather than assuming a uniform shape.
TENANT_BEING_PROVISIONED is fired by the SaaS signup flow that provisions a workspace, not by the CMS itself. Handlers run in series and are awaited, and one that throws aborts the provisioning — so a module can write its own signup data (acquisition source, team size, referral code, carried in an opaque extras object the CMS never reads) knowing a workspace will never be provisioned without it. The converse does not hold: there is no shared transaction, so rows a handler already committed survive a later failure — key them on tenantId and keep the write idempotent. Without such a module, nothing invokes it.TENANT_DATA_EXPORT is driven by the cms-saas module (SaaS mode): when a tenant requests an export of their data, every registered handler contributes to the archive being assembled. Register a handler to include your module's tenant data in that export. Without cms-saas, nothing invokes this hook. See Tenant data export archive for the full contract, including heavy artifacts.Notifications
Build a notification with the fluent Notification() builder; .build() returns a SendableNotification you dispatch with its own to* / broadcast methods.
import { Notification, GeneralSubject } from "@antelopejs-private/cms/interfaces/cms-notifications";
const n = Notification()
.icon("i-ph-bell")
.title("$my_app.notifications.welcome.title") // $-key or literal
.description("$my_app.notifications.welcome.body")
.params({ teamName: "Acme" }) // i18n interpolation params for title/description
.subject(GeneralSubject)
.linkTo("/dashboard")
.build(); // → SendableNotification
await n.toUser(userId);
await n.toUsers([a, b]); // optional 2nd arg: { readScope }
await n.toRoles([roleId]);
await n.broadcast();
Notifications are organized by category → subject, and users can toggle the subjects they receive from their notification settings (a send to a user who disabled the subject is silently skipped). The built-in subjects are GeneralSubject, SecuritySubject, AccountSubject, CollaborationSubject, and AutomationSubject (under SystemCategory).
Register your own with the public NotificationCategory(id, opts) / NotificationSubject(id, opts) functions, then pass your subject to .subject(...).
A category or subject can also set togglePermission: "forbidden" to make it non-optional for users.
Two delivery details worth knowing:
readScope(ontoUsers/toRoles/broadcast) —"individual"(default) gives each recipient their own read state;"shared"groups the copies, so one recipient reading it marks it read for everyone (useful for "someone should handle this" alerts).- Delivery is live: each stored notification is also published on the recipient's realtime stream, so badges and previews update without a refresh (Realtime & notifications).
Module update notifications (dev mode)
When the project runs in development mode (ajs project dev), the CMS watches the npm registry for updates to the project's package-sourced Antelope modules: at startup and then hourly, it compares each module's configured version range against the latest published version and broadcasts a notification under the dev-only updates subject (SystemCategory) listing the modules whose latest version falls outside their configured range — in-range updates install themselves on the next boot, so only updates that need an antelope.config.ts edit (or ajs project modules update) are surfaced. The same set of outdated modules is only notified once, registry failures are silent, and nothing runs in production.
Realtime
Backend code pushes live updates to the dashboard by publishing to named topics. Topics are bound to a page — subscribing to the page activates them — and reach the browser over each user's single SSE stream.
Register a topic for a page, then publish when data changes:
import { RegisterPageTopic, PublishMessage } from "@antelopejs-private/cms/interfaces/cms/realtime";
RegisterPageTopic("stats", "stats:live"); // bind topic to page "stats"
// later, when data changes:
await PublishMessage("stats:live", "stats.update", { payload: { total } });
PublishMessage(topic, type, options?) takes an optional payload and actorId — set actorId to the acting user's id so their own client can skip echoing the change back to them. On the frontend, components subscribe with useRealtimeTopic or the page-scoped realtime API — see Realtime & notifications for the full consuming API.
Backend code can also read the stream back. SubscribeMessage(topic, handler) delivers every message published on topic — whichever instance published it, since the subscription rides the same broker as PublishMessage:
import { SubscribeMessage } from "@antelopejs-private/cms/interfaces/cms/realtime";
const unsubscribe = SubscribeMessage("stats:live", (message) => {
// message: { topic, type, payload?, actorId?, ts }
});
This is the composition point for a module serving realtime channels of its own — an SSE endpoint it exposes itself: subscribe here and let the CMS broker carry the messages across instances instead of running a second broker for the same job. Handlers run on the delivering instance's event loop and must not throw. Delivery is fire-and-forget: there is no replay, and a message published while nobody was subscribed is gone. The returned function ends the subscription (calling it twice is a no-op); subscriptions are removed automatically when the registering module unloads.
Some builders wire this for you: a chart with a realtimeTopic registers the topic on its page and refetches on publish (Charts & widgets), and a TableView publishes row mutations (created/updated/deleted) and edit presence on its own topics unless you opt out with realtime: false (Tables).
realtime.driver config; the in-memory driver does not cross instances (Configuration).Distributed job locks
When the DMS runs as multiple instances, a cron job or one-off task that must run once across the fleet should hold a lock. runWithLock(lockKey, ttlMs, fn) tries to acquire the lock; when it succeeds it runs fn and releases the lock afterwards:
import { runWithLock, WORKER_ID } from "@antelopejs-private/cms/interfaces/job-locks";
await runWithLock("nightly-prune", 30_000, async () => {
// runs on exactly one instance; others that fail to acquire skip the work
});
runWithLock returns a boolean: true if it acquired the lock and ran fn, false if another worker held it and the work was skipped. The lock releases in a finally, so it frees whether fn resolves or throws. WORKER_ID is a per-process UUID (new on every restart) that identifies the lock holder. Use a stable, descriptive lockKey per job and a ttlMs comfortably longer than the work so the lock isn't lost mid-run.
The cron + lock recipe
A cron job in a multi-instance deployment fires on every instance at the schedule time — node-cron has no cross-instance awareness. Wrap the work in runWithLock so exactly one instance runs it:
import cron, { type ScheduledTask } from "node-cron";
import { Logging } from "@antelopejs/interface-core/logging";
import { runWithLock } from "@antelopejs-private/cms/interfaces/job-locks";
const CRON_NAME = "recompute-segments";
const CRON_SCHEDULE = "*/15 * * * *";
const LOCK_TTL_MS = 20 * 60 * 1000; // comfortably longer than the work
export function scheduleRecomputeSegments(): ScheduledTask {
return cron.schedule(CRON_SCHEDULE, () => {
void runWithLock(`cron:${CRON_NAME}`, LOCK_TTL_MS, recomputeAllSegments).catch(
(error: unknown) => Logging.Error(`[cron:${CRON_NAME}] failed`, error),
);
});
}
The void + .catch is deliberate: node-cron's callback is synchronous, so the code fires the promise and logs its failure. Use a cron:<name> lock key and a TTL sized to the job. The DMS's own housekeeping crons (invite cleanup, export sweep — see Built-in dashboard) follow this shape.
ttlMs to exceed the work's worst-case duration. If the lock expires mid-run, a second instance can start a duplicate. After a crash, the holder's lock auto-releases only via TTL (the new process has a fresh WORKER_ID), so an over-long TTL blocks the job until it expires.The export-job engine
The asynchronous export behind a TableView's Export button (Tables) is a reusable engine. A module can run its own long exports through it and get the same job rows, progress tracking, storage upload, ownership checks, and stale-artifact sweeping:
import { runExportJob } from "@antelopejs-private/cms/interfaces/cms-base/export-jobs";
// start: creates a job row, processes in the background, returns the ticket
const ticket = await runExportJob({
ctx,
user,
scope: "acme-report", // namespaces the job for the access checks
filename: "monthly-report", // base name — the engine adds the timestamp and extension when serving
extension: "csv",
contentType: "text/csv",
generate: async ({ localPath, reportProgress, signal }) => {
// write the artifact to localPath, calling reportProgress as you go
// (clamped to 0–99; the engine sets 100 on completion);
// signal aborts when the job exceeds maxExecutionTimeMs (default 10 min)
},
});
// ticket → { jobId, extension, filename }
generate receives the jobId and tenantId, a localPath, the job context, a reportProgress callback, and an abort signal — it writes the artifact to localPath, and the engine uploads it to file storage and marks the job completed (or failed, if generate throws or times out). Expose three routes — start, getExportJobStatus (poll), downloadExportJob (stream, then clean up) — and point either an exportJob action target or the frontend useExportJob runner at them.
Jobs are owned by their requesting user and tenant — the status and download loaders enforce it (plus an optional isOwnedBy ACL) — and artifacts older than 24 hours are swept by a built-in cron. The exporter registry ships CSV; the delivery registry ships download and email.
By default the engine drops a job and its stored artifact once it has been downloaded. Pass retainUntilExpiry: true to keep both until the sweep expires them — required whenever a link is delivered or a history is listed, since either outlives the first download:
import { listExportJobs } from "@antelopejs-private/cms/interfaces/cms-base/export-jobs";
const jobs = await listExportJobs(ctx, user, { scope: "acme-report" });
// jobs → [{ jobId, scope, status, progress, filename, createdAt, expiresAt, result, … }]
generate may return an ExportJobResultSummary ({ partial, failures }). It is stored on the job and returned by getExportJobStatus and listExportJobs, so an incomplete export is never presented as a complete one.
Email delivery (delivery: "email") sends the requesting user the EmailExportReady template once the job completes: a link built from deliveryPath — a client-side path, required by this delivery — plus the job id, the expiry, and the failed parts when the export is partial. Delivery failures are logged and never fail an otherwise completed job.
Tenant data export archive
A TENANT_DATA_EXPORT contributor receives the archive being assembled and an abort signal alongside the tenant id:
import {
Hook,
RegisterTenantDataExportContributor,
} from "@antelopejs-private/cms/interfaces/cms/hooks";
RegisterTenantDataExportContributor(
"my-module",
async (tenantId, archive, signal) => {
await archive.addJson("config.json", await readConfig(tenantId));
await archive.addFile("report.pdf", localReportPath);
await archive.addStream("volumes/data.tar", await openRemoteDump(signal));
return { moduleId: "my-module", data: { summary } }; // optional
},
);
- Paths are namespaced with
modules/<moduleId>/by the implementation — contributors cannot collide, and colliding module ids are disambiguated. - Await every
add*call. Contributors run serially and each call resolves only once the archiver has consumed the entry: that is what propagates back-pressure to the producer and keeps memory flat whatever the volume.addStreamis the path for a dump that must never be held in memory. - A returned contribution stays valid, written to
modules/<moduleId>.json— a handler that ignores the two new arguments keeps working unchanged. - A failing contributor does not fail the export. Its error is recorded in
manifest.jsonand the archive is delivered as partial; the job's result summary carries the failure so a history table and the delivered e-mail can say so. An aborted signal, on the other hand, cancels the whole export.
The resulting ZIP holds manifest.json (tenant id, timestamp, partial, and one report per contributor), one modules/<moduleId>.json per JSON contribution, and the heavy entries each contributor streamed in.
The module owning the export request runs it as a job:
import { startTenantExportJob } from "@antelopejs-private/cms/interfaces/cms-base";
const ticket = await startTenantExportJob({
ctx,
user,
scope: "tenant-data-export",
filename: `workspace-export-${tenantId}`,
delivery: "email",
deliveryPath: "/settings/tenant/data-export",
});
startTenantExportJob runs buildTenantExportArchive through the export-job engine with the ZIP content type and retainUntilExpiry. Call buildTenantExportArchive({ tenantId, localPath, signal, reportProgress }) directly only to assemble an archive outside a job.
HTML / email rendering
Register named HTML templates and render them on the backend (used for emails and static HTML):
import {
RegisterHtmlTemplate,
GenerateHtml,
GetHtmlTemplate,
} from "@antelopejs-private/cms/interfaces/cms-html-render";
// Register a named template; the type param types its props and you get a typed ref back.
const welcomeEmail = RegisterHtmlTemplate<{ userName: string }>("welcome-email");
// Render it with props (pass the ref, not the name).
const html = await GenerateHtml(welcomeEmail, { userName: "Jane" });
GetHtmlTemplate(name) / GetAllHtmlTemplates() read the registry. Rendering happens on the frontend: GenerateHtml POSTs to the configured htmlRender.renderEndpoint with a short-lived service token (Configuration), where the Nuxt app renders the named email component.
Sending the rendered HTML is a separate concern: the DMS dispatches mail through the standard AntelopeJS interface-email Send function, so your antelope.config.ts must load an implementing module — typically @antelopejs/nodemailer with your SMTP settings.
Actions & reactivity
The behavioral side of the component DSL — permissioned actions, the events components emit, action targets, and the watch-actions that make components react to each other from backend configuration.
Localization
How the DMS resolves $-prefixed i18n keys, how your layer ships locales, and the runtime translation editor.