How OpenMAIC Handles DSL Versioning and Migration: A Deep-Dive Guide
OpenMAIC maintains two separate version lines for its DSL: the persistent document version (DSL_VERSION) and the in-memory runtime version (RUNTIME_DSL_VERSION), with pure, side-effect-free migration transforms that upgrade documents through an ordered ladder of changes.
The Domain-Specific Language (DSL) in OpenMAIC defines the shape of serialized slide contracts stored on disk and the structure of runtime session objects. Unlike typical npm packages where a single version number suffices, OpenMAIC decouples the DSL contract version from the package version. This separation allows API refactors and internal improvements without forcing document migrations.
Core Versioning Concepts
Separated Version Lines
OpenMAIC tracks two distinct DSL versions:
DSL_VERSION— The version of persisted slide documents, defined inpackages/@openmaic/dsl/src/version.tsat line 61RUNTIME_DSL_VERSION— The version of in-memory runtime session objects, defined at line 76
These constants are intentionally independent of the package.json version. A package bump from 2.1.0 to 2.2.0 might add utility functions without touching DSL_VERSION, meaning existing documents require no migration.
Envelope Keys for Version Stamps
Documents and sessions carry their version in envelope fields:
DSL_VERSION_KEY(value:'dslVersion') — stamps persisted documents at line 91RUNTIME_DSL_VERSION_KEY(value:'runtimeDslVersion') — stamps runtime sessions at line 108
These keys enable the runtime to inspect any object and determine its DSL lineage without parsing the full payload.
The Migration Ladder Architecture
Ordered Transform Arrays
Migrations are declared as pure functions in ordered arrays:
DSL_MIGRATIONS— transforms for persisted documents, defined at line 235RUNTIME_DSL_MIGRATIONS— transforms for runtime sessions
Each migration step specifies from version, to version, and a migrate function. The ladder is append-only; new versions extend the array rather than modifying existing entries.
Key Properties of Migration Functions
Every transform in the ladder must be:
- Pure — no side effects, returns a new object
- Idempotent — running twice produces the same result
- Forward-compatible — handles documents already at or beyond its target version
These constraints allow OpenMAIC to safely re-migrate documents during import, sync, or recovery operations.
Version Inspection and Migration Helpers
Reading Versions Safely
The dslVersionOf function at line 407 and runtimeDslVersionOf at line 423 enforce well-formedness during version extraction:
- Handle legacy documents lacking any version stamp (unversioned epoch)
- Reject ambiguous cross-line stamps (a document carrying
runtimeDslVersionbut notdslVersion) - Return the current version constant for valid, stamped objects
Runtime sessions have no unversioned epoch — an unstamped runtime object triggers a no-runtime-epoch error, indicating either a mis-routed legacy document or a producer bug.
Detecting Migration Needs
The predicate functions needsMigration (line 507) and needsRuntimeMigration (line 532) compare an object's version against the current constants. They share validation logic with the migrators, ensuring consistent error handling.
Executing Migrations
The migrate function at line 640 and migrateRuntime at line 670 implement the migration runner:
- Look up the current version in the ladder
- Apply each sequential
migratefunction - Stamp the new version after each step
- Repeat until reaching
DSL_VERSIONorRUNTIME_DSL_VERSION
If the document is newer than the target version, it returns unchanged. If a migration path is missing or the envelope is malformed, the runner fails loudly rather than silently corrupting data.
Practical DSL Migration Examples
Detecting and Migrating Persisted Slides
import { dslVersionOf, needsMigration, migrate } from '@openmaic/dsl';
// `rawDoc` is the JSON object read from storage
const rawDoc: unknown = fetchFromDatabase();
console.log('Current DSL stamp:', dslVersionOf(rawDoc));
if (needsMigration(rawDoc)) {
const migrated = migrate(rawDoc);
console.log('Migrated to', dslVersionOf(migrated));
// Persist `migrated` back to storage
}
Working with Runtime Sessions
import {
runtimeDslVersionOf,
needsRuntimeMigration,
migrateRuntime,
} from '@openmaic/dsl';
const session: unknown = getRuntimeSession();
console.log('Runtime DSL stamp:', runtimeDslVersionOf(session));
if (needsRuntimeMigration(session)) {
const upgraded = migrateRuntime(session);
console.log('Session upgraded to', runtimeDslVersionOf(upgraded));
// Continue using `upgraded` (e.g., feed it to the agent)
}
Adding a New Migration Step
// 1. Implement a pure transform function:
function addNewField(doc: unknown): unknown {
if (typeof doc === 'object' && doc !== null) {
return { ...doc, newField: 'defaultValue' };
}
return doc;
}
// 2. Append it to the ladder (in `version.ts`):
export const DSL_MIGRATIONS = [
...previousMigrations,
{ from: '0.3.0', to: '0.4.0', migrate: addNewField },
] as const;
// 3. Bump `DSL_VERSION` to `'0.4.0'` and increase the npm package version
// according to the caret-escape rule (minor → major at 1.0.0 boundary).
Release Discipline and Breaking Changes
The Caret-Escape Rule
Changing DSL_VERSION or RUNTIME_DSL_VERSION must accompany a package version bump that breaks dependents' caret ranges. This rule, enforced by scripts/check-package-version-bumps.mjs, ensures downstream packages (@openmaic/storage, @openmaic/renderer, @openmaic/importer) cannot silently consume incompatible DSL shapes.
For example, if DSL_VERSION increments from 0.3.0 to 0.4.0 while the package is at 1.2.3, the new package version becomes 2.0.0. Caret ranges (^1.2.3) will not match 2.0.0, forcing explicit upgrades.
Summary
- Separate version lines —
DSL_VERSIONfor persisted documents,RUNTIME_DSL_VERSIONfor sessions, both independent of npm package version - Migration ladders — ordered arrays of pure, idempotent transforms in
DSL_MIGRATIONSandRUNTIME_DSL_MIGRATIONS - Strict validation —
dslVersionOfandruntimeDslVersionOfenforce envelope well-formedness and reject cross-line contamination - Loud failures — missing migration paths or malformed envelopes throw rather than corrupt
- Caret-escape releases — DSL version changes force breaking package bumps to protect downstream consumers
Frequently Asked Questions
What happens if I try to migrate a runtime session using the document migrator?
The version inspection functions enforce cross-line safety. If an object carries runtimeDslVersion but not dslVersion, dslVersionOf throws a cross-line error. This prevents accidental corruption by ensuring document and runtime migrations stay strictly separated.
Can I skip migration steps in the ladder?
No. The migrate and migrateRuntime runners walk the ladder sequentially from the object's current version to the target. Each step must exist; if a version jump lacks an intermediate transform, the runner fails loudly. This design guarantees every schema change is explicitly handled.
Why does the runtime version have no unversioned epoch?
Persisted documents may originate from early prototypes before versioning existed, so dslVersionOf handles the unversioned epoch gracefully. Runtime sessions are always produced by code that should stamp them correctly; an unstamped runtime object indicates a bug or mis-routing that should surface immediately rather than be silently patched.
How do I verify a migration won't break existing documents?
The test suite in tests/document-store/migration.test.ts exercises migration paths with fixtures representing each historical DSL version. Before releasing a new migration, add a fixture at the previous version and assert it upgrades correctly to the new DSL_VERSION.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →