@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.
@@ -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 (typeof result[key] === "object" && result[key] !== null) {
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, result[key] as Partial<InferState<typeof 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, email: undefined });
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, email: undefined });
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() skips undefined values", () => {
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]!.payload).toBe("John");
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", () => {