@ultimat3/notify
Advanced tools
| // The two sweeps over this package's tables, read off the installed seam rather than off a store | ||
| // somebody handed the caller. `setNotifyStores` is an APP's boot line and runs after the boot that | ||
| // owns the hourly sweep, so the sweep cannot hold the stores — it can only ask, per attempt, what | ||
| // is installed now. Same shape and the same reason as `purgeAuthLimits()`. | ||
| import type { InboxPurgeBefore, PgInboxStore } from './inbox-pg'; | ||
| import type { PgDeliveryLedger } from './ledger-pg'; | ||
| import { notifyStores } from './stores'; | ||
| /** | ||
| * Whether the installed store can delete by age at all. | ||
| * | ||
| * A DECLARED capability check, never duck typing: `PgInboxStore` and `PgDeliveryLedger` widen the | ||
| * seam their memory siblings satisfy, so "has this method" is exactly "is this the Postgres one". | ||
| * The memory stores deliberately have none — a heap map is bounded by process life, and adding the | ||
| * method to `InboxStore`/`DeliveryLedger` would break every app that wrote its own implementation. | ||
| */ | ||
| const purgeable = <T>(store: unknown, method: string): store is T => | ||
| typeof store === 'object' && | ||
| store !== null && | ||
| typeof (store as Record<string, unknown>)[method] === 'function'; | ||
| /** | ||
| * Delete inbox rows past whichever windows the app named, and answer how many. | ||
| * | ||
| * ZERO IS THE ANSWER FOR "nothing to do", and there are three ways to reach it — no inbox | ||
| * installed, a memory inbox, or both windows unset. None of them is an error: an app that never | ||
| * configured retention has made a decision, and a sweep that threw would take the other framework | ||
| * tables' sweep down with it. | ||
| */ | ||
| export async function purgeNotifyInbox(before: InboxPurgeBefore): Promise<number> { | ||
| const store = notifyStores().inbox; | ||
| if (!purgeable<PgInboxStore>(store, 'purgeBefore')) return 0; | ||
| return store.purgeBefore(before); | ||
| } | ||
| /** | ||
| * Delete delivery claims past the ledger's own window, and answer how many. The window is the | ||
| * LEDGER's, never this caller's: `createPgDeliveryLedger({ windowMs })` is where an app states it, | ||
| * beside the statement that reads it, so there is one number rather than two that can disagree. | ||
| * | ||
| * `nowMs` is the job's clock for the reason `x_rate_limit`'s target states — `at` is written by | ||
| * whichever process took the delivery, so a cutoff computed inside Postgres measures the offset | ||
| * between two clocks rather than the age of the row. | ||
| */ | ||
| export async function purgeNotifyDeliveries(nowMs: number): Promise<number> { | ||
| const ledger = notifyStores().ledger; | ||
| if (!purgeable<PgDeliveryLedger>(ledger, 'purgeExpired')) return 0; | ||
| return ledger.purgeExpired(nowMs); | ||
| } |
+22
-5
@@ -74,2 +74,3 @@ # @ultimat3/notify — boundary | ||
| | `stores.ts` | the one installer for all four | | ||
| | `retention.ts` | the two sweeps, read off the installed seam | | ||
| | `errors.ts` | this package's `X_NOTIFY_*` codes and their titles | | ||
@@ -87,10 +88,26 @@ | ||
| ## Homeless work this package cannot do | ||
| ## Retention | ||
| `packages/cli/src/dev-queue.ts`'s `applySchema` installs the jobs, idempotency, rate-limit and | ||
| auth-limit tables. **It does not install `x_notify_deliveries` or `x_notify_inbox`**, so an app using | ||
| the Postgres stores runs that DDL itself until they join the list — the same gap | ||
| `SQL_AUDIT_TABLE` has. | ||
| Both tables are swept by the boot's hourly `x.purge` job, and neither store is handed to it: | ||
| `setNotifyStores` is an APP's boot line that runs when the app's modules import, after the boot that | ||
| installs the sweep. So `retention.ts` reads the seam **per attempt** — the same shape as | ||
| `purgeAuthLimits()` — and answers `0` for a memory store or none at all, which is a boot that made a | ||
| decision rather than a failure. | ||
| | Table | Window | Named where | | ||
| |---|---|---| | ||
| | `x_notify_deliveries` | `PgDeliveryLedgerOptions.windowMs`, default 24 h | beside the statement that reads it. **Never shorter than the app's idempotency window** — a job replayed inside that window against a purged claim claims cleanly and sends twice. Pass `idempotency.windowMs` | | ||
| | `x_notify_inbox` | `notify.inboxReadRetentionMs` / `notify.inboxUnreadRetentionMs` in `AppConfig`, **both absent by default** | the app's `app.config.ts`, because an inbox row is a message a person has not read yet and when it disappears is a product decision (axiom 8) | | ||
| `purgeBefore` and `purgeExpired` live on the **Postgres stores' own wider types** | ||
| (`PgInboxStore`, `PgDeliveryLedger`), never on `InboxStore`/`DeliveryLedger`: adding a method to the | ||
| seam every implementation must satisfy is a breaking change for an app that wrote its own, and a | ||
| heap map bounded by process life has nothing to delete. Exactly the shape `PostgresIdempotencyStore` | ||
| already has. | ||
| `packages/cli/src/framework-schema.ts` applies both tables' DDL on every boot, **whether or not that | ||
| boot calls `setNotifyStores`** — this file said it did not until 2026-08-27, which is a sentence that | ||
| outlived its fact. | ||
| Commands: `bun test packages/notify/src`, `bun run boundaries`, | ||
| `bunx biome check packages/notify`. |
+6
-6
| { | ||
| "name": "@ultimat3/notify", | ||
| "version": "17.0.0", | ||
| "version": "18.0.0", | ||
| "description": "Notifications: one declaration, many channels — fan-out, preference gate, digest window, delivery ledger, in-app inbox", | ||
@@ -31,3 +31,3 @@ "license": "MIT", | ||
| "engines": { | ||
| "bun": ">=1.3.0" | ||
| "bun": ">=1.4.0" | ||
| }, | ||
@@ -39,7 +39,7 @@ "scripts": { | ||
| "dependencies": { | ||
| "@ultimat3/core": "17.0.0", | ||
| "@ultimat3/jobs": "17.0.0", | ||
| "@ultimat3/schema": "17.0.0", | ||
| "@ultimat3/time": "17.0.0" | ||
| "@ultimat3/core": "18.0.0", | ||
| "@ultimat3/jobs": "18.0.0", | ||
| "@ultimat3/schema": "18.0.0", | ||
| "@ultimat3/time": "18.0.0" | ||
| } | ||
| } |
+31
-1
@@ -108,2 +108,3 @@ # @ultimat3/notify | ||
| declare const executor: PgExecutor; // `@ultimat3/cli`'s pgExecutorFor(client) | ||
| declare const idempotency: { readonly windowMs: number }; // the boot's own store | ||
| // The app's preference table, behind whatever taxonomy it named. | ||
@@ -115,3 +116,5 @@ declare const prefs: { | ||
| setNotifyStores({ | ||
| ledger: createPgDeliveryLedger({ executor }), | ||
| // `windowMs` is how long a settled claim is kept. NEVER SHORTER than your idempotency window — | ||
| // a job replayed inside that window against a purged claim claims cleanly and sends twice. | ||
| ledger: createPgDeliveryLedger({ executor, windowMs: idempotency.windowMs }), | ||
| inbox: createPgInboxStore({ executor }), | ||
@@ -137,2 +140,29 @@ digest: createMemoryDigestStore(), | ||
| ## Retention | ||
| Both tables grow with traffic, and the boot's hourly `x.purge` job sweeps both — but only against | ||
| the **Postgres** stores. `createPgInboxStore` carries `purgeBefore` and `createPgDeliveryLedger` | ||
| carries `purgeExpired`; the memory ones do not, and a boot that installed a memory store sweeps | ||
| nothing. The methods are on those stores' own wider types (`PgInboxStore`, `PgDeliveryLedger`), not | ||
| on `InboxStore`/`DeliveryLedger`, so an app that wrote its own implementation is unaffected. | ||
| | Table | Window | Default | | ||
| |---|---|---| | ||
| | `x_notify_deliveries` | `createPgDeliveryLedger({ windowMs })` | 24 h. A settled claim ages from its **last** attempt — `settle` moves `at` | | ||
| | `x_notify_inbox` | `notify.inboxReadRetentionMs` / `notify.inboxUnreadRetentionMs` in `app.config.ts` | **neither** — never swept | | ||
| The inbox default is deliberate and is not a missing number. An inbox row is a message a person has | ||
| not read yet, so when it disappears is your decision, not the framework's — which is why the key | ||
| lives in **your** config and why there are two of them: read notices gone in a month with unread | ||
| ones kept forever is the shape most apps want, and it is only expressible if the two windows are | ||
| separate. | ||
| ```ts | ||
| // app.config.ts | ||
| notify: { inboxReadRetentionMs: 30 * 24 * 60 * 60 * 1000 } | ||
| ``` | ||
| A read row ages from `read_at` and an unread one from `created_at` — ageing a read row from | ||
| `created_at` would delete a notification the moment the recipient opened an old one. | ||
| ## Channels | ||
@@ -139,0 +169,0 @@ |
+49
-1
@@ -77,2 +77,19 @@ // The shared in-app inbox: one Postgres table, applied by the boot the way `x_jobs` is. | ||
| /** | ||
| * Both windows in ONE statement, and each half is inert when its window is absent: a `null` | ||
| * cutoff makes its `is not null` guard false, so the other half runs alone. One round trip, and | ||
| * — more importantly — no way to express "purge read rows" and "purge unread rows" as two | ||
| * statements that could disagree about which rows are which. | ||
| * | ||
| * A READ row ages from `read_at` and an UNREAD one from `created_at`. Ageing a read row from | ||
| * `created_at` would delete a notification the moment the recipient opened it, if it happened to | ||
| * be old — which is the opposite of what a read window means. | ||
| */ | ||
| export const SQL_NOTIFY_INBOX_PURGE = ` | ||
| delete from x_notify_inbox | ||
| where ($1::timestamptz is not null and read_at is not null and read_at < $1) | ||
| or ($2::timestamptz is not null and read_at is null and created_at < $2) | ||
| returning id | ||
| `; | ||
| export const SQL_NOTIFY_INBOX_MARK_SEEN = ` | ||
@@ -116,3 +133,23 @@ update x_notify_inbox set seen_at = $2 where recipient = $1 and seen_at is null returning id | ||
| export function createPgInboxStore(options: PgInboxStoreOptions): InboxStore { | ||
| /** | ||
| * The two cutoffs, each `undefined` where its window is unset. Never a single window: the axiom-8 | ||
| * objection to sweeping an inbox is only about UNREAD messages, so the two have to be separately | ||
| * expressible or an app that wants read notices gone in a month is forced to choose between | ||
| * deleting unread ones too and sweeping nothing. | ||
| */ | ||
| export interface InboxPurgeBefore { | ||
| readonly read?: Date | undefined; | ||
| readonly unread?: Date | undefined; | ||
| } | ||
| /** | ||
| * The Postgres inbox's own wider type. `purgeBefore` is HERE and not on `InboxStore` for the | ||
| * reason `PgDeliveryLedger.purgeExpired` is not on `DeliveryLedger`: adding a method to the seam | ||
| * every implementation must satisfy is a breaking change for an app that wrote its own. | ||
| */ | ||
| export interface PgInboxStore extends InboxStore { | ||
| purgeBefore(before: InboxPurgeBefore): Promise<number>; | ||
| } | ||
| export function createPgInboxStore(options: PgInboxStoreOptions): PgInboxStore { | ||
| const { executor } = options; | ||
@@ -165,2 +202,13 @@ const newId = options.newId ?? uuid; | ||
| }, | ||
| async purgeBefore(before) { | ||
| // Neither window set is not an error and not a no-op to leave to Postgres: the statement | ||
| // would run, match nothing and cost a scan on every hourly sweep of every app that never | ||
| // configured retention, which is every app by default. | ||
| if (before.read === undefined && before.unread === undefined) return 0; | ||
| const rows = await executor.query<{ id: string }>(SQL_NOTIFY_INBOX_PURGE, [ | ||
| before.read ?? null, | ||
| before.unread ?? null, | ||
| ]); | ||
| return rows.length; | ||
| }, | ||
| async markSeen(input) { | ||
@@ -167,0 +215,0 @@ const rows = await executor.query<{ id: string }>(SQL_NOTIFY_INBOX_MARK_SEEN, [ |
+6
-2
@@ -47,3 +47,3 @@ // Public API of @ultimat3/notify. Explicit re-exports only — no `export *`. | ||
| export { createMemoryInboxStore, DEFAULT_INBOX_PAGE } from './inbox'; | ||
| export type { PgInboxStoreOptions } from './inbox-pg'; | ||
| export type { InboxPurgeBefore, PgInboxStore, PgInboxStoreOptions } from './inbox-pg'; | ||
| export { | ||
@@ -55,2 +55,3 @@ createPgInboxStore, | ||
| SQL_NOTIFY_INBOX_PAGE, | ||
| SQL_NOTIFY_INBOX_PURGE, | ||
| SQL_NOTIFY_INBOX_TABLE, | ||
@@ -73,6 +74,8 @@ SQL_NOTIFY_INBOX_UNREAD, | ||
| } from './ledger'; | ||
| export type { PgDeliveryLedgerOptions } from './ledger-pg'; | ||
| export type { PgDeliveryLedger, PgDeliveryLedgerOptions } from './ledger-pg'; | ||
| export { | ||
| createPgDeliveryLedger, | ||
| DEFAULT_DELIVERY_WINDOW_MS, | ||
| SQL_NOTIFY_CLAIM, | ||
| SQL_NOTIFY_DELIVERIES_PURGE, | ||
| SQL_NOTIFY_DELIVERIES_TABLE, | ||
@@ -100,2 +103,3 @@ SQL_NOTIFY_FIND, | ||
| export { allowAllPreferences, createMemoryPreferenceStore } from './preferences'; | ||
| export { purgeNotifyDeliveries, purgeNotifyInbox } from './retention'; | ||
| export type { InstalledNotifyStores, NotifyStores } from './stores'; | ||
@@ -102,0 +106,0 @@ export { |
+64
-1
@@ -7,2 +7,3 @@ // The shared delivery ledger: one Postgres table, `insert … on conflict` for the atomicity. | ||
| import { finiteCount } from '@ultimat3/core'; | ||
| import type { PgExecutor } from '@ultimat3/jobs'; | ||
@@ -84,9 +85,71 @@ import type { DeliveryClaim, DeliveryLedger, DeliveryRecord, DeliveryStatus } from './ledger'; | ||
| /** | ||
| * Delete every claim older than the window, and answer how many. `at` is the claim's own | ||
| * timestamp and `SQL_NOTIFY_SETTLE` moves it, so a row ages from its LAST attempt rather than its | ||
| * first — a delivery still being retried is never swept out from under the retry. | ||
| * | ||
| * Unconditional on status, deliberately. A `sending` row past the window belonged to a process | ||
| * that died and never came back; keeping it forever protects nothing, because a re-claim is | ||
| * already allowed on it (see `DeliveryLedger.claim`). | ||
| */ | ||
| export const SQL_NOTIFY_DELIVERIES_PURGE = ` | ||
| delete from x_notify_deliveries where at < $1 | ||
| `; | ||
| /** | ||
| * The statement the store actually runs. A bare `delete` answers NO ROWS through `PgExecutor`, so | ||
| * a count read off it is zero forever — a sweep that reports it deleted nothing while deleting | ||
| * everything, which is indistinguishable in a log from a sweep that is not wired at all. | ||
| * `SQL_IDEMPOTENCY_PURGE` is spliced the same way for the same reason. | ||
| */ | ||
| const SQL_NOTIFY_DELIVERIES_PURGE_COUNTED = `${SQL_NOTIFY_DELIVERIES_PURGE} returning key`; | ||
| export interface PgDeliveryLedgerOptions { | ||
| readonly executor: PgExecutor; | ||
| /** | ||
| * How long a settled claim is kept. Defaults to `DEFAULT_DELIVERY_WINDOW_MS`. | ||
| * | ||
| * NEVER SHORTER THAN YOUR IDEMPOTENCY WINDOW, and this is the one dangerous direction: a job | ||
| * replayed inside the idempotency window, against a delivery claim that has already been | ||
| * purged, claims cleanly and sends the notification a second time. `createPgDeliveryLedger({ | ||
| * executor, windowMs: idempotency.windowMs })` makes that impossible by construction rather | ||
| * than by two defaults that happen to agree. | ||
| */ | ||
| readonly windowMs?: number | undefined; | ||
| } | ||
| export function createPgDeliveryLedger(options: PgDeliveryLedgerOptions): DeliveryLedger { | ||
| /** 24 hours — the same default `postgresIdempotencyStore` carries, for the reason above. */ | ||
| export const DEFAULT_DELIVERY_WINDOW_MS = 24 * 60 * 60 * 1000; | ||
| /** | ||
| * The Postgres ledger's own wider type. `purgeExpired` is HERE and not on `DeliveryLedger` | ||
| * because a heap map bounded by process life has nothing to delete, and adding a method to the | ||
| * seam every implementation must satisfy is a breaking change for an app that wrote its own. | ||
| * Exactly the shape `PostgresIdempotencyStore` already has. | ||
| */ | ||
| export interface PgDeliveryLedger extends DeliveryLedger { | ||
| readonly windowMs: number; | ||
| purgeExpired(nowMs: number): Promise<number>; | ||
| } | ||
| export function createPgDeliveryLedger(options: PgDeliveryLedgerOptions): PgDeliveryLedger { | ||
| const { executor } = options; | ||
| const windowMs = finiteCount( | ||
| 'createPgDeliveryLedger', | ||
| 'windowMs', | ||
| options.windowMs ?? DEFAULT_DELIVERY_WINDOW_MS, | ||
| 1, | ||
| ); | ||
| return { | ||
| windowMs, | ||
| async purgeExpired(nowMs) { | ||
| // The JOB's clock, not the server's, and the same rule `x_rate_limit`'s target states: `at` | ||
| // is written by whichever process took the delivery, so a cutoff computed from `now()` in | ||
| // Postgres measures the offset between two clocks instead of the age of the row. | ||
| const before = new Date(finiteCount('purgeExpired', 'nowMs', nowMs, 0) - windowMs); | ||
| const rows = await executor.query<{ key: string }>(SQL_NOTIFY_DELIVERIES_PURGE_COUNTED, [ | ||
| before, | ||
| ]); | ||
| return rows.length; | ||
| }, | ||
| async claim(claim, at) { | ||
@@ -93,0 +156,0 @@ const rows = await executor.query<{ attempts: number }>(SQL_NOTIFY_CLAIM, [ |
118162
10.62%25
4.17%2088
7.96%247
13.82%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated