@tldraw/store
Version:
tldraw infinite canvas SDK (store).
815 lines (782 loc) • 25.5 kB
text/typescript
import {
assert,
exhaustiveSwitchError,
getOwnProperty,
isEqual,
objectMapEntries,
Result,
structuredClone,
} from '@tldraw/utils'
import { UnknownRecord } from './BaseRecord'
import { devFreeze } from './devFreeze'
import {
Migration,
MigrationFailureReason,
MigrationId,
MigrationResult,
MigrationSequence,
parseMigrationId,
sortMigrations,
SynchronousStorage,
validateMigrations,
} from './migrate'
import { RecordType } from './RecordType'
import { SerializedStore, Store, StoreSnapshot } from './Store'
/**
* Version 1 format for serialized store schema information.
*
* This is the legacy format used before schema version 2. Version 1 schemas
* separate store-level versioning from record-level versioning, and support
* subtypes for complex record types like shapes.
*
* @example
* ```ts
* const schemaV1: SerializedSchemaV1 = {
* schemaVersion: 1,
* storeVersion: 2,
* recordVersions: {
* book: { version: 3 },
* shape: {
* version: 2,
* subTypeVersions: { rectangle: 1, circle: 2 },
* subTypeKey: 'type'
* }
* }
* }
* ```
*
* @public
*/
export interface SerializedSchemaV1 {
/** Schema version is the version for this type you're looking at right now */
schemaVersion: 1
/**
* Store version is the version for the structure of the store. e.g. higher level structure like
* removing or renaming a record type.
*/
storeVersion: number
/** Record versions are the versions for each record type. e.g. adding a new field to a record */
recordVersions: Record<
string,
| {
version: number
}
| {
// subtypes are used for migrating shape and asset props
version: number
subTypeVersions: Record<string, number>
subTypeKey: string
}
>
}
/**
* Version 2 format for serialized store schema information.
*
* This is the current format that uses a unified sequence-based approach
* for tracking versions across all migration sequences. Each sequence ID
* maps to the latest version number for that sequence.
*
* @example
* ```ts
* const schemaV2: SerializedSchemaV2 = {
* schemaVersion: 2,
* sequences: {
* 'com.tldraw.store': 3,
* 'com.tldraw.book': 2,
* 'com.tldraw.shape': 4,
* 'com.tldraw.shape.rectangle': 1
* }
* }
* ```
*
* @public
*/
export interface SerializedSchemaV2 {
schemaVersion: 2
sequences: {
[sequenceId: string]: number
}
}
/**
* Union type representing all supported serialized schema formats.
*
* This type allows the store to handle both legacy (V1) and current (V2)
* schema formats during deserialization and migration.
*
* @example
* ```ts
* function handleSchema(schema: SerializedSchema) {
* if (schema.schemaVersion === 1) {
* // Handle V1 format
* console.log('Store version:', schema.storeVersion)
* } else {
* // Handle V2 format
* console.log('Sequences:', schema.sequences)
* }
* }
* ```
*
* @public
*/
export type SerializedSchema = SerializedSchemaV1 | SerializedSchemaV2
/**
* Upgrades a serialized schema from version 1 to version 2 format.
*
* Version 1 schemas use separate `storeVersion` and `recordVersions` fields,
* while version 2 schemas use a unified `sequences` object with sequence IDs.
*
* @param schema - The serialized schema to upgrade
* @returns A Result containing the upgraded schema or an error message
*
* @example
* ```ts
* const v1Schema = {
* schemaVersion: 1,
* storeVersion: 1,
* recordVersions: {
* book: { version: 2 },
* author: { version: 1, subTypeVersions: { fiction: 1 }, subTypeKey: 'genre' }
* }
* }
*
* const result = upgradeSchema(v1Schema)
* if (result.ok) {
* console.log(result.value.sequences)
* // { 'com.tldraw.store': 1, 'com.tldraw.book': 2, 'com.tldraw.author': 1, 'com.tldraw.author.fiction': 1 }
* }
* ```
*
* @public
*/
export function upgradeSchema(schema: SerializedSchema): Result<SerializedSchemaV2, string> {
if (schema.schemaVersion > 2 || schema.schemaVersion < 1) return Result.err('Bad schema version')
if (schema.schemaVersion === 2) return Result.ok(schema as SerializedSchemaV2)
const result: SerializedSchemaV2 = {
schemaVersion: 2,
sequences: {
'com.tldraw.store': schema.storeVersion,
},
}
for (const [typeName, recordVersion] of Object.entries(schema.recordVersions)) {
result.sequences[`com.tldraw.${typeName}`] = recordVersion.version
if ('subTypeKey' in recordVersion) {
for (const [subType, version] of Object.entries(recordVersion.subTypeVersions)) {
result.sequences[`com.tldraw.${typeName}.${subType}`] = version
}
}
}
return Result.ok(result)
}
/**
* Information about a record validation failure that occurred in the store.
*
* This interface provides context about validation errors, including the failed
* record, the store state, and the operation phase where the failure occurred.
* It's used by validation failure handlers to implement recovery strategies.
*
* @example
* ```ts
* const schema = StoreSchema.create(
* { book: Book },
* {
* onValidationFailure: (failure: StoreValidationFailure<Book>) => {
* console.error(`Validation failed during ${failure.phase}:`, failure.error)
* console.log('Failed record:', failure.record)
* console.log('Previous record:', failure.recordBefore)
*
* // Return a corrected version of the record
* return { ...failure.record, title: failure.record.title || 'Untitled' }
* }
* }
* )
* ```
*
* @public
*/
export interface StoreValidationFailure<R extends UnknownRecord> {
error: unknown
store: Store<R>
record: R
phase: 'initialize' | 'createRecord' | 'updateRecord' | 'tests'
recordBefore: R | null
}
/**
* Configuration options for creating a StoreSchema.
*
* These options control migration behavior, validation error handling,
* and integrity checking for the store schema.
*
* @example
* ```ts
* const options: StoreSchemaOptions<MyRecord, MyProps> = {
* migrations: [bookMigrations, authorMigrations],
* onValidationFailure: (failure) => {
* // Log the error and return a corrected record
* console.error('Validation failed:', failure.error)
* return sanitizeRecord(failure.record)
* },
* createIntegrityChecker: (store) => {
* // Set up integrity checking logic
* return setupIntegrityChecks(store)
* }
* }
* ```
*
* @public
*/
export interface StoreSchemaOptions<R extends UnknownRecord, P> {
migrations?: MigrationSequence[]
/** @public */
onValidationFailure?(data: StoreValidationFailure<R>): R
/** @internal */
createIntegrityChecker?(store: Store<R, P>): void
}
/**
* Manages the schema definition, validation, and migration system for a Store.
*
* StoreSchema coordinates record types, handles data migrations between schema
* versions, validates records, and provides the foundational structure for
* reactive stores. It acts as the central authority for data consistency
* and evolution within the store system.
*
* @example
* ```ts
* // Define record types
* const Book = createRecordType<Book>('book', { scope: 'document' })
* const Author = createRecordType<Author>('author', { scope: 'document' })
*
* // Create schema with migrations
* const schema = StoreSchema.create(
* { book: Book, author: Author },
* {
* migrations: [bookMigrations, authorMigrations],
* onValidationFailure: (failure) => {
* console.warn('Validation failed, using default:', failure.error)
* return failure.record // or return a corrected version
* }
* }
* )
*
* // Use with store
* const store = new Store({ schema })
* ```
*
* @public
*/
export class StoreSchema<R extends UnknownRecord, P = unknown> {
/**
* Creates a new StoreSchema with the given record types and options.
*
* This static factory method is the recommended way to create a StoreSchema.
* It ensures type safety while providing a clean API for schema definition.
*
* @param types - Object mapping type names to their RecordType definitions
* @param options - Optional configuration for migrations, validation, and integrity checking
* @returns A new StoreSchema instance
*
* @example
* ```ts
* const Book = createRecordType<Book>('book', { scope: 'document' })
* const Author = createRecordType<Author>('author', { scope: 'document' })
*
* const schema = StoreSchema.create(
* {
* book: Book,
* author: Author
* },
* {
* migrations: [bookMigrations],
* onValidationFailure: (failure) => failure.record
* }
* )
* ```
*
* @public
*/
static create<R extends UnknownRecord, P = unknown>(
// HACK: making this param work with RecordType is an enormous pain
// let's just settle for making sure each typeName has a corresponding RecordType
// and accept that this function won't be able to infer the record type from it's arguments
types: { [TypeName in R['typeName']]: { createId: any } },
options?: StoreSchemaOptions<R, P>
): StoreSchema<R, P> {
return new StoreSchema<R, P>(types as any, options ?? {})
}
readonly migrations: Record<string, MigrationSequence> = {}
readonly sortedMigrations: readonly Migration[]
private readonly migrationCache = new WeakMap<SerializedSchema, Result<Migration[], string>>()
private constructor(
public readonly types: {
[Record in R as Record['typeName']]: RecordType<R, any>
},
private readonly options: StoreSchemaOptions<R, P>
) {
for (const m of options.migrations ?? []) {
assert(!this.migrations[m.sequenceId], `Duplicate migration sequenceId ${m.sequenceId}`)
validateMigrations(m)
this.migrations[m.sequenceId] = m
}
const allMigrations = Object.values(this.migrations).flatMap((m) => m.sequence)
this.sortedMigrations = sortMigrations(allMigrations)
for (const migration of this.sortedMigrations) {
if (!migration.dependsOn?.length) continue
for (const dep of migration.dependsOn) {
const depMigration = allMigrations.find((m) => m.id === dep)
assert(depMigration, `Migration '${migration.id}' depends on missing migration '${dep}'`)
}
}
}
/**
* Validates a record using its corresponding RecordType validator.
*
* This method ensures that records conform to their type definitions before
* being stored. If validation fails and an onValidationFailure handler is
* provided, it will be called to potentially recover from the error.
*
* @param store - The store instance where validation is occurring
* @param record - The record to validate
* @param phase - The lifecycle phase where validation is happening
* @param recordBefore - The previous version of the record (for updates)
* @returns The validated record, potentially modified by validation failure handler
*
* @example
* ```ts
* try {
* const validatedBook = schema.validateRecord(
* store,
* { id: 'book:1', typeName: 'book', title: '', author: 'Jane Doe' },
* 'createRecord',
* null
* )
* } catch (error) {
* console.error('Record validation failed:', error)
* }
* ```
*
* @public
*/
validateRecord(
store: Store<R>,
record: R,
phase: 'initialize' | 'createRecord' | 'updateRecord' | 'tests',
recordBefore: R | null
): R {
try {
const recordType = getOwnProperty(this.types, record.typeName)
if (!recordType) {
throw new Error(`Missing definition for record type ${record.typeName}`)
}
return recordType.validate(record, recordBefore ?? undefined)
} catch (error: unknown) {
if (this.options.onValidationFailure) {
return this.options.onValidationFailure({
store,
record,
phase,
recordBefore,
error,
})
} else {
throw error
}
}
}
/**
* Gets all migrations that need to be applied to upgrade from a persisted schema
* to the current schema version.
*
* This method compares the persisted schema with the current schema and determines
* which migrations need to be applied to bring the data up to date. It handles
* both regular migrations and retroactive migrations, and caches results for
* performance.
*
* @param persistedSchema - The schema version that was previously persisted
* @returns A Result containing the list of migrations to apply, or an error message
*
* @example
* ```ts
* const persistedSchema = {
* schemaVersion: 2,
* sequences: { 'com.tldraw.book': 1, 'com.tldraw.author': 0 }
* }
*
* const migrationsResult = schema.getMigrationsSince(persistedSchema)
* if (migrationsResult.ok) {
* console.log('Migrations to apply:', migrationsResult.value.length)
* // Apply each migration to bring data up to date
* }
* ```
*
* @public
*/
public getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string> {
// Check cache first
const cached = this.migrationCache.get(persistedSchema)
if (cached) {
return cached
}
const upgradeResult = upgradeSchema(persistedSchema)
if (!upgradeResult.ok) {
// Cache the error result
this.migrationCache.set(persistedSchema, upgradeResult)
return upgradeResult
}
const schema = upgradeResult.value
const sequenceIdsToInclude = new Set(
// start with any shared sequences
Object.keys(schema.sequences).filter((sequenceId) => this.migrations[sequenceId])
)
// also include any sequences that are not in the persisted schema but are marked as postHoc
for (const sequenceId in this.migrations) {
if (schema.sequences[sequenceId] === undefined && this.migrations[sequenceId].retroactive) {
sequenceIdsToInclude.add(sequenceId)
}
}
if (sequenceIdsToInclude.size === 0) {
const result = Result.ok([])
// Cache the empty result
this.migrationCache.set(persistedSchema, result)
return result
}
const allMigrationsToInclude = new Set<MigrationId>()
for (const sequenceId of sequenceIdsToInclude) {
const theirVersion = schema.sequences[sequenceId]
if (
(typeof theirVersion !== 'number' && this.migrations[sequenceId].retroactive) ||
theirVersion === 0
) {
for (const migration of this.migrations[sequenceId].sequence) {
allMigrationsToInclude.add(migration.id)
}
continue
}
const theirVersionId = `${sequenceId}/${theirVersion}`
const idx = this.migrations[sequenceId].sequence.findIndex((m) => m.id === theirVersionId)
// todo: better error handling
if (idx === -1) {
const result = Result.err('Incompatible schema?')
// Cache the error result
this.migrationCache.set(persistedSchema, result)
return result
}
for (const migration of this.migrations[sequenceId].sequence.slice(idx + 1)) {
allMigrationsToInclude.add(migration.id)
}
}
// collect any migrations
const result = Result.ok(
this.sortedMigrations.filter(({ id }) => allMigrationsToInclude.has(id))
)
// Cache the result
this.migrationCache.set(persistedSchema, result)
return result
}
/**
* Migrates a single persisted record to match the current schema version.
*
* This method applies the necessary migrations to transform a record from an
* older (or newer) schema version to the current version. It supports both
* forward ('up') and backward ('down') migrations.
*
* @param record - The record to migrate
* @param persistedSchema - The schema version the record was persisted with
* @param direction - Direction to migrate ('up' for newer, 'down' for older)
* @returns A MigrationResult containing the migrated record or an error
*
* @example
* ```ts
* const oldRecord = { id: 'book:1', typeName: 'book', title: 'Old Title', publishDate: '2020-01-01' }
* const oldSchema = { schemaVersion: 2, sequences: { 'com.tldraw.book': 1 } }
*
* const result = schema.migratePersistedRecord(oldRecord, oldSchema, 'up')
* if (result.type === 'success') {
* console.log('Migrated record:', result.value)
* // Record now has publishedYear instead of publishDate
* } else {
* console.error('Migration failed:', result.reason)
* }
* ```
*
* @public
*/
migratePersistedRecord(
record: R,
persistedSchema: SerializedSchema,
direction: 'up' | 'down' = 'up'
): MigrationResult<R> {
const migrations = this.getMigrationsSince(persistedSchema)
if (!migrations.ok) {
// TODO: better error
console.error('Error migrating record', migrations.error)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
}
let migrationsToApply = migrations.value
if (migrationsToApply.length === 0) {
return { type: 'success', value: record }
}
if (!migrationsToApply.every((m) => m.scope === 'record')) {
return {
type: 'error',
reason:
direction === 'down'
? MigrationFailureReason.TargetVersionTooOld
: MigrationFailureReason.TargetVersionTooNew,
}
}
if (direction === 'down') {
if (!migrationsToApply.every((m) => m.scope === 'record' && m.down)) {
return {
type: 'error',
reason: MigrationFailureReason.TargetVersionTooOld,
}
}
migrationsToApply = migrationsToApply.slice().reverse()
}
record = structuredClone(record)
try {
for (const migration of migrationsToApply) {
if (migration.scope === 'store') throw new Error(/* won't happen, just for TS */)
if (migration.scope === 'storage') throw new Error(/* won't happen, just for TS */)
const shouldApply = migration.filter ? migration.filter(record) : true
if (!shouldApply) continue
const result = migration[direction]!(record)
if (result) {
record = structuredClone(result) as any
}
}
} catch (e) {
console.error('Error migrating record', e)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
}
return { type: 'success', value: record }
}
migrateStorage(storage: SynchronousStorage<R>) {
const schema = storage.getSchema()
assert(schema, 'Schema is missing.')
const migrations = this.getMigrationsSince(schema)
if (!migrations.ok) {
console.error('Error migrating store', migrations.error)
throw new Error(migrations.error)
}
const migrationsToApply = migrations.value
if (migrationsToApply.length === 0) {
return
}
storage.setSchema(this.serialize())
for (const migration of migrationsToApply) {
if (migration.scope === 'record') {
// Collect updates during iteration, then apply them after.
// This avoids issues with live iterators (e.g., SQLite) where updating
// records during iteration can cause them to be visited multiple times.
const updates: [string, R][] = []
for (const [id, state] of storage.entries()) {
const shouldApply = migration.filter ? migration.filter(state) : true
if (!shouldApply) continue
const record = structuredClone(state)
const result = migration.up!(record as any) ?? record
if (!isEqual(result, state)) {
updates.push([id, result as R])
}
}
for (const [id, record] of updates) {
storage.set(id, record)
}
} else if (migration.scope === 'store') {
// legacy
const prevStore = Object.fromEntries(storage.entries())
let nextStore = structuredClone(prevStore)
nextStore = (migration.up!(nextStore) as any) ?? nextStore
for (const [id, state] of Object.entries(nextStore)) {
if (!state) continue // these will be deleted in the next loop
if (!isEqual(state, prevStore[id])) {
storage.set(id, state)
}
}
for (const id of Object.keys(prevStore)) {
if (!nextStore[id]) {
storage.delete(id)
}
}
} else if (migration.scope === 'storage') {
migration.up!(storage)
} else {
exhaustiveSwitchError(migration)
}
}
// Clean up by filtering out any non-document records.
// This is mainly legacy support for extremely early days tldraw.
for (const [id, state] of storage.entries()) {
if (this.getType(state.typeName).scope !== 'document') {
storage.delete(id)
}
}
}
/**
* Migrates an entire store snapshot to match the current schema version.
*
* This method applies all necessary migrations to bring a persisted store
* snapshot up to the current schema version. It handles both record-level
* and store-level migrations, and can optionally mutate the input store
* for performance.
*
* @param snapshot - The store snapshot containing data and schema information
* @param opts - Options controlling migration behavior
* - mutateInputStore - Whether to modify the input store directly (default: false)
* @returns A MigrationResult containing the migrated store or an error
*
* @example
* ```ts
* const snapshot = {
* schema: { schemaVersion: 2, sequences: { 'com.tldraw.book': 1 } },
* store: {
* 'book:1': { id: 'book:1', typeName: 'book', title: 'Old Book', publishDate: '2020-01-01' }
* }
* }
*
* const result = schema.migrateStoreSnapshot(snapshot)
* if (result.type === 'success') {
* console.log('Migrated store:', result.value)
* // All records are now at current schema version
* }
* ```
*
* @public
*/
migrateStoreSnapshot(
snapshot: StoreSnapshot<R>,
opts?: { mutateInputStore?: boolean }
): MigrationResult<SerializedStore<R>> {
const migrations = this.getMigrationsSince(snapshot.schema)
if (!migrations.ok) {
// TODO: better error
console.error('Error migrating store', migrations.error)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
}
const migrationsToApply = migrations.value
if (migrationsToApply.length === 0) {
return { type: 'success', value: snapshot.store }
}
const store = Object.assign(
new Map<string, R>(objectMapEntries(snapshot.store).map(devFreeze)),
{
getSchema: () => snapshot.schema,
setSchema: (_: SerializedSchema) => {},
}
)
try {
this.migrateStorage(store)
if (opts?.mutateInputStore) {
for (const [id, record] of store.entries()) {
snapshot.store[id as keyof typeof snapshot.store] = record
}
for (const id of Object.keys(snapshot.store)) {
if (!store.has(id)) {
delete snapshot.store[id as keyof typeof snapshot.store]
}
}
return { type: 'success', value: snapshot.store }
} else {
return {
type: 'success',
value: Object.fromEntries(store.entries()) as SerializedStore<R>,
}
}
} catch (e) {
console.error('Error migrating store', e)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
}
}
/**
* Creates an integrity checker function for the given store.
*
* This method calls the createIntegrityChecker option if provided, allowing
* custom integrity checking logic to be set up for the store. The integrity
* checker is used to validate store consistency and catch data corruption.
*
* @param store - The store instance to create an integrity checker for
* @returns An integrity checker function, or undefined if none is configured
*
* @internal
*/
createIntegrityChecker(store: Store<R, P>): (() => void) | undefined {
return this.options.createIntegrityChecker?.(store) ?? undefined
}
/**
* Serializes the current schema to a SerializedSchemaV2 format.
*
* This method creates a serialized representation of the current schema,
* capturing the latest version number for each migration sequence.
* The result can be persisted and later used to determine what migrations
* need to be applied when loading data.
*
* @returns A SerializedSchemaV2 object representing the current schema state
*
* @example
* ```ts
* const serialized = schema.serialize()
* console.log(serialized)
* // {
* // schemaVersion: 2,
* // sequences: {
* // 'com.tldraw.book': 3,
* // 'com.tldraw.author': 2
* // }
* // }
*
* // Store this with your data for future migrations
* localStorage.setItem('schema', JSON.stringify(serialized))
* ```
*
* @public
*/
serialize(): SerializedSchemaV2 {
return {
schemaVersion: 2,
sequences: Object.fromEntries(
Object.values(this.migrations).map(({ sequenceId, sequence }) => [
sequenceId,
sequence.length ? parseMigrationId(sequence.at(-1)!.id).version : 0,
])
),
}
}
/**
* Serializes a schema representing the earliest possible version.
*
* This method creates a serialized schema where all migration sequences
* are set to version 0, representing the state before any migrations
* have been applied. This is used in specific legacy scenarios.
*
* @returns A SerializedSchema with all sequences set to version 0
*
* @deprecated This is only here for legacy reasons, don't use it unless you have david's blessing!
* @internal
*/
serializeEarliestVersion(): SerializedSchema {
return {
schemaVersion: 2,
sequences: Object.fromEntries(
Object.values(this.migrations).map(({ sequenceId }) => [sequenceId, 0])
),
}
}
/**
* Gets the RecordType definition for a given type name.
*
* This method retrieves the RecordType associated with the specified
* type name, which contains the record's validation, creation, and
* other behavioral logic.
*
* @param typeName - The name of the record type to retrieve
* @returns The RecordType definition for the specified type
*
* @throws Will throw an error if the record type does not exist
*
* @internal
*/
getType(typeName: string) {
const type = getOwnProperty(this.types, typeName)
assert(type, 'record type does not exists')
return type
}
}