@friggframework/core 2.0.0--canary.622.16ee19d.0 → 2.0.0--canary.622.faffe5f.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 (48) hide show
  1. package/application/commands/usage-commands.js +5 -4
  2. package/application/index.js +9 -18
  3. package/core/create-handler.js +17 -6
  4. package/generated/prisma-mongodb/edge.js +3 -3
  5. package/generated/prisma-mongodb/index.d.ts +80 -55
  6. package/generated/prisma-mongodb/index.js +3 -3
  7. package/generated/prisma-mongodb/package.json +1 -1
  8. package/generated/prisma-mongodb/schema.prisma +5 -3
  9. package/generated/prisma-mongodb/wasm.js +3 -3
  10. package/generated/prisma-postgresql/edge.js +3 -3
  11. package/generated/prisma-postgresql/index.d.ts +92 -16
  12. package/generated/prisma-postgresql/index.js +3 -3
  13. package/generated/prisma-postgresql/package.json +1 -1
  14. package/generated/prisma-postgresql/schema.prisma +5 -3
  15. package/generated/prisma-postgresql/wasm.js +3 -3
  16. package/handlers/integration-event-dispatcher.js +1 -1
  17. package/index.js +1 -1
  18. package/integrations/integration-base.js +23 -4
  19. package/modules/requester/requester.js +12 -2
  20. package/package.json +5 -5
  21. package/prisma-mongodb/schema.prisma +5 -3
  22. package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +3 -3
  23. package/prisma-postgresql/schema.prisma +5 -3
  24. package/reporting/reporting-router.js +1 -1
  25. package/reporting/use-cases/list-integrations-report.js +2 -2
  26. package/telemetry/README.md +22 -6
  27. package/telemetry/bind-telemetry-context.js +1 -1
  28. package/telemetry/canonical-counters.js +1 -1
  29. package/telemetry/exporters/exporter-factory.js +2 -2
  30. package/telemetry/instrument-handler.js +1 -1
  31. package/telemetry/no-op-telemetry.js +1 -1
  32. package/telemetry/north-star.js +1 -1
  33. package/telemetry/otel-telemetry.js +1 -1
  34. package/telemetry/plugin-subscribers-singleton.js +2 -2
  35. package/telemetry/plugin-subscribers.js +1 -1
  36. package/telemetry/telemetry-config.js +1 -1
  37. package/telemetry/telemetry-context.js +1 -1
  38. package/telemetry/telemetry-event-bus.js +2 -2
  39. package/telemetry/telemetry-service.js +3 -3
  40. package/telemetry/telemetry-singleton.js +1 -1
  41. package/telemetry/usage-rollup-singleton.js +1 -1
  42. package/telemetry/usage-rollup-subscriber.js +3 -3
  43. package/telemetry/usage-windows.js +1 -1
  44. package/usage/README.md +6 -4
  45. package/usage/repositories/usage-repository-documentdb.js +193 -9
  46. package/usage/repositories/usage-repository-interface.js +1 -1
  47. package/usage/repositories/usage-repository-postgres.js +22 -12
  48. package/usage/tracked-metrics.js +1 -1
@@ -1,7 +1,7 @@
1
1
  const { createUsageRollupSubscriber } = require('./usage-rollup-subscriber');
2
2
 
