npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@broberg/notifications

v0.4.0

Published

Headless in-app notification core for the broberg.ai fleet — one counting rule shared by every app, so the list and the OS badge can never disagree. The package never owns your count query, your database, or your list UI: you supply a store, it owns the c

Readme

@broberg/notifications

Headless in-app notification core for the broberg.ai fleet: the bell, the list, and the OS badge all read one number, so they cannot disagree — for every change that goes through the core.

That last clause is not hedging, it is the contract. Write, clear or delete a row around the core and nothing announces it: the badge keeps its old number over a list that has already changed. Route your mutations through here (including deletes — see Deleting rows) and the guarantee holds.

The package does not own your database, your count query, or your list UI. You supply a store; it owns the choreography.

npm i @broberg/notifications

v0.2.0 — a failing fan-out no longer undoes the mutation

Filed by moovyy after adopting 0.1.0, and it bit in production shape.

onCountChanged is awaited after every mutation. Until 0.2.0 it was awaited bare — so a dead phone, a 410 Gone, or a wrong VAPID key rejected the whole notify(), after the row was already written. Their case: a downloaded film would not be booked because a number could not be moved. And a caller that retries then writes the row twice.

The write already happened, so the operation succeeded; only the announcement failed. Those are two different facts, and the caller is entitled to the first.

createNotifications({
  store,
  onCountChanged: (subjectId, count) => sendSilent({ badge: count }),
  onCountChangedError: (err, subjectId, count) => log.warn({ err, subjectId, count }),
})

It is never swallowed. Without onCountChangedError the failure goes to console.error. Silence would leave the badge and the list disagreeing with nobody told — which is the defect this package exists to prevent, moved inside the package.

A failing STORE still throws. Only the fan-out is forgiving: if the write did not happen, the caller must hear about it. Both directions have a test.

Two things to do when you upgrade from 0.1.0

Both reported by moovyy after running the upgrade against their own suite rather than taking the release note's word for it.

1 · You will silently lose a red test. If you wrapped onCountChanged in your own try/catch — as you should have on 0.1.0 — the mutation "remove the consumer's error handling" was RED before this release and is GREEN after, because the guard now lives here. That is correct, and it is invisible: nothing tells you a test stopped defending anything. Re-run your mutation pass after upgrading, and delete or re-aim the ones that have gone equivalent.

And this applies to your FIRST adoption too, not only to upgrades. The sentence above says upgrade because that is where moovyy hit it, and read literally it lets a new consumer skip the check. It should not: whatever you were doing by hand before you installed this package — your own try/catch, your own recount after a clear, your own badge write — is now done here, so the tests guarding it are equivalent mutants from the moment you adopt. Run the pass when you wire the package up, not only when you bump it.

Re-running is only half of it — moovyy's sharpening, after they did the first half and caught themselves. They found the equivalent mutant and wrote it in the commit message, but the test kept its old name and its old promise: "a failing badge must not cost the message". The next person reads the file, not yesterday's commit, and believes it still guards their error handling. It does not.

A test whose name promises something it no longer holds is worse than no test. It is a claim somebody believes.

They re-aimed rather than deleted: it is now named as a contract test, its comment says it proves this package's guarantee rather than their own code, and it goes red again if a future release rolls the guarantee back. That is the shape to copy.

2 · onCountChangedError is where YOUR log shape goes — not an optional extra. The fallback is deliberately neutral: one plain console.error line, no [ERROR] prefix, no emoji, because a package has no business deciding how your logs look. The consequence is that in a viewer which highlights on markers, the fallback does not stand out — moovyy measured that a phone which never receives its number disappears into the grey stream. Pass the handler and format it the way the rest of your system is formatted:

onCountChangedError: (err, subjectId, count) =>
  log.error(`❌ badge ${count} not delivered to ${subjectId}`, err),

Why this exists

Three apps built the same notification list. They looked different and behaved the same — and the part they got wrong was never the menu.

On xrt81 the bell counted unopened notifications while a test route counted unread messages. The route sent badge = 0, which does not mean "no badge" but "remove the badge" — so the test deleted the number it existed to prove. One counting rule, in one place, is the whole product.

Use

import { createNotifications } from "@broberg/notifications";

const notifications = createNotifications({
  store,                                    // your database, behind 5 methods
  onCountChanged: async (subjectId, count) => {
    sse.publish(subjectId, { count });      // open clients
    await syncBadge(subjectId, count);      // closed phones — @broberg/webpush
  },
});

await notifications.notify(userId, row);              // → { count }
await notifications.markAllSeen(userId);              // → { clearedIds, count }
await notifications.markSeenByRef(userId, ["ny-film"], driveId);
await notifications.unseenCount(userId);              // → number

onCountChanged fires after every mutation, with the recounted number. That is the enforcement: the row and the badge change together or not at all.

The counting rule

unseenCount is "things that pushed you and you have not opened" — never "everything you have not read".

And the destination rule, from xrt81:

Count a notification only if there is somewhere the user can go that CLEARS it. Whether that destination is stored on the row or derived does not matter — but if it cannot be derived, the row must fall out of the count rather than count with a link that clears nothing. A number no action can remove teaches people to ignore it.

