-
Notifications
You must be signed in to change notification settings - Fork 4
Versioned schemas
Scrinium stores a schema id in every document, so several versions of a type can live in one
collection at once. The id lives in the _s element of the document
(ModelMapSchema.IdElementName); documents carrying it in the deprecated _m element
(ModelMapSchema.DeprecatedIdElementName) deserialize the same way, and the Admin dashboard
brings them onto the current element. When you change a model, you don't migrate all its documents —
you register the old shape as a secondary schema, and old documents keep deserializing correctly.
- The active schema (the one you pass to
AddModelMap) serializes new writes and is stamped into the documents it produces. -
Secondary schemas are read-only: they teach Scrinium how to deserialize documents written by
earlier versions. Add them on the builder returned by
AddModelMap.
A document read through a secondary schema is upgraded to the active schema the next time it's saved (the member-level save falls back to a migrating whole-document replace — see Change tracking and saving). So documents drift forward lazily, as they're touched.
Not every model change is a new version — the document model is flexible:
-
Adding or removing members keeps the schema id. A document missing a new member deserializes
it to its default; elements of a removed member land in the
ExtraElementsbag, emptied after the load. - Renaming a member or changing its type needs a new active schema id, keeping the old shape as a secondary schema: the same element can't deserialize two ways under one id.
A member added without a new schema id relies on the model contract: Scrinium builds a model through its parameterless constructor, so a member the document doesn't carry keeps its default. A model that isn't an entity model and declares no parameterless constructor, or whose own constructor feeds a member with no setter, is built by that constructor instead: it takes every argument from the document, so a member added to it as a constructor argument needs a new schema id, like a rename.
IModelMapBuilder<TModel> AddSecondarySchema(
string id,
Action<BsonClassMap<TModel>>? modelMapSchemaInitializer = null,
string? baseSchemaId = null,
Func<IDbContext, TModel, Task<TModel>>? fixDeserializedModelFunc = null);If a property was renamed in code, map it in the secondary schema to its old element name so old documents still bind:
dbContextEngine.MapRegistry.AddModelMap<Cat>("d2f…v2", map => map.AutoMap())
.AddSecondarySchema("a1b…v1", map =>
{
map.AutoMap();
map.GetMemberMap(c => c.Birthday).SetElementName("BirthDate"); // the pre-rename name
});Some evolutions need logic, not just remapping: hoist a value out of ExtraElements, split a field,
or recompute a denormalized value. fixDeserializedModelFunc runs after deserialization, receives
the current db context scope and the loaded model, and returns the repaired
model:
.AddSecondarySchema(
"c3d…v1",
fixDeserializedModelFunc: (dbContext, order) =>
{
// e.g. rebuild StatusHistory from a legacy single value kept in ExtraElements
order.RebuildLegacyState();
return Task.FromResult(order);
});Note — The fix function is the last point where
ExtraElementsis readable: right after it runs, Scrinium empties the bag, so a loaded model doesn't carry stale extra data around and never persists it back.
A single map can carry one active schema plus several secondary ones, each with its own fix function
migrating legacy ExtraElements and renamed elements into the current shape.
The fix function belongs to root model maps: the schemas of a
reference configuration build through
IReferenceModelMapBuilder<TModel>, which doesn't accept one. A reference document is a denormalized
summary — repair logic lives on the schemas of the origin document, and every member a summary
doesn't carry lazy-loads from the repaired origin.
A fix function repairs a model from outside it, and a persisted model keeps its setters
non-public. Etherna.Scrinium.Core.ReflectionHelper, the static helper the serializers
themselves read and write members with, writes a member whatever the accessibility of its setter:
.AddSecondarySchema(
"c3d…v1",
fixDeserializedModelFunc: (dbContext, order) =>
{
// the v1 document carried the status in a "State" element: write it through the protected setter
ReflectionHelper.SetValue(order, o => o.Status, (string)order.ExtraElements!["State"]);
return Task.FromResult(order);
});The lambda selects a property or a field of the model with a direct member access (o => o.Status);
a lambda selecting no member throws InvalidOperationException. The writes of a fix function precede
the capture of the model document that change tracking diffs against:
they are loaded state, never changes to save.
| Member | Behavior |
|---|---|
SetValue(destination, memberLambda, value) |
Writes the member the lambda selects, whatever the accessibility of its setter. The member resolves on the runtime type of destination: a lambda typed on an interface writes the property implementing it. |
SetValue(destination, memberInfo, value) |
Writes a field or a property through its MemberInfo, whatever its accessibility. A readonly field and a property without a setter are left untouched, without error. |
GetValue(source, memberInfo) |
Reads a field or a readable property; null for any other member. |
GetMemberInfoFromLambda(memberLambda, actualType = null) |
The MemberInfo of the property or field a member access lambda selects. With actualType a class, a property declared by an interface resolves to its implementation on that type. |
FindPropertyImplementation(interfacePropertyInfo, actualType) |
The property of actualType implementing an interface property, explicit implementations included. |
GetWritableInstanceProperties(objectType) |
The instance properties with a setter, public and non-public, declared on the type and on its bases; computed once per type. |
Warning — Outside a fix function, a loaded entity is a proxy flagging itself as changed through the setter overrides it emits for public, protected and protected internal setters (Change tracking and saving): a reflection write through a private setter flags nothing, and unless another member set or method call flags the model, the save skips it.
-
baseSchemaIdnames which schema of the base model type's map this schema chains to for its inherited members (by default, the base's active schema) — set it when a secondary schema must bind to the base type's matching secondary schema. -
AddSecondarySchema<TOverrideNominal>(...)(whereTOverrideNominal : class, TModel) deserializes that schema's documents asTOverrideNominalinstead ofTModel— for documents whose stored concrete type changed across versions.
Documents with an unrecognized or missing schema id (legacy data written before schemas existed) are handled by a fallback, if you register one:
.AddFallbackSchema(map => map.AutoMap()) // a model map for unknown-id documents
// or
.AddFallbackCustomSerializer(new MyLegacySerializer()); // a custom serializer insteadWithout a fallback, a referenced document with an unknown id
deserializes by reading only its reference id (other members lazy-load from the origin), and a
root document is read by the active schema. The id fallback is reserved for these schemas and
can't be used as a normal schema id.
Both those defaults are reported: Scrinium logs a warning naming the model type and the unrecognized id, once per pair (up to a hundred distinct pairs per serializer). The warning is the only signal of an otherwise invisible degradation. A configured fallback logs nothing: there the handling of unknown documents is your decision, not a surprise.
Two ways to evolve stored data — use both as needed:
| Secondary schema | DocumentMigration |
|
|---|---|---|
| When it runs | On read, per document, in memory | Eagerly, rewriting all documents |
| Cost | None up front; documents upgrade as touched | A full pass over the collection |
| Use it when | You just need old documents to keep working | You need every document on the new schema now (e.g. for an index or query) |
You can pair them: a type with secondary schemas for element renames and a DocumentMigration to
actively rewrite documents when required.
To decide, count how many documents still sit on each schema id: CountDocumentsBySchemaIdAsync on
the repository (see Repositories), or the Model schemas section of the Admin dashboard,
which also reports schema ids no map declares and documents carrying none.
Next: Migrations for eager rewrites, Model mapping for the mapping basics, or References and denormalization for relating documents.
Scrinium — source · issues (SCR) · GNU LGPL-3.0 · info@etherna.io
Getting started
Core concepts
Working with data
Serialization & mapping
Operations & maintenance
Advanced & reference