[data-reveal]{opacity:1!important;transform:none!important}
Hooks
Interfaces

Hooks

A hook is a named platform event with a typed argument list and a typed result.

Overview

A hook is a named platform event with a typed argument list and a typed result. Modules register handlers with RegisterHook, and the code that owns the event dispatches it: ExecuteHooks runs every handler and discards what they return, CollectHooks gathers their return values, and GetRegisteredHooks hands back the handlers themselves for a caller that must invoke each one differently. HookSignatures ties each Hook member to its arguments and result, so the compiler checks a handler against the hook it registers for.

Everything on this page lives in @antelopejs/interface-dms/hooks.

Register a Handler

import { Hook, RegisterHook } from "@antelopejs/interface-dms/hooks";

export function registerBillingHooks(): void {
  RegisterHook(Hook.MEMBER_ADDED, async (payload) => {
    // payload: { tenantId, userId, isTenantOwner }
    await seedBillingProfile(payload.tenantId, payload.userId);
  });

  RegisterHook(Hook.TENANT_DELETED, async (tenantId) => {
    await dropBillingProfiles(tenantId);
  });
}

RegisterHook<H>(name: H, callback: HookHandler<H>, options?: HookRegistrationOptions) appends the callback to the hook's list. The check is a TypeScript one: HookHandler<H> derives the parameter and return types from HookSignatures[H], so a mismatched handler is a compile error. At runtime the call performs no validation.

A hook whose result is undefined is an event: its dispatchers discard what the handlers return, so such a handler may return anything — nothing, the result of the write it awaited, or a promise of it. HookHandlerResult<H> is that rule: unknown for these hooks, the hook's result (or a promise of it) for a hook that has one, such as Hook.TENANT_DATA_EXPORT.

A registration belongs to the module that makes it, and goes away with it. When the module is destroyed — it stops for good, or it reloads in development — its handlers are released, and a reloaded module registers its own again from its next construct(). A module therefore has nothing to unregister on its way out.

Every registration records the module it came from, resolved from the registering call site. HookRegistrationOptions.moduleId overrides that resolution:

interface HookRegistrationOptions {
  moduleId?: string;
}

The id only matters to a dispatcher that treats handlers per module rather than as one flat list — the tenant data export namespaces each contributor's archive entries by it. Set it explicitly whenever the registration happens somewhere the runtime would attribute to another module, such as a shared helper called during your module's construction. It does not change when the registration is released: that follows the module that made it.

UnregisterHook<H>(name: H, callback: HookHandler<H>) removes a previously registered callback by identity, for a module that stops listening while it stays loaded. Keep a reference to the function you registered:

import {
  Hook,
  type HookHandler,
  RegisterHook,
  UnregisterHook,
} from "@antelopejs/interface-dms/hooks";

const onMemberAdded: HookHandler<Hook.MEMBER_ADDED> = async (payload) => {
  await seedBillingProfile(payload.tenantId, payload.userId);
};

RegisterHook(Hook.MEMBER_ADDED, onMemberAdded);
UnregisterHook(Hook.MEMBER_ADDED, onMemberAdded);

HookCallback is the untyped shape the registry stores internally, (...args: any[]) => Promise<any> | any. Register through HookHandler<H> instead so the payload keeps its type.

The Hook Enum

