UNPKG

@ogc/postgres-tmp

Version:

Fastest full featured PostgreSQL client for Node.js

279 lines (235 loc) 7.66 kB
import { Buffer } from 'node:buffer' const noop = () => { /* noop */ } export default function Subscribe(postgres, options) { const subscribers = new Map() , slot = 'postgresjs_' + Math.random().toString(36).slice(2) , state = {} let connection , stream , ended = false const sql = subscribe.sql = postgres({ ...options, transform: { column: {}, value: {}, row: {} }, max: 1, fetch_types: false, idle_timeout: null, max_lifetime: null, connection: { ...options.connection, replication: 'database' }, onclose: async function() { if (ended) return stream = null state.pid = state.secret = undefined connected(await init(sql, slot, options.publications)) subscribers.forEach(event => event.forEach(({ onsubscribe }) => onsubscribe())) }, no_subscribe: true }) const end = sql.end , close = sql.close sql.end = async() => { ended = true stream && (await new Promise(r => (stream.once('close', r), stream.end()))) return end() } sql.close = async() => { stream && (await new Promise(r => (stream.once('close', r), stream.end()))) return close() } return subscribe async function subscribe(event, fn, onsubscribe = noop, onerror = noop) { event = parseEvent(event) if (!connection) connection = init(sql, slot, options.publications) const subscriber = { fn, onsubscribe } const fns = subscribers.has(event) ? subscribers.get(event).add(subscriber) : subscribers.set(event, new Set([subscriber])).get(event) const unsubscribe = () => { fns.delete(subscriber) fns.size === 0 && subscribers.delete(event) } return connection.then(x => { connected(x) onsubscribe() stream && stream.on('error', onerror) return { unsubscribe, state, sql } }) } function connected(x) { stream = x.stream state.pid = x.state.pid state.secret = x.state.secret } async function init(sql, slot, publications) { if (!publications) throw new Error('Missing publication names') const xs = await sql.unsafe( `CREATE_REPLICATION_SLOT ${ slot } TEMPORARY LOGICAL pgoutput NOEXPORT_SNAPSHOT` ) const [x] = xs const stream = await sql.unsafe( `START_REPLICATION SLOT ${ slot } LOGICAL ${ x.consistent_point } (proto_version '1', publication_names '${ publications }')` ).writable() const state = { lsn: Buffer.concat(x.consistent_point.split('/').map(x => Buffer.from(('00000000' + x).slice(-8), 'hex'))) } stream.on('data', data) stream.on('error', error) stream.on('close', sql.close) return { stream, state: xs.state } function error(e) { console.error('Unexpected error during logical streaming - reconnecting', e) // eslint-disable-line } function data(x) { if (x[0] === 0x77) { parse(x.subarray(25), state, sql.options.parsers, handle, options.transform) } else if (x[0] === 0x6b && x[17]) { state.lsn = x.subarray(1, 9) pong() } } function handle(a, b) { const path = b.relation.schema + '.' + b.relation.table call('*', a, b) call('*:' + path, a, b) b.relation.keys.length && call('*:' + path + '=' + b.relation.keys.map(x => a[x.name]), a, b) call(b.command, a, b) call(b.command + ':' + path, a, b) b.relation.keys.length && call(b.command + ':' + path + '=' + b.relation.keys.map(x => a[x.name]), a, b) } function pong() { const x = Buffer.alloc(34) x[0] = 'r'.charCodeAt(0) x.fill(state.lsn, 1) x.writeBigInt64BE(BigInt(Date.now() - Date.UTC(2000, 0, 1)) * BigInt(1000), 25) stream.write(x) } } function call(x, a, b) { subscribers.has(x) && subscribers.get(x).forEach(({ fn }) => fn(a, b, x)) } } function Time(x) { return new Date(Date.UTC(2000, 0, 1) + Number(x / BigInt(1000))) } function parse(x, state, parsers, handle, transform) { const char = (acc, [k, v]) => (acc[k.charCodeAt(0)] = v, acc) Object.entries({ R: x => { // Relation let i = 1 const r = state[x.readUInt32BE(i)] = { schema: x.toString('utf8', i += 4, i = x.indexOf(0, i)) || 'pg_catalog', table: x.toString('utf8', i + 1, i = x.indexOf(0, i + 1)), columns: Array(x.readUInt16BE(i += 2)), keys: [] } i += 2 let columnIndex = 0 , column while (i < x.length) { column = r.columns[columnIndex++] = { key: x[i++], name: transform.column.from ? transform.column.from(x.toString('utf8', i, i = x.indexOf(0, i))) : x.toString('utf8', i, i = x.indexOf(0, i)), type: x.readUInt32BE(i += 1), parser: parsers[x.readUInt32BE(i)], atttypmod: x.readUInt32BE(i += 4) } column.key && r.keys.push(column) i += 4 } }, Y: () => { /* noop */ }, // Type O: () => { /* noop */ }, // Origin B: x => { // Begin state.date = Time(x.readBigInt64BE(9)) state.lsn = x.subarray(1, 9) }, I: x => { // Insert let i = 1 const relation = state[x.readUInt32BE(i)] const { row } = tuples(x, relation.columns, i += 7, transform) handle(row, { command: 'insert', relation }) }, D: x => { // Delete let i = 1 const relation = state[x.readUInt32BE(i)] i += 4 const key = x[i] === 75 handle(key || x[i] === 79 ? tuples(x, relation.columns, i += 3, transform).row : null , { command: 'delete', relation, key }) }, U: x => { // Update let i = 1 const relation = state[x.readUInt32BE(i)] i += 4 const key = x[i] === 75 const xs = key || x[i] === 79 ? tuples(x, relation.columns, i += 3, transform) : null xs && (i = xs.i) const { row } = tuples(x, relation.columns, i + 3, transform) handle(row, { command: 'update', relation, key, old: xs && xs.row }) }, T: () => { /* noop */ }, // Truncate, C: () => { /* noop */ } // Commit }).reduce(char, {})[x[0]](x) } function tuples(x, columns, xi, transform) { let type , column , value const row = transform.raw ? new Array(columns.length) : {} for (let i = 0; i < columns.length; i++) { type = x[xi++] column = columns[i] value = type === 110 // n ? null : type === 117 // u ? undefined : column.parser === undefined ? x.toString('utf8', xi + 4, xi += 4 + x.readUInt32BE(xi)) : column.parser.array === true ? column.parser(x.toString('utf8', xi + 5, xi += 4 + x.readUInt32BE(xi))) : column.parser(x.toString('utf8', xi + 4, xi += 4 + x.readUInt32BE(xi))) transform.raw ? (row[i] = transform.raw === true ? value : transform.value.from ? transform.value.from(value, column) : value) : (row[column.name] = transform.value.from ? transform.value.from(value, column) : value ) } return { i: xi, row: transform.row.from ? transform.row.from(row) : row } } function parseEvent(x) { const xs = x.match(/^(\*|insert|update|delete)?:?([^.]+?\.?[^=]+)?=?(.+)?/i) || [] if (!xs) throw new Error('Malformed subscribe pattern: ' + x) const [, command, path, key] = xs return (command || '*') + (path ? ':' + (path.indexOf('.') === -1 ? 'public.' + path : path) : '') + (key ? '=' + key : '') }