@livestore/sync-electric 0.4.0-dev.0 → 0.4.0-dev.10

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/index.ts CHANGED
@@ -1,14 +1,21 @@
1
- import type { IsOfflineError, SyncBackend, SyncBackendConstructor } from '@livestore/common'
2
- import { InvalidPullError, InvalidPushError, UnexpectedError } from '@livestore/common'
1
+ import {
2
+ InvalidPullError,
3
+ InvalidPushError,
4
+ type IsOfflineError,
5
+ SyncBackend,
6
+ UnexpectedError,
7
+ } from '@livestore/common'
3
8
  import { LiveStoreEvent } from '@livestore/common/schema'
4
- import { notYetImplemented, shouldNeverHappen } from '@livestore/utils'
9
+ import { notYetImplemented } from '@livestore/utils'
5
10
  import {
6
- Chunk,
11
+ type Duration,
7
12
  Effect,
8
13
  HttpClient,
9
14
  HttpClientRequest,
10
15
  HttpClientResponse,
11
16
  Option,
17
+ ReadonlyArray,
18
+ Schedule,
12
19
  Schema,
13
20
  Stream,
14
21
  SubscriptionRef,
@@ -16,7 +23,13 @@ import {
16
23
 
17
24
  import * as ApiSchema from './api-schema.ts'
18
25
 
26
+ export class InvalidOperationError extends Schema.TaggedError<InvalidOperationError>()('InvalidOperationError', {
27
+ operation: Schema.Union(Schema.Literal('delete'), Schema.Literal('update')),
28
+ message: Schema.String,
29
+ }) {}
30
+
19
31
  export * as ApiSchema from './api-schema.ts'
32
+ export * from './make-electric-url.ts'
20
33
 
21
34
  /*
22
35
  Example data:
@@ -65,27 +78,36 @@ const LiveStoreEventGlobalFromStringRecord = Schema.Struct({
65
78
  args: Schema.parseJson(Schema.Any),
66
79
  clientId: Schema.String,
67
80
  sessionId: Schema.String,
68
- }).pipe(
69
- Schema.transform(LiveStoreEvent.AnyEncodedGlobal, {
70
- decode: (_) => _,
71
- encode: (_) => _,
72
- }),
73
- )
74
-
75
- const ResponseItem = Schema.Struct({
81
+ })
82
+ .pipe(
83
+ Schema.transform(LiveStoreEvent.AnyEncodedGlobal, {
84
+ decode: (_) => _,
85
+ encode: (_) => _,
86
+ }),
87
+ )
88
+ .annotations({ title: '@livestore/sync-electric:LiveStoreEventGlobalFromStringRecord' })
89
+
90
+ const ResponseItemInsert = Schema.Struct({
76
91
  /** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
77
92
  key: Schema.optional(Schema.String),
78
- value: Schema.optional(LiveStoreEventGlobalFromStringRecord),
79
- headers: Schema.Union(
80
- Schema.Struct({
81
- operation: Schema.Union(Schema.Literal('insert'), Schema.Literal('update'), Schema.Literal('delete')),
82
- relation: Schema.Array(Schema.String),
83
- }),
84
- Schema.Struct({
85
- control: Schema.String,
86
- }),
87
- ),
88
- })
93
+ value: LiveStoreEventGlobalFromStringRecord,
94
+ headers: Schema.Struct({ operation: Schema.Literal('insert'), relation: Schema.Array(Schema.String) }),
95
+ }).annotations({ title: '@livestore/sync-electric:ResponseItemInsert' })
96
+
97
+ const ResponseItemInvalid = Schema.Struct({
98
+ /** Postgres path (e.g. `"public"."events_9069baf0_b3e6_42f7_980f_188416eab3fx3"/"0"`) */
99
+ key: Schema.optional(Schema.String),
100
+ value: Schema.Any,
101
+ headers: Schema.Struct({ operation: Schema.Literal('update', 'delete'), relation: Schema.Array(Schema.String) }),
102
+ }).annotations({ title: '@livestore/sync-electric:ResponseItemInvalid' })
103
+
104
+ const ResponseItemControl = Schema.Struct({
105
+ key: Schema.optional(Schema.String),
106
+ value: Schema.optional(Schema.Any),
107
+ headers: Schema.Struct({ control: Schema.String }),
108
+ }).annotations({ title: '@livestore/sync-electric:ResponseItemControl' })
109
+
110
+ const ResponseItem = Schema.Union(ResponseItemInsert, ResponseItemInvalid, ResponseItemControl)
89
111
 
