@friggframework/core 2.0.0-next.102 → 2.0.0-next.103

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 (55) hide show
  1. package/README.md +40 -0
  2. package/application/commands/usage-commands.js +56 -0
  3. package/application/index.js +10 -9
  4. package/core/create-handler.js +112 -10
  5. package/generated/prisma-mongodb/edge.js +16 -4
  6. package/generated/prisma-mongodb/index-browser.js +13 -1
  7. package/generated/prisma-mongodb/index.d.ts +1503 -105
  8. package/generated/prisma-mongodb/index.js +16 -4
  9. package/generated/prisma-mongodb/package.json +1 -1
  10. package/generated/prisma-mongodb/schema.prisma +23 -0
  11. package/generated/prisma-mongodb/wasm.js +16 -4
  12. package/generated/prisma-postgresql/edge.js +16 -4
  13. package/generated/prisma-postgresql/index-browser.js +13 -1
  14. package/generated/prisma-postgresql/index.d.ts +1540 -91
  15. package/generated/prisma-postgresql/index.js +16 -4
  16. package/generated/prisma-postgresql/package.json +1 -1
  17. package/generated/prisma-postgresql/schema.prisma +22 -0
  18. package/generated/prisma-postgresql/wasm.js +16 -4
  19. package/handlers/app-definition-loader.js +26 -3
  20. package/handlers/integration-event-dispatcher.js +29 -15
  21. package/handlers/routers/integration-webhook-routers.js +20 -7
  22. package/index.js +16 -9
  23. package/integrations/integration-base.js +64 -7
  24. package/modules/requester/requester.js +106 -5
  25. package/package.json +12 -5
  26. package/prisma-mongodb/schema.prisma +23 -0
  27. package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +26 -0
  28. package/prisma-postgresql/schema.prisma +22 -0
  29. package/reporting/README.md +8 -1
  30. package/reporting/reporting-router.js +8 -1
  31. package/reporting/use-cases/list-integrations-report.js +53 -6
  32. package/telemetry/README.md +331 -0
  33. package/telemetry/bind-telemetry-context.js +73 -0
  34. package/telemetry/canonical-counters.js +52 -0
  35. package/telemetry/exporters.js +85 -0
  36. package/telemetry/index.js +26 -0
  37. package/telemetry/instrument-handler.js +87 -0
  38. package/telemetry/no-op-telemetry.js +67 -0
  39. package/telemetry/north-star.js +103 -0
  40. package/telemetry/otel-telemetry.js +213 -0
  41. package/telemetry/plugin-subscribers.js +77 -0
  42. package/telemetry/telemetry-config.js +120 -0
  43. package/telemetry/telemetry-context.js +40 -0
  44. package/telemetry/telemetry-event-bus.js +58 -0
  45. package/telemetry/telemetry-runtime.js +147 -0
  46. package/telemetry/telemetry-service.js +51 -0
  47. package/telemetry/usage-rollup-subscriber.js +116 -0
  48. package/usage/README.md +54 -0
  49. package/usage/index.js +17 -0
  50. package/usage/repositories/usage-repository-documentdb.js +194 -0
  51. package/usage/repositories/usage-repository-factory.js +25 -0
  52. package/usage/repositories/usage-repository-interface.js +37 -0
  53. package/usage/repositories/usage-repository-prisma.js +146 -0
  54. package/usage/tracked-metrics.js +38 -0
  55. package/usage/usage-windows.js +24 -0
