jazz-tools 0.13.16 → 0.13.18

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.
Files changed (83) hide show
  1. package/.turbo/turbo-build.log +7 -7
  2. package/CHANGELOG.md +19 -0
  3. package/dist/{chunk-GIZWJXSR.js → chunk-ITSHLDQB.js} +731 -600
  4. package/dist/chunk-ITSHLDQB.js.map +1 -0
  5. package/dist/coValues/account.d.ts +5 -4
  6. package/dist/coValues/account.d.ts.map +1 -1
  7. package/dist/coValues/coFeed.d.ts +3 -3
  8. package/dist/coValues/coFeed.d.ts.map +1 -1
  9. package/dist/coValues/coList.d.ts +1 -0
  10. package/dist/coValues/coList.d.ts.map +1 -1
  11. package/dist/coValues/coMap.d.ts +4 -2
  12. package/dist/coValues/coMap.d.ts.map +1 -1
  13. package/dist/coValues/coPlainText.d.ts +2 -2
  14. package/dist/coValues/coPlainText.d.ts.map +1 -1
  15. package/dist/coValues/deepLoading.d.ts +1 -9
  16. package/dist/coValues/deepLoading.d.ts.map +1 -1
  17. package/dist/coValues/extensions/imageDef.d.ts.map +1 -1
  18. package/dist/coValues/group.d.ts.map +1 -1
  19. package/dist/coValues/inbox.d.ts.map +1 -1
  20. package/dist/coValues/interfaces.d.ts +4 -1
  21. package/dist/coValues/interfaces.d.ts.map +1 -1
  22. package/dist/implementation/createContext.d.ts.map +1 -1
  23. package/dist/implementation/refs.d.ts +5 -10
  24. package/dist/implementation/refs.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/internal.d.ts +1 -1
  27. package/dist/internal.d.ts.map +1 -1
  28. package/dist/subscribe/CoValueCoreSubscription.d.ts +14 -0
  29. package/dist/subscribe/CoValueCoreSubscription.d.ts.map +1 -0
  30. package/dist/subscribe/JazzError.d.ts +16 -0
  31. package/dist/subscribe/JazzError.d.ts.map +1 -0
  32. package/dist/subscribe/SubscriptionScope.d.ts +42 -0
  33. package/dist/subscribe/SubscriptionScope.d.ts.map +1 -0
  34. package/dist/subscribe/index.d.ts +21 -0
  35. package/dist/subscribe/index.d.ts.map +1 -0
  36. package/dist/subscribe/types.d.ts +12 -0
  37. package/dist/subscribe/types.d.ts.map +1 -0
  38. package/dist/subscribe/utils.d.ts +10 -0
  39. package/dist/subscribe/utils.d.ts.map +1 -0
  40. package/dist/testing.js +2 -2
  41. package/dist/testing.js.map +1 -1
  42. package/dist/tests/coMap.record.test.d.ts +2 -0
  43. package/dist/tests/coMap.record.test.d.ts.map +1 -0
  44. package/dist/tests/utils.d.ts +2 -2
  45. package/dist/tests/utils.d.ts.map +1 -1
  46. package/package.json +2 -2
  47. package/src/coValues/account.ts +43 -31
  48. package/src/coValues/coFeed.ts +28 -13
  49. package/src/coValues/coList.ts +13 -17
  50. package/src/coValues/coMap.ts +72 -80
  51. package/src/coValues/coPlainText.ts +13 -2
  52. package/src/coValues/deepLoading.ts +4 -277
  53. package/src/coValues/extensions/imageDef.ts +1 -7
  54. package/src/coValues/group.ts +7 -6
  55. package/src/coValues/inbox.ts +4 -11
  56. package/src/coValues/interfaces.ts +54 -111
  57. package/src/implementation/createContext.ts +3 -4
  58. package/src/implementation/invites.ts +2 -2
  59. package/src/implementation/refs.ts +30 -121
  60. package/src/internal.ts +1 -2
  61. package/src/subscribe/CoValueCoreSubscription.ts +71 -0
  62. package/src/subscribe/JazzError.ts +48 -0
  63. package/src/subscribe/SubscriptionScope.ts +523 -0
  64. package/src/subscribe/index.ts +82 -0
  65. package/src/subscribe/types.ts +7 -0
  66. package/src/subscribe/utils.ts +36 -0
  67. package/src/testing.ts +1 -1
  68. package/src/tests/ContextManager.test.ts +13 -9
  69. package/src/tests/coFeed.test.ts +104 -4
  70. package/src/tests/coList.test.ts +304 -115
  71. package/src/tests/coMap.record.test.ts +325 -0
  72. package/src/tests/coMap.test.ts +718 -645
  73. package/src/tests/coPlainText.test.ts +2 -2
  74. package/src/tests/createContext.test.ts +8 -8
  75. package/src/tests/deepLoading.test.ts +8 -34
  76. package/src/tests/groupsAndAccounts.test.ts +6 -4
  77. package/src/tests/subscribe.test.ts +357 -42
  78. package/src/tests/utils.ts +8 -6
  79. package/tsconfig.json +2 -1
  80. package/dist/chunk-GIZWJXSR.js.map +0 -1
  81. package/dist/implementation/subscriptionScope.d.ts +0 -34
  82. package/dist/implementation/subscriptionScope.d.ts.map +0 -1
  83. package/src/implementation/subscriptionScope.ts +0 -165
