streamfold 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -1
- package/src/index.d.ts +4 -0
- package/src/internal/abortable-source.js +82 -0
- package/src/read-structured.js +32 -6
- package/src/testing.d.ts +13 -0
- package/src/testing.js +121 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamfold",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Protocol-neutral incremental state for structured AI streams",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
"!src/**/*.test.js"
|
|
13
13
|
],
|
|
14
14
|
"exports": {
|
|
15
|
+
"./testing": {
|
|
16
|
+
"types": "./src/testing.d.ts",
|
|
17
|
+
"default": "./src/testing.js"
|
|
18
|
+
},
|
|
15
19
|
".": {
|
|
16
20
|
"types": "./src/index.d.ts",
|
|
17
21
|
"default": "./src/index.js"
|
package/src/index.d.ts
CHANGED
|
@@ -175,6 +175,8 @@ export function defineAdapter<Event, Id = string>(
|
|
|
175
175
|
|
|
176
176
|
export interface ReadStructuredOptions<Event, Id = string>
|
|
177
177
|
extends StructuredStreamIntegrationOptions {
|
|
178
|
+
/** Stop waiting and release parsers; pass the same signal to the transport. */
|
|
179
|
+
readonly signal?: AbortSignal;
|
|
178
180
|
readonly adapter: StructuredStreamAdapter<Event, Id>;
|
|
179
181
|
readonly integration?: never;
|
|
180
182
|
readonly limits?: StructuredStreamPoolOptions;
|
|
@@ -182,6 +184,8 @@ export interface ReadStructuredOptions<Event, Id = string>
|
|
|
182
184
|
|
|
183
185
|
export interface ReadStructuredIntegrationOptions<Event, Id = string>
|
|
184
186
|
extends StructuredStreamIntegrationOptions {
|
|
187
|
+
/** Stop waiting and release parsers; pass the same signal to the transport. */
|
|
188
|
+
readonly signal?: AbortSignal;
|
|
185
189
|
readonly integration: BatchStructuredStreamIntegration<Event, Id>;
|
|
186
190
|
readonly adapter?: never;
|
|
187
191
|
readonly limits?: StructuredStreamPoolOptions;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function abortableSource(events, signal, onAbort) {
|
|
2
|
+
const reader = typeof events?.getReader === "function" ? events.getReader() : undefined;
|
|
3
|
+
const asyncFactory = reader === undefined ? events[Symbol.asyncIterator] : undefined;
|
|
4
|
+
const synchronous = reader === undefined && asyncFactory == null;
|
|
5
|
+
const iterator = reader === undefined
|
|
6
|
+
? (asyncFactory ?? events[Symbol.iterator]).call(events)
|
|
7
|
+
: undefined;
|
|
8
|
+
let closed = false;
|
|
9
|
+
// Each read removes its listener; one shared pending promise would retain
|
|
10
|
+
// a Promise.race reaction for every event until the session ends.
|
|
11
|
+
const waitFor = (promise) => new Promise((resolve, reject) => {
|
|
12
|
+
let listening = true;
|
|
13
|
+
const detach = () => {
|
|
14
|
+
if (!listening) return;
|
|
15
|
+
listening = false;
|
|
16
|
+
signal.removeEventListener("abort", stop);
|
|
17
|
+
};
|
|
18
|
+
const stop = () => { detach(); reject(signal.reason); };
|
|
19
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
20
|
+
Promise.resolve(promise).then(
|
|
21
|
+
(value) => {
|
|
22
|
+
detach();
|
|
23
|
+
if (signal.aborted) reject(signal.reason);
|
|
24
|
+
else resolve(value);
|
|
25
|
+
},
|
|
26
|
+
(error) => { detach(); reject(signal.aborted ? signal.reason : error); },
|
|
27
|
+
);
|
|
28
|
+
if (signal.aborted) stop();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const close = (reason) => {
|
|
32
|
+
if (closed) return Promise.resolve();
|
|
33
|
+
closed = true;
|
|
34
|
+
return Promise.resolve().then(async () => {
|
|
35
|
+
if (reader !== undefined) {
|
|
36
|
+
try { return reader.cancel(reason); }
|
|
37
|
+
finally { reader.releaseLock(); }
|
|
38
|
+
} else {
|
|
39
|
+
await iterator.return?.();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
const abort = () => {
|
|
44
|
+
try { onAbort(); } catch { /* Cancellation preserves the signal's reason. */ }
|
|
45
|
+
close(signal.reason).catch(() => {});
|
|
46
|
+
};
|
|
47
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
48
|
+
if (signal.aborted) abort();
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
[Symbol.asyncIterator]() { return this; },
|
|
52
|
+
async next() {
|
|
53
|
+
signal.throwIfAborted();
|
|
54
|
+
const result = await waitFor(
|
|
55
|
+
Promise.resolve().then(async () => {
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
const next = reader !== undefined ? await reader.read() : await iterator.next();
|
|
58
|
+
if (next === null || typeof next !== "object") {
|
|
59
|
+
throw new TypeError("Iterator result must be an object");
|
|
60
|
+
}
|
|
61
|
+
return synchronous ? { done: next.done, value: await next.value } : next;
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
signal.throwIfAborted();
|
|
65
|
+
if (result.done) {
|
|
66
|
+
closed = true;
|
|
67
|
+
reader?.releaseLock();
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
},
|
|
71
|
+
async return() {
|
|
72
|
+
const cleanup = close();
|
|
73
|
+
cleanup.catch(() => {});
|
|
74
|
+
if (!signal.aborted) await waitFor(cleanup);
|
|
75
|
+
return { done: true, value: undefined };
|
|
76
|
+
},
|
|
77
|
+
async dispose() {
|
|
78
|
+
try { await this.return(); }
|
|
79
|
+
finally { signal.removeEventListener("abort", abort); }
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/read-structured.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createStructuredStreamPool } from "./index.js";
|
|
2
2
|
import { streamError } from "./internal/errors.js";
|
|
3
|
+
import { abortableSource } from "./internal/abortable-source.js";
|
|
3
4
|
|
|
4
5
|
export async function* readStructured(
|
|
5
6
|
events,
|
|
6
|
-
{ adapter, integration, limits, onDiagnostic },
|
|
7
|
+
{ adapter, integration, limits, onDiagnostic, signal },
|
|
7
8
|
) {
|
|
8
9
|
if (
|
|
9
10
|
(adapter === undefined) === (integration === undefined) ||
|
|
@@ -17,7 +18,14 @@ export async function* readStructured(
|
|
|
17
18
|
}
|
|
18
19
|
let pool;
|
|
19
20
|
let stream;
|
|
21
|
+
let source;
|
|
22
|
+
let failed = false;
|
|
23
|
+
const dispose = () => {
|
|
24
|
+
if (pool === undefined) stream?.dispose();
|
|
25
|
+
else for (const id of pool.activeIds) pool.abort(id);
|
|
26
|
+
};
|
|
20
27
|
try {
|
|
28
|
+
signal?.throwIfAborted();
|
|
21
29
|
if (integration !== undefined) {
|
|
22
30
|
pool = createStructuredStreamPool(limits);
|
|
23
31
|
stream = integration(pool, { onDiagnostic });
|
|
@@ -26,23 +34,41 @@ export async function* readStructured(
|
|
|
26
34
|
onDiagnostic === undefined ? limits : { ...limits, onDiagnostic },
|
|
27
35
|
);
|
|
28
36
|
}
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
signal?.throwIfAborted();
|
|
38
|
+
source = signal === undefined ? undefined : abortableSource(events, signal, dispose);
|
|
39
|
+
for await (const event of source ?? events) {
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
for (const update of stream.pushAll(event)) {
|
|
42
|
+
signal?.throwIfAborted();
|
|
43
|
+
yield update;
|
|
44
|
+
}
|
|
31
45
|
}
|
|
46
|
+
signal?.throwIfAborted();
|
|
32
47
|
if (pool === undefined) {
|
|
33
|
-
for (const completed of stream.finish())
|
|
48
|
+
for (const completed of stream.finish()) {
|
|
49
|
+
signal?.throwIfAborted();
|
|
50
|
+
yield completed;
|
|
51
|
+
}
|
|
34
52
|
} else {
|
|
35
53
|
// SDK finish() appends pending calls to its legacy completion history.
|
|
36
54
|
const pending = pool.size;
|
|
37
55
|
const completed = stream.finish();
|
|
38
56
|
if (pending > 0) {
|
|
39
57
|
for (const result of completed.slice(-pending)) {
|
|
58
|
+
signal?.throwIfAborted();
|
|
40
59
|
yield { type: "complete", ...result };
|
|
41
60
|
}
|
|
42
61
|
}
|
|
43
62
|
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
failed = true;
|
|
65
|
+
throw error;
|
|
44
66
|
} finally {
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
try { dispose(); }
|
|
68
|
+
catch (error) { if (!failed) throw error; }
|
|
69
|
+
finally {
|
|
70
|
+
try { await source?.dispose(); }
|
|
71
|
+
catch (error) { if (!failed) throw error; }
|
|
72
|
+
}
|
|
47
73
|
}
|
|
48
74
|
}
|
package/src/testing.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { StructuredStreamAdapter, StructuredStreamOperation } from "./index.js";
|
|
2
|
+
|
|
3
|
+
export interface AdapterContractTest {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
run(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Node-only helpers for node:test, Vitest, and other Node-based test runners. */
|
|
9
|
+
export function adapterContractTests<Event>(options: {
|
|
10
|
+
readonly adapter: StructuredStreamAdapter<Event, string>;
|
|
11
|
+
/** Encode every operation in order; may split or batch your decoded events. */
|
|
12
|
+
readonly encode: (operations: readonly StructuredStreamOperation<string>[]) => Iterable<Event>;
|
|
13
|
+
}): readonly AdapterContractTest[];
|
package/src/testing.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readStructured } from "./read-structured.js";
|
|
3
|
+
|
|
4
|
+
/** Contract tests for defineAdapter-compatible factories, not legacy SDK adapters. */
|
|
5
|
+
export function adapterContractTests({ adapter, encode }) {
|
|
6
|
+
const start = (id) => ({ type: "start", id });
|
|
7
|
+
const delta = (id, text) => ({ type: "delta", id, text });
|
|
8
|
+
const end = (id) => ({ type: "end", id });
|
|
9
|
+
const push = (session, operations) => {
|
|
10
|
+
const updates = [];
|
|
11
|
+
for (const event of encode(operations)) updates.push(...session.pushAll(event));
|
|
12
|
+
return updates;
|
|
13
|
+
};
|
|
14
|
+
const check = (name, run) => ({
|
|
15
|
+
name,
|
|
16
|
+
async run() {
|
|
17
|
+
const session = adapter({ snapshots: "immutable" });
|
|
18
|
+
try { await run(session); }
|
|
19
|
+
finally { session.dispose(); }
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return [
|
|
23
|
+
check("interleaved calls preserve lifecycle order and values", (session) => {
|
|
24
|
+
const updates = push(session, [
|
|
25
|
+
start("a"), start("b"), delta("a", '{"city":"San'),
|
|
26
|
+
delta("b", "42"), delta("a", ' Francisco"}'), end("b"), end("a"),
|
|
27
|
+
]);
|
|
28
|
+
assert.deepEqual(updates.map(({ type, id }) => [type, id]), [
|
|
29
|
+
["start", "a"], ["start", "b"], ["update", "a"], ["update", "b"],
|
|
30
|
+
["update", "a"], ["complete", "b"], ["complete", "a"],
|
|
31
|
+
]);
|
|
32
|
+
assert.deepEqual(updates[2].partialValue, { city: "San" });
|
|
33
|
+
assert.equal(updates[5].value, 42);
|
|
34
|
+
assert.deepEqual(updates[6].value, { city: "San Francisco" });
|
|
35
|
+
assert.deepEqual(session.finish(), []);
|
|
36
|
+
}),
|
|
37
|
+
check("immutable snapshots retain nested values across later events", (session) => {
|
|
38
|
+
const first = push(session, [start("a"), delta("a", '{"items":[{"name":"A')]).at(-1);
|
|
39
|
+
push(session, [delta("a", 'B"}]}'), end("a")]);
|
|
40
|
+
assert.deepEqual(first.partialValue, { items: [{ name: "A" }] });
|
|
41
|
+
assert.ok(Object.isFrozen(first.partialValue));
|
|
42
|
+
assert.ok(Object.isFrozen(first.partialValue.items[0]));
|
|
43
|
+
}),
|
|
44
|
+
check("EOF finishes pending calls exactly once", (session) => {
|
|
45
|
+
push(session, [start("a"), delta("a", "null"), start("b"), delta("b", "[]"), end("b")]);
|
|
46
|
+
const completed = session.finish();
|
|
47
|
+
assert.equal(completed.length, 1);
|
|
48
|
+
assert.equal(completed[0].id, "a");
|
|
49
|
+
assert.equal(completed[0].type, "complete");
|
|
50
|
+
assert.equal(completed[0].value, null);
|
|
51
|
+
assert.deepEqual(session.finish(), []);
|
|
52
|
+
}),
|
|
53
|
+
check("abort abandons incomplete JSON and allows ID reuse", (session) => {
|
|
54
|
+
const updates = push(session, [
|
|
55
|
+
start("a"), delta("a", "{"), { type: "abort", id: "a" },
|
|
56
|
+
start("a"), delta("a", "true"), end("a"),
|
|
57
|
+
start("a"), delta("a", "false"), end("a"),
|
|
58
|
+
]);
|
|
59
|
+
assert.deepEqual(updates.filter(({ type }) => type === "complete").map(({ value }) => value), [true, false]);
|
|
60
|
+
assert.deepEqual(session.finish(), []);
|
|
61
|
+
}),
|
|
62
|
+
check("malformed input fails terminally instead of emitting a completion", (session) => {
|
|
63
|
+
assert.throws(() => push(session, [start("a"), delta("a", "{]")]), SyntaxError);
|
|
64
|
+
assert.throws(() => session.finish());
|
|
65
|
+
assert.throws(() => push(session, [start("b")]));
|
|
66
|
+
}),
|
|
67
|
+
check("unknown calls and duplicate starts are rejected", (session) => {
|
|
68
|
+
assert.throws(() => push(session, [delta("missing", "{}")]), { code: "UNKNOWN_STREAM" });
|
|
69
|
+
const other = adapter();
|
|
70
|
+
try {
|
|
71
|
+
assert.throws(() => push(other, [start("a"), start("a")]), { code: "DUPLICATE_STREAM" });
|
|
72
|
+
} finally { other.dispose(); }
|
|
73
|
+
}),
|
|
74
|
+
check("sessions are isolated and dispose is idempotent", (session) => {
|
|
75
|
+
push(session, [start("a"), delta("a", "{")]);
|
|
76
|
+
const other = adapter();
|
|
77
|
+
try {
|
|
78
|
+
const updates = push(other, [start("a"), delta("a", "42"), end("a")]);
|
|
79
|
+
assert.equal(updates.at(-1).value, 42);
|
|
80
|
+
} finally { other.dispose(); }
|
|
81
|
+
session.dispose();
|
|
82
|
+
session.dispose();
|
|
83
|
+
assert.throws(() => push(session, [start("b")]), { code: "STREAM_DISPOSED" });
|
|
84
|
+
}),
|
|
85
|
+
check("configured parser limits are forwarded", () => {
|
|
86
|
+
const limited = adapter({ maxActiveStreams: 1, maxBytes: 2 });
|
|
87
|
+
try {
|
|
88
|
+
assert.throws(() => push(limited, [start("a"), start("b")]), { code: "MAX_ACTIVE_STREAMS_EXCEEDED" });
|
|
89
|
+
} finally { limited.dispose(); }
|
|
90
|
+
const bytes = adapter({ maxBytes: 2 });
|
|
91
|
+
try {
|
|
92
|
+
assert.throws(() => push(bytes, [start("a"), delta("a", "true")]), { code: "MAX_BYTES_EXCEEDED" });
|
|
93
|
+
} finally { bytes.dispose(); }
|
|
94
|
+
}),
|
|
95
|
+
{
|
|
96
|
+
name: "managed early exit closes the source and disposes its session",
|
|
97
|
+
async run() {
|
|
98
|
+
let closed = false;
|
|
99
|
+
let disposed = false;
|
|
100
|
+
function* source() {
|
|
101
|
+
try { yield* encode([start("a"), delta("a", "{")]); }
|
|
102
|
+
finally { closed = true; }
|
|
103
|
+
}
|
|
104
|
+
const factory = (options) => {
|
|
105
|
+
const session = adapter(options);
|
|
106
|
+
return {
|
|
107
|
+
pushAll: (event) => session.pushAll(event),
|
|
108
|
+
finish: () => session.finish(),
|
|
109
|
+
dispose() { disposed = true; session.dispose(); },
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
for await (const update of readStructured(source(), { adapter: factory })) {
|
|
113
|
+
assert.equal(update.type, "start");
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
assert.equal(closed, true);
|
|
117
|
+
assert.equal(disposed, true);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|