@reprova/sdk 0.5.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.
- package/README.md +75 -0
- package/dist/cjs/context.js +28 -0
- package/dist/cjs/distributionBaselinePoller.js +87 -0
- package/dist/cjs/distributionDrift.js +170 -0
- package/dist/cjs/drizzle.js +204 -0
- package/dist/cjs/frameworkAdapter.js +2 -0
- package/dist/cjs/httpFetch.js +59 -0
- package/dist/cjs/idempotency.js +132 -0
- package/dist/cjs/idempotencySettingsPoller.js +71 -0
- package/dist/cjs/index.js +96 -0
- package/dist/cjs/invariantInference.js +90 -0
- package/dist/cjs/invariantRules.js +142 -0
- package/dist/cjs/knex.js +86 -0
- package/dist/cjs/kysely.js +175 -0
- package/dist/cjs/legacyPrisma.js +130 -0
- package/dist/cjs/mikroorm.js +74 -0
- package/dist/cjs/mongoSchemaReporter.js +85 -0
- package/dist/cjs/mongoose.js +335 -0
- package/dist/cjs/nPlusOne.js +0 -0
- package/dist/cjs/nest.js +136 -0
- package/dist/cjs/nextjs.js +180 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/prisma.js +363 -0
- package/dist/cjs/proto.gen.js +4 -0
- package/dist/cjs/registry.js +100 -0
- package/dist/cjs/retryStorm.js +70 -0
- package/dist/cjs/rulesPoller.js +77 -0
- package/dist/cjs/sdk.js +1231 -0
- package/dist/cjs/sequelize.js +108 -0
- package/dist/cjs/shape.js +45 -0
- package/dist/cjs/stack.js +51 -0
- package/dist/cjs/transport.js +71 -0
- package/dist/cjs/typeorm.js +200 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js.map +1 -1
- package/dist/distributionBaselinePoller.d.ts +36 -0
- package/dist/distributionBaselinePoller.d.ts.map +1 -0
- package/dist/distributionBaselinePoller.js +84 -0
- package/dist/distributionBaselinePoller.js.map +1 -0
- package/dist/distributionDrift.d.ts +41 -0
- package/dist/distributionDrift.d.ts.map +1 -0
- package/dist/distributionDrift.js +161 -0
- package/dist/distributionDrift.js.map +1 -0
- package/dist/drizzle.d.ts.map +1 -1
- package/dist/drizzle.js +10 -4
- package/dist/drizzle.js.map +1 -1
- package/dist/frameworkAdapter.d.ts +5 -0
- package/dist/frameworkAdapter.d.ts.map +1 -0
- package/dist/frameworkAdapter.js +2 -0
- package/dist/frameworkAdapter.js.map +1 -0
- package/dist/httpFetch.d.ts +13 -0
- package/dist/httpFetch.d.ts.map +1 -0
- package/dist/httpFetch.js +54 -0
- package/dist/httpFetch.js.map +1 -0
- package/dist/idempotency.d.ts +26 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +124 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/idempotencySettingsPoller.d.ts +23 -0
- package/dist/idempotencySettingsPoller.d.ts.map +1 -0
- package/dist/idempotencySettingsPoller.js +68 -0
- package/dist/idempotencySettingsPoller.js.map +1 -0
- package/dist/index.d.ts +30 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -3
- package/dist/index.js.map +1 -1
- package/dist/invariantInference.d.ts +28 -0
- package/dist/invariantInference.d.ts.map +1 -0
- package/dist/invariantInference.js +87 -0
- package/dist/invariantInference.js.map +1 -0
- package/dist/invariantRules.d.ts +16 -0
- package/dist/invariantRules.d.ts.map +1 -0
- package/dist/invariantRules.js +104 -0
- package/dist/invariantRules.js.map +1 -0
- package/dist/knex.d.ts.map +1 -1
- package/dist/knex.js +1 -0
- package/dist/knex.js.map +1 -1
- package/dist/kysely.d.ts.map +1 -1
- package/dist/kysely.js +2 -1
- package/dist/kysely.js.map +1 -1
- package/dist/legacyPrisma.d.ts +6 -0
- package/dist/legacyPrisma.d.ts.map +1 -0
- package/dist/legacyPrisma.js +128 -0
- package/dist/legacyPrisma.js.map +1 -0
- package/dist/mikroorm.d.ts +2 -0
- package/dist/mikroorm.d.ts.map +1 -0
- package/dist/mikroorm.js +72 -0
- package/dist/mikroorm.js.map +1 -0
- package/dist/mongoSchemaReporter.d.ts +23 -0
- package/dist/mongoSchemaReporter.d.ts.map +1 -0
- package/dist/mongoSchemaReporter.js +82 -0
- package/dist/mongoSchemaReporter.js.map +1 -0
- package/dist/mongoose.d.ts +48 -0
- package/dist/mongoose.d.ts.map +1 -0
- package/dist/mongoose.js +331 -0
- package/dist/mongoose.js.map +1 -0
- package/dist/nPlusOne.d.ts +52 -0
- package/dist/nPlusOne.d.ts.map +1 -0
- package/dist/nPlusOne.js +0 -0
- package/dist/nPlusOne.js.map +1 -0
- package/dist/nest.d.ts +13 -0
- package/dist/nest.d.ts.map +1 -1
- package/dist/nest.js +39 -0
- package/dist/nest.js.map +1 -1
- package/dist/nextjs.d.ts +27 -0
- package/dist/nextjs.d.ts.map +1 -0
- package/dist/nextjs.js +178 -0
- package/dist/nextjs.js.map +1 -0
- package/dist/prisma.d.ts +21 -5
- package/dist/prisma.d.ts.map +1 -1
- package/dist/prisma.js +247 -22
- package/dist/prisma.js.map +1 -1
- package/dist/proto.gen.d.ts +10 -2
- package/dist/proto.gen.d.ts.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +24 -0
- package/dist/registry.js.map +1 -1
- package/dist/retryStorm.d.ts +42 -0
- package/dist/retryStorm.d.ts.map +1 -0
- package/dist/retryStorm.js +65 -0
- package/dist/retryStorm.js.map +1 -0
- package/dist/rulesPoller.d.ts +26 -0
- package/dist/rulesPoller.d.ts.map +1 -0
- package/dist/rulesPoller.js +74 -0
- package/dist/rulesPoller.js.map +1 -0
- package/dist/sdk.d.ts +48 -7
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +619 -47
- package/dist/sdk.js.map +1 -1
- package/dist/sequelize.d.ts.map +1 -1
- package/dist/sequelize.js +4 -3
- package/dist/sequelize.js.map +1 -1
- package/dist/stack.d.ts.map +1 -1
- package/dist/stack.js +6 -3
- package/dist/stack.js.map +1 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +2 -1
- package/dist/transport.js.map +1 -1
- package/dist/typeorm.d.ts +1 -0
- package/dist/typeorm.d.ts.map +1 -1
- package/dist/typeorm.js +133 -1
- package/dist/typeorm.js.map +1 -1
- package/package.json +14 -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,14 +25,31 @@ 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;
|
package/dist/context.d.ts.map
CHANGED
|
@@ -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;
|
|
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.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.js","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,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"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { httpFetch } from './httpFetch.js';
|
|
2
|
+
const WARN_INTERVAL_MS = 60_000;
|
|
3
|
+
// Same start()/stop()/setInterval+unref shape as RulesPoller and
|
|
4
|
+
// IdempotencySettingsPoller — periodically fetches the tenant's current
|
|
5
|
+
// aggregate baselines (and the drift-detection on/off toggle) so a running
|
|
6
|
+
// process can check each response against a live baseline without a
|
|
7
|
+
// redeploy, and so drift detection stays off by default until an admin
|
|
8
|
+
// opts in from the dashboard. Falls back to disabled + no baselines before
|
|
9
|
+
// the first successful poll and on any failure, so a bad response or an
|
|
10
|
+
// unreachable control plane fails closed (no captures), not open (spurious
|
|
11
|
+
// captures from a stale or empty baseline).
|
|
12
|
+
export class DistributionBaselinePoller {
|
|
13
|
+
enabled = false;
|
|
14
|
+
baselines = new Map();
|
|
15
|
+
timer = null;
|
|
16
|
+
lastWarnAt = 0;
|
|
17
|
+
endpoint;
|
|
18
|
+
apiKey;
|
|
19
|
+
intervalMs;
|
|
20
|
+
constructor(opts) {
|
|
21
|
+
this.endpoint = opts.endpoint;
|
|
22
|
+
this.apiKey = opts.apiKey;
|
|
23
|
+
this.intervalMs = opts.intervalMs ?? 60_000;
|
|
24
|
+
}
|
|
25
|
+
start() {
|
|
26
|
+
void this.refresh();
|
|
27
|
+
this.timer = setInterval(() => void this.refresh(), this.intervalMs);
|
|
28
|
+
if (this.timer.unref)
|
|
29
|
+
this.timer.unref();
|
|
30
|
+
}
|
|
31
|
+
stop() {
|
|
32
|
+
if (this.timer) {
|
|
33
|
+
clearInterval(this.timer);
|
|
34
|
+
this.timer = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
isEnabled() {
|
|
38
|
+
return this.enabled;
|
|
39
|
+
}
|
|
40
|
+
getBaseline(route, field) {
|
|
41
|
+
return this.baselines.get(`${route}\x00${field}`);
|
|
42
|
+
}
|
|
43
|
+
async refresh() {
|
|
44
|
+
try {
|
|
45
|
+
const res = await httpFetch(this.endpoint, {
|
|
46
|
+
headers: { 'X-Reprova-Key': this.apiKey },
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
this.warnOnce(`reprova: distribution baselines fetch returned ${res.status}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const body = (await res.json());
|
|
53
|
+
this.applyResponse(body);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
this.warnOnce(`reprova: distribution baselines fetch error: ${err.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Applies an already-fetched response directly, with no network call —
|
|
60
|
+
// used by replay mode to seed a one-time snapshot the CLI fetched ahead of
|
|
61
|
+
// time (there's no live control plane to poll from inside a replayed
|
|
62
|
+
// app), so drift detection (checkDistributionDriftIfAny/
|
|
63
|
+
// captureWriteCountDriftIfAny in sdk.ts) sees exactly the same shape it
|
|
64
|
+
// would from a real refresh().
|
|
65
|
+
seed(body) {
|
|
66
|
+
this.applyResponse(body);
|
|
67
|
+
}
|
|
68
|
+
applyResponse(body) {
|
|
69
|
+
this.enabled = body.enabled ?? false;
|
|
70
|
+
const next = new Map();
|
|
71
|
+
for (const b of body.baselines ?? []) {
|
|
72
|
+
next.set(`${b.route}\x00${b.field}`, { sampleCount: b.sample_count, mean: b.mean, stddev: b.stddev });
|
|
73
|
+
}
|
|
74
|
+
this.baselines = next;
|
|
75
|
+
}
|
|
76
|
+
warnOnce(msg) {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
if (now - this.lastWarnAt > WARN_INTERVAL_MS) {
|
|
79
|
+
this.lastWarnAt = now;
|
|
80
|
+
console.warn(msg);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=distributionBaselinePoller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"distributionBaselinePoller.js","sourceRoot":"","sources":["../src/distributionBaselinePoller.ts"],"names":[],"mappings":"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"}
|