@reprova/sdk 0.5.0 → 0.6.1

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 (148) hide show
  1. package/README.md +75 -0
  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 +101 -0
  11. package/dist/cjs/invariantInference.js +90 -0
  12. package/dist/cjs/invariantRules.js +142 -0
  13. package/dist/cjs/knex.js +105 -0
  14. package/dist/cjs/kysely.js +201 -0
  15. package/dist/cjs/legacyPrisma.js +130 -0
  16. package/dist/cjs/mikroorm.js +107 -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 +146 -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 +231 -0
  34. package/dist/context.d.ts +18 -0
  35. package/dist/context.d.ts.map +1 -1
  36. package/dist/context.js.map +1 -1
  37. package/dist/distributionBaselinePoller.d.ts +36 -0
  38. package/dist/distributionBaselinePoller.d.ts.map +1 -0
  39. package/dist/distributionBaselinePoller.js +84 -0
  40. package/dist/distributionBaselinePoller.js.map +1 -0
  41. package/dist/distributionDrift.d.ts +41 -0
  42. package/dist/distributionDrift.d.ts.map +1 -0
  43. package/dist/distributionDrift.js +161 -0
  44. package/dist/distributionDrift.js.map +1 -0
  45. package/dist/drizzle.d.ts.map +1 -1
  46. package/dist/drizzle.js +10 -4
  47. package/dist/drizzle.js.map +1 -1
  48. package/dist/frameworkAdapter.d.ts +5 -0
  49. package/dist/frameworkAdapter.d.ts.map +1 -0
  50. package/dist/frameworkAdapter.js +2 -0
  51. package/dist/frameworkAdapter.js.map +1 -0
  52. package/dist/httpFetch.d.ts +13 -0
  53. package/dist/httpFetch.d.ts.map +1 -0
  54. package/dist/httpFetch.js +54 -0
  55. package/dist/httpFetch.js.map +1 -0
  56. package/dist/idempotency.d.ts +26 -0
  57. package/dist/idempotency.d.ts.map +1 -0
  58. package/dist/idempotency.js +124 -0
  59. package/dist/idempotency.js.map +1 -0
  60. package/dist/idempotencySettingsPoller.d.ts +23 -0
  61. package/dist/idempotencySettingsPoller.d.ts.map +1 -0
  62. package/dist/idempotencySettingsPoller.js +68 -0
  63. package/dist/idempotencySettingsPoller.js.map +1 -0
  64. package/dist/index.d.ts +33 -8
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +20 -6
  67. package/dist/index.js.map +1 -1
  68. package/dist/invariantInference.d.ts +28 -0
  69. package/dist/invariantInference.d.ts.map +1 -0
  70. package/dist/invariantInference.js +87 -0
  71. package/dist/invariantInference.js.map +1 -0
  72. package/dist/invariantRules.d.ts +16 -0
  73. package/dist/invariantRules.d.ts.map +1 -0
  74. package/dist/invariantRules.js +104 -0
  75. package/dist/invariantRules.js.map +1 -0
  76. package/dist/knex.d.ts +1 -0
  77. package/dist/knex.d.ts.map +1 -1
  78. package/dist/knex.js +19 -0
  79. package/dist/knex.js.map +1 -1
  80. package/dist/kysely.d.ts +1 -0
  81. package/dist/kysely.d.ts.map +1 -1
  82. package/dist/kysely.js +27 -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 +3 -0
  89. package/dist/mikroorm.d.ts.map +1 -0
  90. package/dist/mikroorm.js +104 -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 +13 -0
  105. package/dist/nest.d.ts.map +1 -1
  106. package/dist/nest.js +39 -0
  107. package/dist/nest.js.map +1 -1
  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 +10 -2
  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 +48 -7
  131. package/dist/sdk.d.ts.map +1 -1
  132. package/dist/sdk.js +619 -47
  133. package/dist/sdk.js.map +1 -1
  134. package/dist/sequelize.d.ts +1 -0
  135. package/dist/sequelize.d.ts.map +1 -1
  136. package/dist/sequelize.js +41 -3
  137. package/dist/sequelize.js.map +1 -1
  138. package/dist/stack.d.ts.map +1 -1
  139. package/dist/stack.js +6 -3
  140. package/dist/stack.js.map +1 -1
  141. package/dist/transport.d.ts.map +1 -1
  142. package/dist/transport.js +2 -1
  143. package/dist/transport.js.map +1 -1
  144. package/dist/typeorm.d.ts +2 -0
  145. package/dist/typeorm.d.ts.map +1 -1
  146. package/dist/typeorm.js +163 -1
  147. package/dist/typeorm.js.map +1 -1
  148. package/package.json +14 -5
