@substrat-run/engine-notifications 0.0.0-stage → 0.3.0
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/LICENSE +661 -0
- package/README.md +172 -2
- package/dist/entities.d.ts +200 -0
- package/dist/entities.d.ts.map +1 -0
- package/dist/entities.js +150 -0
- package/dist/entities.js.map +1 -0
- package/dist/events.d.ts +212 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +20 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +411 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +889 -0
- package/dist/index.js.map +1 -0
- package/dist/model.d.ts +11 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +18 -0
- package/dist/model.js.map +1 -0
- package/dist/operations.d.ts +243 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +139 -0
- package/dist/operations.js.map +1 -0
- package/dist/render.d.ts +60 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +84 -0
- package/dist/render.js.map +1 -0
- package/dist/seam.d.ts +5 -0
- package/dist/seam.d.ts.map +1 -0
- package/dist/seam.js +14 -0
- package/dist/seam.js.map +1 -0
- package/package.json +50 -3
package/README.md
CHANGED
|
@@ -1,3 +1,173 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @substrat-run/engine-notifications
|
|
2
2
|
|
|
3
|
-
|
|
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"}
|
package/dist/entities.js
ADDED
|
@@ -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":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,oFAAoF;AACpF,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,cAAc,CAAC;IAClD,oBAAoB,EAAE;QACpB,KAAK,EAAE,sBAAsB;QAC7B,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC;YACf,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;YACjB,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;YACvB,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE;YACpB,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;SACxB,CAAC;QACF,UAAU,EAAE,CAAC,OAAO,CAAC;KACtB;IACD,wBAAwB,EAAE;QACxB,KAAK,EAAE,0BAA0B;QACjC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC;YACf,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;YACrB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;YACjB,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;SACvB,CAAC;QACF,UAAU,EAAE,CAAC,WAAW,CAAC;QACzB,QAAQ,EAAE,CAAC,OAAO,CAAC;QACnB,+EAA+E;QAC/E,OAAO,EAAE,EAAE,QAAQ,EAAE,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE;KACrD;IACD,mBAAmB,EAAE;QACnB,KAAK,EAAE,qBAAqB;QAC5B,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC;YACf,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;YACrB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;YACjB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;YACnB,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;SACvB,CAAC;QACF,UAAU,EAAE,CAAC,WAAW,EAAE,OAAO,EAAE,SAAS,CAAC;KAC9C;IACD,uBAAuB,EAAE;QACvB,KAAK,EAAE,0BAA0B;QACjC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC;YACf,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;YACd,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;YACjB,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;YACtB,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;YACrB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;YACnB,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,eAAe,CAAC,CAAC;YAC5E,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC7B,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YACjC,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YACjC,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC/B,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;YACtB,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;SAClC,CAAC;QACF,GAAG,EAAE,CAAC,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,CAAC;KACrD;IACD,sBAAsB,EAAE;QACtB,KAAK,EAAE,wBAAwB;QAC/B,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC;YACf,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;YACrB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;YACjB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;YACnB,OAAO,EAAE,aAAa;YACtB,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;SACvB,CAAC;QACF,UAAU,EAAE,CAAC,WAAW,EAAE,OAAO,EAAE,SAAS,CAAC;KAC9C;IACD,0BAA0B,EAAE;QAC1B,KAAK,EAAE,4BAA4B;QACnC,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC;YACf,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;YACd,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;YACrB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;YACnB,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE;YACjB,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE;YACtB,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;YACnB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;YAChB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;YAChB,UAAU,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YACjC,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAC/B,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE;YACrB,0FAA0F;YAC1F,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;YAChC,wFAAwF;YACxF,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;SACtC,CAAC;QACF,QAAQ,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC;QACrC,4EAA4E;QAC5E,OAAO,EAAE,EAAE,QAAQ,EAAE,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE;KACrD;CACF,CAAC,CAAC;AAEH,0FAA0F;AAC1F,MAAM,CAAC,MAAM,WAAW,GAAG,qBAAqB,CAAC,uBAAuB,CAAC,CAAC,MAAM,CAAC;AAEjF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,CAAC,IAAI,CAAC;IAC5C,EAAE,EAAE,IAAI;IACR,KAAK,EAAE,IAAI;IACX,OAAO,EAAE,IAAI;IACb,MAAM,EAAE,IAAI;IACZ,MAAM,EAAE,IAAI;IACZ,UAAU,EAAE,IAAI;IAChB,QAAQ,EAAE,IAAI;IACd,UAAU,EAAE,IAAI;IAChB,UAAU,EAAE,IAAI;CACjB,CAAC,CAAC;AAEH,iFAAiF;AACjF,MAAM,CAAC,MAAM,aAAa,GAAG,qBAAqB,CAAC,0BAA0B,CAAC,CAAC,MAAM,CAAC"}
|