@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,180 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createNextAdapter = createNextAdapter;
4
+ const context_js_1 = require("./context.js");
5
+ const sdk_js_1 = require("./sdk.js");
6
+ const registry_js_1 = require("./registry.js");
7
+ const stack_js_1 = require("./stack.js");
8
+ function toError(value) {
9
+ if (value instanceof Error)
10
+ return value;
11
+ return new Error(typeof value === 'string' ? value : JSON.stringify(value));
12
+ }
13
+ function headersToRecord(headers) {
14
+ const out = {};
15
+ headers.forEach((value, key) => {
16
+ out[key] = value;
17
+ });
18
+ return out;
19
+ }
20
+ // Builds a brand-new, self-contained context directly from a Fetch API
21
+ // Request — used when there is no ambient ALS context AND no outer context
22
+ // could be recovered by traceId (instrumentHttp disabled, an edge runtime
23
+ // with no Node http.Server underneath, or any other setup with no "outer"
24
+ // context to begin with). Registered/completed by the caller, same lifecycle
25
+ // runWithContext/runJob already give background work with no HTTP layer at
26
+ // all.
27
+ function contextFromFetchRequest(req) {
28
+ let path = '/';
29
+ try {
30
+ path = new URL(req.url).pathname;
31
+ }
32
+ catch {
33
+ // keep the default
34
+ }
35
+ const traceId = req.headers.get('traceparent') ?? (0, stack_js_1.generateTraceId)();
36
+ return {
37
+ traceId,
38
+ capturedAt: new Date(),
39
+ request: {
40
+ method: req.method,
41
+ path,
42
+ headers: headersToRecord(req.headers),
43
+ body: '',
44
+ },
45
+ footprint: [],
46
+ outboundCalls: [],
47
+ businessInvariantHits: [],
48
+ writeCountDriftHits: [],
49
+ };
50
+ }
51
+ // The core of wrapRouteHandler's universality: never assumes ambient ALS
52
+ // context survived whatever dispatch mechanism called into this route.
53
+ // Three cases, in order:
54
+ // 1. Ambient context is present — the common case for Pages Router/tRPC,
55
+ // and for any future Next.js version where App Router propagation
56
+ // works. Used as-is; nothing to register/complete, that's already
57
+ // owned by whoever established it.
58
+ // 2. No ambient context, but the request carries the SAME traceparent
59
+ // value contextFromIncoming wrote onto the raw connection — recovers
60
+ // the REAL outer context (the one instrumentHttpServers's res 'finish'
61
+ // handler already closes over for its own http_5xx fallback), so
62
+ // running the handler inside it and capturing marks that SAME ctx as
63
+ // captured, suppressing the fallback with zero coordination beyond the
64
+ // header. This is the case that fixes Next.js App Router: Next
65
+ // constructs its own Fetch Request rather than reusing the
66
+ // IncomingMessage, so object-identity recovery (ctxByRequest) can't
67
+ // work there, but the header survives regardless of what request
68
+ // object Next builds.
69
+ // 3. Neither — genuinely nothing to recover (instrumentHttp disabled, an
70
+ // edge runtime). Build a fresh context from the Request itself so a
71
+ // real capture still happens instead of silently deferring to a
72
+ // fallback that, in this case, doesn't exist either.
73
+ function resolveRequestContext(req) {
74
+ const existing = context_js_1.contextStorage.getStore();
75
+ if (existing)
76
+ return { ctx: existing, isFresh: false };
77
+ const traceId = req.headers.get('traceparent');
78
+ const recovered = traceId ? (0, registry_js_1.findContextByTraceId)(traceId) : undefined;
79
+ if (recovered)
80
+ return { ctx: recovered, isFresh: false };
81
+ const fresh = contextFromFetchRequest(req);
82
+ (0, registry_js_1.registerContext)(fresh);
83
+ return { ctx: fresh, isFresh: true };
84
+ }
85
+ // Shared by wrapRouteHandler and withRequestContext: resolves the context
86
+ // once, runs fn() inside it via ALS, and tears down a freshly-built one
87
+ // (never a recovered or already-ambient one — those are owned by whoever
88
+ // created them).
89
+ async function runInResolvedContext(req, fn) {
90
+ const { ctx, isFresh } = resolveRequestContext(req);
91
+ try {
92
+ return await context_js_1.contextStorage.run(ctx, () => fn(ctx));
93
+ }
94
+ finally {
95
+ if (isFresh)
96
+ (0, registry_js_1.completeContext)(ctx);
97
+ }
98
+ }
99
+ function createNextAdapter(opts) {
100
+ const { prisma, dmmf, ...reprovaOpts } = opts;
101
+ const sdk = sdk_js_1.Reprova.init(reprovaOpts);
102
+ // Not caught here deliberately: a Prisma instrumentation failure is a real
103
+ // setup bug (bad dmmf, a client shape instrumentPrisma doesn't recognize),
104
+ // not something to silently swallow — every ensureInit() caller sees the
105
+ // same rejection instrumentPrisma itself produced, same as the original
106
+ // cal.diy wiring this replaces (`await reprovaSdk.instrumentPrisma(...)`,
107
+ // no try/catch).
108
+ const initPromise = prisma !== undefined ? sdk.instrumentPrisma(prisma, { dmmf }) : Promise.resolve();
109
+ function ensureInit() {
110
+ return initPromise;
111
+ }
112
+ // Used by wrapApiHandler and trpcMiddleware — both reliably see the
113
+ // ambient context established further up the stack (Pages Router's
114
+ // apiResolver mutates the SAME req object instrumentHttpServers already
115
+ // saw; tRPC procedures run inside whatever context wrapped the HTTP
116
+ // handler that dispatches them). Confirmed live: only App Router's own
117
+ // internal dispatch loses the ALS store (see wrapRouteHandler below for
118
+ // the fix) — Pages Router and tRPC carry it through fine, so they don't
119
+ // need the recovery dance.
120
+ function captureUnhandled(err) {
121
+ const ctx = context_js_1.contextStorage.getStore();
122
+ if (!ctx || ctx.captured)
123
+ return;
124
+ sdk.capture('unhandled_exception', toError(err), ctx);
125
+ }
126
+ return {
127
+ ensureInit,
128
+ captureError(err) {
129
+ const ctx = context_js_1.contextStorage.getStore();
130
+ if (!ctx || ctx.captured)
131
+ return;
132
+ sdk.captureError(toError(err));
133
+ },
134
+ wrapRouteHandler(handler) {
135
+ return (async (req, ...rest) => {
136
+ await ensureInit();
137
+ // Establishing ALS directly around this call — rather than
138
+ // depending on whatever context Next's own dispatch may or may not
139
+ // have carried through to get here — is what makes this work
140
+ // regardless of Next.js version, App Router internals, or
141
+ // Turbopack vs. webpack: there is no dispatch layer left between
142
+ // this contextStorage.run() and the handler for anything to lose.
143
+ return runInResolvedContext(req, async (ctx) => {
144
+ try {
145
+ return await handler(req, ...rest);
146
+ }
147
+ catch (err) {
148
+ if (!ctx.captured)
149
+ sdk.capture('unhandled_exception', toError(err), ctx);
150
+ throw err;
151
+ }
152
+ });
153
+ });
154
+ },
155
+ async withRequestContext(req, fn) {
156
+ await ensureInit();
157
+ return runInResolvedContext(req, () => Promise.resolve(fn()));
158
+ },
159
+ wrapApiHandler(handler) {
160
+ return async (req, res) => {
161
+ await ensureInit();
162
+ try {
163
+ await handler(req, res);
164
+ }
165
+ catch (err) {
166
+ captureUnhandled(err);
167
+ throw err;
168
+ }
169
+ };
170
+ },
171
+ trpcMiddleware: async ({ next }) => {
172
+ await ensureInit();
173
+ const result = await next();
174
+ if (!result.ok && result.error?.code === 'INTERNAL_SERVER_ERROR') {
175
+ captureUnhandled(result.error.cause ?? result.error);
176
+ }
177
+ return result;
178
+ },
179
+ };
180
+ }
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -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,4 @@
1
+ "use strict";
2
+ // Code generated by scripts/gen-ts-types.mjs — DO NOT EDIT MANUALLY
3
+ // Source: proto/schemas/*.json
4
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
+ }