feat: make Kafka projector the Workbench realtime authority
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/* 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 };
|
||||
Reference in New Issue
Block a user