@@ -0,0 +1,147 @@
1
+ // The process composition root for telemetry: one shared state, per-part lazy
2
+ // getters, one atomic reset — replacing three separate singleton module caches
3
+ // (telemetry / usage-rollup / plugin-subscribers) that previously had to be
4
+ // reset together in the right order. Bus identity across the parts is guaranteed
5
+ // by construction: the rollup and plugin subscribers attach to the same
6
+ // getTelemetry() instance reached through this module's own closure.
7
+
8
+ const { createTelemetry } = require('./telemetry-service');
9
+ const { NoOpTelemetry } = require('./no-op-telemetry');
10
+
11
+ const UNSET = Symbol('unset');
12
+
13
+ function newState() {
14
+ return { telemetry: null, usageRollup: UNSET, pluginSubscribers: UNSET };
15
+ }
16
+ let state = newState();
17
+
18
+ function loadTelemetryConfig() {
19
+ // Lazy-required to avoid a load-time cycle with the handlers layer.
20
+ const { loadAppDefinition } = require('../handlers/app-definition-loader');
21
+ return loadAppDefinition();
22
+ }
23
+
24
+ /**
25
+ * The process telemetry service. Created lazily on first use and reused for the
26
+ * life of the Lambda container, so the OTel SDK initialises at most once per cold
27
+ * start. Never throws — any failure falls back to the no-op.
28
+ */
29
+ function getTelemetry() {
30
+ if (state.telemetry) return state.telemetry;
31
+ try {
32
+ const { telemetry = {} } = loadTelemetryConfig();
33
+ const stage = process.env.STAGE || process.env.NODE_ENV || 'production';
34
+ state.telemetry = createTelemetry({
35
+ exporter: telemetry.exporter,
36
+ sampleRatio: telemetry.sampleRatio,
37
+ resource: { service: process.env.FRIGG_STACK || 'frigg', stage },
38
+ });
39
+ } catch (_) {
40
+ state.telemetry = new NoOpTelemetry();
41
+ }
42
+ return state.telemetry;
43
+ }
44
+
45
+ /**
46
+ * The process usage-rollup subscriber, wired to getTelemetry()'s bus. Returns
47
+ * null when usage is disabled (no Definition.usage opt-in and no North Star) or
48
+ * the app definition can't load. Never throws — the warn lets operators tell
49
+ * "usage off" from "usage broken" (e.g. unresolved DB_TYPE).
50
+ */
51
+ function getUsageRollupSubscriber() {
52
+ if (state.usageRollup !== UNSET) return state.usageRollup;
53
+ state.usageRollup = null;
54
+ try {
55
+ const { computeTrackedMetrics } = require('../usage/tracked-metrics');
56
+ const {
57
+ northStarKeys,
58
+ createNorthStarDerivationSubscriber,
59
+ } = require('./north-star');
60
+ const { integrations = [], telemetry = {} } = loadTelemetryConfig();
61
+
62
+ // Track both Definition.usage opt-ins and any North Star counter keys,
63
+ // so a derived North Star persists even without an explicit usage decl.
64
+ const trackedMetrics = computeTrackedMetrics(integrations);
65
+ for (const key of northStarKeys(telemetry.northStar)) {
66
+ trackedMetrics.add(key);
67
+ }
68
+ if (trackedMetrics.size === 0) return state.usageRollup;
69
+
70
+ const {
71
+ createUsageRepository,
72
+ } = require('../usage/repositories/usage-repository-factory');
73
+ const {
74
+ createUsageRollupSubscriber,
75
+ } = require('./usage-rollup-subscriber');
76
+ const telemetryService = getTelemetry(); // same memo → shared bus
77
+
78
+ state.usageRollup = createUsageRollupSubscriber({
79
+ telemetry: telemetryService,
80
+ usageRepository: createUsageRepository(),
81
+ trackedMetrics,
82
+ });
83
+
84
+ // North Star derived-from-trace: emits the North Star counter when a
85
+ // configured signal matches; the emission flows to the rollup above.
86
+ if (telemetry.northStar) {
87
+ createNorthStarDerivationSubscriber({
88
+ telemetry: telemetryService,
89
+ northStar: telemetry.northStar,
90
+ });
91
+ }
92
+ } catch (error) {
93
+ console.warn(
94
+ `[Frigg][usage] rollup disabled: ${error && error.message}`
95
+ );
96
+ state.usageRollup = null;
97
+ }
98
+ return state.usageRollup;
99
+ }
100
+
101
+ /**
102
+ * Adopter-declared telemetry subscribers, wired to getTelemetry()'s bus once per
103
+ * cold start. Independent of the usage rollup (adopter taps wire even when no
104
+ * integration declares Definition.usage). Never throws.
105
+ */
106
+ function getPluginTelemetrySubscribers() {
107
+ if (state.pluginSubscribers !== UNSET) return state.pluginSubscribers;
108
+ state.pluginSubscribers = [];
109
+ try {
110
+ const { wireTelemetrySubscribers } = require('./plugin-subscribers');
111
+ const { telemetry = {} } = loadTelemetryConfig();
112
+ const subscribers = telemetry.subscribers || [];
113
+ if (subscribers.length === 0) return state.pluginSubscribers;
114
+
115
+ state.pluginSubscribers = wireTelemetrySubscribers({
116
+ telemetry: getTelemetry(),
117
+ subscribers,
118
+ });
119
+ } catch (error) {
120
+ console.warn(
121
+ `[Frigg][telemetry] plugin subscribers disabled: ${
122
+ error && error.message
123
+ }`
124
+ );
125
+ state.pluginSubscribers = [];
126
+ }
127
+ return state.pluginSubscribers;
128
+ }
129
+
130
+ /** Test-only: reset the whole runtime — no stale cross-memo states possible. */
131
+ function resetTelemetryRuntimeForTests() {
132
+ state = newState();
133
+ }
134
+
135
+ /** Test-only: install a telemetry service; dependent memos clear atomically. */
136
+ function setTelemetryForTests(telemetry) {
137
+ state = newState();
138
+ state.telemetry = telemetry;
139
+ }
140
+
141
+ module.exports = {
142
+ getTelemetry,
143
+ getUsageRollupSubscriber,
144
+ getPluginTelemetrySubscribers,
145
+ resetTelemetryRuntimeForTests,
146
+ setTelemetryForTests,
147
+ };
@@ -0,0 +1,51 @@
1
+ const { NoOpTelemetry } = require('./no-op-telemetry');
2
+ const { createTelemetryEventBus } = require('./telemetry-event-bus');
3
+
4
+ /**
5
+ * @typedef {object} TelemetryService The vendor-neutral telemetry port. Both
6
+ * NoOpTelemetry and OtelTelemetry implement it (duck-typed; the bound wrapper
7
+ * from bindTelemetryContext is a plain object that also satisfies it):
8
+ * count(name, value?, attributes?, context?) · event(name, attributes?, context?)
9
+ * span(name, fn) · startSpan(name?) · withContext(context, fn)
10
+ * on(eventType, cb) · forceFlush() · shutdown() · isEnabled()
11
+ */
12
+
13
+ /**
14
+ * Determine whether an exporter descriptor means "emit nothing".
15
+ * Absent config or `{ type: 'none' }` (or `'noop'`) → no-op.
16
+ */
17
+ function isNoOpExporter(exporter) {
18
+ if (!exporter) return true;
19
+ const type = exporter.type;
20
+ return !type || type === 'none' || type === 'noop';
21
+ }
22
+
23
+ /**
24
+ * Create the vendor-neutral telemetry service — the adapter selector. Integration
25
+ * code uses the returned instance (`this.telemetry`) and never imports a backend
26
+ * SDK. With no exporter configured it returns `NoOpTelemetry` (loads zero OTel);
27
+ * a real exporter lazily constructs `OtelTelemetry`.
28
+ *
29
+ * @param {object} [options]
30
+ * @param {object} [options.exporter] Exporter descriptor, e.g. `{ type: 'otlp', endpoint }`.
31
+ * @param {object} [options.resource] Resource attributes, e.g. `{ service, stage }`.
32
+ * @param {number} [options.sampleRatio] Parent-based sampling ratio (0..1).
33
+ * @param {object} [options.bus] Event bus to reuse (defaults to a fresh one).
34
+ */
35
+ function createTelemetry(options = {}) {
36
+ // The internal event stream is always on and independent of OTel export:
37
+ // the usage rollup + plugin taps must work even when
38
+ // the OTel exporter is a no-op. The bus is pure JS, so this does not load
39
+ // any OTel module on the no-op path.
40
+ const bus = options.bus || createTelemetryEventBus();
41
+
42
+ if (isNoOpExporter(options.exporter)) {
43
+ return new NoOpTelemetry({ bus });
44
+ }
45
+
46
+ // Lazy-required so the no-op path never loads the OTel SDK.
47
+ const { OtelTelemetry } = require('./otel-telemetry');
48
+ return new OtelTelemetry({ ...options, bus });
49
+ }
50
+
51
+ module.exports = { createTelemetry, isNoOpExporter };
@@ -0,0 +1,116 @@
1
+ const { computeUsageWindows } = require('../usage/usage-windows');
2
+
3
+ const WEBHOOK_EVENT_NAMES = new Set(['ON_WEBHOOK']);
4
+
5
+ /**
6
+ * Framework auto-signal metric names → the canonical usage key they feed.
7
+ * Resolvers receive (attributes, context) and may return null to
8
+ * decline. Handler invocations map by event: USER_ACTION → user_actions; the
9
+ * DB-connected `ON_WEBHOOK` queue dispatch → webhooks.received (per-integration,
10
+ * and where a durable write is actually possible — the HTTP receipt handler is
11
+ * DB-free so its buffer is discarded).
12
+ */
13
+ const METRIC_TO_CANONICAL = {
14
+ 'frigg.apimodule.requests': () => 'api.requests',
15
+ 'frigg.handler.invocations': (attrs, ctx) => {
16
+ if (attrs && attrs.event === 'USER_ACTION') return 'user_actions';
17
+ if (ctx && WEBHOOK_EVENT_NAMES.has(ctx.event_name)) {
18
+ return 'webhooks.received';
19
+ }
20
+ return null;
21
+ },
22
+ };
23
+
24
+ /**
25
+ * The built-in usage-rollup subscriber. Subscribes to the
26
+ * telemetry event bus and folds *declared* counters (canonical or custom) into
27
+ * the durable usage store. It buffers within an invocation and writes on
28
+ * `flush()` (no timers — Lambda-safe), or drops the buffer on `discard()` for an
29
+ * SQS redelivery so at-least-once delivery does not double-count (approximate
30
+ * accuracy contract).
31
+ *
32
+ * Attribution: high-cardinality ids never ride metric labels, so integrationId
33
+ * comes from the bus-only `context` (populated from the ambient handler context)
34
+ * and otherwise falls back to the bounded integration_type.
35
+ */
36
+ function createUsageRollupSubscriber({
37
+ telemetry,
38
+ usageRepository,
39
+ trackedMetrics = new Set(),
40
+ now = () => new Date(),
41
+ }) {
42
+ // Buffer key is a JSON tuple [integrationId, integrationType, metric, window]
43
+ // — collision-proof for developer-defined custom keys / names.
44
+ let buffer = new Map();
45
+
46
+ function resolveUsageKey(name, attributes, context) {
47
+ if (trackedMetrics.has(name)) return name;
48
+ const resolver = METRIC_TO_CANONICAL[name];
49
+ const canonical = resolver ? resolver(attributes || {}, context) : null;
50
+ return canonical && trackedMetrics.has(canonical) ? canonical : null;
51
+ }
52
+
53
+ function onMetric(payload) {
54
+ try {
55
+ const { name, value = 1, attributes = {}, context } = payload;
56
+ const metric = resolveUsageKey(name, attributes, context);
57
+ if (!metric) return;
58
+
59
+ const integrationType =
60
+ context?.integrationType || attributes.integration_type || null;
61
+ if (!integrationType) return; // cannot attribute — skip
62
+
63
+ const integrationId = context?.integrationId || integrationType;
64
+
65
+ for (const window of computeUsageWindows(now())) {
66
+ const key = JSON.stringify([
67
+ integrationId,
68
+ integrationType,
69
+ metric,
70
+ window,
71
+ ]);
72
+ buffer.set(key, (buffer.get(key) || 0) + value);
73
+ }
74
+ } catch (_) {
75
+ // Rollup must never break emission.
76
+ }
77
+ }
78
+
79
+ const unsubscribe = telemetry.on('metric', onMetric);
80
+
81
+ async function flush() {
82
+ if (buffer.size === 0) return;
83
+ const pending = buffer;
84
+ buffer = new Map();
85
+ for (const [key, value] of pending) {
86
+ const [integrationId, integrationType, metric, window] =
87
+ JSON.parse(key);
88
+ try {
89
+ await usageRepository.increment({
90
+ integrationId,
91
+ integrationType,
92
+ metric,
93
+ window,
94
+ value,
95
+ });
96
+ } catch (_) {
97
+ // A single failed write must not abort the rest of the flush.
98
+ }
99
+ }
100
+ }
101
+
102
+ function discard() {
103
+ buffer = new Map();
104
+ }
105
+
106
+ return {
107
+ flush,
108
+ discard,
109
+ unsubscribe,
110
+ get bufferSize() {
111
+ return buffer.size;
112
+ },
113
+ };
114
+ }
115
+
116
+ module.exports = { createUsageRollupSubscriber, METRIC_TO_CANONICAL };
@@ -0,0 +1,54 @@
1
+ # Usage Store
2
+
3
+ The durable, Frigg-owned store behind ADR-011 feature-usage tracking. Holds
4
+ per-integration counters that the reporting endpoint reads for cross-integration
5
+ comparison and trend series.
6
+
7
+ Populated by the telemetry usage rollup and read via `frigg.usage.*` — see
8
+ [`telemetry/README.md`](../telemetry/README.md) for the full guide, config, and
9
+ usage examples. This README covers the store internals only.
10
+
11
+ ## Fact row
12
+
13
+ `UsageCounter { integrationId, integrationType, metric, window, value, updatedAt }`,
14
+ uniquely keyed by `(integrationId, integrationType, metric, window)`. `window` is
15
+ `day:YYYY-MM-DD` or `hour:YYYY-MM-DDTHH` (UTC).
16
+
17
+ ## Isolation (ADR-010 Decision 3)
18
+
19
+ The store is deliberately isolated from user/integration-scoped data:
20
+
21
+ - **No `userId`** and **no foreign key** to `Integration` — a user-scoped query
22
+ can never return a usage row, and usage history survives integration deletion.
23
+ - **Not** in the encryption registry — dimensions are bounded and non-sensitive.
24
+
25
+ ## Repository triad
26
+
27
+ Mirrors the reporting/process pattern — an interface plus PostgreSQL, MongoDB and
28
+ DocumentDB adapters selected by `DB_TYPE`:
29
+
30
+ ```js
31
+ const { createUsageRepository } = require('@friggframework/core'); // usage-repository-factory
32
+
33
+ class UsageRepositoryInterface {
34
+ async increment({ integrationId, integrationType, metric, window, value }) {} // atomic upsert
35
+ async getTotalsByDimension({ metric, groupBy, since, bucket }) {} // comparison (one window granularity)
36
+ async getTimeSeries({ metric, integrationType, from, to, bucket }) {} // trend (aggregated across instances)
37
+ }
38
+ ```
39
+
40
+ - **`increment`** is atomic: PostgreSQL uses Prisma `upsert` with
41
+ `{ value: { increment } }`; a concurrent first-insert race (`P2002`) retries
42
+ once onto the atomic update path.
43
+ - **`getTotalsByDimension`** filters to a single window granularity (default `day`) so day and
44
+ hour rows are never double-summed. Requires a `metric`; `groupBy` is
45
+ allow-listed to `integrationType` / `metric`.
46
+ - **`getTimeSeries`** aggregates across integration instances (`groupBy(window) + sum`)
47
+ and range-filters on the window key. Requires an `integrationType`.
48
+
49
+ > **DocumentDB:** the adapter overrides increment/getTotalsByDimension/getTimeSeries with raw commands
50
+ > (`$runCommandRaw`: a `$inc` upsert via `documentdb-utils.updateOne`, and a
51
+ > cursor-drained `$aggregate` `$group/$sum`) — matching every other DocumentDB
52
+ > adapter, since Prisma's Mongo engine emits upsert/groupBy shapes DocumentDB
53
+ > rejects and cursor reads truncate at ~101 docs. Command shapes are unit-tested;
54
+ > run an end-to-end check against a real cluster before GA.
package/usage/index.js ADDED
@@ -0,0 +1,17 @@
1
+ const {
2
+ createUsageRepository,
3
+ UsageRepositoryPrisma,
4
+ UsageRepositoryDocumentDB,
5
+ } = require('./repositories/usage-repository-factory');
6
+ const {
7
+ UsageRepositoryInterface,
8
+ } = require('./repositories/usage-repository-interface');
9
+ const { computeTrackedMetrics } = require('./tracked-metrics');
10
+
11
+ module.exports = {
12
+ createUsageRepository,
13
+ computeTrackedMetrics,
14
+ UsageRepositoryInterface,
15
+ UsageRepositoryPrisma,
16
+ UsageRepositoryDocumentDB,
17
+ };
@@ -0,0 +1,194 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const { updateOne } = require('../../database/documentdb-utils');
3
+ const { UsageRepositoryPrisma } = require('./usage-repository-prisma');
4
+ const { windowKey } = require('../usage-windows');
5
+
6
+ const COLLECTION = 'UsageCounter';
7
+ const DRAIN_BATCH_SIZE = 1000;
8
+ const MAX_BATCHES = 100000;
9
+ const VALID_GROUP_BY = new Set(['integrationType', 'metric']);
10
+ const VALID_BUCKETS = new Set(['day', 'hour']);
11
+
12
+ /**
13
+ * DocumentDB usage store. Amazon DocumentDB does not accept the command shapes
14
+ * Prisma's Mongo engine emits for `upsert({ update: { value: { increment } } })`
15
+ * and `groupBy({ _sum })`, and cursor reads truncate at ~101 docs — so, like
16
+ * every other DocumentDB adapter in this repo, these operations are issued as
17
+ * raw commands (`$runCommandRaw`) via the validated documentdb-utils helpers and
18
+ * a drained aggregate cursor. No timestamps are managed: getTotalsByDimension/getTimeSeries filter on
19
+ * the window KEY (not write-time), so createdAt/updatedAt are unnecessary here.
20
+ */
21
+ class UsageRepositoryDocumentDB extends UsageRepositoryPrisma {
22
+ constructor() {
23
+ super();
24
+ this.prisma = prisma;
25
+ }
26
+
27
+ async increment({ integrationId, integrationType, metric, window, value = 1 }) {
28
+ // Atomic upsert-increment: $inc creates the field at the increment value
29
+ // on insert; $setOnInsert stamps the identity on first write. The compound
30
+ // filter is the unique key, so concurrent writers converge on one row.
31
+ await updateOne(
32
+ this.prisma,
33
+ COLLECTION,
34
+ { integrationId, integrationType, metric, window },
35
+ {
36
+ $inc: { value },
37
+ $setOnInsert: {
38
+ integrationId,
39
+ integrationType,
40
+ metric,
41
+ window,
42
+ },
43
+ },
44
+ { upsert: true }
45
+ );
46
+ }
47
+
48
+ async getTotalsByDimension({
49
+ metric,
50
+ groupBy = 'integrationType',
51
+ since,
52
+ bucket = 'day',
53
+ } = {}) {
54
+ if (!metric) {
55
+ throw new Error('getTotalsByDimension requires a metric (units are per-metric)');
56
+ }
57
+ assertGroupBy(groupBy);
58
+ assertBucket(bucket);
59
+
60
+ // Single window granularity (day: OR hour:) — never sum across both.
61
+ // `since` bounds on the window KEY (mirrors getTimeSeries / the Prisma adapter).
62
+ const rows = await this._aggregateDrained([
63
+ {
64
+ $match: {
65
+ metric,
66
+ window: windowMatch(bucket, {
67
+ gte: since ? windowKey(bucket, since) : undefined,
68
+ }),
69
+ },
70
+ },
71
+ { $group: { _id: `$${groupBy}`, value: { $sum: '$value' } } },
72
+ ]);
73
+
74
+ return rows.map((row) => ({
75
+ [groupBy]: row._id,
76
+ value: toNumber(row.value),
77
+ }));
78
+ }
79
+
80
+ async getTimeSeries({ metric, integrationType, from, to, bucket = 'day' } = {}) {
81
+ assertBucket(bucket);
82
+ if (!integrationType) {
83
+ throw new Error('getTimeSeries requires an integrationType');
84
+ }
85
+
86
+ const rows = await this._aggregateDrained([
87
+ {
88
+ $match: {
89
+ metric,
90
+ integrationType,
91
+ window: windowMatch(bucket, {
92
+ gte: from ? windowKey(bucket, from) : undefined,
93
+ lte: to ? windowKey(bucket, to) : undefined,
94
+ }),
95
+ },
96
+ },
97
+ { $group: { _id: '$window', value: { $sum: '$value' } } },
98
+ { $sort: { _id: 1 } },
99
+ ]);
100
+
101
+ return rows.map((row) => ({
102
+ bucket: row._id,
103
+ value: toNumber(row.value),
104
+ }));
105
+ }
106
+
107
+ async _aggregateDrained(pipeline) {
108
+ const first = await this.prisma.$runCommandRaw({
109
+ aggregate: COLLECTION,
110
+ pipeline,
111
+ cursor: { batchSize: DRAIN_BATCH_SIZE },
112
+ });
113
+ return drainCursor(this.prisma, COLLECTION, first);
114
+ }
115
+ }
116
+
117
+ /** A `$match` window clause: prefix by granularity, optionally range-bounded. */
118
+ function windowMatch(bucket, { gte, lte } = {}) {
119
+ const clause = { $regex: `^${bucket}:` };
120
+ if (gte) clause.$gte = gte;
121
+ if (lte) clause.$lte = lte;
122
+ return clause;
123
+ }
124
+
125
+ /**
126
+ * Coerce an aggregate `$sum` result to a JS Number. Prisma $runCommandRaw returns
127
+ * extended JSON, so a 64-bit sum can arrive as { $numberLong: "..." } (or
128
+ * $numberInt/$numberDouble); counts never approach 2^53.
129
+ */
130
+ function toNumber(value) {
131
+ if (value === null || value === undefined) return 0;
132
+ if (typeof value === 'number') return value;
133
+ if (typeof value === 'bigint') return Number(value);
134
+ if (typeof value === 'object') {
135
+ const raw =
136
+ value.$numberLong ?? value.$numberInt ?? value.$numberDouble;
137
+ if (raw !== undefined) return Number(raw);
138
+ }
139
+ return Number(value) || 0;
140
+ }
141
+
142
+ async function drainCursor(client, collection, firstResult) {
143
+ const cursor = firstResult?.cursor || {};
144
+ const docs = [...(cursor.firstBatch || [])];
145
+ let cursorId = cursor.id;
146
+ let batches = 0;
147
+
148
+ while (isCursorOpen(cursorId) && batches < MAX_BATCHES) {
149
+ batches += 1;
150
+ const next = await client.$runCommandRaw({
151
+ getMore: cursorId,
152
+ collection,
153
+ batchSize: DRAIN_BATCH_SIZE,
154
+ });
155
+ const nextCursor = next?.cursor || {};
156
+ const nextBatch = nextCursor.nextBatch || [];
157
+ docs.push(...nextBatch);
158
+ cursorId = nextCursor.id;
159
+ if (nextBatch.length === 0) break;
160
+ }
161
+ return docs;
162
+ }
163
+
164
+ function isCursorOpen(id) {
165
+ if (id === undefined || id === null) return false;
166
+ if (typeof id === 'number') return id !== 0;
167
+ if (typeof id === 'bigint') return id !== 0n;
168
+ if (typeof id === 'object' && id.$numberLong !== undefined) {
169
+ return id.$numberLong !== '0';
170
+ }
171
+ return String(id) !== '0';
172
+ }
173
+
174
+ function assertGroupBy(groupBy) {
175
+ if (!VALID_GROUP_BY.has(groupBy)) {
176
+ throw new Error(
177
+ `Invalid groupBy "${groupBy}". Allowed: ${[...VALID_GROUP_BY].join(
178
+ ', '
179
+ )}`
180
+ );
181
+ }
182
+ }
183
+
184
+ function assertBucket(bucket) {
185
+ if (!VALID_BUCKETS.has(bucket)) {
186
+ throw new Error(
187
+ `Invalid bucket "${bucket}". Allowed: ${[...VALID_BUCKETS].join(
188
+ ', '
189
+ )}`
190
+ );
191
+ }
192
+ }
193
+
194
+ module.exports = { UsageRepositoryDocumentDB };
@@ -0,0 +1,25 @@
1
+ const { UsageRepositoryPrisma } = require('./usage-repository-prisma');
2
+ const { UsageRepositoryDocumentDB } = require('./usage-repository-documentdb');
3
+ const config = require('../../database/config');
4
+
5
+ function createUsageRepository() {
6
+ const dbType = config.DB_TYPE;
7
+
8
+ switch (dbType) {
9
+ case 'mongodb':
10
+ case 'postgresql':
11
+ return new UsageRepositoryPrisma();
12
+ case 'documentdb':
13
+ return new UsageRepositoryDocumentDB();
14
+ default:
15
+ throw new Error(
16
+ `Unsupported database type: ${dbType}. Supported values: 'mongodb', 'documentdb', 'postgresql'`
17
+ );
18
+ }
19
+ }
20
+
21
+ module.exports = {
22
+ createUsageRepository,
23
+ UsageRepositoryPrisma,
24
+ UsageRepositoryDocumentDB,
25
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Port for the durable usage-counter store.
3
+ *
4
+ * The store is deliberately isolated (ADR-010 Decision 3): its fact rows carry
5
+ * NO userId and NO foreign key to Integration, so a user-scoped query can never
6
+ * return a usage row and usage history survives integration deletion.
7
+ *
8
+ * Fact row: { integrationId, integrationType, metric, window, value, updatedAt }
9
+ * Dimensions must be bounded — high-cardinality ids stay on traces.
10
+ */
11
+ class UsageRepositoryInterface {
12
+ /**
13
+ * Atomically add `value` to the counter for
14
+ * (integrationId, integrationType, metric, window), inserting if absent.
15
+ */
16
+ async increment(/* { integrationId, integrationType, metric, window, value } */) {
17
+ throw new Error('increment must be implemented by subclass');
18
+ }
19
+
20
+ /**
21
+ * Sum a metric grouped by a bounded dimension since an optional timestamp.
22
+ * @returns {Promise<Array<{[groupBy]: string, value: number}>>}
23
+ */
24
+ async getTotalsByDimension(/* { metric, groupBy, since } */) {
25
+ throw new Error('getTotalsByDimension must be implemented by subclass');
26
+ }
27
+
28
+ /**
29
+ * Time series of a metric for one integration type.
30
+ * @returns {Promise<Array<{bucket: string, value: number}>>}
31
+ */
32
+ async getTimeSeries(/* { metric, integrationType, from, to, bucket } */) {
33
+ throw new Error('getTimeSeries must be implemented by subclass');
34
+ }
35
+ }
36
+
37
+ module.exports = { UsageRepositoryInterface };