Files
pikasTech-HWLAB/internal/db/runtime-store-postgres-notify.ts

146 lines
4.8 KiB
TypeScript

/* PostgreSQL projection notifications wake SSE readers; the outbox remains the payload authority. */
const WORKBENCH_PROJECTION_CHANNEL = "hwlab_workbench_projection";
const RECONNECT_DELAY_MS = 500;
const hubs = new WeakMap();
export async function subscribeWorkbenchProjectionCommits(store, listener) {
if (typeof listener !== "function") throw codedError("workbench_projection_listener_invalid", "Projection notification listener must be a function.");
let hub = hubs.get(store);
if (!hub) {
hub = { store, listeners: new Set(), client: null, connectPromise: null, reconnectTimer: null, connectedOnce: false, closing: false };
hubs.set(store, hub);
}
hub.listeners.add(listener);
hub.closing = false;
await ensureConnected(hub);
let subscribed = true;
return async () => {
if (!subscribed) return;
subscribed = false;
hub.listeners.delete(listener);
if (hub.listeners.size === 0) await closeHub(hub);
};
}
async function ensureConnected(hub) {
if (hub.client || hub.connectPromise || hub.closing || hub.listeners.size === 0) return hub.connectPromise;
hub.connectPromise = (async () => {
const queryClient = await hub.store.getQueryClient();
const client = typeof queryClient?.connect === "function" ? await queryClient.connect() : queryClient;
if (!client || typeof client.query !== "function" || typeof client.on !== "function") {
client?.release?.();
throw codedError("workbench_projection_notify_unsupported", "Postgres runtime adapter requires a dedicated LISTEN connection.");
}
const onNotification = (message) => {
if (message?.channel !== WORKBENCH_PROJECTION_CHANNEL) return;
const signal = parseSignal(message.payload);
for (const notify of [...hub.listeners]) {
try { notify(signal); } catch {}
}
};
const onDisconnect = (error) => disconnectHubClient(hub, client, error);
client.on("notification", onNotification);
client.on("error", onDisconnect);
client.on("end", onDisconnect);
client.__hwlabProjectionNotifyHandlers = { onNotification, onDisconnect };
try {
await client.query(`LISTEN ${WORKBENCH_PROJECTION_CHANNEL}`);
} catch (error) {
detachClient(client, error);
throw error;
}
if (hub.closing || hub.listeners.size === 0) {
detachClient(client);
return;
}
hub.client = client;
if (hub.connectedOnce) emitRecovery(hub);
hub.connectedOnce = true;
})().finally(() => {
hub.connectPromise = null;
});
return hub.connectPromise;
}
function disconnectHubClient(hub, client, error) {
if (client && hub.client !== client) return;
if (client) {
hub.client = null;
detachClient(client, error);
}
scheduleReconnect(hub, error);
}
function scheduleReconnect(hub, error) {
if (hub.closing || hub.listeners.size === 0 || hub.reconnectTimer) return;
hub.store.logger?.warn?.({ event: "workbench_projection_notify_disconnected", errorCode: error?.code ?? "UNKNOWN", valuesRedacted: true });
hub.reconnectTimer = setTimeout(() => {
hub.reconnectTimer = null;
void ensureConnected(hub).catch((connectError) => scheduleReconnect(hub, connectError));
}, RECONNECT_DELAY_MS);
hub.reconnectTimer.unref?.();
}
async function closeHub(hub) {
hub.closing = true;
if (hub.reconnectTimer) clearTimeout(hub.reconnectTimer);
hub.reconnectTimer = null;
const client = hub.client;
hub.client = null;
if (client) {
try { await client.query(`UNLISTEN ${WORKBENCH_PROJECTION_CHANNEL}`); } catch {}
detachClient(client);
}
hubs.delete(hub.store);
}
function detachClient(client, error = null) {
const handlers = client?.__hwlabProjectionNotifyHandlers;
if (handlers) {
client.off?.("notification", handlers.onNotification);
client.off?.("error", handlers.onDisconnect);
client.off?.("end", handlers.onDisconnect);
delete client.__hwlabProjectionNotifyHandlers;
}
client?.release?.(error || undefined);
}
function emitRecovery(hub) {
for (const notify of [...hub.listeners]) {
try { notify({ recovery: true, valuesRedacted: true }); } catch {}
}
}
function parseSignal(payload) {
try {
const value = JSON.parse(String(payload ?? "{}"));
return {
sessionId: text(value.sessionId),
traceId: text(value.traceId),
outboxSeq: nonNegativeInteger(value.outboxSeq),
valuesRedacted: true
};
} catch {
return { recovery: true, valuesRedacted: true };
}
}
function text(value) {
const normalized = String(value ?? "").trim();
return normalized || null;
}
function nonNegativeInteger(value) {
const number = Number(value);
return Number.isInteger(number) && number >= 0 ? number : 0;
}
function codedError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
export { WORKBENCH_PROJECTION_CHANNEL };