@replayio-app-building/netlify-recorder 0.11.0 → 0.12.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,46 @@ 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 after each query,
446
+ * any new `audit_log` rows (with NULL `replay_request_id`) are tagged
447
+ * with the current request ID and an incrementing call index.
448
+ *
449
+ * The request ID is read from module-level state set by
450
+ * `createRecordingRequestHandler`. If no request is active, the
451
+ * wrapper passes calls through without tagging.
452
+ *
453
+ * @example
454
+ * ```ts
455
+ * import { createAuditedSql } from "@replayio-app-building/netlify-recorder";
456
+ *
457
+ * const sql = createAuditedSql(getSql());
458
+ * await sql`INSERT INTO haikus (text) VALUES (${haiku})`;
459
+ * // audit_log entries from this INSERT are now tagged with the request ID
460
+ * ```
461
+ */
462
+ declare function createAuditedSql(sql: SqlFunction): SqlFunction;
463
+
464
+ declare function getCurrentRequestId(): string | null;
465
+
466
+ 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,107 @@ 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
+ BEGIN
1134
+ IF TG_OP = 'INSERT' THEN
1135
+ INSERT INTO audit_log (table_name, record_id, action, new_data)
1136
+ VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', to_jsonb(NEW));
1137
+ RETURN NEW;
1138
+ ELSIF TG_OP = 'UPDATE' THEN
1139
+ SELECT ARRAY_AGG(n.key) INTO changed_cols
1140
+ FROM jsonb_each_text(to_jsonb(NEW)) n
1141
+ LEFT JOIN jsonb_each_text(to_jsonb(OLD)) o ON n.key = o.key
1142
+ WHERE o.value IS DISTINCT FROM n.value;
1143
+
1144
+ INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_fields)
1145
+ VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]));
1146
+ RETURN NEW;
1147
+ ELSIF TG_OP = 'DELETE' THEN
1148
+ INSERT INTO audit_log (table_name, record_id, action, old_data)
1149
+ VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', to_jsonb(OLD));
1150
+ RETURN OLD;
1151
+ END IF;
1152
+ RETURN NULL;
1153
+ END;
1154
+ $$ LANGUAGE plpgsql
1155
+ `;
1156
+ }
1157
+ async function databaseAuditMonitorTable(sql, tableName) {
1158
+ if (tableName === "audit_log") {
1159
+ throw new Error("Cannot monitor the audit_log table itself");
1160
+ }
1161
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
1162
+ throw new Error(`Invalid table name: ${tableName}`);
1163
+ }
1164
+ const triggerName = `audit_trigger_${tableName}`;
1165
+ await sql(
1166
+ `DROP TRIGGER IF EXISTS ${triggerName} ON "${tableName}"`,
1167
+ []
1168
+ );
1169
+ await sql(
1170
+ `CREATE TRIGGER ${triggerName} AFTER INSERT OR UPDATE OR DELETE ON "${tableName}" FOR EACH ROW EXECUTE FUNCTION audit_trigger_function()`,
1171
+ []
1172
+ );
1173
+ }
1174
+ async function databaseAuditDumpLogTable(sql) {
1175
+ const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
1176
+ return rows;
1177
+ }
1178
+ function createAuditedSql(sql) {
1179
+ let callIndex = 0;
1180
+ const requestId = getCurrentRequestId();
1181
+ const auditedSql = async (strings, ...values) => {
1182
+ const result = await sql(strings, ...values);
1183
+ callIndex++;
1184
+ if (requestId) {
1185
+ try {
1186
+ await sql`
1187
+ UPDATE audit_log
1188
+ SET replay_request_id = ${requestId}, replay_request_call_index = ${callIndex}
1189
+ WHERE replay_request_id IS NULL
1190
+ `;
1191
+ } catch (err) {
1192
+ console.warn("netlify-recorder: failed to update audit log:", err);
1193
+ }
1194
+ }
1195
+ return result;
1196
+ };
1197
+ return auditedSql;
1198
+ }
1094
1199
  export {
1200
+ createAuditedSql,
1095
1201
  createRecordingRequestHandler,
1096
1202
  createRequestRecording,
1203
+ databaseAuditDumpLogTable,
1204
+ databaseAuditEnsureLogTable,
1205
+ databaseAuditMonitorTable,
1097
1206
  ensureRequestRecording,
1098
1207
  finishRequest,
1208
+ getCurrentRequestId,
1099
1209
  readInfraConfigFromEnv,
1100
1210
  redactBlobData,
1101
1211
  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.12.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {