Skip to content
bitzorcas
中EN

Reference

DocumentStructure template graph, versioning, and persistence

A deep guide to template graph validation, temporary-node ID remapping, tenant constraints, replacement writes, historical JSON snapshots, version increments, and concurrency boundaries.

Last updated

A folder template consists of a root record, its current nodes, and historical node snapshots. This is an explicit multi-table persistence model rather than a one-to-one domain aggregate mirror. The store owns trusted-tenant propagation, key remapping, and replacement writes.

1. Three-table model

TemplateIdTemplateId

DocsFolderTemplate
Name · TargetType · CurrentVersion · IsActive

DocsTemplateNode
current nodes · ParentNodeId · SortOrder

DocsTemplateVersion
old version · NodesSnapshotJson · description

The root and nodes carry soft-delete metadata. Deleting a template physically removes current nodes and marks the root deleted. Historical versions are not physically deleted with the root. No public API reads that history, so snapshots are persistence evidence today, not a usable versioning product.

2. Graph invariants

ValidateNodeGraph is shared by Create and Update. It verifies:

  • every ID is nonblank;
  • IDs are request-unique with ordinal comparison;
  • each nonblank ParentNodeId names another request node;
  • walking from any node to the root does not form a cycle;
  • depth, including the node itself, does not exceed MaxNodeDepth = 10.

It does not validate field lengths or formats for Name, expression, icon, color, or description. It does not cap total nodes, forbid sibling duplicates or repeated SortOrder, or allowlist TargetType. Oversized values may surface only as persistence failures.

Construct a valid three-level graph
var nodes = new TemplateNodeDefinition[]
{
// Request IDs are local graph handles, not stable external identifiers.
new("root", null, "Root", null, null, null, null, 10),
new("phase", "root", "Phase", null, null, null, null, 20),
new("deliverable", "phase", "Deliverable", null, null, null, null, 30)
};
var result = await mediator.Send(new CreateFolderTemplate.Command(
"Project layout", null, "KnowledgeBase", nodes), cancellationToken);
// Successful response nodes contain the final persistent IDs.
return result.Value!.Nodes;

3. Temporary-ID remapping

The store preallocates a final ID for every NewTemplateNodeRecord.Id, builds a lookup, remaps each ParentNodeId to its parent’s final ID, and then calls AddRange. The algorithm does not depend on database-generated key order and can be shared by both ORM adapters.

If a parent is not in the lookup, ResolveParentId raises a storage error. The normal handler rejects this earlier. The store also repeats nonblank/unique ID checks, providing defense at the persistence boundary.

4. Trusted tenant boundary

Read methods require the caller-supplied TenantId to match ICurrentTenant.Tenant.EffectiveTenantId. Writes use EffectiveTenantId instead of trusting an external tenant field. Update invokes EnsureCurrentTenant and EnsureSnapshotOwnership, rejecting roots or nodes from another tenant when constructing history.

Review shape for a tenant-scoped store read
var template = await store.GetTemplateAsync(
currentUser.User.TenantId,
templateId,
cancellationToken);
// Do not accept a client tenant ID and pass it directly into a write.
if (template is null)
return DocsErrors.Template.NotFound(templateId);

5. Create semantics

Create fixes the root version at 1.0.0 and IsActive = true. An empty TargetType falls back to KnowledgeBase; any other string is stored unchanged. The application pre-checks duplicate names, while the database enforces unique (TenantId, Name).

Two concurrent creates can both pass the pre-check before one loses the unique race. Unless shared exception mapping identifies that index, the loser may receive an infrastructure error rather than Docs.Template.NameAlreadyExists. Both ORMs need a real concurrent test.

6. Update and version-number behavior

Update validates the new graph, loads the root, checks name conflicts, loads the old nodes, and increments the final component of CurrentVersion. 1.0.0 becomes 1.0.1; 1.2 becomes 1.3; an unparsable last component becomes 1. This is not complete Semantic Versioning and carries no major/minor product meaning.

The store appends an old-node snapshot, updates root fields/version, deletes current nodes, then inserts the new nodes. Atomicity depends on the command transaction pipeline and adapter using the same unit of work. The store method does not open its own transaction.

7. Snapshot payload

The JSON snapshot includes old node Id, ParentNodeId, Name, DynamicNameExpression, Icon, Color, Description, and SortOrder. Serialization uses generated DocumentStructureJsonSerializerContext, preserving AOT compatibility.

It omits template Name, Description, TargetType, IsActive, SnapshotSchemaVersion, actor, and policy version. A future reader cannot reconstruct the complete historical template. Old persistent node IDs also require remapping during rollback and cannot simply be copied into another template.

Conceptual snapshot envelope required for GA
{
"snapshotSchemaVersion": 1,
"templateVersion": "1.0.1",
"template": { "name": "Project layout", "targetType": "KnowledgeBase" },
"nodes": [
{
"id": "old-node-id",
"parentNodeId": null,
"name": "Root",
"sortOrder": 10
}
]
}

This envelope is a target contract, not the current serialized shape.

8. Replacement writes and concurrency

Two editors can both read 1.0.0, calculate 1.0.1, append snapshots, and let the later commit overwrite the earlier node set. The root has no conditional version update, and history has no unique (TenantId, TemplateId, VersionNumber) constraint. Commercial GA requires an ETag/version precondition and a typed conflict result.

Replacement cost grows with node count and increases lock and transaction-log pressure. Measure hundred- and thousand-node templates before choosing among full replacement, a diff-based update, or immutable PublishedVersion plus Draft.

9. Delete semantics

Delete loads the tenant-scoped template, removes current nodes, and soft-deletes the root. Historical versions remain, but no restore-template API exists. Favorite references to the template remain. Categories already created by Apply remain and carry no TemplateId/version provenance.

Delete racing with Update or Apply is unspecified. Apply may read IsActive, another request may delete the template, and the first request may continue creating categories.

10. Choose an explicit target version model

Two coherent options are available:

  1. Mutable template: every update stores a complete snapshot and exposes history, diff, and rollback; Apply records the exact version.
  2. Immutable published versions: editors change a Draft, publication creates an immutable version, and Apply accepts only Published.

Both options require snapshot schema versioning, concurrency tokens, complete metadata, actor/time, change notes, rollback audit, and migration for old snapshots.

11. Evidence matrix

ScenarioCurrent evidenceGA addition
duplicate temporary IDhandler testHTTP error contract
missing parenthandler testdeep/cross-template references
cycle and depthimplementationdepth 10/11 and large-graph tests
key remappinghandler/store testsdual-ORM parity
cross-tenant snapshotstore testHTTP/integration attack
concurrent same namenoneunique-conflict mapping
concurrent updatenoneETag and no lost update
insert failure mid-updatenonetransaction rollback

12. Review commands

Terminal window
# Graph, version, snapshot, and replacement-write surfaces.
rg -n "ValidateNodeGraph|MaxNodeDepth|NodesSnapshotJson|DeleteWhereAsync|AddNodesAsync" \
src/Platform/DocumentStructure -g '*.cs'
# Version reading, rollback, and concurrency contracts should currently have no matches.
rg -n "ListTemplateVersions|RollbackTemplate|SnapshotSchemaVersion|ConcurrencyToken" \
src/Platform/DocumentStructure -g '*.cs'

DocumentStructure overview · Template preview and apply · Testing and GA

100%

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