@pgxsinkit/pgwasm
Version:
602 lines (595 loc) • 21.5 kB
JavaScript
// @bun
import {
randomId,
parseDescribeStatementResults
} from "../chunk-tdvrvgt5.js";
import {
TEXT
} from "../chunk-d82gg1hx.js";
import {
serialize2,
protocolAccessOf
} from "../chunk-74gcrk47.js";
// packages/pgwasm/src/live/debounce-mutex.ts
function debounceMutex(fn) {
let next;
let isRunning = false;
const processNext = async () => {
const current = next;
if (!current) {
isRunning = false;
return;
}
isRunning = true;
next = undefined;
try {
current.resolve(await fn(...current.args));
} catch (error) {
current.reject(error);
} finally {
processNext();
}
};
return async (...args) => {
next?.resolve(undefined);
const promise = new Promise((resolve, reject) => {
next = { args, resolve, reject };
});
if (!isRunning)
processNext();
return await promise;
};
}
// packages/pgwasm/src/live/format-query.ts
async function formatQuery(pg, query, params, tx) {
if (!params || params.length === 0) {
return query;
}
const wire = protocolAccessOf(pg);
const messages = [];
try {
await wire.execProtocol(serialize2.parse({ text: query }), { persist: false });
messages.push(...(await wire.execProtocol(serialize2.describe({ type: "S" }), { persist: false })).messages);
} finally {
messages.push(...(await wire.execProtocol(serialize2.sync(), { persist: false })).messages);
}
const dataTypeIDs = parseDescribeStatementResults(messages);
const subbedQuery = query.replace(/\$([0-9]+)/g, (_, num) => `%${num}$L`);
const runner = tx ?? pg;
const result = await runner.query(`SELECT format($1, ${params.map((_, i) => `$${i + 2}`).join(", ")}) as query`, [subbedQuery, ...params], { paramTypes: [TEXT, ...dataTypeIDs] });
const formatted = result.rows[0]?.query;
if (formatted === undefined)
throw new Error("format() returned no row");
return formatted;
}
// packages/pgwasm/src/live/index.ts
var MAX_RETRIES = 5;
function runResultCallbacks(callbacks, results) {
for (const callback of callbacks)
callback(results);
}
function runChangeCallbacks(callbacks, changes) {
for (const callback of callbacks)
callback(changes);
}
async function getTablesForView(tx, viewName) {
const result = await tx.query(`
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
}));
}
async function addNotifyTriggersToTables(tx, tables, tableNotifyTriggersAdded) {
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(`
`);
if (triggers.trim() !== "") {
await tx.exec(triggers);
}
for (const table of tables)
tableNotifyTriggersAdded.add(`${table.schema_oid}_${table.table_oid}`);
}
function listenToTables(tx, tables, onChange) {
return Promise.all(tables.map((table) => tx.listen(`"table_change__${table.schema_oid}__${table.table_oid}"`, () => {
onChange();
})));
}
function unsubscribeOnAbort(signal, unsubscribe) {
if (signal?.aborted)
return unsubscribe();
signal?.addEventListener("abort", () => {
unsubscribe();
}, { once: true });
}
function createNamespace(pg) {
const tableNotifyTriggersAdded = new Set;
async function query(queryOrOptions, paramsArg, callbackArg) {
let signal;
let offset;
let limit;
let query;
let params = 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 = callback ? [callback] : [];
const id = randomId();
let dead = false;
let totalCount;
let results = { rows: [], fields: [] };
let unsubList = [];
let refreshReady = false;
let refreshPending = false;
const notifyRefresh = () => {
if (!refreshReady) {
refreshPending = true;
return;
}
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(`EXECUTE live_query_${id}_get_total_count;`)).rows[0]?.count;
results = {
...await tx.query(`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(`EXECUTE live_query_${id}_get;`);
}
unsubList = await listenToTables(tx, tables, notifyRefresh);
});
};
await init();
const refresh = debounceMutex(async ({ offset: newOffset, limit: newLimit } = {}) => {
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) => {
if (callbacks.length === 0)
return;
try {
if (isWindowed) {
results = {
...await pg.query(`EXECUTE live_query_${id}_get(${limit}, ${offset});`),
...offset === undefined ? {} : { offset },
...limit === undefined ? {} : { limit },
...totalCount === undefined ? {} : { totalCount }
};
} else {
results = await pg.query(`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")) {
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(`EXECUTE live_query_${id}_get_total_count;`)).rows[0]?.count;
if (newTotalCount !== totalCount) {
totalCount = newTotalCount;
refresh();
}
}
};
await run();
});
refreshReady = true;
if (refreshPending) {
refreshPending = false;
refresh();
}
const subscribe = (subscriber) => {
if (dead) {
throw new Error("Live query is no longer active and cannot be subscribed to");
}
callbacks.push(subscriber);
};
const unsubscribe = async (subscriber) => {
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(queryOrOptions, paramsArg, keyArg, callbackArg) {
let signal;
let query;
let params = 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 = callback ? [callback] : [];
const id = randomId();
let dead = false;
let stateSwitch = 1;
let changesResult;
let unsubList = [];
let refreshReady = false;
let refreshPending = false;
const notifyRefresh = () => {
if (!refreshReady) {
refreshPending = true;
return;
}
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 = [
...(await tx.query(`
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" }
];
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) => `NULL${column.data_type === "USER-DEFINED" ? `::${column.udt_name ?? ""}` : ""}`;
for (const curr of [1, 2]) {
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(`,
`)},
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(`,
`)},
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(`,
`)},
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(`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`) {
reset = true;
await init();
continue;
}
throw error;
}
}
const resetChange = { __op__: "RESET" };
runChangeCallbacks(callbacks, [...reset ? [resetChange] : [], ...changesResult?.rows ?? []]);
});
refreshReady = true;
const subscribe = (subscriber) => {
if (dead) {
throw new Error("Live query is no longer active and cannot be subscribed to");
}
callbacks.push(subscriber);
};
const unsubscribe = async (subscriber) => {
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);
await refresh();
if (refreshPending) {
refreshPending = false;
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(queryOrOptions, paramsArg, keyArg, callbackArg) {
let signal;
let query;
let params = 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 = callback ? [callback] : [];
const rowsMap = new Map;
const afterMap = new Map;
let lastRows = [];
let firstRun = true;
const {
fields,
unsubscribe: unsubscribeChanges,
refresh
} = await changes(query, params, keyColumn, (changeList) => {
for (const change of changeList) {
const {
__op__: op,
__changed_columns__: changedColumns,
...obj
} = change;
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]);
if (oldObj && oldObj["__after__"] !== null) {
afterMap.delete(oldObj["__after__"]);
}
break;
}
case "UPDATE": {
const newObj = { ...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;
}
}
}
const rows = [];
let lastKey = 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);
lastKey = nextKey;
}
lastRows = rows;
if (!firstRun) {
runResultCallbacks(callbacks, { rows, fields });
}
});
firstRun = false;
runResultCallbacks(callbacks, { rows: lastRows, fields });
const subscribe = (subscriber) => {
callbacks.push(subscriber);
};
const unsubscribe = async (subscriber) => {
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 };
}
var live = {
name: "Live Queries",
setup: async (pg) => ({ namespace: createNamespace(pg) })
};
export {
live
};
//# debugId=323267DFA5AC6F5064756E2164756E21