@sylphx/lens-server 1.11.3 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1244 -260
- package/dist/index.js +1700 -1158
- package/package.json +2 -2
- package/src/context/index.test.ts +425 -0
- package/src/context/index.ts +90 -0
- package/src/e2e/server.test.ts +215 -433
- package/src/handlers/framework.ts +294 -0
- package/src/handlers/http.test.ts +215 -0
- package/src/handlers/http.ts +189 -0
- package/src/handlers/index.ts +55 -0
- package/src/handlers/unified.ts +114 -0
- package/src/handlers/ws-types.ts +126 -0
- package/src/handlers/ws.ts +669 -0
- package/src/index.ts +127 -24
- package/src/plugin/index.ts +41 -0
- package/src/plugin/op-log.ts +286 -0
- package/src/plugin/optimistic.ts +375 -0
- package/src/plugin/types.ts +551 -0
- package/src/reconnect/index.ts +9 -0
- package/src/reconnect/operation-log.test.ts +480 -0
- package/src/reconnect/operation-log.ts +450 -0
- package/src/server/create.test.ts +256 -2193
- package/src/server/create.ts +285 -1481
- package/src/server/dataloader.ts +60 -0
- package/src/server/selection.ts +44 -0
- package/src/server/types.ts +289 -0
- package/src/sse/handler.ts +123 -56
- package/src/state/index.ts +9 -11
- package/src/storage/index.ts +26 -0
- package/src/storage/memory.ts +279 -0
- package/src/storage/types.ts +205 -0
- package/src/state/graph-state-manager.test.ts +0 -1105
- package/src/state/graph-state-manager.ts +0 -890
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-core - Operation Log Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
6
|
+
import { applyPatch, type OperationLogEntry, type PatchOperation } from "@sylphx/lens-core";
|
|
7
|
+
import { coalescePatches, estimatePatchSize, OperationLog } from "./operation-log.js";
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// Test Helpers
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
function createEntry(
|
|
14
|
+
entityKey: string,
|
|
15
|
+
version: number,
|
|
16
|
+
patch: PatchOperation[] = [],
|
|
17
|
+
timestamp = Date.now(),
|
|
18
|
+
): OperationLogEntry {
|
|
19
|
+
return {
|
|
20
|
+
entityKey,
|
|
21
|
+
version,
|
|
22
|
+
timestamp,
|
|
23
|
+
patch,
|
|
24
|
+
patchSize: JSON.stringify(patch).length,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// OperationLog Tests
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
describe("OperationLog", () => {
|
|
33
|
+
let log: OperationLog;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
log = new OperationLog({ cleanupInterval: 0 }); // Disable auto cleanup for tests
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
log.dispose();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ===========================================================================
|
|
44
|
+
// Basic Operations
|
|
45
|
+
// ===========================================================================
|
|
46
|
+
|
|
47
|
+
describe("append", () => {
|
|
48
|
+
it("appends entries correctly", () => {
|
|
49
|
+
log.append(createEntry("user:123", 1));
|
|
50
|
+
log.append(createEntry("user:123", 2));
|
|
51
|
+
|
|
52
|
+
const stats = log.getStats();
|
|
53
|
+
expect(stats.entryCount).toBe(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("tracks memory usage", () => {
|
|
57
|
+
const patch = [{ op: "replace" as const, path: "/name", value: "Alice" }];
|
|
58
|
+
log.append(createEntry("user:123", 1, patch));
|
|
59
|
+
|
|
60
|
+
const stats = log.getStats();
|
|
61
|
+
expect(stats.memoryUsage).toBeGreaterThan(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("updates entity indices", () => {
|
|
65
|
+
log.append(createEntry("user:123", 1));
|
|
66
|
+
log.append(createEntry("post:456", 1));
|
|
67
|
+
|
|
68
|
+
const stats = log.getStats();
|
|
69
|
+
expect(stats.entityCount).toBe(2);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("appendBatch", () => {
|
|
74
|
+
it("appends multiple entries efficiently", () => {
|
|
75
|
+
const entries = [createEntry("user:123", 1), createEntry("user:123", 2), createEntry("user:123", 3)];
|
|
76
|
+
|
|
77
|
+
log.appendBatch(entries);
|
|
78
|
+
|
|
79
|
+
const stats = log.getStats();
|
|
80
|
+
expect(stats.entryCount).toBe(3);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ===========================================================================
|
|
85
|
+
// Retrieval
|
|
86
|
+
// ===========================================================================
|
|
87
|
+
|
|
88
|
+
describe("getSince", () => {
|
|
89
|
+
it("returns entries since version", () => {
|
|
90
|
+
log.append(createEntry("user:123", 1));
|
|
91
|
+
log.append(createEntry("user:123", 2));
|
|
92
|
+
log.append(createEntry("user:123", 3));
|
|
93
|
+
|
|
94
|
+
const entries = log.getSince("user:123", 1);
|
|
95
|
+
expect(entries).not.toBeNull();
|
|
96
|
+
expect(entries!.length).toBe(2);
|
|
97
|
+
expect(entries![0].version).toBe(2);
|
|
98
|
+
expect(entries![1].version).toBe(3);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns empty array when at latest version", () => {
|
|
102
|
+
log.append(createEntry("user:123", 1));
|
|
103
|
+
log.append(createEntry("user:123", 2));
|
|
104
|
+
|
|
105
|
+
const entries = log.getSince("user:123", 2);
|
|
106
|
+
expect(entries).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null when version too old", () => {
|
|
110
|
+
log.append(createEntry("user:123", 5));
|
|
111
|
+
log.append(createEntry("user:123", 6));
|
|
112
|
+
|
|
113
|
+
const entries = log.getSince("user:123", 2);
|
|
114
|
+
expect(entries).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns empty array for unknown entity with fromVersion 0", () => {
|
|
118
|
+
log.append(createEntry("user:123", 1));
|
|
119
|
+
|
|
120
|
+
// Unknown entity with fromVersion=0 means "I've never seen this entity"
|
|
121
|
+
// Return empty array because there's nothing to sync (entity doesn't exist for this client)
|
|
122
|
+
const entries = log.getSince("user:456", 0);
|
|
123
|
+
expect(entries).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns null for unknown entity with fromVersion > 0", () => {
|
|
127
|
+
log.append(createEntry("user:123", 1));
|
|
128
|
+
|
|
129
|
+
// Unknown entity with fromVersion > 0 means client had data that no longer exists
|
|
130
|
+
const entries = log.getSince("user:456", 5);
|
|
131
|
+
expect(entries).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns entries in version order", () => {
|
|
135
|
+
// Append out of order (simulating concurrent operations)
|
|
136
|
+
log.append(createEntry("user:123", 3));
|
|
137
|
+
log.append(createEntry("user:123", 1));
|
|
138
|
+
log.append(createEntry("user:123", 2));
|
|
139
|
+
|
|
140
|
+
const entries = log.getSince("user:123", 0);
|
|
141
|
+
expect(entries).not.toBeNull();
|
|
142
|
+
expect(entries!.map((e) => e.version)).toEqual([1, 2, 3]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("handles version gap correctly", () => {
|
|
146
|
+
log.append(createEntry("user:123", 1));
|
|
147
|
+
log.append(createEntry("user:123", 3)); // Gap at version 2
|
|
148
|
+
|
|
149
|
+
// Asking for version 0 should fail (gap detected)
|
|
150
|
+
const entries = log.getSince("user:123", 0);
|
|
151
|
+
expect(entries).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("hasVersion", () => {
|
|
156
|
+
it("returns true for versions in range", () => {
|
|
157
|
+
log.append(createEntry("user:123", 1));
|
|
158
|
+
log.append(createEntry("user:123", 2));
|
|
159
|
+
log.append(createEntry("user:123", 3));
|
|
160
|
+
|
|
161
|
+
expect(log.hasVersion("user:123", 1)).toBe(true);
|
|
162
|
+
expect(log.hasVersion("user:123", 2)).toBe(true);
|
|
163
|
+
expect(log.hasVersion("user:123", 3)).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns false for versions out of range", () => {
|
|
167
|
+
log.append(createEntry("user:123", 2));
|
|
168
|
+
log.append(createEntry("user:123", 3));
|
|
169
|
+
|
|
170
|
+
expect(log.hasVersion("user:123", 1)).toBe(false);
|
|
171
|
+
expect(log.hasVersion("user:123", 4)).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns false for unknown entity", () => {
|
|
175
|
+
expect(log.hasVersion("user:123", 1)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("getOldestVersion / getNewestVersion", () => {
|
|
180
|
+
it("returns correct versions", () => {
|
|
181
|
+
log.append(createEntry("user:123", 5));
|
|
182
|
+
log.append(createEntry("user:123", 6));
|
|
183
|
+
log.append(createEntry("user:123", 7));
|
|
184
|
+
|
|
185
|
+
expect(log.getOldestVersion("user:123")).toBe(5);
|
|
186
|
+
expect(log.getNewestVersion("user:123")).toBe(7);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("returns null for unknown entity", () => {
|
|
190
|
+
expect(log.getOldestVersion("user:123")).toBeNull();
|
|
191
|
+
expect(log.getNewestVersion("user:123")).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("getAll", () => {
|
|
196
|
+
it("returns all entries for entity", () => {
|
|
197
|
+
log.append(createEntry("user:123", 1));
|
|
198
|
+
log.append(createEntry("user:123", 2));
|
|
199
|
+
log.append(createEntry("post:456", 1));
|
|
200
|
+
|
|
201
|
+
const entries = log.getAll("user:123");
|
|
202
|
+
expect(entries.length).toBe(2);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("returns entries in version order", () => {
|
|
206
|
+
log.append(createEntry("user:123", 3));
|
|
207
|
+
log.append(createEntry("user:123", 1));
|
|
208
|
+
log.append(createEntry("user:123", 2));
|
|
209
|
+
|
|
210
|
+
const entries = log.getAll("user:123");
|
|
211
|
+
expect(entries.map((e) => e.version)).toEqual([1, 2, 3]);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ===========================================================================
|
|
216
|
+
// Eviction
|
|
217
|
+
// ===========================================================================
|
|
218
|
+
|
|
219
|
+
describe("eviction", () => {
|
|
220
|
+
it("evicts based on maxEntries", () => {
|
|
221
|
+
const smallLog = new OperationLog({
|
|
222
|
+
maxEntries: 3,
|
|
223
|
+
cleanupInterval: 0,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
smallLog.append(createEntry("user:123", 1));
|
|
227
|
+
smallLog.append(createEntry("user:123", 2));
|
|
228
|
+
smallLog.append(createEntry("user:123", 3));
|
|
229
|
+
smallLog.append(createEntry("user:123", 4));
|
|
230
|
+
smallLog.append(createEntry("user:123", 5));
|
|
231
|
+
|
|
232
|
+
const stats = smallLog.getStats();
|
|
233
|
+
expect(stats.entryCount).toBeLessThanOrEqual(3);
|
|
234
|
+
|
|
235
|
+
smallLog.dispose();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("evicts based on maxAge", () => {
|
|
239
|
+
const oldLog = new OperationLog({
|
|
240
|
+
maxAge: 100, // 100ms
|
|
241
|
+
cleanupInterval: 0,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const oldTimestamp = Date.now() - 200; // 200ms ago
|
|
245
|
+
oldLog.append(createEntry("user:123", 1, [], oldTimestamp));
|
|
246
|
+
oldLog.append(createEntry("user:123", 2, [], Date.now()));
|
|
247
|
+
|
|
248
|
+
oldLog.cleanup();
|
|
249
|
+
|
|
250
|
+
const stats = oldLog.getStats();
|
|
251
|
+
expect(stats.entryCount).toBe(1);
|
|
252
|
+
|
|
253
|
+
oldLog.dispose();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("evicts based on maxMemory", () => {
|
|
257
|
+
const smallMemLog = new OperationLog({
|
|
258
|
+
maxMemory: 100, // 100 bytes
|
|
259
|
+
cleanupInterval: 0,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const largePatch = [
|
|
263
|
+
{
|
|
264
|
+
op: "replace" as const,
|
|
265
|
+
path: "/data",
|
|
266
|
+
value: "x".repeat(50),
|
|
267
|
+
},
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
smallMemLog.append(createEntry("user:123", 1, largePatch));
|
|
271
|
+
smallMemLog.append(createEntry("user:123", 2, largePatch));
|
|
272
|
+
smallMemLog.append(createEntry("user:123", 3, largePatch));
|
|
273
|
+
|
|
274
|
+
const stats = smallMemLog.getStats();
|
|
275
|
+
expect(stats.memoryUsage).toBeLessThanOrEqual(100);
|
|
276
|
+
|
|
277
|
+
smallMemLog.dispose();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ===========================================================================
|
|
282
|
+
// Lifecycle
|
|
283
|
+
// ===========================================================================
|
|
284
|
+
|
|
285
|
+
describe("lifecycle", () => {
|
|
286
|
+
it("clears all entries", () => {
|
|
287
|
+
log.append(createEntry("user:123", 1));
|
|
288
|
+
log.append(createEntry("user:123", 2));
|
|
289
|
+
|
|
290
|
+
log.clear();
|
|
291
|
+
|
|
292
|
+
const stats = log.getStats();
|
|
293
|
+
expect(stats.entryCount).toBe(0);
|
|
294
|
+
expect(stats.entityCount).toBe(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("updates config", () => {
|
|
298
|
+
log.updateConfig({ maxEntries: 5 });
|
|
299
|
+
|
|
300
|
+
const stats = log.getStats();
|
|
301
|
+
expect(stats.config.maxEntries).toBe(5);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ===========================================================================
|
|
306
|
+
// Statistics
|
|
307
|
+
// ===========================================================================
|
|
308
|
+
|
|
309
|
+
describe("getStats", () => {
|
|
310
|
+
it("returns correct statistics", () => {
|
|
311
|
+
const timestamp = Date.now();
|
|
312
|
+
log.append(createEntry("user:123", 1, [], timestamp));
|
|
313
|
+
log.append(createEntry("user:123", 2, [], timestamp + 100));
|
|
314
|
+
log.append(createEntry("post:456", 1, [], timestamp + 200));
|
|
315
|
+
|
|
316
|
+
const stats = log.getStats();
|
|
317
|
+
expect(stats.entryCount).toBe(3);
|
|
318
|
+
expect(stats.entityCount).toBe(2);
|
|
319
|
+
expect(stats.oldestTimestamp).toBe(timestamp);
|
|
320
|
+
expect(stats.newestTimestamp).toBe(timestamp + 200);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("returns null timestamps for empty log", () => {
|
|
324
|
+
const stats = log.getStats();
|
|
325
|
+
expect(stats.oldestTimestamp).toBeNull();
|
|
326
|
+
expect(stats.newestTimestamp).toBeNull();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// =============================================================================
|
|
332
|
+
// coalescePatches Tests
|
|
333
|
+
// =============================================================================
|
|
334
|
+
|
|
335
|
+
describe("coalescePatches", () => {
|
|
336
|
+
it("merges sequential replace operations", () => {
|
|
337
|
+
const patches: PatchOperation[][] = [
|
|
338
|
+
[{ op: "replace", path: "/name", value: "Alice" }],
|
|
339
|
+
[{ op: "replace", path: "/name", value: "Bob" }],
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
const coalesced = coalescePatches(patches);
|
|
343
|
+
expect(coalesced.length).toBe(1);
|
|
344
|
+
expect(coalesced[0].value).toBe("Bob");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("preserves different paths", () => {
|
|
348
|
+
const patches: PatchOperation[][] = [
|
|
349
|
+
[{ op: "replace", path: "/name", value: "Alice" }],
|
|
350
|
+
[{ op: "replace", path: "/age", value: 30 }],
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
const coalesced = coalescePatches(patches);
|
|
354
|
+
expect(coalesced.length).toBe(2);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("remove trumps add/replace", () => {
|
|
358
|
+
const patches: PatchOperation[][] = [
|
|
359
|
+
[{ op: "add", path: "/name", value: "Alice" }],
|
|
360
|
+
[{ op: "remove", path: "/name" }],
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
const coalesced = coalescePatches(patches);
|
|
364
|
+
expect(coalesced.length).toBe(1);
|
|
365
|
+
expect(coalesced[0].op).toBe("remove");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("handles empty patches", () => {
|
|
369
|
+
const patches: PatchOperation[][] = [[], []];
|
|
370
|
+
const coalesced = coalescePatches(patches);
|
|
371
|
+
expect(coalesced.length).toBe(0);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("sorts by path depth", () => {
|
|
375
|
+
const patches: PatchOperation[][] = [
|
|
376
|
+
[{ op: "replace", path: "/a/b/c", value: 1 }],
|
|
377
|
+
[{ op: "replace", path: "/a", value: 2 }],
|
|
378
|
+
[{ op: "replace", path: "/a/b", value: 3 }],
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
const coalesced = coalescePatches(patches);
|
|
382
|
+
expect(coalesced[0].path).toBe("/a");
|
|
383
|
+
expect(coalesced[1].path).toBe("/a/b");
|
|
384
|
+
expect(coalesced[2].path).toBe("/a/b/c");
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// =============================================================================
|
|
389
|
+
// estimatePatchSize Tests
|
|
390
|
+
// =============================================================================
|
|
391
|
+
|
|
392
|
+
describe("estimatePatchSize", () => {
|
|
393
|
+
it("estimates size correctly", () => {
|
|
394
|
+
const patch: PatchOperation[] = [{ op: "replace", path: "/name", value: "Alice" }];
|
|
395
|
+
|
|
396
|
+
const size = estimatePatchSize(patch);
|
|
397
|
+
expect(size).toBeGreaterThan(0);
|
|
398
|
+
expect(size).toBe(JSON.stringify(patch).length);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("returns 2 for empty patch", () => {
|
|
402
|
+
const size = estimatePatchSize([]);
|
|
403
|
+
expect(size).toBe(2); // "[]"
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// =============================================================================
|
|
408
|
+
// applyPatch Tests
|
|
409
|
+
// =============================================================================
|
|
410
|
+
|
|
411
|
+
describe("applyPatch", () => {
|
|
412
|
+
it("applies replace operation", () => {
|
|
413
|
+
const target = { name: "Alice", age: 30 };
|
|
414
|
+
const patch: PatchOperation[] = [{ op: "replace", path: "/name", value: "Bob" }];
|
|
415
|
+
|
|
416
|
+
const result = applyPatch(target, patch);
|
|
417
|
+
expect(result.name).toBe("Bob");
|
|
418
|
+
expect(result.age).toBe(30);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("applies add operation", () => {
|
|
422
|
+
const target = { name: "Alice" };
|
|
423
|
+
const patch: PatchOperation[] = [{ op: "add", path: "/age", value: 30 }];
|
|
424
|
+
|
|
425
|
+
const result = applyPatch(target, patch);
|
|
426
|
+
expect(result.name).toBe("Alice");
|
|
427
|
+
expect(result.age).toBe(30);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("applies remove operation", () => {
|
|
431
|
+
const target = { name: "Alice", age: 30 };
|
|
432
|
+
const patch: PatchOperation[] = [{ op: "remove", path: "/age" }];
|
|
433
|
+
|
|
434
|
+
const result = applyPatch(target, patch);
|
|
435
|
+
expect(result.name).toBe("Alice");
|
|
436
|
+
expect("age" in result).toBe(false);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("applies nested operations", () => {
|
|
440
|
+
const target = { user: { name: "Alice", address: { city: "NYC" } } };
|
|
441
|
+
const patch: PatchOperation[] = [{ op: "replace", path: "/user/address/city", value: "LA" }];
|
|
442
|
+
|
|
443
|
+
const result = applyPatch(target, patch);
|
|
444
|
+
expect(result.user.address.city).toBe("LA");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("does not mutate original", () => {
|
|
448
|
+
const target = { name: "Alice" };
|
|
449
|
+
const patch: PatchOperation[] = [{ op: "replace", path: "/name", value: "Bob" }];
|
|
450
|
+
|
|
451
|
+
applyPatch(target, patch);
|
|
452
|
+
expect(target.name).toBe("Alice");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("handles multiple operations", () => {
|
|
456
|
+
const target = { name: "Alice", age: 30 };
|
|
457
|
+
const patch: PatchOperation[] = [
|
|
458
|
+
{ op: "replace", path: "/name", value: "Bob" },
|
|
459
|
+
{ op: "replace", path: "/age", value: 31 },
|
|
460
|
+
{ op: "add", path: "/city", value: "NYC" },
|
|
461
|
+
];
|
|
462
|
+
|
|
463
|
+
const result = applyPatch(target, patch);
|
|
464
|
+
expect(result.name).toBe("Bob");
|
|
465
|
+
expect(result.age).toBe(31);
|
|
466
|
+
expect((result as Record<string, unknown>).city).toBe("NYC");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("handles JSON pointer escaping", () => {
|
|
470
|
+
const target = { "a/b": 1, "a~b": 2 };
|
|
471
|
+
const patch: PatchOperation[] = [
|
|
472
|
+
{ op: "replace", path: "/a~1b", value: 10 }, // a/b
|
|
473
|
+
{ op: "replace", path: "/a~0b", value: 20 }, // a~b
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
const result = applyPatch(target, patch);
|
|
477
|
+
expect(result["a/b"]).toBe(10);
|
|
478
|
+
expect(result["a~b"]).toBe(20);
|
|
479
|
+
});
|
|
480
|
+
});
|