@tldraw/tlschema
Version:
tldraw infinite canvas SDK (schema).
277 lines (268 loc) • 8.03 kB
text/typescript
import {
Migration,
MigrationId,
MigrationSequence,
RecordType,
StandaloneDependsOn,
UnknownRecord,
createMigrationSequence,
} from '@tldraw/store'
import { MakeUndefinedOptional, assert } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { SchemaPropsInfo } from './createTLSchema'
/**
* Maps a record's property types to their corresponding validators.
*
* This utility type takes a record type with a `props` object and creates
* a mapping where each property key maps to a validator for that property's type.
* This is used to define validation schemas for record properties.
*
* @example
* ```ts
* interface MyShape extends TLBaseShape<'custom', { width: number; color: string }> {}
*
* // Define validators for the shape properties
* const myShapeProps: RecordProps<MyShape> = {
* width: T.number,
* color: T.string
* }
* ```
*
* @public
*/
export type RecordProps<R extends UnknownRecord & { props: object }> = {
[K in keyof R['props']]: T.Validatable<R['props'][K]>
}
/**
* Extracts the TypeScript types from a record properties configuration.
*
* Takes a configuration object where values are validators and returns the
* corresponding TypeScript types, with undefined values made optional.
*
* @example
* ```ts
* const shapePropsConfig = {
* width: T.number,
* height: T.number,
* color: T.optional(T.string)
* }
*
* type ShapeProps = RecordPropsType<typeof shapePropsConfig>
* // Result: { width: number; height: number; color?: string }
* ```
*
* @public
*/
export type RecordPropsType<Config extends Record<string, T.Validatable<any>>> =
MakeUndefinedOptional<{
[K in keyof Config]: T.TypeOf<Config[K]>
}>
/**
* A migration definition for shape or record properties.
*
* Defines how to transform record properties when migrating between schema versions.
* Each migration has an `up` function to upgrade data and an optional `down` function
* to downgrade data if needed.
*
* @example
* ```ts
* const addColorMigration: TLPropsMigration = {
* id: 'com.myapp.shape.custom/1.0.0',
* up: (props) => {
* // Add a default color property
* return { ...props, color: 'black' }
* },
* down: (props) => {
* // Remove the color property
* const { color, ...rest } = props
* return rest
* }
* }
* ```
*
* @public
*/
export interface TLPropsMigration {
readonly id: MigrationId
readonly dependsOn?: MigrationId[]
// eslint-disable-next-line tldraw/method-signature-style
readonly up: (props: any) => any
/**
* If a down migration was deployed more than a couple of months ago it should be safe to retire it.
* We only really need them to smooth over the transition between versions, and some folks do keep
* browser tabs open for months without refreshing, but at a certain point that kind of behavior is
* on them. Plus anyway recently chrome has started to actually kill tabs that are open for too long
* rather than just suspending them, so if other browsers follow suit maybe it's less of a concern.
*
* @public
*/
readonly down?: 'none' | 'retired' | ((props: any) => any)
}
/**
* A sequence of property migrations for a record type.
*
* Contains an ordered array of migrations that should be applied to transform
* record properties from one version to another. Migrations can include both
* property-specific migrations and standalone dependency declarations.
*
* @example
* ```ts
* const myShapeMigrations: TLPropsMigrations = {
* sequence: [
* {
* id: 'com.myapp.shape.custom/1.0.0',
* up: (props) => ({ ...props, version: 1 })
* },
* {
* id: 'com.myapp.shape.custom/2.0.0',
* up: (props) => ({ ...props, newFeature: true })
* }
* ]
* }
* ```
*
* @public
*/
export interface TLPropsMigrations {
readonly sequence: Array<StandaloneDependsOn | TLPropsMigration>
}
/**
* Processes property migrations for all record types in a schema.
*
* Takes a collection of record configurations and converts their migrations
* into proper migration sequences that can be used by the store system.
* Handles different migration formats including legacy migrations.
*
* @param typeName - The base type name for the records (e.g., 'shape', 'binding')
* @param records - Record of type names to their schema configuration
* @returns Array of processed migration sequences
*
* @example
* ```ts
* const shapeRecords = {
* geo: { props: geoProps, migrations: geoMigrations },
* arrow: { props: arrowProps, migrations: arrowMigrations }
* }
*
* const sequences = processPropsMigrations('shape', shapeRecords)
* ```
*
* @internal
*/
export function processPropsMigrations<R extends UnknownRecord & { type: string; props: object }>(
typeName: R['typeName'],
records: Record<string, SchemaPropsInfo>
) {
const result: MigrationSequence[] = []
for (const [subType, { migrations }] of Object.entries(records)) {
const sequenceId = `com.tldraw.${typeName}.${subType}`
if (!migrations) {
// provide empty migrations sequence to allow for future migrations
result.push(
createMigrationSequence({
sequenceId,
retroactive: true,
sequence: [],
})
)
} else if ('sequenceId' in migrations) {
assert(
sequenceId === migrations.sequenceId,
`sequenceId mismatch for ${subType} ${RecordType} migrations. Expected '${sequenceId}', got '${migrations.sequenceId}'`
)
result.push(migrations)
} else if ('sequence' in migrations) {
result.push(
createMigrationSequence({
sequenceId,
retroactive: true,
sequence: migrations.sequence.map((m) =>
'id' in m ? createPropsMigration(typeName, subType, m) : m
),
})
)
} else {
// legacy migrations, will be removed in the future
result.push(
createMigrationSequence({
sequenceId,
retroactive: true,
sequence: Object.keys(migrations.migrators)
.map((k) => Number(k))
.sort((a: number, b: number) => a - b)
.map(
(version): Migration => ({
id: `${sequenceId}/${version}`,
scope: 'record',
filter: (r) => r.typeName === typeName && (r as R).type === subType,
up: (record: any) => {
const result = migrations.migrators[version].up(record)
if (result) {
return result
}
},
down: (record: any) => {
const result = migrations.migrators[version].down(record)
if (result) {
return result
}
},
})
),
})
)
}
}
return result
}
/**
* Creates a store migration from a props migration definition.
*
* Converts a high-level property migration into a low-level store migration
* that can be applied to records. The resulting migration will only affect
* records of the specified type and subtype.
*
* @param typeName - The base type name (e.g., 'shape', 'binding')
* @param subType - The specific subtype (e.g., 'geo', 'arrow')
* @param m - The property migration definition
* @returns A store migration that applies the property transformation
*
* @example
* ```ts
* const propsMigration: TLPropsMigration = {
* id: 'com.myapp.shape.custom/1.0.0',
* up: (props) => ({ ...props, color: 'blue' })
* }
*
* const storeMigration = createPropsMigration('shape', 'custom', propsMigration)
* ```
*
* @internal
*/
export function createPropsMigration<R extends UnknownRecord & { type: string; props: object }>(
typeName: R['typeName'],
subType: R['type'],
m: TLPropsMigration
): Migration {
return {
id: m.id,
dependsOn: m.dependsOn,
scope: 'record',
filter: (r) => r.typeName === typeName && (r as R).type === subType,
up: (record: any) => {
const result = m.up(record.props)
if (result) {
record.props = result
}
},
down:
typeof m.down === 'function'
? (record: any) => {
const result = (m.down as (props: any) => any)(record.props)
if (result) {
record.props = result
}
}
: undefined,
}
}