@@ -23,7 +23,11 @@ import {
23
23
  createCoValueObservable,
24
24
  subscribeToCoValue,
25
25
  } from "../internal.js";
26
- import { createJazzTestAccount, setupJazzTestSync } from "../testing.js";
26
+ import {
27
+ createJazzTestAccount,
28
+ getPeerConnectedToTestSyncServer,
29
+ setupJazzTestSync,
30
+ } from "../testing.js";
27
31
  import { setupAccount, waitFor } from "./utils.js";
28
32
 
29
33
  class ChatRoom extends CoMap {
@@ -70,11 +74,16 @@ describe("subscribeToCoValue", () => {
70
74
  const chatRoom = createChatRoom(me, "General");
71
75
  const updateFn = vi.fn();
72
76
 
77
+ let result = null as Resolved<ChatRoom, {}> | null;
78
+
73
79
  const unsubscribe = subscribeToCoValue(
74
80
  ChatRoom,
75
81
  chatRoom.id,
76
82
  { loadAs: meOnSecondPeer },
77
- updateFn,
83
+ (value) => {
84
+ result = value;
85
+ updateFn();
86
+ },
78
87
  );
79
88
 
80
89
  onTestFinished(unsubscribe);
@@ -83,14 +92,10 @@ describe("subscribeToCoValue", () => {
83
92
  expect(updateFn).toHaveBeenCalled();
84
93
  });
85
94
 
86
- expect(updateFn).toHaveBeenCalledWith(
87
- expect.objectContaining({
88
- id: chatRoom.id,
89
- messages: null,
90
- name: "General",
91
- }),
92
- expect.any(Function),
93
- );
95
+ expect(result).not.toBeNull();
96
+ expect(result?.id).toBe(chatRoom.id);
97
+ expect(result?.messages).toEqual(null);
98
+ expect(result?.name).toBe("General");
94
99
 
95
100
  updateFn.mockClear();
96
101
 
@@ -98,14 +103,7 @@ describe("subscribeToCoValue", () => {
98
103
  expect(updateFn).toHaveBeenCalled();
99
104
  });
100
105
 
101
- expect(updateFn).toHaveBeenCalledWith(
102
- expect.objectContaining({
103
- id: chatRoom.id,
104
- name: "General",
105
- messages: expect.any(Array),
106
- }),
107
- expect.any(Function),
108
- );
106
+ expect(result?.messages).toEqual([]);
109
107
 
110
108
  updateFn.mockClear();
111
109
  chatRoom.name = "Lounge";
@@ -114,14 +112,7 @@ describe("subscribeToCoValue", () => {
114
112
  expect(updateFn).toHaveBeenCalled();
115
113
  });
116
114
 
117
- expect(updateFn).toHaveBeenCalledWith(
118
- expect.objectContaining({
119
- id: chatRoom.id,
120
- name: "Lounge",
121
- messages: expect.any(Array),
122
- }),
123
- expect.any(Function),
124
- );
115
+ expect(result?.name).toBe("Lounge");
125
116
  });
126
117
 
127
118
  it("shouldn't fire updates until the declared load depth isn't reached", async () => {
@@ -130,6 +121,8 @@ describe("subscribeToCoValue", () => {
130
121
  const chatRoom = createChatRoom(me, "General");
131
122
  const updateFn = vi.fn();
132
123
 
124
+ let result = null as Resolved<ChatRoom, {}> | null;
125
+
133
126
  const unsubscribe = subscribeToCoValue(
134
127
  ChatRoom,
135
128
  chatRoom.id,
@@ -139,7 +132,10 @@ describe("subscribeToCoValue", () => {
139
132
  messages: true,
140
133
  },
141
134
  },
142
- updateFn,
135
+ (value) => {
136
+ result = value;
137
+ updateFn();
138
+ },
143
139
  );
144
140
 
145
141
  onTestFinished(unsubscribe);
@@ -149,14 +145,11 @@ describe("subscribeToCoValue", () => {
149
145
  });
150
146
 
151
147
  expect(updateFn).toHaveBeenCalledTimes(1);
152
- expect(updateFn).toHaveBeenCalledWith(
153
- expect.objectContaining({
154
- id: chatRoom.id,
155
- name: "General",
156
- messages: expect.any(Array),
157
- }),
158
- expect.any(Function),
159
- );
148
+ expect(result).toMatchObject({
149
+ id: chatRoom.id,
150
+ name: "General",
151
+ messages: [],
152
+ });
160
153
  });
161
154
 
162
155
  it("shouldn't fire updates after unsubscribing", async () => {
@@ -263,6 +256,17 @@ describe("subscribeToCoValue", () => {
263
256
 
264
257
  const updateFn = vi.fn();
265
258
 
259
+ const updates = [] as Resolved<
260
+ ChatRoom,
261
+ {
262
+ messages: {
263
+ $each: {
264
+ reactions: true;
265
+ };
266
+ };
267
+ }
268
+ >[];
269
+
266
270
  const unsubscribe = subscribeToCoValue(
267
271
  ChatRoom,
268
272
  chatRoom.id,
@@ -276,22 +280,25 @@ describe("subscribeToCoValue", () => {
276
280
  },
277
281
  },
278
282
  },
279
- updateFn,
283
+ (value) => {
284
+ updates.push(value);
285
+ updateFn();
286
+ },
280
287
  );
281
288
 
282
289
  onTestFinished(unsubscribe);
283
290
 
284
291
  await waitFor(() => {
285
- const lastValue = updateFn.mock.lastCall?.[0];
292
+ const lastValue = updates.at(-1);
286
293
 
287
294
  expect(lastValue?.messages?.[0]?.text).toBe(message.text);
288
295
  });
289
296
 
290
- const initialValue = updateFn.mock.lastCall?.[0];
297
+ const initialValue = updates.at(0);
291
298
  const initialMessagesList = initialValue?.messages;
292
299
  const initialMessage1 = initialValue?.messages[0];
293
300
  const initialMessage2 = initialValue?.messages[1];
294
- const initialMessageReactions = initialValue?.messages[0].reactions;
301
+ const initialMessageReactions = initialValue?.messages[0]?.reactions;
295
302
 
296
303
  message.reactions?.push("👍");
297
304
 
@@ -301,11 +308,11 @@ describe("subscribeToCoValue", () => {
301
308
  expect(updateFn).toHaveBeenCalled();
302
309
  });
303
310
 
304
- const lastValue = updateFn.mock.lastCall?.[0];
311
+ const lastValue = updates.at(-1)!;
305
312
  expect(lastValue).not.toBe(initialValue);
306
313
  expect(lastValue.messages).not.toBe(initialMessagesList);
307
314
  expect(lastValue.messages[0]).not.toBe(initialMessage1);
308
- expect(lastValue.messages[0].reactions).not.toBe(initialMessageReactions);
315
+ expect(lastValue.messages[0]?.reactions).not.toBe(initialMessageReactions);
309
316
 
310
317
  // This shouldn't change
311
318
  expect(lastValue.messages[1]).toBe(initialMessage2);
@@ -418,7 +425,7 @@ describe("subscribeToCoValue", () => {
418
425
  expect(updateFn).toHaveBeenCalledTimes(1);
419
426
  });
420
427
 
421
- it("should emit when all the items become available", async () => {
428
+ it("should emit when all the items become accessible", async () => {
422
429
  class TestMap extends CoMap {
423
430
  value = co.string;
424
431
  }
@@ -488,6 +495,82 @@ describe("subscribeToCoValue", () => {
488
495
  expect(updateFn).toHaveBeenCalledTimes(1);
489
496
  });
490
497
 
498
+ it("should emit when all the items become available", async () => {
499
+ class TestMap extends CoMap {
500
+ value = co.string;
501
+ }
502
+
503
+ class TestList extends CoList.Of(co.ref(TestMap)) {}
504
+
505
+ const reader = await createJazzTestAccount({
506
+ isCurrentActiveAccount: true,
507
+ });
508
+
509
+ const creator = await createJazzTestAccount({
510
+ isCurrentActiveAccount: true,
511
+ });
512
+
513
+ // Disconnect the creator from the sync server
514
+ creator._raw.core.node.syncManager.getPeers().forEach((peer) => {
515
+ peer.gracefulShutdown();
516
+ });
517
+
518
+ const everyone = Group.create(creator);
519
+ everyone.addMember("everyone", "reader");
520
+
521
+ const list = TestList.create(
522
+ [
523
+ TestMap.create({ value: "1" }, everyone),
524
+ TestMap.create({ value: "2" }, everyone),
525
+ ],
526
+ everyone,
527
+ );
528
+
529
+ let result = null as Resolved<TestList, { $each: true }> | null;
530
+
531
+ const updateFn = vi.fn().mockImplementation((value) => {
532
+ result = value;
533
+ });
534
+ const onUnauthorized = vi.fn();
535
+ const onUnavailable = vi.fn();
536
+
537
+ const unsubscribe = subscribeToCoValue(
538
+ TestList,
539
+ list.id,
540
+ {
541
+ loadAs: reader,
542
+ resolve: {
543
+ $each: true,
544
+ },
545
+ onUnauthorized,
546
+ onUnavailable,
547
+ },
548
+ updateFn,
549
+ );
550
+
551
+ onTestFinished(unsubscribe);
552
+
553
+ await waitFor(() => {
554
+ expect(onUnavailable).toHaveBeenCalled();
555
+ });
556
+
557
+ creator._raw.core.node.syncManager.addPeer(
558
+ getPeerConnectedToTestSyncServer(),
559
+ );
560
+
561
+ await waitFor(() => {
562
+ expect(updateFn).toHaveBeenCalled();
563
+ });
564
+
565
+ assert(result);
566
+
567
+ expect(result[0]?.value).toBe("1");
568
+
569
+ // expect(updateFn).toHaveBeenCalledTimes(1);
570
+ // TODO: Getting an extra update here due to https://github.com/garden-co/jazz/issues/2117
571
+ expect(updateFn).toHaveBeenCalledTimes(2);
572
+ });
573
+
491
574
  it("should handle null values in lists with required refs", async () => {
492
575
  class TestMap extends CoMap {
493
576
  value = co.string;
@@ -622,6 +705,238 @@ describe("subscribeToCoValue", () => {
622
705
 
623
706
  expect(updateFn).toHaveBeenCalledTimes(1);
624
707
  });
708
+
709
+ it("should unsubscribe from a nested ref when the value is set to null", async () => {
710
+ class TestMap extends CoMap {
711
+ value = co.string;
712
+ }
713
+
714
+ class TestList extends CoList.Of(co.optional.ref(TestMap)) {}
715
+
716
+ const creator = await createJazzTestAccount({
717
+ isCurrentActiveAccount: true,
718
+ });
719
+
720
+ const list = TestList.create(
721
+ [
722
+ TestMap.create({ value: "1" }, creator),
723
+ TestMap.create({ value: "2" }, creator),
724
+ ],
725
+ creator,
726
+ );
727
+
728
+ let result = null as Resolved<TestList, { $each: true }> | null;
729
+
730
+ const updateFn = vi.fn().mockImplementation((value) => {
731
+ result = value;
732
+ });
733
+
734
+ const unsubscribe = subscribeToCoValue(
735
+ TestList,
736
+ list.id,
737
+ {
738
+ loadAs: creator,
739
+ resolve: {
740
+ $each: true,
741
+ },
742
+ },
743
+ updateFn,
744
+ );
745
+
746
+ onTestFinished(unsubscribe);
747
+
748
+ await waitFor(() => {
749
+ expect(updateFn).toHaveBeenCalled();
750
+ });
751
+
752
+ assert(result);
753
+ expect(result[0]?.value).toBe("1");
754
+ expect(result[1]?.value).toBe("2");
755
+
756
+ const firstItem = result[0]!;
757
+
758
+ updateFn.mockClear();
759
+
760
+ list[0] = null;
761
+
762
+ await waitFor(() => {
763
+ expect(updateFn).toHaveBeenCalled();
764
+ });
765
+
766
+ assert(result);
767
+ expect(result[0]).toBeNull();
768
+
769
+ updateFn.mockClear();
770
+
771
+ firstItem.value = "3";
772
+
773
+ expect(updateFn).not.toHaveBeenCalled();
774
+ });
775
+
776
+ it("should unsubscribe from a nested ref when the value is changed to a different ref", async () => {
777
+ class TestMap extends CoMap {
778
+ value = co.string;
779
+ }
780
+
781
+ class TestList extends CoList.Of(co.ref(TestMap)) {}
782
+
783
+ const creator = await createJazzTestAccount({
784
+ isCurrentActiveAccount: true,
785
+ });
786
+
787
+ const list = TestList.create(
788
+ [
789
+ TestMap.create({ value: "1" }, creator),
790
+ TestMap.create({ value: "2" }, creator),
791
+ ],
792
+ creator,
793
+ );
794
+
795
+ let result = null as Resolved<TestList, { $each: true }> | null;
796
+
797
+ const updateFn = vi.fn().mockImplementation((value) => {
798
+ result = value;
799
+ });
800
+
801
+ const unsubscribe = subscribeToCoValue(
802
+ TestList,
803
+ list.id,
804
+ {
805
+ loadAs: creator,
806
+ resolve: {
807
+ $each: true,
808
+ },
809
+ },
810
+ updateFn,
811
+ );
812
+
813
+ onTestFinished(unsubscribe);
814
+
815
+ await waitFor(() => {
816
+ expect(updateFn).toHaveBeenCalled();
817
+ });
818
+
819
+ assert(result);
820
+ expect(result[0]?.value).toBe("1");
821
+ expect(result[1]?.value).toBe("2");
822
+
823
+ updateFn.mockClear();
824
+ const firstItem = result[0]!;
825
+
826
+ // Replace the first item with a new map
827
+ const newMap = TestMap.create({ value: "3" }, creator);
828
+ list[0] = newMap;
829
+
830
+ await waitFor(() => {
831
+ expect(updateFn).toHaveBeenCalled();
832
+ });
833
+
834
+ assert(result);
835
+ expect(result[0]?.value).toBe("3");
836
+ expect(result[1]?.value).toBe("2");
837
+
838
+ updateFn.mockClear();
839
+
840
+ firstItem.value = "4";
841
+
842
+ expect(updateFn).not.toHaveBeenCalled();
843
+
844
+ newMap.value = "5";
845
+
846
+ expect(updateFn).toHaveBeenCalled();
847
+ expect(result[0]?.value).toBe("5");
848
+ });
849
+
850
+ it("should emit on group changes, even when the amount of totalValidTransactions doesn't change but the content does", async () => {
851
+ class Person extends CoMap {
852
+ name = co.string;
853
+ }
854
+
855
+ const creator = await createJazzTestAccount();
856
+
857
+ const writer1 = await createJazzTestAccount();
858
+ const writer2 = await createJazzTestAccount();
859
+
860
+ const reader = await createJazzTestAccount();
861
+
862
+ await Promise.all([
863
+ writer1.waitForAllCoValuesSync(),
864
+ writer2.waitForAllCoValuesSync(),
865
+ reader.waitForAllCoValuesSync(),
866
+ ]);
867
+
868
+ const group = Group.create(creator);
869
+ group.addMember(writer1, "writer");
870
+ group.addMember(writer2, "reader");
871
+ group.addMember(reader, "reader");
872
+
873
+ const person = Person.create({ name: "creator" }, group);
874
+
875
+ await person.waitForSync();
876
+
877
+ // Disconnect from the sync server, so we can change permissions but not sync them
878
+ creator._raw.core.node.syncManager.getPeers().forEach((peer) => {
879
+ peer.gracefulShutdown();
880
+ });
881
+
882
+ group.removeMember(writer1);
883
+ group.addMember(writer2, "writer");
884
+
885
+ let value: Resolved<Person, {}> | null = null as Resolved<
886
+ Person,
887
+ {}
888
+ > | null;
889
+ const spy = vi.fn((update) => {
890
+ value = update;
891
+ });
892
+
893
+ const unsubscribe = subscribeToCoValue(
894
+ Person,
895
+ person.id,
896
+ {
897
+ loadAs: reader,
898
+ },
899
+ spy,
900
+ );
901
+
902
+ onTestFinished(unsubscribe);
903
+
904
+ await waitFor(() => expect(spy).toHaveBeenCalled());
905
+ expect(spy).toHaveBeenCalledTimes(1);
906
+ expect(value?.name).toBe("creator");
907
+
908
+ const personOnWriter1 = await Person.load(person.id, {
909
+ loadAs: writer1,
910
+ });
911
+
912
+ const personOnWriter2 = await Person.load(person.id, {
913
+ loadAs: writer2,
914
+ });
915
+
916
+ spy.mockClear();
917
+
918
+ assert(personOnWriter1);
919
+ assert(personOnWriter2);
920
+ personOnWriter1.name = "writer1";
921
+ personOnWriter2.name = "writer2";
922
+
923
+ await waitFor(() => expect(spy).toHaveBeenCalled());
924
+ expect(spy).toHaveBeenCalledTimes(1);
925
+ expect(value?.name).toBe("writer1");
926
+ expect(value?._raw.totalValidTransactions).toBe(2);
927
+
928
+ spy.mockClear();
929
+
930
+ // Reconnect to the sync server
931
+ creator._raw.core.node.syncManager.addPeer(
932
+ getPeerConnectedToTestSyncServer(),
933
+ );
934
+
935
+ await waitFor(() => expect(spy).toHaveBeenCalled());
936
+ expect(spy).toHaveBeenCalledTimes(1);
937
+ expect(value?.name).toBe("writer2");
938
+ expect(value?._raw.totalValidTransactions).toBe(2);
939
+ });
625
940
  });
626
941
 
627
942
  describe("createCoValueObservable", () => {
@@ -34,7 +34,7 @@ export async function setupAccount() {
34
34
  await createJazzContextFromExistingCredentials({
35
35
  credentials: {
36
36
  accountID: me.id,
37
- secret: me._raw.agentSecret,
37
+ secret: me._raw.core.node.getCurrentAgent().agentSecret,
38
38
  },
39
39
  sessionProvider: randomSessionProvider,
40
40
  peersToLoadFrom: [initialAsPeer],
@@ -96,11 +96,13 @@ export async function setupTwoNodes(options?: {
96
96
  };
97
97
  }
98
98
 
99
- export function waitFor(callback: () => boolean | void) {
99
+ export function waitFor(
100
+ callback: () => boolean | void | Promise<boolean | void>,
101
+ ) {
100
102
  return new Promise<void>((resolve, reject) => {
101
- const checkPassed = () => {
103
+ const checkPassed = async () => {
102
104
  try {
103
- return { ok: callback(), error: null };
105
+ return { ok: await callback(), error: null };
104
106
  } catch (error) {
105
107
  return { ok: false, error };
106
108
  }
@@ -108,8 +110,8 @@ export function waitFor(callback: () => boolean | void) {
108
110
 
109
111
  let retries = 0;
110
112
 
111
- const interval = setInterval(() => {
112
- const { ok, error } = checkPassed();
113
+ const interval = setInterval(async () => {
114
+ const { ok, error } = await checkPassed();
113
115
 
114
116
  if (ok !== false) {
115
117
  clearInterval(interval);
package/tsconfig.json CHANGED
@@ -12,7 +12,8 @@
12
12
  "esModuleInterop": true,
13
13
  "declaration": true,
14
14
  "emitDeclarationOnly": true,
15
- "declarationMap": true
15
+ "declarationMap": true,
16
+ "rootDir": "./src"
16
17
  },
17
18
  "include": ["./src/**/*.ts"],
18
19
  "exclude": ["./node_modules"]