@electric-sql/client 0.5.1 → 0.6.1

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,12 +1,19 @@
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
- import { MessageProcessor, MessageProcessorInterface } from './queue'
5
11
  import { FetchError, FetchBackoffAbortError } from './error'
6
12
  import {
7
13
  BackoffDefaults,
8
14
  BackoffOptions,
9
15
  createFetchWithBackoff,
16
+ createFetchWithChunkBuffer,
10
17
  } from './fetch'
11
18
  import {
12
19
  CHUNK_LAST_OFFSET_HEADER,
@@ -21,7 +28,7 @@ import {
21
28
  /**
22
29
  * Options for constructing a ShapeStream.
23
30
  */
24
- export interface ShapeStreamOptions {
31
+ export interface ShapeStreamOptions<T = never> {
25
32
  /**
26
33
  * The full URL to where the Shape is hosted. This can either be the Electric server
27
34
  * directly or a proxy. E.g. for a local Electric instance, you might set `http://localhost:3000/v1/shape/foo`
@@ -46,6 +53,13 @@ export interface ShapeStreamOptions {
46
53
  */
47
54
  shapeId?: string
48
55
  backoffOptions?: BackoffOptions
56
+
57
+ /**
58
+ * HTTP headers to attach to requests made by the client.
59
+ * Can be used for adding authentication headers.
60
+ */
61
+ headers?: Record<string, string>
62
+
49
63
  /**
50
64
  * Automatically fetch updates to the Shape. If you just want to sync the current
51
65
  * shape and stop, pass false.
@@ -53,10 +67,10 @@ export interface ShapeStreamOptions {
53
67
  subscribe?: boolean
54
68
  signal?: AbortSignal
55
69
  fetchClient?: typeof fetch
56
- parser?: Parser
70
+ parser?: Parser<T>
57
71
  }
58
72
 
59
- export interface ShapeStreamInterface<T extends Row = Row> {
73
+ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
60
74
  subscribe(
61
75
  callback: (messages: Message<T>[]) => MaybePromise<void>,
62
76
  onError?: (error: FetchError | Error) => void
@@ -108,10 +122,10 @@ export interface ShapeStreamInterface<T extends Row = Row> {
108
122
  * ```
109
123
  */
110
124
 
111
- export class ShapeStream<T extends Row = Row>
125
+ export class ShapeStream<T extends Row<unknown> = Row>
112
126
  implements ShapeStreamInterface<T>
113
127
  {
114
- readonly options: ShapeStreamOptions
128
+ readonly options: ShapeStreamOptions<GetExtensions<T>>
115
129
 
116
130
  readonly #fetchClient: typeof fetch
117
131
  readonly #messageParser: MessageParser<T>
@@ -119,7 +133,7 @@ export class ShapeStream<T extends Row = Row>
119
133
  readonly #subscribers = new Map<
120
134
  number,
121
135
  [
122
- MessageProcessorInterface<Message<T>[]>,
136
+ (messages: Message<T>[]) => MaybePromise<void>,
123
137
  ((error: Error) => void) | undefined,
124
138
  ]
125
139
  >()
@@ -135,24 +149,26 @@ export class ShapeStream<T extends Row = Row>
135
149
  #shapeId?: string
136
150
  #schema?: Schema
137
151
 
138
- constructor(options: ShapeStreamOptions) {
152
+ constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
139
153
  validateOptions(options)
140
154
  this.options = { subscribe: true, ...options }
141
155
  this.#lastOffset = this.options.offset ?? `-1`
142
156
  this.#shapeId = this.options.shapeId
143
157
  this.#messageParser = new MessageParser<T>(options.parser)
144
158
 
145
- this.#fetchClient = createFetchWithBackoff(
159
+ const baseFetchClient =
146
160
  options.fetchClient ??
147
- ((...args: Parameters<typeof fetch>) => fetch(...args)),
148
- {
149
- ...(options.backoffOptions ?? BackoffDefaults),
150
- onFailedAttempt: () => {
151
- this.#connected = false
152
- options.backoffOptions?.onFailedAttempt?.()
153
- },
154
- }
155
- )
161
+ ((...args: Parameters<typeof fetch>) => fetch(...args))
162
+
163
+ const fetchWithBackoffClient = createFetchWithBackoff(baseFetchClient, {
164
+ ...(options.backoffOptions ?? BackoffDefaults),
165
+ onFailedAttempt: () => {
166
+ this.#connected = false
167
+ options.backoffOptions?.onFailedAttempt?.()
168
+ },
169
+ })
170
+
171
+ this.#fetchClient = createFetchWithChunkBuffer(fetchWithBackoffClient)
156
172
 
157
173
  this.start()
158
174
  }
@@ -190,7 +206,10 @@ export class ShapeStream<T extends Row = Row>
190
206
 
191
207
  let response!: Response
192
208
  try {
193
- response = await this.#fetchClient(fetchUrl.toString(), { signal })
209
+ response = await this.#fetchClient(fetchUrl.toString(), {
210
+ signal,
211
+ headers: this.options.headers,
212
+ })
194
213
  this.#connected = true
195
214
  } catch (e) {
196
215
  if (e instanceof FetchBackoffAbortError) break // interrupted
@@ -199,14 +218,14 @@ export class ShapeStream<T extends Row = Row>
199
218
  // The request is invalid, most likely because the shape has been deleted.
200
219
  // We should start from scratch, this will force the shape to be recreated.
201
220
  this.#reset()
202
- this.#publish(e.json as Message<T>[])
221
+ await this.#publish(e.json as Message<T>[])
203
222
  continue
204
223
  } else if (e.status == 409) {
205
224
  // Upon receiving a 409, we should start from scratch
206
225
  // with the newly provided shape ID
207
226
  const newShapeId = e.headers[SHAPE_ID_HEADER]
208
227
  this.#reset(newShapeId)
209
- this.#publish(e.json as Message<T>[])
228
+ await this.#publish(e.json as Message<T>[])
210
229
  continue
211
230
  } else if (e.status >= 400 && e.status < 500) {
212
231
  // Notify subscribers
@@ -246,16 +265,17 @@ export class ShapeStream<T extends Row = Row>
246
265
 
247
266
  // Update isUpToDate
248
267
  if (batch.length > 0) {
268
+ const prevUpToDate = this.#isUpToDate
249
269
  const lastMessage = batch[batch.length - 1]
250
270
  if (isUpToDateMessage(lastMessage)) {
251
271
  this.#lastSyncedAt = Date.now()
252
- if (!this.#isUpToDate) {
253
- this.#isUpToDate = true
254
- this.#notifyUpToDateSubscribers()
255
- }
272
+ this.#isUpToDate = true
256
273
  }
257
274
 
258
- this.#publish(batch)
275
+ await this.#publish(batch)
276
+ if (!prevUpToDate && this.#isUpToDate) {
277
+ this.#notifyUpToDateSubscribers()
278
+ }
259
279
  }
260
280
  }
261
281
  } finally {
@@ -268,9 +288,8 @@ export class ShapeStream<T extends Row = Row>
268
288
  onError?: (error: FetchError | Error) => void
269
289
  ) {
270
290
  const subscriptionId = Math.random()
271
- const subscriber = new MessageProcessor(callback)
272
291
 
273
- this.#subscribers.set(subscriptionId, [subscriber, onError])
292
+ this.#subscribers.set(subscriptionId, [callback, onError])
274
293
 
275
294
  return () => {
276
295
  this.#subscribers.delete(subscriptionId)
@@ -319,10 +338,18 @@ export class ShapeStream<T extends Row = Row>
319
338
  return !this.isUpToDate
320
339
  }
321
340
 
322
- #publish(messages: Message<T>[]) {
323
- this.#subscribers.forEach(([subscriber, _]) => {
324
- subscriber.process(messages)
325
- })
341
+ async #publish(messages: Message<T>[]): Promise<void> {
342
+ await Promise.all(
343
+ Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
344
+ try {
345
+ await callback(messages)
346
+ } catch (err) {
347
+ queueMicrotask(() => {
348
+ throw err
349
+ })
350
+ }
351
+ })
352
+ )
326
353
  }
