@electric-sql/client 1.0.10 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -5,9 +5,11 @@ import {
5
5
  Row,
6
6
  MaybePromise,
7
7
  GetExtensions,
8
+ ChangeMessage,
9
+ SnapshotMetadata,
8
10
  } from './types'
9
11
  import { MessageParser, Parser, TransformFunction } from './parser'
10
- import { getOffset, isUpToDateMessage } from './helpers'
12
+ import { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'
11
13
  import {
12
14
  FetchError,
13
15
  FetchBackoffAbortError,
@@ -43,12 +45,19 @@ import {
43
45
  PAUSE_STREAM,
44
46
  EXPERIMENTAL_LIVE_SSE_QUERY_PARAM,
45
47
  ELECTRIC_PROTOCOL_QUERY_PARAMS,
48
+ LOG_MODE_QUERY_PARAM,
49
+ SUBSET_PARAM_WHERE,
50
+ SUBSET_PARAM_WHERE_PARAMS,
51
+ SUBSET_PARAM_LIMIT,
52
+ SUBSET_PARAM_OFFSET,
53
+ SUBSET_PARAM_ORDER_BY,
46
54
  } from './constants'
47
55
  import {
48
56
  EventSourceMessage,
49
57
  fetchEventSource,
50
58
  } from '@microsoft/fetch-event-source'
51
59
  import { expiredShapesCache } from './expired-shapes-cache'
60
+ import { SnapshotTracker } from './snapshot-tracker'
52
61
 
53
62
  const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
54
63
  LIVE_CACHE_BUSTER_QUERY_PARAM,
@@ -58,6 +67,7 @@ const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
58
67
  ])
59
68
 
60
69
  type Replica = `full` | `default`
70
+ export type LogMode = `changes_only` | `full`
61
71
 
62
72
  /**
63
73
  * PostgreSQL-specific shape parameters that can be provided externally
@@ -114,11 +124,20 @@ export type ExternalParamsRecord<T extends Row<unknown> = Row> = {
114
124
  [K in string]: ParamValue | undefined
115
125
  } & Partial<PostgresParams<T>> & { [K in ReservedParamKeys]?: never }
116
126
 
127
+ export type SubsetParams = {
128
+ where?: string
129
+ params?: Record<string, string>
130
+ limit?: number
131
+ offset?: number
132
+ orderBy?: string
133
+ }
134
+
117
135
  type ReservedParamKeys =
118
136
  | typeof LIVE_CACHE_BUSTER_QUERY_PARAM
119
137
  | typeof SHAPE_HANDLE_QUERY_PARAM
120
138
  | typeof LIVE_QUERY_PARAM
121
139
  | typeof OFFSET_QUERY_PARAM
140
+ | `subset__${string}`
122
141
 
123
142
  /**
124
143
  * External headers type - what users provide.
@@ -258,6 +277,11 @@ export interface ShapeStreamOptions<T = never> {
258
277
  */
259
278
  experimentalLiveSse?: boolean
260
279
 
280
+ /**
281
+ * Initial data loading mode
282
+ */
283
+ mode?: LogMode
284
+
261
285
  signal?: AbortSignal
262
286
  fetchClient?: typeof fetch
263
287
  backoffOptions?: BackoffOptions
@@ -293,8 +317,20 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
293
317
  lastOffset: Offset
294
318
  shapeHandle?: string
295
319
  error?: unknown
320
+ mode: LogMode
296
321
 
297
322
  forceDisconnectAndRefresh(): Promise<void>
323
+
324
+ requestSnapshot(params: {
325
+ where?: string
326
+ params?: Record<string, string>
327
+ limit: number
328
+ offset?: number
329
+ orderBy: string
330
+ }): Promise<{
331
+ metadata: SnapshotMetadata
332
+ data: Array<Message<T>>
333
+ }>
298
334
  }
299
335
 
