UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

550 lines (510 loc) • 19.7 kB
import { assert, objectMapEntries } from '@tldraw/utils' import { UnknownRecord } from './BaseRecord' import { SerializedStore } from './Store' import { SerializedSchema } from './StoreSchema' function squashDependsOn(sequence: Array<Migration | StandaloneDependsOn>): Migration[] { const result: Migration[] = [] for (let i = sequence.length - 1; i >= 0; i--) { const elem = sequence[i] if (!('id' in elem)) { const dependsOn = elem.dependsOn const prev = result[0] if (prev) { result[0] = { ...prev, dependsOn: dependsOn.concat(prev.dependsOn ?? []), } } } else { result.unshift(elem) } } return result } /** * Creates a migration sequence that defines how to transform data as your schema evolves. * * A migration sequence contains a series of migrations that are applied in order to transform * data from older versions to newer versions. Each migration is identified by a unique ID * and can operate at either the record level (transforming individual records) or store level * (transforming the entire store structure). * * See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API. * @param options - Configuration for the migration sequence * - sequenceId - Unique identifier for this migration sequence (e.g., 'com.myapp.book') * - sequence - Array of migrations or dependency declarations to include in the sequence * - retroactive - Whether migrations should apply to snapshots created before this sequence was added (defaults to true) * @returns A validated migration sequence that can be included in a store schema * @example * ```ts * const bookMigrations = createMigrationSequence({ * sequenceId: 'com.myapp.book', * sequence: [ * { * id: 'com.myapp.book/1', * scope: 'record', * up: (record) => ({ ...record, newField: 'default' }) * } * ] * }) * ``` * @public */ export function createMigrationSequence({ sequence, sequenceId, retroactive = true, }: { sequenceId: string retroactive?: boolean sequence: Array<Migration | StandaloneDependsOn> }): MigrationSequence { const migrations: MigrationSequence = { sequenceId, retroactive, sequence: squashDependsOn(sequence), } validateMigrations(migrations) return migrations } /** * Creates a named set of migration IDs from version numbers and a sequence ID. * * This utility function helps generate properly formatted migration IDs that follow * the required `sequenceId/version` pattern. It takes a sequence ID and a record * of named versions, returning migration IDs that can be used in migration definitions. * * See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API. * @param sequenceId - The sequence identifier (e.g., 'com.myapp.book') * @param versions - Record mapping version names to numbers * @returns Record mapping version names to properly formatted migration IDs * @example * ```ts * const migrationIds = createMigrationIds('com.myapp.book', { * addGenre: 1, * addPublisher: 2, * removeOldField: 3 * }) * // Result: { * // addGenre: 'com.myapp.book/1', * // addPublisher: 'com.myapp.book/2', * // removeOldField: 'com.myapp.book/3' * // } * ``` * @public */ export function createMigrationIds< const ID extends string, const Versions extends Record<string, number>, >(sequenceId: ID, versions: Versions): { [K in keyof Versions]: `${ID}/${Versions[K]}` } { return Object.fromEntries( objectMapEntries(versions).map(([key, version]) => [key, `${sequenceId}/${version}`] as const) ) as any } /** * Creates a migration sequence specifically for record-level migrations. * * This is a convenience function that creates a migration sequence where all migrations * operate at the record scope and are automatically filtered to apply only to records * of a specific type. Each migration in the sequence will be enhanced with the record * scope and appropriate filtering logic. * @param opts - Configuration for the record migration sequence * - recordType - The record type name these migrations should apply to * - filter - Optional additional filter function to determine which records to migrate * - retroactive - Whether migrations should apply to snapshots created before this sequence was added * - sequenceId - Unique identifier for this migration sequence * - sequence - Array of record migration definitions (scope will be added automatically) * @returns A migration sequence configured for record-level operations * @internal */ export function createRecordMigrationSequence(opts: { recordType: string filter?(record: UnknownRecord): boolean retroactive?: boolean sequenceId: string sequence: Omit<Extract<Migration, { scope: 'record' }>, 'scope'>[] }): MigrationSequence { const sequenceId = opts.sequenceId return createMigrationSequence({ sequenceId, retroactive: opts.retroactive ?? true, sequence: opts.sequence.map((m) => 'id' in m ? { ...m, scope: 'record', filter: (r: UnknownRecord) => r.typeName === opts.recordType && (m.filter?.(r) ?? true) && (opts.filter?.(r) ?? true), } : m ), }) } /** * Legacy migration interface for backward compatibility. * * This interface represents the old migration format that included both `up` and `down` * transformation functions. While still supported, new code should use the `Migration` * type which provides more flexibility and better integration with the current system. * @public */ export interface LegacyMigration<Before = any, After = any> { // eslint-disable-next-line @typescript-eslint/method-signature-style up: (oldState: Before) => After // eslint-disable-next-line @typescript-eslint/method-signature-style down: (newState: After) => Before } /** * Unique identifier for a migration in the format `sequenceId/version`. * * Migration IDs follow a specific pattern where the sequence ID identifies the migration * sequence and the version number indicates the order within that sequence. For example: * 'com.myapp.book/1', 'com.myapp.book/2', etc. * @public */ export type MigrationId = `${string}/${number}` /** * Declares dependencies for migrations without being a migration itself. * * This interface allows you to specify that future migrations in a sequence depend on * migrations from other sequences, without defining an actual migration transformation. * It's used to establish cross-sequence dependencies in the migration graph. * @public */ export interface StandaloneDependsOn { readonly dependsOn: readonly MigrationId[] } /** * Defines a single migration that transforms data from one schema version to another. * * A migration can operate at two different scopes: * - `record`: Transforms individual records, with optional filtering to target specific records * - `store`: Transforms the entire serialized store structure * * Each migration has a unique ID and can declare dependencies on other migrations that must * be applied first. The `up` function performs the forward transformation, while the optional * `down` function can reverse the migration if needed. * @public */ export type Migration = { readonly id: MigrationId readonly dependsOn?: readonly MigrationId[] | undefined } & ( | { readonly scope: 'record' // eslint-disable-next-line @typescript-eslint/method-signature-style readonly filter?: (record: UnknownRecord) => boolean // eslint-disable-next-line @typescript-eslint/method-signature-style readonly up: (oldState: UnknownRecord) => void | UnknownRecord // eslint-disable-next-line @typescript-eslint/method-signature-style readonly down?: (newState: UnknownRecord) => void | UnknownRecord } | { readonly scope: 'store' // eslint-disable-next-line @typescript-eslint/method-signature-style readonly up: ( oldState: SerializedStore<UnknownRecord> ) => void | SerializedStore<UnknownRecord> // eslint-disable-next-line @typescript-eslint/method-signature-style readonly down?: ( newState: SerializedStore<UnknownRecord> ) => void | SerializedStore<UnknownRecord> } | { readonly scope: 'storage' // eslint-disable-next-line @typescript-eslint/method-signature-style readonly up: (storage: SynchronousRecordStorage<UnknownRecord>) => void readonly down?: never } ) /** * Abstraction over the store that can be used to perform migrations. * @public */ export interface SynchronousRecordStorage<R extends UnknownRecord> { get(id: string): R | undefined set(id: string, record: R): void delete(id: string): void keys(): Iterable<string> values(): Iterable<R> entries(): Iterable<[string, R]> } /** * Abstraction over the storage that can be used to perform migrations. * @public */ export interface SynchronousStorage<R extends UnknownRecord> extends SynchronousRecordStorage<R> { getSchema(): SerializedSchema setSchema(schema: SerializedSchema): void } /** * Base interface for legacy migration information. * * Contains the basic structure used by the legacy migration system, including version * range information and the migration functions indexed by version number. This is * maintained for backward compatibility with older migration definitions. * @public */ export interface LegacyBaseMigrationsInfo { firstVersion: number currentVersion: number migrators: { [version: number]: LegacyMigration } } /** * Legacy migration configuration with support for sub-type migrations. * * This interface extends the base legacy migration info to support migrations that * vary based on a sub-type key within records. This allows different migration paths * for different variants of the same record type, which was useful in older migration * systems but is now handled more elegantly by the current Migration system. * @public */ export interface LegacyMigrations extends LegacyBaseMigrationsInfo { subTypeKey?: string subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo> } /** * A complete sequence of migrations that can be applied to transform data. * * A migration sequence represents a series of ordered migrations that belong together, * typically for a specific part of your schema. The sequence includes metadata about * whether it should be applied retroactively to existing data and contains the actual * migration definitions in execution order. * @public */ export interface MigrationSequence { sequenceId: string /** * retroactive should be true if the migrations should be applied to snapshots that were created before * this migration sequence was added to the schema. * * In general: * * - retroactive should be true when app developers create their own new migration sequences. * - retroactive should be false when library developers ship a migration sequence. When you install a library for the first time, any migrations that were added in the library before that point should generally _not_ be applied to your existing data. */ retroactive: boolean sequence: Migration[] } /** * Sorts migrations using a distance-minimizing topological sort. * * This function respects two types of dependencies: * 1. Implicit sequence dependencies (foo/1 must come before foo/2) * 2. Explicit dependencies via `dependsOn` property * * The algorithm minimizes the total distance between migrations and their explicit * dependencies in the final ordering, while maintaining topological correctness. * This means when migration A depends on migration B, A will be scheduled as close * as possible to B (while respecting all constraints). * * Implementation uses Kahn's algorithm with priority scoring: * - Builds dependency graph and calculates in-degrees * - Uses priority queue that prioritizes migrations which unblock explicit dependencies * - Processes migrations in urgency order while maintaining topological constraints * - Detects cycles by ensuring all migrations are processed * * @param migrations - Array of migrations to sort * @returns Sorted array of migrations in execution order * @throws Assertion error if circular dependencies are detected * @example * ```ts * const sorted = sortMigrations([ * { id: 'app/2', scope: 'record', up: (r) => r }, * { id: 'app/1', scope: 'record', up: (r) => r }, * { id: 'lib/1', scope: 'record', up: (r) => r, dependsOn: ['app/1'] } * ]) * // Result: [app/1, app/2, lib/1] (respects both sequence and explicit deps) * ``` * @public */ export function sortMigrations(migrations: Migration[]): Migration[] { if (migrations.length === 0) return [] // Build dependency graph and calculate in-degrees const byId = new Map(migrations.map((m) => [m.id, m])) const dependents = new Map<MigrationId, Set<MigrationId>>() // who depends on this const inDegree = new Map<MigrationId, number>() const explicitDeps = new Map<MigrationId, Set<MigrationId>>() // explicit dependsOn relationships // Initialize for (const m of migrations) { inDegree.set(m.id, 0) dependents.set(m.id, new Set()) explicitDeps.set(m.id, new Set()) } // Add implicit sequence dependencies and explicit dependencies for (const m of migrations) { const { version, sequenceId } = parseMigrationId(m.id) // Implicit dependency on previous in sequence const prevId = `${sequenceId}/${version - 1}` as MigrationId if (byId.has(prevId)) { dependents.get(prevId)!.add(m.id) inDegree.set(m.id, inDegree.get(m.id)! + 1) } // Explicit dependencies if (m.dependsOn) { for (const depId of m.dependsOn) { if (byId.has(depId)) { dependents.get(depId)!.add(m.id) explicitDeps.get(m.id)!.add(depId) inDegree.set(m.id, inDegree.get(m.id)! + 1) } } } } // Priority queue: migrations ready to process (in-degree 0) const ready = migrations.filter((m) => inDegree.get(m.id) === 0) const result: Migration[] = [] const processed = new Set<MigrationId>() while (ready.length > 0) { // Calculate urgency scores for ready migrations and pick the best one let bestCandidate: Migration | undefined let bestCandidateScore = -Infinity for (const m of ready) { let urgencyScore = 0 for (const depId of dependents.get(m.id) || []) { if (!processed.has(depId)) { // Priority 1: Count all unprocessed dependents (to break ties) urgencyScore += 1 // Priority 2: If this migration is explicitly depended on by others, boost priority if (explicitDeps.get(depId)!.has(m.id)) { urgencyScore += 100 } } } if ( urgencyScore > bestCandidateScore || // Tiebreaker: prefer lower sequence/version (urgencyScore === bestCandidateScore && m.id.localeCompare(bestCandidate?.id ?? '') < 0) ) { bestCandidate = m bestCandidateScore = urgencyScore } } const nextMigration = bestCandidate! ready.splice(ready.indexOf(nextMigration), 1) // Cycle detection - if we have processed everything and still have items left, there's a cycle // This is handled by Kahn's algorithm naturally - if we finish with items unprocessed, there's a cycle // Process this migration result.push(nextMigration) processed.add(nextMigration.id) // Update in-degrees and add newly ready migrations for (const depId of dependents.get(nextMigration.id) || []) { if (!processed.has(depId)) { inDegree.set(depId, inDegree.get(depId)! - 1) if (inDegree.get(depId) === 0) { ready.push(byId.get(depId)!) } } } } // Check for cycles - if we didn't process all migrations, there's a cycle if (result.length !== migrations.length) { const unprocessed = migrations.filter((m) => !processed.has(m.id)) assert(false, `Circular dependency in migrations: ${unprocessed[0].id}`) } return result } /** * Parses a migration ID to extract the sequence ID and version number. * * Migration IDs follow the format `sequenceId/version`, and this function splits * them into their component parts. This is used internally for sorting migrations * and understanding their relationships. * @param id - The migration ID to parse * @returns Object containing the sequence ID and numeric version * @example * ```ts * const { sequenceId, version } = parseMigrationId('com.myapp.book/5') * // sequenceId: 'com.myapp.book', version: 5 * ``` * @internal */ export function parseMigrationId(id: MigrationId): { sequenceId: string; version: number } { const [sequenceId, version] = id.split('/') return { sequenceId, version: parseInt(version) } } function validateMigrationId(id: string, expectedSequenceId?: string) { if (expectedSequenceId) { assert( id.startsWith(expectedSequenceId + '/'), `Every migration in sequence '${expectedSequenceId}' must have an id starting with '${expectedSequenceId}/'. Got invalid id: '${id}'` ) } assert(id.match(/^(.*?)\/(0|[1-9]\d*)$/), `Invalid migration id: '${id}'`) } /** * Validates that a migration sequence is correctly structured. * * Performs several validation checks to ensure the migration sequence is valid: * - Sequence ID doesn't contain invalid characters * - All migration IDs belong to the expected sequence * - Migration versions start at 1 and increment by 1 * - Migration IDs follow the correct format * @param migrations - The migration sequence to validate * @throws Assertion error if any validation checks fail * @example * ```ts * const sequence = createMigrationSequence({ * sequenceId: 'com.myapp.book', * sequence: [{ id: 'com.myapp.book/1', scope: 'record', up: (r) => r }] * }) * validateMigrations(sequence) // Passes validation * ``` * @public */ export function validateMigrations(migrations: MigrationSequence) { assert( !migrations.sequenceId.includes('/'), `sequenceId cannot contain a '/', got ${migrations.sequenceId}` ) assert(migrations.sequenceId.length, 'sequenceId must be a non-empty string') if (migrations.sequence.length === 0) { return } validateMigrationId(migrations.sequence[0].id, migrations.sequenceId) let n = parseMigrationId(migrations.sequence[0].id).version assert( n === 1, `Expected the first migrationId to be '${migrations.sequenceId}/1' but got '${migrations.sequence[0].id}'` ) for (let i = 1; i < migrations.sequence.length; i++) { const id = migrations.sequence[i].id validateMigrationId(id, migrations.sequenceId) const m = parseMigrationId(id).version assert( m === n + 1, `Migration id numbers must increase in increments of 1, expected ${migrations.sequenceId}/${n + 1} but got '${migrations.sequence[i].id}'` ) n = m } } /** * Result type returned by migration operations. * * Migration operations can either succeed and return the transformed value, * or fail with a specific reason. This discriminated union type allows for * safe handling of both success and error cases when applying migrations. * @public */ export type MigrationResult<T> = | { type: 'success'; value: T } | { type: 'error'; reason: MigrationFailureReason } /** * Enumeration of possible reasons why a migration might fail. * * These reasons help identify what went wrong during migration processing, * allowing applications to handle different failure scenarios appropriately. * Common failures include incompatible data formats, unknown record types, * and version mismatches between the data and available migrations. * @public */ export enum MigrationFailureReason { IncompatibleSubtype = 'incompatible-subtype', UnknownType = 'unknown-type', TargetVersionTooNew = 'target-version-too-new', TargetVersionTooOld = 'target-version-too-old', MigrationError = 'migration-error', UnrecognizedSubtype = 'unrecognized-subtype', }