package/README.md CHANGED
@@ -95,6 +95,39 @@ it ever reaches the captured bug — `repro run` reports `DIFFERENT FAILURE`
95
95
  instead of reproducing the original error. `setAuthClaims` is a no-op
96
96
  outside a Reprova request context, so it's safe to call unconditionally.
97
97
 
98
+ ## Framework adapters (auth claims + migration_id for anything else)
99
+
100
+ `setAuthClaims` (above) and `sdk.setMigrationId` cover apps that can call
101
+ something from inside their own request/startup code. Some frameworks
102
+ resolve both through their own machinery instead — a DI container, a
103
+ non-Express request shape — with no single call site to hook. One call
104
+ covers both at once:
105
+
106
+ ```ts
107
+ const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha });
108
+
109
+ sdk.registerFrameworkAdapter({
110
+ // Return undefined (not {}) to fall back to the built-in req.auth/req.user
111
+ // check instead of claiming "no auth" — useful if your framework only
112
+ // sometimes populates its own property.
113
+ extractAuthClaims: (rawRequest) => {
114
+ const req = rawRequest as { auth_context?: Record<string, unknown> };
115
+ return req.auth_context; // e.g. Medusa's own auth context
116
+ },
117
+ // Called once at registration — re-register (or call sdk.setMigrationId
118
+ // directly) if it can change during the process's lifetime.
119
+ resolveMigrationId: async () => {
120
+ const row = await db.query('SELECT name FROM my_migrations_table ORDER BY applied_at DESC LIMIT 1');
121
+ return row?.name ?? 'unknown';
122
+ },
123
+ });
124
+ ```
125
+
126
+ Both fields are optional independently — supply only the one your framework
127
+ needs. Neither ever throws into request handling: `extractAuthClaims`
128
+ falls back to `req.auth ?? req.user` on error or `undefined`, and a
129
+ rejected `resolveMigrationId` just leaves `migration_id` as `'unknown'`.
130
+
98
131
  ## Prisma (optional enrichment — automatic footprints)
99
132
 
100
133
  One call wires both automatic footprints and `migration_id`:
@@ -229,6 +262,48 @@ exported individually — pass the SAME logger instance to `createDrizzleCache`
229
262
  to get the merge behavior; omit it to run the cache standalone (exact
230
263
  SELECT PKs, no `where_shape`, no write coverage).
231
264
 
265
+ ## N+1 query detection (automatic once any ORM adapter above is wired up)
266
+
267
+ Every adapter above feeds the same per-request footprint (one entry per
268
+ query call). The SDK watches that footprint on every response — success or
269
+ failure — for the same `(model, operation)` shape executed repeatedly as
270
+ separate calls in one request: a `for` loop firing one `findUnique` per
271
+ item instead of a single batched query. No exception, no 5xx — the request
272
+ usually returns 200 while doing far more DB round-trips than it should,
273
+ which is exactly the case nothing else here catches.
274
+
275
+ ```ts
276
+ const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha, n1Threshold: 5 });
277
+ ```
278
+
279
+ `n1Threshold` (default `5`) is how many times the same shape has to repeat
280
+ in one request before it's flagged; `0` disables detection entirely. It
281
+ can't mistake one efficient query for N+1: an `include`/join produces
282
+ exactly one footprint entry per relation regardless of how many rows it
283
+ returns, so only genuinely separate calls count.
284
+
285
+ ## Retry storm detection (automatic — no wiring needed)
286
+
287
+ Every outbound `fetch()` call is already recorded per request (host,
288
+ method, path, status, outcome — used for outbound-call replay). The SDK
289
+ watches those calls, on every response, for the same `(host, method, path)`
290
+ called repeatedly with at least one failure among the attempts — a naive
291
+ retry loop hammering an already-struggling dependency instead of backing
292
+ off. Repetition alone isn't enough to flag: calling the same endpoint
293
+ several times and getting `200` every time is normal pagination or batch
294
+ fetching, not a storm — only repeats that include a failure (a `5xx`, or a
295
+ network error/timeout) count.
296
+
297
+ ```ts
298
+ const sdk = Reprova.init({ dsn: process.env.REPROVA_DSN, release: gitSha, retryStormThreshold: 3 });
299
+ ```
300
+
301
+ `retryStormThreshold` (default `3`, lower than `n1Threshold`'s `5` since
302
+ real backoff configs commonly cap around 3 attempts) is how many calls to
303
+ the same target it takes before a repeat-with-failures is flagged; `0`
304
+ disables it. The query string is stripped before grouping, since a real
305
+ retry commonly varies a request id or cache-busting param per attempt.
306
+
232
307
  ## NestJS (optional enrichment — original stacks + replay parity)
