How to Build a Versioned Notion Archive in C#
An archive of Notion pages has three storage requirements that a single exported file does not meet well. Each change to a page must be kept as a new revision without overwriting the old one. A reader must be able to open one revision and find its blocks and attachment without scanning the whole archive. An import must either apply completely, including its record of how far it got, or not at all.
This article builds that archive on ObjectSpace from Sphere10 Framework. Unique indexes detect revisions and attachments that are already stored, a nonunique index groups blocks under their revision, and one file transaction covers the content and the import cursor together.
1. A first look
The sample opens the store with a minimal builder. It selects a file, names the folder for uncommitted pages, declares each record type with its indexes and leaves every size at its default.
public static FileObjectSpace Open(string root) {
var pending = Path.Combine(root, "pending");
Directory.CreateDirectory(pending);
var builder = new ObjectSpaceBuilder()
.UseFile(Path.Combine(root, "archive.bin"))
.KeepUncommittedPagesAt(pending);
builder.AddDimension<ArchivePage>(ignoreAnnotations: true)
.WithIdentifier(item => item.Id)
.WithUniqueIndexOn(item => item.RevisionKey)
.Done();
builder.AddDimension<ArchiveBlock>(ignoreAnnotations: true)
.WithIdentifier(item => item.Id)
.WithIndexOn(item => item.PageRevisionId)
.Done();
builder.AddDimension<ArchiveAttachment>(ignoreAnnotations: true)
.WithIdentifier(item => item.Id)
.WithUniqueIndexOn(item => item.Digest)
.Done();
builder.AddDimension<ImportCheckpoint>(ignoreAnnotations: true)
.WithIdentifier(item => item.Id)
.WithUniqueIndexOn(item => item.Source)
.Done();
var store = (FileObjectSpace)builder.Build();
store.Load();
return store;
}
Build() constructs the space and Load() opens the file, creating it on first use. The same method serves every later open, which keeps the dimension order and index declarations identical each time.
2. The archive's dimensions and indexes
The store has four dimensions. Each index exists to answer one specific question during import or reading.
| Dimension | Index | Question it answers |
ArchivePage |
Unique on RevisionKey |
Is this exact revision already stored? |
ArchiveBlock |
Nonunique on PageRevisionId |
Which blocks belong to this revision? |
ArchiveAttachment |
Unique on Digest |
Are these bytes already stored? |
ImportCheckpoint |
Unique on Source |
Where did the last committed import stop? |
The three content dimensions refer to one another through plain identifier values. ImportCheckpoint stands alone.
Diagram source
erDiagram
ArchivePage ||--o{ ArchiveBlock : "PageRevisionId"
ArchiveBlock }o--|| ArchiveAttachment : "AttachmentId"
ArchivePage {
long Id PK
string RevisionKey UK
}
ArchiveBlock {
long Id PK
long PageRevisionId
long AttachmentId
int Position
}
ArchiveAttachment {
long Id PK
string Digest UK
}
3. Normalize the export boundary
The importer does not read Notion's export format directly. It accepts a normalized batch with a source cursor and an array of pages, where each page carries its ID, title, ordered block texts and attachment bytes. Producing that shape from a real export or from the Notion API is the exporter's job and is outside this article.
Two hashes drive versioning. The revision key is the page ID joined to a SHA-256 of the serialized page, so any change to a page yields a new key and an unchanged page yields the same one. The attachment digest is a SHA-256 of the attachment bytes, which lets several revisions share one stored copy.
4. Import a batch in one transaction
ArchiveStore.Import stages every record for the batch and commits once at the end. For each incoming page it first asks the two unique indexes whether the revision and the attachment are already stored.
foreach (var document in batch.Pages) {
var key = RevisionKey(document);
if (store.TryGet<ArchivePage, string>(item => item.RevisionKey, key, out var existingPage))
continue;
var digest = Digest(document.Attachment);
if (!store.TryGet<ArchiveAttachment, string>(item => item.Digest, digest, out var attachment)) {
attachment = store.New<ArchiveAttachment>();
attachment.Id = nextAttachment++;
attachment.Digest = digest;
attachment.Content = document.Attachment;
store.Save(attachment);
}
The loop then saves the ArchivePage and one ArchiveBlock for each block in the same way. After the loop, the method stores the cursor and ends the transaction.
if (!store.TryGet<ImportCheckpoint, string>(item => item.Source, Source, out var checkpoint)) {
checkpoint = store.New<ImportCheckpoint>();
checkpoint.Id = 1;
checkpoint.Source = Source;
}
checkpoint.Cursor = batch.Cursor;
store.Save(checkpoint);
if (failBeforeCommit)
throw new InvalidDataException("Injected failure before commit.");
store.Commit();
return inserted;
} catch {
store.Rollback();
throw;
}
The failBeforeCommit argument exists only so that the rollback path can be exercised. Four ObjectSpace behaviours carry the design.
-
TryGeton a unique index is the duplicate check. A revision key that is already present is skipped, so importing the same batch twice inserts nothing. The same call onDigestreuses an attachment that an earlier revision stored. -
New<T>()creates a tracked instance that becomes a stored record onSave. Identifiers are assigned by the application, because an identifier index does not generate them. -
Saveonly stages. Pages, blocks, attachments and the checkpoint all sit in pending pages untilCommit(). -
The cursor is saved in the same transaction as the content. A committed cursor therefore always describes content that was committed with it.
If anything throws before Commit(), the catch block calls Rollback() and the file keeps its previous content and its previous cursor. Rollback reloads the space and invalidates every object fetched from it. The method disposes the store straight afterwards, and the next import opens a new one.
Diagram source
---
config:
sequence:
actorMargin: 8
width: 96
mirrorActors: false
messageMargin: 28
noteMargin: 8
---
sequenceDiagram
participant I as Import
participant S as FileObjectSpace
participant F as archive.bin
loop each page
I->>S: TryGet(RevisionKey)
I->>S: Save page, blocks
end
I->>S: Save(checkpoint)
alt no exception
I->>S: Commit()
S->>F: publish pages
else exception
I->>S: Rollback()
Note over F: unchanged
end
5. Read one revision back
Reading uses the same indexes. The unique index finds the revision, and the nonunique index returns the slots of its blocks, which are then loaded through the space.
public static ArchiveBlock[] BlocksOf(ObjectSpace store, long revisionId) {
var dimension = (ObjectSpace.Dimension<ArchiveBlock>)store.Dimensions.Single(item => item.Definition.ObjectType == typeof(ArchiveBlock));
Expression<Func<ArchiveBlock, long>> member = item => item.PageRevisionId;
var positions = dimension.Container.ObjectStream.GetIndexFor(member).Values[revisionId].ToArray();
return positions.Select(store.Get<ArchiveBlock>).OrderBy(block => block.Position).ToArray();
}
using var reopened = ArchiveStore.Open(root);
using (reopened.EnterAccessScope()) {
var page = reopened.Get<ArchivePage, string>(item => item.RevisionKey, ArchiveStore.RevisionKey(edited));
var blocks = ArchiveStore.BlocksOf(reopened, page.Id);
var attachment = reopened.Get<ArchiveAttachment, long>(item => item.Id, blocks[0].AttachmentId);
var checkpoint = reopened.Get<ImportCheckpoint, string>(item => item.Source, ArchiveStore.Source);
The index lookup makes no promise about block order, so the helper sorts the matching blocks by Position. Only the blocks of the requested revision are loaded.
6. What the archive does not do
-
No full-text search. Lookups go through the declared indexes. Searching block text would need a separate index that the application maintains.
-
No enforced relationships.
PageRevisionIdandAttachmentIdare plain values. The importer keeps them consistent by saving the records in one transaction. -
No authentication of content. The hashes identify a revision and deduplicate attachments. They do not prove who produced the export.
-
Rollback covers application failures. The sample exercises an exception during import. It does not test power loss.
7. Get the package and run the sample
Add the package to a .NET 10 project.
dotnet add package Sphere10.Framework --version 3.1.3
Download the sample project. It targets .NET 10 and the Sphere10.Framework 3.1.3 package.
dotnet run -c Release -- ./fresh-example-data
The run imports three pages, imports a new revision of one page, repeats that batch, and forces a fourth import to fail before its commit. It then reopens the store and reads the new revision back.
Inserted revisions per batch: 3, 1, 0
Failed batch rolled back
Stored revisions: 4; blocks: 8; attachments: 3
Latest revision of page-2: 2 blocks; first block: Page 2, first block, edited.
Attachment shared with the earlier revision: True
Committed cursor: cursor-2
The
systemd timer article
wraps the same ArchiveStore.Import call in a one-shot process that a scheduler can run unattended.
The ObjectSpace storage guide documents the builder, the index lookups and the transaction rules used here, and the Sphere10 Framework guide lists the other components.
For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .
Author: Herman Schoenfeld