300
336
  /**
@@ -383,8 +419,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
383
419
  #liveCacheBuster: string // Seconds since our Electric Epoch 😎
384
420
  #lastSyncedAt?: number // unix time
385
421
  #isUpToDate: boolean = false
422
+ #isMidStream: boolean = true
386
423
  #connected: boolean = false
387
424
  #shapeHandle?: string
425
+ #mode: LogMode
388
426
  #schema?: Schema
389
427
  #onError?: ShapeStreamErrorHandler
390
428
  #requestAbortController?: AbortController
@@ -393,6 +431,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
393
431
  #tickPromiseResolver?: () => void
394
432
  #tickPromiseRejecter?: (reason?: unknown) => void
395
433
  #messageChain = Promise.resolve<void[]>([]) // promise chain for incoming messages
434
+ #snapshotTracker = new SnapshotTracker()
435
+ #activeSnapshotRequests = 0 // counter for concurrent snapshot requests
436
+ #midStreamPromise?: Promise<void>
437
+ #midStreamPromiseResolver?: () => void
396
438
 
397
439
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
398
440
  this.options = { subscribe: true, ...options }
@@ -405,6 +447,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
405
447
  options.transformer
406
448
  )
407
449
  this.#onError = this.options.onError
450
+ this.#mode = this.options.mode ?? `full`
408
451
 
409
452
  const baseFetchClient =
410
453
  options.fetchClient ??
@@ -447,6 +490,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
447
490
  return this.#lastOffset
448
491
  }
449
492
 
493
+ get mode() {
494
+ return this.#mode
495
+ }
496
+
450
497
  async #start(): Promise<void> {
451
498
  this.#started = true
452
499
 
@@ -573,7 +620,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
573
620
  return this.#requestShape()
574
621
  }
575
622
 
576
- async #constructUrl(url: string, resumingFromPause: boolean) {
623
+ async #constructUrl(
624
+ url: string,
625
+ resumingFromPause: boolean,
626
+ subsetParams?: SubsetParams
627
+ ) {
577
628
  // Resolve headers and params in parallel
578
629
  const [requestHeaders, params] = await Promise.all([
579
630
  resolveHeaders(this.options.headers),
@@ -583,9 +634,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
583
634
  ])
584
635
 
585
636
  // Validate params after resolution
586
- if (params) {
587
- validateParams(params)
588
- }
637
+ if (params) validateParams(params)
589
638
 
590
639
  const fetchUrl = new URL(url)
591
640
 
@@ -612,8 +661,22 @@ export class ShapeStream<T extends Row<unknown> = Row>
612
661
  }
613
662
  }
614
663
 
664
+ if (subsetParams) {
665
+ if (subsetParams.where)
666
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, subsetParams.where)
667
+ if (subsetParams.params)
668
+ setQueryParam(fetchUrl, SUBSET_PARAM_WHERE_PARAMS, subsetParams.params)
669
+ if (subsetParams.limit)
670
+ setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
671
+ if (subsetParams.offset)
672
+ setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
673
+ if (subsetParams.orderBy)
674
+ setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, subsetParams.orderBy)
675
+ }
676
+
615
677
  // Add Electric's internal parameters
616
678
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
679
+ fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, this.#mode)
617
680
 
618
681
  if (this.#isUpToDate) {
619
682
  // If we are resuming from a paused state, we don't want to perform a live request
@@ -705,6 +768,9 @@ export class ShapeStream<T extends Row<unknown> = Row>
705
768
  async #onMessages(batch: Array<Message<T>>, isSseMessage = false) {
706
769
  // Update isUpToDate
707
770
  if (batch.length > 0) {
771
+ // Set isMidStream to true when we receive any data
772
+ this.#isMidStream = true
773
+
708
774
  const lastMessage = batch[batch.length - 1]
709
775
  if (isUpToDateMessage(lastMessage)) {
710
776
  if (isSseMessage) {
@@ -718,9 +784,21 @@ export class ShapeStream<T extends Row<unknown> = Row>
718
784
  }
719
785
  this.#lastSyncedAt = Date.now()
720
786
  this.#isUpToDate = true
787
+ // Set isMidStream to false when we see an up-to-date message
788
+ this.#isMidStream = false
789
+ // Resolve the promise waiting for mid-stream to end
790
+ this.#midStreamPromiseResolver?.()
721
791
  }
722
792
 
723
- await this.#publish(batch)
793
+ // Filter messages using snapshot tracker
794
+ const messagesToProcess = batch.filter((message) => {
795
+ if (isChangeMessage(message)) {
796
+ return !this.#snapshotTracker.shouldRejectMessage(message)
797
+ }
798
+ return true // Always process control messages
799
+ })
800
+
801
+ await this.#publish(messagesToProcess)
724
802
  }
725
803
  }
726
804
 
@@ -904,6 +982,24 @@ export class ShapeStream<T extends Row<unknown> = Row>
904
982
  return this.#tickPromise
905
983
  }
906
984
 
985
+ /** Await until we're not in the middle of a stream (i.e., until we see an up-to-date message) */
986
+ async #waitForStreamEnd() {
987
+ if (!this.#isMidStream) {
988
+ return
989
+ }
990
+ if (this.#midStreamPromise) {
991
+ return this.#midStreamPromise
992
+ }
993
+ this.#midStreamPromise = new Promise((resolve) => {
994
+ this.#midStreamPromiseResolver = resolve
995
+ })
996
+ this.#midStreamPromise.finally(() => {
997
+ this.#midStreamPromise = undefined
998
+ this.#midStreamPromiseResolver = undefined
999
+ })
1000
+ return this.#midStreamPromise
1001
+ }
1002
+
907
1003
  /**
908
1004
  * Refreshes the shape stream.
909
1005
  * This preemptively aborts any ongoing long poll and reconnects without
@@ -976,8 +1072,108 @@ export class ShapeStream<T extends Row<unknown> = Row>
976
1072
  this.#liveCacheBuster = ``
977
1073
  this.#shapeHandle = handle
978
1074
  this.#isUpToDate = false
1075
+ this.#isMidStream = true
979
1076
  this.#connected = false
980
1077
  this.#schema = undefined
1078
+ this.#activeSnapshotRequests = 0
1079
+ }
1080
+
1081
+ /**
1082
+ * Request a snapshot for subset of data.
1083
+ *
1084
+ * Only available when mode is `changes_only`.
1085
+ * Returns the insertion point & the data, but more importantly injects the data
1086
+ * into the subscribed data stream. Returned value is unlikely to be useful for the caller,
1087
+ * unless the caller has complicated additional logic.
1088
+ *
1089
+ * Data will be injected in a way that's also tracking further incoming changes, and it'll
1090
+ * skip the ones that are already in the snapshot.
1091
+ *
1092
+ * @param opts - The options for the snapshot request.
1093
+ * @returns The metadata and the data for the snapshot.
1094
+ */
1095
+ async requestSnapshot(opts: SubsetParams): Promise<{
1096
+ metadata: SnapshotMetadata
1097
+ data: Array<ChangeMessage<T>>
1098
+ }> {
1099
+ if (this.#mode === `full`) {
1100
+ throw new Error(
1101
+ `Snapshot requests are not supported in ${this.#mode} mode, as the consumer is guaranteed to observe all data`
1102
+ )
1103
+ }
1104
+ // We shouldn't be getting a snapshot on a shape that's not started
1105
+ if (!this.#started) await this.#start()
1106
+
1107
+ // Wait until we're not mid-stream before pausing
1108
+ // This ensures we don't pause in the middle of a transaction
1109
+ await this.#waitForStreamEnd()
1110
+
1111
+ // Pause the stream if this is the first snapshot request
1112
+ this.#activeSnapshotRequests++
1113
+
1114
+ try {
1115
+ if (this.#activeSnapshotRequests === 1) {
1116
+ // Currently this cannot throw, but in case it can later it's in this try block to not have a stuck counter
1117
+ this.#pause()
1118
+ }
1119
+
1120
+ const { fetchUrl, requestHeaders } = await this.#constructUrl(
1121
+ this.options.url,
1122
+ true,
1123
+ opts
1124
+ )
1125
+
1126
+ const { metadata, data } = await this.#fetchSnapshot(
1127
+ fetchUrl,
1128
+ requestHeaders
1129
+ )
1130
+
1131
+ const dataWithEndBoundary = (data as Array<Message<T>>).concat([
1132
+ { headers: { control: `snapshot-end`, ...metadata } },
1133
+ ])
1134
+
1135
+ this.#snapshotTracker.addSnapshot(
1136
+ metadata,
1137
+ new Set(data.map((message) => message.key))
1138
+ )
1139
+ this.#onMessages(dataWithEndBoundary, false)
1140
+
1141
+ return {
1142
+ metadata,
1143
+ data,
1144
+ }
1145
+ } finally {
1146
+ // Resume the stream if this was the last snapshot request
1147
+ this.#activeSnapshotRequests--
1148
+ if (this.#activeSnapshotRequests === 0) {
1149
+ this.#resume()
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ async #fetchSnapshot(url: URL, headers: Record<string, string>) {
1155
+ const response = await this.#fetchClient(url.toString(), { headers })
1156
+
1157
+ if (!response.ok) {
1158
+ throw new FetchError(
1159
+ response.status,
1160
+ undefined,
1161
+ undefined,
1162
+ Object.fromEntries([...response.headers.entries()]),
1163
+ url.toString()
1164
+ )
1165
+ }
1166
+
1167
+ const { metadata, data } = await response.json()
1168
+ const batch = this.#messageParser.parse<Array<ChangeMessage<T>>>(
1169
+ JSON.stringify(data),
1170
+ this.#schema!
1171
+ )
1172
+
1173
+ return {
1174
+ metadata,
1175
+ data: batch,
1176
+ }
981
1177
  }
