@voidhash/mimic 0.0.1-alpha.6 → 0.0.1-alpha.7

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.
@@ -11,11 +11,18 @@ import * as Transform from "../Transform";
11
11
  /**
12
12
  * Base interface that all primitives must implement.
13
13
  * Provides type inference helpers and internal operations.
14
+ *
15
+ * @typeParam TState - The state type this primitive holds
16
+ * @typeParam TProxy - The proxy type for interacting with this primitive
17
+ * @typeParam TDefined - Whether the value is guaranteed to be defined (via required() or default())
18
+ * @typeParam THasDefault - Whether this primitive has a default value
14
19
  */
15
- export interface Primitive<TState, TProxy> {
20
+ export interface Primitive<TState, TProxy, TDefined extends boolean = false, THasDefault extends boolean = false> {
16
21
  readonly _tag: string;
17
22
  readonly _State: TState;
18
23
  readonly _Proxy: TProxy;
24
+ readonly _TDefined: TDefined;
25
+ readonly _THasDefault: THasDefault;
19
26
  readonly _internal: PrimitiveInternal<TState, TProxy>;
20
27
  }
21
28
 
@@ -46,17 +53,17 @@ export interface Primitive<TState, TProxy> {
46
53
  /**
47
54
  * Any primitive type - used for generic constraints.
48
55
  */
49
- export type AnyPrimitive = Primitive<any, any>;
56
+ export type AnyPrimitive = Primitive<any, any, any, any>;
50
57
 
51
58
  /**
52
59
  * Infer the state type from a primitive.
53
60
  */
54
- export type InferState<T> = T extends Primitive<infer S, any> ? S : never;
61
+ export type InferState<T> = T extends Primitive<infer S, any, any, any> ? S : never;
55
62
 
56
63
  /**
57
64
  * Infer the proxy type from a primitive.
58
65
  */
59
- export type InferProxy<T> = T extends Primitive<any, infer P> ? P : never;
66
+ export type InferProxy<T> = T extends Primitive<any, infer P, any, any> ? P : never;
60
67
 
61
68
  /**
62
69
  * Helper type to conditionally add undefined based on TDefined.
@@ -69,9 +76,48 @@ export interface Primitive<TState, TProxy> {
69
76
  * Infer the snapshot type from a primitive.
70
77
  * The snapshot is a readonly, type-safe structure suitable for rendering.
71
78
  */
72
- export type InferSnapshot<T> = T extends Primitive<any, infer P>
79
+ export type InferSnapshot<T> = T extends Primitive<any, infer P, any, any>
73
80
  ? P extends { toSnapshot(): infer S } ? S : never
74
81
  : never;
82
+
83
+ /**
84
+ * Extract THasDefault from a primitive.
85
+ */
86
+ export type HasDefault<T> = T extends Primitive<any, any, any, infer H> ? H : false;
87
+
88
+ /**
89
+ * Extract TDefined from a primitive.
90
+ */
91
+ export type IsDefined<T> = T extends Primitive<any, any, infer D, any> ? D : false;
92
+
93
+ /**
94
+ * Determines if a field is required for set() operations.
95
+ * A field is required if: TDefined is true AND THasDefault is false
96
+ */
97
+ export type IsRequiredForSet<T> = T extends Primitive<any, any, true, false> ? true : false;
98
+
99
+ /**
100
+ * Extract keys of fields that are required for set() (required without default).
101
+ */
102
+ export type RequiredSetKeys<TFields extends Record<string, AnyPrimitive>> = {
103
+ [K in keyof TFields]: IsRequiredForSet<TFields[K]> extends true ? K : never;
104
+ }[keyof TFields];
105
+
106
+ /**
107
+ * Extract keys of fields that are optional for set() (has default OR not required).
108
+ */
109
+ export type OptionalSetKeys<TFields extends Record<string, AnyPrimitive>> = {
110
+ [K in keyof TFields]: IsRequiredForSet<TFields[K]> extends true ? never : K;
111
+ }[keyof TFields];
112
+
113
+ /**
114
+ * Compute the input type for set() operations on a struct.
115
+ * Required fields (required without default) must be provided.
116
+ * Optional fields (has default or not required) can be omitted.
117
+ */
118
+ export type StructSetInput<TFields extends Record<string, AnyPrimitive>> =
119
+ { readonly [K in RequiredSetKeys<TFields>]: InferState<TFields[K]> } &
120
+ { readonly [K in OptionalSetKeys<TFields>]?: InferState<TFields[K]> };
75
121
 
76
122
  // =============================================================================
77
123
  // Validation Errors
@@ -120,3 +166,58 @@ export function isCompatibleOperation(operation: Operation.Operation<any, any, a
120
166
  return values.some(value => value.kind === operation.kind);
121
167
  }
122
168
 
169
+ // =============================================================================
170
+ // Default Value Utilities
171
+ // =============================================================================
172
+
173
+ /**
174
+ * Applies default values to a partial input, recursively handling nested structs.
175
+ *
176
+ * Uses a two-layer approach:
177
+ * 1. First, get the struct's initial state (which includes struct-level defaults)
178
+ * 2. Then, layer the provided values on top
179
+ * 3. Finally, ensure nested structs are recursively processed
180
+ *
181
+ * @param primitive - The primitive definition containing field information
182
+ * @param value - The partial value provided by the user
183
+ * @returns The value with defaults applied for missing fields
184
+ */
185
+ export function applyDefaults<T extends AnyPrimitive>(
186
+ primitive: T,
187
+ value: Partial<InferState<T>>
188
+ ): InferState<T> {
189
+ // Only structs need default merging
190
+ if (primitive._tag === "StructPrimitive") {
191
+ const structPrimitive = primitive as unknown as {
192
+ fields: Record<string, AnyPrimitive>;
193
+ _internal: { getInitialState: () => Record<string, unknown> | undefined };
194
+ };
195
+
196
+ // Start with the struct's initial state (struct-level default or field defaults)
197
+ const structInitialState = structPrimitive._internal.getInitialState() ?? {};
198
+
199
+ // Layer the provided values on top of initial state
200
+ const result: Record<string, unknown> = { ...structInitialState, ...value };
201
+
202
+ for (const key in structPrimitive.fields) {
203
+ const fieldPrimitive = structPrimitive.fields[key]!;
204
+
205
+ if (result[key] === undefined) {
206
+ // Field still not provided after merging - try individual field default
207
+ const fieldDefault = fieldPrimitive._internal.getInitialState();
208
+ if (fieldDefault !== undefined) {
209
+ result[key] = fieldDefault;
210
+ }
211
+ } else if (fieldPrimitive._tag === "StructPrimitive" && typeof result[key] === "object" && result[key] !== null) {
212
+ // Recursively apply defaults to nested structs
213
+ result[key] = applyDefaults(fieldPrimitive, result[key] as Partial<InferState<typeof fieldPrimitive>>);
214
+ }
215
+ }
216
+
217
+ return result as InferState<T>;
218
+ }
219
+
220
+ // For non-struct primitives, return the value as-is
221
+ return value as InferState<T>;
222
+ }
223
+
@@ -66,6 +66,46 @@ describe("StructPrimitive", () => {
66
66
  expect(operations[0]!.payload).toEqual({ name: "Alice", age: "30" });
67
67
  });
68
68
 
69
+ it("set() only requires fields that are required and without defaults", () => {
70
+ const operations: Operation.Operation<any, any, any>[] = [];
71
+ const env = ProxyEnvironment.make((op) => {
72
+ operations.push(op);
73
+ });
74
+
75
+ const structPrimitive = Primitive.Struct({
76
+ name: Primitive.String().required().default("John Doe"),
77
+ age: Primitive.Number().required(),
78
+ email: Primitive.String(),
79
+ });
80
+
81
+ const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
82
+ proxy.set({ age: 30 });
83
+
84
+ expect(operations).toHaveLength(1);
85
+ expect(operations[0]!.kind).toBe("struct.set");
86
+ expect(operations[0]!.payload).toEqual({ name: "John Doe", age: 30, email: undefined });
87
+ });
88
+
89
+ it("set() only requires fields that are required and without defaults", () => {
90
+ const operations: Operation.Operation<any, any, any>[] = [];
91
+ const env = ProxyEnvironment.make((op) => {
92
+ operations.push(op);
93
+ });
94
+
95
+ const structPrimitive = Primitive.Struct({
96
+ name: Primitive.String().required(),
97
+ age: Primitive.Number().required(),
98
+ email: Primitive.String(),
99
+ }).default({ name: "John Doe" });
100
+
101
+ const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
102
+ proxy.set({ age: 30 });
103
+
104
+ expect(operations).toHaveLength(1);
105
+ expect(operations[0]!.kind).toBe("struct.set");
106
+ expect(operations[0]!.payload).toEqual({ name: "John Doe", age: 30, email: undefined });
107
+ });
108
+
69
109
  it("multiple field sets generate separate operations", () => {
70
110
  const operations: Operation.Operation<any, any, any>[] = [];
71
111
  const env = ProxyEnvironment.make((op) => {
@@ -304,6 +344,216 @@ describe("StructPrimitive", () => {
304
344
  expect(typeof proxy.set).toBe("function");
305
345
  });
306
346
  });
347
+
348
+ describe("update", () => {
349
+ it("update() generates individual field operations", () => {
350
+ const operations: Operation.Operation<any, any, any>[] = [];
351
+ const env = ProxyEnvironment.make((op) => {
352
+ operations.push(op);
353
+ });
354
+
355
+ const structPrimitive = Primitive.Struct({
356
+ name: Primitive.String(),
357
+ email: Primitive.String(),
358
+ });
359
+
360
+ const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
361
+
362
+ proxy.update({ name: "John" });
363
+
364
+ expect(operations).toHaveLength(1);
365
+ expect(operations[0]!.kind).toBe("string.set");
366
+ expect(operations[0]!.payload).toBe("John");
367
+ expect(operations[0]!.path.toTokens()).toEqual(["name"]);
368
+ });
369
+
370
+ it("update() with multiple fields generates multiple operations", () => {
371
+ const operations: Operation.Operation<any, any, any>[] = [];
372
+ const env = ProxyEnvironment.make((op) => {
373
+ operations.push(op);
374
+ });
375
+
376
+ const structPrimitive = Primitive.Struct({
377
+ firstName: Primitive.String(),
378
+ lastName: Primitive.String(),
379
+ email: Primitive.String(),
380
+ });
381
+
382
+ const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
383
+
384
+ proxy.update({ firstName: "John", lastName: "Doe" });
385
+
386
+ expect(operations).toHaveLength(2);
387
+ expect(operations.map((op) => op.payload)).toContain("John");
388
+ expect(operations.map((op) => op.payload)).toContain("Doe");
389
+ });
390
+
391
+ it("update() skips undefined values", () => {
392
+ const operations: Operation.Operation<any, any, any>[] = [];
393
+ const env = ProxyEnvironment.make((op) => {
394
+ operations.push(op);
395
+ });
396
+
397
+ const structPrimitive = Primitive.Struct({
398
+ name: Primitive.String(),
399
+ email: Primitive.String(),
400
+ });
401
+
402
+ const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
403
+
404
+ proxy.update({ name: "John", email: undefined });
405
+
406
+ expect(operations).toHaveLength(1);
407
+ expect(operations[0]!.payload).toBe("John");
408
+ });
409
+
410
+ it("update() recursively updates nested structs", () => {
411
+ const operations: Operation.Operation<any, any, any>[] = [];
412
+ const env = ProxyEnvironment.make((op) => {
413
+ operations.push(op);
414
+ });
415
+
416
+ const addressPrimitive = Primitive.Struct({
417
+ street: Primitive.String(),
418
+ city: Primitive.String(),
419
+ zip: Primitive.String(),
420
+ });
421
+
422
+ const personPrimitive = Primitive.Struct({
423
+ name: Primitive.String(),
424
+ address: addressPrimitive,
425
+ });
426
+
427
+ const proxy = personPrimitive._internal.createProxy(env, OperationPath.make(""));
428
+
429
+ // Partial update of nested struct - only city should be updated
430
+ proxy.update({ address: { city: "New York" } });
431
+
432
+ expect(operations).toHaveLength(1);
433
+ expect(operations[0]!.kind).toBe("string.set");
434
+ expect(operations[0]!.payload).toBe("New York");
435
+ expect(operations[0]!.path.toTokens()).toEqual(["address", "city"]);
436
+ });
437
+
438
+ it("update() handles deeply nested structs", () => {
439
+ const operations: Operation.Operation<any, any, any>[] = [];
440
+ const env = ProxyEnvironment.make((op) => {
441
+ operations.push(op);
442
+ });
443
+
444
+ const coordsPrimitive = Primitive.Struct({
445
+ lat: Primitive.String(),
446
+ lng: Primitive.String(),
447
+ });
448
+
449
+ const locationPrimitive = Primitive.Struct({
450
+ name: Primitive.String(),
451
+ coords: coordsPrimitive,
452
+ });
453
+
454
+ const personPrimitive = Primitive.Struct({
455
+ name: Primitive.String(),
456
+ location: locationPrimitive,
457
+ });
458
+
459
+ const proxy = personPrimitive._internal.createProxy(env, OperationPath.make(""));
460
+
461
+ proxy.update({ location: { coords: { lat: "40.7128" } } });
462
+
463
+ expect(operations).toHaveLength(1);
464
+ expect(operations[0]!.kind).toBe("string.set");
465
+ expect(operations[0]!.payload).toBe("40.7128");
466
+ expect(operations[0]!.path.toTokens()).toEqual(["location", "coords", "lat"]);
467
+ });
468
+
469
+ it("update() can update both nested and top-level fields", () => {
470
+ const operations: Operation.Operation<any, any, any>[] = [];
471
+ const env = ProxyEnvironment.make((op) => {
472
+ operations.push(op);
473
+ });
474
+
475
+ const addressPrimitive = Primitive.Struct({
476
+ city: Primitive.String(),
477
+ zip: Primitive.String(),
478
+ });
479
+
480
+ const personPrimitive = Primitive.Struct({
481
+ name: Primitive.String(),
482
+ address: addressPrimitive,
483
+ });
484
+
485
+ const proxy = personPrimitive._internal.createProxy(env, OperationPath.make(""));
486
+
487
+ proxy.update({ name: "Jane", address: { city: "Boston" } });
488
+
489
+ expect(operations).toHaveLength(2);
490
+
491
+ const nameOp = operations.find((op) => op.path.toTokens().join("/") === "name");
492
+ const cityOp = operations.find((op) => op.path.toTokens().join("/") === "address/city");
493
+
494
+ expect(nameOp).toBeDefined();
495
+ expect(nameOp!.payload).toBe("Jane");
496
+
497
+ expect(cityOp).toBeDefined();
498
+ expect(cityOp!.payload).toBe("Boston");
499
+ });
500
+
501
+ it("update() with empty object generates no operations", () => {
502
+ const operations: Operation.Operation<any, any, any>[] = [];
503
+ const env = ProxyEnvironment.make((op) => {
504
+ operations.push(op);
505
+ });
506
+
507
+ const structPrimitive = Primitive.Struct({
508
+ name: Primitive.String(),
509
+ email: Primitive.String(),
510
+ });
511
+
512
+ const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
513
+
514
+ proxy.update({});
515
+
516
+ expect(operations).toHaveLength(0);
517
+ });
518
+
519
+ it("update() ignores unknown fields", () => {
520
+ const operations: Operation.Operation<any, any, any>[] = [];
521
+ const env = ProxyEnvironment.make((op) => {
522
+ operations.push(op);
523
+ });
524
+
525
+ const structPrimitive = Primitive.Struct({
526
+ name: Primitive.String(),
527
+ });
528
+
529
+ const proxy = structPrimitive._internal.createProxy(env, OperationPath.make(""));
530
+
531
+ // Cast to any to bypass type checking for testing unknown fields
532
+ (proxy as any).update({ name: "John", unknownField: "value" });
533
+
534
+ expect(operations).toHaveLength(1);
535
+ expect(operations[0]!.payload).toBe("John");
536
+ });
537
+
538
+ it("update() with nested path prefix", () => {
539
+ const operations: Operation.Operation<any, any, any>[] = [];
540
+ const env = ProxyEnvironment.make((op) => {
541
+ operations.push(op);
542
+ });
543
+
544
+ const structPrimitive = Primitive.Struct({
545
+ name: Primitive.String(),
546
+ email: Primitive.String(),
547
+ });
548
+
549
+ const proxy = structPrimitive._internal.createProxy(env, OperationPath.make("users/0"));
550
+
551
+ proxy.update({ name: "Updated" });
552
+
553
+ expect(operations).toHaveLength(1);
554
+ expect(operations[0]!.path.toTokens()).toEqual(["users", "0", "name"]);
555
+ });
556
+ });
307
557
  });
308
558
 
309
559
  // =============================================================================
@@ -460,6 +460,128 @@ describe("TreePrimitive", () => {
460
460
  expect(withDefault._internal.getInitialState()).toEqual(defaultState);
461
461
  });
462
462
  });
