assistant-stream 0.3.26 → 0.3.28

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 (145) hide show
  1. package/dist/core/AssistantStream.d.ts.map +1 -1
  2. package/dist/core/AssistantStreamChunk.d.ts +3 -3
  3. package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
  4. package/dist/core/accumulators/AssistantMessageStream.js +0 -1
  5. package/dist/core/accumulators/AssistantMessageStream.js.map +1 -1
  6. package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
  7. package/dist/core/accumulators/assistant-message-accumulator.js +65 -28
  8. package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
  9. package/dist/core/converters/toGenericMessages.js.map +1 -1
  10. package/dist/core/gorp/GorpStreamAccumulator.d.ts +15 -0
  11. package/dist/core/gorp/GorpStreamAccumulator.d.ts.map +1 -0
  12. package/dist/core/{object/ObjectStreamAccumulator.js → gorp/GorpStreamAccumulator.js} +11 -9
  13. package/dist/core/gorp/GorpStreamAccumulator.js.map +1 -0
  14. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts +17 -0
  15. package/dist/core/gorp/GorpStreamDeltaTracker.d.ts.map +1 -0
  16. package/dist/core/gorp/GorpStreamDeltaTracker.js +38 -0
  17. package/dist/core/gorp/GorpStreamDeltaTracker.js.map +1 -0
  18. package/dist/core/gorp/GorpStreamResponse.d.ts +16 -0
  19. package/dist/core/gorp/GorpStreamResponse.d.ts.map +1 -0
  20. package/dist/core/gorp/GorpStreamResponse.js +51 -0
  21. package/dist/core/gorp/GorpStreamResponse.js.map +1 -0
  22. package/dist/core/gorp/changeTree.d.ts +16 -0
  23. package/dist/core/gorp/changeTree.d.ts.map +1 -0
  24. package/dist/core/gorp/changeTree.js +77 -0
  25. package/dist/core/gorp/changeTree.js.map +1 -0
  26. package/dist/core/gorp/createGorpStream.d.ts +16 -0
  27. package/dist/core/gorp/createGorpStream.d.ts.map +1 -0
  28. package/dist/core/{object/createObjectStream.js → gorp/createGorpStream.js} +13 -8
  29. package/dist/core/gorp/createGorpStream.js.map +1 -0
  30. package/dist/core/{object → gorp}/types.d.ts +6 -5
  31. package/dist/core/gorp/types.d.ts.map +1 -0
  32. package/dist/core/modules/assistant-stream.d.ts +5 -4
  33. package/dist/core/modules/assistant-stream.d.ts.map +1 -1
  34. package/dist/core/modules/assistant-stream.js +10 -6
  35. package/dist/core/modules/assistant-stream.js.map +1 -1
  36. package/dist/core/modules/tool-call.d.ts.map +1 -1
  37. package/dist/core/modules/tool-call.js +3 -3
  38. package/dist/core/modules/tool-call.js.map +1 -1
  39. package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
  40. package/dist/core/serialization/assistant-transport/AssistantTransport.js +91 -1
  41. package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
  42. package/dist/core/serialization/data-stream/DataStream.js +48 -48
  43. package/dist/core/serialization/data-stream/DataStream.js.map +1 -1
  44. package/dist/core/serialization/data-stream/chunk-types.d.ts +25 -24
  45. package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
  46. package/dist/core/serialization/data-stream/chunk-types.js +22 -23
  47. package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
  48. package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
  49. package/dist/core/serialization/data-stream/serialization.js +14 -2
  50. package/dist/core/serialization/data-stream/serialization.js.map +1 -1
  51. package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
  52. package/dist/core/serialization/ui-message-stream/UIMessageStream.js +12 -1
  53. package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
  54. package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
  55. package/dist/core/tool/ToolCallReader.js.map +1 -1
  56. package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
  57. package/dist/core/tool/ToolExecutionStream.js +9 -1
  58. package/dist/core/tool/ToolExecutionStream.js.map +1 -1
  59. package/dist/core/tool/ToolResponse.d.ts.map +1 -1
  60. package/dist/core/tool/tool-types.d.ts +6 -0
  61. package/dist/core/tool/tool-types.d.ts.map +1 -1
  62. package/dist/core/tool/toolResultStream.d.ts.map +1 -1
  63. package/dist/core/tool/toolResultStream.js +7 -2
  64. package/dist/core/tool/toolResultStream.js.map +1 -1
  65. package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
  66. package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
  67. package/dist/core/utils/stream/SSE.d.ts.map +1 -1
  68. package/dist/core/utils/stream/SSE.js +11 -2
  69. package/dist/core/utils/stream/SSE.js.map +1 -1
  70. package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -1
  71. package/dist/core/utils/stream/merge.d.ts +2 -0
  72. package/dist/core/utils/stream/merge.d.ts.map +1 -1
  73. package/dist/core/utils/stream/merge.js +27 -8
  74. package/dist/core/utils/stream/merge.js.map +1 -1
  75. package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
  76. package/dist/index.d.ts +25 -4
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +14 -3
  79. package/dist/index.js.map +1 -0
  80. package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
  81. package/dist/resumable/ResumableStreamContext.js +12 -5
  82. package/dist/resumable/ResumableStreamContext.js.map +1 -1
  83. package/dist/resumable/errors.d.ts.map +1 -1
  84. package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
  85. package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
  86. package/dist/utils/AsyncIterableStream.js +18 -3
  87. package/dist/utils/AsyncIterableStream.js.map +1 -1
  88. package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
  89. package/package.json +2 -2
  90. package/src/core/AssistantStreamChunk.ts +3 -3
  91. package/src/core/accumulators/AssistantMessageStream.ts +3 -1
  92. package/src/core/accumulators/assistant-message-accumulator.test.ts +374 -1
  93. package/src/core/accumulators/assistant-message-accumulator.ts +75 -26
  94. package/src/core/converters/toGenericMessages.test.ts +3 -39
  95. package/src/core/{object/ObjectStream.test.ts → gorp/GorpStream.test.ts} +86 -25
  96. package/src/core/gorp/GorpStreamAccumulator.test.ts +28 -0
  97. package/src/core/{object/ObjectStreamAccumulator.ts → gorp/GorpStreamAccumulator.ts} +13 -15
  98. package/src/core/gorp/GorpStreamDeltaTracker.test.ts +212 -0
  99. package/src/core/gorp/GorpStreamDeltaTracker.ts +55 -0
  100. package/src/core/{object/ObjectStreamResponse.test.ts → gorp/GorpStreamResponse.test.ts} +6 -6
  101. package/src/core/gorp/GorpStreamResponse.ts +88 -0
  102. package/src/core/gorp/changeTree.ts +96 -0
  103. package/src/core/{object/createObjectStream.ts → gorp/createGorpStream.ts} +23 -17
  104. package/src/core/{object → gorp}/types.ts +5 -3
  105. package/src/core/modules/assistant-stream.test.ts +154 -2
  106. package/src/core/modules/assistant-stream.ts +16 -6
  107. package/src/core/modules/tool-call.test.ts +67 -0
  108. package/src/core/modules/tool-call.ts +7 -4
  109. package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +174 -1
  110. package/src/core/serialization/assistant-transport/AssistantTransport.ts +77 -1
  111. package/src/core/serialization/data-stream/DataStream.ts +1 -1
  112. package/src/core/serialization/data-stream/chunk-types.ts +26 -24
  113. package/src/core/serialization/data-stream/serialization.test.ts +133 -0
  114. package/src/core/serialization/data-stream/serialization.ts +20 -2
  115. package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +135 -0
  116. package/src/core/serialization/ui-message-stream/UIMessageStream.ts +21 -1
  117. package/src/core/tool/ToolCallReader.ts +5 -1
  118. package/src/core/tool/ToolExecutionStream.ts +10 -0
  119. package/src/core/tool/tool-types.ts +7 -0
  120. package/src/core/tool/toolResultStream.test.ts +50 -0
  121. package/src/core/tool/toolResultStream.ts +7 -2
  122. package/src/core/utils/stream/SSE.test.ts +97 -0
  123. package/src/core/utils/stream/SSE.ts +13 -2
  124. package/src/core/utils/stream/merge.ts +31 -9
  125. package/src/gorp.test.ts +32 -0
  126. package/src/index.ts +21 -6
  127. package/src/resumable/ResumableStreamContext.test.ts +116 -1
  128. package/src/resumable/ResumableStreamContext.ts +32 -5
  129. package/src/resumable/createResumableAssistantStreamResponse.test.ts +1 -25
  130. package/src/utils/AsyncIterableStream.test.ts +61 -0
  131. package/src/utils/AsyncIterableStream.ts +18 -3
  132. package/dist/core/object/ObjectStreamAccumulator.d.ts +0 -15
  133. package/dist/core/object/ObjectStreamAccumulator.d.ts.map +0 -1
  134. package/dist/core/object/ObjectStreamAccumulator.js.map +0 -1
  135. package/dist/core/object/ObjectStreamResponse.d.ts +0 -16
  136. package/dist/core/object/ObjectStreamResponse.d.ts.map +0 -1
  137. package/dist/core/object/ObjectStreamResponse.js +0 -57
  138. package/dist/core/object/ObjectStreamResponse.js.map +0 -1
  139. package/dist/core/object/createObjectStream.d.ts +0 -16
  140. package/dist/core/object/createObjectStream.d.ts.map +0 -1
  141. package/dist/core/object/createObjectStream.js.map +0 -1
  142. package/dist/core/object/types.d.ts.map +0 -1
  143. package/src/core/accumulators/TimingTracker.test.ts +0 -100
  144. package/src/core/object/ObjectStreamResponse.ts +0 -114
  145. /package/dist/core/{object → gorp}/types.js +0 -0