If your table only has read_at

unseenCount means things that pushed you and you have not opened — which is not the same as everything you have not read. A table with a single read_at column has collapsed the two, and that is worth saying out loud rather than leaving you to discover it.

If your app never distinguishes them — one list, every row pushes, opening it is reading it — map read_at onto seenAt and you are done. That is a real and common shape; nothing here is broken by it.

If your app has rows that do NOT push (a digest, a settings change, anything written for the record), do not map it. read_at IS NULL will then count rows that never asked for the user's attention, the badge shows a number the user cannot explain, and the fastest thing you can teach someone is to ignore a badge. Add a second column, or exclude those kinds in countUnseen.

We do not offer a mapping helper, deliberately: which of your rows push is a fact only your app has, and a helper would have to guess it.

The core never writes the count query — you do

store.countUnseen(subjectId) is your SQL, and it is deliberately yours, because the obvious rule is wrong:

-- WRONG for at least two of the three consumers
SELECT count(*) FROM notifications WHERE subject_id = ? AND seen_at IS NULL

Both cardmem and xrt81 exclude a user's muted kinds from the count, so unread and counted are different sets. Measured on cardmem's production (2026-08-18) one live user had raw_unseen = 50 against a shown count of 1 — a badge permanently red with a kind they had switched off, which is the fastest way to teach someone to ignore a badge.

If the core assumed seen IS NULL, both apps would have to keep a second counter, which is exactly the defect above.

Scope of that evidence, stated plainly: cardmem and xrt81 each measured one user, one muted kind, and zero simultaneous mutes. Two measurements of the same narrow shape are one proof twice, not two proofs. The rule is right in principle; it is not yet measured broadly.

createMemoryStore() ships as the reference implementation and a real starting point — but it counts seenAt === null and nothing else. Swap in your own query the moment kinds can be muted.

clearedIds exists exactly once — a contract, not a tip

Every clearing call returns the ids whose seenAt transitioned on that call — never the ids you requested, so an already-seen row and somebody else's row are both absent.

It is how a surface highlights "here are the three that were new" after a badge brought the user in. There is no way to ask for it again: the rows are seen afterwards, so a second call returns an empty set.

const { clearedIds } = await notifications.markAllSeen(userId);
holdForThisVisit(clearedIds);          // ✅ survives navigation
// ❌ re-deriving the highlight per render loses the rest after the first tap

The row

interface NotificationRow {
  id: string;
  kind: string;            // your category — an UNKNOWN kind must still count
  title: string;           // required, non-blank
  body: string | null;
  navigate: string | null; // where tapping it goes
  refId: string | null;    // WHAT it is about — see markSeenByRef
  createdAt: number;       // epoch ms
  seenAt: number | null;   // null = unseen
}

title / body / navigate are exactly the three fields @broberg/webpush's buildPayload reads, so the same names run the whole length of the pipe. A consumer once passed its own field names into that boundary and every message would have arrived empty — and four mutations survived it, because the tests read send()'s return value and never the body on the wire.

Extend it structurally. cardmem adds projectId, moovyy a poster; no core change, no cast, no generic to thread through your call-sites.

interface MyRow extends NotificationRow { poster: string }
const store = createMemoryStore<MyRow>();

refId and markSeenByRef

The rule nothing fails without: read the thing somewhere else in the app, and the notification about it clears. Leave it out and nothing breaks — the bell just keeps promising something the user already read.

The store

interface NotificationStore<Row extends NotificationRow = NotificationRow> {
  insert(subjectId, row): Promise<void>;
  countUnseen(subjectId): Promise<number>;                 // THE one counting place
  markSeen(subjectId, ids): Promise<string[]>;             // transitioned ids
  markAllSeen(subjectId): Promise<string[]>;
  markSeenByRef(subjectId, kinds, refId): Promise<string[]>;

  remove?(subjectId, ids): Promise<string[]>;                // removed ids — OPTIONAL
  removeAll?(subjectId): Promise<string[]>;
}

Every mark* returns the ids whose seenAt went from null on this call. remove* returns the ids actually removed — the transition, never the request.

Deleting rows (v0.4.0)

Filed by fd-sundhed, who read it out of the published 0.3.0 rather than guessing: their bell lets a user delete a notification, not only mark it read — built after a sync sent 42 false messages the owner had to clear one at a time.

On 0.3.0 the store had no delete, so that deletion happened around the core: onCountChanged never fired, the badge kept its number, the list was empty. That is verbatim the defect this package exists to prevent, in the one corner it did not cover — and the consumer who deletes got the worst variant, because it looked protected.

const { removedIds, count } = await notifications.remove(userId, ['n1', 'n2']);
await notifications.removeAll(userId);

if (notifications.canRemove) { /* render the delete control */ }

