@electric-sql/client 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { Message, Offset, Schema, Row, MaybePromise } from './types'
1
+ import {
2
+ Message,
3
+ Offset,
4
+ Schema,
5
+ Row,
6
+ MaybePromise,
7
+ GetExtensions,
8
+ } from './types'
2
9
  import { MessageParser, Parser } from './parser'
3
10
  import { isUpToDateMessage } from './helpers'
4
11
  import { FetchError, FetchBackoffAbortError } from './error'
@@ -10,6 +17,8 @@ import {
10
17
  } from './fetch'
11
18
  import {
12
19
  CHUNK_LAST_OFFSET_HEADER,
20
+ LIVE_CACHE_BUSTER_HEADER,
21
+ LIVE_CACHE_BUSTER_QUERY_PARAM,
13
22
  LIVE_QUERY_PARAM,
14
23
  OFFSET_QUERY_PARAM,
15
24
  SHAPE_ID_HEADER,
@@ -21,7 +30,7 @@ import {
21
30
  /**
22
31
  * Options for constructing a ShapeStream.
23
32
  */
24
- export interface ShapeStreamOptions {
33
+ export interface ShapeStreamOptions<T = never> {
25
34
  /**
26
35
  * The full URL to where the Shape is hosted. This can either be the Electric server
27
36
  * directly or a proxy. E.g. for a local Electric instance, you might set `http://localhost:3000/v1/shape/foo`
@@ -46,6 +55,13 @@ export interface ShapeStreamOptions {
46
55
  */
47
56
  shapeId?: string
48
57
  backoffOptions?: BackoffOptions
58
+
59
+ /**
60
+ * HTTP headers to attach to requests made by the client.
61
+ * Can be used for adding authentication headers.
62
+ */
63
+ headers?: Record<string, string>
64
+
49
65
  /**
50
66
  * Automatically fetch updates to the Shape. If you just want to sync the current
51
67
  * shape and stop, pass false.
@@ -53,10 +69,10 @@ export interface ShapeStreamOptions {
53
69
  subscribe?: boolean
54
70
  signal?: AbortSignal
55
71
  fetchClient?: typeof fetch
56
- parser?: Parser
72
+ parser?: Parser<T>
57
73
  }
58
74
 
59
- export interface ShapeStreamInterface<T extends Row = Row> {
75
+ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
60
76
  subscribe(
61
77
  callback: (messages: Message<T>[]) => MaybePromise<void>,
62
78
  onError?: (error: FetchError | Error) => void
@@ -108,10 +124,10 @@ export interface ShapeStreamInterface<T extends Row = Row> {
108
124
  * ```
109
125
  */
110
126
 
111
- export class ShapeStream<T extends Row = Row>
127
+ export class ShapeStream<T extends Row<unknown> = Row>
112
128
  implements ShapeStreamInterface<T>
113
129
  {
114
- readonly options: ShapeStreamOptions
130
+ readonly options: ShapeStreamOptions<GetExtensions<T>>
115
131
 
116
132
  readonly #fetchClient: typeof fetch
117
133
  readonly #messageParser: MessageParser<T>
@@ -129,16 +145,18 @@ export class ShapeStream<T extends Row = Row>
129
145
  >()
130
146
 
131
147
  #lastOffset: Offset
148
+ #liveCacheBuster: string // Seconds since our Electric Epoch 😎
132
149
  #lastSyncedAt?: number // unix time
133
150
  #isUpToDate: boolean = false
134
151
  #connected: boolean = false
135
152
  #shapeId?: string
136
153
  #schema?: Schema
137
154
 
138
- constructor(options: ShapeStreamOptions) {
155
+ constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
139
156
  validateOptions(options)
140
157
  this.options = { subscribe: true, ...options }
141
158
  this.#lastOffset = this.options.offset ?? `-1`
159
+ this.#liveCacheBuster = ``
142
160
  this.#shapeId = this.options.shapeId
143
161
  this.#messageParser = new MessageParser<T>(options.parser)
144
162
 
@@ -183,6 +201,10 @@ export class ShapeStream<T extends Row = Row>
183
201
 
184
202
  if (this.#isUpToDate) {
185
203
  fetchUrl.searchParams.set(LIVE_QUERY_PARAM, `true`)
204
+ fetchUrl.searchParams.set(
205
+ LIVE_CACHE_BUSTER_QUERY_PARAM,
206
+ this.#liveCacheBuster
207
+ )
186
208
  }
187
209
 
188
210
  if (this.#shapeId) {
@@ -192,7 +214,10 @@ export class ShapeStream<T extends Row = Row>
192
214
 
193
215
  let response!: Response
194
216
  try {
195
- response = await this.#fetchClient(fetchUrl.toString(), { signal })
217
+ response = await this.#fetchClient(fetchUrl.toString(), {
218
+ signal,
219
+ headers: this.options.headers,
220
+ })
196
221
  this.#connected = true
197
222
  } catch (e) {
198
223
  if (e instanceof FetchBackoffAbortError) break // interrupted
@@ -231,6 +256,11 @@ export class ShapeStream<T extends Row = Row>
231
256
  this.#lastOffset = lastOffset as Offset
232
257
  }
233
258
 
259
+ const liveCacheBuster = headers.get(LIVE_CACHE_BUSTER_HEADER)
260
+ if (liveCacheBuster) {
261
+ this.#liveCacheBuster = liveCacheBuster
262
+ }
263
+
234
264
  const getSchema = (): Schema => {
235
265
  const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
236
266
  return schemaHeader ? JSON.parse(schemaHeader) : {}
@@ -359,6 +389,7 @@ export class ShapeStream<T extends Row = Row>
359
389
  */
360
390
  #reset(shapeId?: string) {
361
391
  this.#lastOffset = `-1`
392
+ this.#liveCacheBuster = ``
362
393
  this.#shapeId = shapeId
363
394
  this.#isUpToDate = false
364
395
  this.#connected = false
@@ -366,7 +397,7 @@ export class ShapeStream<T extends Row = Row>
366
397
  }
367
398
  }
368
399
 
369
- function validateOptions(options: Partial<ShapeStreamOptions>): void {
400
+ function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
370
401
  if (!options.url) {
371
402
  throw new Error(`Invalid shape option. It must provide the url`)
372
403
  }
package/src/constants.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export const SHAPE_ID_HEADER = `electric-shape-id`
2
+ export const LIVE_CACHE_BUSTER_HEADER = `electric-next-cursor`
3
+ export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`
2
4
  export const CHUNK_LAST_OFFSET_HEADER = `electric-chunk-last-offset`
3
5
  export const CHUNK_UP_TO_DATE_HEADER = `electric-chunk-up-to-date`
4
6
  export const SHAPE_SCHEMA_HEADER = `electric-schema`
package/src/fetch.ts CHANGED
@@ -8,6 +8,10 @@ import {
8
8
  } from './constants'
9
9
  import { FetchError, FetchBackoffAbortError } from './error'
10
10
 
11
+ // Some specific 4xx and 5xx HTTP status codes that we definitely
12
+ // want to retry
13
+ const HTTP_RETRY_STATUS_CODES = [429]
14
+
11
15
  export interface BackoffOptions {
12
16
  /**
13
17
  * Initial delay before retrying in milliseconds
@@ -63,6 +67,7 @@ export function createFetchWithBackoff(
63
67
  throw new FetchBackoffAbortError()
64
68
  } else if (
65
69
  e instanceof FetchError &&
70
+ !HTTP_RETRY_STATUS_CODES.includes(e.status) &&
66
71
  e.status >= 400 &&
67
72
  e.status < 500
68
73
  ) {
package/src/helpers.ts CHANGED
@@ -17,7 +17,7 @@ import { ChangeMessage, ControlMessage, Message, Row } from './types'
17
17
  * }
18
18
  * ```
19
19
  */
20
- export function isChangeMessage<T extends Row = Row>(
20
+ export function isChangeMessage<T extends Row<unknown> = Row>(
21
21
  message: Message<T>
22
22
  ): message is ChangeMessage<T> {
23
23
  return `key` in message
@@ -40,13 +40,13 @@ export function isChangeMessage<T extends Row = Row>(
40
40
  * }
41
41
  * ```
42
42
  */
43
- export function isControlMessage<T extends Row = Row>(
43
+ export function isControlMessage<T extends Row<unknown> = Row>(
44
44
  message: Message<T>
45
45
  ): message is ControlMessage {
46
46
  return !isChangeMessage(message)
47
47
  }
48
48
 
49
- export function isUpToDateMessage<T extends Row = Row>(
49
+ export function isUpToDateMessage<T extends Row<unknown> = Row>(
50
50
  message: Message<T>
51
51
  ): message is ControlMessage & { up_to_date: true } {
52
52
  return isControlMessage(message) && message.headers.control === `up-to-date`
package/src/parser.ts CHANGED
@@ -1,17 +1,23 @@
1
- import { ColumnInfo, Message, Row, Schema, Value } from './types'
1
+ import { ColumnInfo, GetExtensions, Message, Row, Schema, Value } from './types'
2
2
 
3
3
  type NullToken = null | `NULL`
4
4
  type Token = Exclude<string, NullToken>
5
5
  type NullableToken = Token | NullToken
6
- export type ParseFunction = (
6
+ export type ParseFunction<Extensions = never> = (
7
7
  value: Token,
8
8
  additionalInfo?: Omit<ColumnInfo, `type` | `dims`>
9
- ) => Value
10
- type NullableParseFunction = (
9
+ ) => Value<Extensions>
10
+ type NullableParseFunction<Extensions = never> = (
11
11
  value: NullableToken,
12
12
  additionalInfo?: Omit<ColumnInfo, `type` | `dims`>
13
- ) => Value
14
- export type Parser = { [key: string]: ParseFunction }
13
+ ) => Value<Extensions>
14
+ /**
15
+ * @typeParam Extensions - Additional types that can be parsed by this parser beyond the standard SQL types.
16
+ * Defaults to no additional types.
17
+ */
18
+ export type Parser<Extensions = never> = {
19
+ [key: string]: ParseFunction<Extensions>
20
+ }
15
21
 
16
22
  const parseNumber = (value: string) => Number(value)
17
23
  const parseBool = (value: string) => value === `true` || value === `t`
@@ -31,7 +37,10 @@ export const defaultParser: Parser = {
31
37
  }
32
38
 
33
39
  // Taken from: https://github.com/electric-sql/pglite/blob/main/packages/pglite/src/types.ts#L233-L279
34
- export function pgArrayParser(value: Token, parser?: ParseFunction): Value {
40
+ export function pgArrayParser<Extensions>(
41
+ value: Token,
42
+ parser?: ParseFunction<Extensions>
43
+ ): Value<Extensions> {
35
44
  let i = 0
36
45
  let char = null
37
46
  let str = ``
@@ -39,7 +48,7 @@ export function pgArrayParser(value: Token, parser?: ParseFunction): Value {
39
48
  let last = 0
40
49
  let p: string | undefined = undefined
41
50
 
42
- function loop(x: string): Value[] {
51
+ function loop(x: string): Array<Value<Extensions>> {
43
52
  const xs = []
44
53
  for (; i < x.length; i++) {
45
54
  char = x[i]
@@ -79,9 +88,9 @@ export function pgArrayParser(value: Token, parser?: ParseFunction): Value {
79
88
  return loop(value)[0]
80
89
  }
81
90
 
82
- export class MessageParser<T extends Row> {
83
- private parser: Parser
84
- constructor(parser?: Parser) {
91
+ export class MessageParser<T extends Row<unknown>> {
92
+ private parser: Parser<GetExtensions<T>>
93
+ constructor(parser?: Parser<GetExtensions<T>>) {
85
94
  // Merge the provided parser with the default parser
86
95
  // to use the provided parser whenever defined
87
96
  // and otherwise fall back to the default parser
@@ -96,7 +105,7 @@ export class MessageParser<T extends Row> {
96
105
  // But `typeof null === 'object'` so we need to make an explicit check.
97
106
  if (key === `value` && typeof value === `object` && value !== null) {
98
107
  // Parse the row values
99
- const row = value as Record<string, Value>
108
+ const row = value as Record<string, Value<GetExtensions<T>>>
100
109
  Object.keys(row).forEach((key) => {
101
110
  row[key] = this.parseRow(key, row[key] as NullableToken, schema)
102
111
  })
@@ -106,7 +115,11 @@ export class MessageParser<T extends Row> {
106
115
  }
107
116
 
108
117
  // Parses the message values using the provided parser based on the schema information
109
- private parseRow(key: string, value: NullableToken, schema: Schema): Value {
118
+ private parseRow(
119
+ key: string,
120
+ value: NullableToken,
121
+ schema: Schema
122
+ ): Value<GetExtensions<T>> {
110
123
  const columnInfo = schema[key]
111
124
  if (!columnInfo) {
112
125
  // We don't have information about the value
@@ -137,11 +150,11 @@ export class MessageParser<T extends Row> {
137
150
  }
138
151
  }
139
152
 
140
- function makeNullableParser(
141
- parser: ParseFunction,
153
+ function makeNullableParser<Extensions>(
154
+ parser: ParseFunction<Extensions>,
142
155
  columnInfo: ColumnInfo,
143
156
  columnName?: string
144
- ): NullableParseFunction {
157
+ ): NullableParseFunction<Extensions> {
145
158
  const isNullable = !(columnInfo.not_null ?? false)
146
159
  // The sync service contains `null` value for a column whose value is NULL
147
160
  // but if the column value is an array that contains a NULL value
package/src/shape.ts CHANGED
@@ -3,8 +3,8 @@ import { isChangeMessage, isControlMessage } from './helpers'
3
3
  import { FetchError } from './error'
4
4
  import { ShapeStreamInterface } from './client'
5
5
 
6
- export type ShapeData<T extends Row = Row> = Map<string, T>
7
- export type ShapeChangedCallback<T extends Row = Row> = (
6
+ export type ShapeData<T extends Row<unknown> = Row> = Map<string, T>
7
+ export type ShapeChangedCallback<T extends Row<unknown> = Row> = (
8
8
  value: ShapeData<T>
9
9
  ) => void
10
10
 
@@ -39,7 +39,7 @@ export type ShapeChangedCallback<T extends Row = Row> = (
39
39
  * console.log(shapeData)
40
40
  * })
41
41
  */
42
- export class Shape<T extends Row = Row> {
42
+ export class Shape<T extends Row<unknown> = Row> {
43
43
  readonly #stream: ShapeStreamInterface<T>
44
44
 
45
45
  readonly #data: ShapeData<T> = new Map()
package/src/types.ts CHANGED
@@ -1,13 +1,21 @@
1
- export type Value =
1
+ /**
2
+ * Default types for SQL but can be extended with additional types when using a custom parser.
3
+ * @typeParam Extensions - Additional value types.
4
+ */
5
+ export type Value<Extensions = never> =
2
6
  | string
3
7
  | number
4
8
  | boolean
5
9
  | bigint
6
10
  | null
7
- | Value[]
8
- | { [key: string]: Value }
11
+ | Extensions
12
+ | Value<Extensions>[]
13
+ | { [key: string]: Value<Extensions> }
14
+
15
+ export type Row<Extensions = never> = Record<string, Value<Extensions>>
9
16
 
10
- export type Row = { [key: string]: Value }
17
+ export type GetExtensions<T extends Row<unknown>> =
18
+ T extends Row<infer Extensions> ? Extensions : never
11
19
 
12
20
  export type Offset = `-1` | `${number}_${number}`
13
21
 
@@ -19,7 +27,7 @@ export type ControlMessage = {
19
27
  headers: Header & { control: `up-to-date` | `must-refetch` }
20
28
  }
21
29
 
22
- export type ChangeMessage<T extends Row = Row> = {
30
+ export type ChangeMessage<T extends Row<unknown> = Row> = {
23
31
  key: string
24
32
  value: T
25
33
  headers: Header & { operation: `insert` | `update` | `delete` }
@@ -27,7 +35,9 @@ export type ChangeMessage<T extends Row = Row> = {
27
35
  }
28
36
 
29
37
  // Define the type for a record
30
- export type Message<T extends Row = Row> = ControlMessage | ChangeMessage<T>
38
+ export type Message<T extends Row<unknown> = Row> =
39
+ | ControlMessage
40
+ | ChangeMessage<T>
31
41
 
32
42
  /**
33
43
  * Common properties for all columns.
@@ -104,7 +114,7 @@ export type ColumnInfo =
104
114
 
105
115
  export type Schema = { [key: string]: ColumnInfo }
106
116
 
107
- export type TypedMessages<T extends Row = Row> = {
117
+ export type TypedMessages<T extends Row<unknown> = Row> = {
108
118
  messages: Array<Message<T>>
109
119
  schema: ColumnInfo
110
120
  }