MemberValueArgumentsResult
Hook.DATABASE_INITIALIZED"database:initialized"noneundefined — fires on every DMS start; a handler registered later runs at once (see Initialise Against the Database)
Hook.TENANT_BEING_PROVISIONED"tenant:being-provisioned"TenantProvisioningHookPayloadundefined
Hook.TENANT_DELETED"tenant:deleted"tenantId: stringundefined
Hook.TENANT_DATA_EXPORT"tenant:data-export"tenantId: string, archive: TenantExportArchive, signal: AbortSignalTenantDataExportContribution | void
Hook.INVITE_BEING_CREATED"invite:being-created"InviteHookPayloadundefined
Hook.INVITE_CREATED"invite:created"InviteCreatedHookPayloadundefined
Hook.INVITE_DELETED"invite:deleted"InviteDeletedHookPayloadundefined
Hook.MEMBER_BEING_ADDED"member:being-added"MemberHookPayloadundefined
Hook.MEMBER_ADDED"member:added"MemberHookPayloadundefined
Hook.MEMBER_REMOVED"member:removed"MemberRemovedHookPayloadundefined
Hook.USER_REGISTERED"user:registered"UserRegisteredHookPayloadundefined
Hook.USER_DELETED"user:deleted"UserDeletedHookPayloadundefined — fires after every user deletion the DMS performs (today: a user deleting their own account), once its rows are gone; a failing handler is logged and does not bring the account back

The *_BEING_* members fire before the write they precede; the past-tense members fire after it. TENANT_BEING_PROVISIONED has no past-tense counterpart — see Enrich a Workspace Being Provisioned.

Payload Interfaces

interface TenantProvisioningHookPayload {
  tenantId: string;
  userId: string;
  extras: Record<string, unknown>;
}

interface InviteHookPayload {
  tenantId: string;
  email: string;
  asTenantOwner: boolean;
  roleIds: string[];
}

interface InviteCreatedHookPayload extends InviteHookPayload {
  inviteId: string;
  token: string;
}

interface InviteDeletedHookPayload {
  tenantId: string;
  inviteId: string;
  email: string;
}

interface MemberHookPayload {
  tenantId: string;
  userId: string;
  isTenantOwner: boolean;
}

interface MemberRemovedHookPayload {
  tenantId: string;
  userIds: string[];
}

interface UserRegisteredHookPayload {
  tenantId: string;
  userId: string;
  email: string;
  name: string;
}

type UserDeletionReason = "self";

interface UserDeletedHookPayload {
  userId: string;
  email: string;
  tenantIds: string[];
  reason: UserDeletionReason;
}

The shapes are not uniform, and the difference between the addition and removal payloads is deliberate: MEMBER_BEING_ADDED and MEMBER_ADDED describe one member with userId, while MEMBER_REMOVED carries userIds, because a removal can cover several members at once. Let the handler's inferred parameter type guide you.

UserDeletedHookPayload.tenantIds lists the workspaces the user was a member of, all left before the deletion; delete the per-user rows your module keeps in them. reason is "self" today, the user deleting their own account.

InviteCreatedHookPayload carries the invite token. It is a signup credential: keep it inside the handler rather than forwarding it to a wider surface.

TenantProvisioningHookPayload.extras is opaque: the DMS reads no key of it, validates nothing and stores nothing. See Enrich a Workspace Being Provisioned.

TENANT_DATA_EXPORT is the one hook whose handler produces something rather than only reacting. Its argument and return types live in @antelopejs/interface-dms/tenant-export and are re-exported from this module, so either import path works. They are covered in Contribute to a Tenant Data Export.

Dispatch: ExecuteHooks vs CollectHooks

Both take the hook and its arguments and await each handler in registration order. They differ in what they do with the outcome.

async function ExecuteHooks<H extends Hook>(
  name: H,
  ...args: HookSignatures[H]["args"]
): Promise<void>;

async function CollectHooks<H extends Hook>(
  name: H,
  ...args: HookSignatures[H]["args"]
): Promise<HookSignatures[H]["result"][]>;
ExecuteHooksCollectHooks
Return valuePromise<void> — handler results are discardedPromise<Result[]> — one entry per handler that returned
A handler that throwsThe rejection propagates to the caller and the remaining handlers do not runThe error is logged and the handler contributes nothing; the remaining handlers still run
Use forNotifying that something happenedAsking every handler to contribute something

Use ExecuteHooks for the notification hooks — a failing handler should be able to fail the operation that fired it. Use CollectHooks for contribution hooks, where one broken module must not sink the whole collection.

