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.
- package/dist/core/AssistantStream.d.ts.map +1 -1
- package/dist/core/AssistantStreamChunk.d.ts +3 -3
- package/dist/core/accumulators/AssistantMessageStream.d.ts.map +1 -1
- package/dist/core/accumulators/AssistantMessageStream.js +0 -1
- package/dist/core/accumulators/AssistantMessageStream.js.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.d.ts.map +1 -1
- package/dist/core/accumulators/assistant-message-accumulator.js +65 -28
- package/dist/core/accumulators/assistant-message-accumulator.js.map +1 -1
- package/dist/core/converters/toGenericMessages.js.map +1 -1
- package/dist/core/gorp/GorpStreamAccumulator.d.ts +15 -0
- package/dist/core/gorp/GorpStreamAccumulator.d.ts.map +1 -0
- package/dist/core/{object/ObjectStreamAccumulator.js → gorp/GorpStreamAccumulator.js} +11 -9
- package/dist/core/gorp/GorpStreamAccumulator.js.map +1 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.d.ts +17 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.d.ts.map +1 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.js +38 -0
- package/dist/core/gorp/GorpStreamDeltaTracker.js.map +1 -0
- package/dist/core/gorp/GorpStreamResponse.d.ts +16 -0
- package/dist/core/gorp/GorpStreamResponse.d.ts.map +1 -0
- package/dist/core/gorp/GorpStreamResponse.js +51 -0
- package/dist/core/gorp/GorpStreamResponse.js.map +1 -0
- package/dist/core/gorp/changeTree.d.ts +16 -0
- package/dist/core/gorp/changeTree.d.ts.map +1 -0
- package/dist/core/gorp/changeTree.js +77 -0
- package/dist/core/gorp/changeTree.js.map +1 -0
- package/dist/core/gorp/createGorpStream.d.ts +16 -0
- package/dist/core/gorp/createGorpStream.d.ts.map +1 -0
- package/dist/core/{object/createObjectStream.js → gorp/createGorpStream.js} +13 -8
- package/dist/core/gorp/createGorpStream.js.map +1 -0
- package/dist/core/{object → gorp}/types.d.ts +6 -5
- package/dist/core/gorp/types.d.ts.map +1 -0
- package/dist/core/modules/assistant-stream.d.ts +5 -4
- package/dist/core/modules/assistant-stream.d.ts.map +1 -1
- package/dist/core/modules/assistant-stream.js +10 -6
- package/dist/core/modules/assistant-stream.js.map +1 -1
- package/dist/core/modules/tool-call.d.ts.map +1 -1
- package/dist/core/modules/tool-call.js +3 -3
- package/dist/core/modules/tool-call.js.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.d.ts.map +1 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.js +91 -1
- package/dist/core/serialization/assistant-transport/AssistantTransport.js.map +1 -1
- package/dist/core/serialization/data-stream/DataStream.js +48 -48
- package/dist/core/serialization/data-stream/DataStream.js.map +1 -1
- package/dist/core/serialization/data-stream/chunk-types.d.ts +25 -24
- package/dist/core/serialization/data-stream/chunk-types.d.ts.map +1 -1
- package/dist/core/serialization/data-stream/chunk-types.js +22 -23
- package/dist/core/serialization/data-stream/chunk-types.js.map +1 -1
- package/dist/core/serialization/data-stream/serialization.d.ts.map +1 -1
- package/dist/core/serialization/data-stream/serialization.js +14 -2
- package/dist/core/serialization/data-stream/serialization.js.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts.map +1 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js +12 -1
- package/dist/core/serialization/ui-message-stream/UIMessageStream.js.map +1 -1
- package/dist/core/tool/ToolCallReader.d.ts.map +1 -1
- package/dist/core/tool/ToolCallReader.js.map +1 -1
- package/dist/core/tool/ToolExecutionStream.d.ts.map +1 -1
- package/dist/core/tool/ToolExecutionStream.js +9 -1
- package/dist/core/tool/ToolExecutionStream.js.map +1 -1
- package/dist/core/tool/ToolResponse.d.ts.map +1 -1
- package/dist/core/tool/tool-types.d.ts +6 -0
- package/dist/core/tool/tool-types.d.ts.map +1 -1
- package/dist/core/tool/toolResultStream.d.ts.map +1 -1
- package/dist/core/tool/toolResultStream.js +7 -2
- package/dist/core/tool/toolResultStream.js.map +1 -1
- package/dist/core/utils/stream/AssistantTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/PipeableTransformStream.d.ts.map +1 -1
- package/dist/core/utils/stream/SSE.d.ts.map +1 -1
- package/dist/core/utils/stream/SSE.js +11 -2
- package/dist/core/utils/stream/SSE.js.map +1 -1
- package/dist/core/utils/stream/SSEEventDecoder.d.ts.map +1 -1
- package/dist/core/utils/stream/merge.d.ts +2 -0
- package/dist/core/utils/stream/merge.d.ts.map +1 -1
- package/dist/core/utils/stream/merge.js +27 -8
- package/dist/core/utils/stream/merge.js.map +1 -1
- package/dist/core/utils/stream/path-utils.d.ts.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -0
- package/dist/resumable/ResumableStreamContext.d.ts.map +1 -1
- package/dist/resumable/ResumableStreamContext.js +12 -5
- package/dist/resumable/ResumableStreamContext.js.map +1 -1
- package/dist/resumable/errors.d.ts.map +1 -1
- package/dist/resumable/stores/redis-impl.d.ts.map +1 -1
- package/dist/utils/AsyncIterableStream.d.ts.map +1 -1
- package/dist/utils/AsyncIterableStream.js +18 -3
- package/dist/utils/AsyncIterableStream.js.map +1 -1
- package/dist/utils/promiseWithResolvers.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/core/AssistantStreamChunk.ts +3 -3
- package/src/core/accumulators/AssistantMessageStream.ts +3 -1
- package/src/core/accumulators/assistant-message-accumulator.test.ts +374 -1
- package/src/core/accumulators/assistant-message-accumulator.ts +75 -26
- package/src/core/converters/toGenericMessages.test.ts +3 -39
- package/src/core/{object/ObjectStream.test.ts → gorp/GorpStream.test.ts} +86 -25
- package/src/core/gorp/GorpStreamAccumulator.test.ts +28 -0
- package/src/core/{object/ObjectStreamAccumulator.ts → gorp/GorpStreamAccumulator.ts} +13 -15
- package/src/core/gorp/GorpStreamDeltaTracker.test.ts +212 -0
- package/src/core/gorp/GorpStreamDeltaTracker.ts +55 -0
- package/src/core/{object/ObjectStreamResponse.test.ts → gorp/GorpStreamResponse.test.ts} +6 -6
- package/src/core/gorp/GorpStreamResponse.ts +88 -0
- package/src/core/gorp/changeTree.ts +96 -0
- package/src/core/{object/createObjectStream.ts → gorp/createGorpStream.ts} +23 -17
- package/src/core/{object → gorp}/types.ts +5 -3
- package/src/core/modules/assistant-stream.test.ts +154 -2
- package/src/core/modules/assistant-stream.ts +16 -6
- package/src/core/modules/tool-call.test.ts +67 -0
- package/src/core/modules/tool-call.ts +7 -4
- package/src/core/serialization/assistant-transport/AssistantTransport.test.ts +174 -1
- package/src/core/serialization/assistant-transport/AssistantTransport.ts +77 -1
- package/src/core/serialization/data-stream/DataStream.ts +1 -1
- package/src/core/serialization/data-stream/chunk-types.ts +26 -24
- package/src/core/serialization/data-stream/serialization.test.ts +133 -0
- package/src/core/serialization/data-stream/serialization.ts +20 -2
- package/src/core/serialization/ui-message-stream/UIMessageStream.test.ts +135 -0
- package/src/core/serialization/ui-message-stream/UIMessageStream.ts +21 -1
- package/src/core/tool/ToolCallReader.ts +5 -1
- package/src/core/tool/ToolExecutionStream.ts +10 -0
- package/src/core/tool/tool-types.ts +7 -0
- package/src/core/tool/toolResultStream.test.ts +50 -0
- package/src/core/tool/toolResultStream.ts +7 -2
- package/src/core/utils/stream/SSE.test.ts +97 -0
- package/src/core/utils/stream/SSE.ts +13 -2
- package/src/core/utils/stream/merge.ts +31 -9
- package/src/gorp.test.ts +32 -0
- package/src/index.ts +21 -6
- package/src/resumable/ResumableStreamContext.test.ts +116 -1
- package/src/resumable/ResumableStreamContext.ts +32 -5
- package/src/resumable/createResumableAssistantStreamResponse.test.ts +1 -25
- package/src/utils/AsyncIterableStream.test.ts +61 -0
- package/src/utils/AsyncIterableStream.ts +18 -3
- package/dist/core/object/ObjectStreamAccumulator.d.ts +0 -15
- package/dist/core/object/ObjectStreamAccumulator.d.ts.map +0 -1
- package/dist/core/object/ObjectStreamAccumulator.js.map +0 -1
- package/dist/core/object/ObjectStreamResponse.d.ts +0 -16
- package/dist/core/object/ObjectStreamResponse.d.ts.map +0 -1
- package/dist/core/object/ObjectStreamResponse.js +0 -57
- package/dist/core/object/ObjectStreamResponse.js.map +0 -1
- package/dist/core/object/createObjectStream.d.ts +0 -16
- package/dist/core/object/createObjectStream.d.ts.map +0 -1
- package/dist/core/object/createObjectStream.js.map +0 -1
- package/dist/core/object/types.d.ts.map +0 -1
- package/src/core/accumulators/TimingTracker.test.ts +0 -100
- package/src/core/object/ObjectStreamResponse.ts +0 -114
- /package/dist/core/{object → gorp}/types.js +0 -0
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
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 {
|
|
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<
|
|
31
|
-
): Promise<ReadableStream<
|
|
27
|
+
stream: ReadableStream<GorpStreamChunk>,
|
|
28
|
+
): Promise<ReadableStream<GorpStreamChunk>> {
|
|
32
29
|
// Encode the stream to Uint8Array (simulating network transmission)
|
|
33
|
-
const encodedStream = stream.pipeThrough(new
|
|
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
|
|
49
|
-
return reconstructedStream.pipeThrough(new
|
|
45
|
+
// Decode the stream back to GorpStreamChunk
|
|
46
|
+
return reconstructedStream.pipeThrough(new GorpStreamDecoder());
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
describe("
|
|
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:
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
2
|
+
import { assertSafePathSegment } from "./changeTree";
|
|
3
|
+
import type { GorpStreamOperation } from "./types";
|
|
3
4
|
|
|
4
|
-
export class
|
|
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
|
|
16
|
+
append(ops: readonly GorpStreamOperation[]) {
|
|
16
17
|
this._state = ops.reduce(
|
|
17
|
-
(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:
|
|
23
|
+
private static apply(state: ReadonlyJSONValue, op: GorpStreamOperation) {
|
|
23
24
|
const type = op.type;
|
|
24
25
|
switch (type) {
|
|
25
26
|
case "set":
|
|
26
|
-
return
|
|
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
|
|
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] =
|
|
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] =
|
|
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 {
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
)
|
|
27
|
+
expect(() => fromGorpStreamResponse(createResponse(contentType))).toThrow(
|
|
28
|
+
"Response is not an event stream",
|
|
29
|
+
);
|
|
30
30
|
},
|
|
31
31
|
);
|
|
32
32
|
});
|