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
| Data | Storage | Meaning |
|---|---|---|
| Title/Description/Content | ordinary columns | Current authoritative content |
| ContentType/LanguageCode | ordinary columns | Declared format and language |
| Status | Draft/Published/Archived | Publication lifecycle |
| CurrentVersion | string | Current snapshot identifier |
| Versions | VersionsJson | Every full snapshot |
| Tags | TagsJson | Case-insensitive unique tags |
| AccessRules | AccessRulesJson | Access data, not currently enforced |
| StorageKey/FileSize | ordinary columns | Reserved 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
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
PUT /api/v1/documents/doc-42/content HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-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.
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
| Operation | Precondition | Effect | Extra fact |
|---|---|---|---|
| Publish | Draft | Published | Marks current version published, sets PublishedTime/By, raises domain event |
| Unpublish | Published | Draft | Clears document PublishedTime/By; version marker remains true |
| Archive | Published | Archived | Raises DocumentArchived domain event |
| Repeated publish | Published | Conflict | Not idempotent success |
| Archive Draft | Draft | Failure | Publish 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
// 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.
{ "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
| Operation | Archived behavior |
|---|---|
| UpdateInfo / SetOptions / CreateNewVersion | Rejected |
| Publish / Unpublish / Archive | Rejected or conflict |
| AddTag / AddAccessRule | Rejected |
| RemoveTag / RemoveAccessRule | Currently allowed |
| IncrementViewCount / SetStorage | No 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
[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.