@loro-extended/change 1.0.0 → 1.1.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loro-extended/change",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative data mutations",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -737,6 +737,257 @@ describe("TypedLoroDoc", () => {
737
737
  expect(result.profile.email).toBe("john@example.com")
738
738
  expect(result.profile.age).toBeNull()
739
739
  })
740
+
741
+ describe("Nullable Builder", () => {
742
+ describe("Basic nullable types", () => {
743
+ it("should handle nullable string with null placeholder", () => {
744
+ const schema = Shape.doc({
745
+ profile: Shape.struct({
746
+ email: Shape.plain.string().nullable(),
747
+ }),
748
+ })
749
+
750
+ const typedDoc = createTypedDoc(schema)
751
+
752
+ // Should start with null placeholder
753
+ expect(typedDoc.toJSON().profile.email).toBeNull()
754
+
755
+ // Should accept string value
756
+ const result = change(typedDoc, draft => {
757
+ draft.profile.set("email", "test@example.com")
758
+ }).toJSON()
759
+
760
+ expect(result.profile.email).toBe("test@example.com")
761
+
762
+ // Should accept null value
763
+ const result2 = change(typedDoc, draft => {
764
+ draft.profile.set("email", null)
765
+ }).toJSON()
766
+
767
+ expect(result2.profile.email).toBeNull()
768
+ })
769
+
770
+ it("should handle nullable number with null placeholder", () => {
771
+ const schema = Shape.doc({
772
+ stats: Shape.struct({
773
+ age: Shape.plain.number().nullable(),
774
+ }),
775
+ })
776
+
777
+ const typedDoc = createTypedDoc(schema)
778
+
779
+ expect(typedDoc.toJSON().stats.age).toBeNull()
780
+
781
+ const result = change(typedDoc, draft => {
782
+ draft.stats.set("age", 25)
783
+ }).toJSON()
784
+
785
+ expect(result.stats.age).toBe(25)
786
+ })
787
+
788
+ it("should handle nullable boolean with null placeholder", () => {
789
+ const schema = Shape.doc({
790
+ settings: Shape.struct({
791
+ enabled: Shape.plain.boolean().nullable(),
792
+ }),
793
+ })
794
+
795
+ const typedDoc = createTypedDoc(schema)
796
+
797
+ expect(typedDoc.toJSON().settings.enabled).toBeNull()
798
+
799
+ const result = change(typedDoc, draft => {
800
+ draft.settings.set("enabled", true)
801
+ }).toJSON()
802
+
803
+ expect(result.settings.enabled).toBe(true)
804
+ })
805
+
806
+ it("should handle nullable record with null placeholder", () => {
807
+ const schema = Shape.doc({
808
+ data: Shape.struct({
809
+ candidates: Shape.plain.record(Shape.plain.string()).nullable(),
810
+ }),
811
+ })
812
+
813
+ const typedDoc = createTypedDoc(schema)
814
+
815
+ expect(typedDoc.toJSON().data.candidates).toBeNull()
816
+
817
+ const result = change(typedDoc, draft => {
818
+ draft.data.set("candidates", { a: "Alice", b: "Bob" })
819
+ }).toJSON()
820
+
821
+ expect(result.data.candidates).toEqual({ a: "Alice", b: "Bob" })
822
+
823
+ // Should accept null value
824
+ const result2 = change(typedDoc, draft => {
825
+ draft.data.set("candidates", null)
826
+ }).toJSON()
827
+
828
+ expect(result2.data.candidates).toBeNull()
829
+ })
830
+
831
+ it("should handle nullable array with null placeholder", () => {
832
+ const schema = Shape.doc({
833
+ data: Shape.struct({
834
+ tags: Shape.plain.array(Shape.plain.string()).nullable(),
835
+ }),
836
+ })
837
+
838
+ const typedDoc = createTypedDoc(schema)
839
+
840
+ expect(typedDoc.toJSON().data.tags).toBeNull()
841
+
842
+ const result = change(typedDoc, draft => {
843
+ draft.data.set("tags", ["a", "b", "c"])
844
+ }).toJSON()
845
+
846
+ expect(result.data.tags).toEqual(["a", "b", "c"])
847
+
848
+ // Should accept null value
849
+ const result2 = change(typedDoc, draft => {
850
+ draft.data.set("tags", null)
851
+ }).toJSON()
852
+
853
+ expect(result2.data.tags).toBeNull()
854
+ })
855
+
856
+ it("should handle nullable struct with null placeholder", () => {
857
+ const schema = Shape.doc({
858
+ data: Shape.struct({
859
+ point: Shape.plain
860
+ .struct({
861
+ x: Shape.plain.number(),
862
+ y: Shape.plain.number(),
863
+ })
864
+ .nullable(),
865
+ }),
866
+ })
867
+
868
+ const typedDoc = createTypedDoc(schema)
869
+
870
+ expect(typedDoc.toJSON().data.point).toBeNull()
871
+
872
+ const result = change(typedDoc, draft => {
873
+ draft.data.set("point", { x: 10, y: 20 })
874
+ }).toJSON()
875
+
876
+ expect(result.data.point).toEqual({ x: 10, y: 20 })
877
+
878
+ // Should accept null value
879
+ const result2 = change(typedDoc, draft => {
880
+ draft.data.set("point", null)
881
+ }).toJSON()
882
+
883
+ expect(result2.data.point).toBeNull()
884
+ })
885
+ })
886
+
887
+ describe("Nullable with custom placeholder", () => {
888
+ it("should allow custom placeholder after nullable()", () => {
889
+ const schema = Shape.doc({
890
+ profile: Shape.struct({
891
+ name: Shape.plain.string().nullable().placeholder("Anonymous"),
892
+ }),
893
+ })
894
+
895
+ const typedDoc = createTypedDoc(schema)
896
+
897
+ // Should use custom placeholder, not null
898
+ expect(typedDoc.toJSON().profile.name).toBe("Anonymous")
899
+
900
+ // Should still accept null
901
+ const result = change(typedDoc, draft => {
902
+ draft.profile.set("name", null)
903
+ }).toJSON()
904
+
905
+ expect(result.profile.name).toBeNull()
906
+ })
907
+
908
+ it("should allow number placeholder after nullable()", () => {
909
+ const schema = Shape.doc({
910
+ stats: Shape.struct({
911
+ score: Shape.plain.number().nullable().placeholder(0),
912
+ }),
913
+ })
914
+
915
+ const typedDoc = createTypedDoc(schema)
916
+
917
+ expect(typedDoc.toJSON().stats.score).toBe(0)
918
+ })
919
+ })
920
+
921
+ describe("Type inference", () => {
922
+ it("should infer correct types for nullable fields", () => {
923
+ const schema = Shape.doc({
924
+ data: Shape.struct({
925
+ nullableString: Shape.plain.string().nullable(),
926
+ nullableNumber: Shape.plain.number().nullable(),
927
+ nullableBoolean: Shape.plain.boolean().nullable(),
928
+ }),
929
+ })
930
+
931
+ const typedDoc = createTypedDoc(schema)
932
+
933
+ // TypeScript should allow these assignments
934
+ change(typedDoc, draft => {
935
+ draft.data.set("nullableString", "hello")
936
+ draft.data.set("nullableString", null)
937
+ draft.data.set("nullableNumber", 42)
938
+ draft.data.set("nullableNumber", null)
939
+ draft.data.set("nullableBoolean", true)
940
+ draft.data.set("nullableBoolean", null)
941
+ })
942
+
943
+ // Verify the types work correctly
944
+ const json = typedDoc.toJSON()
945
+ const str: string | null = json.data.nullableString
946
+ const num: number | null = json.data.nullableNumber
947
+ const bool: boolean | null = json.data.nullableBoolean
948
+
949
+ expect(str).toBeNull()
950
+ expect(num).toBeNull()
951
+ expect(bool).toBeNull()
952
+ })
953
+ })
954
+
955
+ describe("Equivalence to union pattern", () => {
956
+ it("should behave identically to manual union pattern", () => {
957
+ // Using nullable()
958
+ const schema1 = Shape.doc({
959
+ profile: Shape.struct({
960
+ email: Shape.plain.string().nullable(),
961
+ }),
962
+ })
963
+
964
+ // Using manual union
965
+ const schema2 = Shape.doc({
966
+ profile: Shape.struct({
967
+ email: Shape.plain
968
+ .union([Shape.plain.null(), Shape.plain.string()])
969
+ .placeholder(null),
970
+ }),
971
+ })
972
+
973
+ const doc1 = createTypedDoc(schema1)
974
+ const doc2 = createTypedDoc(schema2)
975
+
976
+ // Both should have same initial state
977
+ expect(doc1.toJSON()).toEqual(doc2.toJSON())
978
+
979
+ // Both should accept same operations
980
+ change(doc1, draft => {
981
+ draft.profile.set("email", "test@example.com")
982
+ })
983
+ change(doc2, draft => {
984
+ draft.profile.set("email", "test@example.com")
985
+ })
986
+
987
+ expect(doc1.toJSON()).toEqual(doc2.toJSON())
988
+ })
989
+ })
990
+ })
740
991
  })
