@aglyn/aglyn 1.0.0-beta.268 → 1.0.0-beta.270
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -11
- package/src/lib/app-utils/account-preferences.d.ts +248 -0
- package/src/lib/app-utils/account-preferences.js +434 -0
- package/src/lib/app-utils/account-preferences.js.map +1 -0
- package/src/lib/app-utils/country-options.d.ts +27 -0
- package/src/lib/app-utils/country-options.js +96 -0
- package/src/lib/app-utils/country-options.js.map +1 -0
- package/src/lib/app-utils/docs-help.generated.d.ts +6 -0
- package/src/lib/app-utils/docs-help.generated.js +13 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +17 -10
- 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/mobile-push.d.ts +8 -0
- package/src/lib/app-utils/mobile-push.js +4 -1
- package/src/lib/app-utils/mobile-push.js.map +1 -1
- package/src/lib/app-utils/native-app-association.d.ts +3 -2
- package/src/lib/app-utils/native-app-association.js +4 -2
- package/src/lib/app-utils/native-app-association.js.map +1 -1
- package/src/lib/app-utils/native-nav.d.ts +265 -0
- package/src/lib/app-utils/native-nav.js +741 -0
- package/src/lib/app-utils/native-nav.js.map +1 -0
- package/src/lib/app-utils/native-themes.d.ts +249 -0
- package/src/lib/app-utils/native-themes.js +732 -0
- package/src/lib/app-utils/native-themes.js.map +1 -0
- package/src/lib/app-utils/notifications.d.ts +1 -1
- package/src/lib/app-utils/notifications.js +10 -0
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/one-box-search.d.ts +108 -0
- package/src/lib/app-utils/one-box-search.js +167 -0
- package/src/lib/app-utils/one-box-search.js.map +1 -0
- package/src/lib/app-utils/org-permissions.js +10 -61
- package/src/lib/app-utils/org-permissions.js.map +1 -1
- package/src/lib/app-utils/org-role-grants.d.ts +95 -0
- package/src/lib/app-utils/org-role-grants.js +193 -0
- package/src/lib/app-utils/org-role-grants.js.map +1 -0
- package/src/lib/app-utils/plan-entitlements.d.ts +41 -1
- package/src/lib/app-utils/plan-entitlements.js +65 -3
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/tenant-dns-targets.d.ts +62 -0
- package/src/lib/app-utils/tenant-dns-targets.js +80 -0
- package/src/lib/app-utils/tenant-dns-targets.js.map +1 -0
- package/src/lib/foundation/definitions/org-billing.types.d.ts +14 -0
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/foundation/definitions/platform.types.d.ts +9 -0
- package/src/lib/foundation/definitions/platform.types.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +38 -0
- package/src/lib/plugin-manager/feature-plugins.js +20 -0
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +22 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.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-domain-events.d.ts +4 -3
- package/src/lib/plugin-manager/plugin-domain-events.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-media-ingest.d.ts +25 -0
- package/src/lib/plugin-manager/plugin-media-ingest.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-search-sources.d.ts +85 -0
- package/src/lib/plugin-manager/plugin-search-sources.js +91 -0
- package/src/lib/plugin-manager/plugin-search-sources.js.map +1 -0
- package/src/lib/plugin-manager/plugin-services.js +6 -0
- package/src/lib/plugin-manager/plugin-services.js.map +1 -1
- package/src/lib/plugin-manager/plugin-usage-meters.js +10 -1
- package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -1
- package/src/lib/plugin-manager/usage-alert-contributors.js +10 -1
- package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
|
@@ -50,6 +50,18 @@ export interface PluginMediaIngestRequest {
|
|
|
50
50
|
description?: string;
|
|
51
51
|
/** Where a photo copied from a stock library came from (`stockPhoto` on the asset). */
|
|
52
52
|
stockPhoto?: Omit<AglynHostMediaStockSource, 'importedAt'>;
|
|
53
|
+
/**
|
|
54
|
+
* The library folder the asset is filed in (AGL-3957); absent or null,
|
|
55
|
+
* the library's root. An id the library does not hold files the asset at
|
|
56
|
+
* the root, as an upload naming a stale folder does.
|
|
57
|
+
*/
|
|
58
|
+
folderId?: string | null;
|
|
59
|
+
/**
|
|
60
|
+
* The asset is the business's own photo, sent through a site photo link
|
|
61
|
+
* (AGL-3957) — the id of the link it came through. Stamps `ownerPhoto` on
|
|
62
|
+
* the asset, which is what the AI reads its owner's pictures by.
|
|
63
|
+
*/
|
|
64
|
+
ownerPhotoLinkId?: string;
|
|
53
65
|
}
|
|
54
66
|
/** A stored asset, as a page names it. */
|
|
55
67
|
export interface PluginMediaAsset {
|
|
@@ -58,6 +70,8 @@ export interface PluginMediaAsset {
|
|
|
58
70
|
src: string;
|
|
59
71
|
width?: number;
|
|
60
72
|
height?: number;
|
|
73
|
+
/** What the asset's own alt text says it shows, where a member wrote one. */
|
|
74
|
+
alt?: string;
|
|
61
75
|
}
|
|
62
76
|
export type PluginMediaIngestResult = ({
|
|
63
77
|
ok: true;
|
|
@@ -77,6 +91,17 @@ export interface PluginMediaIngest {
|
|
|
77
91
|
hostId: string;
|
|
78
92
|
sourceKey: string;
|
|
79
93
|
}): Promise<PluginMediaAsset | null>;
|
|
94
|
+
/**
|
|
95
|
+
* The site's live, public photos of the business itself (AGL-3957): what
|
|
96
|
+
* came through a site photo link, newest first, at most `limit`. A photo
|
|
97
|
+
* of the owner's own work is placed before any stock photo is searched
|
|
98
|
+
* for. Optional: an implementation registered before the link existed
|
|
99
|
+
* answers nothing, and a caller treats that as an empty library.
|
|
100
|
+
*/
|
|
101
|
+
listOwnerPhotos?(input: {
|
|
102
|
+
hostId: string;
|
|
103
|
+
limit?: number;
|
|
104
|
+
}): Promise<PluginMediaAsset[]>;
|
|
80
105
|
}
|
|
81
106
|
/** One implementation: the app that holds the media ingress. */
|
|
82
107
|
export declare const PLUGIN_MEDIA_INGEST: import("./plugin-services").PluginServiceContract<PluginMediaIngest>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-media-ingest.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 type { AglynHostMediaStockSource } from '../foundation/definitions/platform.types'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginService,\n} from './plugin-services'\n\n/**\n * A SERVER PROCESS STORES A PICTURE IN A SITE'S MEDIA LIBRARY (AGL-3660).\n *\n * The media library takes bytes through the console's own ingress — the\n * upload routes and the v1 API — which check the caller, the site's\n * lockdown, the bytes' structure, the takedown list, the plan and the\n * storage band before anything is written. A plugin that holds a picture on\n * the server with no browser request behind it (an AI job copying a stock\n * photo) has no route to post to. This is that route's door for it: the\n * console registers the one implementation at boot (`instrumentation.ts`),\n * the same direction as `core.site-cache`, and it applies the ingress checks\n * as the member named, so a plugin never writes a media document or a\n * Storage object itself.\n *\n * Images only: a server-held video or document has no caller yet.\n *\n * A process with no implementation answers `null` from\n * {@link pluginMediaIngest}, and a caller keeps its own fallback.\n */\n\nexport interface PluginMediaIngestRequest {\n hostId: string\n /**\n * The member the asset is stored as. Their access to the site, the site's\n * lockdown and the workspace's plan and storage band are checked as for\n * an upload they made themselves.\n */\n uid: string\n fileName: string\n /** A raster image type: `image/jpeg`, `image/png`, `image/webp` or `image/gif`. */\n contentType: string\n bytes: Uint8Array\n alt?: string\n description?: string\n /** Where a photo copied from a stock library came from (`stockPhoto` on the asset). */\n stockPhoto?: Omit<AglynHostMediaStockSource, 'importedAt'>\n}\n\n/** A stored asset, as a page names it. */\nexport interface PluginMediaAsset {\n mediaId: string\n /** What an `image` node's `src` holds: the asset's media reference, else its URL. */\n src: string\n width?: number\n height?: number\n}\n\nexport type PluginMediaIngestResult =\n | ({ ok: true } & PluginMediaAsset)\n | { ok: false; status: number; reason: string }\n\nexport interface PluginMediaIngest {\n /** Stores one picture in the site's own library. */\n ingest(request: PluginMediaIngestRequest): Promise<PluginMediaIngestResult>\n /**\n * The site's live, public asset copied from a stock photo with this source\n * key (`{provider}:{id}`), so a photo is never stored twice in one library.\n */\n findStockPhoto(input: { hostId: string; sourceKey: string }): Promise<PluginMediaAsset | null>\n}\n\n/** One implementation: the app that holds the media ingress. */\nexport const PLUGIN_MEDIA_INGEST = definePluginServiceContract<PluginMediaIngest>(\n 'core.media-ingest',\n { multiple: false },\n)\n\nexport function registerPluginMediaIngest(\n ingest: PluginMediaIngest,\n options?: { pluginId?: string },\n): void {\n if (typeof ingest?.ingest !== 'function' || typeof ingest?.findStockPhoto !== 'function') {\n throw new Error('a media ingest needs ingest and findStockPhoto')\n }\n registerPluginService(PLUGIN_MEDIA_INGEST, ingest, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n })\n}\n\n/** The registered ingest, or `null` in a process that registered none. */\nexport function pluginMediaIngest(): PluginMediaIngest | null {\n return resolvePluginService(PLUGIN_MEDIA_INGEST) ?? null\n}\n"],"names":["definePluginServiceContract","registerPluginService","resolvePluginService","PLUGIN_MEDIA_INGEST","multiple","registerPluginMediaIngest","ingest","options","findStockPhoto","Error","pluginId","pluginMediaIngest"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SACEA,2BAA2B,EAC3BC,qBAAqB,EACrBC,oBAAoB,QACf,uBAAmB;
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-media-ingest.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 type { AglynHostMediaStockSource } from '../foundation/definitions/platform.types'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginService,\n} from './plugin-services'\n\n/**\n * A SERVER PROCESS STORES A PICTURE IN A SITE'S MEDIA LIBRARY (AGL-3660).\n *\n * The media library takes bytes through the console's own ingress — the\n * upload routes and the v1 API — which check the caller, the site's\n * lockdown, the bytes' structure, the takedown list, the plan and the\n * storage band before anything is written. A plugin that holds a picture on\n * the server with no browser request behind it (an AI job copying a stock\n * photo) has no route to post to. This is that route's door for it: the\n * console registers the one implementation at boot (`instrumentation.ts`),\n * the same direction as `core.site-cache`, and it applies the ingress checks\n * as the member named, so a plugin never writes a media document or a\n * Storage object itself.\n *\n * Images only: a server-held video or document has no caller yet.\n *\n * A process with no implementation answers `null` from\n * {@link pluginMediaIngest}, and a caller keeps its own fallback.\n */\n\nexport interface PluginMediaIngestRequest {\n hostId: string\n /**\n * The member the asset is stored as. Their access to the site, the site's\n * lockdown and the workspace's plan and storage band are checked as for\n * an upload they made themselves.\n */\n uid: string\n fileName: string\n /** A raster image type: `image/jpeg`, `image/png`, `image/webp` or `image/gif`. */\n contentType: string\n bytes: Uint8Array\n alt?: string\n description?: string\n /** Where a photo copied from a stock library came from (`stockPhoto` on the asset). */\n stockPhoto?: Omit<AglynHostMediaStockSource, 'importedAt'>\n /**\n * The library folder the asset is filed in (AGL-3957); absent or null,\n * the library's root. An id the library does not hold files the asset at\n * the root, as an upload naming a stale folder does.\n */\n folderId?: string | null\n /**\n * The asset is the business's own photo, sent through a site photo link\n * (AGL-3957) — the id of the link it came through. Stamps `ownerPhoto` on\n * the asset, which is what the AI reads its owner's pictures by.\n */\n ownerPhotoLinkId?: string\n}\n\n/** A stored asset, as a page names it. */\nexport interface PluginMediaAsset {\n mediaId: string\n /** What an `image` node's `src` holds: the asset's media reference, else its URL. */\n src: string\n width?: number\n height?: number\n /** What the asset's own alt text says it shows, where a member wrote one. */\n alt?: string\n}\n\nexport type PluginMediaIngestResult =\n | ({ ok: true } & PluginMediaAsset)\n | { ok: false; status: number; reason: string }\n\nexport interface PluginMediaIngest {\n /** Stores one picture in the site's own library. */\n ingest(request: PluginMediaIngestRequest): Promise<PluginMediaIngestResult>\n /**\n * The site's live, public asset copied from a stock photo with this source\n * key (`{provider}:{id}`), so a photo is never stored twice in one library.\n */\n findStockPhoto(input: { hostId: string; sourceKey: string }): Promise<PluginMediaAsset | null>\n /**\n * The site's live, public photos of the business itself (AGL-3957): what\n * came through a site photo link, newest first, at most `limit`. A photo\n * of the owner's own work is placed before any stock photo is searched\n * for. Optional: an implementation registered before the link existed\n * answers nothing, and a caller treats that as an empty library.\n */\n listOwnerPhotos?(input: { hostId: string; limit?: number }): Promise<PluginMediaAsset[]>\n}\n\n/** One implementation: the app that holds the media ingress. */\nexport const PLUGIN_MEDIA_INGEST = definePluginServiceContract<PluginMediaIngest>(\n 'core.media-ingest',\n { multiple: false },\n)\n\nexport function registerPluginMediaIngest(\n ingest: PluginMediaIngest,\n options?: { pluginId?: string },\n): void {\n if (typeof ingest?.ingest !== 'function' || typeof ingest?.findStockPhoto !== 'function') {\n throw new Error('a media ingest needs ingest and findStockPhoto')\n }\n registerPluginService(PLUGIN_MEDIA_INGEST, ingest, {\n ...(options?.pluginId ? { pluginId: options.pluginId } : {}),\n })\n}\n\n/** The registered ingest, or `null` in a process that registered none. */\nexport function pluginMediaIngest(): PluginMediaIngest | null {\n return resolvePluginService(PLUGIN_MEDIA_INGEST) ?? null\n}\n"],"names":["definePluginServiceContract","registerPluginService","resolvePluginService","PLUGIN_MEDIA_INGEST","multiple","registerPluginMediaIngest","ingest","options","findStockPhoto","Error","pluginId","pluginMediaIngest"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SACEA,2BAA2B,EAC3BC,qBAAqB,EACrBC,oBAAoB,QACf,uBAAmB;AAqF1B,8DAA8D,GAC9D,OAAO,MAAMC,sBAAsBH,4BACjC,qBACA;IAAEI,UAAU;AAAM,GACnB;AAED,OAAO,SAASC,0BACdC,MAAyB,EACzBC,OAA+B;IAE/B,IAAI,QAAOD,0BAAAA,OAAQA,MAAM,MAAK,cAAc,QAAOA,0BAAAA,OAAQE,cAAc,MAAK,YAAY;QACxF,MAAM,IAAIC,MAAM;IAClB;IACAR,sBAAsBE,qBAAqBG,QAAQ,aAC7CC,CAAAA,2BAAAA,QAASG,QAAQ,IAAG;QAAEA,UAAUH,QAAQG,QAAQ;IAAC,IAAI,CAAC;AAE9D;AAEA,wEAAwE,GACxE,OAAO,SAASC;QACPT;IAAP,QAAOA,wBAAAA,qBAAqBC,gCAArBD,wBAA6C;AACtD"}
|
|
@@ -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,85 @@
|
|
|
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
|
+
* WHAT A PLUGIN LETS THE ONE-BOX SEARCH FIND, ON THE SERVER (AGL-3943).
|
|
19
|
+
*
|
|
20
|
+
* The console's palette reads a plugin's `ConsoleSearchSource`s in the
|
|
21
|
+
* browser, through the Firestore rules. The native apps' one box asks the
|
|
22
|
+
* console's `/api/search` instead, which reads with the Admin SDK, so it
|
|
23
|
+
* needs the same sources in a server process that never loads a plugin's
|
|
24
|
+
* console bundle. A plugin registers them here from its
|
|
25
|
+
* `consoleServerDeclarations`, usually the very objects its console
|
|
26
|
+
* extension lists, with the extension's own gates beside them.
|
|
27
|
+
*
|
|
28
|
+
* The Admin SDK bypasses the rules, so the route applies every gate itself:
|
|
29
|
+
* the plugin on for the workspace and the site, the plan flag and quota, the
|
|
30
|
+
* permission, the reader's host role (`hostAccess`), and each org-shared
|
|
31
|
+
* row's `visibleTo` against the reader's reach.
|
|
32
|
+
*
|
|
33
|
+
* The core never imports a plugin; this is how its search reaches one.
|
|
34
|
+
*/
|
|
35
|
+
import type { ListQueryDeclaration } from '@aglyn/shared-util-tools/list-query/list-query-plan';
|
|
36
|
+
import type { ConsoleSearchSource } from './feature-plugins';
|
|
37
|
+
/** A plugin's search source as the server reads it. */
|
|
38
|
+
export interface PluginSearchSource extends ConsoleSearchSource {
|
|
39
|
+
/**
|
|
40
|
+
* Who on the site may find these rows: any member (the default), or only
|
|
41
|
+
* the members who may read the site's sensitive documents (admin and
|
|
42
|
+
* editor, the rules' `canReadHostSensitive`), as for orders.
|
|
43
|
+
*/
|
|
44
|
+
hostAccess?: 'member' | 'sensitive';
|
|
45
|
+
/**
|
|
46
|
+
* Read through the list's own query rather than a capped window: the
|
|
47
|
+
* search word goes on `listQuery.search` (a stored token array), planned
|
|
48
|
+
* by `planListQuery`, so the rows are the list's own matches, newest first,
|
|
49
|
+
* on the index the list already uses.
|
|
50
|
+
*/
|
|
51
|
+
listQuery?: ListQueryDeclaration;
|
|
52
|
+
/** The row's label when no name field says it (an order's number). */
|
|
53
|
+
label?(row: Readonly<Record<string, unknown>>): string;
|
|
54
|
+
/** One line under the label (an order's buyer). */
|
|
55
|
+
detail?(row: Readonly<Record<string, unknown>>): string | null;
|
|
56
|
+
}
|
|
57
|
+
/** The gates the plugin's console extension puts on every source it lists. */
|
|
58
|
+
export interface PluginSearchSourceGates {
|
|
59
|
+
/** The plan flag the extension needs (`ConsoleExtension.featureFlag`). */
|
|
60
|
+
featureFlag?: string;
|
|
61
|
+
/** The permission the extension needs (`ConsoleExtension.permission`). */
|
|
62
|
+
permission?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface RegisteredPluginSearchSource {
|
|
65
|
+
pluginId: string;
|
|
66
|
+
gates: PluginSearchSourceGates;
|
|
67
|
+
source: PluginSearchSource;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Registers a plugin's server search sources. Owner = the loader's marker
|
|
71
|
+
* inside a register fn, else `options.pluginId`; neither throws. Registering
|
|
72
|
+
* again replaces the plugin's earlier sources, so a module evaluated twice
|
|
73
|
+
* does not list a group twice.
|
|
74
|
+
*/
|
|
75
|
+
export declare function registerPluginSearchSources(sources: readonly PluginSearchSource[], options?: PluginSearchSourceGates & {
|
|
76
|
+
pluginId?: string;
|
|
77
|
+
}): void;
|
|
78
|
+
/**
|
|
79
|
+
* The sources of the plugins in `enabledPluginIds` (every registered one
|
|
80
|
+
* when omitted), in registration order. A second source with an id already
|
|
81
|
+
* listed is dropped, as the palette drops it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function listPluginSearchSources(enabledPluginIds?: readonly string[]): RegisteredPluginSearchSource[];
|
|
84
|
+
/** Only for specs: forgets every source. */
|
|
85
|
+
export declare function resetPluginSearchSourcesForTests(): void;
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
* WHAT A PLUGIN LETS THE ONE-BOX SEARCH FIND, ON THE SERVER (AGL-3943).
|
|
18
|
+
*
|
|
19
|
+
* The console's palette reads a plugin's `ConsoleSearchSource`s in the
|
|
20
|
+
* browser, through the Firestore rules. The native apps' one box asks the
|
|
21
|
+
* console's `/api/search` instead, which reads with the Admin SDK, so it
|
|
22
|
+
* needs the same sources in a server process that never loads a plugin's
|
|
23
|
+
* console bundle. A plugin registers them here from its
|
|
24
|
+
* `consoleServerDeclarations`, usually the very objects its console
|
|
25
|
+
* extension lists, with the extension's own gates beside them.
|
|
26
|
+
*
|
|
27
|
+
* The Admin SDK bypasses the rules, so the route applies every gate itself:
|
|
28
|
+
* the plugin on for the workspace and the site, the plan flag and quota, the
|
|
29
|
+
* permission, the reader's host role (`hostAccess`), and each org-shared
|
|
30
|
+
* row's `visibleTo` against the reader's reach.
|
|
31
|
+
*
|
|
32
|
+
* The core never imports a plugin; this is how its search reaches one.
|
|
33
|
+
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
34
|
+
var _globalScope_REGISTRY_KEY;
|
|
35
|
+
import { getRegisteringPluginId } from "../app-utils/registering-plugin.js";
|
|
36
|
+
/**
|
|
37
|
+
* On `globalThis` under `Symbol.for`, never at module scope (AGL-3947). The
|
|
38
|
+
* sources are registered at boot, from `instrumentation.ts`, which Next
|
|
39
|
+
* compiles apart from the routes, so the boot and `/api/search` each hold
|
|
40
|
+
* their own copy of this module.
|
|
41
|
+
*/ const REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-search-sources');
|
|
42
|
+
const globalScope = globalThis;
|
|
43
|
+
const registrations = (_globalScope_REGISTRY_KEY = globalScope[REGISTRY_KEY]) != null ? _globalScope_REGISTRY_KEY : globalScope[REGISTRY_KEY] = [];
|
|
44
|
+
/**
|
|
45
|
+
* Registers a plugin's server search sources. Owner = the loader's marker
|
|
46
|
+
* inside a register fn, else `options.pluginId`; neither throws. Registering
|
|
47
|
+
* again replaces the plugin's earlier sources, so a module evaluated twice
|
|
48
|
+
* does not list a group twice.
|
|
49
|
+
*/ export function registerPluginSearchSources(sources, options = {}) {
|
|
50
|
+
var _ref, _getRegisteringPluginId;
|
|
51
|
+
const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options.pluginId) != null ? _ref : '').trim();
|
|
52
|
+
if (!pluginId) {
|
|
53
|
+
throw new Error('plugin search sources were registered with no owner: pass { pluginId } ' + 'when registering outside a plugin register fn');
|
|
54
|
+
}
|
|
55
|
+
const gates = _extends({}, options.featureFlag ? {
|
|
56
|
+
featureFlag: options.featureFlag
|
|
57
|
+
} : {}, options.permission ? {
|
|
58
|
+
permission: options.permission
|
|
59
|
+
} : {});
|
|
60
|
+
for(let index = registrations.length - 1; index >= 0; index -= 1){
|
|
61
|
+
if (registrations[index].pluginId === pluginId) registrations.splice(index, 1);
|
|
62
|
+
}
|
|
63
|
+
for (const source of sources){
|
|
64
|
+
if (source == null ? void 0 : source.id) registrations.push({
|
|
65
|
+
pluginId,
|
|
66
|
+
gates,
|
|
67
|
+
source
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The sources of the plugins in `enabledPluginIds` (every registered one
|
|
73
|
+
* when omitted), in registration order. A second source with an id already
|
|
74
|
+
* listed is dropped, as the palette drops it.
|
|
75
|
+
*/ export function listPluginSearchSources(enabledPluginIds) {
|
|
76
|
+
const on = enabledPluginIds ? new Set(enabledPluginIds) : null;
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const entry of registrations){
|
|
80
|
+
if (on && !on.has(entry.pluginId)) continue;
|
|
81
|
+
if (seen.has(entry.source.id)) continue;
|
|
82
|
+
seen.add(entry.source.id);
|
|
83
|
+
out.push(entry);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Only for specs: forgets every source. */ export function resetPluginSearchSourcesForTests() {
|
|
88
|
+
registrations.length = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
//# sourceMappingURL=plugin-search-sources.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-search-sources.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 * WHAT A PLUGIN LETS THE ONE-BOX SEARCH FIND, ON THE SERVER (AGL-3943).\n *\n * The console's palette reads a plugin's `ConsoleSearchSource`s in the\n * browser, through the Firestore rules. The native apps' one box asks the\n * console's `/api/search` instead, which reads with the Admin SDK, so it\n * needs the same sources in a server process that never loads a plugin's\n * console bundle. A plugin registers them here from its\n * `consoleServerDeclarations`, usually the very objects its console\n * extension lists, with the extension's own gates beside them.\n *\n * The Admin SDK bypasses the rules, so the route applies every gate itself:\n * the plugin on for the workspace and the site, the plan flag and quota, the\n * permission, the reader's host role (`hostAccess`), and each org-shared\n * row's `visibleTo` against the reader's reach.\n *\n * The core never imports a plugin; this is how its search reaches one.\n */\n\nimport type { ListQueryDeclaration } from '@aglyn/shared-util-tools/list-query/list-query-plan'\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\nimport type { ConsoleSearchSource } from './feature-plugins'\n\n/** A plugin's search source as the server reads it. */\nexport interface PluginSearchSource extends ConsoleSearchSource {\n /**\n * Who on the site may find these rows: any member (the default), or only\n * the members who may read the site's sensitive documents (admin and\n * editor, the rules' `canReadHostSensitive`), as for orders.\n */\n hostAccess?: 'member' | 'sensitive'\n /**\n * Read through the list's own query rather than a capped window: the\n * search word goes on `listQuery.search` (a stored token array), planned\n * by `planListQuery`, so the rows are the list's own matches, newest first,\n * on the index the list already uses.\n */\n listQuery?: ListQueryDeclaration\n /** The row's label when no name field says it (an order's number). */\n label?(row: Readonly<Record<string, unknown>>): string\n /** One line under the label (an order's buyer). */\n detail?(row: Readonly<Record<string, unknown>>): string | null\n}\n\n/** The gates the plugin's console extension puts on every source it lists. */\nexport interface PluginSearchSourceGates {\n /** The plan flag the extension needs (`ConsoleExtension.featureFlag`). */\n featureFlag?: string\n /** The permission the extension needs (`ConsoleExtension.permission`). */\n permission?: string\n}\n\nexport interface RegisteredPluginSearchSource {\n pluginId: string\n gates: PluginSearchSourceGates\n source: PluginSearchSource\n}\n\n/**\n * On `globalThis` under `Symbol.for`, never at module scope (AGL-3947). The\n * sources are registered at boot, from `instrumentation.ts`, which Next\n * compiles apart from the routes, so the boot and `/api/search` each hold\n * their own copy of this module.\n */\nconst REGISTRY_KEY = Symbol.for('@aglyn/aglyn:plugin-search-sources')\nconst globalScope = globalThis as typeof globalThis & {\n [REGISTRY_KEY]?: RegisteredPluginSearchSource[]\n}\nconst registrations: RegisteredPluginSearchSource[] =\n globalScope[REGISTRY_KEY] ?? (globalScope[REGISTRY_KEY] = [])\n\n/**\n * Registers a plugin's server search sources. Owner = the loader's marker\n * inside a register fn, else `options.pluginId`; neither throws. Registering\n * again replaces the plugin's earlier sources, so a module evaluated twice\n * does not list a group twice.\n */\nexport function registerPluginSearchSources(\n sources: readonly PluginSearchSource[],\n options: PluginSearchSourceGates & { pluginId?: string } = {},\n): void {\n const pluginId = (getRegisteringPluginId() ?? options.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n 'plugin search sources were registered with no owner: pass { pluginId } ' +\n 'when registering outside a plugin register fn',\n )\n }\n const gates: PluginSearchSourceGates = {\n ...(options.featureFlag ? { featureFlag: options.featureFlag } : {}),\n ...(options.permission ? { permission: options.permission } : {}),\n }\n for (let index = registrations.length - 1; index >= 0; index -= 1) {\n if (registrations[index].pluginId === pluginId) registrations.splice(index, 1)\n }\n for (const source of sources) {\n if (source?.id) registrations.push({ pluginId, gates, source })\n }\n}\n\n/**\n * The sources of the plugins in `enabledPluginIds` (every registered one\n * when omitted), in registration order. A second source with an id already\n * listed is dropped, as the palette drops it.\n */\nexport function listPluginSearchSources(\n enabledPluginIds?: readonly string[],\n): RegisteredPluginSearchSource[] {\n const on = enabledPluginIds ? new Set(enabledPluginIds) : null\n const seen = new Set<string>()\n const out: RegisteredPluginSearchSource[] = []\n for (const entry of registrations) {\n if (on && !on.has(entry.pluginId)) continue\n if (seen.has(entry.source.id)) continue\n seen.add(entry.source.id)\n out.push(entry)\n }\n return out\n}\n\n/** Only for specs: forgets every source. */\nexport function resetPluginSearchSourcesForTests(): void {\n registrations.length = 0\n}\n"],"names":["globalScope","getRegisteringPluginId","REGISTRY_KEY","Symbol","for","globalThis","registrations","registerPluginSearchSources","sources","options","pluginId","trim","Error","gates","featureFlag","permission","index","length","splice","source","id","push","listPluginSearchSources","enabledPluginIds","on","Set","seen","out","entry","has","add","resetPluginSearchSourcesForTests"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;CAiBC;IAoDCA;AAjDF,SAASC,sBAAsB,QAAQ,qCAAiC;AAsCxE;;;;;CAKC,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,4BACdC,OAAsC,EACtCC,UAA2D,CAAC,CAAC;QAE3CR,MAAAA;IAAlB,MAAMS,WAAW,EAACT,QAAAA,0BAAAA,oCAAAA,0BAA4BQ,QAAQC,QAAQ,YAA5CT,OAAgD,IAAIU,IAAI;IAC1E,IAAI,CAACD,UAAU;QACb,MAAM,IAAIE,MACR,4EACE;IAEN;IACA,MAAMC,QAAiC,aACjCJ,QAAQK,WAAW,GAAG;QAAEA,aAAaL,QAAQK,WAAW;IAAC,IAAI,CAAC,GAC9DL,QAAQM,UAAU,GAAG;QAAEA,YAAYN,QAAQM,UAAU;IAAC,IAAI,CAAC;IAEjE,IAAK,IAAIC,QAAQV,cAAcW,MAAM,GAAG,GAAGD,SAAS,GAAGA,SAAS,EAAG;QACjE,IAAIV,aAAa,CAACU,MAAM,CAACN,QAAQ,KAAKA,UAAUJ,cAAcY,MAAM,CAACF,OAAO;IAC9E;IACA,KAAK,MAAMG,UAAUX,QAAS;QAC5B,IAAIW,0BAAAA,OAAQC,EAAE,EAAEd,cAAce,IAAI,CAAC;YAAEX;YAAUG;YAAOM;QAAO;IAC/D;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASG,wBACdC,gBAAoC;IAEpC,MAAMC,KAAKD,mBAAmB,IAAIE,IAAIF,oBAAoB;IAC1D,MAAMG,OAAO,IAAID;IACjB,MAAME,MAAsC,EAAE;IAC9C,KAAK,MAAMC,SAAStB,cAAe;QACjC,IAAIkB,MAAM,CAACA,GAAGK,GAAG,CAACD,MAAMlB,QAAQ,GAAG;QACnC,IAAIgB,KAAKG,GAAG,CAACD,MAAMT,MAAM,CAACC,EAAE,GAAG;QAC/BM,KAAKI,GAAG,CAACF,MAAMT,MAAM,CAACC,EAAE;QACxBO,IAAIN,IAAI,CAACO;IACX;IACA,OAAOD;AACT;AAEA,0CAA0C,GAC1C,OAAO,SAASI;IACdzB,cAAcW,MAAM,GAAG;AACzB"}
|
|
@@ -32,6 +32,12 @@ var _globalScope_PLUGIN_SERVICES_KEY;
|
|
|
32
32
|
* contract token carries the implementation type with it, so a seam another
|
|
33
33
|
* plugin invents needs no core edit at all.
|
|
34
34
|
*
|
|
35
|
+
* A plugin's token lives in its contracts module, which another plugin
|
|
36
|
+
* imports as `@aglyn/plugins-<id>/contracts` after naming that plugin in its
|
|
37
|
+
* `uses` (AGL-3980, docs/PACKAGES.md), so both sides share one id and one
|
|
38
|
+
* type. Declaring the same id twice still works, but a second declaration is
|
|
39
|
+
* a copy that drifts.
|
|
40
|
+
*
|
|
35
41
|
* Attribution follows every other registry: the owner is the plugin whose
|
|
36
42
|
* register fn is running (`setRegisteringPluginId`), or the `pluginId` the
|
|
37
43
|
* caller names for a registration made at module scope, where the loader's
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-services.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 * Typed plugin service registry (AGL-2940).\n *\n * A plugin that wants OTHER plugins to extend it — a provider behind a\n * contract, a tool a generator can call, a generator kind — declares a\n * contract token here, and any plugin (first-party or marketplace) registers\n * an implementation against the token. The declaring plugin resolves the\n * implementations when it needs them and never imports the plugin that\n * supplied one, so the dependency runs only from an adopter to a contract.\n *\n * `registerBillingWebhookHandler` and `registerSiteRuntime` are the\n * precedent: one registry per extension seam, filled at registration time,\n * read lazily. What those cannot do is carry a TYPE for the thing registered\n * beyond their one shape, and each new seam meant a new module in core. A\n * contract token carries the implementation type with it, so a seam another\n * plugin invents needs no core edit at all.\n *\n * Attribution follows every other registry: the owner is the plugin whose\n * register fn is running (`setRegisteringPluginId`), or the `pluginId` the\n * caller names for a registration made at module scope, where the loader's\n * marker is not yet set. An implementation with no owner is refused —\n * ownership is what lets a duplicate single-service registration name both\n * claimants rather than silently taking the later one.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/**\n * A contract token. `T` is the implementation type and is carried at the\n * type level only; `__type` never holds a value.\n */\nexport interface PluginServiceContract<T> {\n readonly id: string\n /**\n * Whether more than one plugin may register against the contract. A\n * single-implementation contract (`false`) is a slot — an AI provider\n * routing table, a payment processor — where a second registration from a\n * different plugin is a conflict, not a list. A multiple contract is a set\n * every implementation joins.\n */\n readonly multiple: boolean\n readonly __type?: T\n}\n\nexport interface PluginServiceRegistration<T> {\n contractId: string\n pluginId: string\n /**\n * Which of a plugin's implementations this is, on a multiple contract\n * where one plugin registers several (two provider adapters from one\n * plugin). Absent when the plugin registers one.\n */\n key?: string\n impl: T\n /** Higher resolves first among several implementations; default 0. */\n priority: number\n}\n\n/**\n * ONE REGISTRY PER PROCESS, not per module copy — the `aglyn.ts` pattern\n * (AGL-53), and for the same reason.\n *\n * Next.js compiles `instrumentation.ts` apart from the routes, so the boot\n * file and a route handler each hold their own copy of this module. With the\n * maps at module scope, what the boot registered (the console's trust signer,\n * its site cache, the declarations repair) went into the boot's copy, and\n * every route resolved an empty one: production answered \"Trust signing is\n * not available on this deployment\" with the key deployed and the signer\n * registered. Keyed on `globalThis` under `Symbol.for`, every copy in a\n * realm reads the same maps.\n */\nconst PLUGIN_SERVICES_KEY = Symbol.for('@aglyn/aglyn:plugin-services')\n\ninterface PluginServiceTables {\n contracts: Map<string, PluginServiceContract<unknown>>\n registrations: Map<string, PluginServiceRegistration<unknown>[]>\n}\n\nconst globalScope = globalThis as typeof globalThis & {\n [PLUGIN_SERVICES_KEY]?: PluginServiceTables\n}\n\nconst tables: PluginServiceTables =\n globalScope[PLUGIN_SERVICES_KEY] ??\n (globalScope[PLUGIN_SERVICES_KEY] = {\n contracts: new Map(),\n registrations: new Map(),\n })\nconst { contracts, registrations } = tables\n\n/**\n * Declares a contract. Idempotent by id for the same `multiple` setting, so\n * a hot reload or a second surface re-evaluating the declaring module gets\n * the token back; declaring the same id with a DIFFERENT setting is a\n * programming error and throws, because two plugins would then disagree\n * about whether the seam is a slot or a set.\n */\nexport function definePluginServiceContract<T>(\n id: string,\n options: { multiple: boolean },\n): PluginServiceContract<T> {\n const key = id.trim()\n if (!key) throw new Error('a plugin service contract needs an id')\n const existing = contracts.get(key)\n if (existing) {\n if (existing.multiple !== options.multiple) {\n throw new Error(\n `plugin service contract \"${key}\" is already defined with ` +\n `multiple=${existing.multiple}; cannot redefine it with ` +\n `multiple=${options.multiple}`,\n )\n }\n return existing as PluginServiceContract<T>\n }\n const contract: PluginServiceContract<T> = { id: key, multiple: options.multiple }\n contracts.set(key, contract)\n return contract\n}\n\nfunction requireContract<T>(contract: PluginServiceContract<T>): PluginServiceContract<T> {\n const known = contracts.get(contract.id)\n if (!known) {\n throw new Error(\n `plugin service contract \"${contract.id}\" is not defined; call ` +\n 'definePluginServiceContract before registering against it',\n )\n }\n return known as PluginServiceContract<T>\n}\n\n/**\n * Registers an implementation. The owner is the loader's marker when a\n * register fn is running, else `options.pluginId`; a registration with\n * neither throws.\n *\n * Re-registration by the SAME plugin under the same `key` replaces its\n * previous entry (hot reload, a second surface, a repeated init); a plugin\n * registering several implementations on a multiple contract tells them\n * apart with `key`. On a single-implementation contract a registration\n * from a DIFFERENT plugin throws naming both, and the incumbent keeps\n * serving.\n */\nexport function registerPluginService<T>(\n contract: PluginServiceContract<T>,\n impl: T,\n options?: { pluginId?: string; priority?: number; key?: string },\n): void {\n const known = requireContract(contract)\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin service \"${known.id}\" registered with no owner: pass ` +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const key = options?.key?.trim() || undefined\n const list = registrations.get(known.id) ?? []\n const foreign = list.find((entry) => entry.pluginId !== pluginId)\n if (!known.multiple && foreign) {\n throw new Error(\n `plugin service \"${known.id}\" is a single-implementation contract ` +\n `already registered by \"${foreign.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n const kept = list.filter(\n (entry) => !(entry.pluginId === pluginId && (entry.key ?? null) === (key ?? null)),\n )\n kept.push({\n contractId: known.id,\n pluginId,\n ...(key ? { key } : {}),\n impl,\n priority: options?.priority ?? 0,\n })\n registrations.set(known.id, kept)\n}\n\n/**\n * Every implementation of a contract, highest priority first and\n * registration order within a priority. Empty when nothing registered.\n */\nexport function resolvePluginServices<T>(\n contract: PluginServiceContract<T>,\n): PluginServiceRegistration<T>[] {\n const known = requireContract(contract)\n const list = (registrations.get(known.id) ?? []) as PluginServiceRegistration<T>[]\n return [...list].sort((a, b) => b.priority - a.priority)\n}\n\n/**\n * The one implementation of a single contract — or, on a multiple\n * contract, the highest-priority one — or `undefined`.\n */\nexport function resolvePluginService<T>(\n contract: PluginServiceContract<T>,\n): T | undefined {\n return resolvePluginServices(contract)[0]?.impl\n}\n\n/** Whether any plugin has registered against the contract. */\nexport function hasPluginService<T>(contract: PluginServiceContract<T>): boolean {\n return (registrations.get(requireContract(contract).id) ?? []).length > 0\n}\n\n/** Every declared contract id, for diagnostics. */\nexport function listPluginServiceContracts(): string[] {\n return [...contracts.keys()]\n}\n\n/**\n * Drops a plugin's registrations across every contract — what the loader's\n * reverse (a bundle unloading) would call, and what a spec calls to give\n * one fake plugin back its slot.\n */\nexport function unregisterPluginServices(pluginId: string): void {\n for (const [id, list] of registrations) {\n registrations.set(\n id,\n list.filter((entry) => entry.pluginId !== pluginId),\n )\n }\n}\n\n/** Test seam: forget every registration; contracts stay defined. */\nexport function resetPluginServicesForTests(): void {\n registrations.clear()\n}\n"],"names":["globalScope","getRegisteringPluginId","PLUGIN_SERVICES_KEY","Symbol","for","globalThis","tables","contracts","Map","registrations","definePluginServiceContract","id","options","key","trim","Error","existing","get","multiple","contract","set","requireContract","known","registerPluginService","impl","pluginId","undefined","list","foreign","find","entry","kept","filter","push","contractId","priority","resolvePluginServices","sort","a","b","resolvePluginService","hasPluginService","length","listPluginServiceContracts","keys","unregisterPluginServices","resetPluginServicesForTests","clear"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/plugin-services.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 * Typed plugin service registry (AGL-2940).\n *\n * A plugin that wants OTHER plugins to extend it — a provider behind a\n * contract, a tool a generator can call, a generator kind — declares a\n * contract token here, and any plugin (first-party or marketplace) registers\n * an implementation against the token. The declaring plugin resolves the\n * implementations when it needs them and never imports the plugin that\n * supplied one, so the dependency runs only from an adopter to a contract.\n *\n * `registerBillingWebhookHandler` and `registerSiteRuntime` are the\n * precedent: one registry per extension seam, filled at registration time,\n * read lazily. What those cannot do is carry a TYPE for the thing registered\n * beyond their one shape, and each new seam meant a new module in core. A\n * contract token carries the implementation type with it, so a seam another\n * plugin invents needs no core edit at all.\n *\n * A plugin's token lives in its contracts module, which another plugin\n * imports as `@aglyn/plugins-<id>/contracts` after naming that plugin in its\n * `uses` (AGL-3980, docs/PACKAGES.md), so both sides share one id and one\n * type. Declaring the same id twice still works, but a second declaration is\n * a copy that drifts.\n *\n * Attribution follows every other registry: the owner is the plugin whose\n * register fn is running (`setRegisteringPluginId`), or the `pluginId` the\n * caller names for a registration made at module scope, where the loader's\n * marker is not yet set. An implementation with no owner is refused —\n * ownership is what lets a duplicate single-service registration name both\n * claimants rather than silently taking the later one.\n */\n\nimport { getRegisteringPluginId } from '../app-utils/registering-plugin'\n\n/**\n * A contract token. `T` is the implementation type and is carried at the\n * type level only; `__type` never holds a value.\n */\nexport interface PluginServiceContract<T> {\n readonly id: string\n /**\n * Whether more than one plugin may register against the contract. A\n * single-implementation contract (`false`) is a slot — an AI provider\n * routing table, a payment processor — where a second registration from a\n * different plugin is a conflict, not a list. A multiple contract is a set\n * every implementation joins.\n */\n readonly multiple: boolean\n readonly __type?: T\n}\n\nexport interface PluginServiceRegistration<T> {\n contractId: string\n pluginId: string\n /**\n * Which of a plugin's implementations this is, on a multiple contract\n * where one plugin registers several (two provider adapters from one\n * plugin). Absent when the plugin registers one.\n */\n key?: string\n impl: T\n /** Higher resolves first among several implementations; default 0. */\n priority: number\n}\n\n/**\n * ONE REGISTRY PER PROCESS, not per module copy — the `aglyn.ts` pattern\n * (AGL-53), and for the same reason.\n *\n * Next.js compiles `instrumentation.ts` apart from the routes, so the boot\n * file and a route handler each hold their own copy of this module. With the\n * maps at module scope, what the boot registered (the console's trust signer,\n * its site cache, the declarations repair) went into the boot's copy, and\n * every route resolved an empty one: production answered \"Trust signing is\n * not available on this deployment\" with the key deployed and the signer\n * registered. Keyed on `globalThis` under `Symbol.for`, every copy in a\n * realm reads the same maps.\n */\nconst PLUGIN_SERVICES_KEY = Symbol.for('@aglyn/aglyn:plugin-services')\n\ninterface PluginServiceTables {\n contracts: Map<string, PluginServiceContract<unknown>>\n registrations: Map<string, PluginServiceRegistration<unknown>[]>\n}\n\nconst globalScope = globalThis as typeof globalThis & {\n [PLUGIN_SERVICES_KEY]?: PluginServiceTables\n}\n\nconst tables: PluginServiceTables =\n globalScope[PLUGIN_SERVICES_KEY] ??\n (globalScope[PLUGIN_SERVICES_KEY] = {\n contracts: new Map(),\n registrations: new Map(),\n })\nconst { contracts, registrations } = tables\n\n/**\n * Declares a contract. Idempotent by id for the same `multiple` setting, so\n * a hot reload or a second surface re-evaluating the declaring module gets\n * the token back; declaring the same id with a DIFFERENT setting is a\n * programming error and throws, because two plugins would then disagree\n * about whether the seam is a slot or a set.\n */\nexport function definePluginServiceContract<T>(\n id: string,\n options: { multiple: boolean },\n): PluginServiceContract<T> {\n const key = id.trim()\n if (!key) throw new Error('a plugin service contract needs an id')\n const existing = contracts.get(key)\n if (existing) {\n if (existing.multiple !== options.multiple) {\n throw new Error(\n `plugin service contract \"${key}\" is already defined with ` +\n `multiple=${existing.multiple}; cannot redefine it with ` +\n `multiple=${options.multiple}`,\n )\n }\n return existing as PluginServiceContract<T>\n }\n const contract: PluginServiceContract<T> = { id: key, multiple: options.multiple }\n contracts.set(key, contract)\n return contract\n}\n\nfunction requireContract<T>(contract: PluginServiceContract<T>): PluginServiceContract<T> {\n const known = contracts.get(contract.id)\n if (!known) {\n throw new Error(\n `plugin service contract \"${contract.id}\" is not defined; call ` +\n 'definePluginServiceContract before registering against it',\n )\n }\n return known as PluginServiceContract<T>\n}\n\n/**\n * Registers an implementation. The owner is the loader's marker when a\n * register fn is running, else `options.pluginId`; a registration with\n * neither throws.\n *\n * Re-registration by the SAME plugin under the same `key` replaces its\n * previous entry (hot reload, a second surface, a repeated init); a plugin\n * registering several implementations on a multiple contract tells them\n * apart with `key`. On a single-implementation contract a registration\n * from a DIFFERENT plugin throws naming both, and the incumbent keeps\n * serving.\n */\nexport function registerPluginService<T>(\n contract: PluginServiceContract<T>,\n impl: T,\n options?: { pluginId?: string; priority?: number; key?: string },\n): void {\n const known = requireContract(contract)\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) {\n throw new Error(\n `plugin service \"${known.id}\" registered with no owner: pass ` +\n '{ pluginId } when registering outside a plugin register fn',\n )\n }\n const key = options?.key?.trim() || undefined\n const list = registrations.get(known.id) ?? []\n const foreign = list.find((entry) => entry.pluginId !== pluginId)\n if (!known.multiple && foreign) {\n throw new Error(\n `plugin service \"${known.id}\" is a single-implementation contract ` +\n `already registered by \"${foreign.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n const kept = list.filter(\n (entry) => !(entry.pluginId === pluginId && (entry.key ?? null) === (key ?? null)),\n )\n kept.push({\n contractId: known.id,\n pluginId,\n ...(key ? { key } : {}),\n impl,\n priority: options?.priority ?? 0,\n })\n registrations.set(known.id, kept)\n}\n\n/**\n * Every implementation of a contract, highest priority first and\n * registration order within a priority. Empty when nothing registered.\n */\nexport function resolvePluginServices<T>(\n contract: PluginServiceContract<T>,\n): PluginServiceRegistration<T>[] {\n const known = requireContract(contract)\n const list = (registrations.get(known.id) ?? []) as PluginServiceRegistration<T>[]\n return [...list].sort((a, b) => b.priority - a.priority)\n}\n\n/**\n * The one implementation of a single contract — or, on a multiple\n * contract, the highest-priority one — or `undefined`.\n */\nexport function resolvePluginService<T>(\n contract: PluginServiceContract<T>,\n): T | undefined {\n return resolvePluginServices(contract)[0]?.impl\n}\n\n/** Whether any plugin has registered against the contract. */\nexport function hasPluginService<T>(contract: PluginServiceContract<T>): boolean {\n return (registrations.get(requireContract(contract).id) ?? []).length > 0\n}\n\n/** Every declared contract id, for diagnostics. */\nexport function listPluginServiceContracts(): string[] {\n return [...contracts.keys()]\n}\n\n/**\n * Drops a plugin's registrations across every contract — what the loader's\n * reverse (a bundle unloading) would call, and what a spec calls to give\n * one fake plugin back its slot.\n */\nexport function unregisterPluginServices(pluginId: string): void {\n for (const [id, list] of registrations) {\n registrations.set(\n id,\n list.filter((entry) => entry.pluginId !== pluginId),\n )\n }\n}\n\n/** Test seam: forget every registration; contracts stay defined. */\nexport function resetPluginServicesForTests(): void {\n registrations.clear()\n}\n"],"names":["globalScope","getRegisteringPluginId","PLUGIN_SERVICES_KEY","Symbol","for","globalThis","tables","contracts","Map","registrations","definePluginServiceContract","id","options","key","trim","Error","existing","get","multiple","contract","set","requireContract","known","registerPluginService","impl","pluginId","undefined","list","foreign","find","entry","kept","filter","push","contractId","priority","resolvePluginServices","sort","a","b","resolvePluginService","hasPluginService","length","listPluginServiceContracts","keys","unregisterPluginServices","resetPluginServicesForTests","clear"],"mappings":";IA0GEA;AA1GF;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BC,GAED,SAASC,sBAAsB,QAAQ,qCAAiC;AAiCxE;;;;;;;;;;;;CAYC,GACD,MAAMC,sBAAsBC,OAAOC,GAAG,CAAC;AAOvC,MAAMJ,cAAcK;AAIpB,MAAMC,UACJN,mCAAAA,WAAW,CAACE,oBAAoB,YAAhCF,mCACCA,WAAW,CAACE,oBAAoB,GAAG;IAClCK,WAAW,IAAIC;IACfC,eAAe,IAAID;AACrB;AACF,MAAM,EAAED,SAAS,EAAEE,aAAa,EAAE,GAAGH;AAErC;;;;;;CAMC,GACD,OAAO,SAASI,4BACdC,EAAU,EACVC,OAA8B;IAE9B,MAAMC,MAAMF,GAAGG,IAAI;IACnB,IAAI,CAACD,KAAK,MAAM,IAAIE,MAAM;IAC1B,MAAMC,WAAWT,UAAUU,GAAG,CAACJ;IAC/B,IAAIG,UAAU;QACZ,IAAIA,SAASE,QAAQ,KAAKN,QAAQM,QAAQ,EAAE;YAC1C,MAAM,IAAIH,MACR,CAAC,yBAAyB,EAAEF,IAAI,0BAA0B,CAAC,GACzD,CAAC,SAAS,EAAEG,SAASE,QAAQ,CAAC,0BAA0B,CAAC,GACzD,CAAC,SAAS,EAAEN,QAAQM,QAAQ,EAAE;QAEpC;QACA,OAAOF;IACT;IACA,MAAMG,WAAqC;QAAER,IAAIE;QAAKK,UAAUN,QAAQM,QAAQ;IAAC;IACjFX,UAAUa,GAAG,CAACP,KAAKM;IACnB,OAAOA;AACT;AAEA,SAASE,gBAAmBF,QAAkC;IAC5D,MAAMG,QAAQf,UAAUU,GAAG,CAACE,SAASR,EAAE;IACvC,IAAI,CAACW,OAAO;QACV,MAAM,IAAIP,MACR,CAAC,yBAAyB,EAAEI,SAASR,EAAE,CAAC,uBAAuB,CAAC,GAC9D;IAEN;IACA,OAAOW;AACT;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,sBACdJ,QAAkC,EAClCK,IAAO,EACPZ,OAAgE;QAG9CX,MAAAA,yBAQLQ;QADDG;IARZ,MAAMU,QAAQD,gBAAgBF;IAC9B,MAAMM,WAAW,EAACxB,QAAAA,0BAAAA,oCAAAA,0BAA4BW,2BAAAA,QAASa,QAAQ,YAA7CxB,OAAiD,IAAIa,IAAI;IAC3E,IAAI,CAACW,UAAU;QACb,MAAM,IAAIV,MACR,CAAC,gBAAgB,EAAEO,MAAMX,EAAE,CAAC,iCAAiC,CAAC,GAC5D;IAEN;IACA,MAAME,MAAMD,CAAAA,4BAAAA,eAAAA,QAASC,GAAG,qBAAZD,aAAcE,IAAI,OAAMY;IACpC,MAAMC,QAAOlB,qBAAAA,cAAcQ,GAAG,CAACK,MAAMX,EAAE,aAA1BF,qBAA+B,EAAE;IAC9C,MAAMmB,UAAUD,KAAKE,IAAI,CAAC,CAACC,QAAUA,MAAML,QAAQ,KAAKA;IACxD,IAAI,CAACH,MAAMJ,QAAQ,IAAIU,SAAS;QAC9B,MAAM,IAAIb,MACR,CAAC,gBAAgB,EAAEO,MAAMX,EAAE,CAAC,sCAAsC,CAAC,GACjE,CAAC,uBAAuB,EAAEiB,QAAQH,QAAQ,CAAC,YAAY,EAAEA,SAAS,CAAC,CAAC;IAE1E;IACA,MAAMM,OAAOJ,KAAKK,MAAM,CACtB,CAACF;YAA4CA;eAAlC,CAAEA,CAAAA,MAAML,QAAQ,KAAKA,YAAY,EAACK,aAAAA,MAAMjB,GAAG,YAATiB,aAAa,WAAWjB,cAAAA,MAAO,KAAI;;IAElFkB,KAAKE,IAAI,CAAC;QACRC,YAAYZ,MAAMX,EAAE;QACpBc;OACIZ,MAAM;QAAEA;IAAI,IAAI,CAAC;QACrBW;QACAW,QAAQ,WAAEvB,2BAAAA,QAASuB,QAAQ,oBAAI;;IAEjC1B,cAAcW,GAAG,CAACE,MAAMX,EAAE,EAAEoB;AAC9B;AAEA;;;CAGC,GACD,OAAO,SAASK,sBACdjB,QAAkC;QAGpBV;IADd,MAAMa,QAAQD,gBAAgBF;IAC9B,MAAMQ,QAAQlB,qBAAAA,cAAcQ,GAAG,CAACK,MAAMX,EAAE,aAA1BF,qBAA+B,EAAE;IAC/C,OAAO;WAAIkB;KAAK,CAACU,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAEJ,QAAQ,GAAGG,EAAEH,QAAQ;AACzD;AAEA;;;CAGC,GACD,OAAO,SAASK,qBACdrB,QAAkC;QAE3BiB;IAAP,QAAOA,0BAAAA,sBAAsBjB,SAAS,CAAC,EAAE,qBAAlCiB,wBAAoCZ,IAAI;AACjD;AAEA,4DAA4D,GAC5D,OAAO,SAASiB,iBAAoBtB,QAAkC;QAC5DV;IAAR,OAAO,EAACA,qBAAAA,cAAcQ,GAAG,CAACI,gBAAgBF,UAAUR,EAAE,aAA9CF,qBAAmD,EAAE,EAAEiC,MAAM,GAAG;AAC1E;AAEA,iDAAiD,GACjD,OAAO,SAASC;IACd,OAAO;WAAIpC,UAAUqC,IAAI;KAAG;AAC9B;AAEA;;;;CAIC,GACD,OAAO,SAASC,yBAAyBpB,QAAgB;IACvD,KAAK,MAAM,CAACd,IAAIgB,KAAK,IAAIlB,cAAe;QACtCA,cAAcW,GAAG,CACfT,IACAgB,KAAKK,MAAM,CAAC,CAACF,QAAUA,MAAML,QAAQ,KAAKA;IAE9C;AACF;AAEA,kEAAkE,GAClE,OAAO,SAASqB;IACdrC,cAAcsC,KAAK;AACrB"}
|
|
@@ -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
|