Path: blob/main/src/vs/workbench/contrib/chat/common/model/objectMutationLog.ts
5241 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { assertNever } from '../../../../../base/common/assert.js';6import { VSBuffer } from '../../../../../base/common/buffer.js';7import { isUndefinedOrNull } from '../../../../../base/common/types.js';8910/** IMPORTANT: `Key` comes first. Then we should sort in order of least->most expensive to diff */11const enum TransformKind {12Key,13Primitive,14Array,15Object,16}1718/** Schema entries sorted with key properties first */19export type SchemaEntries = [string, Transform<unknown, unknown>][];2021interface TransformBase<TFrom, TTo> {22readonly kind: TransformKind;23/** Extracts the serializable value from the source object */24extract(from: TFrom): TTo;25}2627/** Transform for primitive values (keys and values) that can be compared for equality */28export interface TransformValue<TFrom, TTo> extends TransformBase<TFrom, TTo> {29readonly kind: TransformKind.Key | TransformKind.Primitive;30/** Compares two serialized values for equality */31equals(a: TTo, b: TTo): boolean;32}3334/** Transform for arrays with an item schema */35export interface TransformArray<TFrom, TTo> extends TransformBase<TFrom, TTo> {36readonly kind: TransformKind.Array;37/** The schema for array items */38readonly itemSchema: TransformObject<unknown, unknown> | TransformValue<unknown, unknown>;39}4041/** Transform for objects with child properties */42export interface TransformObject<TFrom, TTo> extends TransformBase<TFrom, TTo> {43readonly kind: TransformKind.Object;44/** Schema entries sorted with Key properties first */45readonly children: SchemaEntries;46/** Checks if the object is sealed (won't change). */47sealed?(obj: TTo, wasSerialized: boolean): boolean;48}4950export type Transform<TFrom, TTo> =51| TransformValue<TFrom, TTo>52| TransformArray<TFrom, TTo>53| TransformObject<TFrom, TTo>;5455export type Schema<TFrom, TTo> = {56[K in keyof Required<TTo>]: Transform<TFrom, TTo[K]>57};5859/**60* A primitive that will be tracked and compared first. If this is changed, the entire61* object is thrown out and re-stored.62*/63export function key<T, R = T>(comparator?: (a: R, b: R) => boolean): TransformValue<T, R> {64return {65kind: TransformKind.Key,66extract: (from: T) => from as unknown as R,67equals: comparator ?? ((a, b) => a === b),68};69}7071/** A value that will be tracked and replaced if the comparator is not equal. */72export function value<T, R extends string | number | boolean | undefined>(): TransformValue<T, R>;73export function value<T, R>(comparator: (a: R, b: R) => boolean): TransformValue<T, R>;74export function value<T, R>(comparator?: (a: R, b: R) => boolean): TransformValue<T, R> {75return {76kind: TransformKind.Primitive,77extract: (from: T) => {78let value = from as unknown as R;79// We map the object to JSON for two reasons (a) reduce issues with references to80// mutable type that could be held internally in the LogAdapter and (b) to make81// object comparison work with the data we re-hydrate from disk (e.g. if using82// objectsEqual, a hydrated URI is not equal to the serialized UriComponents)83if (!!value && typeof value === 'object') {84value = JSON.parse(JSON.stringify(value));85}8687return value;88},89equals: comparator ?? ((a, b) => a === b),90};91}9293/** An array that will use the schema to compare items positionally. */94export function array<T, R>(schema: TransformObject<T, R> | TransformValue<T, R>): TransformArray<readonly T[], R[]> {95return {96kind: TransformKind.Array,97itemSchema: schema,98extract: from => from?.map(item => schema.extract(item)),99};100}101102export interface ObjectOptions<R> {103/**104* Returns true if the object is sealed and will never change again.105* When comparing two sealed objects, only key fields are compared106* (to detect replacement), but other fields are not diffed.107*/108sealed?: (obj: R, wasSerialized: boolean) => boolean;109}110111/** An object schema. */112export function object<T, R extends object>(schema: Schema<T, R>, options?: ObjectOptions<R>): TransformObject<T, R> {113// Sort entries with key properties first for fast key checking114const entries = (Object.entries(schema) as [string, Transform<T, R[keyof R]>][]).sort(([, a], [, b]) => a.kind - b.kind);115return {116kind: TransformKind.Object,117children: entries as SchemaEntries,118sealed: options?.sealed,119extract: (from: T) => {120if (isUndefinedOrNull(from)) {121return from as unknown as R;122}123124const result: Record<string, unknown> = Object.create(null);125for (const [key, transform] of entries) {126result[key] = transform.extract(from);127}128return result as R;129},130};131}132133/**134* Defines a getter on the object to extract a value, compared with the given schema.135* It should return the value that will get serialized in the resulting log file.136*/137export function t<T, O, R>(getter: (obj: T) => O, schema: Transform<O, R>): Transform<T, R> {138return {139...schema,140extract: (from: T) => schema.extract(getter(from)),141};142}143144/** Shortcut for t(fn, value()) */145export function v<T, R extends string | number | boolean | undefined>(getter: (obj: T) => R): TransformValue<T, R>;146export function v<T, R>(getter: (obj: T) => R, comparator: (a: R, b: R) => boolean): TransformValue<T, R>;147export function v<T, R>(getter: (obj: T) => R, comparator?: (a: R, b: R) => boolean): TransformValue<T, R> {148const inner = value(comparator!);149return {150...inner,151extract: (from: T) => inner.extract(getter(from)),152};153}154155156const enum EntryKind {157/** Initial complete object state, valid only as the first entry */158Initial = 0,159/** Property update */160Set = 1,161/** Array push/splice. */162Push = 2,163/** Delete a property */164Delete = 3,165}166167type ObjectPath = (string | number)[];168169type Entry =170| { kind: EntryKind.Initial; v: unknown }171/** Update a property of an object, replacing it entirely */172| { kind: EntryKind.Set; k: ObjectPath; v: unknown }173/** Delete a property of an object */174| { kind: EntryKind.Delete; k: ObjectPath }175/** Pushes 0 or more new entries to an array. If `i` is set, everything after that index is removed */176| { kind: EntryKind.Push; k: ObjectPath; v?: unknown[]; i?: number };177178const LF = VSBuffer.fromString('\n');179180/**181* An implementation of an append-based mutation logger. Given a `Transform`182* definition of an object, it can recreate it from a file on disk. It is183* then stateful, and given a `write` call it can update the log in a minimal184* way.185*/186export class ObjectMutationLog<TFrom, TTo> {187private _previous: TTo | undefined;188private _entryCount = 0;189190constructor(191private readonly _transform: Transform<TFrom, TTo>,192private readonly _compactAfterEntries = 512,193) { }194195/**196* Creates an initial log file from the given object.197*/198createInitial(current: TFrom): VSBuffer {199return this.createInitialFromSerialized(this._transform.extract(current));200}201202203/**204* Creates an initial log file from the serialized object.205*/206createInitialFromSerialized(value: TTo): VSBuffer {207this._previous = value;208this._entryCount = 1;209const entry: Entry = { kind: EntryKind.Initial, v: value };210return VSBuffer.fromString(JSON.stringify(entry) + '\n');211}212213/**214* Reads and reconstructs the state from a log file.215*/216read(content: VSBuffer): TTo {217let state: unknown;218let lineCount = 0;219220let start = 0;221const len = content.byteLength;222while (start < len) {223let end = content.indexOf(LF, start);224if (end === -1) {225end = len;226}227228if (end > start) {229const line = content.slice(start, end);230if (line.byteLength > 0) {231lineCount++;232const entry = JSON.parse(line.toString()) as Entry;233switch (entry.kind) {234case EntryKind.Initial:235state = entry.v;236break;237case EntryKind.Set:238this._applySet(state, entry.k, entry.v);239break;240case EntryKind.Push:241this._applyPush(state, entry.k, entry.v, entry.i);242break;243case EntryKind.Delete:244this._applySet(state, entry.k, undefined);245break;246default:247assertNever(entry);248}249}250}251start = end + 1;252}253254if (lineCount === 0) {255throw new Error('Empty log file');256}257258this._previous = state as TTo;259this._entryCount = lineCount;260return state as TTo;261}262263/**264* Writes updates to the log. Returns the operation type and data to write.265*/266write(current: TFrom): { op: 'append' | 'replace'; data: VSBuffer } {267const currentValue = this._transform.extract(current);268269if (!this._previous || this._entryCount > this._compactAfterEntries) {270// No previous state, create initial271this._previous = currentValue;272this._entryCount = 1;273const entry: Entry = { kind: EntryKind.Initial, v: currentValue };274return { op: 'replace', data: VSBuffer.fromString(JSON.stringify(entry) + '\n') };275}276277// Generate diff entries278const entries: Entry[] = [];279const path: ObjectPath = [];280this._diff(this._transform, path, this._previous, currentValue, entries);281282if (entries.length === 0) {283// No changes284return { op: 'append', data: VSBuffer.fromString('') };285}286287this._entryCount += entries.length;288this._previous = currentValue;289290// Append entries - build string directly291let data = '';292for (const e of entries) {293data += JSON.stringify(e) + '\n';294}295return { op: 'append', data: VSBuffer.fromString(data) };296}297298private _applySet(state: unknown, path: ObjectPath, value: unknown): void {299if (path.length === 0) {300return; // Root replacement handled by caller301}302303let current = state as Record<string | number, unknown>;304for (let i = 0; i < path.length - 1; i++) {305current = current[path[i]] as Record<string | number, unknown>;306}307308current[path[path.length - 1]] = value;309}310311private _applyPush(state: unknown, path: ObjectPath, values: unknown[] | undefined, startIndex: number | undefined): void {312let current = state as Record<string | number, unknown>;313for (let i = 0; i < path.length - 1; i++) {314current = current[path[i]] as Record<string | number, unknown>;315}316317const arrayKey = path[path.length - 1];318const arr = current[arrayKey] as unknown[] || [];319320if (startIndex !== undefined) {321arr.length = startIndex;322}323324if (values && values.length > 0) {325arr.push(...values);326}327328current[arrayKey] = arr;329}330331private _diff<T, R>(332transform: Transform<T, R>,333path: ObjectPath,334prev: R,335curr: R,336entries: Entry[]337): void {338if (transform.kind === TransformKind.Key || transform.kind === TransformKind.Primitive) {339// Simple value change - copy path since we're storing it340if (!transform.equals(prev, curr)) {341entries.push({ kind: EntryKind.Set, k: path.slice(), v: curr });342}343} else if (isUndefinedOrNull(prev) || isUndefinedOrNull(curr)) {344if (prev !== curr) {345if (curr === undefined) {346entries.push({ kind: EntryKind.Delete, k: path.slice() });347} else if (curr === null) {348entries.push({ kind: EntryKind.Set, k: path.slice(), v: null });349} else {350entries.push({ kind: EntryKind.Set, k: path.slice(), v: curr });351}352}353} else if (transform.kind === TransformKind.Array) {354this._diffArray(transform, path, prev as unknown[], curr as unknown[], entries);355} else if (transform.kind === TransformKind.Object) {356this._diffObject(transform.children, path, prev, curr, entries, transform.sealed as ((obj: unknown, wasSerialized: boolean) => boolean) | undefined);357} else {358throw new Error(`Unknown transform kind ${JSON.stringify(transform)}`);359}360}361362private _diffObject(363children: SchemaEntries,364path: ObjectPath,365prev: unknown,366curr: unknown,367entries: Entry[],368sealed?: (obj: unknown, wasSerialized: boolean) => boolean,369): void {370const prevObj = prev as Record<string, unknown> | undefined;371const currObj = curr as Record<string, unknown>;372373// First check key fields (sorted to front) - if any key changed, replace the entire object374let i = 0;375for (; i < children.length; i++) {376const [key, transform] = children[i];377if (transform.kind !== TransformKind.Key) {378break; // Keys are sorted to front, so we can stop379}380if (!transform.equals(prevObj?.[key], currObj[key])) {381// Key changed, replace entire object382entries.push({ kind: EntryKind.Set, k: path.slice(), v: curr });383return;384}385}386387// If both objects are sealed, we've already verified keys match above,388// so we can skip diffing the other properties since sealed objects don't change389if (sealed && sealed(prev, true) && sealed(curr, false)) {390return;391}392393// Diff each property using mutable path394for (; i < children.length; i++) {395const [key, transform] = children[i];396path.push(key);397this._diff(transform, path, prevObj?.[key], currObj[key], entries);398path.pop();399}400}401402private _diffArray<T, R>(403transform: TransformArray<T, R>,404path: ObjectPath,405prev: unknown[] | undefined,406curr: unknown[] | undefined,407entries: Entry[]408): void {409const prevArr = prev || [];410const currArr = curr || [];411412const itemSchema = transform.itemSchema;413const minLen = Math.min(prevArr.length, currArr.length);414415// If the item schema is an object, we can recurse into it to diff individual416// properties instead of replacing the entire item. However, we only do this417// if the key fields match.418if (itemSchema.kind === TransformKind.Object) {419const childEntries = itemSchema.children;420421// Diff common elements by recursing into them422for (let i = 0; i < minLen; i++) {423const prevItem = prevArr[i];424const currItem = currArr[i];425426// Check if key fields match - if not, we need to replace from this point427if (this._hasKeyMismatch(childEntries, prevItem, currItem)) {428// Key mismatch: replace from this point onward429const newItems = currArr.slice(i);430entries.push({ kind: EntryKind.Push, k: path.slice(), v: newItems.length > 0 ? newItems : undefined, i });431return;432}433434// Keys match, recurse into the object435path.push(i);436this._diffObject(childEntries, path, prevItem, currItem, entries, itemSchema.sealed);437path.pop();438}439440// Handle length changes441if (currArr.length > prevArr.length) {442entries.push({ kind: EntryKind.Push, k: path.slice(), v: currArr.slice(prevArr.length) });443} else if (currArr.length < prevArr.length) {444entries.push({ kind: EntryKind.Push, k: path.slice(), i: currArr.length });445}446} else {447// No children schema, use the original positional comparison448let firstMismatch = -1;449450for (let i = 0; i < minLen; i++) {451if (!itemSchema.equals(prevArr[i], currArr[i])) {452firstMismatch = i;453break;454}455}456457if (firstMismatch === -1) {458// All common elements match459if (currArr.length > prevArr.length) {460// New items appended461entries.push({ kind: EntryKind.Push, k: path.slice(), v: currArr.slice(prevArr.length) });462} else if (currArr.length < prevArr.length) {463// Items removed from end464entries.push({ kind: EntryKind.Push, k: path.slice(), i: currArr.length });465}466// else: same length, all match - no change467} else {468// Mismatch found, rewrite from that point469const newItems = currArr.slice(firstMismatch);470entries.push({ kind: EntryKind.Push, k: path.slice(), v: newItems.length > 0 ? newItems : undefined, i: firstMismatch });471}472}473}474475private _hasKeyMismatch(children: SchemaEntries, prev: unknown, curr: unknown): boolean {476const prevObj = prev as Record<string, unknown> | undefined;477const currObj = curr as Record<string, unknown>;478for (const [key, transform] of children) {479if (transform.kind !== TransformKind.Key) {480break; // Keys are sorted to front, so we can stop481}482if (!transform.equals(prevObj?.[key], currObj[key])) {483return true;484}485}486return false;487}488}489490491