3
3
  /**
4
- * Process-wide usage-rollup subscriber (ADR-011 P9). Built once per cold start,
4
+ * Process-wide usage-rollup subscriber. Built once per cold start,
5
5
  * subscribed to the telemetry singleton's bus, and reused across invocations.
6
6
  * Returns null when usage is disabled (no integration declares Definition.usage)
7
7
  * or when the app definition can't be loaded — the handler then skips the flush.
@@ -3,8 +3,8 @@ const { computeUsageWindows } = require('./usage-windows');
3
3
  const WEBHOOK_EVENT_NAMES = new Set(['ON_WEBHOOK']);
4
4
 
5
5
  /**
6
- * Framework auto-signal metric names → the canonical usage key they feed
7
- * (ADR-011 §3). Resolvers receive (attributes, context) and may return null to
6
+ * Framework auto-signal metric names → the canonical usage key they feed.
7
+ * Resolvers receive (attributes, context) and may return null to
8
8
  * decline. Handler invocations map by event: USER_ACTION → user_actions; the
9
9
  * DB-connected `ON_WEBHOOK` queue dispatch → webhooks.received (per-integration,
10
10
  * and where a durable write is actually possible — the HTTP receipt handler is
@@ -22,7 +22,7 @@ const METRIC_TO_CANONICAL = {
22
22
  };
23
23
 
24
24
  /**
25
- * The built-in usage-rollup subscriber (ADR-011 Decision 7). Subscribes to the
25
+ * The built-in usage-rollup subscriber. Subscribes to the
26
26
  * telemetry event bus and folds *declared* counters (canonical or custom) into
27
27
  * the durable usage store. It buffers within an invocation and writes on
28
28
  * `flush()` (no timers — Lambda-safe), or drops the buffer on `discard()` for an
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Compute the rollup window keys a usage event falls into (ADR-011 §4).
2
+ * Compute the rollup window keys a usage event falls into.
3
3
  * Windows are UTC and prefixed by their granularity so the store can filter a
4
4
  * series by bucket (`window startsWith 'day:'`).
5
5
  *
package/usage/README.md CHANGED
@@ -46,7 +46,9 @@ class UsageRepositoryInterface {
46
46
  - **`series`** aggregates across integration instances (`groupBy(window) + sum`)
47
47
  and range-filters on the window key. Requires an `integrationType`.
48
48
 
49
- > **DocumentDB:** the adapter currently inherits the Mongo (Prisma) implementation
50
- > and is **not yet verified** against a real DocumentDB cluster (every other
51
- > DocumentDB adapter in this repo needed raw commands). Verify before relying on
52
- > it in production.
49
+ > **DocumentDB:** the adapter overrides increment/totals/series 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.
@@ -1,15 +1,199 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const { updateOne } = require('../../database/documentdb-utils');
1
3
  const { UsageRepositoryMongo } = require('./usage-repository-mongo');
2
4
 
5
+ const COLLECTION = 'UsageCounter';
6
+ const DRAIN_BATCH_SIZE = 1000;
7
+ const MAX_BATCHES = 100000;
8
+ const VALID_GROUP_BY = new Set(['integrationType', 'metric']);
9
+ const VALID_BUCKETS = new Set(['day', 'hour']);
10
+
3
11
  /**
4
- * DocumentDB usage store. Inherits the Mongo (Prisma) implementation — the
5
- * UsageCounter operations are plain upsert/groupBy/findMany with no encrypted
6
- * fields, so no `$runCommandRaw` variant is needed.
7
- *
8
- * TODO(verify): confirm Prisma `upsert` with `{ value: { increment } }` and
9
- * `groupBy._sum` behave correctly against a real DocumentDB cluster before GA
10
- * (ADR-011 open question). If DocumentDB rejects the native upsert, add a
11
- * raw-command override here only.
12
+ * DocumentDB usage store. Amazon DocumentDB does not accept the command shapes
13
+ * Prisma's Mongo engine emits for `upsert({ update: { value: { increment } } })`
14
+ * and `groupBy({ _sum })`, and cursor reads truncate at ~101 docs — so, like
15
+ * every other DocumentDB adapter in this repo, these operations are issued as
16
+ * raw commands (`$runCommandRaw`) via the validated documentdb-utils helpers and
17
+ * a drained aggregate cursor. No timestamps are managed: totals/series filter on
18
+ * the window KEY (not write-time), so createdAt/updatedAt are unnecessary here.
12
19
  */
