@antelopejs/interface-dms 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +70 -68
  3. package/dist/auth/db/models/sessions.model.d.ts +1 -0
  4. package/dist/auth/db/models/userExternalIdentities.model.d.ts +1 -0
  5. package/dist/auth/db/models/users.model.d.ts +1 -0
  6. package/dist/base/block-types.d.ts +1 -0
  7. package/dist/base/block-types.js +1 -0
  8. package/dist/base/block-types.js.map +1 -1
  9. package/dist/base/form-block-schema.d.ts +24 -24
  10. package/dist/base/live-toggle.d.ts +2 -2
  11. package/dist/base/period-selector.d.ts +2 -2
  12. package/dist/base/section.d.ts +2 -2
  13. package/dist/base/tab.d.ts +4 -4
  14. package/dist/base/table-view/schema.d.ts +4 -4
  15. package/dist/db/internal/exportJobs.model.d.ts +1 -0
  16. package/dist/db/internal/inviteResolutions.model.d.ts +1 -0
  17. package/dist/db/internal/tenantLifecycle.model.d.ts +1 -0
  18. package/dist/db/models/roles.model.d.ts +1 -0
  19. package/dist/db/models/tenantMembers.model.d.ts +1 -0
  20. package/dist/db/models/tenants.model.d.ts +1 -0
  21. package/dist/db/models/user_invites.model.d.ts +1 -0
  22. package/docs/.navigation.yml +2 -0
  23. package/docs/1.core/.navigation.yml +2 -0
  24. package/docs/1.core/1.introduction.md +169 -0
  25. package/docs/1.core/10.layout-banners.md +133 -0
  26. package/docs/1.core/2.pages-and-modules.md +647 -0
  27. package/docs/1.core/3.components.md +287 -0
  28. package/docs/1.core/4.permissions.md +279 -0
  29. package/docs/1.core/5.tenancy.md +356 -0
  30. package/docs/1.core/6.invites.md +239 -0
  31. package/docs/1.core/7.hooks.md +354 -0
  32. package/docs/1.core/8.realtime.md +147 -0
  33. package/docs/1.core/9.quick-actions.md +115 -0
  34. package/docs/2.auth/.navigation.yml +2 -0
  35. package/docs/2.auth/1.introduction.md +75 -0
  36. package/docs/2.auth/2.authentication.md +222 -0
  37. package/docs/2.auth/3.tokens-and-sessions.md +352 -0
  38. package/docs/2.auth/4.users.md +256 -0
  39. package/docs/2.auth/5.external-identities.md +122 -0
  40. package/docs/3.base/.navigation.yml +2 -0
  41. package/docs/3.base/1.introduction.md +202 -0
  42. package/docs/3.base/10.display-blocks.md +136 -0
  43. package/docs/3.base/2.types.md +402 -0
  44. package/docs/3.base/3.forms.md +349 -0
  45. package/docs/3.base/4.tables.md +608 -0
  46. package/docs/3.base/5.charts-and-widgets.md +466 -0
  47. package/docs/3.base/6.layout.md +431 -0
  48. package/docs/3.base/8.data-types.md +361 -0
  49. package/docs/3.base/9.helpers.md +414 -0
  50. package/docs/4.notifications/.navigation.yml +2 -0
  51. package/docs/4.notifications/1.introduction.md +79 -0
  52. package/docs/4.notifications/2.categories-and-subjects.md +212 -0
  53. package/docs/4.notifications/3.sending.md +283 -0
  54. package/docs/5.html-render/.navigation.yml +2 -0
  55. package/docs/5.html-render/1.introduction.md +372 -0
  56. package/docs/5.html-render/2.built-in-emails.md +107 -0
  57. package/docs/index.md +100 -0
  58. package/docs/tsconfig.json +11 -0
  59. package/package.json +24 -18