463
+
464
+ describe("proxy - partial update", () => {
465
+ it("update() on TypedNodeProxy updates only specified fields", () => {
466
+ const initialState: Primitive.TreeState<typeof FolderNode> = [
467
+ { id: "root", type: "folder", parentId: null, pos: "a0", data: { name: "Root" } },
468
+ { id: "file1", type: "file", parentId: "root", pos: "a0", data: { name: "File1", size: 100 } },
469
+ ];
470
+ const { env, operations } = createEnvWithState(initialState);
471
+ const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
472
+
473
+ // Use the update method via as()
474
+ const fileProxy = proxy.node("file1")!.as(FileNode);
475
+ fileProxy.update({ name: "UpdatedName" });
476
+
477
+ // Should generate only a string.set operation for the name field
478
+ expect(operations).toHaveLength(1);
479
+ expect(operations[0]!.kind).toBe("string.set");
480
+ expect(operations[0]!.path.toTokens()).toEqual(["file1", "name"]);
481
+ expect(operations[0]!.payload).toBe("UpdatedName");
482
+ });
483
+
484
+ it("update() preserves other fields when updating partial data", () => {
485
+ const initialState: Primitive.TreeState<typeof FolderNode> = [
486
+ { id: "root", type: "folder", parentId: null, pos: "a0", data: { name: "Root" } },
487
+ { id: "file1", type: "file", parentId: "root", pos: "a0", data: { name: "File1", size: 100 } },
488
+ ];
489
+ const { env, operations } = createEnvWithState(initialState);
490
+ const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
491
+
492
+ // Update only the size field
493
+ proxy.node("file1")!.as(FileNode).update({ size: 200 });
494
+
495
+ // Should generate only a number.set operation for the size field
496
+ expect(operations).toHaveLength(1);
497
+ expect(operations[0]!.kind).toBe("number.set");
498
+ expect(operations[0]!.path.toTokens()).toEqual(["file1", "size"]);
499
+ expect(operations[0]!.payload).toBe(200);
500
+
501
+ // The name should remain unchanged in the state
502
+ const updatedState = proxy.get();
503
+ const file1 = updatedState.find(n => n.id === "file1");
504
+ expect(file1!.data).toEqual({ name: "File1", size: 200 });
505
+ });
506
+
507
+ it("update() handles multiple fields at once", () => {
508
+ const initialState: Primitive.TreeState<typeof FolderNode> = [
509
+ { id: "root", type: "folder", parentId: null, pos: "a0", data: { name: "Root" } },
510
+ { id: "file1", type: "file", parentId: "root", pos: "a0", data: { name: "File1", size: 100 } },
511
+ ];
512
+ const { env, operations } = createEnvWithState(initialState);
513
+ const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
514
+
515
+ // Update both name and size
516
+ proxy.node("file1")!.as(FileNode).update({ name: "NewFile", size: 500 });
517
+
518
+ // Should generate two operations
519
+ expect(operations).toHaveLength(2);
520
+
521
+ // Verify both fields were updated
522
+ const updatedState = proxy.get();
523
+ const file1 = updatedState.find(n => n.id === "file1");
524
+ expect(file1!.data).toEqual({ name: "NewFile", size: 500 });
525
+ });
526
+
527
+ it("updateAt() provides convenient partial update by node id", () => {
528
+ const initialState: Primitive.TreeState<typeof FolderNode> = [
529
+ { id: "root", type: "folder", parentId: null, pos: "a0", data: { name: "Root" } },
530
+ { id: "file1", type: "file", parentId: "root", pos: "a0", data: { name: "File1", size: 100 } },
531
+ ];
532
+ const { env, operations } = createEnvWithState(initialState);
533
+ const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
534
+
535
+ // Use updateAt for convenience
536
+ proxy.updateAt("file1", FileNode, { name: "QuickUpdate" });
537
+
538
+ expect(operations).toHaveLength(1);
539
+ expect(operations[0]!.kind).toBe("string.set");
540
+ expect(operations[0]!.path.toTokens()).toEqual(["file1", "name"]);
541
+ expect(operations[0]!.payload).toBe("QuickUpdate");
542
+ });
543
+
544
+ it("updateAt() throws for wrong node type", () => {
545
+ const initialState: Primitive.TreeState<typeof FolderNode> = [
546
+ { id: "file1", type: "file", parentId: null, pos: "a0", data: { name: "File1", size: 100 } },
547
+ ];
548
+ const { env } = createEnvWithState(initialState);
549
+ const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
550
+
551
+ // Trying to update a file node as a folder should throw
552
+ expect(() => proxy.updateAt("file1", FolderNode, { name: "NewName" })).toThrow(
553
+ Primitive.ValidationError
554
+ );
555
+ });
556
+
557
+ it("updateAt() throws for non-existent node", () => {
558
+ const initialState: Primitive.TreeState<typeof FolderNode> = [
559
+ { id: "root", type: "folder", parentId: null, pos: "a0", data: { name: "Root" } },
560
+ ];
561
+ const { env } = createEnvWithState(initialState);
562
+ const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
563
+
564
+ expect(() => proxy.updateAt("nonexistent", FileNode, { name: "Name" })).toThrow(
565
+ Primitive.ValidationError
566
+ );
567
+ });
568
+
569
+ it("data.update() on at() proxy also works for partial updates", () => {
570
+ const initialState: Primitive.TreeState<typeof FolderNode> = [
571
+ { id: "root", type: "folder", parentId: null, pos: "a0", data: { name: "Root" } },
572
+ { id: "file1", type: "file", parentId: "root", pos: "a0", data: { name: "File1", size: 100 } },
573
+ ];
574
+ const { env, operations } = createEnvWithState(initialState);
575
+ const proxy = fileSystemTree._internal.createProxy(env, OperationPath.make(""));
576
+
577
+ // The at() method returns the data proxy which has update()
578
+ proxy.at("file1", FileNode).update({ size: 999 });
579
+
580
+ expect(operations).toHaveLength(1);
581
+ expect(operations[0]!.kind).toBe("number.set");
582
+ expect(operations[0]!.path.toTokens()).toEqual(["file1", "size"]);
583
+ });
584
+ });
463
585
  });
464
586
 
465
587
  // =============================================================================