alex/auto-undo-redo: nullable RecordsDiff

alex/no-batches
alex 2024-04-09 11:41:40 +01:00
rodzic 463957ae94
commit a631642b2d
4 zmienionych plików z 195 dodań i 191 usunięć

Wyświetl plik

@ -1,13 +1,7 @@
export type { BaseRecord, IdOf, RecordId, UnknownRecord } from './lib/BaseRecord'
export { IncrementalSetConstructor } from './lib/IncrementalSetConstructor'
export { RecordType, assertIdType, createRecordType } from './lib/RecordType'
export {
isRecordsDiffEmpty,
reverseRecordsDiff,
squashRecordDiffs,
squashRecordDiffsMutable,
type RecordsDiff,
} from './lib/RecordsDiff'
export { RecordsDiff } from './lib/RecordsDiff'
export { Store } from './lib/Store'
export type {
CollectionDiff,

Wyświetl plik

@ -1,4 +1,4 @@
import { objectMapEntries } from '@tldraw/utils'
import { objectMapValues } from '@tldraw/utils'
import { IdOf, UnknownRecord } from './BaseRecord'
/**
@ -7,96 +7,135 @@ import { IdOf, UnknownRecord } from './BaseRecord'
* @public
*/
export type RecordsDiff<R extends UnknownRecord> = {
added: Record<IdOf<R>, R>
updated: Record<IdOf<R>, [from: R, to: R]>
removed: Record<IdOf<R>, R>
added: Record<IdOf<R>, R> | null
updated: Record<IdOf<R>, [from: R, to: R]> | null
removed: Record<IdOf<R>, R> | null
}
/** @public */
export function reverseRecordsDiff(diff: RecordsDiff<any>) {
const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: {} }
for (const [from, to] of Object.values(diff.updated)) {
result.updated[from.id] = [to, from]
}
return result
}
export const RecordsDiff = {
/** Create an empty RecordsDiff */
create<R extends UnknownRecord>(): RecordsDiff<R> {
return { added: null, updated: null, removed: null } as RecordsDiff<R>
},
/**
* Is a records diff empty?
* @internal
*/
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>) {
return (
Object.keys(diff.added).length === 0 &&
Object.keys(diff.updated).length === 0 &&
Object.keys(diff.removed).length === 0
)
}
/**
* Reverse a record diff. Applying the reversed diff will undo the changes of the original diff.
*/
reverse<R extends UnknownRecord>(diff: RecordsDiff<R>): RecordsDiff<R> {
const result: RecordsDiff<any> = { added: diff.removed, removed: diff.added, updated: null }
if (diff.updated) {
result.updated = {}
for (const [from, to] of objectMapValues(diff.updated)) {
result.updated[from.id] = [to, from]
}
}
return result
},
/**
* Squash a collection of diffs into a single diff.
*
* @param diffs - An array of diffs to squash.
* @returns A single diff that represents the squashed diffs.
* @public
*/
export function squashRecordDiffs<T extends UnknownRecord>(
diffs: RecordsDiff<T>[]
): RecordsDiff<T> {
const result = { added: {}, removed: {}, updated: {} } as RecordsDiff<T>
/**
* Check if a diff is completely empty.
*/
isEmpty<R extends UnknownRecord>(diff: RecordsDiff<R>): boolean {
return !diff.added && !diff.updated && !diff.removed
},
squashRecordDiffsMutable(result, diffs)
return result
}
/**
* Squash a collection of diffs into a single diff.
*
* @param diffs - An array of diffs to squash.
* @returns A single diff that represents the squashed diffs.
* @public
*/
squash<R extends UnknownRecord>(diffs: RecordsDiff<R>[]): RecordsDiff<R> {
const result = RecordsDiff.create<R>()
for (const diff of diffs) {
RecordsDiff.applyInPlace(result, diff)
}
return result
},
/**
* Apply the array `diffs` to the `target` diff, mutating it in-place.
* @internal
*/
export function squashRecordDiffsMutable<T extends UnknownRecord>(
target: RecordsDiff<T>,
diffs: RecordsDiff<T>[]
): void {
for (const diff of diffs) {
for (const [id, value] of objectMapEntries(diff.added)) {
if (target.removed[id]) {
const original = target.removed[id]
delete target.removed[id]
if (original !== value) {
target.updated[id] = [original, value]
}
} else {
target.added[id] = value
/**
* Apply the array `diffs` to the `target` diff, mutating it in-place.
* @internal
*/
applyInPlace<T extends UnknownRecord>(target: RecordsDiff<T>, diff: RecordsDiff<T>): void {
if (diff.added) {
for (const record of objectMapValues(diff.added)) {
RecordsDiff.addRecord(target, record)
}
}
for (const [id, [_from, to]] of objectMapEntries(diff.updated)) {
if (target.added[id]) {
target.added[id] = to
delete target.updated[id]
delete target.removed[id]
continue
}
if (target.updated[id]) {
target.updated[id] = [target.updated[id][0], to]
delete target.removed[id]
continue
if (diff.updated) {
for (const update of objectMapValues(diff.updated)) {
RecordsDiff.updateRecord(target, update[0], update[1])
}
}
target.updated[id] = diff.updated[id]
if (diff.removed) {
for (const value of objectMapValues(diff.removed)) {
RecordsDiff.removeRecord(target, value)
}
}
},
/**
* Record `record` as being added in diff `target`. Updates the diff in-place.
* @internal
*/
addRecord<R extends UnknownRecord>(target: RecordsDiff<R>, record: R): void {
const id: IdOf<R> = record.id
if (target.removed?.[id]) {
const original = target.removed[id]
delete target.removed[id]
if (original !== record) {
if (!target.updated) target.updated = {} as Record<IdOf<R>, [R, R]>
target.updated[id] = [original, record]
}
} else {
if (!target.added) target.added = {} as Record<IdOf<R>, R>
target.added[id] = record
}
},
/**
* Record `record` as being updated in diff `target`. Updates the diff in-place.
* @internal
*/
updateRecord<R extends UnknownRecord>(target: RecordsDiff<R>, from: R, to: R) {
const id: IdOf<R> = from.id
if (target.added?.[id]) {
target.added[id] = to
if (target.updated) delete target.updated[id]
if (target.removed) delete target.removed[id]
return
}
if (target.updated?.[id]) {
target.updated[id][1] = to
if (target.removed) delete target.removed[id]
return
}
for (const [id, value] of objectMapEntries(diff.removed)) {
// the same record was added in this diff sequence, just drop it
if (target.added[id]) {
delete target.added[id]
} else if (target.updated[id]) {
target.removed[id] = target.updated[id][0]
delete target.updated[id]
} else {
target.removed[id] = value
}
if (!target.updated) target.updated = {} as Record<IdOf<R>, [R, R]>
target.updated[id] = [from, to]
if (target.removed) delete target.removed[id]
},
/**
* Record `record` as being removed in diff `target`. Updates the diff in-place.
* @internal
*/
removeRecord<R extends UnknownRecord>(target: RecordsDiff<R>, record: R) {
const id: IdOf<R> = record.id
// the same record was added in this diff sequence, just drop it
if (target.added?.[id]) {
delete target.added[id]
return
}
}
if (target.updated?.[id]) {
delete target.updated[id]
}
if (!target.removed) target.removed = {} as Record<IdOf<R>, R>
target.removed[id] = record
},
}

Wyświetl plik

@ -12,7 +12,7 @@ import { nanoid } from 'nanoid'
import { IdOf, RecordId, UnknownRecord } from './BaseRecord'
import { Cache } from './Cache'
import { RecordScope } from './RecordType'
import { RecordsDiff, squashRecordDiffs, squashRecordDiffsMutable } from './RecordsDiff'
import { RecordsDiff } from './RecordsDiff'
import { StoreQueries } from './StoreQueries'
import { SerializedSchema, StoreSchema } from './StoreSchema'
import { devFreeze } from './devFreeze'
@ -258,19 +258,22 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
* @param change - the records diff
* @returns
*/
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope) {
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): RecordsDiff<R> | null {
const result = {
added: filterEntries(change.added, (_, r) => this.scopedTypes[scope].has(r.typeName)),
updated: filterEntries(change.updated, (_, r) => this.scopedTypes[scope].has(r[1].typeName)),
removed: filterEntries(change.removed, (_, r) => this.scopedTypes[scope].has(r.typeName)),
}
if (
Object.keys(result.added).length === 0 &&
Object.keys(result.updated).length === 0 &&
Object.keys(result.removed).length === 0
) {
return null
added: change.added
? filterEntries(change.added, (_, r) => this.scopedTypes[scope].has(r.typeName))
: null,
updated: change.updated
? filterEntries(change.updated, (_, r) => this.scopedTypes[scope].has(r[1].typeName))
: null,
removed: change.removed
? filterEntries(change.removed, (_, r) => this.scopedTypes[scope].has(r.typeName))
: null,
}
if (result.added && Object.keys(result.added).length === 0) result.added = null
if (result.updated && Object.keys(result.updated).length === 0) result.updated = null
if (result.removed && Object.keys(result.removed).length === 0) result.removed = null
if (RecordsDiff.isEmpty(result)) return null
return result
}
@ -702,23 +705,7 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
const dispose = this.historyAccumulator.addInterceptor((entry) => changes.push(entry.changes))
try {
transact(fn)
return squashRecordDiffs(changes)
} finally {
dispose()
}
}
/**
* Like {@link extractingChanges}, but accumulates the changes into the provided accumulator
* diff instead of returning a fresh diff.
* @internal
*/
accumulatingChanges(accumulator: RecordsDiff<R>, fn: () => void) {
const changes: Array<RecordsDiff<R>> = []
const dispose = this.historyAccumulator.addInterceptor((entry) => changes.push(entry.changes))
try {
transact(fn)
squashRecordDiffsMutable(accumulator, changes)
return RecordsDiff.squash(changes)
} finally {
dispose()
}
@ -726,10 +713,10 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
applyDiff(diff: RecordsDiff<R>, runCallbacks = true) {
this.transactWithAfterEvents(() => {
const toPut = objectMapValues(diff.added).concat(
objectMapValues(diff.updated).map(([_from, to]) => to)
)
const toRemove = objectMapKeys(diff.removed)
const toAdd = diff.added ? objectMapValues(diff.added) : []
const toUpdate = diff.updated ? objectMapValues(diff.updated).map((u) => u[1]) : []
const toPut = toAdd.concat(toUpdate)
const toRemove = diff.removed ? objectMapKeys(diff.removed) : []
if (toPut.length) {
this.put(toPut)
}
@ -922,7 +909,7 @@ function squashHistoryEntries<T extends UnknownRecord>(
return devFreeze(
chunked.map((chunk) => ({
source: chunk[0].source,
changes: squashRecordDiffs(chunk.map((e) => e.changes)),
changes: RecordsDiff.squash(chunk.map((e) => e.changes)),
}))
)
}

Wyświetl plik

@ -81,63 +81,35 @@ export class StoreQueries<R extends UnknownRecord> {
const diff = this.history.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) return this.history.get()
const res = { added: {}, removed: {}, updated: {} } as RecordsDiff<S>
let numAdded = 0
let numRemoved = 0
let numUpdated = 0
const res = RecordsDiff.create<S>()
for (const changes of diff) {
for (const added of objectMapValues(changes.added)) {
if (added.typeName === typeName) {
if (res.removed[added.id as IdOf<S>]) {
const original = res.removed[added.id as IdOf<S>]
delete res.removed[added.id as IdOf<S>]
numRemoved--
if (original !== added) {
res.updated[added.id as IdOf<S>] = [original, added as S]
numUpdated++
}
} else {
res.added[added.id as IdOf<S>] = added as S
numAdded++
if (changes.added) {
for (const added of objectMapValues(changes.added)) {
if (added.typeName === typeName) {
RecordsDiff.addRecord(res, added)
}
}
}
for (const [from, to] of objectMapValues(changes.updated)) {
if (to.typeName === typeName) {
if (res.added[to.id as IdOf<S>]) {
res.added[to.id as IdOf<S>] = to as S
} else if (res.updated[to.id as IdOf<S>]) {
res.updated[to.id as IdOf<S>] = [res.updated[to.id as IdOf<S>][0], to as S]
} else {
res.updated[to.id as IdOf<S>] = [from as S, to as S]
numUpdated++
if (changes.updated) {
for (const [from, to] of objectMapValues(changes.updated)) {
if (to.typeName === typeName) {
RecordsDiff.updateRecord(res, from, to)
}
}
}
for (const removed of objectMapValues(changes.removed)) {
if (removed.typeName === typeName) {
if (res.added[removed.id as IdOf<S>]) {
// was added during this diff sequence, so just undo the add
delete res.added[removed.id as IdOf<S>]
numAdded--
} else if (res.updated[removed.id as IdOf<S>]) {
// remove oldest version
res.removed[removed.id as IdOf<S>] = res.updated[removed.id as IdOf<S>][0]
delete res.updated[removed.id as IdOf<S>]
numUpdated--
numRemoved++
} else {
res.removed[removed.id as IdOf<S>] = removed as S
numRemoved++
if (changes.removed) {
for (const removed of objectMapValues(changes.removed)) {
if (removed.typeName === typeName) {
RecordsDiff.removeRecord(res, removed)
}
}
}
}
if (numAdded || numRemoved || numUpdated) {
if (!RecordsDiff.isEmpty(res)) {
return withDiff(this.history.get(), res)
} else {
return lastValue
@ -239,26 +211,32 @@ export class StoreQueries<R extends UnknownRecord> {
}
for (const changes of history) {
for (const record of objectMapValues(changes.added)) {
if (record.typeName === typeName) {
const value = (record as S)[property]
add(value, record.id)
}
}
for (const [from, to] of objectMapValues(changes.updated)) {
if (to.typeName === typeName) {
const prev = (from as S)[property]
const next = (to as S)[property]
if (prev !== next) {
remove(prev, to.id)
add(next, to.id)
if (changes.added) {
for (const record of objectMapValues(changes.added)) {
if (record.typeName === typeName) {
const value = (record as S)[property]
add(value, record.id)
}
}
}
for (const record of objectMapValues(changes.removed)) {
if (record.typeName === typeName) {
const value = (record as S)[property]
remove(value, record.id)
if (changes.updated) {
for (const [from, to] of objectMapValues(changes.updated)) {
if (to.typeName === typeName) {
const prev = (from as S)[property]
const next = (to as S)[property]
if (prev !== next) {
remove(prev, to.id)
add(next, to.id)
}
}
}
}
if (changes.removed) {
for (const record of objectMapValues(changes.removed)) {
if (record.typeName === typeName) {
const value = (record as S)[property]
remove(value, record.id)
}
}
}
}
@ -416,23 +394,29 @@ export class StoreQueries<R extends UnknownRecord> {
) as IncrementalSetConstructor<IdOf<S>>
for (const changes of history) {
for (const added of objectMapValues(changes.added)) {
if (added.typeName === typeName && objectMatchesQuery(query, added)) {
setConstructor.add(added.id)
}
}
for (const [_, updated] of objectMapValues(changes.updated)) {
if (updated.typeName === typeName) {
if (objectMatchesQuery(query, updated)) {
setConstructor.add(updated.id)
} else {
setConstructor.remove(updated.id)
if (changes.added) {
for (const added of objectMapValues(changes.added)) {
if (added.typeName === typeName && objectMatchesQuery(query, added)) {
setConstructor.add(added.id)
}
}
}
for (const removed of objectMapValues(changes.removed)) {
if (removed.typeName === typeName) {
setConstructor.remove(removed.id)
if (changes.updated) {
for (const [_, updated] of objectMapValues(changes.updated)) {
if (updated.typeName === typeName) {
if (objectMatchesQuery(query, updated)) {
setConstructor.add(updated.id)
} else {
setConstructor.remove(updated.id)
}
}
}
}
if (changes.removed) {
for (const removed of objectMapValues(changes.removed)) {
if (removed.typeName === typeName) {
setConstructor.remove(removed.id)
}
}
}
}