13
- class UsageRepositoryDocumentDB extends UsageRepositoryMongo {}
20
+ class UsageRepositoryDocumentDB extends UsageRepositoryMongo {
21
+ constructor() {
22
+ super();
23
+ this.prisma = prisma;
24
+ }
25
+
26
+ async increment({ integrationId, integrationType, metric, window, value = 1 }) {
27
+ // Atomic upsert-increment: $inc creates the field at the increment value
28
+ // on insert; $setOnInsert stamps the identity on first write. The compound
29
+ // filter is the unique key, so concurrent writers converge on one row.
30
+ await updateOne(
31
+ this.prisma,
32
+ COLLECTION,
33
+ { integrationId, integrationType, metric, window },
34
+ {
35
+ $inc: { value },
36
+ $setOnInsert: {
37
+ integrationId,
38
+ integrationType,
39
+ metric,
40
+ window,
41
+ },
42
+ },
43
+ { upsert: true }
44
+ );
45
+ }
46
+
47
+ async totals({
48
+ metric,
49
+ groupBy = 'integrationType',
50
+ since,
51
+ bucket = 'day',
52
+ } = {}) {
53
+ if (!metric) {
54
+ throw new Error('totals requires a metric (units are per-metric)');
55
+ }
56
+ assertGroupBy(groupBy);
57
+ assertBucket(bucket);
58
+
59
+ // Single window granularity (day: OR hour:) — never sum across both.
60
+ // `since` bounds on the window KEY (mirrors series / the Prisma adapter).
61
+ const rows = await this._aggregateDrained([
62
+ {
63
+ $match: {
64
+ metric,
65
+ window: windowMatch(bucket, {
66
+ gte: since ? windowKey(bucket, since) : undefined,
67
+ }),
68
+ },
69
+ },
70
+ { $group: { _id: `$${groupBy}`, value: { $sum: '$value' } } },
71
+ ]);
72
+
73
+ return rows.map((row) => ({
74
+ [groupBy]: row._id,
75
+ value: toNumber(row.value),
76
+ }));
77
+ }
78
+
79
+ async series({ metric, integrationType, from, to, bucket = 'day' } = {}) {
80
+ assertBucket(bucket);
81
+ if (!integrationType) {
82
+ throw new Error('series requires an integrationType');
83
+ }
84
+
85
+ const rows = await this._aggregateDrained([
86
+ {
87
+ $match: {
88
+ metric,
89
+ integrationType,
90
+ window: windowMatch(bucket, {
91
+ gte: from ? windowKey(bucket, from) : undefined,
92
+ lte: to ? windowKey(bucket, to) : undefined,
93
+ }),
94
+ },
95
+ },
96
+ { $group: { _id: '$window', value: { $sum: '$value' } } },
97
+ { $sort: { _id: 1 } },
98
+ ]);
99
+
100
+ return rows.map((row) => ({
101
+ bucket: row._id,
102
+ value: toNumber(row.value),
103
+ }));
104
+ }
105
+
106
+ async _aggregateDrained(pipeline) {
107
+ const first = await this.prisma.$runCommandRaw({
108
+ aggregate: COLLECTION,
109
+ pipeline,
110
+ cursor: { batchSize: DRAIN_BATCH_SIZE },
111
+ });
112
+ return drainCursor(this.prisma, COLLECTION, first);
113
+ }
114
+ }
115
+
116
+ /** A `$match` window clause: prefix by granularity, optionally range-bounded. */
117
+ function windowMatch(bucket, { gte, lte } = {}) {
118
+ const clause = { $regex: `^${bucket}:` };
119
+ if (gte) clause.$gte = gte;
120
+ if (lte) clause.$lte = lte;
121
+ return clause;
122
+ }
123
+
124
+ /** Window key for a date at a granularity (mirrors telemetry/usage-windows). */
125
+ function windowKey(bucket, date) {
126
+ const iso = new Date(date).toISOString();
127
+ return `${bucket}:${bucket === 'hour' ? iso.slice(0, 13) : iso.slice(0, 10)}`;
128
+ }
129
+
130
+ /**
131
+ * Coerce an aggregate `$sum` result to a JS Number. Prisma $runCommandRaw returns
132
+ * extended JSON, so a 64-bit sum can arrive as { $numberLong: "..." } (or
133
+ * $numberInt/$numberDouble); counts never approach 2^53.
134
+ */
135
+ function toNumber(value) {
136
+ if (value === null || value === undefined) return 0;
137
+ if (typeof value === 'number') return value;
138
+ if (typeof value === 'bigint') return Number(value);
139
+ if (typeof value === 'object') {
140
+ const raw =
141
+ value.$numberLong ?? value.$numberInt ?? value.$numberDouble;
142
+ if (raw !== undefined) return Number(raw);
143
+ }
144
+ return Number(value) || 0;
145
+ }
146
+
147
+ async function drainCursor(client, collection, firstResult) {
148
+ const cursor = firstResult?.cursor || {};
149
+ const docs = [...(cursor.firstBatch || [])];
150
+ let cursorId = cursor.id;
151
+ let batches = 0;
152
+
153
+ while (isCursorOpen(cursorId) && batches < MAX_BATCHES) {
154
+ batches += 1;
155
+ const next = await client.$runCommandRaw({
156
+ getMore: cursorId,
157
+ collection,
158
+ batchSize: DRAIN_BATCH_SIZE,
159
+ });
160
+ const nextCursor = next?.cursor || {};
161
+ const nextBatch = nextCursor.nextBatch || [];
162
+ docs.push(...nextBatch);
163
+ cursorId = nextCursor.id;
164
+ if (nextBatch.length === 0) break;
165
+ }
166
+ return docs;
167
+ }
168
+
169
+ function isCursorOpen(id) {
170
+ if (id === undefined || id === null) return false;
171
+ if (typeof id === 'number') return id !== 0;
172
+ if (typeof id === 'bigint') return id !== 0n;
173
+ if (typeof id === 'object' && id.$numberLong !== undefined) {
174
+ return id.$numberLong !== '0';
175
+ }
176
+ return String(id) !== '0';
177
+ }
178
+
179
+ function assertGroupBy(groupBy) {
180
+ if (!VALID_GROUP_BY.has(groupBy)) {
181
+ throw new Error(
182
+ `Invalid groupBy "${groupBy}". Allowed: ${[...VALID_GROUP_BY].join(
183
+ ', '
184
+ )}`
185
+ );
186
+ }
187
+ }
188
+
189
+ function assertBucket(bucket) {
190
+ if (!VALID_BUCKETS.has(bucket)) {
191
+ throw new Error(
192
+ `Invalid bucket "${bucket}". Allowed: ${[...VALID_BUCKETS].join(
193
+ ', '
194
+ )}`
195
+ );
196
+ }
197
+ }
14
198
 
