@tldraw/store
Version:
tldraw infinite canvas SDK (store).
237 lines (236 loc) • 8.14 kB
JavaScript
import {
Result,
assert,
exhaustiveSwitchError,
getOwnProperty,
structuredClone
} from "@tldraw/utils";
import {
MigrationFailureReason,
parseMigrationId,
sortMigrations,
validateMigrations
} from "./migrate.mjs";
function upgradeSchema(schema) {
if (schema.schemaVersion > 2 || schema.schemaVersion < 1) return Result.err("Bad schema version");
if (schema.schemaVersion === 2) return Result.ok(schema);
const result = {
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);
}
class StoreSchema {
constructor(types, options) {
this.types = types;
this.options = options;
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}'`);
}
}
}
static create(types, options) {
return new StoreSchema(types, options ?? {});
}
migrations = {};
sortedMigrations;
validateRecord(store, record, phase, recordBefore) {
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 ?? void 0);
} catch (error) {
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
getMigrationsSince(persistedSchema) {
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])
);
for (const sequenceId in this.migrations) {
if (schema.sequences[sequenceId] === void 0 && this.migrations[sequenceId].retroactive) {
sequenceIdsToInclude.add(sequenceId);
}
}
if (sequenceIdsToInclude.size === 0) {
return Result.ok([]);
}
const allMigrationsToInclude = /* @__PURE__ */ new Set();
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);
if (idx === -1) {
return Result.err("Incompatible schema?");
}
for (const migration of this.migrations[sequenceId].sequence.slice(idx + 1)) {
allMigrationsToInclude.add(migration.id);
}
}
return Result.ok(this.sortedMigrations.filter(({ id }) => allMigrationsToInclude.has(id)));
}
migratePersistedRecord(record, persistedSchema, direction = "up") {
const migrations = this.getMigrationsSince(persistedSchema);
if (!migrations.ok) {
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);
}
}
} catch (e) {
console.error("Error migrating record", e);
return { type: "error", reason: MigrationFailureReason.MigrationError };
}
return { type: "success", value: record };
}
migrateStoreSnapshot(snapshot) {
let { store } = snapshot;
const migrations = this.getMigrationsSince(snapshot.schema);
if (!migrations.ok) {
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) : true;
if (!shouldApply) continue;
const result = migration.up(record);
if (result) {
store[id] = structuredClone(result);
}
}
} else if (migration.scope === "store") {
const result = migration.up(store);
if (result) {
store = structuredClone(result);
}
} 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) {
return this.options.createIntegrityChecker?.(store) ?? void 0;
}
serialize() {
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() {
return {
schemaVersion: 2,
sequences: Object.fromEntries(
Object.values(this.migrations).map(({ sequenceId }) => [sequenceId, 0])
)
};
}
/** @internal */
getType(typeName) {
const type = getOwnProperty(this.types, typeName);
assert(type, "record type does not exists");
return type;
}
}
export {
StoreSchema,
upgradeSchema
};
//# sourceMappingURL=StoreSchema.mjs.map