Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/common/model/objectMutationLog.ts
5241 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { assertNever } from '../../../../../base/common/assert.js';
7
import { VSBuffer } from '../../../../../base/common/buffer.js';
8
import { isUndefinedOrNull } from '../../../../../base/common/types.js';
9
10
11
/** IMPORTANT: `Key` comes first. Then we should sort in order of least->most expensive to diff */
12
const enum TransformKind {
13
Key,
14
Primitive,
15
Array,
16
Object,
17
}
18
19
/** Schema entries sorted with key properties first */
20
export type SchemaEntries = [string, Transform<unknown, unknown>][];
21
22
interface TransformBase<TFrom, TTo> {
23
readonly kind: TransformKind;
24
/** Extracts the serializable value from the source object */
25
extract(from: TFrom): TTo;
26
}
27
28
/** Transform for primitive values (keys and values) that can be compared for equality */
29
export interface TransformValue<TFrom, TTo> extends TransformBase<TFrom, TTo> {
30
readonly kind: TransformKind.Key | TransformKind.Primitive;
31
/** Compares two serialized values for equality */
32
equals(a: TTo, b: TTo): boolean;
33
}
34
35
/** Transform for arrays with an item schema */
36
export interface TransformArray<TFrom, TTo> extends TransformBase<TFrom, TTo> {
37
readonly kind: TransformKind.Array;
38
/** The schema for array items */
39
readonly itemSchema: TransformObject<unknown, unknown> | TransformValue<unknown, unknown>;
40
}
41
42
/** Transform for objects with child properties */
43
export interface TransformObject<TFrom, TTo> extends TransformBase<TFrom, TTo> {
44
readonly kind: TransformKind.Object;
45
/** Schema entries sorted with Key properties first */
46
readonly children: SchemaEntries;
47
/** Checks if the object is sealed (won't change). */
48
sealed?(obj: TTo, wasSerialized: boolean): boolean;
49
}
50
51
export type Transform<TFrom, TTo> =
52
| TransformValue<TFrom, TTo>
53
| TransformArray<TFrom, TTo>
54
| TransformObject<TFrom, TTo>;
55
56
export type Schema<TFrom, TTo> = {
57
[K in keyof Required<TTo>]: Transform<TFrom, TTo[K]>
58
};
59
60
/**
61
* A primitive that will be tracked and compared first. If this is changed, the entire
62
* object is thrown out and re-stored.
63
*/
64
export function key<T, R = T>(comparator?: (a: R, b: R) => boolean): TransformValue<T, R> {
65
return {
66
kind: TransformKind.Key,
67
extract: (from: T) => from as unknown as R,
68
equals: comparator ?? ((a, b) => a === b),
69
};
70
}
71
72
/** A value that will be tracked and replaced if the comparator is not equal. */
73
export function value<T, R extends string | number | boolean | undefined>(): TransformValue<T, R>;
74
export function value<T, R>(comparator: (a: R, b: R) => boolean): TransformValue<T, R>;
75
export function value<T, R>(comparator?: (a: R, b: R) => boolean): TransformValue<T, R> {
76
return {
77
kind: TransformKind.Primitive,
78
extract: (from: T) => {
79
let value = from as unknown as R;
80
// We map the object to JSON for two reasons (a) reduce issues with references to
81
// mutable type that could be held internally in the LogAdapter and (b) to make
82
// object comparison work with the data we re-hydrate from disk (e.g. if using
83
// objectsEqual, a hydrated URI is not equal to the serialized UriComponents)
84
if (!!value && typeof value === 'object') {
85
value = JSON.parse(JSON.stringify(value));
86
}
87
88
return value;
89
},
90
equals: comparator ?? ((a, b) => a === b),
91
};
92
}
93
94
/** An array that will use the schema to compare items positionally. */
95
export function array<T, R>(schema: TransformObject<T, R> | TransformValue<T, R>): TransformArray<readonly T[], R[]> {
96
return {
97
kind: TransformKind.Array,
98
itemSchema: schema,
99
extract: from => from?.map(item => schema.extract(item)),
100
};
101
}
102
103
export interface ObjectOptions<R> {
104
/**
105
* Returns true if the object is sealed and will never change again.
106
* When comparing two sealed objects, only key fields are compared
107
* (to detect replacement), but other fields are not diffed.
108
*/
109
sealed?: (obj: R, wasSerialized: boolean) => boolean;
110
}
111
112
/** An object schema. */
113
export function object<T, R extends object>(schema: Schema<T, R>, options?: ObjectOptions<R>): TransformObject<T, R> {
114
// Sort entries with key properties first for fast key checking
115
const entries = (Object.entries(schema) as [string, Transform<T, R[keyof R]>][]).sort(([, a], [, b]) => a.kind - b.kind);
116
return {
117
kind: TransformKind.Object,
118
children: entries as SchemaEntries,
119
sealed: options?.sealed,
120
extract: (from: T) => {
121
if (isUndefinedOrNull(from)) {
122
return from as unknown as R;
123
}
124
125
const result: Record<string, unknown> = Object.create(null);
126
for (const [key, transform] of entries) {
127
result[key] = transform.extract(from);
128
}
129
return result as R;
130
},
131
};
132
}
133
134
/**
135
* Defines a getter on the object to extract a value, compared with the given schema.
136
* It should return the value that will get serialized in the resulting log file.
137
*/
138
export function t<T, O, R>(getter: (obj: T) => O, schema: Transform<O, R>): Transform<T, R> {
139
return {
140
...schema,
141
extract: (from: T) => schema.extract(getter(from)),
142
};
143
}
144
145
/** Shortcut for t(fn, value()) */
146
export function v<T, R extends string | number | boolean | undefined>(getter: (obj: T) => R): TransformValue<T, R>;
147
export function v<T, R>(getter: (obj: T) => R, comparator: (a: R, b: R) => boolean): TransformValue<T, R>;
148
export function v<T, R>(getter: (obj: T) => R, comparator?: (a: R, b: R) => boolean): TransformValue<T, R> {
149
const inner = value(comparator!);
150
return {
151
...inner,
152
extract: (from: T) => inner.extract(getter(from)),
153
};
154
}
155
156
157
const enum EntryKind {
158
/** Initial complete object state, valid only as the first entry */
159
Initial = 0,
160
/** Property update */
161
Set = 1,
162
/** Array push/splice. */
163
Push = 2,
164
/** Delete a property */
165
Delete = 3,
166
}
167
168
type ObjectPath = (string | number)[];
169
170
type Entry =
171
| { kind: EntryKind.Initial; v: unknown }
172
/** Update a property of an object, replacing it entirely */
173
| { kind: EntryKind.Set; k: ObjectPath; v: unknown }
174
/** Delete a property of an object */
175
| { kind: EntryKind.Delete; k: ObjectPath }
176
/** Pushes 0 or more new entries to an array. If `i` is set, everything after that index is removed */
177
| { kind: EntryKind.Push; k: ObjectPath; v?: unknown[]; i?: number };
178
179
const LF = VSBuffer.fromString('\n');
180
181
/**
182
* An implementation of an append-based mutation logger. Given a `Transform`
183
* definition of an object, it can recreate it from a file on disk. It is
184
* then stateful, and given a `write` call it can update the log in a minimal
185
* way.
186
*/
187
export class ObjectMutationLog<TFrom, TTo> {
188
private _previous: TTo | undefined;
189
private _entryCount = 0;
190
191
constructor(
192
private readonly _transform: Transform<TFrom, TTo>,
193
private readonly _compactAfterEntries = 512,
194
) { }
195
196
/**
197
* Creates an initial log file from the given object.
198
*/
199
createInitial(current: TFrom): VSBuffer {
200
return this.createInitialFromSerialized(this._transform.extract(current));
201
}
202
203
204
/**
205
* Creates an initial log file from the serialized object.
206
*/
207
createInitialFromSerialized(value: TTo): VSBuffer {
208
this._previous = value;
209
this._entryCount = 1;
210
const entry: Entry = { kind: EntryKind.Initial, v: value };
211
return VSBuffer.fromString(JSON.stringify(entry) + '\n');
212
}
213
214
/**
215
* Reads and reconstructs the state from a log file.
216
*/
217
read(content: VSBuffer): TTo {
218
let state: unknown;
219
let lineCount = 0;
220
221
let start = 0;
222
const len = content.byteLength;
223
while (start < len) {
224
let end = content.indexOf(LF, start);
225
if (end === -1) {
226
end = len;
227
}
228
229
if (end > start) {
230
const line = content.slice(start, end);
231
if (line.byteLength > 0) {
232
lineCount++;
233
const entry = JSON.parse(line.toString()) as Entry;
234
switch (entry.kind) {
235
case EntryKind.Initial:
236
state = entry.v;
237
break;
238
case EntryKind.Set:
239
this._applySet(state, entry.k, entry.v);
240
break;
241
case EntryKind.Push:
242
this._applyPush(state, entry.k, entry.v, entry.i);
243
break;
244
case EntryKind.Delete:
245
this._applySet(state, entry.k, undefined);
246
break;
247
default:
248
assertNever(entry);
249
}
250
}
251
}
252
start = end + 1;
253
}
254
255
if (lineCount === 0) {
256
throw new Error('Empty log file');
257
}
258
259
this._previous = state as TTo;
260
this._entryCount = lineCount;
261
return state as TTo;
262
}
263
264
/**
265
* Writes updates to the log. Returns the operation type and data to write.
266
*/
267
write(current: TFrom): { op: 'append' | 'replace'; data: VSBuffer } {
268
const currentValue = this._transform.extract(current);
269
270
if (!this._previous || this._entryCount > this._compactAfterEntries) {
271
// No previous state, create initial
272
this._previous = currentValue;
273
this._entryCount = 1;
274
const entry: Entry = { kind: EntryKind.Initial, v: currentValue };
275
return { op: 'replace', data: VSBuffer.fromString(JSON.stringify(entry) + '\n') };
276
}
277
278
// Generate diff entries
279
const entries: Entry[] = [];
280
const path: ObjectPath = [];
281
this._diff(this._transform, path, this._previous, currentValue, entries);
282
283
if (entries.length === 0) {
284
// No changes
285
return { op: 'append', data: VSBuffer.fromString('') };
286
}
287
288
this._entryCount += entries.length;
289
this._previous = currentValue;
290
291
// Append entries - build string directly
292
let data = '';
293
for (const e of entries) {
294
data += JSON.stringify(e) + '\n';
295
}
296
return { op: 'append', data: VSBuffer.fromString(data) };
297
}
298
299
private _applySet(state: unknown, path: ObjectPath, value: unknown): void {
300
if (path.length === 0) {
301
return; // Root replacement handled by caller
302
}
303
304
let current = state as Record<string | number, unknown>;
305
for (let i = 0; i < path.length - 1; i++) {
306
current = current[path[i]] as Record<string | number, unknown>;
307
}
308
309
current[path[path.length - 1]] = value;
310
}
311
312
private _applyPush(state: unknown, path: ObjectPath, values: unknown[] | undefined, startIndex: number | undefined): void {
313
let current = state as Record<string | number, unknown>;
314
for (let i = 0; i < path.length - 1; i++) {
315
current = current[path[i]] as Record<string | number, unknown>;
316
}
317
318
const arrayKey = path[path.length - 1];
319
const arr = current[arrayKey] as unknown[] || [];
320
321
if (startIndex !== undefined) {
322
arr.length = startIndex;
323
}
324
325
if (values && values.length > 0) {
326
arr.push(...values);
327
}
328
329
current[arrayKey] = arr;
330
}
331
332
private _diff<T, R>(
333
transform: Transform<T, R>,
334
path: ObjectPath,
335
prev: R,
336
curr: R,
337
entries: Entry[]
338
): void {
339
if (transform.kind === TransformKind.Key || transform.kind === TransformKind.Primitive) {
340
// Simple value change - copy path since we're storing it
341
if (!transform.equals(prev, curr)) {
342
entries.push({ kind: EntryKind.Set, k: path.slice(), v: curr });
343
}
344
} else if (isUndefinedOrNull(prev) || isUndefinedOrNull(curr)) {
345
if (prev !== curr) {
346
if (curr === undefined) {
347
entries.push({ kind: EntryKind.Delete, k: path.slice() });
348
} else if (curr === null) {
349
entries.push({ kind: EntryKind.Set, k: path.slice(), v: null });
350
} else {
351
entries.push({ kind: EntryKind.Set, k: path.slice(), v: curr });
352
}
353
}
354
} else if (transform.kind === TransformKind.Array) {
355
this._diffArray(transform, path, prev as unknown[], curr as unknown[], entries);
356
} else if (transform.kind === TransformKind.Object) {
357
this._diffObject(transform.children, path, prev, curr, entries, transform.sealed as ((obj: unknown, wasSerialized: boolean) => boolean) | undefined);
358
} else {
359
throw new Error(`Unknown transform kind ${JSON.stringify(transform)}`);
360
}
361
}
362
363
private _diffObject(
364
children: SchemaEntries,
365
path: ObjectPath,
366
prev: unknown,
367
curr: unknown,
368
entries: Entry[],
369
sealed?: (obj: unknown, wasSerialized: boolean) => boolean,
370
): void {
371
const prevObj = prev as Record<string, unknown> | undefined;
372
const currObj = curr as Record<string, unknown>;
373
374
// First check key fields (sorted to front) - if any key changed, replace the entire object
375
let i = 0;
376
for (; i < children.length; i++) {
377
const [key, transform] = children[i];
378
if (transform.kind !== TransformKind.Key) {
379
break; // Keys are sorted to front, so we can stop
380
}
381
if (!transform.equals(prevObj?.[key], currObj[key])) {
382
// Key changed, replace entire object
383
entries.push({ kind: EntryKind.Set, k: path.slice(), v: curr });
384
return;
385
}
386
}
387
388
// If both objects are sealed, we've already verified keys match above,
389
// so we can skip diffing the other properties since sealed objects don't change
390
if (sealed && sealed(prev, true) && sealed(curr, false)) {
391
return;
392
}
393
394
// Diff each property using mutable path
395
for (; i < children.length; i++) {
396
const [key, transform] = children[i];
397
path.push(key);
398
this._diff(transform, path, prevObj?.[key], currObj[key], entries);
399
path.pop();
400
}
401
}
402
403
private _diffArray<T, R>(
404
transform: TransformArray<T, R>,
405
path: ObjectPath,
406
prev: unknown[] | undefined,
407
curr: unknown[] | undefined,
408
entries: Entry[]
409
): void {
410
const prevArr = prev || [];
411
const currArr = curr || [];
412
413
const itemSchema = transform.itemSchema;
414
const minLen = Math.min(prevArr.length, currArr.length);
415
416
// If the item schema is an object, we can recurse into it to diff individual
417
// properties instead of replacing the entire item. However, we only do this
418
// if the key fields match.
419
if (itemSchema.kind === TransformKind.Object) {
420
const childEntries = itemSchema.children;
421
422
// Diff common elements by recursing into them
423
for (let i = 0; i < minLen; i++) {
424
const prevItem = prevArr[i];
425
const currItem = currArr[i];
426
427
// Check if key fields match - if not, we need to replace from this point
428
if (this._hasKeyMismatch(childEntries, prevItem, currItem)) {
429
// Key mismatch: replace from this point onward
430
const newItems = currArr.slice(i);
431
entries.push({ kind: EntryKind.Push, k: path.slice(), v: newItems.length > 0 ? newItems : undefined, i });
432
return;
433
}
434
435
// Keys match, recurse into the object
436
path.push(i);
437
this._diffObject(childEntries, path, prevItem, currItem, entries, itemSchema.sealed);
438
path.pop();
439
}
440
441
// Handle length changes
442
if (currArr.length > prevArr.length) {
443
entries.push({ kind: EntryKind.Push, k: path.slice(), v: currArr.slice(prevArr.length) });
444
} else if (currArr.length < prevArr.length) {
445
entries.push({ kind: EntryKind.Push, k: path.slice(), i: currArr.length });
446
}
447
} else {
448
// No children schema, use the original positional comparison
449
let firstMismatch = -1;
450
451
for (let i = 0; i < minLen; i++) {
452
if (!itemSchema.equals(prevArr[i], currArr[i])) {
453
firstMismatch = i;
454
break;
455
}
456
}
457
458
if (firstMismatch === -1) {
459
// All common elements match
460
if (currArr.length > prevArr.length) {
461
// New items appended
462
entries.push({ kind: EntryKind.Push, k: path.slice(), v: currArr.slice(prevArr.length) });
463
} else if (currArr.length < prevArr.length) {
464
// Items removed from end
465
entries.push({ kind: EntryKind.Push, k: path.slice(), i: currArr.length });
466
}
467
// else: same length, all match - no change
468
} else {
469
// Mismatch found, rewrite from that point
470
const newItems = currArr.slice(firstMismatch);
471
entries.push({ kind: EntryKind.Push, k: path.slice(), v: newItems.length > 0 ? newItems : undefined, i: firstMismatch });
472
}
473
}
474
}
475
476
private _hasKeyMismatch(children: SchemaEntries, prev: unknown, curr: unknown): boolean {
477
const prevObj = prev as Record<string, unknown> | undefined;
478
const currObj = curr as Record<string, unknown>;
479
for (const [key, transform] of children) {
480
if (transform.kind !== TransformKind.Key) {
481
break; // Keys are sorted to front, so we can stop
482
}
483
if (!transform.equals(prevObj?.[key], currObj[key])) {
484
return true;
485
}
486
}
487
return false;
488
}
489
}
490
491