@house-rules/notifications 0.0.0-stage → 0.1.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/AGENTS.md +21 -0
- package/LICENSE +21 -0
- package/README.md +67 -2
- package/dist/adapters/memory/notification.d.ts +3 -0
- package/dist/adapters/memory/notification.js +197 -0
- package/dist/adapters/postgres/notification.d.ts +4 -0
- package/dist/adapters/postgres/notification.js +75 -0
- package/dist/contract.d.ts +22 -0
- package/dist/contract.js +5 -0
- package/dist/facade.d.ts +452 -0
- package/dist/facade.js +28 -0
- package/dist/index.d.ts +188 -0
- package/dist/index.js +16 -0
- package/dist/notification/bind-notifications.d.ts +3 -0
- package/dist/notification/bind-notifications.js +19 -0
- package/dist/notification/notification.d.ts +277 -0
- package/dist/notification/notification.js +108 -0
- package/dist/notification/notifications.d.ts +206 -0
- package/dist/notification/notifications.js +241 -0
- package/dist/notification/operations.d.ts +185 -0
- package/dist/notification/operations.js +119 -0
- package/dist/notification/options.d.ts +23 -0
- package/dist/notification/options.js +23 -0
- package/dist/notification/permissions.d.ts +6 -0
- package/dist/notification/permissions.js +5 -0
- package/dist/notification/timing.d.ts +4 -0
- package/dist/notification/timing.js +54 -0
- package/dist/storage.d.ts +36 -0
- package/dist/storage.js +3 -0
- package/migrations/0001_notifications.sql +61 -0
- package/package.json +57 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @house-rules/notifications: notes for agents
|
|
2
|
+
|
|
3
|
+
## Mounting
|
|
4
|
+
|
|
5
|
+
Use `NotificationsMount` with `Notifications.layerMemory(options)` or `Notifications.layerPostgres(options)` in composition/modules.ts. Provide one matching unit adapter. Postgres needs `SqlClient` and this package's migrations before acquisition. Register `{ "package": "@house-rules/notifications" }` in the app's module list.
|
|
6
|
+
|
|
7
|
+
## Options and ports
|
|
8
|
+
|
|
9
|
+
Options declare `recipientKind`, enabled `channels`, `categories` with unique names, default channels and mandatory flags, and `maxPageSize`. `dailyDigestTime` defaults to `08:00`; `maxDueBatch` defaults to 100 complete delivery groups, not rows. Retry options are `maxAttempts` (default 5), `retryBaseMs` (1000) and `retryCapMs` (3600000). `Notifications.presets.default` supplies these limits and all three channels. Add your recipient kind and categories. There is no host port. Person methods accept an explicit Actor and use `{ kind: recipientKind, id: actor.id }`.
|
|
10
|
+
|
|
11
|
+
## Fixed rules
|
|
12
|
+
|
|
13
|
+
Only system callers may use notify, dueDeliveries, markSent and markFailed bindings. Other operation bindings require a person. Writes join the feature's unit and never open one. `erase(recipient)` is a facade cascade hook, not an operation.
|
|
14
|
+
|
|
15
|
+
The module owns inbox rows, preferences, quiet windows and delivery ledger rows. It never sends, emits events, captures CurrentActor or imports event-log. NotificationCreated and NotificationRead schemas are in the contract; writes return their facts alongside notifications.
|
|
16
|
+
|
|
17
|
+
The inbox row is the in-app delivery. Never create in-app ledger rows. In-app is immediate and cannot be disabled. Mandatory default channels ignore saved off/digest modes and quiet hours. Quiet windows hold push/email until the next real local minute outside the window. Timing uses Intl and the Effect clock. `setMyTimeZone(actor, zone)` stores a person's zone independently of quiet hours; null restores UTC. Quiet hours contain only start/end. Digests and quiet windows use the same person zone.
|
|
18
|
+
|
|
19
|
+
Preference, quiet-hours and zone writes query only that person's pending/held rows, then refresh non-mandatory external rows in the same unit. Keep retryAt independent of dueAt: recompute quiet-window holds on retry rows, never scheduling before their backoff. Off cancels queued rows without delivery. Sent, cancelled and failed rows stay terminal; mandatory rows are untouched. Digest groups are keyed by recipient, channel and mode. Selection bounds groups and never splits their due rows.
|
|
20
|
+
|
|
21
|
+
Send schedule items through a separate app system feature and channel plugs. Settle their delivery ids with markSent only after sending. Use ledger ids as provider idempotency keys. markFailed increments attempts and stores the private error. Below maxAttempts it sets retryAt to now + min(retryBaseMs * 2^attempts, retryCapMs). For a non-mandatory external delivery, dueAt is held until quiet hours end when retryAt falls inside the recipient's window and zone; retryAt stays independent of dueAt. At maxAttempts it becomes terminal failed. dueDeliveries does not re-check quiet hours at selection, so a late polling tick can select a row whose scheduled time was before the window. No distributed lease or exactly-once provider guarantee exists.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jakub Szwajka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,68 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @house-rules/notifications
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An inbox with channel preferences, quiet hours and a delivery ledger. Push and email senders live outside this package.
|
|
4
|
+
|
|
5
|
+
## Mount
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const options = {
|
|
9
|
+
...Notifications.presets.default,
|
|
10
|
+
recipientKind: "person",
|
|
11
|
+
categories: [
|
|
12
|
+
{ name: "news", defaultChannels: ["inapp", "email"], mandatory: false },
|
|
13
|
+
{ name: "security", defaultChannels: ["email", "push"], mandatory: true },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// In composition/modules.ts, pair NotificationsMount with this layer.
|
|
18
|
+
const notifications = Notifications.layerPostgres(options);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Provide SqlClient and the SQL unit adapter for Postgres, or use layerMemory and the memory unit adapter. Run the shipped migrations through `@house-rules/migrations`. No contact, party or event-log service is required.
|
|
22
|
+
|
|
23
|
+
## Operations
|
|
24
|
+
|
|
25
|
+
Import `NotificationsOperations` and schemas from `@house-rules/notifications/contract`. `NotificationsMount` captures `bindNotifications` once during composition.
|
|
26
|
+
|
|
27
|
+
| Operation | Caller | Permission | Result |
|
|
28
|
+
| --- | --- | --- | --- |
|
|
29
|
+
| notify | system | notifications:write | `{ notifications, events: NotificationCreated[] }` |
|
|
30
|
+
| listMine | person | notifications:read | `{ notifications, next? }` |
|
|
31
|
+
| unreadCount | person | notifications:read | number |
|
|
32
|
+
| markRead, markAllRead | person | notifications:write | `{ notifications, events: NotificationRead[] }` |
|
|
33
|
+
| getMyPreferences | person | notifications:read | `{ preferences, quietHours, timeZone }` |
|
|
34
|
+
| setMyPreference, setMyQuietHours, setMyTimeZone | person | notifications:write | void |
|
|
35
|
+
| dueDeliveries | system | notifications:deliver | schedule item array |
|
|
36
|
+
| markSent, markFailed | system | notifications:deliver | void |
|
|
37
|
+
|
|
38
|
+
All writes require the feature's open unit. `notify` takes `{ recipients: [{ kind, id }], category, title, body, data }`. It deduplicates recipients and creates inbox and ledger rows atomically. It returns facts, never publishes them. The app feature decides whether to record these facts through event-log.
|
|
39
|
+
|
|
40
|
+
Mine operations derive a ref using configured recipientKind and the explicit actor id. They never accept another person's ref. Lists sort newest first with exclusive `(createdAt, id)` cursors and clamp requested size to maxPageSize. Read marks return facts only for rows that changed from unread to read. Missing, already-read and foreign ids return empty results.
|
|
41
|
+
|
|
42
|
+
`erase(recipient)` removes the inbox, ledger, preferences, time zone and quiet hours in one unit. It is a facade-only account deletion hook.
|
|
43
|
+
|
|
44
|
+
## Modes and timing
|
|
45
|
+
|
|
46
|
+
Enabled channels are inapp, push and email. An explicit non-off preference enables a non-default push/email channel; otherwise category defaults select channels. The inbox row is the in-app delivery, so no in-app ledger rows are created. In-app stays immediate. The inbox exists even when all external channels are off.
|
|
47
|
+
|
|
48
|
+
Mandatory categories force their default channels to immediate even if old stored preferences say off or digest. They ignore quiet hours. The preference setter rejects attempts to delay mandatory categories or disable/delay in-app.
|
|
49
|
+
|
|
50
|
+
Hourly digests use the next local hour boundary. Daily digests use the next local dailyDigestTime, default 08:00. `setMyTimeZone(actor, timeZone)` stores the person's IANA zone independently of quiet hours. Null clears it and restores UTC. `getMyPreferences` returns the stored zone or null. Digests and quiet hours use that zone. `setMyQuietHours(actor, { start, end })` accepts HH:mm values; null clears the window. Equal endpoints are invalid. A window can cross midnight.
|
|
51
|
+
|
|
52
|
+
Quiet hours hold external deliveries whose scheduled send falls inside the window. dueAt becomes the end of that window in the person's zone. DST gaps release at the first real minute outside the window. Repeated hours are checked as real instants. All current time reads use Effect's testable clock; Intl handles zone conversion. Changing preferences, quiet hours or time zone refreshes that person's pending/held non-mandatory external rows in the same unit. Refresh queries exclude terminal ledger history. Scheduling starts from the notification's creation time and keeps elapsed digest times due. Each row stores retryAt separately from dueAt, so a changed quiet window can release a retry sooner without shortening its backoff. Switching a channel off cancels its queued rows without delivery. Mandatory and terminal rows are untouched.
|
|
53
|
+
|
|
54
|
+
## Delivery work
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
app system feature -> notify -> inbox + ledger -> returned facts
|
|
58
|
+
schedule selector -> dueDeliveries -> one item per immediate row
|
|
59
|
+
-> one digest per recipient/channel/mode
|
|
60
|
+
schedule item -> channel sender -> markSent(deliveryIds)
|
|
61
|
+
-> markFailed(deliveryId, error)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
maxDueBatch bounds complete groups, default 100. Each item includes recipient, channel, mode and deliveries with the inbox content and ledger ids. Digest rows group by recipient, channel and mode, so hourly and daily work never mixes. One group's due rows are never split at the limit; a large group can exceed maxDueBatch rows. Immediate rows stay individual items. Sent, failed and cancelled rows are terminal and are not selected; pending and released held rows are. Selection does not re-check quiet hours: a delivery scheduled before a window can be returned during it if the polling tick runs late. Quiet hours govern the scheduled send time, not a later selection time.
|
|
65
|
+
|
|
66
|
+
markSent is idempotent, including repeated ids, and does not change an already sent row's timestamp or attempts. markFailed increments attempts and keeps the private error in the ledger, without losing the inbox notification. Below maxAttempts (default 5), it sets retryAt = now + min(retryBaseMs * 2^attempts, retryCapMs). Defaults are 1000 ms base and 3600000 ms cap. For a non-mandatory external delivery, if retryAt falls inside quiet hours, dueAt moves to the end of that window in the recipient's time zone. DST gaps and repeated hours use the same real-instant release rule as initial scheduling. Otherwise dueAt equals retryAt. dueDeliveries reselects retries at or after dueAt. At maxAttempts the row becomes terminal failed. Neither settlement method changes terminal rows; a late failure cannot overwrite sent.
|
|
67
|
+
|
|
68
|
+
Pass each ledger id as a provider idempotency key. Sending then settling is at least once: a crash between them can duplicate a send. There is no distributed lease, and read selection does not claim rows. Event-log outbox wiring and channel consumers belong to the app, so a rolled-back notification never produces committed delivery work.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { UnitOfWork } from "@house-rules/domain";
|
|
2
|
+
import { Effect, Layer } from "effect";
|
|
3
|
+
import { NotificationStoreUnavailable, } from "../../notification/notification.js";
|
|
4
|
+
import { NotificationStore } from "../../storage.js";
|
|
5
|
+
const same = (a, b) => a.kind === b.kind && a.id === b.id;
|
|
6
|
+
const key = (recipient) => JSON.stringify([recipient.kind, recipient.id]);
|
|
7
|
+
export const memoryNotificationStore = () => Layer.sync(NotificationStore, () => {
|
|
8
|
+
const notifications = new Map();
|
|
9
|
+
const deliveries = new Map();
|
|
10
|
+
const preferences = new Map();
|
|
11
|
+
const quiet = new Map();
|
|
12
|
+
const timeZones = new Map();
|
|
13
|
+
const change = Effect.fn("NotificationStore.memory.change")(function* (run) {
|
|
14
|
+
yield* UnitOfWork.required;
|
|
15
|
+
const snapshots = [
|
|
16
|
+
new Map(notifications),
|
|
17
|
+
new Map(deliveries),
|
|
18
|
+
new Map(preferences),
|
|
19
|
+
new Map(quiet),
|
|
20
|
+
new Map(timeZones),
|
|
21
|
+
];
|
|
22
|
+
yield* UnitOfWork.onRollback(Effect.sync(() => {
|
|
23
|
+
notifications.clear();
|
|
24
|
+
for (const [id, row] of snapshots[0])
|
|
25
|
+
notifications.set(id, row);
|
|
26
|
+
deliveries.clear();
|
|
27
|
+
for (const [id, row] of snapshots[1])
|
|
28
|
+
deliveries.set(id, row);
|
|
29
|
+
preferences.clear();
|
|
30
|
+
for (const [id, rows] of snapshots[2])
|
|
31
|
+
preferences.set(id, rows);
|
|
32
|
+
quiet.clear();
|
|
33
|
+
for (const [id, row] of snapshots[3])
|
|
34
|
+
quiet.set(id, row);
|
|
35
|
+
timeZones.clear();
|
|
36
|
+
for (const [id, row] of snapshots[4])
|
|
37
|
+
timeZones.set(id, row);
|
|
38
|
+
}));
|
|
39
|
+
run();
|
|
40
|
+
});
|
|
41
|
+
return NotificationStore.of({
|
|
42
|
+
insert: Effect.fn("NotificationStore.memory.insert")(function* (notification, rows) {
|
|
43
|
+
yield* UnitOfWork.required;
|
|
44
|
+
const naturalKeys = rows.map((row) => JSON.stringify([row.notificationId, row.channel, key(row.recipient)]));
|
|
45
|
+
if (notifications.has(notification.id) ||
|
|
46
|
+
new Set(rows.map((row) => row.id)).size !== rows.length ||
|
|
47
|
+
new Set(naturalKeys).size !== rows.length ||
|
|
48
|
+
rows.some((row) => deliveries.has(row.id) ||
|
|
49
|
+
row.notificationId !== notification.id ||
|
|
50
|
+
!same(row.recipient, notification.recipient)))
|
|
51
|
+
return yield* new NotificationStoreUnavailable({
|
|
52
|
+
cause: "Duplicate or inconsistent notification delivery.",
|
|
53
|
+
});
|
|
54
|
+
yield* change(() => {
|
|
55
|
+
notifications.set(notification.id, structuredClone(notification));
|
|
56
|
+
for (const row of rows)
|
|
57
|
+
deliveries.set(row.id, structuredClone(row));
|
|
58
|
+
});
|
|
59
|
+
}),
|
|
60
|
+
list: (recipient, page) => Effect.sync(() => [...notifications.values()]
|
|
61
|
+
.filter((row) => same(row.recipient, recipient) &&
|
|
62
|
+
(page.after === undefined ||
|
|
63
|
+
row.createdAt < page.after.createdAt ||
|
|
64
|
+
(row.createdAt === page.after.createdAt && row.id < page.after.id)))
|
|
65
|
+
.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : a.id === b.id ? 0 : -1))
|
|
66
|
+
.slice(0, page.size)
|
|
67
|
+
.map((row) => structuredClone(row))),
|
|
68
|
+
unreadCount: (recipient) => Effect.sync(() => [...notifications.values()].filter((row) => same(row.recipient, recipient) && row.readAt === null).length),
|
|
69
|
+
markRead: Effect.fn("NotificationStore.memory.markRead")(function* (recipient, at, id) {
|
|
70
|
+
const changed = [];
|
|
71
|
+
yield* change(() => {
|
|
72
|
+
for (const [rowId, row] of notifications)
|
|
73
|
+
if (same(row.recipient, recipient) &&
|
|
74
|
+
row.readAt === null &&
|
|
75
|
+
(id === undefined || rowId === id)) {
|
|
76
|
+
const next = { ...row, readAt: at };
|
|
77
|
+
notifications.set(rowId, next);
|
|
78
|
+
changed.push(structuredClone(next));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return changed.sort((a, b) => (a.id < b.id ? -1 : a.id === b.id ? 0 : 1));
|
|
82
|
+
}),
|
|
83
|
+
preferences: (recipient) => Effect.sync(() => structuredClone(preferences.get(key(recipient)) ?? [])),
|
|
84
|
+
setPreference: (recipient, preference) => change(() => {
|
|
85
|
+
const entries = preferences.get(key(recipient)) ?? [];
|
|
86
|
+
preferences.set(key(recipient), [
|
|
87
|
+
...entries.filter((row) => row.category !== preference.category || row.channel !== preference.channel),
|
|
88
|
+
structuredClone(preference),
|
|
89
|
+
]);
|
|
90
|
+
}),
|
|
91
|
+
quietHours: (recipient) => Effect.sync(() => structuredClone(quiet.get(key(recipient)) ?? null)),
|
|
92
|
+
setQuietHours: (recipient, hours) => change(() => {
|
|
93
|
+
if (hours === null)
|
|
94
|
+
quiet.delete(key(recipient));
|
|
95
|
+
else
|
|
96
|
+
quiet.set(key(recipient), structuredClone(hours));
|
|
97
|
+
}),
|
|
98
|
+
timeZone: (recipient) => Effect.sync(() => timeZones.get(key(recipient)) ?? null),
|
|
99
|
+
setTimeZone: (recipient, timeZone) => change(() => {
|
|
100
|
+
if (timeZone === null)
|
|
101
|
+
timeZones.delete(key(recipient));
|
|
102
|
+
else
|
|
103
|
+
timeZones.set(key(recipient), timeZone);
|
|
104
|
+
}),
|
|
105
|
+
deliveries: (recipient, query) => Effect.sync(() => [...deliveries.values()]
|
|
106
|
+
.filter((row) => same(row.recipient, recipient) &&
|
|
107
|
+
(!query?.unsettledOnly || row.state === "pending" || row.state === "held"))
|
|
108
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id === b.id ? 0 : 1))
|
|
109
|
+
.flatMap((delivery) => {
|
|
110
|
+
const notification = notifications.get(delivery.notificationId);
|
|
111
|
+
return notification === undefined
|
|
112
|
+
? []
|
|
113
|
+
: [structuredClone({ delivery, notification })];
|
|
114
|
+
})),
|
|
115
|
+
delivery: (id) => Effect.sync(() => {
|
|
116
|
+
const delivery = deliveries.get(id);
|
|
117
|
+
const notification = delivery === undefined ? undefined : notifications.get(delivery.notificationId);
|
|
118
|
+
return delivery === undefined || notification === undefined
|
|
119
|
+
? null
|
|
120
|
+
: structuredClone({ delivery, notification });
|
|
121
|
+
}),
|
|
122
|
+
reschedule: (id, schedule) => change(() => {
|
|
123
|
+
const row = deliveries.get(id);
|
|
124
|
+
if (row !== undefined && (row.state === "pending" || row.state === "held"))
|
|
125
|
+
deliveries.set(id, {
|
|
126
|
+
...row,
|
|
127
|
+
mode: schedule.mode,
|
|
128
|
+
state: schedule.state,
|
|
129
|
+
dueAt: schedule.dueAt,
|
|
130
|
+
});
|
|
131
|
+
}),
|
|
132
|
+
due: (at, limit) => Effect.sync(() => {
|
|
133
|
+
const rows = [...deliveries.values()]
|
|
134
|
+
.filter((row) => (row.state === "pending" || row.state === "held") && row.dueAt <= at)
|
|
135
|
+
.sort((a, b) => a.dueAt - b.dueAt || (a.id < b.id ? -1 : a.id === b.id ? 0 : 1));
|
|
136
|
+
const groups = new Set();
|
|
137
|
+
return rows
|
|
138
|
+
.filter((row) => {
|
|
139
|
+
const group = row.mode === "immediate"
|
|
140
|
+
? row.id
|
|
141
|
+
: JSON.stringify([key(row.recipient), row.channel, row.mode]);
|
|
142
|
+
if (!groups.has(group) && groups.size >= limit)
|
|
143
|
+
return false;
|
|
144
|
+
groups.add(group);
|
|
145
|
+
return true;
|
|
146
|
+
})
|
|
147
|
+
.flatMap((delivery) => {
|
|
148
|
+
const notification = notifications.get(delivery.notificationId);
|
|
149
|
+
return notification === undefined
|
|
150
|
+
? []
|
|
151
|
+
: [structuredClone({ delivery, notification })];
|
|
152
|
+
});
|
|
153
|
+
}),
|
|
154
|
+
markSent: (ids, at) => change(() => {
|
|
155
|
+
for (const id of new Set(ids)) {
|
|
156
|
+
const row = deliveries.get(id);
|
|
157
|
+
if (row !== undefined &&
|
|
158
|
+
row.state !== "sent" &&
|
|
159
|
+
row.state !== "failed" &&
|
|
160
|
+
row.state !== "cancelled")
|
|
161
|
+
deliveries.set(id, {
|
|
162
|
+
...row,
|
|
163
|
+
state: "sent",
|
|
164
|
+
sentAt: at,
|
|
165
|
+
attempts: row.attempts + 1,
|
|
166
|
+
lastError: null,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}),
|
|
170
|
+
markFailed: (id, error, at, retry) => change(() => {
|
|
171
|
+
const row = deliveries.get(id);
|
|
172
|
+
if (row !== undefined && (row.state === "pending" || row.state === "held")) {
|
|
173
|
+
const attempts = row.attempts + 1;
|
|
174
|
+
const retryAt = at + Math.min(retry.baseMs * 2 ** attempts, retry.capMs);
|
|
175
|
+
deliveries.set(id, {
|
|
176
|
+
...row,
|
|
177
|
+
state: attempts >= retry.maxAttempts ? "failed" : "pending",
|
|
178
|
+
dueAt: attempts >= retry.maxAttempts ? row.dueAt : retryAt,
|
|
179
|
+
retryAt: attempts >= retry.maxAttempts ? row.retryAt : retryAt,
|
|
180
|
+
attempts,
|
|
181
|
+
lastError: error,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}),
|
|
185
|
+
erase: (recipient) => change(() => {
|
|
186
|
+
for (const [id, row] of deliveries)
|
|
187
|
+
if (same(row.recipient, recipient))
|
|
188
|
+
deliveries.delete(id);
|
|
189
|
+
for (const [id, row] of notifications)
|
|
190
|
+
if (same(row.recipient, recipient))
|
|
191
|
+
notifications.delete(id);
|
|
192
|
+
preferences.delete(key(recipient));
|
|
193
|
+
quiet.delete(key(recipient));
|
|
194
|
+
timeZones.delete(key(recipient));
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { UnitOfWork } from "@house-rules/domain";
|
|
2
|
+
import { Effect, Layer, Schema } from "effect";
|
|
3
|
+
import { SqlClient } from "effect/sql/SqlClient";
|
|
4
|
+
import { DeliveryItem, Notification, NotificationStoreUnavailable, Preference, QuietHours, TimeZone, } from "../../notification/notification.js";
|
|
5
|
+
import { NotificationStore } from "../../storage.js";
|
|
6
|
+
const unavailable = (cause) => new NotificationStoreUnavailable({ cause });
|
|
7
|
+
export const postgresNotificationStore = Layer.effect(NotificationStore, Effect.gen(function* () {
|
|
8
|
+
const sql = yield* SqlClient;
|
|
9
|
+
const columns = sql.literal(`id, json_build_object('kind', recipient_kind, 'id', recipient_id) as recipient, category, title, body, data, created_at::float8 as "createdAt", read_at::float8 as "readAt"`);
|
|
10
|
+
const deliveryColumns = sql.literal(`json_build_object('id', d.id, 'notificationId', d.notification_id, 'recipient', json_build_object('kind', d.recipient_kind, 'id', d.recipient_id), 'channel', d.channel, 'mode', d.mode, 'state', d.state, 'dueAt', d.due_at::float8, 'retryAt', d.retry_at::float8, 'sentAt', d.sent_at::float8, 'attempts', d.attempts, 'lastError', d.last_error) as delivery, json_build_object('id', n.id, 'recipient', json_build_object('kind', n.recipient_kind, 'id', n.recipient_id), 'category', n.category, 'title', n.title, 'body', n.body, 'data', n.data, 'createdAt', n.created_at::float8, 'readAt', n.read_at::float8) as notification`);
|
|
11
|
+
const read = (schema, query) => query.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(schema))), Effect.mapError(unavailable));
|
|
12
|
+
const write = Effect.fn("NotificationStore.postgres.write")(function* (query) {
|
|
13
|
+
yield* UnitOfWork.required;
|
|
14
|
+
yield* query.pipe(Effect.mapError(unavailable));
|
|
15
|
+
});
|
|
16
|
+
return NotificationStore.of({
|
|
17
|
+
insert: Effect.fn("NotificationStore.postgres.insert")(function* (notification, deliveries) {
|
|
18
|
+
yield* write(sql `insert into notifications (id, recipient_kind, recipient_id, category, title, body, data, created_at, read_at) values (${notification.id}, ${notification.recipient.kind}, ${notification.recipient.id}, ${notification.category}, ${notification.title}, ${notification.body}, ${JSON.stringify(notification.data)}::jsonb, ${notification.createdAt}, ${notification.readAt})`);
|
|
19
|
+
for (const row of deliveries)
|
|
20
|
+
yield* write(sql `insert into notification_deliveries (id, notification_id, recipient_kind, recipient_id, channel, mode, state, due_at, retry_at, sent_at, attempts, last_error) values (${row.id}, ${row.notificationId}, ${row.recipient.kind}, ${row.recipient.id}, ${row.channel}, ${row.mode}, ${row.state}, ${row.dueAt}, ${row.retryAt}, ${row.sentAt}, ${row.attempts}, ${row.lastError})`);
|
|
21
|
+
}),
|
|
22
|
+
list: (recipient, page) => read(Notification, page.after === undefined
|
|
23
|
+
? sql `select ${columns} from notifications where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id} order by created_at desc, id collate "C" desc limit ${page.size}`
|
|
24
|
+
: sql `select ${columns} from notifications where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id} and (created_at, id collate "C") < (${page.after.createdAt}, ${page.after.id}) order by created_at desc, id collate "C" desc limit ${page.size}`),
|
|
25
|
+
unreadCount: (recipient) => read(Schema.Struct({ count: Schema.Int }), sql `select count(*)::int as count from notifications where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id} and read_at is null`).pipe(Effect.map((rows) => rows[0]?.count ?? 0)),
|
|
26
|
+
markRead: Effect.fn("NotificationStore.postgres.markRead")(function* (recipient, at, id) {
|
|
27
|
+
yield* UnitOfWork.required;
|
|
28
|
+
const rows = yield* read(Notification, id === undefined
|
|
29
|
+
? sql `update notifications set read_at = ${at} where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id} and read_at is null returning ${columns}`
|
|
30
|
+
: sql `update notifications set read_at = ${at} where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id} and id = ${id} and read_at is null returning ${columns}`);
|
|
31
|
+
return [...rows].sort((a, b) => (a.id < b.id ? -1 : a.id === b.id ? 0 : 1));
|
|
32
|
+
}),
|
|
33
|
+
preferences: (recipient) => read(Preference, sql `select category, channel, mode from notification_preferences where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id} order by category collate "C", channel collate "C"`),
|
|
34
|
+
setPreference: (recipient, preference) => write(sql `insert into notification_preferences (recipient_kind, recipient_id, category, channel, mode) values (${recipient.kind}, ${recipient.id}, ${preference.category}, ${preference.channel}, ${preference.mode}) on conflict (recipient_kind, recipient_id, category, channel) do update set mode = excluded.mode`),
|
|
35
|
+
quietHours: (recipient) => read(QuietHours, sql `select start_time as start, end_time as end from notification_quiet_hours where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id}`).pipe(Effect.map((rows) => rows[0] ?? null)),
|
|
36
|
+
setQuietHours: (recipient, hours) => write(hours === null
|
|
37
|
+
? sql `delete from notification_quiet_hours where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id}`
|
|
38
|
+
: sql `insert into notification_quiet_hours (recipient_kind, recipient_id, start_time, end_time) values (${recipient.kind}, ${recipient.id}, ${hours.start}, ${hours.end}) on conflict (recipient_kind, recipient_id) do update set start_time = excluded.start_time, end_time = excluded.end_time`),
|
|
39
|
+
timeZone: (recipient) => read(Schema.Struct({ timeZone: TimeZone }), sql `select time_zone as "timeZone" from notification_time_zones where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id}`).pipe(Effect.map((rows) => rows[0]?.timeZone ?? null)),
|
|
40
|
+
setTimeZone: (recipient, timeZone) => write(timeZone === null
|
|
41
|
+
? sql `delete from notification_time_zones where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id}`
|
|
42
|
+
: sql `insert into notification_time_zones (recipient_kind, recipient_id, time_zone) values (${recipient.kind}, ${recipient.id}, ${timeZone}) on conflict (recipient_kind, recipient_id) do update set time_zone = excluded.time_zone`),
|
|
43
|
+
deliveries: (recipient, query) => read(DeliveryItem, sql `select ${deliveryColumns} from notification_deliveries d join notifications n on n.id = d.notification_id where d.recipient_kind = ${recipient.kind} and d.recipient_id = ${recipient.id} ${query?.unsettledOnly ? sql `and d.state in ('pending', 'held')` : sql ``} order by d.id collate "C"`),
|
|
44
|
+
delivery: (id) => read(DeliveryItem, sql `select ${deliveryColumns} from notification_deliveries d join notifications n on n.id = d.notification_id where d.id = ${id}`).pipe(Effect.map((rows) => rows[0] ?? null)),
|
|
45
|
+
reschedule: (id, schedule) => write(sql `update notification_deliveries set mode = ${schedule.mode}, state = ${schedule.state}, due_at = ${schedule.dueAt} where id = ${id} and state in ('pending', 'held')`),
|
|
46
|
+
due: (at, limit) => read(DeliveryItem, sql `with eligible as (
|
|
47
|
+
select *, case when mode = 'immediate' then jsonb_build_array(id)
|
|
48
|
+
else jsonb_build_array(recipient_kind, recipient_id, channel, mode) end as group_key
|
|
49
|
+
from notification_deliveries where state in ('pending', 'held') and due_at <= ${at}
|
|
50
|
+
), selected as (
|
|
51
|
+
select group_key from eligible group by group_key
|
|
52
|
+
order by min(due_at), min(id collate "C") limit ${limit}
|
|
53
|
+
) select ${deliveryColumns} from eligible d join selected s on s.group_key = d.group_key
|
|
54
|
+
join notifications n on n.id = d.notification_id order by d.due_at, d.id collate "C"`),
|
|
55
|
+
markSent: Effect.fn("NotificationStore.postgres.markSent")(function* (ids, at) {
|
|
56
|
+
yield* UnitOfWork.required;
|
|
57
|
+
for (const id of new Set(ids))
|
|
58
|
+
yield* write(sql `update notification_deliveries set state = 'sent', sent_at = ${at}, attempts = attempts + 1, last_error = null where id = ${id} and state <> 'sent' and state not in ('failed', 'cancelled')`);
|
|
59
|
+
}),
|
|
60
|
+
markFailed: (id, error, at, retry) => write(sql `update notification_deliveries set
|
|
61
|
+
state = case when attempts + 1 >= ${retry.maxAttempts} then 'failed' else 'pending' end,
|
|
62
|
+
due_at = case when attempts + 1 >= ${retry.maxAttempts} then due_at
|
|
63
|
+
else ${at}::bigint + least(${retry.baseMs}::numeric * power(2::numeric, attempts + 1), ${retry.capMs}::numeric)::bigint end,
|
|
64
|
+
retry_at = case when attempts + 1 >= ${retry.maxAttempts} then retry_at
|
|
65
|
+
else ${at}::bigint + least(${retry.baseMs}::numeric * power(2::numeric, attempts + 1), ${retry.capMs}::numeric)::bigint end,
|
|
66
|
+
attempts = attempts + 1, last_error = ${error}
|
|
67
|
+
where id = ${id} and state in ('pending', 'held')`),
|
|
68
|
+
erase: Effect.fn("NotificationStore.postgres.erase")(function* (recipient) {
|
|
69
|
+
yield* write(sql `delete from notifications where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id}`);
|
|
70
|
+
yield* write(sql `delete from notification_preferences where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id}`);
|
|
71
|
+
yield* write(sql `delete from notification_quiet_hours where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id}`);
|
|
72
|
+
yield* write(sql `delete from notification_time_zones where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id}`);
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
}));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { NotificationsOperations } from "./notification/operations.js";
|
|
2
|
+
export { type NotificationPermission, NotificationPermissions, } from "./notification/permissions.js";
|
|
3
|
+
export { Channel, Delivery, DeliveryItem, DeliveryMode, DueDelivery, LocalTime, MyPreferences, Notification, NotificationCreated, NotificationCursor, NotificationForbidden, NotificationInputInvalid, NotificationList, NotificationNotFound, NotificationPage, NotificationRead, NotificationStoreUnavailable, NotifyInput, NotifyResult, Preference, QuietHours, ReadResult, Recipient, TimeZone, } from "./notification/notification.js";
|
|
4
|
+
export declare const NotificationEvents: {
|
|
5
|
+
readonly NotificationCreated: import("effect/Schema").Struct<{
|
|
6
|
+
readonly kind: import("effect/Schema").Literal<"NotificationCreated">;
|
|
7
|
+
readonly notificationId: import("effect/Schema").NonEmptyString;
|
|
8
|
+
readonly recipient: import("effect/Schema").Struct<{
|
|
9
|
+
readonly kind: import("effect/Schema").NonEmptyString;
|
|
10
|
+
readonly id: import("effect/Schema").NonEmptyString;
|
|
11
|
+
}>;
|
|
12
|
+
readonly channels: import("effect/Schema").$Array<import("effect/Schema").Literals<readonly ["inapp", "push", "email"]>>;
|
|
13
|
+
}>;
|
|
14
|
+
readonly NotificationRead: import("effect/Schema").Struct<{
|
|
15
|
+
readonly kind: import("effect/Schema").Literal<"NotificationRead">;
|
|
16
|
+
readonly notificationId: import("effect/Schema").NonEmptyString;
|
|
17
|
+
readonly recipient: import("effect/Schema").Struct<{
|
|
18
|
+
readonly kind: import("effect/Schema").NonEmptyString;
|
|
19
|
+
readonly id: import("effect/Schema").NonEmptyString;
|
|
20
|
+
}>;
|
|
21
|
+
}>;
|
|
22
|
+
};
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { NotificationsOperations } from "./notification/operations.js";
|
|
2
|
+
export { NotificationPermissions, } from "./notification/permissions.js";
|
|
3
|
+
export { Channel, Delivery, DeliveryItem, DeliveryMode, DueDelivery, LocalTime, MyPreferences, Notification, NotificationCreated, NotificationCursor, NotificationForbidden, NotificationInputInvalid, NotificationList, NotificationNotFound, NotificationPage, NotificationRead, NotificationStoreUnavailable, NotifyInput, NotifyResult, Preference, QuietHours, ReadResult, Recipient, TimeZone, } from "./notification/notification.js";
|
|
4
|
+
import { NotificationCreated, NotificationRead } from "./notification/notification.js";
|
|
5
|
+
export const NotificationEvents = { NotificationCreated, NotificationRead };
|