@aglyn/aglyn 1.0.0-beta.268 → 1.0.0-beta.269
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/package.json +11 -11
- package/src/lib/app-utils/docs-index.generated.js +1 -1
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/element-motion.d.ts +14 -2
- package/src/lib/app-utils/element-motion.js +33 -13
- package/src/lib/app-utils/element-motion.js.map +1 -1
- package/src/lib/plugin-manager/operator-alerts.js +10 -1
- package/src/lib/plugin-manager/operator-alerts.js.map +1 -1
- package/src/lib/plugin-manager/plugin-config.js +10 -1
- package/src/lib/plugin-manager/plugin-config.js.map +1 -1
- package/src/lib/plugin-manager/plugin-consent-group-change.js +11 -2
- package/src/lib/plugin-manager/plugin-consent-group-change.js.map +1 -1
- package/src/lib/plugin-manager/plugin-console-crons.js +10 -1
- package/src/lib/plugin-manager/plugin-console-crons.js.map +1 -1
- package/src/lib/plugin-manager/plugin-events.js +11 -2
- package/src/lib/plugin-manager/plugin-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-lead-conversion.js +10 -1
- package/src/lib/plugin-manager/plugin-lead-conversion.js.map +1 -1
- package/src/lib/plugin-manager/plugin-membership-detach.js +10 -1
- package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
- package/src/lib/plugin-manager/plugin-person-matches.js +10 -1
- package/src/lib/plugin-manager/plugin-person-matches.js.map +1 -1
- package/src/lib/plugin-manager/plugin-usage-meters.js +10 -1
- package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -1
- package/src/lib/plugin-manager/usage-alert-contributors.js +10 -1
- package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-console-crons.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's own scheduled jobs, run by the CONSOLE (AGL-2981).\n *\n * The platform already has a beat for work that belongs on the tenant — the\n * job runner behind `pluginJobsBeat` — and it is the wrong place for a job\n * that holds a credential only the console may hold: a provider key, a\n * sealed grant to a person's mailbox. Those jobs have to run in the console,\n * and until now the only way to get one there was a console route of its own\n * and a literal in the scheduler's route list, which is a plugin's route in\n * the platform's code.\n *\n * So a plugin DECLARES a console job here instead, and the platform runs\n * every declared job from one route, `/api/admin/plugin-crons`, which the\n * Cloud Scheduler job that drives the console's fifteen-minute sweeps posts\n * like any other of its routes. A plugin that adds a job adds a declaration;\n * the scheduler, its firewall rule and its deploy are unchanged.\n *\n * ## One schedule\n *\n * Every declared job runs on that tick: {@link PLUGIN_CONSOLE_CRON_SCHEDULE}.\n * A job that has nothing to do returns at once. A job that needs a slower\n * cadence keeps its own \"last ran\" mark and skips; one that needs a faster\n * one is not a job for this runner.\n *\n * ## Watched like every other job\n *\n * Each job has its own row on `/api/health/crons` —\n * {@link pluginConsoleCronScheduledJobs} — and the runner stamps that row's\n * `platformCronBeats` mark when it invokes the job, BEFORE the work, the rule\n * every console cron follows: the mark answers \"is this job still being\n * scheduled\", and a job that throws was still scheduled. A job that stops\n * being run — its plugin unregistered it, the route stopped being posted —\n * goes red on the board within its grace.\n *\n * ## Isolated, and bounded\n *\n * Jobs run concurrently, each in its own try: a throw is logged against its\n * plugin and reported as `null`, and never stops another job. Each is handed\n * a deadline, and a job that starts no new unit of work after it lets the\n * route answer before its caller gives up.\n *\n * Declared from a plugin's `consoleServerDeclarations` — the console's boot\n * registrations — with the job's body imported lazily, so boot pays for the\n * declaration and the tick pays for the work.\n */\n\nimport { SCHEDULED_JOBS, type ScheduledJob } from '../app-utils/health-report'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** The console route that runs every declared job, as the scheduler posts it. */\nexport const PLUGIN_CONSOLE_CRONS_ROUTE = '/api/admin/plugin-crons'\n\n/** The runner's own row on `/api/health/crons`: is the route being posted at all. */\nexport const PLUGIN_CONSOLE_CRONS_JOB_ID = 'plugin-console-crons'\n\n/**\n * The tick every declared job runs on, five-field UTC: the console's\n * fifteen-minute sweep (`CONSOLE_FAST_CRON_SCHEDULE` in `cloud/functions`).\n */\nexport const PLUGIN_CONSOLE_CRON_SCHEDULE = '*/15 * * * *'\n\n/** How late a declared job may be before its row goes red: three missed ticks. */\nexport const PLUGIN_CONSOLE_CRON_GRACE_MINUTES = 45\n\n/** What one run of a job is handed. */\nexport interface PluginConsoleCronContext {\n /** When the tick began, epoch ms. */\n nowMs: number\n /** Start no new unit of work after this, epoch ms. */\n deadlineMs: number\n}\n\n/** What a job reports for the tick's answer and log: counts, flags, a word — never content. */\nexport type PluginConsoleCronReport = Readonly<Record<string, number | boolean | string | null>>\n\nexport interface PluginConsoleCronJob {\n /**\n * The job's id: its `platformCronBeats` document and its health row.\n * Starts with the plugin's own id — `acme-mail-sync` for `acme-mail` — so\n * two plugins' jobs, and a plugin's job and the platform's, never share one.\n */\n id: string\n /** What the health board calls it. */\n label: string\n /** What stops happening when it stops, in a sentence the board renders. */\n drives: string\n run(context: PluginConsoleCronContext): Promise<PluginConsoleCronReport>\n}\n\n/** A declared job, without its body. */\nexport interface PluginConsoleCronDeclaration {\n pluginId: string\n id: string\n label: string\n drives: string\n}\n\ninterface Registration extends PluginConsoleCronDeclaration {\n run: PluginConsoleCronJob['run']\n}\n\nconst registrations: Registration[] = []\n\n/** A job id as a beat document and a health row can carry it. */\nconst JOB_ID = /^[a-z][a-z0-9-]*$/\n\n/** Job ids the platform's own inventory holds. */\nconst PLATFORM_JOB_IDS = new Set([...SCHEDULED_JOBS.map((job) => job.id), PLUGIN_CONSOLE_CRONS_JOB_ID])\n\n/**\n * Declares a plugin's console job. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a job with neither throws, as does\n * an id that is not the plugin's own (`<pluginId>-…`), one the platform's\n * inventory holds, one another plugin declared, and a job with no label or\n * no consequence. The same plugin declaring the same id again replaces its\n * job in place.\n */\nexport function registerPluginConsoleCron(\n job: PluginConsoleCronJob,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin console job was registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const id = String(job?.id ?? '').trim()\n if (!JOB_ID.test(id) || !id.startsWith(`${pluginId}-`) || id === `${pluginId}-`) {\n throw new Error(\n `plugin console job \"${id}\" from \"${pluginId}\" needs an id of its own: ` +\n `\"${pluginId}-\" and a lowercase name`,\n )\n }\n if (PLATFORM_JOB_IDS.has(id)) {\n throw new Error(`plugin console job \"${id}\" is a platform job's id; refused \"${pluginId}\"`)\n }\n const incumbent = registrations.find((entry) => entry.id === id)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `plugin console job \"${id}\" is already declared by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n const label = String(job.label ?? '').trim()\n const drives = String(job.drives ?? '').trim()\n if (!label || !drives || typeof job.run !== 'function') {\n throw new Error(`plugin console job \"${id}\" needs a label, what it drives, and a run function`)\n }\n const registration: Registration = { pluginId, id, label, drives, run: job.run }\n const index = registrations.findIndex((entry) => entry.id === id)\n if (index >= 0) registrations[index] = registration\n else registrations.push(registration)\n}\n\n/** Every declared job, in declaration order, without its body. */\nexport function listPluginConsoleCrons(): PluginConsoleCronDeclaration[] {\n return registrations.map(({ pluginId, id, label, drives }) => ({ pluginId, id, label, drives }))\n}\n\n/**\n * The declared jobs as rows of the scheduled-job inventory, for\n * `/api/health/crons` to judge beside the platform's own.\n */\nexport function pluginConsoleCronScheduledJobs(): ScheduledJob[] {\n return registrations.map((entry) => ({\n id: entry.id,\n label: entry.label,\n cron: PLUGIN_CONSOLE_CRON_SCHEDULE,\n runner: 'cloud-scheduler',\n target: `consoleFastCrons → console ${PLUGIN_CONSOLE_CRONS_ROUTE} (${entry.pluginId})`,\n graceMinutes: PLUGIN_CONSOLE_CRON_GRACE_MINUTES,\n drives: entry.drives,\n }))\n}\n\nexport interface RunPluginConsoleCronsOptions {\n nowMs: number\n deadlineMs: number\n /** Only these jobs, by id — a manual re-run of one. Absent runs every job. */\n jobIds?: readonly string[] | null\n /**\n * Stamps a job's `platformCronBeats` mark. Must not throw; the runner\n * awaits it before the job starts.\n */\n beat(jobId: string, nowMs: number): Promise<unknown>\n}\n\n/**\n * Runs the declared jobs — every one, or the ones named — concurrently,\n * each after its beat. Answers each job's report by id, or `null` for one\n * that threw. Never throws.\n */\nexport async function runPluginConsoleCrons(\n options: RunPluginConsoleCronsOptions,\n): Promise<Record<string, PluginConsoleCronReport | null>> {\n const wanted = options.jobIds?.length ? new Set(options.jobIds) : null\n const jobs = [...registrations].filter((entry) => !wanted || wanted.has(entry.id))\n const settled = await Promise.all(\n jobs.map(async (job): Promise<[string, PluginConsoleCronReport | null]> => {\n try {\n await options.beat(job.id, options.nowMs)\n } catch {\n // A beat that cannot be written must not take down the job it\n // describes; a write that keeps failing reads as a silent job.\n }\n try {\n return [job.id, await job.run({ nowMs: options.nowMs, deadlineMs: options.deadlineMs })]\n } catch (error) {\n console.error(`[plugins] ${job.pluginId} console job ${job.id} failed`, error)\n return [job.id, null]\n }\n }),\n )\n // Declaration order, whichever job finished first.\n return Object.fromEntries(settled)\n}\n\n/** Test seam: forget every declared job. */\nexport function resetPluginConsoleCronsForTests(): void {\n registrations.length = 0\n}\n"],"names":["SCHEDULED_JOBS","getRegisteringPluginId","PLUGIN_CONSOLE_CRONS_ROUTE","PLUGIN_CONSOLE_CRONS_JOB_ID","PLUGIN_CONSOLE_CRON_SCHEDULE","PLUGIN_CONSOLE_CRON_GRACE_MINUTES","registrations","JOB_ID","PLATFORM_JOB_IDS","Set","map","job","id","registerPluginConsoleCron","options","pluginId","trim","Error","String","test","startsWith","has","incumbent","find","entry","label","drives","run","registration","index","findIndex","push","listPluginConsoleCrons","pluginConsoleCronScheduledJobs","cron","runner","target","graceMinutes","runPluginConsoleCrons","wanted","jobIds","length","jobs","filter","settled","Promise","all","beat","nowMs","deadlineMs","error","console","Object","fromEntries","resetPluginConsoleCronsForTests"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-console-crons.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's own scheduled jobs, run by the CONSOLE (AGL-2981).\n *\n * The platform already has a beat for work that belongs on the tenant — the\n * job runner behind `pluginJobsBeat` — and it is the wrong place for a job\n * that holds a credential only the console may hold: a provider key, a\n * sealed grant to a person's mailbox. Those jobs have to run in the console,\n * and until now the only way to get one there was a console route of its own\n * and a literal in the scheduler's route list, which is a plugin's route in\n * the platform's code.\n *\n * So a plugin DECLARES a console job here instead, and the platform runs\n * every declared job from one route, `/api/admin/plugin-crons`, which the\n * Cloud Scheduler job that drives the console's fifteen-minute sweeps posts\n * like any other of its routes. A plugin that adds a job adds a declaration;\n * the scheduler, its firewall rule and its deploy are unchanged.\n *\n * ## One schedule\n *\n * Every declared job runs on that tick: {@link PLUGIN_CONSOLE_CRON_SCHEDULE}.\n * A job that has nothing to do returns at once. A job that needs a slower\n * cadence keeps its own \"last ran\" mark and skips; one that needs a faster\n * one is not a job for this runner.\n *\n * ## Watched like every other job\n *\n * Each job has its own row on `/api/health/crons` —\n * {@link pluginConsoleCronScheduledJobs} — and the runner stamps that row's\n * `platformCronBeats` mark when it invokes the job, BEFORE the work, the rule\n * every console cron follows: the mark answers \"is this job still being\n * scheduled\", and a job that throws was still scheduled. A job that stops\n * being run — its plugin unregistered it, the route stopped being posted —\n * goes red on the board within its grace.\n *\n * ## Isolated, and bounded\n *\n * Jobs run concurrently, each in its own try: a throw is logged against its\n * plugin and reported as `null`, and never stops another job. Each is handed\n * a deadline, and a job that starts no new unit of work after it lets the\n * route answer before its caller gives up.\n *\n * Declared from a plugin's `consoleServerDeclarations` — the console's boot\n * registrations — with the job's body imported lazily, so boot pays for the\n * declaration and the tick pays for the work.\n */\n\nimport { SCHEDULED_JOBS, type ScheduledJob } from '../app-utils/health-report'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** The console route that runs every declared job, as the scheduler posts it. */\nexport const PLUGIN_CONSOLE_CRONS_ROUTE = '/api/admin/plugin-crons'\n\n/** The runner's own row on `/api/health/crons`: is the route being posted at all. */\nexport const PLUGIN_CONSOLE_CRONS_JOB_ID = 'plugin-console-crons'\n\n/**\n * The tick every declared job runs on, five-field UTC: the console's\n * fifteen-minute sweep (`CONSOLE_FAST_CRON_SCHEDULE` in `cloud/functions`).\n */\nexport const PLUGIN_CONSOLE_CRON_SCHEDULE = '*/15 * * * *'\n\n/** How late a declared job may be before its row goes red: three missed ticks. */\nexport const PLUGIN_CONSOLE_CRON_GRACE_MINUTES = 45\n\n/** What one run of a job is handed. */\nexport interface PluginConsoleCronContext {\n /** When the tick began, epoch ms. */\n nowMs: number\n /** Start no new unit of work after this, epoch ms. */\n deadlineMs: number\n}\n\n/** What a job reports for the tick's answer and log: counts, flags, a word — never content. */\nexport type PluginConsoleCronReport = Readonly<Record<string, number | boolean | string | null>>\n\nexport interface PluginConsoleCronJob {\n /**\n * The job's id: its `platformCronBeats` document and its health row.\n * Starts with the plugin's own id — `acme-mail-sync` for `acme-mail` — so\n * two plugins' jobs, and a plugin's job and the platform's, never share one.\n */\n id: string\n /** What the health board calls it. */\n label: string\n /** What stops happening when it stops, in a sentence the board renders. */\n drives: string\n run(context: PluginConsoleCronContext): Promise<PluginConsoleCronReport>\n}\n\n/** A declared job, without its body. */\nexport interface PluginConsoleCronDeclaration {\n pluginId: string\n id: string\n label: string\n drives: string\n}\n\ninterface Registration extends PluginConsoleCronDeclaration {\n run: PluginConsoleCronJob['run']\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The jobs are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-console-crons')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Registration[]\n}\nconst registrations: Registration[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/** A job id as a beat document and a health row can carry it. */\nconst JOB_ID = /^[a-z][a-z0-9-]*$/\n\n/** Job ids the platform's own inventory holds. */\nconst PLATFORM_JOB_IDS = new Set([...SCHEDULED_JOBS.map((job) => job.id), PLUGIN_CONSOLE_CRONS_JOB_ID])\n\n/**\n * Declares a plugin's console job. Owner = the loader's marker inside a\n * register fn, else `options.pluginId`; a job with neither throws, as does\n * an id that is not the plugin's own (`<pluginId>-…`), one the platform's\n * inventory holds, one another plugin declared, and a job with no label or\n * no consequence. The same plugin declaring the same id again replaces its\n * job in place.\n */\nexport function registerPluginConsoleCron(\n job: PluginConsoleCronJob,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin console job was registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const id = String(job?.id ?? '').trim()\n if (!JOB_ID.test(id) || !id.startsWith(`${pluginId}-`) || id === `${pluginId}-`) {\n throw new Error(\n `plugin console job \"${id}\" from \"${pluginId}\" needs an id of its own: ` +\n `\"${pluginId}-\" and a lowercase name`,\n )\n }\n if (PLATFORM_JOB_IDS.has(id)) {\n throw new Error(`plugin console job \"${id}\" is a platform job's id; refused \"${pluginId}\"`)\n }\n const incumbent = registrations.find((entry) => entry.id === id)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `plugin console job \"${id}\" is already declared by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n const label = String(job.label ?? '').trim()\n const drives = String(job.drives ?? '').trim()\n if (!label || !drives || typeof job.run !== 'function') {\n throw new Error(`plugin console job \"${id}\" needs a label, what it drives, and a run function`)\n }\n const registration: Registration = { pluginId, id, label, drives, run: job.run }\n const index = registrations.findIndex((entry) => entry.id === id)\n if (index >= 0) registrations[index] = registration\n else registrations.push(registration)\n}\n\n/** Every declared job, in declaration order, without its body. */\nexport function listPluginConsoleCrons(): PluginConsoleCronDeclaration[] {\n return registrations.map(({ pluginId, id, label, drives }) => ({ pluginId, id, label, drives }))\n}\n\n/**\n * The declared jobs as rows of the scheduled-job inventory, for\n * `/api/health/crons` to judge beside the platform's own.\n */\nexport function pluginConsoleCronScheduledJobs(): ScheduledJob[] {\n return registrations.map((entry) => ({\n id: entry.id,\n label: entry.label,\n cron: PLUGIN_CONSOLE_CRON_SCHEDULE,\n runner: 'cloud-scheduler',\n target: `consoleFastCrons → console ${PLUGIN_CONSOLE_CRONS_ROUTE} (${entry.pluginId})`,\n graceMinutes: PLUGIN_CONSOLE_CRON_GRACE_MINUTES,\n drives: entry.drives,\n }))\n}\n\nexport interface RunPluginConsoleCronsOptions {\n nowMs: number\n deadlineMs: number\n /** Only these jobs, by id — a manual re-run of one. Absent runs every job. */\n jobIds?: readonly string[] | null\n /**\n * Stamps a job's `platformCronBeats` mark. Must not throw; the runner\n * awaits it before the job starts.\n */\n beat(jobId: string, nowMs: number): Promise<unknown>\n}\n\n/**\n * Runs the declared jobs — every one, or the ones named — concurrently,\n * each after its beat. Answers each job's report by id, or `null` for one\n * that threw. Never throws.\n */\nexport async function runPluginConsoleCrons(\n options: RunPluginConsoleCronsOptions,\n): Promise<Record<string, PluginConsoleCronReport | null>> {\n const wanted = options.jobIds?.length ? new Set(options.jobIds) : null\n const jobs = [...registrations].filter((entry) => !wanted || wanted.has(entry.id))\n const settled = await Promise.all(\n jobs.map(async (job): Promise<[string, PluginConsoleCronReport | null]> => {\n try {\n await options.beat(job.id, options.nowMs)\n } catch {\n // A beat that cannot be written must not take down the job it\n // describes; a write that keeps failing reads as a silent job.\n }\n try {\n return [job.id, await job.run({ nowMs: options.nowMs, deadlineMs: options.deadlineMs })]\n } catch (error) {\n console.error(`[plugins] ${job.pluginId} console job ${job.id} failed`, error)\n return [job.id, null]\n }\n }),\n )\n // Declaration order, whichever job finished first.\n return Object.fromEntries(settled)\n}\n\n/** Test seam: forget every declared job. */\nexport function resetPluginConsoleCronsForTests(): void {\n registrations.length = 0\n}\n"],"names":["globalScope","SCHEDULED_JOBS","getRegisteringPluginId","PLUGIN_CONSOLE_CRONS_ROUTE","PLUGIN_CONSOLE_CRONS_JOB_ID","PLUGIN_CONSOLE_CRON_SCHEDULE","PLUGIN_CONSOLE_CRON_GRACE_MINUTES","REGISTRY_KEY","Symbol","for","globalThis","registrations","JOB_ID","PLATFORM_JOB_IDS","Set","map","job","id","registerPluginConsoleCron","options","pluginId","trim","Error","String","test","startsWith","has","incumbent","find","entry","label","drives","run","registration","index","findIndex","push","listPluginConsoleCrons","pluginConsoleCronScheduledJobs","cron","runner","target","graceMinutes","runPluginConsoleCrons","wanted","jobIds","length","jobs","filter","settled","Promise","all","beat","nowMs","deadlineMs","error","console","Object","fromEntries","resetPluginConsoleCronsForTests"],"mappings":"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"}
|
|
@@ -31,8 +31,17 @@
|
|
|
31
31
|
* logged and the next one runs, because these are consequences of a write
|
|
32
32
|
* that has already happened — an activity row, a notification — and a
|
|
33
33
|
* failure in one must not undo or hide the write for the customer.
|
|
34
|
-
*/
|
|
35
|
-
|
|
34
|
+
*/ var _globalScope_REGISTRY_KEY;
|
|
35
|
+
import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
|
|
36
|
+
/**
|
|
37
|
+
* On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
|
|
38
|
+
* The handlers are registered at boot, from `instrumentation.ts`, and Next
|
|
39
|
+
* compiles that file apart from the routes, so the boot and a route each hold
|
|
40
|
+
* their own copy of this module. At module scope, the boot filled its copy and
|
|
41
|
+
* every route read an empty one (AGL-3412, AGL-3868).
|
|
42
|
+
*/ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-event-handlers');
|
|
43
|
+
const globalScope = globalThis;
|
|
44
|
+
const handlers = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = new Map();
|
|
36
45
|
/**
|
|
37
46
|
* Subscribes to an event. Owner = the loader's marker inside a register fn,
|
|
38
47
|
* else `options.pluginId`; a subscription with neither throws. A plugin
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Platform events a plugin listens to (AGL-2939, AGL-2940).\n *\n * Core raises a small number of facts about the workspace that a plugin\n * may care about — a seat add-on was bought or dropped, a permission key\n * moved on a member, a role or a site — and a plugin subscribes from its\n * declarations rather than core calling the plugin's own writer. The\n * billing-webhook hooks are the precedent for one vendor's events; this is\n * the same shape for the platform's own.\n *\n * Events a PLUGIN raises for other plugins — an order paid, a return\n * refunded — are `plugin-domain-events.ts` (AGL-3611): declared by the plugin,\n * delivered through an outbox, at least once.\n *\n * Handlers run sequentially and are ISOLATED: a plugin that throws is\n * logged and the next one runs, because these are consequences of a write\n * that has already happened — an activity row, a notification — and a\n * failure in one must not undo or hide the write for the customer.\n */\n\nimport type { OrgSeatAddons } from '../foundation'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who did it, as the activity logs record an actor. */\nexport interface PluginEventActor {\n uid: string | null\n email: string | null\n}\n\n/** The payload each platform event carries. */\nexport interface PluginEventPayloads {\n /** An org's purchased seat add-ons were written (a purchase, a removal, a webhook mirror). */\n 'org.seatAddons.changed': {\n orgId: string\n actor: PluginEventActor\n before: OrgSeatAddons | null | undefined\n after: OrgSeatAddons | null | undefined\n }\n /**\n * The workspace's Stripe invoice was paid, in full, by a real charge.\n *\n * Raised from the platform billing webhook once the invoice has resolved\n * to a workspace, so a plugin that bills its own usage learns the outcome\n * of the invoice it asked for without subscribing to Stripe itself. The\n * invoice's own `metadata` travels with it, unread by core: a plugin that\n * stamped `pluginId` on the invoice it created recognizes its own by it,\n * and every other handler ignores the event.\n *\n * `paidOutOfBand` is Stripe's own flag for an invoice marked paid without\n * a charge. A plugin counting payment history must not count one: it is a\n * staff action, not evidence that money moved.\n */\n 'billing.invoice.paid': {\n orgId: string\n invoiceId: string\n amountPaidCents: number\n currency: string\n paidOutOfBand: boolean\n metadata: Readonly<Record<string, string>>\n }\n /** A charge attempt on the workspace's invoice failed or needs action. */\n 'billing.invoice.failed': {\n orgId: string\n invoiceId: string\n amountDueCents: number\n metadata: Readonly<Record<string, string>>\n }\n /**\n * An invoice was closed without being paid — voided, or written off as\n * uncollectible.\n *\n * ONE event for both Stripe events because they are one fact to a plugin:\n * this invoice will never be paid and nothing more is owed on it. A\n * handler that must tell them apart reads `reason`; one that only needs\n * to stop waiting does not.\n */\n 'billing.invoice.closed': {\n orgId: string\n invoiceId: string\n reason: 'voided' | 'uncollectible'\n metadata: Readonly<Record<string, string>>\n }\n /** A cardholder disputed a charge of the workspace's. */\n 'billing.dispute.opened': {\n orgId: string\n chargeId: string\n invoiceId: string | null\n amountCents: number\n }\n /**\n * The workspace's default payment method changed, or its type did.\n *\n * `defaultType` is Stripe's own type string (`card`, `us_bank_account`,\n * `link`, …), or `null` when the customer has no default. Core states the\n * fact and takes no view: which types a plugin will extend credit against\n * is the plugin's decision, not the platform's.\n */\n 'billing.paymentMethod.changed': {\n orgId: string\n defaultType: string | null\n }\n /**\n * Records were REMOVED from a collection, by a path that is not the\n * owning plugin's own (AGL-3330): a deletion through the public API, a\n * person's erasure.\n *\n * Raised after the delete has landed, with each removed document's data\n * as it stood, so a plugin that keeps a figure over those rows — a counter\n * no increment can take back — can recount what they fed. Core names the\n * collection and takes no view of what it holds.\n *\n * `hostIds` are the sites the records were on: the one site for a site's\n * collection, every site of the organization for an organization row.\n */\n 'host.records.removed': {\n orgId: string | null\n hostIds: readonly string[]\n collection: string\n records: ReadonlyArray<{ id: string; data: Readonly<Record<string, unknown>> }>\n }\n /**\n * A site's email was opened, or a link in it clicked (AGL-3605): the\n * delivery events one webhook delivery recorded for a send tagged with the\n * site, the FIRST of each type per message only — the delivery log decides\n * which those are, so a replay or a reader opening one email six times is\n * one event. Raised by the plugin whose webhook reads the provider, after\n * the log has recorded them; a subscriber that counts engagement of its own\n * reads it here rather than subscribing to the provider.\n */\n 'host.email.engaged': {\n hostId: string\n events: ReadonlyArray<{\n /** The recipient, lowercased. */\n to: string\n /** `opened` or `clicked`. */\n type: string\n /** When it happened, epoch ms. */\n at: number\n firstOfType: boolean\n }>\n }\n /**\n * A custom domain was connected to a site and the hosting provider accepted\n * it (AGL-3629). Raised once the claim is written, so `domain` is the site's\n * `cname` and no other site's. The site may still be awaiting a certificate\n * or a DNS change; a plugin that needs the domain registered somewhere — a\n * payment provider, a search console — does that now rather than waiting\n * for traffic.\n */\n 'host.domain.attached': {\n orgId: string | null\n hostId: string\n domain: string\n }\n /**\n * A domain stopped serving a site (AGL-3629): its custom domain was\n * disconnected, or the site was deleted — which releases its platform\n * subdomain too, raised as a second event with that name. A plugin undoes\n * whatever it registered for the name, and must check the name is not\n * still served by ANOTHER site first (a `www.` twin, a reused subdomain).\n */\n 'host.domain.released': {\n orgId: string | null\n hostId: string\n domain: string\n reason: 'detached' | 'site-deleted'\n }\n /** One permission key moved on a subject of the org. */\n 'org.permissions.changed': {\n orgId: string\n actor: PluginEventActor\n subject: {\n type: 'org' | 'role' | 'member' | 'host'\n id?: string | null\n name?: string | null\n }\n permission: string\n granted: boolean\n }\n}\n\nexport type PluginEventName = keyof PluginEventPayloads\n\nexport type PluginEventHandler<E extends PluginEventName> = (\n payload: PluginEventPayloads[E],\n) => void | Promise<void>\n\ninterface Registration {\n pluginId: string\n handler: PluginEventHandler<PluginEventName>\n}\n\nconst handlers = new Map<PluginEventName, Registration[]>()\n\n/**\n * Subscribes to an event. Owner = the loader's marker inside a register fn,\n * else `options.pluginId`; a subscription with neither throws. A plugin\n * re-subscribing replaces its earlier handler for that event, so a module\n * evaluated twice does not write two rows.\n */\nexport function registerPluginEventHandler<E extends PluginEventName>(\n event: E,\n handler: PluginEventHandler<E>,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${event}\" subscribed with no owner: pass { pluginId } ` +\n 'when subscribing outside a plugin register fn',\n )\n }\n const list = (handlers.get(event) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginEventHandler<PluginEventName> })\n handlers.set(event, list)\n}\n\n/**\n * Raises an event. Every handler runs, in subscription order; a throw is\n * logged with the plugin's id and does not stop the others.\n */\nexport async function runPluginEventHandlers<E extends PluginEventName>(\n event: E,\n payload: PluginEventPayloads[E],\n): Promise<{ handled: number; failed: string[] }> {\n let handled = 0\n const failed: string[] = []\n for (const { pluginId, handler } of handlers.get(event) ?? []) {\n try {\n await handler(payload)\n handled += 1\n } catch (error) {\n failed.push(pluginId)\n console.error(`[plugins] ${pluginId} failed on event ${event}`, error)\n }\n }\n return { handled, failed }\n}\n\n/** The plugins subscribed to an event, for diagnostics. */\nexport function listPluginEventHandlers(event: PluginEventName): string[] {\n return (handlers.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every subscription. */\nexport function resetPluginEventHandlersForTests(): void {\n handlers.clear()\n}\n"],"names":["getRegisteringPluginId","handlers","Map","registerPluginEventHandler","event","handler","options","pluginId","trim","Error","list","get","filter","entry","push","set","runPluginEventHandlers","payload","handled","failed","error","console","listPluginEventHandlers","map","resetPluginEventHandlersForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;CAkBC,GAGD,SAASA,sBAAsB,QAAQ,qCAAiC;AA2KxE,MAAMC,WAAW,IAAIC;AAErB;;;;;CAKC,GACD,OAAO,SAASC,2BACdC,KAAQ,EACRC,OAA8B,EAC9BC,OAA+B;QAEbN,MAAAA,yBAOJC;IAPd,MAAMM,WAAW,EAACP,QAAAA,0BAAAA,oCAAAA,0BAA4BM,2BAAAA,QAASC,QAAQ,YAA7CP,OAAiD,IAAIQ,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,CAAC,cAAc,EAAEL,MAAM,8CAA8C,CAAC,GACpE;IAEN;IACA,MAAMM,OAAO,EAACT,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEW,MAAM,CAAC,CAACC,QAAUA,MAAMN,QAAQ,KAAKA;IAC9EG,KAAKI,IAAI,CAAC;QAAEP;QAAUF,SAASA;IAA+C;IAC9EJ,SAASc,GAAG,CAACX,OAAOM;AACtB;AAEA;;;CAGC,GACD,OAAO,eAAeM,uBACpBZ,KAAQ,EACRa,OAA+B;QAIKhB;IAFpC,IAAIiB,UAAU;IACd,MAAMC,SAAmB,EAAE;IAC3B,KAAK,MAAM,EAAEZ,QAAQ,EAAEF,OAAO,EAAE,KAAIJ,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,CAAE;QAC7D,IAAI;YACF,MAAMI,QAAQY;YACdC,WAAW;QACb,EAAE,OAAOE,OAAO;YACdD,OAAOL,IAAI,CAACP;YACZc,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAEb,SAAS,iBAAiB,EAAEH,OAAO,EAAEgB;QAClE;IACF;IACA,OAAO;QAAEF;QAASC;IAAO;AAC3B;AAEA,yDAAyD,GACzD,OAAO,SAASG,wBAAwBlB,KAAsB;QACpDH;IAAR,OAAO,EAACA,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEsB,GAAG,CAAC,CAACV,QAAUA,MAAMN,QAAQ;AAClE;AAEA,0CAA0C,GAC1C,OAAO,SAASiB;IACdvB,SAASwB,KAAK;AAChB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Platform events a plugin listens to (AGL-2939, AGL-2940).\n *\n * Core raises a small number of facts about the workspace that a plugin\n * may care about — a seat add-on was bought or dropped, a permission key\n * moved on a member, a role or a site — and a plugin subscribes from its\n * declarations rather than core calling the plugin's own writer. The\n * billing-webhook hooks are the precedent for one vendor's events; this is\n * the same shape for the platform's own.\n *\n * Events a PLUGIN raises for other plugins — an order paid, a return\n * refunded — are `plugin-domain-events.ts` (AGL-3611): declared by the plugin,\n * delivered through an outbox, at least once.\n *\n * Handlers run sequentially and are ISOLATED: a plugin that throws is\n * logged and the next one runs, because these are consequences of a write\n * that has already happened — an activity row, a notification — and a\n * failure in one must not undo or hide the write for the customer.\n */\n\nimport type { OrgSeatAddons } from '../foundation'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who did it, as the activity logs record an actor. */\nexport interface PluginEventActor {\n uid: string | null\n email: string | null\n}\n\n/** The payload each platform event carries. */\nexport interface PluginEventPayloads {\n /** An org's purchased seat add-ons were written (a purchase, a removal, a webhook mirror). */\n 'org.seatAddons.changed': {\n orgId: string\n actor: PluginEventActor\n before: OrgSeatAddons | null | undefined\n after: OrgSeatAddons | null | undefined\n }\n /**\n * The workspace's Stripe invoice was paid, in full, by a real charge.\n *\n * Raised from the platform billing webhook once the invoice has resolved\n * to a workspace, so a plugin that bills its own usage learns the outcome\n * of the invoice it asked for without subscribing to Stripe itself. The\n * invoice's own `metadata` travels with it, unread by core: a plugin that\n * stamped `pluginId` on the invoice it created recognizes its own by it,\n * and every other handler ignores the event.\n *\n * `paidOutOfBand` is Stripe's own flag for an invoice marked paid without\n * a charge. A plugin counting payment history must not count one: it is a\n * staff action, not evidence that money moved.\n */\n 'billing.invoice.paid': {\n orgId: string\n invoiceId: string\n amountPaidCents: number\n currency: string\n paidOutOfBand: boolean\n metadata: Readonly<Record<string, string>>\n }\n /** A charge attempt on the workspace's invoice failed or needs action. */\n 'billing.invoice.failed': {\n orgId: string\n invoiceId: string\n amountDueCents: number\n metadata: Readonly<Record<string, string>>\n }\n /**\n * An invoice was closed without being paid — voided, or written off as\n * uncollectible.\n *\n * ONE event for both Stripe events because they are one fact to a plugin:\n * this invoice will never be paid and nothing more is owed on it. A\n * handler that must tell them apart reads `reason`; one that only needs\n * to stop waiting does not.\n */\n 'billing.invoice.closed': {\n orgId: string\n invoiceId: string\n reason: 'voided' | 'uncollectible'\n metadata: Readonly<Record<string, string>>\n }\n /** A cardholder disputed a charge of the workspace's. */\n 'billing.dispute.opened': {\n orgId: string\n chargeId: string\n invoiceId: string | null\n amountCents: number\n }\n /**\n * The workspace's default payment method changed, or its type did.\n *\n * `defaultType` is Stripe's own type string (`card`, `us_bank_account`,\n * `link`, …), or `null` when the customer has no default. Core states the\n * fact and takes no view: which types a plugin will extend credit against\n * is the plugin's decision, not the platform's.\n */\n 'billing.paymentMethod.changed': {\n orgId: string\n defaultType: string | null\n }\n /**\n * Records were REMOVED from a collection, by a path that is not the\n * owning plugin's own (AGL-3330): a deletion through the public API, a\n * person's erasure.\n *\n * Raised after the delete has landed, with each removed document's data\n * as it stood, so a plugin that keeps a figure over those rows — a counter\n * no increment can take back — can recount what they fed. Core names the\n * collection and takes no view of what it holds.\n *\n * `hostIds` are the sites the records were on: the one site for a site's\n * collection, every site of the organization for an organization row.\n */\n 'host.records.removed': {\n orgId: string | null\n hostIds: readonly string[]\n collection: string\n records: ReadonlyArray<{ id: string; data: Readonly<Record<string, unknown>> }>\n }\n /**\n * A site's email was opened, or a link in it clicked (AGL-3605): the\n * delivery events one webhook delivery recorded for a send tagged with the\n * site, the FIRST of each type per message only — the delivery log decides\n * which those are, so a replay or a reader opening one email six times is\n * one event. Raised by the plugin whose webhook reads the provider, after\n * the log has recorded them; a subscriber that counts engagement of its own\n * reads it here rather than subscribing to the provider.\n */\n 'host.email.engaged': {\n hostId: string\n events: ReadonlyArray<{\n /** The recipient, lowercased. */\n to: string\n /** `opened` or `clicked`. */\n type: string\n /** When it happened, epoch ms. */\n at: number\n firstOfType: boolean\n }>\n }\n /**\n * A custom domain was connected to a site and the hosting provider accepted\n * it (AGL-3629). Raised once the claim is written, so `domain` is the site's\n * `cname` and no other site's. The site may still be awaiting a certificate\n * or a DNS change; a plugin that needs the domain registered somewhere — a\n * payment provider, a search console — does that now rather than waiting\n * for traffic.\n */\n 'host.domain.attached': {\n orgId: string | null\n hostId: string\n domain: string\n }\n /**\n * A domain stopped serving a site (AGL-3629): its custom domain was\n * disconnected, or the site was deleted — which releases its platform\n * subdomain too, raised as a second event with that name. A plugin undoes\n * whatever it registered for the name, and must check the name is not\n * still served by ANOTHER site first (a `www.` twin, a reused subdomain).\n */\n 'host.domain.released': {\n orgId: string | null\n hostId: string\n domain: string\n reason: 'detached' | 'site-deleted'\n }\n /** One permission key moved on a subject of the org. */\n 'org.permissions.changed': {\n orgId: string\n actor: PluginEventActor\n subject: {\n type: 'org' | 'role' | 'member' | 'host'\n id?: string | null\n name?: string | null\n }\n permission: string\n granted: boolean\n }\n}\n\nexport type PluginEventName = keyof PluginEventPayloads\n\nexport type PluginEventHandler<E extends PluginEventName> = (\n payload: PluginEventPayloads[E],\n) => void | Promise<void>\n\ninterface Registration {\n pluginId: string\n handler: PluginEventHandler<PluginEventName>\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The handlers are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-event-handlers')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Map<PluginEventName, Registration[]>\n}\nconst handlers: Map<PluginEventName, Registration[]> =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = new Map())\n\n/**\n * Subscribes to an event. Owner = the loader's marker inside a register fn,\n * else `options.pluginId`; a subscription with neither throws. A plugin\n * re-subscribing replaces its earlier handler for that event, so a module\n * evaluated twice does not write two rows.\n */\nexport function registerPluginEventHandler<E extends PluginEventName>(\n event: E,\n handler: PluginEventHandler<E>,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin event \"${event}\" subscribed with no owner: pass { pluginId } ` +\n 'when subscribing outside a plugin register fn',\n )\n }\n const list = (handlers.get(event) ?? []).filter((entry) => entry.pluginId !== pluginId)\n list.push({ pluginId, handler: handler as PluginEventHandler<PluginEventName> })\n handlers.set(event, list)\n}\n\n/**\n * Raises an event. Every handler runs, in subscription order; a throw is\n * logged with the plugin's id and does not stop the others.\n */\nexport async function runPluginEventHandlers<E extends PluginEventName>(\n event: E,\n payload: PluginEventPayloads[E],\n): Promise<{ handled: number; failed: string[] }> {\n let handled = 0\n const failed: string[] = []\n for (const { pluginId, handler } of handlers.get(event) ?? []) {\n try {\n await handler(payload)\n handled += 1\n } catch (error) {\n failed.push(pluginId)\n console.error(`[plugins] ${pluginId} failed on event ${event}`, error)\n }\n }\n return { handled, failed }\n}\n\n/** The plugins subscribed to an event, for diagnostics. */\nexport function listPluginEventHandlers(event: PluginEventName): string[] {\n return (handlers.get(event) ?? []).map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every subscription. */\nexport function resetPluginEventHandlersForTests(): void {\n handlers.clear()\n}\n"],"names":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","handlers","Map","registerPluginEventHandler","event","handler","options","pluginId","trim","Error","list","get","filter","entry","push","set","runPluginEventHandlers","payload","handled","failed","error","console","listPluginEventHandlers","map","resetPluginEventHandlersForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;CAkBC,OA0LCA;AAvLF,SAASC,sBAAsB,QAAQ,qCAAiC;AA2KxE;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,YACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,IAAIK;AAEhE;;;;;CAKC,GACD,OAAO,SAASC,2BACdC,KAAQ,EACRC,OAA8B,EAC9BC,OAA+B;QAEbV,MAAAA,yBAOJK;IAPd,MAAMM,WAAW,EAACX,QAAAA,0BAAAA,oCAAAA,0BAA4BU,2BAAAA,QAASC,QAAQ,YAA7CX,OAAiD,IAAIY,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,CAAC,cAAc,EAAEL,MAAM,8CAA8C,CAAC,GACpE;IAEN;IACA,MAAMM,OAAO,EAACT,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEW,MAAM,CAAC,CAACC,QAAUA,MAAMN,QAAQ,KAAKA;IAC9EG,KAAKI,IAAI,CAAC;QAAEP;QAAUF,SAASA;IAA+C;IAC9EJ,SAASc,GAAG,CAACX,OAAOM;AACtB;AAEA;;;CAGC,GACD,OAAO,eAAeM,uBACpBZ,KAAQ,EACRa,OAA+B;QAIKhB;IAFpC,IAAIiB,UAAU;IACd,MAAMC,SAAmB,EAAE;IAC3B,KAAK,MAAM,EAAEZ,QAAQ,EAAEF,OAAO,EAAE,KAAIJ,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,CAAE;QAC7D,IAAI;YACF,MAAMI,QAAQY;YACdC,WAAW;QACb,EAAE,OAAOE,OAAO;YACdD,OAAOL,IAAI,CAACP;YACZc,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAEb,SAAS,iBAAiB,EAAEH,OAAO,EAAEgB;QAClE;IACF;IACA,OAAO;QAAEF;QAASC;IAAO;AAC3B;AAEA,yDAAyD,GACzD,OAAO,SAASG,wBAAwBlB,KAAsB;QACpDH;IAAR,OAAO,EAACA,gBAAAA,SAASU,GAAG,CAACP,kBAAbH,gBAAuB,EAAE,EAAEsB,GAAG,CAAC,CAACV,QAAUA,MAAMN,QAAQ;AAClE;AAEA,0CAA0C,GAC1C,OAAO,SAASiB;IACdvB,SAASwB,KAAK;AAChB"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
var _globalScope_REGISTRY_KEY;
|
|
2
3
|
/**
|
|
3
4
|
* @license
|
|
4
5
|
* Copyright 2026 Aglyn LLC
|
|
@@ -41,7 +42,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
41
42
|
* the plugin's API surface. The core never imports a plugin; this is how a
|
|
42
43
|
* conversion the tenancy runtime performs reaches a plugin's records.
|
|
43
44
|
*/ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
|
|
44
|
-
|
|
45
|
+
/**
|
|
46
|
+
* On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
|
|
47
|
+
* The listeners are registered at boot, from `instrumentation.ts`, and Next
|
|
48
|
+
* compiles that file apart from the routes, so the boot and a route each hold
|
|
49
|
+
* their own copy of this module. At module scope, the boot filled its copy and
|
|
50
|
+
* every route read an empty one (AGL-3412, AGL-3868).
|
|
51
|
+
*/ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-lead-conversion-listeners');
|
|
52
|
+
const globalScope = globalThis;
|
|
53
|
+
const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
|
|
45
54
|
/**
|
|
46
55
|
* Registers a plugin's conversion listener. Owner = the loader's marker
|
|
47
56
|
* inside a register fn, else `options.pluginId`; a listener with neither
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-lead-conversion.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's share of a LEAD CONVERSION (AGL-3233).\n *\n * When a lead becomes a contact — by the convert dialog, over the REST API,\n * or on its own when the person signs up or buys — the record system moves\n * what it filed on the lead onto the contact: the activities, the tasks.\n * A plugin that keeps records ABOUT the lead where those sweeps do not\n * reach — a sequence enrollment naming the lead, say — registers a\n * listener here, and the conversion runs every listener with the lead and\n * the contact it became, so the plugin can re-point its own records.\n *\n * Listeners run AFTER the lead is stamped converted, so a listener that\n * reads the lead finds `convertedContactId` on it, and after the record\n * system's own moves, so a listener that reads the contact's timeline\n * finds the lead's history already there.\n *\n * Listeners run in registration order and are ISOLATED: a throw is logged\n * against its plugin, recorded as `null`, and does not stop the next one or\n * the conversion — which has already happened. The `null` is how the\n * conversion's report says that plugin's records may still name the lead.\n *\n * Registered from a plugin's declarations — `serverDeclarations`, or\n * `consoleServerDeclarations` for a plugin whose server code runs only in\n * the console — so the listener is in place in a process that never loaded\n * the plugin's API surface. The core never imports a plugin; this is how a\n * conversion the tenancy runtime performs reaches a plugin's records.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Which door converted the lead. */\nexport type PluginLeadConversionBy =\n /** A member, from the console's convert dialog. */\n | 'member'\n /** An API key, over `POST /v1/leads/{id}/convert`. */\n | 'api'\n /** The person opened a member account. */\n | 'signup'\n /** The person bought something. */\n | 'purchase'\n /** The one-time migration to the one-record model. */\n | 'backfill'\n\n/** The lead that converted, and the contact it became. */\nexport interface PluginLeadConversionRequest {\n orgId: string\n /** The site the lead lived under. */\n hostId: string\n /** `hosts/{hostId}/leads/{leadId}` — the person key. */\n leadId: string\n /** `orgs/{orgId}/contacts/{contactId}`. */\n contactId: string\n /** The person's address, normalized. */\n email: string\n by: PluginLeadConversionBy\n}\n\n/**\n * What a listener reports: counts and flags, never content. A `null`\n * field is a figure the listener could not measure, which is not zero.\n */\nexport type PluginLeadConversionReport = Readonly<Record<string, number | boolean | null>>\n\nexport type PluginLeadConversionListener = (\n request: PluginLeadConversionRequest,\n) => Promise<PluginLeadConversionReport>\n\ninterface Registration {\n pluginId: string\n listener: PluginLeadConversionListener\n}\n\nconst registrations: Registration[] = []\n\n/**\n * Registers a plugin's conversion listener. Owner = the loader's marker\n * inside a register fn, else `options.pluginId`; a listener with neither\n * throws. One listener per plugin: registering again replaces the plugin's\n * earlier listener in place, so a module evaluated twice does not move\n * records twice.\n */\nexport function registerPluginLeadConversionListener(\n listener: PluginLeadConversionListener,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin lead-conversion listener was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, listener }\n else registrations.push({ pluginId, listener })\n}\n\n/** The plugins with a listener in place, in registration order. Only for specs and the guards. */\nexport function listPluginLeadConversionListeners(): readonly string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Only for specs: forgets every listener. */\nexport function resetPluginLeadConversionListenersForTests(): void {\n registrations.length = 0\n}\n\n/**\n * Runs every listener, in registration order, and answers each plugin's\n * report by plugin id: the listener's own report, or `null` for one that\n * threw. Never throws.\n */\nexport async function runPluginLeadConversionListeners(\n request: PluginLeadConversionRequest,\n): Promise<Record<string, PluginLeadConversionReport | null>> {\n const reports: Record<string, PluginLeadConversionReport | null> = {}\n for (const { pluginId, listener } of [...registrations]) {\n try {\n reports[pluginId] = await listener({ ...request })\n } catch (error) {\n reports[pluginId] = null\n // The ids, never the address: this line outlives the person's records.\n console.error(\n `[plugins] ${pluginId} failed to follow lead ${request.leadId} to contact ${request.contactId} in org ${request.orgId}`,\n error,\n )\n }\n }\n return reports\n}\n"],"names":["getRegisteringPluginId","registrations","registerPluginLeadConversionListener","listener","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginLeadConversionListeners","map","resetPluginLeadConversionListenersForTests","length","runPluginLeadConversionListeners","request","reports","error","console","leadId","contactId","orgId"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-lead-conversion.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's share of a LEAD CONVERSION (AGL-3233).\n *\n * When a lead becomes a contact — by the convert dialog, over the REST API,\n * or on its own when the person signs up or buys — the record system moves\n * what it filed on the lead onto the contact: the activities, the tasks.\n * A plugin that keeps records ABOUT the lead where those sweeps do not\n * reach — a sequence enrollment naming the lead, say — registers a\n * listener here, and the conversion runs every listener with the lead and\n * the contact it became, so the plugin can re-point its own records.\n *\n * Listeners run AFTER the lead is stamped converted, so a listener that\n * reads the lead finds `convertedContactId` on it, and after the record\n * system's own moves, so a listener that reads the contact's timeline\n * finds the lead's history already there.\n *\n * Listeners run in registration order and are ISOLATED: a throw is logged\n * against its plugin, recorded as `null`, and does not stop the next one or\n * the conversion — which has already happened. The `null` is how the\n * conversion's report says that plugin's records may still name the lead.\n *\n * Registered from a plugin's declarations — `serverDeclarations`, or\n * `consoleServerDeclarations` for a plugin whose server code runs only in\n * the console — so the listener is in place in a process that never loaded\n * the plugin's API surface. The core never imports a plugin; this is how a\n * conversion the tenancy runtime performs reaches a plugin's records.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Which door converted the lead. */\nexport type PluginLeadConversionBy =\n /** A member, from the console's convert dialog. */\n | 'member'\n /** An API key, over `POST /v1/leads/{id}/convert`. */\n | 'api'\n /** The person opened a member account. */\n | 'signup'\n /** The person bought something. */\n | 'purchase'\n /** The one-time migration to the one-record model. */\n | 'backfill'\n\n/** The lead that converted, and the contact it became. */\nexport interface PluginLeadConversionRequest {\n orgId: string\n /** The site the lead lived under. */\n hostId: string\n /** `hosts/{hostId}/leads/{leadId}` — the person key. */\n leadId: string\n /** `orgs/{orgId}/contacts/{contactId}`. */\n contactId: string\n /** The person's address, normalized. */\n email: string\n by: PluginLeadConversionBy\n}\n\n/**\n * What a listener reports: counts and flags, never content. A `null`\n * field is a figure the listener could not measure, which is not zero.\n */\nexport type PluginLeadConversionReport = Readonly<Record<string, number | boolean | null>>\n\nexport type PluginLeadConversionListener = (\n request: PluginLeadConversionRequest,\n) => Promise<PluginLeadConversionReport>\n\ninterface Registration {\n pluginId: string\n listener: PluginLeadConversionListener\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The listeners are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-lead-conversion-listeners')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Registration[]\n}\nconst registrations: Registration[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/**\n * Registers a plugin's conversion listener. Owner = the loader's marker\n * inside a register fn, else `options.pluginId`; a listener with neither\n * throws. One listener per plugin: registering again replaces the plugin's\n * earlier listener in place, so a module evaluated twice does not move\n * records twice.\n */\nexport function registerPluginLeadConversionListener(\n listener: PluginLeadConversionListener,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin lead-conversion listener was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, listener }\n else registrations.push({ pluginId, listener })\n}\n\n/** The plugins with a listener in place, in registration order. Only for specs and the guards. */\nexport function listPluginLeadConversionListeners(): readonly string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Only for specs: forgets every listener. */\nexport function resetPluginLeadConversionListenersForTests(): void {\n registrations.length = 0\n}\n\n/**\n * Runs every listener, in registration order, and answers each plugin's\n * report by plugin id: the listener's own report, or `null` for one that\n * threw. Never throws.\n */\nexport async function runPluginLeadConversionListeners(\n request: PluginLeadConversionRequest,\n): Promise<Record<string, PluginLeadConversionReport | null>> {\n const reports: Record<string, PluginLeadConversionReport | null> = {}\n for (const { pluginId, listener } of [...registrations]) {\n try {\n reports[pluginId] = await listener({ ...request })\n } catch (error) {\n reports[pluginId] = null\n // The ids, never the address: this line outlives the person's records.\n console.error(\n `[plugins] ${pluginId} failed to follow lead ${request.leadId} to contact ${request.contactId} in org ${request.orgId}`,\n error,\n )\n }\n }\n return reports\n}\n"],"names":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","registrations","registerPluginLeadConversionListener","listener","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginLeadConversionListeners","map","resetPluginLeadConversionListenersForTests","length","runPluginLeadConversionListeners","request","reports","error","console","leadId","contactId","orgId"],"mappings":";IAqGEA;AArGF;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAASC,sBAAsB,QAAQ,qCAAiC;AA4CxE;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,iBACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,EAAE;AAE9D;;;;;;CAMC,GACD,OAAO,SAASK,qCACdC,QAAsC,EACtCC,OAA+B;QAEbR,MAAAA;IAAlB,MAAMS,WAAW,EAACT,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASC,QAAQ,YAA7CT,OAAiD,IAAIU,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,0EACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAS;SACvDF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAS;AAC/C;AAEA,gGAAgG,GAChG,OAAO,SAASS;IACd,OAAOX,cAAcY,GAAG,CAAC,CAACH,QAAUA,MAAML,QAAQ;AACpD;AAEA,4CAA4C,GAC5C,OAAO,SAASS;IACdb,cAAcc,MAAM,GAAG;AACzB;AAEA;;;;CAIC,GACD,OAAO,eAAeC,iCACpBC,OAAoC;IAEpC,MAAMC,UAA6D,CAAC;IACpE,KAAK,MAAM,EAAEb,QAAQ,EAAEF,QAAQ,EAAE,IAAI;WAAIF;KAAc,CAAE;QACvD,IAAI;YACFiB,OAAO,CAACb,SAAS,GAAG,MAAMF,SAAS,aAAKc;QAC1C,EAAE,OAAOE,OAAO;YACdD,OAAO,CAACb,SAAS,GAAG;YACpB,uEAAuE;YACvEe,QAAQD,KAAK,CACX,CAAC,UAAU,EAAEd,SAAS,uBAAuB,EAAEY,QAAQI,MAAM,CAAC,YAAY,EAAEJ,QAAQK,SAAS,CAAC,QAAQ,EAAEL,QAAQM,KAAK,EAAE,EACvHJ;QAEJ;IACF;IACA,OAAOD;AACT"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
var _globalScope_REGISTRY_KEY;
|
|
2
3
|
/**
|
|
3
4
|
* @license
|
|
4
5
|
* Copyright 2026 Aglyn LLC
|
|
@@ -47,7 +48,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
47
48
|
* never imports a plugin; this is how a removal one plugin performs reaches
|
|
48
49
|
* another plugin's records.
|
|
49
50
|
*/ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
|
|
50
|
-
|
|
51
|
+
/**
|
|
52
|
+
* On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
|
|
53
|
+
* The detachers are registered at boot, from `instrumentation.ts`, and Next
|
|
54
|
+
* compiles that file apart from the routes, so the boot and a route each hold
|
|
55
|
+
* their own copy of this module. At module scope, the boot filled its copy and
|
|
56
|
+
* every route read an empty one (AGL-3412, AGL-3868).
|
|
57
|
+
*/ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-membership-detachers');
|
|
58
|
+
const globalScope = globalThis;
|
|
59
|
+
const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
|
|
51
60
|
/**
|
|
52
61
|
* Registers a plugin's membership detacher. Owner = the loader's marker
|
|
53
62
|
* inside a register fn, else `options.pluginId`; a detacher with neither
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-membership-detach.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's share of taking a CONTAINER away (AGL-3254).\n *\n * A container — a campaign is the first — is a document other records join\n * by naming it in a membership array on their OWN documents\n * (`app-utils/container-membership.ts` says why the edge lives on the\n * member). When the container is removed, its owner clears the id off every\n * member it can name. A plugin that keeps members of its own where that\n * walk does not reach — records under the org, in collections the owner\n * does not know — registers a detacher here, and the removal runs every\n * detacher with the container's id, the membership field it is held in,\n * the site and the org, so the plugin can clear its own records.\n *\n * Detachers run BEFORE the container is deleted, for the reason the owner's\n * own passes do: a run that stops between them leaves records already out\n * of the container, which every surface draws correctly, while deleting\n * first would leave a plugin's record naming an id nothing can resolve. A\n * detacher that reports `remaining` holds the deletion, exactly as the\n * owner's own pass does when the container is on more records than one\n * request can clear.\n *\n * Detachers run in registration order and are ISOLATED: a throw is logged\n * against its plugin, recorded as `null`, and does not stop the next one.\n * The owner reads a `null` as \"this plugin's records may still name the\n * container\" and refuses to remove it, which is the direction a failure\n * here has to fall.\n *\n * Registered from a plugin's declarations — `consoleServerDeclarations`,\n * since a container is removed from the console — so the detacher is in\n * place in a process that never loaded the plugin's API surface. The core\n * never imports a plugin; this is how a removal one plugin performs reaches\n * another plugin's records.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** The container being removed, and where its members may be. */\nexport interface PluginMembershipDetachRequest {\n /** The site the container belongs to. */\n hostId: string\n /** The site's organization, or `''` for a site with no org behind it. */\n orgId: string\n /** The membership field a member holds the container's id in. */\n field: string\n /** The container's document id — what the field names. */\n id: string\n}\n\n/**\n * What a detacher reports: how many of its records it took the container\n * off, and whether any were left for a second run. Counts, never content.\n */\nexport interface PluginMembershipDetachReport {\n detached: number\n remaining: boolean\n}\n\nexport type PluginMembershipDetacher = (\n request: PluginMembershipDetachRequest,\n) => Promise<PluginMembershipDetachReport>\n\ninterface Registration {\n pluginId: string\n detacher: PluginMembershipDetacher\n}\n\nconst registrations: Registration[] = []\n\n/**\n * Registers a plugin's membership detacher. Owner = the loader's marker\n * inside a register fn, else `options.pluginId`; a detacher with neither\n * throws. One detacher per plugin: registering again replaces the plugin's\n * earlier detacher in place, so a module evaluated twice does not run twice.\n */\nexport function registerPluginMembershipDetacher(\n detacher: PluginMembershipDetacher,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin membership detacher was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, detacher }\n else registrations.push({ pluginId, detacher })\n}\n\n/**\n * Runs every detacher, in registration order, and answers each plugin's\n * report by plugin id: the detacher's own report, or `null` for one that\n * threw. Never throws.\n */\nexport async function runPluginMembershipDetachers(\n request: PluginMembershipDetachRequest,\n): Promise<Record<string, PluginMembershipDetachReport | null>> {\n const reports: Record<string, PluginMembershipDetachReport | null> = {}\n for (const { pluginId, detacher } of [...registrations]) {\n try {\n const report = await detacher({ ...request })\n reports[pluginId] = {\n detached: Math.max(0, Math.floor(Number(report?.detached ?? 0)) || 0),\n remaining: report?.remaining === true,\n }\n } catch (error) {\n reports[pluginId] = null\n console.error(\n `[plugins] ${pluginId} failed to detach ${request.field} ${request.id} on host ${request.hostId}`,\n error,\n )\n }\n }\n return reports\n}\n\n/** The plugins with a detacher in place, in registration order. Only for specs and the guards. */\nexport function listPluginMembershipDetachers(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every detacher. */\nexport function resetPluginMembershipDetachersForTests(): void {\n registrations.length = 0\n}\n"],"names":["getRegisteringPluginId","registrations","registerPluginMembershipDetacher","detacher","options","pluginId","trim","Error","index","findIndex","entry","push","runPluginMembershipDetachers","request","reports","report","detached","Math","max","floor","Number","remaining","error","console","field","id","hostId","listPluginMembershipDetachers","map","resetPluginMembershipDetachersForTests","length"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-membership-detach.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's share of taking a CONTAINER away (AGL-3254).\n *\n * A container — a campaign is the first — is a document other records join\n * by naming it in a membership array on their OWN documents\n * (`app-utils/container-membership.ts` says why the edge lives on the\n * member). When the container is removed, its owner clears the id off every\n * member it can name. A plugin that keeps members of its own where that\n * walk does not reach — records under the org, in collections the owner\n * does not know — registers a detacher here, and the removal runs every\n * detacher with the container's id, the membership field it is held in,\n * the site and the org, so the plugin can clear its own records.\n *\n * Detachers run BEFORE the container is deleted, for the reason the owner's\n * own passes do: a run that stops between them leaves records already out\n * of the container, which every surface draws correctly, while deleting\n * first would leave a plugin's record naming an id nothing can resolve. A\n * detacher that reports `remaining` holds the deletion, exactly as the\n * owner's own pass does when the container is on more records than one\n * request can clear.\n *\n * Detachers run in registration order and are ISOLATED: a throw is logged\n * against its plugin, recorded as `null`, and does not stop the next one.\n * The owner reads a `null` as \"this plugin's records may still name the\n * container\" and refuses to remove it, which is the direction a failure\n * here has to fall.\n *\n * Registered from a plugin's declarations — `consoleServerDeclarations`,\n * since a container is removed from the console — so the detacher is in\n * place in a process that never loaded the plugin's API surface. The core\n * never imports a plugin; this is how a removal one plugin performs reaches\n * another plugin's records.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** The container being removed, and where its members may be. */\nexport interface PluginMembershipDetachRequest {\n /** The site the container belongs to. */\n hostId: string\n /** The site's organization, or `''` for a site with no org behind it. */\n orgId: string\n /** The membership field a member holds the container's id in. */\n field: string\n /** The container's document id — what the field names. */\n id: string\n}\n\n/**\n * What a detacher reports: how many of its records it took the container\n * off, and whether any were left for a second run. Counts, never content.\n */\nexport interface PluginMembershipDetachReport {\n detached: number\n remaining: boolean\n}\n\nexport type PluginMembershipDetacher = (\n request: PluginMembershipDetachRequest,\n) => Promise<PluginMembershipDetachReport>\n\ninterface Registration {\n pluginId: string\n detacher: PluginMembershipDetacher\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The detachers are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-membership-detachers')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Registration[]\n}\nconst registrations: Registration[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/**\n * Registers a plugin's membership detacher. Owner = the loader's marker\n * inside a register fn, else `options.pluginId`; a detacher with neither\n * throws. One detacher per plugin: registering again replaces the plugin's\n * earlier detacher in place, so a module evaluated twice does not run twice.\n */\nexport function registerPluginMembershipDetacher(\n detacher: PluginMembershipDetacher,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin membership detacher was registered with no owner: pass ' +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, detacher }\n else registrations.push({ pluginId, detacher })\n}\n\n/**\n * Runs every detacher, in registration order, and answers each plugin's\n * report by plugin id: the detacher's own report, or `null` for one that\n * threw. Never throws.\n */\nexport async function runPluginMembershipDetachers(\n request: PluginMembershipDetachRequest,\n): Promise<Record<string, PluginMembershipDetachReport | null>> {\n const reports: Record<string, PluginMembershipDetachReport | null> = {}\n for (const { pluginId, detacher } of [...registrations]) {\n try {\n const report = await detacher({ ...request })\n reports[pluginId] = {\n detached: Math.max(0, Math.floor(Number(report?.detached ?? 0)) || 0),\n remaining: report?.remaining === true,\n }\n } catch (error) {\n reports[pluginId] = null\n console.error(\n `[plugins] ${pluginId} failed to detach ${request.field} ${request.id} on host ${request.hostId}`,\n error,\n )\n }\n }\n return reports\n}\n\n/** The plugins with a detacher in place, in registration order. Only for specs and the guards. */\nexport function listPluginMembershipDetachers(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every detacher. */\nexport function resetPluginMembershipDetachersForTests(): void {\n registrations.length = 0\n}\n"],"names":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","registrations","registerPluginMembershipDetacher","detacher","options","pluginId","trim","Error","index","findIndex","entry","push","runPluginMembershipDetachers","request","reports","report","detached","Math","max","floor","Number","remaining","error","console","field","id","hostId","listPluginMembershipDetachers","map","resetPluginMembershipDetachersForTests","length"],"mappings":";IA+FEA;AA/FF;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCC,GAED,SAASC,sBAAsB,QAAQ,qCAAiC;AAgCxE;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,iBACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,EAAE;AAE9D;;;;;CAKC,GACD,OAAO,SAASK,iCACdC,QAAkC,EAClCC,OAA+B;QAEbR,MAAAA;IAAlB,MAAMS,WAAW,EAACT,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASC,QAAQ,YAA7CT,OAAiD,IAAIU,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,qEACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAS;SACvDF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAS;AAC/C;AAEA;;;;CAIC,GACD,OAAO,eAAeS,6BACpBC,OAAsC;IAEtC,MAAMC,UAA+D,CAAC;IACtE,KAAK,MAAM,EAAET,QAAQ,EAAEF,QAAQ,EAAE,IAAI;WAAIF;KAAc,CAAE;QACvD,IAAI;;YACF,MAAMc,SAAS,MAAMZ,SAAS,aAAKU;YACnCC,OAAO,CAACT,SAAS,GAAG;gBAClBW,UAAUC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,KAAK,CAACC,eAAOL,0BAAAA,OAAQC,QAAQ,mBAAI,OAAO;gBACnEK,WAAWN,CAAAA,0BAAAA,OAAQM,SAAS,MAAK;YACnC;QACF,EAAE,OAAOC,OAAO;YACdR,OAAO,CAACT,SAAS,GAAG;YACpBkB,QAAQD,KAAK,CACX,CAAC,UAAU,EAAEjB,SAAS,kBAAkB,EAAEQ,QAAQW,KAAK,CAAC,CAAC,EAAEX,QAAQY,EAAE,CAAC,SAAS,EAAEZ,QAAQa,MAAM,EAAE,EACjGJ;QAEJ;IACF;IACA,OAAOR;AACT;AAEA,gGAAgG,GAChG,OAAO,SAASa;IACd,OAAO1B,cAAc2B,GAAG,CAAC,CAAClB,QAAUA,MAAML,QAAQ;AACpD;AAEA,sCAAsC,GACtC,OAAO,SAASwB;IACd5B,cAAc6B,MAAM,GAAG;AACzB"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
var _globalScope_REGISTRY_KEY;
|
|
2
3
|
/**
|
|
3
4
|
* @license
|
|
4
5
|
* Copyright 2026 Aglyn LLC
|
|
@@ -45,7 +46,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
45
46
|
* Registered from a plugin's server declarations, so the matcher is in place
|
|
46
47
|
* in a process that never loaded the plugin's API surface.
|
|
47
48
|
*/ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
|
|
48
|
-
|
|
49
|
+
/**
|
|
50
|
+
* On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
|
|
51
|
+
* The matchers are registered at boot, from `instrumentation.ts`, and Next
|
|
52
|
+
* compiles that file apart from the routes, so the boot and a route each hold
|
|
53
|
+
* their own copy of this module. At module scope, the boot filled its copy and
|
|
54
|
+
* every route read an empty one (AGL-3412, AGL-3868).
|
|
55
|
+
*/ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-person-matchers');
|
|
56
|
+
const globalScope = globalThis;
|
|
57
|
+
const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
|
|
49
58
|
/**
|
|
50
59
|
* Registers a plugin's matcher. Owner = the loader's marker inside a register
|
|
51
60
|
* fn, else `options.pluginId`; a matcher with neither throws. One matcher per
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-person-matches.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's answer to \"did this workspace already know this person?\"\n * (AGL-3289).\n *\n * The platform asks when a staff member reads where an account came from: a\n * sign-up whose address a sales workspace was already working, or whose name\n * matches somebody on a list, is the answer to \"is this the person we have\n * been writing to — on a personal address?\". The platform keeps no people of\n * its own, so it cannot answer; any plugin that keeps records about people\n * can, and registers a matcher here. The platform runs every matcher and\n * shows what comes back.\n *\n * Two kinds of answer, and the distinction is the point of the seam:\n *\n * - **`email`** — the record carries the account's address. Certain.\n * - **`name`** — the record's name resembles the account's. A GUESS, which the\n * staff card labels \"possible\" and never presents as a fact.\n *\n * A matcher leaves out the record the account's own sign-up created: a\n * platform that files every new account into its sales workspace would\n * otherwise answer \"yes, we know them\" for everybody, which says nothing.\n * What is worth showing is a record that existed BEFORE the account did.\n *\n * Matchers run in registration order and are ISOLATED: a throw is logged\n * against its plugin and reported as that plugin having failed, so a card can\n * say \"could not check\" rather than showing an absence it did not measure.\n * Nothing here writes; every matcher is a read.\n *\n * Registered from a plugin's server declarations, so the matcher is in place\n * in a process that never loaded the plugin's API surface.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who to look for, and where. */\nexport interface PluginPersonMatchRequest {\n /** The workspace whose records are searched. */\n orgId: string\n /** Its slug, so a matcher can link to what it found. */\n orgSlug: string | null\n /** The account's address, normalized; null when it has none. */\n email: string | null\n /** The account's name, for the name half; null when it has none. */\n name: string | null\n /**\n * When the account was created. A record the account's own sign-up made at\n * or after this moment is the account, not somebody already known.\n */\n accountCreatedAtMs: number | null\n}\n\n/** One record a matcher found. */\nexport interface PluginPersonMatch {\n /** The record's kind, in the plugin's own words. */\n kind: string\n id: string\n /** What a reader recognizes the record by — a name, else its address. */\n label: string\n email: string | null\n /** `email` is certain; `name` is a resemblance, shown as \"possible\". */\n basis: 'email' | 'name'\n /** When the workspace first knew this record, when it can say. */\n firstSeenAtMs: number | null\n /** How the workspace came to know them, in the plugin's words. */\n sources: string[]\n /** A console path to the record, when the plugin publishes one. */\n href: string | null\n}\n\nexport type PluginPersonMatcher = (\n request: PluginPersonMatchRequest,\n) => Promise<PluginPersonMatch[]>\n\ninterface Registration {\n pluginId: string\n matcher: PluginPersonMatcher\n}\n\nconst registrations: Registration[] = []\n\n/**\n * Registers a plugin's matcher. Owner = the loader's marker inside a register\n * fn, else `options.pluginId`; a matcher with neither throws. One matcher per\n * plugin: registering again replaces the earlier one in place.\n */\nexport function registerPluginPersonMatcher(\n matcher: PluginPersonMatcher,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin person matcher was registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, matcher }\n else registrations.push({ pluginId, matcher })\n}\n\n/** The plugins with a matcher registered, in registration order. */\nexport function listPluginPersonMatchers(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam — forget every matcher. */\nexport function resetPluginPersonMatchers(): void {\n registrations.length = 0\n}\n\n/** What every matcher answered, together. */\nexport interface PluginPersonMatchReport {\n /** Every match, tagged with the plugin that found it; certain ones first. */\n matches: Array<PluginPersonMatch & { pluginId: string }>\n /** Plugins whose matcher threw — their absence from `matches` measures nothing. */\n failed: string[]\n /** Plugins that were asked. None means nothing on this install can answer. */\n asked: string[]\n}\n\n/** Runs every matcher. Never throws. */\nexport async function runPluginPersonMatchers(\n request: PluginPersonMatchRequest,\n): Promise<PluginPersonMatchReport> {\n const report: PluginPersonMatchReport = { matches: [], failed: [], asked: [] }\n for (const { pluginId, matcher } of [...registrations]) {\n report.asked.push(pluginId)\n try {\n const found = await matcher({ ...request })\n for (const match of Array.isArray(found) ? found : []) {\n report.matches.push({ ...match, pluginId })\n }\n } catch (error) {\n report.failed.push(pluginId)\n // The org, never the person: this line is read by people who were not\n // asked to look the person up.\n console.error(`[plugins] ${pluginId} failed to match a person in org ${request.orgId}`, error)\n }\n }\n report.matches.sort((a, b) =>\n a.basis === b.basis ? 0 : a.basis === 'email' ? -1 : 1,\n )\n return report\n}\n"],"names":["getRegisteringPluginId","registrations","registerPluginPersonMatcher","matcher","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginPersonMatchers","map","resetPluginPersonMatchers","length","runPluginPersonMatchers","request","report","matches","failed","asked","found","match","Array","isArray","error","console","orgId","sort","a","b","basis"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-person-matches.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A plugin's answer to \"did this workspace already know this person?\"\n * (AGL-3289).\n *\n * The platform asks when a staff member reads where an account came from: a\n * sign-up whose address a sales workspace was already working, or whose name\n * matches somebody on a list, is the answer to \"is this the person we have\n * been writing to — on a personal address?\". The platform keeps no people of\n * its own, so it cannot answer; any plugin that keeps records about people\n * can, and registers a matcher here. The platform runs every matcher and\n * shows what comes back.\n *\n * Two kinds of answer, and the distinction is the point of the seam:\n *\n * - **`email`** — the record carries the account's address. Certain.\n * - **`name`** — the record's name resembles the account's. A GUESS, which the\n * staff card labels \"possible\" and never presents as a fact.\n *\n * A matcher leaves out the record the account's own sign-up created: a\n * platform that files every new account into its sales workspace would\n * otherwise answer \"yes, we know them\" for everybody, which says nothing.\n * What is worth showing is a record that existed BEFORE the account did.\n *\n * Matchers run in registration order and are ISOLATED: a throw is logged\n * against its plugin and reported as that plugin having failed, so a card can\n * say \"could not check\" rather than showing an absence it did not measure.\n * Nothing here writes; every matcher is a read.\n *\n * Registered from a plugin's server declarations, so the matcher is in place\n * in a process that never loaded the plugin's API surface.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/** Who to look for, and where. */\nexport interface PluginPersonMatchRequest {\n /** The workspace whose records are searched. */\n orgId: string\n /** Its slug, so a matcher can link to what it found. */\n orgSlug: string | null\n /** The account's address, normalized; null when it has none. */\n email: string | null\n /** The account's name, for the name half; null when it has none. */\n name: string | null\n /**\n * When the account was created. A record the account's own sign-up made at\n * or after this moment is the account, not somebody already known.\n */\n accountCreatedAtMs: number | null\n}\n\n/** One record a matcher found. */\nexport interface PluginPersonMatch {\n /** The record's kind, in the plugin's own words. */\n kind: string\n id: string\n /** What a reader recognizes the record by — a name, else its address. */\n label: string\n email: string | null\n /** `email` is certain; `name` is a resemblance, shown as \"possible\". */\n basis: 'email' | 'name'\n /** When the workspace first knew this record, when it can say. */\n firstSeenAtMs: number | null\n /** How the workspace came to know them, in the plugin's words. */\n sources: string[]\n /** A console path to the record, when the plugin publishes one. */\n href: string | null\n}\n\nexport type PluginPersonMatcher = (\n request: PluginPersonMatchRequest,\n) => Promise<PluginPersonMatch[]>\n\ninterface Registration {\n pluginId: string\n matcher: PluginPersonMatcher\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The matchers are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-person-matchers')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Registration[]\n}\nconst registrations: Registration[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/**\n * Registers a plugin's matcher. Owner = the loader's marker inside a register\n * fn, else `options.pluginId`; a matcher with neither throws. One matcher per\n * plugin: registering again replaces the earlier one in place.\n */\nexport function registerPluginPersonMatcher(\n matcher: PluginPersonMatcher,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'a plugin person matcher was registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const index = registrations.findIndex((entry) => entry.pluginId === pluginId)\n if (index >= 0) registrations[index] = { pluginId, matcher }\n else registrations.push({ pluginId, matcher })\n}\n\n/** The plugins with a matcher registered, in registration order. */\nexport function listPluginPersonMatchers(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam — forget every matcher. */\nexport function resetPluginPersonMatchers(): void {\n registrations.length = 0\n}\n\n/** What every matcher answered, together. */\nexport interface PluginPersonMatchReport {\n /** Every match, tagged with the plugin that found it; certain ones first. */\n matches: Array<PluginPersonMatch & { pluginId: string }>\n /** Plugins whose matcher threw — their absence from `matches` measures nothing. */\n failed: string[]\n /** Plugins that were asked. None means nothing on this install can answer. */\n asked: string[]\n}\n\n/** Runs every matcher. Never throws. */\nexport async function runPluginPersonMatchers(\n request: PluginPersonMatchRequest,\n): Promise<PluginPersonMatchReport> {\n const report: PluginPersonMatchReport = { matches: [], failed: [], asked: [] }\n for (const { pluginId, matcher } of [...registrations]) {\n report.asked.push(pluginId)\n try {\n const found = await matcher({ ...request })\n for (const match of Array.isArray(found) ? found : []) {\n report.matches.push({ ...match, pluginId })\n }\n } catch (error) {\n report.failed.push(pluginId)\n // The org, never the person: this line is read by people who were not\n // asked to look the person up.\n console.error(`[plugins] ${pluginId} failed to match a person in org ${request.orgId}`, error)\n }\n }\n report.matches.sort((a, b) =>\n a.basis === b.basis ? 0 : a.basis === 'email' ? -1 : 1,\n )\n return report\n}\n"],"names":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","registrations","registerPluginPersonMatcher","matcher","options","pluginId","trim","Error","index","findIndex","entry","push","listPluginPersonMatchers","map","resetPluginPersonMatchers","length","runPluginPersonMatchers","request","report","matches","failed","asked","found","match","Array","isArray","error","console","orgId","sort","a","b","basis"],"mappings":";IA2GEA;AA3GF;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GAED,SAASC,sBAAsB,QAAQ,qCAAiC;AA8CxE;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,iBACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,EAAE;AAE9D;;;;CAIC,GACD,OAAO,SAASK,4BACdC,OAA4B,EAC5BC,OAA+B;QAEbR,MAAAA;IAAlB,MAAMS,WAAW,EAACT,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,2BAAAA,QAASC,QAAQ,YAA7CT,OAAiD,IAAIU,IAAI;IAC3E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,6EACE;IAEN;IACA,MAAMC,QAAQP,cAAcQ,SAAS,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACpE,IAAIG,SAAS,GAAGP,aAAa,CAACO,MAAM,GAAG;QAAEH;QAAUF;IAAQ;SACtDF,cAAcU,IAAI,CAAC;QAAEN;QAAUF;IAAQ;AAC9C;AAEA,kEAAkE,GAClE,OAAO,SAASS;IACd,OAAOX,cAAcY,GAAG,CAAC,CAACH,QAAUA,MAAML,QAAQ;AACpD;AAEA,sCAAsC,GACtC,OAAO,SAASS;IACdb,cAAcc,MAAM,GAAG;AACzB;AAYA,sCAAsC,GACtC,OAAO,eAAeC,wBACpBC,OAAiC;IAEjC,MAAMC,SAAkC;QAAEC,SAAS,EAAE;QAAEC,QAAQ,EAAE;QAAEC,OAAO,EAAE;IAAC;IAC7E,KAAK,MAAM,EAAEhB,QAAQ,EAAEF,OAAO,EAAE,IAAI;WAAIF;KAAc,CAAE;QACtDiB,OAAOG,KAAK,CAACV,IAAI,CAACN;QAClB,IAAI;YACF,MAAMiB,QAAQ,MAAMnB,QAAQ,aAAKc;YACjC,KAAK,MAAMM,SAASC,MAAMC,OAAO,CAACH,SAASA,QAAQ,EAAE,CAAE;gBACrDJ,OAAOC,OAAO,CAACR,IAAI,CAAC,aAAKY;oBAAOlB;;YAClC;QACF,EAAE,OAAOqB,OAAO;YACdR,OAAOE,MAAM,CAACT,IAAI,CAACN;YACnB,sEAAsE;YACtE,+BAA+B;YAC/BsB,QAAQD,KAAK,CAAC,CAAC,UAAU,EAAErB,SAAS,iCAAiC,EAAEY,QAAQW,KAAK,EAAE,EAAEF;QAC1F;IACF;IACAR,OAAOC,OAAO,CAACU,IAAI,CAAC,CAACC,GAAGC,IACtBD,EAAEE,KAAK,KAAKD,EAAEC,KAAK,GAAG,IAAIF,EAAEE,KAAK,KAAK,UAAU,CAAC,IAAI;IAEvD,OAAOd;AACT"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
var _globalScope_REGISTRY_KEY;
|
|
2
3
|
/**
|
|
3
4
|
* @license
|
|
4
5
|
* Copyright 2026 Aglyn LLC
|
|
@@ -50,7 +51,15 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
50
51
|
* never costs the platform's own metering.
|
|
51
52
|
*/ import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
|
|
52
53
|
import { PLUGIN_USAGE_METERS_DECLARED } from "./first-party-plugins.generated.js";
|
|
53
|
-
|
|
54
|
+
/**
|
|
55
|
+
* On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
|
|
56
|
+
* The meters are registered at boot, from `instrumentation.ts`, and Next
|
|
57
|
+
* compiles that file apart from the routes, so the boot and a route each hold
|
|
58
|
+
* their own copy of this module. At module scope, the boot filled its copy and
|
|
59
|
+
* every route read an empty one (AGL-3412, AGL-3868).
|
|
60
|
+
*/ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-usage-meters');
|
|
61
|
+
const globalScope = globalThis;
|
|
62
|
+
const meters = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = new Map();
|
|
54
63
|
const keyOf = (pluginId, id)=>`${pluginId}:${id}`;
|
|
55
64
|
/**
|
|
56
65
|
* Registers a meter. Idempotent per plugin and id: the same pair again
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-usage-meters.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A METER A PLUGIN MEASURES IN THE MONTHLY USAGE SWEEP (AGL-3080).\n *\n * The usage sweep (`/api/billing/report-usage`) writes one rollup per\n * workspace and month — `orgs/{orgId}/usage/{month}` — and posts the month's\n * billed figure as one Stripe meter event. The platform's own meters (storage,\n * bandwidth, API requests, email) it measures itself. A plugin's meter it\n * cannot: the plugin owns the collection the usage is recorded in, the band\n * it is measured against and the price past the band.\n *\n * So the plugin registers a meter, and the sweep asks it once per workspace:\n * `measure` answers the fields the plugin writes onto the rollup — under the\n * names its usage axes declare, so the cost model and the utilization table\n * read them — and the dollars that enter the month's billed figure. The sweep\n * never learns what the meter counts or how it prices it, and the plugin\n * never edits the sweep.\n *\n * ## Declared, then registered, and refused when missing\n *\n * A meter is a runtime registration because measuring is code — a read of the\n * plugin's own storage, a band, a price. But a meter that failed to register\n * is money the sweep would silently stop billing, so the plugin also DECLARES\n * it (`usageAxes` → `meters`, compiled into the catalog file), and the sweep\n * refuses to report a workspace's month while a declared meter is missing:\n * the workspace fails loudly for this pass and is swept again tomorrow, which\n * is late and visible, where a month billed without the meter would be short\n * and final.\n *\n * ## A throw is the workspace's, not the sweep's\n *\n * `measure` throwing fails that workspace's pass the same way — a meter that\n * could not measure has no figure worth billing. `closeMonth` is different:\n * it settles what the plugin bills on its own, is logged when it fails, and\n * never costs the platform's own metering.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport type { ReleaseFlagKey } from '../app-utils/release-flags'\nimport { PLUGIN_USAGE_METERS_DECLARED } from './first-party-plugins.generated'\n\n/** What the sweep tells a meter about one workspace's month. */\nexport interface PluginUsageMeterContext {\n orgId: string\n /** The workspace document, as the sweep read it for every decision. */\n org: Readonly<Record<string, unknown>>\n /** `YYYY-MM`, the month being measured. */\n month: string\n /**\n * Whether the month has CLOSED. A closed month is the one an invoice is\n * about; an open one is measured daily for the budget and never billed.\n */\n closed: boolean\n /**\n * The month's rollup as it stood before this run — for a stock meter, the\n * last reading an earlier sweep stamped inside the month.\n */\n previous: Readonly<Record<string, unknown>>\n /**\n * A release flag's verdict for this workspace, resolved as the sweep\n * resolves every flag it gates billing on: bucketed by workspace, with its\n * overrides and its tier.\n */\n releaseFlagOn: (key: ReleaseFlagKey) => boolean\n}\n\n/** What a meter answers for one workspace's month. */\nexport interface PluginUsageMeterReading {\n /**\n * Written onto the month's rollup on every run, zeros included. A field the\n * platform writes itself is never overwritten.\n */\n fields: Readonly<Record<string, unknown>>\n /**\n * Written only while the month is OPEN — a stock meter's period-end reading,\n * which a closed sweep then bills from. Never written for a closed month,\n * so a re-run of a closed month reads the same input every time.\n */\n periodEndFields?: Readonly<Record<string, unknown>>\n /**\n * Dollars that enter the month's billed figure. Rounded to cents per meter,\n * like every plan-priced line beside it; 0 for a meter that bills nothing.\n */\n billedUsd: number\n /**\n * For a stock meter on a CLOSED month: `true` when the reading came from a\n * period-end stamp taken inside the month. The rollup's `stockBasis` is\n * `period-end` when any stock reading was.\n */\n periodEndBasis?: boolean\n}\n\n/** What a meter's close-out is told about one workspace's closed month. */\nexport interface PluginUsageMeterCloseContext extends PluginUsageMeterContext {\n /**\n * The workspace's Stripe customer, or `null` when it has none. Read only\n * when asked, so a close-out with nothing to settle costs no read.\n */\n stripeCustomerId: () => Promise<string | null>\n}\n\n/** A plugin's meter in the monthly usage sweep. */\nexport interface PluginUsageMeter {\n pluginId: string\n /** As the plugin's `usageAxes` declaration names it under `meters`. */\n id: string\n measure: (context: PluginUsageMeterContext) => Promise<PluginUsageMeterReading>\n /**\n * Called once per workspace for a CLOSED month, every day the sweep runs,\n * so a plugin that bills part of its usage itself can settle what is left\n * of it. Idempotent by the plugin's own records; errors are logged.\n */\n closeMonth?: (context: PluginUsageMeterCloseContext) => Promise<void>\n}\n\nconst meters = new Map<string, PluginUsageMeter>()\n\nconst keyOf = (pluginId: string, id: string) => `${pluginId}:${id}`\n\n/**\n * Registers a meter. Idempotent per plugin and id: the same pair again\n * replaces the earlier registration, so a declarations module evaluated twice\n * measures once. A meter with no owner, no id or no `measure` throws.\n */\nexport function registerPluginUsageMeter(\n meter: Omit<PluginUsageMeter, 'pluginId'> & { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? meter.pluginId ?? '').trim()\n const id = String(meter.id ?? '').trim()\n if (!pluginId || !id) {\n throw new Error('a usage meter needs a pluginId and an id')\n }\n if (typeof meter.measure !== 'function') {\n throw new Error(`usage meter \"${keyOf(pluginId, id)}\" has no measure function`)\n }\n meters.set(keyOf(pluginId, id), {\n pluginId,\n id,\n measure: meter.measure,\n ...(typeof meter.closeMonth === 'function'\n ? { closeMonth: meter.closeMonth }\n : {}),\n })\n}\n\n/**\n * Every registered meter, in the order the sweep runs them: declared meters in\n * catalog order, then any other in registration order. The order is fixed so\n * two processes write one workspace's rollup the same way.\n */\nexport function listPluginUsageMeters(): PluginUsageMeter[] {\n const declared = PLUGIN_USAGE_METERS_DECLARED.map((one) =>\n keyOf(one.pluginId, one.id),\n )\n const rank = (key: string) => {\n const index = declared.indexOf(key)\n return index === -1 ? Number.MAX_SAFE_INTEGER : index\n }\n return [...meters.entries()]\n .map(([key, meter], sequence) => ({ key, meter, sequence }))\n .sort((a, b) => rank(a.key) - rank(b.key) || a.sequence - b.sequence)\n .map(({ meter }) => ({ ...meter }))\n}\n\n/**\n * Every DECLARED meter nothing registered, as `pluginId:id`. The sweep refuses\n * to report a workspace's month while this is not empty.\n */\nexport function unregisteredPluginUsageMeters(): string[] {\n return PLUGIN_USAGE_METERS_DECLARED.map((one) =>\n keyOf(one.pluginId, one.id),\n ).filter((key) => !meters.has(key))\n}\n\n/** Test seam: forget every meter. */\nexport function resetPluginUsageMetersForTests(): void {\n meters.clear()\n}\n"],"names":["getRegisteringPluginId","PLUGIN_USAGE_METERS_DECLARED","meters","Map","keyOf","pluginId","id","registerPluginUsageMeter","meter","trim","String","Error","measure","set","closeMonth","listPluginUsageMeters","declared","map","one","rank","key","index","indexOf","Number","MAX_SAFE_INTEGER","entries","sequence","sort","a","b","unregisteredPluginUsageMeters","filter","has","resetPluginUsageMetersForTests","clear"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,SAASA,sBAAsB,QAAQ,qCAAiC;AAExE,SAASC,4BAA4B,QAAQ,qCAAiC;AA4E9E,MAAMC,SAAS,IAAIC;AAEnB,MAAMC,QAAQ,CAACC,UAAkBC,KAAe,GAAGD,SAAS,CAAC,EAAEC,IAAI;AAEnE;;;;CAIC,GACD,OAAO,SAASC,yBACdC,KAAiE;QAE/CR,MAAAA,yBACAQ;IADlB,MAAMH,WAAW,EAACL,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,MAAMH,QAAQ,YAA1CL,OAA8C,IAAIS,IAAI;IACxE,MAAMH,KAAKI,QAAOF,YAAAA,MAAMF,EAAE,YAARE,YAAY,IAAIC,IAAI;IACtC,IAAI,CAACJ,YAAY,CAACC,IAAI;QACpB,MAAM,IAAIK,MAAM;IAClB;IACA,IAAI,OAAOH,MAAMI,OAAO,KAAK,YAAY;QACvC,MAAM,IAAID,MAAM,CAAC,aAAa,EAAEP,MAAMC,UAAUC,IAAI,yBAAyB,CAAC;IAChF;IACAJ,OAAOW,GAAG,CAACT,MAAMC,UAAUC,KAAK;QAC9BD;QACAC;QACAM,SAASJ,MAAMI,OAAO;OAClB,OAAOJ,MAAMM,UAAU,KAAK,aAC5B;QAAEA,YAAYN,MAAMM,UAAU;IAAC,IAC/B,CAAC;AAET;AAEA;;;;CAIC,GACD,OAAO,SAASC;IACd,MAAMC,WAAWf,6BAA6BgB,GAAG,CAAC,CAACC,MACjDd,MAAMc,IAAIb,QAAQ,EAAEa,IAAIZ,EAAE;IAE5B,MAAMa,OAAO,CAACC;QACZ,MAAMC,QAAQL,SAASM,OAAO,CAACF;QAC/B,OAAOC,UAAU,CAAC,IAAIE,OAAOC,gBAAgB,GAAGH;IAClD;IACA,OAAO;WAAInB,OAAOuB,OAAO;KAAG,CACzBR,GAAG,CAAC,CAAC,CAACG,KAAKZ,MAAM,EAAEkB,WAAc,CAAA;YAAEN;YAAKZ;YAAOkB;QAAS,CAAA,GACxDC,IAAI,CAAC,CAACC,GAAGC,IAAMV,KAAKS,EAAER,GAAG,IAAID,KAAKU,EAAET,GAAG,KAAKQ,EAAEF,QAAQ,GAAGG,EAAEH,QAAQ,EACnET,GAAG,CAAC,CAAC,EAAET,KAAK,EAAE,GAAM,aAAKA;AAC9B;AAEA;;;CAGC,GACD,OAAO,SAASsB;IACd,OAAO7B,6BAA6BgB,GAAG,CAAC,CAACC,MACvCd,MAAMc,IAAIb,QAAQ,EAAEa,IAAIZ,EAAE,GAC1ByB,MAAM,CAAC,CAACX,MAAQ,CAAClB,OAAO8B,GAAG,CAACZ;AAChC;AAEA,mCAAmC,GACnC,OAAO,SAASa;IACd/B,OAAOgC,KAAK;AACd"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-usage-meters.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A METER A PLUGIN MEASURES IN THE MONTHLY USAGE SWEEP (AGL-3080).\n *\n * The usage sweep (`/api/billing/report-usage`) writes one rollup per\n * workspace and month — `orgs/{orgId}/usage/{month}` — and posts the month's\n * billed figure as one Stripe meter event. The platform's own meters (storage,\n * bandwidth, API requests, email) it measures itself. A plugin's meter it\n * cannot: the plugin owns the collection the usage is recorded in, the band\n * it is measured against and the price past the band.\n *\n * So the plugin registers a meter, and the sweep asks it once per workspace:\n * `measure` answers the fields the plugin writes onto the rollup — under the\n * names its usage axes declare, so the cost model and the utilization table\n * read them — and the dollars that enter the month's billed figure. The sweep\n * never learns what the meter counts or how it prices it, and the plugin\n * never edits the sweep.\n *\n * ## Declared, then registered, and refused when missing\n *\n * A meter is a runtime registration because measuring is code — a read of the\n * plugin's own storage, a band, a price. But a meter that failed to register\n * is money the sweep would silently stop billing, so the plugin also DECLARES\n * it (`usageAxes` → `meters`, compiled into the catalog file), and the sweep\n * refuses to report a workspace's month while a declared meter is missing:\n * the workspace fails loudly for this pass and is swept again tomorrow, which\n * is late and visible, where a month billed without the meter would be short\n * and final.\n *\n * ## A throw is the workspace's, not the sweep's\n *\n * `measure` throwing fails that workspace's pass the same way — a meter that\n * could not measure has no figure worth billing. `closeMonth` is different:\n * it settles what the plugin bills on its own, is logged when it fails, and\n * never costs the platform's own metering.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport type { ReleaseFlagKey } from '../app-utils/release-flags'\nimport { PLUGIN_USAGE_METERS_DECLARED } from './first-party-plugins.generated'\n\n/** What the sweep tells a meter about one workspace's month. */\nexport interface PluginUsageMeterContext {\n orgId: string\n /** The workspace document, as the sweep read it for every decision. */\n org: Readonly<Record<string, unknown>>\n /** `YYYY-MM`, the month being measured. */\n month: string\n /**\n * Whether the month has CLOSED. A closed month is the one an invoice is\n * about; an open one is measured daily for the budget and never billed.\n */\n closed: boolean\n /**\n * The month's rollup as it stood before this run — for a stock meter, the\n * last reading an earlier sweep stamped inside the month.\n */\n previous: Readonly<Record<string, unknown>>\n /**\n * A release flag's verdict for this workspace, resolved as the sweep\n * resolves every flag it gates billing on: bucketed by workspace, with its\n * overrides and its tier.\n */\n releaseFlagOn: (key: ReleaseFlagKey) => boolean\n}\n\n/** What a meter answers for one workspace's month. */\nexport interface PluginUsageMeterReading {\n /**\n * Written onto the month's rollup on every run, zeros included. A field the\n * platform writes itself is never overwritten.\n */\n fields: Readonly<Record<string, unknown>>\n /**\n * Written only while the month is OPEN — a stock meter's period-end reading,\n * which a closed sweep then bills from. Never written for a closed month,\n * so a re-run of a closed month reads the same input every time.\n */\n periodEndFields?: Readonly<Record<string, unknown>>\n /**\n * Dollars that enter the month's billed figure. Rounded to cents per meter,\n * like every plan-priced line beside it; 0 for a meter that bills nothing.\n */\n billedUsd: number\n /**\n * For a stock meter on a CLOSED month: `true` when the reading came from a\n * period-end stamp taken inside the month. The rollup's `stockBasis` is\n * `period-end` when any stock reading was.\n */\n periodEndBasis?: boolean\n}\n\n/** What a meter's close-out is told about one workspace's closed month. */\nexport interface PluginUsageMeterCloseContext extends PluginUsageMeterContext {\n /**\n * The workspace's Stripe customer, or `null` when it has none. Read only\n * when asked, so a close-out with nothing to settle costs no read.\n */\n stripeCustomerId: () => Promise<string | null>\n}\n\n/** A plugin's meter in the monthly usage sweep. */\nexport interface PluginUsageMeter {\n pluginId: string\n /** As the plugin's `usageAxes` declaration names it under `meters`. */\n id: string\n measure: (context: PluginUsageMeterContext) => Promise<PluginUsageMeterReading>\n /**\n * Called once per workspace for a CLOSED month, every day the sweep runs,\n * so a plugin that bills part of its usage itself can settle what is left\n * of it. Idempotent by the plugin's own records; errors are logged.\n */\n closeMonth?: (context: PluginUsageMeterCloseContext) => Promise<void>\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The meters are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-usage-meters')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: Map<string, PluginUsageMeter>\n}\nconst meters: Map<string, PluginUsageMeter> =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = new Map())\n\nconst keyOf = (pluginId: string, id: string) => `${pluginId}:${id}`\n\n/**\n * Registers a meter. Idempotent per plugin and id: the same pair again\n * replaces the earlier registration, so a declarations module evaluated twice\n * measures once. A meter with no owner, no id or no `measure` throws.\n */\nexport function registerPluginUsageMeter(\n meter: Omit<PluginUsageMeter, 'pluginId'> & { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? meter.pluginId ?? '').trim()\n const id = String(meter.id ?? '').trim()\n if (!pluginId || !id) {\n throw new Error('a usage meter needs a pluginId and an id')\n }\n if (typeof meter.measure !== 'function') {\n throw new Error(`usage meter \"${keyOf(pluginId, id)}\" has no measure function`)\n }\n meters.set(keyOf(pluginId, id), {\n pluginId,\n id,\n measure: meter.measure,\n ...(typeof meter.closeMonth === 'function'\n ? { closeMonth: meter.closeMonth }\n : {}),\n })\n}\n\n/**\n * Every registered meter, in the order the sweep runs them: declared meters in\n * catalog order, then any other in registration order. The order is fixed so\n * two processes write one workspace's rollup the same way.\n */\nexport function listPluginUsageMeters(): PluginUsageMeter[] {\n const declared = PLUGIN_USAGE_METERS_DECLARED.map((one) =>\n keyOf(one.pluginId, one.id),\n )\n const rank = (key: string) => {\n const index = declared.indexOf(key)\n return index === -1 ? Number.MAX_SAFE_INTEGER : index\n }\n return [...meters.entries()]\n .map(([key, meter], sequence) => ({ key, meter, sequence }))\n .sort((a, b) => rank(a.key) - rank(b.key) || a.sequence - b.sequence)\n .map(({ meter }) => ({ ...meter }))\n}\n\n/**\n * Every DECLARED meter nothing registered, as `pluginId:id`. The sweep refuses\n * to report a workspace's month while this is not empty.\n */\nexport function unregisteredPluginUsageMeters(): string[] {\n return PLUGIN_USAGE_METERS_DECLARED.map((one) =>\n keyOf(one.pluginId, one.id),\n ).filter((key) => !meters.has(key))\n}\n\n/** Test seam: forget every meter. */\nexport function resetPluginUsageMetersForTests(): void {\n meters.clear()\n}\n"],"names":["globalScope","getRegisteringPluginId","PLUGIN_USAGE_METERS_DECLARED","REGISTRY_KEY","Symbol","for","globalThis","meters","Map","keyOf","pluginId","id","registerPluginUsageMeter","meter","trim","String","Error","measure","set","closeMonth","listPluginUsageMeters","declared","map","one","rank","key","index","indexOf","Number","MAX_SAFE_INTEGER","entries","sequence","sort","a","b","unregisteredPluginUsageMeters","filter","has","resetPluginUsageMetersForTests","clear"],"mappings":";IA+IEA;AA/IF;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,SAASC,sBAAsB,QAAQ,qCAAiC;AAExE,SAASC,4BAA4B,QAAQ,qCAAiC;AA4E9E;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAML,cAAcM;AAGpB,MAAMC,UACJP,4BAAAA,WAAW,CAACG,aAAa,YAAzBH,4BAA8BA,WAAW,CAACG,aAAa,GAAG,IAAIK;AAEhE,MAAMC,QAAQ,CAACC,UAAkBC,KAAe,GAAGD,SAAS,CAAC,EAAEC,IAAI;AAEnE;;;;CAIC,GACD,OAAO,SAASC,yBACdC,KAAiE;QAE/CZ,MAAAA,yBACAY;IADlB,MAAMH,WAAW,EAACT,QAAAA,0BAAAA,oCAAAA,0BAA4BY,MAAMH,QAAQ,YAA1CT,OAA8C,IAAIa,IAAI;IACxE,MAAMH,KAAKI,QAAOF,YAAAA,MAAMF,EAAE,YAARE,YAAY,IAAIC,IAAI;IACtC,IAAI,CAACJ,YAAY,CAACC,IAAI;QACpB,MAAM,IAAIK,MAAM;IAClB;IACA,IAAI,OAAOH,MAAMI,OAAO,KAAK,YAAY;QACvC,MAAM,IAAID,MAAM,CAAC,aAAa,EAAEP,MAAMC,UAAUC,IAAI,yBAAyB,CAAC;IAChF;IACAJ,OAAOW,GAAG,CAACT,MAAMC,UAAUC,KAAK;QAC9BD;QACAC;QACAM,SAASJ,MAAMI,OAAO;OAClB,OAAOJ,MAAMM,UAAU,KAAK,aAC5B;QAAEA,YAAYN,MAAMM,UAAU;IAAC,IAC/B,CAAC;AAET;AAEA;;;;CAIC,GACD,OAAO,SAASC;IACd,MAAMC,WAAWnB,6BAA6BoB,GAAG,CAAC,CAACC,MACjDd,MAAMc,IAAIb,QAAQ,EAAEa,IAAIZ,EAAE;IAE5B,MAAMa,OAAO,CAACC;QACZ,MAAMC,QAAQL,SAASM,OAAO,CAACF;QAC/B,OAAOC,UAAU,CAAC,IAAIE,OAAOC,gBAAgB,GAAGH;IAClD;IACA,OAAO;WAAInB,OAAOuB,OAAO;KAAG,CACzBR,GAAG,CAAC,CAAC,CAACG,KAAKZ,MAAM,EAAEkB,WAAc,CAAA;YAAEN;YAAKZ;YAAOkB;QAAS,CAAA,GACxDC,IAAI,CAAC,CAACC,GAAGC,IAAMV,KAAKS,EAAER,GAAG,IAAID,KAAKU,EAAET,GAAG,KAAKQ,EAAEF,QAAQ,GAAGG,EAAEH,QAAQ,EACnET,GAAG,CAAC,CAAC,EAAET,KAAK,EAAE,GAAM,aAAKA;AAC9B;AAEA;;;CAGC,GACD,OAAO,SAASsB;IACd,OAAOjC,6BAA6BoB,GAAG,CAAC,CAACC,MACvCd,MAAMc,IAAIb,QAAQ,EAAEa,IAAIZ,EAAE,GAC1ByB,MAAM,CAAC,CAACX,MAAQ,CAAClB,OAAO8B,GAAG,CAACZ;AAChC;AAEA,mCAAmC,GACnC,OAAO,SAASa;IACd/B,OAAOgC,KAAK;AACd"}
|
|
@@ -64,8 +64,17 @@
|
|
|
64
64
|
* loaded the declarations, and a list that followed it could send and report
|
|
65
65
|
* one org's alerts in a different order per process.
|
|
66
66
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
67
|
+
var _globalScope_REGISTRY_KEY;
|
|
67
68
|
import { FIRST_PARTY_PLUGINS } from "./enabled-plugins.js";
|
|
68
|
-
|
|
69
|
+
/**
|
|
70
|
+
* On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).
|
|
71
|
+
* The contributors are registered at boot, from `instrumentation.ts`, and Next
|
|
72
|
+
* compiles that file apart from the routes, so the boot and a route each hold
|
|
73
|
+
* their own copy of this module. At module scope, the boot filled its copy and
|
|
74
|
+
* every route read an empty one (AGL-3412, AGL-3868).
|
|
75
|
+
*/ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:usage-alert-contributors');
|
|
76
|
+
const globalScope = globalThis;
|
|
77
|
+
const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
|
|
69
78
|
const CATALOG_ORDER = new Map(FIRST_PARTY_PLUGINS.map((plugin, index)=>[
|
|
70
79
|
plugin.id,
|
|
71
80
|
index
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/usage-alert-contributors.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Alerts a plugin contributes to the usage-alerts sweep (AGL-2984).\n *\n * The usage-alerts cron walks every org, reads its usage once, and sends\n * core's own alerts: the plan quotas, the customer's budget and the free\n * plan's bandwidth cap. A plugin that meters a cost or enforces a ceiling\n * core knows nothing about registers a contributor here from its\n * `serverDeclarations` entry, in either or both of two shapes:\n *\n * - `quotaChecks` answers the bands the WORKSPACE is warned about — a used\n * figure against a limit, how often it may be announced, what happens at\n * it, and the words for approaching and reaching it. The sweep runs them\n * after its own quota checks, through the same approach threshold, guards\n * and both channels, so a plugin's band warns exactly as a platform band\n * does.\n * - `evaluate` decides STAFF alerts, and the sweep hands it each org's\n * reading after the budget alert and before the bandwidth cap.\n *\n * THE PIPELINE IS THE SWEEP'S. A contributor decides whether an alert is due\n * and what it says; the context does everything else:\n *\n * - `recordAlert(key, threshold)` records the dedupe guard and answers\n * whether the alert may be SENT. On an org's first, silent evaluation it\n * records the guard as though the alert went out and answers `false`, so a\n * contributor that sends only on `true` honors the first-sweep seeding\n * without knowing when it applies.\n * - `alertStaff(alert)` is the sweep's own sender: the staff bell, then the\n * staff inbox, then a row in the run's report saying whether the mail went\n * out. On an org's first, silent evaluation it sends nothing, whoever calls\n * it.\n * - `guards` is the org's guard map as the sweep read it. A contributor reads\n * it to dedupe and never writes a guard itself: the sweep writes what\n * `recordAlert` recorded, once, with the rest of the org's update.\n *\n * Guard keys share one map with core's own checks (`hosts`, `bandwidth`,\n * `budget` and the rest), so a contributor names its keys for what it\n * measures and never reuses one of core's.\n *\n * Contributors run one at a time and are ISOLATED: a throw is logged against\n * the contributor and the next one runs. A guard the failed contributor\n * recorded for an alert it never delivered is dropped rather than written, so\n * the next sweep tries that alert again instead of counting it as announced.\n * One plugin's failure costs no other plugin's alert, none of core's, and not\n * the rest of the sweep.\n *\n * The order is deterministic: the catalog order of `FIRST_PARTY_PLUGINS`,\n * then any other plugin id in code-unit order, then registration order within\n * a plugin. The order contributors REGISTER in depends on how each process\n * loaded the declarations, and a list that followed it could send and report\n * one org's alerts in a different order per process.\n */\n\nimport type { NotificationLevel } from '../app-utils/notifications'\nimport type {\n OrgSpendBreakdown,\n UsageAlertGuard,\n} from '../app-utils/usage-budget'\nimport { FIRST_PARTY_PLUGINS } from './enabled-plugins'\n\n/** One staff alert, as the sweep delivers and reports it. */\nexport interface UsageStaffAlert {\n /** The guard key the alert was recorded under; the report row's `quota`. */\n quota: string\n /** The threshold passed to `recordAlert`; the report row's `threshold`. */\n threshold: number\n /** The bell's title and the email's subject: one set of words for both. */\n title: string\n /** The bell's body. The email carries it verbatim, followed by the link. */\n body: string\n /** A console path. The bell links it; the email makes it absolute. */\n link: string\n /** The email's tag and log label. */\n emailContext: string\n /**\n * How loud the bell draws it (AGL-3437). A contributor's threshold is its\n * own unit, not a percentage, so only the contributor can say; absent, the\n * `billing.usage` default answers.\n */\n level?: NotificationLevel\n}\n\n/** What the sweep hands a contributor for one org. */\nexport interface UsageAlertContext {\n orgId: string\n /** The org's slug, or `null` when the document carries none. */\n orgSlug: string | null\n /** The org document as the sweep read it. */\n org: Readonly<Record<string, unknown>>\n /** The `YYYY-MM` month the sweep dedupes against, fixed for the whole run. */\n month: string\n /** The org's spend this month, built from the figures the sweep read. */\n spend: Readonly<OrgSpendBreakdown>\n /** The org's guard map as the sweep read it, before this run's updates. */\n guards: Readonly<Record<string, Readonly<UsageAlertGuard>>>\n /**\n * Records the guard for `key` at `threshold` and answers whether the alert\n * may be sent. Call it once the alert is due, and send only on `true`.\n */\n recordAlert: (key: string, threshold: number) => boolean\n /** Delivers one staff alert through the sweep's senders and reports it. */\n alertStaff: (alert: UsageStaffAlert) => Promise<void>\n}\n\n/**\n * One band a workspace is warned about as it approaches and reaches it — the\n * shape of a plugin's quota check.\n *\n * The sweep owns everything past the numbers and the words: the approach\n * percentage, the guard under `key`, the first-sweep seeding, both channels,\n * and the sentence every notice opens with — the workspace, then the figures,\n * then `noun` (\"The Acme workspace has used 2,300 of the 2,750 AI assist\n * credits your plan includes this month\").\n */\nexport interface UsageQuotaCheck {\n /** The guard key; named for what it measures, never one of core's. */\n key: string\n /** How the alert's title names the band: \"AI assist credits\". */\n label: string\n /** What the figures count, in the opening sentence. */\n noun: string\n used: number\n /** `UNLIMITED` or anything not above 0 is skipped. */\n limit: number\n /**\n * How often one threshold may be announced: `crossing` for something the\n * workspace HAS, announced once when reached and again only after usage\n * falls back below; `monthly` for a meter that resets on the 1st.\n */\n cadence: 'crossing' | 'monthly'\n /**\n * What happens at the band, which the notice has to say because the three\n * call for different actions: `stops` (new use is refused, nothing is\n * charged), `bills` (it keeps working and starts charging) or `continues`\n * (nothing is refused and nothing is charged).\n */\n outcome: 'stops' | 'bills' | 'continues'\n /** What is true now, at or past the band. */\n reached: string\n /** What will happen at the band, said while approaching it. */\n approach: string\n /** Replaces the generic title at 100%, when that would not be true. */\n reachedTitle?: string\n /**\n * Replaces the figures in the opening sentence at 100%, after the\n * workspace's name (\"has used its free AI credits for this month.\"). For a\n * band nothing is ever sold past, where \"442 — more than the 300 your plan\n * includes\" reads like a charge that cannot happen.\n */\n reachedLead?: string\n /**\n * At 100%, link the plans page instead of the usage section: the band is\n * an allowance that only an upgrade adds to, and the notice says so.\n */\n reachedLinksUpgrade?: boolean\n}\n\n/** What the sweep hands a contributor's quota checks for one org. */\nexport interface UsageQuotaContext {\n orgId: string\n /** The org document as the sweep read it. */\n org: Readonly<Record<string, unknown>>\n /** The `YYYY-MM` month the sweep dedupes against. */\n month: string\n /** The org's spend this month, built from the figures the sweep read. */\n spend: Readonly<OrgSpendBreakdown>\n}\n\n/** A plugin's rule for the usage-alerts sweep. */\nexport interface UsageAlertContributor {\n pluginId: string\n /** Unique within the plugin; two plugins may use the same id. */\n id: string\n /** Evaluates one org's STAFF alerts, sending only through the context. */\n evaluate?: (context: UsageAlertContext) => Promise<void>\n /**\n * The bands the WORKSPACE is warned about, which the sweep runs after its\n * own through the same thresholds, guards and senders.\n */\n quotaChecks?: (context: UsageQuotaContext) => Promise<readonly UsageQuotaCheck[]>\n}\n\nconst registrations: UsageAlertContributor[] = []\n\nconst CATALOG_ORDER = new Map(\n FIRST_PARTY_PLUGINS.map((plugin, index) => [plugin.id, index]),\n)\n\nfunction catalogRank(pluginId: string): number {\n return CATALOG_ORDER.get(pluginId) ?? Number.MAX_SAFE_INTEGER\n}\n\n/** Code-unit order, which no locale can reorder. */\nfunction codeUnitOrder(a: string, b: string): number {\n return a < b ? -1 : a > b ? 1 : 0\n}\n\n/**\n * Registers a contributor. Idempotent per plugin and id: registering the same\n * pair again replaces the earlier contributor in place, so a declarations\n * module evaluated twice does not alert twice. A contributor with no plugin,\n * no id, or neither `evaluate` nor `quotaChecks` throws.\n */\nexport function registerUsageAlertContributor(\n contributor: UsageAlertContributor,\n): void {\n const pluginId = String(contributor.pluginId ?? '').trim()\n const id = String(contributor.id ?? '').trim()\n if (!pluginId || !id) {\n throw new Error('a usage alert contributor needs a pluginId and an id')\n }\n const evaluate =\n typeof contributor.evaluate === 'function' ? contributor.evaluate : undefined\n const quotaChecks =\n typeof contributor.quotaChecks === 'function'\n ? contributor.quotaChecks\n : undefined\n if (!evaluate && !quotaChecks) {\n throw new Error(\n `usage alert contributor \"${pluginId}:${id}\" has neither an evaluate ` +\n 'nor a quotaChecks function',\n )\n }\n const entry: UsageAlertContributor = {\n pluginId,\n id,\n ...(evaluate ? { evaluate } : {}),\n ...(quotaChecks ? { quotaChecks } : {}),\n }\n const index = registrations.findIndex(\n (existing) => existing.pluginId === pluginId && existing.id === id,\n )\n if (index >= 0) registrations[index] = entry\n else registrations.push(entry)\n}\n\n/** Every registered contributor, in the order the sweep runs them. */\nexport function listUsageAlertContributors(): UsageAlertContributor[] {\n return registrations\n .map((entry, sequence) => ({ entry, sequence }))\n .sort(\n (a, b) =>\n catalogRank(a.entry.pluginId) - catalogRank(b.entry.pluginId) ||\n codeUnitOrder(a.entry.pluginId, b.entry.pluginId) ||\n a.sequence - b.sequence,\n )\n .map(({ entry }) => ({ ...entry }))\n}\n\n/** Test seam: forget every contributor. */\nexport function resetUsageAlertContributorsForTests(): void {\n registrations.length = 0\n}\n"],"names":["FIRST_PARTY_PLUGINS","registrations","CATALOG_ORDER","Map","map","plugin","index","id","catalogRank","pluginId","get","Number","MAX_SAFE_INTEGER","codeUnitOrder","a","b","registerUsageAlertContributor","contributor","String","trim","Error","evaluate","undefined","quotaChecks","entry","findIndex","existing","push","listUsageAlertContributors","sequence","sort","resetUsageAlertContributorsForTests","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC;AAOD,SAASA,mBAAmB,QAAQ,uBAAmB;AA4HvD,MAAMC,gBAAyC,EAAE;AAEjD,MAAMC,gBAAgB,IAAIC,IACxBH,oBAAoBI,GAAG,CAAC,CAACC,QAAQC,QAAU;QAACD,OAAOE,EAAE;QAAED;KAAM;AAG/D,SAASE,YAAYC,QAAgB;QAC5BP;IAAP,QAAOA,qBAAAA,cAAcQ,GAAG,CAACD,qBAAlBP,qBAA+BS,OAAOC,gBAAgB;AAC/D;AAEA,kDAAkD,GAClD,SAASC,cAAcC,CAAS,EAAEC,CAAS;IACzC,OAAOD,IAAIC,IAAI,CAAC,IAAID,IAAIC,IAAI,IAAI;AAClC;AAEA;;;;;CAKC,GACD,OAAO,SAASC,8BACdC,WAAkC;QAEVA,uBACNA;IADlB,MAAMR,WAAWS,QAAOD,wBAAAA,YAAYR,QAAQ,YAApBQ,wBAAwB,IAAIE,IAAI;IACxD,MAAMZ,KAAKW,QAAOD,kBAAAA,YAAYV,EAAE,YAAdU,kBAAkB,IAAIE,IAAI;IAC5C,IAAI,CAACV,YAAY,CAACF,IAAI;QACpB,MAAM,IAAIa,MAAM;IAClB;IACA,MAAMC,WACJ,OAAOJ,YAAYI,QAAQ,KAAK,aAAaJ,YAAYI,QAAQ,GAAGC;IACtE,MAAMC,cACJ,OAAON,YAAYM,WAAW,KAAK,aAC/BN,YAAYM,WAAW,GACvBD;IACN,IAAI,CAACD,YAAY,CAACE,aAAa;QAC7B,MAAM,IAAIH,MACR,CAAC,yBAAyB,EAAEX,SAAS,CAAC,EAAEF,GAAG,0BAA0B,CAAC,GACpE;IAEN;IACA,MAAMiB,QAA+B;QACnCf;QACAF;OACIc,WAAW;QAAEA;IAAS,IAAI,CAAC,GAC3BE,cAAc;QAAEA;IAAY,IAAI,CAAC;IAEvC,MAAMjB,QAAQL,cAAcwB,SAAS,CACnC,CAACC,WAAaA,SAASjB,QAAQ,KAAKA,YAAYiB,SAASnB,EAAE,KAAKA;IAElE,IAAID,SAAS,GAAGL,aAAa,CAACK,MAAM,GAAGkB;SAClCvB,cAAc0B,IAAI,CAACH;AAC1B;AAEA,oEAAoE,GACpE,OAAO,SAASI;IACd,OAAO3B,cACJG,GAAG,CAAC,CAACoB,OAAOK,WAAc,CAAA;YAAEL;YAAOK;QAAS,CAAA,GAC5CC,IAAI,CACH,CAAChB,GAAGC,IACFP,YAAYM,EAAEU,KAAK,CAACf,QAAQ,IAAID,YAAYO,EAAES,KAAK,CAACf,QAAQ,KAC5DI,cAAcC,EAAEU,KAAK,CAACf,QAAQ,EAAEM,EAAES,KAAK,CAACf,QAAQ,KAChDK,EAAEe,QAAQ,GAAGd,EAAEc,QAAQ,EAE1BzB,GAAG,CAAC,CAAC,EAAEoB,KAAK,EAAE,GAAM,aAAKA;AAC9B;AAEA,yCAAyC,GACzC,OAAO,SAASO;IACd9B,cAAc+B,MAAM,GAAG;AACzB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/usage-alert-contributors.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Alerts a plugin contributes to the usage-alerts sweep (AGL-2984).\n *\n * The usage-alerts cron walks every org, reads its usage once, and sends\n * core's own alerts: the plan quotas, the customer's budget and the free\n * plan's bandwidth cap. A plugin that meters a cost or enforces a ceiling\n * core knows nothing about registers a contributor here from its\n * `serverDeclarations` entry, in either or both of two shapes:\n *\n * - `quotaChecks` answers the bands the WORKSPACE is warned about — a used\n * figure against a limit, how often it may be announced, what happens at\n * it, and the words for approaching and reaching it. The sweep runs them\n * after its own quota checks, through the same approach threshold, guards\n * and both channels, so a plugin's band warns exactly as a platform band\n * does.\n * - `evaluate` decides STAFF alerts, and the sweep hands it each org's\n * reading after the budget alert and before the bandwidth cap.\n *\n * THE PIPELINE IS THE SWEEP'S. A contributor decides whether an alert is due\n * and what it says; the context does everything else:\n *\n * - `recordAlert(key, threshold)` records the dedupe guard and answers\n * whether the alert may be SENT. On an org's first, silent evaluation it\n * records the guard as though the alert went out and answers `false`, so a\n * contributor that sends only on `true` honors the first-sweep seeding\n * without knowing when it applies.\n * - `alertStaff(alert)` is the sweep's own sender: the staff bell, then the\n * staff inbox, then a row in the run's report saying whether the mail went\n * out. On an org's first, silent evaluation it sends nothing, whoever calls\n * it.\n * - `guards` is the org's guard map as the sweep read it. A contributor reads\n * it to dedupe and never writes a guard itself: the sweep writes what\n * `recordAlert` recorded, once, with the rest of the org's update.\n *\n * Guard keys share one map with core's own checks (`hosts`, `bandwidth`,\n * `budget` and the rest), so a contributor names its keys for what it\n * measures and never reuses one of core's.\n *\n * Contributors run one at a time and are ISOLATED: a throw is logged against\n * the contributor and the next one runs. A guard the failed contributor\n * recorded for an alert it never delivered is dropped rather than written, so\n * the next sweep tries that alert again instead of counting it as announced.\n * One plugin's failure costs no other plugin's alert, none of core's, and not\n * the rest of the sweep.\n *\n * The order is deterministic: the catalog order of `FIRST_PARTY_PLUGINS`,\n * then any other plugin id in code-unit order, then registration order within\n * a plugin. The order contributors REGISTER in depends on how each process\n * loaded the declarations, and a list that followed it could send and report\n * one org's alerts in a different order per process.\n */\n\nimport type { NotificationLevel } from '../app-utils/notifications'\nimport type {\n OrgSpendBreakdown,\n UsageAlertGuard,\n} from '../app-utils/usage-budget'\nimport { FIRST_PARTY_PLUGINS } from './enabled-plugins'\n\n/** One staff alert, as the sweep delivers and reports it. */\nexport interface UsageStaffAlert {\n /** The guard key the alert was recorded under; the report row's `quota`. */\n quota: string\n /** The threshold passed to `recordAlert`; the report row's `threshold`. */\n threshold: number\n /** The bell's title and the email's subject: one set of words for both. */\n title: string\n /** The bell's body. The email carries it verbatim, followed by the link. */\n body: string\n /** A console path. The bell links it; the email makes it absolute. */\n link: string\n /** The email's tag and log label. */\n emailContext: string\n /**\n * How loud the bell draws it (AGL-3437). A contributor's threshold is its\n * own unit, not a percentage, so only the contributor can say; absent, the\n * `billing.usage` default answers.\n */\n level?: NotificationLevel\n}\n\n/** What the sweep hands a contributor for one org. */\nexport interface UsageAlertContext {\n orgId: string\n /** The org's slug, or `null` when the document carries none. */\n orgSlug: string | null\n /** The org document as the sweep read it. */\n org: Readonly<Record<string, unknown>>\n /** The `YYYY-MM` month the sweep dedupes against, fixed for the whole run. */\n month: string\n /** The org's spend this month, built from the figures the sweep read. */\n spend: Readonly<OrgSpendBreakdown>\n /** The org's guard map as the sweep read it, before this run's updates. */\n guards: Readonly<Record<string, Readonly<UsageAlertGuard>>>\n /**\n * Records the guard for `key` at `threshold` and answers whether the alert\n * may be sent. Call it once the alert is due, and send only on `true`.\n */\n recordAlert: (key: string, threshold: number) => boolean\n /** Delivers one staff alert through the sweep's senders and reports it. */\n alertStaff: (alert: UsageStaffAlert) => Promise<void>\n}\n\n/**\n * One band a workspace is warned about as it approaches and reaches it — the\n * shape of a plugin's quota check.\n *\n * The sweep owns everything past the numbers and the words: the approach\n * percentage, the guard under `key`, the first-sweep seeding, both channels,\n * and the sentence every notice opens with — the workspace, then the figures,\n * then `noun` (\"The Acme workspace has used 2,300 of the 2,750 AI assist\n * credits your plan includes this month\").\n */\nexport interface UsageQuotaCheck {\n /** The guard key; named for what it measures, never one of core's. */\n key: string\n /** How the alert's title names the band: \"AI assist credits\". */\n label: string\n /** What the figures count, in the opening sentence. */\n noun: string\n used: number\n /** `UNLIMITED` or anything not above 0 is skipped. */\n limit: number\n /**\n * How often one threshold may be announced: `crossing` for something the\n * workspace HAS, announced once when reached and again only after usage\n * falls back below; `monthly` for a meter that resets on the 1st.\n */\n cadence: 'crossing' | 'monthly'\n /**\n * What happens at the band, which the notice has to say because the three\n * call for different actions: `stops` (new use is refused, nothing is\n * charged), `bills` (it keeps working and starts charging) or `continues`\n * (nothing is refused and nothing is charged).\n */\n outcome: 'stops' | 'bills' | 'continues'\n /** What is true now, at or past the band. */\n reached: string\n /** What will happen at the band, said while approaching it. */\n approach: string\n /** Replaces the generic title at 100%, when that would not be true. */\n reachedTitle?: string\n /**\n * Replaces the figures in the opening sentence at 100%, after the\n * workspace's name (\"has used its free AI credits for this month.\"). For a\n * band nothing is ever sold past, where \"442 — more than the 300 your plan\n * includes\" reads like a charge that cannot happen.\n */\n reachedLead?: string\n /**\n * At 100%, link the plans page instead of the usage section: the band is\n * an allowance that only an upgrade adds to, and the notice says so.\n */\n reachedLinksUpgrade?: boolean\n}\n\n/** What the sweep hands a contributor's quota checks for one org. */\nexport interface UsageQuotaContext {\n orgId: string\n /** The org document as the sweep read it. */\n org: Readonly<Record<string, unknown>>\n /** The `YYYY-MM` month the sweep dedupes against. */\n month: string\n /** The org's spend this month, built from the figures the sweep read. */\n spend: Readonly<OrgSpendBreakdown>\n}\n\n/** A plugin's rule for the usage-alerts sweep. */\nexport interface UsageAlertContributor {\n pluginId: string\n /** Unique within the plugin; two plugins may use the same id. */\n id: string\n /** Evaluates one org's STAFF alerts, sending only through the context. */\n evaluate?: (context: UsageAlertContext) => Promise<void>\n /**\n * The bands the WORKSPACE is warned about, which the sweep runs after its\n * own through the same thresholds, guards and senders.\n */\n quotaChecks?: (context: UsageQuotaContext) => Promise<readonly UsageQuotaCheck[]>\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947).\n * The contributors are registered at boot, from `instrumentation.ts`, and Next\n * compiles that file apart from the routes, so the boot and a route each hold\n * their own copy of this module. At module scope, the boot filled its copy and\n * every route read an empty one (AGL-3412, AGL-3868).\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:usage-alert-contributors')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: UsageAlertContributor[]\n}\nconst registrations: UsageAlertContributor[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\nconst CATALOG_ORDER = new Map(\n FIRST_PARTY_PLUGINS.map((plugin, index) => [plugin.id, index]),\n)\n\nfunction catalogRank(pluginId: string): number {\n return CATALOG_ORDER.get(pluginId) ?? Number.MAX_SAFE_INTEGER\n}\n\n/** Code-unit order, which no locale can reorder. */\nfunction codeUnitOrder(a: string, b: string): number {\n return a < b ? -1 : a > b ? 1 : 0\n}\n\n/**\n * Registers a contributor. Idempotent per plugin and id: registering the same\n * pair again replaces the earlier contributor in place, so a declarations\n * module evaluated twice does not alert twice. A contributor with no plugin,\n * no id, or neither `evaluate` nor `quotaChecks` throws.\n */\nexport function registerUsageAlertContributor(\n contributor: UsageAlertContributor,\n): void {\n const pluginId = String(contributor.pluginId ?? '').trim()\n const id = String(contributor.id ?? '').trim()\n if (!pluginId || !id) {\n throw new Error('a usage alert contributor needs a pluginId and an id')\n }\n const evaluate =\n typeof contributor.evaluate === 'function' ? contributor.evaluate : undefined\n const quotaChecks =\n typeof contributor.quotaChecks === 'function'\n ? contributor.quotaChecks\n : undefined\n if (!evaluate && !quotaChecks) {\n throw new Error(\n `usage alert contributor \"${pluginId}:${id}\" has neither an evaluate ` +\n 'nor a quotaChecks function',\n )\n }\n const entry: UsageAlertContributor = {\n pluginId,\n id,\n ...(evaluate ? { evaluate } : {}),\n ...(quotaChecks ? { quotaChecks } : {}),\n }\n const index = registrations.findIndex(\n (existing) => existing.pluginId === pluginId && existing.id === id,\n )\n if (index >= 0) registrations[index] = entry\n else registrations.push(entry)\n}\n\n/** Every registered contributor, in the order the sweep runs them. */\nexport function listUsageAlertContributors(): UsageAlertContributor[] {\n return registrations\n .map((entry, sequence) => ({ entry, sequence }))\n .sort(\n (a, b) =>\n catalogRank(a.entry.pluginId) - catalogRank(b.entry.pluginId) ||\n codeUnitOrder(a.entry.pluginId, b.entry.pluginId) ||\n a.sequence - b.sequence,\n )\n .map(({ entry }) => ({ ...entry }))\n}\n\n/** Test seam: forget every contributor. */\nexport function resetUsageAlertContributorsForTests(): void {\n registrations.length = 0\n}\n"],"names":["globalScope","FIRST_PARTY_PLUGINS","REGISTRY_KEY","Symbol","for","globalThis","registrations","CATALOG_ORDER","Map","map","plugin","index","id","catalogRank","pluginId","get","Number","MAX_SAFE_INTEGER","codeUnitOrder","a","b","registerUsageAlertContributor","contributor","String","trim","Error","evaluate","undefined","quotaChecks","entry","findIndex","existing","push","listUsageAlertContributors","sequence","sort","resetUsageAlertContributorsForTests","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDC;IA+ICA;AAxIF,SAASC,mBAAmB,QAAQ,uBAAmB;AA4HvD;;;;;;CAMC,GACD,MAAMC,eAAeC,OAAOC,GAAG,CAAC;AAChC,MAAMJ,cAAcK;AAGpB,MAAMC,iBACJN,4BAAAA,WAAW,CAACE,aAAa,YAAzBF,4BAA8BA,WAAW,CAACE,aAAa,GAAG,EAAE;AAE9D,MAAMK,gBAAgB,IAAIC,IACxBP,oBAAoBQ,GAAG,CAAC,CAACC,QAAQC,QAAU;QAACD,OAAOE,EAAE;QAAED;KAAM;AAG/D,SAASE,YAAYC,QAAgB;QAC5BP;IAAP,QAAOA,qBAAAA,cAAcQ,GAAG,CAACD,qBAAlBP,qBAA+BS,OAAOC,gBAAgB;AAC/D;AAEA,kDAAkD,GAClD,SAASC,cAAcC,CAAS,EAAEC,CAAS;IACzC,OAAOD,IAAIC,IAAI,CAAC,IAAID,IAAIC,IAAI,IAAI;AAClC;AAEA;;;;;CAKC,GACD,OAAO,SAASC,8BACdC,WAAkC;QAEVA,uBACNA;IADlB,MAAMR,WAAWS,QAAOD,wBAAAA,YAAYR,QAAQ,YAApBQ,wBAAwB,IAAIE,IAAI;IACxD,MAAMZ,KAAKW,QAAOD,kBAAAA,YAAYV,EAAE,YAAdU,kBAAkB,IAAIE,IAAI;IAC5C,IAAI,CAACV,YAAY,CAACF,IAAI;QACpB,MAAM,IAAIa,MAAM;IAClB;IACA,MAAMC,WACJ,OAAOJ,YAAYI,QAAQ,KAAK,aAAaJ,YAAYI,QAAQ,GAAGC;IACtE,MAAMC,cACJ,OAAON,YAAYM,WAAW,KAAK,aAC/BN,YAAYM,WAAW,GACvBD;IACN,IAAI,CAACD,YAAY,CAACE,aAAa;QAC7B,MAAM,IAAIH,MACR,CAAC,yBAAyB,EAAEX,SAAS,CAAC,EAAEF,GAAG,0BAA0B,CAAC,GACpE;IAEN;IACA,MAAMiB,QAA+B;QACnCf;QACAF;OACIc,WAAW;QAAEA;IAAS,IAAI,CAAC,GAC3BE,cAAc;QAAEA;IAAY,IAAI,CAAC;IAEvC,MAAMjB,QAAQL,cAAcwB,SAAS,CACnC,CAACC,WAAaA,SAASjB,QAAQ,KAAKA,YAAYiB,SAASnB,EAAE,KAAKA;IAElE,IAAID,SAAS,GAAGL,aAAa,CAACK,MAAM,GAAGkB;SAClCvB,cAAc0B,IAAI,CAACH;AAC1B;AAEA,oEAAoE,GACpE,OAAO,SAASI;IACd,OAAO3B,cACJG,GAAG,CAAC,CAACoB,OAAOK,WAAc,CAAA;YAAEL;YAAOK;QAAS,CAAA,GAC5CC,IAAI,CACH,CAAChB,GAAGC,IACFP,YAAYM,EAAEU,KAAK,CAACf,QAAQ,IAAID,YAAYO,EAAES,KAAK,CAACf,QAAQ,KAC5DI,cAAcC,EAAEU,KAAK,CAACf,QAAQ,EAAEM,EAAES,KAAK,CAACf,QAAQ,KAChDK,EAAEe,QAAQ,GAAGd,EAAEc,QAAQ,EAE1BzB,GAAG,CAAC,CAAC,EAAEoB,KAAK,EAAE,GAAM,aAAKA;AAC9B;AAEA,yCAAyC,GACzC,OAAO,SAASO;IACd9B,cAAc+B,MAAM,GAAG;AACzB"}
|