remove and removeAll are optional on the store, and that is a decision, not an oversight. Required would break every consumer on 0.2.x/0.3.0 the day they upgrade, and they have done nothing wrong. So instead:

  • A store without the pair gets one named warning at createNotifications() — not from remove(). That is fd-sundhed's correction and it is the whole point: a consumer who deletes rows in their own table never calls remove(), so an error thrown there would never reach the person actually in danger.
  • Construction still succeeds and everything else still works.
  • notifications.remove() on a store that cannot remove throws a named error. Secondary to the warning, never a substitute for it.
  • notifications.canRemove (asked for by cardmem) lets you hide a delete control rather than render one that throws. Without it the defect only moves — from the badge lies to the button does not do what it says.

removedIds is deliberately not called clearedIds. A cleared row still exists and can be highlighted; a removed row is gone. Two different facts, two different names.

What is deliberately absent

  • A list component. Three consumers, three brands, three row shapes. A shared renderer becomes a prop-soup nobody dares change.
  • A clearOn option. Clearing on app-open versus on interaction is which call-site you put markAllSeen() on — the core cannot know when your app opens, and an option describing something it does not execute is documentation disguised as API.
  • User preferences. Muting lives in your count query, because a core that owned it would fit exactly one app.
  • Push. sendSilent / syncBadge belong to @broberg/webpush. Wire them in onCountChanged; this package has no runtime dependencies at all.
  • A prune/TTL policy — still absent, and remove() is not one. Deleting rows on a user's instruction and deleting them on a schedule are different features; the second needs numbers we do not have. fd-sundhed goes live in September with 16,838 users against xrt81's 15, three orders of magnitude apart, so a policy designed today would be designed on a guess. Neither production consumer prunes today. xrt81 measured ~11,000 rows/year for a 15-person club and calls the absence a gap; cardmem warned against designing a TTL from an assumption they have one. It gets its own release, with real numbers.

Notes

The server-only write path is first-class: notify() works with no client anywhere — no tab, no foreground app, a locked phone — because one consumer's normal state is a background watcher writing rows with nobody watching. Your silent push hangs off onCountChanged.


Part of the broberg.ai shared inventory. Built by components · F074.


@broberg/notifications/shell — the bell and its panel (F074.4, in progress)

Not published yet. The headless state machine is here; the /react and /preact wrappers are not. Nothing to adopt until they land.

Four apps each hand-rolled the same bell, and each got the same things wrong. The row is not the shared part — three brands, three row shapes. The shell is:

import { createBellShell } from "@broberg/notifications/shell";

const shell = createBellShell({
  labels,                                   // REQUIRED — see below
  countUnseen: () => notifications.unseenCount(userId),
  markAllSeen: () => notifications.markAllSeen(userId),
  loadRows:    () => db.recentFor(userId, 10),
});

labels is required, and there are no defaults anywhere

Not a convenience — the absence of a default is the feature.

A consumer's bell had shipped Danish text for months in an app whose UI must be English. The giveaway was Loading… in English one line away: nobody chose Danish; nobody chose anything. A default lets "no decision" look exactly like "a decision", and every consumer inherits whichever language we happened to write. Omitting labels fails your typecheck instead.

aria-labels are in there too, for the reason the visible strings are not the problem: buttons get translated because someone can see them.

⚠️ Two different settings that are easy to confuse, and the confusion is structural rather than anyone's mistake:

  • Project language governs what language the agent writes to the human in (chat, reports, intercom).
  • App-UI language is what the user sees on screen.

They are independent, and in at least one fleet repo they are deliberately different: Danish chat, English UI. A session that reads the first as the second builds the wrong screen with a clear conscience — which is what makes it hard to catch in review.

The stack, not "one dialog at a time"

A row can open a modal on top of the still-open panel. pushLayer stacks; popLayer closes the top and returns the focus target that layer declared — for an alarm opened from a row, that is the row, not the bell.

Escape is an outcome, not a close

shell.pushLayer({ id: "alarm", modal: true, focusReturn: rowEl });
shell.escape();   // → "keep": a modal does not close on Escape by default

A shell that hardcoded "Escape closes" would let someone dismiss an incident alarm with one keypress and nothing recorded. That is not an accessibility detail; it is whether the alarm means anything. Non-modal layers get "close" for free; a modal layer must decide, via onEscape.

One count, however many bells

The count lives in the shell, not in the bell. A consumer rendering the bell in a mobile bar and a desktop bar gets one number — the production bug this replaces was two components each counting, where "mark all read" zeroed one of them and the other sat at 2 with every row read.

clearedIds is held for the visit

markAllSeen() returns the ids it actually cleared, and that set exists exactly once — afterwards the rows are seen. Open the app, see three highlighted, tap one, go back, and the other two are gone if you re-derive the highlight per render. The shell holds it until the panel closes.

The anchor is captured at the instant of opening

open(rect) takes the trigger's measured rect, because a dropdown anchors from it at the moment it opens. A bottom-sheet consumer passes null — the shell is never indifferent to anchoring; it is told.

If you have a fixed bottom bar

Set overscroll-behavior: none on it. The panel opens over it, and on an iPhone in standalone the rubber-band makes the bar float. It is your CSS, not ours, but it is only reproducible on that one configuration.