@voidhash/mimic 1.0.0-beta.13 → 1.0.0-beta.15
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/.turbo/turbo-build.log +27 -27
- package/dist/Primitive.cjs +1 -0
- package/dist/Primitive.d.cts +2 -2
- package/dist/Primitive.d.mts +2 -2
- package/dist/Primitive.mjs +2 -1
- package/dist/client/ClientDocument.cjs +36 -7
- package/dist/client/ClientDocument.d.mts.map +1 -1
- package/dist/client/ClientDocument.mjs +36 -7
- package/dist/client/ClientDocument.mjs.map +1 -1
- package/dist/primitives/Struct.cjs +38 -13
- package/dist/primitives/Struct.d.cts +1 -0
- package/dist/primitives/Struct.d.cts.map +1 -1
- package/dist/primitives/Struct.d.mts +1 -0
- package/dist/primitives/Struct.d.mts.map +1 -1
- package/dist/primitives/Struct.mjs +39 -14
- package/dist/primitives/Struct.mjs.map +1 -1
- package/dist/primitives/shared.cjs +28 -4
- package/dist/primitives/shared.d.cts +6 -1
- package/dist/primitives/shared.d.cts.map +1 -1
- package/dist/primitives/shared.d.mts +6 -1
- package/dist/primitives/shared.d.mts.map +1 -1
- package/dist/primitives/shared.mjs +28 -5
- package/dist/primitives/shared.mjs.map +1 -1
- package/package.json +2 -2
- package/src/client/ClientDocument.ts +40 -3
- package/src/primitives/Struct.ts +52 -18
- package/src/primitives/shared.ts +51 -5
- package/tests/client/ClientDocument.test.ts +99 -0
- package/tests/primitives/Struct.test.ts +141 -4
- package/tests/primitives/Tree.test.ts +143 -0
package/src/primitives/shared.ts
CHANGED
|
@@ -171,6 +171,23 @@ export function runValidators<T>(value: T, validators: readonly { validate: (val
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Returns true if a primitive can represent null as a meaningful value.
|
|
176
|
+
* This is used to avoid pruning explicit null values for null-capable scalar unions.
|
|
177
|
+
*/
|
|
178
|
+
export function primitiveAllowsNullValue(primitive: AnyPrimitive): boolean {
|
|
179
|
+
if (primitive._tag === "LiteralPrimitive") {
|
|
180
|
+
return (primitive as { literal: unknown }).literal === null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (primitive._tag === "EitherPrimitive") {
|
|
184
|
+
const variants = (primitive as { _schema?: { variants?: readonly AnyPrimitive[] } })._schema?.variants;
|
|
185
|
+
return Array.isArray(variants) && variants.some((variant) => primitiveAllowsNullValue(variant));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
174
191
|
/**
|
|
175
192
|
* Checks if an operation is compatible with the given operation definitions.
|
|
176
193
|
* @param operation - The operation to check.
|
|
@@ -214,20 +231,50 @@ export function applyDefaults<T extends AnyPrimitive>(
|
|
|
214
231
|
|
|
215
232
|
// Layer the provided values on top of initial state
|
|
216
233
|
const result: Record<string, unknown> = { ...structInitialState, ...value };
|
|
234
|
+
const inputObject =
|
|
235
|
+
typeof value === "object" && value !== null
|
|
236
|
+
? (value as Record<string, unknown>)
|
|
237
|
+
: undefined;
|
|
217
238
|
|
|
218
239
|
for (const key in structPrimitive.fields) {
|
|
219
240
|
const fieldPrimitive = structPrimitive.fields[key]!;
|
|
241
|
+
const hasExplicitKey = inputObject !== undefined && Object.prototype.hasOwnProperty.call(inputObject, key);
|
|
242
|
+
const explicitValue = hasExplicitKey ? inputObject[key] : undefined;
|
|
243
|
+
const fieldDefault = fieldPrimitive._internal.getInitialState();
|
|
244
|
+
const isRequiredWithoutDefault =
|
|
245
|
+
(fieldPrimitive as { _schema?: { required?: boolean } })._schema?.required === true &&
|
|
246
|
+
fieldDefault === undefined;
|
|
247
|
+
|
|
248
|
+
// Explicit undefined values always prune optional keys.
|
|
249
|
+
// Explicit null values prune optional keys unless null is a valid semantic value for this field.
|
|
250
|
+
// Required fields without defaults reject nullish values.
|
|
251
|
+
const shouldPruneExplicitNullish =
|
|
252
|
+
hasExplicitKey &&
|
|
253
|
+
(
|
|
254
|
+
explicitValue === undefined ||
|
|
255
|
+
(explicitValue === null && !primitiveAllowsNullValue(fieldPrimitive))
|
|
256
|
+
);
|
|
257
|
+
if (shouldPruneExplicitNullish) {
|
|
258
|
+
if (isRequiredWithoutDefault) {
|
|
259
|
+
throw new ValidationError(`Field "${key}" is required and cannot be null or undefined`);
|
|
260
|
+
}
|
|
261
|
+
delete result[key];
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
220
264
|
|
|
221
|
-
if (result[key] === undefined) {
|
|
265
|
+
if (!hasExplicitKey && result[key] === undefined) {
|
|
222
266
|
// Field still not provided after merging - try individual field default
|
|
223
|
-
const fieldDefault = fieldPrimitive._internal.getInitialState();
|
|
224
267
|
if (fieldDefault !== undefined) {
|
|
225
268
|
result[key] = fieldDefault;
|
|
226
269
|
}
|
|
227
|
-
} else if (
|
|
270
|
+
} else if (
|
|
271
|
+
hasExplicitKey &&
|
|
272
|
+
typeof explicitValue === "object" &&
|
|
273
|
+
explicitValue !== null
|
|
274
|
+
) {
|
|
228
275
|
// Recursively apply defaults to nested structs and unions
|
|
229
276
|
if (fieldPrimitive._tag === "StructPrimitive" || fieldPrimitive._tag === "UnionPrimitive") {
|
|
230
|
-
result[key] = applyDefaults(fieldPrimitive,
|
|
277
|
+
result[key] = applyDefaults(fieldPrimitive, explicitValue as Partial<InferState<typeof fieldPrimitive>>);
|
|
231
278
|
}
|
|
232
279
|
}
|
|
233
280
|
}
|
|
@@ -285,4 +332,3 @@ export function applyDefaults<T extends AnyPrimitive>(
|
|
|
285
332
|
// For other primitives, return the value as-is
|
|
286
333
|
return value as InferState<T>;
|
|
287
334
|
}
|
|
288
|
-
|
|
@@ -1878,5 +1878,104 @@ describe("ClientDocument Presence", () => {
|
|
|
1878
1878
|
|
|
1879
1879
|
expect(transport.sentTransactions.length).toBe(0);
|
|
1880
1880
|
});
|
|
1881
|
+
|
|
1882
|
+
it("should NEVER send transactions to server during draft.update() - explicit verification", async () => {
|
|
1883
|
+
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1884
|
+
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1885
|
+
await client.connect();
|
|
1886
|
+
|
|
1887
|
+
// Track when transport.send is called
|
|
1888
|
+
const sendCalls: Transaction.Transaction[] = [];
|
|
1889
|
+
const originalSend = transport.send;
|
|
1890
|
+
transport.send = (tx) => {
|
|
1891
|
+
sendCalls.push(tx);
|
|
1892
|
+
originalSend.call(transport, tx);
|
|
1893
|
+
};
|
|
1894
|
+
|
|
1895
|
+
const draft = client.createDraft();
|
|
1896
|
+
|
|
1897
|
+
// Perform multiple updates
|
|
1898
|
+
draft.update((root) => root.title.set("Update 1"));
|
|
1899
|
+
expect(sendCalls.length).toBe(0);
|
|
1900
|
+
|
|
1901
|
+
draft.update((root) => root.count.set(10));
|
|
1902
|
+
expect(sendCalls.length).toBe(0);
|
|
1903
|
+
|
|
1904
|
+
draft.update((root) => root.title.set("Update 2"));
|
|
1905
|
+
expect(sendCalls.length).toBe(0);
|
|
1906
|
+
|
|
1907
|
+
draft.update((root) => {
|
|
1908
|
+
root.title.set("Update 3");
|
|
1909
|
+
root.count.set(20);
|
|
1910
|
+
});
|
|
1911
|
+
expect(sendCalls.length).toBe(0);
|
|
1912
|
+
|
|
1913
|
+
// Verify optimistic state is updated
|
|
1914
|
+
expect(client.get()?.title).toBe("Update 3");
|
|
1915
|
+
expect(client.get()?.count).toBe(20);
|
|
1916
|
+
|
|
1917
|
+
// Still no transactions sent
|
|
1918
|
+
expect(sendCalls.length).toBe(0);
|
|
1919
|
+
expect(transport.sentTransactions.length).toBe(0);
|
|
1920
|
+
|
|
1921
|
+
// Only after commit should transaction be sent
|
|
1922
|
+
draft.commit();
|
|
1923
|
+
expect(sendCalls.length).toBe(1);
|
|
1924
|
+
expect(transport.sentTransactions.length).toBe(1);
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
it("should never call transport.send during draft lifecycle until commit", async () => {
|
|
1928
|
+
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1929
|
+
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1930
|
+
await client.connect();
|
|
1931
|
+
|
|
1932
|
+
// Create a spy to track exact moments of transport.send calls
|
|
1933
|
+
const sendTimestamps: { time: number; action: string }[] = [];
|
|
1934
|
+
const originalSend = transport.send;
|
|
1935
|
+
transport.send = (tx) => {
|
|
1936
|
+
sendTimestamps.push({ time: Date.now(), action: "send" });
|
|
1937
|
+
originalSend.call(transport, tx);
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
// Draft operations should NOT trigger send
|
|
1941
|
+
const draft = client.createDraft();
|
|
1942
|
+
expect(sendTimestamps.length).toBe(0);
|
|
1943
|
+
|
|
1944
|
+
draft.update((root) => root.title.set("Draft"));
|
|
1945
|
+
expect(sendTimestamps.length).toBe(0);
|
|
1946
|
+
|
|
1947
|
+
// Regular transaction SHOULD trigger send
|
|
1948
|
+
client.transaction((root) => root.count.set(5));
|
|
1949
|
+
expect(sendTimestamps.length).toBe(1);
|
|
1950
|
+
|
|
1951
|
+
// More draft updates should NOT trigger send
|
|
1952
|
+
draft.update((root) => root.title.set("Draft 2"));
|
|
1953
|
+
expect(sendTimestamps.length).toBe(1);
|
|
1954
|
+
|
|
1955
|
+
// Commit SHOULD trigger send
|
|
1956
|
+
draft.commit();
|
|
1957
|
+
expect(sendTimestamps.length).toBe(2);
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
it("should not send transactions when draft is discarded", async () => {
|
|
1961
|
+
const initialState: TestState = { title: "Hello", count: 0, items: [] };
|
|
1962
|
+
const client = ClientDocument.make({ schema: TestSchema, transport, initialState });
|
|
1963
|
+
await client.connect();
|
|
1964
|
+
|
|
1965
|
+
const draft = client.createDraft();
|
|
1966
|
+
draft.update((root) => root.title.set("Will be discarded"));
|
|
1967
|
+
draft.update((root) => root.count.set(999));
|
|
1968
|
+
|
|
1969
|
+
expect(transport.sentTransactions.length).toBe(0);
|
|
1970
|
+
|
|
1971
|
+
draft.discard();
|
|
1972
|
+
|
|
1973
|
+
// Still no transactions should be sent
|
|
1974
|
+
expect(transport.sentTransactions.length).toBe(0);
|
|
1975
|
+
|
|
1976
|
+
// State should revert
|
|
1977
|
+
expect(client.get()?.title).toBe("Hello");
|
|
1978
|
+
expect(client.get()?.count).toBe(0);
|
|
1979
|
+
});
|
|
1881
1980
|
});
|
|
1882
1981
|
});
|
|
@@ -4,6 +4,9 @@ import * as ProxyEnvironment from "../../src/ProxyEnvironment";
|
|
|
4
4
|
import * as OperationPath from "../../src/OperationPath";
|
|
5
5
|
import * as Operation from "../../src/Operation";
|
|
6
6
|
|
|
7
|
+
const hasOwn = (value: unknown, key: string): boolean =>
|
|
8
|
+
Object.prototype.hasOwnProperty.call(value, key);
|
|
9
|
+
|
|
7
10
|
describe("StructPrimitive", () => {
|
|
8
11
|
describe("proxy", () => {
|
|
9
12
|
it("nested field access returns field primitive proxy", () => {
|
|
@@ -83,7 +86,8 @@ describe("StructPrimitive", () => {
|
|
|
83
86
|
|
|
84
87
|
expect(operations).toHaveLength(1);
|
|
85
88
|
expect(operations[0]!.kind).toBe("struct.set");
|
|
86
|
-
expect(operations[0]!.payload).toEqual({ name: "John Doe", age: 30
|
|
89
|
+
expect(operations[0]!.payload).toEqual({ name: "John Doe", age: 30 });
|
|
90
|
+
expect(hasOwn(operations[0]!.payload, "email")).toBe(false);
|
|
87
91
|
});
|
|
88
92
|
|
|
89
93
|
it("set() only requires fields that are required and without defaults", () => {
|
|
@@ -103,7 +107,48 @@ describe("StructPrimitive", () => {
|
|
|
103
107
|
|
|
104
108
|
expect(operations).toHaveLength(1);
|
|
105
109
|
expect(operations[0]!.kind).toBe("struct.set");
|
|
106
|
-
expect(operations[0]!.payload).toEqual({ name: "John Doe", age: 30
|
|
110
|
+
expect(operations[0]!.payload).toEqual({ name: "John Doe", age: 30 });
|
|
111
|
+
expect(hasOwn(operations[0]!.payload, "email")).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("set() prunes optional keys explicitly set to undefined", () => {
|
|
115
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
116
|
+
const env = ProxyEnvironment.make((op) => {
|
|
117
|
+
operations.push(op);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const structPrimitive = Primitive.Struct({
|
|
121
|
+
name: Primitive.String().required(),
|
|
122
|
+
email: Primitive.String(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
|
|
126
|
+
proxy.set({ name: "Alice", email: undefined });
|
|
127
|
+
|
|
128
|
+
expect(operations).toHaveLength(1);
|
|
129
|
+
expect(operations[0]!.kind).toBe("struct.set");
|
|
130
|
+
expect(operations[0]!.payload).toEqual({ name: "Alice" });
|
|
131
|
+
expect(hasOwn(operations[0]!.payload, "email")).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("set() prunes optional keys explicitly set to null", () => {
|
|
135
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
136
|
+
const env = ProxyEnvironment.make((op) => {
|
|
137
|
+
operations.push(op);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const structPrimitive = Primitive.Struct({
|
|
141
|
+
name: Primitive.String().required(),
|
|
142
|
+
email: Primitive.String(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
|
|
146
|
+
(proxy as any).set({ name: "Alice", email: null });
|
|
147
|
+
|
|
148
|
+
expect(operations).toHaveLength(1);
|
|
149
|
+
expect(operations[0]!.kind).toBe("struct.set");
|
|
150
|
+
expect(operations[0]!.payload).toEqual({ name: "Alice" });
|
|
151
|
+
expect(hasOwn(operations[0]!.payload, "email")).toBe(false);
|
|
107
152
|
});
|
|
108
153
|
|
|
109
154
|
it("multiple field sets generate separate operations", () => {
|
|
@@ -388,7 +433,7 @@ describe("StructPrimitive", () => {
|
|
|
388
433
|
expect(operations.map((op) => op.payload)).toContain("Doe");
|
|
389
434
|
});
|
|
390
435
|
|
|
391
|
-
it("update()
|
|
436
|
+
it("update() emits struct.unset for undefined optional values", () => {
|
|
392
437
|
const operations: Operation.Operation<any, any, any>[] = [];
|
|
393
438
|
const env = ProxyEnvironment.make((op) => {
|
|
394
439
|
operations.push(op);
|
|
@@ -403,8 +448,100 @@ describe("StructPrimitive", () => {
|
|
|
403
448
|
|
|
404
449
|
proxy.update({ name: "John", email: undefined });
|
|
405
450
|
|
|
451
|
+
expect(operations).toHaveLength(2);
|
|
452
|
+
const nameOp = operations.find((op) => op.path.toTokens().join("/") === "name");
|
|
453
|
+
const unsetOp = operations.find((op) => op.path.toTokens().join("/") === "email");
|
|
454
|
+
expect(nameOp!.kind).toBe("string.set");
|
|
455
|
+
expect(nameOp!.payload).toBe("John");
|
|
456
|
+
expect(unsetOp!.kind).toBe("struct.unset");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("update() removes existing optional key for undefined value", () => {
|
|
460
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
461
|
+
const structPrimitive = Primitive.Struct({
|
|
462
|
+
name: Primitive.String(),
|
|
463
|
+
email: Primitive.String(),
|
|
464
|
+
});
|
|
465
|
+
let state: Primitive.InferState<typeof structPrimitive> | undefined = {
|
|
466
|
+
name: "John",
|
|
467
|
+
email: "john@example.com",
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const env = ProxyEnvironment.make({
|
|
471
|
+
onOperation: (op) => {
|
|
472
|
+
operations.push(op);
|
|
473
|
+
state = structPrimitive._internal.applyOperation(state, op);
|
|
474
|
+
},
|
|
475
|
+
getState: () => state,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
|
|
479
|
+
proxy.update({ email: undefined });
|
|
480
|
+
|
|
406
481
|
expect(operations).toHaveLength(1);
|
|
407
|
-
expect(operations[0]!.
|
|
482
|
+
expect(operations[0]!.kind).toBe("struct.unset");
|
|
483
|
+
expect(state).toEqual({ name: "John" });
|
|
484
|
+
expect(hasOwn(state, "email")).toBe(false);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("update() removes existing optional key for null value", () => {
|
|
488
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
489
|
+
const structPrimitive = Primitive.Struct({
|
|
490
|
+
name: Primitive.String(),
|
|
491
|
+
email: Primitive.String(),
|
|
492
|
+
});
|
|
493
|
+
let state: Primitive.InferState<typeof structPrimitive> | undefined = {
|
|
494
|
+
name: "John",
|
|
495
|
+
email: "john@example.com",
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const env = ProxyEnvironment.make({
|
|
499
|
+
onOperation: (op) => {
|
|
500
|
+
operations.push(op);
|
|
501
|
+
state = structPrimitive._internal.applyOperation(state, op);
|
|
502
|
+
},
|
|
503
|
+
getState: () => state,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
|
|
507
|
+
(proxy as any).update({ email: null });
|
|
508
|
+
|
|
509
|
+
expect(operations).toHaveLength(1);
|
|
510
|
+
expect(operations[0]!.kind).toBe("struct.unset");
|
|
511
|
+
expect(state).toEqual({ name: "John" });
|
|
512
|
+
expect(hasOwn(state, "email")).toBe(false);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("update() throws for undefined on required fields without defaults", () => {
|
|
516
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
517
|
+
const env = ProxyEnvironment.make((op) => {
|
|
518
|
+
operations.push(op);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const structPrimitive = Primitive.Struct({
|
|
522
|
+
name: Primitive.String().required(),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
|
|
526
|
+
|
|
527
|
+
expect(() => proxy.update({ name: undefined as never })).toThrow(Primitive.ValidationError);
|
|
528
|
+
expect(operations).toHaveLength(0);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("update() throws for null on required fields without defaults", () => {
|
|
532
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
533
|
+
const env = ProxyEnvironment.make((op) => {
|
|
534
|
+
operations.push(op);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const structPrimitive = Primitive.Struct({
|
|
538
|
+
name: Primitive.String().required(),
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
|
|
542
|
+
|
|
543
|
+
expect(() => (proxy as any).update({ name: null })).toThrow(Primitive.ValidationError);
|
|
544
|
+
expect(operations).toHaveLength(0);
|
|
408
545
|
});
|
|
409
546
|
|
|
410
547
|
it("update() recursively updates nested structs", () => {
|
|
@@ -4,6 +4,9 @@ import * as ProxyEnvironment from "../../src/ProxyEnvironment";
|
|
|
4
4
|
import * as OperationPath from "../../src/OperationPath";
|
|
5
5
|
import * as Operation from "../../src/Operation";
|
|
6
6
|
|
|
7
|
+
const hasOwn = (value: unknown, key: string): boolean =>
|
|
8
|
+
Object.prototype.hasOwnProperty.call(value, key);
|
|
9
|
+
|
|
7
10
|
describe("TreePrimitive", () => {
|
|
8
11
|
// Define node types using the new TreeNode API
|
|
9
12
|
const FileNode = Primitive.TreeNode("file", {
|
|
@@ -609,6 +612,108 @@ describe("TreePrimitive", () => {
|
|
|
609
612
|
expect(operations[0]!.kind).toBe("number.set");
|
|
610
613
|
expect(operations[0]!.path.toTokens()).toEqual(["file1", "size"]);
|
|
611
614
|
});
|
|
615
|
+
|
|
616
|
+
it("update() removes optional node data key when value is undefined", () => {
|
|
617
|
+
const initialState: Primitive.TreeState<typeof FolderNode> = [
|
|
618
|
+
{ id: "root", type: "folder", parentId: null, pos: "a0", data: { name: "Root" } },
|
|
619
|
+
{ id: "file1", type: "file", parentId: "root", pos: "a0", data: { name: "File1", size: 100 } },
|
|
620
|
+
];
|
|
621
|
+
const { env, operations } = createEnvWithState(initialState);
|
|
622
|
+
const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
|
|
623
|
+
|
|
624
|
+
proxy.node("file1")!.as(FileNode).update({ size: undefined });
|
|
625
|
+
|
|
626
|
+
expect(operations).toHaveLength(1);
|
|
627
|
+
expect(operations[0]!.kind).toBe("struct.unset");
|
|
628
|
+
expect(operations[0]!.path.toTokens()).toEqual(["file1", "size"]);
|
|
629
|
+
|
|
630
|
+
const file1 = proxy.get().find((n) => n.id === "file1")!;
|
|
631
|
+
expect(file1.data).toEqual({ name: "File1" });
|
|
632
|
+
expect(hasOwn(file1.data, "size")).toBe(false);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("updateAt() removes optional node data key when value is null", () => {
|
|
636
|
+
const initialState: Primitive.TreeState<typeof FolderNode> = [
|
|
637
|
+
{ id: "root", type: "folder", parentId: null, pos: "a0", data: { name: "Root" } },
|
|
638
|
+
{ id: "file1", type: "file", parentId: "root", pos: "a0", data: { name: "File1", size: 100 } },
|
|
639
|
+
];
|
|
640
|
+
const { env, operations } = createEnvWithState(initialState);
|
|
641
|
+
const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
|
|
642
|
+
|
|
643
|
+
(proxy as any).updateAt("file1", FileNode, { size: null });
|
|
644
|
+
|
|
645
|
+
expect(operations).toHaveLength(1);
|
|
646
|
+
expect(operations[0]!.kind).toBe("struct.unset");
|
|
647
|
+
expect(operations[0]!.path.toTokens()).toEqual(["file1", "size"]);
|
|
648
|
+
|
|
649
|
+
const file1 = proxy.get().find((n) => n.id === "file1")!;
|
|
650
|
+
expect(file1.data).toEqual({ name: "File1" });
|
|
651
|
+
expect(hasOwn(file1.data, "size")).toBe(false);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("throws when required node data field is updated with undefined", () => {
|
|
655
|
+
const StrictFileNode = Primitive.TreeNode("strict-file", {
|
|
656
|
+
data: Primitive.Struct({
|
|
657
|
+
name: Primitive.String().required(),
|
|
658
|
+
note: Primitive.String(),
|
|
659
|
+
}),
|
|
660
|
+
children: [] as const,
|
|
661
|
+
});
|
|
662
|
+
const StrictFolderNode = Primitive.TreeNode("strict-folder", {
|
|
663
|
+
data: Primitive.Struct({ label: Primitive.String() }),
|
|
664
|
+
children: [StrictFileNode] as const,
|
|
665
|
+
});
|
|
666
|
+
const strictTree = Primitive.Tree({ root: StrictFolderNode });
|
|
667
|
+
const initialState: Primitive.TreeState<typeof StrictFolderNode> = [
|
|
668
|
+
{ id: "root", type: "strict-folder", parentId: null, pos: "a0", data: { label: "Root" } },
|
|
669
|
+
{ id: "file1", type: "strict-file", parentId: "root", pos: "a0", data: { name: "File1", note: "keep" } },
|
|
670
|
+
];
|
|
671
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
672
|
+
const env = ProxyEnvironment.make({
|
|
673
|
+
onOperation: (op) => {
|
|
674
|
+
operations.push(op);
|
|
675
|
+
},
|
|
676
|
+
getState: () => initialState,
|
|
677
|
+
});
|
|
678
|
+
const proxy = strictTree._internal.createProxy(env, OperationPath.make(""));
|
|
679
|
+
|
|
680
|
+
expect(() => proxy.node("file1")!.as(StrictFileNode).update({ name: undefined as never })).toThrow(
|
|
681
|
+
Primitive.ValidationError
|
|
682
|
+
);
|
|
683
|
+
expect(operations).toHaveLength(0);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("throws when required node data field is updated with null", () => {
|
|
687
|
+
const StrictFileNode = Primitive.TreeNode("strict-file", {
|
|
688
|
+
data: Primitive.Struct({
|
|
689
|
+
name: Primitive.String().required(),
|
|
690
|
+
note: Primitive.String(),
|
|
691
|
+
}),
|
|
692
|
+
children: [] as const,
|
|
693
|
+
});
|
|
694
|
+
const StrictFolderNode = Primitive.TreeNode("strict-folder", {
|
|
695
|
+
data: Primitive.Struct({ label: Primitive.String() }),
|
|
696
|
+
children: [StrictFileNode] as const,
|
|
697
|
+
});
|
|
698
|
+
const strictTree = Primitive.Tree({ root: StrictFolderNode });
|
|
699
|
+
const initialState: Primitive.TreeState<typeof StrictFolderNode> = [
|
|
700
|
+
{ id: "root", type: "strict-folder", parentId: null, pos: "a0", data: { label: "Root" } },
|
|
701
|
+
{ id: "file1", type: "strict-file", parentId: "root", pos: "a0", data: { name: "File1", note: "keep" } },
|
|
702
|
+
];
|
|
703
|
+
const operations: Operation.Operation<any, any, any>[] = [];
|
|
704
|
+
const env = ProxyEnvironment.make({
|
|
705
|
+
onOperation: (op) => {
|
|
706
|
+
operations.push(op);
|
|
707
|
+
},
|
|
708
|
+
getState: () => initialState,
|
|
709
|
+
});
|
|
710
|
+
const proxy = strictTree._internal.createProxy(env, OperationPath.make(""));
|
|
711
|
+
|
|
712
|
+
expect(() => (proxy as any).updateAt("file1", StrictFileNode, { name: null })).toThrow(
|
|
713
|
+
Primitive.ValidationError
|
|
714
|
+
);
|
|
715
|
+
expect(operations).toHaveLength(0);
|
|
716
|
+
});
|
|
612
717
|
});
|
|
613
718
|
|
|
614
719
|
describe("proxy - insert with defaults", () => {
|
|
@@ -922,6 +1027,44 @@ describe("TreePrimitive - nested input for set() and default()", () => {
|
|
|
922
1027
|
const fileNode = payload.find(n => n.type === "file");
|
|
923
1028
|
expect((fileNode!.data as any).size).toBe(0); // Default value
|
|
924
1029
|
});
|
|
1030
|
+
|
|
1031
|
+
it("prunes optional keys explicitly set to undefined in nested input", () => {
|
|
1032
|
+
const { env, operations } = createEnvWithState();
|
|
1033
|
+
const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
|
|
1034
|
+
|
|
1035
|
+
proxy.set({
|
|
1036
|
+
type: "folder",
|
|
1037
|
+
name: "Root",
|
|
1038
|
+
children: [
|
|
1039
|
+
{ type: "file", name: "file.txt", size: undefined, children: [] },
|
|
1040
|
+
],
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
const payload = operations[0]!.payload as Primitive.TreeState<typeof FolderNode>;
|
|
1044
|
+
const fileNode = payload.find(n => n.type === "file");
|
|
1045
|
+
expect(fileNode).toBeDefined();
|
|
1046
|
+
expect(fileNode!.data).toEqual({ name: "file.txt" });
|
|
1047
|
+
expect(hasOwn(fileNode!.data, "size")).toBe(false);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("prunes optional keys explicitly set to null in nested input", () => {
|
|
1051
|
+
const { env, operations } = createEnvWithState();
|
|
1052
|
+
const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
|
|
1053
|
+
|
|
1054
|
+
(proxy as any).set({
|
|
1055
|
+
type: "folder",
|
|
1056
|
+
name: "Root",
|
|
1057
|
+
children: [
|
|
1058
|
+
{ type: "file", name: "file.txt", size: null, children: [] },
|
|
1059
|
+
],
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
const payload = operations[0]!.payload as Primitive.TreeState<typeof FolderNode>;
|
|
1063
|
+
const fileNode = payload.find(n => n.type === "file");
|
|
1064
|
+
expect(fileNode).toBeDefined();
|
|
1065
|
+
expect(fileNode!.data).toEqual({ name: "file.txt" });
|
|
1066
|
+
expect(hasOwn(fileNode!.data, "size")).toBe(false);
|
|
1067
|
+
});
|
|
925
1068
|
});
|
|
926
1069
|
|
|
927
1070
|
describe("default() with nested input", () => {
|