cojson 0.8.36 → 0.8.38

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.
@@ -1,5 +1,5 @@
1
1
  import { CoID, RawCoValue } from "../coValue.js";
2
- import { CoValueCore } from "../coValueCore.js";
2
+ import { CoValueCore, DecryptedTransaction } from "../coValueCore.js";
3
3
  import { AgentID, TransactionID } from "../ids.js";
4
4
  import { JsonObject, JsonValue } from "../jsonValue.js";
5
5
  import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
@@ -49,6 +49,9 @@ export class RawCoMapView<
49
49
  cachedOps?: {
50
50
  [Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>[];
51
51
  };
52
+ /** @internal */
53
+ validSortedTransactions?: DecryptedTransaction[];
54
+
52
55
  /** @internal */
53
56
  options?: { ignorePrivateTransactions: boolean; atTime?: number };
54
57
  /** @internal */
@@ -59,27 +62,58 @@ export class RawCoMapView<
59
62
  /** @internal */
60
63
  constructor(
61
64
  core: CoValueCore,
62
- options?: { ignorePrivateTransactions: boolean; atTime?: number },
65
+ options?: {
66
+ ignorePrivateTransactions: boolean;
67
+ atTime?: number;
68
+ validSortedTransactions?: DecryptedTransaction[];
69
+ cachedOps?: {
70
+ [Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>[];
71
+ };
72
+ },
63
73
  ) {
64
74
  this.id = core.id as CoID<this>;
65
75
  this.core = core;
66
76
  this.latest = {};
67
77
  this.latestTxMadeAt = 0;
68
78
  this.options = options;
79
+ this.cachedOps = options?.cachedOps;
80
+ this.validSortedTransactions = options?.validSortedTransactions;
69
81
 
70
82
  this.processLatestTransactions();
71
83
  }
72
84
 
73
- processLatestTransactions() {
85
+ /** @internal */
86
+ private getValidSortedTransactions() {
87
+ if (this.validSortedTransactions) {
88
+ return this.validSortedTransactions;
89
+ }
90
+
91
+ const validSortedTransactions = this.core.getValidSortedTransactions({
92
+ ignorePrivateTransactions:
93
+ this.options?.ignorePrivateTransactions ?? false,
94
+ });
95
+
96
+ this.validSortedTransactions = validSortedTransactions;
97
+
98
+ return validSortedTransactions;
99
+ }
100
+
101
+ private resetCachedValues() {
102
+ this.validSortedTransactions = undefined;
103
+ this.cachedOps = undefined;
104
+ }
105
+
106
+ private processLatestTransactions() {
107
+ // Reset all internal state and cached values
74
108
  this.latest = {};
75
109
  this.latestTxMadeAt = 0;
76
110
 
77
- const { core, options, latest } = this;
111
+ const { latest } = this;
112
+
113
+ const atTimeFilter = this.options?.atTime;
78
114
 
79
- for (const { txID, changes, madeAt } of core.getValidSortedTransactions({
80
- ignorePrivateTransactions: options?.ignorePrivateTransactions ?? false,
81
- })) {
82
- if (options?.atTime && madeAt > options.atTime) {
115
+ for (const { txID, changes, madeAt } of this.getValidSortedTransactions()) {
116
+ if (atTimeFilter && madeAt > atTimeFilter) {
83
117
  continue;
84
118
  }
85
119
 
@@ -92,15 +126,14 @@ export class RawCoMapView<
92
126
  keyof Shape & string,
93
127
  Shape[keyof Shape & string]
94
128
  >;
95
- let entry = latest[change.key];
129
+ const entry = latest[change.key];
96
130
  if (!entry) {
97
- entry = {
131
+ latest[change.key] = {
98
132
  txID,
99
133
  madeAt,
100
134
  changeIdx,
101
135
  change,
102
136
  };
103
- latest[change.key] = entry;
104
137
  } else if (madeAt >= entry.madeAt) {
105
138
  entry.txID = txID;
106
139
  entry.madeAt = madeAt;
@@ -111,6 +144,11 @@ export class RawCoMapView<
111
144
  }
112
145
  }
113
146
 
147
+ revalidateTransactions() {
148
+ this.resetCachedValues();
149
+ this.processLatestTransactions();
150
+ }
151
+
114
152
  private getOps() {
115
153
  if (this.cachedOps) {
116
154
  return this.cachedOps;
@@ -120,11 +158,7 @@ export class RawCoMapView<
120
158
  [Key in keyof Shape & string]?: MapOp<Key, Shape[Key]>[];
121
159
  } = {};
122
160
 
123
- for (const {
124
- txID,
125
- changes,
126
- madeAt,
127
- } of this.core.getValidSortedTransactions(this.options)) {
161
+ for (const { txID, changes, madeAt } of this.getValidSortedTransactions()) {
128
162
  for (let changeIdx = 0; changeIdx < changes.length; changeIdx++) {
129
163
  const change = changes[changeIdx] as MapOpPayload<
130
164
  keyof Shape & string,
@@ -168,6 +202,8 @@ export class RawCoMapView<
168
202
  ignorePrivateTransactions:
169
203
  this.options?.ignorePrivateTransactions ?? false,
170
204
  atTime: time,
205
+ cachedOps: this.cachedOps,
206
+ validSortedTransactions: this.validSortedTransactions,
171
207
  });
172
208
  Object.setPrototypeOf(clone, this);
173
209
  return clone as this;
@@ -182,10 +218,10 @@ export class RawCoMapView<
182
218
  return undefined;
183
219
  }
184
220
 
185
- if (this.atTimeFilter) {
186
- return this.getOps()[key]?.filter(
187
- (op) => op.madeAt <= this.atTimeFilter!,
188
- );
221
+ const atTimeFilter = this.options?.atTime;
222
+
223
+ if (atTimeFilter) {
224
+ return this.getOps()[key]?.filter((op) => op.madeAt <= atTimeFilter);
189
225
  } else {
190
226
  return this.getOps()[key];
191
227
  }
@@ -197,17 +233,13 @@ export class RawCoMapView<
197
233
  * @category 1. Reading */
198
234
  keys<K extends keyof Shape & string = keyof Shape & string>(): K[] {
199
235
  return (Object.keys(this.latest) as K[]).filter((key) => {
200
- const latest = this.latest[key];
201
- if (!latest) {
202
- return undefined;
203
- }
236
+ const latestChange = this.latest[key];
204
237
 
205
- const includeUntil = this.atTimeFilter;
206
- const lastEntry = includeUntil
207
- ? this.getOps()[key]?.findLast((entry) => entry.madeAt <= includeUntil)
208
- : latest!;
238
+ if (!latestChange) {
239
+ return false;
240
+ }
209
241
 
210
- if (lastEntry?.change.op === "del") {
242
+ if (latestChange.change.op === "del") {
211
243
  return false;
212
244
  } else {
213
245
  return true;
@@ -221,22 +253,15 @@ export class RawCoMapView<
221
253
  * @category 1. Reading
222
254
  **/
223
255
  get<K extends keyof Shape & string>(key: K): Shape[K] | undefined {
224
- const latest = this.latest[key];
225
- if (!latest) {
256
+ const latestChange = this.latest[key];
257
+ if (!latestChange) {
226
258
  return undefined;
227
259
  }
228
260
 
229
- const includeUntil = this.atTimeFilter;
230
- const lastEntry = includeUntil
231
- ? this.timeFilteredOps(key)?.findLast(
232
- (entry) => entry.madeAt <= includeUntil,
233
- )
234
- : latest;
235
-
236
- if (lastEntry?.change.op === "del") {
261
+ if (latestChange.change.op === "del") {
237
262
  return undefined;
238
263
  } else {
239
- return lastEntry?.change.value;
264
+ return latestChange.change.value as Shape[K];
240
265
  }
241
266
  }
242
267
 
@@ -248,7 +273,7 @@ export class RawCoMapView<
248
273
  [K in keyof Shape & string]: Shape[K];
249
274
  }> = {};
250
275
 
251
- for (const key of this.keys()) {
276
+ for (const key of Object.keys(this.latest) as (keyof Shape & string)[]) {
252
277
  const value = this.get(key);
253
278
  if (value !== undefined) {
254
279
  object[key] = value;
@@ -268,34 +293,21 @@ export class RawCoMapView<
268
293
  }
269
294
 
270
295
  /** @category 5. Edit history */
271
- nthEditAt<K extends keyof Shape & string>(
272
- key: K,
273
- n: number,
274
- ):
275
- | {
276
- by: RawAccountID | AgentID;
277
- tx: TransactionID;
278
- at: Date;
279
- value?: Shape[K];
280
- }
281
- | undefined {
282
- const ops = this.timeFilteredOps(key);
283
- if (!ops || ops.length <= n) {
296
+ nthEditAt<K extends keyof Shape & string>(key: K, n: number) {
297
+ const ops = this.getOps()[key];
298
+
299
+ const atTimeFilter = this.options?.atTime;
300
+ const entry = ops?.[n];
301
+
302
+ if (!entry) {
284
303
  return undefined;
285
304
  }
286
305
 
287
- const entry = ops[n]!;
288
-
289
- if (this.atTimeFilter && entry.madeAt > this.atTimeFilter) {
306
+ if (atTimeFilter && entry.madeAt > atTimeFilter) {
290
307
  return undefined;
291
308
  }
292
309
 
293
- return {
294
- by: accountOrAgentIDfromSessionID(entry.txID.sessionID),
295
- tx: entry.txID,
296
- at: new Date(entry.madeAt),
297
- value: entry.change.op === "del" ? undefined : entry.change.value,
298
- };
310
+ return operationToEditEntry(entry);
299
311
  }
300
312
 
301
313
  /** @category 5. Edit history */
@@ -310,10 +322,13 @@ export class RawCoMapView<
310
322
  }
311
323
  | undefined {
312
324
  const ops = this.timeFilteredOps(key);
313
- if (!ops || ops.length === 0) {
325
+ const lastEntry = ops?.[ops.length - 1];
326
+
327
+ if (!lastEntry) {
314
328
  return undefined;
315
329
  }
316
- return this.nthEditAt(key, ops.length - 1);
330
+
331
+ return operationToEditEntry(lastEntry);
317
332
  }
318
333
 
319
334
  /** @category 5. Edit history */
@@ -323,8 +338,8 @@ export class RawCoMapView<
323
338
  return;
324
339
  }
325
340
 
326
- for (let i = 0; i < ops.length; i++) {
327
- yield this.nthEditAt(key, i)!;
341
+ for (const entry of ops) {
342
+ yield operationToEditEntry(entry);
328
343
  }
329
344
  }
330
345
 
@@ -370,8 +385,7 @@ export class RawCoMap<
370
385
  privacy,
371
386
  );
372
387
 
373
- this.processLatestTransactions();
374
- this.cachedOps = undefined;
388
+ this.revalidateTransactions();
375
389
  }
376
390
 
377
391
  /** Delete the given key (setting it to undefined).
@@ -396,7 +410,18 @@ export class RawCoMap<
396
410
  privacy,
397
411
  );
398
412
 
399
- this.processLatestTransactions();
400
- this.cachedOps = undefined;
413
+ this.revalidateTransactions();
401
414
  }
402
415
  }
416
+
417
+ export function operationToEditEntry<
418
+ K extends string,
419
+ V extends JsonValue | undefined,
420
+ >(op: MapOp<K, V>) {
421
+ return {
422
+ by: accountOrAgentIDfromSessionID(op.txID.sessionID),
423
+ tx: op.txID,
424
+ at: new Date(op.madeAt),
425
+ value: op.change.op === "del" ? undefined : op.change.value,
426
+ };
427
+ }
@@ -81,6 +81,7 @@ export class RawGroup<
81
81
  accountID: RawAccountID | AgentID | typeof EVERYONE,
82
82
  ): { role: Role; via: CoID<RawGroup> | undefined } | undefined {
83
83
  const roleHere = this.get(accountID);
84
+
84
85
  if (roleHere === "revoked") {
85
86
  return undefined;
86
87
  }
@@ -92,7 +93,7 @@ export class RawGroup<
92
93
  }
93
94
  | undefined = roleHere && { role: roleHere, via: undefined };
94
95
 
95
- const parentGroups = this.getParentGroups();
96
+ const parentGroups = this.getParentGroups(this.options?.atTime);
96
97
 
97
98
  for (const parentGroup of parentGroups) {
98
99
  const roleInParent = parentGroup.roleOfInternal(accountID);
@@ -109,7 +110,7 @@ export class RawGroup<
109
110
  return roleInfo;
110
111
  }
111
112
 
112
- getParentGroups() {
113
+ getParentGroups(atTime?: number) {
113
114
  const groups: RawGroup[] = [];
114
115
 
115
116
  for (const key of this.keys()) {
@@ -118,7 +119,14 @@ export class RawGroup<
118
119
  getParentGroupId(key),
119
120
  "Expected parent group to be loaded",
120
121
  );
121
- groups.push(expectGroup(parent.getCurrentContent()));
122
+
123
+ const parentGroup = expectGroup(parent.getCurrentContent());
124
+
125
+ if (atTime) {
126
+ groups.push(parentGroup.atTime(atTime));
127
+ } else {
128
+ groups.push(parentGroup);
129
+ }
122
130
  }
123
131
  }
124
132
 
@@ -1,14 +1,90 @@
1
- import { describe, expect, test } from "vitest";
1
+ import { metrics } from "@opentelemetry/api";
2
+ import {
3
+ AggregationTemporality,
4
+ InMemoryMetricExporter,
5
+ MeterProvider,
6
+ MetricReader,
7
+ type MetricReaderOptions,
8
+ type PushMetricExporter,
9
+ } from "@opentelemetry/sdk-metrics";
10
+ import { afterEach, describe, expect, test } from "vitest";
2
11
  import { PriorityBasedMessageQueue } from "../PriorityBasedMessageQueue.js";
3
12
  import { CO_VALUE_PRIORITY } from "../priority.js";
4
- import { SyncMessage } from "../sync.js";
13
+ import type { SyncMessage } from "../sync.js";
14
+
15
+ interface A extends MetricReaderOptions {
16
+ exporter: PushMetricExporter;
17
+ }
18
+
19
+ /**
20
+ * This is a test metric reader that uses an in-memory metric exporter and exposes a method to get the value of a metric given its name and attributes.
21
+ *
22
+ * This is useful for testing the values of metrics that are collected by the SDK.
23
+ *
24
+ * TODO: We could move this to a separate file and make it a utility class that can be used in other tests.
25
+ * TODO: We may want to rethink how we access metrics (see `getMetricValue` method) to make it more flexible.
26
+ */
27
+ class TestMetricReader extends MetricReader {
28
+ private _exporter = new InMemoryMetricExporter(
29
+ AggregationTemporality.CUMULATIVE,
30
+ );
31
+
32
+ protected onShutdown(): Promise<void> {
33
+ throw new Error("Method not implemented.");
34
+ }
35
+ protected onForceFlush(): Promise<void> {
36
+ throw new Error("Method not implemented.");
37
+ }
38
+
39
+ async getMetricValue(
40
+ name: string,
41
+ attributes: { [key: string]: string | number } = {},
42
+ ) {
43
+ await this.collectAndExport();
44
+ const metric = this._exporter
45
+ .getMetrics()[0]
46
+ ?.scopeMetrics[0]?.metrics.find((m) => m.descriptor.name === name);
47
+
48
+ const dp = metric?.dataPoints.find(
49
+ (dp) => JSON.stringify(dp.attributes) === JSON.stringify(attributes),
50
+ );
51
+
52
+ this._exporter.reset();
53
+
54
+ return dp?.value;
55
+ }
56
+
57
+ async collectAndExport(): Promise<void> {
58
+ const result = await this.collect();
59
+ await new Promise<void>((resolve, reject) => {
60
+ this._exporter.export(result.resourceMetrics, (result) => {
61
+ if (result.error != null) {
62
+ reject(result.error);
63
+ } else {
64
+ resolve();
65
+ }
66
+ });
67
+ });
68
+ }
69
+ }
5
70
 
6
71
  function setup() {
72
+ const metricReader = new TestMetricReader();
73
+ metrics.setGlobalMeterProvider(
74
+ new MeterProvider({
75
+ readers: [metricReader],
76
+ }),
77
+ );
78
+
7
79
  const queue = new PriorityBasedMessageQueue(CO_VALUE_PRIORITY.MEDIUM);
8
- return { queue };
80
+ return { queue, metricReader };
9
81
  }
10
82
 
11
83
  describe("PriorityBasedMessageQueue", () => {
84
+ afterEach(() => {
85
+ metrics.disable();
86
+ });
87
+
12
88
  test("should initialize with correct properties", () => {
13
89
  const { queue } = setup();
14
90
  expect(queue["defaultPriority"]).toBe(CO_VALUE_PRIORITY.MEDIUM);
@@ -43,7 +119,7 @@ describe("PriorityBasedMessageQueue", () => {
43
119
  });
44
120
 
45
121
  test("should pull messages in priority order", async () => {
46
- const { queue } = setup();
122
+ const { queue, metricReader } = setup();
47
123
  const lowPriorityMsg: SyncMessage = {
48
124
  action: "content",
49
125
  id: "co_zlow",
@@ -64,12 +140,42 @@ describe("PriorityBasedMessageQueue", () => {
64
140
  };
65
141
 
66
142
  void queue.push(lowPriorityMsg);
143
+ expect(
144
+ await metricReader.getMetricValue("jazz.messagequeue.size", {
145
+ priority: lowPriorityMsg.priority,
146
+ }),
147
+ ).toBe(1);
67
148
  void queue.push(mediumPriorityMsg);
149
+ expect(
150
+ await metricReader.getMetricValue("jazz.messagequeue.size", {
151
+ priority: mediumPriorityMsg.priority,
152
+ }),
153
+ ).toBe(1);
68
154
  void queue.push(highPriorityMsg);
155
+ expect(
156
+ await metricReader.getMetricValue("jazz.messagequeue.size", {
157
+ priority: highPriorityMsg.priority,
158
+ }),
159
+ ).toBe(1);
69
160
 
70
161
  expect(queue.pull()?.msg).toEqual(highPriorityMsg);
162
+ expect(
163
+ await metricReader.getMetricValue("jazz.messagequeue.size", {
164
+ priority: highPriorityMsg.priority,
165
+ }),
166
+ ).toBe(0);
71
167
  expect(queue.pull()?.msg).toEqual(mediumPriorityMsg);
168
+ expect(
169
+ await metricReader.getMetricValue("jazz.messagequeue.size", {
170
+ priority: mediumPriorityMsg.priority,
171
+ }),
172
+ ).toBe(0);
72
173
  expect(queue.pull()?.msg).toEqual(lowPriorityMsg);
174
+ expect(
175
+ await metricReader.getMetricValue("jazz.messagequeue.size", {
176
+ priority: lowPriorityMsg.priority,
177
+ }),
178
+ ).toBe(0);
73
179
  });
74
180
 
75
181
  test("should return undefined when pulling from empty queue", () => {
@@ -1,9 +1,10 @@
1
1
  import { expect, test } from "vitest";
2
2
  import { expectMap } from "../coValue.js";
3
+ import { operationToEditEntry } from "../coValues/coMap.js";
3
4
  import { WasmCrypto } from "../crypto/WasmCrypto.js";
4
5
  import { LocalNode } from "../localNode.js";
5
6
  import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
6
- import { randomAnonymousAccountAndSessionID } from "./testUtils.js";
7
+ import { hotSleep, randomAnonymousAccountAndSessionID } from "./testUtils.js";
7
8
 
8
9
  const Crypto = await WasmCrypto.create();
9
10
 
@@ -62,26 +63,40 @@ test("Can get CoMap entry values at different points in time", () => {
62
63
 
63
64
  expect(content.type).toEqual("comap");
64
65
 
65
- const beforeA = Date.now();
66
- while (Date.now() < beforeA + 10) {
67
- /* hot sleep */
68
- }
66
+ const beforeA = hotSleep(10);
69
67
  content.set("hello", "A", "trusting");
70
- const beforeB = Date.now();
71
- while (Date.now() < beforeB + 10) {
72
- /* hot sleep */
73
- }
68
+ const beforeB = hotSleep(10);
74
69
  content.set("hello", "B", "trusting");
75
- const beforeC = Date.now();
76
- while (Date.now() < beforeC + 10) {
77
- /* hot sleep */
78
- }
70
+ const beforeC = hotSleep(10);
79
71
  content.set("hello", "C", "trusting");
80
72
  expect(content.get("hello")).toEqual("C");
81
73
  expect(content.atTime(Date.now()).get("hello")).toEqual("C");
82
74
  expect(content.atTime(beforeA).get("hello")).toEqual(undefined);
83
75
  expect(content.atTime(beforeB).get("hello")).toEqual("A");
84
76
  expect(content.atTime(beforeC).get("hello")).toEqual("B");
77
+
78
+ const ops = content.timeFilteredOps("hello");
79
+
80
+ expect(content.atTime(beforeC).lastEditAt("hello")).toEqual(
81
+ operationToEditEntry(ops![1]!),
82
+ );
83
+ expect(content.atTime(beforeC).nthEditAt("hello", 0)).toEqual(
84
+ operationToEditEntry(ops![0]!),
85
+ );
86
+ expect(content.atTime(beforeC).nthEditAt("hello", 2)).toEqual(undefined);
87
+
88
+ expect([...content.atTime(beforeC).editsAt("hello")]).toEqual([
89
+ operationToEditEntry(ops![0]!),
90
+ operationToEditEntry(ops![1]!),
91
+ ]);
92
+
93
+ expect(content.atTime(beforeB).asObject()).toEqual({
94
+ hello: "A",
95
+ });
96
+
97
+ expect(content.atTime(beforeC).asObject()).toEqual({
98
+ hello: "B",
99
+ });
85
100
  });
86
101
 
87
102
  test("Can get all historic values of key in CoMap", () => {
@@ -7,10 +7,8 @@ import { WasmCrypto } from "../crypto/WasmCrypto.js";
7
7
  import { LocalNode } from "../localNode.js";
8
8
  import {
9
9
  createThreeConnectedNodes,
10
- createTwoConnectedNodes,
11
10
  loadCoValueOrFail,
12
11
  randomAnonymousAccountAndSessionID,
13
- waitFor,
14
12
  } from "./testUtils.js";
15
13
 
16
14
  const Crypto = await WasmCrypto.create();
@@ -7,6 +7,8 @@ import {
7
7
  createTwoConnectedNodes,
8
8
  groupWithTwoAdmins,
9
9
  groupWithTwoAdminsHighLevel,
10
+ hotSleep,
11
+ loadCoValueOrFail,
10
12
  newGroup,
11
13
  newGroupHighLevel,
12
14
  } from "./testUtils.js";
@@ -1802,29 +1804,25 @@ test("Admins can set child extensions", () => {
1802
1804
  });
1803
1805
 
1804
1806
  test("Admins can set child extensions when the admin role is inherited", async () => {
1805
- const { node1, node2 } = createTwoConnectedNodes("server", "server");
1807
+ const { node1, node2 } = await createTwoConnectedNodes("server", "server");
1806
1808
 
1807
- const node2Account = node2.account;
1808
- const group = node1.createGroup();
1809
+ const node2AccountOnNode1 = await loadCoValueOrFail(
1810
+ node1.node,
1811
+ node2.accountID,
1812
+ );
1809
1813
 
1810
- group.addMember(node2Account, "admin");
1814
+ const group = node1.node.createGroup();
1811
1815
 
1812
- const groupOnNode2 = await node2.load(group.id);
1816
+ group.addMember(node2AccountOnNode1, "admin");
1813
1817
 
1814
- if (groupOnNode2 === "unavailable") {
1815
- throw new Error("Group not found on node2");
1816
- }
1818
+ const groupOnNode2 = await loadCoValueOrFail(node2.node, group.id);
1817
1819
 
1818
- const childGroup = node2.createGroup();
1820
+ const childGroup = node2.node.createGroup();
1819
1821
  childGroup.extend(groupOnNode2);
1820
1822
 
1821
- const childGroupOnNode1 = await node1.load(childGroup.id);
1823
+ const childGroupOnNode1 = await loadCoValueOrFail(node1.node, childGroup.id);
1822
1824
 
1823
- if (childGroupOnNode1 === "unavailable") {
1824
- throw new Error("Child group not found on node1");
1825
- }
1826
-
1827
- const grandChildGroup = node2.createGroup();
1825
+ const grandChildGroup = node2.node.createGroup();
1828
1826
  grandChildGroup.extend(childGroupOnNode1);
1829
1827
 
1830
1828
  expect(childGroupOnNode1.get(`child_${grandChildGroup.id}`)).toEqual(
@@ -2315,6 +2313,38 @@ test("When rotating the key of a parent group, the keys of all child groups are
2315
2313
  expect(newChildReadKeyID).not.toEqual(currentChildReadKeyID);
2316
2314
  });
2317
2315
 
2316
+ test("When rotating the key of a parent group, the old transactions should still be valid", async () => {
2317
+ const { node1, node2 } = await createTwoConnectedNodes("server", "server");
2318
+
2319
+ const group = node1.node.createGroup();
2320
+ const parentGroup = node1.node.createGroup();
2321
+
2322
+ group.extend(parentGroup);
2323
+
2324
+ const node2AccountOnNode1 = await loadCoValueOrFail(
2325
+ node1.node,
2326
+ node2.accountID,
2327
+ );
2328
+
2329
+ parentGroup.addMember(node2AccountOnNode1, "writer");
2330
+
2331
+ const map = group.createMap();
2332
+ map.set("from", "node1", "private");
2333
+
2334
+ const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
2335
+ mapOnNode2.set("from", "node2", "private");
2336
+
2337
+ await new Promise((resolve) => setTimeout(resolve, 10));
2338
+
2339
+ parentGroup.removeMember(node2AccountOnNode1);
2340
+
2341
+ await new Promise((resolve) => setTimeout(resolve, 10));
2342
+
2343
+ const mapOnNode1 = await loadCoValueOrFail(node1.node, map.id);
2344
+
2345
+ expect(mapOnNode1.get("from")).toEqual("node2");
2346
+ });
2347
+
2318
2348
  test("When rotating the key of a grand-parent group, the keys of all child and grand-child groups are also rotated", () => {
2319
2349
  const { group, node } = newGroupHighLevel();
2320
2350
  const grandParentGroup = node.createGroup();