@durable-streams/state 0.1.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 ADDED
@@ -0,0 +1,654 @@
1
+ # @durable-streams/state
2
+
3
+ Building blocks for transmitting structured state over Durable Streams. Use these primitives for any real-time protocol: AI token streams, presence updates, collaborative editing, or database sync.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @durable-streams/state
9
+ ```
10
+
11
+ ## Overview
12
+
13
+ This package provides flexible primitives for streaming structured state. You choose how much structure you need:
14
+
15
+ - **Simple state updates**: Stream JSON payloads and track current values
16
+ - **Typed collections**: Add schemas and primary keys for structured entities
17
+ - **Reactive queries**: Build on TanStack DB for subscriptions and optimistic updates
18
+
19
+ Stream whatever state your protocol requires.
20
+
21
+ ## Quick Start
22
+
23
+ ### Simple State
24
+
25
+ Stream structured JSON and query current values:
26
+
27
+ ```typescript
28
+ import { MaterializedState } from "@durable-streams/state"
29
+
30
+ const state = new MaterializedState()
31
+
32
+ // Apply any structured change
33
+ state.apply({
34
+ type: "token",
35
+ key: "stream-1",
36
+ value: { content: "Hello", model: "claude-3" },
37
+ headers: { operation: "insert" },
38
+ })
39
+
40
+ // Query current state
41
+ const token = state.get("token", "stream-1")
42
+ const allTokens = state.getType("token")
43
+ ```
44
+
45
+ ### Typed Collections
46
+
47
+ Add schemas and validation for structured entities:
48
+
49
+ ```typescript
50
+ import { createStateSchema, createStreamDB } from "@durable-streams/state"
51
+
52
+ // Define your schema
53
+ const schema = createStateSchema({
54
+ users: {
55
+ schema: userSchema, // Standard Schema validator
56
+ type: "user", // Event type field
57
+ primaryKey: "id", // Primary key field name
58
+ },
59
+ messages: {
60
+ schema: messageSchema,
61
+ type: "message",
62
+ primaryKey: "id",
63
+ },
64
+ })
65
+
66
+ // Create a stream-backed database
67
+ const db = createStreamDB({
68
+ streamOptions: {
69
+ url: "https://api.example.com/streams/my-stream",
70
+ contentType: "application/json",
71
+ },
72
+ state: schema,
73
+ })
74
+
75
+ // Load initial data
76
+ await db.preload()
77
+
78
+ // Reactive queries with useLiveQuery
79
+ import { useLiveQuery } from "@tanstack/react-db" // or solid-db, vue-db
80
+ import { eq } from "@tanstack/db"
81
+
82
+ const userQuery = useLiveQuery((q) =>
83
+ q
84
+ .from({ users: db.collections.users })
85
+ .where(({ users }) => eq(users.id, "123"))
86
+ .findOne()
87
+ )
88
+
89
+ const allUsersQuery = useLiveQuery((q) =>
90
+ q.from({ users: db.collections.users })
91
+ )
92
+ ```
93
+
94
+ ## Core Concepts
95
+
96
+ ### State Protocol
97
+
98
+ The Durable Streams State Protocol defines a standard format for state change events:
99
+
100
+ - **Change Events**: `insert`, `update`, `delete` operations on entities
101
+ - **Control Events**: `snapshot-start`, `snapshot-end`, `reset` signals
102
+ - **Entity Types**: Discriminator field that routes events to collections
103
+ - **Primary Keys**: Unique identifiers extracted from entity values
104
+
105
+ See [STATE-PROTOCOL.md](./STATE-PROTOCOL.md) for the full specification.
106
+
107
+ ### MaterializedState
108
+
109
+ Simple in-memory state container for basic use cases:
110
+
111
+ ```typescript
112
+ import { MaterializedState } from "@durable-streams/state"
113
+
114
+ const state = new MaterializedState()
115
+
116
+ // Apply change events
117
+ state.apply({
118
+ type: "user",
119
+ key: "1",
120
+ value: { name: "Kyle" },
121
+ headers: { operation: "insert" },
122
+ })
123
+
124
+ // Query state
125
+ const user = state.get("user", "1")
126
+ const allUsers = state.getType("user")
127
+ ```
128
+
129
+ ### StreamDB
130
+
131
+ Stream-backed database with TanStack DB collections. Provides reactive queries, subscriptions, and optimistic updates.
132
+
133
+ ## Schema Definition
134
+
135
+ ### createStateSchema()
136
+
137
+ Define your application state structure:
138
+
139
+ ```typescript
140
+ const schema = createStateSchema({
141
+ users: {
142
+ schema: userSchema, // Standard Schema validator
143
+ type: "user", // Event type for routing
144
+ primaryKey: "id", // Field to use as primary key
145
+ },
146
+ messages: {
147
+ schema: messageSchema,
148
+ type: "message",
149
+ primaryKey: "id",
150
+ },
151
+ })
152
+ ```
153
+
154
+ ### Standard Schema Support
155
+
156
+ Uses [Standard Schema](https://standardschema.dev/) for validation, supporting multiple libraries:
157
+
158
+ ```typescript
159
+ // Zod
160
+ import { z } from "zod"
161
+
162
+ const userSchema = z.object({
163
+ id: z.string(),
164
+ name: z.string(),
165
+ email: z.string().email(),
166
+ })
167
+
168
+ // Valibot
169
+ import * as v from "valibot"
170
+
171
+ const userSchema = v.object({
172
+ id: v.string(),
173
+ name: v.string(),
174
+ email: v.pipe(v.string(), v.email()),
175
+ })
176
+
177
+ // Manual Standard Schema
178
+ const userSchema = {
179
+ "~standard": {
180
+ version: 1,
181
+ vendor: "my-app",
182
+ validate: (value) => {
183
+ // Your validation logic
184
+ if (isValid(value)) {
185
+ return { value }
186
+ }
187
+ return { issues: [{ message: "Invalid user" }] }
188
+ },
189
+ },
190
+ }
191
+ ```
192
+
193
+ ## Event Helpers
194
+
195
+ Schema provides typed event creation helpers:
196
+
197
+ ```typescript
198
+ // Insert
199
+ const insertEvent = schema.users.insert({
200
+ value: { id: "1", name: "Kyle", email: "kyle@example.com" },
201
+ key: "1", // Optional, defaults to value[primaryKey]
202
+ })
203
+
204
+ // Update
205
+ const updateEvent = schema.users.update({
206
+ value: { id: "1", name: "Kyle Mathews", email: "kyle@example.com" },
207
+ oldValue: { id: "1", name: "Kyle", email: "kyle@example.com" }, // Optional
208
+ })
209
+
210
+ // Delete
211
+ const deleteEvent = schema.users.delete({
212
+ key: "1",
213
+ oldValue: { id: "1", name: "Kyle", email: "kyle@example.com" }, // Optional
214
+ })
215
+
216
+ // Custom headers
217
+ const eventWithTxId = schema.users.insert({
218
+ value: { id: "1", name: "Kyle" },
219
+ headers: {
220
+ txid: crypto.randomUUID(),
221
+ timestamp: new Date().toISOString(),
222
+ },
223
+ })
224
+ ```
225
+
226
+ ## StreamDB
227
+
228
+ ### Creating a Database
229
+
230
+ ```typescript
231
+ const db = createStreamDB({
232
+ streamOptions: {
233
+ url: "https://api.example.com/streams/my-stream",
234
+ contentType: "application/json",
235
+ // All DurableStream options supported
236
+ headers: { Authorization: "Bearer token" },
237
+ batching: true,
238
+ },
239
+ state: schema,
240
+ })
241
+
242
+ // The stream is created lazily when preload() is called
243
+ await db.preload()
244
+ ```
245
+
246
+ ### Reactive Queries with TanStack DB
247
+
248
+ StreamDB collections are TanStack DB collections. Use TanStack DB's query builder for filtering, sorting, aggregation, and joins with **differential dataflow** - dramatically faster than JavaScript filtering:
249
+
250
+ ```typescript
251
+ import { useLiveQuery } from "@tanstack/[framework]-db" // react-db, solid-db, etc
252
+ import { eq, gt, and, count } from "@tanstack/db"
253
+
254
+ // Simple collection access
255
+ const query = useLiveQuery((q) => q.from({ users: db.collections.users }))
256
+
257
+ // Filtering with WHERE
258
+ const activeQuery = useLiveQuery((q) =>
259
+ q
260
+ .from({ users: db.collections.users })
261
+ .where(({ users }) => eq(users.active, true))
262
+ )
263
+
264
+ // Complex conditions
265
+ const query = useLiveQuery((q) =>
266
+ q
267
+ .from({ users: db.collections.users })
268
+ .where(({ users }) => and(eq(users.active, true), gt(users.age, 18)))
269
+ )
270
+
271
+ // Sorting and limiting
272
+ const topUsersQuery = useLiveQuery((q) =>
273
+ q
274
+ .from({ users: db.collections.users })
275
+ .orderBy(({ users }) => users.lastSeen, "desc")
276
+ .limit(10)
277
+ )
278
+
279
+ // Aggregation with GROUP BY and ordering
280
+ const langStatsQuery = useLiveQuery((q) => {
281
+ const languageCounts = q
282
+ .from({ events: db.collections.events })
283
+ .groupBy(({ events }) => events.language)
284
+ .select(({ events }) => ({
285
+ language: events.language,
286
+ total: count(events.id),
287
+ }))
288
+
289
+ return q
290
+ .from({ stats: languageCounts })
291
+ .orderBy(({ stats }) => stats.total, "desc")
292
+ })
293
+
294
+ // Joins across collections
295
+ const query = useLiveQuery((q) =>
296
+ q
297
+ .from({ messages: db.collections.messages })
298
+ .join({ users: db.collections.users }, ({ messages, users }) =>
299
+ eq(messages.userId, users.id)
300
+ )
301
+ .select(({ messages, users }) => ({
302
+ messageId: messages.id,
303
+ text: messages.text,
304
+ userName: users.name,
305
+ }))
306
+ )
307
+ ```
308
+
309
+ **Why use the query builder?**
310
+
311
+ - **Differential dataflow**: Incremental updates only recompute affected results
312
+ - **Dramatically faster**: Push filtering/sorting into the DB engine vs JavaScript
313
+ - **Reactive**: Queries automatically update when data changes
314
+ - **Type-safe**: Full TypeScript support with autocomplete
315
+
316
+ **Framework integration**: See [TanStack DB docs](https://tanstack.com/db) for framework-specific guides:
317
+
318
+ - [@tanstack/react-db](https://tanstack.com/db/latest/docs/framework/react/overview)
319
+ - [@tanstack/solid-db](https://tanstack.com/db/latest/docs/framework/solid/overview)
320
+ - [@tanstack/vue-db](https://tanstack.com/db/latest/docs/framework/vue/overview)
321
+
322
+ ### Lifecycle Methods
323
+
324
+ ```typescript
325
+ // Load all data until up-to-date
326
+ await db.preload()
327
+
328
+ // Stop syncing and cleanup
329
+ db.close()
330
+
331
+ // Wait for a transaction to be confirmed
332
+ await db.utils.awaitTxId("txid-uuid", 5000) // 5 second timeout
333
+ ```
334
+
335
+ ## Optimistic Actions
336
+
337
+ Define actions with optimistic updates and server confirmation:
338
+
339
+ ```typescript
340
+ const db = createStreamDB({
341
+ streamOptions: { url: streamUrl, contentType: "application/json" },
342
+ state: schema,
343
+ actions: ({ db, stream }) => ({
344
+ addUser: {
345
+ // Optimistic update (runs immediately)
346
+ onMutate: (user) => {
347
+ db.collections.users.insert(user)
348
+ },
349
+ // Server mutation (runs async)
350
+ mutationFn: async (user) => {
351
+ const txid = crypto.randomUUID()
352
+
353
+ await stream.append(
354
+ schema.users.insert({
355
+ value: user,
356
+ headers: { txid },
357
+ })
358
+ )
359
+
360
+ // Wait for confirmation
361
+ await db.utils.awaitTxId(txid)
362
+ },
363
+ },
364
+
365
+ updateUser: {
366
+ onMutate: ({ id, updates }) => {
367
+ db.collections.users.update(id, (draft) => {
368
+ Object.assign(draft, updates)
369
+ })
370
+ },
371
+ mutationFn: async ({ id, updates }) => {
372
+ const txid = crypto.randomUUID()
373
+ const current = await db.collections.users.get(id)
374
+
375
+ await stream.append(
376
+ schema.users.update({
377
+ value: { ...current, ...updates },
378
+ oldValue: current,
379
+ headers: { txid },
380
+ })
381
+ )
382
+
383
+ await db.utils.awaitTxId(txid)
384
+ },
385
+ },
386
+ }),
387
+ })
388
+
389
+ // Call actions
390
+ await db.actions.addUser({ id: "1", name: "Kyle", email: "kyle@example.com" })
391
+ await db.actions.updateUser({ id: "1", updates: { name: "Kyle Mathews" } })
392
+ ```
393
+
394
+ ## Framework Integration
395
+
396
+ Use TanStack DB's framework adapters for reactive queries:
397
+
398
+ ### React
399
+
400
+ ```typescript
401
+ import { useLiveQuery } from '@tanstack/react-db'
402
+ import { eq } from '@tanstack/db'
403
+
404
+ function UserProfile({ userId }: { userId: string }) {
405
+ const userQuery = useLiveQuery((q) =>
406
+ q.from({ users: db.collections.users })
407
+ .where(({ users }) => eq(users.id, userId))
408
+ .findOne()
409
+ )
410
+
411
+ if (userQuery.isLoading()) return <div>Loading...</div>
412
+ if (!userQuery.data) return <div>Not found</div>
413
+
414
+ return (
415
+ <div>
416
+ <h1>{userQuery.data.name}</h1>
417
+ <p>{userQuery.data.email}</p>
418
+ </div>
419
+ )
420
+ }
421
+ ```
422
+
423
+ See [@tanstack/react-db docs](https://tanstack.com/db/latest/docs/framework/react/overview) for more.
424
+
425
+ ### Solid.js
426
+
427
+ ```typescript
428
+ import { useLiveQuery } from '@tanstack/solid-db'
429
+ import { eq } from '@tanstack/db'
430
+
431
+ function MessageList() {
432
+ const messagesQuery = useLiveQuery((q) =>
433
+ q.from({ messages: db.collections.messages })
434
+ .orderBy(({ messages }) => messages.timestamp, 'desc')
435
+ .limit(50)
436
+ )
437
+
438
+ return (
439
+ <For each={messagesQuery.data}>
440
+ {(message) => <MessageCard message={message} />}
441
+ </For>
442
+ )
443
+ }
444
+ ```
445
+
446
+ See [@tanstack/solid-db docs](https://tanstack.com/db/latest/docs/framework/solid/overview) for more.
447
+
448
+ ## Common Patterns
449
+
450
+ ### Key/Value Store
451
+
452
+ ```typescript
453
+ const schema = createStateSchema({
454
+ config: {
455
+ schema: configSchema,
456
+ type: "config",
457
+ primaryKey: "key",
458
+ },
459
+ })
460
+
461
+ // Set value
462
+ await stream.append(
463
+ schema.config.insert({
464
+ value: { key: "theme", value: "dark" },
465
+ })
466
+ )
467
+
468
+ // Query value reactively
469
+ const themeQuery = useLiveQuery((q) =>
470
+ q
471
+ .from({ config: db.collections.config })
472
+ .where(({ config }) => eq(config.key, "theme"))
473
+ .findOne()
474
+ )
475
+ ```
476
+
477
+ ### Presence Tracking
478
+
479
+ ```typescript
480
+ const schema = createStateSchema({
481
+ presence: {
482
+ schema: presenceSchema,
483
+ type: "presence",
484
+ primaryKey: "userId",
485
+ },
486
+ })
487
+
488
+ // Update presence
489
+ await stream.append(
490
+ schema.presence.update({
491
+ value: {
492
+ userId: "kyle",
493
+ status: "online",
494
+ lastSeen: Date.now(),
495
+ },
496
+ })
497
+ )
498
+
499
+ // Query presence with TanStack DB
500
+ const presenceQuery = useLiveQuery((q) =>
501
+ q
502
+ .from({ presence: db.collections.presence })
503
+ .where(({ presence }) => eq(presence.status, "online"))
504
+ )
505
+ ```
506
+
507
+ ### Multi-Type Chat Room
508
+
509
+ ```typescript
510
+ const schema = createStateSchema({
511
+ users: { schema: userSchema, type: "user", primaryKey: "id" },
512
+ messages: { schema: messageSchema, type: "message", primaryKey: "id" },
513
+ reactions: { schema: reactionSchema, type: "reaction", primaryKey: "id" },
514
+ typing: { schema: typingSchema, type: "typing", primaryKey: "userId" },
515
+ })
516
+
517
+ // Different types coexist in the same stream
518
+ await stream.append(schema.users.insert({ value: user }))
519
+ await stream.append(schema.messages.insert({ value: message }))
520
+ await stream.append(schema.reactions.insert({ value: reaction }))
521
+ ```
522
+
523
+ ## Best Practices
524
+
525
+ ### 1. Use Object Values
526
+
527
+ StreamDB requires object values (not primitives) for the primary key pattern:
528
+
529
+ ```typescript
530
+ // ❌ Won't work
531
+ { type: 'count', key: 'views', value: 42 }
532
+
533
+ // ✅ Works
534
+ { type: 'count', key: 'views', value: { count: 42 } }
535
+ ```
536
+
537
+ ### 2. Always Call close()
538
+
539
+ ```typescript
540
+ useEffect(() => {
541
+ const db = createStreamDB({ streamOptions, state: schema })
542
+
543
+ return () => db.close() // Cleanup on unmount
544
+ }, [])
545
+ ```
546
+
547
+ ### 3. Validate at Boundaries
548
+
549
+ Use Standard Schema to validate data at system boundaries:
550
+
551
+ ```typescript
552
+ const userSchema = z.object({
553
+ id: z.string().uuid(),
554
+ email: z.string().email(),
555
+ age: z.number().min(0).max(150),
556
+ })
557
+ ```
558
+
559
+ ### 4. Use Transaction IDs
560
+
561
+ For critical operations, always use transaction IDs to ensure confirmation:
562
+
563
+ ```typescript
564
+ const txid = crypto.randomUUID()
565
+ await stream.append(schema.users.insert({ value: user, headers: { txid } }))
566
+ await db.utils.awaitTxId(txid, 10000) // Wait up to 10 seconds
567
+ ```
568
+
569
+ ### 5. Handle Errors Gracefully
570
+
571
+ ```typescript
572
+ try {
573
+ await db.actions.addUser(user)
574
+ } catch (error) {
575
+ if (error.message.includes("Timeout")) {
576
+ // Handle timeout
577
+ } else {
578
+ // Handle other errors
579
+ }
580
+ }
581
+ ```
582
+
583
+ ## API Reference
584
+
585
+ ### Types
586
+
587
+ ```typescript
588
+ export type Operation = "insert" | "update" | "delete"
589
+
590
+ export interface ChangeEvent<T = unknown> {
591
+ type: string
592
+ key: string
593
+ value?: T
594
+ old_value?: T
595
+ headers: ChangeHeaders
596
+ }
597
+
598
+ export interface ChangeHeaders {
599
+ operation: Operation
600
+ txid?: string
601
+ timestamp?: string
602
+ }
603
+
604
+ export interface ControlEvent {
605
+ headers: {
606
+ control: "snapshot-start" | "snapshot-end" | "reset"
607
+ offset?: string
608
+ }
609
+ }
610
+
611
+ export type StateEvent<T = unknown> = ChangeEvent<T> | ControlEvent
612
+ ```
613
+
614
+ ### Functions
615
+
616
+ ```typescript
617
+ // Create a state schema with typed collections and event helpers
618
+ export function createStateSchema<
619
+ T extends Record<string, CollectionDefinition>,
620
+ >(collections: T): StateSchema<T>
621
+
622
+ // Create a stream-backed database
623
+ export async function createStreamDB<
624
+ TDef extends StreamStateDefinition,
625
+ TActions extends Record<string, ActionDefinition<any>>,
626
+ >(
627
+ options: CreateStreamDBOptions<TDef, TActions>
628
+ ): Promise<StreamDB<TDef> | StreamDBWithActions<TDef, TActions>>
629
+ ```
630
+
631
+ ### Classes
632
+
633
+ ```typescript
634
+ export class MaterializedState {
635
+ apply(event: ChangeEvent): void
636
+ applyBatch(events: ChangeEvent[]): void
637
+ get<T>(type: string, key: string): T | undefined
638
+ getType(type: string): Map<string, unknown>
639
+ clear(): void
640
+ readonly typeCount: number
641
+ readonly types: string[]
642
+ }
643
+ ```
644
+
645
+ ## License
646
+
647
+ Apache-2.0
648
+
649
+ ## Learn More
650
+
651
+ - [STATE-PROTOCOL.md](./STATE-PROTOCOL.md) - Full protocol specification
652
+ - [Durable Streams Protocol](../../PROTOCOL.md) - Base protocol
653
+ - [Standard Schema](https://standardschema.dev/) - Schema validation
654
+ - [TanStack DB](https://tanstack.com/db) - Reactive collections