@aglyn/aglyn 1.0.0-beta.266 → 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 +12 -12
- 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/app-utils/health-report.d.ts +78 -6
- package/src/lib/app-utils/health-report.js +113 -6
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/notifications.js +6 -10
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/upload-inspection.d.ts +9 -0
- package/src/lib/app-utils/upload-inspection.js +27 -0
- package/src/lib/app-utils/upload-inspection.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-site-erase.d.ts +81 -0
- package/src/lib/plugin-manager/plugin-site-erase.js +56 -0
- package/src/lib/plugin-manager/plugin-site-erase.js.map +1 -0
- 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-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"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A SERVER PROCESS DELETES A SITE THE WAY ITS ADMIN DOES (AGL-3936).
|
|
19
|
+
*
|
|
20
|
+
* Deleting a site is more than its documents: the erase itself
|
|
21
|
+
* (`eraseHost`: Storage, the routing index, the org's `hosts` map and its
|
|
22
|
+
* pooled seats, every member's projection, the Firestore tree), the site's
|
|
23
|
+
* sending domain, which only the console holds the credentials to release,
|
|
24
|
+
* the tenant's cached pages and names, and the record of it in the
|
|
25
|
+
* workspace's feed and the staff audit. The console's delete route does all
|
|
26
|
+
* of it in that order. A plugin with no request behind it — the AI beat
|
|
27
|
+
* removing the empty site a cancelled guided start left — has no route to
|
|
28
|
+
* call, and a partial delete of its own would strand whichever half it left
|
|
29
|
+
* out. So the console registers its one implementation at boot
|
|
30
|
+
* (`instrumentation.ts`), the same direction as `core.site-cache`, and a
|
|
31
|
+
* plugin names the site and why.
|
|
32
|
+
*
|
|
33
|
+
* Authorization is the caller's. The implementation re-reads the site, and
|
|
34
|
+
* refuses one in a consent group as the delete route does.
|
|
35
|
+
*
|
|
36
|
+
* A process with no implementation answers `{ erased: false, reason:
|
|
37
|
+
* 'unavailable' }` from {@link erasePluginSite}, never a silent success: a
|
|
38
|
+
* caller keeps whatever told it to delete, and tries again later.
|
|
39
|
+
*/
|
|
40
|
+
export interface PluginSiteEraseRequest {
|
|
41
|
+
hostId: string;
|
|
42
|
+
/**
|
|
43
|
+
* What the workspace's feed says happened, in its own words: "Removed an
|
|
44
|
+
* empty site". The feed names the site beside it.
|
|
45
|
+
*/
|
|
46
|
+
activity: string;
|
|
47
|
+
/** Why, for the staff audit and the log: `'ai-start-empty'`. */
|
|
48
|
+
reason: string;
|
|
49
|
+
/**
|
|
50
|
+
* The workspace the caller believes owns the site. A site that now belongs
|
|
51
|
+
* to another workspace is not erased (`reason: 'moved'`).
|
|
52
|
+
*/
|
|
53
|
+
orgId: string;
|
|
54
|
+
}
|
|
55
|
+
export type PluginSiteEraseResult = {
|
|
56
|
+
erased: true;
|
|
57
|
+
} | {
|
|
58
|
+
erased: false;
|
|
59
|
+
/**
|
|
60
|
+
* `missing`: the site is already gone. `moved`: it belongs to another
|
|
61
|
+
* workspace now. `consent-group`: its consent group still needs it.
|
|
62
|
+
* `unavailable`: nothing is installed, or the erase failed.
|
|
63
|
+
*/
|
|
64
|
+
reason: 'missing' | 'moved' | 'consent-group' | 'unavailable';
|
|
65
|
+
};
|
|
66
|
+
export interface PluginSiteEraser {
|
|
67
|
+
erase(request: PluginSiteEraseRequest): Promise<PluginSiteEraseResult>;
|
|
68
|
+
}
|
|
69
|
+
/** One implementation: the app that holds the delete route's credentials. */
|
|
70
|
+
export declare const PLUGIN_SITE_ERASER: import("./plugin-services").PluginServiceContract<PluginSiteEraser>;
|
|
71
|
+
export declare function registerPluginSiteEraser(eraser: PluginSiteEraser, options?: {
|
|
72
|
+
pluginId?: string;
|
|
73
|
+
}): void;
|
|
74
|
+
/** Whether an implementation is installed. */
|
|
75
|
+
export declare function hasPluginSiteEraser(): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Deletes one site through the app's implementation. Never throws: nothing
|
|
78
|
+
* installed, or an implementation that threw, answers `unavailable` with
|
|
79
|
+
* the reason logged.
|
|
80
|
+
*/
|
|
81
|
+
export declare function erasePluginSite(request: PluginSiteEraseRequest): Promise<PluginSiteEraseResult>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { definePluginServiceContract, registerPluginService, resolvePluginService } from "./plugin-services.js";
|
|
18
|
+
/** One implementation: the app that holds the delete route's credentials. */ export const PLUGIN_SITE_ERASER = definePluginServiceContract('core.site-erase', {
|
|
19
|
+
multiple: false
|
|
20
|
+
});
|
|
21
|
+
export function registerPluginSiteEraser(eraser, options) {
|
|
22
|
+
if (typeof (eraser == null ? void 0 : eraser.erase) !== 'function') {
|
|
23
|
+
throw new Error('a site eraser needs an erase function');
|
|
24
|
+
}
|
|
25
|
+
registerPluginService(PLUGIN_SITE_ERASER, eraser, _extends({}, (options == null ? void 0 : options.pluginId) ? {
|
|
26
|
+
pluginId: options.pluginId
|
|
27
|
+
} : {}));
|
|
28
|
+
}
|
|
29
|
+
/** Whether an implementation is installed. */ export function hasPluginSiteEraser() {
|
|
30
|
+
return Boolean(resolvePluginService(PLUGIN_SITE_ERASER));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Deletes one site through the app's implementation. Never throws: nothing
|
|
34
|
+
* installed, or an implementation that threw, answers `unavailable` with
|
|
35
|
+
* the reason logged.
|
|
36
|
+
*/ export async function erasePluginSite(request) {
|
|
37
|
+
const eraser = resolvePluginService(PLUGIN_SITE_ERASER);
|
|
38
|
+
if (!eraser) {
|
|
39
|
+
console.error(`[site-erase] no implementation is installed, so site ${request.hostId} ` + `was not deleted (${request.reason}). The app registers this at boot.`);
|
|
40
|
+
return {
|
|
41
|
+
erased: false,
|
|
42
|
+
reason: 'unavailable'
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return await eraser.erase(request);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error(`[site-erase] deleting site ${request.hostId} failed (${request.reason})`, error);
|
|
49
|
+
return {
|
|
50
|
+
erased: false,
|
|
51
|
+
reason: 'unavailable'
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
//# sourceMappingURL=plugin-site-erase.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-site-erase.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\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginService,\n} from './plugin-services'\n\n/**\n * A SERVER PROCESS DELETES A SITE THE WAY ITS ADMIN DOES (AGL-3936).\n *\n * Deleting a site is more than its documents: the erase itself\n * (`eraseHost`: Storage, the routing index, the org's `hosts` map and its\n * pooled seats, every member's projection, the Firestore tree), the site's\n * sending domain, which only the console holds the credentials to release,\n * the tenant's cached pages and names, and the record of it in the\n * workspace's feed and the staff audit. The console's delete route does all\n * of it in that order. A plugin with no request behind it — the AI beat\n * removing the empty site a cancelled guided start left — has no route to\n * call, and a partial delete of its own would strand whichever half it left\n * out. So the console registers its one implementation at boot\n * (`instrumentation.ts`), the same direction as `core.site-cache`, and a\n * plugin names the site and why.\n *\n * Authorization is the caller's. The implementation re-reads the site, and\n * refuses one in a consent group as the delete route does.\n *\n * A process with no implementation answers `{ erased: false, reason:\n * 'unavailable' }` from {@link erasePluginSite}, never a silent success: a\n * caller keeps whatever told it to delete, and tries again later.\n */\n\nexport interface PluginSiteEraseRequest {\n hostId: string\n /**\n * What the workspace's feed says happened, in its own words: \"Removed an\n * empty site\". The feed names the site beside it.\n */\n activity: string\n /** Why, for the staff audit and the log: `'ai-start-empty'`. */\n reason: string\n /**\n * The workspace the caller believes owns the site. A site that now belongs\n * to another workspace is not erased (`reason: 'moved'`).\n */\n orgId: string\n}\n\nexport type PluginSiteEraseResult =\n | { erased: true }\n | {\n erased: false\n /**\n * `missing`: the site is already gone. `moved`: it belongs to another\n * workspace now. `consent-group`: its consent group still needs it.\n * `unavailable`: nothing is installed, or the erase failed.\n */\n reason: 'missing' | 'moved' | 'consent-group' | 'unavailable'\n }\n\nexport interface PluginSiteEraser {\n erase(request: PluginSiteEraseRequest): Promise<PluginSiteEraseResult>\n}\n\n/** One implementation: the app that holds the delete route's credentials. */\nexport const PLUGIN_SITE_ERASER = definePluginServiceContract<PluginSiteEraser>(\n 'core.site-erase',\n { multiple: false },\n)\n\nexport function registerPluginSiteEraser(\n eraser: PluginSiteEraser,\n options?: { pluginId?: string },\n): void {\n if (typeof eraser?.erase !== 'function') {\n throw new Error('a site eraser needs an erase function')\n }\n registerPluginService(PLUGIN_SITE_ERASER, eraser, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n })\n}\n\n/** Whether an implementation is installed. */\nexport function hasPluginSiteEraser(): boolean {\n return Boolean(resolvePluginService(PLUGIN_SITE_ERASER))\n}\n\n/**\n * Deletes one site through the app's implementation. Never throws: nothing\n * installed, or an implementation that threw, answers `unavailable` with\n * the reason logged.\n */\nexport async function erasePluginSite(\n request: PluginSiteEraseRequest,\n): Promise<PluginSiteEraseResult> {\n const eraser = resolvePluginService(PLUGIN_SITE_ERASER)\n if (!eraser) {\n console.error(\n `[site-erase] no implementation is installed, so site ${request.hostId} ` +\n `was not deleted (${request.reason}). The app registers this at boot.`,\n )\n return { erased: false, reason: 'unavailable' }\n }\n try {\n return await eraser.erase(request)\n } catch (error) {\n console.error(`[site-erase] deleting site ${request.hostId} failed (${request.reason})`, error)\n return { erased: false, reason: 'unavailable' }\n }\n}\n"],"names":["definePluginServiceContract","registerPluginService","resolvePluginService","PLUGIN_SITE_ERASER","multiple","registerPluginSiteEraser","eraser","options","erase","Error","pluginId","hasPluginSiteEraser","Boolean","erasePluginSite","request","console","error","hostId","reason","erased"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,2BAA2B,EAC3BC,qBAAqB,EACrBC,oBAAoB,QACf,uBAAmB;AA0D1B,2EAA2E,GAC3E,OAAO,MAAMC,qBAAqBH,4BAChC,mBACA;IAAEI,UAAU;AAAM,GACnB;AAED,OAAO,SAASC,yBACdC,MAAwB,EACxBC,OAA+B;IAE/B,IAAI,QAAOD,0BAAAA,OAAQE,KAAK,MAAK,YAAY;QACvC,MAAM,IAAIC,MAAM;IAClB;IACAR,sBAAsBE,oBAAoBG,QAAQ,aAC5CC,CAAAA,2BAAAA,QAASG,QAAQ,IAAG;QAAEA,UAAUH,QAAQG,QAAQ;IAAC,IAAI,CAAC;AAE9D;AAEA,4CAA4C,GAC5C,OAAO,SAASC;IACd,OAAOC,QAAQV,qBAAqBC;AACtC;AAEA;;;;CAIC,GACD,OAAO,eAAeU,gBACpBC,OAA+B;IAE/B,MAAMR,SAASJ,qBAAqBC;IACpC,IAAI,CAACG,QAAQ;QACXS,QAAQC,KAAK,CACX,CAAC,qDAAqD,EAAEF,QAAQG,MAAM,CAAC,CAAC,CAAC,GACvE,CAAC,iBAAiB,EAAEH,QAAQI,MAAM,CAAC,kCAAkC,CAAC;QAE1E,OAAO;YAAEC,QAAQ;YAAOD,QAAQ;QAAc;IAChD;IACA,IAAI;QACF,OAAO,MAAMZ,OAAOE,KAAK,CAACM;IAC5B,EAAE,OAAOE,OAAO;QACdD,QAAQC,KAAK,CAAC,CAAC,2BAA2B,EAAEF,QAAQG,MAAM,CAAC,SAAS,EAAEH,QAAQI,MAAM,CAAC,CAAC,CAAC,EAAEF;QACzF,OAAO;YAAEG,QAAQ;YAAOD,QAAQ;QAAc;IAChD;AACF"}
|
|
@@ -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"}
|