982
1178
  }
983
1179
 
@@ -1007,6 +1203,7 @@ function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
1007
1203
  if (
1008
1204
  options.offset !== undefined &&
1009
1205
  options.offset !== `-1` &&
1206
+ options.offset !== `now` &&
1010
1207
  !options.handle
1011
1208
  ) {
1012
1209
  throw new MissingShapeHandleError()
package/src/constants.ts CHANGED
@@ -16,6 +16,12 @@ export const WHERE_PARAMS_PARAM = `params`
16
16
  export const EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse`
17
17
  export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh`
18
18
  export const PAUSE_STREAM = `pause-stream`
19
+ export const LOG_MODE_QUERY_PARAM = `log`
20
+ export const SUBSET_PARAM_WHERE = `subset__where`
21
+ export const SUBSET_PARAM_LIMIT = `subset__limit`
22
+ export const SUBSET_PARAM_OFFSET = `subset__offset`
23
+ export const SUBSET_PARAM_ORDER_BY = `subset__order_by`
24
+ export const SUBSET_PARAM_WHERE_PARAMS = `subset__params`
19
25
 
20
26
  // Query parameters that should be passed through when proxying Electric requests
21
27
  export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
@@ -24,4 +30,10 @@ export const ELECTRIC_PROTOCOL_QUERY_PARAMS: Array<string> = [
24
30
  OFFSET_QUERY_PARAM,
25
31
  LIVE_CACHE_BUSTER_QUERY_PARAM,
26
32
  EXPIRED_HANDLE_QUERY_PARAM,
33
+ LOG_MODE_QUERY_PARAM,
34
+ SUBSET_PARAM_WHERE,
35
+ SUBSET_PARAM_LIMIT,
36
+ SUBSET_PARAM_OFFSET,
37
+ SUBSET_PARAM_ORDER_BY,
38
+ SUBSET_PARAM_WHERE_PARAMS,
27
39
  ]
package/src/fetch.ts CHANGED
@@ -5,6 +5,11 @@ import {
5
5
  OFFSET_QUERY_PARAM,
6
6
  SHAPE_HANDLE_HEADER,
7
7
  SHAPE_HANDLE_QUERY_PARAM,
8
+ SUBSET_PARAM_LIMIT,
9
+ SUBSET_PARAM_OFFSET,
10
+ SUBSET_PARAM_ORDER_BY,
11
+ SUBSET_PARAM_WHERE,
12
+ SUBSET_PARAM_WHERE_PARAMS,
8
13
  } from './constants'
9
14
  import {
10
15
  FetchError,
@@ -206,11 +211,24 @@ export function createFetchWithResponseHeadersCheck(
206
211
 
207
212
  const addMissingHeaders = (requiredHeaders: Array<string>) =>
208
213
  missingHeaders.push(...requiredHeaders.filter((h) => !headers.has(h)))
209
- addMissingHeaders(requiredElectricResponseHeaders)
210
214
 
211
215
  const input = args[0]
212
216
  const urlString = input.toString()
213
217
  const url = new URL(urlString)
218
+
219
+ // Snapshot responses (subset params) return a JSON object and do not include Electric chunk headers
220
+ const isSnapshotRequest = [
221
+ SUBSET_PARAM_WHERE,
222
+ SUBSET_PARAM_WHERE_PARAMS,
223
+ SUBSET_PARAM_LIMIT,
224
+ SUBSET_PARAM_OFFSET,
225
+ SUBSET_PARAM_ORDER_BY,
226
+ ].some((p) => url.searchParams.has(p))
227
+ if (isSnapshotRequest) {
228
+ return response
229
+ }
230
+
231
+ addMissingHeaders(requiredElectricResponseHeaders)
214
232
  if (url.searchParams.get(LIVE_QUERY_PARAM) === `true`) {
215
233
  addMissingHeaders(requiredLiveResponseHeaders)
216
234
  }
package/src/helpers.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { ChangeMessage, ControlMessage, Message, Offset, Row } from './types'
1
+ import {
2
+ ChangeMessage,
3
+ ControlMessage,
4
+ Message,
5
+ NormalizedPgSnapshot,
6
+ Offset,
7
+ PostgresSnapshot,
8
+ Row,
9
+ } from './types'
2
10
 
3
11
  /**
4
12
  * Type guard for checking {@link Message} is {@link ChangeMessage}.
@@ -64,3 +72,28 @@ export function getOffset(message: ControlMessage): Offset | undefined {
64
72
  }
65
73
  return `${lsn}_0` as Offset
66
74
  }
75
+
76
+ /**
77
+ * Checks if a transaction is visible in a snapshot.
78
+ *
79
+ * @param txid - the transaction id to check
80
+ * @param snapshot - the information about the snapshot
81
+ * @returns true if the transaction is visible in the snapshot
82
+ */
83
+ export function isVisibleInSnapshot(
84
+ txid: number | bigint | `${bigint}`,
85
+ snapshot: PostgresSnapshot | NormalizedPgSnapshot
86
+ ): boolean {
87
+ const xid = BigInt(txid)
88
+ const xmin = BigInt(snapshot.xmin)
89
+ const xmax = BigInt(snapshot.xmax)
90
+ const xip = snapshot.xip_list.map(BigInt)
91
+
92
+ // If the transaction id is less than the minimum transaction id, it is visible in the snapshot.
93
+ // If the transaction id is less than the maximum transaction id and not in the list of active
94
+ // transactions at the time of the snapshot, it has been committed before the snapshot was taken
95
+ // and is therefore visible in the snapshot.
96
+ // Otherwise, it is not visible in the snapshot.
97
+
98
+ return xid < xmin || (xid < xmax && !xip.includes(xid))
99
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  export * from './client'
2
2
  export * from './shape'
3
3
  export * from './types'
4
- export { isChangeMessage, isControlMessage } from './helpers'
4
+ export {
5
+ isChangeMessage,
6
+ isControlMessage,
7
+ isVisibleInSnapshot,
8
+ } from './helpers'
5
9
  export { FetchError } from './error'
6
10
  export { type BackoffOptions, BackoffDefaults } from './fetch'
7
11
  export { ELECTRIC_PROTOCOL_QUERY_PARAMS } from './constants'
package/src/shape.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Message, Offset, Row } from './types'
2
2
  import { isChangeMessage, isControlMessage } from './helpers'
3
3
  import { FetchError } from './error'
4
- import { ShapeStreamInterface } from './client'
4
+ import { LogMode, ShapeStreamInterface } from './client'
5
5
 
6
6
  export type ShapeData<T extends Row<unknown> = Row> = Map<string, T>
7
7
  export type ShapeChangedCallback<T extends Row<unknown> = Row> = (data: {
@@ -52,6 +52,9 @@ export class Shape<T extends Row<unknown> = Row> {
52
52
 
53
53
  readonly #data: ShapeData<T> = new Map()
54
54
  readonly #subscribers = new Map<number, ShapeChangedCallback<T>>()
55
+ readonly #insertedKeys = new Set<string>()
56
+ readonly #requestedSubSnapshots = new Set<string>()
57
+ #reexecuteSnapshotsPending = false
55
58
  #status: ShapeStatus = `syncing`
56
59
  #error: FetchError | false = false
57
60
 
@@ -125,6 +128,26 @@ export class Shape<T extends Row<unknown> = Row> {
125
128
  return this.stream.isConnected()
126
129
  }
127
130
 
131
+ /** Current log mode of the underlying stream */
132
+ get mode(): LogMode {
133
+ return this.stream.mode
134
+ }
135
+
136
+ /**
137
+ * Request a snapshot for subset of data. Only available when mode is changes_only.
138
+ * Returns void; data will be emitted via the stream and processed by this Shape.
139
+ */
140
+ async requestSnapshot(
141
+ params: Parameters<ShapeStreamInterface<T>[`requestSnapshot`]>[0]
142
+ ): Promise<void> {
143
+ // Track this snapshot request for future re-execution on shape rotation
144
+ const key = JSON.stringify(params)
145
+ this.#requestedSubSnapshots.add(key)
146
+ // Ensure the stream is up-to-date so schema is available for parsing
147
+ await this.#awaitUpToDate()
148
+ await this.stream.requestSnapshot(params)
149
+ }
150
+
128
151
  subscribe(callback: ShapeChangedCallback<T>): () => void {
129
152
  const subscriptionId = Math.random()
130
153
 
@@ -149,19 +172,43 @@ export class Shape<T extends Row<unknown> = Row> {
149
172
  messages.forEach((message) => {
150
173
  if (isChangeMessage(message)) {
151
174
  shouldNotify = this.#updateShapeStatus(`syncing`)
152
- switch (message.headers.operation) {
153
- case `insert`:
154
- this.#data.set(message.key, message.value)
155
- break
156
- case `update`:
157
- this.#data.set(message.key, {
158
- ...this.#data.get(message.key)!,
159
- ...message.value,
160
- })
161
- break
162
- case `delete`:
163
- this.#data.delete(message.key)
164
- break
175
+ if (this.mode === `full`) {
176
+ switch (message.headers.operation) {
177
+ case `insert`:
178
+ this.#data.set(message.key, message.value)
179
+ break
180
+ case `update`:
181
+ this.#data.set(message.key, {
182
+ ...this.#data.get(message.key)!,
183
+ ...message.value,
184
+ })
185
+ break
186
+ case `delete`:
187
+ this.#data.delete(message.key)
188
+ break
189
+ }
190
+ } else {
191
+ // changes_only: only apply updates/deletes for keys for which we observed an insert
192
+ switch (message.headers.operation) {
193
+ case `insert`:
194
+ this.#insertedKeys.add(message.key)
195
+ this.#data.set(message.key, message.value)
196
+ break
197
+ case `update`:
198
+ if (this.#insertedKeys.has(message.key)) {
199
+ this.#data.set(message.key, {
200
+ ...this.#data.get(message.key)!,
201
+ ...message.value,
202
+ })
203
+ }
204
+ break
205
+ case `delete`:
206
+ if (this.#insertedKeys.has(message.key)) {
207
+ this.#data.delete(message.key)
208
+ this.#insertedKeys.delete(message.key)
209
+ }
210
+ break
211
+ }
165
212
  }
166
213
  }
167
214
 
@@ -169,11 +216,18 @@ export class Shape<T extends Row<unknown> = Row> {
169
216
  switch (message.headers.control) {
170
217
  case `up-to-date`:
171
218
  shouldNotify = this.#updateShapeStatus(`up-to-date`)
219
+ if (this.#reexecuteSnapshotsPending) {
220
+ this.#reexecuteSnapshotsPending = false
221
+ void this.#reexecuteSnapshots()
222
+ }
172
223
  break
173
224
  case `must-refetch`:
174
225
  this.#data.clear()
226
+ this.#insertedKeys.clear()
175
227
  this.#error = false
176
228
  shouldNotify = this.#updateShapeStatus(`syncing`)
229
+ // Flag to re-execute sub-snapshots once the new shape is up-to-date
230
+ this.#reexecuteSnapshotsPending = true
177
231
  break
178
232
  }
179
233
  }
@@ -182,6 +236,42 @@ export class Shape<T extends Row<unknown> = Row> {
182
236
  if (shouldNotify) this.#notify()
183
237
  }
184
238
 
239
+ async #reexecuteSnapshots(): Promise<void> {
240
+ // Wait until stream is up-to-date again (ensures schema is available)
241
+ await this.#awaitUpToDate()
242
+
243
+ // Re-execute all snapshots concurrently
244
+ await Promise.all(
245
+ Array.from(this.#requestedSubSnapshots).map(async (jsonParams) => {
246
+ try {
247
+ const snapshot = JSON.parse(jsonParams)
248
+ await this.stream.requestSnapshot(snapshot)
249
+ } catch (_) {
250
+ // Ignore and continue; errors will be surfaced via stream onError
251
+ }
252
+ })
253
+ )
254
+ }
255
+
256
+ async #awaitUpToDate(): Promise<void> {
257
+ if (this.stream.isUpToDate) return
258
+ await new Promise<void>((resolve) => {
259
+ const check = () => {
260
+ if (this.stream.isUpToDate) {
261
+ clearInterval(interval)
262
+ unsub()
263
+ resolve()
264
+ }
265
+ }
266
+ const interval = setInterval(check, 10)
267
+ const unsub = this.stream.subscribe(
268
+ () => check(),
269
+ () => check()
270
+ )
271
+ check()
272
+ })
273
+ }
274
+
185
275
  #updateShapeStatus(status: ShapeStatus): boolean {
186
276
  const stateChanged = this.#status !== status
187
277
  this.#status = status