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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamfold",
3
- "version": "0.1.5",
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
+ }
@@ -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
- for await (const event of events) {
30
- for (const update of stream.pushAll(event)) yield update;
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()) yield completed;
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
- if (pool === undefined) stream?.dispose();
46
- else for (const id of pool.activeIds) pool.abort(id);
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
  }
@@ -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
+ }