@house-rules/notifications 0.1.0 → 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
CHANGED
|
@@ -18,4 +18,4 @@ The inbox row is the in-app delivery. Never create in-app ledger rows. In-app is
|
|
|
18
18
|
|
|
19
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
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
|
|
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/README.md
CHANGED
|
@@ -61,8 +61,8 @@ schedule item -> channel sender -> markSent(deliveryIds)
|
|
|
61
61
|
-> markFailed(deliveryId, error)
|
|
62
62
|
```
|
|
63
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.
|
|
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
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
|
|
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
67
|
|
|
68
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.
|
|
@@ -112,6 +112,13 @@ export const memoryNotificationStore = () => Layer.sync(NotificationStore, () =>
|
|
|
112
112
|
? []
|
|
113
113
|
: [structuredClone({ delivery, notification })];
|
|
114
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
|
+
}),
|
|
115
122
|
reschedule: (id, schedule) => change(() => {
|
|
116
123
|
const row = deliveries.get(id);
|
|
117
124
|
if (row !== undefined && (row.state === "pending" || row.state === "held"))
|
|
@@ -41,6 +41,7 @@ export const postgresNotificationStore = Layer.effect(NotificationStore, Effect.
|
|
|
41
41
|
? sql `delete from notification_time_zones where recipient_kind = ${recipient.kind} and recipient_id = ${recipient.id}`
|
|
42
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
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)),
|
|
44
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')`),
|
|
45
46
|
due: (at, limit) => read(DeliveryItem, sql `with eligible as (
|
|
46
47
|
select *, case when mode = 'immediate' then jsonb_build_array(id)
|
|
@@ -210,11 +210,28 @@ export const makeNotifications = (store, options) => {
|
|
|
210
210
|
yield* UnitOfWork.required;
|
|
211
211
|
const id = yield* decode(Schema.NonEmptyString)(deliveryId);
|
|
212
212
|
const detail = yield* decode(Schema.String)(error);
|
|
213
|
-
|
|
213
|
+
const at = yield* now;
|
|
214
|
+
const retry = {
|
|
214
215
|
maxAttempts: options.maxAttempts ?? 5,
|
|
215
216
|
baseMs: options.retryBaseMs ?? 1000,
|
|
216
217
|
capMs: options.retryCapMs ?? 3_600_000,
|
|
217
|
-
}
|
|
218
|
+
};
|
|
219
|
+
yield* store.markFailed(id, detail, at, retry);
|
|
220
|
+
const item = yield* store.delivery(id);
|
|
221
|
+
if (item === null || item.delivery.state !== "pending")
|
|
222
|
+
return;
|
|
223
|
+
if (item.delivery.channel === "inapp")
|
|
224
|
+
return;
|
|
225
|
+
const category = categoryOf(item.notification.category);
|
|
226
|
+
if (category === undefined || category.mandatory)
|
|
227
|
+
return;
|
|
228
|
+
const hours = yield* store.quietHours(item.delivery.recipient);
|
|
229
|
+
if (hours === null)
|
|
230
|
+
return;
|
|
231
|
+
const retryAt = item.delivery.retryAt ?? at;
|
|
232
|
+
const dueAt = releaseAt(retryAt, hours, (yield* store.timeZone(item.delivery.recipient)) ?? "UTC");
|
|
233
|
+
if (dueAt > retryAt)
|
|
234
|
+
yield* store.reschedule(item.delivery.id, { ...item.delivery, state: "held", dueAt });
|
|
218
235
|
}),
|
|
219
236
|
erase: Effect.fn("Notifications.erase")(function* (recipient) {
|
|
220
237
|
yield* UnitOfWork.required;
|
package/dist/storage.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ declare const NotificationStore_base: Context.ServiceClass<NotificationStore, "@
|
|
|
20
20
|
readonly deliveries: (recipient: Recipient, query?: {
|
|
21
21
|
readonly unsettledOnly: boolean;
|
|
22
22
|
}) => Read<readonly DeliveryItem[]>;
|
|
23
|
+
readonly delivery: (id: string) => Read<DeliveryItem | null>;
|
|
23
24
|
readonly reschedule: (id: string, schedule: Pick<Delivery, "mode" | "state" | "dueAt">) => Write;
|
|
24
25
|
readonly due: (at: number, limit: number) => Read<readonly DeliveryItem[]>;
|
|
25
26
|
readonly markSent: (ids: readonly string[], at: number) => Write;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@house-rules/notifications",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Inbox, channel preferences, quiet hours and a delivery ledger.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"migrations": "migrations"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@house-rules/application": "0.
|
|
40
|
-
"@house-rules/domain": "0.2.
|
|
39
|
+
"@house-rules/application": "0.3.0",
|
|
40
|
+
"@house-rules/domain": "0.2.1",
|
|
41
41
|
"effect": "4.0.3"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"@effect/platform-node-shared": "4.0.3",
|
|
46
46
|
"@effect/sql-pg": "4.0.3",
|
|
47
47
|
"@effect/vitest": "4.0.3",
|
|
48
|
-
"@house-rules/application": "0.
|
|
49
|
-
"@house-rules/domain": "0.2.
|
|
50
|
-
"@house-rules/migrations": "0.4.
|
|
48
|
+
"@house-rules/application": "0.3.0",
|
|
49
|
+
"@house-rules/domain": "0.2.1",
|
|
50
|
+
"@house-rules/migrations": "0.4.1",
|
|
51
51
|
"effect": "4.0.3",
|
|
52
52
|
"typescript": "7.0.2",
|
|
53
53
|
"vitest": "5.0.1"
|