@durable-streams/state 0.2.6 → 0.2.8
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 +128 -21
- package/dist/index.d.cts +34 -5
- package/dist/index.d.ts +34 -5
- package/dist/index.js +81 -23
- package/package.json +4 -7
- package/src/index.ts +14 -1
- package/src/stream-db.ts +166 -32
- package/src/types.ts +2 -0
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 =
|
|
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
|
-
|
|
457
|
-
id:
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
507
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
190
|
-
streamOptions
|
|
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
|
|
190
|
-
streamOptions
|
|
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 =
|
|
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
|
-
|
|
433
|
-
id:
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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.
|
|
4
|
+
"version": "0.2.8",
|
|
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
|
-
"@
|
|
54
|
-
|
|
55
|
-
"peerDependencies": {
|
|
56
|
-
"@tanstack/db": ">=0.5.0"
|
|
53
|
+
"@tanstack/db": "^0.6.0",
|
|
54
|
+
"@durable-streams/client": "0.2.5"
|
|
57
55
|
},
|
|
58
56
|
"devDependencies": {
|
|
59
|
-
"@tanstack/db": "latest",
|
|
60
57
|
"@tanstack/intent": "latest",
|
|
61
58
|
"tsdown": "^0.9.0",
|
|
62
|
-
"@durable-streams/server": "0.3.
|
|
59
|
+
"@durable-streams/server": "0.3.4"
|
|
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 {
|
|
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 {
|
|
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
|
|
124
|
-
streamOptions
|
|
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: (
|
|
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 {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
|
781
|
-
|
|
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
|
-
|
|
829
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|