@durable-streams/state 0.2.6 → 0.2.7

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/index.cjs CHANGED
@@ -120,6 +120,16 @@ var MaterializedState = class {
120
120
  //#endregion
121
121
  //#region src/stream-db.ts
122
122
  /**
123
+ * Build a TanStack collection id for a StreamDB collection.
124
+ *
125
+ * Collection ids must be unique per source stream, not just per schema key,
126
+ * otherwise joining the same collection name from two different streams can
127
+ * collapse to one logical source inside TanStack DB.
128
+ */
129
+ function getStreamDBCollectionId(streamUrl, collectionName) {
130
+ return `stream-db:${streamUrl}:${collectionName}`;
131
+ }
132
+ /**
123
133
  * Internal event dispatcher that routes stream events to collection handlers
124
134
  */
125
135
  var EventDispatcher = class {
@@ -140,6 +150,13 @@ var EventDispatcher = class {
140
150
  txidResolvers = new Map();
141
151
  /** Track existing keys per collection for upsert logic */
142
152
  existingKeys = new Map();
153
+ /** Global sequence counter for insertion ordering */
154
+ seq = 0;
155
+ comparableRow(row) {
156
+ const clone = { ...row };
157
+ delete clone._seq;
158
+ return clone;
159
+ }
143
160
  /**
144
161
  * Register a handler for a specific event type
145
162
  */
@@ -151,8 +168,9 @@ var EventDispatcher = class {
151
168
  * Dispatch a change event to the appropriate collection.
152
169
  * Writes are buffered until commit() is called via markUpToDate().
153
170
  */
154
- dispatchChange(event) {
171
+ dispatchChange(event, cursor) {
155
172
  if (!isChangeEvent(event)) return;
173
+ const eventCursor = event.headers.offset ?? cursor;
156
174
  if (event.headers.txid && typeof event.headers.txid === `string`) this.pendingTxids.add(event.headers.txid);
157
175
  const handler = this.handlers.get(event.type);
158
176
  if (!handler) return;
@@ -163,6 +181,7 @@ var EventDispatcher = class {
163
181
  const originalValue = event.value ?? {};
164
182
  const value = { ...originalValue };
165
183
  value[handler.primaryKey] = event.key;
184
+ value._seq = this.seq++;
166
185
  if (!this.pendingHandlers.has(handler)) {
167
186
  handler.begin();
168
187
  this.pendingHandlers.add(handler);
@@ -173,10 +192,15 @@ var EventDispatcher = class {
173
192
  operation = existing ? `update` : `insert`;
174
193
  }
175
194
  const keys = this.existingKeys.get(event.type);
195
+ if (operation === `insert` && keys?.has(event.key)) operation = `update`;
196
+ else if (operation === `insert` && typeof event.key === `string`) {
197
+ const existingValue = handler.read(event.key);
198
+ if (existingValue && (0, __tanstack_db.deepEquals)(this.comparableRow(existingValue), this.comparableRow(value))) operation = `update`;
199
+ }
176
200
  if (operation === `insert` || operation === `update`) keys?.add(event.key);
177
201
  else keys?.delete(event.key);
178
202
  try {
179
- handler.write(value, operation);
203
+ handler.write(value, operation, eventCursor);
180
204
  } catch (error) {
181
205
  console.error(`[StreamDB] Error in handler.write():`, error);
182
206
  console.error(`[StreamDB] Event that caused error:`, {
@@ -294,16 +318,17 @@ var EventDispatcher = class {
294
318
  /**
295
319
  * Create a sync config for a stream-backed collection
296
320
  */
297
- function createStreamSyncConfig(eventType, dispatcher, primaryKey) {
321
+ function createStreamSyncConfig(eventType, dispatcher, primaryKey, read) {
298
322
  return { sync: ({ begin, write, commit, markReady, truncate }) => {
299
323
  dispatcher.registerHandler(eventType, {
300
324
  begin,
301
- write: (value, type) => {
325
+ write: (value, type, _cursor) => {
302
326
  write({
303
327
  value,
304
328
  type
305
329
  });
306
330
  },
331
+ read: (key) => read(key),
307
332
  commit,
308
333
  markReady,
309
334
  truncate,
@@ -448,16 +473,20 @@ function createStateSchema(collections) {
448
473
  * ```
449
474
  */
450
475
  function createStreamDB(options) {
451
- const { streamOptions, state, actions: actionsFactory, live = true } = options;
452
- const stream = new __durable_streams_client.DurableStream(streamOptions);
476
+ const { streamOptions, state, actions: actionsFactory, live = true, onEvent, onBeforeBatch, onBatch } = options;
477
+ const stream = options.stream ?? (() => {
478
+ if (!streamOptions) throw new Error(`createStreamDB requires stream or streamOptions`);
479
+ return new __durable_streams_client.DurableStream(streamOptions);
480
+ })();
453
481
  const dispatcher = new EventDispatcher();
482
+ const streamIdentity = stream.url;
454
483
  const collectionInstances = {};
455
484
  for (const [name, definition] of Object.entries(state)) {
456
- const collection = (0, __tanstack_db.createCollection)({
457
- id: `stream-db:${name}`,
485
+ let collection = (0, __tanstack_db.createCollection)({
486
+ id: getStreamDBCollectionId(streamIdentity, name),
458
487
  schema: definition.schema,
459
488
  getKey: (item) => String(item[definition.primaryKey]),
460
- sync: createStreamSyncConfig(definition.type, dispatcher, definition.primaryKey),
489
+ sync: createStreamSyncConfig(definition.type, dispatcher, definition.primaryKey, (key) => collection.get(key)),
461
490
  startSync: true,
462
491
  gcTime: 0
463
492
  });
@@ -466,6 +495,12 @@ function createStreamDB(options) {
466
495
  let streamResponse = null;
467
496
  const abortController = new AbortController();
468
497
  let consumerStarted = false;
498
+ let lastConsumedOffset = `-1`;
499
+ const isAbortLikeError = (err) => {
500
+ if (abortController.signal.aborted) return true;
501
+ if (!(err instanceof Error)) return false;
502
+ return err.name === `AbortError` || err.name === `FetchBackoffAbortError` || err.message === `Stream request was aborted`;
503
+ };
469
504
  /**
470
505
  * Start the stream consumer (called lazily on first preload)
471
506
  */
@@ -474,16 +509,29 @@ function createStreamDB(options) {
474
509
  consumerStarted = true;
475
510
  streamResponse = await stream.stream({
476
511
  live,
512
+ json: true,
477
513
  signal: abortController.signal
478
514
  });
515
+ streamResponse.closed.catch((err) => {
516
+ if (isAbortLikeError(err)) return void 0;
517
+ const error = err instanceof Error ? err : new Error(String(err));
518
+ console.error(`[StreamDB] Stream consumer closed unexpectedly:`, error);
519
+ dispatcher.rejectAll(error);
520
+ return void 0;
521
+ });
522
+ lastConsumedOffset = streamResponse.offset;
479
523
  streamResponse.subscribeJson((batch) => {
480
524
  try {
481
- for (const event of batch.items) if (isChangeEvent(event)) dispatcher.dispatchChange(event);
482
- else if (isControlEvent(event)) dispatcher.dispatchControl(event);
483
- if (batch.upToDate) dispatcher.markUpToDate();
525
+ lastConsumedOffset = batch.offset;
526
+ onBeforeBatch?.(batch);
527
+ for (const event of batch.items) if (isChangeEvent(event)) {
528
+ dispatcher.dispatchChange(event, batch.offset);
529
+ onEvent?.(event);
530
+ } else if (isControlEvent(event)) dispatcher.dispatchControl(event);
531
+ onBatch?.(batch);
532
+ if (batch.upToDate || dispatcher.ready) dispatcher.markUpToDate();
484
533
  } catch (error) {
485
534
  console.error(`[StreamDB] Error processing batch:`, error);
486
- console.error(`[StreamDB] Failed batch:`, batch);
487
535
  dispatcher.rejectAll(error);
488
536
  abortController.abort();
489
537
  }
@@ -492,6 +540,9 @@ function createStreamDB(options) {
492
540
  };
493
541
  const dbMethods = {
494
542
  stream,
543
+ get offset() {
544
+ return lastConsumedOffset;
545
+ },
495
546
  preload: async () => {
496
547
  await startConsumer();
497
548
  await dispatcher.waitForUpToDate();
@@ -502,10 +553,14 @@ function createStreamDB(options) {
502
553
  },
503
554
  utils: { awaitTxId: (txid, timeout) => dispatcher.awaitTxId(txid, timeout) }
504
555
  };
505
- const db = {
506
- collections: collectionInstances,
507
- ...dbMethods
508
- };
556
+ const db = Object.create(null);
557
+ Object.defineProperty(db, `collections`, {
558
+ value: collectionInstances,
559
+ enumerable: true,
560
+ configurable: false,
561
+ writable: false
562
+ });
563
+ Object.defineProperties(db, Object.getOwnPropertyDescriptors(dbMethods));
509
564
  if (actionsFactory) {
510
565
  const actionDefs = actionsFactory({
511
566
  db,
@@ -516,10 +571,13 @@ function createStreamDB(options) {
516
571
  onMutate: def.onMutate,
517
572
  mutationFn: def.mutationFn
518
573
  });
519
- return {
520
- ...db,
521
- actions: wrappedActions
522
- };
574
+ Object.defineProperty(db, `actions`, {
575
+ value: wrappedActions,
576
+ enumerable: true,
577
+ configurable: false,
578
+ writable: false
579
+ });
580
+ return db;
523
581
  }
524
582
  return db;
525
583
  }
@@ -538,6 +596,18 @@ Object.defineProperty(exports, 'avg', {
538
596
  return __tanstack_db.avg;
539
597
  }
540
598
  });
599
+ Object.defineProperty(exports, 'coalesce', {
600
+ enumerable: true,
601
+ get: function () {
602
+ return __tanstack_db.coalesce;
603
+ }
604
+ });
605
+ Object.defineProperty(exports, 'concat', {
606
+ enumerable: true,
607
+ get: function () {
608
+ return __tanstack_db.concat;
609
+ }
610
+ });
541
611
  Object.defineProperty(exports, 'count', {
542
612
  enumerable: true,
543
613
  get: function () {
@@ -550,6 +620,12 @@ Object.defineProperty(exports, 'createCollection', {
550
620
  return __tanstack_db.createCollection;
551
621
  }
552
622
  });
623
+ Object.defineProperty(exports, 'createLiveQueryCollection', {
624
+ enumerable: true,
625
+ get: function () {
626
+ return __tanstack_db.createLiveQueryCollection;
627
+ }
628
+ });
553
629
  Object.defineProperty(exports, 'createOptimisticAction', {
554
630
  enumerable: true,
555
631
  get: function () {
@@ -558,12 +634,25 @@ Object.defineProperty(exports, 'createOptimisticAction', {
558
634
  });
559
635
  exports.createStateSchema = createStateSchema
560
636
  exports.createStreamDB = createStreamDB
637
+ Object.defineProperty(exports, 'createTransaction', {
638
+ enumerable: true,
639
+ get: function () {
640
+ return __tanstack_db.createTransaction;
641
+ }
642
+ });
643
+ Object.defineProperty(exports, 'deepEquals', {
644
+ enumerable: true,
645
+ get: function () {
646
+ return __tanstack_db.deepEquals;
647
+ }
648
+ });
561
649
  Object.defineProperty(exports, 'eq', {
562
650
  enumerable: true,
563
651
  get: function () {
564
652
  return __tanstack_db.eq;
565
653
  }
566
654
  });
655
+ exports.getStreamDBCollectionId = getStreamDBCollectionId
567
656
  Object.defineProperty(exports, 'gt', {
568
657
  enumerable: true,
569
658
  get: function () {
@@ -608,6 +697,12 @@ Object.defineProperty(exports, 'like', {
608
697
  return __tanstack_db.like;
609
698
  }
610
699
  });
700
+ Object.defineProperty(exports, 'localOnlyCollectionOptions', {
701
+ enumerable: true,
702
+ get: function () {
703
+ return __tanstack_db.localOnlyCollectionOptions;
704
+ }
705
+ });
611
706
  Object.defineProperty(exports, 'lt', {
612
707
  enumerable: true,
613
708
  get: function () {
@@ -644,9 +739,21 @@ Object.defineProperty(exports, 'or', {
644
739
  return __tanstack_db.or;
645
740
  }
646
741
  });
742
+ Object.defineProperty(exports, 'queryOnce', {
743
+ enumerable: true,
744
+ get: function () {
745
+ return __tanstack_db.queryOnce;
746
+ }
747
+ });
647
748
  Object.defineProperty(exports, 'sum', {
648
749
  enumerable: true,
649
750
  get: function () {
650
751
  return __tanstack_db.sum;
651
752
  }
753
+ });
754
+ Object.defineProperty(exports, 'toArray', {
755
+ enumerable: true,
756
+ get: function () {
757
+ return __tanstack_db.toArray;
758
+ }
652
759
  });
package/dist/index.d.cts CHANGED
@@ -1,6 +1,6 @@
1
- import { Collection, Collection as Collection$1, SyncConfig, and, avg, count, createCollection, createOptimisticAction, createOptimisticAction as createOptimisticAction$1, eq, gt, gte, ilike, inArray, isNull, isUndefined, like, lt, lte, max, min, not, or, sum } from "@tanstack/db";
1
+ import { Collection, Collection as Collection$1, SyncConfig, and, avg, coalesce, concat, count, createCollection, createLiveQueryCollection, createOptimisticAction, createOptimisticAction as createOptimisticAction$1, createTransaction, deepEquals, eq, gt, gte, ilike, inArray, isNull, isUndefined, like, localOnlyCollectionOptions, lt, lte, max, min, not, or, queryOnce, sum, toArray } from "@tanstack/db";
2
2
  import { StandardSchemaV1 } from "@standard-schema/spec";
3
- import { DurableStream, DurableStreamOptions, LiveMode } from "@durable-streams/client";
3
+ import { DurableStream, DurableStreamOptions, JsonBatch, LiveMode } from "@durable-streams/client";
4
4
 
5
5
  //#region src/types.d.ts
6
6
  /**
@@ -27,6 +27,8 @@ type ChangeHeaders = {
27
27
  operation: Operation;
28
28
  txid?: string;
29
29
  timestamp?: string;
30
+ from?: string;
31
+ offset?: string;
30
32
  };
31
33
  /**
32
34
  * A change event represents a state change event (insert/update/delete)
@@ -186,14 +188,29 @@ type ActionMap<TActions extends Record<string, ActionDefinition<any>>> = { [K in
186
188
  * Options for creating a stream DB
187
189
  */
188
190
  interface CreateStreamDBOptions<TDef extends StreamStateDefinition = StreamStateDefinition, TActions extends Record<string, ActionDefinition<any>> = Record<string, never>> {
189
- /** Options for creating the durable stream (stream is created lazily on preload) */
190
- streamOptions: DurableStreamOptions;
191
+ /** Options for creating a new durable stream. Ignored when `stream` is provided. */
192
+ streamOptions?: DurableStreamOptions;
193
+ /** Pre-existing DurableStream instance to reuse (avoids creating a second connection). */
194
+ stream?: DurableStream;
191
195
  /** Live read mode used by the StreamDB consumer. Defaults to true. */
192
196
  live?: LiveMode;
193
197
  /** The stream state definition */
194
198
  state: TDef;
195
199
  /** Optional factory function to create actions with db and stream context */
196
200
  actions?: ActionFactory<TDef, TActions>;
201
+ /** Called for every ChangeEvent as it flows through the stream consumer. */
202
+ onEvent?: (event: ChangeEvent) => void;
203
+ /**
204
+ * Called once per consumed stream batch before items are dispatched.
205
+ * Useful when external consumers need batch metadata available during
206
+ * downstream collection/effect processing.
207
+ */
208
+ onBeforeBatch?: (batch: JsonBatch<StateEvent>) => void;
209
+ /**
210
+ * Called once per consumed stream batch after items have been dispatched.
211
+ * Useful for tracking safe offsets for external ack/lease protocols.
212
+ */
213
+ onBatch?: (batch: JsonBatch<StateEvent>) => void;
197
214
  }
198
215
  /**
199
216
  * Extract the value type from a CollectionDefinition
@@ -236,6 +253,10 @@ interface StreamDBMethods {
236
253
  */
237
254
  stream: DurableStream;
238
255
  /**
256
+ * Current stream offset (tracks the last consumed position).
257
+ */
258
+ readonly offset: string;
259
+ /**
239
260
  * Preload all collections by consuming the stream until up-to-date
240
261
  */
241
262
  preload: () => Promise<void>;
@@ -249,6 +270,14 @@ interface StreamDBMethods {
249
270
  utils: StreamDBUtils;
250
271
  }
251
272
  /**
273
+ * Build a TanStack collection id for a StreamDB collection.
274
+ *
275
+ * Collection ids must be unique per source stream, not just per schema key,
276
+ * otherwise joining the same collection name from two different streams can
277
+ * collapse to one logical source inside TanStack DB.
278
+ */
279
+ declare function getStreamDBCollectionId(streamUrl: string, collectionName: string): string;
280
+ /**
252
281
  * Create a state schema definition with typed collections and event helpers
253
282
  */
254
283
  declare function createStateSchema<T extends Record<string, CollectionDefinition>>(collections: T): StateSchema<T>;
@@ -283,4 +312,4 @@ declare function createStateSchema<T extends Record<string, CollectionDefinition
283
312
  declare function createStreamDB<TDef extends StreamStateDefinition, TActions extends Record<string, ActionDefinition<any>> = Record<string, never>>(options: CreateStreamDBOptions<TDef, TActions>): TActions extends Record<string, never> ? StreamDB<TDef> : StreamDBWithActions<TDef, TActions>;
284
313
 
285
314
  //#endregion
286
- export { ActionDefinition, ActionFactory, ActionMap, ChangeEvent, ChangeHeaders, Collection, CollectionDefinition, CollectionEventHelpers, CollectionWithHelpers, ControlEvent, CreateStreamDBOptions, MaterializedState, Operation, Row, StateEvent, StateSchema, StreamDB, StreamDBMethods, StreamDBUtils, StreamDBWithActions, StreamStateDefinition, SyncConfig, Value, and, avg, count, createCollection, createOptimisticAction, createStateSchema, createStreamDB, eq, gt, gte, ilike, inArray, isChangeEvent, isControlEvent, isNull, isUndefined, like, lt, lte, max, min, not, or, sum };
315
+ export { ActionDefinition, ActionFactory, ActionMap, ChangeEvent, ChangeHeaders, Collection, CollectionDefinition, CollectionEventHelpers, CollectionWithHelpers, ControlEvent, CreateStreamDBOptions, MaterializedState, Operation, Row, StateEvent, StateSchema, StreamDB, StreamDBMethods, StreamDBUtils, StreamDBWithActions, StreamStateDefinition, SyncConfig, Value, and, avg, coalesce, concat, count, createCollection, createLiveQueryCollection, createOptimisticAction, createStateSchema, createStreamDB, createTransaction, deepEquals, eq, getStreamDBCollectionId, gt, gte, ilike, inArray, isChangeEvent, isControlEvent, isNull, isUndefined, like, localOnlyCollectionOptions, lt, lte, max, min, not, or, queryOnce, sum, toArray };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Collection, Collection as Collection$1, SyncConfig, and, avg, count, createCollection, createOptimisticAction, createOptimisticAction as createOptimisticAction$1, eq, gt, gte, ilike, inArray, isNull, isUndefined, like, lt, lte, max, min, not, or, sum } from "@tanstack/db";
2
- import { DurableStream, DurableStreamOptions, LiveMode } from "@durable-streams/client";
1
+ import { Collection, Collection as Collection$1, SyncConfig, and, avg, coalesce, concat, count, createCollection, createLiveQueryCollection, createOptimisticAction, createOptimisticAction as createOptimisticAction$1, createTransaction, deepEquals, eq, gt, gte, ilike, inArray, isNull, isUndefined, like, localOnlyCollectionOptions, lt, lte, max, min, not, or, queryOnce, sum, toArray } from "@tanstack/db";
2
+ import { DurableStream, DurableStreamOptions, JsonBatch, LiveMode } from "@durable-streams/client";
3
3
  import { StandardSchemaV1 } from "@standard-schema/spec";
4
4
 
5
5
  //#region src/types.d.ts
@@ -27,6 +27,8 @@ type ChangeHeaders = {
27
27
  operation: Operation;
28
28
  txid?: string;
29
29
  timestamp?: string;
30
+ from?: string;
31
+ offset?: string;
30
32
  };
31
33
  /**
32
34
  * A change event represents a state change event (insert/update/delete)
@@ -186,14 +188,29 @@ type ActionMap<TActions extends Record<string, ActionDefinition<any>>> = { [K in
186
188
  * Options for creating a stream DB
187
189
  */
188
190
  interface CreateStreamDBOptions<TDef extends StreamStateDefinition = StreamStateDefinition, TActions extends Record<string, ActionDefinition<any>> = Record<string, never>> {
189
- /** Options for creating the durable stream (stream is created lazily on preload) */
190
- streamOptions: DurableStreamOptions;
191
+ /** Options for creating a new durable stream. Ignored when `stream` is provided. */
192
+ streamOptions?: DurableStreamOptions;
193
+ /** Pre-existing DurableStream instance to reuse (avoids creating a second connection). */
194
+ stream?: DurableStream;
191
195
  /** Live read mode used by the StreamDB consumer. Defaults to true. */
192
196
  live?: LiveMode;
193
197
  /** The stream state definition */
194
198
  state: TDef;
195
199
  /** Optional factory function to create actions with db and stream context */
196
200
  actions?: ActionFactory<TDef, TActions>;
201
+ /** Called for every ChangeEvent as it flows through the stream consumer. */
202
+ onEvent?: (event: ChangeEvent) => void;
203
+ /**
204
+ * Called once per consumed stream batch before items are dispatched.
205
+ * Useful when external consumers need batch metadata available during
206
+ * downstream collection/effect processing.
207
+ */
208
+ onBeforeBatch?: (batch: JsonBatch<StateEvent>) => void;
209
+ /**
210
+ * Called once per consumed stream batch after items have been dispatched.
211
+ * Useful for tracking safe offsets for external ack/lease protocols.
212
+ */
213
+ onBatch?: (batch: JsonBatch<StateEvent>) => void;
197
214
  }
198
215
  /**
199
216
  * Extract the value type from a CollectionDefinition
@@ -236,6 +253,10 @@ interface StreamDBMethods {
236
253
  */
237
254
  stream: DurableStream;
238
255
  /**
256
+ * Current stream offset (tracks the last consumed position).
257
+ */
258
+ readonly offset: string;
259
+ /**
239
260
  * Preload all collections by consuming the stream until up-to-date
240
261
  */
241
262
  preload: () => Promise<void>;
@@ -249,6 +270,14 @@ interface StreamDBMethods {
249
270
  utils: StreamDBUtils;
250
271
  }
251
272
  /**
273
+ * Build a TanStack collection id for a StreamDB collection.
274
+ *
275
+ * Collection ids must be unique per source stream, not just per schema key,
276
+ * otherwise joining the same collection name from two different streams can
277
+ * collapse to one logical source inside TanStack DB.
278
+ */
279
+ declare function getStreamDBCollectionId(streamUrl: string, collectionName: string): string;
280
+ /**
252
281
  * Create a state schema definition with typed collections and event helpers
253
282
  */
254
283
  declare function createStateSchema<T extends Record<string, CollectionDefinition>>(collections: T): StateSchema<T>;
@@ -283,4 +312,4 @@ declare function createStateSchema<T extends Record<string, CollectionDefinition
283
312
  declare function createStreamDB<TDef extends StreamStateDefinition, TActions extends Record<string, ActionDefinition<any>> = Record<string, never>>(options: CreateStreamDBOptions<TDef, TActions>): TActions extends Record<string, never> ? StreamDB<TDef> : StreamDBWithActions<TDef, TActions>;
284
313
 
285
314
  //#endregion
286
- export { ActionDefinition, ActionFactory, ActionMap, ChangeEvent, ChangeHeaders, Collection, CollectionDefinition, CollectionEventHelpers, CollectionWithHelpers, ControlEvent, CreateStreamDBOptions, MaterializedState, Operation, Row, StateEvent, StateSchema, StreamDB, StreamDBMethods, StreamDBUtils, StreamDBWithActions, StreamStateDefinition, SyncConfig, Value, and, avg, count, createCollection, createOptimisticAction, createStateSchema, createStreamDB, eq, gt, gte, ilike, inArray, isChangeEvent, isControlEvent, isNull, isUndefined, like, lt, lte, max, min, not, or, sum };
315
+ export { ActionDefinition, ActionFactory, ActionMap, ChangeEvent, ChangeHeaders, Collection, CollectionDefinition, CollectionEventHelpers, CollectionWithHelpers, ControlEvent, CreateStreamDBOptions, MaterializedState, Operation, Row, StateEvent, StateSchema, StreamDB, StreamDBMethods, StreamDBUtils, StreamDBWithActions, StreamStateDefinition, SyncConfig, Value, and, avg, coalesce, concat, count, createCollection, createLiveQueryCollection, createOptimisticAction, createStateSchema, createStreamDB, createTransaction, deepEquals, eq, getStreamDBCollectionId, gt, gte, ilike, inArray, isChangeEvent, isControlEvent, isNull, isUndefined, like, localOnlyCollectionOptions, lt, lte, max, min, not, or, queryOnce, sum, toArray };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { and, avg, count, createCollection, createCollection as createCollection$1, createOptimisticAction, createOptimisticAction as createOptimisticAction$1, eq, gt, gte, ilike, inArray, isNull, isUndefined, like, lt, lte, max, min, not, or, sum } from "@tanstack/db";
1
+ import { and, avg, coalesce, concat, count, createCollection, createCollection as createCollection$1, createLiveQueryCollection, createOptimisticAction, createOptimisticAction as createOptimisticAction$1, createTransaction, deepEquals, deepEquals as deepEquals$1, eq, gt, gte, ilike, inArray, isNull, isUndefined, like, localOnlyCollectionOptions, lt, lte, max, min, not, or, queryOnce, sum, toArray } from "@tanstack/db";
2
2
  import { DurableStream } from "@durable-streams/client";
3
3
 
4
4
  //#region src/types.ts
@@ -96,6 +96,16 @@ var MaterializedState = class {
96
96
  //#endregion
97
97
  //#region src/stream-db.ts
98
98
  /**
99
+ * Build a TanStack collection id for a StreamDB collection.
100
+ *
101
+ * Collection ids must be unique per source stream, not just per schema key,
102
+ * otherwise joining the same collection name from two different streams can
103
+ * collapse to one logical source inside TanStack DB.
104
+ */
105
+ function getStreamDBCollectionId(streamUrl, collectionName) {
106
+ return `stream-db:${streamUrl}:${collectionName}`;
107
+ }
108
+ /**
99
109
  * Internal event dispatcher that routes stream events to collection handlers
100
110
  */
101
111
  var EventDispatcher = class {
@@ -116,6 +126,13 @@ var EventDispatcher = class {
116
126
  txidResolvers = new Map();
117
127
  /** Track existing keys per collection for upsert logic */
118
128
  existingKeys = new Map();
129
+ /** Global sequence counter for insertion ordering */
130
+ seq = 0;
131
+ comparableRow(row) {
132
+ const clone = { ...row };
133
+ delete clone._seq;
134
+ return clone;
135
+ }
119
136
  /**
120
137
  * Register a handler for a specific event type
121
138
  */
@@ -127,8 +144,9 @@ var EventDispatcher = class {
127
144
  * Dispatch a change event to the appropriate collection.
128
145
  * Writes are buffered until commit() is called via markUpToDate().
129
146
  */
130
- dispatchChange(event) {
147
+ dispatchChange(event, cursor) {
131
148
  if (!isChangeEvent(event)) return;
149
+ const eventCursor = event.headers.offset ?? cursor;
132
150
  if (event.headers.txid && typeof event.headers.txid === `string`) this.pendingTxids.add(event.headers.txid);
133
151
  const handler = this.handlers.get(event.type);
134
152
  if (!handler) return;
@@ -139,6 +157,7 @@ var EventDispatcher = class {
139
157
  const originalValue = event.value ?? {};
140
158
  const value = { ...originalValue };
141
159
  value[handler.primaryKey] = event.key;
160
+ value._seq = this.seq++;
142
161
  if (!this.pendingHandlers.has(handler)) {
143
162
  handler.begin();
144
163
  this.pendingHandlers.add(handler);
@@ -149,10 +168,15 @@ var EventDispatcher = class {
149
168
  operation = existing ? `update` : `insert`;
150
169
  }
151
170
  const keys = this.existingKeys.get(event.type);
171
+ if (operation === `insert` && keys?.has(event.key)) operation = `update`;
172
+ else if (operation === `insert` && typeof event.key === `string`) {
173
+ const existingValue = handler.read(event.key);
174
+ if (existingValue && deepEquals$1(this.comparableRow(existingValue), this.comparableRow(value))) operation = `update`;
175
+ }
152
176
  if (operation === `insert` || operation === `update`) keys?.add(event.key);
153
177
  else keys?.delete(event.key);
154
178
  try {
155
- handler.write(value, operation);
179
+ handler.write(value, operation, eventCursor);
156
180
  } catch (error) {
157
181
  console.error(`[StreamDB] Error in handler.write():`, error);
158
182
  console.error(`[StreamDB] Event that caused error:`, {
@@ -270,16 +294,17 @@ var EventDispatcher = class {
270
294
  /**
271
295
  * Create a sync config for a stream-backed collection
272
296
  */
273
- function createStreamSyncConfig(eventType, dispatcher, primaryKey) {
297
+ function createStreamSyncConfig(eventType, dispatcher, primaryKey, read) {
274
298
  return { sync: ({ begin, write, commit, markReady, truncate }) => {
275
299
  dispatcher.registerHandler(eventType, {
276
300
  begin,
277
- write: (value, type) => {
301
+ write: (value, type, _cursor) => {
278
302
  write({
279
303
  value,
280
304
  type
281
305
  });
282
306
  },
307
+ read: (key) => read(key),
283
308
  commit,
284
309
  markReady,
285
310
  truncate,
@@ -424,16 +449,20 @@ function createStateSchema(collections) {
424
449
  * ```
425
450
  */
426
451
  function createStreamDB(options) {
427
- const { streamOptions, state, actions: actionsFactory, live = true } = options;
428
- const stream = new DurableStream(streamOptions);
452
+ const { streamOptions, state, actions: actionsFactory, live = true, onEvent, onBeforeBatch, onBatch } = options;
453
+ const stream = options.stream ?? (() => {
454
+ if (!streamOptions) throw new Error(`createStreamDB requires stream or streamOptions`);
455
+ return new DurableStream(streamOptions);
456
+ })();
429
457
  const dispatcher = new EventDispatcher();
458
+ const streamIdentity = stream.url;
430
459
  const collectionInstances = {};
431
460
  for (const [name, definition] of Object.entries(state)) {
432
- const collection = createCollection$1({
433
- id: `stream-db:${name}`,
461
+ let collection = createCollection$1({
462
+ id: getStreamDBCollectionId(streamIdentity, name),
434
463
  schema: definition.schema,
435
464
  getKey: (item) => String(item[definition.primaryKey]),
436
- sync: createStreamSyncConfig(definition.type, dispatcher, definition.primaryKey),
465
+ sync: createStreamSyncConfig(definition.type, dispatcher, definition.primaryKey, (key) => collection.get(key)),
437
466
  startSync: true,
438
467
  gcTime: 0
439
468
  });
@@ -442,6 +471,12 @@ function createStreamDB(options) {
442
471
  let streamResponse = null;
443
472
  const abortController = new AbortController();
444
473
  let consumerStarted = false;
474
+ let lastConsumedOffset = `-1`;
475
+ const isAbortLikeError = (err) => {
476
+ if (abortController.signal.aborted) return true;
477
+ if (!(err instanceof Error)) return false;
478
+ return err.name === `AbortError` || err.name === `FetchBackoffAbortError` || err.message === `Stream request was aborted`;
479
+ };
445
480
  /**
446
481
  * Start the stream consumer (called lazily on first preload)
447
482
  */
@@ -450,16 +485,29 @@ function createStreamDB(options) {
450
485
  consumerStarted = true;
451
486
  streamResponse = await stream.stream({
452
487
  live,
488
+ json: true,
453
489
  signal: abortController.signal
454
490
  });
491
+ streamResponse.closed.catch((err) => {
492
+ if (isAbortLikeError(err)) return void 0;
493
+ const error = err instanceof Error ? err : new Error(String(err));
494
+ console.error(`[StreamDB] Stream consumer closed unexpectedly:`, error);
495
+ dispatcher.rejectAll(error);
496
+ return void 0;
497
+ });
498
+ lastConsumedOffset = streamResponse.offset;
455
499
  streamResponse.subscribeJson((batch) => {
456
500
  try {
457
- for (const event of batch.items) if (isChangeEvent(event)) dispatcher.dispatchChange(event);
458
- else if (isControlEvent(event)) dispatcher.dispatchControl(event);
459
- if (batch.upToDate) dispatcher.markUpToDate();
501
+ lastConsumedOffset = batch.offset;
502
+ onBeforeBatch?.(batch);
503
+ for (const event of batch.items) if (isChangeEvent(event)) {
504
+ dispatcher.dispatchChange(event, batch.offset);
505
+ onEvent?.(event);
506
+ } else if (isControlEvent(event)) dispatcher.dispatchControl(event);
507
+ onBatch?.(batch);
508
+ if (batch.upToDate || dispatcher.ready) dispatcher.markUpToDate();
460
509
  } catch (error) {
461
510
  console.error(`[StreamDB] Error processing batch:`, error);
462
- console.error(`[StreamDB] Failed batch:`, batch);
463
511
  dispatcher.rejectAll(error);
464
512
  abortController.abort();
465
513
  }
@@ -468,6 +516,9 @@ function createStreamDB(options) {
468
516
  };
469
517
  const dbMethods = {
470
518
  stream,
519
+ get offset() {
520
+ return lastConsumedOffset;
521
+ },
471
522
  preload: async () => {
472
523
  await startConsumer();
473
524
  await dispatcher.waitForUpToDate();
@@ -478,10 +529,14 @@ function createStreamDB(options) {
478
529
  },
479
530
  utils: { awaitTxId: (txid, timeout) => dispatcher.awaitTxId(txid, timeout) }
480
531
  };
481
- const db = {
482
- collections: collectionInstances,
483
- ...dbMethods
484
- };
532
+ const db = Object.create(null);
533
+ Object.defineProperty(db, `collections`, {
534
+ value: collectionInstances,
535
+ enumerable: true,
536
+ configurable: false,
537
+ writable: false
538
+ });
539
+ Object.defineProperties(db, Object.getOwnPropertyDescriptors(dbMethods));
485
540
  if (actionsFactory) {
486
541
  const actionDefs = actionsFactory({
487
542
  db,
@@ -492,13 +547,16 @@ function createStreamDB(options) {
492
547
  onMutate: def.onMutate,
493
548
  mutationFn: def.mutationFn
494
549
  });
495
- return {
496
- ...db,
497
- actions: wrappedActions
498
- };
550
+ Object.defineProperty(db, `actions`, {
551
+ value: wrappedActions,
552
+ enumerable: true,
553
+ configurable: false,
554
+ writable: false
555
+ });
556
+ return db;
499
557
  }
500
558
  return db;
501
559
  }
502
560
 
503
561
  //#endregion
504
- export { MaterializedState, and, avg, count, createCollection, createOptimisticAction, createStateSchema, createStreamDB, eq, gt, gte, ilike, inArray, isChangeEvent, isControlEvent, isNull, isUndefined, like, lt, lte, max, min, not, or, sum };
562
+ export { MaterializedState, and, avg, coalesce, concat, count, createCollection, createLiveQueryCollection, createOptimisticAction, createStateSchema, createStreamDB, createTransaction, deepEquals, eq, getStreamDBCollectionId, gt, gte, ilike, inArray, isChangeEvent, isControlEvent, isNull, isUndefined, like, localOnlyCollectionOptions, lt, lte, max, min, not, or, queryOnce, sum, toArray };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@durable-streams/state",
3
3
  "description": "State change event protocol for Durable Streams",
4
- "version": "0.2.6",
4
+ "version": "0.2.7",
5
5
  "author": "Durable Stream contributors",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -50,16 +50,13 @@
50
50
  ],
51
51
  "dependencies": {
52
52
  "@standard-schema/spec": "^1.0.0",
53
+ "@tanstack/db": "^0.6.0",
53
54
  "@durable-streams/client": "0.2.4"
54
55
  },
55
- "peerDependencies": {
56
- "@tanstack/db": ">=0.5.0"
57
- },
58
56
  "devDependencies": {
59
- "@tanstack/db": "latest",
60
57
  "@tanstack/intent": "latest",
61
58
  "tsdown": "^0.9.0",
62
- "@durable-streams/server": "0.3.2"
59
+ "@durable-streams/server": "0.3.3"
63
60
  },
64
61
  "engines": {
65
62
  "node": ">=18.0.0"
package/src/index.ts CHANGED
@@ -15,7 +15,11 @@ export { isChangeEvent, isControlEvent } from "./types"
15
15
  export { MaterializedState } from "./materialized-state"
16
16
 
17
17
  // Stream DB
18
- export { createStreamDB, createStateSchema } from "./stream-db"
18
+ export {
19
+ createStreamDB,
20
+ createStateSchema,
21
+ getStreamDBCollectionId,
22
+ } from "./stream-db"
19
23
  export type {
20
24
  CollectionDefinition,
21
25
  CollectionEventHelpers,
@@ -37,7 +41,12 @@ export type {
37
41
  export type { Collection, SyncConfig } from "@tanstack/db"
38
42
  export {
39
43
  createCollection,
44
+ createLiveQueryCollection,
40
45
  createOptimisticAction,
46
+ createTransaction,
47
+ deepEquals,
48
+ localOnlyCollectionOptions,
49
+ queryOnce,
41
50
  // Comparison operators
42
51
  eq,
43
52
  gt,
@@ -60,4 +69,8 @@ export {
60
69
  avg,
61
70
  min,
62
71
  max,
72
+ // Includes/projection functions
73
+ concat,
74
+ coalesce,
75
+ toArray,
63
76
  } from "@tanstack/db"
package/src/stream-db.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { createCollection, createOptimisticAction } from "@tanstack/db"
1
+ import {
2
+ createCollection,
3
+ createOptimisticAction,
4
+ deepEquals,
5
+ } from "@tanstack/db"
2
6
  import { DurableStream as DurableStreamClass } from "@durable-streams/client"
3
7
  import { isChangeEvent, isControlEvent } from "./types"
4
8
  import type { Collection, SyncConfig } from "@tanstack/db"
@@ -7,6 +11,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
7
11
  import type {
8
12
  DurableStream,
9
13
  DurableStreamOptions,
14
+ JsonBatch,
10
15
  LiveMode,
11
16
  StreamResponse,
12
17
  } from "@durable-streams/client"
@@ -120,14 +125,29 @@ export interface CreateStreamDBOptions<
120
125
  never
121
126
  >,
122
127
  > {
123
- /** Options for creating the durable stream (stream is created lazily on preload) */
124
- streamOptions: DurableStreamOptions
128
+ /** Options for creating a new durable stream. Ignored when `stream` is provided. */
129
+ streamOptions?: DurableStreamOptions
130
+ /** Pre-existing DurableStream instance to reuse (avoids creating a second connection). */
131
+ stream?: DurableStream
125
132
  /** Live read mode used by the StreamDB consumer. Defaults to true. */
126
133
  live?: LiveMode
127
134
  /** The stream state definition */
128
135
  state: TDef
129
136
  /** Optional factory function to create actions with db and stream context */
130
137
  actions?: ActionFactory<TDef, TActions>
138
+ /** Called for every ChangeEvent as it flows through the stream consumer. */
139
+ onEvent?: (event: ChangeEvent) => void
140
+ /**
141
+ * Called once per consumed stream batch before items are dispatched.
142
+ * Useful when external consumers need batch metadata available during
143
+ * downstream collection/effect processing.
144
+ */
145
+ onBeforeBatch?: (batch: JsonBatch<StateEvent>) => void
146
+ /**
147
+ * Called once per consumed stream batch after items have been dispatched.
148
+ * Useful for tracking safe offsets for external ack/lease protocols.
149
+ */
150
+ onBatch?: (batch: JsonBatch<StateEvent>) => void
131
151
  }
132
152
 
133
153
  /**
@@ -182,6 +202,11 @@ export interface StreamDBMethods {
182
202
  */
183
203
  stream: DurableStream
184
204
 
205
+ /**
206
+ * Current stream offset (tracks the last consumed position).
207
+ */
208
+ readonly offset: string
209
+
185
210
  /**
186
211
  * Preload all collections by consuming the stream until up-to-date
187
212
  */
@@ -198,6 +223,20 @@ export interface StreamDBMethods {
198
223
  utils: StreamDBUtils
199
224
  }
200
225
 
226
+ /**
227
+ * Build a TanStack collection id for a StreamDB collection.
228
+ *
229
+ * Collection ids must be unique per source stream, not just per schema key,
230
+ * otherwise joining the same collection name from two different streams can
231
+ * collapse to one logical source inside TanStack DB.
232
+ */
233
+ export function getStreamDBCollectionId(
234
+ streamUrl: string,
235
+ collectionName: string
236
+ ): string {
237
+ return `stream-db:${streamUrl}:${collectionName}`
238
+ }
239
+
201
240
  // ============================================================================
202
241
  // Internal Event Dispatcher
203
242
  // ============================================================================
@@ -207,7 +246,12 @@ export interface StreamDBMethods {
207
246
  */
208
247
  interface CollectionSyncHandler {
209
248
  begin: () => void
210
- write: (value: object, type: `insert` | `update` | `delete`) => void
249
+ write: (
250
+ value: object,
251
+ type: `insert` | `update` | `delete`,
252
+ cursor?: string
253
+ ) => void
254
+ read: (key: string) => object | undefined
211
255
  commit: () => void
212
256
  markReady: () => void
213
257
  truncate: () => void
@@ -250,6 +294,15 @@ class EventDispatcher {
250
294
  /** Track existing keys per collection for upsert logic */
251
295
  private existingKeys = new Map<string, Set<string>>()
252
296
 
297
+ /** Global sequence counter for insertion ordering */
298
+ private seq = 0
299
+
300
+ private comparableRow(row: object): Record<string, unknown> {
301
+ const clone = { ...(row as Record<string, unknown>) }
302
+ delete clone._seq
303
+ return clone
304
+ }
305
+
253
306
  /**
254
307
  * Register a handler for a specific event type
255
308
  */
@@ -265,9 +318,11 @@ class EventDispatcher {
265
318
  * Dispatch a change event to the appropriate collection.
266
319
  * Writes are buffered until commit() is called via markUpToDate().
267
320
  */
268
- dispatchChange(event: StateEvent): void {
321
+ dispatchChange(event: StateEvent, cursor?: string): void {
269
322
  if (!isChangeEvent(event)) return
270
323
 
324
+ const eventCursor = event.headers.offset ?? cursor
325
+
271
326
  // Check for txid in headers and collect it
272
327
  if (event.headers.txid && typeof event.headers.txid === `string`) {
273
328
  this.pendingTxids.add(event.headers.txid)
@@ -299,6 +354,9 @@ class EventDispatcher {
299
354
  // Set the primary key field on the value object from the event key
300
355
  ;(value as any)[handler.primaryKey] = event.key
301
356
 
357
+ // Stamp global insertion order for cross-collection sorting
358
+ ;(value as any)._seq = this.seq++
359
+
302
360
  // Begin transaction on first write to this handler
303
361
  if (!this.pendingHandlers.has(handler)) {
304
362
  handler.begin()
@@ -312,8 +370,24 @@ class EventDispatcher {
312
370
  operation = existing ? `update` : `insert`
313
371
  }
314
372
 
315
- // Track key existence for upsert logic
316
373
  const keys = this.existingKeys.get(event.type)
374
+
375
+ // Live stream reconnects can replay an already-synced insert for the same
376
+ // row. Normalize that case to update so observation replays remain
377
+ // idempotent instead of tripping TanStack DB's duplicate-key path.
378
+ if (operation === `insert` && keys?.has(event.key)) {
379
+ operation = `update`
380
+ } else if (operation === `insert` && typeof event.key === `string`) {
381
+ const existingValue = handler.read(event.key)
382
+ if (
383
+ existingValue &&
384
+ deepEquals(this.comparableRow(existingValue), this.comparableRow(value))
385
+ ) {
386
+ operation = `update`
387
+ }
388
+ }
389
+
390
+ // Track key existence for upsert logic
317
391
  if (operation === `insert` || operation === `update`) {
318
392
  keys?.add(event.key)
319
393
  } else {
@@ -322,7 +396,7 @@ class EventDispatcher {
322
396
  }
323
397
 
324
398
  try {
325
- handler.write(value, operation)
399
+ handler.write(value, operation, eventCursor)
326
400
  } catch (error) {
327
401
  console.error(`[StreamDB] Error in handler.write():`, error)
328
402
  console.error(`[StreamDB] Event that caused error:`, {
@@ -507,19 +581,21 @@ class EventDispatcher {
507
581
  function createStreamSyncConfig<T extends object>(
508
582
  eventType: string,
509
583
  dispatcher: EventDispatcher,
510
- primaryKey: string
584
+ primaryKey: string,
585
+ read: (key: string) => T | undefined
511
586
  ): SyncConfig<T, string> {
512
587
  return {
513
588
  sync: ({ begin, write, commit, markReady, truncate }) => {
514
589
  // Register this collection's handler with the dispatcher
515
590
  dispatcher.registerHandler(eventType, {
516
591
  begin,
517
- write: (value, type) => {
592
+ write: (value, type, _cursor) => {
518
593
  write({
519
594
  value: value as T,
520
595
  type,
521
596
  })
522
597
  },
598
+ read: (key) => read(key),
523
599
  commit,
524
600
  markReady,
525
601
  truncate,
@@ -765,26 +841,45 @@ export function createStreamDB<
765
841
  ): TActions extends Record<string, never>
766
842
  ? StreamDB<TDef>
767
843
  : StreamDBWithActions<TDef, TActions> {
768
- const { streamOptions, state, actions: actionsFactory, live = true } = options
769
-
770
- // Create a stream handle (lightweight, doesn't connect until stream() is called)
771
- const stream = new DurableStreamClass(streamOptions)
844
+ const {
845
+ streamOptions,
846
+ state,
847
+ actions: actionsFactory,
848
+ live = true,
849
+ onEvent,
850
+ onBeforeBatch,
851
+ onBatch,
852
+ } = options
853
+
854
+ // Reuse provided stream or create a new one
855
+ const stream =
856
+ options.stream ??
857
+ (() => {
858
+ if (!streamOptions) {
859
+ throw new Error(`createStreamDB requires stream or streamOptions`)
860
+ }
861
+ return new DurableStreamClass(streamOptions)
862
+ })()
772
863
 
773
864
  // Create the event dispatcher
774
865
  const dispatcher = new EventDispatcher()
775
866
 
867
+ const streamIdentity = stream.url
868
+
776
869
  // Create TanStack DB collections for each definition
777
870
  const collectionInstances: Record<string, Collection<object, string>> = {}
778
871
 
779
872
  for (const [name, definition] of Object.entries(state)) {
780
- const collection = createCollection({
781
- id: `stream-db:${name}`,
873
+ // eslint-disable-next-line prefer-const -- self-referential: collection.get() used in its own sync config
874
+ let collection: Collection<object, string> = createCollection({
875
+ id: getStreamDBCollectionId(streamIdentity, name),
782
876
  schema: definition.schema as StandardSchemaV1<object>,
783
877
  getKey: (item: any) => String(item[definition.primaryKey]),
784
878
  sync: createStreamSyncConfig(
785
879
  definition.type,
786
880
  dispatcher,
787
- definition.primaryKey
881
+ definition.primaryKey,
882
+ (key) => collection.get(key) as object | undefined
788
883
  ),
789
884
  startSync: true, // Start syncing immediately
790
885
  // Disable GC - we manage lifecycle via db.close()
@@ -800,6 +895,20 @@ export function createStreamDB<
800
895
  let streamResponse: StreamResponse<StateEvent> | null = null
801
896
  const abortController = new AbortController()
802
897
  let consumerStarted = false
898
+ let lastConsumedOffset = `-1`
899
+ const isAbortLikeError = (err: unknown): boolean => {
900
+ if (abortController.signal.aborted) {
901
+ return true
902
+ }
903
+ if (!(err instanceof Error)) {
904
+ return false
905
+ }
906
+ return (
907
+ err.name === `AbortError` ||
908
+ err.name === `FetchBackoffAbortError` ||
909
+ err.message === `Stream request was aborted`
910
+ )
911
+ }
803
912
 
804
913
  /**
805
914
  * Start the stream consumer (called lazily on first preload)
@@ -811,32 +920,48 @@ export function createStreamDB<
811
920
  // Start streaming (this is where the connection actually happens)
812
921
  streamResponse = await stream.stream<StateEvent>({
813
922
  live,
923
+ json: true,
814
924
  signal: abortController.signal,
815
925
  })
926
+ // StreamDB consumes batches via subscribeJson(); it does not await the
927
+ // session's closed promise. Swallow that terminal rejection so aborting
928
+ // the live session during db.close() doesn't surface as an unhandled
929
+ // rejection after the real error has already been routed elsewhere.
930
+ void streamResponse.closed.catch((err) => {
931
+ if (isAbortLikeError(err)) {
932
+ return undefined
933
+ }
934
+ const error = err instanceof Error ? err : new Error(String(err))
935
+ console.error(`[StreamDB] Stream consumer closed unexpectedly:`, error)
936
+ dispatcher.rejectAll(error)
937
+ return undefined
938
+ })
939
+ lastConsumedOffset = streamResponse.offset
816
940
 
817
941
  // Process events as they come in
818
942
  streamResponse.subscribeJson((batch) => {
819
943
  try {
944
+ lastConsumedOffset = batch.offset
945
+ onBeforeBatch?.(batch)
946
+
820
947
  for (const event of batch.items) {
821
948
  if (isChangeEvent(event)) {
822
- dispatcher.dispatchChange(event)
949
+ dispatcher.dispatchChange(event, batch.offset)
950
+ onEvent?.(event)
823
951
  } else if (isControlEvent(event)) {
824
952
  dispatcher.dispatchControl(event)
825
953
  }
826
954
  }
827
955
 
828
- // Check batch-level up-to-date signal
829
- if (batch.upToDate) {
956
+ onBatch?.(batch)
957
+
958
+ if (batch.upToDate || dispatcher.ready) {
830
959
  dispatcher.markUpToDate()
831
960
  }
832
961
  } catch (error) {
833
962
  console.error(`[StreamDB] Error processing batch:`, error)
834
- console.error(`[StreamDB] Failed batch:`, batch)
835
- // Reject all waiting preload promises
836
963
  dispatcher.rejectAll(error as Error)
837
- // Abort the stream to stop further processing
838
964
  abortController.abort()
839
- // Don't rethrow - we've already rejected the promise
840
965
  }
841
966
  return Promise.resolve()
842
967
  })
@@ -845,6 +970,9 @@ export function createStreamDB<
845
970
  // Build the StreamDB object with methods
846
971
  const dbMethods: StreamDBMethods = {
847
972
  stream,
973
+ get offset() {
974
+ return lastConsumedOffset
975
+ },
848
976
  preload: async () => {
849
977
  await startConsumer()
850
978
  await dispatcher.waitForUpToDate()
@@ -860,11 +988,14 @@ export function createStreamDB<
860
988
  },
861
989
  }
862
990
 
863
- // Combine collections with methods
864
- const db = {
865
- collections: collectionInstances,
866
- ...dbMethods,
867
- } as unknown as StreamDB<TDef>
991
+ const db = Object.create(null) as StreamDB<TDef>
992
+ Object.defineProperty(db, `collections`, {
993
+ value: collectionInstances,
994
+ enumerable: true,
995
+ configurable: false,
996
+ writable: false,
997
+ })
998
+ Object.defineProperties(db, Object.getOwnPropertyDescriptors(dbMethods))
868
999
 
869
1000
  // If actions factory is provided, wrap actions and return db with actions
870
1001
  if (actionsFactory) {
@@ -880,10 +1011,13 @@ export function createStreamDB<
880
1011
  })
881
1012
  }
882
1013
 
883
- return {
884
- ...db,
885
- actions: wrappedActions,
886
- } as any
1014
+ Object.defineProperty(db, `actions`, {
1015
+ value: wrappedActions,
1016
+ enumerable: true,
1017
+ configurable: false,
1018
+ writable: false,
1019
+ })
1020
+ return db as any
887
1021
  }
888
1022
 
889
1023
  return db as any
package/src/types.ts CHANGED
@@ -31,6 +31,8 @@ export type ChangeHeaders = {
31
31
  operation: Operation
32
32
  txid?: string
33
33
  timestamp?: string
34
+ from?: string
35
+ offset?: string
34
36
  }
35
37
 
36
38
  /**