diff --git a/app/api/definitions/components/release-tracks.yml b/app/api/definitions/components/release-tracks.yml index dc23c3f2..225f862a 100644 --- a/app/api/definitions/components/release-tracks.yml +++ b/app/api/definitions/components/release-tracks.yml @@ -52,6 +52,18 @@ components: type: object description: 'A snapshot document for a release track, containing versioned member objects and workflow tiers' properties: + draft_cleanup: + $ref: '#/components/schemas/draft-cleanup-result' + snapshot_count: + type: integer + minimum: 0 + readOnly: true + description: Current registry total, not the size of a filtered history page. + tagged_release_count: + type: integer + minimum: 0 + readOnly: true + description: Current registry release total for virtual Workbench responses. alias: type: string nullable: true @@ -1005,6 +1017,146 @@ components: type: string format: date-time + draft-retention: + type: object + additionalProperties: false + required: [max_drafts] + description: | + Virtual tracks only. A manual materialization may supply this policy + for that operation only; recurring policy belongs inside the cron schedule. + Missing policy or max_drafts null disables cleanup. All untagged creation + causes count across the track. Tags and protected sources always survive. + Saving a cron policy creates no draft and deletes nothing. + properties: + max_drafts: + type: integer + nullable: true + minimum: 1 + maximum: 9007199254740991 + + draft-squash: + type: object + readOnly: true + required: [lower_bound, upper_bound, eligible_count, protected_count, fingerprint] + description: | + Read-only preview. Bounds are exclusive snapshot modified timestamps, + not tagging times. Null lower_bound means the first release. + Submit the fingerprint to the exact upper_bound snapshot endpoint. + properties: + lower_bound: + type: string + format: date-time + nullable: true + upper_bound: + type: string + format: date-time + eligible_count: + type: integer + minimum: 0 + protected_count: + type: integer + minimum: 0 + fingerprint: + type: string + + draft-cleanup-result: + type: object + readOnly: true + required: [operation_id, status, kind, eligible_count, deleted_count, protected_count] + description: | + Nonpersistent operation response, backed by a durable audit intent. + A successful snapshot or release remains committed when cleanup is + pending or failed. Retry this operation, never repeat release POST. + properties: + operation_id: + type: string + format: uuid + status: + type: string + enum: [pending, completed, failed] + kind: + type: string + enum: [retention, squash] + eligible_count: + type: integer + minimum: 0 + deleted_count: + type: integer + minimum: 0 + protected_count: + type: integer + minimum: 0 + target_modified: + type: string + format: date-time + release_committed: + type: boolean + error: + type: string + + release-request: + type: object + additionalProperties: false + properties: + increment: + type: string + enum: [major, minor] + description: Mutually exclusive with version. + version: + type: string + pattern: '^\d+\.\d+$' + description: + type: string + maxLength: 4000 + squash_drafts: + type: boolean + default: false + description: | + Administrator-only virtual-track opt-in. Finish publication before + deleting earlier eligible drafts in the reviewed interval. + squash_fingerprint: + type: string + minLength: 1 + maxLength: 128 + description: Required when squash_drafts is true; stale previews return 409 before tagging. + + release-summary: + type: object + required: + [track_id, type, source_snapshot_modified, release_snapshot_modified, version, releasable] + properties: + track_id: + type: string + type: + type: string + enum: [standard, virtual] + source_snapshot_modified: + type: string + format: date-time + release_snapshot_modified: + type: string + format: date-time + version: + type: string + releasable: + type: boolean + draft_squash: + $ref: '#/components/schemas/draft-squash' + + draft-cleanup-error: + type: object + description: Release publication did not complete. Inspect release_committed and retry the existing cleanup operation to repair partial publication. + properties: + message: + type: string + operation_id: + type: string + format: uuid + release_committed: + type: boolean + draft_cleanup: + $ref: '#/components/schemas/draft-cleanup-result' + snapshot-schedule: description: | Virtual snapshot-creation schedule. Cron and explicit dates are @@ -1034,6 +1186,8 @@ components: type: string description: 'Five-field UTC cron expression' example: '0 0 1 1,7 *' + draft_retention: + $ref: '#/components/schemas/draft-retention' - type: object additionalProperties: false required: diff --git a/app/api/definitions/openapi.yml b/app/api/definitions/openapi.yml index 6f70ed80..c3d25c7e 100644 --- a/app/api/definitions/openapi.yml +++ b/app/api/definitions/openapi.yml @@ -388,6 +388,12 @@ paths: /api/release-tracks/{id}/virtual/schedule: $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1schedule' + /api/release-tracks/{id}/virtual/draft-cleanup: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1draft-cleanup' + + /api/release-tracks/{id}/virtual/draft-cleanup/{operationId}/retry: + $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1draft-cleanup~1{operationId}~1retry' + /api/release-tracks/{id}/virtual/snapshots/create: $ref: 'paths/release-tracks-paths.yml#/paths/~1api~1release-tracks~1{id}~1virtual~1snapshots~1create' diff --git a/app/api/definitions/paths/release-tracks-paths.yml b/app/api/definitions/paths/release-tracks-paths.yml index d102b881..7aea0753 100644 --- a/app/api/definitions/paths/release-tracks-paths.yml +++ b/app/api/definitions/paths/release-tracks-paths.yml @@ -241,9 +241,22 @@ paths: draft snapshot and is limited to 4000 characters. `alias` is an optional URL-safe slug (2-64 lowercase letters, digits, and hyphens, unique across tracks) accepted wherever the track ID is. + Administrators may provide snapshot_schedule.draft_retention in the + cron variant only, with max_drafts a positive safe integer or null. + This applies only to future recurring materializations. Initial track + creation never deletes history; no top-level retention policy is accepted. tags: - 'Release Tracks' # Request body validation moved to Zod in controller + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + snapshot_schedule: + $ref: '../components/release-tracks.yml#/components/schemas/snapshot-schedule' responses: '201': description: 'Release track created successfully' @@ -253,6 +266,8 @@ paths: $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' '400': description: 'Invalid request parameters' + '403': + description: 'Only administrators may supply a retention policy' /api/release-tracks/new-from-bundle: post: @@ -402,6 +417,9 @@ paths: Relative increments use the latest tagged release. A historical standard draft is published as a new release at the current time and must follow the current version lineage. + Administrators may opt into virtual-only squash_drafts with the exact + preview squash_fingerprint. Stale previews fail before tagging. Successful + publication may return pending/failed draft_cleanup; use cleanup retry. tags: - 'Release Tracks' parameters: @@ -420,21 +438,28 @@ paths: content: application/json: schema: - type: object - additionalProperties: true + $ref: '../components/release-tracks.yml#/components/schemas/release-request' responses: '200': description: 'Snapshot released successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' + '403': + description: 'Only administrators may opt into draft squash' '400': description: 'Invalid release request' '409': description: 'Already released, another release is in progress, conflicting snapshot, or missing persisted primary revisions' '500': - description: 'The release may be tagged, but durable membership-protection reconciliation failed' + description: 'Publication or reconciliation failed; squash errors include operation_id and release_committed for cleanup-only recovery' content: application/json: schema: - $ref: '../components/release-tracks.yml#/components/schemas/release-track-reconciliation-error' + anyOf: + - $ref: '../components/release-tracks.yml#/components/schemas/release-track-reconciliation-error' + - $ref: '../components/release-tracks.yml#/components/schemas/draft-cleanup-error' /api/release-tracks/{id}/snapshots/latest/release/preview: get: @@ -497,6 +522,12 @@ paths: responses: '200': description: 'Release preview generated' + content: + application/json: + schema: + anyOf: + - $ref: '../components/release-tracks.yml#/components/schemas/release-summary' + - $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' '409': description: 'Virtual draft is unmaterialized, release is blocked by a conflict, or persisted primary revisions are missing' '501': @@ -763,6 +794,10 @@ paths: responses: '200': description: 'Configuration updated successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' # ============================================================================= # Object version history @@ -859,6 +894,10 @@ paths: responses: '200': description: 'Composition updated successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' '400': description: 'Track is not virtual or composition is invalid' @@ -871,8 +910,12 @@ paths: track without creating or mutating a content snapshot. Request bodies are strictly validated via Zod: manual accepts only mode, cron requires one five-field UTC expression, and dates requires at least one ISO UTC - timestamp. Scheduler reconciliation observes the replacement on its - next configured pass. + timestamp. Only cron accepts draft_retention, with max_drafts a positive + safe integer or null. Changing that threshold while remaining in cron + requires an administrator, including disabling retention. Editors may + preserve its value while editing cron timing, or switch away from cron. + Policy changes delete nothing. Scheduler reconciliation observes the + replacement on its next configured pass. tags: - 'Release Tracks' parameters: @@ -901,9 +944,80 @@ paths: $ref: '../components/release-tracks.yml#/components/schemas/snapshot-schedule' '400': description: 'Track is not virtual or schedule is invalid' + '403': + description: 'Changing cron retention requires an administrator' '404': description: 'Release track not found' + /api/release-tracks/{id}/virtual/draft-cleanup: + get: + summary: Discover pending or failed virtual draft cleanup + operationId: release-tracks-draft-cleanup-list + description: Returns the most recent 25 pending/failed audit-bound operations, including operations whose HTTP response was lost. + tags: ['Release Tracks'] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Recent incomplete cleanup operations + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: array + items: + $ref: '../components/release-tracks.yml#/components/schemas/draft-cleanup-result' + '400': + description: Nonvirtual track + '404': + description: Track not found + + /api/release-tracks/{id}/virtual/draft-cleanup/{operationId}/retry: + post: + summary: Repair an existing draft cleanup operation + operationId: release-tracks-draft-cleanup-retry + description: | + Administrator-only cleanup recovery. Accepts no fresh selector or release + request. Repairs partial publication for the original release event and + never tags again. Rechecks protected sources, new tags, and live retention + policy without widening the recorded interval. Disabling retention permits + orphan/counter repair but no additional draft deletion. + tags: ['Release Tracks'] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: operationId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Current operation result, including any repair failure + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/draft-cleanup-result' + '400': + description: Nonvirtual track + '403': + description: Administrator required + '404': + description: Track or existing cleanup intent not found + '409': + description: Another lifecycle operation holds the track lock + /api/release-tracks/{id}/virtual/snapshots/create: post: summary: 'Create a virtual track snapshot' @@ -924,6 +1038,10 @@ paths: Component release locks are held from resolution through persistence. A concurrent release, rollback, retag, or materialization sharing a component may return 409; retry after the competing operation finishes. + Administrators may supply draft_retention for this operation only. + Omitted policy, null, or max_drafts null means no cleanup. Manual + requests never inherit or persist recurring policy, and supplied + scheduled_materialization metadata cannot invoke cron retention. tags: - 'Release Tracks' parameters: @@ -943,13 +1061,30 @@ paths: type: string maxLength: 4000 description: 'Optional notes for this snapshot' + draft_retention: + type: object + nullable: true + additionalProperties: false + required: [max_drafts] + properties: + max_drafts: + type: integer + nullable: true + minimum: 1 + maximum: 9007199254740991 scheduled_materialization: $ref: '../components/release-tracks.yml#/components/schemas/scheduled-materialization' responses: '201': description: 'Virtual snapshot created successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' '400': description: 'Track is not virtual or cannot resolve its composition' + '403': + description: 'Supplying manual draft retention requires an administrator' '409': description: 'A component release lock is busy or a resolved snapshot references missing primary revisions' content: @@ -1022,6 +1157,11 @@ paths: summaries also expose bundle_id and bundle_hashes. Standard releases with a preserved source draft also expose release_source_modified, allowing clients to hide that retained draft. + Counts describe every snapshot matching the tagged filter before + pagination, including when the requested page is empty. Latest snapshot + identities are unfiltered live track metadata, independent of the page + and tagged filter. The latest tagged identity is chronological, not + the highest semantic version. tags: - 'Release Tracks' parameters: @@ -1063,6 +1203,9 @@ paths: required: - data - pagination + - counts + - latest_snapshot_modified + - latest_tagged_snapshot_modified properties: data: type: array @@ -1070,6 +1213,34 @@ paths: oneOf: - $ref: '../components/release-tracks.yml#/components/schemas/standard-snapshot-summary' - $ref: '../components/release-tracks.yml#/components/schemas/virtual-snapshot-summary' + counts: + type: object + description: 'Counts across all snapshots matching the tagged filter, before pagination' + required: + - tagged + - drafts + - total + properties: + tagged: + type: integer + minimum: 0 + drafts: + type: integer + minimum: 0 + total: + type: integer + minimum: 0 + description: 'Sum of tagged and drafts; equals pagination.total' + latest_snapshot_modified: + type: string + format: date-time + nullable: true + description: 'Latest snapshot identity for the track, independent of filtering and pagination; null when absent' + latest_tagged_snapshot_modified: + type: string + format: date-time + nullable: true + description: 'Chronologically latest tagged snapshot identity for the track, independent of filtering and pagination; null when absent' pagination: type: object required: @@ -1463,6 +1634,10 @@ paths: selected draft is historical. An optional `description` is stored as snapshot-local release notes. Relative increments use the latest tagged release. + Explicit virtual squash requires an administrator and the preview + squash_fingerprint. Its exclusive chronological interval never includes + the target, a newer draft, or a tagged release. Publication completes + before cleanup; failures expose an existing cleanup operation for repair. tags: - 'Release Tracks' parameters: @@ -1486,21 +1661,28 @@ paths: content: application/json: schema: - type: object - additionalProperties: true + $ref: '../components/release-tracks.yml#/components/schemas/release-request' responses: '200': description: 'Snapshot released successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' + '403': + description: 'Only administrators may opt into draft squash' '400': description: 'Invalid release request' '409': description: 'Already released, another release is in progress, conflicting snapshot, or missing persisted primary revisions' '500': - description: 'The release may be tagged, but durable membership-protection reconciliation failed' + description: 'Publication or reconciliation failed; squash errors include operation_id and release_committed for cleanup-only recovery' content: application/json: schema: - $ref: '../components/release-tracks.yml#/components/schemas/release-track-reconciliation-error' + anyOf: + - $ref: '../components/release-tracks.yml#/components/schemas/release-track-reconciliation-error' + - $ref: '../components/release-tracks.yml#/components/schemas/draft-cleanup-error' put: summary: 'Change a release version' operationId: 'release-tracks-release-retag' @@ -1615,6 +1797,12 @@ paths: responses: '200': description: 'Release preview generated' + content: + application/json: + schema: + anyOf: + - $ref: '../components/release-tracks.yml#/components/schemas/release-summary' + - $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' '409': description: 'Release is blocked because the draft is unmaterialized, conflicting, or references missing primary revisions' '501': diff --git a/app/controllers/release-tracks-controller.js b/app/controllers/release-tracks-controller.js index ee9b1b41..83ccd372 100644 --- a/app/controllers/release-tracks-controller.js +++ b/app/controllers/release-tracks-controller.js @@ -373,6 +373,7 @@ exports.createReleaseTrack = async function createReleaseTrack(req, res, next) { const result = await releaseTracksService.createTrack({ ...bodyResult.data, userAccountId: req.user?.userAccountId, + actor: destructiveActor(req), }); logger.debug(`Success: Created release track "${bodyResult.data.name}"`); return res.status(201).send(result); @@ -527,6 +528,7 @@ exports.releaseLatest = async function releaseLatest(req, res, next) { const result = await releaseTracksService.releaseLatest(req.params.id, { ...bodyResult.data, userAccountId: req.user?.userAccountId, + actor: destructiveActor(req), }); logger.debug(`Success: Released latest snapshot for track ${req.params.id}`); return res.status(200).send(result); @@ -626,6 +628,7 @@ exports.releaseByModified = async function releaseByModified(req, res, next) { { ...bodyResult.data, userAccountId: req.user?.userAccountId, + actor: destructiveActor(req), }, ); logger.debug(`Success: Released snapshot ${req.params.modified}`); @@ -1085,7 +1088,11 @@ exports.updateSchedule = async function updateSchedule(req, res, next) { ); } - const result = await releaseTracksService.updateSchedule(req.params.id, bodyResult.data); + const result = await releaseTracksService.updateSchedule( + req.params.id, + bodyResult.data, + destructiveActor(req), + ); logger.debug(`Success: Updated snapshot schedule for track ${req.params.id}`); return res.status(200).send(result); } catch (err) { @@ -1094,6 +1101,30 @@ exports.updateSchedule = async function updateSchedule(req, res, next) { } }; +exports.listDraftCleanup = async function listDraftCleanup(req, res, next) { + try { + return res.status(200).send(await releaseTracksService.listDraftCleanup(req.params.id)); + } catch (error) { + return next(error); + } +}; + +exports.retryDraftCleanup = async function retryDraftCleanup(req, res, next) { + try { + return res + .status(200) + .send( + await releaseTracksService.retryDraftCleanup( + req.params.id, + req.params.operationId, + destructiveActor(req), + ), + ); + } catch (error) { + return next(error); + } +}; + /** POST /api/release-tracks/:id/virtual/snapshots/create */ exports.createVirtualSnapshot = async function createVirtualSnapshot(req, res, next) { try { @@ -1113,6 +1144,7 @@ exports.createVirtualSnapshot = async function createVirtualSnapshot(req, res, n ...snapshotOptions, scheduledMaterialization, userAccountId: req.user?.userAccountId, + actor: destructiveActor(req), }); logger.debug(`Success: Created virtual snapshot for track ${req.params.id}`); return res.status(201).send(result); diff --git a/app/exceptions/index.js b/app/exceptions/index.js index 2f672542..e9fceb52 100644 --- a/app/exceptions/index.js +++ b/app/exceptions/index.js @@ -335,6 +335,15 @@ class ReleaseTrackReconciliationError extends CustomError { } } +class ReleasePublicationError extends CustomError { + constructor(options) { + super( + 'Release publication did not complete; inspect or retry the existing cleanup operation', + options, + ); + } +} + class ReleaseTrackAuditError extends CustomError { constructor(trackId, auditEventId, options = {}) { super('Release-track audit recording could not be finalized', { @@ -487,6 +496,7 @@ module.exports = { ReleaseContentIntegrityError, ReleaseTrackReconciliationError, ReleaseTrackAuditError, + ReleasePublicationError, NoTaggedSnapshotsError, InvalidComponentTypeError, VirtualSnapshotNotMaterializedError, diff --git a/app/lib/error-handler.js b/app/lib/error-handler.js index 0d644433..3f98cfed 100644 --- a/app/lib/error-handler.js +++ b/app/lib/error-handler.js @@ -46,6 +46,7 @@ const { ReleaseContentIntegrityError, ReleaseTrackReconciliationError, ReleaseTrackAuditError, + ReleasePublicationError, NoTaggedSnapshotsError, InvalidComponentTypeError, VirtualSnapshotNotMaterializedError, @@ -172,7 +173,8 @@ exports.serviceExceptions = function (err, req, res, next) { err instanceof GenericServiceError || err instanceof DatabaseError || err instanceof ReleaseTrackReconciliationError || - err instanceof ReleaseTrackAuditError + err instanceof ReleaseTrackAuditError || + err instanceof ReleasePublicationError ) { logger.error('Service error: %s', JSON.stringify(buildErrorResponse(err))); return res.status(500).send(buildErrorResponse(err)); diff --git a/app/lib/release-tracks/release-track-schemas.js b/app/lib/release-tracks/release-track-schemas.js index c157d538..0080b2df 100644 --- a/app/lib/release-tracks/release-track-schemas.js +++ b/app/lib/release-tracks/release-track-schemas.js @@ -308,6 +308,10 @@ const updateConfigBodySchema = z.object({ // Request body schemas (used inline by controller handlers) // ============================================================================= +const draftRetentionSchema = z + .object({ max_drafts: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullable() }) + .strict(); + /** POST /release-tracks/new */ const snapshotScheduleSchema = z.discriminatedUnion('mode', [ z @@ -319,6 +323,7 @@ const snapshotScheduleSchema = z.discriminatedUnion('mode', [ .object({ mode: z.literal('cron'), cron: cronSchema, + draft_retention: draftRetentionSchema.optional(), }) .strict(), z @@ -500,6 +505,8 @@ const releaseBodySchema = z increment: releaseIncrementSchema.optional(), version: xMitreVersionSchema.optional(), description: snapshotDescriptionSchema.optional(), + squash_drafts: z.boolean().optional(), + squash_fingerprint: z.string().min(1).max(128).optional(), }) .strict() .refine((value) => !(value.increment && value.version), { @@ -583,6 +590,7 @@ const updateCompositionBodySchema = z const createVirtualSnapshotBodySchema = z .object({ description: snapshotDescriptionSchema.optional(), + draft_retention: draftRetentionSchema.nullable().optional(), scheduled_materialization: scheduledMaterializationSchema.optional(), }) .strict() @@ -711,6 +719,7 @@ module.exports = { componentTrackSchema, compositionSchema, snapshotScheduleSchema, + draftRetentionSchema, scheduledMaterializationSchema, objectRefEntrySchema, promotionConflictsSchema, diff --git a/app/models/release-tracks/release-track-audit-event-model.js b/app/models/release-tracks/release-track-audit-event-model.js index dad63313..57b635fa 100644 --- a/app/models/release-tracks/release-track-audit-event-model.js +++ b/app/models/release-tracks/release-track-audit-event-model.js @@ -9,7 +9,14 @@ const releaseTrackAuditEventSchema = new mongoose.Schema( action: { type: String, required: true, - enum: ['delete_track', 'delete_release', 'retag_release', 'convert_release_to_draft'], + enum: [ + 'delete_track', + 'delete_release', + 'retag_release', + 'convert_release_to_draft', + 'draft_retention', + 'draft_squash', + ], }, track_id: { type: String, required: true, validate: validateTrackId }, status: { @@ -22,6 +29,7 @@ const releaseTrackAuditEventSchema = new mongoose.Schema( confirmation: { type: String, required: true }, request: { type: mongoose.Schema.Types.Mixed, default: {} }, result: { type: mongoose.Schema.Types.Mixed, default: null }, + cleanup: { type: mongoose.Schema.Types.Mixed, default: undefined }, error: { name: String, message: String, diff --git a/app/models/release-tracks/release-track-registry-model.js b/app/models/release-tracks/release-track-registry-model.js index 219b95ea..d5936681 100644 --- a/app/models/release-tracks/release-track-registry-model.js +++ b/app/models/release-tracks/release-track-registry-model.js @@ -22,6 +22,19 @@ const snapshotScheduleDefinition = { type: String, validate: validateCron, }, + draft_retention: { + type: new mongoose.Schema( + { + max_drafts: { + type: Number, + default: null, + validate: (value) => value == null || (Number.isSafeInteger(value) && value > 0), + }, + }, + { _id: false }, + ), + default: undefined, + }, dates: { type: [Date], default: undefined }, }; const snapshotScheduleSchema = new mongoose.Schema(snapshotScheduleDefinition, { _id: false }); diff --git a/app/models/release-tracks/release-track-snapshot-schema.js b/app/models/release-tracks/release-track-snapshot-schema.js index 15c5a2a1..c75b51b6 100644 --- a/app/models/release-tracks/release-track-snapshot-schema.js +++ b/app/models/release-tracks/release-track-snapshot-schema.js @@ -441,6 +441,8 @@ const releaseTrackSnapshotDefinition = { publication: { type: frozenPublicationSchema }, bundle_id: { type: String }, bundle_hashes: { type: bundleHashesSchema }, + // Identifies the original release event across retags, but not rollback/release cycles. + release_event_id: { type: String }, creation_actor: { type: new mongoose.Schema( { diff --git a/app/models/release-tracks/virtual-track-schedule-occurrence-model.js b/app/models/release-tracks/virtual-track-schedule-occurrence-model.js index ce1f9f35..657e1b5d 100644 --- a/app/models/release-tracks/virtual-track-schedule-occurrence-model.js +++ b/app/models/release-tracks/virtual-track-schedule-occurrence-model.js @@ -24,9 +24,11 @@ const virtualTrackScheduleOccurrenceSchema = new mongoose.Schema( }, attempt_count: { type: Number, required: true, default: 0 }, claimed_at: { type: Date, default: null }, + claim_token: { type: String, default: null }, claim_expires_at: { type: Date, default: null }, next_retry_at: { type: Date, default: null }, finished_at: { type: Date, default: null }, + // Durable, write-once receipt: retained even when the snapshot is removed. snapshot_modified: { type: Date, default: null }, last_error: { type: mongoose.Schema.Types.Mixed, default: null }, }, diff --git a/app/repository/release-tracks/release-track-audit-event.repository.js b/app/repository/release-tracks/release-track-audit-event.repository.js index 884e1679..ab57be78 100644 --- a/app/repository/release-tracks/release-track-audit-event.repository.js +++ b/app/repository/release-tracks/release-track-audit-event.repository.js @@ -70,6 +70,46 @@ class ReleaseTrackAuditEventRepository { throw new DatabaseError(repositoryError); } } + async getCleanup(trackId, eventId) { + return ReleaseTrackAuditEvent.findOne({ + track_id: trackId, + event_id: eventId, + action: { $in: ['draft_retention', 'draft_squash'] }, + }) + .lean() + .exec(); + } + + async listCleanup(trackId) { + return ReleaseTrackAuditEvent.find({ + track_id: trackId, + action: { $in: ['draft_retention', 'draft_squash'] }, + status: { $in: ['pending', 'failed'] }, + }) + .sort({ started_at: -1 }) + .limit(25) + .lean() + .exec(); + } + + async saveCleanup(eventId, cleanup, status = 'pending', error = null) { + const event = await ReleaseTrackAuditEvent.findOneAndUpdate( + { event_id: eventId, action: { $in: ['draft_retention', 'draft_squash'] } }, + { + $set: { + cleanup, + status, + error: error + ? { name: error.name || 'Error', message: error.message || String(error) } + : null, + finished_at: status === 'pending' ? null : new Date(), + }, + }, + { new: true, lean: true }, + ).exec(); + if (!event) throw new Error(`Cleanup intent ${eventId} no longer exists`); + return event; + } } module.exports = new ReleaseTrackAuditEventRepository(); diff --git a/app/repository/release-tracks/release-track-dynamic.repository.js b/app/repository/release-tracks/release-track-dynamic.repository.js index 683808fa..495519cb 100644 --- a/app/repository/release-tracks/release-track-dynamic.repository.js +++ b/app/repository/release-tracks/release-track-dynamic.repository.js @@ -1,6 +1,7 @@ 'use strict'; const modelFactory = require('../../models/release-tracks/model-factory'); +const versionUtils = require('../../lib/release-tracks/version-utils'); const { DatabaseError, DuplicateIdError, @@ -23,10 +24,14 @@ class ReleaseTrackDynamicRepository { return this.modelFactory.getModel(trackId); } - async getLatestSnapshot(trackId) { + async getLatestSnapshot(trackId, projection) { try { const Model = this._getModel(trackId); - return await Model.findOne({ id: trackId }).sort({ modified: -1 }).lean().exec(); + return await Model.findOne({ id: trackId }) + .select(projection) + .sort({ modified: -1 }) + .lean() + .exec(); } catch (err) { throw new DatabaseError(err); } @@ -71,10 +76,10 @@ class ReleaseTrackDynamicRepository { } } - async getSnapshotByModified(trackId, modified) { + async getSnapshotByModified(trackId, modified, projection) { try { const Model = this._getModel(trackId); - return await Model.findOne({ id: trackId, modified }).lean().exec(); + return await Model.findOne({ id: trackId, modified }).select(projection).lean().exec(); } catch (err) { if (err.name === 'CastError') { throw new BadlyFormattedParameterError({ parameterName: 'modified' }); @@ -126,7 +131,7 @@ class ReleaseTrackDynamicRepository { } } - async getReleaseBySourceModified(trackId, sourceModified) { + async getReleaseBySourceModified(trackId, sourceModified, projection) { try { const Model = this._getModel(trackId); return await Model.findOne({ @@ -134,6 +139,7 @@ class ReleaseTrackDynamicRepository { version: { $type: 'string' }, release_source_modified: sourceModified, }) + .select(projection) .lean() .exec(); } catch (err) { @@ -285,7 +291,20 @@ class ReleaseTrackDynamicRepository { query.version = null; } - const totalCount = await Model.countDocuments(query).exec(); + const [summaryCounts] = await Model.aggregate([ + { $match: query }, + { + $group: { + _id: null, + total: { $sum: 1 }, + tagged: { + $sum: { $cond: [{ $eq: [{ $type: '$version' }, 'string'] }, 1, 0] }, + }, + }, + }, + ]).exec(); + const total = summaryCounts?.total || 0; + const tagged = summaryCounts?.tagged || 0; const aggregation = [ { $match: query }, { $sort: { modified: -1 } }, @@ -325,8 +344,9 @@ class ReleaseTrackDynamicRepository { return { data: documents, + counts: { tagged, drafts: total - tagged, total }, pagination: { - total: totalCount, + total, offset: options.offset || 0, limit: options.limit || 0, }, @@ -587,6 +607,106 @@ class ReleaseTrackDynamicRepository { } } + async getDraftRetentionBoundary(trackId, count) { + return this._getModel(trackId) + .findOne({ id: trackId, version: null }) + .sort({ modified: -1 }) + .skip(count - 1) + .select('modified') + .lean() + .exec(); + } + + async getHistoricalDraftBatch(trackId, { lower_bound, upper_bound, cursor }, limit = 100) { + const lower = [lower_bound, cursor].filter(Boolean).map((value) => new Date(value)); + const modified = { $lt: new Date(upper_bound) }; + if (lower.length) modified.$gt = new Date(Math.max(...lower.map(Number))); + return this._getModel(trackId) + .find({ id: trackId, version: null, modified }) + .select('id modified content_manifest_id scheduled_materialization') + .sort({ modified: 1 }) + .limit(limit) + .lean() + .exec(); + } + + async deleteHistoricalDraft(trackId, snapshot, bounds, latestModified) { + const modified = { + $eq: new Date(snapshot.modified), + $lt: new Date(bounds.upper_bound), + $ne: new Date(latestModified), + }; + if (bounds.lower_bound) modified.$gt = new Date(bounds.lower_bound); + return this._getModel(trackId) + .findOneAndDelete({ + id: trackId, + _id: snapshot._id, + version: null, + modified, + content_manifest_id: snapshot.content_manifest_id, + }) + .lean() + .exec(); + } + + async getSnapshotCounters(trackId) { + const Model = this._getModel(trackId); + const [counts] = await Model.aggregate([ + { $match: { id: trackId } }, + { + $group: { + _id: null, + snapshot_count: { $sum: 1 }, + tagged_release_count: { + $sum: { $cond: [{ $ne: [{ $ifNull: ['$version', null] }, null] }, 1, 0] }, + }, + latest_snapshot_modified: { $max: '$modified' }, + }, + }, + ]).exec(); + let highest = null; + const tagged = Model.find({ id: trackId, version: { $type: 'string' } }) + .select('version') + .lean() + .cursor({ batchSize: 100 }); + for await (const snapshot of tagged) { + if (!highest || versionUtils.compareVersions(snapshot.version, highest) > 0) { + highest = snapshot.version; + } + } + return { + snapshot_count: counts?.snapshot_count || 0, + tagged_release_count: counts?.tagged_release_count || 0, + latest_snapshot_modified: counts?.latest_snapshot_modified || null, + latest_tagged_version: highest, + }; + } + + async hasSnapshotResolvingComponent(trackId, componentTrackId, modified) { + return Boolean( + await this._getModel(trackId).exists({ + id: trackId, + 'composition_resolution.component_snapshots': { + $elemMatch: { track_id: componentTrackId, resolved_snapshot_id: new Date(modified) }, + }, + }), + ); + } + + async getScheduledSnapshotBatch(trackId, afterModified) { + return this._getModel(trackId) + .find({ + id: trackId, + 'scheduled_materialization.scheduled_for': { $type: 'date' }, + ...(afterModified ? { modified: { $gt: new Date(afterModified) } } : {}), + }) + .select('id modified scheduled_materialization') + .sort({ modified: 1 }) + .limit(100) + .lean() + .exec(); + } + async deleteSnapshot(trackId, modified) { try { const Model = this._getModel(trackId); diff --git a/app/repository/release-tracks/release-track-registry.repository.js b/app/repository/release-tracks/release-track-registry.repository.js index 0d3f4135..6b79b9e3 100644 --- a/app/repository/release-tracks/release-track-registry.repository.js +++ b/app/repository/release-tracks/release-track-registry.repository.js @@ -68,9 +68,9 @@ class ReleaseTrackRegistryRepository { } } - async findByTrackId(trackId) { + async findByTrackId(trackId, projection) { try { - return await this.model.findOne({ track_id: trackId }).lean().exec(); + return await this.model.findOne({ track_id: trackId }).select(projection).lean().exec(); } catch (err) { if (err.name === 'CastError') { throw new BadlyFormattedParameterError({ parameterName: 'trackId' }); @@ -101,7 +101,7 @@ class ReleaseTrackRegistryRepository { }); } - aggregation.push({ $project: { release_lock: 0 } }); + aggregation.push({ $project: { release_lock: 0, draft_retention: 0 } }); // Total count before pagination const totalCountResult = await this.model.aggregate(aggregation).count('totalCount').exec(); @@ -128,6 +128,19 @@ class ReleaseTrackRegistryRepository { } } + async findVirtualTrackBatch(afterTrackId) { + return this.model + .find({ + type: 'virtual', + ...(afterTrackId ? { track_id: { $gt: afterTrackId } } : {}), + }) + .select('track_id') + .sort({ track_id: 1 }) + .limit(100) + .lean() + .exec(); + } + async findWithTaggedReleases(options = {}) { try { const query = { 'tagged_releases.0': { $exists: true } }; @@ -222,6 +235,24 @@ class ReleaseTrackRegistryRepository { } } + async renewReleaseLock(trackId, token, staleBefore) { + try { + return await this.model + .findOneAndUpdate( + { + track_id: trackId, + 'release_lock.token': token, + 'release_lock.acquired_at': { $gte: staleBefore }, + }, + { $set: { 'release_lock.acquired_at': new Date() } }, + { new: true, lean: true, projection: { _id: 1 } }, + ) + .exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + async releaseReleaseLock(trackId, token) { try { return await this.model diff --git a/app/repository/release-tracks/virtual-track-schedule-occurrence.repository.js b/app/repository/release-tracks/virtual-track-schedule-occurrence.repository.js index b470f00b..4a9996f7 100644 --- a/app/repository/release-tracks/virtual-track-schedule-occurrence.repository.js +++ b/app/repository/release-tracks/virtual-track-schedule-occurrence.repository.js @@ -1,7 +1,8 @@ 'use strict'; +const { randomUUID } = require('node:crypto'); const VirtualTrackScheduleOccurrence = require('../../models/release-tracks/virtual-track-schedule-occurrence-model'); -const { DatabaseError } = require('../../exceptions'); +const { DatabaseError, ReleaseConflictError } = require('../../exceptions'); class VirtualTrackScheduleOccurrenceRepository { async register(trackId, scheduleMode, scheduledFor) { @@ -24,6 +25,49 @@ class VirtualTrackScheduleOccurrenceRepository { } } + async getMaterializationReceipt(trackId, scheduledFor) { + try { + return await VirtualTrackScheduleOccurrence.findOne({ + track_id: trackId, + scheduled_for: scheduledFor, + }) + .lean() + .exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + + async recordMaterialization(trackId, scheduledMaterialization, snapshotModified) { + const { schedule_mode: scheduleMode, scheduled_for: scheduledFor } = scheduledMaterialization; + await this.register(trackId, scheduleMode, scheduledFor); + let receipt; + try { + receipt = await VirtualTrackScheduleOccurrence.findOneAndUpdate( + { + track_id: trackId, + scheduled_for: scheduledFor, + $or: [{ snapshot_modified: null }, { snapshot_modified: snapshotModified }], + }, + { $set: { snapshot_modified: snapshotModified } }, + { new: true, lean: true }, + ).exec(); + } catch (err) { + throw new DatabaseError(err); + } + if (receipt) return receipt; + + const existing = await this.getMaterializationReceipt(trackId, scheduledFor); + throw new ReleaseConflictError('Scheduled occurrence already has a different materialization', { + details: { + track_id: trackId, + scheduled_for: scheduledFor, + snapshot_modified: existing?.snapshot_modified, + requested_snapshot_modified: snapshotModified, + }, + }); + } + async findDue(now) { try { return await VirtualTrackScheduleOccurrence.find({ @@ -57,6 +101,7 @@ class VirtualTrackScheduleOccurrenceRepository { $set: { status: 'running', claimed_at: now, + claim_token: randomUUID(), claim_expires_at: claimExpiresAt, next_retry_at: null, finished_at: null, @@ -71,42 +116,61 @@ class VirtualTrackScheduleOccurrenceRepository { } } - async complete(trackId, scheduledFor, snapshotModified) { - return this._finish(trackId, scheduledFor, { - status: 'completed', - snapshot_modified: snapshotModified, - finished_at: new Date(), - claim_expires_at: null, - next_retry_at: null, - last_error: null, - }); + async complete(claimed) { + return this._finish( + claimed, + { snapshot_modified: { $ne: null } }, + { + status: 'completed', + finished_at: new Date(), + claim_expires_at: null, + next_retry_at: null, + last_error: null, + }, + ); } - async fail(trackId, scheduledFor, error, nextRetryAt) { - return this._finish(trackId, scheduledFor, { - status: 'failed', - finished_at: new Date(), - claim_expires_at: null, - next_retry_at: nextRetryAt, - last_error: error, - }); + async fail(claimed, error, nextRetryAt) { + // A saved result remains a receipt even when its audit needs retrying. + return this._finish( + claimed, + {}, + { + status: 'failed', + finished_at: new Date(), + claim_expires_at: null, + next_retry_at: nextRetryAt, + last_error: error, + }, + ); } - async skip(trackId, scheduledFor, reason) { - return this._finish(trackId, scheduledFor, { - status: 'skipped', - finished_at: new Date(), - claim_expires_at: null, - next_retry_at: null, - last_error: { message: reason }, - }); + async skip(claimed, reason) { + return this._finish( + claimed, + { snapshot_modified: null }, + { + status: 'skipped', + finished_at: new Date(), + claim_expires_at: null, + next_retry_at: null, + last_error: { message: reason }, + }, + ); } - async _finish(trackId, scheduledFor, updates) { + async _finish(claimed, conditions, updates) { + if (!claimed?.claim_token) return null; try { return await VirtualTrackScheduleOccurrence.findOneAndUpdate( - { track_id: trackId, scheduled_for: scheduledFor }, - { $set: updates }, + { + track_id: claimed.track_id, + scheduled_for: claimed.scheduled_for, + status: 'running', + claim_token: claimed.claim_token, + ...conditions, + }, + { $set: { ...updates, claim_token: null } }, { new: true, lean: true }, ).exec(); } catch (err) { diff --git a/app/routes/release-tracks-routes.js b/app/routes/release-tracks-routes.js index 2872c192..b3e6177e 100644 --- a/app/routes/release-tracks-routes.js +++ b/app/routes/release-tracks-routes.js @@ -330,6 +330,22 @@ router releaseTracksController.updateSchedule, ); +router + .route('/release-tracks/:id/virtual/draft-cleanup') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + releaseTracksController.listDraftCleanup, + ); + +router + .route('/release-tracks/:id/virtual/draft-cleanup/:operationId/retry') + .post( + authn.authenticate, + authz.requireRole(authz.admin), + releaseTracksController.retryDraftCleanup, + ); + // ============================================================================= // Delete release track (must be last -- :id is a catch-all param) // ============================================================================= diff --git a/app/scheduler/virtual-track-snapshots-task.js b/app/scheduler/virtual-track-snapshots-task.js index 0d6c930e..705d825c 100644 --- a/app/scheduler/virtual-track-snapshots-task.js +++ b/app/scheduler/virtual-track-snapshots-task.js @@ -49,7 +49,8 @@ async function auditAttempt(occurrence, execute) { }); try { - const { snapshot, recovered } = await execute(); + const result = await execute(); + const { snapshot, receipt, recovered } = result; await recorder.recordItem({ status: recovered ? 'unchanged' : 'changed', action: recovered ? 'recover_scheduled_virtual_snapshot' : 'materialize_virtual_snapshot', @@ -59,9 +60,13 @@ async function auditAttempt(occurrence, execute) { }, details: { scheduled_for: scheduledFor, - snapshot_modified: snapshot.modified, - members_count: snapshot.members?.length || 0, - quarantine_count: snapshot.quarantine?.length || 0, + snapshot_modified: receipt.snapshot_modified, + ...(snapshot + ? { + members_count: snapshot.members?.length || 0, + quarantine_count: snapshot.quarantine?.length || 0, + } + : { materialized_and_removed: true }), recovered, }, }); @@ -76,7 +81,7 @@ async function auditAttempt(occurrence, execute) { : `Materialized scheduled virtual snapshot for ${occurrence.track_id}`, }, }); - return snapshot; + return result; } catch (err) { const serialized = serializeError(err); await recorder.recordItem({ @@ -101,6 +106,27 @@ async function auditAttempt(occurrence, execute) { } } +async function recoverMaterialization(claimed) { + const receipt = await occurrenceRepo.getMaterializationReceipt( + claimed.track_id, + claimed.scheduled_for, + ); + const snapshot = await dynamicRepo.getSnapshotByScheduledMaterialization( + claimed.track_id, + claimed.scheduled_for, + ); + if (receipt?.snapshot_modified) return { snapshot, receipt, recovered: true }; + if (!snapshot) return null; + + // Backfill legacy saved results before audit completion or any later pruning. + const recorded = await occurrenceRepo.recordMaterialization( + claimed.track_id, + snapshot.scheduled_materialization, + snapshot.modified, + ); + return { snapshot, receipt: recorded, recovered: true }; +} + async function executeOccurrence(occurrence, now = new Date()) { const scheduledFor = new Date(occurrence.scheduled_for); const claimed = await occurrenceRepo.claim( @@ -111,43 +137,56 @@ async function executeOccurrence(occurrence, now = new Date()) { ); if (!claimed) return null; - const track = await registryRepo.findByTrackId(claimed.track_id); - if (!isConfiguredOccurrence(track, claimed)) { - await occurrenceRepo.skip( - claimed.track_id, - scheduledFor, - 'Track was deleted or no longer has the schedule that produced this occurrence', - ); - return null; - } - try { - const snapshot = await auditAttempt(claimed, async () => { - // A worker may have persisted the snapshot and exited before completing - // the occurrence ledger. Recover that durable result without recomputing - // composition, which may no longer be resolvable after the crash. - const existing = await dynamicRepo.getSnapshotByScheduledMaterialization( - claimed.track_id, - scheduledFor, - ); - if (existing) { - return { snapshot: existing, recovered: true }; + const recovered = await recoverMaterialization(claimed); + if (!recovered) { + const track = await registryRepo.findByTrackId(claimed.track_id); + if (!isConfiguredOccurrence(track, claimed)) { + await occurrenceRepo.skip( + claimed, + 'Track was deleted or no longer has the schedule that produced this occurrence', + ); + return null; } + } + + const result = await auditAttempt(claimed, async () => { + if (recovered) return recovered; - const materialized = await virtualTrackService.createVirtualSnapshot(claimed.track_id, { - scheduledMaterialization: { - schedule_mode: claimed.schedule_mode, - scheduled_for: scheduledFor, - }, - }); - return { snapshot: materialized, recovered: false }; + const scheduledMaterialization = { + schedule_mode: claimed.schedule_mode, + scheduled_for: scheduledFor, + }; + let materialized; + try { + materialized = await virtualTrackService.createVirtualSnapshot(claimed.track_id, { + scheduledMaterialization, + useRecurringRetention: claimed.schedule_mode === 'cron', + }); + } catch (err) { + // Another worker may have committed and pruned the result after our + // initial read. The locked service rejects replay; recover the receipt. + const committed = await recoverMaterialization(claimed); + if (committed) return committed; + throw err; + } + const receipt = await occurrenceRepo.recordMaterialization( + claimed.track_id, + scheduledMaterialization, + materialized.modified, + ); + return { snapshot: materialized, receipt, recovered: false }; }); - await occurrenceRepo.complete(claimed.track_id, scheduledFor, snapshot.modified); - return snapshot; + await occurrenceRepo.complete(claimed); + return ( + result.snapshot || { + snapshot_modified: result.receipt.snapshot_modified, + materialized_and_removed: true, + } + ); } catch (err) { await occurrenceRepo.fail( - claimed.track_id, - scheduledFor, + claimed, serializeError(err), new Date(now.getTime() + RETRY_DELAY_MS), ); diff --git a/app/services/release-tracks/content-manifest-service.js b/app/services/release-tracks/content-manifest-service.js index 7deae832..3bcf6c9e 100644 --- a/app/services/release-tracks/content-manifest-service.js +++ b/app/services/release-tracks/content-manifest-service.js @@ -347,10 +347,10 @@ async function activate(manifestId) { async function discard(manifestId) { if (!manifestId) return; - await Promise.all([ - ReleaseTrackContentManifestEntry.deleteMany({ manifest_id: manifestId }).exec(), - ReleaseTrackContentManifest.deleteOne({ manifest_id: manifestId }).exec(), - ]); + // Keep the discoverable header until every entry is gone. A failed entry + // deletion can then be resumed by orphan repair instead of stranding data. + await ReleaseTrackContentManifestEntry.deleteMany({ manifest_id: manifestId }).exec(); + await ReleaseTrackContentManifest.deleteOne({ manifest_id: manifestId }).exec(); } /** @@ -367,34 +367,47 @@ async function discardUnreferenced(trackId, manifestIds) { return unreferenced; } -async function discardTrack(trackId) { - const manifests = await ReleaseTrackContentManifest.find({ track_id: trackId }) - .select({ manifest_id: 1, _id: 0 }) - .lean() - .exec(); - const manifestIds = manifests.map((manifest) => manifest.manifest_id); - - await Promise.all([ - manifestIds.length > 0 - ? ReleaseTrackContentManifestEntry.deleteMany({ manifest_id: { $in: manifestIds } }).exec() - : Promise.resolve(), - ReleaseTrackContentManifest.deleteMany({ track_id: trackId }).exec(), - ]); +async function discardTrack(trackId, assertOwned = async () => {}) { + for (;;) { + await assertOwned(); + const manifests = await ReleaseTrackContentManifest.find({ track_id: trackId }) + .select({ manifest_id: 1, _id: 0 }) + .limit(100) + .lean() + .exec(); + if (!manifests.length) return; + for (const manifest of manifests) { + await assertOwned(); + await discard(manifest.manifest_id); + } + } } /** * Remove manifests owned by a track that no surviving snapshot references. * Used by deletion recovery paths. */ -async function discardOrphans(trackId) { - const manifests = await ReleaseTrackContentManifest.find({ track_id: trackId }) - .select({ manifest_id: 1, _id: 0 }) - .lean() - .exec(); - return discardUnreferenced( - trackId, - manifests.map((manifest) => manifest.manifest_id), - ); +async function discardOrphans(trackId, assertOwned = async () => {}) { + let cursor; + for (;;) { + await assertOwned(); + const manifests = await ReleaseTrackContentManifest.find({ + track_id: trackId, + ...(cursor ? { _id: { $gt: cursor } } : {}), + }) + .select({ manifest_id: 1 }) + .sort({ _id: 1 }) + .limit(100) + .lean() + .exec(); + if (!manifests.length) return; + await assertOwned(); + await discardUnreferenced( + trackId, + manifests.map((manifest) => manifest.manifest_id), + ); + cursor = manifests.at(-1)._id; + } } // ============================================================================= diff --git a/app/services/release-tracks/draft-cleanup-service.js b/app/services/release-tracks/draft-cleanup-service.js new file mode 100644 index 00000000..a5d6a0f1 --- /dev/null +++ b/app/services/release-tracks/draft-cleanup-service.js @@ -0,0 +1,518 @@ +'use strict'; + +const { createHash } = require('crypto'); +const authz = require('../../lib/authz-middleware'); +const { draftRetentionSchema } = require('../../lib/release-tracks/release-track-schemas'); +const registryRepo = require('../../repository/release-tracks/release-track-registry.repository'); +const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); +const auditRepo = require('../../repository/release-tracks/release-track-audit-event.repository'); +const occurrenceRepo = require('../../repository/release-tracks/virtual-track-schedule-occurrence.repository'); +const manifests = require('./content-manifest-service'); +const { + BadRequestError, + InsufficientRoleError, + NotFoundError, + ReleaseConflictError, + ReleasePublicationError, + TrackNotFoundError, +} = require('../../exceptions'); + +const BATCH_SIZE = 100; +const MAX_BATCHES = 10; +const iso = (value) => new Date(value).toISOString(); +const IDENTITY_FIELDS = + 'id modified version content_manifest_id scheduled_materialization release_event_id'; + +function assertAdmin(actor) { + if (actor?.role !== authz.userRoles.admin) { + throw new InsufficientRoleError('administrator', { + details: + 'Configuring draft retention, squashing drafts, and retrying cleanup require an administrator.', + }); + } +} +exports.assertAdmin = assertAdmin; + +function validatePolicy(policy, actor, type) { + assertAdmin(actor); + if (type !== 'virtual') + throw new BadRequestError({ message: 'Draft retention is only available for virtual tracks' }); + const parsed = draftRetentionSchema.nullable().safeParse(policy); + if (!parsed.success) + throw new BadRequestError({ + message: 'Invalid draft retention policy', + details: parsed.error.errors, + }); + return parsed.data; +} +exports.validatePolicy = validatePolicy; + +async function virtualRegistry(trackId) { + const registry = await registryRepo.findByTrackId(trackId, 'track_id type snapshot_schedule'); + if (!registry) throw new TrackNotFoundError(trackId); + if (registry.type !== 'virtual') + throw new BadRequestError({ message: 'Draft cleanup is only available for virtual tracks' }); + return registry; +} + +// Called under the target lock before any write carrying scheduled metadata. +// A durable receipt outlives its snapshot; its absence is never permission to replay. +exports.findScheduledResult = async function findScheduledResult(trackId, scheduled) { + if (!scheduled) return null; + const receipt = await occurrenceRepo.getMaterializationReceipt(trackId, scheduled.scheduled_for); + const existing = receipt?.snapshot_modified + ? await dynamicRepo.getSnapshotByModified(trackId, receipt.snapshot_modified) + : await dynamicRepo.getSnapshotByScheduledMaterialization(trackId, scheduled.scheduled_for); + if (receipt?.snapshot_modified && !existing) { + throw new ReleaseConflictError( + 'This scheduled occurrence was already materialized and its snapshot was removed', + { + track_id: trackId, + scheduled_for: iso(scheduled.scheduled_for), + snapshot_modified: iso(receipt.snapshot_modified), + already_materialized: true, + snapshot_removed: true, + }, + ); + } + if (existing) await occurrenceRepo.recordMaterialization(trackId, scheduled, existing.modified); + return existing; +}; + +exports.recordScheduledResult = async function recordScheduledResult(snapshot) { + if (snapshot.scheduled_materialization) { + await occurrenceRepo.recordMaterialization( + snapshot.id, + snapshot.scheduled_materialization, + snapshot.modified, + ); + } +}; + +async function protection(trackId, snapshot, latest) { + if (iso(snapshot.modified) === iso(latest.modified)) return 'latest'; + if (await dynamicRepo.getReleaseBySourceModified(trackId, snapshot.modified, '_id')) + return 'release_source'; + let afterTrackId; + for (;;) { + const tracks = await registryRepo.findVirtualTrackBatch(afterTrackId); + if (!tracks.length) break; + for (const track of tracks) { + if ( + await dynamicRepo.hasSnapshotResolvingComponent(track.track_id, trackId, snapshot.modified) + ) + return 'dependency'; + } + afterTrackId = tracks.at(-1).track_id; + } + const scheduled = snapshot.scheduled_materialization; + if (scheduled) { + const receipt = await occurrenceRepo.getMaterializationReceipt( + trackId, + scheduled.scheduled_for, + ); + if (receipt?.snapshot_modified && iso(receipt.snapshot_modified) !== iso(snapshot.modified)) + return 'scheduled_receipt_conflict'; + } + return null; +} + +exports.previewSquash = async function previewSquash(trackId, target) { + const previous = await dynamicRepo.getLatestTaggedSnapshotBefore(trackId, target.modified); + const bounds = { + lower_bound: previous ? iso(previous.modified) : null, + upper_bound: iso(target.modified), + }; + const latest = await dynamicRepo.getLatestSnapshot(trackId, 'modified'); + const hash = createHash('sha256'); + hash.update( + JSON.stringify([ + trackId, + bounds, + target.content_manifest_id, + previous?.release_event_id || null, + ]), + ); + let eligible = 0; + let protectedCount = 0; + let cursor; + for (;;) { + const batch = await dynamicRepo.getHistoricalDraftBatch( + trackId, + { ...bounds, cursor }, + BATCH_SIZE, + ); + if (!batch.length) break; + for (const snapshot of batch) { + const reason = await protection(trackId, snapshot, latest); + if (reason) protectedCount += 1; + else eligible += 1; + hash.update( + JSON.stringify([ + iso(snapshot.modified), + snapshot.content_manifest_id, + reason, + snapshot.scheduled_materialization || null, + ]), + ); + } + cursor = iso(batch.at(-1).modified); + } + return { + ...bounds, + eligible_count: eligible, + protected_count: protectedCount, + fingerprint: hash.digest('hex'), + }; +}; + +function stateFor(event) { + return ( + event.cleanup || { + kind: event.request.kind, + eligible_count: event.request.eligible_count || 0, + deleted_count: 0, + protected_count: 0, + cursor: null, + pending_batch: [], + release_committed: false, + publication_complete: false, + } + ); +} + +function resultFor( + event, + state = stateFor(event), + status = event.status, + error = event.error?.message, +) { + return { + operation_id: event.event_id, + status, + kind: state.kind, + eligible_count: state.eligible_count, + deleted_count: state.deleted_count, + protected_count: state.protected_count, + ...(event.request.target_modified ? { target_modified: event.request.target_modified } : {}), + ...(state.kind === 'squash' ? { release_committed: state.release_committed } : {}), + ...(error ? { error } : {}), + }; +} +exports.resultFor = resultFor; + +async function createIntent(trackId, actor, request) { + return auditRepo.create({ + action: request.kind === 'squash' ? 'draft_squash' : 'draft_retention', + trackId, + actor, + confirmation: request.fingerprint || trackId, + request, + }); +} + +exports.beginSquash = async function beginSquash(plan, options, lease) { + assertAdmin(options.actor); + if (plan.sourceSnapshot.type !== 'virtual') + throw new BadRequestError({ message: 'Draft squash is only available for virtual tracks' }); + const preview = await exports.previewSquash(plan.trackId, plan.sourceSnapshot); + if (!options.squash_fingerprint || options.squash_fingerprint !== preview.fingerprint) { + throw new ReleaseConflictError( + 'The draft squash preview changed; review a fresh preview before releasing', + { draft_squash: preview }, + ); + } + await lease.assertOwned(); + return createIntent(plan.trackId, options.actor, { + ...preview, + kind: 'squash', + target_modified: iso(plan.sourceSnapshot.modified), + version: plan.version, + }); +}; + +exports.failDraft = async function failDraft(snapshot, event, error) { + const state = stateFor(event); + try { + await auditRepo.saveCleanup(event.event_id, state, 'failed', error); + } catch { + /* The pending intent remains discoverable. */ + } + return { ...snapshot, draft_cleanup: resultFor(event, state, 'failed', error.message) }; +}; + +exports.failRelease = async function failRelease(event, error) { + const state = stateFor(event); + try { + const snapshot = await dynamicRepo.getSnapshotByModified( + event.track_id, + event.request.target_modified, + IDENTITY_FIELDS, + ); + state.release_committed = + snapshot?.version != null && snapshot.release_event_id === event.event_id; + } catch { + // An unavailable store cannot establish whether an unacknowledged tag + // persisted. Omit the outcome rather than reporting an uncommitted release. + state.release_committed = undefined; + } + try { + await auditRepo.saveCleanup(event.event_id, state, 'failed', error); + } catch { + /* The pending intent remains discoverable. */ + } + return new ReleasePublicationError({ + cause: error, + details: error.message, + operation_id: event.event_id, + release_committed: state.release_committed, + draft_cleanup: resultFor(event, state, 'failed', error.message), + }); +}; + +async function boundsFor(event, registry) { + const original = event.request; + if (original.kind === 'retention') { + // Legacy intents did not identify an approved trigger/policy. They may + // repair already-deleted storage, but must never select more history. + if ( + !['manual', 'recurring'].includes(original.source) || + !Number.isSafeInteger(original.max_drafts) || + original.max_drafts < 1 || + !original.upper_bound + ) + return null; + const maximum = + original.source === 'manual' + ? original.max_drafts + : registry.snapshot_schedule?.mode === 'cron' + ? registry.snapshot_schedule.draft_retention?.max_drafts + : null; + if (!Number.isSafeInteger(maximum) || maximum < 1) return null; + const boundary = await dynamicRepo.getDraftRetentionBoundary(event.track_id, maximum); + if (!boundary) return null; + return { + lower_bound: original.lower_bound, + upper_bound: iso( + Math.min(new Date(original.upper_bound).getTime(), new Date(boundary.modified).getTime()), + ), + }; + } + // A newly inserted tag narrows the original interval. Rollback never widens it. + const previous = await dynamicRepo.getLatestTaggedSnapshotBefore( + event.track_id, + original.upper_bound, + ); + const lower = [original.lower_bound, previous?.modified] + .filter(Boolean) + .map((value) => new Date(value).getTime()); + return { + lower_bound: lower.length ? iso(Math.max(...lower)) : null, + upper_bound: original.upper_bound, + }; +} + +function inBounds(snapshot, bounds) { + return ( + bounds && + new Date(snapshot.modified) < new Date(bounds.upper_bound) && + (!bounds.lower_bound || new Date(snapshot.modified) > new Date(bounds.lower_bound)) + ); +} + +// One bounded, durable page is the write-ahead log. A crash after a snapshot +// delete but before manifest/count updates replays that page, not a fresh selector. +async function runCleanup(event, lease) { + const state = stateFor(event); + try { + await lease.assertOwned(); + let registry = await virtualRegistry(event.track_id); + // Repair already removed storage even after rollback or policy disabling. + await manifests.discardOrphans(event.track_id, () => lease.assertOwned()); + await require('./snapshot-service').syncRegistryCounters(event.track_id); + if (state.kind === 'retention') { + const created = await dynamicRepo.getSnapshotByModified( + event.track_id, + event.request.target_modified, + IDENTITY_FIELDS, + ); + if (!created && !state.creation_complete) + throw new ReleaseConflictError( + 'The draft creation associated with this cleanup did not persist or no longer exists', + ); + if (created) await exports.recordScheduledResult(created); + if (created) await manifests.activate(created.content_manifest_id); + if (!state.creation_complete) { + const latest = await dynamicRepo.getLatestSnapshot(event.track_id); + await require('./snapshot-service').emitContentsChanged(event.track_id, latest); + state.creation_complete = true; + } + } + if (state.kind === 'squash') { + const release = await dynamicRepo.getSnapshotByModified( + event.track_id, + event.request.target_modified, + ); + if (!release?.version || release.release_event_id !== event.event_id) { + state.release_committed = false; + throw new ReleaseConflictError( + 'The original release event is no longer present; cleanup cannot resume', + { + operation_id: event.event_id, + release_committed: false, + }, + ); + } + state.release_committed = true; + if (!state.publication_complete) { + const versioning = require('./versioning-service'); + await lease.assertOwned(); + await versioning.refreshReleaseArtifacts(release); + await require('./release-history-service').reconcileTaggedReleases(event.track_id); + const latest = await dynamicRepo.getLatestSnapshot(event.track_id); + await require('./snapshot-service').emitContentsChanged(event.track_id, latest); + state.publication_complete = true; + await auditRepo.saveCleanup(event.event_id, state); + } + } + + for (let page = 0; page < MAX_BATCHES; page += 1) { + await lease.assertOwned(); + registry = await virtualRegistry(event.track_id); + const bounds = await boundsFor(event, registry); + if (!state.pending_batch.length && bounds) { + state.pending_batch = await dynamicRepo.getHistoricalDraftBatch( + event.track_id, + { ...bounds, cursor: state.cursor }, + BATCH_SIZE, + ); + if (state.kind === 'retention') state.eligible_count += state.pending_batch.length; + await auditRepo.saveCleanup(event.event_id, state); + } + if (!state.pending_batch.length) { + // Also repair manifests from an interrupted older cleanup. No additional + // drafts are chosen if a retention policy has since been disabled. + await manifests.discardOrphans(event.track_id, () => lease.assertOwned()); + await require('./snapshot-service').syncRegistryCounters(event.track_id); + const completed = await auditRepo.saveCleanup(event.event_id, state, 'completed'); + return resultFor(completed); + } + + while (state.pending_batch.length) { + const candidate = state.pending_batch[0]; + await lease.assertOwned(); + const snapshot = await dynamicRepo.getSnapshotByModified( + event.track_id, + candidate.modified, + IDENTITY_FIELDS, + ); + if (!snapshot) { + await manifests.discardUnreferenced(event.track_id, [candidate.content_manifest_id]); + state.deleted_count += 1; + } else { + const latest = await dynamicRepo.getLatestSnapshot(event.track_id, 'modified'); + let reason = + snapshot.version != null || !inBounds(snapshot, bounds) + ? 'outside_current_policy' + : await protection(event.track_id, snapshot, latest); + if (!reason && snapshot.scheduled_materialization) { + try { + await exports.recordScheduledResult(snapshot); + } catch { + reason = 'scheduled_receipt_unavailable'; + } + } + if (reason) state.protected_count += 1; + else { + await lease.assertOwned(); + const deleted = await dynamicRepo.deleteHistoricalDraft( + event.track_id, + candidate, + bounds, + latest.modified, + ); + if (deleted) { + await manifests.discardUnreferenced(event.track_id, [candidate.content_manifest_id]); + state.deleted_count += 1; + } else state.protected_count += 1; + } + } + state.cursor = iso(candidate.modified); + state.pending_batch.shift(); + await auditRepo.saveCleanup(event.event_id, state); + } + await require('./snapshot-service').syncRegistryCounters(event.track_id); + } + return resultFor(await auditRepo.saveCleanup(event.event_id, state)); + } catch (error) { + // Failure to record completion must still be visible, while the persisted + // intent/page makes repair possible even if this response is lost. + try { + await auditRepo.saveCleanup(event.event_id, state, 'failed', error); + } catch { + /* Preserve the last durable page. */ + } + return resultFor(event, state, 'failed', error.message); + } +} +exports.runCleanup = runCleanup; + +exports.prepareRetention = async function prepareRetention(snapshot, retention, lease) { + const maximum = retention.policy.max_drafts; + // Account for the new draft before it is saved. Preserve this original cutoff + // durably so retries never expand into drafts retained by the approved run. + const boundary = + maximum === 1 + ? snapshot + : await dynamicRepo.getDraftRetentionBoundary(snapshot.id, maximum - 1); + await lease.assertOwned(); + // Insert before saving the new draft: an audit outage cannot produce a + // successful creation with an undiscoverable/fabricated cleanup operation. + return createIntent(snapshot.id, retention.actor, { + kind: 'retention', + source: retention.source, + max_drafts: maximum, + lower_bound: null, + upper_bound: boundary ? iso(boundary.modified) : null, + target_modified: iso(snapshot.modified), + }); +}; + +exports.afterDraft = async function afterDraft(snapshot, lease, event) { + if (!event) return snapshot; + event.cleanup = { ...stateFor(event), creation_complete: true }; + return { ...snapshot, draft_cleanup: await runCleanup(event, lease) }; +}; + +exports.list = async function list(trackId) { + await virtualRegistry(trackId); + const events = await auditRepo.listCleanup(trackId); + const data = await Promise.all( + events.map(async (event) => { + const state = { ...stateFor(event) }; + if (state.kind === 'squash') { + const release = await dynamicRepo.getSnapshotByModified( + trackId, + event.request.target_modified, + IDENTITY_FIELDS, + ); + state.release_committed = + release?.version != null && release.release_event_id === event.event_id; + } + return resultFor(event, state); + }), + ); + return { data }; +}; + +exports.retry = async function retry(trackId, operationId, actor) { + assertAdmin(actor); + return require('./versioning-service').withReleaseLock(trackId, async (lease) => { + await virtualRegistry(trackId); + const event = await auditRepo.getCleanup(trackId, operationId); + if (!event) + throw new NotFoundError({ details: 'No cleanup intent exists for this track and operation' }); + if (event.status === 'completed') return resultFor(event); + return runCleanup(event, lease); + }); +}; diff --git a/app/services/release-tracks/release-tracks-service.js b/app/services/release-tracks/release-tracks-service.js index 1e935e0c..93b6d5ab 100644 --- a/app/services/release-tracks/release-tracks-service.js +++ b/app/services/release-tracks/release-tracks-service.js @@ -37,6 +37,7 @@ const bundleImportService = require('./bundle-import-service'); const memberSyncService = require('./member-sync-service'); const releaseHistoryService = require('./release-history-service'); const destructiveAuditService = require('./destructive-audit-service'); +const draftCleanupService = require('./draft-cleanup-service'); const attackObjectsService = require('../stix/attack-objects-service'); const userAccountsService = require('../system/user-accounts-service'); const revisionReference = require('../../lib/release-tracks/revision-reference'); @@ -246,10 +247,12 @@ async function formatWorkbenchSnapshot(snapshot, options) { const [attributed] = await addCreationActors([enriched]); // Registry-derived, read-only metadata used alongside snapshot content. const metadata = await snapshotService.getTrackMetadata(snapshot.id); - enriched.alias = metadata.alias; - enriched.creation_cause = snapshot.creation_cause || 'unknown'; + attributed.alias = metadata.alias; + attributed.creation_cause = snapshot.creation_cause || 'unknown'; if (snapshot.type === 'virtual') { - enriched.snapshot_schedule = metadata.snapshot_schedule || { mode: 'manual' }; + attributed.snapshot_schedule = metadata.snapshot_schedule || { mode: 'manual' }; + attributed.snapshot_count = metadata.snapshot_count; + attributed.tagged_release_count = metadata.tagged_release_count; } return filterSnapshotTiers(attributed, options?.include); } @@ -272,7 +275,6 @@ exports.getReleasesByObject = function getReleasesByObject(objectRef, options) { exports.createTrack = async function createTrack(data) { let validatedData = data; - if (data.scheduled_materialization !== undefined) { if (data.type !== 'virtual') { throw new BadRequestError({ @@ -309,7 +311,7 @@ exports.createTrack = async function createTrack(data) { details: scheduleResult.error.errors, }); } - validatedData = { ...data, snapshot_schedule: scheduleResult.data }; + validatedData = { ...validatedData, snapshot_schedule: scheduleResult.data }; } if (validatedData.composition !== undefined) { @@ -392,15 +394,17 @@ exports.cloneFromSnapshot = function cloneFromSnapshot(trackId, modified, option }; exports.deleteTrack = function deleteTrack(trackId, actor, confirmation) { - return destructiveAuditService.execute( - { - action: 'delete_track', - trackId, - ...destructiveIdentity(trackId, actor, confirmation), - request: {}, - result: () => ({ deleted: true }), - }, - () => snapshotService.deleteTrack(trackId), + return versioningService.withReleaseLock(trackId, (lease) => + destructiveAuditService.execute( + { + action: 'delete_track', + trackId, + ...destructiveIdentity(trackId, actor, confirmation), + request: {}, + result: () => ({ deleted: true }), + }, + () => snapshotService.deleteTrack(trackId, lease), + ), ); }; @@ -617,7 +621,7 @@ exports.updateComposition = function updateComposition(trackId, composition, use }); }; -exports.updateSchedule = function updateSchedule(trackId, schedule) { +exports.updateSchedule = function updateSchedule(trackId, schedule, actor) { const scheduleResult = snapshotScheduleSchema.safeParse(schedule); if (!scheduleResult.success) { throw new BadRequestError({ @@ -625,9 +629,12 @@ exports.updateSchedule = function updateSchedule(trackId, schedule) { details: scheduleResult.error.errors, }); } - return virtualTrackService.updateSchedule(trackId, scheduleResult.data); + return virtualTrackService.updateSchedule(trackId, scheduleResult.data, actor); }; +exports.listDraftCleanup = draftCleanupService.list; +exports.retryDraftCleanup = draftCleanupService.retry; + exports.createVirtualSnapshot = function createVirtualSnapshot(trackId, options) { let validatedOptions = options; if (options?.scheduledMaterialization !== undefined) { diff --git a/app/services/release-tracks/snapshot-service.js b/app/services/release-tracks/snapshot-service.js index 68f8e80e..d3ab9a47 100644 --- a/app/services/release-tracks/snapshot-service.js +++ b/app/services/release-tracks/snapshot-service.js @@ -25,7 +25,6 @@ const registryRepo = require('../../repository/release-tracks/release-track-regi const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); const modelFactory = require('../../models/release-tracks/model-factory'); const logger = require('../../lib/logger'); -const versionUtils = require('../../lib/release-tracks/version-utils'); const tierRevisionInvariant = require('../../lib/release-tracks/tier-revision-invariant'); const primaryRevisionService = require('./primary-revision-service'); const reconciliationService = require('./reconciliation-service'); @@ -86,32 +85,8 @@ async function mapWithConcurrency(items, concurrency, mapper) { * @param {string} trackId */ async function syncRegistryCounters(trackId) { - const { data: snapshots } = await dynamicRepo.getAllSnapshots(trackId, { - projection: 'modified version', - }); - - const snapshotCount = snapshots.length; - const tagged = snapshots.filter((s) => s.version != null); - const taggedReleaseCount = tagged.length; - - // Latest snapshot is first (sorted desc by modified) - const latestSnapshotModified = snapshots.length > 0 ? snapshots[0].modified : null; - - const latestTaggedVersion = tagged.reduce( - (highest, snapshot) => - !highest || versionUtils.compareVersions(snapshot.version, highest) > 0 - ? snapshot.version - : highest, - null, - ); - - await registryRepo.updateByTrackId(trackId, { - snapshot_count: snapshotCount, - tagged_release_count: taggedReleaseCount, - latest_snapshot_modified: latestSnapshotModified, - latest_tagged_version: latestTaggedVersion, - updated_at: new Date(), - }); + const counters = await dynamicRepo.getSnapshotCounters(trackId); + await registryRepo.updateByTrackId(trackId, { ...counters, updated_at: new Date() }); } exports.syncRegistryCounters = syncRegistryCounters; @@ -159,10 +134,11 @@ exports.findVirtualSnapshotDependents = findVirtualSnapshotDependents; * @param {string} reason - seal_reason enum value * @returns {Promise} The saved snapshot */ -async function saveSealedSnapshot(trackId, snapshotData, reason) { +async function saveSealedSnapshot(trackId, snapshotData, reason, lease) { const manifestId = await contentManifestService.seal(snapshotData, { reason }); let saved; try { + if (lease) await lease.assertOwned(); saved = await dynamicRepo.saveSnapshot(trackId, { ...snapshotData, content_manifest_id: manifestId, @@ -214,6 +190,13 @@ exports.createTrack = async function createTrack(data, options = {}) { const trackId = `release-track--${uuidv4()}`; const now = new Date(); const trackType = data.type || 'standard'; + if (data.snapshot_schedule?.draft_retention !== undefined) { + require('./draft-cleanup-service').validatePolicy( + data.snapshot_schedule.draft_retention, + data.actor, + trackType, + ); + } if (data.alias) await assertAliasAvailable(data.alias); const initialSnapshot = { @@ -238,11 +221,12 @@ exports.createTrack = async function createTrack(data, options = {}) { version_history: [], }; - // Create collection + indexes, then persist the initial sealed snapshot await modelFactory.ensureIndexes(trackId); - const snapshot = await saveSealedSnapshot(trackId, initialSnapshot, 'track_creation'); + let snapshot; + if (trackType !== 'virtual') { + snapshot = await saveSealedSnapshot(trackId, initialSnapshot, 'track_creation'); + } - // Register in the central registry await registryRepo.create({ track_id: trackId, type: trackType, @@ -250,12 +234,26 @@ exports.createTrack = async function createTrack(data, options = {}) { alias: data.alias || undefined, description: data.description, latest_snapshot_modified: now, - snapshot_count: 1, + snapshot_count: snapshot ? 1 : 0, tagged_release_count: 0, created_at: now, updated_at: now, snapshot_schedule: trackType === 'virtual' ? data.snapshot_schedule : undefined, }); + if (trackType === 'virtual') { + const { withReleaseLock } = require('./versioning-service'); + snapshot = await withReleaseLock(trackId, async (lease) => { + await require('./draft-cleanup-service').findScheduledResult( + trackId, + data.scheduled_materialization, + ); + await lease.assertOwned(); + const created = await saveSealedSnapshot(trackId, initialSnapshot, 'track_creation', lease); + await require('./draft-cleanup-service').recordScheduledResult(created); + await syncRegistryCounters(trackId); + return created; + }); + } logger.verbose(`SnapshotService: Created ${trackType} track "${data.name}" (${trackId})`); return snapshot; @@ -290,6 +288,8 @@ exports.getTrackMetadata = async function getTrackMetadata(trackId) { return { alias: entry?.alias ?? null, snapshot_schedule: entry?.snapshot_schedule, + snapshot_count: entry?.snapshot_count, + tagged_release_count: entry?.tagged_release_count, }; }; @@ -307,7 +307,8 @@ exports.getTrackMetadata = async function getTrackMetadata(trackId) { * * @param {string} trackId * @param {Object} options - { tagged?, limit, offset } - * @returns {Promise<{data: Object[], pagination: Object}>} + * @returns {Promise<{data: Object[], pagination: Object, counts: Object, + * latest_snapshot_modified: Date|null, latest_tagged_snapshot_modified: Date|null}>} * @throws {TrackNotFoundError} If the release track does not exist */ exports.listSnapshots = async function listSnapshots(trackId, options) { @@ -320,8 +321,15 @@ exports.listSnapshots = async function listSnapshots(trackId, options) { const statisticsByManifestId = await contentManifestService.getStatisticsByManifestIds( result.data.map((snapshot) => snapshot.content_manifest_id), ); + const latestTaggedModified = (track.tagged_releases || []).reduce( + (latest, release) => + !latest || release.snapshot_modified > latest ? release.snapshot_modified : latest, + null, + ); return { ...result, + latest_snapshot_modified: track.latest_snapshot_modified ?? null, + latest_tagged_snapshot_modified: latestTaggedModified, data: result.data.map((snapshot) => { const common = { id: snapshot.id, @@ -430,16 +438,24 @@ exports.cloneSnapshot = async function cloneSnapshot( overrides, options = {}, ) { - if (sourceSnapshot.type === 'standard') { - const { withReleaseLock } = require('./versioning-service'); - return withReleaseLock(trackId, () => - cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options), - ); - } - return cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options); + if (options.lease) return cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options); + const { withReleaseLock } = require('./versioning-service'); + return withReleaseLock(trackId, (lease) => + cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, { ...options, lease }), + ); }; async function cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options) { + await options.lease.assertOwned(); + if (sourceSnapshot.type === 'virtual') { + const existing = await require('./draft-cleanup-service').findScheduledResult( + trackId, + overrides?.scheduled_materialization, + ); + if (existing) return existing; + // Direct callers must not resurrect a source removed while acquiring the lock. + await exports.getSnapshotByModified(trackId, sourceSnapshot.modified); + } const clone = deepClone(sourceSnapshot); const hasSnapshotDescriptionOverride = Object.prototype.hasOwnProperty.call( overrides || {}, @@ -449,7 +465,10 @@ async function cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options delete clone.publication; delete clone.bundle_id; delete clone.bundle_hashes; - clone.modified = new Date(); + delete clone.release_event_id; + delete clone.draft_retention; + delete clone.draft_cleanup; + clone.modified = new Date(Math.max(Date.now(), new Date(sourceSnapshot.modified).getTime() + 1)); clone.version = null; // clones are always drafts delete clone.scheduled_materialization; @@ -477,38 +496,63 @@ async function cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options clone.creation_cause = options.creationCause || CreationCause.Unknown; clone.creation_actor = creationActor(options.userAccountId); const normalized = tierRevisionInvariant.normalizeSnapshot(clone); + await options.lease.assertOwned(); + const cleanupIntent = + normalized.snapshot.type === 'virtual' && options.retention + ? await require('./draft-cleanup-service').prepareRetention( + normalized.snapshot, + options.retention, + options.lease, + ) + : null; let saved; - if (rewritesMembers || !normalized.snapshot.content_manifest_id) { - saved = await saveSealedSnapshot( - trackId, - normalized.snapshot, - options.sealReason || 'members_written', - ); - } else { - saved = await dynamicRepo.saveSnapshot(trackId, normalized.snapshot); - } + try { + if (rewritesMembers || !normalized.snapshot.content_manifest_id) { + saved = await saveSealedSnapshot( + trackId, + normalized.snapshot, + options.sealReason || 'members_written', + options.lease, + ); + } else { + saved = await dynamicRepo.saveSnapshot(trackId, normalized.snapshot); + } - if (saved.type === 'standard') { - // Materialization holds this same track's release lock until provenance - // is persisted, so this scan cannot miss a concurrently created dependent. - const virtualTracks = (await registryRepo.findAll({ type: 'virtual' })).data; - const referencedDrafts = await mapWithConcurrency(virtualTracks, 12, (track) => - dynamicRepo.findResolvedComponentSnapshotIds(track.track_id, trackId), - ); - const prunedDrafts = await dynamicRepo.deleteOlderDrafts( - trackId, - saved.modified, - referencedDrafts.flat(), - ); - await contentManifestService.discardUnreferenced( - trackId, - prunedDrafts.map((snapshot) => snapshot.content_manifest_id), - ); + if (saved.type === 'standard') { + // Materialization holds this same track's release lock until provenance + // is persisted, so this scan cannot miss a concurrently created dependent. + const virtualTracks = (await registryRepo.findAll({ type: 'virtual' })).data; + const referencedDrafts = await mapWithConcurrency(virtualTracks, 12, (track) => + dynamicRepo.findResolvedComponentSnapshotIds(track.track_id, trackId), + ); + const prunedDrafts = await dynamicRepo.deleteOlderDrafts( + trackId, + saved.modified, + referencedDrafts.flat(), + ); + await contentManifestService.discardUnreferenced( + trackId, + prunedDrafts.map((snapshot) => snapshot.content_manifest_id), + ); + } + await syncRegistryCounters(trackId); + + // The clone (modified = now) is the track's new latest snapshot + await emitContentsChanged(trackId, saved); + if (saved.type === 'virtual') { + await require('./draft-cleanup-service').recordScheduledResult(saved); + saved = await require('./draft-cleanup-service').afterDraft( + saved, + options.lease, + cleanupIntent, + ); + } + } catch (error) { + if (!cleanupIntent) throw error; + const persisted = saved || (await dynamicRepo.getSnapshotByModified(trackId, clone.modified)); + if (!persisted) throw error; + return require('./draft-cleanup-service').failDraft(persisted, cleanupIntent, error); } - await syncRegistryCounters(trackId); - - // The clone (modified = now) is the track's new latest snapshot - await emitContentsChanged(trackId, saved); if (normalized.removed.length > 0) { logger.warn( @@ -532,8 +576,11 @@ async function cloneSnapshotUnlocked(trackId, sourceSnapshot, overrides, options * @returns {Promise} The initial snapshot of the new track */ exports.cloneTrack = async function cloneTrack(trackId, options) { - const source = await exports.getLatestSnapshot(trackId); - return _cloneToNewTrack(source, options); + return require('./versioning-service').withReleaseLock(trackId, async (lease) => { + const source = await exports.getLatestSnapshot(trackId); + await lease.assertOwned(); + return _cloneToNewTrack(source, { ...options, lease }); + }); }; /** @@ -545,8 +592,11 @@ exports.cloneTrack = async function cloneTrack(trackId, options) { * @returns {Promise} The initial snapshot of the new track */ exports.cloneFromSnapshot = async function cloneFromSnapshot(trackId, modified, options) { - const source = await exports.getSnapshotByModified(trackId, modified); - return _cloneToNewTrack(source, options); + return require('./versioning-service').withReleaseLock(trackId, async (lease) => { + const source = await exports.getSnapshotByModified(trackId, modified); + await lease.assertOwned(); + return _cloneToNewTrack(source, { ...options, lease }); + }); }; /** @@ -561,6 +611,9 @@ async function _cloneToNewTrack(sourceSnapshot, options = {}) { delete clone.publication; delete clone.bundle_id; delete clone.bundle_hashes; + delete clone.release_event_id; + delete clone.draft_retention; + delete clone.draft_cleanup; clone.id = newTrackId; clone.creation_cause = CreationCause.TrackCloned; clone.creation_actor = creationActor(options.userAccountId); @@ -585,7 +638,12 @@ async function _cloneToNewTrack(sourceSnapshot, options = {}) { ); await modelFactory.ensureIndexes(newTrackId); - const saved = await saveSealedSnapshot(newTrackId, normalized.snapshot, 'track_clone'); + const saved = await saveSealedSnapshot( + newTrackId, + normalized.snapshot, + 'track_clone', + options.lease, + ); await registryRepo.create({ track_id: newTrackId, @@ -631,29 +689,32 @@ async function _cloneToNewTrack(sourceSnapshot, options = {}) { */ exports.updateMetadata = async function updateMetadata(trackId, updates, userId) { - const source = await exports.getLatestSnapshot(trackId); - const overrides = {}; - if (updates.name !== undefined) overrides.name = updates.name; - if (updates.description !== undefined) overrides.description = updates.description; - - if (updates.alias !== undefined) { - if (updates.alias) await assertAliasAvailable(updates.alias, trackId); - await registryRepo.setAlias(trackId, updates.alias); - } + return require('./versioning-service').withReleaseLock(trackId, async (lease) => { + const source = await exports.getLatestSnapshot(trackId); + const overrides = {}; + if (updates.name !== undefined) overrides.name = updates.name; + if (updates.description !== undefined) overrides.description = updates.description; + + if (updates.alias !== undefined) { + if (updates.alias) await assertAliasAvailable(updates.alias, trackId); + await registryRepo.setAlias(trackId, updates.alias); + } - // Also update the registry name/description if changed - const registryUpdates = {}; - if (updates.name !== undefined) registryUpdates.name = updates.name; - if (updates.description !== undefined) registryUpdates.description = updates.description; - if (Object.keys(registryUpdates).length > 0) { - registryUpdates.updated_at = new Date(); - await registryRepo.updateByTrackId(trackId, registryUpdates); - } + // Also update the registry name/description if changed + const registryUpdates = {}; + if (updates.name !== undefined) registryUpdates.name = updates.name; + if (updates.description !== undefined) registryUpdates.description = updates.description; + if (Object.keys(registryUpdates).length > 0) { + registryUpdates.updated_at = new Date(); + await registryRepo.updateByTrackId(trackId, registryUpdates); + } - if (Object.keys(overrides).length === 0) return source; - return exports.cloneSnapshot(trackId, source, overrides, { - creationCause: CreationCause.MetadataUpdated, - userAccountId: userId, + if (Object.keys(overrides).length === 0) return source; + return exports.cloneSnapshot(trackId, source, overrides, { + creationCause: CreationCause.MetadataUpdated, + userAccountId: userId, + lease, + }); }); }; @@ -743,49 +804,51 @@ exports.getConfig = async function getConfig(trackId) { */ exports.updateConfig = async function updateConfig(trackId, config, userId) { - const source = await exports.getLatestSnapshot(trackId); - const existing = source.config || {}; - - const mergedConfig = { ...existing }; - - if (config.candidacy_threshold !== undefined) - mergedConfig.candidacy_threshold = config.candidacy_threshold; - if (config.auto_promote !== undefined) mergedConfig.auto_promote = config.auto_promote; - if (config.promotion_conflicts !== undefined) { - mergedConfig.promotion_conflicts = { - ...(existing.promotion_conflicts || {}), - ...config.promotion_conflicts, - }; - } - if (config.member_sync !== undefined) { - const existingMemberSync = existing.member_sync || {}; - mergedConfig.member_sync = { - ...existingMemberSync, - ...config.member_sync, - }; - // Nested merge for supplant sub-object - if (config.member_sync.supplant !== undefined) { - mergedConfig.member_sync.supplant = { - ...(existingMemberSync.supplant || {}), - ...config.member_sync.supplant, + return require('./versioning-service').withReleaseLock(trackId, async (lease) => { + const source = await exports.getLatestSnapshot(trackId); + const existing = source.config || {}; + + const mergedConfig = { ...existing }; + + if (config.candidacy_threshold !== undefined) + mergedConfig.candidacy_threshold = config.candidacy_threshold; + if (config.auto_promote !== undefined) mergedConfig.auto_promote = config.auto_promote; + if (config.promotion_conflicts !== undefined) { + mergedConfig.promotion_conflicts = { + ...(existing.promotion_conflicts || {}), + ...config.promotion_conflicts, }; } - } - if (config.publication !== undefined) { - const hasReleases = (source.version_history || []).length > 0; - mergedConfig.publication = publicationService.mergePublicationConfig( - existing.publication, - config.publication, - hasReleases, - ); - } + if (config.member_sync !== undefined) { + const existingMemberSync = existing.member_sync || {}; + mergedConfig.member_sync = { + ...existingMemberSync, + ...config.member_sync, + }; + // Nested merge for supplant sub-object + if (config.member_sync.supplant !== undefined) { + mergedConfig.member_sync.supplant = { + ...(existingMemberSync.supplant || {}), + ...config.member_sync.supplant, + }; + } + } + if (config.publication !== undefined) { + const hasReleases = (source.version_history || []).length > 0; + mergedConfig.publication = publicationService.mergePublicationConfig( + existing.publication, + config.publication, + hasReleases, + ); + } - return exports.cloneSnapshot( - trackId, - source, - { config: mergedConfig }, - { creationCause: CreationCause.ConfigurationUpdated, userAccountId: userId }, - ); + return exports.cloneSnapshot( + trackId, + source, + { config: mergedConfig }, + { creationCause: CreationCause.ConfigurationUpdated, userAccountId: userId, lease }, + ); + }); }; // ============================================================================= @@ -872,7 +935,12 @@ exports.reconstructManifest = async function reconstructManifest(trackId, modifi * @param {string} trackId * @throws {TrackNotFoundError} If the track does not exist in the registry */ -exports.deleteTrack = async function deleteTrack(trackId) { +exports.deleteTrack = async function deleteTrack(trackId, lease) { + if (!lease) { + return require('./versioning-service').withReleaseLock(trackId, (heldLease) => + exports.deleteTrack(trackId, heldLease), + ); + } const registry = await registryRepo.findByTrackId(trackId); if (!registry) { // A previous delete may have removed the registry only after dropping the @@ -881,8 +949,21 @@ exports.deleteTrack = async function deleteTrack(trackId) { throw new TrackNotFoundError(trackId); } + if (registry.type === 'virtual') { + let cursor; + for (;;) { + await lease.assertOwned(); + const scheduled = await dynamicRepo.getScheduledSnapshotBatch(trackId, cursor); + if (!scheduled.length) break; + for (const snapshot of scheduled) { + await require('./draft-cleanup-service').recordScheduledResult(snapshot); + } + cursor = scheduled.at(-1).modified; + } + } + await lease.assertOwned(); await dynamicRepo.dropCollection(trackId); - await contentManifestService.discardTrack(trackId); + await contentManifestService.discardTrack(trackId, () => lease.assertOwned()); await registryRepo.deleteByTrackId(trackId); // Remove all backrefs to the deleted track @@ -959,7 +1040,7 @@ exports.convertReleaseToDraft = async function convertReleaseToDraft(trackId, mo // composition resolution, notes, and immutable creation provenance. await dynamicRepo.updateSnapshot(trackId, snapshot.modified, { $set: { version: null }, - $unset: { publication: '', bundle_id: '', bundle_hashes: '' }, + $unset: { publication: '', bundle_id: '', bundle_hashes: '', release_event_id: '' }, }); draft = snapshot; } @@ -1031,6 +1112,7 @@ exports.deleteSnapshot = async function deleteSnapshot(trackId, modified) { snapshot_modified: new Date(snapshot.modified).toISOString(), }); } + await require('./draft-cleanup-service').recordScheduledResult(snapshot); await dynamicRepo.deleteSnapshot(trackId, modified); await contentManifestService.discardUnreferenced(trackId, [snapshot.content_manifest_id]); diff --git a/app/services/release-tracks/versioning-service.js b/app/services/release-tracks/versioning-service.js index 17544970..ca41f1a7 100644 --- a/app/services/release-tracks/versioning-service.js +++ b/app/services/release-tracks/versioning-service.js @@ -18,6 +18,7 @@ const contentManifestService = require('./content-manifest-service'); const publicationService = require('./publication-service'); const bundleHashService = require('./bundle-hash-service'); const registryRepo = require('../../repository/release-tracks/release-track-registry.repository'); +const draftCleanupService = require('./draft-cleanup-service'); const uuid = require('uuid'); const logger = require('../../lib/logger'); const { @@ -368,6 +369,9 @@ async function planLoadedSnapshot(trackId, snapshot, options) { plan.plannedSnapshot.members, ); } + if (snapshot.type === 'virtual') { + plan.summary.draft_squash = await draftCleanupService.previewSquash(trackId, snapshot); + } return plan; } @@ -402,7 +406,7 @@ async function refreshReleaseArtifacts(tagged) { } exports.refreshReleaseArtifacts = refreshReleaseArtifacts; -async function commitPlan(plan) { +async function commitPlan(plan, lease) { if (plan.blockingError) throw plan.blockingError; const source = plan.sourceSnapshot; @@ -425,9 +429,11 @@ async function commitPlan(plan) { } setOps.publication = await publicationService.freezePublication(source); setOps.bundle_id = `bundle--${uuid.v4()}`; + if (source.type === 'virtual') setOps.release_event_id = plan.releaseEventId || uuid.v4(); let tagged; try { + await lease.assertOwned(); if (source.type === 'standard') { const releaseSnapshot = { ...plan.plannedSnapshot, ...setOps }; delete releaseSnapshot._id; @@ -459,6 +465,7 @@ async function commitPlan(plan) { } const withArtifacts = await refreshReleaseArtifacts(tagged); + await lease.assertOwned(); await releaseHistoryService.reconcileTaggedReleases(plan.trackId); if (source.type === 'standard') { @@ -495,8 +502,22 @@ async function withReleaseLock(trackId, operation) { }); } + const lease = { + async assertOwned() { + const renewed = await registryRepo.renewReleaseLock( + trackId, + token, + new Date(Date.now() - RELEASE_LOCK_TIMEOUT_MS), + ); + if (!renewed) { + throw new ReleaseConflictError('The release-track lifecycle lease was lost', { + track_id: trackId, + }); + } + }, + }; try { - return await operation(); + return await operation(lease); } finally { try { await registryRepo.releaseReleaseLock(trackId, token); @@ -531,15 +552,45 @@ exports.planReleaseByModified = async function planReleaseByModified( return planLoadedSnapshot(trackId, snapshot, options); }; +async function releaseLocked(trackId, loadPlan, options) { + if (options.squash_drafts) draftCleanupService.assertAdmin(options.actor); + return withReleaseLock(trackId, async (lease) => { + const plan = await loadPlan(); + const intent = options.squash_drafts + ? await draftCleanupService.beginSquash(plan, options, lease) + : null; + if (intent) plan.releaseEventId = intent.event_id; + let released; + try { + released = await commitPlan(plan, lease); + } catch (error) { + if (intent) throw await draftCleanupService.failRelease(intent, error); + throw error; + } + if (!intent) return released; + intent.cleanup = { + kind: 'squash', + eligible_count: intent.request.eligible_count, + deleted_count: 0, + protected_count: 0, + cursor: null, + pending_batch: [], + release_committed: true, + publication_complete: true, + }; + return { ...released, draft_cleanup: await draftCleanupService.runCleanup(intent, lease) }; + }); +} + exports.releaseLatest = async function releaseLatest(trackId, options = {}) { - return withReleaseLock(trackId, async () => - commitPlan(await exports.planLatestRelease(trackId, options)), - ); + return releaseLocked(trackId, () => exports.planLatestRelease(trackId, options), options); }; exports.releaseByModified = async function releaseByModified(trackId, modified, options = {}) { - return withReleaseLock(trackId, async () => - commitPlan(await exports.planReleaseByModified(trackId, modified, options)), + return releaseLocked( + trackId, + () => exports.planReleaseByModified(trackId, modified, options), + options, ); }; diff --git a/app/services/release-tracks/virtual-track-service.js b/app/services/release-tracks/virtual-track-service.js index 2dc1d3a5..5fe4e940 100644 --- a/app/services/release-tracks/virtual-track-service.js +++ b/app/services/release-tracks/virtual-track-service.js @@ -19,6 +19,7 @@ const CreationCause = require('../../lib/release-tracks/snapshot-creation-causes // ============================================================================= const snapshotService = require('./snapshot-service'); +const draftCleanup = require('./draft-cleanup-service'); const primaryRevisionService = require('./primary-revision-service'); const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); const registryRepo = require('../../repository/release-tracks/release-track-registry.repository'); @@ -470,30 +471,37 @@ exports.updateComposition = async function updateComposition( userId, options = {}, ) { - const source = await snapshotService.getLatestSnapshot(trackId); - assertVirtualTrack(source); - - // Validate all component tracks - await validateComponentTracks(composition.component_tracks); - - const snapshot = await snapshotService.cloneSnapshot( - trackId, - source, - { - composition, - members: [], - quarantine: [], - composition_resolution: null, - scheduled_materialization: options.scheduledMaterialization, - }, - { creationCause: CreationCause.CompositionUpdated, userAccountId: userId }, - ); + return require('./versioning-service').withReleaseLock(trackId, async (lease) => { + const existing = await require('./draft-cleanup-service').findScheduledResult( + trackId, + options.scheduledMaterialization, + ); + if (existing) return existing; + const source = await snapshotService.getLatestSnapshot(trackId); + assertVirtualTrack(source); - logger.verbose( - `VirtualTrackService: Updated composition for track "${trackId}" ` + - `(${composition.component_tracks.length} component track(s))`, - ); - return snapshot; + // Validate all component tracks + await validateComponentTracks(composition.component_tracks); + + const snapshot = await snapshotService.cloneSnapshot( + trackId, + source, + { + composition, + members: [], + quarantine: [], + composition_resolution: null, + scheduled_materialization: options.scheduledMaterialization, + }, + { creationCause: CreationCause.CompositionUpdated, userAccountId: userId, lease }, + ); + + logger.verbose( + `VirtualTrackService: Updated composition for track "${trackId}" ` + + `(${composition.component_tracks.length} component track(s))`, + ); + return snapshot; + }); }; /** @@ -503,25 +511,38 @@ exports.updateComposition = async function updateComposition( * * @param {string} trackId * @param {Object} schedule + * @param {Object} actor * @returns {Promise<{snapshot_schedule: Object}>} */ -exports.updateSchedule = async function updateSchedule(trackId, schedule) { - const registry = await registryRepo.findByTrackId(trackId); - if (!registry) { - throw new TrackNotFoundError(trackId); - } - if (registry.type !== 'virtual') { - throw new BadRequestError({ - message: 'This operation is only available for virtual release tracks', - details: `Track ${trackId} is a ${registry.type} track`, - }); - } - - const updated = await registryRepo.setSnapshotSchedule(trackId, schedule); - logger.verbose( - `VirtualTrackService: Updated snapshot schedule for track "${trackId}" to ${schedule.mode}`, - ); - return { snapshot_schedule: updated.snapshot_schedule }; +exports.updateSchedule = async function updateSchedule(trackId, schedule, actor) { + return require('./versioning-service').withReleaseLock(trackId, async (lease) => { + const registry = await registryRepo.findByTrackId(trackId); + if (!registry) { + throw new TrackNotFoundError(trackId); + } + if (registry.type !== 'virtual') { + throw new BadRequestError({ + message: 'This operation is only available for virtual release tracks', + details: `Track ${trackId} is a ${registry.type} track`, + }); + } + const previousMaximum = + registry.snapshot_schedule?.mode === 'cron' + ? (registry.snapshot_schedule.draft_retention?.max_drafts ?? null) + : null; + if ( + schedule.mode === 'cron' && + (schedule.draft_retention?.max_drafts ?? null) !== previousMaximum + ) { + draftCleanup.assertAdmin(actor); + } + await lease.assertOwned(); + const updated = await registryRepo.setSnapshotSchedule(trackId, schedule); + logger.verbose( + `VirtualTrackService: Updated snapshot schedule for track "${trackId}" to ${schedule.mode}`, + ); + return { snapshot_schedule: updated.snapshot_schedule }; + }); }; /** @@ -537,82 +558,121 @@ exports.updateSchedule = async function updateSchedule(trackId, schedule) { * @returns {Promise} The new snapshot with composition_resolution metadata */ exports.createVirtualSnapshot = async function createVirtualSnapshot(trackId, options = {}) { - const scheduledFor = options.scheduledMaterialization?.scheduled_for; - if (scheduledFor) { - const existing = await dynamicRepo.getSnapshotByScheduledMaterialization(trackId, scheduledFor); + return require('./versioning-service').withReleaseLock(trackId, async (lease) => { + // Only an internal scheduler option can select recurring policy. Public + // occurrence metadata is an idempotency receipt, never cleanup authority. + const manualPolicy = + options.draft_retention !== undefined + ? draftCleanup.validatePolicy(options.draft_retention, options.actor, 'virtual') + : null; + let retention = null; + if (options.useRecurringRetention === true) { + const registry = await registryRepo.findByTrackId(trackId, 'snapshot_schedule'); + const schedule = registry?.snapshot_schedule; + if (schedule?.mode === 'cron' && schedule.draft_retention?.max_drafts != null) { + retention = { + source: 'recurring', + policy: schedule.draft_retention, + actor: { kind: 'system' }, + }; + } + } else if (manualPolicy?.max_drafts != null) { + retention = { source: 'manual', policy: manualPolicy, actor: options.actor }; + } + const scheduledFor = options.scheduledMaterialization?.scheduled_for; + const existing = await require('./draft-cleanup-service').findScheduledResult( + trackId, + options.scheduledMaterialization, + ); if (existing) return existing; - } - - const source = await snapshotService.getLatestSnapshot(trackId); - assertVirtualTrack(source); - - const composition = source.composition; - if (!composition || !composition.component_tracks || composition.component_tracks.length === 0) { - throw new BadRequestError({ - message: 'Cannot create virtual snapshot: no component tracks configured', - details: 'Update the composition before creating a snapshot', - }); - } - // Hold component release locks from resolution through persistence. Rollback - // cannot pass its dependency scan while a new dependent is being created. - // Sorted acquisition and fail-fast conflicts also release partial lock sets. - const componentIds = [ - ...new Set(composition.component_tracks.map((entry) => entry.track_id)), - ].sort(); - const { withReleaseLock } = require('./versioning-service'); - async function withComponentLocks(index) { - if (index === componentIds.length) return materialize(); - return withReleaseLock(componentIds[index], () => withComponentLocks(index + 1)); - } - return withComponentLocks(0); + const source = await snapshotService.getLatestSnapshot(trackId); + assertVirtualTrack(source); - async function materialize() { - // Validate component tracks - const registryMap = await validateComponentTracks(composition.component_tracks); + const composition = source.composition; + if ( + !composition || + !composition.component_tracks || + composition.component_tracks.length === 0 + ) { + throw new BadRequestError({ + message: 'Cannot create virtual snapshot: no component tracks configured', + details: 'Update the composition before creating a snapshot', + }); + } - // Resolve composition - const { members, quarantined, compositionResolution } = await resolveComposition( - source, - registryMap, - ); - await primaryRevisionService.assertStoredEntries([...members, ...quarantined]); - - // Build overrides for the new snapshot - const overrides = { - members, - quarantine: quarantined, - composition_resolution: compositionResolution, - scheduled_materialization: options.scheduledMaterialization, - snapshot_description: options.description, - }; - - let snapshot; - try { - snapshot = await snapshotService.cloneSnapshot(trackId, source, overrides, { - creationCause: options.scheduledMaterialization - ? CreationCause.ScheduledSnapshot - : CreationCause.ManualSnapshot, - userAccountId: - options.userAccountId || (options.scheduledMaterialization ? 'system' : undefined), + // Hold component release locks from resolution through persistence. Rollback + // cannot pass its dependency scan while a new dependent is being created. + // Sorted acquisition and fail-fast conflicts also release partial lock sets. + const componentIds = [ + ...new Set(composition.component_tracks.map((entry) => entry.track_id)), + ].sort(); + const { withReleaseLock } = require('./versioning-service'); + const componentLeases = []; + async function withComponentLocks(index) { + if (index === componentIds.length) return materialize(); + return withReleaseLock(componentIds[index], (componentLease) => { + componentLeases.push(componentLease); + return withComponentLocks(index + 1); }); - } catch (err) { - if (!scheduledFor || !(err instanceof DuplicateIdError)) throw err; + } + return withComponentLocks(0); + + async function materialize() { + // Validate component tracks + const registryMap = await validateComponentTracks(composition.component_tracks); - const existing = await dynamicRepo.getSnapshotByScheduledMaterialization( - trackId, - scheduledFor, + // Resolve composition + const { members, quarantined, compositionResolution } = await resolveComposition( + source, + registryMap, + ); + await primaryRevisionService.assertStoredEntries([...members, ...quarantined]); + const materializationLease = { + async assertOwned() { + await lease.assertOwned(); + for (const componentLease of componentLeases) await componentLease.assertOwned(); + }, + }; + + // Build overrides for the new snapshot + const overrides = { + members, + quarantine: quarantined, + composition_resolution: compositionResolution, + scheduled_materialization: options.scheduledMaterialization, + snapshot_description: options.description, + }; + + let snapshot; + try { + snapshot = await snapshotService.cloneSnapshot(trackId, source, overrides, { + lease: materializationLease, + retention, + creationCause: options.scheduledMaterialization + ? CreationCause.ScheduledSnapshot + : CreationCause.ManualSnapshot, + userAccountId: + options.userAccountId || (options.scheduledMaterialization ? 'system' : undefined), + }); + } catch (err) { + if (!scheduledFor || !(err instanceof DuplicateIdError)) throw err; + + const existing = await dynamicRepo.getSnapshotByScheduledMaterialization( + trackId, + scheduledFor, + ); + if (!existing) throw err; + snapshot = existing; + } + + logger.verbose( + `VirtualTrackService: Created virtual snapshot for track "${trackId}" ` + + `(${members.length} members, ${quarantined.length} quarantined)`, ); - if (!existing) throw err; - snapshot = existing; + return snapshot; } - - logger.verbose( - `VirtualTrackService: Created virtual snapshot for track "${trackId}" ` + - `(${members.length} members, ${quarantined.length} quarantined)`, - ); - return snapshot; - } + }); }; /** @@ -632,48 +692,50 @@ exports.promoteQuarantinedObject = async function promoteQuarantinedObject( selection, userId, ) { - const source = await snapshotService.getLatestSnapshot(trackId); - assertVirtualTrack(source); - - const selectedTime = new Date(selection.object_modified).getTime(); - const selected = (source.quarantine || []).find( - (entry) => - entry.object_ref === selection.object_ref && - new Date(entry.object_modified).getTime() === selectedTime, - ); + return require('./versioning-service').withReleaseLock(trackId, async (lease) => { + const source = await snapshotService.getLatestSnapshot(trackId); + assertVirtualTrack(source); + + const selectedTime = new Date(selection.object_modified).getTime(); + const selected = (source.quarantine || []).find( + (entry) => + entry.object_ref === selection.object_ref && + new Date(entry.object_modified).getTime() === selectedTime, + ); - if (!selected) { - throw new NotFoundError({ - details: - `Revision '${selection.object_modified}' of '${selection.object_ref}' ` + - `was not found in the latest snapshot's quarantine tier`, - }); - } + if (!selected) { + throw new NotFoundError({ + details: + `Revision '${selection.object_modified}' of '${selection.object_ref}' ` + + `was not found in the latest snapshot's quarantine tier`, + }); + } - const members = (source.members || []) - .filter((entry) => entry.object_ref !== selected.object_ref) - .concat({ - object_ref: selected.object_ref, - object_modified: selected.object_modified, - }); - const quarantine = (source.quarantine || []).filter( - (entry) => entry.object_ref !== selected.object_ref, - ); - await primaryRevisionService.assertStoredEntries([...members, ...quarantine]); - - const snapshot = await snapshotService.cloneSnapshot( - trackId, - source, - { - members, - quarantine, - }, - { creationCause: CreationCause.QuarantinePromoted, userAccountId: userId }, - ); + const members = (source.members || []) + .filter((entry) => entry.object_ref !== selected.object_ref) + .concat({ + object_ref: selected.object_ref, + object_modified: selected.object_modified, + }); + const quarantine = (source.quarantine || []).filter( + (entry) => entry.object_ref !== selected.object_ref, + ); + await primaryRevisionService.assertStoredEntries([...members, ...quarantine]); - logger.verbose( - `VirtualTrackService: Promoted quarantined revision "${selected.object_ref}" ` + - `at ${new Date(selected.object_modified).toISOString()} in track "${trackId}"`, - ); - return snapshot; + const snapshot = await snapshotService.cloneSnapshot( + trackId, + source, + { + members, + quarantine, + }, + { creationCause: CreationCause.QuarantinePromoted, userAccountId: userId, lease }, + ); + + logger.verbose( + `VirtualTrackService: Promoted quarantined revision "${selected.object_ref}" ` + + `at ${new Date(selected.object_modified).toISOString()} in track "${trackId}"`, + ); + return snapshot; + }); }; diff --git a/app/tests/api/release-tracks/draft-lifecycle.spec.js b/app/tests/api/release-tracks/draft-lifecycle.spec.js new file mode 100644 index 00000000..39b71233 --- /dev/null +++ b/app/tests/api/release-tracks/draft-lifecycle.spec.js @@ -0,0 +1,1041 @@ +'use strict'; + +const request = require('supertest'); +const { expect } = require('expect'); +const config = require('../../../config/config'); +const database = require('../../../lib/database-in-memory'); +const databaseConfiguration = require('../../../lib/database-configuration'); +const login = require('../../shared/login'); +const service = require('../../../services/release-tracks/release-tracks-service'); +const snapshotService = require('../../../services/release-tracks/snapshot-service'); +const virtual = require('../../../services/release-tracks/virtual-track-service'); +const versioning = require('../../../services/release-tracks/versioning-service'); +const cleanup = require('../../../services/release-tracks/draft-cleanup-service'); +const manifests = require('../../../services/release-tracks/content-manifest-service'); +const bundleHashes = require('../../../services/release-tracks/bundle-hash-service'); +const publication = require('../../../services/release-tracks/publication-service'); +const dynamicRepo = require('../../../repository/release-tracks/release-track-dynamic.repository'); +const registryRepo = require('../../../repository/release-tracks/release-track-registry.repository'); +const auditRepo = require('../../../repository/release-tracks/release-track-audit-event.repository'); +const occurrenceRepo = require('../../../repository/release-tracks/virtual-track-schedule-occurrence.repository'); +const modelFactory = require('../../../models/release-tracks/model-factory'); +const { + ReleaseTrackContentManifest, + ReleaseTrackContentManifestEntry, +} = require('../../../models/release-tracks/release-track-content-manifest-model'); +const { ReleaseConflictError, InsufficientRoleError } = require('../../../exceptions'); +const admin = { role: 'admin', user_account_id: 'identity--00000000-0000-4000-8000-000000000901' }; +const editor = { ...admin, role: 'editor' }; +const iso = (value) => new Date(value).toISOString(); + +// These tests exercise observable history, exports, recovery, and lock conflicts. +// Faults are restored in finally blocks so the shared API process remains usable. +describe('Virtual draft lifecycle API', function () { + let app; + let cookie; + let fixtureNumber = 0; + + before(async function () { + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + app = await require('../../../index').initializeApp(); + cookie = await login.loginAnonymous(app); + }); + + after(async function () { + await database.closeConnection(); + }); + + async function api(method, path, body, status = 200) { + const call = request(app) + [method](path) + .set('Accept', 'application/json') + .set('Cookie', `${cookie.name}=${cookie.value}`); + if (body !== undefined) call.send(body); + return (await call.expect(status)).body; + } + + function base(track) { + return `/api/release-tracks/${track.id}`; + } + function selected(track, snapshot) { + return `${base(track)}/snapshots/${encodeURIComponent(iso(snapshot.modified))}`; + } + async function history(track) { + return api('get', `${base(track)}/snapshots?limit=200&offset=0`); + } + async function draft(track, description = 'Draft') { + return api('post', `${base(track)}/meta`, { description }); + } + async function policy(track, maximum) { + return api('put', `${base(track)}/virtual/schedule`, { + mode: 'cron', + cron: '0 0 * * *', + draft_retention: { max_drafts: maximum }, + }); + } + async function materialize(track, maximum, options = {}) { + return api( + 'post', + `${base(track)}/virtual/snapshots/create`, + { + ...(maximum === undefined ? {} : { draft_retention: { max_drafts: maximum } }), + ...options, + }, + 201, + ); + } + async function recurringDraft(track) { + return JSON.parse( + JSON.stringify( + await virtual.createVirtualSnapshot(track.id, { useRecurringRetention: true }), + ), + ); + } + async function preview(track, snapshot) { + return api('get', `${selected(track, snapshot)}/release/preview`); + } + async function squash(track, target, version = '1.0') { + const reviewed = await preview(track, target); + return api('post', `${selected(track, target)}/release`, { + version, + squash_drafts: true, + squash_fingerprint: reviewed.draft_squash.fingerprint, + }); + } + + async function fixture() { + fixtureNumber += 1; + const component = await api( + 'post', + '/api/release-tracks/new', + { name: `Lifecycle Component ${fixtureNumber}`, type: 'standard' }, + 201, + ); + const composition = { + component_tracks: [ + { track_id: component.id, resolution_strategy: 'latest_preview', priority: 0 }, + ], + }; + const track = await api( + 'post', + '/api/release-tracks/new', + { name: `Lifecycle Virtual ${fixtureNumber}`, type: 'virtual', composition }, + 201, + ); + const materialized = await api('post', `${base(track)}/virtual/snapshots/create`, {}, 201); + return { track, materialized, component, composition }; + } + + it('saves cron policy without a content snapshot and never projects a global policy', async function () { + const { track } = await fixture(); + await draft(track, 'Unlimited first'); + const latest = await draft(track, 'Unlimited second'); + const before = await history(track); + expect(before.pagination.total).toBe(4); + const saved = await policy(track, 1); + expect(saved.snapshot_schedule.draft_retention).toEqual({ max_drafts: 1 }); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual( + before.data.map((entry) => entry.modified), + ); + const oldView = await api('get', selected(track, track)); + expect(oldView.snapshot_schedule).toEqual(saved.snapshot_schedule); + expect(oldView).not.toHaveProperty('draft_retention'); + expect(oldView.snapshot_count).toBe(4); + expect(oldView.tagged_release_count).toBe(0); + expect(await api('get', `${base(track)}/config`)).not.toHaveProperty('draft_retention'); + const clone = await api( + 'post', + `${selected(track, latest)}/clone`, + { name: `Lifecycle Clone ${fixtureNumber}` }, + 201, + ); + expect((await api('get', `${base(clone)}/snapshots/latest`)).snapshot_schedule).toEqual({ + mode: 'manual', + }); + expect((await history(track)).pagination.total).toBe(4); + await api('put', `${base(track)}/virtual/draft-retention`, { max_drafts: 1 }, 404); + await api( + 'post', + '/api/release-tracks/new', + { name: 'Retired Policy Input', type: 'virtual', draft_retention: { max_drafts: 1 } }, + 400, + ); + }); + + it('rejects destructive policy values and enforces administrator authorization in the service', async function () { + const { track, materialized, component } = await fixture(); + for (const value of [0, -1, 1.5, '10', Number.MAX_SAFE_INTEGER + 1]) { + await api( + 'post', + `${base(track)}/virtual/snapshots/create`, + { draft_retention: { max_drafts: value } }, + 400, + ); + await api( + 'put', + `${base(track)}/virtual/schedule`, + { mode: 'cron', cron: '0 0 * * *', draft_retention: { max_drafts: value } }, + 400, + ); + } + for (const mode of ['manual', 'dates']) { + await api( + 'put', + `${base(track)}/virtual/schedule`, + { + mode, + ...(mode === 'dates' ? { dates: ['2030-01-01T00:00:00.000Z'] } : {}), + draft_retention: { max_drafts: 1 }, + }, + 400, + ); + } + await api( + 'post', + `${base(track)}/virtual/snapshots/create`, + { useRecurringRetention: true }, + 400, + ); + await expect( + service.createVirtualSnapshot(track.id, { + draft_retention: { max_drafts: 1 }, + actor: editor, + }), + ).rejects.toBeInstanceOf(InsufficientRoleError); + await expect( + service.createVirtualSnapshot(track.id, { draft_retention: null, actor: editor }), + ).rejects.toBeInstanceOf(InsufficientRoleError); + await expect( + service.createTrack({ + name: 'Forbidden Retention', + type: 'virtual', + snapshot_schedule: { mode: 'cron', cron: '0 0 * * *', draft_retention: { max_drafts: 1 } }, + actor: editor, + }), + ).rejects.toBeInstanceOf(InsufficientRoleError); + await expect( + versioning.releaseByModified(track.id, materialized.modified, { + squash_drafts: true, + actor: editor, + }), + ).rejects.toBeInstanceOf(InsufficientRoleError); + await expect( + cleanup.retry(track.id, '00000000-0000-4000-8000-000000000902', editor), + ).rejects.toBeInstanceOf(InsufficientRoleError); + await api( + 'post', + `${base(component)}/snapshots/latest/release`, + { squash_drafts: true, squash_fingerprint: 'not valid' }, + 400, + ); + // Ordinary tagging remains an editor operation, without any destructive opt-in. + expect( + (await versioning.releaseByModified(track.id, materialized.modified, { actor: editor })) + .version, + ).toBe('1.0'); + }); + + it('never cleans unrelated clones or metadata-spoofed materialization; explicit cleanup counts every cause', async function () { + const { track, composition } = await fixture(); + await policy(track, 1); + // A stored pre-cutover root policy is inert even when read through lean queries. + await registryRepo.model.collection.updateOne( + { track_id: track.id }, + { $set: { draft_retention: { max_drafts: 1 } } }, + ); + const writes = [ + () => draft(track, 'Metadata counted'), + () => api('put', `${base(track)}/config`, { auto_promote: true }), + () => api('put', `${base(track)}/virtual/composition`, composition), + () => api('post', `${base(track)}/virtual/snapshots/create`, {}, 201), + () => + api( + 'post', + `${base(track)}/virtual/snapshots/create`, + { + scheduled_materialization: { + schedule_mode: 'cron', + scheduled_for: '2030-02-01T00:00:00.000Z', + }, + }, + 201, + ), + () => + api( + 'post', + `${base(track)}/virtual/snapshots/create`, + { + scheduled_materialization: { + schedule_mode: 'dates', + scheduled_for: '2030-01-01T00:00:00.000Z', + }, + }, + 201, + ), + ]; + let total = 2; + for (const write of writes) { + const created = await write(); + expect(created).not.toHaveProperty('draft_cleanup'); + const remaining = await history(track); + expect(remaining.pagination.total).toBe(++total); + } + const technique = await api( + 'post', + '/api/techniques', + { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: 'attack-pattern', + spec_version: '2.1', + name: 'Lifecycle Quarantine', + created: '2026-01-01T00:00:00.000Z', + modified: '2026-01-01T00:00:00.000Z', + x_mitre_platforms: ['Windows'], + x_mitre_is_subtechnique: false, + kill_chain_phases: [{ kill_chain_name: 'mitre-attack', phase_name: 'persistence' }], + }, + }, + 201, + ); + const latest = await dynamicRepo.getLatestSnapshot(track.id); + await modelFactory.getModel(track.id).updateOne( + { modified: latest.modified }, + { + $set: { + quarantine: [ + { + object_ref: technique.stix.id, + object_modified: technique.stix.modified, + source_track_id: composition.component_tracks[0].track_id, + source_track_name: 'Lifecycle source', + source_snapshot_version: null, + conflict_reason: 'Alternative exact revision', + }, + ], + }, + }, + ); + const promoted = await api('post', `${base(track)}/virtual/quarantine/promote`, { + object_ref: technique.stix.id, + object_modified: technique.stix.modified, + }); + expect(promoted.creation_cause).toBe('quarantine_promoted'); + expect(promoted).not.toHaveProperty('draft_cleanup'); + expect((await history(track)).pagination.total).toBe(++total); + const retainedObject = await api( + 'get', + `/api/techniques/${technique.stix.id}/modified/${technique.stix.modified}`, + ); + expect(retainedObject.stix.id).toBe(technique.stix.id); + expect( + retainedObject.workspace.release_tracks.find((entry) => entry.id === track.id), + ).toMatchObject({ + type: 'virtual', + tier: 'members', + }); + const cleaned = await materialize(track, 1); + expect(cleaned.draft_cleanup).toMatchObject({ status: 'completed', deleted_count: total }); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([cleaned.modified]); + }); + + it('keeps ad-hoc retention independent of the recurring policy and defaults every manual run to unlimited', async function () { + const { track } = await fixture(); + await policy(track, 1); + await draft(track, 'Retain this metadata'); + const retained = await draft(track, 'Retain this too'); + const created = await materialize(track, 3); + expect(created.draft_cleanup).toMatchObject({ status: 'completed', deleted_count: 2 }); + expect((await history(track)).pagination.total).toBe(3); + expect((await api('get', selected(track, retained))).modified).toBe(retained.modified); + await materialize(track); + await materialize(track, null); + await materialize(track, undefined, { draft_retention: null }); + const latest = await api('get', `${base(track)}/snapshots/latest`); + expect(latest.snapshot_schedule.draft_retention).toEqual({ max_drafts: 1 }); + expect(latest.snapshot_count).toBe(6); + expect(latest.tagged_release_count).toBe(0); + }); + + it('retries manual cleanup with its immutable limit and original cutoff after later cron edits', async function () { + const { track } = await fixture(); + const boundary = await draft(track, 'Original retained boundary'); + const retained = await draft(track, 'Original retained draft'); + await policy(track, 10); + const remove = dynamicRepo.deleteHistoricalDraft; + let created; + try { + dynamicRepo.deleteHistoricalDraft = async () => { + throw new Error('Pause manual cleanup before deletion'); + }; + created = await materialize(track, 3); + } finally { + dynamicRepo.deleteHistoricalDraft = remove; + } + expect(created.draft_cleanup.status).toBe('failed'); + await policy(track, 1); + const newer = await draft(track, 'Later history remains outside the original request'); + await policy(track, null); + const repaired = await cleanup.retry(track.id, created.draft_cleanup.operation_id, admin); + expect(repaired).toMatchObject({ status: 'completed', deleted_count: 2 }); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([ + newer.modified, + created.modified, + retained.modified, + boundary.modified, + ]); + }); + + it('repairs legacy retention storage without adopting the intent as a new manual cleanup', async function () { + const { track, materialized } = await fixture(); + const latest = await draft(track, 'Legacy cleanup target'); + await policy(track, 1); + const event = await auditRepo.create({ + action: 'draft_retention', + trackId: track.id, + actor: { kind: 'system' }, + confirmation: track.id, + request: { + kind: 'retention', + lower_bound: null, + upper_bound: latest.modified, + target_modified: latest.modified, + }, + }); + await auditRepo.saveCleanup(event.event_id, { + kind: 'retention', + eligible_count: 2, + deleted_count: 0, + protected_count: 0, + cursor: null, + pending_batch: [track, materialized], + creation_complete: true, + }); + await dynamicRepo.deleteSnapshot(track.id, track.modified); + const repaired = await cleanup.retry(track.id, event.event_id, admin); + expect(repaired).toMatchObject({ status: 'completed', deleted_count: 1, protected_count: 1 }); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([ + latest.modified, + materialized.modified, + ]); + expect( + await ReleaseTrackContentManifest.exists({ manifest_id: track.content_manifest_id }), + ).toBeNull(); + expect((await api('get', `${base(track)}/snapshots/latest`)).snapshot_count).toBe(2); + }); + + it('keeps ten drafts across release boundaries while tags never consume the count', async function () { + const { track, materialized } = await fixture(); + const released = await api('post', `${selected(track, materialized)}/release`, { + version: '1.0', + }); + const drafts = []; + for (let index = 0; index < 12; index += 1) + drafts.push(await materialize(track, 10, { description: `Count ${index}` })); + const all = await history(track); + expect(all.pagination.total).toBe(11); + expect( + all.data.filter((entry) => entry.version == null).map((entry) => entry.modified), + ).toEqual( + drafts + .slice(-10) + .reverse() + .map((entry) => entry.modified), + ); + expect(all.data.find((entry) => entry.version === '1.0').modified).toBe(released.modified); + const latest = await api('get', `${base(track)}/snapshots/latest`); + expect([latest.snapshot_count, latest.tagged_release_count]).toEqual([11, 1]); + }); + + it('squashes first and historical releases by snapshot chronology, preserving newer history', async function () { + const { track, materialized } = await fixture(); + const selectedDraft = await draft(track, 'Selected first release'); + const newerDraft = await draft(track, 'Newer draft must survive'); + const laterRelease = await draft(track, 'Tag later first'); + await api('post', `${selected(track, laterRelease)}/release`, { version: '3.0' }); + const reviewed = await preview(track, selectedDraft); + expect(reviewed.draft_squash).toMatchObject({ + lower_bound: null, + upper_bound: selectedDraft.modified, + eligible_count: 2, + protected_count: 0, + }); + const first = await squash(track, selectedDraft); + expect(first.modified).toBe(selectedDraft.modified); + expect(first.content_manifest_id).toBe(selectedDraft.content_manifest_id); + expect(first.draft_cleanup).toMatchObject({ + status: 'completed', + deleted_count: 2, + release_committed: true, + }); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([ + laterRelease.modified, + newerDraft.modified, + selectedDraft.modified, + ]); + await api('get', selected(track, materialized), undefined, 404); + const lowerDraft = await dynamicRepo.getSnapshotByModified(track.id, newerDraft.modified); + expect((await preview(track, lowerDraft)).draft_squash.lower_bound).toBe( + selectedDraft.modified, + ); + }); + + it('rejects a stale squash preview before any tag or history deletion', async function () { + const { track, materialized } = await fixture(); + const target = await draft(track, 'Stale target'); + const reviewed = await preview(track, target); + await api('post', `${selected(track, materialized)}/release`, { version: '1.0' }); + const before = await history(track); + await api( + 'post', + `${selected(track, target)}/release`, + { + version: '2.0', + squash_drafts: true, + squash_fingerprint: reviewed.draft_squash.fingerprint, + }, + 409, + ); + expect((await history(track)).data).toEqual(before.data); + expect((await api('get', selected(track, target))).version).toBeNull(); + }); + + it('preserves shared manifests and surviving exports, and removes only orphan manifest storage', async function () { + const { track, materialized } = await fixture(); + const orphanId = track.content_manifest_id; + const released = await api('post', `${selected(track, materialized)}/release`, { + version: '1.0', + }); + const beforeBundle = await api('get', `${selected(track, released)}?format=bundle`); + const sharedDraft = await draft(track, 'Shares released manifest'); + expect(sharedDraft.content_manifest_id).toBe(released.content_manifest_id); + const latest = await materialize(track, 1); + expect(await ReleaseTrackContentManifest.exists({ manifest_id: orphanId })).toBeNull(); + expect(await ReleaseTrackContentManifestEntry.countDocuments({ manifest_id: orphanId })).toBe( + 0, + ); + expect( + await ReleaseTrackContentManifest.exists({ manifest_id: released.content_manifest_id }), + ).not.toBeNull(); + expect(await api('get', `${selected(track, released)}?format=bundle`)).toEqual(beforeBundle); + expect((await api('get', selected(track, released))).bundle_hashes).toEqual( + released.bundle_hashes, + ); + const stored = await dynamicRepo.getSnapshotByModified(track.id, latest.modified); + expect(stored).not.toHaveProperty('draft_cleanup'); + expect(stored).not.toHaveProperty('draft_retention'); + }); + + it('skips source-protected excess and secures receipts before removing scheduled drafts', async function () { + const { track, materialized } = await fixture(); + const released = await api('post', `${selected(track, materialized)}/release`, { + version: '1.0', + }); + // Preserve a legacy source pointer even though new virtual releases tag in place. + await modelFactory + .getModel(track.id) + .updateOne( + { modified: released.modified }, + { $set: { release_source_modified: track.modified } }, + ); + const scheduledFor = '2031-01-01T00:00:00.000Z'; + const scheduled = await api( + 'post', + `${base(track)}/virtual/snapshots/create`, + { scheduled_materialization: { schedule_mode: 'dates', scheduled_for: scheduledFor } }, + 201, + ); + const latest = await materialize(track, 1); + expect(latest.draft_cleanup.protected_count).toBe(1); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([ + latest.modified, + released.modified, + track.modified, + ]); + expect( + iso( + (await occurrenceRepo.getMaterializationReceipt(track.id, scheduledFor)).snapshot_modified, + ), + ).toBe(scheduled.modified); + const conflict = await api( + 'post', + `${base(track)}/virtual/snapshots/create`, + { scheduled_materialization: { schedule_mode: 'dates', scheduled_for: scheduledFor } }, + 409, + ); + expect(conflict).toMatchObject({ already_materialized: true, snapshot_removed: true }); + expect((await history(track)).pagination.total).toBe(3); + }); + + it('repairs failed manifest cleanup after retention is disabled without choosing more drafts', async function () { + const { track } = await fixture(); + await draft(track, 'Second old draft'); + await policy(track, 1); + const discard = manifests.discardUnreferenced; + let created; + try { + manifests.discardUnreferenced = async () => { + throw new Error('Injected orphan cleanup failure'); + }; + created = await recurringDraft(track); + } finally { + manifests.discardUnreferenced = discard; + } + expect(created.draft_cleanup.status).toBe('failed'); + const countBeforeRetry = (await history(track)).pagination.total; + await policy(track, null); + const discovered = await api('get', `${base(track)}/virtual/draft-cleanup`); + expect(discovered.data.map((entry) => entry.operation_id)).toContain( + created.draft_cleanup.operation_id, + ); + const repaired = await api( + 'post', + `${base(track)}/virtual/draft-cleanup/${created.draft_cleanup.operation_id}/retry`, + {}, + ); + expect(repaired.status).toBe('completed'); + expect((await history(track)).pagination.total).toBe(countBeforeRetry); + const registry = await registryRepo.findByTrackId(track.id); + expect(registry.snapshot_count).toBe(countBeforeRetry); + const snapshots = (await dynamicRepo.getAllSnapshots(track.id)).data; + const survivingIds = [...new Set(snapshots.map((entry) => entry.content_manifest_id))]; + expect( + (await ReleaseTrackContentManifest.find({ track_id: track.id }).lean()) + .map((entry) => entry.manifest_id) + .sort(), + ).toEqual(survivingIds.sort()); + }); + + it('narrows recurring retries to the current cron threshold and stops selection after leaving cron', async function () { + const { track } = await fixture(); + const retained = await draft(track, 'Retained by the replacement threshold'); + await policy(track, 1); + const remove = dynamicRepo.deleteHistoricalDraft; + let first; + try { + dynamicRepo.deleteHistoricalDraft = async () => { + throw new Error('Pause recurring cleanup before deletion'); + }; + first = await recurringDraft(track); + } finally { + dynamicRepo.deleteHistoricalDraft = remove; + } + expect(first.draft_cleanup.status).toBe('failed'); + const newer = await draft(track, 'New draft beyond the cleanup cutoff'); + await policy(track, 3); + const narrowed = await cleanup.retry(track.id, first.draft_cleanup.operation_id, admin); + expect(narrowed).toMatchObject({ status: 'completed', deleted_count: 2 }); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([ + newer.modified, + first.modified, + retained.modified, + ]); + + await policy(track, 1); + let second; + try { + dynamicRepo.deleteHistoricalDraft = async () => { + throw new Error('Pause recurring cleanup before leaving cron'); + }; + second = await recurringDraft(track); + } finally { + dynamicRepo.deleteHistoricalDraft = remove; + } + expect(second.draft_cleanup.status).toBe('failed'); + await service.updateSchedule(track.id, { mode: 'manual' }, editor); + const stopped = await cleanup.retry(track.id, second.draft_cleanup.operation_id, admin); + expect(stopped).toMatchObject({ status: 'completed', deleted_count: 0 }); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([ + second.modified, + newer.modified, + first.modified, + retained.modified, + ]); + }); + + it('finishes partial release publication through cleanup retry, never a repeated tag', async function () { + const { track } = await fixture(); + const target = await draft(track, 'Partial publication target'); + const reviewed = await preview(track, target); + const generate = bundleHashes.generateBundleHashes; + let failed; + try { + bundleHashes.generateBundleHashes = async () => { + throw new Error('Injected publication failure'); + }; + failed = await api( + 'post', + `${selected(track, target)}/release`, + { + version: '1.0', + squash_drafts: true, + squash_fingerprint: reviewed.draft_squash.fingerprint, + }, + 500, + ); + } finally { + bundleHashes.generateBundleHashes = generate; + } + expect(failed.release_committed).toBe(true); + expect((await history(track)).pagination.total).toBe(3); + await api('post', `${selected(track, target)}/release`, { version: '1.0' }, 409); + const repaired = await api( + 'post', + `${base(track)}/virtual/draft-cleanup/${failed.operation_id}/retry`, + {}, + ); + expect(repaired).toMatchObject({ + status: 'completed', + deleted_count: 2, + release_committed: true, + }); + const release = await api('get', selected(track, target)); + expect(release.bundle_hashes).toBeDefined(); + expect( + release.version_history.filter((entry) => entry.snapshot_id === target.modified), + ).toHaveLength(1); + expect((await history(track)).pagination.total).toBe(1); + }); + + it('does not resume a squash against a rolled-back and re-released timestamp', async function () { + const { track } = await fixture(); + const target = await draft(track, 'Original release identity'); + const remove = dynamicRepo.deleteHistoricalDraft; + let first; + try { + dynamicRepo.deleteHistoricalDraft = async () => { + throw new Error('Injected deletion failure'); + }; + first = await squash(track, target); + } finally { + dynamicRepo.deleteHistoricalDraft = remove; + } + expect(first.draft_cleanup).toMatchObject({ status: 'failed', release_committed: true }); + await api('post', `${selected(track, target)}/draft`, { confirm_version: '1.0' }); + await api('post', `${selected(track, target)}/release`, { version: '1.0' }); + const before = await history(track); + const retry = await api( + 'post', + `${base(track)}/virtual/draft-cleanup/${first.draft_cleanup.operation_id}/retry`, + {}, + ); + expect(retry).toMatchObject({ status: 'failed', release_committed: false, deleted_count: 0 }); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual( + before.data.map((entry) => entry.modified), + ); + }); + + it('narrows a pending squash around a newly inserted tag without crossing it', async function () { + const { track, materialized } = await fixture(); + const middle = await draft(track, 'New boundary'); + const target = await draft(track, 'Upper boundary'); + const remove = dynamicRepo.deleteHistoricalDraft; + let released; + try { + dynamicRepo.deleteHistoricalDraft = async () => { + throw new Error('Pause squash'); + }; + released = await squash(track, target, '3.0'); + } finally { + dynamicRepo.deleteHistoricalDraft = remove; + } + await api('post', `${selected(track, middle)}/release`, { version: '2.0' }); + const repaired = await api( + 'post', + `${base(track)}/virtual/draft-cleanup/${released.draft_cleanup.operation_id}/retry`, + {}, + ); + expect(repaired.status).toBe('completed'); + expect((await api('get', selected(track, materialized))).version).toBeNull(); + expect((await history(track)).pagination.total).toBe(4); + }); + + it('keeps successful releases when final audit recording fails and retries only repair', async function () { + const { track } = await fixture(); + const target = await draft(track, 'Audit completion failure'); + const save = auditRepo.saveCleanup; + let released; + try { + auditRepo.saveCleanup = async function (eventId, state, status, error) { + if (status === 'completed') throw new Error('Injected audit completion failure'); + return save.call(this, eventId, state, status, error); + }; + released = await squash(track, target); + } finally { + auditRepo.saveCleanup = save; + } + expect(released.draft_cleanup).toMatchObject({ status: 'failed', deleted_count: 2 }); + expect((await history(track)).pagination.total).toBe(1); + const repaired = await api( + 'post', + `${base(track)}/virtual/draft-cleanup/${released.draft_cleanup.operation_id}/retry`, + {}, + ); + expect(repaired).toMatchObject({ status: 'completed', deleted_count: 2 }); + expect((await api('get', selected(track, target))).bundle_id).toBe(released.bundle_id); + }); + + it('persists nested cron policy but never applies it to creation or scheduled composition clones', async function () { + const { component, composition } = await fixture(); + const initialScheduled = { schedule_mode: 'dates', scheduled_for: '2032-01-01T00:00:00.000Z' }; + const track = await api( + 'post', + '/api/release-tracks/new', + { + name: `Lifecycle Configured ${fixtureNumber}`, + type: 'virtual', + composition, + snapshot_schedule: { mode: 'cron', cron: '0 0 * * *', draft_retention: { max_drafts: 1 } }, + scheduled_materialization: initialScheduled, + }, + 201, + ); + expect((await api('get', `${base(track)}/snapshots/latest`)).snapshot_schedule).toEqual({ + mode: 'cron', + cron: '0 0 * * *', + draft_retention: { max_drafts: 1 }, + }); + expect( + iso( + (await occurrenceRepo.getMaterializationReceipt(track.id, initialScheduled.scheduled_for)) + .snapshot_modified, + ), + ).toBe(track.modified); + const nextScheduled = { schedule_mode: 'dates', scheduled_for: '2032-02-01T00:00:00.000Z' }; + const updated = await api('put', `${base(track)}/virtual/composition`, { + component_tracks: [ + { track_id: component.id, resolution_strategy: 'latest_preview', priority: 0 }, + ], + scheduled_materialization: nextScheduled, + }); + expect(updated).not.toHaveProperty('draft_cleanup'); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([ + updated.modified, + track.modified, + ]); + expect( + iso( + (await occurrenceRepo.getMaterializationReceipt(track.id, nextScheduled.scheduled_for)) + .snapshot_modified, + ), + ).toBe(updated.modified); + await materialize(track, 1); + await api( + 'put', + `${base(track)}/virtual/composition`, + { + ...composition, + scheduled_materialization: nextScheduled, + }, + 409, + ); + expect((await history(track)).pagination.total).toBe(1); + }); + + it('keeps a scheduled candidate when its receipt cannot be durably secured', async function () { + const { track } = await fixture(); + const scheduledFor = '2033-01-01T00:00:00.000Z'; + const scheduled = await api( + 'post', + `${base(track)}/virtual/snapshots/create`, + { + scheduled_materialization: { schedule_mode: 'dates', scheduled_for: scheduledFor }, + }, + 201, + ); + const record = occurrenceRepo.recordMaterialization; + let latest; + try { + occurrenceRepo.recordMaterialization = async () => { + throw new Error('Receipt store unavailable'); + }; + latest = await materialize(track, 1); + } finally { + occurrenceRepo.recordMaterialization = record; + } + expect(latest.draft_cleanup).toMatchObject({ status: 'completed', protected_count: 1 }); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([ + latest.modified, + scheduled.modified, + ]); + }); + + it('retains manifest headers until failed entry deletion can be repaired', async function () { + const { track } = await fixture(); + const manifestId = track.content_manifest_id; + const originalEntries = await ReleaseTrackContentManifestEntry.countDocuments({ + manifest_id: manifestId, + }); + const removeEntries = ReleaseTrackContentManifestEntry.deleteMany; + let latest; + try { + ReleaseTrackContentManifestEntry.deleteMany = function (query) { + if (query.manifest_id === manifestId) + return { + exec: async () => { + throw new Error('Entry deletion unavailable'); + }, + }; + return removeEntries.call(this, query); + }; + latest = await materialize(track, 1); + } finally { + ReleaseTrackContentManifestEntry.deleteMany = removeEntries; + } + expect(latest.draft_cleanup.status).toBe('failed'); + expect(await ReleaseTrackContentManifest.exists({ manifest_id: manifestId })).not.toBeNull(); + expect(await ReleaseTrackContentManifestEntry.countDocuments({ manifest_id: manifestId })).toBe( + originalEntries, + ); + const repaired = await api( + 'post', + `${base(track)}/virtual/draft-cleanup/${latest.draft_cleanup.operation_id}/retry`, + {}, + ); + expect(repaired.status).toBe('completed'); + expect(repaired.error).toBeUndefined(); + expect(await ReleaseTrackContentManifest.exists({ manifest_id: manifestId })).toBeNull(); + expect(await ReleaseTrackContentManifestEntry.countDocuments({ manifest_id: manifestId })).toBe( + 0, + ); + expect((await history(track)).data.map((entry) => entry.modified)).toEqual([latest.modified]); + }); + + it('deletes nothing when publication fails before tagging and retry never tags implicitly', async function () { + const { track } = await fixture(); + const target = await draft(track, 'Failed before tag'); + const reviewed = await preview(track, target); + const freeze = publication.freezePublication; + let failed; + try { + publication.freezePublication = async () => { + throw new Error('Cannot freeze publication'); + }; + failed = await api( + 'post', + `${selected(track, target)}/release`, + { + squash_drafts: true, + squash_fingerprint: reviewed.draft_squash.fingerprint, + }, + 500, + ); + } finally { + publication.freezePublication = freeze; + } + expect(failed.release_committed).toBe(false); + const retried = await api( + 'post', + `${base(track)}/virtual/draft-cleanup/${failed.operation_id}/retry`, + {}, + ); + expect(retried).toMatchObject({ status: 'failed', release_committed: false, deleted_count: 0 }); + expect((await history(track)).pagination.total).toBe(3); + expect((await api('get', selected(track, target))).version).toBeNull(); + }); + + it('repairs persisted draft completion before attempting retention after a counter failure', async function () { + const { track } = await fixture(); + const update = registryRepo.updateByTrackId; + let created; + try { + registryRepo.updateByTrackId = async function (id, values) { + if (id === track.id && values.snapshot_count !== undefined) + throw new Error('Counter repair unavailable'); + return update.call(this, id, values); + }; + created = await materialize(track, 1); + } finally { + registryRepo.updateByTrackId = update; + } + expect(created.draft_cleanup.status).toBe('failed'); + expect((await history(track)).pagination.total).toBe(3); + const repaired = await api( + 'post', + `${base(track)}/virtual/draft-cleanup/${created.draft_cleanup.operation_id}/retry`, + {}, + ); + expect(repaired).toMatchObject({ status: 'completed', deleted_count: 2 }); + expect((await api('get', `${base(track)}/snapshots/latest`)).modified).toBe(created.modified); + expect((await registryRepo.findByTrackId(track.id)).snapshot_count).toBe(1); + }); + + it('serializes materialization and deletion while a metadata writer has read its source', async function () { + const { track } = await fixture(); + await policy(track, 1); + const readLatest = snapshotService.getLatestSnapshot; + let enter; + let resume; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const paused = new Promise((resolve) => { + resume = resolve; + }); + let first = true; + let writing; + try { + snapshotService.getLatestSnapshot = async function (trackId, options) { + const source = await readLatest(trackId, options); + if (trackId === track.id && first) { + first = false; + enter(); + await paused; + } + return source; + }; + writing = service.updateMetadata(track.id, { description: 'Serialized metadata winner' }); + await entered; + await expect(service.createVirtualSnapshot(track.id, {})).rejects.toBeInstanceOf( + ReleaseConflictError, + ); + await expect(service.deleteTrack(track.id, admin, track.id)).rejects.toBeInstanceOf( + ReleaseConflictError, + ); + } finally { + resume(); + snapshotService.getLatestSnapshot = readLatest; + if (writing) await writing; + } + const latest = await api('get', `${base(track)}/snapshots/latest`); + expect(latest.description).toBe('Serialized metadata winner'); + expect((await history(track)).pagination.total).toBe(3); + expect((await api('get', `${selected(track, latest)}?format=bundle`)).type).toBe('bundle'); + }); + + it('locks every virtual source read against deletion and refuses an expired cleanup lease', async function () { + const { track, composition } = await fixture(); + const before = (await history(track)).pagination.total; + await versioning.withReleaseLock(track.id, async (lease) => { + const competing = [ + () => service.updateMetadata(track.id, { description: 'blocked' }), + () => service.updateConfig(track.id, { auto_promote: true }), + () => service.updateComposition(track.id, composition), + () => service.updateSchedule(track.id, { mode: 'manual' }, editor), + () => service.createVirtualSnapshot(track.id, {}), + () => + service.promoteQuarantinedObject(track.id, { + object_ref: 'attack-pattern--00000000-0000-4000-8000-000000000903', + object_modified: new Date(), + }), + () => snapshotService.cloneTrack(track.id, { name: 'Blocked clone' }), + () => + snapshotService.cloneFromSnapshot(track.id, track.modified, { + name: 'Blocked exact clone', + }), + () => service.deleteTrack(track.id, admin, track.id), + ]; + for (const operation of competing) + await expect(operation()).rejects.toBeInstanceOf(ReleaseConflictError); + await registryRepo.model.updateOne( + { track_id: track.id }, + { $set: { 'release_lock.acquired_at': new Date(0) } }, + ); + await expect(lease.assertOwned()).rejects.toBeInstanceOf(ReleaseConflictError); + }); + expect((await history(track)).pagination.total).toBe(before); + }); +}); diff --git a/app/tests/api/release-tracks/snapshot-history.spec.js b/app/tests/api/release-tracks/snapshot-history.spec.js index 3102145f..304bdf6d 100644 --- a/app/tests/api/release-tracks/snapshot-history.spec.js +++ b/app/tests/api/release-tracks/snapshot-history.spec.js @@ -6,6 +6,7 @@ const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const login = require('../../shared/login'); const dynamicRepo = require('../../../repository/release-tracks/release-track-dynamic.repository'); +const registryRepo = require('../../../repository/release-tracks/release-track-registry.repository'); const { ReleaseTrackContentManifestEntry, } = require('../../../models/release-tracks/release-track-content-manifest-model'); @@ -130,6 +131,22 @@ describe('GET /api/release-tracks/:id/snapshots', function () { staged: [stagedEntry(1, standardLatestModified), stagedEntry(2, standardLatestModified)], candidates: [candidateEntry(3, standardLatestModified)], }); + await registryRepo.updateByTrackId(standardTrack.id, { + latest_snapshot_modified: standardLatestModified, + snapshot_count: 3, + }); + await registryRepo.replaceTaggedReleases( + standardTrack.id, + [ + { + snapshot_modified: standardTaggedModified, + version: '1.0', + tagged_at: standardTaggedModified, + tagged_by: 'snapshot-history-test', + }, + ], + '1.0', + ); const virtualCreated = new Date(virtualTrack.modified); const virtualTaggedModified = new Date(virtualCreated.getTime() + 1000); @@ -176,6 +193,22 @@ describe('GET /api/release-tracks/:id/snapshots', function () { }, ], }); + await registryRepo.updateByTrackId(virtualTrack.id, { + latest_snapshot_modified: virtualTaggedModified, + snapshot_count: 2, + }); + await registryRepo.replaceTaggedReleases( + virtualTrack.id, + [ + { + snapshot_modified: virtualTaggedModified, + version: '1.0', + tagged_at: virtualTaggedModified, + tagged_by: 'snapshot-history-test', + }, + ], + '1.0', + ); }); async function createTrack(name, type) { @@ -232,6 +265,11 @@ describe('GET /api/release-tracks/:id/snapshots', function () { limit: 50, offset: 0, }); + expect(response.body.counts).toEqual({ tagged: 1, drafts: 2, total: 3 }); + expect(response.body.latest_snapshot_modified).toBe(standardLatestModified.toISOString()); + expect(response.body.latest_tagged_snapshot_modified).toBe( + standardTaggedModified.toISOString(), + ); expect(response.body.data).toHaveLength(3); expect(response.body.data[0]).toMatchObject({ id: standardTrack.id, @@ -285,6 +323,9 @@ describe('GET /api/release-tracks/:id/snapshots', function () { const response = await get(`/api/release-tracks/${virtualTrack.id}/snapshots?tagged=true`); expect(response.body.pagination.total).toBe(1); + expect(response.body.counts).toEqual({ tagged: 1, drafts: 0, total: 1 }); + expect(response.body.latest_snapshot_modified).toBe(response.body.data[0].modified); + expect(response.body.latest_tagged_snapshot_modified).toBe(response.body.data[0].modified); expect(response.body.data).toHaveLength(1); expect(response.body.data[0]).toMatchObject({ id: virtualTrack.id, @@ -325,6 +366,9 @@ describe('GET /api/release-tracks/:id/snapshots', function () { limit: 1, offset: 0, }); + expect(tagged.body.counts).toEqual({ tagged: 1, drafts: 0, total: 1 }); + expect(tagged.body.latest_snapshot_modified).toBe(standardLatestModified.toISOString()); + expect(tagged.body.latest_tagged_snapshot_modified).toBe(standardTaggedModified.toISOString()); expect(tagged.body.data.map((snapshot) => snapshot.version)).toEqual(['1.0']); const untagged = await get( @@ -335,10 +379,122 @@ describe('GET /api/release-tracks/:id/snapshots', function () { limit: 1, offset: 1, }); + expect(untagged.body.counts).toEqual({ tagged: 0, drafts: 2, total: 2 }); + expect(untagged.body.latest_snapshot_modified).toBe(standardLatestModified.toISOString()); + expect(untagged.body.latest_tagged_snapshot_modified).toBe( + standardTaggedModified.toISOString(), + ); expect(untagged.body.data).toHaveLength(1); expect(untagged.body.data[0].version).toBeNull(); }); + it('retains full matching counts on partial and off-end pages', async function () { + const partial = await get(`/api/release-tracks/${standardTrack.id}/snapshots?limit=1&offset=1`); + expect(partial.body.counts).toEqual({ tagged: 1, drafts: 2, total: 3 }); + expect(partial.body.pagination).toEqual({ total: 3, limit: 1, offset: 1 }); + expect(partial.body.data.map((snapshot) => snapshot.modified)).toEqual([ + standardTaggedModified.toISOString(), + ]); + + for (const [filter, counts] of [ + ['', { tagged: 1, drafts: 2, total: 3 }], + ['&tagged=true', { tagged: 1, drafts: 0, total: 1 }], + ['&tagged=false', { tagged: 0, drafts: 2, total: 2 }], + ]) { + const response = await get( + `/api/release-tracks/${standardTrack.id}/snapshots?limit=1&offset=10${filter}`, + ); + expect(response.body.data).toEqual([]); + expect(response.body.counts).toEqual(counts); + expect(response.body.pagination).toEqual({ total: counts.total, limit: 1, offset: 10 }); + expect(response.body.latest_snapshot_modified).toBe(standardLatestModified.toISOString()); + expect(response.body.latest_tagged_snapshot_modified).toBe( + standardTaggedModified.toISOString(), + ); + } + }); + + it('reports no releases for a draft-only track without losing its latest identity', async function () { + const track = await createTrack('History Draft Only', 'virtual'); + const all = await get(`/api/release-tracks/${track.id}/snapshots`); + expect(all.body.counts).toEqual({ tagged: 0, drafts: 1, total: 1 }); + expect(all.body.latest_tagged_snapshot_modified).toBeNull(); + + const releases = await get(`/api/release-tracks/${track.id}/snapshots?tagged=true`); + expect(releases.body.data).toEqual([]); + expect(releases.body.counts).toEqual({ tagged: 0, drafts: 0, total: 0 }); + expect(releases.body.pagination.total).toBe(0); + expect(releases.body.latest_snapshot_modified).toBe(track.modified); + expect(releases.body.latest_tagged_snapshot_modified).toBeNull(); + }); + + it('uses chronological release identities on historical pages of a release-only track', async function () { + const track = await createTrack('History Releases Only', 'standard'); + await dynamicRepo.updateSnapshot(track.id, track.modified, { $set: { version: '9.0' } }); + const newerModified = new Date(new Date(track.modified).getTime() + 1000); + await dynamicRepo.saveSnapshot(track.id, { + ...snapshotBase(track), + modified: newerModified, + version: '1.0', + }); + await registryRepo.updateByTrackId(track.id, { + latest_snapshot_modified: newerModified, + snapshot_count: 2, + }); + // The newest snapshot comes first and has the lower version: neither the + // final ledger entry nor the highest semantic version identifies Latest. + await registryRepo.replaceTaggedReleases( + track.id, + [ + { + snapshot_modified: newerModified, + version: '1.0', + tagged_at: newerModified, + tagged_by: 'snapshot-history-test', + }, + { + snapshot_modified: track.modified, + version: '9.0', + tagged_at: track.modified, + tagged_by: 'snapshot-history-test', + }, + ], + '9.0', + ); + + const releases = await get( + `/api/release-tracks/${track.id}/snapshots?tagged=true&limit=1&offset=1`, + ); + expect(releases.body.data.map((snapshot) => snapshot.version)).toEqual(['9.0']); + expect(releases.body.counts).toEqual({ tagged: 2, drafts: 0, total: 2 }); + expect(releases.body.pagination.total).toBe(2); + expect(releases.body.latest_snapshot_modified).toBe(newerModified.toISOString()); + expect(releases.body.latest_tagged_snapshot_modified).toBe(newerModified.toISOString()); + + const drafts = await get(`/api/release-tracks/${track.id}/snapshots?tagged=false`); + expect(drafts.body.data).toEqual([]); + expect(drafts.body.counts).toEqual({ tagged: 0, drafts: 0, total: 0 }); + expect(drafts.body.pagination.total).toBe(0); + expect(drafts.body.latest_snapshot_modified).toBe(newerModified.toISOString()); + expect(drafts.body.latest_tagged_snapshot_modified).toBe(newerModified.toISOString()); + }); + + it('returns zero counts and null identities for an empty track history', async function () { + const track = await createTrack('History Empty', 'virtual'); + await dynamicRepo.deleteSnapshot(track.id, track.modified); + await registryRepo.updateByTrackId(track.id, { + latest_snapshot_modified: null, + snapshot_count: 0, + }); + + const response = await get(`/api/release-tracks/${track.id}/snapshots?limit=1&offset=10`); + expect(response.body.data).toEqual([]); + expect(response.body.counts).toEqual({ tagged: 0, drafts: 0, total: 0 }); + expect(response.body.pagination).toEqual({ total: 0, limit: 1, offset: 10 }); + expect(response.body.latest_snapshot_modified).toBeNull(); + expect(response.body.latest_tagged_snapshot_modified).toBeNull(); + }); + it('retrieves the latest snapshot from the canonical endpoint', async function () { const response = await get(`/api/release-tracks/${standardTrack.id}/snapshots/latest`); @@ -357,6 +513,8 @@ describe('GET /api/release-tracks/:id/snapshots', function () { await get(`/api/release-tracks/${standardTrack.id}/snapshots?limit=0`, 400); await get(`/api/release-tracks/${standardTrack.id}/snapshots?limit=201`, 400); await get(`/api/release-tracks/${standardTrack.id}/snapshots?offset=-1`, 400); + await get(`/api/release-tracks/${standardTrack.id}/snapshots?versions=all`, 400); + await get(`/api/release-tracks/${standardTrack.id}/snapshots?include=members`, 400); }); it('returns 404 when the release track does not exist', async function () { diff --git a/app/tests/api/release-tracks/virtual-snapshot-schedule-validation.spec.js b/app/tests/api/release-tracks/virtual-snapshot-schedule-validation.spec.js index 9fdc8324..bae0d384 100644 --- a/app/tests/api/release-tracks/virtual-snapshot-schedule-validation.spec.js +++ b/app/tests/api/release-tracks/virtual-snapshot-schedule-validation.spec.js @@ -9,6 +9,8 @@ const databaseConfiguration = require('../../../lib/database-configuration'); const login = require('../../shared/login'); const ReleaseTrackRegistry = require('../../../models/release-tracks/release-track-registry-model'); const releaseTracksService = require('../../../services/release-tracks/release-tracks-service'); +const { InsufficientRoleError } = require('../../../exceptions'); +const editor = { role: 'editor' }; describe('Virtual release-track snapshot schedule validation API', function () { let app; @@ -65,6 +67,7 @@ describe('Virtual release-track snapshot schedule validation API', function () { const schedules = [ { mode: 'manual' }, { mode: 'cron', cron: '0 0 1 1,7 *' }, + { mode: 'cron', cron: '0 0 * * *', draft_retention: { max_drafts: 10 } }, { mode: 'dates', dates: ['2027-01-15T00:00:00.000Z', '2027-07-15T00:00:00.000Z'], @@ -137,11 +140,50 @@ describe('Virtual release-track snapshot schedule validation API', function () { expect(() => releaseTracksService.updateSchedule(virtual.body.id, { mode: 'cron' })).toThrow(); }); + it('allows editor timing edits but requires admin for every changed cron retention threshold', async function () { + const created = await createTrack({ + mode: 'cron', + cron: '0 0 * * *', + draft_retention: { max_drafts: 10 }, + }); + const trackId = created.body.id; + const changedTiming = { + mode: 'cron', + cron: '30 1 * * *', + draft_retention: { max_drafts: 10 }, + }; + await releaseTracksService.updateSchedule(trackId, changedTiming, editor); + expect((await getRegistryTrack(created.name)).snapshot_schedule).toEqual(changedTiming); + for (const draftRetention of [{ max_drafts: 1 }, { max_drafts: null }, undefined]) { + await expect( + releaseTracksService.updateSchedule( + trackId, + { mode: 'cron', cron: changedTiming.cron, draft_retention: draftRetention }, + editor, + ), + ).rejects.toBeInstanceOf(InsufficientRoleError); + } + expect((await getRegistryTrack(created.name)).snapshot_schedule).toEqual(changedTiming); + await releaseTracksService.updateSchedule( + trackId, + { mode: 'dates', dates: ['2030-01-01T00:00:00.000Z'] }, + editor, + ); + await expect( + releaseTracksService.updateSchedule(trackId, changedTiming, editor), + ).rejects.toBeInstanceOf(InsufficientRoleError); + await releaseTracksService.updateSchedule(trackId, { mode: 'manual' }, editor); + const registry = await getRegistryTrack(created.name); + expect(registry.snapshot_schedule).toEqual({ mode: 'manual' }); + expect(registry.snapshot_count).toBe(1); + }); + it('rejects fields that do not apply to manual schedules', async function () { const invalidSchedules = [ { mode: 'manual', cron: '0 0 1 1,7 *' }, { mode: 'manual', dates: ['2027-01-15T00:00:00.000Z'] }, { mode: 'manual', unexpected: true }, + { mode: 'manual', draft_retention: { max_drafts: 1 } }, ]; for (const schedule of invalidSchedules) { @@ -174,6 +216,11 @@ describe('Virtual release-track snapshot schedule validation API', function () { dates: ['2027-01-15T00:00:00.000Z'], cron: '0 0 1 1,7 *', }, + { + mode: 'dates', + dates: ['2027-01-15T00:00:00.000Z'], + draft_retention: { max_drafts: 1 }, + }, ]; for (const schedule of invalidSchedules) { diff --git a/app/tests/scheduler/virtual-track-snapshots-task.spec.js b/app/tests/scheduler/virtual-track-snapshots-task.spec.js index 885f57ff..807211db 100644 --- a/app/tests/scheduler/virtual-track-snapshots-task.spec.js +++ b/app/tests/scheduler/virtual-track-snapshots-task.spec.js @@ -10,6 +10,8 @@ const databaseConfiguration = require('../../lib/database-configuration'); const ReleaseTrackRegistry = require('../../models/release-tracks/release-track-registry-model'); const VirtualTrackScheduleOccurrence = require('../../models/release-tracks/virtual-track-schedule-occurrence-model'); const dynamicRepo = require('../../repository/release-tracks/release-track-dynamic.repository'); +const occurrenceRepo = require('../../repository/release-tracks/virtual-track-schedule-occurrence.repository'); +const { ReleaseConflictError } = require('../../exceptions'); const releaseTracksService = require('../../services/release-tracks/release-tracks-service'); describe('Scheduled virtual release-track materialization', function () { @@ -18,6 +20,7 @@ describe('Scheduled virtual release-track materialization', function () { before(async function () { config.scheduler.enableScheduler = false; + config.validateRequests.withAttackDataModel = true; await database.initializeConnection(); await databaseConfiguration.checkSystemConfiguration(); task = require('../../scheduler/virtual-track-snapshots-task'); @@ -58,6 +61,7 @@ describe('Scheduled virtual release-track materialization', function () { ], }, snapshot_schedule: snapshotSchedule, + actor: { role: 'admin' }, }); } @@ -178,6 +182,50 @@ describe('Scheduled virtual release-track materialization', function () { ).toBe(1); }); + it('applies only saved cron retention on trusted recurring execution, never on manual or date runs', async function () { + const component = await createComponent(); + const virtual = await createVirtual(component.id, { + mode: 'cron', + cron: '0 0 * * *', + draft_retention: { max_drafts: 2 }, + }); + await releaseTracksService.updateMetadata(virtual.id, { description: 'Count metadata too' }); + await releaseTracksService.createVirtualSnapshot(virtual.id); + const spoofed = await releaseTracksService.createVirtualSnapshot(virtual.id, { + scheduledMaterialization: { + schedule_mode: 'cron', + scheduled_for: new Date('2026-07-01T00:00:00.000Z'), + }, + }); + expect(spoofed).not.toHaveProperty('draft_cleanup'); + expect(await snapshotCount(virtual.id)).toBe(4); + const scheduled = await task.executeCronOccurrence( + virtual.id, + new Date('2026-07-02T00:00:00.000Z'), + ); + expect(scheduled.draft_cleanup).toMatchObject({ status: 'completed', deleted_count: 3 }); + expect(await snapshotCount(virtual.id)).toBe(2); + const remaining = (await dynamicRepo.getAllSnapshots(virtual.id)).data; + expect(remaining.map((entry) => entry.modified)).toEqual([ + scheduled.modified, + spoofed.modified, + ]); + + const scheduledFor = new Date('2026-07-03T00:00:00.000Z'); + await releaseTracksService.updateSchedule( + virtual.id, + { mode: 'dates', dates: [scheduledFor.toISOString()] }, + { role: 'editor' }, + ); + await task.reconcileSchedules(scheduledFor); + expect(await snapshotCount(virtual.id)).toBe(3); + const dateSnapshot = await dynamicRepo.getSnapshotByScheduledMaterialization( + virtual.id, + scheduledFor, + ); + expect(dateSnapshot.scheduled_materialization.schedule_mode).toBe('dates'); + }); + it('registers cron tracks in UTC and removes their jobs after track deletion', async function () { const component = await createComponent(); const virtual = await createVirtual(component.id, { @@ -218,6 +266,7 @@ describe('Scheduled virtual release-track materialization', function () { expect(occurrence).toMatchObject({ status: 'failed', attempt_count: 1, + snapshot_modified: null, }); expect(occurrence.last_error.message).toContain('has no tagged snapshots'); expect(await snapshotCount(virtual.id)).toBe(1); @@ -308,6 +357,11 @@ describe('Scheduled virtual release-track materialization', function () { scheduled_for: scheduledFor, }, }); + // Simulate a legacy save/crash before the receipt was durably written. + await VirtualTrackScheduleOccurrence.updateOne( + { track_id: virtual.id, scheduled_for: scheduledFor }, + { $set: { snapshot_modified: null } }, + ); // A persisted scheduled snapshot is the authoritative result. Recovery // must not depend on the component still being available. @@ -341,6 +395,189 @@ describe('Scheduled virtual release-track materialization', function () { }); }); + it('recovers a pruned result after save but before completion even after schedule removal', async function () { + const component = await createComponent(); + const scheduledFor = new Date('2026-08-01T00:00:00.000Z'); + const virtual = await createVirtual(component.id, { + mode: 'dates', + dates: [scheduledFor.toISOString()], + }); + await occurrenceRepo.register(virtual.id, 'dates', scheduledFor); + await occurrenceRepo.claim( + virtual.id, + scheduledFor, + scheduledFor, + new Date(scheduledFor.getTime() + 5 * 60 * 1000), + ); + const materialized = await releaseTracksService.createVirtualSnapshot(virtual.id, { + scheduledMaterialization: { schedule_mode: 'dates', scheduled_for: scheduledFor }, + }); + expect(await occurrenceRepo.getMaterializationReceipt(virtual.id, scheduledFor)).toMatchObject({ + status: 'running', + snapshot_modified: materialized.modified, + }); + + await dynamicRepo.deleteSnapshot(virtual.id, materialized.modified); + await releaseTracksService.deleteTrack(component.id); + await ReleaseTrackRegistry.updateOne( + { track_id: virtual.id }, + { $set: { snapshot_schedule: { mode: 'manual' } } }, + ); + await task.reconcileSchedules(new Date(scheduledFor.getTime() + 10 * 60 * 1000)); + + expect(await snapshotCount(virtual.id)).toBe(1); + expect(await occurrenceRepo.getMaterializationReceipt(virtual.id, scheduledFor)).toMatchObject({ + status: 'completed', + attempt_count: 2, + snapshot_modified: materialized.modified, + }); + const db = mongoose.connection.getClient().db(); + const run = await db.collection('automationRuns').findOne({ 'scope.track_id': virtual.id }); + expect(run).toMatchObject({ + status: 'completed', + counts: { materialized: 0, recovered: 1, failed: 0 }, + }); + const item = await db.collection('automationRunItems').findOne({ run_id: run.run_id }); + expect(item.details).toMatchObject({ + snapshot_modified: materialized.modified, + materialized_and_removed: true, + recovered: true, + }); + expect(item.details).not.toHaveProperty('members_count'); + }); + + it('fences stale completion, failure and skip and never replays a completed pruned result', async function () { + const component = await createComponent(); + const scheduledFor = new Date('2026-09-01T00:00:00.000Z'); + const virtual = await createVirtual(component.id, { + mode: 'dates', + dates: [scheduledFor.toISOString()], + }); + await occurrenceRepo.register(virtual.id, 'dates', scheduledFor); + const expires = new Date(scheduledFor.getTime() + 5 * 60 * 1000); + const stale = await occurrenceRepo.claim(virtual.id, scheduledFor, scheduledFor, expires); + const owner = await occurrenceRepo.claim( + virtual.id, + scheduledFor, + expires, + new Date(expires.getTime() + 5 * 60 * 1000), + ); + expect(await occurrenceRepo.fail(stale, { message: 'stale failure' }, expires)).toBeNull(); + expect(await occurrenceRepo.skip(stale, 'stale skip')).toBeNull(); + + const materialized = await releaseTracksService.createVirtualSnapshot(virtual.id, { + scheduledMaterialization: { schedule_mode: 'dates', scheduled_for: scheduledFor }, + }); + expect(await occurrenceRepo.complete(stale)).toBeNull(); + expect(await occurrenceRepo.skip(owner, 'cannot skip saved work')).toBeNull(); + await occurrenceRepo.complete(owner); + await dynamicRepo.deleteSnapshot(virtual.id, materialized.modified); + + expect(await occurrenceRepo.fail(owner, { message: 'late failure' }, expires)).toBeNull(); + expect(await occurrenceRepo.fail(stale, { message: 'stale failure' }, expires)).toBeNull(); + expect(await occurrenceRepo.skip(stale, 'late skip')).toBeNull(); + expect(await occurrenceRepo.complete(stale)).toBeNull(); + await task.reconcileSchedules(new Date(expires.getTime() + 10 * 60 * 1000)); + expect(await snapshotCount(virtual.id)).toBe(1); + expect(await occurrenceRepo.getMaterializationReceipt(virtual.id, scheduledFor)).toMatchObject({ + status: 'completed', + attempt_count: 2, + snapshot_modified: materialized.modified, + }); + }); + + it('repairs API-originated materialization without a ledger and refuses a different receipt', async function () { + const component = await createComponent(); + const virtual = await createVirtual(component.id, { mode: 'manual' }); + const scheduledMaterialization = { + schedule_mode: 'dates', + scheduled_for: new Date('2026-10-01T00:00:00.000Z'), + }; + const materialized = await releaseTracksService.createVirtualSnapshot(virtual.id, { + scheduledMaterialization, + }); + // Older API-originated metadata did not create an occurrence ledger. + await VirtualTrackScheduleOccurrence.deleteOne({ + track_id: virtual.id, + scheduled_for: scheduledMaterialization.scheduled_for, + }); + expect( + await occurrenceRepo.getMaterializationReceipt( + virtual.id, + scheduledMaterialization.scheduled_for, + ), + ).toBeNull(); + await Promise.all([ + occurrenceRepo.recordMaterialization( + virtual.id, + scheduledMaterialization, + materialized.modified, + ), + occurrenceRepo.recordMaterialization( + virtual.id, + scheduledMaterialization, + materialized.modified, + ), + ]); + await expect( + occurrenceRepo.recordMaterialization( + virtual.id, + scheduledMaterialization, + new Date(new Date(materialized.modified).getTime() + 1), + ), + ).rejects.toBeInstanceOf(ReleaseConflictError); + + const receipt = await occurrenceRepo.getMaterializationReceipt( + virtual.id, + scheduledMaterialization.scheduled_for, + ); + await dynamicRepo.deleteSnapshot(virtual.id, materialized.modified); + expect(await task.executeOccurrence(receipt)).toEqual({ + snapshot_modified: materialized.modified, + materialized_and_removed: true, + }); + expect(await snapshotCount(virtual.id)).toBe(1); + expect( + await occurrenceRepo.getMaterializationReceipt( + virtual.id, + scheduledMaterialization.scheduled_for, + ), + ).toMatchObject({ status: 'completed', snapshot_modified: materialized.modified }); + }); + + it('retains a receipt through audit failure and retries only recovery', async function () { + const component = await createComponent(); + const scheduledFor = new Date('2026-11-01T00:00:00.000Z'); + const virtual = await createVirtual(component.id, { + mode: 'dates', + dates: [scheduledFor.toISOString()], + }); + await occurrenceRepo.register(virtual.id, 'dates', scheduledFor); + const claimed = await occurrenceRepo.claim( + virtual.id, + scheduledFor, + scheduledFor, + new Date(scheduledFor.getTime() + 5 * 60 * 1000), + ); + const materialized = await releaseTracksService.createVirtualSnapshot(virtual.id, { + scheduledMaterialization: { schedule_mode: 'dates', scheduled_for: scheduledFor }, + }); + const retryAt = new Date(scheduledFor.getTime() + 60 * 1000); + await occurrenceRepo.fail(claimed, { message: 'audit unavailable after save' }, retryAt); + expect(await occurrenceRepo.getMaterializationReceipt(virtual.id, scheduledFor)).toMatchObject({ + status: 'failed', + snapshot_modified: materialized.modified, + }); + await dynamicRepo.deleteSnapshot(virtual.id, materialized.modified); + await task.reconcileSchedules(retryAt); + expect(await snapshotCount(virtual.id)).toBe(1); + expect(await occurrenceRepo.getMaterializationReceipt(virtual.id, scheduledFor)).toMatchObject({ + status: 'completed', + attempt_count: 2, + snapshot_modified: materialized.modified, + }); + }); + it('does not schedule or materialize manual tracks', async function () { const component = await createComponent(); const virtual = await createVirtual(component.id, { mode: 'manual' }); diff --git a/docs/README.md b/docs/README.md index 7953c3fb..51eaa7c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,6 +51,8 @@ Architecture, patterns, and implementation details for contributors. - [Entities](developer/release-tracks/entities.md): Database schemas and data models - [Snapshot Creation Causes](developer/release-tracks/snapshot-creation-causes.md): Persisted creation-cause enum, standard/virtual operation mapping, and historical fallback - [Sealed Content Manifests](developer/release-tracks/sealed-content-manifests.md): Why every snapshot seals its bill of materials, how the collection object is projected, and publication inheritance +- [Deletion Guardrails](developer/release-tracks/deletion-guardrails.md): Plain-language explanation of durable scheduler receipts, coordinated snapshot changes, and recoverable cleanup +- [Draft Lifecycle Plan](developer/release-tracks/draft-lifecycle-plan.md): Approved virtual draft-retention and release-time squash implementation plan - [Backref Reconciliation](developer/release-tracks/backref-reconciliation.md): How `workspace.release_tracks` backrefs stay in sync with snapshots - [Member Sync Strategies](developer/release-tracks/member-sync-strategies.md): Automatic tracking of member object revisions - [Error Handling](developer/release-tracks/error-handling.md): Error handling patterns diff --git a/docs/admin/release-track-audit.md b/docs/admin/release-track-audit.md index 6e754a8f..a87289c2 100644 --- a/docs/admin/release-track-audit.md +++ b/docs/admin/release-track-audit.md @@ -1,10 +1,13 @@ # Release-Track Destructive Audit Events -Workbench stores administrator-initiated destructive attempts in -`releaseTrackAuditEvents`: full-track deletion (`delete_track`), rollback of a -track's most recent release (`convert_release_to_draft`), and release-version correction -(`retag_release`). The collection is empty until an administrator performs one -of those actions. +Workbench stores destructive attempts in `releaseTrackAuditEvents`: full-track +deletion (`delete_track`), release conversion (`convert_release_to_draft`), +version correction (`retag_release`), virtual draft retention (`draft_retention`), +and opt-in release-time draft squash (`draft_squash`). Recurring retention records +a system actor under the saved cron policy; one-shot retention and explicit +destructive actions record the authenticated administrator. Retention intents +record their source, applied threshold and original cutoff. Legacy unscoped +intents can repair storage but cannot select additional drafts. Older `delete_release` events retain their historical meaning. New snapshot DELETE requests reject tagged releases; conversion and draft deletion are @@ -50,8 +53,20 @@ db.releaseTrackAuditEvents ``` A `pending` event can mean the process stopped after the audit insert or the -track was deleted but the final audit update failed. Confirm whether the track -still exists before retrying. +operation committed before final audit/progress recording. Do not repeat a tag +to repair draft cleanup. Inspect +`GET /api/release-tracks/:id/virtual/draft-cleanup`; administrators can resume an +existing operation with +`POST /api/release-tracks/:id/virtual/draft-cleanup/:operationId/retry`. + +Cleanup events retain bounded selectors, the original release-event identity +where relevant, and a bounded write-ahead batch. Missing snapshots from a +partially processed batch are repaired idempotently. A retry never expands the +approved interval or attaches an old intent to a rollback/re-tagged release. +`release_committed: true` means the release exists even if cleanup failed; an +omitted outcome means a store failure prevented establishing it. + +The canonical safety explanation is [Deletion Guardrails](../developer/release-tracks/deletion-guardrails.md). These records have no automatic TTL. Establish retention and archive policy according to local audit requirements. diff --git a/docs/admin/virtual-track-schedules.md b/docs/admin/virtual-track-schedules.md index a184c35a..23cbd95d 100644 --- a/docs/admin/virtual-track-schedules.md +++ b/docs/admin/virtual-track-schedules.md @@ -26,20 +26,50 @@ Editors can replace the active schedule through immediately in track and Workbench-format snapshot responses; executable jobs are refreshed on the next `VIRTUAL_TRACK_SCHEDULES_CRON` reconciliation pass. +Recurring schedules may include administrator-managed retention: + +```json +{ + "mode": "cron", + "cron": "0 * * * *", + "draft_retention": { "max_drafts": 10 } +} +``` + +Only actual scheduler cron execution uses this persistent policy. Explicit +materialization uses only its optional request-scoped policy, and date schedules +never inherit recurring retention. Client-supplied occurrence metadata is not +authority to apply the saved policy. Manual/dates schedule payloads reject +retention fields. Missing/null limits disable cleanup. + +Editors can change timing while preserving the existing cron policy or switch +away from recurring mode; changing a cron retention limit requires an +administrator. Schedule-only updates create no snapshot and delete nothing +immediately. The former global policy is inert and must be configured explicitly +as recurring retention if desired. + ## Idempotency and multiple instances The `virtualTrackScheduleOccurrences` collection stores one durable occurrence per track and UTC timestamp. Workers atomically claim pending or retryable -occurrences. The resulting snapshot also records +occurrences with an ownership token. The resulting snapshot also records `scheduled_materialization.scheduled_for` under a unique track-local index. -Together, these controls prevent duplicate drafts across restarts, retry -delivery, and multiple scheduler-enabled API instances. - -If a worker persists the scheduled snapshot but exits before marking the -occurrence complete, the next worker treats that snapshot as the authoritative -result. It completes the occurrence from the persisted snapshot without -recomputing composition. The recovery attempt is audited as an unchanged -`recover_scheduled_virtual_snapshot` item with `counts.recovered: 1`. +The occurrence's monotonic `snapshot_modified` receipt survives snapshot cleanup; +it must not be TTL-expired while the track remains in use. + +If a worker persists a snapshot but exits before marking the occurrence complete, +the next worker recovers the existing snapshot or its receipt. A receipt whose +snapshot has been deleted means the occurrence already materialized and was +subsequently removed; it never authorizes recomputing composition. API-originated +scheduled metadata and legacy snapshots receive the same receipt protection +before deletion. Explicit attempts to recreate removed occurrences return `409`. + +Recovery is audited as `recover_scheduled_virtual_snapshot`, including +`materialized_and_removed` when no snapshot remains. Claim ownership prevents +stale worker failure/completion from reopening another worker's completed run. + +For why scheduled execution evidence must outlive retained snapshots, see +[Deletion Guardrails](../developer/release-tracks/deletion-guardrails.md). ## Failures and retries diff --git a/docs/developer/TODO.md b/docs/developer/TODO.md index af7362ed..1994e890 100644 --- a/docs/developer/TODO.md +++ b/docs/developer/TODO.md @@ -2934,6 +2934,27 @@ database. - [x] Verify the real frontend/API flow, focused regressions, and the full backend test suite; update API documentation and Bruno request notes. +## Virtual draft retention and release-time squash (2026-09-23) + +Approved plan: [Draft lifecycle](release-tracks/draft-lifecycle-plan.md). +Safety rationale: [Deletion guardrails](release-tracks/deletion-guardrails.md). + +- [x] Prepare both feature branches from `next`, preserving draft composition + and the frontend save-dialog regression fix. +- [x] Preserve the plain-language deletion guardrails explanation and link + redundant documentation to it. +- [x] Serialize virtual target lifecycle operations and preserve durable + scheduler materialization receipts after snapshot deletion. +- [x] Implement guarded, auditable, recoverable historical-draft cleanup and + reference-safe manifest deletion. +- [x] Add disabled-by-default, administrator-managed draft-count retention. +- [x] Add previewed, fingerprint-confirmed release-time draft squash and + cleanup-only status/retry. +- [x] Add frontend retention controls and paginated virtual history. +- [x] Add explicit squash consent and partial-outcome recovery feedback. +- [x] Verify API/scheduler regressions and the complete backend suite. +- [x] Exercise retention, squash, pagination and recovery on the real frontend. + ## Published versus preview composition - [x] Prepare isolated worktrees on `next` and agree on `latest_tagged` versus @@ -2958,3 +2979,31 @@ that a source with zero members, one staged object and one candidate contributes only the staged object, without changing its snapshots, tiers or release history. Virtual tagging left that source untagged; later staged revisions left the existing virtual release frozen; a tagged newest source also composed correctly. + +## Virtual draft lifecycle feedback + +- [x] Make completed cleanup feedback floating and dismissible without adding + a notification-center subsystem. +- [x] Refresh authoritative total snapshot counts after draft creation. +- [x] Center history filters and refresh controls above the cards. +- [x] Remove the redundant "Current draft (pinned)" label. +- [x] Gate persistent retention editing behind Edit Config and preserve Cancel. +- [x] Replace global retention with one-shot Create Draft policy and saved + Recurring-schedule policy; unrelated writes never trigger retention. +- [x] Verify affected regressions and the real frontend/API workflow. + +## Upstream integration and history views (2026-09-25) + +- [x] Merge upstream `next` into both feature branches, retaining local + uncommitted retention feedback and upstream preview/priority behavior. +- [x] Offer Drafts only, Releases only, and All releases with exact filtering. +- [x] Replace the manual refresh button with visible-history polling and + focus/visibility refresh, preserving page selection and edit state. +- [x] Report filtered tagged, draft, and total counts across matching pages. +- [x] Verify upstream integration regressions and the live browser workflow. + +Merged backend `a69abef9` and frontend `592c087c` from upstream `next`, preserving +the local trigger-specific retention feedback. Full backend verification passed +1,158 tests; 161 focused frontend tests passed. Browser checks covered all three +views, zero results, query-wide counts across pages, periodic external-create +refresh, focus/tab-entry refresh and preservation of page selection/unsaved edits. diff --git a/docs/developer/release-tracks/authorization.md b/docs/developer/release-tracks/authorization.md index abef107b..e360cfa9 100644 --- a/docs/developer/release-tracks/authorization.md +++ b/docs/developer/release-tracks/authorization.md @@ -17,6 +17,9 @@ history requires an administrator. | Convert the track's most recent release to draft | No | No | Yes | | Change a tagged release's semantic version | No | No | Yes | | Delete an entire track and all snapshot history | No | No | Yes | +| Inspect pending/failed virtual draft cleanup | Yes | Yes | Yes | +| Set an ad-hoc limit or change recurring draft retention | No | No | Yes | +| Opt into draft squash when tagging or retry draft cleanup | No | No | Yes | Full-track deletion also requires `confirm_track_id` to equal the `:id` path parameter. `POST /snapshots/:modified/draft` requires a JSON `confirm_version` @@ -35,10 +38,20 @@ release lock. Both conversion and retag capture audit identity under that same lock, so a competing version correction cannot invalidate confirmation or change the version between audit capture and mutation. +Supplying an ad-hoc retention policy, changing a recurring retention limit, and +explicit squash/retry use the existing global administrator role; there is no +track-specific administrator role. Editors may change cron timing while keeping +its retention policy unchanged, or switch away from recurring mode. Ordinary +tagging retains editor-or-higher access. Squash authorization and the reviewed +fingerprint are checked before tagging. Recurring retention records its system +actor; ad-hoc cleanup records the invoking administrator. + ## Audited destructive actions -The `delete_track`, `convert_release_to_draft`, and `retag_release` actions create a -`releaseTrackAuditEvents` record before the business operation begins. +The `delete_track`, `convert_release_to_draft`, `retag_release`, `draft_retention`, +and `draft_squash` actions create `releaseTrackAuditEvents` records. +Cleanup actions use bounded durable intent/progress; see +[Deletion Guardrails](deletion-guardrails.md) for the recovery rationale. The legacy `delete_release` value remains readable for historical audit events; new requests never use it. @@ -49,4 +62,9 @@ persists but final audit-state recording fails, the API returns a structured `500` containing the audit event ID instead of reporting unconditional success. +Cleanup-only failures after successful creation/release are reported in +`draft_cleanup` without undoing the committed snapshot. Interrupted publication +uses a structured `500` with the cleanup operation ID and the known release +outcome. Administrators resume via the cleanup retry endpoint, not another tag. + See the [operator audit guide](../../admin/release-track-audit.md). diff --git a/docs/developer/release-tracks/deletion-guardrails.md b/docs/developer/release-tracks/deletion-guardrails.md new file mode 100644 index 00000000..9ca682f7 --- /dev/null +++ b/docs/developer/release-tracks/deletion-guardrails.md @@ -0,0 +1,124 @@ +# Deletion Guardrails + +These safeguards prevent automatic cleanup from **recreating snapshots, deleting a newly tagged release, or leaving the database inconsistent if an operation fails halfway through**. + +They are implementation requirements for the proposed features—not additional settings you would need to manage. + +## 1. “Scheduler receipts must survive snapshot deletion” + +**The system needs to remember that a scheduled run already happened, even after its snapshot has been deleted.** + +Suppose a virtual track creates a snapshot every hour: + +1. The 10:00 scheduled run creates snapshot A. +2. The server crashes before recording that the scheduled run finished. +3. After restarting, the scheduler sees the unfinished 10:00 run and considers retrying it. + +Today, the scheduler can find snapshot A and conclude: “The snapshot was already created; I just need to finish recording success.” + +Automatic truncation introduces a problem: + +4. Newer snapshots are created, and retention deletes A. +5. The scheduler retries the unfinished 10:00 run. +6. It cannot find A, so it could create another snapshot for the same scheduled occurrence. + +That replacement might contain different content because the component tracks have changed since 10:00. + +### The safeguard + +Keep a small, separate record—a **receipt**—saying: + +> The 10:00 occurrence already created snapshot A. + +Deleting A must not delete that receipt. A retry then knows that the work already happened and must not repeat it. + +The backend already has records for scheduled occurrences, so we can build on those rather than introduce a separate scheduling system. These small records would remain even while the much larger snapshot history and associated metadata are trimmed. + +--- + +## 2. “Serialize the virtual target’s lifecycle” + +Here, **serialize** means “make conflicting operations take turns.” The **target** is the virtual track being changed. + +Two operations can happen at almost the same time: + +- The scheduler creates a draft and starts pruning old drafts. +- A user tags one of those old drafts as a release. + +Without coordination, this sequence is possible: + +1. Cleanup checks snapshot A and sees that it is an old draft. +2. The user tags A as a release. +3. Cleanup deletes A based on its earlier check. + +That would violate our most important rule: **never delete tagged releases.** + +There is another possible race: one operation reads a snapshot as the basis for a new draft while another deletes that snapshot and cleans up metadata the new draft still needs. + +### The safeguard + +Use a database-backed lock for the affected virtual track. Conceptually: + +> “I’m changing this track’s snapshots. Another conflicting operation cannot change them until I finish.” + +The backend already uses this locking mechanism for releases and some other operations. We need to extend its coverage so virtual-track creation and cleanup participate too. + +We should also make the final deletion conditional: + +> Delete A only if it is **still an eligible draft**. + +That provides an additional check rather than trusting an earlier decision. + +This does **not** mean stopping all Workbench activity. It coordinates conflicting changes to the affected track. Under the existing locking convention, a competing operation may receive a conflict response and need to retry. + +--- + +## 3. “Make cleanup recoverable” + +**Cleanup has several steps. A failure halfway through must not lose the new release or leave cleanup permanently unfinished.** + +For example, a user tags a draft and requests squashing: + +1. The release is successfully created. +2. Cleanup deletes three earlier drafts. +3. The database connection fails before the remaining seven drafts are deleted. + +At that point, two different things are true: + +- **The release succeeded.** +- **Cleanup is incomplete.** + +It would be misleading to show only “Release failed—try again.” Retrying the tagging operation is not the right way to finish deleting old drafts. + +### The safeguard + +Record the cleanup request and its progress separately. The system can then report: + +> Release created successfully. Draft cleanup is incomplete and can be retried. + +A retry should finish only the remaining cleanup. It must not create another release, change the released contents, or broaden the set of drafts originally approved for deletion. + +### Shared metadata also needs care + +Snapshots can share a **content manifest**: a stored list of the exact object versions and relationships needed to reproduce their exports. + +If drafts A and B share the same manifest: + +- Deleting A must leave the manifest intact because B still needs it. +- Deleting the last snapshot that references that manifest can make the manifest eligible for removal. + +Cleanup must also update snapshot counts, so the UI does not report snapshots that no longer exist. + +None of this should delete the underlying ATT&CK/STIX objects. We are removing obsolete snapshot history and metadata that no surviving snapshot needs. + +--- + +In short: + +| Safeguard | What it prevents | +|---|---| +| Keep scheduler receipts | A deleted scheduled snapshot being recreated by a retry | +| Coordinate conflicting operations | Cleanup deleting something that another operation just released or still needs | +| Record and resume cleanup | Partial failures leaving misleading results, stale counts, or unfinished database cleanup | + +The intended user experience remains simple: configure a retention count, or check a squash option when releasing. These safeguards make those actions safe behind the scenes. diff --git a/docs/developer/release-tracks/draft-lifecycle-plan.md b/docs/developer/release-tracks/draft-lifecycle-plan.md new file mode 100644 index 00000000..abc4ff45 --- /dev/null +++ b/docs/developer/release-tracks/draft-lifecycle-plan.md @@ -0,0 +1,291 @@ +# Virtual draft retention and release-time squash + +Status: implemented and verified. Work is on +`feat/virtual-track-draft-retention` in both repositories, created from local +`next` with the prior draft-composition commits preserved: + +- REST API: `b1acfaad` — `feat(release-tracks): support draft components in virtual tracks` +- Frontend: `60b3ac93` — `feat(release-tracks): expose draft component selection` + +The frontend's subsequent save-dialog timing regression fix is also preserved. + +## Recommendation + +Both features are viable. Implement them as two selection policies using one +internal, guarded historical-draft cleanup operation. Keep normal latest-draft +DELETE semantics unchanged. Add a compact history view independently of storage +retention: hiding drafts is reversible; deleting them is not. + +The defaults below include the subsequent frontend-feedback refinements: + +| Decision | Recommended behavior | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Scope | Virtual tracks only; leave standard rolling drafts and rollback sources unchanged | +| Retention triggers | One-shot manual materialization policy or persistent cron-schedule policy, both disabled by default | +| Suggested starting threshold | 10; positive integer, minimum 1; no zero-as-delete-all behavior | +| Counted drafts | All virtual drafts, including manual, scheduled, composition/configuration/metadata and quarantine-resolution drafts | +| Tagged snapshots | Never count against N and never delete | +| Policy changes | Saved cron schedule updates create no content draft/deletion; manual policies last one request | +| New track clones | Retention disabled rather than inheriting a destructive policy silently | +| Release-time squash | Explicit, unchecked opt-in on each virtual release | +| First-release squash | Delete all eligible drafts strictly before the selected snapshot | +| Protected snapshots | Skip and report; safety overrides the configured count | +| Authorization | Existing application administrators configure retention and opt into bulk squash; ordinary tagging permissions remain unchanged | + +There is no track-specific administrator/ACL model today. A new per-track role +system is outside this proposal. Extending these controls to existing team leads +or editors is a product decision, not a reason to introduce ACL infrastructure. + +## Baseline implementation findings + +- `snapshot-service.cloneSnapshot` locks and prunes standard tracks, but retains + virtual history (`app/services/release-tracks/snapshot-service.js:427-521`). +- Virtual materialization locks its standard components, not its virtual target + (`app/services/release-tracks/virtual-track-service.js:523-600`). Other virtual + creation paths include composition updates, quarantine promotion, and shared + metadata/configuration updates. Schedule updates and snapshot notes do not + create drafts. +- Public snapshot DELETE refuses historical drafts and reverts latest membership + to the predecessor (`snapshot-service.js:999-1045`). It is not a suitable + implementation of retention or squash. +- Virtual tagging keeps the selected snapshot's timestamp, members, manifest and + composition provenance. Historical tagging is supported. The existing release + planner finds the preceding tag by snapshot `modified`, not `tagged_at` or + highest version (`versioning-service.js:121-347`; existing retroactive release + cases in `app/tests/api/release-tracks/release-tracks-release.spec.js`). +- Scheduled recovery looks for the persisted scheduled snapshot before retrying. + The occurrence is marked complete only after materialization and audit finish + (`app/scheduler/virtual-track-snapshots-task.js:104-158`). Deleting the recovery + snapshot without a durable receipt can cause a completed occurrence to run again. +- Manifests can be shared by snapshots. `content-manifest-service` provides + reference-aware deletion and orphan repair. Its current concurrent deletion of + entries and header can strand entries if only header deletion succeeds + (`app/services/release-tracks/content-manifest-service.js:341-398`). +- The frontend connector requests 200 history summaries; the page ignores + pagination metadata and renders every returned item. Older history beyond that + page is not navigable through these controls + (`release-tracks.service.ts:223-245`, `release-track-page.component.ts:1058-1081`, + `release-track-page.component.html:545-570` in the frontend repository). + +## Feature A: trigger-scoped count-based retention + +Frontend feedback replaced the original global policy with two distinct choices: + +- **Ad-hoc:** optional `draft_retention: { max_drafts: N }` on + `POST /api/release-tracks/:id/virtual/snapshots/create`. It applies only to this + materialization, is off by default, and never changes/inherits recurring policy. +- **Recurring:** optional `snapshot_schedule.draft_retention` within the cron + schedule, saved through `PUT /api/release-tracks/:id/virtual/schedule` or initial + virtual-track creation. Only trusted scheduler cron execution applies it. + +Positive safe integers are accepted; missing/null limits disable cleanup. +Manual/dates schedule shapes reject retention fields. Metadata, configuration, +composition and quarantine writes do not invoke retention. Both policies still +count all untagged snapshots across the track, not separate cause pools. + +Supplying an ad-hoc policy or changing a cron limit requires an administrator. +Editors can preserve the policy while editing cron timing or switch away from +recurring mode. Schedule-only saves create no draft or immediate deletion. + +Materialization locks its target before reading its source, chooses the policy +from the trusted trigger, and creates durable bounded intent before saving. +After successful persistence it removes eligible older drafts, preserving the +newest N, tagged snapshots, and source/receipt protections. Shared manifests and +registry counters are repaired through the existing cleanup path. + +Intent records preserve `source`, `max_drafts`, and the original cutoff. Manual +retry retains its immutable request limit independently of later schedule edits; +recurring retry also respects the current applicable cron policy. Disabling or +leaving recurring mode stops additional recurring selection. Legacy intents with +no trigger/limit may repair already-deleted storage but cannot select more rows. + +The earlier global field/endpoint are removed, with no compatibility alias. +Stored legacy global values are inert rather than silently adopted as new +destructive policies. The existing deletion guardrails and squash behavior remain. + +## Feature B: opt-in release-time squash + +This is history deletion, not a merge of contents. Do not combine members, +quarantine choices, notes, manifests or provenance into a new synthetic snapshot. +The reviewed release remains the exact artifact being tagged. + +For selected virtual draft R and preceding tagged snapshot P, eligible drafts D +satisfy: + +```text +D.version == null +P.modified < D.modified < R.modified +``` + +If P does not exist, omit the lower bound. Always retain R, all tags, and every +snapshot at or after R. Historical tagging therefore cannot remove newer drafts +or a later release. An active draft means the latest snapshot when untagged, not +an arbitrary older untagged snapshot remaining after the target is tagged. + +Example: + +```text +v1.0 -> draft A -> draft B -> selected R -> newer draft -> v3.0 +``` + +Tagging R with squash removes A/B only. It preserves v1.0, R, the newer draft and +v3.0. Use snapshot chronology, never wall-clock tagging order or semantic-version +magnitude, to determine these boundaries. + +### API and UI + +- Extend the existing strict release payload with `squash_drafts: boolean`, false + by default. Reject true for standard tracks and unauthorized actors before + tagging. Keep existing increment/version and notes behavior. +- Extend the virtual release summary preview with a `draft_squash` section: + boundaries, eligible count, protected count/reasons, and a preview fingerprint. + Use the same selector as commit; preview performs no deletion. +- Recommend requiring that fingerprint for destructive opt-in. Under the target + lock, recompute eligibility; return 409 and request a fresh preview if the + reviewed boundary/candidate set changed. This avoids silently expanding what + the user approved. Ordinary tagging remains unaffected. +- In the preview dialog, show an unchecked **Delete earlier drafts after tagging** + checkbox, the count and preceding release boundary (or “since track creation”), + and an irreversible-deletion warning. Do not imply content merging. +- Submit to the previewed exact `:modified` endpoint, not a moving `latest` target. +- Commit and finish release publication/artifacts before deleting any old drafts. + A failure before successful release completion deletes no squash candidates. +- Return the normal snapshot response plus operation-only `draft_cleanup` status, + counts and audit operation ID. Do not put mutable cleanup progress in the + released snapshot's content or history fields. + +Converting the newest virtual release back to a draft still preserves that +snapshot and its content. It does not resurrect squashed earlier drafts. + +## Shared prerequisites and safeguards + +The rationale and failure examples are documented in +[Deletion Guardrails](deletion-guardrails.md). That document is the canonical +plain-language explanation of scheduler receipts, target-track serialization, +and recoverable cleanup; do not duplicate that explanation here. + +Implementation requirements specific to this plan: + +- Acquire the virtual target lock before source reads; hold standard component + locks in sorted order during materialization. Cover every creation path and + whole-track deletion; avoid nested non-reentrant acquisition. Use bounded + cleanup batches and verify lock ownership before destructive writes. +- Preserve monotonic scheduled-occurrence receipts, including legacy and + API-supplied occurrence metadata. Receipt-only recovery must never rematerialize + deleted results, and stale workers must not reopen completed work. +- Persist bounded audit-backed cleanup intent before deletion. Bind squash to + its original release event and non-expanding interval. Expose cleanup-only + status/retry, independently of normal release POST. +- Reference-check shared manifests, make entry/header deletion retryable, repair + registry counts without rolling back latest membership, and never delete STIX + objects or component snapshots as a side effect. + +## Frontend history changes + +Virtual-track history now offers **Drafts only**, **Releases only**, and **All +releases** (the default). All releases includes both states. Use exact server +tagged filtering with no pinned-draft injection, and retain the 25-item paginator. +The small summary reports filtered tagged/draft/total counts across matching +pages, including zero results, rather than registry-wide totals or page length. + +Refresh lightweight history and cleanup status every 30 seconds while Releases +is visible, plus immediately on entry/focus/visibility return. Pause during +editing, dialogs, mutations and active requests; preserve filter/page/scroll and +clamp an emptied page. Tear down timers/listeners and cancel stale requests. +Unfiltered server latest identities control Latest markers and latest-only +actions. Keep standard-track source-draft hiding behavior unchanged. + +The **Create Draft** dialog offers a fresh, off-by-default ad-hoc policy. +Saved recurring policy controls appear only for **Recurring** mode and follow +**Edit Config**, **Cancel**, and **Save Config**. Center the history controls, +refresh authoritative counts with stale-response protection, and omit the +redundant pinned label. Completed cleanup uses a floating dismissible notification; +pending/failed cleanup remains discoverable with cleanup-only retry. + +## Implementation sequence on the same feature branches + +1. **Shared lifecycle safety:** target-lock coverage, guarded historical cleanup, + recoverable shared-manifest cleanup, durable intent/status/repair, scheduler + receipt preservation and non-replay checks. Keep both policies disabled until + these prerequisites are verified. +2. **Automatic retention:** registry field, strict API/authorization, creation + integration, config read/write projection and documentation/Bruno updates. +3. **Release-time squash:** pure interval selection, preview/fingerprint, opt-in + release commit, audit-bound cleanup-only recovery and OpenAPI documentation. +4. **Frontend:** retention configuration, virtual history filters/pagination, + opt-in preview control and partial-outcome handling. These consume the agreed + backend schemas; avoid a parallel alternative frontend contract. +5. **Verification:** focused API and scheduler regressions, real-browser flows + against isolated data, then the full backend suite and affected frontend + tests. Commit backend/frontend behavior as separate `feat(release-tracks)` + changes; use appropriately scoped commits for prerequisite fixes. + +## Acceptance checks for implementation + +- Disabled retention and omitted/false squash preserve existing history. +- N=1 and N=10 behave correctly with interleaved releases and every draft cause; + invalid settings fail, policy-only save creates no draft, new clones are safe. +- All tagged snapshots, current/target snapshots and protected sources survive. +- First-release and historical-release squash respect strict boundaries, including + tagging order different from snapshot chronology; stale preview refreshes. +- Surviving release exports/hashes, manifests, provenance and latest backrefs are + unchanged; unreferenced manifest entries really disappear; STIX objects remain. +- A scheduled result pruned before/after occurrence completion is never recreated + after restart, duplicate delivery, stale-worker failure or explicit API retry. +- Fault injection after draft/tag persistence, during deletes, manifest cleanup, + counter repair and audit completion converges via cleanup-only recovery without + a duplicate release or a widened deletion interval. +- Concurrent materialization/configuration/tagging/rollback/deletion either + serializes or reports 409; no tagged row or live shared manifest is deleted. +- Authorization is enforced in the service, not only hidden frontend controls. +- Real UI checks cover many drafts, more than one page of tags, default compact + view, policy save, unchecked squash default, clear deletion counts, disappearing + drafts and a committed release with deferred cleanup. + +## Initial evaluation + +Read the implementation, request contracts and existing regression scenarios; +no retention/squash implementation was changed and no database cleanup ran. +Executed an in-memory model of the proposed selectors covering count thresholds, +interleaved tags, historical and first releases, newer drafts, and protected +snapshots. All six scenario groups passed. This validates the proposed boundary +rules only, not MongoDB locking, scheduler durability, or production behavior. + +## Execution evidence + +The approved defaults are implemented. Browser verification against an isolated +API demonstrated compact history, 25-item draft pagination, policy-only saves, +31 drafts reduced to 10 while preserving a tag, first-release squash, and +cleanup-only repair after an injected manifest-deletion failure. The release's +bundle hashes remained unchanged through repair. A regression also verifies +that a successful repair clears the previous failure from its response. + +Focused lifecycle/scheduler and frontend regressions exercise the API contracts, +fault recovery, authorization and UI transitions. See the +[implementation backlog](../TODO.md#virtual-draft-retention-and-release-time-squash-2026-09-23) +for verification completion. Public behavior is documented in the +[API reference](../../user/release-tracks/api-reference.md#virtual-draft-retention); +the explanation requested during planning is preserved in +[Deletion Guardrails](deletion-guardrails.md). + +Initial lifecycle verification completed: 1,143 backend tests across all `npm test` stages and +143 focused frontend tests passed. Backend lint and changed frontend source lint +passed. Real-browser checks also covered 26 tagged releases across two pages, +with the current draft pinned and global counts unchanged between pages. + +The local test environment exposed a Supertest transport issue: it hardcodes an +IPv4 client URL even when the ephemeral listener uses IPv6, occasionally reaching +an unrelated local listener. Full-suite verification used a temporary adapter +matching the listener's address family, without changing test selection, +assertions, or production code. That diagnostic tooling was removed afterward. +Repository-wide frontend lint still reports existing errors in untouched files; +the changed lifecycle files pass their scoped lint check. + +Trigger-policy feedback verification: all 1,149 backend tests and 132 focused +frontend tests passed, together with backend and changed-source frontend lint. +The real browser/API flow verified centered controls, no redundant pinned text, +manual counter growth (4 to 5), one-shot cleanup and fresh-dialog reset, floating +notification dismissal without refresh resurrection, Edit Config gating/Cancel, +and schedule-only saving without a new snapshot. Actual cron execution reduced +five drafts to its saved limit of three; a separate ad-hoc limit of one left that +saved recurring limit unchanged. diff --git a/docs/developer/release-tracks/entities.md b/docs/developer/release-tracks/entities.md index ade8ef46..20be9f92 100644 --- a/docs/developer/release-tracks/entities.md +++ b/docs/developer/release-tracks/entities.md @@ -6,13 +6,31 @@ This document tracks new database schemas, interfaces, etc.; as well as changes | Collection | Purpose | Written by | Growth and retention | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `releaseTrackRegistry` | One document per track: name, type, denormalized counters, the tagged-release catalogue (`tagged_releases`), the release lock, and virtual schedules. The index that maps a track to its own snapshot collection. | Track create/delete, every snapshot write (counters), release commit and conversion to draft (catalogue). | One document per track. | -| `release-track--` | The track's snapshots: one active rolling draft, preserved standard release sources, source drafts referenced by virtual provenance, and tagged releases; every materialized draft plus releases for a virtual track. | Snapshot service and release commit. | Standard tracks retain releases, their source drafts, virtual-pinned drafts, and one active draft; virtual tracks grow by materializations. | +| `releaseTrackRegistry` | Track identity, counters, tagged catalogue, lifecycle lock, and live virtual schedule with cron-only nested retention. | Track creation/deletion, snapshot lifecycle and schedule updates. | One document per track. | +| `release-track--` | Standard rolling drafts, preserved release sources, virtual-pinned source drafts and tags; virtual drafts and releases. | Snapshot service and release commit. | Virtual drafts can be bounded by count retention or explicit release-time squash; tagged snapshots are never pruned. | | `releaseTrackContentManifests` | The sealed bill of materials each snapshot references (`content_manifest_id`). Several snapshots share one manifest when their member sets are identical. | Sealed whenever members are written; discarded when no snapshot references it. | Bounded by member-changing writes, not by snapshot count. | | `releaseTrackContentManifestEntries` | One exact-revision pointer per object a manifest emits or depends on. The `(object_ref, object_modified)` index is what protects referenced revisions from deletion. | With its manifest. | Roughly members + relationships + a few supporting objects per manifest. | | `releaseTrackReconciliations` | Outstanding backref reconciliation work only: a record is created before the `workspace.release_tracks` listeners run and deleted when they succeed, so anything present is pending or failed and needs repair. | Every snapshot write. | Normally empty. | -| `releaseTrackAuditEvents` | Audit trail for administrator-only track deletion, release conversion to draft, and release retagging (`delete_track`, `convert_release_to_draft` (legacy: `delete_release`), `retag_release`). | Those operations. | Empty until an administrator performs one of those operations. | -| `virtualTrackScheduleOccurrences` | Durable claims for scheduled virtual materialization (cron or dated schedules) so restarts and duplicate delivery execute each occurrence once. | The scheduler. | One record per scheduled occurrence; empty when no virtual track has a schedule. | +| `releaseTrackAuditEvents` | Destructive audit and bounded cleanup intent/progress for deletion, conversion, retag, `draft_retention`, and `draft_squash`. | Destructive operations and cleanup recovery. | Durable audit records; cleanup results are discoverable independently of snapshot survival. | +| `virtualTrackScheduleOccurrences` | Ownership-fenced claims and monotonic `snapshot_modified` materialization receipts. | Scheduler and scheduled snapshot lifecycle. | One record per track/time, retained after snapshot cleanup; also covers API-originated occurrence metadata. | + +Retention is `{ max_drafts: null | positive safe integer }`, supplied either on +the manual materialization request or inside the live cron `snapshot_schedule`. +There is no global registry retention policy. Workbench virtual responses +project the schedule plus registry `snapshot_count` and `tagged_release_count` +for paginated clients; these are not historical snapshot content. + +Retention audit intents persist their `source` (`manual` or `recurring`), applied +`max_drafts`, and original cutoff. Manual retries use that fixed request policy; +recurring retries additionally honor the currently enabled cron policy. Legacy +intents lacking these fields cannot select further drafts, but storage repair +remains available. + +A server-controlled `release_event_id` binds release-time cleanup to the original +tagging event, independently of mutable version labels and reusable virtual +snapshot timestamps. Cleanup progress belongs to its audit record, not the +released manifest or version-history content. See [Deletion Guardrails](deletion-guardrails.md) +and the [API lifecycle contracts](../../user/release-tracks/api-reference.md#virtual-draft-retention). Removed by the sealed-manifest work: the former `releaseTrackGraphManifests` and `releaseTrackGraphManifestEntries` collections (renamed in place by the @@ -617,9 +635,11 @@ on the resulting snapshot and projected into track-list and snapshot-history responses. Snapshot clones clear inherited occurrence metadata unless the mutation explicitly supplies a replacement. -The track-local unique index on `scheduled_for`, together with the durable -`virtualTrackScheduleOccurrences` claim record, makes duplicate delivery and -restart recovery idempotent. Failed occurrences remain retryable. +The track-local unique index prevents duplicate surviving scheduled snapshots. +The occurrence ledger also retains a monotonic materialization receipt after +cleanup, so missing snapshots do not make completed work executable again. +Unmaterialized failures remain retryable; stale claim owners cannot overwrite a +new worker's completion. See [Deletion Guardrails](deletion-guardrails.md). **Key Differences from Standard Tracks:** @@ -649,8 +669,8 @@ restart recovery idempotent. Failed occurrences remain retryable. - Component IDs and priorities are validated before initial virtual-track persistence as well as during composition updates and materialization - Snapshot schedules are strict and mode-discriminated: `manual` accepts only - `mode`, `cron` requires only a five-field `cron` expression, and `dates` - requires only a nonempty `dates` array + `mode`, `cron` requires a five-field `cron` expression and accepts optional + nested `draft_retention`, and `dates` requires only a nonempty `dates` array - Standard tracks reject `snapshot_schedule`; virtual `cron` and `dates` schedules execute through the global scheduler - `filters.object_types` uses the canonical Workbench STIX type names from diff --git a/docs/developer/release-tracks/implementation-notes.md b/docs/developer/release-tracks/implementation-notes.md index 6600fe52..28cbffe8 100644 --- a/docs/developer/release-tracks/implementation-notes.md +++ b/docs/developer/release-tracks/implementation-notes.md @@ -250,12 +250,12 @@ so existing members-only results remain truthful and releasable. Saved rules must be explicitly replaced before rematerialization. No nightly-data migration silently opts existing operators into staged content. -Standard clone save/prune and virtual materialization share the existing -release lock. Concurrent operations fail fast with `409 Conflict`, preventing -pruning between source resolution and persisted virtual provenance. Pruning -retains every source snapshot named by persisted virtual provenance, including -historical virtual drafts. Once the last dependent disappears, the next -standard clone can prune the source if no other retention rule protects it. +Standard clone save/prune and virtual materialization share the existing release +lock; contention returns `409 Conflict`. See [Deletion Guardrails](deletion-guardrails.md) +for the concurrency rationale. Pruning retains every source snapshot named by +persisted virtual provenance, including historical virtual drafts. Once the last +dependent disappears, the next standard clone can prune the source if no other +retention rule protects it. Component `priority` is always required, even when the selected deduplication strategy does not inspect it. Zod rejects duplicate component IDs and @@ -400,9 +400,10 @@ and a list response. `release-track-dynamic.repository.getSnapshotSummaries` performs tagged-state filtering, descending timestamp ordering, pagination, and tier counts in -MongoDB. It projects counts with `$size` rather than hydrating the potentially -large tier arrays. The filter is applied to both the data query and -`countDocuments`, making `pagination.total` the filtered total. +MongoDB. A metadata-only grouping computes filtered `counts.tagged`, +`counts.drafts`, and `counts.total` before pagination. The page query projects +tier counts with `$size` only for its bounded result, rather than hydrating every +snapshot's potentially large arrays. `pagination.total` equals `counts.total`. The service shapes projected counts according to `snapshot.type`: @@ -422,6 +423,15 @@ match, and shared manifests are counted once. The service fills zero-valued categories for empty manifests. This keeps history latency to one additional bounded query rather than one query per snapshot. +The history service also projects unfiltered `latest_snapshot_modified` and +`latest_tagged_snapshot_modified` from the registry metadata it already reads. +The latter uses the newest catalogue snapshot timestamp, not highest semantic +version or tagging time. Filtered pages therefore retain correct latest-only +action identities. The frontend polls this lightweight history plus outstanding +cleanup while Releases is visible; it does not poll full member/configuration +payloads. Visibility/focus and tab entry refresh immediately, while edits, +dialogs, mutations and active requests pause background refresh. + ## Integrating with the Event-Driven Architecture ### Events Published diff --git a/docs/developer/release-tracks/sealed-content-manifests.md b/docs/developer/release-tracks/sealed-content-manifests.md index 58a9def6..931b54ea 100644 --- a/docs/developer/release-tracks/sealed-content-manifests.md +++ b/docs/developer/release-tracks/sealed-content-manifests.md @@ -130,12 +130,10 @@ valid; new conversions use `convert_release_to_draft`. Virtual materialization acquires the existing database-backed release locks for all component tracks in sorted order, before resolving any source snapshot, and holds them through snapshot persistence. Partial acquisition and failed -materialization unwind the locks. Contention fails fast with 409. The rollback -dependency scan therefore cannot miss an in-flight materialization: either -rollback owns the lock first, or it sees the persisted virtual dependency -after materialization releases the lock. -Standard clone save/prune acquires the same lock, so a source draft cannot -disappear between resolution and persistence. Pruning retains drafts referenced +materialization unwind the locks. Contention fails fast with 409. Standard +clone save/prune acquires the same component lock. The concurrency and shared +manifest deletion rationale is documented in [Deletion Guardrails](deletion-guardrails.md), +rather than repeated here. Pruning retains drafts referenced by virtual provenance; after the final dependent is removed, a later standard clone can prune an otherwise-unprotected draft. diff --git a/docs/user/release-tracks/api-reference.md b/docs/user/release-tracks/api-reference.md index c5ebd278..e1e108bb 100644 --- a/docs/user/release-tracks/api-reference.md +++ b/docs/user/release-tracks/api-reference.md @@ -455,6 +455,18 @@ GET /api/release-tracks/:id/snapshots Filtering occurs before pagination, so `pagination.total` is the total number of snapshots matching `tagged`, not the total number in the track. +The response also includes `counts: { tagged, drafts, total }`. All three counts +use the same `tagged` filter before pagination: `counts.total` equals +`pagination.total` and `counts.tagged + counts.drafts`. They remain query-wide +when the requested page is partial or empty. Thus `tagged=true` always reports +zero drafts, and `tagged=false` always reports zero tagged releases. + +`latest_snapshot_modified` and `latest_tagged_snapshot_modified` are unfiltered +track identities, not the first row of the selected page. They are nullable ISO +timestamps; the tagged identity is chronological rather than the highest semantic +version. Clients use them to mark the actual latest snapshot and gate latest-only +actions while refreshing filtered/paginated history. + Every summary contains `id`, `type`, `modified`, `version`, `name`, the track-level `description` (when set), `snapshot_description` (when the snapshot has user-authored notes), `members_count`, the opaque `content_manifest_id` @@ -513,10 +525,17 @@ Inapplicable count keys are omitted rather than returned as zero. } ], "pagination": { - "total": 47, + "total": 1, "limit": 50, "offset": 0 - } + }, + "counts": { + "tagged": 1, + "drafts": 0, + "total": 1 + }, + "latest_snapshot_modified": "2024-01-15T16:20:00.000Z", + "latest_tagged_snapshot_modified": "2024-01-15T16:20:00.000Z" } ``` @@ -1404,6 +1423,143 @@ Historical composition/provenance retains its original label and contents. See [virtual-tracks.md](./virtual-tracks.md) for complete documentation. +### Virtual Draft Retention + +Retention has two independent, administrator-controlled triggers. Both count +untagged snapshots across the whole virtual track, regardless of creation cause; +tags and protected snapshots remain excluded from deletion. + +**Ad-hoc policy:** pass a one-shot limit when explicitly creating a draft: + +```http +POST /api/release-tracks/:id/virtual/snapshots/create +Content-Type: application/json + +{ "description": "Reviewed composition", "draft_retention": { "max_drafts": 10 } } +``` + +Omitting `draft_retention`, or setting `max_drafts` to `null`, means no retention +for this operation. The policy is not saved and never inherits or changes the +recurring schedule's policy. A supplied policy requires an administrator. + +**Recurring policy:** save a limit inside the cron schedule: + +```http +PUT /api/release-tracks/:id/virtual/schedule +Content-Type: application/json + +{ "mode": "cron", "cron": "0 * * * *", "draft_retention": { "max_drafts": 10 } } +``` + +Only trusted scheduler cron execution applies this saved policy. Manual +materialization never inherits it, and dated schedules do not use it. Clients +cannot activate it by supplying `scheduled_materialization` provenance. The +policy can also be nested in `snapshot_schedule` when creating a virtual track. + +Counts must be positive safe integers; missing/null limits disable cleanup. +The `manual` and `dates` schedule shapes reject retention fields. Changing a +cron retention policy requires an administrator; editors can change timing while +preserving that policy, or switch away from recurring mode. Saving only the +schedule/policy creates no draft and deletes nothing immediately. + +Configuration, metadata, composition and quarantine updates never trigger +retention. Cleanup still runs only after successful materialization. Manual +cleanup retries preserve the approved request limit and original cutoff; +recurring retries also honor the current saved policy, stopping further deletion +if it is disabled or the schedule changes away from cron. + +The former global policy, top-level track-creation field, and +`PUT /virtual/draft-retention` endpoint are retired. Existing root/global settings +are inert; configure the recurring policy explicitly. Legacy cleanup intents +without trigger/limit provenance may repair already-deleted storage but cannot +select additional drafts. + +### Release-Time Draft Squash + +The existing summary release preview includes virtual-only `draft_squash`: + +```json +{ + "draft_squash": { + "lower_bound": null, + "upper_bound": "2026-09-23T10:00:00.000Z", + "eligible_count": 3, + "protected_count": 0, + "fingerprint": "opaque-preview-fingerprint" + } +} +``` + +An administrator can explicitly opt into cleanup when tagging the exact +previewed draft: + +```http +POST /api/release-tracks/:id/snapshots/:modified/release +Content-Type: application/json + +{ "increment": "minor", "squash_drafts": true, "squash_fingerprint": "opaque-preview-fingerprint" } +``` + +Use the actual preview fingerprint. A changed selection returns `409` before +tagging; fetch a fresh preview rather than retrying blindly. Omitted/false squash +retains ordinary tagging behavior, including editor/team-lead access. True is +virtual-only and administrator-only. Both latest and exact release endpoints +accept the option, but the UI uses the exact previewed timestamp. + +Cleanup removes only eligible untagged snapshots strictly after the preceding +tagged snapshot and strictly before the selected snapshot, ordered by `modified`, +not `tagged_at` or version magnitude. With no preceding tag, all strictly earlier +eligible drafts are considered. Every tagged snapshot and every newer snapshot +is preserved. Release content, its manifest and composition provenance are not +merged or rewritten. Deleted history is not restored by release-to-draft conversion. + +### Inspect or Retry Draft Cleanup + +Creation, opted-in release, and retry responses can include an operation-only +`draft_cleanup` result: + +```json +{ + "operation_id": "97508a36-62cf-4a72-9ed6-49f2690d3609", + "status": "failed", + "kind": "squash", + "eligible_count": 3, + "deleted_count": 1, + "protected_count": 0, + "target_modified": "2026-09-23T10:00:00.000Z", + "release_committed": true, + "error": "Cleanup could not finish" +} +``` + +`status` is `pending`, `completed`, or `failed`; `kind` is `retention` or +`squash`. `release_committed` applies to squash; it is omitted if a store failure +prevents determining the release outcome. A successful release with failed +cleanup remains a successful release response with a failed cleanup result. +Interrupted publication may return structured `500` with top-level +`operation_id`, `release_committed` when known, and `draft_cleanup`. + +```http +GET /api/release-tracks/:id/virtual/draft-cleanup +POST /api/release-tracks/:id/virtual/draft-cleanup/:operationId/retry +``` + +GET returns `{ "data": [...] }`, up to 25 recent pending/failed operations, and +requires ordinary read access. POST takes `{}`, requires an administrator, and +returns the cleanup result. It resumes an existing intent; it cannot authorize +arbitrary new deletion or implicitly tag a draft. Normal repeated release POST +still rejects an already-tagged snapshot. + +Each cleanup invocation processes at most ten 100-snapshot pages. Large histories +can return `pending`; retry the same operation until complete. Repair is bounded +by the original intent and current protections. Disabling retention prevents +further candidate selection but does not prevent repairing already-deleted +storage. Shared manifests survive while referenced, and underlying STIX objects +are not cleanup targets. + +For the safety rationale and failure examples, see +[Deletion Guardrails](../../developer/release-tracks/deletion-guardrails.md). + ### Create Virtual Track ``` @@ -1474,7 +1630,7 @@ by the resolved component snapshot. is enabled. Its shape depends on `mode`: - `manual` accepts only `{ "mode": "manual" }`; -- `cron` requires a five-field `cron` expression and rejects `dates`; +- `cron` requires a five-field `cron` expression, accepts optional `draft_retention`, and rejects `dates`; - `dates` requires at least one ISO timestamp and rejects `cron`. Unknown schedule properties return `400 Bad Request`. Standard tracks also diff --git a/docs/user/release-tracks/virtual-tracks.md b/docs/user/release-tracks/virtual-tracks.md index 13aadb0c..e7a437ed 100644 --- a/docs/user/release-tracks/virtual-tracks.md +++ b/docs/user/release-tracks/virtual-tracks.md @@ -17,6 +17,33 @@ content. Replace it with `PUT /api/release-tracks/:id/virtual/schedule`; this does not create a draft. Workbench-format snapshot responses project the current schedule for configuration interfaces. +## Draft Retention and Release-Time Squash + +Administrators choose an optional **ad-hoc** retention limit in **Create Draft** +or configure a **persistent recurring** limit inside a cron snapshot schedule. +Both default to disabled and accept a positive safe integer such as 10. +The one-shot policy applies only to its manual materialization; the recurring +policy applies only to actual scheduler cron executions. Neither inherits from +the other. Dated schedules, configuration/metadata/composition changes, and +quarantine promotion do not trigger retention. + +Both policies retain the newest N untagged snapshots across the track's history, +not separate manual/scheduled pools. Tagged releases are never removed. +Saving only the schedule/policy creates no draft and deletes nothing immediately. + +When tagging a reviewed draft, administrators can separately opt into deleting +earlier drafts since the preceding tagged snapshot. The preview reports the +strict timestamp bounds and eligible/protected counts, and its fingerprint must +still match at commit. First-release squash considers all earlier eligible +drafts. Historical tagging preserves all newer snapshots. This removes history, +not content: the selected snapshot and its manifest remain unchanged. + +Both controls preserve protected snapshots and expose incomplete cleanup as a +retryable operation distinct from release success. Deleted drafts and their +notes cannot be restored by rollback. See the +[API retention, squash and recovery contracts](api-reference.md#virtual-draft-retention) +and [Deletion Guardrails](../../developer/release-tracks/deletion-guardrails.md). + ## Use Cases ### Scenario 1: Different Cadences for Different Object Types