import {
  CollectHooks,
  ExecuteHooks,
  Hook,
} from "@antelopejs/interface-dms/hooks";

// notify: every handler runs, nothing is returned
await ExecuteHooks(Hook.TENANT_DELETED, tenantId);

// collect: gather what every handler returned, skipping the ones that threw
const payloads = await CollectHooks(Hook.MEMBER_ADDED, {
  tenantId,
  userId,
  isTenantOwner: false,
});

A caller that must not be broken by a subscriber wraps ExecuteHooks in its own try/catch — the DMS does this for USER_REGISTERED, where a failing subscriber would otherwise strand a user whose account was already created.

Dispatch Handler by Handler: GetRegisteredHooks

Both dispatchers pass the same arguments to every handler. When each handler needs arguments of its own, take the list instead:

function GetRegisteredHooks<H extends Hook>(name: H): RegisteredHook<H>[];

interface RegisteredHook<H extends Hook> {
  moduleId?: string;
  handler: HookHandler<H>;
}

moduleId is what the registration recorded — the HookRegistrationOptions.moduleId that was passed, or the module the runtime attributed the call to — and is undefined when neither resolved. Handling the failures is the caller's job here: nothing wraps the handler for you.

This is how the tenant data export runs. Each contributor needs an archive namespaced under its own module id, which no shared argument list can express.

Initialise Against the Database

DATABASE_INITIALIZED behaves like an already-resolved promise rather than a one-off event. The DMS fires it from its start(), once the database schemas are registered, and the hook then stays settled until the DMS stops:

  • A handler registered before that runs when the DMS starts, in series with the others, as ExecuteHooks runs them. One that throws fails the DMS start.
  • A handler registered while it is settled — by a module loaded after the DMS started, or one reloaded in development — runs at once, in the background. The registration does not wait for it, and a failure is logged with the hook and module name rather than thrown into the registering module's construct() or start().
  • A handler registered while the DMS is stopped waits for its next start.

A handler therefore runs once per DMS start and once per registration made while the DMS runs, which adds up to several times over the life of a process: the DMS itself reloading replays every handler, and a module that reloads registers — and so runs — its own again. Write it to be safe to replay against a live database: create what is missing, never insert unconditionally, and never send a message or charge from it without a record that it already happened.

The settled state is what SettleHook and UnsettleHook manage. Only the hooks typed StickyHook accept them, and only DATABASE_INITIALIZED is one; the DMS is the only caller.

Enrich a Workspace Being Provisioned

A SaaS signup provisions a workspace, and the product running it usually collects more at that moment than the DMS models — an acquisition source, a team size, a referral code. TENANT_BEING_PROVISIONED is where a module writes those to its own tables, while the provisioning is still undone.

import {
  Hook,
  RegisterHook,
} from "@antelopejs/interface-dms/hooks";

RegisterHook(Hook.TENANT_BEING_PROVISIONED, async ({ tenantId, userId, extras }) => {
  await recordAcquisition(tenantId, userId, {
    source: extras.acquisitionSource,
    referral: extras.referralCode,
  });
});

The hook is dispatched with ExecuteHooks, so handlers run in series, each awaited, and a handler that throws aborts the provisioning: the rejection reaches the emitter, later handlers do not run, and the emitter unwinds the workspace it was creating. A workspace therefore never ends up provisioned while the enrichment a consumer needed failed to be written — that direction is the guarantee, and the reason to fire before the commit rather than after it.

The other direction is not guaranteed, and no hook could provide it: there is no shared transaction to enlist in. A handler that already returned has committed its own rows, and neither a later handler throwing nor the emitter failing afterwards takes them back. Two habits make that survivable:

  • Key the write on tenantId, so it can be cleaned up along with the workspace — subscribing to TENANT_DELETED covers the ordinary deletion path.
  • Make it idempotent, so a retried signup does not double it.