741
992
 
742
993
  describe("Raw vs Overlaid Values", () => {
package/src/index.ts CHANGED
@@ -35,6 +35,8 @@ export type {
35
35
  UnionValueShape,
36
36
  // Value shapes
37
37
  ValueShape,
38
+ // WithNullable type for shapes that support .nullable()
39
+ WithNullable,
38
40
  // WithPlaceholder type for shapes that support .placeholder()
39
41
  WithPlaceholder,
40
42
  } from "./shape.js"
package/src/shape.ts CHANGED
@@ -28,6 +28,14 @@ export type WithPlaceholder<S extends Shape<any, any, any>> = S & {
28
28
  placeholder(value: S["_placeholder"]): S
29
29
  }
30
30
 
31
+ /**
32
+ * Type for value shapes that support the .nullable() method.
33
+ * Returns a union of null and the original shape with null as the default placeholder.
34
+ */
35
+ export type WithNullable<S extends ValueShape> = {
36
+ nullable(): WithPlaceholder<UnionValueShape<[NullValueShape, S]>>
37
+ }
38
+
31
39
  export interface DocShape<
32
40
  NestedShapes extends Record<string, ContainerShape> = Record<
33
41
  string,
@@ -258,6 +266,41 @@ export type ValueShape =
258
266
 
259
267
  export type ContainerOrValueShape = ContainerShape | ValueShape
260
268
 
269
+ /**
270
+ * Creates a nullable version of a value shape.
271
+ * @internal
272
+ */
273
+ function makeNullable<S extends ValueShape>(
274
+ shape: S,
275
+ ): WithPlaceholder<UnionValueShape<[NullValueShape, S]>> {
276
+ const nullShape: NullValueShape = {
277
+ _type: "value" as const,
278
+ valueType: "null" as const,
279
+ _plain: null,
280
+ _mutable: null,
281
+ _placeholder: null,
282
+ }
283
+
284
+ const base: UnionValueShape<[NullValueShape, S]> = {
285
+ _type: "value" as const,
286
+ valueType: "union" as const,
287
+ shapes: [nullShape, shape] as [NullValueShape, S],
288
+ _plain: null as any,
289
+ _mutable: null as any,
290
+ _placeholder: null as any, // Default placeholder is null
291
+ }
292
+
293
+ return Object.assign(base, {
294
+ placeholder(
295
+ value: S["_placeholder"] | null,
296
+ ): UnionValueShape<[NullValueShape, S]> {
297
+ return { ...base, _placeholder: value } as UnionValueShape<
298
+ [NullValueShape, S]
299
+ >
300
+ },
301
+ })
302
+ }
303
+
261
304
  /**
262
305
  * The LoroShape factory object
263
306
  *
@@ -386,7 +429,8 @@ export const Shape = {
386
429
  plain: {
387
430
  string: <T extends string = string>(
388
431
  ...options: T[]
389
- ): WithPlaceholder<StringValueShape<T>> => {
432
+ ): WithPlaceholder<StringValueShape<T>> &
433
+ WithNullable<StringValueShape<T>> => {
390
434
  const base: StringValueShape<T> = {
391
435
  _type: "value" as const,
392
436
  valueType: "string" as const,
@@ -399,10 +443,16 @@ export const Shape = {
399
443
  placeholder(value: T): StringValueShape<T> {
400
444
  return { ...base, _placeholder: value }
401
445
  },
446
+ nullable(): WithPlaceholder<
447
+ UnionValueShape<[NullValueShape, StringValueShape<T>]>
448
+ > {
449
+ return makeNullable(base)
450
+ },
402
451
  })
403
452
  },
404
453
 
405
- number: (): WithPlaceholder<NumberValueShape> => {
454
+ number: (): WithPlaceholder<NumberValueShape> &
455
+ WithNullable<NumberValueShape> => {
406
456
  const base: NumberValueShape = {
407
457
  _type: "value" as const,
408
458
  valueType: "number" as const,
@@ -414,10 +464,16 @@ export const Shape = {
414
464
  placeholder(value: number): NumberValueShape {
415
465
  return { ...base, _placeholder: value }
416
466
  },
467
+ nullable(): WithPlaceholder<
468
+ UnionValueShape<[NullValueShape, NumberValueShape]>
469
+ > {
470
+ return makeNullable(base)
471
+ },
417
472
  })
418
473
  },
419
474
 
420
- boolean: (): WithPlaceholder<BooleanValueShape> => {
475
+ boolean: (): WithPlaceholder<BooleanValueShape> &
476
+ WithNullable<BooleanValueShape> => {
421
477
  const base: BooleanValueShape = {
422
478
  _type: "value" as const,
423
479
  valueType: "boolean" as const,
@@ -429,6 +485,11 @@ export const Shape = {
429
485
  placeholder(value: boolean): BooleanValueShape {
430
486
  return { ...base, _placeholder: value }
431
487
  },
488
+ nullable(): WithPlaceholder<
489
+ UnionValueShape<[NullValueShape, BooleanValueShape]>
490
+ > {
491
+ return makeNullable(base)
492
+ },
432
493
  })
433
494
  },
434
495
 
@@ -470,46 +531,86 @@ export const Shape = {
470
531
  */
471
532
  struct: <T extends Record<string, ValueShape>>(
472
533
  shape: T,
473
- ): StructValueShape<T> => ({
474
- _type: "value" as const,
475
- valueType: "struct" as const,
476
- shape,
477
- _plain: {} as any,
478
- _mutable: {} as any,
479
- _placeholder: {} as any,
480
- }),
534
+ ): StructValueShape<T> & WithNullable<StructValueShape<T>> => {
535
+ const base: StructValueShape<T> = {
536
+ _type: "value" as const,
537
+ valueType: "struct" as const,
538
+ shape,
539
+ _plain: {} as any,
540
+ _mutable: {} as any,
541
+ _placeholder: {} as any,
542
+ }
543
+ return Object.assign(base, {
544
+ nullable(): WithPlaceholder<
545
+ UnionValueShape<[NullValueShape, StructValueShape<T>]>
546
+ > {
547
+ return makeNullable(base)
548
+ },
549
+ })
550
+ },
481
551
 
482
552
  /**
483
553
  * @deprecated Use `Shape.plain.struct` instead. `Shape.plain.struct` will be removed in a future version.
484
554
  */
485
555
  object: <T extends Record<string, ValueShape>>(
486
556
  shape: T,
487
- ): StructValueShape<T> => ({
488
- _type: "value" as const,
489
- valueType: "struct" as const,
490
- shape,
491
- _plain: {} as any,
492
- _mutable: {} as any,
493
- _placeholder: {} as any,
494
- }),
557
+ ): StructValueShape<T> & WithNullable<StructValueShape<T>> => {
558
+ const base: StructValueShape<T> = {
559
+ _type: "value" as const,
560
+ valueType: "struct" as const,
561
+ shape,
562
+ _plain: {} as any,
563
+ _mutable: {} as any,
564
+ _placeholder: {} as any,
565
+ }
566
+ return Object.assign(base, {
567
+ nullable(): WithPlaceholder<
568
+ UnionValueShape<[NullValueShape, StructValueShape<T>]>
569
+ > {
570
+ return makeNullable(base)
571
+ },
572
+ })
573
+ },
495
574
 
