@substrat-run/engine-notifications 0.0.0-stage → 0.3.1

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.
package/README.md CHANGED
@@ -1,3 +1,173 @@
1
- # Temporary Holding Version
1
+ # @substrat-run/engine-notifications
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Notifications engine for [Substrat](https://github.com/substrat-run/substrat). It turns an
4
+ occasion a vertical names into email to the people it concerns, with per-person
5
+ preferences, escaped templates and tracked deliveries.
6
+
7
+ Configured for **public npm publication**; the first npm release is pending. The Handlebar
8
+ demo already uses it in-repo.
9
+
10
+ ## How a vertical uses it
11
+
12
+ The engine is composed **by call**. Register the module, then call `notify` inside your own
13
+ transaction wherever something is worth telling someone, usually your own consumer of a
14
+ domain event:
15
+
16
+ ```ts
17
+ import { notificationsModule, notify } from '@substrat-run/engine-notifications';
18
+
19
+ host.registerModule(notificationsModule);
20
+
21
+ // In your module's `consumers`:
22
+ 'workorder.assigned': async (ctx, event) => {
23
+ const { orderId, technician } = assignedPayload.parse(event.payload); // your own parse
24
+ notify(ctx, {
25
+ topic: 'repair-assigned',
26
+ description: 'A repair was assigned to you', // shown beside the switch
27
+ dedupeKey: event.id, // a redelivered event mails nobody twice
28
+ recipients: [technician], // principal ids, never addresses
29
+ vars: { orderId },
30
+ about: event.entity,
31
+ template: { subject: 'New repair: {{orderId}}', text: 'You have repair {{orderId}}.' },
32
+ });
33
+ },
34
+ ```
35
+
36
+ `notify` checks no permission, like every by-call engine function: the caller checks first.
37
+
38
+ **Prerequisite: the vertical's `emailSender` grant.** `notify` writes its sends with the kernel's `requestEmail`, and the platform delivers those only for a vertical that staff have granted the email-sender capability (the console's `setVerticalEmailSender`). Without it, nothing fails at the call: every delivery is written `requested`, and then settles `refused` with reason `permission_denied` when the platform's outcome comes back.
39
+
40
+ ## What it owns
41
+
42
+ - **Dispatch.** For each person, it writes one send intent with the kernel's `requestEmail`
43
+ in the caller's transaction. The platform's drain delivers it through `adapter-email`.
44
+ Nothing here talks to a provider.
45
+ - **Templates, rendered safely.** `{{name}}` placeholders are filled from `vars`. A value is
46
+ HTML-escaped in the HTML part and kept to one line in the subject. A placeholder with no
47
+ variable makes `notify` throw `validation_failed`, which rolls back the caller: from a
48
+ consumer, the event dead-letters with the reason; from an operation, the operation fails.
49
+ A blank mail is never sent.
50
+ - **Preferences.** Each person sets where their notifications go
51
+ (`notifications/set-my-address`) and switches topics off (`notifications/set-my-preference`).
52
+ A topic passed as `required` cannot be switched off. A topic is recorded the first time it
53
+ is used, and that record is what a preferences screen lists.
54
+ - **Delivery tracking.** There is one row per (dedupe key, topic, person, channel), unique on
55
+ those four. It moves from `requested` to `sent`, `refused` or `dead_lettered` when the
56
+ platform's outcome event arrives. A delivery that was not sent on its own says why: `muted`,
57
+ `no_address`, or `digest`.
58
+ - **A person's own history.** `notifications/my-deliveries`, under `notifications:self`, pages
59
+ the caller's deliveries newest first (`listMyDeliveries` in scope). It is `list-deliveries`
60
+ with the caller's principal pinned in and no `principal` input, so holding the key never
61
+ reaches anyone else's rows; `status` and `topic` narrow it. The row is narrower than the
62
+ administrator's: no `principal`, no `dedupe_key` (the caller's idempotency key) and no
63
+ `request_id` (the platform's send intent). Its `reason` is kept on a `skipped` row,
64
+ where it is this engine's own word (`muted`, `no_address`, `digest`, `quarantined`), and
65
+ is `null` on a `refused` or `dead_lettered` one, whose reason is the platform's outcome
66
+ code and stays with whoever reads the whole log. Digest rows are listed as they are: an
67
+ occasion held for the digest reads `skipped`/`digest` and gets a `settled_at` once the
68
+ digest that carried it is written, the digest is a row of its own under `@digest`
69
+ (`DIGEST_TOPIC`), and a withdrawn one reads `skipped`/`muted`.
70
+ - **Daily digests.** A person can take any topic that is not required as a daily digest
71
+ (`notifications/set-my-preference` with `cadence: 'daily-digest'`). `notify` then records the
72
+ occasion as `skipped` with reason `digest` and holds the rendered message.
73
+
74
+ The engine's own schedule, `notifications/send-digests`, runs every 15 minutes
75
+ (`DIGEST_TICK_MINUTES`) under the engine's system principal. A person is due once their oldest
76
+ held message is a day old, so nothing waits much longer than a day, and a person with no more
77
+ than 50 held gets one digest a day. When a digest is due depends on that person's activity,
78
+ not on a clock.
79
+
80
+ Each due person gets one mail of what is held for them, up to 50 messages, oldest first. That
81
+ mail is one more delivery,
82
+ under the reserved topic `@digest` (`DIGEST_TOPIC`), and it settles like any other. The
83
+ occasions it carried are settled into it: still `skipped`/`digest`, now with a `settled_at`.
84
+ `notifications.digest-sent` lists them.
85
+
86
+ **Backlog limit.** A scope holds at most 32 sends waiting for the platform's drain
87
+ (`MAX_PENDING_PLATFORM_REQUESTS`). A pass therefore sends to at most the room left, less
88
+ `DIGEST_RESERVE` (8) slots kept for mail that must not wait. Everyone else stays held for the
89
+ next pass, oldest first, so a backlog drains over a few passes. The pass never meets the
90
+ cap's refusal.
91
+
92
+ **Item limit.** One digest carries at most 50 occasions (`DIGEST_MAX_ITEMS`), oldest first.
93
+ The rest stay held and are due by the same rule: once the oldest of them is a day old. That is
94
+ on the very next pass if it already is. It is the one case where a person gets more than one
95
+ digest a day.
96
+
97
+ **Contained failures.** Each person's digest is its own `ctx.atomic`. If writing it fails (a
98
+ send or a write that throws), nothing of it happened for that person. Their messages stay held
99
+ and everyone else's digest still goes out. `notifications.digest-failed` and an error line say
100
+ so, and their next attempt waits a day.
101
+
102
+ **Unreadable rows.** A held row that no longer reads, because it left the published shape, is
103
+ set aside rather than allowed to block the rest. It is marked `quarantined_at` and not sent.
104
+ It counts toward nothing: not who is due, not a switch-off's withdrawal, not the stale-hold
105
+ warning. Its occasion's delivery settles `skipped`/`quarantined`, and
106
+ `notifications.digest-quarantined` and an error line name the occasions, never their
107
+ content. The person's readable messages go out as usual.
108
+
109
+ The cause may pass, for instance a rollback to a version that rejects what a newer one
110
+ stored. So a set-aside row is read again once a day. When it reads, it is released: held
111
+ again, its delivery back to `skipped`/`digest`, and `notifications.digest-released` names it.
112
+ It then goes out in the person's next digest. If its person has switched the topic off in the
113
+ meantime, it is withdrawn instead: deleted unsent, its delivery settled `skipped`/`muted`, and
114
+ `notifications.digest-withdrawn` names it. A row that still does not read waits another day,
115
+ and nothing is said again. An erasure of the person deletes set-aside rows with the
116
+ rest.
117
+
118
+ **Mutes and required topics.** A mute still wins: switching a topic off withdraws what is
119
+ held for it, and those occasions' deliveries then read `skipped`/`muted`. The digest checks
120
+ again as it composes: a held message whose topic is switched off is withdrawn, never mailed,
121
+ whatever path brought it there. A required topic always goes out at once, and overrides a
122
+ mute written before it became required. Mutes are per topic and channel; there is no mute
123
+ for a whole channel.
124
+
125
+ A digest adds no words of its own. A digest of one message is that message. A digest of
126
+ several uses the first one's subject with `(+N)` and puts each message under its own subject,
127
+ so the mail stays in the vertical's language. The result is one document. A message whose
128
+ html is a whole document (`<!doctype html><html>…`) cannot nest, so its text part stands in.
129
+
130
+ **Something must run the schedule.** On the hosted platform the scope sweeper does, once a
131
+ scope's provision has granted the schedule's key: an install provisioned before this engine
132
+ declared the schedule gets it from the reconcile that follows a promote. A self-hosted server
133
+ must run `runDueSchedules` or `startPlatformSweeper`, as Handlebar's dev server does.
134
+
135
+ **Something must drain the sends.** Room only comes back as the platform drains the scope's
136
+ pending sends. On the hosted platform the control plane's sweep does this every 15 minutes. A
137
+ scheduled pass gets no immediate drain kick, unlike an operation a request ran. A server that
138
+ drains nothing, as Handlebar's dev server does not, fills its backlog: from then on every
139
+ pass defers everyone and logs that it did.
140
+
141
+ Wherever the schedule does not run or the sends do not drain, messages are held and nothing
142
+ fails. A hold that finds a person's oldest message (set-aside rows aside) two days old writes
143
+ a warning line, whatever the cause.
144
+
145
+ **One topic, one declaration.** A topic's `description` and `required` are recorded from the call that uses it, and follow the latest caller, so a deploy that changes them updates the scope. Every call site for a topic must pass the same pair: two that disagree make the topic flip with whichever ran last, and a mute be refused or allowed accordingly. Keep each topic in one constant that its call sites spread, and change it there.
146
+
147
+ ## Personal data
148
+
149
+ Events carry principals, never addresses. The address lives on the engine's recipient row,
150
+ which a subject erasure deletes (K-46). The send intent carries the recipient as its
151
+ `subjectId`, so the same erasure cancels a send that is still waiting. A message held for a
152
+ digest is rendered mail addressed to the person, so the same erasure deletes it too. Its
153
+ occasion's delivery keeps reason `digest` with no `settled_at`, the way a cancelled send's keeps
154
+ `requested`: an erasure writes no event the engine could settle it from.
155
+
156
+ ## Not yet
157
+
158
+ Channels other than email (SMS, push, in-app), tenant-edited or per-locale templates
159
+ (including a digest's own wording), quiet hours and a digest's time of day, digest cadences
160
+ other than daily. See K-47 for where this engine sits and
161
+ why it is not kernel code.
162
+
163
+ ## Permissions
164
+
165
+ | Key | What it allows |
166
+ | --- | --- |
167
+ | `notifications:self` | Set where your own notifications go and which topics reach you, and read what became of them |
168
+ | `notifications:read` | Read what became of every notification in the scope |
169
+ | `notifications:send-digests` | The digest schedule's key. The engine's system principal holds it. The manifest declares it `holder: 'system'`, so `lint:permissions` refuses a role or entity grant shape that grants it to a person |
170
+
171
+ ## Licence
172
+
173
+ AGPL-3.0-only, or a commercial licence.
@@ -0,0 +1,200 @@
1
+ import { z } from 'zod';
2
+ /** The cadences that hold a topic back for a digest — what a cadence row stores. */
3
+ export declare const digestCadence: z.ZodEnum<{
4
+ "daily-digest": "daily-digest";
5
+ }>;
6
+ /**
7
+ * engine-notifications' entities.
8
+ *
9
+ * - **`notification-topic`** is a kind of notification this scope has sent: recorded by
10
+ * `notify` the first time a topic is used, and kept to the caller's latest description and
11
+ * `required`. It is what a preferences screen lists and what a mute must name — the topics
12
+ * are the vertical's code, so the scope learns them by being told, not by registration.
13
+ * - **`notification-recipient`** is where a person's notifications go: one address per
14
+ * principal. It lives HERE rather than on an event because an address is erasable and an
15
+ * event is the one copy an erasure cannot rewrite (the same reason `todo.list-shared`
16
+ * carries a principal and no address), and module code has no other place to find one:
17
+ * the identity directory is not reachable through `ctx`. Erasing the principal deletes
18
+ * the row (K-46), and the send intents already written for them carry their `subjectId`,
19
+ * so a held send is cancelled by the same erasure.
20
+ * - **`notification-mute`** is a channel a person has switched off for one topic. Its
21
+ * presence is the fact: a topic is on until muted, so a new topic reaches people without
22
+ * a preference row being written for each of them first.
23
+ * - **`notification-delivery`** is what became of one (occasion, topic, person, channel). It
24
+ * names the principal and the platform request, never the address, so it is not
25
+ * personal data beyond the pseudonymous id the event spine already carries.
26
+ * `UNIQUE (dedupe_key, topic, principal, channel)` is the idempotency: the caller names the
27
+ * occasion (from a consumer, the event's id), and a repeat — a redelivered event — mails
28
+ * nobody twice. A digest is one delivery too, under the reserved topic `@digest`.
29
+ * - **`notification-cadence`** is a topic a person has asked to receive in a digest rather
30
+ * than at once, per channel. Like a mute, its presence is the fact: no row is `immediate`,
31
+ * which is what every person had before digests existed.
32
+ * - **`notification-digest-item`** is one occasion held for a person's next digest: the
33
+ * message as `notify` rendered it, keyed by the occasion's own delivery row (`id`). A row
34
+ * leaves when its digest is sent or a switch-off withdraws it. `failed_at` marks a person whose
35
+ * digest could not be written, so the next attempt waits; `quarantined_at` marks a row found
36
+ * unreadable, set aside — unsent, and in nobody's way — and stays until it reads again and is
37
+ * released, or an erasure deletes it; it is when the row was last read and found unreadable.
38
+ * The rendered text is about the occasion and addressed to the person, so it is erasable, and
39
+ * an erasure of the person deletes all their items with their address.
40
+ */
41
+ export declare const notificationsEntities: {
42
+ readonly 'notification-topic': {
43
+ readonly table: "notifications_topics";
44
+ readonly fields: z.ZodObject<{
45
+ topic: z.ZodString;
46
+ description: z.ZodString;
47
+ required: z.ZodNumber;
48
+ recorded_at: z.ZodString;
49
+ }, z.core.$strip>;
50
+ readonly primaryKey: readonly ["topic"];
51
+ };
52
+ readonly 'notification-recipient': {
53
+ readonly table: "notifications_recipients";
54
+ readonly fields: z.ZodObject<{
55
+ principal: z.ZodString;
56
+ email: z.ZodString;
57
+ updated_at: z.ZodString;
58
+ }, z.core.$strip>;
59
+ readonly primaryKey: readonly ["principal"];
60
+ readonly erasable: readonly ["email"];
61
+ readonly erasure: {
62
+ readonly subjects: readonly ["principal"];
63
+ readonly mode: "delete";
64
+ };
65
+ };
66
+ readonly 'notification-mute': {
67
+ readonly table: "notifications_mutes";
68
+ readonly fields: z.ZodObject<{
69
+ principal: z.ZodString;
70
+ topic: z.ZodString;
71
+ channel: z.ZodString;
72
+ created_at: z.ZodString;
73
+ }, z.core.$strip>;
74
+ readonly primaryKey: readonly ["principal", "topic", "channel"];
75
+ };
76
+ readonly 'notification-delivery': {
77
+ readonly table: "notifications_deliveries";
78
+ readonly fields: z.ZodObject<{
79
+ id: z.ZodString;
80
+ topic: z.ZodString;
81
+ dedupe_key: z.ZodString;
82
+ principal: z.ZodString;
83
+ channel: z.ZodString;
84
+ status: z.ZodEnum<{
85
+ dead_lettered: "dead_lettered";
86
+ refused: "refused";
87
+ requested: "requested";
88
+ sent: "sent";
89
+ skipped: "skipped";
90
+ }>;
91
+ reason: z.ZodNullable<z.ZodString>;
92
+ request_id: z.ZodNullable<z.ZodString>;
93
+ about_type: z.ZodNullable<z.ZodString>;
94
+ about_id: z.ZodNullable<z.ZodString>;
95
+ created_at: z.ZodString;
96
+ settled_at: z.ZodNullable<z.ZodString>;
97
+ }, z.core.$strip>;
98
+ readonly key: readonly ["dedupe_key", "topic", "principal", "channel"];
99
+ };
100
+ readonly 'notification-cadence': {
101
+ readonly table: "notifications_cadences";
102
+ readonly fields: z.ZodObject<{
103
+ principal: z.ZodString;
104
+ topic: z.ZodString;
105
+ channel: z.ZodString;
106
+ cadence: z.ZodEnum<{
107
+ "daily-digest": "daily-digest";
108
+ }>;
109
+ updated_at: z.ZodString;
110
+ }, z.core.$strip>;
111
+ readonly primaryKey: readonly ["principal", "topic", "channel"];
112
+ };
113
+ readonly 'notification-digest-item': {
114
+ readonly table: "notifications_digest_items";
115
+ readonly fields: z.ZodObject<{
116
+ id: z.ZodString;
117
+ principal: z.ZodString;
118
+ channel: z.ZodString;
119
+ topic: z.ZodString;
120
+ dedupe_key: z.ZodString;
121
+ subject: z.ZodString;
122
+ text: z.ZodString;
123
+ html: z.ZodString;
124
+ about_type: z.ZodNullable<z.ZodString>;
125
+ about_id: z.ZodNullable<z.ZodString>;
126
+ queued_at: z.ZodString;
127
+ failed_at: z.ZodNullable<z.ZodString>;
128
+ quarantined_at: z.ZodNullable<z.ZodString>;
129
+ }, z.core.$strip>;
130
+ readonly erasable: readonly ["subject", "text", "html"];
131
+ readonly erasure: {
132
+ readonly subjects: readonly ["principal"];
133
+ readonly mode: "delete";
134
+ };
135
+ };
136
+ };
137
+ /** One delivery, as stored and as published — it holds nothing the reader may not see. */
138
+ export declare const deliveryRow: z.ZodObject<{
139
+ id: z.ZodString;
140
+ topic: z.ZodString;
141
+ dedupe_key: z.ZodString;
142
+ principal: z.ZodString;
143
+ channel: z.ZodString;
144
+ status: z.ZodEnum<{
145
+ dead_lettered: "dead_lettered";
146
+ refused: "refused";
147
+ requested: "requested";
148
+ sent: "sent";
149
+ skipped: "skipped";
150
+ }>;
151
+ reason: z.ZodNullable<z.ZodString>;
152
+ request_id: z.ZodNullable<z.ZodString>;
153
+ about_type: z.ZodNullable<z.ZodString>;
154
+ about_id: z.ZodNullable<z.ZodString>;
155
+ created_at: z.ZodString;
156
+ settled_at: z.ZodNullable<z.ZodString>;
157
+ }, z.core.$strip>;
158
+ /**
159
+ * One delivery as the person it went to reads it: what became of it, and what it was about.
160
+ *
161
+ * Left out, deliberately: `principal` (always the reader), `dedupe_key` (the caller's idempotency
162
+ * key — an event id, an entity id — which says nothing to the person), `request_id` (the
163
+ * platform's send intent). `reason` is kept only where the engine wrote it — on a `skipped`
164
+ * row — and is `null` on a `refused` or `dead_lettered` one, whose reason is the platform's
165
+ * outcome code and belongs to whoever reads the whole log (`notifications:read`).
166
+ */
167
+ export declare const myDeliveryRow: z.ZodObject<{
168
+ id: z.ZodString;
169
+ topic: z.ZodString;
170
+ channel: z.ZodString;
171
+ status: z.ZodEnum<{
172
+ dead_lettered: "dead_lettered";
173
+ refused: "refused";
174
+ requested: "requested";
175
+ sent: "sent";
176
+ skipped: "skipped";
177
+ }>;
178
+ reason: z.ZodNullable<z.ZodString>;
179
+ about_type: z.ZodNullable<z.ZodString>;
180
+ about_id: z.ZodNullable<z.ZodString>;
181
+ created_at: z.ZodString;
182
+ settled_at: z.ZodNullable<z.ZodString>;
183
+ }, z.core.$strip>;
184
+ /** One pending digest item, as stored — read back only to compose the digest. */
185
+ export declare const digestItemRow: z.ZodObject<{
186
+ id: z.ZodString;
187
+ principal: z.ZodString;
188
+ channel: z.ZodString;
189
+ topic: z.ZodString;
190
+ dedupe_key: z.ZodString;
191
+ subject: z.ZodString;
192
+ text: z.ZodString;
193
+ html: z.ZodString;
194
+ about_type: z.ZodNullable<z.ZodString>;
195
+ about_id: z.ZodNullable<z.ZodString>;
196
+ queued_at: z.ZodString;
197
+ failed_at: z.ZodNullable<z.ZodString>;
198
+ quarantined_at: z.ZodNullable<z.ZodString>;
199
+ }, z.core.$strip>;
200
+ //# sourceMappingURL=entities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entities.d.ts","sourceRoot":"","sources":["../src/entities.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,oFAAoF;AACpF,eAAO,MAAM,aAAa;;EAA2B,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqFhC,CAAC;AAEH,0FAA0F;AAC1F,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;iBAAwD,CAAC;AAEjF;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;iBAUxB,CAAC;AAEH,iFAAiF;AACjF,eAAO,MAAM,aAAa;;;;;;;;;;;;;;iBAA2D,CAAC"}
@@ -0,0 +1,150 @@
1
+ import { defineEntities } from '@substrat-run/contracts';
2
+ import { z } from 'zod';
3
+ /** The cadences that hold a topic back for a digest — what a cadence row stores. */
4
+ export const digestCadence = z.enum(['daily-digest']);
5
+ /**
6
+ * engine-notifications' entities.
7
+ *
8
+ * - **`notification-topic`** is a kind of notification this scope has sent: recorded by
9
+ * `notify` the first time a topic is used, and kept to the caller's latest description and
10
+ * `required`. It is what a preferences screen lists and what a mute must name — the topics
11
+ * are the vertical's code, so the scope learns them by being told, not by registration.
12
+ * - **`notification-recipient`** is where a person's notifications go: one address per
13
+ * principal. It lives HERE rather than on an event because an address is erasable and an
14
+ * event is the one copy an erasure cannot rewrite (the same reason `todo.list-shared`
15
+ * carries a principal and no address), and module code has no other place to find one:
16
+ * the identity directory is not reachable through `ctx`. Erasing the principal deletes
17
+ * the row (K-46), and the send intents already written for them carry their `subjectId`,
18
+ * so a held send is cancelled by the same erasure.
19
+ * - **`notification-mute`** is a channel a person has switched off for one topic. Its
20
+ * presence is the fact: a topic is on until muted, so a new topic reaches people without
21
+ * a preference row being written for each of them first.
22
+ * - **`notification-delivery`** is what became of one (occasion, topic, person, channel). It
23
+ * names the principal and the platform request, never the address, so it is not
24
+ * personal data beyond the pseudonymous id the event spine already carries.
25
+ * `UNIQUE (dedupe_key, topic, principal, channel)` is the idempotency: the caller names the
26
+ * occasion (from a consumer, the event's id), and a repeat — a redelivered event — mails
27
+ * nobody twice. A digest is one delivery too, under the reserved topic `@digest`.
28
+ * - **`notification-cadence`** is a topic a person has asked to receive in a digest rather
29
+ * than at once, per channel. Like a mute, its presence is the fact: no row is `immediate`,
30
+ * which is what every person had before digests existed.
31
+ * - **`notification-digest-item`** is one occasion held for a person's next digest: the
32
+ * message as `notify` rendered it, keyed by the occasion's own delivery row (`id`). A row
33
+ * leaves when its digest is sent or a switch-off withdraws it. `failed_at` marks a person whose
34
+ * digest could not be written, so the next attempt waits; `quarantined_at` marks a row found
35
+ * unreadable, set aside — unsent, and in nobody's way — and stays until it reads again and is
36
+ * released, or an erasure deletes it; it is when the row was last read and found unreadable.
37
+ * The rendered text is about the occasion and addressed to the person, so it is erasable, and
38
+ * an erasure of the person deletes all their items with their address.
39
+ */
40
+ export const notificationsEntities = defineEntities({
41
+ 'notification-topic': {
42
+ table: 'notifications_topics',
43
+ fields: z.object({
44
+ topic: z.string(),
45
+ description: z.string(),
46
+ required: z.number(),
47
+ recorded_at: z.string(),
48
+ }),
49
+ primaryKey: ['topic'],
50
+ },
51
+ 'notification-recipient': {
52
+ table: 'notifications_recipients',
53
+ fields: z.object({
54
+ principal: z.string(),
55
+ email: z.string(),
56
+ updated_at: z.string(),
57
+ }),
58
+ primaryKey: ['principal'],
59
+ erasable: ['email'],
60
+ // The address is the person's data whole; nothing about the row survives them.
61
+ erasure: { subjects: ['principal'], mode: 'delete' },
62
+ },
63
+ 'notification-mute': {
64
+ table: 'notifications_mutes',
65
+ fields: z.object({
66
+ principal: z.string(),
67
+ topic: z.string(),
68
+ channel: z.string(),
69
+ created_at: z.string(),
70
+ }),
71
+ primaryKey: ['principal', 'topic', 'channel'],
72
+ },
73
+ 'notification-delivery': {
74
+ table: 'notifications_deliveries',
75
+ fields: z.object({
76
+ id: z.string(),
77
+ topic: z.string(),
78
+ dedupe_key: z.string(),
79
+ principal: z.string(),
80
+ channel: z.string(),
81
+ status: z.enum(['requested', 'skipped', 'sent', 'refused', 'dead_lettered']),
82
+ reason: z.string().nullable(),
83
+ request_id: z.string().nullable(),
84
+ about_type: z.string().nullable(),
85
+ about_id: z.string().nullable(),
86
+ created_at: z.string(),
87
+ settled_at: z.string().nullable(),
88
+ }),
89
+ key: ['dedupe_key', 'topic', 'principal', 'channel'],
90
+ },
91
+ 'notification-cadence': {
92
+ table: 'notifications_cadences',
93
+ fields: z.object({
94
+ principal: z.string(),
95
+ topic: z.string(),
96
+ channel: z.string(),
97
+ cadence: digestCadence,
98
+ updated_at: z.string(),
99
+ }),
100
+ primaryKey: ['principal', 'topic', 'channel'],
101
+ },
102
+ 'notification-digest-item': {
103
+ table: 'notifications_digest_items',
104
+ fields: z.object({
105
+ id: z.string(),
106
+ principal: z.string(),
107
+ channel: z.string(),
108
+ topic: z.string(),
109
+ dedupe_key: z.string(),
110
+ subject: z.string(),
111
+ text: z.string(),
112
+ html: z.string(),
113
+ about_type: z.string().nullable(),
114
+ about_id: z.string().nullable(),
115
+ queued_at: z.string(),
116
+ /** When the last attempt to send this person's digest failed; the next waits a window. */
117
+ failed_at: z.string().nullable(),
118
+ /** When this row was last read and found unreadable; null while it is held as usual. */
119
+ quarantined_at: z.string().nullable(),
120
+ }),
121
+ erasable: ['subject', 'text', 'html'],
122
+ // Mail addressed to the person: nothing about a pending item survives them.
123
+ erasure: { subjects: ['principal'], mode: 'delete' },
124
+ },
125
+ });
126
+ /** One delivery, as stored and as published — it holds nothing the reader may not see. */
127
+ export const deliveryRow = notificationsEntities['notification-delivery'].fields;
128
+ /**
129
+ * One delivery as the person it went to reads it: what became of it, and what it was about.
130
+ *
131
+ * Left out, deliberately: `principal` (always the reader), `dedupe_key` (the caller's idempotency
132
+ * key — an event id, an entity id — which says nothing to the person), `request_id` (the
133
+ * platform's send intent). `reason` is kept only where the engine wrote it — on a `skipped`
134
+ * row — and is `null` on a `refused` or `dead_lettered` one, whose reason is the platform's
135
+ * outcome code and belongs to whoever reads the whole log (`notifications:read`).
136
+ */
137
+ export const myDeliveryRow = deliveryRow.pick({
138
+ id: true,
139
+ topic: true,
140
+ channel: true,
141
+ status: true,
142
+ reason: true,
143
+ about_type: true,
144
+ about_id: true,
145
+ created_at: true,
146
+ settled_at: true,
147
+ });
148
+ /** One pending digest item, as stored — read back only to compose the digest. */
149
+ export const digestItemRow = notificationsEntities['notification-digest-item'].fields;
150
+ //# sourceMappingURL=entities.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entities.js","sourceRoot":"","sources":["../src/entities.ts"],"names":[],"mappings":"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"}