@tanstack/electric-db-collection 0.0.2

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/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@tanstack/electric-db-collection",
3
+ "description": "Electric SQL collection for TanStack DB",
4
+ "version": "0.0.2",
5
+ "dependencies": {
6
+ "@electric-sql/client": "1.0.0",
7
+ "@standard-schema/spec": "^1.0.0",
8
+ "@tanstack/db": "workspace:*",
9
+ "@tanstack/store": "^0.7.0",
10
+ "debug": "^4.4.1"
11
+ },
12
+ "devDependencies": {
13
+ "@types/debug": "^4.1.12",
14
+ "@vitest/coverage-istanbul": "^3.0.9"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "import": {
19
+ "types": "./dist/esm/index.d.ts",
20
+ "default": "./dist/esm/index.js"
21
+ },
22
+ "require": {
23
+ "types": "./dist/cjs/index.d.cts",
24
+ "default": "./dist/cjs/index.cjs"
25
+ }
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "main": "dist/cjs/index.cjs",
34
+ "module": "dist/esm/index.js",
35
+ "packageManager": "pnpm@10.6.3",
36
+ "peerDependencies": {
37
+ "@electric-sql/client": ">=1.0.0",
38
+ "typescript": ">=4.7"
39
+ },
40
+ "author": "Kyle Mathews",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/TanStack/db.git",
45
+ "directory": "packages/electric-db-collection"
46
+ },
47
+ "homepage": "https://tanstack.com/db",
48
+ "keywords": [
49
+ "electric",
50
+ "sql",
51
+ "optimistic",
52
+ "typescript"
53
+ ],
54
+ "scripts": {
55
+ "build": "vite build",
56
+ "dev": "vite build --watch",
57
+ "lint": "eslint . --fix",
58
+ "test": "npx vitest --run"
59
+ },
60
+ "sideEffects": false,
61
+ "type": "module",
62
+ "types": "dist/esm/index.d.ts"
63
+ }
@@ -0,0 +1,553 @@
1
+ import {
2
+ ShapeStream,
3
+ isChangeMessage,
4
+ isControlMessage,
5
+ } from "@electric-sql/client"
6
+ import { Store } from "@tanstack/store"
7
+ import DebugModule from "debug"
8
+ import type {
9
+ CollectionConfig,
10
+ DeleteMutationFnParams,
11
+ InsertMutationFnParams,
12
+ SyncConfig,
13
+ UpdateMutationFnParams,
14
+ UtilsRecord,
15
+ } from "@tanstack/db"
16
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
17
+ import type {
18
+ ControlMessage,
19
+ GetExtensions,
20
+ Message,
21
+ Row,
22
+ ShapeStreamOptions,
23
+ } from "@electric-sql/client"
24
+
25
+ const debug = DebugModule.debug(`ts/db:electric`)
26
+
27
+ /**
28
+ * Type representing a transaction ID in Electric SQL
29
+ */
30
+ export type Txid = number
31
+
32
+ // The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package
33
+ // but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row<unknown>`
34
+ // This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema
35
+ type InferSchemaOutput<T> = T extends StandardSchemaV1
36
+ ? StandardSchemaV1.InferOutput<T> extends Row<unknown>
37
+ ? StandardSchemaV1.InferOutput<T>
38
+ : Record<string, unknown>
39
+ : Record<string, unknown>
40
+
41
+ type ResolveType<
42
+ TExplicit extends Row<unknown> = Row<unknown>,
43
+ TSchema extends StandardSchemaV1 = never,
44
+ TFallback extends object = Record<string, unknown>,
45
+ > =
46
+ unknown extends GetExtensions<TExplicit>
47
+ ? [TSchema] extends [never]
48
+ ? TFallback
49
+ : InferSchemaOutput<TSchema>
50
+ : TExplicit
51
+
52
+ /**
53
+ * Configuration interface for Electric collection options
54
+ * @template TExplicit - The explicit type of items in the collection (highest priority)
55
+ * @template TSchema - The schema type for validation and type inference (second priority)
56
+ * @template TFallback - The fallback type if no explicit or schema type is provided
57
+ *
58
+ * @remarks
59
+ * Type resolution follows a priority order:
60
+ * 1. If you provide an explicit type via generic parameter, it will be used
61
+ * 2. If no explicit type is provided but a schema is, the schema's output type will be inferred
62
+ * 3. If neither explicit type nor schema is provided, the fallback type will be used
63
+ *
64
+ * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict.
65
+ */
66
+ export interface ElectricCollectionConfig<
67
+ TExplicit extends Row<unknown> = Row<unknown>,
68
+ TSchema extends StandardSchemaV1 = never,
69
+ TFallback extends Row<unknown> = Row<unknown>,
70
+ > {
71
+ /**
72
+ * Configuration options for the ElectricSQL ShapeStream
73
+ */
74
+ shapeOptions: ShapeStreamOptions<
75
+ GetExtensions<ResolveType<TExplicit, TSchema, TFallback>>
76
+ >
77
+
78
+ /**
79
+ * All standard Collection configuration properties
80
+ */
81
+ id?: string
82
+ schema?: TSchema
83
+ getKey: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`getKey`]
84
+ sync?: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`sync`]
85
+
86
+ /**
87
+ * Optional asynchronous handler function called before an insert operation
88
+ * Must return an object containing a txid number or array of txids
89
+ * @param params Object containing transaction and collection information
90
+ * @returns Promise resolving to an object with txid or txids
91
+ * @example
92
+ * // Basic Electric insert handler - MUST return { txid: number }
93
+ * onInsert: async ({ transaction }) => {
94
+ * const newItem = transaction.mutations[0].modified
95
+ * const result = await api.todos.create({
96
+ * data: newItem
97
+ * })
98
+ * return { txid: result.txid } // Required for Electric sync matching
99
+ * }
100
+ *
101
+ * @example
102
+ * // Insert handler with multiple items - return array of txids
103
+ * onInsert: async ({ transaction }) => {
104
+ * const items = transaction.mutations.map(m => m.modified)
105
+ * const results = await Promise.all(
106
+ * items.map(item => api.todos.create({ data: item }))
107
+ * )
108
+ * return { txid: results.map(r => r.txid) } // Array of txids
109
+ * }
110
+ *
111
+ * @example
112
+ * // Insert handler with error handling
113
+ * onInsert: async ({ transaction }) => {
114
+ * try {
115
+ * const newItem = transaction.mutations[0].modified
116
+ * const result = await api.createTodo(newItem)
117
+ * return { txid: result.txid }
118
+ * } catch (error) {
119
+ * console.error('Insert failed:', error)
120
+ * throw error // This will cause the transaction to fail
121
+ * }
122
+ * }
123
+ *
124
+ * @example
125
+ * // Insert handler with batch operation - single txid
126
+ * onInsert: async ({ transaction }) => {
127
+ * const items = transaction.mutations.map(m => m.modified)
128
+ * const result = await api.todos.createMany({
129
+ * data: items
130
+ * })
131
+ * return { txid: result.txid } // Single txid for batch operation
132
+ * }
133
+ */
134
+ onInsert?: (
135
+ params: InsertMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
136
+ ) => Promise<{ txid: Txid | Array<Txid> }>
137
+
138
+ /**
139
+ * Optional asynchronous handler function called before an update operation
140
+ * Must return an object containing a txid number or array of txids
141
+ * @param params Object containing transaction and collection information
142
+ * @returns Promise resolving to an object with txid or txids
143
+ * @example
144
+ * // Basic Electric update handler - MUST return { txid: number }
145
+ * onUpdate: async ({ transaction }) => {
146
+ * const { original, changes } = transaction.mutations[0]
147
+ * const result = await api.todos.update({
148
+ * where: { id: original.id },
149
+ * data: changes // Only the changed fields
150
+ * })
151
+ * return { txid: result.txid } // Required for Electric sync matching
152
+ * }
153
+ *
154
+ * @example
155
+ * // Update handler with multiple items - return array of txids
156
+ * onUpdate: async ({ transaction }) => {
157
+ * const updates = await Promise.all(
158
+ * transaction.mutations.map(m =>
159
+ * api.todos.update({
160
+ * where: { id: m.original.id },
161
+ * data: m.changes
162
+ * })
163
+ * )
164
+ * )
165
+ * return { txid: updates.map(u => u.txid) } // Array of txids
166
+ * }
167
+ *
168
+ * @example
169
+ * // Update handler with optimistic rollback
170
+ * onUpdate: async ({ transaction }) => {
171
+ * const mutation = transaction.mutations[0]
172
+ * try {
173
+ * const result = await api.updateTodo(mutation.original.id, mutation.changes)
174
+ * return { txid: result.txid }
175
+ * } catch (error) {
176
+ * // Transaction will automatically rollback optimistic changes
177
+ * console.error('Update failed, rolling back:', error)
178
+ * throw error
179
+ * }
180
+ * }
181
+ */
182
+ onUpdate?: (
183
+ params: UpdateMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
184
+ ) => Promise<{ txid: Txid | Array<Txid> }>
185
+
186
+ /**
187
+ * Optional asynchronous handler function called before a delete operation
188
+ * Must return an object containing a txid number or array of txids
189
+ * @param params Object containing transaction and collection information
190
+ * @returns Promise resolving to an object with txid or txids
191
+ * @example
192
+ * // Basic Electric delete handler - MUST return { txid: number }
193
+ * onDelete: async ({ transaction }) => {
194
+ * const mutation = transaction.mutations[0]
195
+ * const result = await api.todos.delete({
196
+ * id: mutation.original.id
197
+ * })
198
+ * return { txid: result.txid } // Required for Electric sync matching
199
+ * }
200
+ *
201
+ * @example
202
+ * // Delete handler with multiple items - return array of txids
203
+ * onDelete: async ({ transaction }) => {
204
+ * const deletes = await Promise.all(
205
+ * transaction.mutations.map(m =>
206
+ * api.todos.delete({
207
+ * where: { id: m.key }
208
+ * })
209
+ * )
210
+ * )
211
+ * return { txid: deletes.map(d => d.txid) } // Array of txids
212
+ * }
213
+ *
214
+ * @example
215
+ * // Delete handler with batch operation - single txid
216
+ * onDelete: async ({ transaction }) => {
217
+ * const idsToDelete = transaction.mutations.map(m => m.original.id)
218
+ * const result = await api.todos.deleteMany({
219
+ * ids: idsToDelete
220
+ * })
221
+ * return { txid: result.txid } // Single txid for batch operation
222
+ * }
223
+ *
224
+ * @example
225
+ * // Delete handler with optimistic rollback
226
+ * onDelete: async ({ transaction }) => {
227
+ * const mutation = transaction.mutations[0]
228
+ * try {
229
+ * const result = await api.deleteTodo(mutation.original.id)
230
+ * return { txid: result.txid }
231
+ * } catch (error) {
232
+ * // Transaction will automatically rollback optimistic changes
233
+ * console.error('Delete failed, rolling back:', error)
234
+ * throw error
235
+ * }
236
+ * }
237
+ *
238
+ */
239
+ onDelete?: (
240
+ params: DeleteMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
241
+ ) => Promise<{ txid: Txid | Array<Txid> }>
242
+ }
243
+
244
+ function isUpToDateMessage<T extends Row<unknown>>(
245
+ message: Message<T>
246
+ ): message is ControlMessage & { up_to_date: true } {
247
+ return isControlMessage(message) && message.headers.control === `up-to-date`
248
+ }
249
+
250
+ // Check if a message contains txids in its headers
251
+ function hasTxids<T extends Row<unknown>>(
252
+ message: Message<T>
253
+ ): message is Message<T> & { headers: { txids?: Array<Txid> } } {
254
+ return `txids` in message.headers && Array.isArray(message.headers.txids)
255
+ }
256
+
257
+ /**
258
+ * Type for the awaitTxId utility function
259
+ */
260
+ export type AwaitTxIdFn = (txId: Txid, timeout?: number) => Promise<boolean>
261
+
262
+ /**
263
+ * Electric collection utilities type
264
+ */
265
+ export interface ElectricCollectionUtils extends UtilsRecord {
266
+ awaitTxId: AwaitTxIdFn
267
+ }
268
+
269
+ /**
270
+ * Creates Electric collection options for use with a standard Collection
271
+ *
272
+ * @template TExplicit - The explicit type of items in the collection (highest priority)
273
+ * @template TSchema - The schema type for validation and type inference (second priority)
274
+ * @template TFallback - The fallback type if no explicit or schema type is provided
275
+ * @param config - Configuration options for the Electric collection
276
+ * @returns Collection options with utilities
277
+ */
278
+ export function electricCollectionOptions<
279
+ TExplicit extends Row<unknown> = Row<unknown>,
280
+ TSchema extends StandardSchemaV1 = never,
281
+ TFallback extends Row<unknown> = Row<unknown>,
282
+ >(config: ElectricCollectionConfig<TExplicit, TSchema, TFallback>) {
283
+ const seenTxids = new Store<Set<Txid>>(new Set([]))
284
+ const sync = createElectricSync<ResolveType<TExplicit, TSchema, TFallback>>(
285
+ config.shapeOptions,
286
+ {
287
+ seenTxids,
288
+ }
289
+ )
290
+
291
+ /**
292
+ * Wait for a specific transaction ID to be synced
293
+ * @param txId The transaction ID to wait for as a number
294
+ * @param timeout Optional timeout in milliseconds (defaults to 30000ms)
295
+ * @returns Promise that resolves when the txId is synced
296
+ */
297
+ const awaitTxId: AwaitTxIdFn = async (
298
+ txId: Txid,
299
+ timeout: number = 30000
300
+ ): Promise<boolean> => {
301
+ debug(`awaitTxId called with txid %d`, txId)
302
+ if (typeof txId !== `number`) {
303
+ throw new TypeError(
304
+ `Expected number in awaitTxId, received ${typeof txId}`
305
+ )
306
+ }
307
+
308
+ const hasTxid = seenTxids.state.has(txId)
309
+ if (hasTxid) return true
310
+
311
+ return new Promise((resolve, reject) => {
312
+ const timeoutId = setTimeout(() => {
313
+ unsubscribe()
314
+ reject(new Error(`Timeout waiting for txId: ${txId}`))
315
+ }, timeout)
316
+
317
+ const unsubscribe = seenTxids.subscribe(() => {
318
+ if (seenTxids.state.has(txId)) {
319
+ debug(`awaitTxId found match for txid %o`, txId)
320
+ clearTimeout(timeoutId)
321
+ unsubscribe()
322
+ resolve(true)
323
+ }
324
+ })
325
+ })
326
+ }
327
+
328
+ // Create wrapper handlers for direct persistence operations that handle txid awaiting
329
+ const wrappedOnInsert = config.onInsert
330
+ ? async (
331
+ params: InsertMutationFnParams<
332
+ ResolveType<TExplicit, TSchema, TFallback>
333
+ >
334
+ ) => {
335
+ // Runtime check (that doesn't follow type)
336
+ // eslint-disable-next-line
337
+ const handlerResult = (await config.onInsert!(params)) ?? {}
338
+ const txid = (handlerResult as { txid?: Txid | Array<Txid> }).txid
339
+
340
+ if (!txid) {
341
+ throw new Error(
342
+ `Electric collection onInsert handler must return a txid or array of txids`
343
+ )
344
+ }
345
+
346
+ // Handle both single txid and array of txids
347
+ if (Array.isArray(txid)) {
348
+ await Promise.all(txid.map((id) => awaitTxId(id)))
349
+ } else {
350
+ await awaitTxId(txid)
351
+ }
352
+
353
+ return handlerResult
354
+ }
355
+ : undefined
356
+
357
+ const wrappedOnUpdate = config.onUpdate
358
+ ? async (
359
+ params: UpdateMutationFnParams<
360
+ ResolveType<TExplicit, TSchema, TFallback>
361
+ >
362
+ ) => {
363
+ // Runtime check (that doesn't follow type)
364
+ // eslint-disable-next-line
365
+ const handlerResult = (await config.onUpdate!(params)) ?? {}
366
+ const txid = (handlerResult as { txid?: Txid | Array<Txid> }).txid
367
+
368
+ if (!txid) {
369
+ throw new Error(
370
+ `Electric collection onUpdate handler must return a txid or array of txids`
371
+ )
372
+ }
373
+
374
+ // Handle both single txid and array of txids
375
+ if (Array.isArray(txid)) {
376
+ await Promise.all(txid.map((id) => awaitTxId(id)))
377
+ } else {
378
+ await awaitTxId(txid)
379
+ }
380
+
381
+ return handlerResult
382
+ }
383
+ : undefined
384
+
385
+ const wrappedOnDelete = config.onDelete
386
+ ? async (
387
+ params: DeleteMutationFnParams<
388
+ ResolveType<TExplicit, TSchema, TFallback>
389
+ >
390
+ ) => {
391
+ const handlerResult = await config.onDelete!(params)
392
+ if (!handlerResult.txid) {
393
+ throw new Error(
394
+ `Electric collection onDelete handler must return a txid or array of txids`
395
+ )
396
+ }
397
+
398
+ // Handle both single txid and array of txids
399
+ if (Array.isArray(handlerResult.txid)) {
400
+ await Promise.all(handlerResult.txid.map((id) => awaitTxId(id)))
401
+ } else {
402
+ await awaitTxId(handlerResult.txid)
403
+ }
404
+
405
+ return handlerResult
406
+ }
407
+ : undefined
408
+
409
+ // Extract standard Collection config properties
410
+ const {
411
+ shapeOptions: _shapeOptions,
412
+ onInsert: _onInsert,
413
+ onUpdate: _onUpdate,
414
+ onDelete: _onDelete,
415
+ ...restConfig
416
+ } = config
417
+
418
+ return {
419
+ ...restConfig,
420
+ sync,
421
+ onInsert: wrappedOnInsert,
422
+ onUpdate: wrappedOnUpdate,
423
+ onDelete: wrappedOnDelete,
424
+ utils: {
425
+ awaitTxId,
426
+ },
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Internal function to create ElectricSQL sync configuration
432
+ */
433
+ function createElectricSync<T extends Row<unknown>>(
434
+ shapeOptions: ShapeStreamOptions<GetExtensions<T>>,
435
+ options: {
436
+ seenTxids: Store<Set<Txid>>
437
+ }
438
+ ): SyncConfig<T> {
439
+ const { seenTxids } = options
440
+
441
+ // Store for the relation schema information
442
+ const relationSchema = new Store<string | undefined>(undefined)
443
+
444
+ /**
445
+ * Get the sync metadata for insert operations
446
+ * @returns Record containing relation information
447
+ */
448
+ const getSyncMetadata = (): Record<string, unknown> => {
449
+ // Use the stored schema if available, otherwise default to 'public'
450
+ const schema = relationSchema.state || `public`
451
+
452
+ return {
453
+ relation: shapeOptions.params?.table
454
+ ? [schema, shapeOptions.params.table]
455
+ : undefined,
456
+ }
457
+ }
458
+
459
+ // Abort controller for the stream - wraps the signal if provided
460
+ const abortController = new AbortController()
461
+ if (shapeOptions.signal) {
462
+ shapeOptions.signal.addEventListener(`abort`, () => {
463
+ abortController.abort()
464
+ })
465
+ if (shapeOptions.signal.aborted) {
466
+ abortController.abort()
467
+ }
468
+ }
469
+
470
+ let unsubscribeStream: () => void
471
+
472
+ return {
473
+ sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
474
+ const { begin, write, commit } = params
475
+ const stream = new ShapeStream({
476
+ ...shapeOptions,
477
+ signal: abortController.signal,
478
+ })
479
+ let transactionStarted = false
480
+ const newTxids = new Set<Txid>()
481
+
482
+ unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {
483
+ let hasUpToDate = false
484
+
485
+ for (const message of messages) {
486
+ // Check for txids in the message and add them to our store
487
+ if (hasTxids(message)) {
488
+ message.headers.txids?.forEach((txid) => newTxids.add(txid))
489
+ }
490
+
491
+ if (isChangeMessage(message)) {
492
+ // Check if the message contains schema information
493
+ const schema = message.headers.schema
494
+ if (schema && typeof schema === `string`) {
495
+ // Store the schema for future use if it's a valid string
496
+ relationSchema.setState(() => schema)
497
+ }
498
+
499
+ if (!transactionStarted) {
500
+ begin()
501
+ transactionStarted = true
502
+ }
503
+
504
+ write({
505
+ type: message.headers.operation,
506
+ value: message.value,
507
+ // Include the primary key and relation info in the metadata
508
+ metadata: {
509
+ ...message.headers,
510
+ },
511
+ })
512
+ } else if (isUpToDateMessage(message)) {
513
+ hasUpToDate = true
514
+ }
515
+ }
516
+
517
+ if (hasUpToDate) {
518
+ // Commit transaction if one was started
519
+ if (transactionStarted) {
520
+ commit()
521
+ transactionStarted = false
522
+ } else {
523
+ // If the shape is empty, do an empty commit to move the collection status
524
+ // to ready.
525
+ begin()
526
+ commit()
527
+ }
528
+
529
+ // Always commit txids when we receive up-to-date, regardless of transaction state
530
+ seenTxids.setState((currentTxids) => {
531
+ const clonedSeen = new Set<Txid>(currentTxids)
532
+ if (newTxids.size > 0) {
533
+ debug(`new txids synced from pg %O`, Array.from(newTxids))
534
+ }
535
+ newTxids.forEach((txid) => clonedSeen.add(txid))
536
+ newTxids.clear()
537
+ return clonedSeen
538
+ })
539
+ }
540
+ })
541
+
542
+ // Return the unsubscribe function
543
+ return () => {
544
+ // Unsubscribe from the stream
545
+ unsubscribeStream()
546
+ // Abort the abort controller to stop the stream
547
+ abortController.abort()
548
+ }
549
+ },
550
+ // Expose the getSyncMetadata function
551
+ getSyncMetadata,
552
+ }
553
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export {
2
+ electricCollectionOptions,
3
+ type ElectricCollectionConfig,
4
+ type ElectricCollectionUtils,
5
+ type Txid,
6
+ } from "./electric"