496
- record: <T extends ValueShape>(shape: T): RecordValueShape<T> => ({
497
- _type: "value" as const,
498
- valueType: "record" as const,
499
- shape,
500
- _plain: {} as any,
501
- _mutable: {} as any,
502
- _placeholder: {} as Record<string, never>,
503
- }),
575
+ record: <T extends ValueShape>(
576
+ shape: T,
577
+ ): RecordValueShape<T> & WithNullable<RecordValueShape<T>> => {
578
+ const base: RecordValueShape<T> = {
579
+ _type: "value" as const,
580
+ valueType: "record" as const,
581
+ shape,
582
+ _plain: {} as any,
583
+ _mutable: {} as any,
584
+ _placeholder: {} as Record<string, never>,
585
+ }
586
+ return Object.assign(base, {
587
+ nullable(): WithPlaceholder<
588
+ UnionValueShape<[NullValueShape, RecordValueShape<T>]>
589
+ > {
590
+ return makeNullable(base)
591
+ },
592
+ })
593
+ },
504
594
 
505
- array: <T extends ValueShape>(shape: T): ArrayValueShape<T> => ({
506
- _type: "value" as const,
507
- valueType: "array" as const,
508
- shape,
509
- _plain: [] as any,
510
- _mutable: [] as any,
511
- _placeholder: [] as never[],
512
- }),
595
+ array: <T extends ValueShape>(
596
+ shape: T,
597
+ ): ArrayValueShape<T> & WithNullable<ArrayValueShape<T>> => {
598
+ const base: ArrayValueShape<T> = {
599
+ _type: "value" as const,
600
+ valueType: "array" as const,
601
+ shape,
602
+ _plain: [] as any,
603
+ _mutable: [] as any,
604
+ _placeholder: [] as never[],
605
+ }
606
+ return Object.assign(base, {
607
+ nullable(): WithPlaceholder<
608
+ UnionValueShape<[NullValueShape, ArrayValueShape<T>]>
609
+ > {
610
+ return makeNullable(base)
611
+ },
612
+ })
613
+ },
513
614
 
514
615
  // Special value type that helps make things like `string | null` representable
515
616
  // TODO(duane): should this be a more general type for containers too?