@@ -0,0 +1,354 @@
1
+ ---
2
+ title: "Hooks"
3
+ description: "A hook is a named platform event with a typed argument list and a typed result."
4
+ ---
5
+
6
+ # Hooks
7
+
8
+ ## Overview
9
+
10
+ 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.
11
+
12
+ Everything on this page lives in `@antelopejs/interface-dms/hooks`.
13
+
14
+ ## Register a Handler
15
+
16
+ ```typescript
17
+ import { Hook, RegisterHook } from "@antelopejs/interface-dms/hooks";
18
+
19
+ export function registerBillingHooks(): void {
20
+ RegisterHook(Hook.MEMBER_ADDED, async (payload) => {
21
+ // payload: { tenantId, userId, isTenantOwner }
22
+ await seedBillingProfile(payload.tenantId, payload.userId);
23
+ });
24
+
25
+ RegisterHook(Hook.TENANT_DELETED, async (tenantId) => {
26
+ await dropBillingProfiles(tenantId);
27
+ });
28
+ }
29
+ ```
30
+
31
+ `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.
32
+
33
+ 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`.
34
+
35
+ 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.
36
+
37
+ Every registration records the module it came from, resolved from the registering call site. `HookRegistrationOptions.moduleId` overrides that resolution:
38
+
39
+ ```typescript
40
+ interface HookRegistrationOptions {
41
+ moduleId?: string;
42
+ }
43
+ ```
44
+
45
+ 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.
46
+
47
+ `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:
48
+
49
+ ```typescript
50
+ import {
51
+ Hook,
52
+ type HookHandler,
53
+ RegisterHook,
54
+ UnregisterHook,
55
+ } from "@antelopejs/interface-dms/hooks";
56
+
57
+ const onMemberAdded: HookHandler<Hook.MEMBER_ADDED> = async (payload) => {
58
+ await seedBillingProfile(payload.tenantId, payload.userId);
59
+ };
60
+
61
+ RegisterHook(Hook.MEMBER_ADDED, onMemberAdded);
62
+ UnregisterHook(Hook.MEMBER_ADDED, onMemberAdded);
63
+ ```
64
+
65
+ `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.
66
+
67
+ ## The `Hook` Enum
68
+
69
+ | Member | Value | Arguments | Result |
70
+ | ------ | ----- | --------- | ------ |
71
+ | `Hook.DATABASE_INITIALIZED` | `"database:initialized"` | none | `undefined` — fires on every DMS start; a handler registered later runs at once (see [Initialise Against the Database](#initialise-against-the-database)) |
72
+ | `Hook.TENANT_BEING_PROVISIONED` | `"tenant:being-provisioned"` | `TenantProvisioningHookPayload` | `undefined` |
73
+ | `Hook.TENANT_DELETED` | `"tenant:deleted"` | `tenantId: string` | `undefined` |
74
+ | `Hook.TENANT_DATA_EXPORT` | `"tenant:data-export"` | `tenantId: string`, `archive: TenantExportArchive`, `signal: AbortSignal` | `TenantDataExportContribution \| void` |
75
+ | `Hook.INVITE_BEING_CREATED` | `"invite:being-created"` | `InviteHookPayload` | `undefined` |
76
+ | `Hook.INVITE_CREATED` | `"invite:created"` | `InviteCreatedHookPayload` | `undefined` |
77
+ | `Hook.INVITE_DELETED` | `"invite:deleted"` | `InviteDeletedHookPayload` | `undefined` |
78
+ | `Hook.MEMBER_BEING_ADDED` | `"member:being-added"` | `MemberHookPayload` | `undefined` |
79
+ | `Hook.MEMBER_ADDED` | `"member:added"` | `MemberHookPayload` | `undefined` |
80
+ | `Hook.MEMBER_REMOVED` | `"member:removed"` | `MemberRemovedHookPayload` | `undefined` |
81
+ | `Hook.USER_REGISTERED` | `"user:registered"` | `UserRegisteredHookPayload` | `undefined` |
82
+ | `Hook.USER_DELETED` | `"user:deleted"` | `UserDeletedHookPayload` | `undefined` — 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 |
83
+
84
+ 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](#enrich-a-workspace-being-provisioned).
85
+
86
+ ## Payload Interfaces
87
+
88
+ ```typescript
89
+ interface TenantProvisioningHookPayload {
90
+ tenantId: string;
91
+ userId: string;
92
+ extras: Record<string, unknown>;
93
+ }
94
+
95
+ interface InviteHookPayload {
96
+ tenantId: string;
97
+ email: string;
98
+ asTenantOwner: boolean;
99
+ roleIds: string[];
100
+ }
101
+
102
+ interface InviteCreatedHookPayload extends InviteHookPayload {
103
+ inviteId: string;
104
+ token: string;
105
+ }
106
+
107
+ interface InviteDeletedHookPayload {
108
+ tenantId: string;
109
+ inviteId: string;
110
+ email: string;
111
+ }
112
+
113
+ interface MemberHookPayload {
114
+ tenantId: string;
115
+ userId: string;
116
+ isTenantOwner: boolean;
117
+ }
118
+
119
+ interface MemberRemovedHookPayload {
120
+ tenantId: string;
121
+ userIds: string[];
122
+ }
123
+
124
+ interface UserRegisteredHookPayload {
125
+ tenantId: string;
126
+ userId: string;
127
+ email: string;
128
+ name: string;
129
+ }
130
+
131
+ type UserDeletionReason = "self";
132
+
133
+ interface UserDeletedHookPayload {
134
+ userId: string;
135
+ email: string;
136
+ tenantIds: string[];
137
+ reason: UserDeletionReason;
138
+ }
139
+ ```
140
+
141
+ 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.
142
+
143
+ `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.
144
+
145
+ `InviteCreatedHookPayload` carries the invite `token`. It is a signup credential: keep it inside the handler rather than forwarding it to a wider surface.
146
+
147
+ `TenantProvisioningHookPayload.extras` is opaque: the DMS reads no key of it, validates nothing and stores nothing. See [Enrich a Workspace Being Provisioned](#enrich-a-workspace-being-provisioned).
148
+
149
+ `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](#contribute-to-a-tenant-data-export).
150
+
151
+ ## Dispatch: `ExecuteHooks` vs `CollectHooks`
152
+
153
+ Both take the hook and its arguments and await each handler in registration order. They differ in what they do with the outcome.
154
+
155
+ ```typescript
156
+ async function ExecuteHooks<H extends Hook>(
157
+ name: H,
158
+ ...args: HookSignatures[H]["args"]
159
+ ): Promise<void>;
160
+
161
+ async function CollectHooks<H extends Hook>(
162
+ name: H,
163
+ ...args: HookSignatures[H]["args"]
164
+ ): Promise<HookSignatures[H]["result"][]>;
165
+ ```
166
+
167
+ | | `ExecuteHooks` | `CollectHooks` |
168
+ | --- | --- | --- |
169
+ | Return value | `Promise<void>` — handler results are discarded | `Promise<Result[]>` — one entry per handler that returned |
170
+ | A handler that throws | The rejection propagates to the caller and the remaining handlers do not run | The error is logged and the handler contributes nothing; the remaining handlers still run |
171
+ | Use for | Notifying that something happened | Asking every handler to contribute something |
172
+
173
+ 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.
174
+
175
+ ```typescript
176
+ import {
177
+ CollectHooks,
178
+ ExecuteHooks,
179
+ Hook,
180
+ } from "@antelopejs/interface-dms/hooks";
181
+
182
+ // notify: every handler runs, nothing is returned
183
+ await ExecuteHooks(Hook.TENANT_DELETED, tenantId);
184
+
185
+ // collect: gather what every handler returned, skipping the ones that threw
186
+ const payloads = await CollectHooks(Hook.MEMBER_ADDED, {
187
+ tenantId,
188
+ userId,
189
+ isTenantOwner: false,
190
+ });
191
+ ```
192
+
193
+ 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.
194
+
195
+ ## Dispatch Handler by Handler: `GetRegisteredHooks`
196
+
197
+ Both dispatchers pass the same arguments to every handler. When each handler needs arguments of its own, take the list instead:
198
+
199
+ ```typescript
200
+ function GetRegisteredHooks<H extends Hook>(name: H): RegisteredHook<H>[];
201
+
202
+ interface RegisteredHook<H extends Hook> {
203
+ moduleId?: string;
204
+ handler: HookHandler<H>;
205
+ }
206
+ ```
207
+
208
+ `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.
209
+
210
+ 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.
211
+
212
+ ## Initialise Against the Database
213
+
214
+ `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:
215
+
216
+ - 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.
217
+ - 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()`.
218
+ - A handler registered while the DMS is stopped waits for its next start.
219
+
220
+ 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.
221
+
222
+ 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.
223
+
224
+ ## Enrich a Workspace Being Provisioned
225
+
226
+ 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.
227
+
228
+ ```typescript
229
+ import {
230
+ Hook,
231
+ RegisterHook,
232
+ } from "@antelopejs/interface-dms/hooks";
233
+
234
+ RegisterHook(Hook.TENANT_BEING_PROVISIONED, async ({ tenantId, userId, extras }) => {
235
+ await recordAcquisition(tenantId, userId, {
236
+ source: extras.acquisitionSource,
237
+ referral: extras.referralCode,
238
+ });
239
+ });
240
+ ```
241
+
242
+ 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.
243
+
244
+ 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:
245
+
246
+ - Key the write on `tenantId`, so it can be cleaned up along with the workspace — subscribing to `TENANT_DELETED` covers the ordinary deletion path.
247
+ - Make it idempotent, so a retried signup does not double it.
248
+
249
+ 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.
250
+
251
+ `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.
252
+
253
+ 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.
254
+
255
+ ## Contribute to a Tenant Data Export
256
+
257
+ `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:
258
+
259
+ - Return a `TenantDataExportContribution` — its `data` is serialized to `modules/<moduleId>.json`.
260
+ - Write entries into the `archive` — for anything too large to hold in memory, or that is not JSON.
261
+
262
+ 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:
263
+
264
+ ```typescript
265
+ import { RegisterTenantDataExportContributor } from "@antelopejs/interface-dms/hooks";
266
+
267
+ RegisterTenantDataExportContributor(
268
+ "@acme/dms-billing",
269
+ async (tenantId, archive, signal) => {
270
+ await archive.addStream(
271
+ "invoices.ndjson",
272
+ streamInvoices(tenantId, signal),
273
+ );
274
+ return {
275
+ moduleId: "@acme/dms-billing",
276
+ data: await collectBillingSettings(tenantId),
277
+ };
278
+ },
279
+ );
280
+
281
+ ```
282
+
283
+ The helper's signature is shown separately for reference:
284
+
285
+ ```typescript
286
+ import type { Hook, HookHandler } from "@antelopejs/interface-dms/hooks";
287
+
288
+ declare function RegisterTenantDataExportContributor(
289
+ moduleId: string,
290
+ handler: HookHandler<Hook.TENANT_DATA_EXPORT>,
291
+ ): void;
292
+ ```
293
+
294
+ A handler that only writes archive entries returns nothing.
295
+
296
+ ```typescript
297
+ interface TenantDataExportContribution {
298
+ moduleId: string;
299
+ data: unknown;
300
+ }
301
+ ```
302
+
303
+ ### `TenantExportArchive`
304
+
305
+ ```typescript
306
+ import type { Readable } from "node:stream";
307
+
308
+ interface TenantExportArchive {
309
+ addJson(path: string, data: unknown): Promise<void>;
310
+ addFile(path: string, localPath: string): Promise<void>;
311
+ addStream(path: string, stream: Readable): Promise<void>;
312
+ }
313
+ ```
314
+
315
+ | Method | Adds |
316
+ | ------ | ---- |
317
+ | `addJson` | `data` serialized as a JSON entry. |
318
+ | `addFile` | An artifact already materialized on disk, read from `localPath`. |
319
+ | `addStream` | A remote or generated dump, without ever holding it in memory. |
320
+
321
+ 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.
322
+
323
+ **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.
324
+
325
+ 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.
326
+
327
+ ### The Manifest
328
+
329
+ Every run writes a `manifest.json` at the archive root recording what each contributor produced:
330
+
331
+ ```typescript
332
+ interface TenantExportManifest {
333
+ tenantId: string;
334
+ exportedAt: string;
335
+ partial: boolean;
336
+ modules: TenantExportModuleReport[];
337
+ }
338
+
339
+ interface TenantExportModuleReport {
340
+ moduleId: string;
341
+ declaredModuleId?: string;
342
+ entries: string[];
343
+ failed: boolean;
344
+ error?: string;
345
+ }
346
+ ```
347
+
348
+ 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.
349
+
350
+ 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](/docs/interfaces/base/introduction) domain, under its helpers.
351
+
352
+ ## Next Steps
353
+
354
+ [Realtime](/docs/interfaces/core/realtime) covers pushing an event to an open page instead of reacting to a platform event.
@@ -0,0 +1,147 @@
1
+ ---
2
+ title: "Realtime"
3
+ description: "Publish events on topics from the backend, and how page-bound topics reach the browsers viewing the page."
4
+ ---
5
+
6
+ # Realtime
7
+
8
+ ## Overview
9
+
10
+ Realtime works on topics. A topic is a plain string a page declares interest in, and any backend code can publish a message on it. `RegisterPageTopic` binds a topic to a page id; `PublishMessage` sends an event on that topic to every client currently subscribed to it. `SubscribeMessage` is the read half: it lets backend code receive what is published, instead of only feeding the dashboard's own streams.
11
+
12
+ Everything on this page lives in `@antelopejs/interface-dms/realtime`.
13
+
14
+ ## How a Page Ends Up Subscribed
15
+
16
+ Subscription is derived, not requested by the publisher:
17
+
18
+ 1. A page — or a component on it — calls `RegisterPageTopic(pageId, topic)` at registration time. `pageId` is the page's `fullId` ([Pages and Modules](/docs/interfaces/core/pages-and-modules)), not its slug.
19
+ 2. A client opens a realtime session and reports which page it is on. The backend checks that the user may access that page, then subscribes the session to every topic registered for that page id, and drops the topics that are no longer wanted.
20
+ 3. `PublishMessage(topic, type)` delivers to the sessions currently subscribed to `topic`.
21
+
22
+ So a publisher never addresses a page or a user. It addresses a topic, and the page-topic registry decides who is listening.
23
+
24
+ Registering the same topic for the same page twice is harmless — a page's topics are a set.
25
+
26
+ A binding belongs to the module that registered it: it is removed when that module is unloaded, and it survives a reload of the DMS module, which gets every binding back from the core. Registering from `onCreated`, as below, makes the module whose page it is that owner.
27
+
28
+ ## Register a Topic for a Page
29
+
30
+ The typical caller is a component builder's `onCreated` callback, which receives the `PageMetadata` of the page the component was declared on ([Components](/docs/interfaces/core/components)):
31
+
32
+ ```typescript
33
+ import { ComponentBuilder } from "@antelopejs/interface-dms/component";
34
+ import { RegisterPageTopic } from "@antelopejs/interface-dms/realtime";
35
+
36
+ export const STOCK_TOPIC = "catalog:stock";
37
+
38
+ export function StockCounter(options: { fetchUrl: string }) {
39
+ return new ComponentBuilder<{ fetchUrl: string }>("acme-stock-counter")
40
+ .options(options)
41
+ .onCreated((page) => {
42
+ const pageInfo = page.pageInfo;
43
+ if (!pageInfo) return;
44
+ RegisterPageTopic(pageInfo.fullId, STOCK_TOPIC);
45
+ });
46
+ }
47
+ ```
48
+
49
+ Registering from `onCreated` is what makes the binding follow the component: whichever page the component is placed on gets the topic. The chart builders of the [Base](/docs/interfaces/base/introduction) domain do exactly this — a chart declaring `realtimeTopic` registers each of its topics on the page it is placed on.
50
+
51
+ To bind a topic to a page you know statically, call `RegisterPageTopic` with that page's `fullId` directly.
52
+
53
+ ```typescript
54
+ RegisterPageTopic(pageId: string, topic: string): void
55
+ ```
56
+
57
+ ## Publish a Message
58
+
59
+ ```typescript
60
+ import { Controller, Post } from "@antelopejs/interface-api";
61
+ import { PublishMessage } from "@antelopejs/interface-dms/realtime";
62
+ import { STOCK_TOPIC } from "../components/stock-counter";
63
+
64
+ export class StockController extends Controller("/api/stock") {
65
+ @Post("/restock")
66
+ async restock(): Promise<{ ok: boolean }> {
67
+ await applyRestock();
68
+ await PublishMessage(STOCK_TOPIC, "stock.updated", {
69
+ payload: { warehouse: "eu-west" },
70
+ actorId: "system",
71
+ });
72
+ return { ok: true };
73
+ }
74
+ }
75
+ ```
76
+
77
+ ```typescript
78
+ PublishMessage(
79
+ topic: string,
80
+ type: string,
81
+ options?: PublishMessageOptions,
82
+ ): Promise<void>
83
+ ```
84
+
85
+ | Parameter | Description |
86
+ | --------- | ----------- |
87
+ | `topic` | The topic to publish on. Subscribers are the sessions on a page that registered this topic. |
88
+ | `type` | The event type carried with the message, for subscribers that distinguish several kinds of event on one topic. |
89
+ | `options` | Optional payload and actor. |
90
+
91
+ ### `PublishMessageOptions`
92
+
93
+ | Option | Type | Description |
94
+ | ------ | ---- | ----------- |
95
+ | `payload` | `Record<string, unknown>` | Data carried with the event. |
96
+ | `actorId` | `string` | Identifies who caused the event, so a client can ignore an event it triggered itself. |
97
+
98
+ `PublishMessage` resolves once the message has been handed to the broker. It does not report how many sessions received it, and publishing on a topic no page registered is not an error — it simply reaches nobody.
99
+
100
+ ## Subscribe from the Backend
101
+
102
+ `SubscribeMessage` receives every message published on a topic, whichever instance published it: the subscription rides the same broker as `PublishMessage`, so what one instance publishes reaches subscribers on every other.
103
+
104
+ ```typescript
105
+ import { SubscribeMessage } from "@antelopejs/interface-dms/realtime";
106
+ import { STOCK_TOPIC } from "../components/stock-counter";
107
+
108
+ const unsubscribe = SubscribeMessage(STOCK_TOPIC, (message) => {
109
+ if (message.type !== "stock.updated") return;
110
+ const warehouse = message.payload?.warehouse;
111
+ if (typeof warehouse === "string") {
112
+ void refreshWarehouseCache(warehouse);
113
+ }
114
+ });
115
+ ```
116
+
117
+ ```typescript
118
+ function SubscribeMessage(
119
+ topic: string,
120
+ handler: RealtimeMessageHandler,
121
+ ): UnsubscribeMessage;
122
+
123
+ type RealtimeMessageHandler = (message: RealtimeMessage) => void;
124
+ type UnsubscribeMessage = () => void;
125
+ ```
126
+
127
+ ### `RealtimeMessage`
128
+
129
+ | Field | Type | Description |
130
+ | ----- | ---- | ----------- |
131
+ | `topic` | `string` | Topic the message was published on. |
132
+ | `type` | `string` | Event type given to `PublishMessage`. |
133
+ | `payload` | `Record<string, unknown>` | Data carried with the event, when the publisher sent any. |
134
+ | `actorId` | `string` | Who caused the event, when the publisher named an actor. |
135
+ | `ts` | `number` | Publication timestamp. |
136
+
137
+ Paired with `PublishMessage`, this turns the DMS's broker into one a module can use for realtime channels of its own — its own endpoint subscribes here and the DMS carries the messages — instead of bringing a second broker to do the same job.
138
+
139
+ Three properties to design around:
140
+
141
+ - **Handlers must not throw.** They are called on the delivering instance's event loop, and nothing catches for you.
142
+ - **Delivery is fire-and-forget.** There is no replay: a message published while nobody was subscribed is gone. Do not use a topic as a queue.
143
+ - **Unsubscribing is automatic** when the registering module is unloaded. The returned `UnsubscribeMessage` is for ending a subscription earlier than that; calling it twice is a no-op.
144
+
145
+ ## Next Steps
146
+
147
+ [Quick Actions](/docs/interfaces/core/quick-actions) covers the other way a module reaches the running dashboard: entries in the command palette.
@@ -0,0 +1,115 @@
1
+ ---
2
+ title: "Quick Actions"
3
+ description: "A quick action is a shortcut the backend contributes to the dashboard's command palette: a labelled entry, grouped under a category, that points at a registered page, a table creation form, a table toolbar button, or…"
4
+ ---
5
+
6
+ # Quick Actions
7
+
8
+ ## Overview
9
+
10
+ A quick action is a shortcut the backend contributes to the dashboard's command palette: a labelled entry, grouped under a category, that points at a registered page, a table creation form, a table toolbar button, or an event. Actions register at import time. Access follows the target page and, for a form or a button, the permission of what it opens.
11
+
12
+ Both functions live in `@antelopejs/interface-dms/quick-actions`.
13
+
14
+ ## Declare a Category and Its Actions
15
+
16
+ `QuickActionCategory(id, info)` registers the group; `QuickAction(id, info)` registers an entry in it. Both return the registered record. Create the category first: registering an action whose category is unknown throws.
17
+
18
+ This example assumes your application exports a registered `TasksPage` from `./pages/tasks`.
19
+
20
+ ```typescript
21
+ import { TasksPage } from "./pages/tasks";
22
+ import {
23
+ QuickAction,
24
+ QuickActionCategory,
25
+ } from "@antelopejs/interface-dms/quick-actions";
26
+
27
+ const tasks = QuickActionCategory("tasks", {
28
+ displayName: "$my_app.quick_actions.tasks",
29
+ icon: "i-ph-check-square",
30
+ order: 10,
31
+ });
32
+
33
+ QuickAction("tasks-new", {
34
+ category: tasks,
35
+ displayName: "$my_app.quick_actions.tasks_new",
36
+ icon: "i-ph-plus",
37
+ target: { type: "navigate", page: TasksPage },
38
+ });
39
+ ```
40
+
41
+ Actions are keyed by their category id and their own id, so the same action id may appear under two categories. Registering an id that is already taken keeps the first registration.
42
+
43
+ ## `QuickActionCategoryInfo`
44
+
45
+ | Field | Type | Description |
46
+ | ----- | ---- | ----------- |
47
+ | `id` | `string` | Category id. Supplied as the first argument of `QuickActionCategory`, not inside `info`. |
48
+ | `displayName` | `string` | Group label. Required. |
49
+ | `icon` | `string` | Icon name. |
50
+ | `order` | `number` | Sort order among categories. |
51
+
52
+ ## `QuickActionInfo`
53
+
54
+ | Field | Type | Description |
55
+ | ----- | ---- | ----------- |
56
+ | `id` | `string` | Action id. Supplied as the first argument of `QuickAction`, not inside `info`. |
57
+ | `category` | `QuickActionCategoryInfo` | The record returned by `QuickActionCategory`. Required. |
58
+ | `displayName` | `string` | Entry label. Required. |
59
+ | `icon` | `string` | Icon name. Required. |
60
+ | `order` | `number` | Sort order within the category. |
61
+ | `target` | `QuickActionTarget` | What running the action does. Required. |
62
+
63
+ Access is computed per caller from the registered target page, including its permission and tenant access gate ([Permissions](/docs/interfaces/core/permissions)). An `openForm` target also checks the selected table's create action; a `button` target checks the button's own `permission`. Configure access on the destination rather than adding a separate `permission` property to the quick action.
64
+
65
+ ## `QuickActionTarget`
66
+
67
+ Every target requires `page`, the registered page controller class. The frontend URL derives from that page, so renaming a route does not leave a stale URL in the action.
68
+
69
+ | Variant | Fields | Meaning |
70
+ | ------- | ------ | ------- |
71
+ | `"navigate"` | `page`, optional `query` | Navigate to the page with optional string query parameters. |
72
+ | `"openForm"` | `page`, optional `component` | Open a table's creation form. Select the component explicitly if the page has more than one eligible table. |
73
+ | `"button"` | `page`, `button`, optional `component` | Open the page and press the table toolbar button whose `id` is `button`, e.g. to open the modal it opens. Select the component explicitly if several components of the page declare that button id. |
74
+ | `"event"` | `page`, `name`, optional `payload` | Navigate to the page and emit the named event. |
75
+
76
+ The following examples assume an existing registered `BooksPage` with a `TableView` stored in its static `table` field. For a nested table, pass a target such as `BooksPage.content.targetChild("table")` instead.
77
+
78
+ ```typescript
79
+ import { BooksPage } from "./pages/books";
80
+ import {
81
+ QuickAction,
82
+ QuickActionCategory,
83
+ } from "@antelopejs/interface-dms/quick-actions";
84
+
85
+ const catalog = QuickActionCategory("catalog", {
86
+ displayName: "$my_app.quick_actions.catalog",
87
+ icon: "i-ph-books",
88
+ });
89
+
90
+ QuickAction("catalog-new-book", {
91
+ category: catalog,
92
+ displayName: "$my_app.quick_actions.new_book",
93
+ icon: "i-ph-plus",
94
+ target: { type: "openForm", page: BooksPage, component: BooksPage.table },
95
+ });
96
+
97
+ // BooksPage.table declares customButtons: [{ id: "import", permission: "add", … }]
98
+ QuickAction("catalog-import", {
99
+ category: catalog,
100
+ displayName: "$my_app.quick_actions.import_books",
101
+ icon: "i-ph-upload",
102
+ target: { type: "button", page: BooksPage, button: "import" },
103
+ });
104
+
105
+ QuickAction("catalog-refresh", {
106
+ category: catalog,
107
+ displayName: "$my_app.quick_actions.refresh",
108
+ icon: "i-ph-arrows-clockwise",
109
+ target: { type: "event", page: BooksPage, name: "catalog:refresh", payload: { force: true } },
110
+ });
111
+ ```
112
+
113
+ Declare quick actions in a file imported by your module so the registrations run at import time, next to the pages they point at.
114
+
115
+ [Layout Banners](/docs/interfaces/core/layout-banners) covers the other global surface a backend module contributes to: messages under the dashboard header.
@@ -0,0 +1,2 @@
1
+ title: Auth
2
+ icon: i-lucide-key-round