@camstack/server 1.2.89 → 1.2.91

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.
@@ -46,6 +46,7 @@ const node_util_1 = require("node:util");
46
46
  const system_1 = require("@camstack/system");
47
47
  const types_1 = require("@camstack/types");
48
48
  const package_dir_utils_js_1 = require("./package-dir-utils.js");
49
+ const update_availability_emitter_js_1 = require("../update-availability-emitter.js");
49
50
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
50
51
  /**
51
52
  * The primary host-provided framework package (kernel + core). Kept as a named
@@ -189,6 +190,7 @@ class AddonPackageService {
189
190
  cachedUpdates = null;
190
191
  searchCache = null;
191
192
  versionCache = new Map();
193
+ updateAvailability;
192
194
  // -- Auto-update state ----------------------------------------------------
193
195
  autoUpdateConfig = {
194
196
  global: { channel: 'off', intervalSeconds: 21600 },
@@ -213,6 +215,10 @@ class AddonPackageService {
213
215
  this.notificationService = notificationService;
214
216
  this.toastService = toastService;
215
217
  this.logger = this.loggingService.createLogger('AddonPackageService');
218
+ this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(this.eventBusService, {
219
+ type: 'core',
220
+ id: 'addon-package-service',
221
+ });
216
222
  // Initialize installer eagerly (no async needed).
217
223
  // Ensures install/uninstall works before full module init completes.
218
224
  try {
@@ -620,6 +626,12 @@ class AddonPackageService {
620
626
  updates,
621
627
  expiresAt: now + AddonPackageService.UPDATE_CACHE_TTL_MS,
622
628
  };
629
+ this.updateAvailability.publishSnapshot('addon', updates.map((update) => ({
630
+ target: 'addon',
631
+ packageName: update.name,
632
+ currentVersion: update.currentVersion,
633
+ latestVersion: update.latestVersion,
634
+ })));
623
635
  return updates;
624
636
  }
625
637
  /** Clear the cached update check results */
