From 194678f14d2444cf63f41583ecca8e2c31ddcc8e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 9 Sep 2026 19:01:13 -0400 Subject: [PATCH] push: a command for who is actually listening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm run push:subscribers reads the live rows in `push-tokens` — the ones the sender will really fan out to — and groups them by topic. Devices and people are counted apart, because one reader with a laptop and a phone is two devices and one subscriber, and the difference matters when the whole list is small. Naming a topic also lists its devices. Legacy FCM-era rows carry no `kind` and are skipped here exactly as shared/push.mjs skips them, so the number is what a broadcast would reach rather than an inflated row count. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AGbWC1RD3FzZTgbW7S4zDx --- package.json | 1 + toolchain/push-subscribers.mjs | 103 +++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 toolchain/push-subscribers.mjs diff --git a/package.json b/package.json index bcf7bc7744..83d075e780 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "pop:ddex": "node pop/bin/ddex.mjs", "pop:site": "node pop/bin/publish-release-records.mjs", "doctor": "node toolchain/doctor.mjs", + "push:subscribers": "node toolchain/push-subscribers.mjs", "menubar:parity": "node slab/bin/menubar-parity.mjs", "domain": "node toolchain/domains/domain.mjs", "laklok:parity": "node toolchain/laklok-sisters/parity.mjs", diff --git a/toolchain/push-subscribers.mjs b/toolchain/push-subscribers.mjs new file mode 100644 index 0000000000..7a574e1474 --- /dev/null +++ b/toolchain/push-subscribers.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// push-subscribers, 26.09.09 +// Who is listening. Counts the live rows in the Mongo `push-tokens` +// collection — the ones shared/push.mjs will actually fan out to — grouped +// by topic, with devices and distinct people counted separately (one person +// with a laptop and a phone is two devices, one subscriber). +// +// node toolchain/push-subscribers.mjs # every topic +// node toolchain/push-subscribers.mjs sotce-pages # one topic, with devices +// +// Legacy FCM-era rows (no `kind`) are ignored here exactly as the sender +// ignores them, so this count is what a broadcast would really reach. + +import { MongoClient } from "mongodb"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const LIVE = { kind: { $in: ["webpush", "apns"] } }; + +// The connection string lives in the vault, not the environment, on a +// workstation — read it the way the dev container would. +function loadEnv(file) { + const full = path.join(ROOT, file); + if (!fs.existsSync(full)) return; + for (const line of fs.readFileSync(full, "utf8").split("\n")) { + const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/); + if (!match) continue; + let value = match[2].trim(); + const quoted = value.startsWith('"') && value.endsWith('"'); + const ticked = value.startsWith("'") && value.endsWith("'"); + if (quoted || ticked) value = value.slice(1, -1); + process.env[match[1]] ??= value; + } +} + +loadEnv("vault/.devcontainer/envs/devcontainer.env"); + +const { MONGODB_CONNECTION_STRING: uri, MONGODB_NAME: name } = process.env; +if (!uri) { + console.error("🔴 MONGODB_CONNECTION_STRING missing — is the vault mounted?"); + process.exit(1); +} + +const only = process.argv[2]; +const client = new MongoClient(uri, { serverSelectionTimeoutMS: 15000 }); + +try { + await client.connect(); + const devices = client.db(name).collection("push-tokens"); + + const rows = await devices + .aggregate([ + { $match: LIVE }, + { $unwind: "$topics" }, + ...(only ? [{ $match: { topics: only } }] : []), + { + $group: { + _id: "$topics", + devices: { $sum: 1 }, + people: { $addToSet: "$user" }, + }, + }, + { $project: { devices: 1, people: { $size: "$people" } } }, + { $sort: { devices: -1 } }, + ]) + .toArray(); + + const total = await devices.countDocuments(LIVE); + console.log(`🔔 ${total} live device${total === 1 ? "" : "s"} registered\n`); + + if (!rows.length) { + console.log(only ? `no devices on "${only}"` : "no topics subscribed"); + } else { + console.log("topic devices subscribers"); + for (const row of rows) { + console.log( + `${row._id.padEnd(16)} ${String(row.devices).padStart(7)} ${String(row.people).padStart(11)}`, + ); + } + } + + // One topic named: name its devices too, so a small list stays legible. + if (only) { + const list = await devices + .find({ ...LIVE, topics: only }) + .project({ _id: 0, label: 1, platform: 1, kind: 1, updatedAt: 1 }) + .sort({ updatedAt: -1 }) + .toArray(); + console.log(""); + for (const device of list) { + const when = device.updatedAt + ? new Date(device.updatedAt).toISOString().slice(0, 10) + : "?"; + console.log( + ` ${device.kind}/${device.platform} ${device.label || "(unlabeled)"} · ${when}`, + ); + } + } +} finally { + await client.close(); +} -- 2.51.2