pediatric-ai-scribe-v3/src/utils/auditQueue.ts
Daniel 6fa0d87da4 refactor(ts): day 4 — middleware + utils + db .js → .ts (24 files)
All remaining backend files renamed:
  src/middleware/auth.ts, logging.ts (2 files)
  src/utils/*.ts         (20 files: ai, auditQueue, config, crypto,
                          embeddings, errors, fileType, logger, models,
                          notify, passwords, platform, promptSafe,
                          prompts, redact, sessions, transcribe*,
                          ttsGoogle)
  src/db/database.ts, migrate.ts (2 files)

Spot-fixes to satisfy tsc (all within the spirit of 'no behavior
change' — added `: any` annotations where the original JS relied on
duck typing that tsc's default inference narrows too aggressively):

  utils/ai.ts — body, converseParams, request literals + fallback
    result object + err.code/model/message casts. AI client has lots
    of provider-specific ad-hoc object shapes; Day 5 will replace the
    `any`s with proper provider-response interfaces.
  utils/embeddings.ts — payload + request as `any`; generateEmbedding
    call sites pass `undefined as any` for the now-required second
    arg (model) until we refactor the signature.
  utils/prompts.ts — PROMPTS typed as Record<string, any> so
    .loadFromDb / .updatePrompt / .getAllPrompts attachments after
    the const literal compile.
  utils/transcribeLocal.ts — buildArgs() has two `var args = [...]`
    in the same function scope (var-hoisted); both now typed as
    any[] so they don't type-clash across conditionals.

Backend is now 54 of 54 TypeScript files, permissive mode.
`npm run typecheck` EXIT 0. Prod container still running the old
JS image — no Dockerfile change yet.

Next: Day 5 flips strict: true, fixes every error tsc surfaces, adds
Vitest + Zod + Knip tooling.
2026-04-23 19:52:16 +02:00

113 lines
3.7 KiB
TypeScript

// ============================================================
// Audit log batcher — collects audit/api/access entries in memory
// and flushes them to Postgres as batched multi-row INSERTs on a
// 1-second interval or when a buffer reaches 50 entries. Reduces
// per-request DB load from "one INSERT per audit call" to "one
// INSERT per ~50 audit calls under load". Transparent to callers.
//
// Trade-off: if the process crashes between flushes, up to ~1s of
// audit entries are lost. The PostgreSQL row is the primary
// destination; Loki is separately pushed fire-and-forget per call
// and is not batched (Loki has its own server-side ingestion that
// handles batching more cheaply).
// ============================================================
var FLUSH_INTERVAL_MS = 1000;
var FLUSH_MAX_BATCH = 50;
function createQueue(tableName, columns) {
var buffer = [];
var flushing = false;
var timer = null;
// Build "($1,$2,$3,...),($N,$N+1,...),..." with running placeholder index
function buildBatchSql(rows) {
var cols = columns.join(', ');
var placeholders = [];
var params = [];
var k = 1;
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var tuple = [];
for (var j = 0; j < columns.length; j++) {
tuple.push('$' + k++);
params.push(row[columns[j]] != null ? row[columns[j]] : null);
}
placeholders.push('(' + tuple.join(',') + ')');
}
return {
sql: 'INSERT INTO ' + tableName + ' (' + cols + ') VALUES ' + placeholders.join(','),
params: params
};
}
async function flush() {
if (flushing || buffer.length === 0) return;
flushing = true;
var toFlush = buffer.splice(0, FLUSH_MAX_BATCH);
try {
var pool = require('../db/database').pool;
var q = buildBatchSql(toFlush);
await pool.query(q.sql, q.params);
} catch (err) {
console.error('[auditQueue:' + tableName + '] flush failed:', err && err.message);
// Don't re-buffer — these are audit entries, not user data.
// Losing a burst on DB hiccup is acceptable; the alternative is
// unbounded memory growth during DB outages.
} finally {
flushing = false;
// If more accumulated during the await, schedule another round
if (buffer.length >= FLUSH_MAX_BATCH) setImmediate(flush);
}
}
function push(row) {
buffer.push(row);
if (buffer.length >= FLUSH_MAX_BATCH) setImmediate(flush);
if (!timer) {
timer = setInterval(flush, FLUSH_INTERVAL_MS);
if (typeof timer.unref === 'function') timer.unref();
}
}
// Called from the SIGTERM shutdown path — drains any remaining
// entries before the process exits.
async function drain() {
if (timer) { clearInterval(timer); timer = null; }
while (buffer.length > 0) {
await flush();
if (flushing) { await new Promise(function(r) { setTimeout(r, 20); }); }
}
}
return { push: push, drain: drain, size: function() { return buffer.length; } };
}
var auditQueue = createQueue('audit_log', [
'user_id', 'action', 'category', 'details',
'ip_address', 'user_agent', 'model_used', 'tokens_used', 'duration_ms', 'status'
]);
var apiQueue = createQueue('api_log', [
'user_id', 'endpoint', 'method', 'status_code',
'request_size', 'response_size', 'model_used',
'tokens_input', 'tokens_output', 'cost_estimate',
'duration_ms', 'ip_address', 'error'
]);
var accessQueue = createQueue('access_log', [
'user_id', 'action', 'ip_address', 'user_agent', 'success'
]);
async function drainAll() {
try { await auditQueue.drain(); } catch(e) {}
try { await apiQueue.drain(); } catch(e) {}
try { await accessQueue.drain(); } catch(e) {}
}
module.exports = {
audit: auditQueue,
api: apiQueue,
access: accessQueue,
drainAll: drainAll
};