@durable-streams/state 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,11 +4,20 @@ Building blocks for transmitting structured state over Durable Streams. Use thes
4
4
 
5
5
  ## Installation
6
6
 
7
+ ```bash
8
+ pnpm add @durable-streams/state
9
+ ```
10
+
11
+ The package exposes two entry points:
12
+
13
+ - **`@durable-streams/state`** — the db-free protocol surface: `createStateSchema` and event helpers, `MaterializedState`, and the event types/guards. No extra dependencies.
14
+ - **`@durable-streams/state/db`** — the reactive, TanStack DB-backed layer (`createStreamDB`, live queries, optimistic actions). This entry requires the `@tanstack/db` peer dependency:
15
+
7
16
  ```bash
8
17
  pnpm add @durable-streams/state @tanstack/db
9
18
  ```
10
19
 
11
- > **Note:** `@tanstack/db` is a peer dependency that must be installed alongside this package. This ensures type compatibility when using StreamDB collections with TanStack DB's query utilities like `useLiveQuery` from `@tanstack/react-db`.
20
+ > **Note:** `@tanstack/db` is a peer dependency of the `/db` entry only. Installing it ensures type compatibility when using StreamDB collections with TanStack DB's query utilities like `useLiveQuery` from `@tanstack/react-db`.
12
21
 
13
22
  ## Overview
14
23
 
@@ -49,7 +58,8 @@ const allTokens = state.getType("token")
49
58
  Add schemas and validation for structured entities:
50
59
 
