@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,363 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getPkFields = getPkFields;
|
|
4
|
+
exports.extractPks = extractPks;
|
|
5
|
+
exports.isWriteOperation = isWriteOperation;
|
|
6
|
+
exports.recordFailedWrite = recordFailedWrite;
|
|
7
|
+
exports.recordPrismaOperation = recordPrismaOperation;
|
|
8
|
+
exports.createPrismaExtension = createPrismaExtension;
|
|
9
|
+
const context_js_1 = require("./context.js");
|
|
10
|
+
const shape_js_1 = require("./shape.js");
|
|
11
|
+
const invariantRules_js_1 = require("./invariantRules.js");
|
|
12
|
+
const distributionDrift_js_1 = require("./distributionDrift.js");
|
|
13
|
+
function getPkFields(dmmf, modelName) {
|
|
14
|
+
const model = dmmf.datamodel.models.find(m => m.name === modelName);
|
|
15
|
+
if (!model)
|
|
16
|
+
return ['id'];
|
|
17
|
+
// Composite PK
|
|
18
|
+
if (model.primaryKey?.fields?.length)
|
|
19
|
+
return model.primaryKey.fields;
|
|
20
|
+
// Single @id field
|
|
21
|
+
const idField = model.fields.find(f => f.isId);
|
|
22
|
+
return idField ? [idField.name] : ['id'];
|
|
23
|
+
}
|
|
24
|
+
function getRelationFields(dmmf, modelName) {
|
|
25
|
+
const model = dmmf.datamodel.models.find(m => m.name === modelName);
|
|
26
|
+
if (!model)
|
|
27
|
+
return [];
|
|
28
|
+
return model.fields.filter(f => f.kind === 'object' && !!f.relationName);
|
|
29
|
+
}
|
|
30
|
+
const NUMERIC_SCALAR_TYPES = new Set(['Int', 'Float', 'Decimal', 'BigInt']);
|
|
31
|
+
function getNumericFieldNames(dmmf, modelName) {
|
|
32
|
+
const model = dmmf.datamodel.models.find(m => m.name === modelName);
|
|
33
|
+
if (!model)
|
|
34
|
+
return new Set();
|
|
35
|
+
const out = new Set();
|
|
36
|
+
for (const f of model.fields) {
|
|
37
|
+
if (f.kind === 'scalar' && NUMERIC_SCALAR_TYPES.has(f.type))
|
|
38
|
+
out.add(f.name);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function extractFromRow(row, pkFields) {
|
|
43
|
+
const vals = pkFields.map(f => row[f]);
|
|
44
|
+
if (vals.some(v => v === undefined))
|
|
45
|
+
return null;
|
|
46
|
+
return vals.map(v => String(v)).join(':');
|
|
47
|
+
}
|
|
48
|
+
function extractPks(result, pkFields) {
|
|
49
|
+
if (result === null || result === undefined)
|
|
50
|
+
return [];
|
|
51
|
+
if (Array.isArray(result)) {
|
|
52
|
+
const pks = [];
|
|
53
|
+
for (const item of result) {
|
|
54
|
+
if (item && typeof item === 'object') {
|
|
55
|
+
const pk = extractFromRow(item, pkFields);
|
|
56
|
+
if (pk !== null)
|
|
57
|
+
pks.push(pk);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return pks;
|
|
61
|
+
}
|
|
62
|
+
if (typeof result === 'object') {
|
|
63
|
+
const pk = extractFromRow(result, pkFields);
|
|
64
|
+
return pk !== null ? [pk] : [];
|
|
65
|
+
}
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
// Walk one level of included relations on the result (single object or array of
|
|
69
|
+
// objects) and produce one footprint entry per relation field that was actually
|
|
70
|
+
// present in the result (i.e. included in the query).
|
|
71
|
+
function extractNestedFootprints(dmmf, modelName, result) {
|
|
72
|
+
if (!dmmf)
|
|
73
|
+
return [];
|
|
74
|
+
const rows = Array.isArray(result)
|
|
75
|
+
? result.filter(r => r && typeof r === 'object')
|
|
76
|
+
: result && typeof result === 'object'
|
|
77
|
+
? [result]
|
|
78
|
+
: [];
|
|
79
|
+
if (rows.length === 0)
|
|
80
|
+
return [];
|
|
81
|
+
const entries = [];
|
|
82
|
+
for (const field of getRelationFields(dmmf, modelName)) {
|
|
83
|
+
if (!rows.some(row => field.name in row))
|
|
84
|
+
continue; // relation wasn't included
|
|
85
|
+
const nestedPkFields = getPkFields(dmmf, field.type);
|
|
86
|
+
const nestedItems = [];
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
const val = row[field.name];
|
|
89
|
+
if (Array.isArray(val))
|
|
90
|
+
nestedItems.push(...val);
|
|
91
|
+
else if (val !== null && val !== undefined)
|
|
92
|
+
nestedItems.push(val);
|
|
93
|
+
}
|
|
94
|
+
const pks = extractPks(field.isList ? nestedItems : nestedItems[0] ?? null, nestedPkFields);
|
|
95
|
+
entries.push({
|
|
96
|
+
model: field.type,
|
|
97
|
+
op: field.isList ? 'findMany' : 'findUnique',
|
|
98
|
+
pks,
|
|
99
|
+
count: nestedItems.length,
|
|
100
|
+
note: nestedItems.length === 0 ? 'returned 0 rows' : undefined,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return entries;
|
|
104
|
+
}
|
|
105
|
+
// Evaluates every enabled rule matching this model/operation against the
|
|
106
|
+
// query result, recording at most one hit per rule per request (see
|
|
107
|
+
// context.ts's businessInvariantHits). Evaluation only sees relation data
|
|
108
|
+
// the app's own query already `include`d — a rule referencing anything else
|
|
109
|
+
// is silently inapplicable (evaluateInvariant returns undefined), never a
|
|
110
|
+
// false violation. Must run here, inside the extension: this is the only
|
|
111
|
+
// place actual field values are available — the footprint pushed above only
|
|
112
|
+
// ever carries PKs/counts, never row content.
|
|
113
|
+
async function applyInvariantRules(ctx, rules, model, operation, result, pkFields) {
|
|
114
|
+
if (rules.length === 0)
|
|
115
|
+
return;
|
|
116
|
+
const rows = Array.isArray(result)
|
|
117
|
+
? result.filter((r) => r && typeof r === 'object')
|
|
118
|
+
: result && typeof result === 'object'
|
|
119
|
+
? [result]
|
|
120
|
+
: [];
|
|
121
|
+
if (rows.length === 0)
|
|
122
|
+
return;
|
|
123
|
+
for (const rule of rules) {
|
|
124
|
+
if (rule.model !== model)
|
|
125
|
+
continue;
|
|
126
|
+
if (!(0, invariantRules_js_1.operationMatchesHook)(operation, rule.when_hook))
|
|
127
|
+
continue;
|
|
128
|
+
if (ctx.businessInvariantHits.some((h) => h.ruleId === rule.id))
|
|
129
|
+
continue; // one hit per rule per request
|
|
130
|
+
const compiled = await (0, invariantRules_js_1.compileInvariant)(rule);
|
|
131
|
+
if (!compiled)
|
|
132
|
+
continue; // invalid CEL syntax (or CEL engine unavailable on this runtime) — never crash, never fire
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
if ((0, invariantRules_js_1.evaluateInvariant)(compiled, row) === false) {
|
|
135
|
+
ctx.businessInvariantHits.push({ ruleId: rule.id, message: rule.message, pks: extractPks(row, pkFields) });
|
|
136
|
+
break; // first offending row is enough evidence for this rule
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Invariant inference, v1 slice: passively observes whether a same-named
|
|
142
|
+
// numeric field on the parent equals the sum of that same field across an
|
|
143
|
+
// already-included to-many relation (Invoice.total vs
|
|
144
|
+
// sum(lineItems.total)), reporting pass/fail counts — never capturing or
|
|
145
|
+
// altering behavior on its own. Same-field-name only, not every numeric-
|
|
146
|
+
// field-pair combination: that keeps candidate volume bounded (at most one
|
|
147
|
+
// candidate per shared field name per relation, not N×M) and the heuristic
|
|
148
|
+
// legible. Runs unconditionally whenever a reporter is configured; see
|
|
149
|
+
// invariantInference.ts's module doc for the fuller design this is a first
|
|
150
|
+
// slice of.
|
|
151
|
+
function observeInferredInvariants(dmmf, modelName, result, reporter) {
|
|
152
|
+
if (!dmmf)
|
|
153
|
+
return;
|
|
154
|
+
const rows = Array.isArray(result)
|
|
155
|
+
? result.filter(r => r && typeof r === 'object')
|
|
156
|
+
: result && typeof result === 'object'
|
|
157
|
+
? [result]
|
|
158
|
+
: [];
|
|
159
|
+
if (rows.length === 0)
|
|
160
|
+
return;
|
|
161
|
+
const parentNumeric = getNumericFieldNames(dmmf, modelName);
|
|
162
|
+
if (parentNumeric.size === 0)
|
|
163
|
+
return;
|
|
164
|
+
for (const field of getRelationFields(dmmf, modelName)) {
|
|
165
|
+
if (!field.isList)
|
|
166
|
+
continue; // sum only makes sense over a to-many relation
|
|
167
|
+
if (!rows.some(row => field.name in row))
|
|
168
|
+
continue; // relation wasn't included
|
|
169
|
+
const childNumeric = getNumericFieldNames(dmmf, field.type);
|
|
170
|
+
const sharedFields = [...parentNumeric].filter(f => childNumeric.has(f));
|
|
171
|
+
if (sharedFields.length === 0)
|
|
172
|
+
continue;
|
|
173
|
+
for (const row of rows) {
|
|
174
|
+
const relVal = row[field.name];
|
|
175
|
+
if (!Array.isArray(relVal))
|
|
176
|
+
continue; // relation not included on this specific row
|
|
177
|
+
for (const fieldName of sharedFields) {
|
|
178
|
+
const parentVal = row[fieldName];
|
|
179
|
+
if (typeof parentVal !== 'number')
|
|
180
|
+
continue;
|
|
181
|
+
let sum = 0;
|
|
182
|
+
let allNumeric = true;
|
|
183
|
+
for (const item of relVal) {
|
|
184
|
+
const v = item && typeof item === 'object' ? item[fieldName] : undefined;
|
|
185
|
+
if (typeof v !== 'number') {
|
|
186
|
+
allNumeric = false;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
sum += v;
|
|
190
|
+
}
|
|
191
|
+
if (!allNumeric)
|
|
192
|
+
continue; // partial/missing data — never guess, same principle as evaluateCondition
|
|
193
|
+
// Float-tolerant: Decimal fields commonly arrive as floating-point
|
|
194
|
+
// numbers, where exact equality would misreport ordinary rounding
|
|
195
|
+
// as a "violation" on every single request.
|
|
196
|
+
const held = Math.abs(parentVal - sum) < 1e-6 * Math.max(1, Math.abs(parentVal));
|
|
197
|
+
reporter.record(modelName, fieldName, field.name, held);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const BULK_WRITE_OPS = new Set(['updateMany', 'deleteMany', 'createMany']);
|
|
203
|
+
// Write-pattern anomalies, row-count-drift slice: baselines the
|
|
204
|
+
// affected-row count of a bulk write (updateMany/deleteMany/createMany —
|
|
205
|
+
// a single update/delete/create always affects exactly 1 row or throws,
|
|
206
|
+
// nothing to baseline there) per (route, operation:model), and flags a
|
|
207
|
+
// live count implausibly far from it — "0 rows where normally ~3" and
|
|
208
|
+
// "50,000 rows where normally ~5" are the same phenomenon, just opposite
|
|
209
|
+
// ends of one baseline. Reuses isDrift and the DistributionDriftReporter/
|
|
210
|
+
// DistributionBaselinePoller classes directly from distributionDrift.ts —
|
|
211
|
+
// see that module's doc comment on WriteCountDrift for why this needs no
|
|
212
|
+
// new statistical machinery, only a new observation site and a second
|
|
213
|
+
// pair of instances pointed at a separate endpoint. Recording is
|
|
214
|
+
// unconditional (same reasoning as observeInferredInvariants/
|
|
215
|
+
// checkDistributionDriftIfAny — the baseline must already be populated by
|
|
216
|
+
// the time an admin opts in); only the CAPTURE side gates on the polled
|
|
217
|
+
// enabled flag, and hits push onto ctx for wrapResponse to drain and
|
|
218
|
+
// capture at response time (the same deferred-capture shape
|
|
219
|
+
// applyInvariantRules already uses via businessInvariantHits) rather than
|
|
220
|
+
// capturing directly from inside the Prisma extension.
|
|
221
|
+
function observeWriteCountDrift(ctx, model, operation, result, reporter, poller) {
|
|
222
|
+
if (!BULK_WRITE_OPS.has(operation))
|
|
223
|
+
return;
|
|
224
|
+
if (!result || typeof result !== 'object' || Array.isArray(result))
|
|
225
|
+
return;
|
|
226
|
+
const count = result.count;
|
|
227
|
+
if (typeof count !== 'number')
|
|
228
|
+
return;
|
|
229
|
+
const req = ctx.rawRequest;
|
|
230
|
+
const routePattern = req?.route?.path;
|
|
231
|
+
if (!routePattern)
|
|
232
|
+
return; // no stable key to group on
|
|
233
|
+
const route = `${ctx.request.method} ${routePattern}`;
|
|
234
|
+
const key = `${operation}:${model}`;
|
|
235
|
+
reporter.record(route, key, count);
|
|
236
|
+
if (!poller.isEnabled())
|
|
237
|
+
return;
|
|
238
|
+
const baseline = poller.getBaseline(route, key);
|
|
239
|
+
if (!baseline || !(0, distributionDrift_js_1.isDrift)(count, baseline))
|
|
240
|
+
return;
|
|
241
|
+
if (ctx.writeCountDriftHits.some((h) => h.route === route && h.model === model && h.operation === operation))
|
|
242
|
+
return;
|
|
243
|
+
ctx.writeCountDriftHits.push({ route, model, operation });
|
|
244
|
+
}
|
|
245
|
+
// Shared with legacyPrisma.ts's instrumentLegacyPrismaClient, which has its
|
|
246
|
+
// own copy of this same set for the exact same reason: a rejected read has
|
|
247
|
+
// no "what would have been written" story, so only writes get a footprint
|
|
248
|
+
// entry when the database rejects them.
|
|
249
|
+
const WRITE_OPS = new Set(['create', 'createMany', 'update', 'updateMany', 'upsert', 'delete', 'deleteMany']);
|
|
250
|
+
function isWriteOperation(operation) {
|
|
251
|
+
return WRITE_OPS.has(operation);
|
|
252
|
+
}
|
|
253
|
+
// Column NAMES only — never the bound values (the too-long username, the
|
|
254
|
+
// duplicate email), which are row data and must never enter a footprint per
|
|
255
|
+
// the metadata-only rule. Mirrors typeorm.ts's parseFailedWrite and
|
|
256
|
+
// legacyPrisma.ts's recordFailedWrite; this is the version createPrismaExtension's
|
|
257
|
+
// $allOperations uses for modern ($extends-capable) clients, whose failed
|
|
258
|
+
// queries previously left no trace at all — `$allOperations` only had a
|
|
259
|
+
// `.then()`, no rejection handler, so a write the database rejected (a
|
|
260
|
+
// unique-constraint violation, a length-limited column, a FK violation)
|
|
261
|
+
// silently vanished from the footprint even though it's often exactly the
|
|
262
|
+
// query that explains the bug.
|
|
263
|
+
function recordFailedWrite(ctx, model, operation, args) {
|
|
264
|
+
const data = args.data;
|
|
265
|
+
const where = args.where;
|
|
266
|
+
const columns = [...new Set([...(data ? Object.keys(data) : []), ...(where ? Object.keys(where) : [])])];
|
|
267
|
+
ctx.footprint.push({
|
|
268
|
+
model,
|
|
269
|
+
op: `${operation}_failed`,
|
|
270
|
+
pks: [],
|
|
271
|
+
count: 0,
|
|
272
|
+
where_shape: columns.length ? columns.join(',') : undefined,
|
|
273
|
+
note: 'rejected by database — see error for reason',
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// The one place a completed Prisma operation (result in hand, whatever
|
|
277
|
+
// hooked into its lifecycle) gets turned into a footprint entry and run
|
|
278
|
+
// through every detector. Shared by BOTH createPrismaExtension's
|
|
279
|
+
// $allOperations (clients with $extends) and legacyPrisma.ts's
|
|
280
|
+
// instrumentLegacyPrismaClient (clients with neither $extends nor $use —
|
|
281
|
+
// confirmed live against a real @prisma/client@2.0.0-beta.4, which has
|
|
282
|
+
// none of Prisma's query-lifecycle hooks at all) — same recording logic
|
|
283
|
+
// regardless of how the installed client exposes its queries. `args` here
|
|
284
|
+
// is whatever the model delegate method itself was called with (where/data/
|
|
285
|
+
// select/include), same shape on every Prisma version.
|
|
286
|
+
async function recordPrismaOperation(ctx, model, operation, args, result, hooks) {
|
|
287
|
+
const { dmmf, getRules, inferenceReporter, writeCountDriftReporter, writeCountBaselinePoller } = hooks;
|
|
288
|
+
const pkFields = dmmf ? getPkFields(dmmf, model) : ['id'];
|
|
289
|
+
const pks = extractPks(result, pkFields);
|
|
290
|
+
const count = Array.isArray(result) ? result.length : (result !== null && result !== undefined ? 1 : 0);
|
|
291
|
+
const whereShape = args.where ? (0, shape_js_1.shapeArgs)(args.where) : undefined;
|
|
292
|
+
ctx.footprint.push({
|
|
293
|
+
model,
|
|
294
|
+
op: operation,
|
|
295
|
+
pks,
|
|
296
|
+
where_shape: whereShape,
|
|
297
|
+
count,
|
|
298
|
+
note: count === 0 ? 'returned 0 rows' : undefined,
|
|
299
|
+
});
|
|
300
|
+
for (const nested of extractNestedFootprints(dmmf, model, result)) {
|
|
301
|
+
ctx.footprint.push(nested);
|
|
302
|
+
}
|
|
303
|
+
if (getRules) {
|
|
304
|
+
await applyInvariantRules(ctx, getRules(), model, operation, result, pkFields);
|
|
305
|
+
}
|
|
306
|
+
if (inferenceReporter) {
|
|
307
|
+
observeInferredInvariants(dmmf, model, result, inferenceReporter);
|
|
308
|
+
}
|
|
309
|
+
if (writeCountDriftReporter && writeCountBaselinePoller) {
|
|
310
|
+
observeWriteCountDrift(ctx, model, operation, result, writeCountDriftReporter, writeCountBaselinePoller);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Creates the Reprova Prisma client extension. getRules returns the SDK's
|
|
314
|
+
// live-cached business-invariant rules (RulesPoller) — read on every query
|
|
315
|
+
// rather than captured once, so a rule enabled/edited in the dashboard
|
|
316
|
+
// takes effect on the next poll without touching this extension.
|
|
317
|
+
// Usage: const prisma = new PrismaClient().$extends(reprova.prismaExtension())
|
|
318
|
+
function createPrismaExtension(dmmf, getRules, inferenceReporter, writeCountDriftReporter, writeCountBaselinePoller) {
|
|
319
|
+
return {
|
|
320
|
+
name: 'reprova-footprint',
|
|
321
|
+
query: {
|
|
322
|
+
$allOperations({ model, operation, args, query }) {
|
|
323
|
+
const ctx = context_js_1.contextStorage.getStore();
|
|
324
|
+
return query(args).then(async (result) => {
|
|
325
|
+
if (!ctx)
|
|
326
|
+
return result;
|
|
327
|
+
// Handle raw queries
|
|
328
|
+
if (model === undefined || operation === '$queryRaw' || operation === '$executeRaw') {
|
|
329
|
+
const sqlText = Array.isArray(args) ? String(args[0]) : '[raw]';
|
|
330
|
+
ctx.footprint.push({
|
|
331
|
+
model: '$raw',
|
|
332
|
+
op: operation,
|
|
333
|
+
pks: [],
|
|
334
|
+
count: 0,
|
|
335
|
+
note: 'raw query — partial coverage',
|
|
336
|
+
where_shape: sqlText.replace(/\$\d+/g, '?'),
|
|
337
|
+
});
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
await recordPrismaOperation(ctx, model, operation, args, result, {
|
|
341
|
+
dmmf,
|
|
342
|
+
getRules,
|
|
343
|
+
inferenceReporter,
|
|
344
|
+
writeCountDriftReporter,
|
|
345
|
+
writeCountBaselinePoller,
|
|
346
|
+
});
|
|
347
|
+
return result;
|
|
348
|
+
},
|
|
349
|
+
// The two-argument form of .then (not a chained .catch) so a
|
|
350
|
+
// throw from the success branch above never gets mistaken for a
|
|
351
|
+
// rejected query. Only writes get a footprint entry here — same
|
|
352
|
+
// "no what-would-have-been-read story" reasoning as the legacy
|
|
353
|
+
// adapters — and the original error is always rethrown unchanged.
|
|
354
|
+
(err) => {
|
|
355
|
+
if (ctx && model !== undefined && isWriteOperation(operation)) {
|
|
356
|
+
recordFailedWrite(ctx, model, operation, args);
|
|
357
|
+
}
|
|
358
|
+
throw err;
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerContext = registerContext;
|
|
4
|
+
exports.completeContext = completeContext;
|
|
5
|
+
exports.findContextByTraceId = findContextByTraceId;
|
|
6
|
+
exports.snapshotCohort = snapshotCohort;
|
|
7
|
+
exports.resetRegistryForTests = resetRegistryForTests;
|
|
8
|
+
const stack_js_1 = require("./stack.js");
|
|
9
|
+
const active = new Set();
|
|
10
|
+
const recent = [];
|
|
11
|
+
// Recovery index for when AsyncLocalStorage's active store has gone missing
|
|
12
|
+
// partway through a request — keyed by traceId rather than object identity,
|
|
13
|
+
// because the framework layer that lost the ALS store may ALSO have
|
|
14
|
+
// constructed a brand-new request object with no reference in common with
|
|
15
|
+
// the one instrumentHttpServers saw (Next.js App Router builds its own Fetch
|
|
16
|
+
// API Request rather than reusing the raw http.IncomingMessage; Pages Router
|
|
17
|
+
// and Express-style frameworks mutate the SAME object instead, which is what
|
|
18
|
+
// ctxByRequest in sdk.ts already handles). traceId survives that boundary
|
|
19
|
+
// because contextFromIncoming writes the resolved value back onto the raw
|
|
20
|
+
// request's headers before handing off to whatever dispatches next — any
|
|
21
|
+
// correct request implementation must copy incoming headers, so the injected
|
|
22
|
+
// traceparent is guaranteed to reach a freshly-constructed request even when
|
|
23
|
+
// object identity doesn't. See nextjs.ts's wrapRouteHandler for the consumer.
|
|
24
|
+
const byTraceId = new Map();
|
|
25
|
+
const RECENT_CAP = 16;
|
|
26
|
+
const RETENTION_MS = 5000;
|
|
27
|
+
const COHORT_CAP = 8; // must match capture_payload.json's maxItems
|
|
28
|
+
const BODY_CAP = 16 * 1024;
|
|
29
|
+
const FOOTPRINT_QUERY_CAP = 200;
|
|
30
|
+
function registerContext(ctx) {
|
|
31
|
+
active.add(ctx);
|
|
32
|
+
byTraceId.set(ctx.traceId, ctx);
|
|
33
|
+
}
|
|
34
|
+
function completeContext(ctx) {
|
|
35
|
+
// 'finish' and 'close' can both fire for one response — only move once.
|
|
36
|
+
if (!active.delete(ctx))
|
|
37
|
+
return;
|
|
38
|
+
if (byTraceId.get(ctx.traceId) === ctx)
|
|
39
|
+
byTraceId.delete(ctx.traceId);
|
|
40
|
+
recent.push({ ctx, endedAt: new Date() });
|
|
41
|
+
const cutoff = Date.now() - RETENTION_MS;
|
|
42
|
+
while (recent.length > RECENT_CAP || (recent.length > 0 && recent[0].endedAt.getTime() < cutoff)) {
|
|
43
|
+
recent.shift();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Looks up a still-in-flight context by its traceId. Returns undefined once
|
|
47
|
+
// the request has completed (completeContext already removed it) — recovery
|
|
48
|
+
// only ever targets a request that is still being handled.
|
|
49
|
+
function findContextByTraceId(traceId) {
|
|
50
|
+
return byTraceId.get(traceId);
|
|
51
|
+
}
|
|
52
|
+
// snapshotCohort returns every other request that overlapped the failing one:
|
|
53
|
+
// all currently-active contexts, plus recently-completed ones that ended at or
|
|
54
|
+
// after the failing request started (their writes shaped the state the failing
|
|
55
|
+
// request saw). offset_ms is signed: negative = started before the failing
|
|
56
|
+
// request. Sorted nearest-first, capped at COHORT_CAP.
|
|
57
|
+
function snapshotCohort(failing) {
|
|
58
|
+
const failingStart = failing.capturedAt.getTime();
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const ctx of active) {
|
|
61
|
+
if (ctx === failing)
|
|
62
|
+
continue;
|
|
63
|
+
out.push(toSnapshot(ctx, failingStart, 'in_flight'));
|
|
64
|
+
}
|
|
65
|
+
const cutoff = Date.now() - RETENTION_MS;
|
|
66
|
+
for (const { ctx, endedAt } of recent) {
|
|
67
|
+
if (ctx === failing)
|
|
68
|
+
continue;
|
|
69
|
+
if (endedAt.getTime() < cutoff)
|
|
70
|
+
continue;
|
|
71
|
+
if (endedAt.getTime() < failingStart)
|
|
72
|
+
continue; // ended before the failing request began — no overlap
|
|
73
|
+
out.push(toSnapshot(ctx, failingStart, 'completed', endedAt));
|
|
74
|
+
}
|
|
75
|
+
out.sort((a, b) => Math.abs(a.offset_ms) - Math.abs(b.offset_ms));
|
|
76
|
+
return out.slice(0, COHORT_CAP);
|
|
77
|
+
}
|
|
78
|
+
function toSnapshot(ctx, failingStart, state, endedAt) {
|
|
79
|
+
const startMs = ctx.capturedAt.getTime();
|
|
80
|
+
return {
|
|
81
|
+
trace_id: ctx.traceId,
|
|
82
|
+
offset_ms: startMs - failingStart,
|
|
83
|
+
state,
|
|
84
|
+
duration_ms: endedAt ? endedAt.getTime() - startMs : undefined,
|
|
85
|
+
request: {
|
|
86
|
+
method: ctx.request.method,
|
|
87
|
+
path: ctx.request.path,
|
|
88
|
+
headers: (0, stack_js_1.stripHeaders)(ctx.request.headers),
|
|
89
|
+
body: ctx.request.body.slice(0, BODY_CAP),
|
|
90
|
+
auth_claims: ctx.request.authClaims,
|
|
91
|
+
},
|
|
92
|
+
data_footprint: ctx.footprint.length > 0 ? { queries: ctx.footprint.slice(0, FOOTPRINT_QUERY_CAP) } : undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Test hook: clears all registry state between tests.
|
|
96
|
+
function resetRegistryForTests() {
|
|
97
|
+
active.clear();
|
|
98
|
+
byTraceId.clear();
|
|
99
|
+
recent.length = 0;
|
|
100
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PotentialRetryStorm = void 0;
|
|
4
|
+
exports.detectRetryStorm = detectRetryStorm;
|
|
5
|
+
exports.describeRetryStorm = describeRetryStorm;
|
|
6
|
+
// A call counts as failed for this purpose if it never got a real response
|
|
7
|
+
// (status 0, the SDK's convention for a network error or timeout — see
|
|
8
|
+
// sdk.ts's patchedFetch) or the server itself reported failure (5xx). 4xx is
|
|
9
|
+
// deliberately excluded: a repeated 404 or 401 is a caller bug, not a
|
|
10
|
+
// struggling dependency being hammered.
|
|
11
|
+
function isFailingCall(call) {
|
|
12
|
+
return call.status === 0 || call.status >= 500;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Groups a request's recorded outbound calls by (host, method, pathname —
|
|
16
|
+
* query string stripped) and flags any group that both repeats at or above
|
|
17
|
+
* the threshold AND includes at least one failing call.
|
|
18
|
+
*
|
|
19
|
+
* Both conditions matter: repetition alone doesn't distinguish a retry storm
|
|
20
|
+
* from legitimate pagination or batch fetching against a healthy dependency
|
|
21
|
+
* (all 200s) — requiring at least one failure targets specifically "the app
|
|
22
|
+
* kept calling a target that was failing," the actual retry-amplification
|
|
23
|
+
* pattern, not "the app called the same endpoint several times." Query
|
|
24
|
+
* string is stripped before grouping because a real retry commonly varies a
|
|
25
|
+
* request id or cache-busting param per attempt — grouping on the exact
|
|
26
|
+
* path would make every retry look like a distinct, one-off call and never
|
|
27
|
+
* cross the threshold.
|
|
28
|
+
*/
|
|
29
|
+
function detectRetryStorm(calls, threshold) {
|
|
30
|
+
if (threshold <= 0)
|
|
31
|
+
return [];
|
|
32
|
+
const groups = new Map();
|
|
33
|
+
for (const call of calls) {
|
|
34
|
+
const pathname = call.path.split('?')[0] ?? call.path;
|
|
35
|
+
const key = `${call.host} ${call.method} ${pathname}`;
|
|
36
|
+
let g = groups.get(key);
|
|
37
|
+
if (!g) {
|
|
38
|
+
g = { host: call.host, method: call.method, path: pathname, count: 0, failureCount: 0 };
|
|
39
|
+
groups.set(key, g);
|
|
40
|
+
}
|
|
41
|
+
g.count++;
|
|
42
|
+
if (isFailingCall(call))
|
|
43
|
+
g.failureCount++;
|
|
44
|
+
}
|
|
45
|
+
return [...groups.values()]
|
|
46
|
+
.filter((g) => g.count >= threshold && g.failureCount > 0)
|
|
47
|
+
.sort((a, b) => b.count - a.count);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A stable, digit-free description — same fingerprinting reasoning as
|
|
51
|
+
* describeNPlusOne: the exact count varies request to request, and must not
|
|
52
|
+
* appear in the message or two occurrences of the same underlying storm
|
|
53
|
+
* would never fingerprint into the same issue.
|
|
54
|
+
*/
|
|
55
|
+
function describeRetryStorm(finding) {
|
|
56
|
+
return `Repeated failing calls to ${finding.method} ${finding.host}${finding.path} — possible retry storm without backoff`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A real Error subclass, not a plain Error with `.name` reassigned — same
|
|
60
|
+
* reasoning as PotentialNPlusOneQuery: capture() derives the payload's
|
|
61
|
+
* error.type (which becomes the issue title verbatim) from
|
|
62
|
+
* err.constructor.name, which for a plain Error is always "Error" no matter
|
|
63
|
+
* what `.name` gets set to afterward.
|
|
64
|
+
*/
|
|
65
|
+
class PotentialRetryStorm extends Error {
|
|
66
|
+
constructor(finding) {
|
|
67
|
+
super(describeRetryStorm(finding));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.PotentialRetryStorm = PotentialRetryStorm;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RulesPoller = void 0;
|
|
4
|
+
const httpFetch_js_1 = require("./httpFetch.js");
|
|
5
|
+
const WARN_INTERVAL_MS = 60_000;
|
|
6
|
+
// Periodically fetches the tenant's business-invariant rules from the
|
|
7
|
+
// control plane and caches the enabled subset in memory — the mechanism
|
|
8
|
+
// that lets a dashboard-authored rule take effect in a running process
|
|
9
|
+
// without a recompile/redeploy. Same start()/stop()/setInterval+unref shape
|
|
10
|
+
// as BatchTransport (transport.ts), the SDK's other background timer.
|
|
11
|
+
class RulesPoller {
|
|
12
|
+
rules = [];
|
|
13
|
+
timer = null;
|
|
14
|
+
lastWarnAt = 0;
|
|
15
|
+
endpoint;
|
|
16
|
+
apiKey;
|
|
17
|
+
intervalMs;
|
|
18
|
+
constructor(opts) {
|
|
19
|
+
this.endpoint = opts.endpoint;
|
|
20
|
+
this.apiKey = opts.apiKey;
|
|
21
|
+
this.intervalMs = opts.intervalMs ?? 60_000;
|
|
22
|
+
}
|
|
23
|
+
start() {
|
|
24
|
+
void this.refresh(); // don't wait a full interval before rules are usable
|
|
25
|
+
this.timer = setInterval(() => void this.refresh(), this.intervalMs);
|
|
26
|
+
if (this.timer.unref)
|
|
27
|
+
this.timer.unref(); // don't block process exit
|
|
28
|
+
}
|
|
29
|
+
stop() {
|
|
30
|
+
if (this.timer) {
|
|
31
|
+
clearInterval(this.timer);
|
|
32
|
+
this.timer = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// The rules currently cached, already filtered to enabled — the control
|
|
36
|
+
// plane returns every rule (dashboard needs to see disabled ones to
|
|
37
|
+
// manage them), so the filtering happens once here rather than on every
|
|
38
|
+
// query in the Prisma extension.
|
|
39
|
+
getEnabledRules() {
|
|
40
|
+
return this.rules;
|
|
41
|
+
}
|
|
42
|
+
async refresh() {
|
|
43
|
+
try {
|
|
44
|
+
const res = await (0, httpFetch_js_1.httpFetch)(this.endpoint, {
|
|
45
|
+
headers: { 'X-Reprova-Key': this.apiKey },
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
this.warnOnce(`reprova: rules fetch returned ${res.status}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const body = (await res.json());
|
|
52
|
+
this.applyResponse(body);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
this.warnOnce(`reprova: rules fetch error: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Applies an already-fetched response body directly, with no network call
|
|
59
|
+
// — used by replay mode to seed a one-time snapshot the CLI fetched ahead
|
|
60
|
+
// of time (there's no live control plane to poll from inside a replayed
|
|
61
|
+
// app), so detection logic (invariantRules.ts) sees exactly the same
|
|
62
|
+
// shape it would from a real refresh().
|
|
63
|
+
seed(body) {
|
|
64
|
+
this.applyResponse(body);
|
|
65
|
+
}
|
|
66
|
+
applyResponse(body) {
|
|
67
|
+
this.rules = (body.rules ?? []).filter((r) => r.enabled);
|
|
68
|
+
}
|
|
69
|
+
warnOnce(msg) {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
if (now - this.lastWarnAt > WARN_INTERVAL_MS) {
|
|
72
|
+
this.lastWarnAt = now;
|
|
73
|
+
console.warn(msg);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
exports.RulesPoller = RulesPoller;
|