diff --git a/docs/usage.md b/docs/usage.md
index ddac028a..52ddfbe8 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -30,7 +30,7 @@ You can read more about the technical specifications for a collection, such as t
The release preview offers minor and major relative tags as well as an exact `MAJOR.MINOR` version. Relative tags are calculated from the tagged snapshot immediately before the selected draft. When releasing an older draft, the exact version must also remain below the next tagged snapshot; the dialog shows these exclusive bounds. Optional release notes are stored on that snapshot and become the `x-mitre-collection` description in exported STIX bundles.
-The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. Administrators can delete a track's most recent release from the Releases tab by confirming its version; its version becomes available again and later drafts are kept. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent release offers a delete button, a progress bar with a status message appears under the page header while a long operation runs (creating a draft, preparing or committing a release, deleting a release or the track, saving the configuration), and deleting an entire track lives in the danger zone at the bottom of the Config tab.
+The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. A standard release preserves its exact pre-release draft, which remains hidden while the release exists. Administrators can roll back the most recent standard release from the Releases tab by confirming its version; the preserved draft then reappears. Rollback is blocked when any virtual snapshot resolved that release. Virtual releases retain their irreversible delete action because virtual materializations are tagged in place. Administrators can also correct a tagged snapshot's version from its card when the replacement remains valid between adjacent releases. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent release offers rollback (standard) or delete (virtual), a progress bar with a status message appears under the page header while a long operation runs, and deleting an entire track lives in the danger zone at the bottom of the Config tab.
Each cached snapshot card displays server-generated SHA-256 hashes for the exact UTF-8 JSON files produced by its STIX 2.0 and STIX 2.1 bundle downloads. The adjacent copy buttons copy a hash for external file-integrity verification. Snapshot notes are locked while the bundle is cached; delete the cache, edit the notes, and cache the bundle again to generate matching hashes.
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 480b5f31..1672373b 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -76,6 +76,7 @@ import { MultipleChoiceDialogComponent } from './components/multiple-choice-dial
import { NavigationComponent } from './components/navigation/navigation.component';
import { ReferenceEditDialogComponent } from './components/reference-edit-dialog/reference-edit-dialog.component';
import { ReleasePreviewDialogComponent } from './components/release-preview-dialog/release-preview-dialog.component';
+import { ReleaseVersionDialogComponent } from './components/release-version-dialog/release-version-dialog.component';
import { SnapshotDescriptionDialogComponent } from './components/snapshot-description-dialog/snapshot-description-dialog.component';
import { HistoryTimelineComponent } from './components/stix/stix-page-tabs/history-timeline/history-timeline.component';
import { MembershipSectionComponent } from './components/stix/stix-page-tabs/membership-section/membership-section.component';
@@ -274,6 +275,7 @@ export function initConfig(appConfigService: AppConfigService) {
ReferenceSidebarComponent,
ReferenceEditDialogComponent,
ReleasePreviewDialogComponent,
+ ReleaseVersionDialogComponent,
SnapshotDescriptionDialogComponent,
MultipleChoiceDialogComponent,
ValidationResultsComponent,
diff --git a/src/app/classes/release-tracks/api.ts b/src/app/classes/release-tracks/api.ts
index 6437c61c..6b7a3cb0 100644
--- a/src/app/classes/release-tracks/api.ts
+++ b/src/app/classes/release-tracks/api.ts
@@ -43,6 +43,10 @@ export type ReleasePayload = (
| { increment?: undefined; version?: undefined }
) & { description?: string };
+export interface RetagReleasePayload {
+ version: string;
+}
+
export interface ClonePayload {
name?: string;
}
@@ -115,6 +119,7 @@ export interface ReleasePreviewSummaryBase {
track_id: string;
type: ReleaseTrackType;
source_snapshot_modified: string;
+ release_snapshot_modified?: string;
version: string;
version_bounds: {
lower: { version: string; modified: string } | null;
@@ -177,6 +182,8 @@ export interface ReleaseTrackSnapshotHistoryItem {
id?: string;
modified?: string | Date;
version?: string | null;
+ /** Exact draft snapshot retained when a standard release was created. */
+ release_source_modified?: string | Date;
content_manifest_id?: string;
publication?: SnapshotPublication;
bundle_id?: string;
diff --git a/src/app/components/release-version-dialog/release-version-dialog.component.html b/src/app/components/release-version-dialog/release-version-dialog.component.html
new file mode 100644
index 00000000..fdf7af44
--- /dev/null
+++ b/src/app/components/release-version-dialog/release-version-dialog.component.html
@@ -0,0 +1,28 @@
+
Change release version
+
+
+
+ Change release {{ data.currentVersion }} without changing the snapshot it
+ identifies. The new version must remain between the adjacent releases.
+
+
+ Release version
+
+ Use MAJOR.MINOR format.
+ Enter a version such as 1.2.
+
+
+
+
+
+
+
diff --git a/src/app/components/release-version-dialog/release-version-dialog.component.scss b/src/app/components/release-version-dialog/release-version-dialog.component.scss
new file mode 100644
index 00000000..4673eeeb
--- /dev/null
+++ b/src/app/components/release-version-dialog/release-version-dialog.component.scss
@@ -0,0 +1,7 @@
+:host {
+ display: block;
+}
+
+mat-form-field {
+ width: 100%;
+}
diff --git a/src/app/components/release-version-dialog/release-version-dialog.component.spec.ts b/src/app/components/release-version-dialog/release-version-dialog.component.spec.ts
new file mode 100644
index 00000000..41d47207
--- /dev/null
+++ b/src/app/components/release-version-dialog/release-version-dialog.component.spec.ts
@@ -0,0 +1,42 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { vi } from 'vitest';
+
+import { ReleaseVersionDialogComponent } from './release-version-dialog.component';
+
+describe('ReleaseVersionDialogComponent', () => {
+ let component: ReleaseVersionDialogComponent;
+ let fixture: ComponentFixture;
+ const dialogRef = { close: vi.fn() };
+
+ beforeEach(async () => {
+ dialogRef.close.mockReset();
+ await TestBed.configureTestingModule({
+ declarations: [ReleaseVersionDialogComponent],
+ imports: [FormsModule],
+ providers: [
+ { provide: MatDialogRef, useValue: dialogRef },
+ { provide: MAT_DIALOG_DATA, useValue: { currentVersion: '1.1' } },
+ ],
+ schemas: [NO_ERRORS_SCHEMA],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ReleaseVersionDialogComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('accepts a normalized MAJOR.MINOR version', () => {
+ component.version = ' 1.2 ';
+ component.save();
+ expect(dialogRef.close).toHaveBeenCalledWith('1.2');
+ });
+
+ it('rejects malformed versions', () => {
+ component.version = '1.2.3';
+ component.save();
+ expect(component.isInvalid).toBe(true);
+ expect(dialogRef.close).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/components/release-version-dialog/release-version-dialog.component.ts b/src/app/components/release-version-dialog/release-version-dialog.component.ts
new file mode 100644
index 00000000..4396398a
--- /dev/null
+++ b/src/app/components/release-version-dialog/release-version-dialog.component.ts
@@ -0,0 +1,36 @@
+import { Component, Inject } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+
+export interface ReleaseVersionDialogData {
+ currentVersion: string;
+}
+
+@Component({
+ selector: 'app-release-version-dialog',
+ templateUrl: './release-version-dialog.component.html',
+ styleUrls: ['./release-version-dialog.component.scss'],
+ standalone: false,
+})
+export class ReleaseVersionDialogComponent {
+ public version: string;
+
+ constructor(
+ public dialogRef: MatDialogRef,
+ @Inject(MAT_DIALOG_DATA) public data: ReleaseVersionDialogData
+ ) {
+ this.version = data.currentVersion;
+ }
+
+ public get normalizedVersion(): string {
+ return this.version.trim();
+ }
+
+ public get isInvalid(): boolean {
+ return !/^\d+\.\d+$/.test(this.normalizedVersion);
+ }
+
+ public save(): void {
+ if (this.isInvalid) return;
+ this.dialogRef.close(this.normalizedVersion);
+ }
+}
diff --git a/src/app/services/connectors/rest-api/release-tracks.service.spec.ts b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts
index d8a66451..e595f4a8 100644
--- a/src/app/services/connectors/rest-api/release-tracks.service.spec.ts
+++ b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts
@@ -147,6 +147,21 @@ describe('ReleaseTracksConnectorService', () => {
expect(options.params.get('confirm_version')).toBe('1.1');
});
+ it('should change a release version by snapshot identity', () => {
+ http.put.mockReturnValue(of({ version: '1.2' }));
+
+ service
+ .retagRelease('release-track--standard', '2026-07-23T13:37:28.000Z', {
+ version: '1.2',
+ })
+ .subscribe();
+
+ expect(http.put).toHaveBeenCalledWith(
+ `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z/release`,
+ { version: '1.2' }
+ );
+ });
+
it('should create virtual snapshots through the virtual namespace', () => {
service
.createVirtualSnapshot('release-track--virtual', {
diff --git a/src/app/services/connectors/rest-api/release-tracks.service.ts b/src/app/services/connectors/rest-api/release-tracks.service.ts
index 0d6224fd..cd15f6d8 100644
--- a/src/app/services/connectors/rest-api/release-tracks.service.ts
+++ b/src/app/services/connectors/rest-api/release-tracks.service.ts
@@ -17,6 +17,7 @@ import type {
ExportFormatType,
PromoteQuarantinePayload,
ReleasePayload,
+ RetagReleasePayload,
ReleasePreviewOptions,
ReleaseTrackConfig,
ReleaseTrackSnapshotHistoryItem,
@@ -35,6 +36,7 @@ export type {
CreateReleaseTrackPayload,
PromoteQuarantinePayload,
ReleasePayload,
+ RetagReleasePayload,
ReleasePreviewOptions,
ReleaseTrackSnapshotHistoryItem,
ReleaseTrackSnapshotOptions,
@@ -517,6 +519,20 @@ export class ReleaseTracksConnectorService extends ApiConnector {
);
}
+ /** Change a tagged snapshot's semantic version without changing its identity. */
+ public retagRelease(
+ id: string,
+ modified: string,
+ body: RetagReleasePayload
+ ): Observable {
+ const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/release`;
+ return this.http.put(url, body).pipe(
+ tap(result => logger.log(`retagged snapshot ${modified}`, result)),
+ catchError(this.handleError_raise()),
+ share()
+ );
+ }
+
/**
* POST /api/release-tracks/:id/virtual/snapshots/create
* Resolve component tracks and create a new draft snapshot for a virtual track.
diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html
index 9745d6d8..0650c8a5 100644
--- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html
+++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html
@@ -765,13 +765,39 @@
cloud_download
Export…
+
diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts
index 6d5316fe..1b90d3ff 100644
--- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts
+++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts
@@ -19,6 +19,7 @@ import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.com
import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component';
import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component';
import { ReleasePreviewDialogComponent } from 'src/app/components/release-preview-dialog/release-preview-dialog.component';
+import { ReleaseVersionDialogComponent } from 'src/app/components/release-version-dialog/release-version-dialog.component';
import { SnapshotDescriptionDialogComponent } from 'src/app/components/snapshot-description-dialog/snapshot-description-dialog.component';
import { ReleaseReviewDialogComponent } from 'src/app/components/release-review-dialog/release-review-dialog.component';
import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service';
@@ -63,6 +64,7 @@ describe('ReleaseTrackPageComponent', () => {
previewRelease: vi.fn(() => createAsyncObservable({})),
releaseLatest: vi.fn(() => createAsyncObservable({})),
releaseSnapshot: vi.fn(() => createAsyncObservable({})),
+ retagRelease: vi.fn(() => createAsyncObservable({})),
getConfig: vi.fn(() => createAsyncObservable(null)),
updateConfig: vi.fn(() => createAsyncObservable({})),
updateComposition: vi.fn(() => createAsyncObservable({})),
@@ -974,7 +976,7 @@ describe('ReleaseTrackPageComponent', () => {
DeleteDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
- title: 'Delete release 1.1?',
+ title: 'Roll back release 1.1?',
stixId: '1.1',
}),
})
@@ -987,12 +989,75 @@ describe('ReleaseTrackPageComponent', () => {
expect(trackSpy).toHaveBeenCalled();
expect(historySpy).toHaveBeenCalled();
expect(mockSnackbar.open).toHaveBeenCalledWith(
- 'Release 1.1 deleted.',
+ 'Release 1.1 rolled back to draft.',
null,
{ duration: 5000 }
);
});
+ it('should change a tagged release version as an administrator', () => {
+ const item = {
+ snapshot: { version: '1.1' },
+ title: 'v1.1',
+ modified: '2026-07-23T13:37:28.000Z',
+ isTagged: true,
+ } as any;
+ mockDialog.open.mockReturnValue({ afterClosed: () => of('1.2') });
+ mockReleaseTrackApiConnector.retagRelease.mockReturnValue(
+ of({ version: '1.2' })
+ );
+ const refreshSpy = vi
+ .spyOn(component as any, 'refreshReleaseTrackState')
+ .mockImplementation(() => undefined);
+ component.id = 'release-track--123';
+
+ component.onRetagRelease(item);
+
+ expect(mockDialog.open).toHaveBeenCalledWith(
+ ReleaseVersionDialogComponent,
+ expect.objectContaining({ data: { currentVersion: '1.1' } })
+ );
+ expect(mockReleaseTrackApiConnector.retagRelease).toHaveBeenCalledWith(
+ 'release-track--123',
+ item.modified,
+ { version: '1.2' }
+ );
+ expect(refreshSpy).toHaveBeenCalled();
+ expect(mockSnackbar.open).toHaveBeenCalledWith(
+ 'Release 1.1 changed to 1.2.',
+ null,
+ { duration: 5000 }
+ );
+ });
+
+ it('should hide a preserved release-source draft until rollback', () => {
+ const sourceModified = '2026-07-23T13:37:27.000Z';
+ mockReleaseTrackApiConnector.listSnapshots.mockReturnValue(
+ of({
+ data: [
+ {
+ id: 'release-track--123',
+ modified: '2026-07-23T13:37:28.000Z',
+ version: '1.0',
+ release_source_modified: sourceModified,
+ },
+ {
+ id: 'release-track--123',
+ modified: sourceModified,
+ version: null,
+ },
+ ],
+ })
+ );
+ component.id = 'release-track--123';
+
+ component.getSnapshotHistory();
+
+ expect(component.snapshotHistory).toHaveLength(1);
+ expect(component.snapshotHistory[0].title).toBe('v1.0');
+ expect(component.hasCurrentDraftSnapshot).toBe(false);
+ });
+
it('should not offer release deletion to non-administrators or for drafts', () => {
mockAuthenticationService.canDelete.mockReturnValue(false);
component.id = 'release-track--123';
diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts
index 20533ea7..38dd67e3 100644
--- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts
+++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts
@@ -49,6 +49,7 @@ import {
ReleasePreviewDialogComponent,
ReleasePreviewSelection,
} from 'src/app/components/release-preview-dialog/release-preview-dialog.component';
+import { ReleaseVersionDialogComponent } from 'src/app/components/release-version-dialog/release-version-dialog.component';
import {
ReleaseReviewDialogComponent,
ReleaseReviewDialogResult,
@@ -242,6 +243,7 @@ export class ReleaseTrackPageComponent implements OnInit {
private createdDraftSnapshot: ReleaseTrackSnapshotHistoryItem | null = null;
private updatingSnapshotDescriptionModified = new Set();
private deletingReleaseModified = new Set();
+ private retaggingReleaseModified = new Set();
public publicationResolved: PublicationResolved | null = null;
public publicationIdentityOptions: PublicationOption[] = [];
public publicationMarkingOptions: PublicationOption[] = [];
@@ -752,6 +754,58 @@ export class ReleaseTrackPageComponent implements OnInit {
return !!item.modified && this.deletingReleaseModified.has(item.modified);
}
+ public isRetaggingRelease(item: SnapshotHistoryViewModel): boolean {
+ return !!item.modified && this.retaggingReleaseModified.has(item.modified);
+ }
+
+ public onRetagRelease(item: SnapshotHistoryViewModel): void {
+ if (
+ !this.id ||
+ !item.modified ||
+ !item.isTagged ||
+ !this.canDeleteRelease ||
+ this.isRetaggingRelease(item)
+ ) {
+ return;
+ }
+
+ const currentVersion = item.snapshot.version || '';
+ const modified = item.modified;
+ const prompt = this.dialog.open(ReleaseVersionDialogComponent, {
+ maxWidth: '35em',
+ disableClose: true,
+ autoFocus: false,
+ data: { currentVersion },
+ });
+
+ prompt
+ .afterClosed()
+ .pipe(take(1))
+ .subscribe(version => {
+ if (!version || version === currentVersion) return;
+
+ this.retaggingReleaseModified.add(modified);
+ this.connector
+ .retagRelease(this.id, modified, { version })
+ .pipe(
+ take(1),
+ finalize(() => this.retaggingReleaseModified.delete(modified))
+ )
+ .subscribe({
+ next: () => {
+ this.snackbar.open(
+ `Release ${currentVersion} changed to ${version}.`,
+ null,
+ { duration: 5000 }
+ );
+ this.refreshReleaseTrackState();
+ },
+ error: err =>
+ console.error('Failed to change release version', err),
+ });
+ });
+ }
+
/**
* Delete the track's most recent release. Only administrators may do this,
* and they confirm by typing the release version.
@@ -768,13 +822,18 @@ export class ReleaseTrackPageComponent implements OnInit {
}
const version = item.snapshot.version || '';
const modified = item.modified;
+ const rollsBackToDraft = !this.isVirtualReleaseTrack;
const prompt = this.dialog.open(DeleteDialogComponent, {
maxWidth: '35em',
disableClose: true,
autoFocus: false,
data: {
- title: `Delete release ${version}?`,
- warning: `Release ${version} of ${this.releaseTrackName || 'this track'} will be permanently deleted. Its version becomes available again and later drafts are kept.`,
+ title: rollsBackToDraft
+ ? `Roll back release ${version}?`
+ : `Delete release ${version}?`,
+ warning: rollsBackToDraft
+ ? `Release ${version} of ${this.releaseTrackName || 'this track'} will be removed and its exact pre-release draft restored. This is blocked if a virtual snapshot depends on the release.`
+ : `Release ${version} of ${this.releaseTrackName || 'this track'} will be permanently deleted.`,
stixId: version,
},
});
@@ -798,16 +857,26 @@ export class ReleaseTrackPageComponent implements OnInit {
)
.subscribe({
next: () => {
- this.snackbar.open(`Release ${version} deleted.`, null, {
- duration: 5000,
- });
+ this.snackbar.open(
+ rollsBackToDraft
+ ? `Release ${version} rolled back to draft.`
+ : `Release ${version} deleted.`,
+ null,
+ {
+ duration: 5000,
+ }
+ );
this.getReleaseTrack();
this.getSnapshotHistory();
},
error: err => {
console.error('Failed to delete release', err);
+ const dependents = err?.error?.dependent_snapshots;
this.snackbar.open(
- 'Unable to delete this release. Please try again.',
+ Array.isArray(dependents) && dependents.length
+ ? `Unable to roll back: ${dependents.length} virtual snapshot${dependents.length === 1 ? '' : 's'} depend on this release.`
+ : err?.error?.message ||
+ 'Unable to roll back this release. Please try again.',
null,
{ duration: 5000, panelClass: 'error' }
);
@@ -1857,7 +1926,12 @@ export class ReleaseTrackPageComponent implements OnInit {
: 'Tagging the release and sealing its content.';
}
if (this.deletingReleaseModified.size > 0) {
- return 'Deleting the release and reconciling the track.';
+ return this.isVirtualReleaseTrack
+ ? 'Deleting the virtual release and reconciling the track.'
+ : 'Rolling back the release and restoring its preserved draft.';
+ }
+ if (this.retaggingReleaseModified.size > 0) {
+ return 'Changing the release version and rebuilding its exports.';
}
if (this.isDeleting) {
return 'Deleting the release track and its history.';
@@ -3064,7 +3138,21 @@ export class ReleaseTrackPageComponent implements OnInit {
private buildSnapshotHistory(
snapshots: ReleaseTrackSnapshotHistoryItem[]
): SnapshotHistoryViewModel[] {
- const sorted = [...snapshots].sort(
+ const retainedSourceDrafts = new Set(
+ snapshots
+ .filter(snapshot => this.isTaggedSnapshot(snapshot))
+ .map(snapshot => snapshot.release_source_modified)
+ .filter((modified): modified is string | Date => !!modified)
+ .map(modified =>
+ modified instanceof Date ? modified.toISOString() : String(modified)
+ )
+ );
+ const visibleSnapshots = snapshots.filter(snapshot => {
+ if (this.isTaggedSnapshot(snapshot)) return true;
+ const modified = this.getSnapshotModified(snapshot);
+ return !modified || !retainedSourceDrafts.has(modified);
+ });
+ const sorted = [...visibleSnapshots].sort(
(a, b) => this.getSnapshotTime(b) - this.getSnapshotTime(a)
);
const latestSnapshot =