15
199
  module.exports = { UsageRepositoryDocumentDB };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Port for the durable usage-counter store (ADR-011 Usage-Counter Contract §4).
2
+ * Port for the durable usage-counter store.
3
3
  *
4
4
  * The store is deliberately isolated (ADR-010 Decision 3): its fact rows carry
5
5
  * NO userId and NO foreign key to Integration, so a user-scoped query can never
@@ -2,7 +2,7 @@ const { prisma } = require('../../database/prisma');
2
2
  const { UsageRepositoryInterface } = require('./usage-repository-interface');
3
3
 
4
4
  /**
5
- * Prisma-backed usage store (ADR-011 §4). This is the canonical implementation;
5
+ * Prisma-backed usage store. This is the canonical implementation;
6
6
  * because Prisma abstracts the underlying database, the Mongo and DocumentDB
7
7
  * adapters extend this class unchanged (see their files). All queries touch only
8
8
  * the isolated `UsageCounter` model — never user/integration-scoped tables.
@@ -34,17 +34,21 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
34
34
  update: { value: { increment: value } },
35
35
  };
36
36
 
37
- try {
38
- await this.prisma.usageCounter.upsert(upsertArgs);
39
- } catch (err) {
40
- // Concurrent first-insert race: two workers both INSERT and one
41
- // hits the unique constraint. Retry once — the row now exists so
42
- // the retry takes the atomic UPDATE (increment) path.
43
- if (err && err.code === 'P2002') {
37
+ // Concurrent first-insert race: two workers both INSERT and one hits the
38
+ // unique constraint (P2002). After the winner's insert the row exists, so
39
+ // a retry takes the atomic UPDATE (increment) path. Bounded so a
40
+ // pathological repeated race can't throw unexpectedly out of the public
41
+ // `recordUsageCounter` write; non-conflict errors surface immediately.
42
+ for (let attempt = 1; ; attempt++) {
43
+ try {
44
44
  await this.prisma.usageCounter.upsert(upsertArgs);
45
45
  return;
46
+ } catch (err) {
47
+ if (err && err.code === 'P2002' && attempt < MAX_UPSERT_ATTEMPTS) {
48
+ continue;
49
+ }
50
+ throw err;
46
51
  }
47
- throw err;
48
52
  }
49
53
  }
50
54
 
@@ -63,8 +67,11 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
63
67
  // Filter to ONE window granularity — every event is written to both a
64
68
  // day: and an hour: row, so summing across granularities would double
65
69
  // (or worse) the true count.
70
+ // Bound `since` on the WINDOW key (mirrors series) — write-time
71
+ // updatedAt would misplace a late/redelivered increment for an earlier
72
+ // window, over- or under-counting the time-bounded total.
66
73
  const where = { metric, window: { startsWith: `${bucket}:` } };
67
- if (since) where.updatedAt = { gte: since };
74
+ if (since) where.window.gte = windowKey(bucket, since);
68
75
 
69
76
  const groups = await this.prisma.usageCounter.groupBy({
70
77
  by: [groupBy],
@@ -72,9 +79,11 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
72
79
  _sum: { value: true },
73
80
  });
74
81
 
82
+ // value is a BigInt column — coerce the sum to a JSON-safe Number
83
+ // (JSON.stringify throws on BigInt; counts never approach 2^53).
75
84
  return groups.map((group) => ({
76
85
  [groupBy]: group[groupBy],
77
- value: group._sum?.value ?? 0,
86
+ value: Number(group._sum?.value ?? 0),
78
87
  }));
79
88
  }
80
89
 
@@ -103,11 +112,12 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
103
112
 
104
113
  return groups.map((group) => ({
105
114
  bucket: group.window,
106
- value: group._sum?.value ?? 0,
115
+ value: Number(group._sum?.value ?? 0),
107
116
  }));
108
117
  }
109
118
  }
110
119
 
120
+ const MAX_UPSERT_ATTEMPTS = 3;
111
121
  const VALID_GROUP_BY = new Set(['integrationType', 'metric']);
112
122
  const VALID_BUCKETS = new Set(['day', 'hour']);
113
123
 
@@ -2,7 +2,7 @@ const { isCanonicalCounter } = require('../telemetry/canonical-counters');
2
2
 
3
3
  /**
4
4
  * Compute the set of usage-counter keys the rollup should persist, from each
5
- * integration's `Definition.usage` opt-in (ADR-011 Usage-Counter Contract §2).
5
+ * integration's `Definition.usage` opt-in.
6
6
  * Declaring a canonical key opts into cross-type comparison + the rollup; custom
7
7
  * keys are tracked per integration type. Unknown canonical keys are dropped with
8
8
  * a warning (lazy validation, matching how Definition is treated elsewhere).