327
354
 
328
355
  #sendErrorToSubscribers(error: Error) {
@@ -356,7 +383,7 @@ export class ShapeStream<T extends Row = Row>
356
383
  }
357
384
  }
358
385
 
359
- function validateOptions(options: Partial<ShapeStreamOptions>): void {
386
+ function validateOptions<T>(options: Partial<ShapeStreamOptions<T>>): void {
360
387
  if (!options.url) {
361
388
  throw new Error(`Invalid shape option. It must provide the url`)
362
389
  }
package/src/constants.ts CHANGED
@@ -1,6 +1,7 @@
1
- export const SHAPE_ID_HEADER = `x-electric-shape-id`
2
- export const CHUNK_LAST_OFFSET_HEADER = `x-electric-chunk-last-offset`
3
- export const SHAPE_SCHEMA_HEADER = `x-electric-schema`
1
+ export const SHAPE_ID_HEADER = `electric-shape-id`
2
+ export const CHUNK_LAST_OFFSET_HEADER = `electric-chunk-last-offset`
3
+ export const CHUNK_UP_TO_DATE_HEADER = `electric-chunk-up-to-date`
4
+ export const SHAPE_SCHEMA_HEADER = `electric-schema`
4
5
  export const SHAPE_ID_QUERY_PARAM = `shape_id`
5
6
  export const OFFSET_QUERY_PARAM = `offset`
6
7
  export const WHERE_QUERY_PARAM = `where`
package/src/fetch.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import {
2
+ CHUNK_LAST_OFFSET_HEADER,
3
+ CHUNK_UP_TO_DATE_HEADER,
4
+ LIVE_QUERY_PARAM,
5
+ OFFSET_QUERY_PARAM,
6
+ SHAPE_ID_HEADER,
7
+ SHAPE_ID_QUERY_PARAM,
8
+ } from './constants'
1
9
  import { FetchError, FetchBackoffAbortError } from './error'
2
10
 
3
11
  export interface BackoffOptions {
@@ -77,3 +85,188 @@ export function createFetchWithBackoff(
77
85
  }
78
86
  }
79
87
  }
88
+
89
+ interface ChunkPrefetchOptions {
90
+ maxChunksToPrefetch: number
91
+ }
92
+
93
+ const ChunkPrefetchDefaults = {
94
+ maxChunksToPrefetch: 2,
95
+ }
96
+
97
+ /**
98
+ * Creates a fetch client that prefetches subsequent log chunks for
99
+ * consumption by the shape stream without waiting for the chunk bodies
100
+ * themselves to be loaded.
101
+ *
102
+ * @param fetchClient the client to wrap
103
+ * @param prefetchOptions options to configure prefetching
104
+ * @returns wrapped client with prefetch capabilities
105
+ */
106
+ export function createFetchWithChunkBuffer(
107
+ fetchClient: typeof fetch,
108
+ prefetchOptions: ChunkPrefetchOptions = ChunkPrefetchDefaults
109
+ ): typeof fetch {
110
+ const { maxChunksToPrefetch } = prefetchOptions
111
+
112
+ let prefetchQueue: PrefetchQueue
113
+
114
+ const prefetchClient = async (...args: Parameters<typeof fetchClient>) => {
115
+ const url = args[0].toString()
116
+
117
+ // try to consume from the prefetch queue first, and if request is
118
+ // not present abort the prefetch queue as it must no longer be valid
119
+ const prefetchedRequest = prefetchQueue?.consume(...args)
120
+ if (prefetchedRequest) {
121
+ return prefetchedRequest
122
+ }
123
+
124
+ prefetchQueue?.abort()
125
+
126
+ // perform request and fire off prefetch queue if request is eligible
127
+ const response = await fetchClient(...args)
128
+ const nextUrl = getNextChunkUrl(url, response)
129
+ if (nextUrl) {
130
+ prefetchQueue = new PrefetchQueue({
131
+ fetchClient,
132
+ maxPrefetchedRequests: maxChunksToPrefetch,
133
+ url: nextUrl,
134
+ requestInit: args[1],
135
+ })
136
+ }
137
+
138
+ return response
139
+ }
140
+
141
+ return prefetchClient
142
+ }
143
+
144
+ class PrefetchQueue {
145
+ readonly #fetchClient: typeof fetch
146
+ readonly #maxPrefetchedRequests: number
147
+ readonly #prefetchQueue = new Map<
148
+ string,
149
+ [Promise<Response>, AbortController]
150
+ >()
151
+ #queueHeadUrl: string | void
152
+ #queueTailUrl: string
153
+
154
+ constructor(options: {
155
+ url: Parameters<typeof fetch>[0]
156
+ requestInit: Parameters<typeof fetch>[1]
157
+ maxPrefetchedRequests: number
158
+ fetchClient?: typeof fetch
159
+ }) {
160
+ this.#fetchClient =
161
+ options.fetchClient ??
162
+ ((...args: Parameters<typeof fetch>) => fetch(...args))
163
+ this.#maxPrefetchedRequests = options.maxPrefetchedRequests
164
+ this.#queueHeadUrl = options.url.toString()
165
+ this.#queueTailUrl = this.#queueHeadUrl
166
+ this.#prefetch(options.url, options.requestInit)
167
+ }
168
+
169
+ abort(): void {
170
+ this.#prefetchQueue.forEach(([_, aborter]) => aborter.abort())
171
+ }
172
+
173
+ consume(...args: Parameters<typeof fetch>): Promise<Response> | void {
174
+ const url = args[0].toString()
175
+
176
+ const request = this.#prefetchQueue.get(url)?.[0]
177
+ // only consume if request is in queue and is the queue "head"
178
+ // if request is in the queue but not the head, the queue is being
179
+ // consumed out of order and should be restarted
180
+ if (!request || url !== this.#queueHeadUrl) return
181
+ this.#prefetchQueue.delete(url)
182
+
183
+ // fire off new prefetch since request has been consumed
184
+ request
185
+ .then((response) => {
186
+ const nextUrl = getNextChunkUrl(url, response)
187
+ this.#queueHeadUrl = nextUrl
188
+ if (!this.#prefetchQueue.has(this.#queueTailUrl)) {
189
+ this.#prefetch(this.#queueTailUrl, args[1])
190
+ }
191
+ })
192
+ .catch(() => {})
193
+
194
+ return request
195
+ }
196
+
197
+ #prefetch(...args: Parameters<typeof fetch>): void {
198
+ const url = args[0].toString()
199
+
200
+ // only prefetch when queue is not full
201
+ if (this.#prefetchQueue.size >= this.#maxPrefetchedRequests) return
202
+
203
+ // initialize aborter per request, to avoid aborting consumed requests that
204
+ // are still streaming their bodies to the consumer
205
+ const aborter = new AbortController()
206
+
207
+ try {
208
+ const request = this.#fetchClient(url, {
209
+ ...(args[1] ?? {}),
210
+ signal: chainAborter(aborter, args[1]?.signal),
211
+ })
212
+ this.#prefetchQueue.set(url, [request, aborter])
213
+ request
214
+ .then((response) => {
215
+ // only keep prefetching if response chain is uninterrupted
216
+ if (!response.ok || aborter.signal.aborted) return
217
+
218
+ const nextUrl = getNextChunkUrl(url, response)
219
+
220
+ // only prefetch when there is a next URL
221
+ if (!nextUrl || nextUrl === url) return
222
+
223
+ this.#queueTailUrl = nextUrl
224
+ return this.#prefetch(nextUrl, args[1])
225
+ })
226
+ .catch(() => {})
227
+ } catch (_) {
228
+ // ignore prefetch errors
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Generate the next chunk's URL if the url and response are valid
235
+ */
236
+ function getNextChunkUrl(url: string, res: Response): string | void {
237
+ const shapeId = res.headers.get(SHAPE_ID_HEADER)
238
+ const lastOffset = res.headers.get(CHUNK_LAST_OFFSET_HEADER)
239
+ const isUpToDate = res.headers.has(CHUNK_UP_TO_DATE_HEADER)
240
+
241
+ // only prefetch if shape ID and offset for next chunk are available, and
242
+ // response is not already up-to-date
243
+ if (!shapeId || !lastOffset || isUpToDate) return
244
+
245
+ const nextUrl = new URL(url)
246
+
247
+ // don't prefetch live requests, rushing them will only
248
+ // potentially miss more recent data
249
+ if (nextUrl.searchParams.has(LIVE_QUERY_PARAM)) return
250
+
251
+ nextUrl.searchParams.set(SHAPE_ID_QUERY_PARAM, shapeId)
252
+ nextUrl.searchParams.set(OFFSET_QUERY_PARAM, lastOffset)
253
+ return nextUrl.toString()
254
+ }
255
+
256
+ /**
257
+ * Chains an abort controller on an optional source signal's
258
+ * aborted state - if the source signal is aborted, the provided abort
259
+ * controller will also abort
260
+ */
261
+ function chainAborter(
262
+ aborter: AbortController,
263
+ sourceSignal?: AbortSignal
264
+ ): AbortSignal {
265
+ if (!sourceSignal) return aborter.signal
266
+ if (sourceSignal.aborted) aborter.abort()
267
+ else
268
+ sourceSignal.addEventListener(`abort`, () => aborter.abort(), {
269
+ once: true,
270
+ })
271
+ return aborter.signal
272
+ }
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
  }
package/src/queue.ts DELETED
@@ -1,62 +0,0 @@
1
- import { MaybePromise } from './types'
2
-
3
- function isThenable(value: MaybePromise<void>): value is Promise<void> {
4
- return (
5
- !!value &&
6
- typeof value === `object` &&
7
- `then` in value &&
8
- typeof value.then === `function`
9
- )
10
- }
11
-
12
- /**
13
- * Processes messages asynchronously in order.
14
- */
15
- export class AsyncProcessingQueue {
16
- #processingChain: MaybePromise<void> = undefined
17
-
18
- public process(callback: () => MaybePromise<void>): MaybePromise<void> {
19
- this.#processingChain = isThenable(this.#processingChain)
20
- ? this.#processingChain.then(callback)
21
- : callback()
22
- return this.#processingChain
23
- }
24
-
25
- public async waitForProcessing(): Promise<void> {
26
- let currentChain: MaybePromise<void>
27
- do {
28
- currentChain = this.#processingChain
29
- await currentChain
30
- } while (this.#processingChain !== currentChain)
31
- }
32
- }
33
-
34
- export interface MessageProcessorInterface<T> {
35
- process(messages: T): MaybePromise<void>
36
- waitForProcessing(): Promise<void>
37
- }
38
-
39
- /**
40
- * Receives messages, puts them on a queue and processes
41
- * them synchronously or asynchronously by passing to a
42
- * registered callback function.
43
- *
44
- * @constructor
45
- * @param {(message: T) => MaybePromise<void>} callback function
46
- */
47
- export class MessageProcessor<T> implements MessageProcessorInterface<T> {
48
- readonly #queue = new AsyncProcessingQueue()
49
- readonly #callback: (messages: T) => MaybePromise<void>
50
-
51
- constructor(callback: (messages: T) => MaybePromise<void>) {
52
- this.#callback = callback
53
- }
54
-
55
- public process(messages: T): void {
56
- this.#queue.process(() => this.#callback(messages))
57
- }
58
-
59
- public async waitForProcessing(): Promise<void> {
60
- await this.#queue.waitForProcessing()
61
- }
62
- }