@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 returns the row to pending at now + min(retryBaseMs * 2^attempts, retryCapMs). At maxAttempts it becomes terminal failed. dueDeliveries reselects pending retries after backoff. No distributed lease or exactly-once provider guarantee exists.
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 pending with dueAt = now + min(retryBaseMs * 2^attempts, retryCapMs). Defaults are 1000 ms base and 3600000 ms cap. dueDeliveries reselects retries after backoff. At maxAttempts the row becomes terminal failed. Neither settlement method changes terminal rows; a late failure cannot overwrite sent.
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
- yield* store.markFailed(id, detail, yield* now, {
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.0",
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.2.0",
40
- "@house-rules/domain": "0.2.0",
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.2.0",
49
- "@house-rules/domain": "0.2.0",
50
- "@house-rules/migrations": "0.4.0",
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"