233
308
 
234
309
  Nest's exception layer catches thrown errors before Express error middleware
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.contextStorage = void 0;
4
+ exports.currentContext = currentContext;
5
+ exports.setAuthClaims = setAuthClaims;
6
+ const async_hooks_1 = require("async_hooks");
7
+ exports.contextStorage = new async_hooks_1.AsyncLocalStorage();
8
+ function currentContext() {
9
+ return exports.contextStorage.getStore();
10
+ }
11
+ /**
12
+ * Explicitly records the authenticated identity for the in-flight request.
13
+ * Auto-detection already covers `req.auth` (express-oauth2-jwt-bearer/Auth0)
14
+ * and `req.user` (Passport and most hand-rolled Express JWT middleware) —
15
+ * see detectAuthClaims in sdk.ts — so most apps need zero code changes. This
16
+ * is the escape hatch for anything else: a custom property, a non-Express
17
+ * framework. Call it from inside your own auth middleware once the token is
18
+ * verified, e.g. `setAuthClaims(decoded)` right after `jwt.verify`. Without
19
+ * captured claims one way or the other, replay has nothing to re-sign into a
20
+ * token and the app bounces the replayed request as unauthenticated before
21
+ * it ever reaches the captured bug. No-ops outside a Reprova request
22
+ * context.
23
+ */
24
+ function setAuthClaims(claims) {
25
+ const ctx = currentContext();
26
+ if (ctx)
27
+ ctx.request.authClaims = claims;
28
+ }
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DistributionBaselinePoller = void 0;
4
+ const httpFetch_js_1 = require("./httpFetch.js");
5
+ const WARN_INTERVAL_MS = 60_000;
6
+ // Same start()/stop()/setInterval+unref shape as RulesPoller and
7
+ // IdempotencySettingsPoller — periodically fetches the tenant's current
8
+ // aggregate baselines (and the drift-detection on/off toggle) so a running
9
+ // process can check each response against a live baseline without a
10
+ // redeploy, and so drift detection stays off by default until an admin
11
+ // opts in from the dashboard. Falls back to disabled + no baselines before
12
+ // the first successful poll and on any failure, so a bad response or an
13
+ // unreachable control plane fails closed (no captures), not open (spurious
14
+ // captures from a stale or empty baseline).
15
+ class DistributionBaselinePoller {
16
+ enabled = false;
17
+ baselines = new Map();
18
+ timer = null;
19
+ lastWarnAt = 0;
20
+ endpoint;
21
+ apiKey;
22
+ intervalMs;
23
+ constructor(opts) {
24
+ this.endpoint = opts.endpoint;
25
+ this.apiKey = opts.apiKey;
26
+ this.intervalMs = opts.intervalMs ?? 60_000;
27
+ }
28
+ start() {
29
+ void this.refresh();
30
+ this.timer = setInterval(() => void this.refresh(), this.intervalMs);
31
+ if (this.timer.unref)
32
+ this.timer.unref();
33
+ }
34
+ stop() {
35
+ if (this.timer) {
36
+ clearInterval(this.timer);
37
+ this.timer = null;
38
+ }
39
+ }
40
+ isEnabled() {
41
+ return this.enabled;
42
+ }
43
+ getBaseline(route, field) {
44
+ return this.baselines.get(`${route}\x00${field}`);
45
+ }
46
+ async refresh() {
47
+ try {
48
+ const res = await (0, httpFetch_js_1.httpFetch)(this.endpoint, {
49
+ headers: { 'X-Reprova-Key': this.apiKey },
50
+ });
51
+ if (!res.ok) {
52
+ this.warnOnce(`reprova: distribution baselines fetch returned ${res.status}`);
53
+ return;
54
+ }
55
+ const body = (await res.json());
56
+ this.applyResponse(body);
57
+ }
58
+ catch (err) {
59
+ this.warnOnce(`reprova: distribution baselines fetch error: ${err.message}`);
60
+ }
61
+ }
62
+ // Applies an already-fetched response directly, with no network call —
63
+ // used by replay mode to seed a one-time snapshot the CLI fetched ahead of
64
+ // time (there's no live control plane to poll from inside a replayed
65
+ // app), so drift detection (checkDistributionDriftIfAny/
66
+ // captureWriteCountDriftIfAny in sdk.ts) sees exactly the same shape it
67
+ // would from a real refresh().
68
+ seed(body) {
69
+ this.applyResponse(body);
70
+ }
71
+ applyResponse(body) {
72
+ this.enabled = body.enabled ?? false;
73
+ const next = new Map();
74
+ for (const b of body.baselines ?? []) {
75
+ next.set(`${b.route}\x00${b.field}`, { sampleCount: b.sample_count, mean: b.mean, stddev: b.stddev });
76
+ }
77
+ this.baselines = next;
78
+ }
79
+ warnOnce(msg) {
80
+ const now = Date.now();
81
+ if (now - this.lastWarnAt > WARN_INTERVAL_MS) {
82
+ this.lastWarnAt = now;
83
+ console.warn(msg);
84
+ }
85
+ }
86
+ }
87
+ exports.DistributionBaselinePoller = DistributionBaselinePoller;
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ // Distributional drift, numeric-only v1 slice: baseline the typical range
3
+ // of a numeric response field per (route, field) using Welford's online
4
+ // mean/variance algorithm, and flag a value that's implausibly far from
5
+ // that baseline — a `total` field that's normally 10-500 suddenly
6
+ // returning -40, with no rule authored and no equality asserted anywhere.
7
+ // Unlike business-invariant-rules and idempotency-key matching, this is a
8
+ // statistical guess with real false-positive risk (there's no
9
+ // "asymptotically safe" case the way an app-asserted equality is), so it's
10
+ // off by default and the thresholds below are fixed, not yet
11
+ // tenant-configurable.
12
+ //
13
+ // Deliberately scoped to numeric fields only for this slice — categorical
14
+ // novelty (a status enum emitting a never-seen value) is a structurally
15
+ // different mechanism (tracking a bounded set of distinct values, not a
16
+ // running mean/variance) and is left for a later slice.
17
+ //
18
+ // Observes the top-level fields of a JSON object response body only — not
19
+ // nested objects/arrays, and not array-shaped top-level responses (a list
20
+ // endpoint's items each have their own values; there's no single baseline
21
+ // for "the array"). That keeps the walk trivial and the key space bounded
22
+ // to (route, field) instead of an unbounded set of JSON paths.
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.DistributionDriftReporter = exports.WriteCountDrift = exports.ValueDistributionDrift = void 0;
25
+ exports.newWelfordState = newWelfordState;
26
+ exports.updateWelfordState = updateWelfordState;
27
+ exports.isDrift = isDrift;
28
+ exports.extractTopLevelNumericFields = extractTopLevelNumericFields;
29
+ const httpFetch_js_1 = require("./httpFetch.js");
30
+ const MIN_SAMPLES = 200;
31
+ const SIGMA_MULTIPLIER = 6;
32
+ class ValueDistributionDrift extends Error {
33
+ }
34
+ exports.ValueDistributionDrift = ValueDistributionDrift;
35
+ // Same mean/variance mechanism, same MIN_SAMPLES/SIGMA_MULTIPLIER thresholds,
36
+ // entirely different observation site: prisma.ts's observeWriteCountDrift
37
+ // baselines a Prisma updateMany/deleteMany/createMany's affected-row count
38
+ // per (route, operation:model) instead of an HTTP response field's value. Shares
39
+ // WelfordState/updateWelfordState/isDrift directly — the math doesn't care
40
+ // what number it's baselining — and DistributionDriftReporter/
41
+ // DistributionBaselinePoller are already fully generic (record/getBaseline
42
+ // take arbitrary string keys), so a second instance of each, pointed at a
43
+ // separate control-plane endpoint, is all that's needed there too. Kept as
44
+ // a distinct Error class/trigger since it's a conceptually separate
45
+ // question ("did this write touch a weird number of rows" vs. "is this
46
+ // response value weird") with its own independent dashboard toggle.
47
+ class WriteCountDrift extends Error {
48
+ }
49
+ exports.WriteCountDrift = WriteCountDrift;
50
+ function newWelfordState() {
51
+ return { count: 0, mean: 0, m2: 0, min: Infinity, max: -Infinity };
52
+ }
53
+ // Standard single-pass Welford update — mean and M2 (sum of squared
54
+ // deviations from the running mean) stay numerically stable across an
55
+ // unbounded stream, unlike naively accumulating sum/sum-of-squares.
56
+ function updateWelfordState(state, x) {
57
+ state.count++;
58
+ const delta = x - state.mean;
59
+ state.mean += delta / state.count;
60
+ const delta2 = x - state.mean;
61
+ state.m2 += delta * delta2;
62
+ if (x < state.min)
63
+ state.min = x;
64
+ if (x > state.max)
65
+ state.max = x;
66
+ }
67
+ // A value is drift when the baseline has enough samples to trust AND the
68
+ // value is more than SIGMA_MULTIPLIER standard deviations from the mean.
69
+ // A zero-variance baseline (every sample identical so far) treats ANY
70
+ // different value as infinite deviation — flagged, since after MIN_SAMPLES
71
+ // consistent observations a field that never varied suddenly doing so is
72
+ // exactly the kind of thing worth surfacing, not a numerical edge case to
73
+ // special-case away.
74
+ function isDrift(value, baseline) {
75
+ if (baseline.sampleCount < MIN_SAMPLES)
76
+ return false;
77
+ const deviation = baseline.stddev === 0 ? (value !== baseline.mean ? Infinity : 0) : Math.abs(value - baseline.mean) / baseline.stddev;
78
+ return deviation > SIGMA_MULTIPLIER;
79
+ }
80
+ // Extracts finite numeric values from the response body's OWN top-level
81
+ // keys — null/NaN/Infinity are skipped (not a "value", nothing to
82
+ // baseline), and a non-object or array body yields nothing (see module doc
83
+ // for why array-shaped top-level responses are out of scope for this slice).
84
+ function extractTopLevelNumericFields(body) {
85
+ if (!body || typeof body !== 'object' || Array.isArray(body))
86
+ return {};
87
+ const out = {};
88
+ for (const [k, v] of Object.entries(body)) {
89
+ if (typeof v === 'number' && Number.isFinite(v))
90
+ out[k] = v;
91
+ }
92
+ return out;
93
+ }
94
+ const WARN_INTERVAL_MS = 60_000;
95
+ // Accumulates local Welford state per (route, field) since the last flush
96
+ // and periodically reports it as a delta — same start/stop/periodic-flush
97
+ // shape as BatchTransport/InferenceReporter. The control plane merges each
98
+ // report into the tenant's cumulative baseline via the parallel-variance
99
+ // formula; a dropped/failed flush only ever loses that batch's
100
+ // contribution (undercounts), never double-reports on retry, since there
101
+ // is no retry — same trade-off every other reporter in this SDK already
102
+ // makes.
103
+ class DistributionDriftReporter {
104
+ states = new Map();
105
+ timer = null;
106
+ lastWarnAt = 0;
107
+ endpoint;
108
+ apiKey;
109
+ flushIntervalMs;
110
+ constructor(opts) {
111
+ this.endpoint = opts.endpoint;
112
+ this.apiKey = opts.apiKey;
113
+ this.flushIntervalMs = opts.flushIntervalMs ?? 30_000;
114
+ }
115
+ start() {
116
+ this.timer = setInterval(() => void this.flush(), this.flushIntervalMs);
117
+ if (this.timer.unref)
118
+ this.timer.unref();
119
+ }
120
+ stop() {
121
+ if (this.timer) {
122
+ clearInterval(this.timer);
123
+ this.timer = null;
124
+ }
125
+ }
126
+ record(route, field, value) {
127
+ const key = `${route}\x00${field}`;
128
+ let entry = this.states.get(key);
129
+ if (!entry) {
130
+ entry = { route, field, state: newWelfordState() };
131
+ this.states.set(key, entry);
132
+ }
133
+ updateWelfordState(entry.state, value);
134
+ }
135
+ async flush() {
136
+ if (this.states.size === 0)
137
+ return;
138
+ const observations = [...this.states.values()].map(({ route, field, state }) => ({
139
+ route,
140
+ field,
141
+ count: state.count,
142
+ mean: state.mean,
143
+ m2: state.m2,
144
+ min: state.min,
145
+ max: state.max,
146
+ }));
147
+ this.states.clear();
148
+ try {
149
+ const res = await (0, httpFetch_js_1.httpFetch)(this.endpoint, {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json', 'X-Reprova-Key': this.apiKey },
152
+ body: JSON.stringify({ observations }),
153
+ });
154
+ if (!res.ok) {
155
+ this.warnOnce(`reprova: distribution baseline report returned ${res.status}`);
156
+ }
157
+ }
158
+ catch (err) {
159
+ this.warnOnce(`reprova: distribution baseline report error: ${err.message}`);
160
+ }
161
+ }
162
+ warnOnce(msg) {
163
+ const now = Date.now();
164
+ if (now - this.lastWarnAt > WARN_INTERVAL_MS) {
165
+ this.lastWarnAt = now;
166
+ console.warn(msg);
167
+ }
168
+ }
169
+ }
170
+ exports.DistributionDriftReporter = DistributionDriftReporter;
@@ -0,0 +1,204 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createDrizzleLogger = createDrizzleLogger;
4
+ exports.createDrizzleCache = createDrizzleCache;
5
+ exports.createDrizzleFootprint = createDrizzleFootprint;
6
+ const node_crypto_1 = require("node:crypto");
7
+ const context_js_1 = require("./context.js");
8
+ const OP_BY_KEYWORD = {
9
+ select: 'select',
10
+ insert: 'insert',
11
+ update: 'update',
12
+ delete: 'delete',
13
+ };
14
+ // First SQL keyword → operation.
15
+ function opOf(sql) {
16
+ const m = /^\s*(select|insert|update|delete)\b/i.exec(sql);
17
+ return m ? OP_BY_KEYWORD[m[1].toLowerCase()] : undefined;
18
+ }
19
+ // The principal table, from the op-appropriate clause.
20
+ function tableOf(sql, op) {
21
+ let m = null;
22
+ if (op === 'select' || op === 'delete')
23
+ m = /\bfrom\s+"?([A-Za-z0-9_]+)"?/i.exec(sql);
24
+ else if (op === 'insert')
25
+ m = /\binto\s+"?([A-Za-z0-9_]+)"?/i.exec(sql);
26
+ else if (op === 'update')
27
+ m = /\bupdate\s+"?([A-Za-z0-9_]+)"?/i.exec(sql);
28
+ return m ? m[1] : undefined;
29
+ }
30
+ // Column names referenced in the WHERE clause — the right-hand identifier of
31
+ // each `"table"."col"` (or a bare `"col"`). Names only; values are `?`.
32
+ function whereColumns(sql) {
33
+ const wIdx = sql.search(/\bwhere\b/i);
34
+ if (wIdx < 0)
35
+ return [];
36
+ const tail = sql.slice(wIdx + 5);
37
+ const cols = new Set();
38
+ const re = /"([A-Za-z0-9_]+)"(?:\s*\.\s*"([A-Za-z0-9_]+)")?/g;
39
+ let m;
40
+ while ((m = re.exec(tail)) !== null)
41
+ cols.add(m[2] ?? m[1]);
42
+ return [...cols].sort();
43
+ }
44
+ function extractPks(rows, pkCols) {
45
+ if (pkCols.length === 0)
46
+ return [];
47
+ const seen = new Set();
48
+ const pks = [];
49
+ for (const row of rows) {
50
+ const vals = pkCols.map((c) => row[c]);
51
+ if (vals.some((v) => v === undefined))
52
+ continue; // column not selected — can't form a PK
53
+ const pk = vals.map((v) => String(v)).join(':');
54
+ if (!seen.has(pk)) {
55
+ seen.add(pk);
56
+ pks.push(pk);
57
+ }
58
+ }
59
+ return pks;
60
+ }
61
+ // Drizzle's own cache key: SHA-256(sql + '-' + JSON.stringify(params)) — see
62
+ // drizzle-orm/cache/core/cache.js. Reimplemented (not imported) so a query's
63
+ // hash computed here from `logger.logQuery`'s (sql, params) matches the hash
64
+ // Drizzle later passes to `cache.put()` for the SAME query, letting the two
65
+ // hooks correlate without ever importing 'drizzle-orm'.
66
+ // Node's own crypto module (not WebCrypto's `crypto.subtle`, which only
67
+ // became a stable global in Node 19+) — createHash has existed since
68
+ // Node's earliest crypto module, so this works on any Node version this
69
+ // SDK targets. Stays async (a plain value from an async function resolves
70
+ // immediately) so callers don't need to change.
71
+ async function hashQuery(sql, params) {
72
+ const data = `${sql}-${JSON.stringify(params)}`;
73
+ return (0, node_crypto_1.createHash)('sha256').update(data).digest('hex');
74
+ }
75
+ // A pending SELECT footprint entry, keyed by query hash, waiting to be
76
+ // upgraded by a paired cache's put(). Linked to its logger via WeakMap so the
77
+ // map is GC'd along with the logger if the app never pairs a cache to it.
78
+ const pendingByLogger = new WeakMap();
79
+ // Creates a Drizzle-compatible logger that records footprints.
80
+ // Usage: drizzle(client, { logger: createDrizzleLogger() })
81
+ function createDrizzleLogger(opts = {}) {
82
+ const captureWhereShape = opts.captureWhereShape !== false;
83
+ const forward = opts.forward;
84
+ const pending = new Map();
85
+ const logger = {
86
+ logQuery(query, params) {
87
+ try {
88
+ const ctx = context_js_1.contextStorage.getStore();
89
+ if (ctx) {
90
+ const op = opOf(query);
91
+ const table = op ? tableOf(query, op) : undefined;
92
+ if (op && table) {
93
+ const where = captureWhereShape ? whereColumns(query).join(',') || undefined : undefined;
94
+ const entry = {
95
+ model: table,
96
+ op,
97
+ pks: [],
98
+ where_shape: where,
99
+ count: 0,
100
+ note: 'drizzle: shape-only (no result access — PKs/count unavailable)',
101
+ resolved: true,
102
+ };
103
+ ctx.footprint.push(entry);
104
+ // A paired cache can upgrade this SAME entry once the real rows
105
+ // are known — see createDrizzleCache(). Mutations get nothing
106
+ // more from Drizzle's cache hook, so they stay as pushed above.
107
+ if (op === 'select') {
108
+ hashQuery(query, params)
109
+ .then((h) => pending.set(h, entry))
110
+ .catch(() => { });
111
+ }
112
+ }
113
+ }
114
+ }
115
+ catch { /* never break a query */ }
116
+ try {
117
+ forward?.logQuery(query, params);
118
+ }
119
+ catch { /* ignore */ }
120
+ },
121
+ };
122
+ pendingByLogger.set(logger, pending);
123
+ return logger;
124
+ }
125
+ // node-postgres (`pg`) QueryResult vs. mysql2's `[rows, fields]` tuple — the
126
+ // two response shapes `cache.put()` actually sees for Reprova's two real
127
+ // customer-database dialects (Postgres, MySQL; see agent/internal/dialect).
128
+ // Unrecognized shapes degrade to "no rows found" rather than guessing.
129
+ function rowsFromResponse(response) {
130
+ if (Array.isArray(response)) {
131
+ const [rows] = response; // mysql2: client.query() resolves [rows, fields]
132
+ return Array.isArray(rows) ? rows : undefined;
133
+ }
134
+ const rows = response?.rows;
135
+ return Array.isArray(rows) ? rows : undefined;
136
+ }
137
+ // Creates a Drizzle Cache that observes (never serves) query results to
138
+ // upgrade SELECT footprints with exact primary keys and row counts.
139
+ // Usage: drizzle(client, { cache: createDrizzleCache({ logger }) })
140
+ function createDrizzleCache(opts = {}) {
141
+ const primaryKey = opts.primaryKey ?? (() => ['id']);
142
+ const pending = opts.logger ? pendingByLogger.get(opts.logger) : undefined;
143
+ return {
144
+ // 'all' — every SELECT is observed automatically; apps never need to
145
+ // call Drizzle's own `.$withCache()` for this to work.
146
+ strategy() {
147
+ return 'all';
148
+ },
149
+ // Always a miss: this cache exists to OBSERVE results, never to REPLACE a
150
+ // real query with a stale one.
151
+ async get() {
152
+ return undefined;
153
+ },
154
+ async put(hashedQuery, response, tables) {
155
+ try {
156
+ if (!tables || tables.length === 0)
157
+ return;
158
+ const rows = rowsFromResponse(response);
159
+ if (!rows)
160
+ return;
161
+ const primary = tables[0];
162
+ const pks = extractPks(rows, primaryKey(primary));
163
+ const count = rows.length;
164
+ const note = count === 0 ? 'returned 0 rows' : undefined;
165
+ const linked = pending?.get(hashedQuery);
166
+ const ctx = context_js_1.contextStorage.getStore();
167
+ if (linked) {
168
+ pending.delete(hashedQuery);
169
+ linked.model = primary;
170
+ linked.pks = pks;
171
+ linked.count = count;
172
+ linked.note = note;
173
+ }
174
+ else if (ctx) {
175
+ ctx.footprint.push({ model: primary, op: 'select', pks, count, note, resolved: true });
176
+ }
177
+ // Joined/subquery tables: recorded as referenced, without PKs — same
178
+ // convention as the Kysely adapter (result columns can't be reliably
179
+ // attributed to a joined table's own primary key).
180
+ if (ctx) {
181
+ for (const t of tables.slice(1)) {
182
+ ctx.footprint.push({ model: t, op: 'select', pks: [], count: 0, note: 'referenced (join/subquery)', resolved: true });
183
+ }
184
+ }
185
+ }
186
+ catch { /* never break a query */ }
187
+ },
188
+ // Drizzle's mutation hook gets table names only — no rows, no count, and
189
+ // it races the real query (Promise.all internally). Nothing usable to
190
+ // record here; mutations are already captured (shape-only) by the paired
191
+ // logger.
192
+ async onMutate() { },
193
+ };
194
+ }
195
+ // Convenience: builds a linked logger+cache pair in one call, the
196
+ // recommended way to get exact SELECT pks/count without double-counting.
197
+ // Usage:
198
+ // const { logger, cache } = createDrizzleFootprint();
199
+ // const db = drizzle(client, { logger, cache });
200
+ function createDrizzleFootprint(opts = {}) {
201
+ const logger = createDrizzleLogger({ captureWhereShape: opts.captureWhereShape });
202
+ const cache = createDrizzleCache({ primaryKey: opts.primaryKey, logger });
203
+ return { logger, cache };
204
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.httpFetch = httpFetch;
7
+ const node_http_1 = __importDefault(require("node:http"));
8
+ const node_https_1 = __importDefault(require("node:https"));
9
+ // Node 18+ ships a global fetch; older Node (found live: Node 14, still a
10
+ // real LTS for legacy apps) has none at all. node-fetch isn't a safe
11
+ // replacement here — v3+ is ESM-only, the exact same require(ESM) trap
12
+ // @marcbachmann/cel-js caused (see invariantRules.ts's own doc comment on
13
+ // loadEnv). Every internal caller in this SDK only ever needs a small,
14
+ // stable subset of the Fetch API (GET/POST, headers, a string body,
15
+ // .ok/.status/.json()/.text()), so this implements just that on top of
16
+ // Node's own always-available http/https modules, and delegates to a real
17
+ // global fetch when one exists (the common case — zero overhead, zero
18
+ // behavior change on any Node version that already has one).
19
+ function httpFetch(url, init) {
20
+ const globalFetch = globalThis.fetch;
21
+ if (typeof globalFetch === 'function') {
22
+ return globalFetch(url, init);
23
+ }
24
+ return new Promise((resolve, reject) => {
25
+ let parsed;
26
+ try {
27
+ parsed = new URL(url);
28
+ }
29
+ catch (err) {
30
+ reject(err);
31
+ return;
32
+ }
33
+ const lib = parsed.protocol === 'https:' ? node_https_1.default : node_http_1.default;
34
+ const req = lib.request({
35
+ hostname: parsed.hostname,
36
+ port: parsed.port || undefined,
37
+ path: `${parsed.pathname}${parsed.search}`,
38
+ method: init?.method ?? 'GET',
39
+ headers: init?.headers,
40
+ }, (res) => {
41
+ const chunks = [];
42
+ res.on('data', (chunk) => chunks.push(chunk));
43
+ res.on('end', () => {
44
+ const text = Buffer.concat(chunks).toString('utf8');
45
+ const status = res.statusCode ?? 0;
46
+ resolve({
47
+ ok: status >= 200 && status < 300,
48
+ status,
49
+ json: async () => JSON.parse(text),
50
+ text: async () => text,
51
+ });
52
+ });
53
+ });
54
+ req.on('error', reject);
55
+ if (init?.body)
56
+ req.write(init.body);
57
+ req.end();
58
+ });
59
+ }