@tanstack/electric-db-collection 0.1.28 → 0.1.30

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.
@@ -11,31 +11,72 @@ function isUpToDateMessage(message) {
11
11
  function isMustRefetchMessage(message) {
12
12
  return client.isControlMessage(message) && message.headers.control === `must-refetch`;
13
13
  }
14
+ function isSnapshotEndMessage(message) {
15
+ return client.isControlMessage(message) && message.headers.control === `snapshot-end`;
16
+ }
17
+ function parseSnapshotMessage(message) {
18
+ return {
19
+ xmin: message.headers.xmin,
20
+ xmax: message.headers.xmax,
21
+ xip_list: message.headers.xip_list
22
+ };
23
+ }
14
24
  function hasTxids(message) {
15
25
  return `txids` in message.headers && Array.isArray(message.headers.txids);
16
26
  }
17
27
  function electricCollectionOptions(config) {
18
28
  const seenTxids = new store.Store(/* @__PURE__ */ new Set([]));
29
+ const seenSnapshots = new store.Store([]);
19
30
  const sync = createElectricSync(config.shapeOptions, {
20
- seenTxids
31
+ seenTxids,
32
+ seenSnapshots,
33
+ collectionId: config.id
21
34
  });
22
35
  const awaitTxId = async (txId, timeout = 3e4) => {
23
- debug(`awaitTxId called with txid %d`, txId);
36
+ debug(
37
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
38
+ txId
39
+ );
24
40
  if (typeof txId !== `number`) {
25
- throw new errors.ExpectedNumberInAwaitTxIdError(typeof txId);
41
+ throw new errors.ExpectedNumberInAwaitTxIdError(typeof txId, config.id);
26
42
  }
27
43
  const hasTxid = seenTxids.state.has(txId);
28
44
  if (hasTxid) return true;
45
+ const hasSnapshot = seenSnapshots.state.some(
46
+ (snapshot) => client.isVisibleInSnapshot(txId, snapshot)
47
+ );
48
+ if (hasSnapshot) return true;
29
49
  return new Promise((resolve, reject) => {
30
50
  const timeoutId = setTimeout(() => {
31
- unsubscribe();
32
- reject(new errors.TimeoutWaitingForTxIdError(txId));
51
+ unsubscribeSeenTxids();
52
+ unsubscribeSeenSnapshots();
53
+ reject(new errors.TimeoutWaitingForTxIdError(txId, config.id));
33
54
  }, timeout);
34
- const unsubscribe = seenTxids.subscribe(() => {
55
+ const unsubscribeSeenTxids = seenTxids.subscribe(() => {
35
56
  if (seenTxids.state.has(txId)) {
36
- debug(`awaitTxId found match for txid %o`, txId);
57
+ debug(
58
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
59
+ txId
60
+ );
37
61
  clearTimeout(timeoutId);
38
- unsubscribe();
62
+ unsubscribeSeenTxids();
63
+ unsubscribeSeenSnapshots();
64
+ resolve(true);
65
+ }
66
+ });
67
+ const unsubscribeSeenSnapshots = seenSnapshots.subscribe(() => {
68
+ const visibleSnapshot = seenSnapshots.state.find(
69
+ (snapshot) => client.isVisibleInSnapshot(txId, snapshot)
70
+ );
71
+ if (visibleSnapshot) {
72
+ debug(
73
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,
74
+ txId,
75
+ visibleSnapshot
76
+ );
77
+ clearTimeout(timeoutId);
78
+ unsubscribeSeenSnapshots();
79
+ unsubscribeSeenTxids();
39
80
  resolve(true);
40
81
  }
41
82
  });
@@ -45,7 +86,7 @@ function electricCollectionOptions(config) {
45
86
  const handlerResult = await config.onInsert(params) ?? {};
46
87
  const txid = handlerResult.txid;
47
88
  if (!txid) {
48
- throw new errors.ElectricInsertHandlerMustReturnTxIdError();
89
+ throw new errors.ElectricInsertHandlerMustReturnTxIdError(config.id);
49
90
  }
50
91
  if (Array.isArray(txid)) {
51
92
  await Promise.all(txid.map((id) => awaitTxId(id)));
@@ -58,7 +99,7 @@ function electricCollectionOptions(config) {
58
99
  const handlerResult = await config.onUpdate(params) ?? {};
59
100
  const txid = handlerResult.txid;
60
101
  if (!txid) {
61
- throw new errors.ElectricUpdateHandlerMustReturnTxIdError();
102
+ throw new errors.ElectricUpdateHandlerMustReturnTxIdError(config.id);
62
103
  }
63
104
  if (Array.isArray(txid)) {
64
105
  await Promise.all(txid.map((id) => awaitTxId(id)));
@@ -70,7 +111,7 @@ function electricCollectionOptions(config) {
70
111
  const wrappedOnDelete = config.onDelete ? async (params) => {
71
112
  const handlerResult = await config.onDelete(params);
72
113
  if (!handlerResult.txid) {
73
- throw new errors.ElectricDeleteHandlerMustReturnTxIdError();
114
+ throw new errors.ElectricDeleteHandlerMustReturnTxIdError(config.id);
74
115
  }
75
116
  if (Array.isArray(handlerResult.txid)) {
76
117
  await Promise.all(handlerResult.txid.map((id) => awaitTxId(id)));
@@ -98,7 +139,7 @@ function electricCollectionOptions(config) {
98
139
  };
99
140
  }
100
141
  function createElectricSync(shapeOptions, options) {
101
- const { seenTxids } = options;
142
+ const { seenTxids, seenSnapshots, collectionId } = options;
102
143
  const relationSchema = new store.Store(void 0);
103
144
  const getSyncMetadata = () => {
104
145
  const schema = relationSchema.state || `public`;
@@ -145,6 +186,7 @@ You can provide an 'onError' handler on the shapeOptions to handle this error, a
145
186
  });
146
187
  let transactionStarted = false;
147
188
  const newTxids = /* @__PURE__ */ new Set();
189
+ const newSnapshots = [];
148
190
  unsubscribeStream = stream.subscribe((messages) => {
149
191
  let hasUpToDate = false;
150
192
  for (const message of messages) {
@@ -168,11 +210,13 @@ You can provide an 'onError' handler on the shapeOptions to handle this error, a
168
210
  ...message.headers
169
211
  }
170
212
  });
213
+ } else if (isSnapshotEndMessage(message)) {
214
+ newSnapshots.push(parseSnapshotMessage(message));
171
215
  } else if (isUpToDateMessage(message)) {
172
216
  hasUpToDate = true;
173
217
  } else if (isMustRefetchMessage(message)) {
174
218
  debug(
175
- `Received must-refetch message, starting transaction with truncate`
219
+ `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`
176
220
  );
177
221
  if (!transactionStarted) {
178
222
  begin();
@@ -191,12 +235,26 @@ You can provide an 'onError' handler on the shapeOptions to handle this error, a
191
235
  seenTxids.setState((currentTxids) => {
192
236
  const clonedSeen = new Set(currentTxids);
193
237
  if (newTxids.size > 0) {
194
- debug(`new txids synced from pg %O`, Array.from(newTxids));
238
+ debug(
239
+ `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
240
+ Array.from(newTxids)
241
+ );
195
242
  }
196
243
  newTxids.forEach((txid) => clonedSeen.add(txid));
197
244
  newTxids.clear();
198
245
  return clonedSeen;
199
246
  });
247
+ seenSnapshots.setState((currentSnapshots) => {
248
+ const seen = [...currentSnapshots, ...newSnapshots];
249
+ newSnapshots.forEach(
250
+ (snapshot) => debug(
251
+ `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
252
+ snapshot
253
+ )
254
+ );
255
+ newSnapshots.length = 0;
256
+ return seen;
257
+ });
200
258
  }
201
259
  });
202
260
  return () => {
@@ -1 +1 @@
1
- {"version":3,"file":"electric.cjs","sources":["../../src/electric.ts"],"sourcesContent":["import {\n ShapeStream,\n isChangeMessage,\n isControlMessage,\n} from \"@electric-sql/client\"\nimport { Store } from \"@tanstack/store\"\nimport DebugModule from \"debug\"\nimport {\n ElectricDeleteHandlerMustReturnTxIdError,\n ElectricInsertHandlerMustReturnTxIdError,\n ElectricUpdateHandlerMustReturnTxIdError,\n ExpectedNumberInAwaitTxIdError,\n TimeoutWaitingForTxIdError,\n} from \"./errors\"\nimport type {\n BaseCollectionConfig,\n CollectionConfig,\n DeleteMutationFnParams,\n Fn,\n InsertMutationFnParams,\n SyncConfig,\n UpdateMutationFnParams,\n UtilsRecord,\n} from \"@tanstack/db\"\nimport type { StandardSchemaV1 } from \"@standard-schema/spec\"\nimport type {\n ControlMessage,\n GetExtensions,\n Message,\n Row,\n ShapeStreamOptions,\n} from \"@electric-sql/client\"\n\nconst debug = DebugModule.debug(`ts/db:electric`)\n\n/**\n * Type representing a transaction ID in ElectricSQL\n */\nexport type Txid = number\n\n// The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package\n// but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row<unknown>`\n// This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema\ntype InferSchemaOutput<T> = T extends StandardSchemaV1\n ? StandardSchemaV1.InferOutput<T> extends Row<unknown>\n ? StandardSchemaV1.InferOutput<T>\n : Record<string, unknown>\n : Record<string, unknown>\n\n/**\n * Configuration interface for Electric collection options\n * @template T - The type of items in the collection\n * @template TSchema - The schema type for validation\n */\nexport interface ElectricCollectionConfig<\n T extends Row<unknown> = Row<unknown>,\n TSchema extends StandardSchemaV1 = never,\n> extends BaseCollectionConfig<\n T,\n string | number,\n TSchema,\n Record<string, Fn>,\n { txid: Txid | Array<Txid> }\n > {\n /**\n * Configuration options for the ElectricSQL ShapeStream\n */\n shapeOptions: ShapeStreamOptions<GetExtensions<T>>\n}\n\nfunction isUpToDateMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is ControlMessage & { up_to_date: true } {\n return isControlMessage(message) && message.headers.control === `up-to-date`\n}\n\nfunction isMustRefetchMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is ControlMessage & { headers: { control: `must-refetch` } } {\n return isControlMessage(message) && message.headers.control === `must-refetch`\n}\n\n// Check if a message contains txids in its headers\nfunction hasTxids<T extends Row<unknown>>(\n message: Message<T>\n): message is Message<T> & { headers: { txids?: Array<Txid> } } {\n return `txids` in message.headers && Array.isArray(message.headers.txids)\n}\n\n/**\n * Type for the awaitTxId utility function\n */\nexport type AwaitTxIdFn = (txId: Txid, timeout?: number) => Promise<boolean>\n\n/**\n * Electric collection utilities type\n */\nexport interface ElectricCollectionUtils extends UtilsRecord {\n awaitTxId: AwaitTxIdFn\n}\n\n/**\n * Creates Electric collection options for use with a standard Collection\n *\n * @template T - The explicit type of items in the collection (highest priority)\n * @template TSchema - The schema type for validation and type inference (second priority)\n * @template TFallback - The fallback type if no explicit or schema type is provided\n * @param config - Configuration options for the Electric collection\n * @returns Collection options with utilities\n */\n\n// Overload for when schema is provided\nexport function electricCollectionOptions<T extends StandardSchemaV1>(\n config: ElectricCollectionConfig<InferSchemaOutput<T>, T> & {\n schema: T\n }\n): CollectionConfig<InferSchemaOutput<T>, string | number, T> & {\n id?: string\n utils: ElectricCollectionUtils\n schema: T\n}\n\n// Overload for when no schema is provided\nexport function electricCollectionOptions<T extends Row<unknown>>(\n config: ElectricCollectionConfig<T> & {\n schema?: never // prohibit schema\n }\n): CollectionConfig<T, string | number> & {\n id?: string\n utils: ElectricCollectionUtils\n schema?: never // no schema in the result\n}\n\nexport function electricCollectionOptions(\n config: ElectricCollectionConfig<any, any>\n): CollectionConfig<any, string | number, any> & {\n id?: string\n utils: ElectricCollectionUtils\n schema?: any\n} {\n const seenTxids = new Store<Set<Txid>>(new Set([]))\n const sync = createElectricSync<any>(config.shapeOptions, {\n seenTxids,\n })\n\n /**\n * Wait for a specific transaction ID to be synced\n * @param txId The transaction ID to wait for as a number\n * @param timeout Optional timeout in milliseconds (defaults to 30000ms)\n * @returns Promise that resolves when the txId is synced\n */\n const awaitTxId: AwaitTxIdFn = async (\n txId: Txid,\n timeout: number = 30000\n ): Promise<boolean> => {\n debug(`awaitTxId called with txid %d`, txId)\n if (typeof txId !== `number`) {\n throw new ExpectedNumberInAwaitTxIdError(typeof txId)\n }\n\n const hasTxid = seenTxids.state.has(txId)\n if (hasTxid) return true\n\n return new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n unsubscribe()\n reject(new TimeoutWaitingForTxIdError(txId))\n }, timeout)\n\n const unsubscribe = seenTxids.subscribe(() => {\n if (seenTxids.state.has(txId)) {\n debug(`awaitTxId found match for txid %o`, txId)\n clearTimeout(timeoutId)\n unsubscribe()\n resolve(true)\n }\n })\n })\n }\n\n // Create wrapper handlers for direct persistence operations that handle txid awaiting\n const wrappedOnInsert = config.onInsert\n ? async (params: InsertMutationFnParams<any>) => {\n // Runtime check (that doesn't follow type)\n\n const handlerResult = (await config.onInsert!(params)) ?? {}\n const txid = (handlerResult as { txid?: Txid | Array<Txid> }).txid\n\n if (!txid) {\n throw new ElectricInsertHandlerMustReturnTxIdError()\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(txid)) {\n await Promise.all(txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(txid)\n }\n\n return handlerResult\n }\n : undefined\n\n const wrappedOnUpdate = config.onUpdate\n ? async (params: UpdateMutationFnParams<any>) => {\n // Runtime check (that doesn't follow type)\n\n const handlerResult = (await config.onUpdate!(params)) ?? {}\n const txid = (handlerResult as { txid?: Txid | Array<Txid> }).txid\n\n if (!txid) {\n throw new ElectricUpdateHandlerMustReturnTxIdError()\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(txid)) {\n await Promise.all(txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(txid)\n }\n\n return handlerResult\n }\n : undefined\n\n const wrappedOnDelete = config.onDelete\n ? async (params: DeleteMutationFnParams<any>) => {\n const handlerResult = await config.onDelete!(params)\n if (!handlerResult.txid) {\n throw new ElectricDeleteHandlerMustReturnTxIdError()\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(handlerResult.txid)) {\n await Promise.all(handlerResult.txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(handlerResult.txid)\n }\n\n return handlerResult\n }\n : undefined\n\n // Extract standard Collection config properties\n const {\n shapeOptions: _shapeOptions,\n onInsert: _onInsert,\n onUpdate: _onUpdate,\n onDelete: _onDelete,\n ...restConfig\n } = config\n\n return {\n ...restConfig,\n sync,\n onInsert: wrappedOnInsert,\n onUpdate: wrappedOnUpdate,\n onDelete: wrappedOnDelete,\n utils: {\n awaitTxId,\n },\n }\n}\n\n/**\n * Internal function to create ElectricSQL sync configuration\n */\nfunction createElectricSync<T extends Row<unknown>>(\n shapeOptions: ShapeStreamOptions<GetExtensions<T>>,\n options: {\n seenTxids: Store<Set<Txid>>\n }\n): SyncConfig<T> {\n const { seenTxids } = options\n\n // Store for the relation schema information\n const relationSchema = new Store<string | undefined>(undefined)\n\n /**\n * Get the sync metadata for insert operations\n * @returns Record containing relation information\n */\n const getSyncMetadata = (): Record<string, unknown> => {\n // Use the stored schema if available, otherwise default to 'public'\n const schema = relationSchema.state || `public`\n\n return {\n relation: shapeOptions.params?.table\n ? [schema, shapeOptions.params.table]\n : undefined,\n }\n }\n\n let unsubscribeStream: () => void\n\n return {\n sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {\n const { begin, write, commit, markReady, truncate, collection } = params\n\n // Abort controller for the stream - wraps the signal if provided\n const abortController = new AbortController()\n\n if (shapeOptions.signal) {\n shapeOptions.signal.addEventListener(\n `abort`,\n () => {\n abortController.abort()\n },\n {\n once: true,\n }\n )\n if (shapeOptions.signal.aborted) {\n abortController.abort()\n }\n }\n\n const stream = new ShapeStream({\n ...shapeOptions,\n signal: abortController.signal,\n onError: (errorParams) => {\n // Just immediately mark ready if there's an error to avoid blocking\n // apps waiting for `.preload()` to finish.\n // Note that Electric sends a 409 error on a `must-refetch` message, but the\n // ShapeStream handled this and it will not reach this handler, therefor\n // this markReady will not be triggers by a `must-refetch`.\n markReady()\n\n if (shapeOptions.onError) {\n return shapeOptions.onError(errorParams)\n } else {\n console.error(\n `An error occurred while syncing collection: ${collection.id}, \\n` +\n `it has been marked as ready to avoid blocking apps waiting for '.preload()' to finish. \\n` +\n `You can provide an 'onError' handler on the shapeOptions to handle this error, and this message will not be logged.`,\n errorParams\n )\n }\n\n return\n },\n })\n let transactionStarted = false\n const newTxids = new Set<Txid>()\n\n unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {\n let hasUpToDate = false\n\n for (const message of messages) {\n // Check for txids in the message and add them to our store\n if (hasTxids(message)) {\n message.headers.txids?.forEach((txid) => newTxids.add(txid))\n }\n\n if (isChangeMessage(message)) {\n // Check if the message contains schema information\n const schema = message.headers.schema\n if (schema && typeof schema === `string`) {\n // Store the schema for future use if it's a valid string\n relationSchema.setState(() => schema)\n }\n\n if (!transactionStarted) {\n begin()\n transactionStarted = true\n }\n\n write({\n type: message.headers.operation,\n value: message.value,\n // Include the primary key and relation info in the metadata\n metadata: {\n ...message.headers,\n },\n })\n } else if (isUpToDateMessage(message)) {\n hasUpToDate = true\n } else if (isMustRefetchMessage(message)) {\n debug(\n `Received must-refetch message, starting transaction with truncate`\n )\n\n // Start a transaction and truncate the collection\n if (!transactionStarted) {\n begin()\n transactionStarted = true\n }\n\n truncate()\n\n // Reset hasUpToDate so we continue accumulating changes until next up-to-date\n hasUpToDate = false\n }\n }\n\n if (hasUpToDate) {\n // Commit transaction if one was started\n if (transactionStarted) {\n commit()\n transactionStarted = false\n }\n\n // Mark the collection as ready now that sync is up to date\n markReady()\n\n // Always commit txids when we receive up-to-date, regardless of transaction state\n seenTxids.setState((currentTxids) => {\n const clonedSeen = new Set<Txid>(currentTxids)\n if (newTxids.size > 0) {\n debug(`new txids synced from pg %O`, Array.from(newTxids))\n }\n newTxids.forEach((txid) => clonedSeen.add(txid))\n newTxids.clear()\n return clonedSeen\n })\n }\n })\n\n // Return the unsubscribe function\n return () => {\n // Unsubscribe from the stream\n unsubscribeStream()\n // Abort the abort controller to stop the stream\n abortController.abort()\n }\n },\n // Expose the getSyncMetadata function\n getSyncMetadata,\n }\n}\n"],"names":["isControlMessage","Store","ExpectedNumberInAwaitTxIdError","TimeoutWaitingForTxIdError","ElectricInsertHandlerMustReturnTxIdError","ElectricUpdateHandlerMustReturnTxIdError","ElectricDeleteHandlerMustReturnTxIdError","ShapeStream","isChangeMessage"],"mappings":"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"}
1
+ {"version":3,"file":"electric.cjs","sources":["../../src/electric.ts"],"sourcesContent":["import {\n ShapeStream,\n isChangeMessage,\n isControlMessage,\n isVisibleInSnapshot,\n} from \"@electric-sql/client\"\nimport { Store } from \"@tanstack/store\"\nimport DebugModule from \"debug\"\nimport {\n ElectricDeleteHandlerMustReturnTxIdError,\n ElectricInsertHandlerMustReturnTxIdError,\n ElectricUpdateHandlerMustReturnTxIdError,\n ExpectedNumberInAwaitTxIdError,\n TimeoutWaitingForTxIdError,\n} from \"./errors\"\nimport type {\n BaseCollectionConfig,\n CollectionConfig,\n DeleteMutationFnParams,\n Fn,\n InsertMutationFnParams,\n SyncConfig,\n UpdateMutationFnParams,\n UtilsRecord,\n} from \"@tanstack/db\"\nimport type { StandardSchemaV1 } from \"@standard-schema/spec\"\nimport type {\n ControlMessage,\n GetExtensions,\n Message,\n PostgresSnapshot,\n Row,\n ShapeStreamOptions,\n} from \"@electric-sql/client\"\n\nconst debug = DebugModule.debug(`ts/db:electric`)\n\n/**\n * Type representing a transaction ID in ElectricSQL\n */\nexport type Txid = number\n\n/**\n * Type representing the result of an insert, update, or delete handler\n */\ntype MaybeTxId =\n | {\n txid?: Txid | Array<Txid>\n }\n | undefined\n | null\n\n/**\n * Type representing a snapshot end message\n */\ntype SnapshotEndMessage = ControlMessage & {\n headers: { control: `snapshot-end` }\n}\n\n// The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package\n// but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row<unknown>`\n// This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema\ntype InferSchemaOutput<T> = T extends StandardSchemaV1\n ? StandardSchemaV1.InferOutput<T> extends Row<unknown>\n ? StandardSchemaV1.InferOutput<T>\n : Record<string, unknown>\n : Record<string, unknown>\n\n/**\n * Configuration interface for Electric collection options\n * @template T - The type of items in the collection\n * @template TSchema - The schema type for validation\n */\nexport interface ElectricCollectionConfig<\n T extends Row<unknown> = Row<unknown>,\n TSchema extends StandardSchemaV1 = never,\n> extends BaseCollectionConfig<\n T,\n string | number,\n TSchema,\n Record<string, Fn>,\n { txid: Txid | Array<Txid> }\n > {\n /**\n * Configuration options for the ElectricSQL ShapeStream\n */\n shapeOptions: ShapeStreamOptions<GetExtensions<T>>\n}\n\nfunction isUpToDateMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is ControlMessage & { up_to_date: true } {\n return isControlMessage(message) && message.headers.control === `up-to-date`\n}\n\nfunction isMustRefetchMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is ControlMessage & { headers: { control: `must-refetch` } } {\n return isControlMessage(message) && message.headers.control === `must-refetch`\n}\n\nfunction isSnapshotEndMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is SnapshotEndMessage {\n return isControlMessage(message) && message.headers.control === `snapshot-end`\n}\n\nfunction parseSnapshotMessage(message: SnapshotEndMessage): PostgresSnapshot {\n return {\n xmin: message.headers.xmin,\n xmax: message.headers.xmax,\n xip_list: message.headers.xip_list,\n }\n}\n\n// Check if a message contains txids in its headers\nfunction hasTxids<T extends Row<unknown>>(\n message: Message<T>\n): message is Message<T> & { headers: { txids?: Array<Txid> } } {\n return `txids` in message.headers && Array.isArray(message.headers.txids)\n}\n\n/**\n * Type for the awaitTxId utility function\n */\nexport type AwaitTxIdFn = (txId: Txid, timeout?: number) => Promise<boolean>\n\n/**\n * Electric collection utilities type\n */\nexport interface ElectricCollectionUtils extends UtilsRecord {\n awaitTxId: AwaitTxIdFn\n}\n\n/**\n * Creates Electric collection options for use with a standard Collection\n *\n * @template T - The explicit type of items in the collection (highest priority)\n * @template TSchema - The schema type for validation and type inference (second priority)\n * @template TFallback - The fallback type if no explicit or schema type is provided\n * @param config - Configuration options for the Electric collection\n * @returns Collection options with utilities\n */\n\n// Overload for when schema is provided\nexport function electricCollectionOptions<T extends StandardSchemaV1>(\n config: ElectricCollectionConfig<InferSchemaOutput<T>, T> & {\n schema: T\n }\n): CollectionConfig<InferSchemaOutput<T>, string | number, T> & {\n id?: string\n utils: ElectricCollectionUtils\n schema: T\n}\n\n// Overload for when no schema is provided\nexport function electricCollectionOptions<T extends Row<unknown>>(\n config: ElectricCollectionConfig<T> & {\n schema?: never // prohibit schema\n }\n): CollectionConfig<T, string | number> & {\n id?: string\n utils: ElectricCollectionUtils\n schema?: never // no schema in the result\n}\n\nexport function electricCollectionOptions(\n config: ElectricCollectionConfig<any, any>\n): CollectionConfig<any, string | number, any> & {\n id?: string\n utils: ElectricCollectionUtils\n schema?: any\n} {\n const seenTxids = new Store<Set<Txid>>(new Set([]))\n const seenSnapshots = new Store<Array<PostgresSnapshot>>([])\n const sync = createElectricSync<any>(config.shapeOptions, {\n seenTxids,\n seenSnapshots,\n collectionId: config.id,\n })\n\n /**\n * Wait for a specific transaction ID to be synced\n * @param txId The transaction ID to wait for as a number\n * @param timeout Optional timeout in milliseconds (defaults to 30000ms)\n * @returns Promise that resolves when the txId is synced\n */\n const awaitTxId: AwaitTxIdFn = async (\n txId: Txid,\n timeout: number = 30000\n ): Promise<boolean> => {\n debug(\n `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,\n txId\n )\n if (typeof txId !== `number`) {\n throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id)\n }\n\n // First check if the txid is in the seenTxids store\n const hasTxid = seenTxids.state.has(txId)\n if (hasTxid) return true\n\n // Then check if the txid is in any of the seen snapshots\n const hasSnapshot = seenSnapshots.state.some((snapshot) =>\n isVisibleInSnapshot(txId, snapshot)\n )\n if (hasSnapshot) return true\n\n return new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n unsubscribeSeenTxids()\n unsubscribeSeenSnapshots()\n reject(new TimeoutWaitingForTxIdError(txId, config.id))\n }, timeout)\n\n const unsubscribeSeenTxids = seenTxids.subscribe(() => {\n if (seenTxids.state.has(txId)) {\n debug(\n `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,\n txId\n )\n clearTimeout(timeoutId)\n unsubscribeSeenTxids()\n unsubscribeSeenSnapshots()\n resolve(true)\n }\n })\n\n const unsubscribeSeenSnapshots = seenSnapshots.subscribe(() => {\n const visibleSnapshot = seenSnapshots.state.find((snapshot) =>\n isVisibleInSnapshot(txId, snapshot)\n )\n if (visibleSnapshot) {\n debug(\n `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,\n txId,\n visibleSnapshot\n )\n clearTimeout(timeoutId)\n unsubscribeSeenSnapshots()\n unsubscribeSeenTxids()\n resolve(true)\n }\n })\n })\n }\n\n // Create wrapper handlers for direct persistence operations that handle txid awaiting\n const wrappedOnInsert = config.onInsert\n ? async (params: InsertMutationFnParams<any>) => {\n // Runtime check (that doesn't follow type)\n\n const handlerResult =\n ((await config.onInsert!(params)) as MaybeTxId) ?? {}\n const txid = handlerResult.txid\n\n if (!txid) {\n throw new ElectricInsertHandlerMustReturnTxIdError(config.id)\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(txid)) {\n await Promise.all(txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(txid)\n }\n\n return handlerResult\n }\n : undefined\n\n const wrappedOnUpdate = config.onUpdate\n ? async (params: UpdateMutationFnParams<any>) => {\n // Runtime check (that doesn't follow type)\n\n const handlerResult =\n ((await config.onUpdate!(params)) as MaybeTxId) ?? {}\n const txid = handlerResult.txid\n\n if (!txid) {\n throw new ElectricUpdateHandlerMustReturnTxIdError(config.id)\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(txid)) {\n await Promise.all(txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(txid)\n }\n\n return handlerResult\n }\n : undefined\n\n const wrappedOnDelete = config.onDelete\n ? async (params: DeleteMutationFnParams<any>) => {\n const handlerResult = await config.onDelete!(params)\n if (!handlerResult.txid) {\n throw new ElectricDeleteHandlerMustReturnTxIdError(config.id)\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(handlerResult.txid)) {\n await Promise.all(handlerResult.txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(handlerResult.txid)\n }\n\n return handlerResult\n }\n : undefined\n\n // Extract standard Collection config properties\n const {\n shapeOptions: _shapeOptions,\n onInsert: _onInsert,\n onUpdate: _onUpdate,\n onDelete: _onDelete,\n ...restConfig\n } = config\n\n return {\n ...restConfig,\n sync,\n onInsert: wrappedOnInsert,\n onUpdate: wrappedOnUpdate,\n onDelete: wrappedOnDelete,\n utils: {\n awaitTxId,\n },\n }\n}\n\n/**\n * Internal function to create ElectricSQL sync configuration\n */\nfunction createElectricSync<T extends Row<unknown>>(\n shapeOptions: ShapeStreamOptions<GetExtensions<T>>,\n options: {\n seenTxids: Store<Set<Txid>>\n seenSnapshots: Store<Array<PostgresSnapshot>>\n collectionId?: string\n }\n): SyncConfig<T> {\n const { seenTxids, seenSnapshots, collectionId } = options\n\n // Store for the relation schema information\n const relationSchema = new Store<string | undefined>(undefined)\n\n /**\n * Get the sync metadata for insert operations\n * @returns Record containing relation information\n */\n const getSyncMetadata = (): Record<string, unknown> => {\n // Use the stored schema if available, otherwise default to 'public'\n const schema = relationSchema.state || `public`\n\n return {\n relation: shapeOptions.params?.table\n ? [schema, shapeOptions.params.table]\n : undefined,\n }\n }\n\n let unsubscribeStream: () => void\n\n return {\n sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {\n const { begin, write, commit, markReady, truncate, collection } = params\n\n // Abort controller for the stream - wraps the signal if provided\n const abortController = new AbortController()\n\n if (shapeOptions.signal) {\n shapeOptions.signal.addEventListener(\n `abort`,\n () => {\n abortController.abort()\n },\n {\n once: true,\n }\n )\n if (shapeOptions.signal.aborted) {\n abortController.abort()\n }\n }\n\n const stream = new ShapeStream({\n ...shapeOptions,\n signal: abortController.signal,\n onError: (errorParams) => {\n // Just immediately mark ready if there's an error to avoid blocking\n // apps waiting for `.preload()` to finish.\n // Note that Electric sends a 409 error on a `must-refetch` message, but the\n // ShapeStream handled this and it will not reach this handler, therefor\n // this markReady will not be triggers by a `must-refetch`.\n markReady()\n\n if (shapeOptions.onError) {\n return shapeOptions.onError(errorParams)\n } else {\n console.error(\n `An error occurred while syncing collection: ${collection.id}, \\n` +\n `it has been marked as ready to avoid blocking apps waiting for '.preload()' to finish. \\n` +\n `You can provide an 'onError' handler on the shapeOptions to handle this error, and this message will not be logged.`,\n errorParams\n )\n }\n\n return\n },\n })\n let transactionStarted = false\n const newTxids = new Set<Txid>()\n const newSnapshots: Array<PostgresSnapshot> = []\n\n unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {\n let hasUpToDate = false\n\n for (const message of messages) {\n // Check for txids in the message and add them to our store\n if (hasTxids(message)) {\n message.headers.txids?.forEach((txid) => newTxids.add(txid))\n }\n\n if (isChangeMessage(message)) {\n // Check if the message contains schema information\n const schema = message.headers.schema\n if (schema && typeof schema === `string`) {\n // Store the schema for future use if it's a valid string\n relationSchema.setState(() => schema)\n }\n\n if (!transactionStarted) {\n begin()\n transactionStarted = true\n }\n\n write({\n type: message.headers.operation,\n value: message.value,\n // Include the primary key and relation info in the metadata\n metadata: {\n ...message.headers,\n },\n })\n } else if (isSnapshotEndMessage(message)) {\n newSnapshots.push(parseSnapshotMessage(message))\n } else if (isUpToDateMessage(message)) {\n hasUpToDate = true\n } else if (isMustRefetchMessage(message)) {\n debug(\n `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`\n )\n\n // Start a transaction and truncate the collection\n if (!transactionStarted) {\n begin()\n transactionStarted = true\n }\n\n truncate()\n\n // Reset hasUpToDate so we continue accumulating changes until next up-to-date\n hasUpToDate = false\n }\n }\n\n if (hasUpToDate) {\n // Commit transaction if one was started\n if (transactionStarted) {\n commit()\n transactionStarted = false\n }\n\n // Mark the collection as ready now that sync is up to date\n markReady()\n\n // Always commit txids when we receive up-to-date, regardless of transaction state\n seenTxids.setState((currentTxids) => {\n const clonedSeen = new Set<Txid>(currentTxids)\n if (newTxids.size > 0) {\n debug(\n `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,\n Array.from(newTxids)\n )\n }\n newTxids.forEach((txid) => clonedSeen.add(txid))\n newTxids.clear()\n return clonedSeen\n })\n\n // Always commit snapshots when we receive up-to-date, regardless of transaction state\n seenSnapshots.setState((currentSnapshots) => {\n const seen = [...currentSnapshots, ...newSnapshots]\n newSnapshots.forEach((snapshot) =>\n debug(\n `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,\n snapshot\n )\n )\n newSnapshots.length = 0\n return seen\n })\n }\n })\n\n // Return the unsubscribe function\n return () => {\n // Unsubscribe from the stream\n unsubscribeStream()\n // Abort the abort controller to stop the stream\n abortController.abort()\n }\n },\n // Expose the getSyncMetadata function\n getSyncMetadata,\n }\n}\n"],"names":["isControlMessage","Store","ExpectedNumberInAwaitTxIdError","isVisibleInSnapshot","TimeoutWaitingForTxIdError","ElectricInsertHandlerMustReturnTxIdError","ElectricUpdateHandlerMustReturnTxIdError","ElectricDeleteHandlerMustReturnTxIdError","ShapeStream","isChangeMessage"],"mappings":"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"}
@@ -2,43 +2,46 @@
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  const db = require("@tanstack/db");
4
4
  class ElectricDBCollectionError extends db.TanStackDBError {
5
- constructor(message) {
6
- super(message);
5
+ constructor(message, collectionId) {
6
+ super(`${collectionId ? `[${collectionId}] ` : ``}${message}`);
7
7
  this.name = `ElectricDBCollectionError`;
8
8
  }
9
9
  }
10
10
  class ExpectedNumberInAwaitTxIdError extends ElectricDBCollectionError {
11
- constructor(txIdType) {
12
- super(`Expected number in awaitTxId, received ${txIdType}`);
11
+ constructor(txIdType, collectionId) {
12
+ super(`Expected number in awaitTxId, received ${txIdType}`, collectionId);
13
13
  this.name = `ExpectedNumberInAwaitTxIdError`;
14
14
  }
15
15
  }
16
16
  class TimeoutWaitingForTxIdError extends ElectricDBCollectionError {
17
- constructor(txId) {
18
- super(`Timeout waiting for txId: ${txId}`);
17
+ constructor(txId, collectionId) {
18
+ super(`Timeout waiting for txId: ${txId}`, collectionId);
19
19
  this.name = `TimeoutWaitingForTxIdError`;
20
20
  }
21
21
  }
22
22
  class ElectricInsertHandlerMustReturnTxIdError extends ElectricDBCollectionError {
23
- constructor() {
23
+ constructor(collectionId) {
24
24
  super(
25
- `Electric collection onInsert handler must return a txid or array of txids`
25
+ `Electric collection onInsert handler must return a txid or array of txids`,
26
+ collectionId
26
27
  );
27
28
  this.name = `ElectricInsertHandlerMustReturnTxIdError`;
28
29
  }
29
30
  }
30
31
  class ElectricUpdateHandlerMustReturnTxIdError extends ElectricDBCollectionError {
31
- constructor() {
32
+ constructor(collectionId) {
32
33
  super(
33
- `Electric collection onUpdate handler must return a txid or array of txids`
34
+ `Electric collection onUpdate handler must return a txid or array of txids`,
35
+ collectionId
34
36
  );
35
37
  this.name = `ElectricUpdateHandlerMustReturnTxIdError`;
36
38
  }
37
39
  }
38
40
  class ElectricDeleteHandlerMustReturnTxIdError extends ElectricDBCollectionError {
39
- constructor() {
41
+ constructor(collectionId) {
40
42
  super(
41
- `Electric collection onDelete handler must return a txid or array of txids`
43
+ `Electric collection onDelete handler must return a txid or array of txids`,
44
+ collectionId
42
45
  );
43
46
  this.name = `ElectricDeleteHandlerMustReturnTxIdError`;
44
47
  }
@@ -1 +1 @@
1
- {"version":3,"file":"errors.cjs","sources":["../../src/errors.ts"],"sourcesContent":["import { TanStackDBError } from \"@tanstack/db\"\n\n// Electric DB Collection Errors\nexport class ElectricDBCollectionError extends TanStackDBError {\n constructor(message: string) {\n super(message)\n this.name = `ElectricDBCollectionError`\n }\n}\n\nexport class ExpectedNumberInAwaitTxIdError extends ElectricDBCollectionError {\n constructor(txIdType: string) {\n super(`Expected number in awaitTxId, received ${txIdType}`)\n this.name = `ExpectedNumberInAwaitTxIdError`\n }\n}\n\nexport class TimeoutWaitingForTxIdError extends ElectricDBCollectionError {\n constructor(txId: number) {\n super(`Timeout waiting for txId: ${txId}`)\n this.name = `TimeoutWaitingForTxIdError`\n }\n}\n\nexport class ElectricInsertHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor() {\n super(\n `Electric collection onInsert handler must return a txid or array of txids`\n )\n this.name = `ElectricInsertHandlerMustReturnTxIdError`\n }\n}\n\nexport class ElectricUpdateHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor() {\n super(\n `Electric collection onUpdate handler must return a txid or array of txids`\n )\n this.name = `ElectricUpdateHandlerMustReturnTxIdError`\n }\n}\n\nexport class ElectricDeleteHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor() {\n super(\n `Electric collection onDelete handler must return a txid or array of txids`\n )\n this.name = `ElectricDeleteHandlerMustReturnTxIdError`\n }\n}\n"],"names":["TanStackDBError"],"mappings":";;;AAGO,MAAM,kCAAkCA,GAAAA,gBAAgB;AAAA,EAC7D,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,uCAAuC,0BAA0B;AAAA,EAC5E,YAAY,UAAkB;AAC5B,UAAM,0CAA0C,QAAQ,EAAE;AAC1D,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,mCAAmC,0BAA0B;AAAA,EACxE,YAAY,MAAc;AACxB,UAAM,6BAA6B,IAAI,EAAE;AACzC,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,cAAc;AACZ;AAAA,MACE;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,cAAc;AACZ;AAAA,MACE;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,cAAc;AACZ;AAAA,MACE;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;;;;;;;"}
1
+ {"version":3,"file":"errors.cjs","sources":["../../src/errors.ts"],"sourcesContent":["import { TanStackDBError } from \"@tanstack/db\"\n\n// Electric DB Collection Errors\nexport class ElectricDBCollectionError extends TanStackDBError {\n constructor(message: string, collectionId?: string) {\n super(`${collectionId ? `[${collectionId}] ` : ``}${message}`)\n this.name = `ElectricDBCollectionError`\n }\n}\n\nexport class ExpectedNumberInAwaitTxIdError extends ElectricDBCollectionError {\n constructor(txIdType: string, collectionId?: string) {\n super(`Expected number in awaitTxId, received ${txIdType}`, collectionId)\n this.name = `ExpectedNumberInAwaitTxIdError`\n }\n}\n\nexport class TimeoutWaitingForTxIdError extends ElectricDBCollectionError {\n constructor(txId: number, collectionId?: string) {\n super(`Timeout waiting for txId: ${txId}`, collectionId)\n this.name = `TimeoutWaitingForTxIdError`\n }\n}\n\nexport class ElectricInsertHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor(collectionId?: string) {\n super(\n `Electric collection onInsert handler must return a txid or array of txids`,\n collectionId\n )\n this.name = `ElectricInsertHandlerMustReturnTxIdError`\n }\n}\n\nexport class ElectricUpdateHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor(collectionId?: string) {\n super(\n `Electric collection onUpdate handler must return a txid or array of txids`,\n collectionId\n )\n this.name = `ElectricUpdateHandlerMustReturnTxIdError`\n }\n}\n\nexport class ElectricDeleteHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor(collectionId?: string) {\n super(\n `Electric collection onDelete handler must return a txid or array of txids`,\n collectionId\n )\n this.name = `ElectricDeleteHandlerMustReturnTxIdError`\n }\n}\n"],"names":["TanStackDBError"],"mappings":";;;AAGO,MAAM,kCAAkCA,GAAAA,gBAAgB;AAAA,EAC7D,YAAY,SAAiB,cAAuB;AAClD,UAAM,GAAG,eAAe,IAAI,YAAY,OAAO,EAAE,GAAG,OAAO,EAAE;AAC7D,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,uCAAuC,0BAA0B;AAAA,EAC5E,YAAY,UAAkB,cAAuB;AACnD,UAAM,0CAA0C,QAAQ,IAAI,YAAY;AACxE,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,mCAAmC,0BAA0B;AAAA,EACxE,YAAY,MAAc,cAAuB;AAC/C,UAAM,6BAA6B,IAAI,IAAI,YAAY;AACvD,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,YAAY,cAAuB;AACjC;AAAA,MACE;AAAA,MACA;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,YAAY,cAAuB;AACjC;AAAA,MACE;AAAA,MACA;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,YAAY,cAAuB;AACjC;AAAA,MACE;AAAA,MACA;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;;;;;;;"}
@@ -1,19 +1,19 @@
1
1
  import { TanStackDBError } from '@tanstack/db';
2
2
  export declare class ElectricDBCollectionError extends TanStackDBError {
3
- constructor(message: string);
3
+ constructor(message: string, collectionId?: string);
4
4
  }
5
5
  export declare class ExpectedNumberInAwaitTxIdError extends ElectricDBCollectionError {
6
- constructor(txIdType: string);
6
+ constructor(txIdType: string, collectionId?: string);
7
7
  }
8
8
  export declare class TimeoutWaitingForTxIdError extends ElectricDBCollectionError {
9
- constructor(txId: number);
9
+ constructor(txId: number, collectionId?: string);
10
10
  }
11
11
  export declare class ElectricInsertHandlerMustReturnTxIdError extends ElectricDBCollectionError {
12
- constructor();
12
+ constructor(collectionId?: string);
13
13
  }
14
14
  export declare class ElectricUpdateHandlerMustReturnTxIdError extends ElectricDBCollectionError {
15
- constructor();
15
+ constructor(collectionId?: string);
16
16
  }
17
17
  export declare class ElectricDeleteHandlerMustReturnTxIdError extends ElectricDBCollectionError {
18
- constructor();
18
+ constructor(collectionId?: string);
19
19
  }
@@ -1,4 +1,4 @@
1
- import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
1
+ import { ShapeStream, isChangeMessage, isVisibleInSnapshot, isControlMessage } from "@electric-sql/client";
2
2
  import { Store } from "@tanstack/store";
3
3
  import DebugModule from "debug";
4
4
  import { ElectricInsertHandlerMustReturnTxIdError, ElectricUpdateHandlerMustReturnTxIdError, ElectricDeleteHandlerMustReturnTxIdError, ExpectedNumberInAwaitTxIdError, TimeoutWaitingForTxIdError } from "./errors.js";
@@ -9,31 +9,72 @@ function isUpToDateMessage(message) {
9
9
  function isMustRefetchMessage(message) {
10
10
  return isControlMessage(message) && message.headers.control === `must-refetch`;
11
11
  }
12
+ function isSnapshotEndMessage(message) {
13
+ return isControlMessage(message) && message.headers.control === `snapshot-end`;
14
+ }
15
+ function parseSnapshotMessage(message) {
16
+ return {
17
+ xmin: message.headers.xmin,
18
+ xmax: message.headers.xmax,
19
+ xip_list: message.headers.xip_list
20
+ };
21
+ }
12
22
  function hasTxids(message) {
13
23
  return `txids` in message.headers && Array.isArray(message.headers.txids);
14
24
  }
15
25
  function electricCollectionOptions(config) {
16
26
  const seenTxids = new Store(/* @__PURE__ */ new Set([]));
27
+ const seenSnapshots = new Store([]);
17
28
  const sync = createElectricSync(config.shapeOptions, {
18
- seenTxids
29
+ seenTxids,
30
+ seenSnapshots,
31
+ collectionId: config.id
19
32
  });
20
33
  const awaitTxId = async (txId, timeout = 3e4) => {
21
- debug(`awaitTxId called with txid %d`, txId);
34
+ debug(
35
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
36
+ txId
37
+ );
22
38
  if (typeof txId !== `number`) {
23
- throw new ExpectedNumberInAwaitTxIdError(typeof txId);
39
+ throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id);
24
40
  }
25
41
  const hasTxid = seenTxids.state.has(txId);
26
42
  if (hasTxid) return true;
43
+ const hasSnapshot = seenSnapshots.state.some(
44
+ (snapshot) => isVisibleInSnapshot(txId, snapshot)
45
+ );
46
+ if (hasSnapshot) return true;
27
47
  return new Promise((resolve, reject) => {
28
48
  const timeoutId = setTimeout(() => {
29
- unsubscribe();
30
- reject(new TimeoutWaitingForTxIdError(txId));
49
+ unsubscribeSeenTxids();
50
+ unsubscribeSeenSnapshots();
51
+ reject(new TimeoutWaitingForTxIdError(txId, config.id));
31
52
  }, timeout);
32
- const unsubscribe = seenTxids.subscribe(() => {
53
+ const unsubscribeSeenTxids = seenTxids.subscribe(() => {
33
54
  if (seenTxids.state.has(txId)) {
34
- debug(`awaitTxId found match for txid %o`, txId);
55
+ debug(
56
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
57
+ txId
58
+ );
35
59
  clearTimeout(timeoutId);
36
- unsubscribe();
60
+ unsubscribeSeenTxids();
61
+ unsubscribeSeenSnapshots();
62
+ resolve(true);
63
+ }
64
+ });
65
+ const unsubscribeSeenSnapshots = seenSnapshots.subscribe(() => {
66
+ const visibleSnapshot = seenSnapshots.state.find(
67
+ (snapshot) => isVisibleInSnapshot(txId, snapshot)
68
+ );
69
+ if (visibleSnapshot) {
70
+ debug(
71
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,
72
+ txId,
73
+ visibleSnapshot
74
+ );
75
+ clearTimeout(timeoutId);
76
+ unsubscribeSeenSnapshots();
77
+ unsubscribeSeenTxids();
37
78
  resolve(true);
38
79
  }
39
80
  });
@@ -43,7 +84,7 @@ function electricCollectionOptions(config) {
43
84
  const handlerResult = await config.onInsert(params) ?? {};
44
85
  const txid = handlerResult.txid;
45
86
  if (!txid) {
46
- throw new ElectricInsertHandlerMustReturnTxIdError();
87
+ throw new ElectricInsertHandlerMustReturnTxIdError(config.id);
47
88
  }
48
89
  if (Array.isArray(txid)) {
49
90
  await Promise.all(txid.map((id) => awaitTxId(id)));
@@ -56,7 +97,7 @@ function electricCollectionOptions(config) {
56
97
  const handlerResult = await config.onUpdate(params) ?? {};
57
98
  const txid = handlerResult.txid;
58
99
  if (!txid) {
59
- throw new ElectricUpdateHandlerMustReturnTxIdError();
100
+ throw new ElectricUpdateHandlerMustReturnTxIdError(config.id);
60
101
  }
61
102
  if (Array.isArray(txid)) {
62
103
  await Promise.all(txid.map((id) => awaitTxId(id)));
@@ -68,7 +109,7 @@ function electricCollectionOptions(config) {
68
109
  const wrappedOnDelete = config.onDelete ? async (params) => {
69
110
  const handlerResult = await config.onDelete(params);
70
111
  if (!handlerResult.txid) {
71
- throw new ElectricDeleteHandlerMustReturnTxIdError();
112
+ throw new ElectricDeleteHandlerMustReturnTxIdError(config.id);
72
113
  }
73
114
  if (Array.isArray(handlerResult.txid)) {
74
115
  await Promise.all(handlerResult.txid.map((id) => awaitTxId(id)));
@@ -96,7 +137,7 @@ function electricCollectionOptions(config) {
96
137
  };
97
138
  }
98
139
  function createElectricSync(shapeOptions, options) {
99
- const { seenTxids } = options;
140
+ const { seenTxids, seenSnapshots, collectionId } = options;
100
141
  const relationSchema = new Store(void 0);
101
142
  const getSyncMetadata = () => {
102
143
  const schema = relationSchema.state || `public`;
@@ -143,6 +184,7 @@ You can provide an 'onError' handler on the shapeOptions to handle this error, a
143
184
  });
144
185
  let transactionStarted = false;
145
186
  const newTxids = /* @__PURE__ */ new Set();
187
+ const newSnapshots = [];
146
188
  unsubscribeStream = stream.subscribe((messages) => {
147
189
  let hasUpToDate = false;
148
190
  for (const message of messages) {
@@ -166,11 +208,13 @@ You can provide an 'onError' handler on the shapeOptions to handle this error, a
166
208
  ...message.headers
167
209
  }
168
210
  });
211
+ } else if (isSnapshotEndMessage(message)) {
212
+ newSnapshots.push(parseSnapshotMessage(message));
169
213
  } else if (isUpToDateMessage(message)) {
170
214
  hasUpToDate = true;
171
215
  } else if (isMustRefetchMessage(message)) {
172
216
  debug(
173
- `Received must-refetch message, starting transaction with truncate`
217
+ `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`
174
218
  );
175
219
  if (!transactionStarted) {
176
220
  begin();
@@ -189,12 +233,26 @@ You can provide an 'onError' handler on the shapeOptions to handle this error, a
189
233
  seenTxids.setState((currentTxids) => {
190
234
  const clonedSeen = new Set(currentTxids);
191
235
  if (newTxids.size > 0) {
192
- debug(`new txids synced from pg %O`, Array.from(newTxids));
236
+ debug(
237
+ `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
238
+ Array.from(newTxids)
239
+ );
193
240
  }
194
241
  newTxids.forEach((txid) => clonedSeen.add(txid));
195
242
  newTxids.clear();
196
243
  return clonedSeen;
197
244
  });
245
+ seenSnapshots.setState((currentSnapshots) => {
246
+ const seen = [...currentSnapshots, ...newSnapshots];
247
+ newSnapshots.forEach(
248
+ (snapshot) => debug(
249
+ `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
250
+ snapshot
251
+ )
252
+ );
253
+ newSnapshots.length = 0;
254
+ return seen;
255
+ });
198
256
  }
199
257
  });
200
258
  return () => {
@@ -1 +1 @@
1
- {"version":3,"file":"electric.js","sources":["../../src/electric.ts"],"sourcesContent":["import {\n ShapeStream,\n isChangeMessage,\n isControlMessage,\n} from \"@electric-sql/client\"\nimport { Store } from \"@tanstack/store\"\nimport DebugModule from \"debug\"\nimport {\n ElectricDeleteHandlerMustReturnTxIdError,\n ElectricInsertHandlerMustReturnTxIdError,\n ElectricUpdateHandlerMustReturnTxIdError,\n ExpectedNumberInAwaitTxIdError,\n TimeoutWaitingForTxIdError,\n} from \"./errors\"\nimport type {\n BaseCollectionConfig,\n CollectionConfig,\n DeleteMutationFnParams,\n Fn,\n InsertMutationFnParams,\n SyncConfig,\n UpdateMutationFnParams,\n UtilsRecord,\n} from \"@tanstack/db\"\nimport type { StandardSchemaV1 } from \"@standard-schema/spec\"\nimport type {\n ControlMessage,\n GetExtensions,\n Message,\n Row,\n ShapeStreamOptions,\n} from \"@electric-sql/client\"\n\nconst debug = DebugModule.debug(`ts/db:electric`)\n\n/**\n * Type representing a transaction ID in ElectricSQL\n */\nexport type Txid = number\n\n// The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package\n// but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row<unknown>`\n// This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema\ntype InferSchemaOutput<T> = T extends StandardSchemaV1\n ? StandardSchemaV1.InferOutput<T> extends Row<unknown>\n ? StandardSchemaV1.InferOutput<T>\n : Record<string, unknown>\n : Record<string, unknown>\n\n/**\n * Configuration interface for Electric collection options\n * @template T - The type of items in the collection\n * @template TSchema - The schema type for validation\n */\nexport interface ElectricCollectionConfig<\n T extends Row<unknown> = Row<unknown>,\n TSchema extends StandardSchemaV1 = never,\n> extends BaseCollectionConfig<\n T,\n string | number,\n TSchema,\n Record<string, Fn>,\n { txid: Txid | Array<Txid> }\n > {\n /**\n * Configuration options for the ElectricSQL ShapeStream\n */\n shapeOptions: ShapeStreamOptions<GetExtensions<T>>\n}\n\nfunction isUpToDateMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is ControlMessage & { up_to_date: true } {\n return isControlMessage(message) && message.headers.control === `up-to-date`\n}\n\nfunction isMustRefetchMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is ControlMessage & { headers: { control: `must-refetch` } } {\n return isControlMessage(message) && message.headers.control === `must-refetch`\n}\n\n// Check if a message contains txids in its headers\nfunction hasTxids<T extends Row<unknown>>(\n message: Message<T>\n): message is Message<T> & { headers: { txids?: Array<Txid> } } {\n return `txids` in message.headers && Array.isArray(message.headers.txids)\n}\n\n/**\n * Type for the awaitTxId utility function\n */\nexport type AwaitTxIdFn = (txId: Txid, timeout?: number) => Promise<boolean>\n\n/**\n * Electric collection utilities type\n */\nexport interface ElectricCollectionUtils extends UtilsRecord {\n awaitTxId: AwaitTxIdFn\n}\n\n/**\n * Creates Electric collection options for use with a standard Collection\n *\n * @template T - The explicit type of items in the collection (highest priority)\n * @template TSchema - The schema type for validation and type inference (second priority)\n * @template TFallback - The fallback type if no explicit or schema type is provided\n * @param config - Configuration options for the Electric collection\n * @returns Collection options with utilities\n */\n\n// Overload for when schema is provided\nexport function electricCollectionOptions<T extends StandardSchemaV1>(\n config: ElectricCollectionConfig<InferSchemaOutput<T>, T> & {\n schema: T\n }\n): CollectionConfig<InferSchemaOutput<T>, string | number, T> & {\n id?: string\n utils: ElectricCollectionUtils\n schema: T\n}\n\n// Overload for when no schema is provided\nexport function electricCollectionOptions<T extends Row<unknown>>(\n config: ElectricCollectionConfig<T> & {\n schema?: never // prohibit schema\n }\n): CollectionConfig<T, string | number> & {\n id?: string\n utils: ElectricCollectionUtils\n schema?: never // no schema in the result\n}\n\nexport function electricCollectionOptions(\n config: ElectricCollectionConfig<any, any>\n): CollectionConfig<any, string | number, any> & {\n id?: string\n utils: ElectricCollectionUtils\n schema?: any\n} {\n const seenTxids = new Store<Set<Txid>>(new Set([]))\n const sync = createElectricSync<any>(config.shapeOptions, {\n seenTxids,\n })\n\n /**\n * Wait for a specific transaction ID to be synced\n * @param txId The transaction ID to wait for as a number\n * @param timeout Optional timeout in milliseconds (defaults to 30000ms)\n * @returns Promise that resolves when the txId is synced\n */\n const awaitTxId: AwaitTxIdFn = async (\n txId: Txid,\n timeout: number = 30000\n ): Promise<boolean> => {\n debug(`awaitTxId called with txid %d`, txId)\n if (typeof txId !== `number`) {\n throw new ExpectedNumberInAwaitTxIdError(typeof txId)\n }\n\n const hasTxid = seenTxids.state.has(txId)\n if (hasTxid) return true\n\n return new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n unsubscribe()\n reject(new TimeoutWaitingForTxIdError(txId))\n }, timeout)\n\n const unsubscribe = seenTxids.subscribe(() => {\n if (seenTxids.state.has(txId)) {\n debug(`awaitTxId found match for txid %o`, txId)\n clearTimeout(timeoutId)\n unsubscribe()\n resolve(true)\n }\n })\n })\n }\n\n // Create wrapper handlers for direct persistence operations that handle txid awaiting\n const wrappedOnInsert = config.onInsert\n ? async (params: InsertMutationFnParams<any>) => {\n // Runtime check (that doesn't follow type)\n\n const handlerResult = (await config.onInsert!(params)) ?? {}\n const txid = (handlerResult as { txid?: Txid | Array<Txid> }).txid\n\n if (!txid) {\n throw new ElectricInsertHandlerMustReturnTxIdError()\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(txid)) {\n await Promise.all(txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(txid)\n }\n\n return handlerResult\n }\n : undefined\n\n const wrappedOnUpdate = config.onUpdate\n ? async (params: UpdateMutationFnParams<any>) => {\n // Runtime check (that doesn't follow type)\n\n const handlerResult = (await config.onUpdate!(params)) ?? {}\n const txid = (handlerResult as { txid?: Txid | Array<Txid> }).txid\n\n if (!txid) {\n throw new ElectricUpdateHandlerMustReturnTxIdError()\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(txid)) {\n await Promise.all(txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(txid)\n }\n\n return handlerResult\n }\n : undefined\n\n const wrappedOnDelete = config.onDelete\n ? async (params: DeleteMutationFnParams<any>) => {\n const handlerResult = await config.onDelete!(params)\n if (!handlerResult.txid) {\n throw new ElectricDeleteHandlerMustReturnTxIdError()\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(handlerResult.txid)) {\n await Promise.all(handlerResult.txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(handlerResult.txid)\n }\n\n return handlerResult\n }\n : undefined\n\n // Extract standard Collection config properties\n const {\n shapeOptions: _shapeOptions,\n onInsert: _onInsert,\n onUpdate: _onUpdate,\n onDelete: _onDelete,\n ...restConfig\n } = config\n\n return {\n ...restConfig,\n sync,\n onInsert: wrappedOnInsert,\n onUpdate: wrappedOnUpdate,\n onDelete: wrappedOnDelete,\n utils: {\n awaitTxId,\n },\n }\n}\n\n/**\n * Internal function to create ElectricSQL sync configuration\n */\nfunction createElectricSync<T extends Row<unknown>>(\n shapeOptions: ShapeStreamOptions<GetExtensions<T>>,\n options: {\n seenTxids: Store<Set<Txid>>\n }\n): SyncConfig<T> {\n const { seenTxids } = options\n\n // Store for the relation schema information\n const relationSchema = new Store<string | undefined>(undefined)\n\n /**\n * Get the sync metadata for insert operations\n * @returns Record containing relation information\n */\n const getSyncMetadata = (): Record<string, unknown> => {\n // Use the stored schema if available, otherwise default to 'public'\n const schema = relationSchema.state || `public`\n\n return {\n relation: shapeOptions.params?.table\n ? [schema, shapeOptions.params.table]\n : undefined,\n }\n }\n\n let unsubscribeStream: () => void\n\n return {\n sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {\n const { begin, write, commit, markReady, truncate, collection } = params\n\n // Abort controller for the stream - wraps the signal if provided\n const abortController = new AbortController()\n\n if (shapeOptions.signal) {\n shapeOptions.signal.addEventListener(\n `abort`,\n () => {\n abortController.abort()\n },\n {\n once: true,\n }\n )\n if (shapeOptions.signal.aborted) {\n abortController.abort()\n }\n }\n\n const stream = new ShapeStream({\n ...shapeOptions,\n signal: abortController.signal,\n onError: (errorParams) => {\n // Just immediately mark ready if there's an error to avoid blocking\n // apps waiting for `.preload()` to finish.\n // Note that Electric sends a 409 error on a `must-refetch` message, but the\n // ShapeStream handled this and it will not reach this handler, therefor\n // this markReady will not be triggers by a `must-refetch`.\n markReady()\n\n if (shapeOptions.onError) {\n return shapeOptions.onError(errorParams)\n } else {\n console.error(\n `An error occurred while syncing collection: ${collection.id}, \\n` +\n `it has been marked as ready to avoid blocking apps waiting for '.preload()' to finish. \\n` +\n `You can provide an 'onError' handler on the shapeOptions to handle this error, and this message will not be logged.`,\n errorParams\n )\n }\n\n return\n },\n })\n let transactionStarted = false\n const newTxids = new Set<Txid>()\n\n unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {\n let hasUpToDate = false\n\n for (const message of messages) {\n // Check for txids in the message and add them to our store\n if (hasTxids(message)) {\n message.headers.txids?.forEach((txid) => newTxids.add(txid))\n }\n\n if (isChangeMessage(message)) {\n // Check if the message contains schema information\n const schema = message.headers.schema\n if (schema && typeof schema === `string`) {\n // Store the schema for future use if it's a valid string\n relationSchema.setState(() => schema)\n }\n\n if (!transactionStarted) {\n begin()\n transactionStarted = true\n }\n\n write({\n type: message.headers.operation,\n value: message.value,\n // Include the primary key and relation info in the metadata\n metadata: {\n ...message.headers,\n },\n })\n } else if (isUpToDateMessage(message)) {\n hasUpToDate = true\n } else if (isMustRefetchMessage(message)) {\n debug(\n `Received must-refetch message, starting transaction with truncate`\n )\n\n // Start a transaction and truncate the collection\n if (!transactionStarted) {\n begin()\n transactionStarted = true\n }\n\n truncate()\n\n // Reset hasUpToDate so we continue accumulating changes until next up-to-date\n hasUpToDate = false\n }\n }\n\n if (hasUpToDate) {\n // Commit transaction if one was started\n if (transactionStarted) {\n commit()\n transactionStarted = false\n }\n\n // Mark the collection as ready now that sync is up to date\n markReady()\n\n // Always commit txids when we receive up-to-date, regardless of transaction state\n seenTxids.setState((currentTxids) => {\n const clonedSeen = new Set<Txid>(currentTxids)\n if (newTxids.size > 0) {\n debug(`new txids synced from pg %O`, Array.from(newTxids))\n }\n newTxids.forEach((txid) => clonedSeen.add(txid))\n newTxids.clear()\n return clonedSeen\n })\n }\n })\n\n // Return the unsubscribe function\n return () => {\n // Unsubscribe from the stream\n unsubscribeStream()\n // Abort the abort controller to stop the stream\n abortController.abort()\n }\n },\n // Expose the getSyncMetadata function\n getSyncMetadata,\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"electric.js","sources":["../../src/electric.ts"],"sourcesContent":["import {\n ShapeStream,\n isChangeMessage,\n isControlMessage,\n isVisibleInSnapshot,\n} from \"@electric-sql/client\"\nimport { Store } from \"@tanstack/store\"\nimport DebugModule from \"debug\"\nimport {\n ElectricDeleteHandlerMustReturnTxIdError,\n ElectricInsertHandlerMustReturnTxIdError,\n ElectricUpdateHandlerMustReturnTxIdError,\n ExpectedNumberInAwaitTxIdError,\n TimeoutWaitingForTxIdError,\n} from \"./errors\"\nimport type {\n BaseCollectionConfig,\n CollectionConfig,\n DeleteMutationFnParams,\n Fn,\n InsertMutationFnParams,\n SyncConfig,\n UpdateMutationFnParams,\n UtilsRecord,\n} from \"@tanstack/db\"\nimport type { StandardSchemaV1 } from \"@standard-schema/spec\"\nimport type {\n ControlMessage,\n GetExtensions,\n Message,\n PostgresSnapshot,\n Row,\n ShapeStreamOptions,\n} from \"@electric-sql/client\"\n\nconst debug = DebugModule.debug(`ts/db:electric`)\n\n/**\n * Type representing a transaction ID in ElectricSQL\n */\nexport type Txid = number\n\n/**\n * Type representing the result of an insert, update, or delete handler\n */\ntype MaybeTxId =\n | {\n txid?: Txid | Array<Txid>\n }\n | undefined\n | null\n\n/**\n * Type representing a snapshot end message\n */\ntype SnapshotEndMessage = ControlMessage & {\n headers: { control: `snapshot-end` }\n}\n\n// The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package\n// but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row<unknown>`\n// This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema\ntype InferSchemaOutput<T> = T extends StandardSchemaV1\n ? StandardSchemaV1.InferOutput<T> extends Row<unknown>\n ? StandardSchemaV1.InferOutput<T>\n : Record<string, unknown>\n : Record<string, unknown>\n\n/**\n * Configuration interface for Electric collection options\n * @template T - The type of items in the collection\n * @template TSchema - The schema type for validation\n */\nexport interface ElectricCollectionConfig<\n T extends Row<unknown> = Row<unknown>,\n TSchema extends StandardSchemaV1 = never,\n> extends BaseCollectionConfig<\n T,\n string | number,\n TSchema,\n Record<string, Fn>,\n { txid: Txid | Array<Txid> }\n > {\n /**\n * Configuration options for the ElectricSQL ShapeStream\n */\n shapeOptions: ShapeStreamOptions<GetExtensions<T>>\n}\n\nfunction isUpToDateMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is ControlMessage & { up_to_date: true } {\n return isControlMessage(message) && message.headers.control === `up-to-date`\n}\n\nfunction isMustRefetchMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is ControlMessage & { headers: { control: `must-refetch` } } {\n return isControlMessage(message) && message.headers.control === `must-refetch`\n}\n\nfunction isSnapshotEndMessage<T extends Row<unknown>>(\n message: Message<T>\n): message is SnapshotEndMessage {\n return isControlMessage(message) && message.headers.control === `snapshot-end`\n}\n\nfunction parseSnapshotMessage(message: SnapshotEndMessage): PostgresSnapshot {\n return {\n xmin: message.headers.xmin,\n xmax: message.headers.xmax,\n xip_list: message.headers.xip_list,\n }\n}\n\n// Check if a message contains txids in its headers\nfunction hasTxids<T extends Row<unknown>>(\n message: Message<T>\n): message is Message<T> & { headers: { txids?: Array<Txid> } } {\n return `txids` in message.headers && Array.isArray(message.headers.txids)\n}\n\n/**\n * Type for the awaitTxId utility function\n */\nexport type AwaitTxIdFn = (txId: Txid, timeout?: number) => Promise<boolean>\n\n/**\n * Electric collection utilities type\n */\nexport interface ElectricCollectionUtils extends UtilsRecord {\n awaitTxId: AwaitTxIdFn\n}\n\n/**\n * Creates Electric collection options for use with a standard Collection\n *\n * @template T - The explicit type of items in the collection (highest priority)\n * @template TSchema - The schema type for validation and type inference (second priority)\n * @template TFallback - The fallback type if no explicit or schema type is provided\n * @param config - Configuration options for the Electric collection\n * @returns Collection options with utilities\n */\n\n// Overload for when schema is provided\nexport function electricCollectionOptions<T extends StandardSchemaV1>(\n config: ElectricCollectionConfig<InferSchemaOutput<T>, T> & {\n schema: T\n }\n): CollectionConfig<InferSchemaOutput<T>, string | number, T> & {\n id?: string\n utils: ElectricCollectionUtils\n schema: T\n}\n\n// Overload for when no schema is provided\nexport function electricCollectionOptions<T extends Row<unknown>>(\n config: ElectricCollectionConfig<T> & {\n schema?: never // prohibit schema\n }\n): CollectionConfig<T, string | number> & {\n id?: string\n utils: ElectricCollectionUtils\n schema?: never // no schema in the result\n}\n\nexport function electricCollectionOptions(\n config: ElectricCollectionConfig<any, any>\n): CollectionConfig<any, string | number, any> & {\n id?: string\n utils: ElectricCollectionUtils\n schema?: any\n} {\n const seenTxids = new Store<Set<Txid>>(new Set([]))\n const seenSnapshots = new Store<Array<PostgresSnapshot>>([])\n const sync = createElectricSync<any>(config.shapeOptions, {\n seenTxids,\n seenSnapshots,\n collectionId: config.id,\n })\n\n /**\n * Wait for a specific transaction ID to be synced\n * @param txId The transaction ID to wait for as a number\n * @param timeout Optional timeout in milliseconds (defaults to 30000ms)\n * @returns Promise that resolves when the txId is synced\n */\n const awaitTxId: AwaitTxIdFn = async (\n txId: Txid,\n timeout: number = 30000\n ): Promise<boolean> => {\n debug(\n `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,\n txId\n )\n if (typeof txId !== `number`) {\n throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id)\n }\n\n // First check if the txid is in the seenTxids store\n const hasTxid = seenTxids.state.has(txId)\n if (hasTxid) return true\n\n // Then check if the txid is in any of the seen snapshots\n const hasSnapshot = seenSnapshots.state.some((snapshot) =>\n isVisibleInSnapshot(txId, snapshot)\n )\n if (hasSnapshot) return true\n\n return new Promise((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n unsubscribeSeenTxids()\n unsubscribeSeenSnapshots()\n reject(new TimeoutWaitingForTxIdError(txId, config.id))\n }, timeout)\n\n const unsubscribeSeenTxids = seenTxids.subscribe(() => {\n if (seenTxids.state.has(txId)) {\n debug(\n `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,\n txId\n )\n clearTimeout(timeoutId)\n unsubscribeSeenTxids()\n unsubscribeSeenSnapshots()\n resolve(true)\n }\n })\n\n const unsubscribeSeenSnapshots = seenSnapshots.subscribe(() => {\n const visibleSnapshot = seenSnapshots.state.find((snapshot) =>\n isVisibleInSnapshot(txId, snapshot)\n )\n if (visibleSnapshot) {\n debug(\n `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,\n txId,\n visibleSnapshot\n )\n clearTimeout(timeoutId)\n unsubscribeSeenSnapshots()\n unsubscribeSeenTxids()\n resolve(true)\n }\n })\n })\n }\n\n // Create wrapper handlers for direct persistence operations that handle txid awaiting\n const wrappedOnInsert = config.onInsert\n ? async (params: InsertMutationFnParams<any>) => {\n // Runtime check (that doesn't follow type)\n\n const handlerResult =\n ((await config.onInsert!(params)) as MaybeTxId) ?? {}\n const txid = handlerResult.txid\n\n if (!txid) {\n throw new ElectricInsertHandlerMustReturnTxIdError(config.id)\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(txid)) {\n await Promise.all(txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(txid)\n }\n\n return handlerResult\n }\n : undefined\n\n const wrappedOnUpdate = config.onUpdate\n ? async (params: UpdateMutationFnParams<any>) => {\n // Runtime check (that doesn't follow type)\n\n const handlerResult =\n ((await config.onUpdate!(params)) as MaybeTxId) ?? {}\n const txid = handlerResult.txid\n\n if (!txid) {\n throw new ElectricUpdateHandlerMustReturnTxIdError(config.id)\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(txid)) {\n await Promise.all(txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(txid)\n }\n\n return handlerResult\n }\n : undefined\n\n const wrappedOnDelete = config.onDelete\n ? async (params: DeleteMutationFnParams<any>) => {\n const handlerResult = await config.onDelete!(params)\n if (!handlerResult.txid) {\n throw new ElectricDeleteHandlerMustReturnTxIdError(config.id)\n }\n\n // Handle both single txid and array of txids\n if (Array.isArray(handlerResult.txid)) {\n await Promise.all(handlerResult.txid.map((id) => awaitTxId(id)))\n } else {\n await awaitTxId(handlerResult.txid)\n }\n\n return handlerResult\n }\n : undefined\n\n // Extract standard Collection config properties\n const {\n shapeOptions: _shapeOptions,\n onInsert: _onInsert,\n onUpdate: _onUpdate,\n onDelete: _onDelete,\n ...restConfig\n } = config\n\n return {\n ...restConfig,\n sync,\n onInsert: wrappedOnInsert,\n onUpdate: wrappedOnUpdate,\n onDelete: wrappedOnDelete,\n utils: {\n awaitTxId,\n },\n }\n}\n\n/**\n * Internal function to create ElectricSQL sync configuration\n */\nfunction createElectricSync<T extends Row<unknown>>(\n shapeOptions: ShapeStreamOptions<GetExtensions<T>>,\n options: {\n seenTxids: Store<Set<Txid>>\n seenSnapshots: Store<Array<PostgresSnapshot>>\n collectionId?: string\n }\n): SyncConfig<T> {\n const { seenTxids, seenSnapshots, collectionId } = options\n\n // Store for the relation schema information\n const relationSchema = new Store<string | undefined>(undefined)\n\n /**\n * Get the sync metadata for insert operations\n * @returns Record containing relation information\n */\n const getSyncMetadata = (): Record<string, unknown> => {\n // Use the stored schema if available, otherwise default to 'public'\n const schema = relationSchema.state || `public`\n\n return {\n relation: shapeOptions.params?.table\n ? [schema, shapeOptions.params.table]\n : undefined,\n }\n }\n\n let unsubscribeStream: () => void\n\n return {\n sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {\n const { begin, write, commit, markReady, truncate, collection } = params\n\n // Abort controller for the stream - wraps the signal if provided\n const abortController = new AbortController()\n\n if (shapeOptions.signal) {\n shapeOptions.signal.addEventListener(\n `abort`,\n () => {\n abortController.abort()\n },\n {\n once: true,\n }\n )\n if (shapeOptions.signal.aborted) {\n abortController.abort()\n }\n }\n\n const stream = new ShapeStream({\n ...shapeOptions,\n signal: abortController.signal,\n onError: (errorParams) => {\n // Just immediately mark ready if there's an error to avoid blocking\n // apps waiting for `.preload()` to finish.\n // Note that Electric sends a 409 error on a `must-refetch` message, but the\n // ShapeStream handled this and it will not reach this handler, therefor\n // this markReady will not be triggers by a `must-refetch`.\n markReady()\n\n if (shapeOptions.onError) {\n return shapeOptions.onError(errorParams)\n } else {\n console.error(\n `An error occurred while syncing collection: ${collection.id}, \\n` +\n `it has been marked as ready to avoid blocking apps waiting for '.preload()' to finish. \\n` +\n `You can provide an 'onError' handler on the shapeOptions to handle this error, and this message will not be logged.`,\n errorParams\n )\n }\n\n return\n },\n })\n let transactionStarted = false\n const newTxids = new Set<Txid>()\n const newSnapshots: Array<PostgresSnapshot> = []\n\n unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {\n let hasUpToDate = false\n\n for (const message of messages) {\n // Check for txids in the message and add them to our store\n if (hasTxids(message)) {\n message.headers.txids?.forEach((txid) => newTxids.add(txid))\n }\n\n if (isChangeMessage(message)) {\n // Check if the message contains schema information\n const schema = message.headers.schema\n if (schema && typeof schema === `string`) {\n // Store the schema for future use if it's a valid string\n relationSchema.setState(() => schema)\n }\n\n if (!transactionStarted) {\n begin()\n transactionStarted = true\n }\n\n write({\n type: message.headers.operation,\n value: message.value,\n // Include the primary key and relation info in the metadata\n metadata: {\n ...message.headers,\n },\n })\n } else if (isSnapshotEndMessage(message)) {\n newSnapshots.push(parseSnapshotMessage(message))\n } else if (isUpToDateMessage(message)) {\n hasUpToDate = true\n } else if (isMustRefetchMessage(message)) {\n debug(\n `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`\n )\n\n // Start a transaction and truncate the collection\n if (!transactionStarted) {\n begin()\n transactionStarted = true\n }\n\n truncate()\n\n // Reset hasUpToDate so we continue accumulating changes until next up-to-date\n hasUpToDate = false\n }\n }\n\n if (hasUpToDate) {\n // Commit transaction if one was started\n if (transactionStarted) {\n commit()\n transactionStarted = false\n }\n\n // Mark the collection as ready now that sync is up to date\n markReady()\n\n // Always commit txids when we receive up-to-date, regardless of transaction state\n seenTxids.setState((currentTxids) => {\n const clonedSeen = new Set<Txid>(currentTxids)\n if (newTxids.size > 0) {\n debug(\n `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,\n Array.from(newTxids)\n )\n }\n newTxids.forEach((txid) => clonedSeen.add(txid))\n newTxids.clear()\n return clonedSeen\n })\n\n // Always commit snapshots when we receive up-to-date, regardless of transaction state\n seenSnapshots.setState((currentSnapshots) => {\n const seen = [...currentSnapshots, ...newSnapshots]\n newSnapshots.forEach((snapshot) =>\n debug(\n `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,\n snapshot\n )\n )\n newSnapshots.length = 0\n return seen\n })\n }\n })\n\n // Return the unsubscribe function\n return () => {\n // Unsubscribe from the stream\n unsubscribeStream()\n // Abort the abort controller to stop the stream\n abortController.abort()\n }\n },\n // Expose the getSyncMetadata function\n getSyncMetadata,\n }\n}\n"],"names":[],"mappings":"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"}
@@ -1,19 +1,19 @@
1
1
  import { TanStackDBError } from '@tanstack/db';
2
2
  export declare class ElectricDBCollectionError extends TanStackDBError {
3
- constructor(message: string);
3
+ constructor(message: string, collectionId?: string);
4
4
  }
5
5
  export declare class ExpectedNumberInAwaitTxIdError extends ElectricDBCollectionError {
6
- constructor(txIdType: string);
6
+ constructor(txIdType: string, collectionId?: string);
7
7
  }
8
8
  export declare class TimeoutWaitingForTxIdError extends ElectricDBCollectionError {
9
- constructor(txId: number);
9
+ constructor(txId: number, collectionId?: string);
10
10
  }
11
11
  export declare class ElectricInsertHandlerMustReturnTxIdError extends ElectricDBCollectionError {
12
- constructor();
12
+ constructor(collectionId?: string);
13
13
  }
14
14
  export declare class ElectricUpdateHandlerMustReturnTxIdError extends ElectricDBCollectionError {
15
- constructor();
15
+ constructor(collectionId?: string);
16
16
  }
17
17
  export declare class ElectricDeleteHandlerMustReturnTxIdError extends ElectricDBCollectionError {
18
- constructor();
18
+ constructor(collectionId?: string);
19
19
  }
@@ -1,42 +1,45 @@
1
1
  import { TanStackDBError } from "@tanstack/db";
2
2
  class ElectricDBCollectionError extends TanStackDBError {
3
- constructor(message) {
4
- super(message);
3
+ constructor(message, collectionId) {
4
+ super(`${collectionId ? `[${collectionId}] ` : ``}${message}`);
5
5
  this.name = `ElectricDBCollectionError`;
6
6
  }
7
7
  }
8
8
  class ExpectedNumberInAwaitTxIdError extends ElectricDBCollectionError {
9
- constructor(txIdType) {
10
- super(`Expected number in awaitTxId, received ${txIdType}`);
9
+ constructor(txIdType, collectionId) {
10
+ super(`Expected number in awaitTxId, received ${txIdType}`, collectionId);
11
11
  this.name = `ExpectedNumberInAwaitTxIdError`;
12
12
  }
13
13
  }
14
14
  class TimeoutWaitingForTxIdError extends ElectricDBCollectionError {
15
- constructor(txId) {
16
- super(`Timeout waiting for txId: ${txId}`);
15
+ constructor(txId, collectionId) {
16
+ super(`Timeout waiting for txId: ${txId}`, collectionId);
17
17
  this.name = `TimeoutWaitingForTxIdError`;
18
18
  }
19
19
  }
20
20
  class ElectricInsertHandlerMustReturnTxIdError extends ElectricDBCollectionError {
21
- constructor() {
21
+ constructor(collectionId) {
22
22
  super(
23
- `Electric collection onInsert handler must return a txid or array of txids`
23
+ `Electric collection onInsert handler must return a txid or array of txids`,
24
+ collectionId
24
25
  );
25
26
  this.name = `ElectricInsertHandlerMustReturnTxIdError`;
26
27
  }
27
28
  }
28
29
  class ElectricUpdateHandlerMustReturnTxIdError extends ElectricDBCollectionError {
29
- constructor() {
30
+ constructor(collectionId) {
30
31
  super(
31
- `Electric collection onUpdate handler must return a txid or array of txids`
32
+ `Electric collection onUpdate handler must return a txid or array of txids`,
33
+ collectionId
32
34
  );
33
35
  this.name = `ElectricUpdateHandlerMustReturnTxIdError`;
34
36
  }
35
37
  }
36
38
  class ElectricDeleteHandlerMustReturnTxIdError extends ElectricDBCollectionError {
37
- constructor() {
39
+ constructor(collectionId) {
38
40
  super(
39
- `Electric collection onDelete handler must return a txid or array of txids`
41
+ `Electric collection onDelete handler must return a txid or array of txids`,
42
+ collectionId
40
43
  );
41
44
  this.name = `ElectricDeleteHandlerMustReturnTxIdError`;
42
45
  }
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sources":["../../src/errors.ts"],"sourcesContent":["import { TanStackDBError } from \"@tanstack/db\"\n\n// Electric DB Collection Errors\nexport class ElectricDBCollectionError extends TanStackDBError {\n constructor(message: string) {\n super(message)\n this.name = `ElectricDBCollectionError`\n }\n}\n\nexport class ExpectedNumberInAwaitTxIdError extends ElectricDBCollectionError {\n constructor(txIdType: string) {\n super(`Expected number in awaitTxId, received ${txIdType}`)\n this.name = `ExpectedNumberInAwaitTxIdError`\n }\n}\n\nexport class TimeoutWaitingForTxIdError extends ElectricDBCollectionError {\n constructor(txId: number) {\n super(`Timeout waiting for txId: ${txId}`)\n this.name = `TimeoutWaitingForTxIdError`\n }\n}\n\nexport class ElectricInsertHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor() {\n super(\n `Electric collection onInsert handler must return a txid or array of txids`\n )\n this.name = `ElectricInsertHandlerMustReturnTxIdError`\n }\n}\n\nexport class ElectricUpdateHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor() {\n super(\n `Electric collection onUpdate handler must return a txid or array of txids`\n )\n this.name = `ElectricUpdateHandlerMustReturnTxIdError`\n }\n}\n\nexport class ElectricDeleteHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor() {\n super(\n `Electric collection onDelete handler must return a txid or array of txids`\n )\n this.name = `ElectricDeleteHandlerMustReturnTxIdError`\n }\n}\n"],"names":[],"mappings":";AAGO,MAAM,kCAAkC,gBAAgB;AAAA,EAC7D,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,uCAAuC,0BAA0B;AAAA,EAC5E,YAAY,UAAkB;AAC5B,UAAM,0CAA0C,QAAQ,EAAE;AAC1D,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,mCAAmC,0BAA0B;AAAA,EACxE,YAAY,MAAc;AACxB,UAAM,6BAA6B,IAAI,EAAE;AACzC,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,cAAc;AACZ;AAAA,MACE;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,cAAc;AACZ;AAAA,MACE;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,cAAc;AACZ;AAAA,MACE;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;"}
1
+ {"version":3,"file":"errors.js","sources":["../../src/errors.ts"],"sourcesContent":["import { TanStackDBError } from \"@tanstack/db\"\n\n// Electric DB Collection Errors\nexport class ElectricDBCollectionError extends TanStackDBError {\n constructor(message: string, collectionId?: string) {\n super(`${collectionId ? `[${collectionId}] ` : ``}${message}`)\n this.name = `ElectricDBCollectionError`\n }\n}\n\nexport class ExpectedNumberInAwaitTxIdError extends ElectricDBCollectionError {\n constructor(txIdType: string, collectionId?: string) {\n super(`Expected number in awaitTxId, received ${txIdType}`, collectionId)\n this.name = `ExpectedNumberInAwaitTxIdError`\n }\n}\n\nexport class TimeoutWaitingForTxIdError extends ElectricDBCollectionError {\n constructor(txId: number, collectionId?: string) {\n super(`Timeout waiting for txId: ${txId}`, collectionId)\n this.name = `TimeoutWaitingForTxIdError`\n }\n}\n\nexport class ElectricInsertHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor(collectionId?: string) {\n super(\n `Electric collection onInsert handler must return a txid or array of txids`,\n collectionId\n )\n this.name = `ElectricInsertHandlerMustReturnTxIdError`\n }\n}\n\nexport class ElectricUpdateHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor(collectionId?: string) {\n super(\n `Electric collection onUpdate handler must return a txid or array of txids`,\n collectionId\n )\n this.name = `ElectricUpdateHandlerMustReturnTxIdError`\n }\n}\n\nexport class ElectricDeleteHandlerMustReturnTxIdError extends ElectricDBCollectionError {\n constructor(collectionId?: string) {\n super(\n `Electric collection onDelete handler must return a txid or array of txids`,\n collectionId\n )\n this.name = `ElectricDeleteHandlerMustReturnTxIdError`\n }\n}\n"],"names":[],"mappings":";AAGO,MAAM,kCAAkC,gBAAgB;AAAA,EAC7D,YAAY,SAAiB,cAAuB;AAClD,UAAM,GAAG,eAAe,IAAI,YAAY,OAAO,EAAE,GAAG,OAAO,EAAE;AAC7D,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,uCAAuC,0BAA0B;AAAA,EAC5E,YAAY,UAAkB,cAAuB;AACnD,UAAM,0CAA0C,QAAQ,IAAI,YAAY;AACxE,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,mCAAmC,0BAA0B;AAAA,EACxE,YAAY,MAAc,cAAuB;AAC/C,UAAM,6BAA6B,IAAI,IAAI,YAAY;AACvD,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,YAAY,cAAuB;AACjC;AAAA,MACE;AAAA,MACA;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,YAAY,cAAuB;AACjC;AAAA,MACE;AAAA,MACA;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,iDAAiD,0BAA0B;AAAA,EACtF,YAAY,cAAuB;AACjC;AAAA,MACE;AAAA,MACA;AAAA,IAAA;AAEF,SAAK,OAAO;AAAA,EACd;AACF;"}
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@tanstack/electric-db-collection",
3
3
  "description": "ElectricSQL collection for TanStack DB",
4
- "version": "0.1.28",
4
+ "version": "0.1.30",
5
5
  "dependencies": {
6
+ "@electric-sql/client": "^1.0.14",
6
7
  "@standard-schema/spec": "^1.0.0",
7
- "@electric-sql/client": "^1.0.10",
8
8
  "@tanstack/store": "^0.7.7",
9
9
  "debug": "^4.4.3",
10
- "@tanstack/db": "0.4.4"
10
+ "@tanstack/db": "0.4.6"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@types/debug": "^4.1.12",
package/src/electric.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  ShapeStream,
3
3
  isChangeMessage,
4
4
  isControlMessage,
5
+ isVisibleInSnapshot,
5
6
  } from "@electric-sql/client"
6
7
  import { Store } from "@tanstack/store"
7
8
  import DebugModule from "debug"
@@ -27,6 +28,7 @@ import type {
27
28
  ControlMessage,
28
29
  GetExtensions,
29
30
  Message,
31
+ PostgresSnapshot,
30
32
  Row,
31
33
  ShapeStreamOptions,
32
34
  } from "@electric-sql/client"
@@ -38,6 +40,23 @@ const debug = DebugModule.debug(`ts/db:electric`)
38
40
  */
39
41
  export type Txid = number
40
42
 
43
+ /**
44
+ * Type representing the result of an insert, update, or delete handler
45
+ */
46
+ type MaybeTxId =
47
+ | {
48
+ txid?: Txid | Array<Txid>
49
+ }
50
+ | undefined
51
+ | null
52
+
53
+ /**
54
+ * Type representing a snapshot end message
55
+ */
56
+ type SnapshotEndMessage = ControlMessage & {
57
+ headers: { control: `snapshot-end` }
58
+ }
59
+
41
60
  // The `InferSchemaOutput` and `ResolveType` are copied from the `@tanstack/db` package
42
61
  // but we modified `InferSchemaOutput` slightly to restrict the schema output to `Row<unknown>`
43
62
  // This is needed in order for `GetExtensions` to be able to infer the parser extensions type from the schema
@@ -80,6 +99,20 @@ function isMustRefetchMessage<T extends Row<unknown>>(
80
99
  return isControlMessage(message) && message.headers.control === `must-refetch`
81
100
  }
82
101
 
102
+ function isSnapshotEndMessage<T extends Row<unknown>>(
103
+ message: Message<T>
104
+ ): message is SnapshotEndMessage {
105
+ return isControlMessage(message) && message.headers.control === `snapshot-end`
106
+ }
107
+
108
+ function parseSnapshotMessage(message: SnapshotEndMessage): PostgresSnapshot {
109
+ return {
110
+ xmin: message.headers.xmin,
111
+ xmax: message.headers.xmax,
112
+ xip_list: message.headers.xip_list,
113
+ }
114
+ }
115
+
83
116
  // Check if a message contains txids in its headers
84
117
  function hasTxids<T extends Row<unknown>>(
85
118
  message: Message<T>
@@ -139,8 +172,11 @@ export function electricCollectionOptions(
139
172
  schema?: any
140
173
  } {
141
174
  const seenTxids = new Store<Set<Txid>>(new Set([]))
175
+ const seenSnapshots = new Store<Array<PostgresSnapshot>>([])
142
176
  const sync = createElectricSync<any>(config.shapeOptions, {
143
177
  seenTxids,
178
+ seenSnapshots,
179
+ collectionId: config.id,
144
180
  })
145
181
 
146
182
  /**
@@ -153,25 +189,57 @@ export function electricCollectionOptions(
153
189
  txId: Txid,
154
190
  timeout: number = 30000
155
191
  ): Promise<boolean> => {
156
- debug(`awaitTxId called with txid %d`, txId)
192
+ debug(
193
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId called with txid %d`,
194
+ txId
195
+ )
157
196
  if (typeof txId !== `number`) {
158
- throw new ExpectedNumberInAwaitTxIdError(typeof txId)
197
+ throw new ExpectedNumberInAwaitTxIdError(typeof txId, config.id)
159
198
  }
160
199
 
200
+ // First check if the txid is in the seenTxids store
161
201
  const hasTxid = seenTxids.state.has(txId)
162
202
  if (hasTxid) return true
163
203
 
204
+ // Then check if the txid is in any of the seen snapshots
205
+ const hasSnapshot = seenSnapshots.state.some((snapshot) =>
206
+ isVisibleInSnapshot(txId, snapshot)
207
+ )
208
+ if (hasSnapshot) return true
209
+
164
210
  return new Promise((resolve, reject) => {
165
211
  const timeoutId = setTimeout(() => {
166
- unsubscribe()
167
- reject(new TimeoutWaitingForTxIdError(txId))
212
+ unsubscribeSeenTxids()
213
+ unsubscribeSeenSnapshots()
214
+ reject(new TimeoutWaitingForTxIdError(txId, config.id))
168
215
  }, timeout)
169
216
 
170
- const unsubscribe = seenTxids.subscribe(() => {
217
+ const unsubscribeSeenTxids = seenTxids.subscribe(() => {
171
218
  if (seenTxids.state.has(txId)) {
172
- debug(`awaitTxId found match for txid %o`, txId)
219
+ debug(
220
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
221
+ txId
222
+ )
223
+ clearTimeout(timeoutId)
224
+ unsubscribeSeenTxids()
225
+ unsubscribeSeenSnapshots()
226
+ resolve(true)
227
+ }
228
+ })
229
+
230
+ const unsubscribeSeenSnapshots = seenSnapshots.subscribe(() => {
231
+ const visibleSnapshot = seenSnapshots.state.find((snapshot) =>
232
+ isVisibleInSnapshot(txId, snapshot)
233
+ )
234
+ if (visibleSnapshot) {
235
+ debug(
236
+ `${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o in snapshot %o`,
237
+ txId,
238
+ visibleSnapshot
239
+ )
173
240
  clearTimeout(timeoutId)
174
- unsubscribe()
241
+ unsubscribeSeenSnapshots()
242
+ unsubscribeSeenTxids()
175
243
  resolve(true)
176
244
  }
177
245
  })
@@ -183,11 +251,12 @@ export function electricCollectionOptions(
183
251
  ? async (params: InsertMutationFnParams<any>) => {
184
252
  // Runtime check (that doesn't follow type)
185
253
 
186
- const handlerResult = (await config.onInsert!(params)) ?? {}
187
- const txid = (handlerResult as { txid?: Txid | Array<Txid> }).txid
254
+ const handlerResult =
255
+ ((await config.onInsert!(params)) as MaybeTxId) ?? {}
256
+ const txid = handlerResult.txid
188
257
 
189
258
  if (!txid) {
190
- throw new ElectricInsertHandlerMustReturnTxIdError()
259
+ throw new ElectricInsertHandlerMustReturnTxIdError(config.id)
191
260
  }
192
261
 
193
262
  // Handle both single txid and array of txids
@@ -205,11 +274,12 @@ export function electricCollectionOptions(
205
274
  ? async (params: UpdateMutationFnParams<any>) => {
206
275
  // Runtime check (that doesn't follow type)
207
276
 
208
- const handlerResult = (await config.onUpdate!(params)) ?? {}
209
- const txid = (handlerResult as { txid?: Txid | Array<Txid> }).txid
277
+ const handlerResult =
278
+ ((await config.onUpdate!(params)) as MaybeTxId) ?? {}
279
+ const txid = handlerResult.txid
210
280
 
211
281
  if (!txid) {
212
- throw new ElectricUpdateHandlerMustReturnTxIdError()
282
+ throw new ElectricUpdateHandlerMustReturnTxIdError(config.id)
213
283
  }
214
284
 
215
285
  // Handle both single txid and array of txids
@@ -227,7 +297,7 @@ export function electricCollectionOptions(
227
297
  ? async (params: DeleteMutationFnParams<any>) => {
228
298
  const handlerResult = await config.onDelete!(params)
229
299
  if (!handlerResult.txid) {
230
- throw new ElectricDeleteHandlerMustReturnTxIdError()
300
+ throw new ElectricDeleteHandlerMustReturnTxIdError(config.id)
231
301
  }
232
302
 
233
303
  // Handle both single txid and array of txids
@@ -269,9 +339,11 @@ function createElectricSync<T extends Row<unknown>>(
269
339
  shapeOptions: ShapeStreamOptions<GetExtensions<T>>,
270
340
  options: {
271
341
  seenTxids: Store<Set<Txid>>
342
+ seenSnapshots: Store<Array<PostgresSnapshot>>
343
+ collectionId?: string
272
344
  }
273
345
  ): SyncConfig<T> {
274
- const { seenTxids } = options
346
+ const { seenTxids, seenSnapshots, collectionId } = options
275
347
 
276
348
  // Store for the relation schema information
277
349
  const relationSchema = new Store<string | undefined>(undefined)
@@ -342,6 +414,7 @@ function createElectricSync<T extends Row<unknown>>(
342
414
  })
343
415
  let transactionStarted = false
344
416
  const newTxids = new Set<Txid>()
417
+ const newSnapshots: Array<PostgresSnapshot> = []
345
418
 
346
419
  unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {
347
420
  let hasUpToDate = false
@@ -373,11 +446,13 @@ function createElectricSync<T extends Row<unknown>>(
373
446
  ...message.headers,
374
447
  },
375
448
  })
449
+ } else if (isSnapshotEndMessage(message)) {
450
+ newSnapshots.push(parseSnapshotMessage(message))
376
451
  } else if (isUpToDateMessage(message)) {
377
452
  hasUpToDate = true
378
453
  } else if (isMustRefetchMessage(message)) {
379
454
  debug(
380
- `Received must-refetch message, starting transaction with truncate`
455
+ `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`
381
456
  )
382
457
 
383
458
  // Start a transaction and truncate the collection
@@ -407,12 +482,28 @@ function createElectricSync<T extends Row<unknown>>(
407
482
  seenTxids.setState((currentTxids) => {
408
483
  const clonedSeen = new Set<Txid>(currentTxids)
409
484
  if (newTxids.size > 0) {
410
- debug(`new txids synced from pg %O`, Array.from(newTxids))
485
+ debug(
486
+ `${collectionId ? `[${collectionId}] ` : ``}new txids synced from pg %O`,
487
+ Array.from(newTxids)
488
+ )
411
489
  }
412
490
  newTxids.forEach((txid) => clonedSeen.add(txid))
413
491
  newTxids.clear()
414
492
  return clonedSeen
415
493
  })
494
+
495
+ // Always commit snapshots when we receive up-to-date, regardless of transaction state
496
+ seenSnapshots.setState((currentSnapshots) => {
497
+ const seen = [...currentSnapshots, ...newSnapshots]
498
+ newSnapshots.forEach((snapshot) =>
499
+ debug(
500
+ `${collectionId ? `[${collectionId}] ` : ``}new snapshot synced from pg %o`,
501
+ snapshot
502
+ )
503
+ )
504
+ newSnapshots.length = 0
505
+ return seen
506
+ })
416
507
  }
417
508
  })
418
509
 
package/src/errors.ts CHANGED
@@ -2,48 +2,51 @@ import { TanStackDBError } from "@tanstack/db"
2
2
 
3
3
  // Electric DB Collection Errors
4
4
  export class ElectricDBCollectionError extends TanStackDBError {
5
- constructor(message: string) {
6
- super(message)
5
+ constructor(message: string, collectionId?: string) {
6
+ super(`${collectionId ? `[${collectionId}] ` : ``}${message}`)
7
7
  this.name = `ElectricDBCollectionError`
8
8
  }
9
9
  }
10
10
 
11
11
  export class ExpectedNumberInAwaitTxIdError extends ElectricDBCollectionError {
12
- constructor(txIdType: string) {
13
- super(`Expected number in awaitTxId, received ${txIdType}`)
12
+ constructor(txIdType: string, collectionId?: string) {
13
+ super(`Expected number in awaitTxId, received ${txIdType}`, collectionId)
14
14
  this.name = `ExpectedNumberInAwaitTxIdError`
15
15
  }
16
16
  }
17
17
 
18
18
  export class TimeoutWaitingForTxIdError extends ElectricDBCollectionError {
19
- constructor(txId: number) {
20
- super(`Timeout waiting for txId: ${txId}`)
19
+ constructor(txId: number, collectionId?: string) {
20
+ super(`Timeout waiting for txId: ${txId}`, collectionId)
21
21
  this.name = `TimeoutWaitingForTxIdError`
22
22
  }
23
23
  }
24
24
 
25
25
  export class ElectricInsertHandlerMustReturnTxIdError extends ElectricDBCollectionError {
26
- constructor() {
26
+ constructor(collectionId?: string) {
27
27
  super(
28
- `Electric collection onInsert handler must return a txid or array of txids`
28
+ `Electric collection onInsert handler must return a txid or array of txids`,
29
+ collectionId
29
30
  )
30
31
  this.name = `ElectricInsertHandlerMustReturnTxIdError`
31
32
  }
32
33
  }
33
34
 
34
35
  export class ElectricUpdateHandlerMustReturnTxIdError extends ElectricDBCollectionError {
35
- constructor() {
36
+ constructor(collectionId?: string) {
36
37
  super(
37
- `Electric collection onUpdate handler must return a txid or array of txids`
38
+ `Electric collection onUpdate handler must return a txid or array of txids`,
39
+ collectionId
38
40
  )
39
41
  this.name = `ElectricUpdateHandlerMustReturnTxIdError`
40
42
  }
41
43
  }
42
44
 
43
45
  export class ElectricDeleteHandlerMustReturnTxIdError extends ElectricDBCollectionError {
44
- constructor() {
46
+ constructor(collectionId?: string) {
45
47
  super(
46
- `Electric collection onDelete handler must return a txid or array of txids`
48
+ `Electric collection onDelete handler must return a txid or array of txids`,
49
+ collectionId
47
50
  )
48
51
  this.name = `ElectricDeleteHandlerMustReturnTxIdError`
49
52
  }