@steadybrew/postgresjs 4.3.0 → 4.4.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/subscribe.js CHANGED
@@ -1,4 +1,9 @@
1
+ import { callback } from './errors.js'
2
+ import { defaultBackoff, backoffSeconds } from './connection.js'
3
+
1
4
  const noop = () => { /* noop */ }
5
+ , commands = { 68: 'delete', 73: 'insert', 85: 'update' }
6
+ , statusInterval = 1000
2
7
 
3
8
  export default function Subscribe(postgres, options) {
4
9
  const subscribers = new Map()
@@ -7,6 +12,9 @@ export default function Subscribe(postgres, options) {
7
12
 
8
13
  let connection
9
14
  , stream
15
+ , timer
16
+ , inflight = false
17
+ , retries = 0
10
18
  , ended = false
11
19
 
12
20
  const sql = subscribe.sql = postgres({
@@ -21,13 +29,12 @@ export default function Subscribe(postgres, options) {
21
29
  ...options.connection,
22
30
  replication: 'database'
23
31
  },
24
- onclose: async function() {
32
+ onclose: function() {
25
33
  if (ended)
26
34
  return
27
35
  stream = null
28
36
  state.pid = state.secret = undefined
29
- connected(await init(sql, slot, options.publications))
30
- subscribers.forEach(event => event.forEach(({ onsubscribe }) => onsubscribe()))
37
+ inflight || timer || !connection || connect().catch(noop)
31
38
  },
32
39
  no_subscribe: true
33
40
  })
@@ -37,6 +44,9 @@ export default function Subscribe(postgres, options) {
37
44
 
38
45
  sql.end = async() => {
39
46
  ended = true
47
+ clearTimeout(timer)
48
+ timer = null
49
+ connection && await connection.catch(noop)
40
50
  stream && (await new Promise(r => (stream.once('close', r), stream.end())))
41
51
  return end()
42
52
  }
@@ -48,34 +58,74 @@ export default function Subscribe(postgres, options) {
48
58
 
49
59
  return subscribe
50
60
 
51
- async function subscribe(event, fn, onsubscribe = noop, onerror = noop) {
61
+ async function subscribe(event, fn, onsubscribe = noop, onerror) {
52
62
  event = parseEvent(event)
53
63
 
54
- if (!connection)
55
- connection = init(sql, slot, options.publications)
64
+ const subscriber = { fn, onsubscribe, onerror }
56
65
 
57
- const subscriber = { fn, onsubscribe }
58
- const fns = subscribers.has(event)
59
- ? subscribers.get(event).add(subscriber)
60
- : subscribers.set(event, new Set([subscriber])).get(event)
66
+ return (connection || connect()).then(() => {
67
+ const fns = subscribers.has(event)
68
+ ? subscribers.get(event).add(subscriber)
69
+ : subscribers.set(event, new Set([subscriber])).get(event)
61
70
 
62
- const unsubscribe = () => {
63
- fns.delete(subscriber)
64
- fns.size === 0 && subscribers.delete(event)
65
- }
71
+ const unsubscribe = () => {
72
+ fns.delete(subscriber)
73
+ fns.size === 0 && subscribers.delete(event)
74
+ subscribers.size === 0 && (clearTimeout(timer), timer = null)
75
+ }
66
76
 
67
- return connection.then(x => {
68
- connected(x)
69
- onsubscribe()
70
- stream && stream.on('error', onerror)
77
+ callback(onsubscribe)
71
78
  return { unsubscribe, state, sql }
72
79
  })
73
80
  }
74
81
 
75
- function connected(x) {
76
- stream = x.stream
77
- state.pid = x.state.pid
78
- state.secret = x.state.secret
82
+ function connect() {
83
+ clearTimeout(timer)
84
+ timer = null
85
+ inflight = true
86
+ const resume = subscribers.size > 0
87
+ const current = connection = init(sql, slot, options.publications).then(x => {
88
+ stream = x.stream
89
+ state.pid = x.state.pid
90
+ state.secret = x.state.secret
91
+ })
92
+
93
+ current.then(
94
+ () => {
95
+ inflight = false
96
+ retries = 0
97
+ resume && !ended && subscribers.forEach(event => event.forEach(({ onsubscribe }) => callback(onsubscribe)))
98
+ },
99
+ error => {
100
+ inflight = false
101
+ connection === current && (connection = null)
102
+ if (ended || subscribers.size === 0)
103
+ return
104
+ reportConnection(error)
105
+ timer = setTimeout(() => connect().catch(noop), retryDelay(retries++))
106
+ }
107
+ )
108
+
109
+ return current
110
+ }
111
+
112
+ function retryDelay(n) {
113
+ let seconds = backoffSeconds(sql.options.backoff, n)
114
+ seconds = Number.isFinite(seconds) ? seconds : defaultBackoff(n)
115
+ return Math.min(Math.max(seconds * 1000, 100), 2 ** 31 - 1)
116
+ }
117
+
118
+ function reportConnection(error) {
119
+ console.error('Unexpected error during logical streaming - reconnecting', error) // eslint-disable-line
120
+ subscribers.forEach(fns => fns.forEach(({ onerror }) => callback(onerror, error)))
121
+ }
122
+
123
+ function report(error, match) {
124
+ let uncaught = false
125
+ subscribers.forEach((fns, event) => match(event) && fns.forEach(({ onerror }) =>
126
+ onerror ? callback(onerror, error) : uncaught = true
127
+ ))
128
+ uncaught && callback(() => { throw error })
79
129
  }
80
130
 
81
131
  async function init(sql, slot, publications) {
@@ -99,18 +149,22 @@ export default function Subscribe(postgres, options) {
99
149
  }
100
150
 
101
151
  stream.on('data', data)
102
- stream.on('error', error)
152
+ stream.on('error', reportConnection)
103
153
  stream.on('close', sql.close)
104
154
 
105
- return { stream, state: xs.state }
155
+ // A walsender can miss the wakeup for a commit and sleep until its timeout; a status update wakes it.
156
+ const status = setInterval(() => stream.writable && pong(), statusInterval)
157
+ stream.on('close', () => clearInterval(status))
106
158
 
107
- function error(e) {
108
- console.error('Unexpected error during logical streaming - reconnecting', e) // eslint-disable-line
109
- }
159
+ return { stream, state: xs.state }
110
160
 
111
161
  function data(x) {
112
162
  if (x[0] === 0x77) {
113
- parse(x.subarray(25), state, sql.options.parsers, handle, options.transform)
163
+ try {
164
+ parse(x.subarray(25), state, sql.options.parsers, handle, options.transform)
165
+ } catch (error) {
166
+ skip(x.subarray(25), error)
167
+ }
114
168
  } else if (x[0] === 0x6b && x[17]) {
115
169
  state.lsn = x.subarray(1, 9)
116
170
  pong()
@@ -119,12 +173,26 @@ export default function Subscribe(postgres, options) {
119
173
 
120
174
  function handle(a, b) {
121
175
  const path = b.relation.schema + '.' + b.relation.table
176
+ , key = b.relation.keys.length && '=' + b.relation.keys.map(x => a[x.name])
122
177
  call('*', a, b)
123
178
  call('*:' + path, a, b)
124
- b.relation.keys.length && call('*:' + path + '=' + b.relation.keys.map(x => a[x.name]), a, b)
179
+ key && call('*:' + path + key, a, b)
125
180
  call(b.command, a, b)
126
181
  call(b.command + ':' + path, a, b)
127
- b.relation.keys.length && call(b.command + ':' + path + '=' + b.relation.keys.map(x => a[x.name]), a, b)
182
+ key && call(b.command + ':' + path + key, a, b)
183
+ }
184
+
185
+ function skip(x, error) {
186
+ const command = commands[x[0]]
187
+ , relation = command && state[x.readUInt32BE(1)]
188
+
189
+ if (!relation)
190
+ throw error
191
+
192
+ const path = relation.schema + '.' + relation.table
193
+ , events = ['*', '*:' + path, command, command + ':' + path]
194
+
195
+ report(error, event => events.includes(event.split('=')[0]))
128
196
  }
129
197
 
130
198
  function pong() {
@@ -137,7 +205,7 @@ export default function Subscribe(postgres, options) {
137
205
  }
138
206
 
139
207
  function call(x, a, b) {
140
- subscribers.has(x) && subscribers.get(x).forEach(({ fn }) => fn(a, b, x))
208
+ subscribers.has(x) && subscribers.get(x).forEach(({ fn }) => callback(fn, a, b, x))
141
209
  }
142
210
  }
143
211
 
@@ -162,19 +230,24 @@ function parse(x, state, parsers, handle, transform) {
162
230
  let columnIndex = 0
163
231
  , column
164
232
 
165
- while (i < x.length) {
166
- column = r.columns[columnIndex++] = {
167
- key: x[i++],
168
- name: transform.column.from
169
- ? transform.column.from(x.toString('utf8', i, i = x.indexOf(0, i)))
170
- : x.toString('utf8', i, i = x.indexOf(0, i)),
171
- type: x.readUInt32BE(i += 1),
172
- parser: parsers[x.readUInt32BE(i)],
173
- atttypmod: x.readUInt32BE(i += 4)
233
+ try {
234
+ while (i < x.length) {
235
+ column = r.columns[columnIndex++] = {
236
+ key: x[i++],
237
+ name: transform.column.from
238
+ ? transform.column.from(x.toString('utf8', i, i = x.indexOf(0, i)))
239
+ : x.toString('utf8', i, i = x.indexOf(0, i)),
240
+ type: x.readUInt32BE(i += 1),
241
+ parser: parsers[x.readUInt32BE(i)],
242
+ atttypmod: x.readUInt32BE(i += 4)
243
+ }
244
+
245
+ column.key && r.keys.push(column)
246
+ i += 4
174
247
  }
175
-
176
- column.key && r.keys.push(column)
177
- i += 4
248
+ } catch (error) {
249
+ r.error = error
250
+ r.failed = true
178
251
  }
179
252
  },
180
253
  Y: () => { /* noop */ }, // Type
@@ -185,7 +258,7 @@ function parse(x, state, parsers, handle, transform) {
185
258
  },
186
259
  I: x => { // Insert
187
260
  let i = 1
188
- const relation = state[x.readUInt32BE(i)]
261
+ const relation = decodable(state[x.readUInt32BE(i)])
189
262
  const { row } = tuples(x, relation.columns, i += 7, transform)
190
263
 
191
264
  handle(row, {
@@ -195,7 +268,7 @@ function parse(x, state, parsers, handle, transform) {
195
268
  },
196
269
  D: x => { // Delete
197
270
  let i = 1
198
- const relation = state[x.readUInt32BE(i)]
271
+ const relation = decodable(state[x.readUInt32BE(i)])
199
272
  i += 4
200
273
  const key = x[i] === 75
201
274
  handle(key || x[i] === 79
@@ -209,7 +282,7 @@ function parse(x, state, parsers, handle, transform) {
209
282
  },
210
283
  U: x => { // Update
211
284
  let i = 1
212
- const relation = state[x.readUInt32BE(i)]
285
+ const relation = decodable(state[x.readUInt32BE(i)])
213
286
  i += 4
214
287
  const key = x[i] === 75
215
288
  const xs = key || x[i] === 79
@@ -232,6 +305,13 @@ function parse(x, state, parsers, handle, transform) {
232
305
  }).reduce(char, {})[x[0]](x)
233
306
  }
234
307
 
308
+ function decodable(relation) {
309
+ if (relation.failed)
310
+ throw relation.error
311
+
312
+ return relation
313
+ }
314
+
235
315
  function tuples(x, columns, xi, transform) {
236
316
  let type
237
317
  , column
@@ -265,7 +345,7 @@ function tuples(x, columns, xi, transform) {
265
345
  }
266
346
 
267
347
  function parseEvent(x) {
268
- const xs = x.match(/^(\*|insert|update|delete)?:?([^.]+?\.?[^=]+)?=?(.+)?/i) || []
348
+ const xs = x.match(/^(\*|insert|update|delete)?:?([^.=]+(?:\.[^=]+)?)?=?(.+)?/i) || []
269
349
 
270
350
  if (!xs)
271
351
  throw new Error('Malformed subscribe pattern: ' + x)
package/types/index.d.ts CHANGED
@@ -67,14 +67,11 @@ interface BaseOptions<T extends Record<string, postgres.PostgresType>> {
67
67
  * @default true
68
68
  */
69
69
  prepare: boolean;
70
- /**
71
- * Called when a notice is received
72
- * @default console.log
73
- */
74
- onnotice: (notice: postgres.Notice) => void;
75
- /** (key; value) when a server param change */
70
+ /** Called when a notice is received, console.log by default and false to ignore them; if it throws, the error is rethrown as an uncaught exception */
71
+ onnotice: ((notice: postgres.Notice) => void) | false;
72
+ /** (key; value) when a server param change; if it throws, the error is rethrown as an uncaught exception */
76
73
  onparameter: (key: string, value: any) => void;
77
- /** Is called with (connection; query; parameters) */
74
+ /** Is called with (connection; query; parameters); if it throws, the error is rethrown as an uncaught exception and the query still runs */
78
75
  debug: boolean | ((connection: number, query: string, parameters: any[], paramTypes: any[]) => void);
79
76
  /** Transform hooks */
80
77
  transform: {
@@ -118,6 +115,7 @@ interface BaseOptions<T extends Record<string, postgres.PostgresType>> {
118
115
  * @default 'alltables'
119
116
  */
120
117
  publications: string
118
+ /** Called with the connection id each time a connection closes; if it throws, the error is rethrown as an uncaught exception */
121
119
  onclose: (connId: number) => void;
122
120
  /** Called with the connection id each time a connection returns to the pool idle and its idle timer starts */
123
121
  onidle: (connId: number) => void;
@@ -463,7 +461,8 @@ declare namespace postgres {
463
461
  | 'COPY_IN_PROGRESS'
464
462
  | 'INVALID_TRANSACTION_NAME'
465
463
  | 'TARGET_SESSION_ATTRS'
466
- | 'UNSAFE_TRANSACTION';
464
+ | 'UNSAFE_TRANSACTION'
465
+ | 'TRANSACTION_ROLLED_BACK';
467
466
  message: string;
468
467
  }
469
468
 
@@ -745,7 +744,8 @@ declare namespace postgres {
745
744
 
746
745
  listen(channel: string, onnotify: (value: string) => void, onlisten?: (() => void) | undefined): ListenRequest;
747
746
 
748
- subscribe(event: string, cb: (row: Row | null, info: ReplicationEvent) => void, onsubscribe?: (() => void), onerror?: (() => any)): Promise<SubscriptionHandle>;
747
+ /** `onerror` receives replication connection errors and errors thrown while a change is decoded; such a change is skipped. */
748
+ subscribe(event: string, cb: (row: Row | null, info: ReplicationEvent) => void, onsubscribe?: (() => void), onerror?: ((error: Error) => void)): Promise<SubscriptionHandle>;
749
749
 
750
750
  largeObject(oid?: number | undefined, /** @default 0x00020000 | 0x00040000 */ mode?: number | undefined): Promise<LargeObject>;
751
751