The versioned data system enables backward-compatible schema evolution for plugin configurations and data. As your plugin evolves, you can change schemas without breaking existing deployments.
Without versioning:
The Versioned<T> class provides:
The unified API for handling versioned data:
import { Versioned, z } from "@checkstack/backend-api";
// Define your versioned type
const configType = new Versioned({
version: 2,
schema: configSchemaV2,
migrations: [v1ToV2Migration],
});
// Parse stored data (auto-migrates and validates)
const config = await configType.parse(storedRecord);
// Create new versioned data
const record = configType.create({ url: "...", method: "GET" });
A simple interface for versioned data stored in the database:
interface VersionedRecord<T> {
/** Schema version of this record */
version: number;
/** The actual data payload */
data: T;
/** When the last migration was applied (if any) */
migratedAt?: Date;
/** Original version before any migrations were applied */
originalVersion?: number;
}
Extends VersionedRecord with plugin context:
interface VersionedPluginRecord<T> extends VersionedRecord<T> {
/** Plugin ID that owns this configuration */
pluginId: string;
}
A function that transforms data from one version to the next:
interface Migration<TFrom, TTo> {
/** Version this migration upgrades FROM */
fromVersion: number;
/** Version this migration upgrades TO (must be fromVersion + 1) */
toVersion: number;
/** Human-readable description */
description: string;
/** Migration function */
migrate: (data: TFrom) => TTo | Promise<TTo>;
}
import { z } from "zod";
export const httpCheckConfigSchema = z.object({
url: z.string().url(),
timeout: z.number().default(5000),
method: z.enum(["GET", "POST", "HEAD"]).default("GET"),
});
export type HttpCheckConfig = z.infer<typeof httpCheckConfigSchema>;
import { Versioned } from "@checkstack/backend-api";
export const httpCheckConfig = new Versioned<HttpCheckConfig>({
version: 1,
schema: httpCheckConfigSchema,
migrations: [], // No migrations yet for v1
});
export class HttpHealthCheckStrategy
implements HealthCheckStrategy<HttpCheckConfig, HttpResult, HttpAggregated>
{
id = "http";
displayName = "HTTP Health Check";
// Use Versioned instance for type-safe schema handling
config: Versioned<HttpCheckConfig> = new Versioned({
version: 1,
schema: httpCheckConfigSchema,
});
async execute(config: HttpCheckConfig) {
// Implementation
}
}
// Create new versioned data
const record = httpCheckConfig.create({
url: "https://example.com",
timeout: 5000,
method: "GET",
});
// Store in database
await db.insert(configs).values({ data: record });
// Later: load and parse (auto-migrates if needed)
const storedRecord = await db.select().from(configs).where(...);
const config = await httpCheckConfig.parse(storedRecord.data);
Version 1:
const configV1 = z.object({
url: z.string().url(),
timeout: z.number(),
});
Version 2: Add method field
const configV2 = z.object({
url: z.string().url(),
timeout: z.number(),
method: z.enum(["GET", "POST", "HEAD"]),
});
const v1ToV2: Migration<ConfigV1, ConfigV2> = {
fromVersion: 1,
toVersion: 2,
description: "Add HTTP method field",
migrate: (data) => ({
...data,
method: "GET", // Default for existing configs
}),
};
export const httpCheckConfig = new Versioned<ConfigV2>({
version: 2,
schema: configV2,
migrations: [v1ToV2],
});
When you have multiple schema versions:
// V1 -> V2: Add method
const v1ToV2: Migration<ConfigV1, ConfigV2> = {
fromVersion: 1,
toVersion: 2,
description: "Add HTTP method",
migrate: (data) => ({ ...data, method: "GET" }),
};
// V2 -> V3: Add headers
const v2ToV3: Migration<ConfigV2, ConfigV3> = {
fromVersion: 2,
toVersion: 3,
description: "Add headers support",
migrate: (data) => ({ ...data, headers: {} }),
};
// V3 -> V4: Rename timeout to timeoutMs
const v3ToV4: Migration<ConfigV3, ConfigV4> = {
fromVersion: 3,
toVersion: 4,
description: "Rename timeout to timeoutMs",
migrate: (data) => ({
url: data.url,
method: data.method,
headers: data.headers,
timeoutMs: data.timeout,
}),
};
// Register all migrations
export const httpCheckConfig = new Versioned<ConfigV4>({
version: 4,
schema: configV4,
migrations: [v1ToV2, v2ToV3, v3ToV4],
});
For better type inference:
import { MigrationBuilder } from "@checkstack/backend-api";
const migrations = new MigrationBuilder<ConfigV1>()
.addMigration<ConfigV2>({
fromVersion: 1,
toVersion: 2,
description: "Add method",
migrate: (data) => ({ ...data, method: "GET" }),
})
.addMigration<ConfigV3>({
fromVersion: 2,
toVersion: 3,
description: "Add headers",
migrate: (data) => ({ ...data, headers: {} }),
})
.build();
// Parse and migrate - returns just the data
const data = await versioned.parse(storedRecord);
// Safe parse - returns result object
const result = await versioned.safeParse(storedRecord);
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}
// Parse and return full record (preserves metadata)
const record = await versioned.parseRecord(storedRecord);
console.log(record.version, record.data, record.migratedAt);
// Create a VersionedRecord
const record = versioned.create({ url: "...", method: "GET" });
// Result: { version: 2, data: { url: "...", method: "GET" } }
// Create with plugin context
const pluginRecord = versioned.createForPlugin(
{ url: "...", method: "GET" },
"my-plugin"
);
// Result: { version: 2, data: {...}, pluginId: "my-plugin" }
// Check if migration is needed
if (versioned.needsMigration(storedRecord)) {
console.log("Data needs migration");
}
// Validate data without migration
const validated = versioned.validate(rawData);
// Safe validate
const result = versioned.safeValidate(rawData);
Always increment version by 1:
// ✅ Good
version: 1 -> 2 -> 3 -> 4
// ❌ Bad
version: 1 -> 3 -> 5
When adding required fields:
migrate: (data) => ({
...data,
newRequiredField: "default-value",
})
// V2 -> V3: BREAKING: Changed timeout from seconds to milliseconds
const v2ToV3: Migration<ConfigV2, ConfigV3> = {
fromVersion: 2,
toVersion: 3,
description: "Convert timeout from seconds to milliseconds",
migrate: (data) => ({
...data,
timeout: data.timeout * 1000,
}),
};
import { describe, expect, test } from "bun:test";
describe("Config Migrations", () => {
test("migrates V1 to V2", () => {
const v1: ConfigV1 = { url: "https://example.com", timeout: 5000 };
const v2 = v1ToV2.migrate(v1);
expect(v2).toEqual({
url: "https://example.com",
timeout: 5000,
method: "GET",
});
});
test("migrates V1 to V4 through chain", async () => {
const storedV1: VersionedRecord<ConfigV1> = {
version: 1,
data: { url: "https://example.com", timeout: 5 },
};
const migrated = await httpCheckConfig.parseRecord(storedV1);
expect(migrated.version).toBe(4);
expect(migrated.data.timeoutMs).toBe(5000);
expect(migrated.originalVersion).toBe(1);
});
});
Don’t delete old schema definitions—you need them for type safety in migrations:
// Keep all versions for reference
export const configV1 = z.object({ /* ... */ });
export const configV2 = z.object({ /* ... */ });
export const configV3 = z.object({ /* ... */ });
export const configV4 = z.object({ /* ... */ }); // Current
export type ConfigV1 = z.infer<typeof configV1>;
export type ConfigV2 = z.infer<typeof configV2>;
export type ConfigV3 = z.infer<typeof configV3>;
export type ConfigV4 = z.infer<typeof configV4>;
Migrations can be async for complex transformations:
const v2ToV3: Migration<ConfigV2, ConfigV3> = {
fromVersion: 2,
toVersion: 3,
description: "Enrich with external metadata",
migrate: async (data) => {
const metadata = await fetchMetadata(data.url);
return { ...data, metadata };
},
};
Check that:
fromVersion and toVersion are correctmigrations arrayversion matches the latest target versionEnsure:
Always: