@replayio-app-building/netlify-recorder 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -421,4 +421,44 @@ interface SpawnRecordingContainerOptions {
421
421
  */
422
422
  declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
423
423
 
424
- export { type BlobData, type CapturedData, type ContainerInfraConfig, type CreateRecordingRequestHandlerOptions, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRecordingRequestHandler, createRequestRecording, ensureRequestRecording, finishRequest, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
424
+ type SqlFunction = (...args: any[]) => Promise<any[]>;
425
+ /**
426
+ * Creates the `audit_log` table and a generic PL/pgSQL trigger function
427
+ * (`audit_trigger_function`) that records INSERT, UPDATE, and DELETE
428
+ * operations on any monitored table.
429
+ *
430
+ * Call this once during schema initialization.
431
+ */
432
+ declare function databaseAuditEnsureLogTable(sql: SqlFunction): Promise<void>;
433
+ /**
434
+ * Creates a trigger on the specified table that calls
435
+ * `audit_trigger_function` for INSERT, UPDATE, and DELETE operations.
436
+ *
437
+ * Throws if `tableName` is `'audit_log'` (cannot monitor itself).
438
+ */
439
+ declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string): Promise<void>;
440
+ /**
441
+ * Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
442
+ */
443
+ declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
444
+ /**
445
+ * Wraps a Neon SQL tagged-template function so that before each query,
446
+ * `SET LOCAL` is used to inject the current request ID and call index
447
+ * into the PostgreSQL session. The `audit_trigger_function` reads these
448
+ * via `current_setting()` and stamps audit rows atomically at INSERT
449
+ * time inside the trigger — no separate UPDATE required.
450
+ *
451
+ * @example
452
+ * ```ts
453
+ * import { createAuditedSql } from "@replayio-app-building/netlify-recorder";
454
+ *
455
+ * const sql = createAuditedSql(getSql());
456
+ * await sql`INSERT INTO haikus (text) VALUES (${haiku})`;
457
+ * // audit_log row created by the trigger already has the request ID
458
+ * ```
459
+ */
460
+ declare function createAuditedSql(sql: SqlFunction): SqlFunction;
461
+
462
+ declare function getCurrentRequestId(): string | null;
463
+
464
+ export { type BlobData, type CapturedData, type ContainerInfraConfig, type CreateRecordingRequestHandlerOptions, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createAuditedSql, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
package/dist/index.js CHANGED
@@ -479,18 +479,32 @@ async function finishRequest(requestContext, callbacks, response, options) {
479
479
 
480
480
  // src/createRecordingRequestHandler.ts
481
481
  import crypto from "crypto";
482
+
483
+ // src/requestState.ts
484
+ var _currentRequestId = null;
485
+ function setCurrentRequestId(id) {
486
+ _currentRequestId = id;
487
+ }
488
+ function getCurrentRequestId() {
489
+ return _currentRequestId;
490
+ }
491
+
492
+ // src/createRecordingRequestHandler.ts
482
493
  function createRecordingRequestHandler(handler, options) {
483
494
  return async (event, context) => {
495
+ const requestId = crypto.randomUUID();
496
+ setCurrentRequestId(requestId);
484
497
  const reqContext = startRequest(event);
485
498
  let response;
486
499
  try {
487
500
  response = await handler(event, context);
488
501
  } catch (err) {
502
+ setCurrentRequestId(null);
489
503
  reqContext.cleanup();
490
504
  throw err;
491
505
  }
492
506
  reqContext.cleanup();
493
- const requestId = crypto.randomUUID();
507
+ setCurrentRequestId(null);
494
508
  const responseWithHeader = {
495
509
  ...response,
496
510
  headers: {
@@ -1091,11 +1105,116 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
1091
1105
  }
1092
1106
  return result;
1093
1107
  }
1108
+
1109
+ // src/databaseAudit.ts
1110
+ async function databaseAuditEnsureLogTable(sql) {
1111
+ await sql`
1112
+ CREATE TABLE IF NOT EXISTS audit_log (
1113
+ id BIGSERIAL PRIMARY KEY,
1114
+ table_name TEXT NOT NULL,
1115
+ record_id TEXT NOT NULL,
1116
+ action TEXT NOT NULL,
1117
+ old_data JSONB,
1118
+ new_data JSONB,
1119
+ changed_fields TEXT[],
1120
+ performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
1121
+ replay_request_id TEXT,
1122
+ replay_request_call_index INTEGER
1123
+ )
1124
+ `;
1125
+ await sql`CREATE INDEX IF NOT EXISTS idx_audit_log_table_name ON audit_log (table_name)`;
1126
+ await sql`CREATE INDEX IF NOT EXISTS idx_audit_log_replay_request_id ON audit_log (replay_request_id)`;
1127
+ await sql`CREATE INDEX IF NOT EXISTS idx_audit_log_performed_at ON audit_log (performed_at DESC)`;
1128
+ await sql`
1129
+ CREATE OR REPLACE FUNCTION audit_trigger_function()
1130
+ RETURNS TRIGGER AS $$
1131
+ DECLARE
1132
+ changed_cols TEXT[];
1133
+ req_id TEXT;
1134
+ call_idx INTEGER;
1135
+ BEGIN
1136
+ -- Read application context set via SET LOCAL by createAuditedSql
1137
+ req_id := COALESCE(current_setting('app.replay_request_id', true), '');
1138
+ IF req_id = '' THEN req_id := NULL; END IF;
1139
+
1140
+ BEGIN
1141
+ call_idx := current_setting('app.replay_call_index', true)::INTEGER;
1142
+ EXCEPTION WHEN OTHERS THEN
1143
+ call_idx := NULL;
1144
+ END;
1145
+
1146
+ IF TG_OP = 'INSERT' THEN
1147
+ INSERT INTO audit_log (table_name, record_id, action, new_data, replay_request_id, replay_request_call_index)
1148
+ VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', to_jsonb(NEW), req_id, call_idx);
1149
+ RETURN NEW;
1150
+ ELSIF TG_OP = 'UPDATE' THEN
1151
+ SELECT ARRAY_AGG(n.key) INTO changed_cols
1152
+ FROM jsonb_each_text(to_jsonb(NEW)) n
1153
+ LEFT JOIN jsonb_each_text(to_jsonb(OLD)) o ON n.key = o.key
1154
+ WHERE o.value IS DISTINCT FROM n.value;
1155
+
1156
+ INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_fields, replay_request_id, replay_request_call_index)
1157
+ VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]), req_id, call_idx);
1158
+ RETURN NEW;
1159
+ ELSIF TG_OP = 'DELETE' THEN
1160
+ INSERT INTO audit_log (table_name, record_id, action, old_data, replay_request_id, replay_request_call_index)
1161
+ VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', to_jsonb(OLD), req_id, call_idx);
1162
+ RETURN OLD;
1163
+ END IF;
1164
+ RETURN NULL;
1165
+ END;
1166
+ $$ LANGUAGE plpgsql
1167
+ `;
1168
+ }
1169
+ async function databaseAuditMonitorTable(sql, tableName) {
1170
+ if (tableName === "audit_log") {
1171
+ throw new Error("Cannot monitor the audit_log table itself");
1172
+ }
1173
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
1174
+ throw new Error(`Invalid table name: ${tableName}`);
1175
+ }
1176
+ const triggerName = `audit_trigger_${tableName}`;
1177
+ await sql(
1178
+ `DROP TRIGGER IF EXISTS ${triggerName} ON "${tableName}"`,
1179
+ []
1180
+ );
1181
+ await sql(
1182
+ `CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function()`,
1183
+ []
1184
+ );
1185
+ }
1186
+ async function databaseAuditDumpLogTable(sql) {
1187
+ const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
1188
+ return rows;
1189
+ }
1190
+ function createAuditedSql(sql) {
1191
+ let callIndex = 0;
1192
+ const rawSql = sql;
1193
+ const auditedSql = async (strings, ...values) => {
1194
+ const requestId = getCurrentRequestId();
1195
+ callIndex++;
1196
+ if (requestId) {
1197
+ try {
1198
+ await rawSql(`SET LOCAL app.replay_request_id = '${requestId.replace(/'/g, "''")}'`);
1199
+ await rawSql(`SET LOCAL app.replay_call_index = '${callIndex}'`);
1200
+ } catch (err) {
1201
+ console.warn("netlify-recorder: failed to SET LOCAL audit context:", err);
1202
+ }
1203
+ }
1204
+ return await sql(strings, ...values);
1205
+ };
1206
+ return auditedSql;
1207
+ }
1094
1208
  export {
1209
+ createAuditedSql,
1095
1210
  createRecordingRequestHandler,
1096
1211
  createRequestRecording,
1212
+ databaseAuditDumpLogTable,
1213
+ databaseAuditEnsureLogTable,
1214
+ databaseAuditMonitorTable,
1097
1215
  ensureRequestRecording,
1098
1216
  finishRequest,
1217
+ getCurrentRequestId,
1099
1218
  readInfraConfigFromEnv,
1100
1219
  redactBlobData,
1101
1220
  remoteCallbacks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {