@durable-streams/state 0.2.5 → 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/README.md +1 -0
- package/dist/index.cjs +129 -22
- package/dist/index.d.cts +36 -5
- package/dist/index.d.ts +36 -5
- package/dist/index.js +82 -24
- package/package.json +4 -7
- package/src/index.ts +14 -1
- package/src/stream-db.ts +170 -33
- package/src/types.ts +2 -0
package/README.md
CHANGED
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 } = 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
|
*/
|
|
@@ -473,17 +508,30 @@ function createStreamDB(options) {
|
|
|
473
508
|
if (consumerStarted) return;
|
|
474
509
|
consumerStarted = true;
|
|
475
510
|
streamResponse = await stream.stream({
|
|
476
|
-
live
|
|
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 } 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,12 +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;
|
|
195
|
+
/** Live read mode used by the StreamDB consumer. Defaults to true. */
|
|
196
|
+
live?: LiveMode;
|
|
191
197
|
/** The stream state definition */
|
|
192
198
|
state: TDef;
|
|
193
199
|
/** Optional factory function to create actions with db and stream context */
|
|
194
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;
|
|
195
214
|
}
|
|
196
215
|
/**
|
|
197
216
|
* Extract the value type from a CollectionDefinition
|
|
@@ -234,6 +253,10 @@ interface StreamDBMethods {
|
|
|
234
253
|
*/
|
|
235
254
|
stream: DurableStream;
|
|
236
255
|
/**
|
|
256
|
+
* Current stream offset (tracks the last consumed position).
|
|
257
|
+
*/
|
|
258
|
+
readonly offset: string;
|
|
259
|
+
/**
|
|
237
260
|
* Preload all collections by consuming the stream until up-to-date
|
|
238
261
|
*/
|
|
239
262
|
preload: () => Promise<void>;
|
|
@@ -247,6 +270,14 @@ interface StreamDBMethods {
|
|
|
247
270
|
utils: StreamDBUtils;
|
|
248
271
|
}
|
|
249
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
|
+
/**
|
|
250
281
|
* Create a state schema definition with typed collections and event helpers
|
|
251
282
|
*/
|
|
252
283
|
declare function createStateSchema<T extends Record<string, CollectionDefinition>>(collections: T): StateSchema<T>;
|
|
@@ -281,4 +312,4 @@ declare function createStateSchema<T extends Record<string, CollectionDefinition
|
|
|
281
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>;
|
|
282
313
|
|
|
283
314
|
//#endregion
|
|
284
|
-
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 } 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,12 +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;
|
|
195
|
+
/** Live read mode used by the StreamDB consumer. Defaults to true. */
|
|
196
|
+
live?: LiveMode;
|
|
191
197
|
/** The stream state definition */
|
|
192
198
|
state: TDef;
|
|
193
199
|
/** Optional factory function to create actions with db and stream context */
|
|
194
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;
|
|
195
214
|
}
|
|
196
215
|
/**
|
|
197
216
|
* Extract the value type from a CollectionDefinition
|
|
@@ -234,6 +253,10 @@ interface StreamDBMethods {
|
|
|
234
253
|
*/
|
|
235
254
|
stream: DurableStream;
|
|
236
255
|
/**
|
|
256
|
+
* Current stream offset (tracks the last consumed position).
|
|
257
|
+
*/
|
|
258
|
+
readonly offset: string;
|
|
259
|
+
/**
|
|
237
260
|
* Preload all collections by consuming the stream until up-to-date
|
|
238
261
|
*/
|
|
239
262
|
preload: () => Promise<void>;
|
|
@@ -247,6 +270,14 @@ interface StreamDBMethods {
|
|
|
247
270
|
utils: StreamDBUtils;
|
|
248
271
|
}
|
|
249
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
|
+
/**
|
|
250
281
|
* Create a state schema definition with typed collections and event helpers
|
|
251
282
|
*/
|
|
252
283
|
declare function createStateSchema<T extends Record<string, CollectionDefinition>>(collections: T): StateSchema<T>;
|
|
@@ -281,4 +312,4 @@ declare function createStateSchema<T extends Record<string, CollectionDefinition
|
|
|
281
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>;
|
|
282
313
|
|
|
283
314
|
//#endregion
|
|
284
|
-
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 } = 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
|
*/
|
|
@@ -449,17 +484,30 @@ function createStreamDB(options) {
|
|
|
449
484
|
if (consumerStarted) return;
|
|
450
485
|
consumerStarted = true;
|
|
451
486
|
streamResponse = await stream.stream({
|
|
452
|
-
live
|
|
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.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
|
-
"@
|
|
54
|
-
|
|
55
|
-
"peerDependencies": {
|
|
56
|
-
"@tanstack/db": ">=0.5.0"
|
|
53
|
+
"@tanstack/db": "^0.6.0",
|
|
54
|
+
"@durable-streams/client": "0.2.4"
|
|
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.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 {
|
|
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,8 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
|
|
|
7
11
|
import type {
|
|
8
12
|
DurableStream,
|
|
9
13
|
DurableStreamOptions,
|
|
14
|
+
JsonBatch,
|
|
15
|
+
LiveMode,
|
|
10
16
|
StreamResponse,
|
|
11
17
|
} from "@durable-streams/client"
|
|
12
18
|
|
|
@@ -119,12 +125,29 @@ export interface CreateStreamDBOptions<
|
|
|
119
125
|
never
|
|
120
126
|
>,
|
|
121
127
|
> {
|
|
122
|
-
/** Options for creating
|
|
123
|
-
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
|
|
132
|
+
/** Live read mode used by the StreamDB consumer. Defaults to true. */
|
|
133
|
+
live?: LiveMode
|
|
124
134
|
/** The stream state definition */
|
|
125
135
|
state: TDef
|
|
126
136
|
/** Optional factory function to create actions with db and stream context */
|
|
127
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
|
|
128
151
|
}
|
|
129
152
|
|
|
130
153
|
/**
|
|
@@ -179,6 +202,11 @@ export interface StreamDBMethods {
|
|
|
179
202
|
*/
|
|
180
203
|
stream: DurableStream
|
|
181
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Current stream offset (tracks the last consumed position).
|
|
207
|
+
*/
|
|
208
|
+
readonly offset: string
|
|
209
|
+
|
|
182
210
|
/**
|
|
183
211
|
* Preload all collections by consuming the stream until up-to-date
|
|
184
212
|
*/
|
|
@@ -195,6 +223,20 @@ export interface StreamDBMethods {
|
|
|
195
223
|
utils: StreamDBUtils
|
|
196
224
|
}
|
|
197
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
|
+
|
|
198
240
|
// ============================================================================
|
|
199
241
|
// Internal Event Dispatcher
|
|
200
242
|
// ============================================================================
|
|
@@ -204,7 +246,12 @@ export interface StreamDBMethods {
|
|
|
204
246
|
*/
|
|
205
247
|
interface CollectionSyncHandler {
|
|
206
248
|
begin: () => void
|
|
207
|
-
write: (
|
|
249
|
+
write: (
|
|
250
|
+
value: object,
|
|
251
|
+
type: `insert` | `update` | `delete`,
|
|
252
|
+
cursor?: string
|
|
253
|
+
) => void
|
|
254
|
+
read: (key: string) => object | undefined
|
|
208
255
|
commit: () => void
|
|
209
256
|
markReady: () => void
|
|
210
257
|
truncate: () => void
|
|
@@ -247,6 +294,15 @@ class EventDispatcher {
|
|
|
247
294
|
/** Track existing keys per collection for upsert logic */
|
|
248
295
|
private existingKeys = new Map<string, Set<string>>()
|
|
249
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
|
+
|
|
250
306
|
/**
|
|
251
307
|
* Register a handler for a specific event type
|
|
252
308
|
*/
|
|
@@ -262,9 +318,11 @@ class EventDispatcher {
|
|
|
262
318
|
* Dispatch a change event to the appropriate collection.
|
|
263
319
|
* Writes are buffered until commit() is called via markUpToDate().
|
|
264
320
|
*/
|
|
265
|
-
dispatchChange(event: StateEvent): void {
|
|
321
|
+
dispatchChange(event: StateEvent, cursor?: string): void {
|
|
266
322
|
if (!isChangeEvent(event)) return
|
|
267
323
|
|
|
324
|
+
const eventCursor = event.headers.offset ?? cursor
|
|
325
|
+
|
|
268
326
|
// Check for txid in headers and collect it
|
|
269
327
|
if (event.headers.txid && typeof event.headers.txid === `string`) {
|
|
270
328
|
this.pendingTxids.add(event.headers.txid)
|
|
@@ -296,6 +354,9 @@ class EventDispatcher {
|
|
|
296
354
|
// Set the primary key field on the value object from the event key
|
|
297
355
|
;(value as any)[handler.primaryKey] = event.key
|
|
298
356
|
|
|
357
|
+
// Stamp global insertion order for cross-collection sorting
|
|
358
|
+
;(value as any)._seq = this.seq++
|
|
359
|
+
|
|
299
360
|
// Begin transaction on first write to this handler
|
|
300
361
|
if (!this.pendingHandlers.has(handler)) {
|
|
301
362
|
handler.begin()
|
|
@@ -309,8 +370,24 @@ class EventDispatcher {
|
|
|
309
370
|
operation = existing ? `update` : `insert`
|
|
310
371
|
}
|
|
311
372
|
|
|
312
|
-
// Track key existence for upsert logic
|
|
313
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
|
|
314
391
|
if (operation === `insert` || operation === `update`) {
|
|
315
392
|
keys?.add(event.key)
|
|
316
393
|
} else {
|
|
@@ -319,7 +396,7 @@ class EventDispatcher {
|
|
|
319
396
|
}
|
|
320
397
|
|
|
321
398
|
try {
|
|
322
|
-
handler.write(value, operation)
|
|
399
|
+
handler.write(value, operation, eventCursor)
|
|
323
400
|
} catch (error) {
|
|
324
401
|
console.error(`[StreamDB] Error in handler.write():`, error)
|
|
325
402
|
console.error(`[StreamDB] Event that caused error:`, {
|
|
@@ -504,19 +581,21 @@ class EventDispatcher {
|
|
|
504
581
|
function createStreamSyncConfig<T extends object>(
|
|
505
582
|
eventType: string,
|
|
506
583
|
dispatcher: EventDispatcher,
|
|
507
|
-
primaryKey: string
|
|
584
|
+
primaryKey: string,
|
|
585
|
+
read: (key: string) => T | undefined
|
|
508
586
|
): SyncConfig<T, string> {
|
|
509
587
|
return {
|
|
510
588
|
sync: ({ begin, write, commit, markReady, truncate }) => {
|
|
511
589
|
// Register this collection's handler with the dispatcher
|
|
512
590
|
dispatcher.registerHandler(eventType, {
|
|
513
591
|
begin,
|
|
514
|
-
write: (value, type) => {
|
|
592
|
+
write: (value, type, _cursor) => {
|
|
515
593
|
write({
|
|
516
594
|
value: value as T,
|
|
517
595
|
type,
|
|
518
596
|
})
|
|
519
597
|
},
|
|
598
|
+
read: (key) => read(key),
|
|
520
599
|
commit,
|
|
521
600
|
markReady,
|
|
522
601
|
truncate,
|
|
@@ -762,26 +841,45 @@ export function createStreamDB<
|
|
|
762
841
|
): TActions extends Record<string, never>
|
|
763
842
|
? StreamDB<TDef>
|
|
764
843
|
: StreamDBWithActions<TDef, TActions> {
|
|
765
|
-
const {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
+
})()
|
|
769
863
|
|
|
770
864
|
// Create the event dispatcher
|
|
771
865
|
const dispatcher = new EventDispatcher()
|
|
772
866
|
|
|
867
|
+
const streamIdentity = stream.url
|
|
868
|
+
|
|
773
869
|
// Create TanStack DB collections for each definition
|
|
774
870
|
const collectionInstances: Record<string, Collection<object, string>> = {}
|
|
775
871
|
|
|
776
872
|
for (const [name, definition] of Object.entries(state)) {
|
|
777
|
-
const collection
|
|
778
|
-
|
|
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),
|
|
779
876
|
schema: definition.schema as StandardSchemaV1<object>,
|
|
780
877
|
getKey: (item: any) => String(item[definition.primaryKey]),
|
|
781
878
|
sync: createStreamSyncConfig(
|
|
782
879
|
definition.type,
|
|
783
880
|
dispatcher,
|
|
784
|
-
definition.primaryKey
|
|
881
|
+
definition.primaryKey,
|
|
882
|
+
(key) => collection.get(key) as object | undefined
|
|
785
883
|
),
|
|
786
884
|
startSync: true, // Start syncing immediately
|
|
787
885
|
// Disable GC - we manage lifecycle via db.close()
|
|
@@ -797,6 +895,20 @@ export function createStreamDB<
|
|
|
797
895
|
let streamResponse: StreamResponse<StateEvent> | null = null
|
|
798
896
|
const abortController = new AbortController()
|
|
799
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
|
+
}
|
|
800
912
|
|
|
801
913
|
/**
|
|
802
914
|
* Start the stream consumer (called lazily on first preload)
|
|
@@ -807,33 +919,49 @@ export function createStreamDB<
|
|
|
807
919
|
|
|
808
920
|
// Start streaming (this is where the connection actually happens)
|
|
809
921
|
streamResponse = await stream.stream<StateEvent>({
|
|
810
|
-
live
|
|
922
|
+
live,
|
|
923
|
+
json: true,
|
|
811
924
|
signal: abortController.signal,
|
|
812
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
|
|
813
940
|
|
|
814
941
|
// Process events as they come in
|
|
815
942
|
streamResponse.subscribeJson((batch) => {
|
|
816
943
|
try {
|
|
944
|
+
lastConsumedOffset = batch.offset
|
|
945
|
+
onBeforeBatch?.(batch)
|
|
946
|
+
|
|
817
947
|
for (const event of batch.items) {
|
|
818
948
|
if (isChangeEvent(event)) {
|
|
819
|
-
dispatcher.dispatchChange(event)
|
|
949
|
+
dispatcher.dispatchChange(event, batch.offset)
|
|
950
|
+
onEvent?.(event)
|
|
820
951
|
} else if (isControlEvent(event)) {
|
|
821
952
|
dispatcher.dispatchControl(event)
|
|
822
953
|
}
|
|
823
954
|
}
|
|
824
955
|
|
|
825
|
-
|
|
826
|
-
|
|
956
|
+
onBatch?.(batch)
|
|
957
|
+
|
|
958
|
+
if (batch.upToDate || dispatcher.ready) {
|
|
827
959
|
dispatcher.markUpToDate()
|
|
828
960
|
}
|
|
829
961
|
} catch (error) {
|
|
830
962
|
console.error(`[StreamDB] Error processing batch:`, error)
|
|
831
|
-
console.error(`[StreamDB] Failed batch:`, batch)
|
|
832
|
-
// Reject all waiting preload promises
|
|
833
963
|
dispatcher.rejectAll(error as Error)
|
|
834
|
-
// Abort the stream to stop further processing
|
|
835
964
|
abortController.abort()
|
|
836
|
-
// Don't rethrow - we've already rejected the promise
|
|
837
965
|
}
|
|
838
966
|
return Promise.resolve()
|
|
839
967
|
})
|
|
@@ -842,6 +970,9 @@ export function createStreamDB<
|
|
|
842
970
|
// Build the StreamDB object with methods
|
|
843
971
|
const dbMethods: StreamDBMethods = {
|
|
844
972
|
stream,
|
|
973
|
+
get offset() {
|
|
974
|
+
return lastConsumedOffset
|
|
975
|
+
},
|
|
845
976
|
preload: async () => {
|
|
846
977
|
await startConsumer()
|
|
847
978
|
await dispatcher.waitForUpToDate()
|
|
@@ -857,11 +988,14 @@ export function createStreamDB<
|
|
|
857
988
|
},
|
|
858
989
|
}
|
|
859
990
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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))
|
|
865
999
|
|
|
866
1000
|
// If actions factory is provided, wrap actions and return db with actions
|
|
867
1001
|
if (actionsFactory) {
|
|
@@ -877,10 +1011,13 @@ export function createStreamDB<
|
|
|
877
1011
|
})
|
|
878
1012
|
}
|
|
879
1013
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1014
|
+
Object.defineProperty(db, `actions`, {
|
|
1015
|
+
value: wrappedActions,
|
|
1016
|
+
enumerable: true,
|
|
1017
|
+
configurable: false,
|
|
1018
|
+
writable: false,
|
|
1019
|
+
})
|
|
1020
|
+
return db as any
|
|
884
1021
|
}
|
|
885
1022
|
|
|
886
1023
|
return db as any
|