New:Socket for Asana Is Now Available.Learn more
Get Started

@ultimat3/notify

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/notify - npm Package Compare versions

Comparing version
17.0.0
to
18.0.0
+50
src/retention.ts
// 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"
}
}

@@ -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 @@

@@ -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, [

@@ -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 {

@@ -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, [