UNPKG

@pgxsinkit/pgwasm

Version:
708 lines (646 loc) • 25.1 kB
// Began as a copy of `@electric-sql/pglite` (taken under its PostgreSQL License option, © ElectricSQL // — see NOTICE). Owned outright (ADR-0062); compatibility with PGlite is an anti-goal — evolve freely. import { randomId } from "../core/names"; import type { Extension, Pgwasm, Results, Row, Transaction } from "../interface"; import { debounceMutex } from "./debounce-mutex"; import { formatQuery } from "./format-query"; import type { Change, LiveChanges, LiveChangesOptions, LiveIncrementalQueryOptions, LiveNamespace, LiveQuery, LiveQueryOptions, LiveQueryResults, } from "./interface"; export type { Change, ChangeDelete, ChangeInsert, ChangeReset, ChangeUpdate, LiveChanges, LiveChangesOptions, LiveIncrementalQueryOptions, LiveNamespace, LiveQuery, LiveQueryOptions, LiveQueryResults, PgwasmWithLive, } from "./interface"; const MAX_RETRIES = 5; interface ViewTable { table_name: string; schema_name: string; table_oid: number; schema_oid: number; } type Unlisten = (tx?: Transaction) => Promise<void>; function runResultCallbacks<T>(callbacks: readonly ((results: Results<T>) => void)[], results: Results<T>): void { for (const callback of callbacks) callback(results); } function runChangeCallbacks<T>(callbacks: readonly ((changes: Change<T>[]) => void)[], changes: Change<T>[]): void { for (const callback of callbacks) callback(changes); } /** * Every table a view reads, following views it reads recursively. */ async function getTablesForView(tx: Transaction, viewName: string): Promise<ViewTable[]> { const result = await tx.query<ViewTable>( ` WITH RECURSIVE view_dependencies AS ( -- Base case: the view's own dependencies SELECT DISTINCT cl.relname AS dependent_name, n.nspname AS schema_name, cl.oid AS dependent_oid, n.oid AS schema_oid, cl.relkind = 'v' AS is_view FROM pg_rewrite r JOIN pg_depend d ON r.oid = d.objid JOIN pg_class cl ON d.refobjid = cl.oid JOIN pg_namespace n ON cl.relnamespace = n.oid WHERE r.ev_class = ( SELECT oid FROM pg_class WHERE relname = $1 AND relkind = 'v' ) AND d.deptype = 'n' UNION ALL -- Recursive case: the dependencies of the views found so far SELECT DISTINCT cl.relname AS dependent_name, n.nspname AS schema_name, cl.oid AS dependent_oid, n.oid AS schema_oid, cl.relkind = 'v' AS is_view FROM view_dependencies vd JOIN pg_rewrite r ON vd.dependent_name = ( SELECT relname FROM pg_class WHERE oid = r.ev_class AND relkind = 'v' ) JOIN pg_depend d ON r.oid = d.objid JOIN pg_class cl ON d.refobjid = cl.oid JOIN pg_namespace n ON cl.relnamespace = n.oid WHERE d.deptype = 'n' ) SELECT DISTINCT dependent_name AS table_name, schema_name, dependent_oid AS table_oid, schema_oid FROM view_dependencies WHERE NOT is_view; -- only tables, not the views in between `, [viewName], ); return result.rows.map((row) => ({ table_name: row.table_name, schema_name: row.schema_name, table_oid: row.table_oid, schema_oid: row.schema_oid, })); } /** A statement-level NOTIFY trigger on each table, created once per table per database. */ async function addNotifyTriggersToTables( tx: Transaction, tables: readonly ViewTable[], tableNotifyTriggersAdded: Set<string>, ): Promise<void> { const triggers = tables .filter((table) => !tableNotifyTriggersAdded.has(`${table.schema_oid}_${table.table_oid}`)) .map( (table) => ` CREATE OR REPLACE FUNCTION "_notify_${table.schema_oid}_${table.table_oid}"() RETURNS TRIGGER AS $$ BEGIN PERFORM pg_notify('table_change__${table.schema_oid}__${table.table_oid}', ''); RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE OR REPLACE TRIGGER "_notify_trigger_${table.schema_oid}_${table.table_oid}" AFTER INSERT OR UPDATE OR DELETE ON "${table.schema_name}"."${table.table_name}" FOR EACH STATEMENT EXECUTE FUNCTION "_notify_${table.schema_oid}_${table.table_oid}"(); `, ) .join("\n"); if (triggers.trim() !== "") { await tx.exec(triggers); } for (const table of tables) tableNotifyTriggersAdded.add(`${table.schema_oid}_${table.table_oid}`); } function listenToTables(tx: Transaction, tables: readonly ViewTable[], onChange: () => void): Promise<Unlisten[]> { return Promise.all( tables.map((table) => tx.listen(`"table_change__${table.schema_oid}__${table.table_oid}"`, () => { onChange(); }), ), ); } function unsubscribeOnAbort(signal: AbortSignal | undefined, unsubscribe: () => Promise<void>): Promise<void> | void { if (signal?.aborted) return unsubscribe(); signal?.addEventListener( "abort", () => { void unsubscribe(); }, { once: true }, ); } function createNamespace(pg: Pgwasm): LiveNamespace { // The notify triggers are only ever added, never removed: remember which tables have one. const tableNotifyTriggersAdded = new Set<string>(); async function query<T>( queryOrOptions: string | LiveQueryOptions<T>, paramsArg?: readonly unknown[] | null, callbackArg?: (results: Results<T>) => void, ): Promise<LiveQuery<T>> { let signal: AbortSignal | undefined; let offset: number | undefined; let limit: number | undefined; let query: string; let params: readonly unknown[] | null | undefined = paramsArg; let callback = callbackArg; if (typeof queryOrOptions === "string") { query = queryOrOptions; } else { signal = queryOrOptions.signal; params = queryOrOptions.params; callback = queryOrOptions.callback; offset = queryOrOptions.offset; limit = queryOrOptions.limit; query = queryOrOptions.query; } if ((offset === undefined) !== (limit === undefined)) { throw new Error("offset and limit must be provided together"); } const isWindowed = offset !== undefined && limit !== undefined; if ( isWindowed && (typeof offset !== "number" || Number.isNaN(offset) || typeof limit !== "number" || Number.isNaN(limit)) ) { throw new Error("offset and limit must be numbers"); } let callbacks: ((results: Results<T>) => void)[] = callback ? [callback] : []; const id = randomId(); let dead = false; let totalCount: number | undefined; let results: LiveQueryResults<T> = { rows: [], fields: [] }; let unsubList: Unlisten[] = []; // `refresh` is created after `init()` completes, but init registers the listeners that call it: a // notification during init is recorded and replayed once `refresh` exists. let refreshReady = false; let refreshPending = false; const notifyRefresh = () => { if (!refreshReady) { refreshPending = true; return; } void refresh(); }; const init = async () => { await pg.transaction(async (tx) => { const formattedQuery = params && params.length > 0 ? await formatQuery(pg, query, params, tx) : query; await tx.exec(`CREATE OR REPLACE TEMP VIEW live_query_${id}_view AS ${formattedQuery}`); const tables = await getTablesForView(tx, `live_query_${id}_view`); await addNotifyTriggersToTables(tx, tables, tableNotifyTriggersAdded); if (isWindowed) { await tx.exec(` PREPARE live_query_${id}_get(int, int) AS SELECT * FROM live_query_${id}_view LIMIT $1 OFFSET $2; `); await tx.exec(` PREPARE live_query_${id}_get_total_count AS SELECT COUNT(*) FROM live_query_${id}_view; `); totalCount = (await tx.query<{ count: number }>(`EXECUTE live_query_${id}_get_total_count;`)).rows[0]?.count; results = { ...(await tx.query<T>(`EXECUTE live_query_${id}_get(${limit}, ${offset});`)), ...(offset === undefined ? {} : { offset }), ...(limit === undefined ? {} : { limit }), ...(totalCount === undefined ? {} : { totalCount }), }; } else { await tx.exec(` PREPARE live_query_${id}_get AS SELECT * FROM live_query_${id}_view; `); results = await tx.query<T>(`EXECUTE live_query_${id}_get;`); } unsubList = await listenToTables(tx, tables, notifyRefresh); }); }; await init(); const refresh = debounceMutex( async ({ offset: newOffset, limit: newLimit }: { offset?: number; limit?: number } = {}) => { // A windowed query can be refreshed onto another window. if (!isWindowed && (newOffset !== undefined || newLimit !== undefined)) { throw new Error("offset and limit cannot be provided for non-windowed queries"); } if ( (newOffset && (typeof newOffset !== "number" || Number.isNaN(newOffset))) || (newLimit && (typeof newLimit !== "number" || Number.isNaN(newLimit))) ) { throw new Error("offset and limit must be numbers"); } offset = newOffset ?? offset; limit = newLimit ?? limit; const run = async (count = 0): Promise<void> => { if (callbacks.length === 0) return; try { if (isWindowed) { // The rows first, with the old total count: count(*) is slow, and the rows on screen should // update as quickly as possible. The count follows below. results = { ...(await pg.query<T>(`EXECUTE live_query_${id}_get(${limit}, ${offset});`)), ...(offset === undefined ? {} : { offset }), ...(limit === undefined ? {} : { limit }), ...(totalCount === undefined ? {} : { totalCount }), }; } else { results = await pg.query<T>(`EXECUTE live_query_${id}_get;`); } } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (msg.startsWith(`prepared statement "live_query_${id}`) && msg.endsWith("does not exist")) { // The prepared statement is gone (the session was reset): set up again and retry. if (count > MAX_RETRIES) throw error; await init(); await run(count + 1); return; } throw error; } runResultCallbacks(callbacks, results); if (isWindowed) { const newTotalCount = (await pg.query<{ count: number }>(`EXECUTE live_query_${id}_get_total_count;`)) .rows[0]?.count; if (newTotalCount !== totalCount) { totalCount = newTotalCount; void refresh(); } } }; await run(); }, ); refreshReady = true; if (refreshPending) { refreshPending = false; void refresh(); } const subscribe = (subscriber: (results: Results<T>) => void) => { if (dead) { throw new Error("Live query is no longer active and cannot be subscribed to"); } callbacks.push(subscriber); }; // Unsubscribe one callback, or all when none is given; with none left, drop the view and listeners. const unsubscribe = async (subscriber?: (results: Results<T>) => void) => { callbacks = subscriber ? callbacks.filter((cb) => cb !== subscriber) : []; if (callbacks.length === 0 && !dead) { dead = true; await pg.transaction(async (tx) => { await Promise.all(unsubList.map((unsub) => unsub(tx))); await tx.exec(` DROP VIEW IF EXISTS live_query_${id}_view; DEALLOCATE live_query_${id}_get; `); }); } }; await unsubscribeOnAbort(signal, unsubscribe); runResultCallbacks(callbacks, results); return { initialResults: results, subscribe, unsubscribe, refresh: async (options) => { await refresh(options); }, }; } async function changes<T>( queryOrOptions: string | LiveChangesOptions<T>, paramsArg?: readonly unknown[] | null, keyArg?: string, callbackArg?: (changes: Change<T>[]) => void, ): Promise<LiveChanges<T>> { let signal: AbortSignal | undefined; let query: string; let params: readonly unknown[] | null | undefined = paramsArg; let key = keyArg; let callback = callbackArg; if (typeof queryOrOptions === "string") { query = queryOrOptions; } else { signal = queryOrOptions.signal; params = queryOrOptions.params; key = queryOrOptions.key; callback = queryOrOptions.callback; query = queryOrOptions.query; } if (!key) { throw new Error("key is required for changes queries"); } const keyColumn = key; let callbacks: ((changes: Change<T>[]) => void)[] = callback ? [callback] : []; const id = randomId(); let dead = false; let stateSwitch: 1 | 2 = 1; let changesResult: Results<Change<T>> | undefined; let unsubList: Unlisten[] = []; let refreshReady = false; let refreshPending = false; const notifyRefresh = () => { if (!refreshReady) { refreshPending = true; return; } void refresh(); }; const init = async () => { await pg.transaction(async (tx) => { const formattedQuery = await formatQuery(pg, query, params, tx); await tx.query(`CREATE OR REPLACE TEMP VIEW live_query_${id}_view AS ${formattedQuery}`); const tables = await getTablesForView(tx, `live_query_${id}_view`); await addNotifyTriggersToTables(tx, tables, tableNotifyTriggersAdded); const columns: { column_name: string; data_type: string; udt_name?: string }[] = [ ...( await tx.query<{ column_name: string; data_type: string; udt_name: string }>(` SELECT column_name, data_type, udt_name FROM information_schema.columns WHERE table_name = 'live_query_${id}_view' `) ).rows, { column_name: "__after__", data_type: "integer" }, ]; // Two state tables, alternately holding the previous and the current result. await tx.exec(` CREATE TEMP TABLE live_query_${id}_state1 (LIKE live_query_${id}_view INCLUDING ALL); CREATE TEMP TABLE live_query_${id}_state2 (LIKE live_query_${id}_view INCLUDING ALL); `); const nullOf = (column: { data_type: string; udt_name?: string }) => `NULL${column.data_type === "USER-DEFINED" ? `::${column.udt_name ?? ""}` : ""}`; // A diff statement per direction: INSERTs carry every column, DELETEs only the key, UPDATEs only // the changed columns (and their names). for (const curr of [1, 2] as const) { const prev = curr === 1 ? 2 : 1; await tx.exec(` PREPARE live_query_${id}_diff${curr} AS WITH prev AS (SELECT LAG("${keyColumn}") OVER () as __after__, * FROM live_query_${id}_state${prev}), curr AS (SELECT LAG("${keyColumn}") OVER () as __after__, * FROM live_query_${id}_state${curr}), data_diff AS ( SELECT 'INSERT' AS __op__, ${columns.map(({ column_name }) => `curr."${column_name}" AS "${column_name}"`).join(",\n")}, ARRAY[]::text[] AS __changed_columns__ FROM curr LEFT JOIN prev ON curr."${keyColumn}" = prev."${keyColumn}" WHERE prev."${keyColumn}" IS NULL UNION ALL SELECT 'DELETE' AS __op__, ${columns .map((column) => column.column_name === keyColumn ? `prev."${column.column_name}" AS "${column.column_name}"` : `${nullOf(column)} AS "${column.column_name}"`, ) .join(",\n")}, ARRAY[]::text[] AS __changed_columns__ FROM prev LEFT JOIN curr ON prev."${keyColumn}" = curr."${keyColumn}" WHERE curr."${keyColumn}" IS NULL UNION ALL SELECT 'UPDATE' AS __op__, ${columns .map((column) => column.column_name === keyColumn ? `curr."${column.column_name}" AS "${column.column_name}"` : `CASE WHEN curr."${column.column_name}" IS DISTINCT FROM prev."${column.column_name}" THEN curr."${column.column_name}" ELSE ${nullOf(column)} END AS "${column.column_name}"`, ) .join(",\n")}, ARRAY(SELECT unnest FROM unnest(ARRAY[${columns .filter(({ column_name }) => column_name !== keyColumn) .map( ({ column_name }) => `CASE WHEN curr."${column_name}" IS DISTINCT FROM prev."${column_name}" THEN '${column_name}' ELSE NULL END`, ) .join(", ")}]) WHERE unnest IS NOT NULL) AS __changed_columns__ FROM curr INNER JOIN prev ON curr."${keyColumn}" = prev."${keyColumn}" WHERE NOT (curr IS NOT DISTINCT FROM prev) ) SELECT * FROM data_diff; `); } unsubList = await listenToTables(tx, tables, notifyRefresh); }); }; await init(); const refresh = debounceMutex(async () => { if (callbacks.length === 0 && changesResult) return; let reset = false; for (let i = 0; i < 5; i++) { try { await pg.transaction(async (tx) => { await tx.exec(` INSERT INTO live_query_${id}_state${stateSwitch} SELECT * FROM live_query_${id}_view; `); changesResult = await tx.query<Change<T>>(`EXECUTE live_query_${id}_diff${stateSwitch};`); stateSwitch = stateSwitch === 1 ? 2 : 1; await tx.exec(` TRUNCATE live_query_${id}_state${stateSwitch}; `); }); break; } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (msg === `relation "live_query_${id}_state${stateSwitch}" does not exist`) { // The state table is gone (the session was reset): set up again and retry. reset = true; await init(); continue; } throw error; } } const resetChange = { __op__: "RESET" } as Change<T>; runChangeCallbacks(callbacks, [...(reset ? [resetChange] : []), ...(changesResult?.rows ?? [])]); }); refreshReady = true; const subscribe = (subscriber: (changes: Change<T>[]) => void) => { if (dead) { throw new Error("Live query is no longer active and cannot be subscribed to"); } callbacks.push(subscriber); }; const unsubscribe = async (subscriber?: (changes: Change<T>[]) => void) => { callbacks = subscriber ? callbacks.filter((cb) => cb !== subscriber) : []; if (callbacks.length === 0 && !dead) { dead = true; await pg.transaction(async (tx) => { await Promise.all(unsubList.map((unsub) => unsub(tx))); await tx.exec(` DROP VIEW IF EXISTS live_query_${id}_view; DROP TABLE IF EXISTS live_query_${id}_state1; DROP TABLE IF EXISTS live_query_${id}_state2; DEALLOCATE live_query_${id}_diff1; DEALLOCATE live_query_${id}_diff2; `); }); } }; await unsubscribeOnAbort(signal, unsubscribe); // The initial changes. await refresh(); if (refreshPending) { // A notification arrived during init. It is replayed after the initial refresh, which would // otherwise consume the initial changes. refreshPending = false; void refresh(); } const fields = (changesResult?.fields ?? []).filter( (field) => !["__after__", "__op__", "__changed_columns__"].includes(field.name), ); return { fields, initialChanges: changesResult?.rows ?? [], subscribe, unsubscribe, refresh: async () => { await refresh(); }, }; } async function incrementalQuery<T>( queryOrOptions: string | LiveIncrementalQueryOptions<T>, paramsArg?: readonly unknown[] | null, keyArg?: string, callbackArg?: (results: Results<T>) => void, ): Promise<LiveQuery<T>> { let signal: AbortSignal | undefined; let query: string; let params: readonly unknown[] | null | undefined = paramsArg; let key = keyArg; let callback = callbackArg; if (typeof queryOrOptions === "string") { query = queryOrOptions; } else { signal = queryOrOptions.signal; params = queryOrOptions.params; key = queryOrOptions.key; callback = queryOrOptions.callback; query = queryOrOptions.query; } if (!key) { throw new Error("key is required for incremental queries"); } const keyColumn = key; let callbacks: ((results: Results<T>) => void)[] = callback ? [callback] : []; const rowsMap = new Map<unknown, Row>(); const afterMap = new Map<unknown, unknown>(); let lastRows: T[] = []; let firstRun = true; const { fields, unsubscribe: unsubscribeChanges, refresh, } = await changes<Row>(query, params, keyColumn, (changeList) => { for (const change of changeList) { const { __op__: op, __changed_columns__: changedColumns, ...obj } = change as Row & { __op__: Change<Row>["__op__"]; __changed_columns__?: string[]; }; switch (op) { case "RESET": rowsMap.clear(); afterMap.clear(); break; case "INSERT": rowsMap.set(obj[keyColumn], obj); afterMap.set(obj["__after__"], obj[keyColumn]); break; case "DELETE": { const oldObj = rowsMap.get(obj[keyColumn]); rowsMap.delete(obj[keyColumn]); // null is the starting point: another insert may already have taken its place. if (oldObj && oldObj["__after__"] !== null) { afterMap.delete(oldObj["__after__"]); } break; } case "UPDATE": { const newObj: Row = { ...rowsMap.get(obj[keyColumn]) }; for (const columnName of changedColumns ?? []) { newObj[columnName] = obj[columnName]; if (columnName === "__after__") { afterMap.set(obj["__after__"], obj[keyColumn]); } } rowsMap.set(obj[keyColumn], newObj); break; } } } // The rows in order, following each row's predecessor link. const rows: T[] = []; let lastKey: unknown = null; for (let i = 0; i < rowsMap.size; i++) { const nextKey = afterMap.get(lastKey); const obj = rowsMap.get(nextKey); if (!obj) break; const { __after__: _after, ...cleanObj } = obj; rows.push(cleanObj as T); lastKey = nextKey; } lastRows = rows; if (!firstRun) { runResultCallbacks(callbacks, { rows, fields }); } }); firstRun = false; runResultCallbacks(callbacks, { rows: lastRows, fields }); const subscribe = (subscriber: (results: Results<T>) => void) => { callbacks.push(subscriber); }; const unsubscribe = async (subscriber?: (results: Results<T>) => void) => { callbacks = subscriber ? callbacks.filter((cb) => cb !== subscriber) : []; if (callbacks.length === 0) { await unsubscribeChanges(); } }; await unsubscribeOnAbort(signal, unsubscribe); return { initialResults: { rows: lastRows, fields }, subscribe, unsubscribe, refresh, }; } return { query, changes, incrementalQuery } as LiveNamespace; } /** * Live queries: `live.query`, `live.changes` and `live.incrementalQuery`, re-run when a table they read * changes (statement-level NOTIFY triggers, created once per table). */ export const live: Extension<LiveNamespace> = { name: "Live Queries", setup: async (pg: Pgwasm) => ({ namespace: createNamespace(pg) }), };