Files
DevRunbook-Public/packages/application/src/playbooks/private-playbook-publication.ts
T
DevRunbook release export cfd2804e27
Managed validation / full (push) Successful in 3m18s
Publish DevRunbook source
2026-09-03 04:09:17 +02:00

213 lines
6.3 KiB
TypeScript

import { DomainError } from '@devrunbook/domain'
import {
authorizeWorkspaceAction,
type AuthenticatedActor,
type WorkspaceAuthorizationLookup,
} from '../auth/workspace-authorization'
import {
assessLifecycleEvidence,
type LifecycleEvidence,
type LifecycleEvidencePolicy,
type PlaybookLifecycle,
} from '../quality/static-quality-evaluation'
import {
formatPrivatePlaybookDraftEtag,
parsePrivatePlaybookDraftEtag,
type PrivatePlaybookDraft,
} from './private-playbook-drafts'
const semverPattern =
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u
export interface PrivatePlaybookPublicationCandidate {
readonly draft: PrivatePlaybookDraft
/** Trusted evidence assembled from persisted validation and evaluation rows. */
readonly evidence: LifecycleEvidence
readonly policy: LifecycleEvidencePolicy
}
export interface PrivatePlaybookPublicationStore {
findPublicationCandidate(
workspaceId: string,
versionId: string,
): Promise<PrivatePlaybookPublicationCandidate | null>
publishDraft(request: {
readonly workspaceId: string
readonly versionId: string
readonly publishedBy: string
readonly expectedRevision: number
readonly expectedDigest: string
readonly lifecycle: Exclude<PlaybookLifecycle, 'draft' | 'battle-tested'>
readonly now: Date
}): Promise<PrivatePlaybookDraft | null>
createNextDraft(request: {
readonly workspaceId: string
readonly sourceVersionId: string
readonly semanticVersion: string
readonly createdBy: string
readonly now: Date
}): Promise<PrivatePlaybookDraft | null>
}
export interface PrivatePlaybookPublicationDependencies {
readonly authorization: WorkspaceAuthorizationLookup
readonly store: PrivatePlaybookPublicationStore
readonly now: () => Date
}
export interface PrivatePlaybookPublicationRequest {
readonly actor: AuthenticatedActor | null
readonly workspaceId: string
readonly versionId: string
readonly expectedEtag: string
readonly lifecycle: Exclude<PlaybookLifecycle, 'draft' | 'battle-tested'>
}
function conflict(message: string, details?: Record<string, unknown>): never {
throw new DomainError('private_playbook_publish_conflict', message, details)
}
function notFound(): never {
throw new DomainError(
'private_playbook_not_found',
'Private playbook not found',
)
}
async function authorizeWrite(
dependencies: PrivatePlaybookPublicationDependencies,
actor: AuthenticatedActor | null,
workspaceId: string,
): Promise<string> {
const context = await authorizeWorkspaceAction(dependencies.authorization, {
actor,
workspaceId,
action: 'write',
})
return context.userId
}
function hasChangelog(draft: PrivatePlaybookDraft): boolean {
return draft.files.some(
(file) => file.role === 'changelog' && file.content.byteLength > 0,
)
}
export async function publishPrivatePlaybookVersion(
dependencies: PrivatePlaybookPublicationDependencies,
request: PrivatePlaybookPublicationRequest,
): Promise<{ readonly version: PrivatePlaybookDraft; readonly etag: string }> {
const publishedBy = await authorizeWrite(
dependencies,
request.actor,
request.workspaceId,
)
const expected = parsePrivatePlaybookDraftEtag(request.expectedEtag)
const candidate = await dependencies.store.findPublicationCandidate(
request.workspaceId,
request.versionId,
)
if (!candidate) notFound()
if (candidate.draft.publishedAt !== null) {
conflict('Published playbook versions are immutable; create a new version.')
}
if (
candidate.draft.draftRevision !== expected.revision ||
candidate.draft.draftDigest !== expected.digest
) {
conflict('The draft changed since it was reviewed.', {
currentEtag: formatPrivatePlaybookDraftEtag(
candidate.draft.draftRevision,
candidate.draft.draftDigest,
),
})
}
if (!hasChangelog(candidate.draft)) {
throw new DomainError(
'private_playbook_changelog_required',
'A non-empty changelog is required before publication',
)
}
const assessment = assessLifecycleEvidence(
request.lifecycle,
candidate.evidence,
candidate.policy,
dependencies.now(),
)
if (!assessment.eligible) {
throw new DomainError(
'private_playbook_quality_evidence_insufficient',
'The requested lifecycle exceeds current quality evidence',
{ requirements: assessment.requirements, findings: assessment.findings },
)
}
const version = await dependencies.store.publishDraft({
workspaceId: request.workspaceId,
versionId: request.versionId,
publishedBy,
expectedRevision: expected.revision,
expectedDigest: expected.digest,
lifecycle: request.lifecycle,
now: dependencies.now(),
})
if (!version) notFound()
return {
version,
etag: formatPrivatePlaybookDraftEtag(
version.draftRevision,
version.draftDigest,
),
}
}
export async function createNextPrivatePlaybookVersion(
dependencies: PrivatePlaybookPublicationDependencies,
request: {
readonly actor: AuthenticatedActor | null
readonly workspaceId: string
readonly sourceVersionId: string
readonly semanticVersion: string
},
): Promise<{ readonly draft: PrivatePlaybookDraft; readonly etag: string }> {
const createdBy = await authorizeWrite(
dependencies,
request.actor,
request.workspaceId,
)
if (!semverPattern.test(request.semanticVersion)) {
throw new DomainError(
'private_playbook_version_invalid',
'A valid Semantic Version is required',
)
}
const source = await dependencies.store.findPublicationCandidate(
request.workspaceId,
request.sourceVersionId,
)
if (!source) notFound()
if (source.draft.publishedAt === null) {
conflict('Only an immutable published version can be used as the source.')
}
if (source.draft.semanticVersion === request.semanticVersion) {
conflict('The new version must use a different Semantic Version.')
}
const draft = await dependencies.store.createNextDraft({
workspaceId: request.workspaceId,
sourceVersionId: request.sourceVersionId,
semanticVersion: request.semanticVersion,
createdBy,
now: dependencies.now(),
})
if (!draft) notFound()
return {
draft,
etag: formatPrivatePlaybookDraftEtag(
draft.draftRevision,
draft.draftDigest,
),
}
}