@pretable/stream-adapter 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # @pretable/stream-adapter
2
2
 
3
- RAF-batched streaming integration for [pretable](https://pretable.dev/). Bridges async streams (HTTP SSE, WebSocket, partial-JSON from LLMs) into the grid with predictable per-frame batching.
3
+ RAF-batched streaming integration for [Pretable](https://pretable.dev/). It bridges async sources such as SSE, WebSockets, and partial JSON into an ID-generic row model with one atomic transaction per animation frame.
4
4
 
5
5
  ## When to reach for this
6
6
 
7
- Use `@pretable/stream-adapter` when you need to drive a grid from a live data stream and want one DOM mutation per animation frame regardless of incoming stream rate. The package ships:
7
+ Use `@pretable/stream-adapter` when a live source emits faster than the browser should publish row-model revisions. The package ships:
8
8
 
9
- - A **batcher** ([`createBatcher`](#createbatcher)) that coalesces `add` / `update` / `remove` into one `applyTransaction` per RAF tick.
10
- - Two **connect** functions that wire an `AsyncIterable` source through the batcher into a grid.
11
- - Two **parse** functions that turn raw UTF-8 string streams (e.g., `fetch().body`) into typed row iterables.
9
+ - A **batcher** that coalesces `add`, `{ id, changes }` updates, and ID removals into one transaction per RAF tick.
10
+ - Two **connectors** that consume `AsyncIterable` sources and target a structural row model.
11
+ - Two **parsers** that turn raw UTF-8 string streams into typed row iterables.
12
12
 
13
- If you only need one-shot row mounting, use `<Pretable>` from `@pretable/react` instead this package is for live streams.
13
+ If your data changes only through ordinary React props, pass `rows` to `@pretable/react` instead. This package is for explicit streaming ownership.
14
14
 
15
15
  ## Install
16
16
 
@@ -19,25 +19,27 @@ npm install @pretable/stream-adapter
19
19
  # or pnpm add @pretable/stream-adapter, yarn add @pretable/stream-adapter
20
20
  ```
21
21
 
22
- ## Minimal example — element stream
22
+ ## Element streams
23
23
 
24
- ```ts
25
- import { connectElementStream, parseElementStream } from "@pretable/stream-adapter";
26
- import { createGrid } from "@pretable/core";
24
+ `connectElementStream` treats every complete element as a row to add. Pass a Pretable row model or any object satisfying `RowModelLike<TRow, TRowId>`.
27
25
 
28
- const grid = createGrid({ columns: [...], rows: [] });
26
+ ```ts
27
+ import {
28
+ connectElementStream,
29
+ parseElementStream,
30
+ } from "@pretable/stream-adapter";
29
31
 
30
32
  const response = await fetch("/api/rows");
31
33
  const stringStream = response.body!.pipeThrough(new TextDecoderStream());
32
- const rowStream = parseElementStream<MyRow>(stringStream);
34
+ const rows = parseElementStream<MyRow>(stringStream);
33
35
 
34
- const connection = connectElementStream(grid, rowStream);
35
- await connection.done; // resolves when the server closes the response
36
+ const connection = connectElementStream(rowModel, rows);
37
+ await connection.done;
36
38
  ```
37
39
 
38
- ## Minimal example — partial-update stream (e.g., LLM)
40
+ ## Partial streams
39
41
 
40
- When an LLM is streaming partial JSON, every chunk is an incomplete row. `connectPartialStream` upserts by row id so each chunk visibly fills out the corresponding row.
42
+ `connectPartialStream` sends every partial to the fixed `options.rowId` as `{ id, changes }`. The target row must already exist unless `createRow(partial, id)` is provided. The connector never asserts that a partial is a complete row.
41
43
 
42
44
  ```ts
43
45
  import {
@@ -45,29 +47,45 @@ import {
45
47
  parsePartialStream,
46
48
  } from "@pretable/stream-adapter";
47
49
 
48
- const partialStream = parsePartialStream<MyRow>(stringStream);
49
- const connection = connectPartialStream(grid, partialStream, { rowId: "id" });
50
+ const partials = parsePartialStream<ChatRow>(stringStream);
51
+ const connection = connectPartialStream(rowModel, partials, {
52
+ rowId: "assistant-1",
53
+ onIssue(issue) {
54
+ console.warn(issue.code, issue.rowId);
55
+ },
56
+ createRow(partial, id) {
57
+ return { id, role: "assistant", content: partial.content ?? "" };
58
+ },
59
+ });
50
60
  ```
51
61
 
52
- ## API
62
+ When the model returns an `unknown-update-id` issue, `onIssue` receives it. If `createRow` is present, its complete result is added in a separate atomic transaction; otherwise nothing is fabricated.
53
63
 
54
- See **[`stream-adapter.api.md`](./stream-adapter.api.md)** for the full generated public-API report.
64
+ ## Direct batching
55
65
 
56
- ### `createBatcher`
66
+ Use `createBatcher` when your source does not fit either connector:
57
67
 
58
- Returns a `TransactionBatcher` that buffers add / update / remove calls and applies them once per `requestAnimationFrame`. Use directly when you have a custom stream source that doesn't fit the `connect*Stream` shape.
59
-
60
- ### `connectElementStream` / `parseElementStream`
68
+ ```ts
69
+ const batcher = createBatcher(rowModel);
61
70
 
62
- `connectElementStream(grid, stream)` drives a grid from an `AsyncIterable<TRow>`. `parseElementStream(stream)` turns a string stream into an `AsyncIterable<TRow>` by parsing top-level array elements.
71
+ batcher.update([{ id: 42, changes: { status: "ready" } }]);
72
+ batcher.remove([7]);
73
+ // The scheduled RAF flushes automatically; flush() is also available.
74
+ ```
63
75
 
64
- ### `connectPartialStream` / `parsePartialStream`
76
+ The batcher preserves string and number IDs. It detaches the transaction payload before publication, clears the published batch before calling the model, and remains usable if `applyTransaction` throws.
65
77
 
66
- Same shape, but for incremental field updates: `parsePartialStream` emits `Partial<TRow>` values as the streaming parser fills each element; `connectPartialStream` upserts by `options.rowId`.
78
+ ## API
67
79
 
68
- ### Types
80
+ See **[`stream-adapter.api.md`](./stream-adapter.api.md)** for the generated public-API report.
69
81
 
70
- `GridLike<TRow>` is the structural grid contract — any object with `applyTransaction({ add?, update?, remove? })` works, including a custom adapter. `TransactionBatcher<TRow>` and `StreamConnection` are the handle types returned by the constructors above. `PartialStreamOptions` is the options bag for `connectPartialStream`.
82
+ - `createBatcher(rowModel)` returns a `TransactionBatcher<TRow, TRowId>`.
83
+ - `connectElementStream(rowModel, stream)` adds complete streamed rows.
84
+ - `connectPartialStream(rowModel, stream, options)` updates one fixed row ID.
85
+ - `parseElementStream(stream)` parses complete top-level array elements.
86
+ - `parsePartialStream(stream)` parses incremental partial objects.
87
+ - `RowModelLike<TRow, TRowId>` is the dependency-free structural model contract.
88
+ - `PartialStreamOptions<TRow, TRowId>`, `TransactionBatcher<TRow, TRowId>`, and `StreamConnection` describe the connector and lifecycle handles.
71
89
 
72
90
  ## License
73
91
 
package/dist/index.cjs CHANGED
@@ -3,39 +3,93 @@
3
3
  var jsonStream = require('@cacheplane/json-stream');
4
4
 
5
5
  // src/create-batcher.ts
6
- function createBatcher(grid) {
6
+ function createBatcher(rowModel) {
7
7
  let addBuffer = [];
8
8
  let updateBuffer = [];
9
9
  let removeBuffer = [];
10
10
  let rafId = null;
11
11
  let disposed = false;
12
+ let rejectError;
13
+ const errorListeners = /* @__PURE__ */ new Set();
14
+ let failed = false;
15
+ let failure;
16
+ const error = new Promise((_resolve, reject) => {
17
+ rejectError = reject;
18
+ });
19
+ error.catch(() => void 0);
20
+ function fail(error2) {
21
+ if (disposed) return;
22
+ disposed = true;
23
+ failed = true;
24
+ failure = error2;
25
+ if (rafId !== null) {
26
+ cancelAnimationFrame(rafId);
27
+ rafId = null;
28
+ }
29
+ addBuffer = [];
30
+ updateBuffer = [];
31
+ removeBuffer = [];
32
+ const listeners = [...errorListeners];
33
+ errorListeners.clear();
34
+ for (const listener of listeners) {
35
+ try {
36
+ listener(error2);
37
+ } catch {
38
+ }
39
+ }
40
+ rejectError(error2);
41
+ }
12
42
  function scheduleFlush() {
13
43
  if (rafId !== null || disposed) return;
14
44
  rafId = requestAnimationFrame(() => {
15
45
  rafId = null;
16
- applyBuffered();
46
+ if (disposed) return;
47
+ try {
48
+ applyBuffered();
49
+ } catch (error2) {
50
+ fail(error2);
51
+ }
17
52
  });
18
53
  }
19
54
  function applyBuffered() {
20
55
  if (addBuffer.length === 0 && updateBuffer.length === 0 && removeBuffer.length === 0) {
21
56
  return;
22
57
  }
23
- const tx = {};
24
- if (addBuffer.length > 0) {
25
- tx.add = addBuffer;
26
- addBuffer = [];
58
+ const bufferedAdds = addBuffer;
59
+ const bufferedUpdates = updateBuffer;
60
+ const bufferedRemovals = removeBuffer;
61
+ addBuffer = [];
62
+ updateBuffer = [];
63
+ removeBuffer = [];
64
+ const transaction = {};
65
+ if (bufferedAdds.length > 0) {
66
+ transaction.add = bufferedAdds.map((row) => ({ ...row }));
27
67
  }
28
- if (updateBuffer.length > 0) {
29
- tx.update = updateBuffer;
30
- updateBuffer = [];
68
+ if (bufferedUpdates.length > 0) {
69
+ transaction.update = bufferedUpdates.map(({ id, changes }) => ({
70
+ id,
71
+ changes: { ...changes }
72
+ }));
31
73
  }
32
- if (removeBuffer.length > 0) {
33
- tx.remove = removeBuffer;
34
- removeBuffer = [];
74
+ if (bufferedRemovals.length > 0) {
75
+ transaction.remove = [...bufferedRemovals];
35
76
  }
36
- grid.applyTransaction(tx);
77
+ rowModel.applyTransaction(transaction);
37
78
  }
38
79
  return {
80
+ error,
81
+ subscribeError(listener) {
82
+ if (failed) {
83
+ try {
84
+ listener(failure);
85
+ } catch {
86
+ }
87
+ return () => void 0;
88
+ }
89
+ if (disposed) return () => void 0;
90
+ errorListeners.add(listener);
91
+ return () => errorListeners.delete(listener);
92
+ },
39
93
  add(rows) {
40
94
  if (disposed) return;
41
95
  addBuffer.push(...rows);
@@ -69,14 +123,17 @@ function createBatcher(grid) {
69
123
  addBuffer = [];
70
124
  updateBuffer = [];
71
125
  removeBuffer = [];
126
+ errorListeners.clear();
72
127
  }
73
128
  };
74
129
  }
75
130
 
76
131
  // src/connect-element-stream.ts
77
- function connectElementStream(grid, stream) {
78
- const batcher = createBatcher(grid);
132
+ function connectElementStream(rowModel, stream) {
133
+ const iterator = stream[Symbol.asyncIterator]();
134
+ const batcher = createBatcher(rowModel);
79
135
  let disposed = false;
136
+ let sourceClosed = false;
80
137
  let resolveDone;
81
138
  let rejectDone;
82
139
  const done = new Promise((resolve, reject) => {
@@ -84,37 +141,102 @@ function connectElementStream(grid, stream) {
84
141
  rejectDone = reject;
85
142
  });
86
143
  done.catch(() => void 0);
87
- (async () => {
144
+ const closeSource = () => {
145
+ if (sourceClosed) return;
146
+ sourceClosed = true;
88
147
  try {
89
- for await (const element of stream) {
90
- if (disposed) break;
91
- batcher.add([element]);
148
+ const closing = iterator.return?.();
149
+ if (closing !== void 0)
150
+ void Promise.resolve(closing).catch(() => void 0);
151
+ } catch {
152
+ }
153
+ };
154
+ let settled = false;
155
+ const settle = (failure, flush = true, close = failure !== void 0) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ disposed = true;
159
+ if (close) closeSource();
160
+ let finalFailure = failure;
161
+ if (flush) {
162
+ try {
163
+ batcher.flush();
164
+ } catch (error) {
165
+ finalFailure ??= { error };
166
+ }
167
+ }
168
+ batcher.dispose();
169
+ if (finalFailure === void 0) resolveDone();
170
+ else rejectDone(finalFailure.error);
171
+ };
172
+ batcher.subscribeError((error) => {
173
+ settle({ error }, false, true);
174
+ });
175
+ void (async () => {
176
+ try {
177
+ while (!disposed) {
178
+ const result = await iterator.next();
179
+ if (disposed) return;
180
+ if (result.done) {
181
+ settle();
182
+ return;
183
+ }
184
+ batcher.add([result.value]);
92
185
  }
93
- batcher.flush();
94
- batcher.dispose();
95
- resolveDone();
96
186
  } catch (err) {
97
- batcher.flush();
98
- batcher.dispose();
99
- rejectDone(err);
187
+ settle({ error: err }, true, true);
100
188
  }
101
- })();
189
+ })().catch((error) => settle({ error }));
102
190
  return {
103
191
  done,
104
192
  dispose() {
105
193
  if (disposed) return;
106
- disposed = true;
107
- batcher.flush();
108
- batcher.dispose();
109
- resolveDone();
194
+ settle(void 0, true, true);
110
195
  }
111
196
  };
112
197
  }
113
198
 
114
199
  // src/connect-partial-stream.ts
115
- function connectPartialStream(grid, stream, options) {
116
- const batcher = createBatcher(grid);
200
+ function sameValueZero(left, right) {
201
+ return left === right || typeof left === "number" && typeof right === "number" && Number.isNaN(left) && Number.isNaN(right);
202
+ }
203
+ function connectPartialStream(rowModel, stream, options) {
204
+ const iterator = stream[Symbol.asyncIterator]();
205
+ const issueAwareRowModel = {
206
+ applyTransaction(transaction) {
207
+ const result = rowModel.applyTransaction(transaction);
208
+ if (result === void 0 || result.issues === void 0) return result;
209
+ let targetMissing = false;
210
+ for (const issue of result.issues) {
211
+ if (issue.code !== "unknown-update-id" || issue.rowId === void 0 || !sameValueZero(issue.rowId, options.rowId)) {
212
+ continue;
213
+ }
214
+ targetMissing = true;
215
+ options.onIssue?.({
216
+ code: "unknown-update-id",
217
+ rowId: options.rowId
218
+ });
219
+ }
220
+ if (targetMissing && options.createRow !== void 0) {
221
+ const updates = transaction.update ?? [];
222
+ const combinedChanges = {};
223
+ let hasTargetUpdate = false;
224
+ for (const update of updates) {
225
+ if (!sameValueZero(update.id, options.rowId)) continue;
226
+ Object.assign(combinedChanges, update.changes);
227
+ hasTargetUpdate = true;
228
+ }
229
+ if (hasTargetUpdate) {
230
+ const row = options.createRow(combinedChanges, options.rowId);
231
+ rowModel.applyTransaction({ add: [row] });
232
+ }
233
+ }
234
+ return result;
235
+ }
236
+ };
237
+ const batcher = createBatcher(issueAwareRowModel);
117
238
  let disposed = false;
239
+ let sourceClosed = false;
118
240
  let resolveDone;
119
241
  let rejectDone;
120
242
  const done = new Promise((resolve, reject) => {
@@ -122,29 +244,57 @@ function connectPartialStream(grid, stream, options) {
122
244
  rejectDone = reject;
123
245
  });
124
246
  done.catch(() => void 0);
125
- (async () => {
247
+ const closeSource = () => {
248
+ if (sourceClosed) return;
249
+ sourceClosed = true;
250
+ try {
251
+ const closing = iterator.return?.();
252
+ if (closing !== void 0)
253
+ void Promise.resolve(closing).catch(() => void 0);
254
+ } catch {
255
+ }
256
+ };
257
+ let settled = false;
258
+ const settle = (failure, flush = true, close = failure !== void 0) => {
259
+ if (settled) return;
260
+ settled = true;
261
+ disposed = true;
262
+ if (close) closeSource();
263
+ let finalFailure = failure;
264
+ if (flush) {
265
+ try {
266
+ batcher.flush();
267
+ } catch (error) {
268
+ finalFailure ??= { error };
269
+ }
270
+ }
271
+ batcher.dispose();
272
+ if (finalFailure === void 0) resolveDone();
273
+ else rejectDone(finalFailure.error);
274
+ };
275
+ batcher.subscribeError((error) => {
276
+ settle({ error }, false, true);
277
+ });
278
+ void (async () => {
126
279
  try {
127
- for await (const partial of stream) {
128
- if (disposed) break;
129
- batcher.update([{ ...partial, id: options.rowId }]);
280
+ while (!disposed) {
281
+ const result = await iterator.next();
282
+ if (disposed) return;
283
+ if (result.done) {
284
+ settle();
285
+ return;
286
+ }
287
+ batcher.update([{ id: options.rowId, changes: result.value }]);
130
288
  }
131
- batcher.flush();
132
- batcher.dispose();
133
- resolveDone();
134
289
  } catch (err) {
135
- batcher.flush();
136
- batcher.dispose();
137
- rejectDone(err);
290
+ settle({ error: err }, true, true);
138
291
  }
139
- })();
292
+ })().catch((error) => settle({ error }));
140
293
  return {
141
294
  done,
142
295
  dispose() {
143
296
  if (disposed) return;
144
- disposed = true;
145
- batcher.flush();
146
- batcher.dispose();
147
- resolveDone();
297
+ settle(void 0, true, true);
148
298
  }
149
299
  };
150
300
  }
package/dist/index.d.cts CHANGED
@@ -1,17 +1,24 @@
1
1
  /**
2
- * Structural type for any grid that supports `applyTransaction`. Avoids
3
- * hard coupling to `@pretable-internal/grid-core` so consumers can wire
4
- * up streaming against a custom grid implementation that conforms to the
5
- * same shape.
2
+ * Structural contract for a row model that accepts atomic row transactions.
3
+ * The adapter depends only on this ID-generic shape, so callers may pass a
4
+ * Pretable row model or a compatible custom implementation.
6
5
  *
7
6
  * @public
8
7
  */
9
- interface GridLike<TRow extends Record<string, unknown>> {
10
- applyTransaction(transaction: {
8
+ interface RowModelLike<TRow extends object, TRowId extends string | number> {
9
+ readonly applyTransaction: (transaction: {
11
10
  add?: TRow[];
12
- update?: Partial<TRow>[];
13
- remove?: string[];
14
- }): void;
11
+ update?: {
12
+ id: TRowId;
13
+ changes: Partial<TRow>;
14
+ }[];
15
+ remove?: TRowId[];
16
+ }) => void | {
17
+ readonly issues?: readonly {
18
+ readonly code: string;
19
+ readonly rowId?: TRowId;
20
+ }[];
21
+ };
15
22
  }
16
23
  /**
17
24
  * RAF-batched mutator returned by {@link createBatcher}. Buffer
@@ -22,12 +29,19 @@ interface GridLike<TRow extends Record<string, unknown>> {
22
29
  *
23
30
  * @public
24
31
  */
25
- interface TransactionBatcher<TRow extends Record<string, unknown>> {
26
- add(rows: TRow[]): void;
27
- update(patches: Partial<TRow>[]): void;
28
- remove(ids: string[]): void;
29
- flush(): void;
30
- dispose(): void;
32
+ interface TransactionBatcher<TRow extends object, TRowId extends string | number> {
33
+ /** Rejects with the exact model error from a scheduled RAF transaction. */
34
+ readonly error: Promise<never>;
35
+ /** Observes a scheduled model failure synchronously before later races. */
36
+ readonly subscribeError: (listener: (error: unknown) => void) => () => void;
37
+ readonly add: (rows: readonly TRow[]) => void;
38
+ readonly update: (patches: readonly {
39
+ readonly id: TRowId;
40
+ readonly changes: Partial<TRow>;
41
+ }[]) => void;
42
+ readonly remove: (ids: readonly TRowId[]) => void;
43
+ readonly flush: () => void;
44
+ readonly dispose: () => void;
31
45
  }
32
46
  /**
33
47
  * Handle returned by the `connect*Stream` functions. `done` resolves
@@ -37,31 +51,30 @@ interface TransactionBatcher<TRow extends Record<string, unknown>> {
37
51
  * @public
38
52
  */
39
53
  interface StreamConnection {
40
- done: Promise<void>;
41
- dispose(): void;
54
+ readonly done: Promise<void>;
55
+ readonly dispose: () => void;
42
56
  }
43
57
 
44
58
  /**
45
59
  * Create a `requestAnimationFrame`-batched mutator that coalesces
46
60
  * `add` / `update` / `remove` calls into a single `applyTransaction` per
47
- * frame. Use this when driving a grid from a stream that emits faster
48
- * than the browser can render — batching keeps DOM mutations to one per
49
- * frame regardless of stream rate.
61
+ * frame. Use this when driving a row model from a stream that emits faster
62
+ * than the browser can render.
50
63
  *
51
64
  * @example
52
65
  * ```ts
53
- * const batcher = createBatcher(grid);
66
+ * const batcher = createBatcher(rowModel);
54
67
  * batcher.add([{ id: "1", name: "Ada" }]);
55
- * batcher.update([{ id: "1", age: 36 }]);
68
+ * batcher.update([{ id: "1", changes: { age: 36 } }]);
56
69
  * batcher.flush(); // optional — RAF will flush automatically
57
70
  * ```
58
71
  *
59
72
  * @public
60
73
  */
61
- declare function createBatcher<TRow extends Record<string, unknown>>(grid: GridLike<TRow>): TransactionBatcher<TRow>;
74
+ declare function createBatcher<TRow extends object, TRowId extends string | number>(rowModel: RowModelLike<TRow, TRowId>): TransactionBatcher<TRow, TRowId>;
62
75
 
63
76
  /**
64
- * Drive a grid from an `AsyncIterable<TRow>`. Each yielded row is added
77
+ * Drive a row model from an `AsyncIterable<TRow>`. Each yielded row is added
65
78
  * via a {@link createBatcher | RAF batcher}; the returned
66
79
  * {@link StreamConnection} resolves `done` when the stream ends and
67
80
  * supports `dispose()` for early cancellation.
@@ -71,34 +84,35 @@ declare function createBatcher<TRow extends Record<string, unknown>>(grid: GridL
71
84
  *
72
85
  * @public
73
86
  */
74
- declare function connectElementStream<TRow extends Record<string, unknown>>(grid: GridLike<TRow>, stream: AsyncIterable<TRow>): StreamConnection;
87
+ declare function connectElementStream<TRow extends object, TRowId extends string | number>(rowModel: RowModelLike<TRow, TRowId>, stream: AsyncIterable<TRow>): StreamConnection;
75
88
 
76
89
  /**
77
- * Options for {@link connectPartialStream}. `rowId` names the field on
78
- * each partial row used to identify it for upsert (the field must be
79
- * present in every emitted partial partials missing the rowId are
80
- * ignored).
90
+ * Options for {@link connectPartialStream}. `rowId` is the fixed target for
91
+ * every partial update. Unknown targets are reported through `onIssue`; an
92
+ * optional `createRow` factory may turn the partial into a complete row to add.
81
93
  *
82
94
  * @public
83
95
  */
84
- interface PartialStreamOptions {
85
- rowId: string;
96
+ interface PartialStreamOptions<TRow extends object, TRowId extends string | number> {
97
+ readonly rowId: TRowId;
98
+ readonly onIssue?: (issue: {
99
+ readonly code: "unknown-update-id";
100
+ readonly rowId: TRowId;
101
+ }) => void;
102
+ readonly createRow?: (partial: Partial<TRow>, id: TRowId) => TRow;
86
103
  }
87
104
  /**
88
- * Drive a grid from an `AsyncIterable<Partial<TRow>>`. Each yielded
89
- * partial is upserted by `options.rowId` new rowIds are added,
90
- * existing rowIds are merged via `applyTransaction.update`. Useful when
91
- * a stream emits incremental field updates (e.g., partial JSON parses)
92
- * rather than complete rows.
105
+ * Drive a row model from an `AsyncIterable<Partial<TRow>>`. Every yielded
106
+ * partial updates the fixed `options.rowId` via a RAF-batched
107
+ * `{ id, changes }` transaction. A missing target is reported instead of
108
+ * fabricating a row; provide `createRow` when the stream is allowed to add one.
93
109
  *
94
110
  * Pair with {@link parsePartialStream} for end-to-end partial-update
95
111
  * streaming over UTF-8 strings.
96
112
  *
97
113
  * @public
98
114
  */
99
- declare function connectPartialStream<TRow extends Record<string, unknown> & {
100
- id: string;
101
- }>(grid: GridLike<TRow>, stream: AsyncIterable<Partial<TRow>>, options: PartialStreamOptions): StreamConnection;
115
+ declare function connectPartialStream<TRow extends object, TRowId extends string | number>(rowModel: RowModelLike<TRow, TRowId>, stream: AsyncIterable<Partial<TRow>>, options: PartialStreamOptions<TRow, TRowId>): StreamConnection;
102
116
 
103
117
  /**
104
118
  * Parse a UTF-8 string stream into an `AsyncIterable<TRow>`. Built on
@@ -126,4 +140,4 @@ declare function parseElementStream<TRow>(stream: AsyncIterable<string>): AsyncI
126
140
  */
127
141
  declare function parsePartialStream<TRow>(stream: AsyncIterable<string>): AsyncIterable<Partial<TRow>>;
128
142
 
129
- export { type GridLike, type PartialStreamOptions, type StreamConnection, type TransactionBatcher, connectElementStream, connectPartialStream, createBatcher, parseElementStream, parsePartialStream };
143
+ export { type PartialStreamOptions, type RowModelLike, type StreamConnection, type TransactionBatcher, connectElementStream, connectPartialStream, createBatcher, parseElementStream, parsePartialStream };
package/dist/index.d.ts CHANGED
@@ -1,17 +1,24 @@
1
1
  /**
2
- * Structural type for any grid that supports `applyTransaction`. Avoids
3
- * hard coupling to `@pretable-internal/grid-core` so consumers can wire
4
- * up streaming against a custom grid implementation that conforms to the
5
- * same shape.
2
+ * Structural contract for a row model that accepts atomic row transactions.
3
+ * The adapter depends only on this ID-generic shape, so callers may pass a
4
+ * Pretable row model or a compatible custom implementation.
6
5
  *
7
6
  * @public
8
7
  */
9
- interface GridLike<TRow extends Record<string, unknown>> {
10
- applyTransaction(transaction: {
8
+ interface RowModelLike<TRow extends object, TRowId extends string | number> {
9
+ readonly applyTransaction: (transaction: {
11
10
  add?: TRow[];
12
- update?: Partial<TRow>[];
13
- remove?: string[];
14
- }): void;
11
+ update?: {
12
+ id: TRowId;
13
+ changes: Partial<TRow>;
14
+ }[];
15
+ remove?: TRowId[];
16
+ }) => void | {
17
+ readonly issues?: readonly {
18
+ readonly code: string;
19
+ readonly rowId?: TRowId;
20
+ }[];
21
+ };
15
22
  }
16
23
  /**
17
24
  * RAF-batched mutator returned by {@link createBatcher}. Buffer
@@ -22,12 +29,19 @@ interface GridLike<TRow extends Record<string, unknown>> {
22
29
  *
23
30
  * @public
24
31
  */
25
- interface TransactionBatcher<TRow extends Record<string, unknown>> {
26
- add(rows: TRow[]): void;
27
- update(patches: Partial<TRow>[]): void;
28
- remove(ids: string[]): void;
29
- flush(): void;
30
- dispose(): void;
32
+ interface TransactionBatcher<TRow extends object, TRowId extends string | number> {
33
+ /** Rejects with the exact model error from a scheduled RAF transaction. */
34
+ readonly error: Promise<never>;
35
+ /** Observes a scheduled model failure synchronously before later races. */
36
+ readonly subscribeError: (listener: (error: unknown) => void) => () => void;
37
+ readonly add: (rows: readonly TRow[]) => void;
38
+ readonly update: (patches: readonly {
39
+ readonly id: TRowId;
40
+ readonly changes: Partial<TRow>;
41
+ }[]) => void;
42
+ readonly remove: (ids: readonly TRowId[]) => void;
43
+ readonly flush: () => void;
44
+ readonly dispose: () => void;
31
45
  }
32
46
  /**
33
47
  * Handle returned by the `connect*Stream` functions. `done` resolves
@@ -37,31 +51,30 @@ interface TransactionBatcher<TRow extends Record<string, unknown>> {
37
51
  * @public
38
52
  */
39
53
  interface StreamConnection {
40
- done: Promise<void>;
41
- dispose(): void;
54
+ readonly done: Promise<void>;
55
+ readonly dispose: () => void;
42
56
  }
43
57
 
44
58
  /**
45
59
  * Create a `requestAnimationFrame`-batched mutator that coalesces
46
60
  * `add` / `update` / `remove` calls into a single `applyTransaction` per
47
- * frame. Use this when driving a grid from a stream that emits faster
48
- * than the browser can render — batching keeps DOM mutations to one per
49
- * frame regardless of stream rate.
61
+ * frame. Use this when driving a row model from a stream that emits faster
62
+ * than the browser can render.
50
63
  *
51
64
  * @example
52
65
  * ```ts
53
- * const batcher = createBatcher(grid);
66
+ * const batcher = createBatcher(rowModel);
54
67
  * batcher.add([{ id: "1", name: "Ada" }]);
55
- * batcher.update([{ id: "1", age: 36 }]);
68
+ * batcher.update([{ id: "1", changes: { age: 36 } }]);
56
69
  * batcher.flush(); // optional — RAF will flush automatically
57
70
  * ```
58
71
  *
59
72
  * @public
60
73
  */
61
- declare function createBatcher<TRow extends Record<string, unknown>>(grid: GridLike<TRow>): TransactionBatcher<TRow>;
74
+ declare function createBatcher<TRow extends object, TRowId extends string | number>(rowModel: RowModelLike<TRow, TRowId>): TransactionBatcher<TRow, TRowId>;
62
75
 
63
76
  /**
64
- * Drive a grid from an `AsyncIterable<TRow>`. Each yielded row is added
77
+ * Drive a row model from an `AsyncIterable<TRow>`. Each yielded row is added
65
78
  * via a {@link createBatcher | RAF batcher}; the returned
66
79
  * {@link StreamConnection} resolves `done` when the stream ends and
67
80
  * supports `dispose()` for early cancellation.
@@ -71,34 +84,35 @@ declare function createBatcher<TRow extends Record<string, unknown>>(grid: GridL
71
84
  *
72
85
  * @public
73
86
  */
74
- declare function connectElementStream<TRow extends Record<string, unknown>>(grid: GridLike<TRow>, stream: AsyncIterable<TRow>): StreamConnection;
87
+ declare function connectElementStream<TRow extends object, TRowId extends string | number>(rowModel: RowModelLike<TRow, TRowId>, stream: AsyncIterable<TRow>): StreamConnection;
75
88
 
76
89
  /**
77
- * Options for {@link connectPartialStream}. `rowId` names the field on
78
- * each partial row used to identify it for upsert (the field must be
79
- * present in every emitted partial partials missing the rowId are
80
- * ignored).
90
+ * Options for {@link connectPartialStream}. `rowId` is the fixed target for
91
+ * every partial update. Unknown targets are reported through `onIssue`; an
92
+ * optional `createRow` factory may turn the partial into a complete row to add.
81
93
  *
82
94
  * @public
83
95
  */
84
- interface PartialStreamOptions {
85
- rowId: string;
96
+ interface PartialStreamOptions<TRow extends object, TRowId extends string | number> {
97
+ readonly rowId: TRowId;
98
+ readonly onIssue?: (issue: {
99
+ readonly code: "unknown-update-id";
100
+ readonly rowId: TRowId;
101
+ }) => void;
102
+ readonly createRow?: (partial: Partial<TRow>, id: TRowId) => TRow;
86
103
  }
87
104
  /**
88
- * Drive a grid from an `AsyncIterable<Partial<TRow>>`. Each yielded
89
- * partial is upserted by `options.rowId` new rowIds are added,
90
- * existing rowIds are merged via `applyTransaction.update`. Useful when
91
- * a stream emits incremental field updates (e.g., partial JSON parses)
92
- * rather than complete rows.
105
+ * Drive a row model from an `AsyncIterable<Partial<TRow>>`. Every yielded
106
+ * partial updates the fixed `options.rowId` via a RAF-batched
107
+ * `{ id, changes }` transaction. A missing target is reported instead of
108
+ * fabricating a row; provide `createRow` when the stream is allowed to add one.
93
109
  *
94
110
  * Pair with {@link parsePartialStream} for end-to-end partial-update
95
111
  * streaming over UTF-8 strings.
96
112
  *
97
113
  * @public
98
114
  */
99
- declare function connectPartialStream<TRow extends Record<string, unknown> & {
100
- id: string;
101
- }>(grid: GridLike<TRow>, stream: AsyncIterable<Partial<TRow>>, options: PartialStreamOptions): StreamConnection;
115
+ declare function connectPartialStream<TRow extends object, TRowId extends string | number>(rowModel: RowModelLike<TRow, TRowId>, stream: AsyncIterable<Partial<TRow>>, options: PartialStreamOptions<TRow, TRowId>): StreamConnection;
102
116
 
103
117
  /**
104
118
  * Parse a UTF-8 string stream into an `AsyncIterable<TRow>`. Built on
@@ -126,4 +140,4 @@ declare function parseElementStream<TRow>(stream: AsyncIterable<string>): AsyncI
126
140
  */
127
141
  declare function parsePartialStream<TRow>(stream: AsyncIterable<string>): AsyncIterable<Partial<TRow>>;
128
142
 
129
- export { type GridLike, type PartialStreamOptions, type StreamConnection, type TransactionBatcher, connectElementStream, connectPartialStream, createBatcher, parseElementStream, parsePartialStream };
143
+ export { type PartialStreamOptions, type RowModelLike, type StreamConnection, type TransactionBatcher, connectElementStream, connectPartialStream, createBatcher, parseElementStream, parsePartialStream };
package/dist/index.mjs CHANGED
@@ -1,39 +1,93 @@
1
1
  import { create, push, isArrayNode, isComplete, finish, isObjectNode } from '@cacheplane/json-stream';
2
2
 
3
3
  // src/create-batcher.ts
4
- function createBatcher(grid) {
4
+ function createBatcher(rowModel) {
5
5
  let addBuffer = [];
6
6
  let updateBuffer = [];
7
7
  let removeBuffer = [];
8
8
  let rafId = null;
9
9
  let disposed = false;
10
+ let rejectError;
11
+ const errorListeners = /* @__PURE__ */ new Set();
12
+ let failed = false;
13
+ let failure;
14
+ const error = new Promise((_resolve, reject) => {
15
+ rejectError = reject;
16
+ });
17
+ error.catch(() => void 0);
18
+ function fail(error2) {
19
+ if (disposed) return;
20
+ disposed = true;
21
+ failed = true;
22
+ failure = error2;
23
+ if (rafId !== null) {
24
+ cancelAnimationFrame(rafId);
25
+ rafId = null;
26
+ }
27
+ addBuffer = [];
28
+ updateBuffer = [];
29
+ removeBuffer = [];
30
+ const listeners = [...errorListeners];
31
+ errorListeners.clear();
32
+ for (const listener of listeners) {
33
+ try {
34
+ listener(error2);
35
+ } catch {
36
+ }
37
+ }
38
+ rejectError(error2);
39
+ }
10
40
  function scheduleFlush() {
11
41
  if (rafId !== null || disposed) return;
12
42
  rafId = requestAnimationFrame(() => {
13
43
  rafId = null;
14
- applyBuffered();
44
+ if (disposed) return;
45
+ try {
46
+ applyBuffered();
47
+ } catch (error2) {
48
+ fail(error2);
49
+ }
15
50
  });
16
51
  }
17
52
  function applyBuffered() {
18
53
  if (addBuffer.length === 0 && updateBuffer.length === 0 && removeBuffer.length === 0) {
19
54
  return;
20
55
  }
21
- const tx = {};
22
- if (addBuffer.length > 0) {
23
- tx.add = addBuffer;
24
- addBuffer = [];
56
+ const bufferedAdds = addBuffer;
57
+ const bufferedUpdates = updateBuffer;
58
+ const bufferedRemovals = removeBuffer;
59
+ addBuffer = [];
60
+ updateBuffer = [];
61
+ removeBuffer = [];
62
+ const transaction = {};
63
+ if (bufferedAdds.length > 0) {
64
+ transaction.add = bufferedAdds.map((row) => ({ ...row }));
25
65
  }
26
- if (updateBuffer.length > 0) {
27
- tx.update = updateBuffer;
28
- updateBuffer = [];
66
+ if (bufferedUpdates.length > 0) {
67
+ transaction.update = bufferedUpdates.map(({ id, changes }) => ({
68
+ id,
69
+ changes: { ...changes }
70
+ }));
29
71
  }
30
- if (removeBuffer.length > 0) {
31
- tx.remove = removeBuffer;
32
- removeBuffer = [];
72
+ if (bufferedRemovals.length > 0) {
73
+ transaction.remove = [...bufferedRemovals];
33
74
  }
34
- grid.applyTransaction(tx);
75
+ rowModel.applyTransaction(transaction);
35
76
  }
36
77
  return {
78
+ error,
79
+ subscribeError(listener) {
80
+ if (failed) {
81
+ try {
82
+ listener(failure);
83
+ } catch {
84
+ }
85
+ return () => void 0;
86
+ }
87
+ if (disposed) return () => void 0;
88
+ errorListeners.add(listener);
89
+ return () => errorListeners.delete(listener);
90
+ },
37
91
  add(rows) {
38
92
  if (disposed) return;
39
93
  addBuffer.push(...rows);
@@ -67,14 +121,17 @@ function createBatcher(grid) {
67
121
  addBuffer = [];
68
122
  updateBuffer = [];
69
123
  removeBuffer = [];
124
+ errorListeners.clear();
70
125
  }
71
126
  };
72
127
  }
73
128
 
74
129
  // src/connect-element-stream.ts
75
- function connectElementStream(grid, stream) {
76
- const batcher = createBatcher(grid);
130
+ function connectElementStream(rowModel, stream) {
131
+ const iterator = stream[Symbol.asyncIterator]();
132
+ const batcher = createBatcher(rowModel);
77
133
  let disposed = false;
134
+ let sourceClosed = false;
78
135
  let resolveDone;
79
136
  let rejectDone;
80
137
  const done = new Promise((resolve, reject) => {
@@ -82,37 +139,102 @@ function connectElementStream(grid, stream) {
82
139
  rejectDone = reject;
83
140
  });
84
141
  done.catch(() => void 0);
85
- (async () => {
142
+ const closeSource = () => {
143
+ if (sourceClosed) return;
144
+ sourceClosed = true;
86
145
  try {
87
- for await (const element of stream) {
88
- if (disposed) break;
89
- batcher.add([element]);
146
+ const closing = iterator.return?.();
147
+ if (closing !== void 0)
148
+ void Promise.resolve(closing).catch(() => void 0);
149
+ } catch {
150
+ }
151
+ };
152
+ let settled = false;
153
+ const settle = (failure, flush = true, close = failure !== void 0) => {
154
+ if (settled) return;
155
+ settled = true;
156
+ disposed = true;
157
+ if (close) closeSource();
158
+ let finalFailure = failure;
159
+ if (flush) {
160
+ try {
161
+ batcher.flush();
162
+ } catch (error) {
163
+ finalFailure ??= { error };
164
+ }
165
+ }
166
+ batcher.dispose();
167
+ if (finalFailure === void 0) resolveDone();
168
+ else rejectDone(finalFailure.error);
169
+ };
170
+ batcher.subscribeError((error) => {
171
+ settle({ error }, false, true);
172
+ });
173
+ void (async () => {
174
+ try {
175
+ while (!disposed) {
176
+ const result = await iterator.next();
177
+ if (disposed) return;
178
+ if (result.done) {
179
+ settle();
180
+ return;
181
+ }
182
+ batcher.add([result.value]);
90
183
  }
91
- batcher.flush();
92
- batcher.dispose();
93
- resolveDone();
94
184
  } catch (err) {
95
- batcher.flush();
96
- batcher.dispose();
97
- rejectDone(err);
185
+ settle({ error: err }, true, true);
98
186
  }
99
- })();
187
+ })().catch((error) => settle({ error }));
100
188
  return {
101
189
  done,
102
190
  dispose() {
103
191
  if (disposed) return;
104
- disposed = true;
105
- batcher.flush();
106
- batcher.dispose();
107
- resolveDone();
192
+ settle(void 0, true, true);
108
193
  }
109
194
  };
110
195
  }
111
196
 
112
197
  // src/connect-partial-stream.ts
113
- function connectPartialStream(grid, stream, options) {
114
- const batcher = createBatcher(grid);
198
+ function sameValueZero(left, right) {
199
+ return left === right || typeof left === "number" && typeof right === "number" && Number.isNaN(left) && Number.isNaN(right);
200
+ }
201
+ function connectPartialStream(rowModel, stream, options) {
202
+ const iterator = stream[Symbol.asyncIterator]();
203
+ const issueAwareRowModel = {
204
+ applyTransaction(transaction) {
205
+ const result = rowModel.applyTransaction(transaction);
206
+ if (result === void 0 || result.issues === void 0) return result;
207
+ let targetMissing = false;
208
+ for (const issue of result.issues) {
209
+ if (issue.code !== "unknown-update-id" || issue.rowId === void 0 || !sameValueZero(issue.rowId, options.rowId)) {
210
+ continue;
211
+ }
212
+ targetMissing = true;
213
+ options.onIssue?.({
214
+ code: "unknown-update-id",
215
+ rowId: options.rowId
216
+ });
217
+ }
218
+ if (targetMissing && options.createRow !== void 0) {
219
+ const updates = transaction.update ?? [];
220
+ const combinedChanges = {};
221
+ let hasTargetUpdate = false;
222
+ for (const update of updates) {
223
+ if (!sameValueZero(update.id, options.rowId)) continue;
224
+ Object.assign(combinedChanges, update.changes);
225
+ hasTargetUpdate = true;
226
+ }
227
+ if (hasTargetUpdate) {
228
+ const row = options.createRow(combinedChanges, options.rowId);
229
+ rowModel.applyTransaction({ add: [row] });
230
+ }
231
+ }
232
+ return result;
233
+ }
234
+ };
235
+ const batcher = createBatcher(issueAwareRowModel);
115
236
  let disposed = false;
237
+ let sourceClosed = false;
116
238
  let resolveDone;
117
239
  let rejectDone;
118
240
  const done = new Promise((resolve, reject) => {
@@ -120,29 +242,57 @@ function connectPartialStream(grid, stream, options) {
120
242
  rejectDone = reject;
121
243
  });
122
244
  done.catch(() => void 0);
123
- (async () => {
245
+ const closeSource = () => {
246
+ if (sourceClosed) return;
247
+ sourceClosed = true;
248
+ try {
249
+ const closing = iterator.return?.();
250
+ if (closing !== void 0)
251
+ void Promise.resolve(closing).catch(() => void 0);
252
+ } catch {
253
+ }
254
+ };
255
+ let settled = false;
256
+ const settle = (failure, flush = true, close = failure !== void 0) => {
257
+ if (settled) return;
258
+ settled = true;
259
+ disposed = true;
260
+ if (close) closeSource();
261
+ let finalFailure = failure;
262
+ if (flush) {
263
+ try {
264
+ batcher.flush();
265
+ } catch (error) {
266
+ finalFailure ??= { error };
267
+ }
268
+ }
269
+ batcher.dispose();
270
+ if (finalFailure === void 0) resolveDone();
271
+ else rejectDone(finalFailure.error);
272
+ };
273
+ batcher.subscribeError((error) => {
274
+ settle({ error }, false, true);
275
+ });
276
+ void (async () => {
124
277
  try {
125
- for await (const partial of stream) {
126
- if (disposed) break;
127
- batcher.update([{ ...partial, id: options.rowId }]);
278
+ while (!disposed) {
279
+ const result = await iterator.next();
280
+ if (disposed) return;
281
+ if (result.done) {
282
+ settle();
283
+ return;
284
+ }
285
+ batcher.update([{ id: options.rowId, changes: result.value }]);
128
286
  }
129
- batcher.flush();
130
- batcher.dispose();
131
- resolveDone();
132
287
  } catch (err) {
133
- batcher.flush();
134
- batcher.dispose();
135
- rejectDone(err);
288
+ settle({ error: err }, true, true);
136
289
  }
137
- })();
290
+ })().catch((error) => settle({ error }));
138
291
  return {
139
292
  done,
140
293
  dispose() {
141
294
  if (disposed) return;
142
- disposed = true;
143
- batcher.flush();
144
- batcher.dispose();
145
- resolveDone();
295
+ settle(void 0, true, true);
146
296
  }
147
297
  };
148
298
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pretable/stream-adapter",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Streaming row adapters for Pretable.",
5
5
  "license": "MIT",
6
6
  "repository": {