UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

299 lines (267 loc) • 9.18 kB
import { assert, objectMapEntries } from '@tldraw/utils' import { UnknownRecord } from './BaseRecord' import { SerializedStore } from './Store' let didWarn = false /** * @public * @deprecated use `createShapePropsMigrationSequence` instead. See [the docs](https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations) for how to migrate. */ export function defineMigrations(opts: { firstVersion?: number currentVersion?: number migrators?: Record<number, LegacyMigration> subTypeKey?: string subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo> }): LegacyMigrations { const { currentVersion, firstVersion, migrators = {}, subTypeKey, subTypeMigrations } = opts if (!didWarn) { console.warn( `The 'defineMigrations' function is deprecated and will be removed in a future release. Use the new migrations API instead. See the migration guide for more info: https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations` ) didWarn = true } // Some basic guards against impossible version combinations, some of which will be caught by TypeScript if (typeof currentVersion === 'number' && typeof firstVersion === 'number') { if ((currentVersion as number) === (firstVersion as number)) { throw Error(`Current version is equal to initial version.`) } else if (currentVersion < firstVersion) { throw Error(`Current version is lower than initial version.`) } } return { firstVersion: (firstVersion as number) ?? 0, // defaults currentVersion: (currentVersion as number) ?? 0, // defaults migrators, subTypeKey, subTypeMigrations, } } 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. * See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API. * @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 given a named set of version numbers and a sequence id. * * See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API. * @public * @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 } /** @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 ), }) } /** @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 } /** @public */ export type MigrationId = `${string}/${number}` /** @public */ export interface StandaloneDependsOn { readonly dependsOn: readonly MigrationId[] } /** @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> } ) /** @public */ export interface LegacyBaseMigrationsInfo { firstVersion: number currentVersion: number migrators: { [version: number]: LegacyMigration } } /** @public */ export interface LegacyMigrations extends LegacyBaseMigrationsInfo { subTypeKey?: string subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo> } /** @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[] } export function sortMigrations(migrations: Migration[]): Migration[] { // we do a topological sort using dependsOn and implicit dependencies between migrations in the same sequence const byId = new Map(migrations.map((m) => [m.id, m])) const isProcessing = new Set<MigrationId>() const result: Migration[] = [] function process(m: Migration) { assert(!isProcessing.has(m.id), `Circular dependency in migrations: ${m.id}`) isProcessing.add(m.id) const { version, sequenceId } = parseMigrationId(m.id) const parent = byId.get(`${sequenceId}/${version - 1}`) if (parent) { process(parent) } if (m.dependsOn) { for (const dep of m.dependsOn) { const depMigration = byId.get(dep) if (depMigration) { process(depMigration) } } } byId.delete(m.id) result.push(m) } for (const m of byId.values()) { process(m) } return result } /** @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}'`) } 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 } } /** @public */ export type MigrationResult<T> = | { type: 'success'; value: T } | { type: 'error'; reason: MigrationFailureReason } /** @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', }