@reprova/sdk 0.4.0 → 0.6.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.
Files changed (147) hide show
  1. package/README.md +170 -8
  2. package/dist/cjs/context.js +28 -0
  3. package/dist/cjs/distributionBaselinePoller.js +87 -0
  4. package/dist/cjs/distributionDrift.js +170 -0
  5. package/dist/cjs/drizzle.js +204 -0
  6. package/dist/cjs/frameworkAdapter.js +2 -0
  7. package/dist/cjs/httpFetch.js +59 -0
  8. package/dist/cjs/idempotency.js +132 -0
  9. package/dist/cjs/idempotencySettingsPoller.js +71 -0
  10. package/dist/cjs/index.js +96 -0
  11. package/dist/cjs/invariantInference.js +90 -0
  12. package/dist/cjs/invariantRules.js +142 -0
  13. package/dist/cjs/knex.js +86 -0
  14. package/dist/cjs/kysely.js +175 -0
  15. package/dist/cjs/legacyPrisma.js +130 -0
  16. package/dist/cjs/mikroorm.js +74 -0
  17. package/dist/cjs/mongoSchemaReporter.js +85 -0
  18. package/dist/cjs/mongoose.js +335 -0
  19. package/dist/cjs/nPlusOne.js +0 -0
  20. package/dist/cjs/nest.js +136 -0
  21. package/dist/cjs/nextjs.js +180 -0
  22. package/dist/cjs/package.json +1 -0
  23. package/dist/cjs/prisma.js +363 -0
  24. package/dist/cjs/proto.gen.js +4 -0
  25. package/dist/cjs/registry.js +100 -0
  26. package/dist/cjs/retryStorm.js +70 -0
  27. package/dist/cjs/rulesPoller.js +77 -0
  28. package/dist/cjs/sdk.js +1231 -0
  29. package/dist/cjs/sequelize.js +108 -0
  30. package/dist/cjs/shape.js +45 -0
  31. package/dist/cjs/stack.js +51 -0
  32. package/dist/cjs/transport.js +71 -0
  33. package/dist/cjs/typeorm.js +200 -0
  34. package/dist/context.d.ts +32 -0
  35. package/dist/context.d.ts.map +1 -1
  36. package/dist/context.js +18 -0
  37. package/dist/context.js.map +1 -1
  38. package/dist/distributionBaselinePoller.d.ts +36 -0
  39. package/dist/distributionBaselinePoller.d.ts.map +1 -0
  40. package/dist/distributionBaselinePoller.js +84 -0
  41. package/dist/distributionBaselinePoller.js.map +1 -0
  42. package/dist/distributionDrift.d.ts +41 -0
  43. package/dist/distributionDrift.d.ts.map +1 -0
  44. package/dist/distributionDrift.js +161 -0
  45. package/dist/distributionDrift.js.map +1 -0
  46. package/dist/drizzle.d.ts +22 -0
  47. package/dist/drizzle.d.ts.map +1 -1
  48. package/dist/drizzle.js +129 -1
  49. package/dist/drizzle.js.map +1 -1
  50. package/dist/frameworkAdapter.d.ts +5 -0
  51. package/dist/frameworkAdapter.d.ts.map +1 -0
  52. package/dist/frameworkAdapter.js +2 -0
  53. package/dist/frameworkAdapter.js.map +1 -0
  54. package/dist/httpFetch.d.ts +13 -0
  55. package/dist/httpFetch.d.ts.map +1 -0
  56. package/dist/httpFetch.js +54 -0
  57. package/dist/httpFetch.js.map +1 -0
  58. package/dist/idempotency.d.ts +26 -0
  59. package/dist/idempotency.d.ts.map +1 -0
  60. package/dist/idempotency.js +124 -0
  61. package/dist/idempotency.js.map +1 -0
  62. package/dist/idempotencySettingsPoller.d.ts +23 -0
  63. package/dist/idempotencySettingsPoller.d.ts.map +1 -0
  64. package/dist/idempotencySettingsPoller.js +68 -0
  65. package/dist/idempotencySettingsPoller.js.map +1 -0
  66. package/dist/index.d.ts +33 -6
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +19 -4
  69. package/dist/index.js.map +1 -1
  70. package/dist/invariantInference.d.ts +28 -0
  71. package/dist/invariantInference.d.ts.map +1 -0
  72. package/dist/invariantInference.js +87 -0
  73. package/dist/invariantInference.js.map +1 -0
  74. package/dist/invariantRules.d.ts +16 -0
  75. package/dist/invariantRules.d.ts.map +1 -0
  76. package/dist/invariantRules.js +104 -0
  77. package/dist/invariantRules.js.map +1 -0
  78. package/dist/knex.d.ts.map +1 -1
  79. package/dist/knex.js +1 -0
  80. package/dist/knex.js.map +1 -1
  81. package/dist/kysely.d.ts.map +1 -1
  82. package/dist/kysely.js +2 -1
  83. package/dist/kysely.js.map +1 -1
  84. package/dist/legacyPrisma.d.ts +6 -0
  85. package/dist/legacyPrisma.d.ts.map +1 -0
  86. package/dist/legacyPrisma.js +128 -0
  87. package/dist/legacyPrisma.js.map +1 -0
  88. package/dist/mikroorm.d.ts +2 -0
  89. package/dist/mikroorm.d.ts.map +1 -0
  90. package/dist/mikroorm.js +72 -0
  91. package/dist/mikroorm.js.map +1 -0
  92. package/dist/mongoSchemaReporter.d.ts +23 -0
  93. package/dist/mongoSchemaReporter.d.ts.map +1 -0
  94. package/dist/mongoSchemaReporter.js +82 -0
  95. package/dist/mongoSchemaReporter.js.map +1 -0
  96. package/dist/mongoose.d.ts +48 -0
  97. package/dist/mongoose.d.ts.map +1 -0
  98. package/dist/mongoose.js +331 -0
  99. package/dist/mongoose.js.map +1 -0
  100. package/dist/nPlusOne.d.ts +52 -0
  101. package/dist/nPlusOne.d.ts.map +1 -0
  102. package/dist/nPlusOne.js +0 -0
  103. package/dist/nPlusOne.js.map +1 -0
  104. package/dist/nest.d.ts +31 -0
  105. package/dist/nest.d.ts.map +1 -0
  106. package/dist/nest.js +133 -0
  107. package/dist/nest.js.map +1 -0
  108. package/dist/nextjs.d.ts +27 -0
  109. package/dist/nextjs.d.ts.map +1 -0
  110. package/dist/nextjs.js +178 -0
  111. package/dist/nextjs.js.map +1 -0
  112. package/dist/prisma.d.ts +21 -5
  113. package/dist/prisma.d.ts.map +1 -1
  114. package/dist/prisma.js +247 -22
  115. package/dist/prisma.js.map +1 -1
  116. package/dist/proto.gen.d.ts +19 -1
  117. package/dist/proto.gen.d.ts.map +1 -1
  118. package/dist/registry.d.ts +1 -0
  119. package/dist/registry.d.ts.map +1 -1
  120. package/dist/registry.js +24 -0
  121. package/dist/registry.js.map +1 -1
  122. package/dist/retryStorm.d.ts +42 -0
  123. package/dist/retryStorm.d.ts.map +1 -0
  124. package/dist/retryStorm.js +65 -0
  125. package/dist/retryStorm.js.map +1 -0
  126. package/dist/rulesPoller.d.ts +26 -0
  127. package/dist/rulesPoller.d.ts.map +1 -0
  128. package/dist/rulesPoller.js +74 -0
  129. package/dist/rulesPoller.js.map +1 -0
  130. package/dist/sdk.d.ts +53 -6
  131. package/dist/sdk.d.ts.map +1 -1
  132. package/dist/sdk.js +663 -34
  133. package/dist/sdk.js.map +1 -1
  134. package/dist/sequelize.d.ts.map +1 -1
  135. package/dist/sequelize.js +4 -3
  136. package/dist/sequelize.js.map +1 -1
  137. package/dist/stack.d.ts.map +1 -1
  138. package/dist/stack.js +6 -3
  139. package/dist/stack.js.map +1 -1
  140. package/dist/transport.d.ts.map +1 -1
  141. package/dist/transport.js +2 -1
  142. package/dist/transport.js.map +1 -1
  143. package/dist/typeorm.d.ts +1 -0
  144. package/dist/typeorm.d.ts.map +1 -1
  145. package/dist/typeorm.js +133 -1
  146. package/dist/typeorm.js.map +1 -1
  147. package/package.json +20 -5
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installSequelizeHooks = installSequelizeHooks;
4
+ const context_js_1 = require("./context.js");
5
+ function readAttr(instance, attr) {
6
+ if (instance && typeof instance.get === 'function') {
7
+ try {
8
+ const v = instance.get(attr);
9
+ if (v !== undefined)
10
+ return v;
11
+ }
12
+ catch {
13
+ // fall through to direct property access
14
+ }
15
+ }
16
+ return instance?.[attr];
17
+ }
18
+ function extractPks(instances, pkAttrs) {
19
+ if (pkAttrs.length === 0)
20
+ return [];
21
+ const seen = new Set();
22
+ const pks = [];
23
+ for (const inst of instances) {
24
+ const vals = pkAttrs.map((a) => readAttr(inst, a));
25
+ if (vals.some((v) => v === undefined || v === null))
26
+ continue;
27
+ const pk = vals.map((v) => String(v)).join(':');
28
+ if (!seen.has(pk)) {
29
+ seen.add(pk);
30
+ pks.push(pk);
31
+ }
32
+ }
33
+ return pks;
34
+ }
35
+ function whereShape(options, capture) {
36
+ if (!capture || !options?.where)
37
+ return undefined;
38
+ const cols = Object.keys(options.where).sort(); // symbol Op keys are skipped
39
+ return cols.length ? cols.join(',') : undefined;
40
+ }
41
+ function push(entry) {
42
+ const ctx = context_js_1.contextStorage.getStore();
43
+ if (ctx)
44
+ ctx.footprint.push(entry);
45
+ }
46
+ function installOnModel(model, capture) {
47
+ const pkAttrs = () => model.primaryKeyAttributes ?? ['id'];
48
+ model.addHook('afterFind', (result, options) => {
49
+ try {
50
+ const items = (Array.isArray(result) ? result : result ? [result] : []);
51
+ const pks = extractPks(items, pkAttrs());
52
+ push({
53
+ model: model.tableName,
54
+ op: 'select',
55
+ pks,
56
+ where_shape: whereShape(options, capture),
57
+ count: items.length,
58
+ note: items.length === 0 ? 'returned 0 rows' : undefined,
59
+ resolved: true,
60
+ });
61
+ }
62
+ catch { /* never break a query */ }
63
+ });
64
+ const recordWrite = (op) => (instance) => {
65
+ try {
66
+ const pks = extractPks([instance], pkAttrs());
67
+ push({ model: model.tableName, op, pks, count: 1, resolved: true });
68
+ }
69
+ catch { /* ignore */ }
70
+ };
71
+ model.addHook('afterCreate', recordWrite('insert'));
72
+ model.addHook('afterUpdate', recordWrite('update'));
73
+ model.addHook('afterDestroy', recordWrite('delete'));
74
+ model.addHook('afterBulkCreate', (instances) => {
75
+ try {
76
+ const items = (Array.isArray(instances) ? instances : []);
77
+ push({ model: model.tableName, op: 'insert', pks: extractPks(items, pkAttrs()), count: items.length, resolved: true });
78
+ }
79
+ catch { /* ignore */ }
80
+ });
81
+ // Bulk update/destroy operate by WHERE without materializing instances, so
82
+ // we record the touch + where_shape but no PKs.
83
+ const recordBulk = (op) => (options) => {
84
+ try {
85
+ push({ model: model.tableName, op, pks: [], where_shape: whereShape(options, capture), count: 0, note: `bulk ${op}`, resolved: true });
86
+ }
87
+ catch { /* ignore */ }
88
+ };
89
+ model.addHook('afterBulkUpdate', recordBulk('update'));
90
+ model.addHook('afterBulkDestroy', recordBulk('delete'));
91
+ }
92
+ // Installs footprint hooks on every model of a Sequelize instance (and any
93
+ // defined later). Call once after your models are defined.
94
+ function installSequelizeHooks(sequelize, opts = {}) {
95
+ const capture = opts.captureWhereShape !== false;
96
+ const sq = sequelize;
97
+ try {
98
+ for (const model of Object.values(sq.models ?? {}))
99
+ installOnModel(model, capture);
100
+ sq.addHook('afterDefine', (model) => {
101
+ try {
102
+ installOnModel(model, capture);
103
+ }
104
+ catch { /* ignore */ }
105
+ });
106
+ }
107
+ catch { /* ignore — never throw from install */ }
108
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ // Shapes a value by replacing every scalar leaf with "[type:length]".
3
+ // This prevents PII from leaking into where_shape / args_shape fields.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.shapeValue = shapeValue;
6
+ exports.shapeArgs = shapeArgs;
7
+ function shapeValue(val, depth = 0) {
8
+ if (val === null || val === undefined)
9
+ return 'null';
10
+ if (depth > 3)
11
+ return '[...]';
12
+ const t = typeof val;
13
+ if (t === 'string')
14
+ return `[string:${val.length}]`;
15
+ if (t === 'number')
16
+ return `[number:${String(val).length}]`;
17
+ if (t === 'boolean')
18
+ return `[boolean:1]`;
19
+ if (t === 'bigint')
20
+ return `[bigint:${String(val).length}]`;
21
+ if (Array.isArray(val)) {
22
+ if (val.length === 0)
23
+ return '[]';
24
+ return `[array:${val.length}]`;
25
+ }
26
+ if (t === 'object') {
27
+ const shaped = {};
28
+ for (const [k, v] of Object.entries(val)) {
29
+ shaped[k] = shapeValue(v, depth + 1);
30
+ }
31
+ return JSON.stringify(shaped);
32
+ }
33
+ return `[${t}]`;
34
+ }
35
+ function shapeArgs(args) {
36
+ if (args === null || args === undefined)
37
+ return 'null';
38
+ if (typeof args !== 'object')
39
+ return shapeValue(args);
40
+ const shaped = {};
41
+ for (const [k, v] of Object.entries(args)) {
42
+ shaped[k] = shapeValue(v);
43
+ }
44
+ return JSON.stringify(shaped);
45
+ }
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseStack = parseStack;
4
+ exports.generateTraceId = generateTraceId;
5
+ exports.stripHeaders = stripHeaders;
6
+ exports.captureHeaders = captureHeaders;
7
+ const node_crypto_1 = require("node:crypto");
8
+ // Converts an Error stack trace into an array of frame strings.
9
+ function parseStack(err) {
10
+ if (!err.stack)
11
+ return [];
12
+ const lines = err.stack.split('\n');
13
+ // Skip first line (error message), return the rest trimmed.
14
+ return lines
15
+ .slice(1)
16
+ .map(l => l.trim())
17
+ .filter(l => l.startsWith('at '));
18
+ }
19
+ // Generates a W3C traceparent header value.
20
+ function generateTraceId() {
21
+ const traceId = randomHex(32);
22
+ const spanId = randomHex(16);
23
+ return `00-${traceId}-${spanId}-01`;
24
+ }
25
+ // Node's own crypto module (not the WebCrypto `crypto` global, which only
26
+ // became a stable global in Node 19+) — randomBytes has existed since
27
+ // Node's earliest crypto module, so this works on any Node version this SDK
28
+ // targets, not just modern ones.
29
+ function randomHex(chars) {
30
+ const bytes = Math.ceil(chars / 2);
31
+ return (0, node_crypto_1.randomBytes)(bytes).toString('hex').slice(0, chars);
32
+ }
33
+ // Strips sensitive HTTP headers, replacing values with '[stripped]'.
34
+ const SENSITIVE = /^(authorization|cookie|token|secret)/i;
35
+ function stripHeaders(headers) {
36
+ const out = {};
37
+ for (const [k, v] of Object.entries(headers)) {
38
+ out[k] = SENSITIVE.test(k) ? '[stripped]' : v;
39
+ }
40
+ return out;
41
+ }
42
+ // Captures HTTP headers from Express-style IncomingMessage header object.
43
+ function captureHeaders(raw) {
44
+ const out = {};
45
+ for (const [k, v] of Object.entries(raw)) {
46
+ if (v === undefined)
47
+ continue;
48
+ out[k] = Array.isArray(v) ? v.join(', ') : v;
49
+ }
50
+ return out;
51
+ }
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BatchTransport = void 0;
4
+ const httpFetch_js_1 = require("./httpFetch.js");
5
+ const WARN_INTERVAL_MS = 60_000;
6
+ class BatchTransport {
7
+ queue = [];
8
+ timer = null;
9
+ lastWarnAt = 0;
10
+ endpoint;
11
+ apiKey;
12
+ maxBatch;
13
+ flushIntervalMs;
14
+ constructor(opts) {
15
+ this.endpoint = opts.endpoint;
16
+ this.apiKey = opts.apiKey;
17
+ this.maxBatch = opts.maxBatch ?? 10;
18
+ this.flushIntervalMs = opts.flushIntervalMs ?? 2_000;
19
+ }
20
+ start() {
21
+ this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
22
+ if (this.timer.unref)
23
+ this.timer.unref(); // don't block process exit
24
+ }
25
+ stop() {
26
+ if (this.timer) {
27
+ clearInterval(this.timer);
28
+ this.timer = null;
29
+ }
30
+ }
31
+ enqueue(payload) {
32
+ this.queue.push(payload);
33
+ if (this.queue.length >= this.maxBatch) {
34
+ void this.flush();
35
+ }
36
+ }
37
+ async flush() {
38
+ if (this.queue.length === 0)
39
+ return;
40
+ const batch = this.queue.splice(0, this.maxBatch);
41
+ for (const payload of batch) {
42
+ await this.sendOne(payload);
43
+ }
44
+ }
45
+ async sendOne(payload) {
46
+ try {
47
+ const res = await (0, httpFetch_js_1.httpFetch)(this.endpoint, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ 'X-Reprova-Key': this.apiKey,
52
+ },
53
+ body: JSON.stringify(payload),
54
+ });
55
+ if (!res.ok) {
56
+ this.warnOnce(`reprova: ingest returned ${res.status}`);
57
+ }
58
+ }
59
+ catch (err) {
60
+ this.warnOnce(`reprova: transport error: ${err.message}`);
61
+ }
62
+ }
63
+ warnOnce(msg) {
64
+ const now = Date.now();
65
+ if (now - this.lastWarnAt > WARN_INTERVAL_MS) {
66
+ this.lastWarnAt = now;
67
+ console.warn(msg);
68
+ }
69
+ }
70
+ }
71
+ exports.BatchTransport = BatchTransport;
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installTypeOrmSubscriber = installTypeOrmSubscriber;
4
+ exports.installTypeOrmQueryLogger = installTypeOrmQueryLogger;
5
+ const context_js_1 = require("./context.js");
6
+ function pkOf(entity, metadata) {
7
+ const cols = metadata?.primaryColumns ?? [];
8
+ if (!entity || cols.length === 0)
9
+ return null;
10
+ const vals = cols.map((c) => entity[c.propertyName]);
11
+ if (vals.some((v) => v === undefined || v === null))
12
+ return null;
13
+ return vals.map((v) => String(v)).join(':');
14
+ }
15
+ // Find-or-create the (table, op) entry for this request and fold in the PK.
16
+ function recordPk(table, op, pk) {
17
+ const ctx = context_js_1.contextStorage.getStore();
18
+ if (!ctx || !table)
19
+ return;
20
+ let entry = ctx.footprint.find((f) => f.model === table && f.op === op);
21
+ if (!entry) {
22
+ entry = { model: table, op, pks: [], count: 0, resolved: true };
23
+ ctx.footprint.push(entry);
24
+ }
25
+ if (pk !== null && !entry.pks.includes(pk))
26
+ entry.pks.push(pk);
27
+ entry.count = entry.pks.length;
28
+ }
29
+ // Installs a footprint subscriber on an initialized TypeORM DataSource. Call
30
+ // AFTER dataSource.initialize() — the subscribers array exists once the source
31
+ // is initialized. Attaches an instance directly, so the host needs no
32
+ // @EventSubscriber()-decorated class.
33
+ function installTypeOrmSubscriber(dataSource) {
34
+ const subscriber = {
35
+ afterLoad(entity, event) {
36
+ try {
37
+ recordPk(event?.metadata?.tableName, 'select', pkOf(entity, event?.metadata));
38
+ }
39
+ catch { /* never break */ }
40
+ },
41
+ afterInsert(event) {
42
+ try {
43
+ recordPk(event?.metadata?.tableName, 'insert', pkOf(event?.entity, event?.metadata));
44
+ }
45
+ catch { /* ignore */ }
46
+ },
47
+ afterUpdate(event) {
48
+ try {
49
+ recordPk(event?.metadata?.tableName, 'update', pkOf(event?.entity, event?.metadata));
50
+ }
51
+ catch { /* ignore */ }
52
+ },
53
+ afterRemove(event) {
54
+ try {
55
+ recordPk(event?.metadata?.tableName, 'delete', pkOf(event?.databaseEntity ?? event?.entity, event?.metadata));
56
+ }
57
+ catch { /* ignore */ }
58
+ },
59
+ };
60
+ try {
61
+ const ds = dataSource;
62
+ if (Array.isArray(ds.subscribers))
63
+ ds.subscribers.push(subscriber);
64
+ else
65
+ ds.subscribers = [subscriber];
66
+ }
67
+ catch { /* never throw from install */ }
68
+ installTypeOrmQueryLogger(dataSource);
69
+ }
70
+ // Entity subscribers only fire on SUCCESS (afterInsert/afterUpdate/etc. never
71
+ // broadcast for a query the database rejected), so a failed write — a
72
+ // constraint violation, a too-long value, a deadlock — leaves no trace in the
73
+ // footprint at all: the request just carries the thrown error with nothing
74
+ // showing what was attempted. This records what the write attempt LOOKED
75
+ // LIKE — table, operation, and the column *names* involved — never the bound
76
+ // parameter values, which are row data (the too-long username, the duplicate
77
+ // email) and must never enter a footprint per the metadata-only rule. Only
78
+ // parses SQL for INSERT/UPDATE/DELETE; a SELECT that fails (e.g. a raw query
79
+ // builder error) has no row-level meaning to capture and is left alone.
80
+ const WRITE_VERBS = new Set(['insert', 'update', 'delete']);
81
+ function parseFailedWrite(sql) {
82
+ const trimmed = sql.trim();
83
+ const verb = trimmed.match(/^(insert|update|delete)\b/i)?.[1]?.toLowerCase();
84
+ if (!verb || !WRITE_VERBS.has(verb))
85
+ return null;
86
+ const stripIdent = (s) => s.trim().replace(/^[`"[]|[`"\]]$/g, '');
87
+ if (verb === 'insert') {
88
+ // INSERT INTO `table` (`a`, `b`) VALUES (?, ?) / INSERT INTO "table" ("a","b") VALUES ($1,$2)
89
+ const m = trimmed.match(/^insert\s+into\s+([`"[\]\w.]+)\s*\(([^)]*)\)/i);
90
+ if (!m)
91
+ return null;
92
+ return {
93
+ op: 'insert',
94
+ table: stripIdent(m[1].split('.').pop() ?? m[1]),
95
+ columns: m[2].split(',').map(stripIdent).filter(Boolean),
96
+ };
97
+ }
98
+ if (verb === 'update') {
99
+ // UPDATE `table` SET `a` = ?, `b` = ? [WHERE ...]
100
+ const m = trimmed.match(/^update\s+([`"[\]\w.]+)\s+set\s+([\s\S]*?)(?:\s+where\s[\s\S]*)?$/i);
101
+ if (!m)
102
+ return null;
103
+ return {
104
+ op: 'update',
105
+ table: stripIdent(m[1].split('.').pop() ?? m[1]),
106
+ columns: m[2].split(',').map((assign) => stripIdent(assign.split('=')[0] ?? '')).filter(Boolean),
107
+ };
108
+ }
109
+ // DELETE FROM `table` [WHERE ...] — no column shape, just the table.
110
+ const m = trimmed.match(/^delete\s+from\s+([`"[\]\w.]+)/i);
111
+ if (!m)
112
+ return null;
113
+ return { op: 'delete', table: stripIdent(m[1].split('.').pop() ?? m[1]), columns: [] };
114
+ }
115
+ function recordFailedWrite(ctx, sql) {
116
+ const parsed = parseFailedWrite(sql);
117
+ if (!parsed || !parsed.table)
118
+ return;
119
+ ctx.footprint.push({
120
+ model: parsed.table,
121
+ op: `${parsed.op}_failed`,
122
+ pks: [],
123
+ count: 0,
124
+ where_shape: parsed.columns.length ? parsed.columns.join(',') : undefined,
125
+ note: 'rejected by database — see error for reason',
126
+ resolved: true,
127
+ });
128
+ }
129
+ // Prototypes already patched, keyed by the prototype object itself (TypeORM
130
+ // shares one QueryRunner class per driver/dialect, not per DataSource
131
+ // instance, so this correctly dedupes even if install is called more than
132
+ // once, or against more than one DataSource using the same driver).
133
+ const patchedQueryRunnerProtos = new WeakSet();
134
+ // TypeORM's own driver (at least the "mysql" — not mysql2 — npm package's,
135
+ // which pre-dates async_hooks-aware pooling) acquires its actual DB
136
+ // connection from a pool INSIDE QueryRunner.query()'s own promise executor,
137
+ // and older callback-based drivers like this one are exactly the class of
138
+ // library that has historically NOT reliably propagated Node's
139
+ // AsyncLocalStorage context across that kind of internal async boundary —
140
+ // the same class of bug already fixed for Mongoose's pooled connections in
141
+ // mongoose.ts (context frozen to whatever was ambient when a pooled
142
+ // connection was first used, not when a later query is issued). This patch
143
+ // defends against that regardless of the exact internal cause, by
144
+ // re-asserting the CALLER's context explicitly around every query rather
145
+ // than trusting the driver's own continuation to carry it.
146
+ //
147
+ // One important limitation found while validating this against a real
148
+ // NestJS + TypeORM + MySQL app: if context is ALREADY gone by the time
149
+ // repository.save()/find() is even called — observed happening upstream of
150
+ // this file entirely, in Nest's own ValidationPipe/@Body() parameter
151
+ // resolution for that app's TypeORM/Nest version combination — this patch
152
+ // (and installTypeOrmSubscriber) has nothing left to capture. That is a
153
+ // separate, framework-level gap this file cannot fix from here.
154
+ //
155
+ // The fix: monkey-patch QueryRunner.prototype.query (once per driver, since
156
+ // TypeORM shares that class across DataSources of the same dialect) to
157
+ // capture the CALLER's context synchronously — before any of the driver's
158
+ // own internal pool/socket work runs — then re-run the original call inside
159
+ // contextStorage.run() with that captured value. Per Node's own
160
+ // AsyncLocalStorage contract, any async operation "created within" a run()
161
+ // callback inherits its store; since query()'s returned Promise (and
162
+ // whatever pool acquisition happens inside it) is created there, this
163
+ // restores continuity for both this file's own failed-write capture AND the
164
+ // pre-existing entity-subscriber path, which shares the same underlying
165
+ // QueryRunner.query() call.
166
+ function installTypeOrmQueryLogger(dataSource) {
167
+ try {
168
+ const ds = dataSource;
169
+ if (typeof ds.createQueryRunner !== 'function')
170
+ return;
171
+ const sample = ds.createQueryRunner();
172
+ const proto = Object.getPrototypeOf(sample);
173
+ if (typeof proto.query !== 'function' || patchedQueryRunnerProtos.has(proto)) {
174
+ void sample.release?.().catch(() => { });
175
+ return;
176
+ }
177
+ patchedQueryRunnerProtos.add(proto);
178
+ const originalQuery = proto.query;
179
+ proto.query = function (...args) {
180
+ const capturedCtx = context_js_1.contextStorage.getStore();
181
+ if (!capturedCtx)
182
+ return originalQuery.apply(this, args);
183
+ const sql = typeof args[0] === 'string' ? args[0] : '';
184
+ const result = context_js_1.contextStorage.run(capturedCtx, () => originalQuery.apply(this, args));
185
+ result.catch(() => {
186
+ try {
187
+ recordFailedWrite(capturedCtx, sql);
188
+ }
189
+ catch {
190
+ /* never break the app's own query */
191
+ }
192
+ });
193
+ return result;
194
+ };
195
+ void sample.release?.().catch(() => { });
196
+ }
197
+ catch {
198
+ /* never throw from install */
199
+ }
200
+ }
package/dist/context.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface FootprintQuery {
13
13
  where_shape?: string;
14
14
  count: number;
15
15
  note?: string;
16
+ resolved?: boolean;
16
17
  }
17
18
  export interface OutboundCall {
18
19
  host: string;
@@ -24,15 +25,46 @@ export interface OutboundCall {
24
25
  duration_ms?: number;
25
26
  outcome?: 'success' | 'timeout' | 'network_error';
26
27
  }
28
+ export interface BusinessInvariantHit {
29
+ ruleId: string;
30
+ message: string;
31
+ pks: string[];
32
+ }
33
+ export interface WriteCountDriftHit {
34
+ route: string;
35
+ model: string;
36
+ operation: string;
37
+ }
27
38
  export interface ReprovaContext {
28
39
  traceId: string;
29
40
  capturedAt: Date;
30
41
  request: RequestSnapshot;
31
42
  footprint: FootprintQuery[];
32
43
  outboundCalls: OutboundCall[];
44
+ businessInvariantHits: BusinessInvariantHit[];
45
+ writeCountDriftHits: WriteCountDriftHit[];
33
46
  captured?: boolean;
34
47
  rawRequest?: unknown;
48
+ replayDetection?: {
49
+ trigger: string;
50
+ type: string;
51
+ message: string;
52
+ };
35
53
  }
36
54
  export declare const contextStorage: AsyncLocalStorage<ReprovaContext>;
37
55
  export declare function currentContext(): ReprovaContext | undefined;
56
+ /**
57
+ * Explicitly records the authenticated identity for the in-flight request.
58
+ * Auto-detection already covers `req.auth` (express-oauth2-jwt-bearer/Auth0)
59
+ * and `req.user` (Passport and most hand-rolled Express JWT middleware) —
60
+ * see detectAuthClaims in sdk.ts — so most apps need zero code changes. This
61
+ * is the escape hatch for anything else: a custom property, a non-Express
62
+ * framework. Call it from inside your own auth middleware once the token is
63
+ * verified, e.g. `setAuthClaims(decoded)` right after `jwt.verify`. Without
64
+ * captured claims one way or the other, replay has nothing to re-sign into a
65
+ * token and the app bounces the replayed request as unauthenticated before
66
+ * it ever reaches the captured bug. No-ops outside a Reprova request
67
+ * context.
68
+ */
69
+ export declare function setAuthClaims(claims: Record<string, unknown>): void;
38
70
  //# sourceMappingURL=context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,EAAE,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,EAAE,OAAO,CAAC;IAG7B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,eAAe,CAAC;CACnD;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,IAAI,CAAC;IACjB,OAAO,EAAE,eAAe,CAAC;IACzB,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,QAAQ,CAAC,EAAE,OAAO,CAAC;IAKnB,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,eAAO,MAAM,cAAc,mCAA0C,CAAC;AAEtE,wBAAgB,cAAc,IAAI,cAAc,GAAG,SAAS,CAE3D"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,EAAE,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IAQd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,EAAE,OAAO,CAAC;IAG7B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,eAAe,CAAC;CACnD;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,EAAE,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,IAAI,CAAC;IACjB,OAAO,EAAE,eAAe,CAAC;IACzB,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,qBAAqB,EAAE,oBAAoB,EAAE,CAAC;IAC9C,mBAAmB,EAAE,kBAAkB,EAAE,CAAC;IAC1C,QAAQ,CAAC,EAAE,OAAO,CAAC;IAKnB,UAAU,CAAC,EAAE,OAAO,CAAC;IAOrB,eAAe,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CACtE;AAED,eAAO,MAAM,cAAc,mCAA0C,CAAC;AAEtE,wBAAgB,cAAc,IAAI,cAAc,GAAG,SAAS,CAE3D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAGnE"}
package/dist/context.js CHANGED
@@ -3,4 +3,22 @@ export const contextStorage = new AsyncLocalStorage();
3
3
  export function currentContext() {
4
4
  return contextStorage.getStore();
5
5
  }
6
+ /**
7
+ * Explicitly records the authenticated identity for the in-flight request.
8
+ * Auto-detection already covers `req.auth` (express-oauth2-jwt-bearer/Auth0)
9
+ * and `req.user` (Passport and most hand-rolled Express JWT middleware) —
10
+ * see detectAuthClaims in sdk.ts — so most apps need zero code changes. This
11
+ * is the escape hatch for anything else: a custom property, a non-Express
12
+ * framework. Call it from inside your own auth middleware once the token is
13
+ * verified, e.g. `setAuthClaims(decoded)` right after `jwt.verify`. Without
14
+ * captured claims one way or the other, replay has nothing to re-sign into a
15
+ * token and the app bounces the replayed request as unauthenticated before
16
+ * it ever reaches the captured bug. No-ops outside a Reprova request
17
+ * context.
18
+ */
19
+ export function setAuthClaims(claims) {
20
+ const ctx = currentContext();
21
+ if (ctx)
22
+ ctx.request.authClaims = claims;
23
+ }
6
24
  //# sourceMappingURL=context.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AA8ChD,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,iBAAiB,EAAkB,CAAC;AAEtE,MAAM,UAAU,cAAc;IAC5B,OAAO,cAAc,CAAC,QAAQ,EAAE,CAAC;AACnC,CAAC"}
1
+ {"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AA2EhD,MAAM,CAAC,MAAM,cAAc,GAAG,IAAI,iBAAiB,EAAkB,CAAC;AAEtE,MAAM,UAAU,cAAc;IAC5B,OAAO,cAAc,CAAC,QAAQ,EAAE,CAAC;AACnC,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,aAAa,CAAC,MAA+B;IAC3D,MAAM,GAAG,GAAG,cAAc,EAAE,CAAC;IAC7B,IAAI,GAAG;QAAE,GAAG,CAAC,OAAO,CAAC,UAAU,GAAG,MAAM,CAAC;AAC3C,CAAC"}
@@ -0,0 +1,36 @@
1
+ import type { DistributionBaseline } from './distributionDrift.js';
2
+ interface DistributionBaselinePollerOptions {
3
+ endpoint: string;
4
+ apiKey: string;
5
+ intervalMs?: number;
6
+ }
7
+ export interface DistributionBaselinesResponse {
8
+ enabled?: boolean;
9
+ baselines?: Array<{
10
+ route: string;
11
+ field: string;
12
+ sample_count: number;
13
+ mean: number;
14
+ stddev: number;
15
+ }>;
16
+ }
17
+ export declare class DistributionBaselinePoller {
18
+ private enabled;
19
+ private baselines;
20
+ private timer;
21
+ private lastWarnAt;
22
+ private readonly endpoint;
23
+ private readonly apiKey;
24
+ private readonly intervalMs;
25
+ constructor(opts: DistributionBaselinePollerOptions);
26
+ start(): void;
27
+ stop(): void;
28
+ isEnabled(): boolean;
29
+ getBaseline(route: string, field: string): DistributionBaseline | undefined;
30
+ refresh(): Promise<void>;
31
+ seed(body: DistributionBaselinesResponse): void;
32
+ private applyResponse;
33
+ private warnOnce;
34
+ }
35
+ export {};
36
+ //# sourceMappingURL=distributionBaselinePoller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"distributionBaselinePoller.d.ts","sourceRoot":"","sources":["../src/distributionBaselinePoller.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAGnE,UAAU,iCAAiC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,6BAA6B;IAC5C,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACzG;AAaD,qBAAa,0BAA0B;IACrC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAA2C;IAC5D,OAAO,CAAC,KAAK,CAA+C;IAC5D,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAExB,IAAI,EAAE,iCAAiC;IAMnD,KAAK,IAAI,IAAI;IAMb,IAAI,IAAI,IAAI;IAOZ,SAAS,IAAI,OAAO;IAIpB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,oBAAoB,GAAG,SAAS;IAIrE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAsB9B,IAAI,CAAC,IAAI,EAAE,6BAA6B,GAAG,IAAI;IAI/C,OAAO,CAAC,aAAa;IASrB,OAAO,CAAC,QAAQ;CAOjB"}