@@ -1,11 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { createObjectStream } from "./createObjectStream";
3
- import {
4
- ObjectStreamEncoder,
5
- ObjectStreamDecoder,
6
- } from "./ObjectStreamResponse";
2
+ import { createGorpStream } from "./createGorpStream";
3
+ import { GorpStreamEncoder, GorpStreamDecoder } from "./GorpStreamResponse";
7
4
  import type { ReadonlyJSONValue } from "../../utils";
8
- import type { ObjectStreamChunk } from "./types";
5
+ import type { GorpStreamChunk } from "./types";
9
6
 
10
7
  // Helper function to collect all chunks from a stream
11
8
  async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
@@ -27,10 +24,10 @@ async function collectChunks<T>(stream: ReadableStream<T>): Promise<T[]> {
27
24
 
28
25
  // Helper function to encode and decode a stream
29
26
  async function encodeAndDecode(
30
- stream: ReadableStream<ObjectStreamChunk>,
31
- ): Promise<ReadableStream<ObjectStreamChunk>> {
27
+ stream: ReadableStream<GorpStreamChunk>,
28
+ ): Promise<ReadableStream<GorpStreamChunk>> {
32
29
  // Encode the stream to Uint8Array (simulating network transmission)
33
- const encodedStream = stream.pipeThrough(new ObjectStreamEncoder());
30
+ const encodedStream = stream.pipeThrough(new GorpStreamEncoder());
34
31
 
35
32
  // Collect all encoded chunks
36
33
  const encodedChunks = await collectChunks(encodedStream);
@@ -45,13 +42,37 @@ async function encodeAndDecode(
45
42
  },
46
43
  });
47
44
 
48
- // Decode the stream back to ObjectStreamChunk
49
- return reconstructedStream.pipeThrough(new ObjectStreamDecoder());
45
+ // Decode the stream back to GorpStreamChunk
46
+ return reconstructedStream.pipeThrough(new GorpStreamDecoder());
50
47
  }
51
48
 
52
- describe("ObjectStream serialization and deserialization", () => {
49
+ describe("GorpStream serialization and deserialization", () => {
50
+ it("does not settle the stream after cancellation", async () => {
51
+ let finishExecution!: () => void;
52
+ let abortSignal!: AbortSignal;
53
+ const execution = new Promise<void>((resolve) => {
54
+ finishExecution = resolve;
55
+ });
56
+ const stream = createGorpStream({
57
+ execute: (controller) => {
58
+ abortSignal = controller.abortSignal;
59
+ return execution;
60
+ },
61
+ });
62
+ const reader = stream.getReader();
63
+
64
+ await reader.cancel("consumer stopped");
65
+
66
+ expect(abortSignal.aborted).toBe(true);
67
+ expect(abortSignal.reason).toBe("consumer stopped");
68
+
69
+ finishExecution();
70
+ await execution;
71
+ await new Promise((resolve) => setTimeout(resolve, 0));
72
+ });
73
+
53
74
  it("should discard an unterminated SSE event", async () => {
54
- const operations: ObjectStreamChunk["operations"] = [
75
+ const operations: GorpStreamChunk["operations"] = [
55
76
  { type: "set", path: ["status"], value: "complete" },
56
77
  ];
57
78
  const stream = new ReadableStream<Uint8Array>({
@@ -64,7 +85,7 @@ describe("ObjectStream serialization and deserialization", () => {
64
85
  });
65
86
 
66
87
  const chunks = await collectChunks(
67
- stream.pipeThrough(new ObjectStreamDecoder()),
88
+ stream.pipeThrough(new GorpStreamDecoder()),
68
89
  );
69
90
 
70
91
  expect(chunks).toEqual([]);
@@ -72,7 +93,7 @@ describe("ObjectStream serialization and deserialization", () => {
72
93
 
73
94
  it("should correctly serialize and deserialize simple objects", async () => {
74
95
  // Create an object stream with simple operations
75
- const stream = createObjectStream({
96
+ const stream = createGorpStream({
76
97
  execute: (controller) => {
77
98
  controller.enqueue([
78
99
  { type: "set", path: ["name"], value: "John" },
@@ -96,7 +117,7 @@ describe("ObjectStream serialization and deserialization", () => {
96
117
  });
97
118
 
98
119
  it("should correctly handle nested objects", async () => {
99
- const stream = createObjectStream({
120
+ const stream = createGorpStream({
100
121
  execute: (controller) => {
101
122
  controller.enqueue([
102
123
  { type: "set", path: ["user", "profile", "name"], value: "Jane" },
@@ -128,7 +149,7 @@ describe("ObjectStream serialization and deserialization", () => {
128
149
  });
129
150
 
130
151
  it("should correctly handle arrays", async () => {
131
- const stream = createObjectStream({
152
+ const stream = createGorpStream({
132
153
  execute: (controller) => {
133
154
  controller.enqueue([
134
155
  { type: "set", path: ["items"], value: [] },
@@ -149,7 +170,7 @@ describe("ObjectStream serialization and deserialization", () => {
149
170
  });
150
171
 
151
172
  it("should correctly handle mixed arrays and objects", async () => {
152
- const stream = createObjectStream({
173
+ const stream = createGorpStream({
153
174
  execute: (controller) => {
154
175
  controller.enqueue([
155
176
  { type: "set", path: ["users"], value: [] },
@@ -176,7 +197,7 @@ describe("ObjectStream serialization and deserialization", () => {
176
197
  });
177
198
 
178
199
  it("should correctly handle append-text operations", async () => {
179
- const stream = createObjectStream({
200
+ const stream = createGorpStream({
180
201
  execute: (controller) => {
181
202
  controller.enqueue([
182
203
  { type: "set", path: ["message"], value: "Hello" },
@@ -196,8 +217,48 @@ describe("ObjectStream serialization and deserialization", () => {
196
217
  });
197
218
  });
198
219
 
220
+ it("does not apply first-chunk operations twice", async () => {
221
+ const stream = createGorpStream({
222
+ defaultValue: { message: "Hello" },
223
+ execute: (controller) => {
224
+ controller.enqueue([
225
+ { type: "append-text", path: ["message"], value: " world" },
226
+ ]);
227
+ controller.enqueue([
228
+ { type: "append-text", path: ["message"], value: "!" },
229
+ ]);
230
+ },
231
+ });
232
+
233
+ const decodedStream = await encodeAndDecode(stream);
234
+ const chunks = await collectChunks(decodedStream);
235
+
236
+ expect(chunks).toEqual([
237
+ {
238
+ snapshot: { message: "Hello world" },
239
+ operations: [
240
+ {
241
+ type: "set",
242
+ path: [],
243
+ value: { message: "Hello world" },
244
+ },
245
+ ],
246
+ },
247
+ {
248
+ snapshot: { message: "Hello world!" },
249
+ operations: [
250
+ {
251
+ type: "append-text",
252
+ path: ["message"],
253
+ value: "!",
254
+ },
255
+ ],
256
+ },
257
+ ]);
258
+ });
259
+
199
260
  it("should correctly handle special characters and Unicode", async () => {
200
- const stream = createObjectStream({
261
+ const stream = createGorpStream({
201
262
  execute: (controller) => {
202
263
  controller.enqueue([
203
264
  {
@@ -229,7 +290,7 @@ describe("ObjectStream serialization and deserialization", () => {
229
290
  });
230
291
 
231
292
  it("should correctly handle null and undefined values", async () => {
232
- const stream = createObjectStream({
293
+ const stream = createGorpStream({
233
294
  execute: (controller) => {
234
295
  controller.enqueue([
235
296
  { type: "set", path: ["nullValue"], value: null },
@@ -252,7 +313,7 @@ describe("ObjectStream serialization and deserialization", () => {
252
313
 
253
314
  it("should correctly handle large nested structures", async () => {
254
315
  // Create a deep nested structure
255
- const stream = createObjectStream({
316
+ const stream = createGorpStream({
256
317
  execute: (controller) => {
257
318
  controller.enqueue([
258
319
  { type: "set", path: ["level1"], value: {} },
@@ -290,7 +351,7 @@ describe("ObjectStream serialization and deserialization", () => {
290
351
  });
291
352
 
292
353
  it("should correctly handle operations in multiple enqueue calls", async () => {
293
- const stream = createObjectStream({
354
+ const stream = createGorpStream({
294
355
  execute: (controller) => {
295
356
  // First batch of operations
296
357
  controller.enqueue([
@@ -339,7 +400,7 @@ describe("ObjectStream serialization and deserialization", () => {
339
400
  });
340
401
 
341
402
  it("should correctly handle overwriting existing values", async () => {
342
- const stream = createObjectStream({
403
+ const stream = createGorpStream({
343
404
  execute: (controller) => {
344
405
  controller.enqueue([
345
406
  { type: "set", path: ["value"], value: "initial" },
@@ -371,7 +432,7 @@ describe("ObjectStream serialization and deserialization", () => {
371
432
  },
372
433
  };
373
434
 
374
- const stream = createObjectStream({
435
+ const stream = createGorpStream({
375
436
  defaultValue: initialValue,
376
437
  execute: (controller) => {
377
438
  controller.enqueue([
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { GorpStreamAccumulator } from "./GorpStreamAccumulator";
3
+
4
+ describe("GorpStreamAccumulator", () => {
5
+ it("rejects unsafe path segments", () => {
6
+ for (const path of [
7
+ ["__proto__", "polluted"],
8
+ ["constructor", "prototype", "polluted"],
9
+ ["a", "__proto__"],
10
+ ]) {
11
+ const acc = new GorpStreamAccumulator({});
12
+ expect(() => acc.append([{ type: "set", path, value: true }])).toThrow(
13
+ /Unsafe gorp path segment/,
14
+ );
15
+ }
16
+ expect(({} as { polluted?: unknown }).polluted).toBeUndefined();
17
+ });
18
+
19
+ it("treats inherited keys as absent when navigating", () => {
20
+ const acc = new GorpStreamAccumulator({});
21
+ acc.append([{ type: "set", path: ["toString", "x"], value: 1 }]);
22
+ expect(acc.state).toEqual({ toString: { x: 1 } });
23
+
24
+ const append = new GorpStreamAccumulator({ toString: "hi" });
25
+ append.append([{ type: "append-text", path: ["toString"], value: "!" }]);
26
+ expect(append.state).toEqual({ toString: "hi!" });
27
+ });
28
+ });
@@ -1,7 +1,8 @@
1
1
  import type { ReadonlyJSONValue, ReadonlyJSONObject } from "../../utils";
2
- import type { ObjectStreamOperation } from "./types";
2
+ import { assertSafePathSegment } from "./changeTree";
3
+ import type { GorpStreamOperation } from "./types";
3
4
 
4
- export class ObjectStreamAccumulator {
5
+ export class GorpStreamAccumulator {
5
6
  private _state: ReadonlyJSONValue;
6
7
 
7
8
  constructor(initialValue: ReadonlyJSONValue = null) {
@@ -12,24 +13,20 @@ export class ObjectStreamAccumulator {
12
13
  return this._state;
13
14
  }
14
15
 
15
- append(ops: readonly ObjectStreamOperation[]) {
16
+ append(ops: readonly GorpStreamOperation[]) {
16
17
  this._state = ops.reduce(
17
- (state, op) => ObjectStreamAccumulator.apply(state, op),
18
+ (state, op) => GorpStreamAccumulator.apply(state, op),
18
19
  this._state,
19
20
  );
20
21
  }
21
22
 
22
- private static apply(state: ReadonlyJSONValue, op: ObjectStreamOperation) {
23
+ private static apply(state: ReadonlyJSONValue, op: GorpStreamOperation) {
23
24
  const type = op.type;
24
25
  switch (type) {
25
26
  case "set":
26
- return ObjectStreamAccumulator.updatePath(
27
- state,
28
- op.path,
29
- () => op.value,
30
- );
27
+ return GorpStreamAccumulator.updatePath(state, op.path, () => op.value);
31
28
  case "append-text":
32
- return ObjectStreamAccumulator.updatePath(state, op.path, (current) => {
29
+ return GorpStreamAccumulator.updatePath(state, op.path, (current) => {
33
30
  if (typeof current !== "string")
34
31
  throw new Error(`Expected string at path [${op.path.join(", ")}]`);
35
32
  return current + op.value;
@@ -57,6 +54,7 @@ export class ObjectStreamAccumulator {
57
54
  }
58
55
 
59
56
  const [key, ...rest] = path as [string, ...(readonly string[])];
57
+ assertSafePathSegment(key);
60
58
  if (Array.isArray(state)) {
61
59
  const idx = Number(key);
62
60
  if (Number.isNaN(idx))
@@ -65,8 +63,8 @@ export class ObjectStreamAccumulator {
65
63
  throw new Error(`Insert array index out of bounds`);
66
64
 
67
65
  const nextState = [...state];
68
- nextState[idx] = ObjectStreamAccumulator.updatePath(
69
- nextState[idx],
66
+ nextState[idx] = GorpStreamAccumulator.updatePath(
67
+ Object.hasOwn(nextState, idx) ? nextState[idx] : undefined,
70
68
  rest,
71
69
  updater,
72
70
  );
@@ -75,8 +73,8 @@ export class ObjectStreamAccumulator {
75
73
  }
76
74
 
77
75
  const nextState = { ...(state as ReadonlyJSONObject) };
78
- nextState[key] = ObjectStreamAccumulator.updatePath(
79
- nextState[key],
76
+ nextState[key] = GorpStreamAccumulator.updatePath(
77
+ Object.hasOwn(nextState, key) ? nextState[key] : undefined,
80
78
  rest,
81
79
  updater,
82
80
  );
@@ -0,0 +1,212 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { GorpStreamDeltaTracker } from "./GorpStreamDeltaTracker";
3
+ import { createGorpStream } from "./createGorpStream";
4
+ import type { ReadonlyJSONObject } from "../../utils";
5
+
6
+ describe("GorpStreamDeltaTracker frames", () => {
7
+ it("reports ops of the current frame in getChangedKeys", () => {
8
+ const tracker = new GorpStreamDeltaTracker({ items: {} });
9
+ tracker.append([
10
+ { type: "set", path: ["items", "a"], value: { name: "alpha" } },
11
+ ]);
12
+ expect(tracker.getChangedKeys(["items"])).toEqual(["a"]);
13
+ expect((tracker.state as ReadonlyJSONObject).items).toEqual({
14
+ a: { name: "alpha" },
15
+ });
16
+ });
17
+
18
+ it("opens a separate frame per append", () => {
19
+ const tracker = new GorpStreamDeltaTracker({ items: {} });
20
+ tracker.append([
21
+ { type: "set", path: ["items", "a"], value: { name: "alpha" } },
22
+ ]);
23
+ tracker.append([
24
+ { type: "set", path: ["items", "b"], value: { name: "beta" } },
25
+ ]);
26
+ expect(tracker.getChangedKeys(["items"])).toEqual(["b"]);
27
+ expect(tracker.isChangedAt(["items", "a"])).toBe(false);
28
+ });
29
+
30
+ it("reports an empty change set before the first append and after an empty append", () => {
31
+ const tracker = new GorpStreamDeltaTracker({ count: 0 });
32
+ expect(tracker.isChangedAt([])).toBe(false);
33
+ expect(tracker.isChangedAt(["count"])).toBe(false);
34
+ expect(tracker.getChangedKeys([])).toEqual([]);
35
+ tracker.append([{ type: "set", path: ["count"], value: 1 }]);
36
+ expect(tracker.isChangedAt([])).toBe(true);
37
+ tracker.append([]);
38
+ expect(tracker.isChangedAt([])).toBe(false);
39
+ expect(tracker.isChangedAt(["count"])).toBe(false);
40
+ expect(tracker.getChangedKeys([])).toEqual([]);
41
+ });
42
+ });
43
+
44
+ describe("GorpStreamDeltaTracker isChangedAt", () => {
45
+ it("returns truthy for any path inside a marked subtree", () => {
46
+ const tracker = new GorpStreamDeltaTracker();
47
+ tracker.append([{ type: "set", path: [], value: { items: {} } }]);
48
+ expect(tracker.isChangedAt([])).toBe(true);
49
+ expect(tracker.isChangedAt(["items"])).toBe(true);
50
+ expect(tracker.isChangedAt(["items", "anything", "deep"])).toBe(true);
51
+ });
52
+
53
+ it("returns true for ancestors of a changed path and false for siblings", () => {
54
+ const tracker = new GorpStreamDeltaTracker({ a: { b: "x" }, c: "y" });
55
+ tracker.append([{ type: "set", path: ["a", "b"], value: "z" }]);
56
+ expect(tracker.isChangedAt([])).toBe(true);
57
+ expect(tracker.isChangedAt(["a"])).toBe(true);
58
+ expect(tracker.isChangedAt(["a", "b"])).toBe(true);
59
+ expect(tracker.isChangedAt(["c"])).toBe(false);
60
+ });
61
+
62
+ it("collapses a subtree to fully-changed when an ancestor is set in the same frame", () => {
63
+ const tracker = new GorpStreamDeltaTracker({ a: { b: 1, c: 2 } });
64
+ tracker.append([
65
+ { type: "set", path: ["a", "b"], value: 10 },
66
+ { type: "set", path: ["a"], value: { d: 3 } },
67
+ ]);
68
+ expect(tracker.isChangedAt(["a", "anything", "deep"])).toBe(true);
69
+ expect(tracker.getChangedKeys(["a"])).toEqual(["d", "c", "b"]);
70
+ });
71
+
72
+ it("keeps a subtree fully-changed when a descendant is set after its ancestor in the same frame", () => {
73
+ const tracker = new GorpStreamDeltaTracker({ a: { b: 1, c: 2 } });
74
+ tracker.append([
75
+ { type: "set", path: ["a"], value: { d: 3 } },
76
+ { type: "set", path: ["a", "d"], value: 4 },
77
+ ]);
78
+ expect(tracker.isChangedAt(["a", "anything", "deep"])).toBe(true);
79
+ expect(tracker.getChangedKeys(["a"])).toEqual(["d", "c", "b"]);
80
+ });
81
+
82
+ it("reports a leaf set twice in one frame as changed once", () => {
83
+ const tracker = new GorpStreamDeltaTracker({ a: { b: 1 } });
84
+ tracker.append([
85
+ { type: "set", path: ["a", "b"], value: 2 },
86
+ { type: "set", path: ["a", "b"], value: 3 },
87
+ ]);
88
+ expect(tracker.getChangedKeys(["a"])).toEqual(["b"]);
89
+ expect((tracker.state as ReadonlyJSONObject).a).toEqual({ b: 3 });
90
+ });
91
+
92
+ it("marks append-text targets as changed", () => {
93
+ const tracker = new GorpStreamDeltaTracker({ message: "Hello" });
94
+ tracker.append([{ type: "append-text", path: ["message"], value: "!" }]);
95
+ expect(tracker.isChangedAt(["message"])).toBe(true);
96
+ expect(tracker.getChangedKeys([])).toEqual(["message"]);
97
+ expect((tracker.state as ReadonlyJSONObject).message).toBe("Hello!");
98
+ });
99
+ });
100
+
101
+ describe("GorpStreamDeltaTracker getChangedKeys", () => {
102
+ it("diffs keys against the previous frame when a subtree is fully replaced", () => {
103
+ const tracker = new GorpStreamDeltaTracker({ count: 0, items: {} });
104
+ tracker.append([
105
+ { type: "set", path: ["items", "a"], value: { name: "alpha" } },
106
+ ]);
107
+ tracker.append([
108
+ { type: "set", path: ["items", "b"], value: { name: "beta" } },
109
+ ]);
110
+ tracker.append([
111
+ {
112
+ type: "set",
113
+ path: [],
114
+ value: {
115
+ count: 99,
116
+ items: { b: { name: "beta-new" }, c: { name: "gamma" } },
117
+ },
118
+ },
119
+ ]);
120
+ expect(tracker.getChangedKeys(["items"])).toEqual(["b", "c", "a"]);
121
+ });
122
+
123
+ it("returns interior node keys for a partially changed subtree", () => {
124
+ const tracker = new GorpStreamDeltaTracker({ items: { a: 1, b: 2 } });
125
+ tracker.append([
126
+ { type: "set", path: ["items", "a"], value: 10 },
127
+ { type: "set", path: ["items", "c"], value: 3 },
128
+ ]);
129
+ expect(tracker.getChangedKeys(["items"])).toEqual(["a", "c"]);
130
+ expect(tracker.getChangedKeys([])).toEqual(["items"]);
131
+ expect(tracker.getChangedKeys(["other"])).toEqual([]);
132
+ });
133
+
134
+ it("diffs keys when the previous value at a replaced path was not an object", () => {
135
+ const tracker = new GorpStreamDeltaTracker({ value: "initial" });
136
+ tracker.append([{ type: "set", path: ["value"], value: { nested: true } }]);
137
+ expect(tracker.getChangedKeys(["value"])).toEqual(["nested"]);
138
+ });
139
+ });
140
+
141
+ describe("GorpStreamDeltaTracker inherited object members", () => {
142
+ it("does not treat inherited members as changes on a fresh tracker", () => {
143
+ const tracker = new GorpStreamDeltaTracker({ items: {} });
144
+ expect(tracker.isChangedAt(["toString"])).toBe(false);
145
+ expect(tracker.isChangedAt(["hasOwnProperty"])).toBe(false);
146
+ expect(tracker.getChangedKeys(["toString"])).toEqual([]);
147
+ });
148
+
149
+ it("does not treat inherited members as changes on a populated tree", () => {
150
+ const tracker = new GorpStreamDeltaTracker({ items: {} });
151
+ tracker.append([{ type: "set", path: ["items", "a"], value: 1 }]);
152
+ expect(tracker.isChangedAt(["toString"])).toBe(false);
153
+ expect(tracker.isChangedAt(["items", "toString"])).toBe(false);
154
+ expect(tracker.isChangedAt(["items", "valueOf", "deep"])).toBe(false);
155
+ expect(tracker.getChangedKeys([])).toEqual(["items"]);
156
+ expect(tracker.getChangedKeys(["items"])).toEqual(["a"]);
157
+ });
158
+ });
159
+
160
+ describe("GorpStreamDeltaTracker failed append", () => {
161
+ it("leaves state and the change frame untouched when an operation throws", () => {
162
+ const tracker = new GorpStreamDeltaTracker({ count: 5, message: "hi" });
163
+ tracker.append([{ type: "set", path: ["message"], value: "hello" }]);
164
+ const stateBefore = tracker.state;
165
+
166
+ expect(() =>
167
+ tracker.append([
168
+ { type: "set", path: ["other"], value: 1 },
169
+ { type: "append-text", path: ["count"], value: "!" },
170
+ ]),
171
+ ).toThrow(/Expected string/);
172
+
173
+ expect(tracker.state).toBe(stateBefore);
174
+ expect(tracker.isChangedAt(["message"])).toBe(true);
175
+ expect(tracker.isChangedAt(["other"])).toBe(false);
176
+ expect(tracker.isChangedAt(["count"])).toBe(false);
177
+ expect(tracker.getChangedKeys([])).toEqual(["message"]);
178
+ });
179
+ });
180
+
181
+ describe("GorpStreamDeltaTracker streamed accumulation", () => {
182
+ it("tracks one frame per streamed chunk", async () => {
183
+ const stream = createGorpStream({
184
+ execute: (controller) => {
185
+ controller.enqueue([
186
+ { type: "set", path: ["user"], value: { name: "Initial" } },
187
+ ]);
188
+ controller.enqueue([
189
+ { type: "set", path: ["user", "name"], value: "Updated" },
190
+ { type: "set", path: ["user", "email"], value: "user@example.com" },
191
+ ]);
192
+ controller.enqueue([
193
+ { type: "set", path: ["status"], value: "complete" },
194
+ ]);
195
+ },
196
+ });
197
+
198
+ const tracker = new GorpStreamDeltaTracker();
199
+ const frames: string[][] = [];
200
+ for await (const chunk of stream) {
201
+ tracker.append(chunk.operations);
202
+ frames.push(tracker.getChangedKeys(["user"]));
203
+ expect(tracker.state).toEqual(chunk.snapshot);
204
+ }
205
+
206
+ expect(frames).toEqual([["name"], ["name", "email"], []]);
207
+ expect(tracker.state).toEqual({
208
+ user: { name: "Updated", email: "user@example.com" },
209
+ status: "complete",
210
+ });
211
+ });
212
+ });
@@ -0,0 +1,55 @@
1
+ import type { ReadonlyJSONValue } from "../../utils";
2
+ import { GorpStreamAccumulator } from "./GorpStreamAccumulator";
3
+ import type { GorpStreamOperation } from "./types";
4
+ import {
5
+ type ChangeNode,
6
+ createChangeNode,
7
+ diffKeys,
8
+ lookupChange,
9
+ lookupValue,
10
+ markChanged,
11
+ } from "./changeTree";
12
+
13
+ export class GorpStreamDeltaTracker {
14
+ private readonly accumulator: GorpStreamAccumulator;
15
+ private previousState: ReadonlyJSONValue;
16
+ private changes: ChangeNode = createChangeNode();
17
+
18
+ constructor(initialValue: ReadonlyJSONValue = null) {
19
+ this.accumulator = new GorpStreamAccumulator(initialValue);
20
+ this.previousState = this.accumulator.state;
21
+ }
22
+
23
+ get state(): ReadonlyJSONValue {
24
+ return this.accumulator.state;
25
+ }
26
+
27
+ append(operations: readonly GorpStreamOperation[]): void {
28
+ const previousState = this.accumulator.state;
29
+ let changes: ChangeNode = createChangeNode();
30
+ for (const op of operations) {
31
+ changes = markChanged(changes, op.path);
32
+ }
33
+ this.accumulator.append(operations);
34
+ this.previousState = previousState;
35
+ this.changes = changes;
36
+ }
37
+
38
+ isChangedAt(path: readonly string[]): boolean {
39
+ const node = lookupChange(this.changes, path);
40
+ if (node === true) return true;
41
+ return node !== false && Object.keys(node).length > 0;
42
+ }
43
+
44
+ getChangedKeys(path: readonly string[]): string[] {
45
+ const node = lookupChange(this.changes, path);
46
+ if (node === false) return [];
47
+ if (node === true) {
48
+ return diffKeys(
49
+ lookupValue(this.accumulator.state, path),
50
+ lookupValue(this.previousState, path),
51
+ );
52
+ }
53
+ return Object.keys(node);
54
+ }
55
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { fromObjectStreamResponse } from "./ObjectStreamResponse";
2
+ import { fromGorpStreamResponse } from "./GorpStreamResponse";
3
3
 
4
4
  const createResponse = (contentType?: string) => {
5
5
  const headers = new Headers({
@@ -10,23 +10,23 @@ const createResponse = (contentType?: string) => {
10
10
  return new Response(new Uint8Array(), { headers });
11
11
  };
12
12
 
13
- describe("fromObjectStreamResponse", () => {
13
+ describe("fromGorpStreamResponse", () => {
14
14
  it.each([
15
15
  "text/event-stream",
16
16
  "text/event-stream; charset=utf-8",
17
17
  "Text/Event-Stream; Charset=UTF-8",
18
18
  ])("accepts the event-stream content type %s", (contentType) => {
19
19
  expect(() =>
20
- fromObjectStreamResponse(createResponse(contentType)),
20
+ fromGorpStreamResponse(createResponse(contentType)),
21
21
  ).not.toThrow();
22
22
  });
23
23
 
24
24
  it.each([undefined, "application/json", "text/event-streaming"])(
25
25
  "rejects the content type %s",
26
26
  (contentType) => {
27
- expect(() =>
28
- fromObjectStreamResponse(createResponse(contentType)),
29
- ).toThrow("Response is not an event stream");
27
+ expect(() => fromGorpStreamResponse(createResponse(contentType))).toThrow(
28
+ "Response is not an event stream",
29
+ );
30
30
  },
31
31
  );
32
32
  });