Skip to content
bitzorcas
中EN

Guide

Documents Content, Versions, Publishing, and Rollback

A deep guide to full snapshots, version numbers, hashes, lifecycle transitions, rollback, diff behavior, concurrency, and editor flows.

Last updated

Documents keeps current content and version history inside one Document aggregate. Every content update creates a complete snapshot and updates the current content and version. Reads and rollback are straightforward, but the aggregate row grows with history and concurrent writers need an explicit policy.

1. Stored content facts

DataStorageMeaning
Title/Description/Contentordinary columnsCurrent authoritative content
ContentType/LanguageCodeordinary columnsDeclared format and language
StatusDraft/Published/ArchivedPublication lifecycle
CurrentVersionstringCurrent snapshot identifier
VersionsVersionsJsonEvery full snapshot
TagsTagsJsonCase-insensitive unique tags
AccessRulesAccessRulesJsonAccess data, not currently enforced
StorageKey/FileSizeordinary columnsReserved metadata with no current SetStorage handler path

Creation writes content and version 1.0.0. A snapshot hashes UTF-8 content with SHA-256 and records the UTF-8 byte count. This hash is a content fact, not a request signature or access token.

2. Version progression

CreateNewVersionCreateNewVersionRollback(1.0.0)

1.0.0
initial full snapshot

1.0.1
edit

1.0.2
edit

1.0.3
content copied from 1.0.0

Rollback reads an old snapshot and creates a new Patch. It never rewinds CurrentVersion, removes later versions, or mutates the historical snapshot. There is no Major/Minor API today, and clients must compare version segments rather than parse the value as a decimal.

3. Updating content

Save an edit
PUT /api/v1/documents/doc-42/content HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"content": "# Refund policy\n\nRefunds after 30 days require approval.",
"changeSummary": "Add late-refund approval"
}

The handler requires a User caller, loads the aggregate inside User.TenantId, calls CreateNewVersion, and saves through the standard existing-aggregate handler. Archived content returns Docs.Document.CannotModifyArchived.

Narrow the stale-edit window in a client
type DocumentDetail = {
documentId: string;
currentVersion: string;
content: string;
};
export async function saveDraft(
original: DocumentDetail,
nextContent: string,
): Promise<void> {
// This read narrows the window but cannot eliminate a concurrent race.
const latest = await fetch(`/api/v1/documents/${original.documentId}`, {
credentials: "include",
}).then((response) => response.json() as Promise<DocumentDetail>);
if (latest.currentVersion !== original.currentVersion)
throw new Error("the document changed; merge before saving");
// The server must eventually enforce ExpectedVersion in the same database write.
const response = await fetch(
`/api/v1/documents/${original.documentId}/content`,
{
method: "PUT",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
content: nextContent,
changeSummary: "Editor save",
}),
},
);
if (!response.ok) throw new Error(`save failed: ${response.status}`);
}

The server-side fix is an ExpectedVersion check in the same transaction plus a database condition/concurrency token so two requests cannot both pass.

4. Publish lifecycle

publishunpublisharchiveedit or rollbackedit or rollback

Draft

Published

Archived

OperationPreconditionEffectExtra fact
PublishDraftPublishedMarks current version published, sets PublishedTime/By, raises domain event
UnpublishPublishedDraftClears document PublishedTime/By; version marker remains true
ArchivePublishedArchivedRaises DocumentArchived domain event
Repeated publishPublishedConflictNot idempotent success
Archive DraftDraftFailurePublish first

The catalog defines docs.document.publish, but Publish uses AuthorizationAction.Update. The generic pipeline therefore evaluates update semantics, not the dedicated publish constant.

5. Rollback

Verify forward-only rollback history
// Request old content while preserving a newly allocated forward version.
using var response = await api.PostAsync(
"/api/v1/documents/doc-42/versions/1.0.0/rollback",
content: null,
cancellationToken);
response.EnsureSuccessStatusCode();
var version = await response.Content
.ReadFromJsonAsync<DocumentVersionDto>(cancellationToken);
// The content can match 1.0.0, but the new identity must advance.
if (version!.VersionNumber == "1.0.0")
throw new InvalidOperationException("rollback must preserve forward history");
public sealed record DocumentVersionDto(
// The response combines the new identity with facts computed from the restored content.
string VersionNumber,
string Content,
string ContentHash,
long FileSize,
string? ChangeSummary);

An unknown target returns Docs.Document.VersionNotFound. Archived documents cannot roll back because rollback ultimately creates a new version.

6. What the diff endpoint computes

GET /api/v1/documents/{id}/versions/diff?fromVersion=1.0.0&toVersion=1.0.2 is not a Git/Myers/LCS diff. It splits on \n, finds an exact common prefix and suffix, marks the entire new middle as additions and the old middle as deletions, then emits one Chinese summary when both exist.

Simplified diff response
{
"fromVersion": "1.0.0",
"toVersion": "1.0.2",
"additions": ["+ Refunds after 30 days require approval."],
"deletions": ["- Refunds after 15 days are rejected."],
"changes": ["~ 第 3 行起 1 行删除, 1 行新增"]
}

This is adequate for one contiguous changed region, not scattered edits, moved blocks, character-level highlighting, or code review. A UI must not present it as an exact patch.

7. The ignored includeVersions flag

Detail exposes includeVersions=false, but FindDocumentByIdAsync ignores the flag and always loads the full aggregate. Mapping always returns Versions, Tags, and AccessRules. Consequently:

  • ordinary detail cost grows with history;
  • historical content and ACL data are returned even when not requested;
  • the API parameter gives callers a false performance expectation.

Use a projection that excludes large JSON columns for ordinary detail and separate queries for history. Dropping fields after full materialization does not fix the database cost.

8. Archive mutability audit

OperationArchived behavior
UpdateInfo / SetOptions / CreateNewVersionRejected
Publish / Unpublish / ArchiveRejected or conflict
AddTag / AddAccessRuleRejected
RemoveTag / RemoveAccessRuleCurrently allowed
IncrementViewCount / SetStorageNo archive check, but no handler currently calls them

If the business definition is immutable archive, enforce it in every aggregate mutation and cover the matrix with parameterized tests.

9. Essential tests

Aggregate rollback contract
[Fact]
public void Rollback_Should_Create_A_New_Forward_Version()
{
// Arrange two immutable snapshots: 1.0.0 and 1.0.1.
var document = CreateDraft(content: "v1");
document.CreateNewVersion("v2", "edit", "user-1", Now).IsSuccess.ShouldBeTrue();
// Act by copying old content without deleting or reusing a historical identity.
var result = document.Rollback("1.0.0", "user-1", Now.AddMinutes(1));
result.IsSuccess.ShouldBeTrue();
document.Content.ShouldBe("v1");
document.CurrentVersion.ShouldBe("1.0.2");
document.Versions.ShouldContain(v => v.VersionNumber == "1.0.1");
}

Add integration coverage for concurrent updates, publish/edit races, large history JSON, actual includeVersions projection, unpublish markers, every archived mutation, and all reads after soft delete.

Back to Documents

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%