90
112
  const ResponseHeaders = Schema.Struct({
91
113
  'electric-handle': Schema.String,
@@ -94,77 +116,8 @@ const ResponseHeaders = Schema.Struct({
94
116
  'electric-offset': Schema.String,
95
117
  })
96
118
 
97
- export const syncBackend = {} as any
98
-
99
119
  export const syncBackendOptions = <TOptions extends SyncBackendOptions>(options: TOptions) => options
100
120
 
101
- /**
102
- * This function should be called in a trusted environment (e.g. a proxy server) as it
103
- * requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
104
- */
105
- export const makeElectricUrl = ({
106
- electricHost,
107
- searchParams: providedSearchParams,
108
- sourceId,
109
- sourceSecret,
110
- apiSecret,
111
- }: {
112
- electricHost: string
113
- /**
114
- * Needed to extract information from the search params which the `@livestore/sync-electric`
115
- * client implementation automatically adds:
116
- * - `handle`: the ElectricSQL handle
117
- * - `storeId`: the Livestore storeId
118
- */
119
- searchParams: URLSearchParams
120
- /** Needed for Electric Cloud */
121
- sourceId?: string
122
- /** Needed for Electric Cloud */
123
- sourceSecret?: string
124
- /** For self-hosted ElectricSQL */
125
- apiSecret?: string
126
- }) => {
127
- const endpointUrl = `${electricHost}/v1/shape`
128
- const argsResult = Schema.decodeUnknownEither(Schema.Struct({ args: Schema.parseJson(ApiSchema.PullPayload) }))(
129
- Object.fromEntries(providedSearchParams.entries()),
130
- )
131
-
132
- if (argsResult._tag === 'Left') {
133
- return shouldNeverHappen(
134
- 'Invalid search params provided to makeElectricUrl',
135
- providedSearchParams,
136
- Object.fromEntries(providedSearchParams.entries()),
137
- )
138
- }
139
-
140
- const args = argsResult.right.args
141
- const tableName = toTableName(args.storeId)
142
- const searchParams = new URLSearchParams()
143
- searchParams.set('table', tableName)
144
- if (sourceId !== undefined) {
145
- searchParams.set('source_id', sourceId)
146
- }
147
- if (sourceSecret !== undefined) {
148
- searchParams.set('source_secret', sourceSecret)
149
- }
150
- if (apiSecret !== undefined) {
151
- searchParams.set('api_secret', apiSecret)
152
- }
153
- if (args.handle._tag === 'None') {
154
- searchParams.set('offset', '-1')
155
- } else {
156
- searchParams.set('offset', args.handle.value.offset)
157
- searchParams.set('handle', args.handle.value.handle)
158
- searchParams.set('live', 'true')
159
- }
160
-
161
- const payload = args.payload
162
-
163
- const url = `${endpointUrl}?${searchParams.toString()}`
164
-
165
- return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None', payload }
166
- }
167
-
168
121
  export interface SyncBackendOptions {
169
122
  /**
170
123
  * The endpoint to pull/push events. Pull is a `GET` request, push is a `POST` request.
@@ -179,7 +132,25 @@ export interface SyncBackendOptions {
179
132
  | {
180
133
  push: string
181
134
  pull: string
135
+ ping: string
182
136
  }
137
+
138
+ ping?: {
139
+ /**
140
+ * @default true
141
+ */
142
+ enabled?: boolean
143
+ /**
144
+ * How long to wait for a ping response before timing out
145
+ * @default 10 seconds
146
+ */
147
+ requestTimeout?: Duration.DurationInput
148
+ /**
149
+ * How often to send ping requests
150
+ * @default 10 seconds
151
+ */
152
+ requestInterval?: Duration.DurationInput
153
+ }
183
154
  }
184
155
 
185
156
  export const SyncMetadata = Schema.Struct({
@@ -188,48 +159,53 @@ export const SyncMetadata = Schema.Struct({
188
159
  handle: Schema.String,
189
160
  })
190
161
 
191
- type SyncMetadata = {
192
- offset: string
193
- // TODO move this into some kind of "global" sync metadata as it's the same for each event
194
- handle: string
195
- }
162
+ export type SyncMetadata = typeof SyncMetadata.Type
196
163
 
197
164
  export const makeSyncBackend =
198
- ({ endpoint }: SyncBackendOptions): SyncBackendConstructor<SyncMetadata> =>
165
+ ({ endpoint, ...options }: SyncBackendOptions): SyncBackend.SyncBackendConstructor<SyncMetadata> =>
199
166
  ({ storeId, payload }) =>
200
167
  Effect.gen(function* () {
201
- const isConnected = yield* SubscriptionRef.make(true)
168
+ const isConnected = yield* SubscriptionRef.make(false)
202
169
  const pullEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.pull
203
170
  const pushEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.push
171
+ const pingEndpoint = typeof endpoint === 'string' ? endpoint : endpoint.ping
172
+
173
+ const httpClient = yield* HttpClient.HttpClient
204
174
 
205
- const pull = (
175
+ const runPull = (
206
176
  handle: Option.Option<SyncMetadata>,
177
+ { live }: { live: boolean },
207
178
  ): Effect.Effect<
208
179
  Option.Option<
209
180
  readonly [
210
- Chunk.Chunk<{
181
+ /** The batch of events */
182
+ ReadonlyArray<{
211
183
  metadata: Option.Option<SyncMetadata>
212
184
  eventEncoded: LiveStoreEvent.AnyEncodedGlobal
213
185
  }>,
186
+ /** The next handle to use for the next pull */
214
187
  Option.Option<SyncMetadata>,
215
188
  ]
216
189
  >,
217
- InvalidPullError | IsOfflineError,
218
- HttpClient.HttpClient
190
+ InvalidPullError | IsOfflineError
219
191
  > =>
220
192
  Effect.gen(function* () {
221
- const argsJson = yield* Schema.encode(Schema.parseJson(ApiSchema.PullPayload))(
222
- ApiSchema.PullPayload.make({ storeId, handle, payload }),
193
+ const argsJson = yield* Schema.encode(ApiSchema.ArgsSchema)(
194
+ ApiSchema.PullPayload.make({ storeId, handle, payload, live }),
223
195
  )
224
196
  const url = `${pullEndpoint}?args=${argsJson}`
225
197
 
226
- const resp = yield* HttpClient.get(url)
198
+ const resp = yield* httpClient.get(url)
227
199
 
228
200
  if (resp.status === 401) {
229
201
  const body = yield* resp.text.pipe(Effect.catchAll(() => Effect.succeed('-')))
230
202
  return yield* InvalidPullError.make({
231
- message: `Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`,
203
+ cause: new Error(`Unauthorized (401): Couldn't connect to ElectricSQL: ${body}`),
232
204
  })
205
+ } else if (resp.status === 400) {
206
+ // Electric returns 400 when table doesn't exist
207
+ // Return empty result for non-existent tables
208
+ return Option.some([[], Option.none()] as const)
233
209
  } else if (resp.status === 409) {
234
210
  // https://electric-sql.com/openapi.html#/paths/~1v1~1shape/get
235
211
  // {
@@ -243,8 +219,9 @@ export const makeSyncBackend =
243
219
  // until we found a new event, then, continue with the new handle
244
220
  return notYetImplemented(`Electric shape not found`)
245
221
  } else if (resp.status < 200 || resp.status >= 300) {
222
+ const body = yield* resp.text
246
223
  return yield* InvalidPullError.make({
247
- message: `Unexpected status code: ${resp.status}`,
224
+ cause: new Error(`Unexpected status code: ${resp.status}: ${body}`),
248
225
  })
249
226
  }
250
227
 
@@ -257,56 +234,105 @@ export const makeSyncBackend =
257
234
  // Electric completes the long-poll request after ~20 seconds with a 204 status
258
235
  // In this case we just retry where we left off
259
236
  if (resp.status === 204) {
260
- return Option.some([Chunk.empty(), Option.some(nextHandle)] as const)
237
+ return Option.some([[], Option.some(nextHandle)] as const)
261
238
  }
262
239
 
263
- const body = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
240
+ const allItems = yield* HttpClientResponse.schemaBodyJson(Schema.Array(ResponseItem), {
264
241
  onExcessProperty: 'preserve',
265
242
  })(resp)
266
243
 
267
- const items = body
268
- .filter((item) => item.value !== undefined && (item.headers as any).operation === 'insert')
269
- .map((item) => ({
270
- metadata: Option.some({ offset: nextHandle.offset!, handle: nextHandle.handle }),
271
- eventEncoded: item.value! as LiveStoreEvent.AnyEncodedGlobal,
272
- }))
244
+ // Check for delete/update operations and throw descriptive error
245
+ const invalidOperations = ReadonlyArray.filterMap(allItems, (item) =>
246
+ Schema.is(ResponseItemInvalid)(item) ? Option.some(item.headers.operation) : Option.none(),
247
+ )
248
+
249
+ if (invalidOperations.length > 0) {
250
+ const operation = invalidOperations[0]!
251
+ return yield* new InvalidOperationError({
252
+ operation,
253
+ message: `ElectricSQL '${operation}' event received. This results from directly mutating the event log. Append a series of events that produce the desired state instead of mutating the event log.`,
254
+ })
255
+ }
273
256
 
274
- // // TODO implement proper `remaining` handling
275
- // remaining: 0,
257
+ const items = allItems.filter(Schema.is(ResponseItemInsert)).map((item) => ({
258
+ metadata: Option.some({ offset: nextHandle.offset, handle: nextHandle.handle }),
259
+ eventEncoded: item.value as LiveStoreEvent.AnyEncodedGlobal,
260
+ }))
276
261
 
277
- // if (listenForNew === false && items.length === 0) {
278
- // return Option.none()
279
- // }
262
+ yield* Effect.annotateCurrentSpan({ itemsCount: items.length, nextHandle })
280
263
 
281
- return Option.some([Chunk.fromIterable(items), Option.some(nextHandle)] as const)
264
+ return Option.some([items, Option.some(nextHandle)] as const)
282
265
  }).pipe(
283
266
  Effect.scoped,
284
- Effect.mapError((cause) =>
285
- cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ message: cause.toString() }),
286
- ),
267
+ Effect.mapError((cause) => (cause._tag === 'InvalidPullError' ? cause : InvalidPullError.make({ cause }))),
268
+ Effect.withSpan('electric-provider:runPull', { attributes: { handle, live } }),
287
269
  )
288
270
 
289
271
  const pullEndpointHasSameOrigin =
290
272
  pullEndpoint.startsWith('/') ||
291
273
  (globalThis.location !== undefined && globalThis.location.origin === new URL(pullEndpoint).origin)
292
274
 
293
- return {
294
- // If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
295
- // otherwise we send a HEAD request to speed up the connection process
296
- connect: pullEndpointHasSameOrigin
297
- ? Effect.void
298
- : HttpClient.head(pullEndpoint).pipe(UnexpectedError.mapToUnexpectedError),
299
- pull: (args) =>
300
- Stream.unfoldChunkEffect(
301
- args.pipe(
302
- Option.map((_) => _.metadata),
303
- Option.flatten,
304
- ),
305
- (metadataOption) => pull(metadataOption),
275
+ const pingTimeout = options.ping?.requestTimeout ?? 10_000
276
+
277
+ const ping: SyncBackend.SyncBackend<SyncMetadata>['ping'] = Effect.gen(function* () {
278
+ yield* httpClient.pipe(HttpClient.filterStatusOk).head(pingEndpoint)
279
+
280
+ yield* SubscriptionRef.set(isConnected, true)
281
+ }).pipe(
282
+ UnexpectedError.mapToUnexpectedError,
283
+ Effect.timeout(pingTimeout),
284
+ Effect.catchTag('TimeoutException', () => SubscriptionRef.set(isConnected, false)),
285
+ Effect.withSpan('electric-provider:ping'),
286
+ )
287
+
288
+ const pingInterval = options.ping?.requestInterval ?? 10_000
289
+
290
+ if (options.ping?.enabled !== false) {
291
+ // Automatically ping the server to keep the connection alive
292
+ yield* ping.pipe(Effect.repeat(Schedule.spaced(pingInterval)), Effect.tapCauseLogPretty, Effect.forkScoped)
293
+ }
294
+
295
+ // If the pull endpoint has the same origin as the current page, we can assume that we already have a connection
296
+ // otherwise we send a HEAD request to speed up the connection process
297
+ const connect: SyncBackend.SyncBackend<SyncMetadata>['connect'] = pullEndpointHasSameOrigin
298
+ ? Effect.void
299
+ : ping.pipe(UnexpectedError.mapToUnexpectedError)
300
+
301
+ return SyncBackend.of({
302
+ connect,
303
+ pull: (cursor, options) => {
304
+ let hasEmittedAtLeastOnce = false
305
+
306
+ return Stream.unfoldEffect(cursor.pipe(Option.flatMap((_) => _.metadata)), (metadataOption) =>
307
+ Effect.gen(function* () {
308
+ const result = yield* runPull(metadataOption, { live: options?.live ?? false })
309
+ if (Option.isNone(result)) return Option.none()
310
+
311
+ const [batch, nextMetadataOption] = result.value
312
+
313
+ // Continue pagination if we have data
314
+ if (batch.length > 0) {
315
+ hasEmittedAtLeastOnce = true
316
+ return Option.some([{ batch, hasMore: true }, nextMetadataOption])
317
+ }
318
+
319
+ // Make sure we emit at least once even if there's no data or we're live-pulling
320
+ if (hasEmittedAtLeastOnce === false || options?.live) {
321
+ hasEmittedAtLeastOnce = true
322
+ return Option.some([{ batch, hasMore: false }, nextMetadataOption])
323
+ }
324
+
325
+ // Stop on empty batch (when not live)
326
+ return Option.none()
327
+ }),
306
328
  ).pipe(
307
- Stream.chunks,
308
- Stream.map((chunk) => ({ batch: [...chunk], remaining: 0 })),
309
- ),
329
+ Stream.map(({ batch, hasMore }) => ({
330
+ batch,
331
+ pageInfo: hasMore ? SyncBackend.pageInfoMoreUnknown : SyncBackend.pageInfoNoMore,
332
+ })),
333
+ Stream.withSpan('electric-provider:pull'),
334
+ )
335
+ },
310
336
 
311
337
  push: (batch) =>
312
338
  Effect.gen(function* () {
@@ -314,18 +340,17 @@ export const makeSyncBackend =
314
340
  HttpClientRequest.post(pushEndpoint),
315
341
  ApiSchema.PushPayload.make({ storeId, batch }),
316
342
  ).pipe(
317
- Effect.andThen(HttpClient.execute),
343
+ Effect.andThen(httpClient.pipe(HttpClient.filterStatusOk).execute),
318
344
  Effect.andThen(HttpClientResponse.schemaBodyJson(Schema.Struct({ success: Schema.Boolean }))),
319
345
  Effect.scoped,
320
- Effect.mapError((cause) =>
321
- InvalidPushError.make({ reason: { _tag: 'Unexpected', message: cause.toString() } }),
322
- ),
346
+ Effect.mapError((cause) => InvalidPushError.make({ cause: UnexpectedError.make({ cause }) })),
323
347
  )
324
348
 
325
349
  if (!resp.success) {
326
- yield* InvalidPushError.make({ reason: { _tag: 'Unexpected', message: 'Push failed' } })
350
+ return yield* InvalidPushError.make({ cause: new UnexpectedError({ cause: new Error('Push failed') }) })
327
351
  }
328
- }),
352
+ }).pipe(Effect.withSpan('electric-provider:push')),
353
+ ping,
329
354
  isConnected,
330
355
  metadata: {
331
356
  name: '@livestore/sync-electric',
@@ -333,17 +358,11 @@ export const makeSyncBackend =
333
358
  protocol: 'http',
334
359
  endpoint,
335
360
  },
336
- } satisfies SyncBackend<SyncMetadata>
361
+ supports: {
362
+ // Given Electric is heavily optimized for immutable caching, we can't know the remaining count
363
+ // until we've reached the end of the stream
364
+ pullPageInfoKnown: false,
365
+ pullLive: true,
366
+ },
367
+ })
337
368
  })
338
-
339
- /**
340
- * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
341
- *
342
- * Changing this version number will lead to a "soft reset".
343
- */
344
- export const PERSISTENCE_FORMAT_VERSION = 6
345
-
346
- export const toTableName = (storeId: string) => {
347
- const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
348
- return `eventlog_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`
349
- }
@@ -0,0 +1,110 @@
1
+ import { shouldNeverHappen } from '@livestore/utils'
2
+ import { Hash, Schema } from '@livestore/utils/effect'
3
+ import * as ApiSchema from './api-schema.ts'
4
+
5
+ /**
6
+ * This function should be called in a trusted environment (e.g. a proxy server) as it
7
+ * requires access to senstive information (e.g. `apiSecret` / `sourceSecret`).
8
+ */
9
+ export const makeElectricUrl = ({
10
+ electricHost,
11
+ searchParams: providedSearchParams,
12
+ sourceId,
13
+ sourceSecret,
14
+ apiSecret,
15
+ }: {
16
+ electricHost: string
17
+ /**
18
+ * Needed to extract information from the search params which the `@livestore/sync-electric`
19
+ * client implementation automatically adds:
20
+ * - `handle`: the ElectricSQL handle
21
+ * - `storeId`: the Livestore storeId
22
+ */
23
+ searchParams: URLSearchParams
24
+ /** Needed for Electric Cloud */
25
+ sourceId?: string
26
+ /** Needed for Electric Cloud */
27
+ sourceSecret?: string
28
+ /** For self-hosted ElectricSQL */
29
+ apiSecret?: string
30
+ }): {
31
+ /**
32
+ * The URL to the ElectricSQL API endpoint with needed search params.
33
+ */
34
+ url: string
35
+ /** The Livestore storeId */
36
+ storeId: string
37
+ /**
38
+ * Whether the Postgres table needs to be created.
39
+ */
40
+ needsInit: boolean
41
+ /** Sync payload provided by the client */
42
+ payload: Schema.JsonValue | undefined
43
+ } => {
44
+ const endpointUrl = `${electricHost}/v1/shape`
45
+ const UrlParamsSchema = Schema.Struct({ args: ApiSchema.ArgsSchema })
46
+ const argsResult = Schema.decodeUnknownEither(UrlParamsSchema)(Object.fromEntries(providedSearchParams.entries()))
47
+
48
+ if (argsResult._tag === 'Left') {
49
+ return shouldNeverHappen(
50
+ 'Invalid search params provided to makeElectricUrl',
51
+ providedSearchParams,
52
+ Object.fromEntries(providedSearchParams.entries()),
53
+ )
54
+ }
55
+
56
+ const args = argsResult.right.args
57
+ const tableName = toTableName(args.storeId)
58
+ // TODO refactor with Effect URLSearchParams schema
59
+ // https://electric-sql.com/openapi.html
60
+ const searchParams = new URLSearchParams()
61
+ // Electric requires table names with capital letters to be quoted
62
+ // Since our table names include the storeId which may have capitals, we always quote
63
+ searchParams.set('table', `"${tableName}"`)
64
+ if (sourceId !== undefined) {
65
+ searchParams.set('source_id', sourceId)
66
+ }
67
+ if (sourceSecret !== undefined) {
68
+ searchParams.set('source_secret', sourceSecret)
69
+ }
70
+ if (apiSecret !== undefined) {
71
+ searchParams.set('api_secret', apiSecret)
72
+ }
73
+ if (args.handle._tag === 'None') {
74
+ searchParams.set('offset', '-1')
75
+ } else {
76
+ searchParams.set('offset', args.handle.value.offset)
77
+ searchParams.set('handle', args.handle.value.handle)
78
+ searchParams.set('live', args.live ? 'true' : 'false')
79
+ }
80
+
81
+ const payload = args.payload
82
+
83
+ const url = `${endpointUrl}?${searchParams.toString()}`
84
+
85
+ return { url, storeId: args.storeId, needsInit: args.handle._tag === 'None', payload }
86
+ }
87
+
88
+ export const toTableName = (storeId: string) => {
89
+ const escapedStoreId = storeId.replaceAll(/[^a-zA-Z0-9_]/g, '_')
90
+ const tableName = `eventlog_${PERSISTENCE_FORMAT_VERSION}_${escapedStoreId}`
91
+
92
+ if (tableName.length > 63) {
93
+ const hashedStoreId = Hash.string(storeId)
94
+
95
+ console.warn(
96
+ `Table name is too long: "${tableName}". Postgres table names are limited to 63 characters. Using hashed storeId instead: "${hashedStoreId}".`,
97
+ )
98
+
99
+ return `eventlog_${PERSISTENCE_FORMAT_VERSION}_hash_${hashedStoreId}`
100
+ }
101
+
102
+ return tableName
103
+ }
104
+
105
+ /**
106
+ * Needs to be bumped when the storage format changes (e.g. eventlogTable schema changes)
107
+ *
108
+ * Changing this version number will lead to a "soft reset".
109
+ */
110
+ export const PERSISTENCE_FORMAT_VERSION = 6