The flip side is the usual one for a *_BEING_* hook: a handler that throws for its own reasons fails somebody's signup, so keep it to what genuinely must not be lost.

extras is opaque by design. The DMS reads no key of it, validates nothing, and stores nothing — it carries the object from the caller of the provisioning flow to the handlers, and each handler writes what it recognises. The shape is a contract between that caller and that handler, one the DMS is deliberately not a party to, so a product can start collecting a new field without waiting on a DMS release.

No DMS code fires this hook: provisioning belongs to the signup flow of a SaaS module such as dms-saas. The DMS declares the contract; the emitter honours it. Without such a module, nothing invokes it.

Contribute to a Tenant Data Export

TENANT_DATA_EXPORT asks every module for its slice of one tenant's data, which is assembled into a single ZIP archive. A handler receives the tenant id, an archive to write into, and an AbortSignal. It has two ways to contribute, and may use both:

  • Return a TenantDataExportContribution — its data is serialized to modules/<moduleId>.json.
  • Write entries into the archive — for anything too large to hold in memory, or that is not JSON.

Register with RegisterTenantDataExportContributor rather than a bare RegisterHook. It names the module id explicitly, and that id is what namespaces the contributor's archive entries:

import { RegisterTenantDataExportContributor } from "@antelopejs/interface-dms/hooks";

RegisterTenantDataExportContributor(
  "@acme/dms-billing",
  async (tenantId, archive, signal) => {
    await archive.addStream(
      "invoices.ndjson",
      streamInvoices(tenantId, signal),
    );
    return {
      moduleId: "@acme/dms-billing",
      data: await collectBillingSettings(tenantId),
    };
  },
);

The helper's signature is shown separately for reference:

import type { Hook, HookHandler } from "@antelopejs/interface-dms/hooks";

declare function RegisterTenantDataExportContributor(
  moduleId: string,
  handler: HookHandler<Hook.TENANT_DATA_EXPORT>,
): void;

A handler that only writes archive entries returns nothing.

interface TenantDataExportContribution {
  moduleId: string;
  data: unknown;
}

TenantExportArchive

import type { Readable } from "node:stream";

interface TenantExportArchive {
  addJson(path: string, data: unknown): Promise<void>;
  addFile(path: string, localPath: string): Promise<void>;
  addStream(path: string, stream: Readable): Promise<void>;
}
MethodAdds
addJsondata serialized as a JSON entry.
addFileAn artifact already materialized on disk, read from localPath.
addStreamA remote or generated dump, without ever holding it in memory.

Paths are relative: the implementation prefixes every entry with modules/<moduleId>/, so two contributors can both write invoices.ndjson without colliding, and none has to defend against another's paths.

Await every call. Each method resolves only once the archiver has consumed the entry — awaiting is what propagates back-pressure to whatever produces the data, and is what keeps memory flat however large the export grows. Firing them without await gives up both.

The signal aborts when the export is cancelled or times out. Pass it down to whatever produces your data so a cancelled export stops reading rather than finishing into a discarded archive.

The Manifest

Every run writes a manifest.json at the archive root recording what each contributor produced:

interface TenantExportManifest {
  tenantId: string;
  exportedAt: string;
  partial: boolean;
  modules: TenantExportModuleReport[];
}

interface TenantExportModuleReport {
  moduleId: string;
  declaredModuleId?: string;
  entries: string[];
  failed: boolean;
  error?: string;
}

A failing contributor does not fail the export: its report carries failed: true and the error message, and partial goes true on the manifest so a consumer can tell an incomplete archive from a complete one. declaredModuleId appears when the returned contribution named a different id than the one the archive namespaced the entries under — a sign the registration and the return value disagree.

Running the export is the other side of this contract: buildTenantExportArchive drives the contributors and writes the ZIP, and startTenantExportJob runs it as a background export job. Both belong to the Base domain, under its helpers.

Next Steps

Realtime covers pushing an event to an open page instead of reacting to a platform event.