Summary#
A FileAsset is a file's identity; a FileVersion is one content it has had. The asset points at the version
that is current, so:
- a business record's FK never re-points —
doc.Pdfis the same row before and after a revision - the file's grants cover its whole history, because a version has no authorization of its own
- superseding copies nothing — a new content is a new row and a moved pointer
var pdf = File.Create("decision.pdf", "application/pdf", bytes); // the file, and its version 1
File.Supersede(pdf, corrected); // version 2; version 1 is untouchedSignature#
FileAsset File.Create(string name, string mimeType, byte[] bytes)
void File.Supersede(FileAsset asset, byte[] bytes)Description#
Why a file is two things#
Because the two change on different clocks. A document's identity — what a case links to, who may see it, which folder it is filed in — outlives any particular content, and a revision must not disturb it. Its content is what gets replaced, and what has to be kept.
Writing content on the file itself would mean copying the old bytes somewhere on every revision. Writing a chain of files instead would mean every record pointing at one had to follow the chain to find out what "current" is. Neither is what an app wants to write.
What each version keeps#
Version | 1 for the first content, counting up |
CreatedBy / CreatedAt | who replaced it, and when — the platform's own audit stamps |
Name / MimeType | what the file was called and what type it was at the time, so a rename is history too |
Size / ContentHash | derived from the bytes by the platform, never declarable by an app |
| the content | so any version can be shown or downloaded, not just the current one |
⚠ A version's CreatedAt is when that content STOPPED being current, not when it started — the row is written at
the moment it is replaced. A history that wants "in force from X to Y" takes Y from the version and X from the
previous one (or from the file's own CreatedAt, for version 1).
Showing every version a file has had#
asset.Versions is every content the file has had, current one included — so a history list is one loop with no
special last entry.
foreach (var v in doc.Pdf.Versions.OrderBy(v => v.Version)) {
Row(gap: Space.Gutter) {
Text("v" + v.Version);
Text(v.Name);
Text(Text.ByteSize(v.Size));
if (v.Id == doc.Pdf.CurrentVersion.Id) { Badge("current", tone: Tone.Success); }
}
}Downloading one version#
A signed URL names one content. Ask for a version and the link fetches that version and nothing else — not the next one, not the current one. Whoever may read the file may read all of it, but a link handed to a browser is a bearer capability, and the one beside "version 2" has no business also fetching version 7.
Erasing a file#
Deleting a FileAsset deletes its versions and their bytes. That is not a convenience: a history that outlived
its file would be a retention problem wearing an archive costume, and rows removed while their content stayed in the
store would make "erase this person's files" true only on paper.
⚠ The consequence to accept: the only way to remove one version is to remove the file. For an archive that is correct — selective deletion of history is what an archive exists to prevent.
Traps that cost real time#
A file's size lives on its content. asset.Size does not exist; asset.CurrentVersion.Size is what it says now,
and v.Size is what a given version weighs. Every one of them counts against the app's storage quota — a document
superseded fifty times is fifty stored contents.
Superseding does not rename. Supersede replaces bytes; asset.Name is an ordinary field you set yourself. The
version records the name that was in force, so a rename after the fact is not retroactive.
A file can exist with no content. new FileAsset { Name = … } is legal and gives you an identity with no
CurrentVersion — useful for a placeholder, and the reason a viewer should handle "nothing to show" rather than
assume bytes.
Examples#
app CaseFiles { use Osysharp.Storage; }
using Osysharp.Storage;
entity CaseDocument {
[MaxLength(200)] string Title;
FileAsset Pdf;
}
// A new document: `File.Create` writes the file AND its first content, along with the size, the hash and the
// inline-vs-external routing — none of which an app declares.
CaseDocument NewDocument(string title, UploadedFile f) {
var doc = new CaseDocument { Title = title, Pdf = File.Create(f.FileName, f.ContentType, File.ReadAllBytes(f.Path)) };
UnitOfWork.Commit();
return doc;
}
// A revision. `doc.Pdf` is the SAME row afterwards, so nothing that points at this document has to be told —
// and what the document used to say is still there, under its own version number.
void ReplaceContent(CaseDocument doc, UploadedFile f) {
File.Supersede(doc.Pdf, File.ReadAllBytes(f.Path));
UnitOfWork.Commit();
}See also#
- File.SignedUrl — how a private file reaches a browser at all
- The PDF kit — a document viewer and a page thumbnail — showing a document, and one page of it as a picture
- use —
use Osysharp.Storage;, which puts all of this in scope