51
60
  ```typescript
52
- import { createStateSchema, createStreamDB } from "@durable-streams/state"
61
+ import { createStateSchema } from "@durable-streams/state"
62
+ import { createStreamDB } from "@durable-streams/state/db"
53
63
 
54
64
  // Define your schema
55
65
  const schema = createStateSchema({
package/dist/db.cjs ADDED
@@ -0,0 +1,561 @@
1
+ "use strict";
2
+ //#region rolldown:runtime
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+
24
+ //#endregion
25
+ const require_src = require('./src-AIE5IYwJ.cjs');
26
+ const __tanstack_db = __toESM(require("@tanstack/db"));
27
+ const __durable_streams_client = __toESM(require("@durable-streams/client"));
28
+
29
+ //#region src/stream-db.ts
30
+ /**
31
+ * Build a TanStack collection id for a StreamDB collection.
32
+ *
33
+ * Collection ids must be unique per source stream, not just per schema key,
34
+ * otherwise joining the same collection name from two different streams can
35
+ * collapse to one logical source inside TanStack DB.
36
+ */
37
+ function getStreamDBCollectionId(streamUrl, collectionName) {
38
+ return `stream-db:${streamUrl}:${collectionName}`;
39
+ }
40
+ /**
41
+ * Internal event dispatcher that routes stream events to collection handlers
42
+ */
43
+ var EventDispatcher = class {
44
+ /** Map from event type to collection handler */
45
+ handlers = new Map();
46
+ /** Handlers that have pending writes (need commit) */
47
+ pendingHandlers = new Set();
48
+ /** Whether we've received the initial up-to-date signal */
49
+ isUpToDate = false;
50
+ /** Resolvers and rejecters for preload promises */
51
+ preloadResolvers = [];
52
+ preloadRejecters = [];
53
+ /** Set of all txids that have been seen and committed */
54
+ seenTxids = new Set();
55
+ /** Txids collected during current batch (before commit) */
56
+ pendingTxids = new Set();
57
+ /** Resolvers waiting for specific txids */
58
+ txidResolvers = new Map();
59
+ /** Track existing keys per collection for upsert logic */
60
+ existingKeys = new Map();
61
+ /** Global sequence counter for insertion ordering */
62
+ seq = 0;
63
+ comparableRow(row) {
64
+ const clone = { ...row };
65
+ delete clone._seq;
66
+ return clone;
67
+ }
68
+ /**
69
+ * Register a handler for a specific event type
70
+ */
71
+ registerHandler(eventType, handler) {
72
+ this.handlers.set(eventType, handler);
73
+ if (!this.existingKeys.has(eventType)) this.existingKeys.set(eventType, new Set());
74
+ }
75
+ /**
76
+ * Dispatch a change event to the appropriate collection.
77
+ * Writes are buffered until commit() is called via markUpToDate().
78
+ */
79
+ dispatchChange(event, cursor) {
80
+ if (!require_src.isChangeEvent(event)) return;
81
+ const eventCursor = event.headers.offset ?? cursor;
82
+ if (event.headers.txid && typeof event.headers.txid === `string`) this.pendingTxids.add(event.headers.txid);
83
+ const handler = this.handlers.get(event.type);
84
+ if (!handler) return;
85
+ let operation = event.headers.operation;
86
+ if (operation !== `delete`) {
87
+ if (typeof event.value !== `object` || event.value === null) throw new Error(`StreamDB collections require object values; got ${typeof event.value} for type=${event.type}, key=${event.key}`);
88
+ }
89
+ const originalValue = event.value ?? {};
90
+ const value = { ...originalValue };
91
+ value[handler.primaryKey] = event.key;
92
+ value._seq = this.seq++;
93
+ if (!this.pendingHandlers.has(handler)) {
94
+ handler.begin();
95
+ this.pendingHandlers.add(handler);
96
+ }
97
+ if (operation === `upsert`) {
98
+ const keys$1 = this.existingKeys.get(event.type);
99
+ const existing = keys$1?.has(event.key);
100
+ operation = existing ? `update` : `insert`;
101
+ }
102
+ const keys = this.existingKeys.get(event.type);
103
+ if (operation === `insert` && keys?.has(event.key)) operation = `update`;
104
+ else if (operation === `insert` && typeof event.key === `string`) {
105
+ const existingValue = handler.read(event.key);
106
+ if (existingValue && (0, __tanstack_db.deepEquals)(this.comparableRow(existingValue), this.comparableRow(value))) operation = `update`;
107
+ }
108
+ if (operation === `insert` || operation === `update`) keys?.add(event.key);
109
+ else keys?.delete(event.key);
110
+ try {
111
+ handler.write(value, operation, eventCursor);
112
+ } catch (error) {
113
+ console.error(`[StreamDB] Error in handler.write():`, error);
114
+ console.error(`[StreamDB] Event that caused error:`, {
115
+ type: event.type,
116
+ key: event.key,
117
+ operation
118
+ });
119
+ throw error;
120
+ }
121
+ }
122
+ /**
123
+ * Handle control events from the stream JSON items
124
+ */
125
+ dispatchControl(event) {
126
+ if (!require_src.isControlEvent(event)) return;
127
+ switch (event.headers.control) {
128
+ case `reset`:
129
+ for (const handler of this.handlers.values()) handler.truncate();
130
+ for (const keys of this.existingKeys.values()) keys.clear();
131
+ this.pendingHandlers.clear();
132
+ this.isUpToDate = false;
133
+ break;
134
+ case `snapshot-start`:
135
+ case `snapshot-end`: break;
136
+ }
137
+ }
138
+ /**
139
+ * Commit all pending writes and handle up-to-date signal
140
+ */
141
+ markUpToDate() {
142
+ for (const handler of this.pendingHandlers) try {
143
+ handler.commit();
144
+ } catch (error) {
145
+ console.error(`[StreamDB] Error in handler.commit():`, error);
146
+ if (error instanceof Error && error.message.includes(`already exists in the collection`) && error.message.includes(`live-query`)) {
147
+ console.warn(`[StreamDB] Known TanStack DB groupBy bug detected - continuing despite error`);
148
+ console.warn(`[StreamDB] Queries with groupBy may show stale data until fixed`);
149
+ continue;
150
+ }
151
+ throw error;
152
+ }
153
+ this.pendingHandlers.clear();
154
+ for (const txid of this.pendingTxids) {
155
+ this.seenTxids.add(txid);
156
+ const resolvers = this.txidResolvers.get(txid);
157
+ if (resolvers) {
158
+ for (const { resolve, timeoutId } of resolvers) {
159
+ clearTimeout(timeoutId);
160
+ resolve();
161
+ }
162
+ this.txidResolvers.delete(txid);
163
+ }
164
+ }
165
+ this.pendingTxids.clear();
166
+ if (!this.isUpToDate) {
167
+ this.isUpToDate = true;
168
+ for (const handler of this.handlers.values()) handler.markReady();
169
+ for (const resolve of this.preloadResolvers) resolve();
170
+ this.preloadResolvers = [];
171
+ }
172
+ }
173
+ /**
174
+ * Wait for the stream to reach up-to-date state
175
+ */
176
+ waitForUpToDate() {
177
+ if (this.isUpToDate) return Promise.resolve();
178
+ return new Promise((resolve, reject) => {
179
+ this.preloadResolvers.push(resolve);
180
+ this.preloadRejecters.push(reject);
181
+ });
182
+ }
183
+ /**
184
+ * Reject all waiting preload promises with an error
185
+ */
186
+ rejectAll(error) {
187
+ for (const reject of this.preloadRejecters) reject(error);
188
+ this.preloadResolvers = [];
189
+ this.preloadRejecters = [];
190
+ for (const resolvers of this.txidResolvers.values()) for (const { reject, timeoutId } of resolvers) {
191
+ clearTimeout(timeoutId);
192
+ reject(error);
193
+ }
194
+ this.txidResolvers.clear();
195
+ }
196
+ /**
197
+ * Check if we've received up-to-date
198
+ */
199
+ get ready() {
200
+ return this.isUpToDate;
201
+ }
202
+ /**
203
+ * Wait for a specific txid to be seen in the stream
204
+ */
205
+ awaitTxId(txid, timeout = 5e3) {
206
+ if (this.seenTxids.has(txid)) return Promise.resolve();
207
+ return new Promise((resolve, reject) => {
208
+ const timeoutId = setTimeout(() => {
209
+ const resolvers = this.txidResolvers.get(txid);
210
+ if (resolvers) {
211
+ const index = resolvers.findIndex((r) => r.timeoutId === timeoutId);
212
+ if (index !== -1) resolvers.splice(index, 1);
213
+ if (resolvers.length === 0) this.txidResolvers.delete(txid);
214
+ }
215
+ reject(new Error(`Timeout waiting for txid: ${txid}`));
216
+ }, timeout);
217
+ if (!this.txidResolvers.has(txid)) this.txidResolvers.set(txid, []);
218
+ this.txidResolvers.get(txid).push({
219
+ resolve,
220
+ reject,
221
+ timeoutId
222
+ });
223
+ });
224
+ }
225
+ };
226
+ /**
227
+ * Create a sync config for a stream-backed collection
228
+ */
229
+ function createStreamSyncConfig(eventType, dispatcher, primaryKey, read) {
230
+ return { sync: ({ begin, write, commit, markReady, truncate }) => {
231
+ dispatcher.registerHandler(eventType, {
232
+ begin,
233
+ write: (value, type, _cursor) => {
234
+ write({
235
+ value,
236
+ type
237
+ });
238
+ },
239
+ read: (key) => read(key),
240
+ commit,
241
+ markReady,
242
+ truncate,
243
+ primaryKey
244
+ });
245
+ if (dispatcher.ready) markReady();
246
+ return () => {};
247
+ } };
248
+ }
249
+ /**
250
+ * Create a stream-backed database with TanStack DB collections
251
+ *
252
+ * This function is synchronous - it creates the stream handle and collections
253
+ * but does not start the stream connection. Call `db.preload()` to connect
254
+ * and sync initial data.
255
+ *
256
+ * @example
257
+ * ```typescript
258
+ * const stateSchema = createStateSchema({
259
+ * users: { schema: userSchema, type: "user", primaryKey: "id" },
260
+ * messages: { schema: messageSchema, type: "message", primaryKey: "id" },
261
+ * })
262
+ *
263
+ * // Create a stream DB (synchronous - stream is created lazily on preload)
264
+ * const db = createStreamDB({
265
+ * streamOptions: {
266
+ * url: "https://api.example.com/streams/my-stream",
267
+ * contentType: "application/json",
268
+ * },
269
+ * state: stateSchema,
270
+ * })
271
+ *
272
+ * // preload() creates the stream and loads initial data
273
+ * await db.preload()
274
+ * const user = await db.collections.users.get("123")
275
+ * ```
276
+ */
277
+ function createStreamDB(options) {
278
+ const { streamOptions, state, actions: actionsFactory, live = true, onEvent, onBeforeBatch, onBatch } = options;
279
+ const stream = options.stream ?? (() => {
280
+ if (!streamOptions) throw new Error(`createStreamDB requires stream or streamOptions`);
281
+ return new __durable_streams_client.DurableStream(streamOptions);
282
+ })();
283
+ const dispatcher = new EventDispatcher();
284
+ const streamIdentity = stream.url;
285
+ const collectionInstances = {};
286
+ for (const [name, definition] of Object.entries(state)) {
287
+ let collection = (0, __tanstack_db.createCollection)({
288
+ id: getStreamDBCollectionId(streamIdentity, name),
289
+ schema: definition.schema,
290
+ getKey: (item) => String(item[definition.primaryKey]),
291
+ sync: createStreamSyncConfig(definition.type, dispatcher, definition.primaryKey, (key) => collection.get(key)),
292
+ startSync: true,
293
+ gcTime: 0
294
+ });
295
+ collectionInstances[name] = collection;
296
+ }
297
+ let streamResponse = null;
298
+ const abortController = new AbortController();
299
+ let consumerStarted = false;
300
+ let lastConsumedOffset = `-1`;
301
+ const isAbortLikeError = (err) => {
302
+ if (abortController.signal.aborted) return true;
303
+ if (!(err instanceof Error)) return false;
304
+ return err.name === `AbortError` || err.name === `FetchBackoffAbortError` || err.message === `Stream request was aborted`;
305
+ };
306
+ /**
307
+ * Start the stream consumer (called lazily on first preload)
308
+ */
309
+ const startConsumer = async () => {
310
+ if (consumerStarted) return;
311
+ consumerStarted = true;
312
+ streamResponse = await stream.stream({
313
+ live,
314
+ json: true,
315
+ signal: abortController.signal
316
+ });
317
+ streamResponse.closed.catch((err) => {
318
+ if (isAbortLikeError(err)) return void 0;
319
+ const error = err instanceof Error ? err : new Error(String(err));
320
+ console.error(`[StreamDB] Stream consumer closed unexpectedly:`, error);
321
+ dispatcher.rejectAll(error);
322
+ return void 0;
323
+ });
324
+ lastConsumedOffset = streamResponse.offset;
325
+ streamResponse.subscribeJson((batch) => {
326
+ try {
327
+ lastConsumedOffset = batch.offset;
328
+ onBeforeBatch?.(batch);
329
+ for (const event of batch.items) if (require_src.isChangeEvent(event)) {
330
+ dispatcher.dispatchChange(event, batch.offset);
331
+ onEvent?.(event);
332
+ } else if (require_src.isControlEvent(event)) dispatcher.dispatchControl(event);
333
+ onBatch?.(batch);
334
+ if (batch.upToDate || dispatcher.ready) dispatcher.markUpToDate();
335
+ } catch (error) {
336
+ console.error(`[StreamDB] Error processing batch:`, error);
337
+ dispatcher.rejectAll(error);
338
+ abortController.abort();
339
+ }
340
+ return Promise.resolve();
341
+ });
342
+ };
343
+ const dbMethods = {
344
+ stream,
345
+ get offset() {
346
+ return lastConsumedOffset;
347
+ },
348
+ preload: async () => {
349
+ await startConsumer();
350
+ await dispatcher.waitForUpToDate();
351
+ },
352
+ close: () => {
353
+ dispatcher.rejectAll(new Error(`StreamDB closed`));
354
+ abortController.abort();
355
+ },
356
+ utils: { awaitTxId: (txid, timeout) => dispatcher.awaitTxId(txid, timeout) }
357
+ };
358
+ const db = Object.create(null);
359
+ Object.defineProperty(db, `collections`, {
360
+ value: collectionInstances,
361
+ enumerable: true,
362
+ configurable: false,
363
+ writable: false
364
+ });
365
+ Object.defineProperties(db, Object.getOwnPropertyDescriptors(dbMethods));
366
+ if (actionsFactory) {
367
+ const actionDefs = actionsFactory({
368
+ db,
369
+ stream
370
+ });
371
+ const wrappedActions = {};
372
+ for (const [name, def] of Object.entries(actionDefs)) wrappedActions[name] = (0, __tanstack_db.createOptimisticAction)({
373
+ onMutate: def.onMutate,
374
+ mutationFn: def.mutationFn
375
+ });
376
+ Object.defineProperty(db, `actions`, {
377
+ value: wrappedActions,
378
+ enumerable: true,
379
+ configurable: false,
380
+ writable: false
381
+ });
382
+ return db;
383
+ }
384
+ return db;
385
+ }
386
+
387
+ //#endregion
388
+ exports.MaterializedState = require_src.MaterializedState
389
+ Object.defineProperty(exports, 'and', {
390
+ enumerable: true,
391
+ get: function () {
392
+ return __tanstack_db.and;
393
+ }
394
+ });
395
+ Object.defineProperty(exports, 'avg', {
396
+ enumerable: true,
397
+ get: function () {
398
+ return __tanstack_db.avg;
399
+ }
400
+ });
401
+ Object.defineProperty(exports, 'coalesce', {
402
+ enumerable: true,
403
+ get: function () {
404
+ return __tanstack_db.coalesce;
405
+ }
406
+ });
407
+ Object.defineProperty(exports, 'concat', {
408
+ enumerable: true,
409
+ get: function () {
410
+ return __tanstack_db.concat;
411
+ }
412
+ });
413
+ Object.defineProperty(exports, 'count', {
414
+ enumerable: true,
415
+ get: function () {
416
+ return __tanstack_db.count;
417
+ }
418
+ });
419
+ Object.defineProperty(exports, 'createCollection', {
420
+ enumerable: true,
421
+ get: function () {
422
+ return __tanstack_db.createCollection;
423
+ }
424
+ });
425
+ Object.defineProperty(exports, 'createLiveQueryCollection', {
426
+ enumerable: true,
427
+ get: function () {
428
+ return __tanstack_db.createLiveQueryCollection;
429
+ }
430
+ });
431
+ Object.defineProperty(exports, 'createOptimisticAction', {
432
+ enumerable: true,
433
+ get: function () {
434
+ return __tanstack_db.createOptimisticAction;
435
+ }
436
+ });
437
+ exports.createStateSchema = require_src.createStateSchema
438
+ exports.createStreamDB = createStreamDB
439
+ Object.defineProperty(exports, 'createTransaction', {
440
+ enumerable: true,
441
+ get: function () {
442
+ return __tanstack_db.createTransaction;
443
+ }
444
+ });
445
+ Object.defineProperty(exports, 'deepEquals', {
446
+ enumerable: true,
447
+ get: function () {
448
+ return __tanstack_db.deepEquals;
449
+ }
450
+ });
451
+ Object.defineProperty(exports, 'eq', {
452
+ enumerable: true,
453
+ get: function () {
454
+ return __tanstack_db.eq;
455
+ }
456
+ });
457
+ exports.getStreamDBCollectionId = getStreamDBCollectionId
458
+ Object.defineProperty(exports, 'gt', {
459
+ enumerable: true,
460
+ get: function () {
461
+ return __tanstack_db.gt;
462
+ }
463
+ });
464
+ Object.defineProperty(exports, 'gte', {
465
+ enumerable: true,
466
+ get: function () {
467
+ return __tanstack_db.gte;
468
+ }
469
+ });
470
+ Object.defineProperty(exports, 'ilike', {
471
+ enumerable: true,
472
+ get: function () {
473
+ return __tanstack_db.ilike;
474
+ }
475
+ });
476
+ Object.defineProperty(exports, 'inArray', {
477
+ enumerable: true,
478
+ get: function () {
479
+ return __tanstack_db.inArray;
480
+ }
481
+ });
482
+ exports.isChangeEvent = require_src.isChangeEvent
483
+ exports.isControlEvent = require_src.isControlEvent
484
+ Object.defineProperty(exports, 'isNull', {
485
+ enumerable: true,
486
+ get: function () {
487
+ return __tanstack_db.isNull;
488
+ }
489
+ });
490
+ Object.defineProperty(exports, 'isUndefined', {
491
+ enumerable: true,
492
+ get: function () {
493
+ return __tanstack_db.isUndefined;
494
+ }
495
+ });
496
+ Object.defineProperty(exports, 'like', {
497
+ enumerable: true,
498
+ get: function () {
499
+ return __tanstack_db.like;
500
+ }
501
+ });
502
+ Object.defineProperty(exports, 'localOnlyCollectionOptions', {
503
+ enumerable: true,
504
+ get: function () {
505
+ return __tanstack_db.localOnlyCollectionOptions;
506
+ }
507
+ });
508
+ Object.defineProperty(exports, 'lt', {
509
+ enumerable: true,
510
+ get: function () {
511
+ return __tanstack_db.lt;
512
+ }
513
+ });
514
+ Object.defineProperty(exports, 'lte', {
515
+ enumerable: true,
516
+ get: function () {
517
+ return __tanstack_db.lte;
518
+ }
519
+ });
520
+ Object.defineProperty(exports, 'max', {
521
+ enumerable: true,
522
+ get: function () {
523
+ return __tanstack_db.max;
524
+ }
525
+ });
526
+ Object.defineProperty(exports, 'min', {
527
+ enumerable: true,
528
+ get: function () {
529
+ return __tanstack_db.min;
530
+ }
531
+ });
532
+ Object.defineProperty(exports, 'not', {
533
+ enumerable: true,
534
+ get: function () {
535
+ return __tanstack_db.not;
536
+ }
537
+ });
538
+ Object.defineProperty(exports, 'or', {
539
+ enumerable: true,
540
+ get: function () {
541
+ return __tanstack_db.or;
542
+ }
543
+ });
544
+ Object.defineProperty(exports, 'queryOnce', {
545
+ enumerable: true,
546
+ get: function () {
547
+ return __tanstack_db.queryOnce;
548
+ }
549
+ });
550
+ Object.defineProperty(exports, 'sum', {
551
+ enumerable: true,
552
+ get: function () {
553
+ return __tanstack_db.sum;
554
+ }
555
+ });
556
+ Object.defineProperty(exports, 'toArray', {
557
+ enumerable: true,
558
+ get: function () {
559
+ return __tanstack_db.toArray;
560
+ }
561
+ });