@@ -636,7 +648,7 @@ class AddonPackageService {
636
648
  * Not cached: the caller decides freshness (an agent roster changes
637
649
  * per deploy, and `forceRefresh` must always be live).
638
650
  */
639
- async checkUpdatesForInstalled(installed) {
651
+ async checkUpdatesForInstalled(installed, nodeId) {
640
652
  // De-dup by package name — one npm package may bundle several addons.
641
653
  const seen = new Map();
642
654
  for (const pkg of installed) {
@@ -648,7 +660,7 @@ class AddonPackageService {
648
660
  if (!this.isAllowedPackage(name))
649
661
  return;
650
662
  const latestVersion = await this.fetchLatestVersion(name);
651
- if (latestVersion === null || latestVersion === version)
663
+ if (latestVersion === null || !isVersionNewer(latestVersion, version))
652
664
  return;
653
665
  const category = this.categorize(name);
654
666
  updates.push({
@@ -659,6 +671,13 @@ class AddonPackageService {
659
671
  requiresRestart: category === 'core',
660
672
  });
661
673
  }));
674
+ this.updateAvailability.publishSnapshot('addon', updates.map((update) => ({
675
+ target: 'addon',
676
+ packageName: update.name,
677
+ currentVersion: update.currentVersion,
678
+ latestVersion: update.latestVersion,
679
+ ...(nodeId !== undefined ? { nodeId } : {}),
680
+ })), nodeId);
662
681
  return updates;
663
682
  }
664
683
  /**
@@ -1243,6 +1262,7 @@ class AddonPackageService {
1243
1262
  this.logger.info('Running auto-update check...');
1244
1263
  const installed = this.listInstalled();
1245
1264
  const targets = [];
1265
+ const available = [];
1246
1266
  for (const pkg of installed) {
1247
1267
  try {
1248
1268
  // Framework packages NEVER auto-update from npm — they ship via
@@ -1289,6 +1309,12 @@ class AddonPackageService {
1289
1309
  },
1290
1310
  });
1291
1311
  targets.push({ name: pkg.name, version: targetVersion });
1312
+ available.push({
1313
+ target: 'addon',
1314
+ packageName: pkg.name,
1315
+ currentVersion: pkg.version,
1316
+ latestVersion: targetVersion,
1317
+ });
1292
1318
  }
1293
1319
  catch (err) {
1294
1320
  this.logger.warn('Auto-update check failed', {
@@ -1296,6 +1322,7 @@ class AddonPackageService {
1296
1322
  });
1297
1323
  }
1298
1324
  }
1325
+ this.updateAvailability.publishCandidates(available);
1299
1326
  if (targets.length === 0) {
1300
1327
  this.logger.debug('Auto-update: all packages up-to-date');
1301
1328
  return;
@@ -1431,7 +1458,7 @@ class AddonPackageService {
1431
1458
  const latestVersion = await this.fetchLatestVersion(name);
1432
1459
  if (!latestVersion)
1433
1460
  continue;
1434
- if (latestVersion !== version) {
1461
+ if (isVersionNewer(latestVersion, version)) {
1435
1462
  updates.push({
1436
1463
  name,
1437
1464
  currentVersion: version,
@@ -0,0 +1,344 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.resolveHubClosurePackageDir = resolveHubClosurePackageDir;
37
+ exports.resolveOverlayPackageDir = resolveOverlayPackageDir;
38
+ exports.computeDistBuildId = computeDistBuildId;
39
+ exports.swapDist = swapDist;
40
+ exports.syncFrameworkDist = syncFrameworkDist;
41
+ /**
42
+ * Framework live-sync — make a `camstack deploy` of a framework package
43
+ * (`@camstack/system`, …) actually reach the code the running hub loads.
44
+ *
45
+ * WHY THIS EXISTS
46
+ * ---------------
47
+ * `installFromTgz` writes the freshly-built framework tree to
48
+ * `CAMSTACK_ADDONS_DIR/<pkg>` (`/data/addons/@camstack/system`). NO runtime
49
+ * consumer loads from there:
50
+ * • the hub main process resolves `@camstack/system` from its OWN
51
+ * `node_modules` closure (Node's `node_modules` walk-up from
52
+ * `@camstack/server` finds the co-located copy BEFORE `NODE_PATH` is ever
53
+ * consulted — so the `/data/framework` overlay is a no-op for it);
54
+ * • the forked addon runners resolve it from `CAMSTACK_FRAMEWORK_DIR`
55
+ * (`/data/framework/node_modules/<pkg>`) via the ESM resolver hook.
56
+ * So a framework deploy landed on disk in a dead location and the subsequent
57
+ * hub restart reloaded the SAME unchanged code.
58
+ *
59
+ * This module mirrors the freshly-built `dist/` into BOTH live locations with
60
+ * an atomic rename-aside swap (mirrors `AddonInstaller.evictInstallDir`), so the
61
+ * scheduled restart actually loads the new framework code in the hub main AND
62
+ * the addon runners. Only `dist/` is swapped — native prebuilds and sibling
63
+ * packages in each closure are left untouched.
64
+ *
65
+ * All failures are surfaced (logged + returned) but never thrown: a failed
66
+ * mirror must not brick the restart, and the rename-aside guarantees a live
67
+ * `dist/` is never left half-written (the incoming copy is staged first, and
68
+ * the previous `dist/` is restored if the final swap fails).
69
+ */
70
+ const fs = __importStar(require("node:fs"));
71
+ const path = __importStar(require("node:path"));
72
+ const node_crypto_1 = require("node:crypto");
73
+ const node_module_1 = require("node:module");
74
+ // Resolve framework packages the same way the running hub does — from THIS
75
+ // module's location, whose walk-up lands on the hub's own `node_modules`
76
+ // closure (identical to the hub main's `@camstack/system` resolution).
77
+ const requireFromHere = (0, node_module_1.createRequire)(__filename);
78
+ // ---------------------------------------------------------------------------
79
+ // Tiny cast-free JSON helper (mirror of addon-package.service.readJsonObject).
80
+ // ---------------------------------------------------------------------------
81
+ function readJsonObjectSafe(filePath) {
82
+ try {
83
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
84
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
85
+ return { ...parsed };
86
+ }
87
+ }
88
+ catch {
89
+ /* missing / malformed — treat as absent */
90
+ }
91
+ return null;
92
+ }
93
+ function packageNameOf(pkgJsonPath) {
94
+ const obj = readJsonObjectSafe(pkgJsonPath);
95
+ const name = obj?.['name'];
96
+ return typeof name === 'string' ? name : null;
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Target resolution
100
+ // ---------------------------------------------------------------------------
101
+ /**
102
+ * The package directory the HUB MAIN process loads `<packageName>` from — its
103
+ * own `node_modules` closure. `require.resolve('<pkg>/package.json')` is the
104
+ * happy path; packages whose `exports` map omits that subpath fall back to
105
+ * resolving the main entry and walking up to the matching `package.json`.
106
+ * Returns null when the package is not resolvable.
107
+ */
108
+ function resolveHubClosurePackageDir(packageName) {
109
+ try {
110
+ return path.dirname(requireFromHere.resolve(`${packageName}/package.json`));
111
+ }
112
+ catch {
113
+ // `exports` map blocks the package.json subpath — fall through.
114
+ }
115
+ try {
116
+ let dir = path.dirname(requireFromHere.resolve(packageName));
117
+ while (dir !== path.dirname(dir)) {
118
+ const candidate = path.join(dir, 'package.json');
119
+ if (fs.existsSync(candidate) && packageNameOf(candidate) === packageName) {
120
+ return dir;
121
+ }
122
+ dir = path.dirname(dir);
123
+ }
124
+ }
125
+ catch {
126
+ // package itself not resolvable — treat as not installed.
127
+ }
128
+ return null;
129
+ }
130
+ /**
131
+ * The package directory the forked ADDON RUNNERS load `<packageName>` from —
132
+ * the `CAMSTACK_FRAMEWORK_DIR` overlay. Returns null when the env is unset
133
+ * (dev / no overlay) or the dir does not exist.
134
+ */
135
+ function resolveOverlayPackageDir(packageName) {
136
+ const frameworkDir = process.env['CAMSTACK_FRAMEWORK_DIR'];
137
+ if (frameworkDir === undefined || frameworkDir.length === 0)
138
+ return null;
139
+ const dir = path.join(frameworkDir, 'node_modules', packageName);
140
+ return fs.existsSync(dir) ? dir : null;
141
+ }
142
+ function resolveLiveTargets(packageName) {
143
+ const out = [];
144
+ const hub = resolveHubClosurePackageDir(packageName);
145
+ if (hub !== null)
146
+ out.push({ role: 'hub-closure', packageDir: hub });
147
+ const overlay = resolveOverlayPackageDir(packageName);
148
+ if (overlay !== null)
149
+ out.push({ role: 'overlay', packageDir: overlay });
150
+ return out;
151
+ }
152
+ function safeRealpath(p) {
153
+ try {
154
+ return fs.realpathSync(p);
155
+ }
156
+ catch {
157
+ return path.resolve(p);
158
+ }
159
+ }
160
+ // ---------------------------------------------------------------------------
161
+ // Build-id (content hash of a `dist/` tree)
162
+ // ---------------------------------------------------------------------------
163
+ function collectJsFiles(root, dir, out) {
164
+ let entries;
165
+ try {
166
+ entries = fs.readdirSync(dir, { withFileTypes: true });
167
+ }
168
+ catch {
169
+ return;
170
+ }
171
+ for (const entry of entries) {
172
+ const full = path.join(dir, entry.name);
173
+ if (entry.isDirectory()) {
174
+ collectJsFiles(root, full, out);
175
+ }
176
+ else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.mjs'))) {
177
+ out.push(path.relative(root, full));
178
+ }
179
+ }
180
+ }
181
+ /**
182
+ * Content build-id of a `dist/` tree — md5 over every `.js`/`.mjs` file's
183
+ * relative path + bytes (sorted for determinism). Analogous to the Docker
184
+ * image's `seed-version` fingerprint. Returns null when the dir is missing or
185
+ * empty. Used to surface "same semver, new code" instead of trusting `version`.
186
+ */
187
+ function computeDistBuildId(distDir) {
188
+ if (!fs.existsSync(distDir))
189
+ return null;
190
+ const files = [];
191
+ collectJsFiles(distDir, distDir, files);
192
+ if (files.length === 0)
193
+ return null;
194
+ files.sort();
195
+ const hash = (0, node_crypto_1.createHash)('md5');
196
+ for (const rel of files) {
197
+ hash.update(rel);
198
+ hash.update('\0');
199
+ hash.update(fs.readFileSync(path.join(distDir, rel)));
200
+ }
201
+ return hash.digest('hex');
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // Atomic dist swap (rename-aside)
205
+ // ---------------------------------------------------------------------------
206
+ function isCrossDeviceError(err) {
207
+ return err instanceof Error && err.code === 'EXDEV';
208
+ }
209
+ /**
210
+ * Replace `<targetPkgDir>/dist` with a copy of `sourceDistDir`.
211
+ *
212
+ * 1. COPY `sourceDistDir` → a staging dir INSIDE the target dir. The live
213
+ * `dist/` is untouched, so a mid-copy failure cannot corrupt it.
214
+ * 2. Evict the current `dist/` aside (rename), then rename the staging dir into
215
+ * place. Both renames are metadata ops WITHIN the target fs (atomic). If the
216
+ * final swap fails, the previous `dist/` is restored.
217
+ *
218
+ * OVERLAYFS FALLBACK: the hub-closure target (`/opt/.../@camstack/system`) lives
219
+ * on the container's overlay rootfs. A `dist/` materialized from a LOWER image
220
+ * layer cannot be `rename()`d (overlayfs returns `EXDEV: cross-device link`),
221
+ * so the atomic aside/swap is impossible there. On EXDEV we fall back to a
222
+ * copy-aside restore point + in-place replace. This opens a brief non-atomic
223
+ * window, but it is safe here: the running process already holds the old
224
+ * modules in memory (nothing reads `dist/` until the scheduled restart), and
225
+ * the copy-aside is kept until the replace completes so a failure is
226
+ * recoverable.
227
+ */
228
+ async function swapDist(sourceDistDir, targetPkgDir) {
229
+ const targetDist = path.join(targetPkgDir, 'dist');
230
+ const stamp = `${process.pid}-${Date.now()}`;
231
+ const incoming = path.join(targetPkgDir, `.dist-incoming-${stamp}`);
232
+ const aside = path.join(targetPkgDir, `.dist-evicted-${stamp}`);
233
+ // Step 1 — stage the incoming dist (cross-device copy is fine here).
234
+ try {
235
+ await fs.promises.cp(sourceDistDir, incoming, { recursive: true, force: true });
236
+ }
237
+ catch (err) {
238
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
239
+ throw err;
240
+ }
241
+ const hadDist = fs.existsSync(targetDist);
242
+ // How the previous dist was preserved: 'rename' (moved aside, same fs),
243
+ // 'copy' (overlayfs EXDEV — copied aside then removed in place), or 'none'.
244
+ let asideKind = 'none';
245
+ const restorePreviousDist = async () => {
246
+ if (asideKind === 'none' || fs.existsSync(targetDist))
247
+ return;
248
+ if (asideKind === 'rename') {
249
+ await fs.promises.rename(aside, targetDist).catch(() => undefined);
250
+ }
251
+ else {
252
+ await fs.promises
253
+ .cp(aside, targetDist, { recursive: true, force: true })
254
+ .catch(() => undefined);
255
+ }
256
+ };
257
+ // Step 2 — evict the current dist. Prefer the atomic rename; fall back to
258
+ // copy-aside + in-place remove on overlayfs EXDEV.
259
+ if (hadDist) {
260
+ try {
261
+ await fs.promises.rename(targetDist, aside);
262
+ asideKind = 'rename';
263
+ }
264
+ catch (err) {
265
+ if (!isCrossDeviceError(err)) {
266
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
267
+ throw err;
268
+ }
269
+ await fs.promises.cp(targetDist, aside, { recursive: true, force: true });
270
+ await fs.promises.rm(targetDist, { recursive: true, force: true });
271
+ asideKind = 'copy';
272
+ }
273
+ }
274
+ // Step 3 — move the incoming dist into place; fall back to copy on EXDEV.
275
+ try {
276
+ await fs.promises.rename(incoming, targetDist);
277
+ }
278
+ catch (err) {
279
+ if (isCrossDeviceError(err)) {
280
+ try {
281
+ await fs.promises.cp(incoming, targetDist, { recursive: true, force: true });
282
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
283
+ }
284
+ catch (copyErr) {
285
+ await restorePreviousDist();
286
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
287
+ throw copyErr;
288
+ }
289
+ }
290
+ else {
291
+ await restorePreviousDist();
292
+ await fs.promises.rm(incoming, { recursive: true, force: true }).catch(() => undefined);
293
+ throw err;
294
+ }
295
+ }
296
+ // Step 4 — best-effort cleanup of the evicted copy.
297
+ if (asideKind !== 'none') {
298
+ await fs.promises.rm(aside, { recursive: true, force: true }).catch(() => undefined);
299
+ }
300
+ }
301
+ // ---------------------------------------------------------------------------
302
+ // Public entry
303
+ // ---------------------------------------------------------------------------
304
+ /**
305
+ * Mirror a freshly-installed framework `dist/` into every live location the
306
+ * runtime actually loads it from (hub-main closure + addon-runner overlay).
307
+ * Never throws — returns a per-target outcome. Call BEFORE the scheduled hub
308
+ * restart so the bounce loads the new code.
309
+ */
310
+ async function syncFrameworkDist(args) {
311
+ const { packageName, sourceDistDir, logger } = args;
312
+ if (!fs.existsSync(sourceDistDir)) {
313
+ logger.warn('framework sync: source dist missing — nothing to mirror', {
314
+ meta: { packageName, sourceDistDir },
315
+ });
316
+ return { results: [] };
317
+ }
318
+ const sourcePkgReal = safeRealpath(path.dirname(sourceDistDir));
319
+ const seen = new Set();
320
+ const results = [];
321
+ for (const target of resolveLiveTargets(packageName)) {
322
+ const real = safeRealpath(target.packageDir);
323
+ // Dedupe (a dev symlink can point both roles at one dir) and never mirror
324
+ // a package dir onto its own source (would delete the code mid-swap).
325
+ if (seen.has(real) || real === sourcePkgReal)
326
+ continue;
327
+ seen.add(real);
328
+ try {
329
+ await swapDist(sourceDistDir, target.packageDir);
330
+ results.push({ role: target.role, packageDir: target.packageDir, ok: true });
331
+ logger.info('framework sync: dist swapped into live location', {
332
+ meta: { packageName, role: target.role, packageDir: target.packageDir },
333
+ });
334
+ }
335
+ catch (err) {
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ results.push({ role: target.role, packageDir: target.packageDir, ok: false, error: message });
338
+ logger.error('framework sync: dist swap FAILED (live dir left intact)', {
339
+ meta: { packageName, role: target.role, packageDir: target.packageDir, error: message },
340
+ });
341
+ }
342
+ }
343
+ return { results };
344
+ }
@@ -55,7 +55,9 @@ const path = __importStar(require("node:path"));
55
55
  const index_js_1 = require("../../server-root/index.js");
56
56
  const system_exec_npm_js_1 = require("./system-exec-npm.js");
57
57
  const system_ensure_prebuilds_js_1 = require("./system-ensure-prebuilds.js");
58
+ const update_availability_emitter_js_1 = require("../update-availability-emitter.js");
58
59
  class ServerUpdateService extends index_js_1.RootUpdateService {
60
+ updateAvailability;
59
61
  constructor(options) {
60
62
  const dataDir = path.resolve(options.dataDir ??
61
63
  options.env?.['CAMSTACK_DATA'] ??
@@ -90,6 +92,29 @@ class ServerUpdateService extends index_js_1.RootUpdateService {
90
92
  env: options.env,
91
93
  now: options.now,
92
94
  });
95
+ this.updateAvailability =
96
+ options.eventBus !== undefined
97
+ ? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, {
98
+ type: 'core',
99
+ id: 'server-update-service',
100
+ })
101
+ : null;
102
+ }
103
+ async checkServerUpdate() {
104
+ const result = await super.checkServerUpdate();
105
+ if (result.error !== null)
106
+ return result;
107
+ this.updateAvailability?.publishSnapshot('server', result.updateAvailable && result.runningVersion !== null && result.latestVersion !== null
108
+ ? [
109
+ {
110
+ target: 'server',
111
+ packageName: result.packageName,
112
+ currentVersion: result.runningVersion,
113
+ latestVersion: result.latestVersion,
114
+ },
115
+ ]
116
+ : []);
117
+ return result;
93
118
  }
94
119
  }
95
120
  exports.ServerUpdateService = ServerUpdateService;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UpdateAvailabilityEmitter = void 0;
4
+ const types_1 = require("@camstack/types");
5
+ /**
6
+ * Emits update availability only when a successful check changes the observed
7
+ * `(currentVersion, latestVersion)` pair. A successful empty snapshot clears
8
+ * prior state; failed checks should not call this method.
9
+ */
10
+ class UpdateAvailabilityEmitter {
11
+ eventBus;
12
+ source;
13
+ signatures = new Map();
14
+ constructor(eventBus, source) {
15
+ this.eventBus = eventBus;
16
+ this.source = source;
17
+ }
18
+ publishSnapshot(target, candidates, nodeId) {
19
+ const scope = nodeId ?? 'hub';
20
+ const present = new Set();
21
+ for (const candidate of candidates) {
22
+ if (candidate.target !== target)
23
+ continue;
24
+ const key = this.key(candidate);
25
+ present.add(key);
26
+ this.publish(candidate);
27
+ }
28
+ for (const key of this.signatures.keys()) {
29
+ if (key.startsWith(`${target}:${scope}:`) && !present.has(key)) {
30
+ this.signatures.delete(key);
31
+ }
32
+ }
33
+ }
34
+ /** Publish a partial candidate set without treating omissions as up-to-date. */
35
+ publishCandidates(candidates) {
36
+ for (const candidate of candidates)
37
+ this.publish(candidate);
38
+ }
39
+ publish(candidate) {
40
+ const key = this.key(candidate);
41
+ const signature = `${candidate.currentVersion}->${candidate.latestVersion}`;
42
+ if (this.signatures.get(key) === signature)
43
+ return;
44
+ this.signatures.set(key, signature);
45
+ this.eventBus.emit({
46
+ id: `update.available:${key}:${signature}`,
47
+ timestamp: new Date(),
48
+ source: this.source,
49
+ category: types_1.EventCategory.UpdateAvailable,
50
+ data: candidate,
51
+ });
52
+ }
53
+ key(candidate) {
54
+ return `${candidate.target}:${candidate.nodeId ?? 'hub'}:${candidate.packageName}`;
55
+ }
56
+ }
57
+ exports.UpdateAvailabilityEmitter = UpdateAvailabilityEmitter;