UNPKG

@tldraw/store

Version:

tldraw infinite canvas SDK (store).

345 lines (314 loc) • 10.4 kB
import { Result, assert, exhaustiveSwitchError, getOwnProperty, structuredClone, } from '@tldraw/utils' import { UnknownRecord } from './BaseRecord' import { RecordType } from './RecordType' import { SerializedStore, Store, StoreSnapshot } from './Store' import { Migration, MigrationFailureReason, MigrationId, MigrationResult, MigrationSequence, parseMigrationId, sortMigrations, validateMigrations, } from './migrate' /** @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 } > } /** @public */ export interface SerializedSchemaV2 { schemaVersion: 2 sequences: { [sequenceId: string]: number } } /** @public */ export type SerializedSchema = SerializedSchemaV1 | SerializedSchemaV2 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) } /** @public */ export interface StoreValidationFailure<R extends UnknownRecord> { error: unknown store: Store<R> record: R phase: 'initialize' | 'createRecord' | 'updateRecord' | 'tests' recordBefore: R | null } /** @public */ export interface StoreSchemaOptions<R extends UnknownRecord, P> { migrations?: MigrationSequence[] /** @public */ onValidationFailure?(data: StoreValidationFailure<R>): R /** @internal */ createIntegrityChecker?(store: Store<R, P>): void } /** @public */ export class StoreSchema<R extends UnknownRecord, P = unknown> { 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 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}'`) } } } 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 } } } // TODO: use a weakmap to store the result of this function public getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string> { const upgradeResult = upgradeSchema(persistedSchema) if (!upgradeResult.ok) { 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) { return Result.ok([]) } 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) { return Result.err('Incompatible schema?') } for (const migration of this.migrations[sequenceId].sequence.slice(idx + 1)) { allMigrationsToInclude.add(migration.id) } } // collect any migrations return Result.ok(this.sortedMigrations.filter(({ id }) => allMigrationsToInclude.has(id))) } 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.some((m) => m.scope === 'store')) { return { type: 'error', reason: direction === 'down' ? MigrationFailureReason.TargetVersionTooOld : MigrationFailureReason.TargetVersionTooNew, } } if (direction === 'down') { if (!migrationsToApply.every((m) => 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 */) 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 } } migrateStoreSnapshot(snapshot: StoreSnapshot<R>): MigrationResult<SerializedStore<R>> { let { store } = snapshot 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: store } } store = structuredClone(store) try { for (const migration of migrationsToApply) { if (migration.scope === 'record') { for (const [id, record] of Object.entries(store)) { const shouldApply = migration.filter ? migration.filter(record as UnknownRecord) : true if (!shouldApply) continue const result = migration.up!(record as any) if (result) { store[id as keyof typeof store] = structuredClone(result) as any } } } else if (migration.scope === 'store') { const result = migration.up!(store) if (result) { store = structuredClone(result) as any } } else { exhaustiveSwitchError(migration) } } } catch (e) { console.error('Error migrating store', e) return { type: 'error', reason: MigrationFailureReason.MigrationError } } return { type: 'success', value: store } } /** @internal */ createIntegrityChecker(store: Store<R, P>): (() => void) | undefined { return this.options.createIntegrityChecker?.(store) ?? undefined } 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, ]) ), } } /** * @deprecated This is only here for legacy reasons, don't use it unless you have david's blessing! */ serializeEarliestVersion(): SerializedSchema { return { schemaVersion: 2, sequences: Object.fromEntries( Object.values(this.migrations).map(({ sequenceId }) => [sequenceId, 0]) ), } } /** @internal */ getType(typeName: string) { const type = getOwnProperty(this.types, typeName) assert(type, 'record type does not exists') return type } }