UNPKG

zephyr-agent

Version:
298 lines (297 loc) • 13.2 kB
import "node:module"; import { zeBuildAssets } from "../lib/transformers/ze-build-assets.mjs"; import { normalizeSnapshotAssetPath } from "../lib/transformers/ze-snapshot-asset-path.mjs"; class BuildSessionStateError extends Error { constructor(message){ super(message); this.name = 'BuildSessionStateError'; } } class BuildSessionNotReadyError extends BuildSessionStateError { readiness; constructor(readiness){ const pending = [ ...readiness.pendingParticipants.map((name)=>`participant:${name}`), ...readiness.pendingPostprocessors.map((name)=>`postprocessor:${name}`) ]; super(`Build session is not ready; waiting for ${pending.join(', ')}`); this.name = 'BuildSessionNotReadyError'; this.readiness = readiness; } } class BuildSessionAssetCollisionError extends Error { path; existingHash; incomingHash; constructor(path, existingHash, incomingHash){ super(`Conflicting assets were contributed for "${path}": ${existingHash} and ${incomingHash}`); this.name = 'BuildSessionAssetCollisionError'; this.path = path; this.existingHash = existingHash; this.incomingHash = incomingHash; } } class BuildSessionAbortedError extends Error { reason; constructor(reason){ const detail = reason instanceof Error ? reason.message : String(reason ?? 'aborted'); super(`Build session was aborted: ${detail}`); this.name = 'BuildSessionAbortedError'; this.reason = reason; } } class BuildParticipantFailedError extends Error { participant; cause; constructor(participant, cause){ const detail = cause instanceof Error ? cause.message : String(cause); super(`Build participant "${participant}" failed: ${detail}`); this.name = 'BuildParticipantFailedError'; this.participant = participant; this.cause = cause; } } class BuildSessionRollbackError extends Error { buildError; rollbackError; constructor(buildError, rollbackError){ super(`Build session failed and its rollback also failed: ${rollbackError.message}`); this.name = 'BuildSessionRollbackError'; this.buildError = buildError; this.rollbackError = rollbackError; } } function createBarrier() { let resolve; let reject; const promise = new Promise((res, rej)=>{ resolve = res; reject = rej; }); promise.catch(()=>void 0); return { promise, resolve, reject }; } function assertNonEmpty(value, description) { if (!value.trim()) throw new BuildSessionStateError(`${description} must not be empty`); } function sameAsset(existing, incoming) { return normalizeAssetPath(existing.path) === normalizeAssetPath(incoming.path) && existing.hash === incoming.hash && existing.size === incoming.size; } function normalizeAssetPath(assetPath) { try { return normalizeSnapshotAssetPath(assetPath); } catch (error) { throw new BuildSessionStateError(error instanceof Error ? error.message : String(error)); } } function cloneAssetsMap(assetsMap) { return Object.fromEntries(Object.values(assetsMap).map((value)=>{ const normalizedPath = normalizeAssetPath(value.path); const normalizedAsset = normalizedPath === value.path ? { ...value } : zeBuildAssets({ filepath: normalizedPath, content: value.buffer }); return [ normalizedAsset.hash, Object.freeze(normalizedAsset) ]; })); } class BuildSession { identity; participants = new Map(); postprocessors = new Set(); completedParticipants = new Set(); completedPostprocessors = new Set(); contributions = new Map(); callbacks; barrier = createBarrier(); state = 'collecting'; terminalError = null; failureNotified = false; sealedPublication = null; publishPromise = null; constructor(identity, participants, postprocessors, callbacks){ this.identity = Object.freeze({ ...identity }); this.callbacks = callbacks; for (const participant of participants){ assertNonEmpty(participant.name, 'Participant name'); if (this.participants.has(participant.name)) throw new BuildSessionStateError(`Build participant "${participant.name}" was registered more than once`); this.participants.set(participant.name, { name: participant.name, role: participant.role ?? participant.name, required: participant.required ?? true }); } for (const postprocessor of postprocessors){ assertNonEmpty(postprocessor, 'Postprocessor name'); if (this.postprocessors.has(postprocessor)) throw new BuildSessionStateError(`Build postprocessor "${postprocessor}" was registered more than once`); this.postprocessors.add(postprocessor); } this.resolveBarrierWhenReady(); } get status() { return this.state; } get failure() { return this.terminalError; } get readiness() { const pendingParticipants = [ ...this.participants.values() ].filter((participant)=>participant.required && !this.completedParticipants.has(participant.name)).map((participant)=>participant.name); const pendingPostprocessors = [ ...this.postprocessors ].filter((postprocessor)=>!this.completedPostprocessors.has(postprocessor)); return Object.freeze({ ready: 0 === pendingParticipants.length && 0 === pendingPostprocessors.length, pendingParticipants: Object.freeze(pendingParticipants), pendingPostprocessors: Object.freeze(pendingPostprocessors) }); } get isTerminal() { return 'published' === this.state || 'failed' === this.state || 'aborted' === this.state; } contribute(contribution) { this.assertCollecting('contribute assets'); const participant = this.participants.get(contribution.participant); if (!participant) throw new BuildSessionStateError(`Unknown build participant "${contribution.participant}"`); if (this.completedParticipants.has(contribution.participant)) throw new BuildSessionStateError(`Build participant "${contribution.participant}" is already complete`); assertNonEmpty(contribution.key, 'Contribution key'); let participantContributions = this.contributions.get(contribution.participant); if (!participantContributions) { participantContributions = new Map(); this.contributions.set(contribution.participant, participantContributions); } participantContributions.set(contribution.key, { ...contribution, assetsMap: cloneAssetsMap(contribution.assetsMap) }); } completeParticipant(participant) { this.assertCollecting('complete a participant'); if (!this.participants.has(participant)) throw new BuildSessionStateError(`Unknown build participant "${participant}"`); this.completedParticipants.add(participant); this.resolveBarrierWhenReady(); } completePostprocess(postprocessor) { this.assertCollecting('complete postprocessing'); if (!this.postprocessors.has(postprocessor)) throw new BuildSessionStateError(`Unknown build postprocessor "${postprocessor}"`); this.completedPostprocessors.add(postprocessor); this.resolveBarrierWhenReady(); } fail(participant, cause) { if (!this.participants.has(participant)) throw new BuildSessionStateError(`Unknown build participant "${participant}"`); if (this.terminalError instanceof BuildSessionRollbackError) throw this.terminalError; if (this.isTerminal) return; if ('publishing' === this.state) throw new BuildSessionStateError('Cannot fail a build participant after its publisher has started'); this.rejectSession(new BuildParticipantFailedError(participant, cause), 'failed'); } abort(reason) { if (this.terminalError instanceof BuildSessionRollbackError) throw this.terminalError; if (this.isTerminal) return; if ('publishing' === this.state) throw new BuildSessionStateError('Cannot abort a build session after its publisher has started'); this.rejectSession(new BuildSessionAbortedError(reason), 'aborted'); } seal() { if (this.sealedPublication) return this.sealedPublication; if (this.terminalError) throw this.terminalError; if ('collecting' !== this.state) throw new BuildSessionStateError(`Cannot seal a build session while it is ${this.state}`); const readiness = this.readiness; if (!readiness.ready) throw new BuildSessionNotReadyError(readiness); this.sealedPublication = this.createPublication(); this.state = 'sealed'; return this.sealedPublication; } publish() { if (!this.publishPromise) this.publishPromise = this.runPublish(); return this.publishPromise; } async runPublish() { try { await this.barrier.promise; if (this.terminalError) throw this.terminalError; const publication = this.seal(); this.state = 'publishing'; await this.callbacks.prepare?.(this.identity); const result = await this.callbacks.publish(publication); await this.callbacks.finish?.(publication, result); this.state = 'published'; return result; } catch (error) { if ('aborted' !== this.state) this.state = 'failed'; if (!this.terminalError) this.terminalError = error instanceof Error ? error : new Error(String(error)); this.notifyFailure(); throw this.terminalError; } } createPublication() { const assetsMap = {}; const assetsByPath = new Map(); const assetsByHash = new Map(); const publishedContributions = []; for (const participant of this.participants.values()){ const participantContributions = this.contributions.get(participant.name); if (participantContributions) for (const contribution of participantContributions.values()){ const publishedContribution = { ...contribution, role: participant.role, assetsMap: Object.freeze({ ...contribution.assetsMap }) }; publishedContributions.push(Object.freeze(publishedContribution)); for (const incoming of Object.values(contribution.assetsMap)){ const normalizedPath = normalizeAssetPath(incoming.path); const samePath = assetsByPath.get(normalizedPath); if (samePath && !sameAsset(samePath, incoming)) throw new BuildSessionAssetCollisionError(normalizedPath, samePath.hash, incoming.hash); const sameHash = assetsByHash.get(incoming.hash); if (sameHash && !sameAsset(sameHash, incoming)) throw new BuildSessionAssetCollisionError(incoming.path, sameHash.hash, incoming.hash); if (!samePath && !sameHash) { assetsByPath.set(normalizedPath, incoming); assetsByHash.set(incoming.hash, incoming); assetsMap[incoming.hash] = incoming; } } } } return Object.freeze({ identity: this.identity, assetsMap: Object.freeze(assetsMap), contributions: Object.freeze(publishedContributions) }); } resolveBarrierWhenReady() { if ('collecting' === this.state && this.readiness.ready) this.barrier.resolve(); } rejectSession(error, status) { this.terminalError = error; this.state = status; this.notifyFailure(); this.barrier.reject(this.terminalError); if (this.terminalError instanceof BuildSessionRollbackError) throw this.terminalError; } notifyFailure() { if (this.failureNotified || !this.terminalError) return; this.failureNotified = true; try { this.callbacks.onFailure?.(this.identity, this.terminalError); } catch (cleanupError) { const normalizedCleanupError = cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)); this.terminalError = new BuildSessionRollbackError(this.terminalError, normalizedCleanupError); } } assertCollecting(action) { if ('collecting' !== this.state) throw new BuildSessionStateError(`Cannot ${action} while build session is ${this.state}`); } } export { BuildParticipantFailedError, BuildSession, BuildSessionAbortedError, BuildSessionAssetCollisionError, BuildSessionNotReadyError, BuildSessionRollbackError, BuildSessionStateError }; //# sourceMappingURL=build-session.mjs.map