UNPKG

@electric-sql/pglite

Version:

PGlite is a WASM Postgres build packaged into a TypeScript client library that enables you to run Postgres in the browser, Node.js and Bun, with no need to install any other dependencies. It is only 3.7mb gzipped.

1 lines 97.4 kB
{"version":3,"sources":["../../src/live/index.ts","../../../pg-protocol/src/string-utils.ts","../../../pg-protocol/src/buffer-writer.ts","../../../pg-protocol/src/serializer.ts","../../../pg-protocol/src/buffer-reader.ts","../../../pg-protocol/src/parser.ts","../../src/types.ts","../../src/parse.ts","../../src/utils.ts"],"sourcesContent":["import type {\n Extension,\n PGliteInterface,\n Results,\n Transaction,\n} from '../interface'\nimport type {\n LiveQueryOptions,\n LiveIncrementalQueryOptions,\n LiveChangesOptions,\n LiveNamespace,\n LiveQuery,\n LiveChanges,\n Change,\n LiveQueryResults,\n} from './interface'\nimport { uuid, formatQuery, debounceMutex } from '../utils.js'\n\nexport type {\n LiveNamespace,\n LiveQuery,\n LiveChanges,\n Change,\n LiveQueryResults,\n} from './interface.js'\n\nconst MAX_RETRIES = 5\n\nconst setup = async (pg: PGliteInterface, _emscriptenOpts: any) => {\n // The notify triggers are only ever added and never removed\n // Keep track of which triggers have been added to avoid adding them multiple times\n const tableNotifyTriggersAdded = new Set<string>()\n\n const namespaceObj: LiveNamespace = {\n async query<T>(\n query: string | LiveQueryOptions<T>,\n params?: any[] | null,\n callback?: (results: Results<T>) => void,\n ) {\n let signal: AbortSignal | undefined\n let offset: number | undefined\n let limit: number | undefined\n if (typeof query !== 'string') {\n signal = query.signal\n params = query.params\n callback = query.callback\n offset = query.offset\n limit = query.limit\n query = query.query\n }\n\n // Offset and limit must be provided together\n if ((offset === undefined) !== (limit === undefined)) {\n throw new Error('offset and limit must be provided together')\n }\n\n const isWindowed = offset !== undefined && limit !== undefined\n let totalCount: number | undefined = undefined\n\n if (\n isWindowed &&\n (typeof offset !== 'number' ||\n isNaN(offset) ||\n typeof limit !== 'number' ||\n isNaN(limit))\n ) {\n throw new Error('offset and limit must be numbers')\n }\n\n let callbacks: Array<(results: Results<T>) => void> = callback\n ? [callback]\n : []\n const id = uuid().replace(/-/g, '')\n let dead = false\n\n let results: LiveQueryResults<T>\n let tables: { table_name: string; schema_name: string }[]\n\n let unsubList: Array<(tx?: Transaction) => Promise<void>>\n const init = async () => {\n await pg.transaction(async (tx) => {\n // Create a temporary view with the query\n const formattedQuery =\n params && params.length > 0\n ? await formatQuery(pg, query, params, tx)\n : query\n await tx.exec(\n `CREATE OR REPLACE TEMP VIEW live_query_${id}_view AS ${formattedQuery}`,\n )\n\n // Get the tables used in the view and add triggers to notify when they change\n tables = await getTablesForView(tx, `live_query_${id}_view`)\n await addNotifyTriggersToTables(tx, tables, tableNotifyTriggersAdded)\n\n if (isWindowed) {\n await tx.exec(`\n PREPARE live_query_${id}_get(int, int) AS\n SELECT * FROM live_query_${id}_view\n LIMIT $1 OFFSET $2;\n `)\n await tx.exec(`\n PREPARE live_query_${id}_get_total_count AS\n SELECT COUNT(*) FROM live_query_${id}_view;\n `)\n totalCount = (\n await tx.query<{ count: number }>(\n `EXECUTE live_query_${id}_get_total_count;`,\n )\n ).rows[0].count\n results = {\n ...(await tx.query<T>(\n `EXECUTE live_query_${id}_get(${limit}, ${offset});`,\n )),\n offset,\n limit,\n totalCount,\n }\n } else {\n await tx.exec(`\n PREPARE live_query_${id}_get AS\n SELECT * FROM live_query_${id}_view;\n `)\n results = await tx.query<T>(`EXECUTE live_query_${id}_get;`)\n }\n // Setup the listeners\n unsubList = await Promise.all(\n tables!.map((table) =>\n tx.listen(\n `\"table_change__${table.schema_name}__${table.table_name}\"`,\n async () => {\n refresh()\n },\n ),\n ),\n )\n })\n }\n await init()\n\n // Function to refresh the query\n const refresh = debounceMutex(\n async ({\n offset: newOffset,\n limit: newLimit,\n }: {\n offset?: number\n limit?: number\n } = {}) => {\n // We can optionally provide new offset and limit values to refresh with\n if (\n !isWindowed &&\n (newOffset !== undefined || newLimit !== undefined)\n ) {\n throw new Error(\n 'offset and limit cannot be provided for non-windowed queries',\n )\n }\n if (\n (newOffset &&\n (typeof newOffset !== 'number' || isNaN(newOffset))) ||\n (newLimit && (typeof newLimit !== 'number' || isNaN(newLimit)))\n ) {\n throw new Error('offset and limit must be numbers')\n }\n offset = newOffset ?? offset\n limit = newLimit ?? limit\n\n const run = async (count = 0) => {\n if (callbacks.length === 0) {\n return\n }\n try {\n if (isWindowed) {\n // For a windowed query we defer the refresh of the total count until\n // after we have returned the results with the old total count. This\n // is due to a count(*) being a fairly slow query and we want to update\n // the rows on screen as quickly as possible.\n results = {\n ...(await pg.query<T>(\n `EXECUTE live_query_${id}_get(${limit}, ${offset});`,\n )),\n offset,\n limit,\n totalCount, // This is the old total count\n }\n } else {\n results = await pg.query<T>(`EXECUTE live_query_${id}_get;`)\n }\n } catch (e) {\n const msg = (e as Error).message\n if (\n msg.startsWith(`prepared statement \"live_query_${id}`) &&\n msg.endsWith('does not exist')\n ) {\n // If the prepared statement does not exist, reset and try again\n // This can happen if using the multi-tab worker\n if (count > MAX_RETRIES) {\n throw e\n }\n await init()\n run(count + 1)\n } else {\n throw e\n }\n }\n\n runResultCallbacks(callbacks, results)\n\n // Update the total count\n // If the total count has changed, refresh the query\n if (isWindowed) {\n const newTotalCount = (\n await pg.query<{ count: number }>(\n `EXECUTE live_query_${id}_get_total_count;`,\n )\n ).rows[0].count\n if (newTotalCount !== totalCount) {\n // The total count has changed, refresh the query\n totalCount = newTotalCount\n refresh()\n }\n }\n }\n await run()\n },\n )\n\n // Function to subscribe to the query\n const subscribe = (callback: (results: Results<T>) => void) => {\n if (dead) {\n throw new Error(\n 'Live query is no longer active and cannot be subscribed to',\n )\n }\n callbacks.push(callback)\n }\n\n // Function to unsubscribe from the query\n // If no function is provided, unsubscribe all callbacks\n // If there are no callbacks, unsubscribe from the notify triggers\n const unsubscribe = async (callback?: (results: Results<T>) => void) => {\n if (callback) {\n callbacks = callbacks.filter((callback) => callback !== callback)\n } else {\n callbacks = []\n }\n if (callbacks.length === 0 && !dead) {\n dead = true\n await pg.transaction(async (tx) => {\n await Promise.all(unsubList.map((unsub) => unsub(tx)))\n await tx.exec(`\n DROP VIEW IF EXISTS live_query_${id}_view;\n DEALLOCATE live_query_${id}_get;\n `)\n })\n }\n }\n\n // If the signal has already been aborted, unsubscribe\n if (signal?.aborted) {\n await unsubscribe()\n } else {\n // Add an event listener to unsubscribe if the signal is aborted\n signal?.addEventListener(\n 'abort',\n () => {\n unsubscribe()\n },\n { once: true },\n )\n }\n\n // Run the callback with the initial results\n runResultCallbacks(callbacks, results!)\n\n // Return the initial results\n return {\n initialResults: results!,\n subscribe,\n unsubscribe,\n refresh,\n } satisfies LiveQuery<T>\n },\n\n async changes<T>(\n query: string | LiveChangesOptions<T>,\n params?: any[] | null,\n key?: string,\n callback?: (changes: Array<Change<T>>) => void,\n ) {\n let signal: AbortSignal | undefined\n if (typeof query !== 'string') {\n signal = query.signal\n params = query.params\n key = query.key\n callback = query.callback\n query = query.query\n }\n if (!key) {\n throw new Error('key is required for changes queries')\n }\n let callbacks: Array<(changes: Array<Change<T>>) => void> = callback\n ? [callback]\n : []\n const id = uuid().replace(/-/g, '')\n let dead = false\n\n let tables: { table_name: string; schema_name: string }[]\n let stateSwitch: 1 | 2 = 1\n let changes: Results<Change<T>>\n\n let unsubList: Array<(tx?: Transaction) => Promise<void>>\n\n const init = async () => {\n await pg.transaction(async (tx) => {\n // Create a temporary view with the query\n const formattedQuery = await formatQuery(pg, query, params, tx)\n await tx.query(\n `CREATE OR REPLACE TEMP VIEW live_query_${id}_view AS ${formattedQuery}`,\n )\n\n // Get the tables used in the view and add triggers to notify when they change\n tables = await getTablesForView(tx, `live_query_${id}_view`)\n await addNotifyTriggersToTables(tx, tables, tableNotifyTriggersAdded)\n\n // Get the columns of the view\n const columns = [\n ...(\n await tx.query<any>(`\n SELECT column_name, data_type, udt_name\n FROM information_schema.columns \n WHERE table_name = 'live_query_${id}_view'\n `)\n ).rows,\n { column_name: '__after__', data_type: 'integer' },\n ]\n\n // Init state tables as empty temp table\n await tx.exec(`\n CREATE TEMP TABLE live_query_${id}_state1 (LIKE live_query_${id}_view INCLUDING ALL);\n CREATE TEMP TABLE live_query_${id}_state2 (LIKE live_query_${id}_view INCLUDING ALL);\n `)\n\n // Create Diff views and prepared statements\n for (const curr of [1, 2]) {\n const prev = curr === 1 ? 2 : 1\n await tx.exec(`\n PREPARE live_query_${id}_diff${curr} AS\n WITH\n prev AS (SELECT LAG(\"${key}\") OVER () as __after__, * FROM live_query_${id}_state${prev}),\n curr AS (SELECT LAG(\"${key}\") OVER () as __after__, * FROM live_query_${id}_state${curr}),\n data_diff AS (\n -- INSERT operations: Include all columns\n SELECT \n 'INSERT' AS __op__,\n ${columns\n .map(\n ({ column_name }) =>\n `curr.\"${column_name}\" AS \"${column_name}\"`,\n )\n .join(',\\n')},\n ARRAY[]::text[] AS __changed_columns__\n FROM curr\n LEFT JOIN prev ON curr.${key} = prev.${key}\n WHERE prev.${key} IS NULL\n UNION ALL\n -- DELETE operations: Include only the primary key\n SELECT \n 'DELETE' AS __op__,\n ${columns\n .map(({ column_name, data_type, udt_name }) => {\n if (column_name === key) {\n return `prev.\"${column_name}\" AS \"${column_name}\"`\n } else {\n return `NULL${data_type === 'USER-DEFINED' ? `::${udt_name}` : ``} AS \"${column_name}\"`\n }\n })\n .join(',\\n')},\n ARRAY[]::text[] AS __changed_columns__\n FROM prev\n LEFT JOIN curr ON prev.${key} = curr.${key}\n WHERE curr.${key} IS NULL\n UNION ALL\n -- UPDATE operations: Include only changed columns\n SELECT \n 'UPDATE' AS __op__,\n ${columns\n .map(({ column_name, data_type, udt_name }) =>\n column_name === key\n ? `curr.\"${column_name}\" AS \"${column_name}\"`\n : `CASE \n WHEN curr.\"${column_name}\" IS DISTINCT FROM prev.\"${column_name}\" \n THEN curr.\"${column_name}\"\n ELSE NULL${data_type === 'USER-DEFINED' ? `::${udt_name}` : ``}\n END AS \"${column_name}\"`,\n )\n .join(',\\n')},\n ARRAY(SELECT unnest FROM unnest(ARRAY[${columns\n .filter(({ column_name }) => column_name !== key)\n .map(\n ({ column_name }) =>\n `CASE\n WHEN curr.\"${column_name}\" IS DISTINCT FROM prev.\"${column_name}\" \n THEN '${column_name}' \n ELSE NULL \n END`,\n )\n .join(\n ', ',\n )}]) WHERE unnest IS NOT NULL) AS __changed_columns__\n FROM curr\n INNER JOIN prev ON curr.${key} = prev.${key}\n WHERE NOT (curr IS NOT DISTINCT FROM prev)\n )\n SELECT * FROM data_diff;\n `)\n }\n\n // Setup the listeners\n unsubList = await Promise.all(\n tables!.map((table) =>\n tx.listen(\n `\"table_change__${table.schema_name}__${table.table_name}\"`,\n async () => {\n refresh()\n },\n ),\n ),\n )\n })\n }\n\n await init()\n\n const refresh = debounceMutex(async () => {\n if (callbacks.length === 0 && changes) {\n return\n }\n let reset = false\n for (let i = 0; i < 5; i++) {\n try {\n await pg.transaction(async (tx) => {\n // Populate the state table\n await tx.exec(`\n INSERT INTO live_query_${id}_state${stateSwitch} \n SELECT * FROM live_query_${id}_view;\n `)\n\n // Get the changes\n changes = await tx.query<any>(\n `EXECUTE live_query_${id}_diff${stateSwitch};`,\n )\n\n // Switch state\n stateSwitch = stateSwitch === 1 ? 2 : 1\n\n // Truncate the old state table\n await tx.exec(`\n TRUNCATE live_query_${id}_state${stateSwitch};\n `)\n })\n break\n } catch (e) {\n const msg = (e as Error).message\n if (\n msg ===\n `relation \"live_query_${id}_state${stateSwitch}\" does not exist`\n ) {\n // If the state table does not exist, reset and try again\n // This can happen if using the multi-tab worker\n reset = true\n await init()\n continue\n } else {\n throw e\n }\n }\n }\n\n runChangeCallbacks(callbacks, [\n ...(reset\n ? [\n {\n __op__: 'RESET' as const,\n },\n ]\n : []),\n ...changes!.rows,\n ])\n })\n\n // Function to subscribe to the query\n const subscribe = (callback: (changes: Array<Change<T>>) => void) => {\n if (dead) {\n throw new Error(\n 'Live query is no longer active and cannot be subscribed to',\n )\n }\n callbacks.push(callback)\n }\n\n // Function to unsubscribe from the query\n const unsubscribe = async (\n callback?: (changes: Array<Change<T>>) => void,\n ) => {\n if (callback) {\n callbacks = callbacks.filter((callback) => callback !== callback)\n } else {\n callbacks = []\n }\n if (callbacks.length === 0 && !dead) {\n dead = true\n await pg.transaction(async (tx) => {\n await Promise.all(unsubList.map((unsub) => unsub(tx)))\n await tx.exec(`\n DROP VIEW IF EXISTS live_query_${id}_view;\n DROP TABLE IF EXISTS live_query_${id}_state1;\n DROP TABLE IF EXISTS live_query_${id}_state2;\n DEALLOCATE live_query_${id}_diff1;\n DEALLOCATE live_query_${id}_diff2;\n `)\n })\n }\n }\n\n // If the signal has already been aborted, unsubscribe\n if (signal?.aborted) {\n await unsubscribe()\n } else {\n // Add an event listener to unsubscribe if the signal is aborted\n signal?.addEventListener(\n 'abort',\n () => {\n unsubscribe()\n },\n { once: true },\n )\n }\n\n // Run the callback with the initial changes\n await refresh()\n\n // Fields\n const fields = changes!.fields.filter(\n (field) =>\n !['__after__', '__op__', '__changed_columns__'].includes(field.name),\n )\n\n // Return the initial results\n return {\n fields,\n initialChanges: changes!.rows,\n subscribe,\n unsubscribe,\n refresh,\n } satisfies LiveChanges<T>\n },\n\n async incrementalQuery<T>(\n query: string | LiveIncrementalQueryOptions<T>,\n params?: any[] | null,\n key?: string,\n callback?: (results: Results<T>) => void,\n ) {\n let signal: AbortSignal | undefined\n if (typeof query !== 'string') {\n signal = query.signal\n params = query.params\n key = query.key\n callback = query.callback\n query = query.query\n }\n if (!key) {\n throw new Error('key is required for incremental queries')\n }\n let callbacks: Array<(results: Results<T>) => void> = callback\n ? [callback]\n : []\n const rowsMap: Map<any, any> = new Map()\n const afterMap: Map<any, any> = new Map()\n let lastRows: T[] = []\n let firstRun = true\n\n const {\n fields,\n unsubscribe: unsubscribeChanges,\n refresh,\n } = await namespaceObj.changes<T>(query, params, key, (changes) => {\n // Process the changes\n for (const change of changes) {\n const {\n __op__: op,\n __changed_columns__: changedColumns,\n ...obj\n } = change as typeof change & { [key: string]: any }\n switch (op) {\n case 'RESET':\n rowsMap.clear()\n afterMap.clear()\n break\n case 'INSERT':\n rowsMap.set(obj[key], obj)\n afterMap.set(obj.__after__, obj[key])\n break\n case 'DELETE': {\n const oldObj = rowsMap.get(obj[key])\n rowsMap.delete(obj[key])\n // null is the starting point, we don't delete it as another insert\n // may have happened thats replacing it\n if (oldObj.__after__ !== null) {\n afterMap.delete(oldObj.__after__)\n }\n break\n }\n case 'UPDATE': {\n const newObj = { ...(rowsMap.get(obj[key]) ?? {}) }\n for (const columnName of changedColumns) {\n newObj[columnName] = obj[columnName]\n if (columnName === '__after__') {\n afterMap.set(obj.__after__, obj[key])\n }\n }\n rowsMap.set(obj[key], newObj)\n break\n }\n }\n }\n\n // Get the rows in order\n const rows: T[] = []\n let lastKey: any = null\n for (let i = 0; i < rowsMap.size; i++) {\n const nextKey = afterMap.get(lastKey)\n const obj = rowsMap.get(nextKey)\n if (!obj) {\n break\n }\n // Remove the __after__ key from the exposed row\n const cleanObj = { ...obj }\n delete cleanObj.__after__\n rows.push(cleanObj)\n lastKey = nextKey\n }\n lastRows = rows\n\n // Run the callbacks\n if (!firstRun) {\n runResultCallbacks(callbacks, {\n rows,\n fields,\n })\n }\n })\n\n firstRun = false\n runResultCallbacks(callbacks, {\n rows: lastRows,\n fields,\n })\n\n const subscribe = (callback: (results: Results<T>) => void) => {\n callbacks.push(callback)\n }\n\n const unsubscribe = async (callback?: (results: Results<T>) => void) => {\n if (callback) {\n callbacks = callbacks.filter((callback) => callback !== callback)\n } else {\n callbacks = []\n }\n if (callbacks.length === 0) {\n await unsubscribeChanges()\n }\n }\n\n if (signal?.aborted) {\n await unsubscribe()\n } else {\n signal?.addEventListener(\n 'abort',\n () => {\n unsubscribe()\n },\n { once: true },\n )\n }\n\n return {\n initialResults: {\n rows: lastRows,\n fields,\n },\n subscribe,\n unsubscribe,\n refresh,\n } satisfies LiveQuery<T>\n },\n }\n\n return {\n namespaceObj,\n }\n}\n\nexport const live = {\n name: 'Live Queries',\n setup,\n} satisfies Extension\n\nexport type PGliteWithLive = PGliteInterface & {\n live: LiveNamespace\n}\n\n/**\n * Get a list of all the tables used in a view, recursively\n * @param tx a transaction or PGlite instance\n * @param viewName the name of the view\n * @returns list of tables used in the view\n */\nasync function getTablesForView(\n tx: Transaction | PGliteInterface,\n viewName: string,\n): Promise<{ table_name: string; schema_name: string }[]> {\n const result = await tx.query<{\n table_name: string\n schema_name: string\n }>(\n `\n WITH RECURSIVE view_dependencies AS (\n -- Base case: Get the initial view's dependencies\n SELECT DISTINCT\n cl.relname AS dependent_name,\n n.nspname AS schema_name,\n cl.relkind = 'v' AS is_view\n FROM pg_rewrite r\n JOIN pg_depend d ON r.oid = d.objid\n JOIN pg_class cl ON d.refobjid = cl.oid\n JOIN pg_namespace n ON cl.relnamespace = n.oid\n WHERE\n r.ev_class = (\n SELECT oid FROM pg_class WHERE relname = $1 AND relkind = 'v'\n )\n AND d.deptype = 'n'\n\n UNION ALL\n\n -- Recursive case: Traverse dependencies for views\n SELECT DISTINCT\n cl.relname AS dependent_name,\n n.nspname AS schema_name,\n cl.relkind = 'v' AS is_view\n FROM view_dependencies vd\n JOIN pg_rewrite r ON vd.dependent_name = (\n SELECT relname FROM pg_class WHERE oid = r.ev_class AND relkind = 'v'\n )\n JOIN pg_depend d ON r.oid = d.objid\n JOIN pg_class cl ON d.refobjid = cl.oid\n JOIN pg_namespace n ON cl.relnamespace = n.oid\n WHERE d.deptype = 'n'\n )\n SELECT DISTINCT\n dependent_name AS table_name,\n schema_name\n FROM view_dependencies\n WHERE NOT is_view; -- Exclude intermediate views\n `,\n [viewName],\n )\n\n return result.rows.map((row) => ({\n table_name: row.table_name,\n schema_name: row.schema_name,\n }))\n}\n\n/**\n * Add triggers to tables to notify when they change\n * @param tx a transaction or PGlite instance\n * @param tables list of tables to add triggers to\n */\nasync function addNotifyTriggersToTables(\n tx: Transaction | PGliteInterface,\n tables: { table_name: string; schema_name: string }[],\n tableNotifyTriggersAdded: Set<string>,\n) {\n const triggers = tables\n .filter(\n (table) =>\n !tableNotifyTriggersAdded.has(\n `${table.schema_name}_${table.table_name}`,\n ),\n )\n .map((table) => {\n return `\n CREATE OR REPLACE FUNCTION \"_notify_${table.schema_name}_${table.table_name}\"() RETURNS TRIGGER AS $$\n BEGIN\n PERFORM pg_notify('table_change__${table.schema_name}__${table.table_name}', '');\n RETURN NULL;\n END;\n $$ LANGUAGE plpgsql;\n CREATE OR REPLACE TRIGGER \"_notify_trigger_${table.schema_name}_${table.table_name}\"\n AFTER INSERT OR UPDATE OR DELETE ON \"${table.schema_name}\".\"${table.table_name}\"\n FOR EACH STATEMENT EXECUTE FUNCTION \"_notify_${table.schema_name}_${table.table_name}\"();\n `\n })\n .join('\\n')\n if (triggers.trim() !== '') {\n await tx.exec(triggers)\n }\n tables.map((table) =>\n tableNotifyTriggersAdded.add(`${table.schema_name}_${table.table_name}`),\n )\n}\n\nconst runResultCallbacks = <T>(\n callbacks: Array<(results: Results<T>) => void>,\n results: Results<T>,\n) => {\n for (const callback of callbacks) {\n callback(results)\n }\n}\n\nconst runChangeCallbacks = <T>(\n callbacks: Array<(changes: Array<Change<T>>) => void>,\n changes: Array<Change<T>>,\n) => {\n for (const callback of callbacks) {\n callback(changes)\n }\n}\n","/**\n * Calculates the byte length of a UTF-8 encoded string\n * Adapted from https://stackoverflow.com/a/23329386\n * @param str - UTF-8 encoded string\n * @returns byte length of string\n */\nfunction byteLengthUtf8(str: string): number {\n let byteLength = str.length\n for (let i = str.length - 1; i >= 0; i--) {\n const code = str.charCodeAt(i)\n if (code > 0x7f && code <= 0x7ff) byteLength++\n else if (code > 0x7ff && code <= 0xffff) byteLength += 2\n if (code >= 0xdc00 && code <= 0xdfff) i-- // trail surrogate\n }\n return byteLength\n}\n\nexport { byteLengthUtf8 }\n","import { byteLengthUtf8 } from './string-utils'\n\nexport class Writer {\n #bufferView: DataView\n #offset: number = 5\n\n readonly #littleEndian = false as const\n readonly #encoder = new TextEncoder()\n readonly #headerPosition: number = 0\n constructor(private size = 256) {\n this.#bufferView = this.#allocateBuffer(size)\n }\n\n #allocateBuffer(size: number): DataView {\n return new DataView(new ArrayBuffer(size))\n }\n\n #ensure(size: number): void {\n const remaining = this.#bufferView.byteLength - this.#offset\n if (remaining < size) {\n const oldBuffer = this.#bufferView.buffer\n // exponential growth factor of around ~ 1.5\n // https://stackoverflow.com/questions/2269063/buffer-growth-strategy\n const newSize = oldBuffer.byteLength + (oldBuffer.byteLength >> 1) + size\n this.#bufferView = this.#allocateBuffer(newSize)\n new Uint8Array(this.#bufferView.buffer).set(new Uint8Array(oldBuffer))\n }\n }\n\n public addInt32(num: number): Writer {\n this.#ensure(4)\n this.#bufferView.setInt32(this.#offset, num, this.#littleEndian)\n this.#offset += 4\n return this\n }\n\n public addInt16(num: number): Writer {\n this.#ensure(2)\n this.#bufferView.setInt16(this.#offset, num, this.#littleEndian)\n this.#offset += 2\n return this\n }\n\n public addCString(string: string): Writer {\n if (string) {\n // TODO(msfstef): might be faster to extract `addString` code and\n // ensure length + 1 once rather than length and then +1?\n this.addString(string)\n }\n\n // set null terminator\n this.#ensure(1)\n this.#bufferView.setUint8(this.#offset, 0)\n this.#offset++\n return this\n }\n\n public addString(string: string = ''): Writer {\n const length = byteLengthUtf8(string)\n this.#ensure(length)\n this.#encoder.encodeInto(\n string,\n new Uint8Array(this.#bufferView.buffer, this.#offset),\n )\n this.#offset += length\n return this\n }\n\n public add(otherBuffer: ArrayBuffer): Writer {\n this.#ensure(otherBuffer.byteLength)\n new Uint8Array(this.#bufferView.buffer).set(\n new Uint8Array(otherBuffer),\n this.#offset,\n )\n\n this.#offset += otherBuffer.byteLength\n return this\n }\n\n #join(code?: number): ArrayBuffer {\n if (code) {\n this.#bufferView.setUint8(this.#headerPosition, code)\n // length is everything in this packet minus the code\n const length = this.#offset - (this.#headerPosition + 1)\n this.#bufferView.setInt32(\n this.#headerPosition + 1,\n length,\n this.#littleEndian,\n )\n }\n return this.#bufferView.buffer.slice(code ? 0 : 5, this.#offset)\n }\n\n public flush(code?: number): Uint8Array {\n const result = this.#join(code)\n this.#offset = 5\n this.#bufferView = this.#allocateBuffer(this.size)\n return new Uint8Array(result)\n }\n}\n","import { Writer } from './buffer-writer'\nimport { byteLengthUtf8 } from './string-utils'\n\nconst enum code {\n startup = 0x70,\n query = 0x51,\n parse = 0x50,\n bind = 0x42,\n execute = 0x45,\n flush = 0x48,\n sync = 0x53,\n end = 0x58,\n close = 0x43,\n describe = 0x44,\n copyFromChunk = 0x64,\n copyDone = 0x63,\n copyFail = 0x66,\n}\n\ntype LegalValue = string | ArrayBuffer | ArrayBufferView | null\n\nconst writer = new Writer()\n\nconst startup = (opts: Record<string, string>): Uint8Array => {\n // protocol version\n writer.addInt16(3).addInt16(0)\n for (const key of Object.keys(opts)) {\n writer.addCString(key).addCString(opts[key])\n }\n\n writer.addCString('client_encoding').addCString('UTF8')\n\n const bodyBuffer = writer.addCString('').flush()\n // this message is sent without a code\n\n const length = bodyBuffer.byteLength + 4\n\n return new Writer().addInt32(length).add(bodyBuffer).flush()\n}\n\nconst requestSsl = (): Uint8Array => {\n const bufferView = new DataView(new ArrayBuffer(8))\n bufferView.setInt32(0, 8, false)\n bufferView.setInt32(4, 80877103, false)\n return new Uint8Array(bufferView.buffer)\n}\n\nconst password = (password: string): Uint8Array => {\n return writer.addCString(password).flush(code.startup)\n}\n\nconst sendSASLInitialResponseMessage = (\n mechanism: string,\n initialResponse: string,\n): Uint8Array => {\n // 0x70 = 'p'\n writer\n .addCString(mechanism)\n .addInt32(byteLengthUtf8(initialResponse))\n .addString(initialResponse)\n\n return writer.flush(code.startup)\n}\n\nconst sendSCRAMClientFinalMessage = (additionalData: string): Uint8Array => {\n return writer.addString(additionalData).flush(code.startup)\n}\n\nconst query = (text: string): Uint8Array => {\n return writer.addCString(text).flush(code.query)\n}\n\ntype ParseOpts = {\n name?: string\n types?: number[]\n text: string\n}\n\nconst emptyValueArray: LegalValue[] = []\n\nconst parse = (query: ParseOpts): Uint8Array => {\n // expect something like this:\n // { name: 'queryName',\n // text: 'select * from blah',\n // types: ['int8', 'bool'] }\n\n // normalize missing query names to allow for null\n const name = query.name ?? ''\n if (name.length > 63) {\n /* eslint-disable no-console */\n console.error(\n 'Warning! Postgres only supports 63 characters for query names.',\n )\n console.error('You supplied %s (%s)', name, name.length)\n console.error(\n 'This can cause conflicts and silent errors executing queries',\n )\n /* eslint-enable no-console */\n }\n\n const buffer = writer\n .addCString(name) // name of query\n .addCString(query.text) // actual query text\n .addInt16(query.types?.length ?? 0)\n\n query.types?.forEach((type) => buffer.addInt32(type))\n\n return writer.flush(code.parse)\n}\n\ntype ValueMapper = (param: unknown, index: number) => LegalValue\n\ntype BindOpts = {\n portal?: string\n binary?: boolean\n statement?: string\n values?: LegalValue[]\n // optional map from JS value to postgres value per parameter\n valueMapper?: ValueMapper\n}\n\nconst paramWriter = new Writer()\n\n// make this a const enum so typescript will inline the value\nconst enum ParamType {\n STRING = 0,\n BINARY = 1,\n}\n\nconst writeValues = (values: LegalValue[], valueMapper?: ValueMapper): void => {\n for (let i = 0; i < values.length; i++) {\n const mappedVal = valueMapper ? valueMapper(values[i], i) : values[i]\n if (mappedVal === null) {\n // add the param type (string) to the writer\n writer.addInt16(ParamType.STRING)\n // write -1 to the param writer to indicate null\n paramWriter.addInt32(-1)\n } else if (\n mappedVal instanceof ArrayBuffer ||\n ArrayBuffer.isView(mappedVal)\n ) {\n const buffer = ArrayBuffer.isView(mappedVal)\n ? mappedVal.buffer.slice(\n mappedVal.byteOffset,\n mappedVal.byteOffset + mappedVal.byteLength,\n )\n : mappedVal\n // add the param type (binary) to the writer\n writer.addInt16(ParamType.BINARY)\n // add the buffer to the param writer\n paramWriter.addInt32(buffer.byteLength)\n paramWriter.add(buffer)\n } else {\n // add the param type (string) to the writer\n writer.addInt16(ParamType.STRING)\n paramWriter.addInt32(byteLengthUtf8(mappedVal))\n paramWriter.addString(mappedVal)\n }\n }\n}\n\nconst bind = (config: BindOpts = {}): Uint8Array => {\n // normalize config\n const portal = config.portal ?? ''\n const statement = config.statement ?? ''\n const binary = config.binary ?? false\n const values = config.values ?? emptyValueArray\n const len = values.length\n\n writer.addCString(portal).addCString(statement)\n writer.addInt16(len)\n\n writeValues(values, config.valueMapper)\n\n writer.addInt16(len)\n writer.add(paramWriter.flush())\n\n // format code\n writer.addInt16(binary ? ParamType.BINARY : ParamType.STRING)\n return writer.flush(code.bind)\n}\n\ntype ExecOpts = {\n portal?: string\n rows?: number\n}\n\nconst emptyExecute = new Uint8Array([\n code.execute,\n 0x00,\n 0x00,\n 0x00,\n 0x09,\n 0x00,\n 0x00,\n 0x00,\n 0x00,\n 0x00,\n])\n\nconst execute = (config?: ExecOpts): Uint8Array => {\n // this is the happy path for most queries\n if (!config || (!config.portal && !config.rows)) {\n return emptyExecute\n }\n\n const portal = config.portal ?? ''\n const rows = config.rows ?? 0\n\n const portalLength = byteLengthUtf8(portal)\n const len = 4 + portalLength + 1 + 4\n // one extra bit for code\n const bufferView = new DataView(new ArrayBuffer(1 + len))\n bufferView.setUint8(0, code.execute)\n bufferView.setInt32(1, len, false)\n new TextEncoder().encodeInto(portal, new Uint8Array(bufferView.buffer, 5))\n bufferView.setUint8(portalLength + 5, 0) // null terminate portal cString\n bufferView.setUint32(bufferView.byteLength - 4, rows, false)\n return new Uint8Array(bufferView.buffer)\n}\n\nconst cancel = (processID: number, secretKey: number): Uint8Array => {\n const bufferView = new DataView(new ArrayBuffer(16))\n bufferView.setInt32(0, 16, false)\n bufferView.setInt16(4, 1234, false)\n bufferView.setInt16(6, 5678, false)\n bufferView.setInt32(8, processID, false)\n bufferView.setInt32(12, secretKey, false)\n return new Uint8Array(bufferView.buffer)\n}\n\ntype PortalOpts = {\n type: 'S' | 'P'\n name?: string\n}\n\nconst cstringMessage = (code: code, string: string): Uint8Array => {\n const writer = new Writer()\n writer.addCString(string)\n return writer.flush(code)\n}\n\nconst emptyDescribePortal = writer.addCString('P').flush(code.describe)\nconst emptyDescribeStatement = writer.addCString('S').flush(code.describe)\n\nconst describe = (msg: PortalOpts): Uint8Array => {\n return msg.name\n ? cstringMessage(code.describe, `${msg.type}${msg.name ?? ''}`)\n : msg.type === 'P'\n ? emptyDescribePortal\n : emptyDescribeStatement\n}\n\nconst close = (msg: PortalOpts): Uint8Array => {\n const text = `${msg.type}${msg.name ?? ''}`\n return cstringMessage(code.close, text)\n}\n\nconst copyData = (chunk: ArrayBuffer): Uint8Array => {\n return writer.add(chunk).flush(code.copyFromChunk)\n}\n\nconst copyFail = (message: string): Uint8Array => {\n return cstringMessage(code.copyFail, message)\n}\n\nconst codeOnlyBuffer = (code: code): Uint8Array =>\n new Uint8Array([code, 0x00, 0x00, 0x00, 0x04])\n\nconst flushBuffer = codeOnlyBuffer(code.flush)\nconst syncBuffer = codeOnlyBuffer(code.sync)\nconst endBuffer = codeOnlyBuffer(code.end)\nconst copyDoneBuffer = codeOnlyBuffer(code.copyDone)\n\nconst serialize = {\n startup,\n password,\n requestSsl,\n sendSASLInitialResponseMessage,\n sendSCRAMClientFinalMessage,\n query,\n parse,\n bind,\n execute,\n describe,\n close,\n flush: () => flushBuffer,\n sync: () => syncBuffer,\n end: () => endBuffer,\n copyData,\n copyDone: () => copyDoneBuffer,\n copyFail,\n cancel,\n}\n\nexport { serialize }\n","const emptyBuffer = new ArrayBuffer(0)\n\nexport class BufferReader {\n #bufferView: DataView = new DataView(emptyBuffer)\n #offset: number\n\n // TODO(bmc): support non-utf8 encoding?\n readonly #encoding: string = 'utf-8' as const\n readonly #decoder = new TextDecoder(this.#encoding)\n readonly #littleEndian: boolean = false as const\n\n constructor(offset: number = 0) {\n this.#offset = offset\n }\n\n public setBuffer(offset: number, buffer: ArrayBuffer): void {\n this.#offset = offset\n this.#bufferView = new DataView(buffer)\n }\n\n public int16(): number {\n // const result = this.buffer.readInt16BE(this.#offset)\n const result = this.#bufferView.getInt16(this.#offset, this.#littleEndian)\n this.#offset += 2\n return result\n }\n\n public byte(): number {\n // const result = this.bufferView[this.#offset]\n const result = this.#bufferView.getUint8(this.#offset)\n this.#offset++\n return result\n }\n\n public int32(): number {\n // const result = this.buffer.readInt32BE(this.#offset)\n const result = this.#bufferView.getInt32(this.#offset, this.#littleEndian)\n this.#offset += 4\n return result\n }\n\n public string(length: number): string {\n // const result = this.#bufferView.toString(\n // this.#encoding,\n // this.#offset,\n // this.#offset + length,\n // )\n // this.#offset += length\n\n const result = this.#decoder.decode(this.bytes(length))\n return result\n }\n\n public cstring(): string {\n // const start = this.#offset\n // let end = start\n // while (this.#bufferView[end++] !== 0) {}\n\n const start = this.#offset\n let end = start\n while (this.#bufferView.getUint8(end++) !== 0) {\n // no-op - increment until terminator reached\n }\n const result = this.string(end - start - 1)\n this.#offset = end\n return result\n }\n\n public bytes(length: number): Uint8Array {\n // const result = this.buffer.slice(this.#offset, this.#offset + length)\n const result = this.#bufferView.buffer.slice(\n this.#offset,\n this.#offset + length,\n )\n this.#offset += length\n return new Uint8Array(result)\n }\n}\n","import {\n bindComplete,\n parseComplete,\n closeComplete,\n noData,\n portalSuspended,\n copyDone,\n replicationStart,\n emptyQuery,\n ReadyForQueryMessage,\n CommandCompleteMessage,\n CopyDataMessage,\n CopyResponse,\n NotificationResponseMessage,\n RowDescriptionMessage,\n ParameterDescriptionMessage,\n Field,\n DataRowMessage,\n ParameterStatusMessage,\n BackendKeyDataMessage,\n DatabaseError,\n BackendMessage,\n MessageName,\n NoticeMessage,\n AuthenticationMessage,\n AuthenticationOk,\n AuthenticationCleartextPassword,\n AuthenticationMD5Password,\n AuthenticationSASL,\n AuthenticationSASLContinue,\n AuthenticationSASLFinal,\n} from './messages'\nimport { BufferParameter, Modes } from './types'\nimport { BufferReader } from './buffer-reader'\n\n// every message is prefixed with a single bye\nconst CODE_LENGTH = 1 as const\n// every message has an int32 length which includes itself but does\n// NOT include the code in the length\nconst LEN_LENGTH = 4 as const\n\nconst HEADER_LENGTH = CODE_LENGTH + LEN_LENGTH\n\nexport type Packet = {\n code: number\n packet: ArrayBuffer\n}\n\nconst emptyBuffer = new ArrayBuffer(0)\n\nconst enum MessageCodes {\n DataRow = 0x44, // D\n ParseComplete = 0x31, // 1\n BindComplete = 0x32, // 2\n CloseComplete = 0x33, // 3\n CommandComplete = 0x43, // C\n ReadyForQuery = 0x5a, // Z\n NoData = 0x6e, // n\n NotificationResponse = 0x41, // A\n AuthenticationResponse = 0x52, // R\n ParameterStatus = 0x53, // S\n BackendKeyData = 0x4b, // K\n ErrorMessage = 0x45, // E\n NoticeMessage = 0x4e, // N\n RowDescriptionMessage = 0x54, // T\n ParameterDescriptionMessage = 0x74, // t\n PortalSuspended = 0x73, // s\n ReplicationStart = 0x57, // W\n EmptyQuery = 0x49, // I\n CopyIn = 0x47, // G\n CopyOut = 0x48, // H\n CopyDone = 0x63, // c\n CopyData = 0x64, // d\n}\n\nexport type MessageCallback = (msg: BackendMessage) => void\n\nexport class Parser {\n #bufferView: DataView = new DataView(emptyBuffer)\n #bufferRemainingLength: number = 0\n #bufferOffset: number = 0\n #reader = new BufferReader()\n\n public parse(buffer: BufferParameter, callback: MessageCallback) {\n this.#mergeBuffer(\n ArrayBuffer.isView(buffer)\n ? buffer.buffer.slice(\n buffer.byteOffset,\n buffer.byteOffset + buffer.byteLength,\n )\n : buffer,\n )\n const bufferFullLength = this.#bufferOffset + this.#bufferRemainingLength\n let offset = this.#bufferOffset\n while (offset + HEADER_LENGTH <= bufferFullLength) {\n // code is 1 byte long - it identifies the message type\n const code = this.#bufferView.getUint8(offset)\n // length is 1 Uint32BE - it is the length of the message EXCLUDING the code\n const length = this.#bufferView.getUint32(offset + CODE_LENGTH, false)\n const fullMessageLength = CODE_LENGTH + length\n if (fullMessageLength + offset <= bufferFullLength) {\n const message = this.#handlePacket(\n offset + HEADER_LENGTH,\n code,\n length,\n this.#bufferView.buffer,\n )\n callback(message)\n offset += fullMessageLength\n } else {\n break\n }\n }\n if (offset === bufferFullLength) {\n // No more use for the buffer\n this.#bufferView = new DataView(emptyBuffer)\n this.#bufferRemainingLength = 0\n this.#bufferOffset = 0\n } else {\n // Adjust the cursors of remainingBuffer\n this.#bufferRemainingLength = bufferFullLength - offset\n this.#bufferOffset = offset\n }\n }\n\n #mergeBuffer(buffer: ArrayBuffer): void {\n if (this.#bufferRemainingLength > 0) {\n const newLength = this.#bufferRemainingLength + buffer.byteLength\n const newFullLength = newLength + this.#bufferOffset\n if (newFullLength > this.#bufferView.byteLength) {\n // We can't concat the new buffer with the remaining one\n let newBuffer: ArrayBuffer\n if (\n newLength <= this.#bufferView.byteLength &&\n this.#bufferOffset >= this.#bufferRemainingLength\n ) {\n // We can move the relevant part to the beginning of the buffer instead of allocating a new buffer\n newBuffer = this.#bufferView.buffer\n } else {\n // Allocate a new larger buffer\n let newBufferLength = this.#bufferView.byteLength * 2\n while (newLength >= newBufferLength) {\n newBufferLength *= 2\n }\n newBuffer = new ArrayBuffer(newBufferLength)\n }\n // Move the remaining buffer to the new one\n new Uint8Array(newBuffer).set(\n new Uint8Array(\n this.#bufferView.buffer,\n this.#bufferOffset,\n this.#bufferRemainingLength,\n ),\n )\n this.#bufferView = new DataView(newBuffer)\n this.#bufferOffset = 0\n }\n\n // Concat the new buffer with the remaining one\n new Uint8Array(this.#bufferView.buffer).set(\n new Uint8Array(buffer),\n this.#bufferOffset + this.#bufferRemainingLength,\n )\n this.#bufferRemainingLength = newLength\n } else {\n this.#bufferView = new DataView(buffer)\n this.#bufferOffset = 0\n this.#bufferRemainingLength = buffer.byteLength\n }\n }\n\n #handlePacket(\n offset: number,\n code: number,\n length: number,\n bytes: ArrayBuffer,\n ): BackendMessage {\n switch (code) {\n case MessageCodes.BindComplete:\n return bindComplete\n case MessageCodes.ParseComplete:\n return parseComplete\n case MessageCodes.CloseComplete:\n return closeComplete\n case MessageCodes.NoData:\n return noData\n case MessageCodes.PortalSuspended:\n return portalSuspended\n case MessageCodes.CopyDone:\n return copyDone\n case MessageCodes.ReplicationStart:\n return replicationStart\n case MessageCodes.EmptyQuery:\n return emptyQuery\n case MessageCodes.DataRow:\n return this.#parseDataRowMessage(offset, length, bytes)\n case MessageCodes.CommandComplete:\n return this.#parseCommandCompleteMessage(offset, length, bytes)\n case MessageCodes.ReadyForQuery:\n return this.#parseReadyForQueryMessage(offset, length, bytes)\n case MessageCodes.NotificationResponse:\n return this.#parseNotificationMessage(offset, length, bytes)\n case MessageCodes.AuthenticationResponse:\n return this.#parseAuthenticationResponse(offset, length, bytes)\n case MessageCodes.ParameterStatus:\n return this.#parseParameterStatusMessage(offset, length, bytes)\n case MessageCodes.BackendKeyData:\n return this.#parseBackendKeyData(offset, length, bytes)\n case MessageCodes.ErrorMessage:\n return this.#parseErrorMessage(offset, length, bytes, 'error')\n case MessageCodes.NoticeMessage:\n return this.#parseErrorMessage(offset, length, bytes, 'notice')\n case MessageCodes.RowDescriptionMessage:\n return this.#parseRowDescriptionMessage(offset, length, bytes)\n case MessageCodes.ParameterDescriptionMessage:\n return this.#parseParameterDescriptionMessage(offset, length, bytes)\n case MessageCodes.CopyIn:\n return this.#parseCopyInMessage(offset, length, bytes)\n case MessageCodes.CopyOut:\n return this.#parseCopyOutMessage(offset, length, bytes)\n case MessageCodes.CopyData:\n return this.#parseCopyData(offset, length, bytes)\n default:\n return new DatabaseError(\n 'received invalid response: ' + code.toString(16),\n length,\n 'error',\n )\n }\n }\n\n #parseReadyForQueryMessage(\n offset: number,\n length: number,\n bytes: ArrayBuffer,\n ) {\n this.#reader.setBuffer(offset, bytes)\n const status = this.#reader.string(1)\n return new ReadyForQueryMessage(length, status)\n }\n\n #parseCommandCompleteMessage(\n offset: number,\n length: number,\n bytes: ArrayBuffer,\n ) {\n this.#reader.setBuffer(offset, bytes)\n const text = this.#reader.cstring()\n return new CommandCompleteMessage(length, text)\n }\n\n #parseCopyData(offset: number, length: number, bytes: ArrayBuffer) {\n const chunk = bytes.slice(offset, offset + (length - 4))\n return new CopyDataMessage(length, new Uint8Array(chunk))\n }\n\n #parseCopyInMessage(offset: number, length: number, bytes: ArrayBuffer) {\n return this.#parseCopyMessage(offset, length, bytes, 'copyInResponse')\n }\n\n #parseCopyOutMessage(offset: num