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. - The registration belongs to your module and is released with it, including when it reloads in development, so there is nothing to unregister in
destroy().
import { Hook, RegisterHook } from "@antelopejs/interface-dms/hooks";
RegisterHook(Hook.DATABASE_INITIALIZED, async () => {
// setup once the DB schema is ready -- must be safe to run again
});
RegisterHook(Hook.MEMBER_ADDED, async (payload) => {
// payload = { tenantId, userId, isTenantOwner } — the user who joined a tenant
});
RegisterHook(Hook.TENANT_DATA_EXPORT, async (tenantId: string) => {
return { moduleId: "my-module", data: { tenantId } };
});
The first two handlers are insertion points for your module's work. Notification hooks return undefined; keep that explicit return after adding awaited operations. The export example contributes only tenant metadata; replace data with your module's export payload.
RegisterHook(hook, handler, { moduleId }) sets the module id a handler is known by; without it the runtime infers it from the call site. The id namespaces the module's entries in a tenant export archive; it does not change when the registration is released.
The current Hook enum:
| Hook | Fires |
|---|---|
DATABASE_INITIALIZED | on every DMS start, once the database schema is ready; a handler registered afterwards runs at once (see note below) |
TENANT_BEING_PROVISIONED | a tenant is being created (the default tenant on its first start, a SaaS workspace at signup) — write its default data (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 } |
USER_DELETED | a user was deleted (today: by deleting their own account), after the DMS removed its rows — { userId, email, tenantIds, reason }: tenantIds lists the workspaces the user was a member of (left before the deletion), reason is "self" (UserDeletionReason); delete the per-user rows your module keeps. A failing handler is logged, it does not bring the account back |
UnregisterHook(hook, handler) removes a handler, for a module that stops listening while it stays loaded.
- 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.
DATABASE_INITIALIZED works like an already-resolved promise: a module loaded or reloaded after the DMS started still gets its handler run, right away and in the background, and a failure there is logged instead of breaking the module's construct(). The flip side is that a handler can run several times in one process — on every DMS start, and again each time its module reloads — so make it safe to replay against a live database: create what is missing rather than inserting unconditionally, and never send mail or charge from it without recording that it already happened.TENANT_BEING_PROVISIONED fires once per tenant, when it is created: for the default tenant, on the DMS start that creates it and after every DATABASE_INITIALIZED handler has finished (its payload has no userId); for a workspace, from the SaaS signup flow. Write a tenant's default data there, once — not from start() nor DATABASE_INITIALIZED, and never again on a later start: from then on it belongs to the tenant. Handlers run in series and are awaited, and one that throws aborts the provisioning — so a module can also write its own signup data (carried in an opaque extras object the DMS 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. Emitters fire it through ProvisionTenant (@antelopejs/interface-dms/tenant-provisioning), which registers the tenant's schema instance before any handler runs.TENANT_DATA_EXPORT is driven by the dms-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 dms-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/interface-dms/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]); // optional 2nd arg: { tenantId, readScope }
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 DMS 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/interface-dms/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, actorId and tenantId — set actorId to the acting user's id so their own client can skip echoing the change back to them, and tenantId to the workspace the data belongs to (getRequestTenantId(ctx), or the tenant a job runs for): without it the message is global and wakes the page in every workspace. See tenant-scoped messages. 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/interface-dms/realtime";
const unsubscribe = SubscribeMessage("stats:live", (message) => {
// message: { topic, type, payload?, actorId?, tenantId?, ts }, every tenant's
});
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 DMS 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).Extension and hook delivery
An invite extension's fields also appear in the edit form of a pending invitation, prefilled with the stored payload. An admin can change it until the invitee accepts: the edited slice is validated against the same schema, stored in place of the old one, and onAccept later receives it as if it had been typed at invite time. onUpdate(payload, previous, context) is called after a changed payload is stored, for an extension that derives something before acceptance; its failures are logged, not raised. Set editable: false to show the fields read-only there. An invitation that already has a terminal decision refuses the edit with a 409.
Durable invite callbacks receive optional deliveryId, stable across retries and concurrent calls. The runtime delivers and cleans up durable invitations with retries: invalid stored payloads, unavailable captured extensions and callback failures retain the decision for retry. Successful callbacks can run again when a later callback fails. Each extension must make its own effects idempotent, using the identity and its domain's atomic constraints. An ID alone does not deduplicate an email, payment or arbitrary callback.
ExecuteHooks awaits handlers in order and propagates errors; CollectHooks logs and skips errors and is not a strict delivery API. TENANT_DELETED accepts (tenantId, context?: TenantDeletionContext), with context.operationId supplied by the deleting module. Deletion handlers can repeat and must use that identity for non-idempotent effects. Do not record success before an external send: retries are at-least-once unless the provider enforces the same idempotency key, so a lost acknowledgement can duplicate a send.
Automation events such as MEMBER_ADDED and INVITE_CREATED remain best-effort: events can repeat or be lost. An optional deliveryId does not guarantee deduplication or successful execution of a procedure.
Tenant deletion closes producer admission first
Put closeTenantLifecycleAdmission(tenantId) before deletion hooks or provisioning rollback, and stop the destructive flow if it rejects. Write membership and invitation rows only through changeTenantMembers(tenantId, plan) from @antelopejs/interface-dms/tenant-members, and await their effects. The tenant lifecycle reference defines the signatures, failure and retry semantics, and the safety rows a deletion must preserve.
Automation nodes
When @antelopejs/dms-automation is loaded, the DMS registers its own nodes in the procedure builder: triggers for its events (dms.member-added, dms.member-removed, dms.invite-created, dms.user-registered, dms.record-created, dms.record-updated, dms.record-deleted) and the actions below. Every input of an action is a port: set it in the inspector or wire it from a trigger's output or a data node.
dms.send-notification
Sends an in-app notification under the AutomationSubject, built as the Notification() builder would build it.
| Input | Type | Notes |
|---|---|---|
title | string, required | Literal text, or a $ translation key resolved in each recipient's language |
description | string | Same; empty by default |
params | object | Interpolation params of both texts. Strings and numbers are kept, booleans read as their text, nested values are dropped with a warning |
tone | neutral primary success warning error | Icon well colour; unset reads primary while unread |
icon, linkTo | string | i-ph-bell by default; the page the notification opens |
userId | string | One recipient, e.g. a trigger's userId |
userIds | string | Recipients; combined with userId |
roleIds | string | Every member holding one of the roles |
tenantId | string | Restricts roleIds to that tenant's members (toRoles(roleIds, { tenantId })). Empty, the default tenant |
idempotencyKey | string | Stable event key, e.g. a trigger's deliveryId: a replay of the same event reaches each recipient once |
At least one of userId, userIds or roleIds is required. A procedure welcoming each new member, with its texts in the project's locales, wires a Member added trigger (dms.member-added) to a Send notification node set as follows:
| Port | Value |
|---|---|
userId | wired from the trigger's userId |
title | $my_app.automation.welcome.title |
description | $my_app.automation.welcome.body |
params | { "team": "Support" }, set in the inspector or wired from a node that outputs an object |
tone | success |
linkTo | /getting-started |
idempotencyKey | wired from the trigger's deliveryId |
The keys live in the project's locale files like any other (Localization):
{
"my_app": {
"automation": {
"welcome": {
"title": "Welcome aboard",
"body": "The {team} team is here to help."
}
}
}
}
Notifications created before these inputs existed, with a literal title and description, keep working unchanged.
dms.send-email
Sends an email through the project's interface-email provider (HTML / email rendering).
| Input | Type | Notes |
|---|---|---|
to | string, required | Recipient address |
subject | string, required | Subject line, sent as given |
html | string | The body, written out |
template | string | Name of a template registered with RegisterHtmlTemplate, rendered instead of html |
props | object | Props the template is rendered with |
language | string | Language the template is rendered in; the recipient's own when the address belongs to a user |
One of html or template is required; a template no module registered fails the run instead of sending an empty email.
dms.invite-user
Invites an address to a tenant, as the members page does: an existing user becomes a member directly, anyone else receives an invitation (and its email, unless sendEmail is false). An address that is already a member is left unchanged.
| Input | Type | Notes |
|---|---|---|
email | string, required | Address to invite |
tenantId | string | Tenant to invite into; see below |
roleIds | string | Roles of the new member |
asTenantOwner | boolean | false by default |
sendEmail | boolean | true by default |
language | string | Language of the invitation email and of the account it leads to; en when empty |
firstname, lastname | string | The invitee's names, stored with the invitation and greeted by its email |
inviterName | string | Who the invitation email says it comes from |
The tenantId input
An action has no tenant of its own: the execution context carries none, whoever fires the procedure. A node acting on tenant data therefore takes a tenantId input, which a procedure wires from its trigger: the member, invite and registration triggers output the tenantId of their event. Left empty, a tenant-scoped node acts on the default tenant (DEFAULT_TENANT_ID, "default", from @antelopejs/interface-dms/constants), which is the only one in a single-tenant project: dms.invite-user invites there and dms.send-notification matches its roleIds there. In a multi-tenant project, wire the trigger's tenantId into every node that takes one.
Test runs
The three actions declare dryRun (@antelopejs/interface-dms-automation 0.4): a Test run in the builder checks their input as a real run would, then sends and creates nothing.
| Action | In a test run | Output |
|---|---|---|
dms.send-email | Refuses a missing body or an unregistered template; renders and sends nothing | { success: true } |
dms.send-notification | Refuses a missing recipient, an unknown tone or bad params; notifies nobody | { ok: true } |
dms.invite-user | Looks the address up in the tenant; creates no user, member or invite and sends no email | already-member or added with the user's id, else invited with no inviteId |
Texts in the builder
The builder shows a node's name and description, and each schema property's title and description, through processI18n, so each can be a $ key instead of literal text. The DMS nodes carry keys only, under dms.automation.* of the dms-core locales (en and fr): triggers.<trigger> and actions.<action> hold a node's name, description and per-node field descriptions, and fields.<property> the field labels its nodes share. A module registering nodes of its own does the same under its namespace:
export const myAction: ActionType = {
id: "my_app.archive",
name: "$my_app.automation.archive.name",
description: "$my_app.automation.archive.description",
inputSchema: {
type: "object",
properties: {
reason: { type: "string", title: "$my_app.automation.fields.reason" },
},
},
// ...
};
An untitled property is named by its key in words, and the canvas labels a port by its key whatever its title.
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/interface-dms/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/interface-dms/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/interface-dms/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/interface-dms/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/interface-dms/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 Inertia/Vue 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.