@aglyn/plugins-marketplace 1.15.0 → 1.15.2

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.
Files changed (107) hide show
  1. package/README.md +1 -0
  2. package/package.json +15 -14
  3. package/src/lib/components/editor-connections-provider.component.d.ts +37 -0
  4. package/src/lib/components/editor-connections-provider.component.js +161 -0
  5. package/src/lib/components/editor-connections-provider.component.js.map +1 -0
  6. package/src/lib/components/listing-connection-credentials-card.component.d.ts +33 -0
  7. package/src/lib/components/listing-connection-credentials-card.component.js +318 -0
  8. package/src/lib/components/listing-connection-credentials-card.component.js.map +1 -0
  9. package/src/lib/components/listing-detail-page.component.js +10 -2
  10. package/src/lib/components/listing-detail-page.component.js.map +1 -1
  11. package/src/lib/components/site-plugin-connections-card.component.d.ts +38 -0
  12. package/src/lib/components/site-plugin-connections-card.component.js +576 -0
  13. package/src/lib/components/site-plugin-connections-card.component.js.map +1 -0
  14. package/src/lib/declarations.console-server.d.ts +17 -0
  15. package/src/lib/declarations.console-server.js +41 -0
  16. package/src/lib/declarations.console-server.js.map +1 -0
  17. package/src/lib/declarations.js +11 -2
  18. package/src/lib/declarations.js.map +1 -1
  19. package/src/lib/declarations.server.js +68 -1
  20. package/src/lib/declarations.server.js.map +1 -1
  21. package/src/lib/hooks/use-plugin-connections-api.d.ts +89 -0
  22. package/src/lib/hooks/use-plugin-connections-api.js +132 -0
  23. package/src/lib/hooks/use-plugin-connections-api.js.map +1 -0
  24. package/src/lib/model/editor-connections.d.ts +46 -0
  25. package/src/lib/model/editor-connections.js +70 -0
  26. package/src/lib/model/editor-connections.js.map +1 -0
  27. package/src/lib/model/listing-price-floor.js +7 -4
  28. package/src/lib/model/listing-price-floor.js.map +1 -1
  29. package/src/lib/model/marketplace-publisher-permissions.d.ts +3 -1
  30. package/src/lib/model/marketplace-publisher-permissions.js +14 -0
  31. package/src/lib/model/marketplace-publisher-permissions.js.map +1 -1
  32. package/src/lib/model/marketplace-site-permissions.d.ts +9 -0
  33. package/src/lib/model/marketplace-site-permissions.js +29 -0
  34. package/src/lib/model/marketplace-site-permissions.js.map +1 -1
  35. package/src/lib/model/plugin-connection-card.d.ts +88 -0
  36. package/src/lib/model/plugin-connection-card.js +187 -0
  37. package/src/lib/model/plugin-connection-card.js.map +1 -0
  38. package/src/lib/model/plugin-connections.d.ts +172 -0
  39. package/src/lib/model/plugin-connections.js +119 -0
  40. package/src/lib/model/plugin-connections.js.map +1 -0
  41. package/src/lib/plugin.js +21 -1
  42. package/src/lib/plugin.js.map +1 -1
  43. package/src/lib/server/admin-reviews.js +18 -12
  44. package/src/lib/server/admin-reviews.js.map +1 -1
  45. package/src/lib/server/artifact-inventory.js +3 -1
  46. package/src/lib/server/artifact-inventory.js.map +1 -1
  47. package/src/lib/server/checkout.js +4 -0
  48. package/src/lib/server/checkout.js.map +1 -1
  49. package/src/lib/server/connections/cleanup.d.ts +84 -0
  50. package/src/lib/server/connections/cleanup.js +116 -0
  51. package/src/lib/server/connections/cleanup.js.map +1 -0
  52. package/src/lib/server/connections/config.d.ts +42 -0
  53. package/src/lib/server/connections/config.js +48 -0
  54. package/src/lib/server/connections/config.js.map +1 -0
  55. package/src/lib/server/connections/connection-store.d.ts +173 -0
  56. package/src/lib/server/connections/connection-store.js +331 -0
  57. package/src/lib/server/connections/connection-store.js.map +1 -0
  58. package/src/lib/server/connections/credentials-store.d.ts +71 -0
  59. package/src/lib/server/connections/credentials-store.js +78 -0
  60. package/src/lib/server/connections/credentials-store.js.map +1 -0
  61. package/src/lib/server/connections/erasure.d.ts +25 -0
  62. package/src/lib/server/connections/erasure.js +72 -0
  63. package/src/lib/server/connections/erasure.js.map +1 -0
  64. package/src/lib/server/connections/installed-connection.d.ts +64 -0
  65. package/src/lib/server/connections/installed-connection.js +92 -0
  66. package/src/lib/server/connections/installed-connection.js.map +1 -0
  67. package/src/lib/server/connections/oauth-state.d.ts +129 -0
  68. package/src/lib/server/connections/oauth-state.js +252 -0
  69. package/src/lib/server/connections/oauth-state.js.map +1 -0
  70. package/src/lib/server/connections/provider-http.d.ts +99 -0
  71. package/src/lib/server/connections/provider-http.js +253 -0
  72. package/src/lib/server/connections/provider-http.js.map +1 -0
  73. package/src/lib/server/connections/record-announce.d.ts +20 -0
  74. package/src/lib/server/connections/record-announce.js +53 -0
  75. package/src/lib/server/connections/record-announce.js.map +1 -0
  76. package/src/lib/server/connections/record-mapping.d.ts +63 -0
  77. package/src/lib/server/connections/record-mapping.js +151 -0
  78. package/src/lib/server/connections/record-mapping.js.map +1 -0
  79. package/src/lib/server/connections/record-routes.d.ts +17 -0
  80. package/src/lib/server/connections/record-routes.js +96 -0
  81. package/src/lib/server/connections/record-routes.js.map +1 -0
  82. package/src/lib/server/connections/revoke.d.ts +29 -0
  83. package/src/lib/server/connections/revoke.js +52 -0
  84. package/src/lib/server/connections/revoke.js.map +1 -0
  85. package/src/lib/server/connections/routes.d.ts +77 -0
  86. package/src/lib/server/connections/routes.js +706 -0
  87. package/src/lib/server/connections/routes.js.map +1 -0
  88. package/src/lib/server/connections/site-records-platform.d.ts +24 -0
  89. package/src/lib/server/connections/site-records-platform.js +94 -0
  90. package/src/lib/server/connections/site-records-platform.js.map +1 -0
  91. package/src/lib/server/connections/site-records.d.ts +95 -0
  92. package/src/lib/server/connections/site-records.js +221 -0
  93. package/src/lib/server/connections/site-records.js.map +1 -0
  94. package/src/lib/server/connections/sync.d.ts +110 -0
  95. package/src/lib/server/connections/sync.js +414 -0
  96. package/src/lib/server/connections/sync.js.map +1 -0
  97. package/src/lib/server/install-plugin.js +56 -6
  98. package/src/lib/server/install-plugin.js.map +1 -1
  99. package/src/lib/server/publish-plugin.js +5 -2
  100. package/src/lib/server/publish-plugin.js.map +1 -1
  101. package/src/lib/server/revenue-source.js +6 -5
  102. package/src/lib/server/revenue-source.js.map +1 -1
  103. package/src/lib/server.js +60 -0
  104. package/src/lib/server.js.map +1 -1
  105. package/src/lib/testing/memory-firestore.d.ts +34 -0
  106. package/src/lib/testing/memory-firestore.js +250 -0
  107. package/src/lib/testing/memory-firestore.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/marketplace/src/lib/server/connections/site-records.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 { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n pluginConnectionRecordVariables,\n validatePluginImageHosts,\n type PluginConnectionDeclaration,\n type PluginConnectionRecordField,\n} from '@aglyn/aglyn/app-utils/plugin-connections'\nimport {\n PLUGIN_CONNECTION_RECORDS_PROP,\n type PluginConnectionRecordsPageData,\n type PluginConnectionRecordValue,\n type PluginConnectionSiteRecord,\n} from '@aglyn/aglyn/app-utils/plugin-connection-records'\nimport type { HostVariable } from '@aglyn/aglyn/app-utils/variables'\nimport type { VariableComputation, VariableComputer } from '@aglyn/aglyn/plugin-manager/computed-variables'\nimport type { PluginSiteCspSource } from '@aglyn/aglyn/plugin-manager/plugin-site-csp'\nimport type { RealmPluginInstall } from '@aglyn/aglyn/plugin-manager/realm-plugins'\nimport type { SitePageContext, SitePageEnricher } from '@aglyn/aglyn/plugin-manager/site-page-hooks'\nimport { PLUGIN_CONNECTIONS_FEATURE, pluginConnectionRecordDocId } from '../../model/plugin-connections'\nimport { connectionsCollection } from './connection-store'\nimport { installedConnectionsForHost } from './installed-connection'\n\n/*\n * WHAT A SITE SHOWS OF ITS PLUGINS' DECLARED CONNECTIONS (AGL-4195).\n *\n * The sync (AGL-4194) writes, per site, the record each connection's\n * declaration maps (`hosts/{hostId}/pluginConnections/record-…`). That\n * collection is server-only, so the published page reads it here, in three\n * places, all on the server:\n *\n * - the PAGE ENRICHER hands each signed element on the page its own\n * listing's records, as one page prop, so the element renders in the\n * page's HTML (`usePluginConnectionRecord` in core looks it up);\n * - the VARIABLE COMPUTER turns each record's declared summary fields into\n * site variables a page binds as `{{var:…}}`;\n * - the CSP SOURCE names the image hosts the site's signed plugins declare\n * (`capabilities.imageHosts`), so the tenant admits them to `img-src` on\n * the sites that run those plugins and nowhere else.\n *\n * A record is exposed only while the plugin is still installed at a version\n * that declares the connection (not revoked), and only on a plan with the\n * connections feature. What is exposed is held to the declaration again:\n * the record's keys are filtered to the declared ones, so a record written\n * under an older version's schema cannot show a field the pinned version no\n * longer declares.\n */\n\n/** Records read for one site at most: four connections a plugin, a page of plugins. */\nexport const SITE_CONNECTION_RECORDS_READ = 40\n\n/** One connection's record on a site, as the page and the variables read it. */\nexport interface SiteConnectionRecordEntry {\n listingId: string\n connectionId: string\n record: PluginConnectionSiteRecord\n variables: Record<string, HostVariable>\n}\n\n/** What reading a site's records needs: the store and the site's workspace. */\nexport interface SiteConnectionRecordsReadDeps {\n firestore: FirebaseFirestore.Firestore\n /** The site's workspace and its document, or `null` for an unindexed site. */\n orgForHost(hostId: string): Promise<{ orgId: string; org: Record<string, unknown> } | null>\n}\n\nconst isPrimitive = (value: unknown): value is PluginConnectionRecordValue =>\n value === null || typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))\n\n/** `source` held to the declared keys: anything else, or a non-primitive, is dropped. */\nfunction declaredOnly(\n source: unknown,\n declared: Readonly<Record<string, PluginConnectionRecordField>>,\n): Record<string, PluginConnectionRecordValue> {\n const out: Record<string, PluginConnectionRecordValue> = {}\n if (!source || typeof source !== 'object' || Array.isArray(source)) return out\n for (const key of Object.keys(declared)) {\n const value = (source as Record<string, unknown>)[key]\n if (value !== undefined && isPrimitive(value)) out[key] = value\n }\n return out\n}\n\n/** A stored record, held to what `connection` declares, or `null` when it is not one. */\nexport function toSiteRecord(\n data: Record<string, unknown> | undefined,\n connection: PluginConnectionDeclaration,\n): PluginConnectionSiteRecord | null {\n const schema = connection.sync?.record\n if (!data || data['kind'] !== 'record' || !schema) return null\n const syncedAtMs = Number(data['syncedAtMs'])\n const items = Array.isArray(data['items']) ? data['items'] : []\n const itemFields = schema.items?.fields ?? {}\n return {\n fields: declaredOnly(data['fields'], schema.fields ?? {}),\n items: schema.items ? items.slice(0, schema.items.maxItems).map((item) => declaredOnly(item, itemFields)) : [],\n syncedAtMs: Number.isFinite(syncedAtMs) ? syncedAtMs : 0,\n }\n}\n\n/**\n * Every record the site's installed plugins may show, read uncached.\n *\n * One query when the site has none — the case for nearly every site — and\n * the install pins and the workspace only when it has some.\n */\nexport async function readSiteConnectionRecords(\n deps: SiteConnectionRecordsReadDeps,\n hostId: string,\n): Promise<SiteConnectionRecordEntry[]> {\n const snapshot = await connectionsCollection(deps.firestore, hostId)\n .where('kind', '==', 'record')\n .limit(SITE_CONNECTION_RECORDS_READ)\n .get()\n if (snapshot.empty) return []\n const owner = await deps.orgForHost(hostId)\n // A site with no workspace has no plan to hold the feature (`checkEntitlement`\n // fails open on a missing org, which is right for a page and wrong here).\n if (!owner || !checkEntitlement(owner.org as never, PLUGIN_CONNECTIONS_FEATURE)) return []\n const installed = await installedConnectionsForHost(deps.firestore, { hostId, orgId: owner.orgId })\n const out: SiteConnectionRecordEntry[] = []\n for (const doc of snapshot.docs) {\n const data = doc.data() as Record<string, unknown>\n const listingId = String(data['listingId'] ?? '')\n const connectionId = String(data['connectionId'] ?? '')\n // The id is the contract (`record-{listingId}__{connectionId}`); a record\n // whose body names another connection than its id is not trusted.\n if (!listingId || !connectionId || doc.id !== pluginConnectionRecordDocId(listingId, connectionId)) continue\n const connection = installed\n .find((plugin) => plugin.listingId === listingId)\n ?.connections.find((declared) => declared.id === connectionId)\n if (!connection) continue\n const record = toSiteRecord(data, connection)\n if (!record) continue\n out.push({\n listingId,\n connectionId,\n record,\n variables: pluginConnectionRecordVariables(listingId, connection, record.fields),\n })\n }\n return out\n}\n\n/* ------------------------------------------------------------------ */\n/* The page enricher */\n/* ------------------------------------------------------------------ */\n\nexport interface ConnectionRecordsEnricherDeps {\n /** The site's signed (realm) installs, as the page loader reads them. */\n realmInstalls(hostId: string): Promise<readonly RealmPluginInstall[]>\n /** The site's records ({@link readSiteConnectionRecords}, cached). */\n records(hostId: string): Promise<readonly SiteConnectionRecordEntry[]>\n}\n\ntype PageNode = { $id?: unknown; componentId?: unknown } | null | undefined\n\n/**\n * Node id → listing id, for every element on the page in a signed install's\n * namespace (`<identity>.<role>`). A node is attributed to the install whose\n * identity is the LONGEST prefix of its component id, so one identity being a\n * prefix of another (`acme.reviews` and `acme.reviews.pro`) cannot hand an\n * element the other's records.\n */\nexport function signedElementOwners(\n nodes: Record<string, PageNode> | null | undefined,\n installs: readonly RealmPluginInstall[],\n): Record<string, string> {\n const owners: Record<string, string> = {}\n if (!nodes || typeof nodes !== 'object') return owners\n const namespaces = installs\n .filter((install) => typeof install.identity === 'string' && install.identity)\n .map((install) => ({ prefix: `${install.identity}.`, listingId: install.listingId }))\n .sort((a, b) => b.prefix.length - a.prefix.length)\n if (!namespaces.length) return owners\n for (const [key, node] of Object.entries(nodes)) {\n const componentId = node?.componentId\n if (typeof componentId !== 'string' || !componentId.includes('.')) continue\n const owner = namespaces.find((namespace) => componentId.startsWith(namespace.prefix))\n if (!owner) continue\n const nodeId = typeof node?.$id === 'string' && node.$id ? node.$id : key\n owners[nodeId] = owner.listingId\n }\n return owners\n}\n\n/**\n * The enricher. A page with no element in a marketplace namespace — nearly\n * every page — returns before reading anything.\n */\nexport function createConnectionRecordsEnricher(deps: ConnectionRecordsEnricherDeps): SitePageEnricher {\n return async (context: SitePageContext) => {\n const nodes = context.nodes as Record<string, PageNode> | undefined\n if (!nodes || typeof nodes !== 'object') return undefined\n const anyNamespaced = Object.values(nodes).some(\n (node) => typeof node?.componentId === 'string' && node.componentId.includes('.'),\n )\n if (!anyNamespaced) return undefined\n const owners = signedElementOwners(nodes, await deps.realmInstalls(context.hostId))\n const listings = new Set(Object.values(owners))\n if (!listings.size) return undefined\n const records: PluginConnectionRecordsPageData['records'] = {}\n for (const entry of await deps.records(context.hostId)) {\n if (!listings.has(entry.listingId)) continue\n ;(records[entry.listingId] ??= {})[entry.connectionId] = entry.record\n }\n const attributed: Record<string, string> = {}\n for (const [nodeId, listingId] of Object.entries(owners)) {\n if (records[listingId]) attributed[nodeId] = listingId\n }\n if (!Object.keys(attributed).length) return undefined\n const data: PluginConnectionRecordsPageData = { nodes: attributed, records }\n return { [PLUGIN_CONNECTION_RECORDS_PROP]: data }\n }\n}\n\n/* ------------------------------------------------------------------ */\n/* The variable computer */\n/* ------------------------------------------------------------------ */\n\nconst unchanged: VariableComputation = ({ variables }) => variables\n\n/**\n * Adds each record's summary-field variables to the site's own. A site's own\n * variable is never replaced: the ids cannot collide by construction\n * (`pluginConnectionVariableId`), and this holds even if one somehow did.\n */\nexport function createConnectionVariableComputer(deps: Pick<ConnectionRecordsEnricherDeps, 'records'>): VariableComputer {\n return {\n prepare: async (hostId) => {\n const entries = await deps.records(hostId)\n const computed: Record<string, HostVariable> = {}\n for (const entry of entries) Object.assign(computed, entry.variables)\n if (!Object.keys(computed).length) return unchanged\n return ({ variables }) => {\n const out = { ...variables }\n for (const [id, variable] of Object.entries(computed)) {\n if (!Object.prototype.hasOwnProperty.call(variables, id)) out[id] = variable\n }\n return out\n }\n },\n }\n}\n\n/* ------------------------------------------------------------------ */\n/* The image hosts */\n/* ------------------------------------------------------------------ */\n\nexport interface PluginImageHostsDeps {\n realmInstalls(hostId: string): Promise<readonly RealmPluginInstall[]>\n /** The `capabilities.imageHosts` the pinned version's manifest declares, unvalidated. */\n declaredImageHosts(listingId: string, version: string): Promise<unknown>\n}\n\n/**\n * The image hosts a site's signed plugins declare, each re-validated, in\n * install order, de-duplicated. A signed (realm) install is the only kind\n * whose elements draw into the page itself; a sandboxed plugin draws in a\n * frame of its own origin, which the page's `img-src` does not govern. A\n * declaration that no longer validates admits nothing at all.\n */\nexport async function siteDeclaredImageHosts(deps: PluginImageHostsDeps, hostId: string): Promise<string[]> {\n const installs = await deps.realmInstalls(hostId)\n if (!installs.length) return []\n const declared = await Promise.all(\n installs.map(async (install) => {\n try {\n const verdict = validatePluginImageHosts(await deps.declaredImageHosts(install.listingId, install.version))\n return verdict.ok ? verdict.value : []\n } catch (error) {\n console.error(`plugin-connections: the image hosts of ${install.listingId} were unreadable`, error)\n return []\n }\n }),\n )\n return [...new Set(declared.flat())]\n}\n\n/** The CSP source the tenant's verdict asks. */\nexport function createPluginImageHostsSource(hosts: (hostId: string) => Promise<string[]>): PluginSiteCspSource {\n return {\n hosts: async ({ hostId }) => {\n const img = await hosts(hostId)\n return img.length ? { img } : {}\n },\n }\n}\n"],"names":["checkEntitlement","pluginConnectionRecordVariables","validatePluginImageHosts","PLUGIN_CONNECTION_RECORDS_PROP","PLUGIN_CONNECTIONS_FEATURE","pluginConnectionRecordDocId","connectionsCollection","installedConnectionsForHost","SITE_CONNECTION_RECORDS_READ","isPrimitive","value","Number","isFinite","declaredOnly","source","declared","out","Array","isArray","key","Object","keys","undefined","toSiteRecord","data","connection","schema","sync","record","syncedAtMs","items","itemFields","fields","slice","maxItems","map","item","readSiteConnectionRecords","deps","hostId","snapshot","firestore","where","limit","get","empty","owner","orgForHost","org","installed","orgId","doc","docs","listingId","String","connectionId","id","find","plugin","connections","push","variables","signedElementOwners","nodes","installs","owners","namespaces","filter","install","identity","prefix","sort","a","b","length","node","entries","componentId","includes","namespace","startsWith","nodeId","$id","createConnectionRecordsEnricher","context","anyNamespaced","values","some","realmInstalls","listings","Set","size","records","entry","has","attributed","unchanged","createConnectionVariableComputer","prepare","computed","assign","variable","prototype","hasOwnProperty","call","siteDeclaredImageHosts","Promise","all","verdict","declaredImageHosts","version","ok","error","console","flat","createPluginImageHostsSource","hosts","img"],"mappings":"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"}
@@ -0,0 +1,110 @@
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
+ import { type PluginConnectionDeclaration } from '@aglyn/aglyn/app-utils/plugin-connections';
18
+ import type { PluginConsoleCronContext, PluginConsoleCronReport } from '@aglyn/aglyn/plugin-manager/plugin-console-crons';
19
+ import type { SecretBoxKeyring } from '@aglyn/shared-util-tools/secret-box';
20
+ import { type PluginConnectionRecord } from './connection-store';
21
+ import { type PluginConnectionClient } from './provider-http';
22
+ /**
23
+ * THE DECLARED SYNC, RUN BY THE PLATFORM (AGL-4194).
24
+ *
25
+ * A connection's manifest declares `sync`: a few templated requests, an
26
+ * interval, and the typed record its answers map into. This runs it, in the
27
+ * CONSOLE only — it opens sealed grants with `PLUGIN_CONNECTIONS_TOKEN_KEY`,
28
+ * which the tenant never holds:
29
+ *
30
+ * - on the console's fifteen-minute `plugin-console-crons` tick, as the
31
+ * `marketplace-connection-sync` job ({@link runPluginConnectionSyncJob}),
32
+ * for every connection whose `nextSyncAtMs` has come, oldest first,
33
+ * {@link PLUGIN_CONNECTION_SYNC_MAX_PER_TICK} at most per tick;
34
+ * - on "Sync now" (`POST plugin-connections/sync`), for one connection.
35
+ *
36
+ * One sync ({@link runConnectionSync}):
37
+ *
38
+ * 1. under the declaration of the version the site has PINNED, never the
39
+ * listing's newest;
40
+ * 2. with the site's access token, refreshed under the store's lease when
41
+ * it is near expiry, and once more on a 401 or 403 from the API;
42
+ * 3. each request the record reads, filled from the site's choices by
43
+ * `preparePluginConnectionRequest` (which refuses anything off the
44
+ * declared `apiHosts`), page by page up to the declared `maxPages` and
45
+ * the record's `maxItems`, each answer under the 2 MB ceiling and the
46
+ * per-attempt timeout of `provider-http.ts`, and no page started after
47
+ * the sync's own time budget;
48
+ * 4. mapped into the record (`record-mapping.ts`: undeclared keys dropped,
49
+ * strings cut, the whole record bounded) and written WHOLE to
50
+ * `hosts/{hostId}/pluginConnections/record-{listingId}__{connectionId}`,
51
+ * which no client rule opens;
52
+ * 5. then the connection is marked synced, next due `intervalHours` on.
53
+ *
54
+ * Failures: a grant the provider refuses (`invalid_grant`, a 401 after a
55
+ * fresh token) is `reconnect_required`, and the schedule stops until the
56
+ * owner connects again. Anything else is `error` with the provider's
57
+ * sentence, retried after a backoff that doubles from fifteen minutes up to
58
+ * the declared interval. The last good record stays in place either way.
59
+ *
60
+ * Skipped quietly, with nothing written: the whole tick when the key is unset.
61
+ * Deferred, with only the next due time moved: a site whose plan has no
62
+ * connections, and a pinned version the kill switch revoked. Forgotten
63
+ * (revoked and deleted, `cleanup.ts`): a connection whose site no longer runs
64
+ * a version that declares it.
65
+ */
66
+ /** The job id: its `platformCronBeats` mark and its row on `/api/health/crons`. */
67
+ export declare const PLUGIN_CONNECTION_SYNC_JOB_ID = "marketplace-connection-sync";
68
+ /** Connections synced at most per tick; the rest wait for the next, oldest first. */
69
+ export declare const PLUGIN_CONNECTION_SYNC_MAX_PER_TICK = 25;
70
+ /** How long one sync may keep starting pages. */
71
+ export declare const PLUGIN_CONNECTION_SYNC_BUDGET_MS = 60000;
72
+ /** The first retry after a failed sync; it doubles up to the declared interval. */
73
+ export declare const PLUGIN_CONNECTION_SYNC_MIN_BACKOFF_MS: number;
74
+ /** How far a deferred connection (plan, kill switch, no workspace) is moved on. */
75
+ export declare const PLUGIN_CONNECTION_SYNC_DEFER_MS: number;
76
+ /** The retry delay after `failures` failed syncs in a row, capped at the interval. */
77
+ export declare function pluginConnectionSyncBackoffMs(failures: number, intervalHours: number): number;
78
+ /** What one sync came to. */
79
+ export type PluginConnectionSyncOutcome = 'synced' | 'error' | 'reconnect' | 'not-ready' | 'no-sync' | 'deferred' | 'forgotten' | 'gone';
80
+ export interface RunConnectionSyncInput {
81
+ keyring: SecretBoxKeyring;
82
+ record: PluginConnectionRecord;
83
+ connection: PluginConnectionDeclaration;
84
+ /** The pinned version the declaration came from. */
85
+ version: string;
86
+ client: PluginConnectionClient;
87
+ /** Start no page after this, epoch ms. */
88
+ deadlineMs: number;
89
+ }
90
+ /**
91
+ * Runs one connection's declared sync and records the outcome on the
92
+ * connection. Never throws for a provider's failure; a Firestore failure
93
+ * propagates.
94
+ */
95
+ export declare function runConnectionSync(input: RunConnectionSyncInput): Promise<PluginConnectionSyncOutcome>;
96
+ /** One due connection: gates, then the sync. */
97
+ export declare function syncDueConnection(keyring: SecretBoxKeyring, record: PluginConnectionRecord, deadlineMs: number): Promise<PluginConnectionSyncOutcome>;
98
+ /**
99
+ * The due connections, oldest first: a collection-group query over every
100
+ * site's `pluginConnections`, which needs its `COLLECTION_GROUP` index
101
+ * (`kind`, `status`, `nextSyncAtMs`) in `cloud/firebase-firestore.indexes.json`.
102
+ * `error` is included: its `nextSyncAtMs` is the backoff. A
103
+ * `reconnect_required` connection has none, so it is never due.
104
+ */
105
+ export declare function dueConnections(nowMs: number, limit?: number): Promise<PluginConnectionRecord[]>;
106
+ /**
107
+ * The `marketplace-connection-sync` console job: every due connection, a few
108
+ * at a time, none started after the tick's deadline. Reports counts only.
109
+ */
110
+ export declare function runPluginConnectionSyncJob(context: PluginConsoleCronContext): Promise<PluginConsoleCronReport>;
@@ -0,0 +1,414 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
+ import { readPluginConnectionPath } from "@aglyn/aglyn/app-utils/plugin-connections";
19
+ import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
20
+ import { PLUGIN_CONNECTIONS_FEATURE, PLUGIN_CONNECTIONS_HOST_COLLECTION } from "../../model/plugin-connections.js";
21
+ import { readPluginConnectionsKeyring } from "./config.js";
22
+ import { forgetSiteConnection, pinnedConnectionState } from "./cleanup.js";
23
+ import { connectionAccessToken, connectionReady, connectionRecordRef, connectionRef, markConnectionError, markConnectionSynced, PluginConnectionReconnectRequiredError, readConnection } from "./connection-store.js";
24
+ import { announceConnectionRecordChange, connectionRecordChanged } from "./record-announce.js";
25
+ import { openCredentials } from "./credentials-store.js";
26
+ import { latestListingConnections } from "./installed-connection.js";
27
+ import { pluginConnectionApiRequest, pluginConnectionRuntime, PluginConnectionProviderError } from "./provider-http.js";
28
+ import { mapPluginConnectionRecord, pluginConnectionSyncRequestsRead } from "./record-mapping.js";
29
+ /**
30
+ * THE DECLARED SYNC, RUN BY THE PLATFORM (AGL-4194).
31
+ *
32
+ * A connection's manifest declares `sync`: a few templated requests, an
33
+ * interval, and the typed record its answers map into. This runs it, in the
34
+ * CONSOLE only — it opens sealed grants with `PLUGIN_CONNECTIONS_TOKEN_KEY`,
35
+ * which the tenant never holds:
36
+ *
37
+ * - on the console's fifteen-minute `plugin-console-crons` tick, as the
38
+ * `marketplace-connection-sync` job ({@link runPluginConnectionSyncJob}),
39
+ * for every connection whose `nextSyncAtMs` has come, oldest first,
40
+ * {@link PLUGIN_CONNECTION_SYNC_MAX_PER_TICK} at most per tick;
41
+ * - on "Sync now" (`POST plugin-connections/sync`), for one connection.
42
+ *
43
+ * One sync ({@link runConnectionSync}):
44
+ *
45
+ * 1. under the declaration of the version the site has PINNED, never the
46
+ * listing's newest;
47
+ * 2. with the site's access token, refreshed under the store's lease when
48
+ * it is near expiry, and once more on a 401 or 403 from the API;
49
+ * 3. each request the record reads, filled from the site's choices by
50
+ * `preparePluginConnectionRequest` (which refuses anything off the
51
+ * declared `apiHosts`), page by page up to the declared `maxPages` and
52
+ * the record's `maxItems`, each answer under the 2 MB ceiling and the
53
+ * per-attempt timeout of `provider-http.ts`, and no page started after
54
+ * the sync's own time budget;
55
+ * 4. mapped into the record (`record-mapping.ts`: undeclared keys dropped,
56
+ * strings cut, the whole record bounded) and written WHOLE to
57
+ * `hosts/{hostId}/pluginConnections/record-{listingId}__{connectionId}`,
58
+ * which no client rule opens;
59
+ * 5. then the connection is marked synced, next due `intervalHours` on.
60
+ *
61
+ * Failures: a grant the provider refuses (`invalid_grant`, a 401 after a
62
+ * fresh token) is `reconnect_required`, and the schedule stops until the
63
+ * owner connects again. Anything else is `error` with the provider's
64
+ * sentence, retried after a backoff that doubles from fifteen minutes up to
65
+ * the declared interval. The last good record stays in place either way.
66
+ *
67
+ * Skipped quietly, with nothing written: the whole tick when the key is unset.
68
+ * Deferred, with only the next due time moved: a site whose plan has no
69
+ * connections, and a pinned version the kill switch revoked. Forgotten
70
+ * (revoked and deleted, `cleanup.ts`): a connection whose site no longer runs
71
+ * a version that declares it.
72
+ */ /** The job id: its `platformCronBeats` mark and its row on `/api/health/crons`. */ export const PLUGIN_CONNECTION_SYNC_JOB_ID = 'marketplace-connection-sync';
73
+ /** Connections synced at most per tick; the rest wait for the next, oldest first. */ export const PLUGIN_CONNECTION_SYNC_MAX_PER_TICK = 25;
74
+ /** Connections synced at once within a tick. */ const CONCURRENCY = 4;
75
+ /** How long one sync may keep starting pages. */ export const PLUGIN_CONNECTION_SYNC_BUDGET_MS = 60000;
76
+ /** The first retry after a failed sync; it doubles up to the declared interval. */ export const PLUGIN_CONNECTION_SYNC_MIN_BACKOFF_MS = 15 * 60 * 1000;
77
+ /** How far a deferred connection (plan, kill switch, no workspace) is moved on. */ export const PLUGIN_CONNECTION_SYNC_DEFER_MS = 6 * 60 * 60 * 1000;
78
+ const HOUR_MS = 60 * 60 * 1000;
79
+ const firestore = ()=>firebaseAdmin.app().firestore();
80
+ /** The retry delay after `failures` failed syncs in a row, capped at the interval. */ export function pluginConnectionSyncBackoffMs(failures, intervalHours) {
81
+ const cap = Math.max(PLUGIN_CONNECTION_SYNC_MIN_BACKOFF_MS, intervalHours * HOUR_MS);
82
+ const exponent = Math.min(Math.max(0, failures - 1), 20);
83
+ return Math.min(cap, PLUGIN_CONNECTION_SYNC_MIN_BACKOFF_MS * 2 ** exponent);
84
+ }
85
+ const isNotFound = (error)=>Boolean(error) && (error.code === 5 || /NOT_FOUND/.test(String(error == null ? void 0 : error.message)));
86
+ /** An update to a connection that may have been disconnected meanwhile: a missing document is not an error. */ async function updateIfPresent(write) {
87
+ try {
88
+ await write();
89
+ return true;
90
+ } catch (error) {
91
+ if (isNotFound(error)) return false;
92
+ throw error;
93
+ }
94
+ }
95
+ /**
96
+ * One declared request, page by page: every page's body (the summary fields
97
+ * read the first) and the items across pages, up to `maxItems`. A 401 or 403
98
+ * refreshes the token once for the whole sync; a second one is final.
99
+ */ async function fetchSyncRequest(connection, request, input) {
100
+ var _ref;
101
+ var _request_pagination;
102
+ const pages = [];
103
+ const items = [];
104
+ let cursor = null;
105
+ const maxPages = input.maxItems > 0 ? (_ref = (_request_pagination = request.pagination) == null ? void 0 : _request_pagination.maxPages) != null ? _ref : 1 : 1;
106
+ for(let page = 0; page < maxPages; page += 1){
107
+ if (pluginConnectionRuntime.now() >= input.deadlineMs) {
108
+ if (page === 0) throw new PluginConnectionProviderError('transient', 'The sync ran out of time. It will try again.');
109
+ break;
110
+ }
111
+ let body;
112
+ try {
113
+ body = await pluginConnectionApiRequest(connection, request, {
114
+ accessToken: input.token.current,
115
+ choices: input.choices,
116
+ cursor
117
+ });
118
+ } catch (error) {
119
+ if (!(error instanceof PluginConnectionProviderError) || error.code !== 'auth' || input.token.refreshed) throw error;
120
+ input.token.current = await input.token.refresh();
121
+ input.token.refreshed = true;
122
+ body = await pluginConnectionApiRequest(connection, request, {
123
+ accessToken: input.token.current,
124
+ choices: input.choices,
125
+ cursor
126
+ });
127
+ }
128
+ pages.push(body);
129
+ if (input.maxItems <= 0) break;
130
+ const list = request.itemsPath ? readPluginConnectionPath(body, request.itemsPath) : undefined;
131
+ if (Array.isArray(list)) items.push(...list.slice(0, Math.max(0, input.maxItems - items.length)));
132
+ if (items.length >= input.maxItems || !request.pagination) break;
133
+ const next = readPluginConnectionPath(body, request.pagination.cursorPath);
134
+ if (typeof next !== 'string' || !next || next.length > 2048 || next === cursor) break;
135
+ cursor = next;
136
+ }
137
+ return {
138
+ pages,
139
+ items
140
+ };
141
+ }
142
+ /** The items a request feeds into the record, or 0 for one only summary fields read. */ function itemsWanted(sync, requestId) {
143
+ var _sync_record_items;
144
+ return ((_sync_record_items = sync.record.items) == null ? void 0 : _sync_record_items.request) === requestId ? sync.record.items.maxItems : 0;
145
+ }
146
+ /**
147
+ * Runs one connection's declared sync and records the outcome on the
148
+ * connection. Never throws for a provider's failure; a Firestore failure
149
+ * propagates.
150
+ */ export async function runConnectionSync(input) {
151
+ const { record, connection } = input;
152
+ const db = firestore();
153
+ const ref = connectionRef(db, record.hostId, record.listingId, record.connectionId);
154
+ const sync = connection.sync;
155
+ if (!sync) {
156
+ // Nothing declared to run: off the schedule until a version declares one.
157
+ await updateIfPresent(()=>ref.update({
158
+ nextSyncAtMs: null,
159
+ updatedAtMs: pluginConnectionRuntime.now()
160
+ }));
161
+ return 'no-sync';
162
+ }
163
+ if (!connectionReady(record, connection)) {
164
+ // The choose route puts it back on the schedule once every resource is chosen.
165
+ await updateIfPresent(()=>ref.update({
166
+ nextSyncAtMs: null,
167
+ updatedAtMs: pluginConnectionRuntime.now()
168
+ }));
169
+ return 'not-ready';
170
+ }
171
+ const choices = Object.fromEntries(Object.entries(record.choices).map(([key, choice])=>[
172
+ key,
173
+ choice.id
174
+ ]));
175
+ const tokenDeps = {
176
+ firestore: db,
177
+ keyring: input.keyring,
178
+ connection,
179
+ client: input.client,
180
+ now: pluginConnectionRuntime.now,
181
+ sleep: pluginConnectionRuntime.sleep
182
+ };
183
+ try {
184
+ const token = {
185
+ current: await connectionAccessToken(tokenDeps, record),
186
+ refreshed: false,
187
+ refresh: ()=>connectionAccessToken(tokenDeps, record, {
188
+ force: true
189
+ })
190
+ };
191
+ const answers = {};
192
+ for (const request of pluginConnectionSyncRequestsRead(sync)){
193
+ answers[request.id] = await fetchSyncRequest(connection, request, {
194
+ token,
195
+ choices,
196
+ maxItems: itemsWanted(sync, request.id),
197
+ deadlineMs: input.deadlineMs
198
+ });
199
+ }
200
+ const nowMs = pluginConnectionRuntime.now();
201
+ const doc = mapPluginConnectionRecord(sync, answers, {
202
+ listingId: record.listingId,
203
+ connectionId: record.connectionId,
204
+ version: input.version,
205
+ syncedAtMs: nowMs
206
+ });
207
+ const recordRef = connectionRecordRef(db, record.hostId, record.listingId, record.connectionId);
208
+ const before = (await recordRef.get()).data();
209
+ // Whole, never merged: a key the new schema dropped must not survive.
210
+ await recordRef.set(doc);
211
+ const marked = await updateIfPresent(()=>markConnectionSynced(db, record, {
212
+ nowMs,
213
+ nextSyncAtMs: nowMs + sync.intervalHours * HOUR_MS
214
+ }));
215
+ if (!marked) {
216
+ // Disconnected while it ran: the record must not outlive the grant.
217
+ await recordRef.delete();
218
+ await announceConnectionRecordChange(record.hostId, `plugin connection ${record.listingId}/${record.connectionId} gone`);
219
+ return 'gone';
220
+ }
221
+ // The site's pages show the new values (AGL-4195); an unchanged record says nothing.
222
+ if (connectionRecordChanged(before, doc)) {
223
+ await announceConnectionRecordChange(record.hostId, `plugin connection ${record.listingId}/${record.connectionId} synced`);
224
+ }
225
+ return 'synced';
226
+ } catch (error) {
227
+ var _record_syncFailures;
228
+ const nowMs = pluginConnectionRuntime.now();
229
+ const grantGone = error instanceof PluginConnectionReconnectRequiredError || error instanceof PluginConnectionProviderError && error.code === 'auth' && error.status === 401;
230
+ if (grantGone) {
231
+ const message = error instanceof PluginConnectionReconnectRequiredError ? error.message : `${connection.label} refused the connection. Connect again.`;
232
+ await updateIfPresent(()=>markConnectionError(db, record, {
233
+ nowMs,
234
+ message,
235
+ reconnect: true
236
+ }));
237
+ return 'reconnect';
238
+ }
239
+ if (!(error instanceof PluginConnectionProviderError)) {
240
+ console.error('plugin-connections: a sync failed', error instanceof Error ? error.message : error);
241
+ }
242
+ const message = error instanceof PluginConnectionProviderError ? `${connection.label} did not answer: ${error.message}` : 'The sync failed. It will try again.';
243
+ const failures = ((_record_syncFailures = record.syncFailures) != null ? _record_syncFailures : 0) + 1;
244
+ await updateIfPresent(()=>markConnectionError(db, record, {
245
+ nowMs,
246
+ message,
247
+ reconnect: false,
248
+ nextSyncAtMs: nowMs + pluginConnectionSyncBackoffMs(failures, sync.intervalHours),
249
+ syncFailures: failures
250
+ }));
251
+ return 'error';
252
+ }
253
+ }
254
+ /** Moves a connection's next run on without touching its status. */ async function defer(record, nowMs) {
255
+ await updateIfPresent(()=>connectionRef(firestore(), record.hostId, record.listingId, record.connectionId).update({
256
+ nextSyncAtMs: nowMs + PLUGIN_CONNECTION_SYNC_DEFER_MS,
257
+ updatedAtMs: nowMs
258
+ }));
259
+ }
260
+ /** The declaration to revoke an orphan with: the listing's newest version's, the pin being gone. */ async function orphanDeclaration(listingId, connectionId) {
261
+ try {
262
+ var _listing_get, _declared_find;
263
+ const listing = await firestore().collection('marketplaceListings').doc(listingId).get();
264
+ const declared = await latestListingConnections(firestore(), listingId, String((_listing_get = listing.get('latestVersion')) != null ? _listing_get : ''));
265
+ return (_declared_find = declared.find((one)=>one.id === connectionId)) != null ? _declared_find : null;
266
+ } catch (unused) {
267
+ return null;
268
+ }
269
+ }
270
+ /** One due connection: gates, then the sync. */ export async function syncDueConnection(keyring, record, deadlineMs) {
271
+ const nowMs = pluginConnectionRuntime.now();
272
+ const resolved = await getOrgForHost(record.hostId);
273
+ if (!resolved) {
274
+ const host = await firestore().collection('hosts').doc(record.hostId).get();
275
+ if (!host.exists) {
276
+ // The site is gone and its eraser missed this: revoke and forget.
277
+ await forgetSiteConnection(firestore(), record, {
278
+ orgId: record.orgId || null,
279
+ declaration: await orphanDeclaration(record.listingId, record.connectionId)
280
+ });
281
+ return 'forgotten';
282
+ }
283
+ await defer(record, nowMs);
284
+ return 'deferred';
285
+ }
286
+ if (!checkEntitlement(resolved.org, PLUGIN_CONNECTIONS_FEATURE)) {
287
+ // A plan without connections keeps its grant and its last record; it is
288
+ // not run. Sync now (plan-gated) is how an upgrade gets a fresh one at once.
289
+ await defer(record, nowMs);
290
+ return 'deferred';
291
+ }
292
+ const pinned = await pinnedConnectionState(firestore(), {
293
+ hostId: record.hostId,
294
+ orgId: resolved.orgId,
295
+ listingId: record.listingId,
296
+ connectionId: record.connectionId
297
+ });
298
+ if (pinned.state === 'gone') {
299
+ await forgetSiteConnection(firestore(), record, {
300
+ orgId: resolved.orgId,
301
+ declaration: await orphanDeclaration(record.listingId, record.connectionId)
302
+ });
303
+ return 'forgotten';
304
+ }
305
+ if (pinned.state === 'paused') {
306
+ await defer(record, nowMs);
307
+ return 'deferred';
308
+ }
309
+ let client = null;
310
+ try {
311
+ client = await openCredentials(firestore(), keyring, record.listingId, record.connectionId);
312
+ } catch (error) {
313
+ console.error('plugin-connections: a listing’s credentials did not open', error instanceof Error ? error.message : error);
314
+ }
315
+ if (!client) {
316
+ var _record_syncFailures;
317
+ const failures = ((_record_syncFailures = record.syncFailures) != null ? _record_syncFailures : 0) + 1;
318
+ await updateIfPresent(()=>{
319
+ var _ref;
320
+ var _pinned_connection_sync;
321
+ return markConnectionError(firestore(), record, {
322
+ nowMs,
323
+ message: 'The plugin’s publisher has not finished setting up this connection yet.',
324
+ reconnect: false,
325
+ nextSyncAtMs: nowMs + pluginConnectionSyncBackoffMs(failures, (_ref = (_pinned_connection_sync = pinned.connection.sync) == null ? void 0 : _pinned_connection_sync.intervalHours) != null ? _ref : 24),
326
+ syncFailures: failures
327
+ });
328
+ });
329
+ return 'error';
330
+ }
331
+ return runConnectionSync({
332
+ keyring,
333
+ record,
334
+ connection: pinned.connection,
335
+ version: pinned.installed.version,
336
+ client,
337
+ deadlineMs: Math.min(deadlineMs, nowMs + PLUGIN_CONNECTION_SYNC_BUDGET_MS)
338
+ });
339
+ }
340
+ /**
341
+ * The due connections, oldest first: a collection-group query over every
342
+ * site's `pluginConnections`, which needs its `COLLECTION_GROUP` index
343
+ * (`kind`, `status`, `nextSyncAtMs`) in `cloud/firebase-firestore.indexes.json`.
344
+ * `error` is included: its `nextSyncAtMs` is the backoff. A
345
+ * `reconnect_required` connection has none, so it is never due.
346
+ */ export async function dueConnections(nowMs, limit = PLUGIN_CONNECTION_SYNC_MAX_PER_TICK) {
347
+ const snapshot = await firestore().collectionGroup(PLUGIN_CONNECTIONS_HOST_COLLECTION).where('kind', '==', 'connection').where('status', 'in', [
348
+ 'connected',
349
+ 'error'
350
+ ]).where('nextSyncAtMs', '<=', nowMs).orderBy('nextSyncAtMs', 'asc').limit(limit).get();
351
+ return snapshot.docs.map((doc)=>{
352
+ var _doc_ref_parent_parent;
353
+ const record = readConnection(doc.data());
354
+ // The site is the one the document sits under, whatever it says.
355
+ const hostId = (_doc_ref_parent_parent = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent.id;
356
+ return record && hostId ? _extends({}, record, {
357
+ hostId
358
+ }) : null;
359
+ }).filter((record)=>Boolean(record));
360
+ }
361
+ /**
362
+ * The `marketplace-connection-sync` console job: every due connection, a few
363
+ * at a time, none started after the tick's deadline. Reports counts only.
364
+ */ export async function runPluginConnectionSyncJob(context) {
365
+ const keyring = readPluginConnectionsKeyring();
366
+ if (!keyring) return {
367
+ configured: false
368
+ };
369
+ const due = await dueConnections(context.nowMs);
370
+ const counts = {
371
+ synced: 0,
372
+ error: 0,
373
+ reconnect: 0,
374
+ 'not-ready': 0,
375
+ 'no-sync': 0,
376
+ deferred: 0,
377
+ forgotten: 0,
378
+ gone: 0,
379
+ failed: 0,
380
+ notStarted: 0
381
+ };
382
+ let next = 0;
383
+ const worker = async ()=>{
384
+ while(next < due.length){
385
+ const record = due[next++];
386
+ if (pluginConnectionRuntime.now() >= context.deadlineMs) {
387
+ counts.notStarted += 1;
388
+ continue;
389
+ }
390
+ try {
391
+ counts[await syncDueConnection(keyring, record, context.deadlineMs)] += 1;
392
+ } catch (error) {
393
+ counts.failed += 1;
394
+ console.error('plugin-connections: a due connection failed', error instanceof Error ? error.message : error);
395
+ }
396
+ }
397
+ };
398
+ await Promise.all(Array.from({
399
+ length: Math.min(CONCURRENCY, due.length)
400
+ }, worker));
401
+ return {
402
+ configured: true,
403
+ due: due.length,
404
+ synced: counts.synced,
405
+ errors: counts.error + counts.failed,
406
+ reconnectRequired: counts.reconnect,
407
+ deferred: counts.deferred,
408
+ forgotten: counts.forgotten,
409
+ notReady: counts['not-ready'] + counts['no-sync'],
410
+ notStarted: counts.notStarted
411
+ };
412
+ }
413
+
414
+ //# sourceMappingURL=sync.js.map