@friggframework/core 2.0.0--canary.658.93c8e07.0 → 2.0.0--canary.658.164d92b.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -370,49 +370,11 @@ class MyIntegration extends IntegrationBase {
370
370
  - `BasicAuthRequester` - Basic authentication
371
371
 
372
372
  **Rate limits** (ADR-049): an API module declares `static rateLimit` on its
373
- `Requester` subclass. For a throttled response the Requester looks for a hint
374
- in this order:
375
-
376
- | Step | Source |
377
- |---|---|
378
- | 1 | `classify({ status, headers, body })` of the module. It recognises a limit that is not a plain 429 (a 403 limit code, a `policyName` in the body) and may name only a `reason` |
379
- | 2 | The header parsers in `parsers` order. Default: `retryAfter`, `resetHeaders`, `ietf` |
380
- | 3 | The static policy: `minRetryAfterMs`, or the `perMs` of the window named by the `reason` |
381
- | 4 | The fixed `backOff` ladder (1, 3, 10, 30, 60, 180 s) |
382
-
383
- - A 429 with no hint from steps 1 to 3 keeps today's ladder: the same calls,
384
- the same delays, then a plain `FetchError`. Nothing budgets it. A response
385
- that `classify` names as a limit but that has no time follows the same
386
- ladder, and the last error is a `FetchError` flagged `isRateLimited` with
387
- the `reason`, so the queue worker does not halt it.
388
- - With a hint, the wait is `max(hint, minRetryAfterMs, 1 s)` plus at most 10 %
389
- jitter. The total sleep of one request is capped at `maxInProcessWaitMs`
390
- (default 5 minutes) and at the time left in the invocation less one request
391
- timeout (`remainingInvocationMs()`).
392
- - A wait that does not fit throws `RateLimitError` (`isRateLimited`, `retryAt`,
393
- `waitMs`, `reason`, `policy`, `source`, `module`, `scopeKey`). The queue
394
- worker does not halt it, whatever its status.
395
- - `Retry-After` is read on a 429 only. Another status counts only when
396
- `classify` recognises it. A 5xx keeps the 5xx ladder.
397
- - `maxInProcessWaitMs: 0` turns off sleeping: every hinted wait throws.
398
- - A module that does not use the Requester (for example one on jsforce) calls
399
- `classifyRateLimit(policy, { status, headers, body })` around its own client
400
- and throws `RateLimitError` itself.
401
-
402
- ```javascript
403
- class Api extends OAuth2Requester {
404
- static rateLimit = {
405
- scope: 'entity',
406
- minRetryAfterMs: 1_000,
407
- classify({ status, body }) {
408
- if (status === 403 && body?.code === 'DAILY_LIMIT') {
409
- return { reason: 'daily', waitMs: 60 * 60_000 };
410
- }
411
- return null;
412
- },
413
- };
414
- }
415
- ```
373
+ `Requester` subclass. A hinted wait (from `classify()`, the header parsers or
374
+ the static policy) sleeps within a budget, or throws `RateLimitError`
375
+ (`isRateLimited`, `retryAt`) when it does not fit. A 429 with no hint keeps the
376
+ fixed `backOff` ladder. See
377
+ [Rate Limits](../../docs/reference/api-module-definition-and-functions.md#rate-limits-static-ratelimit).
416
378
 
417
379
  **Module Factory**:
418
380
  - `ModuleFactory` - Creates and configures API module instances
@@ -568,10 +530,11 @@ if (!userId) {
568
530
  }
569
531
  ```
570
532
 
571
- **Rate-limit message** (ADR-049): when a wait is too long to sleep, the
572
- Requester tells its delegate (`RATE_LIMITED`, with the `RateLimitError`) and
573
- then throws. The notification takes the path of `CREDENTIAL_INVALIDATED`:
574
- Requester, `Module`, `IntegrationBase`.
533
+ **Rate-limit message** (ADR-049): when a wait is longer than the in-process cap
534
+ (`maxInProcessWaitMs`, default 5 min), the Requester tells its delegate
535
+ (`RATE_LIMITED`, with the `RateLimitError`) and then throws. A shorter wait
536
+ that throws sends no notification. The notification takes the path of
537
+ `CREDENTIAL_INVALIDATED`: Requester, `Module`, `IntegrationBase`.
575
538
 
576
539
  - The `Module` sends `{ moduleName, reason, retryAt, policy, statusCode, links }`.
577
540
  `links` is `static rateLimit.userHints[reason].links`, or `[]`. The message,
@@ -607,13 +570,9 @@ Requester, `Module`, `IntegrationBase`.
607
570
 
608
571
  The text is fixed. It holds the module name and the reset time in UTC, rounded
609
572
  up to the minute, and nothing from the request.
610
- `updateIntegrationMessages(id, type, title, body, timestamp)` still works, and
611
- it also takes one item object, `updateIntegrationMessages(id, type, item)`,
612
- whose keys are stored as they are. Every call appends.
613
- `findIntegrationMessages(id, type)` reads the stored items of one type. The
614
- `messages` of the record from `findIntegrationById` is not filled on Postgres
615
- and Mongo, because the schema keeps the messages in the `errors`, `warnings`,
616
- `info` and `logs` columns.
573
+
574
+ Integration messages (the item form, `findIntegrationMessages`, the newest 50
575
+ per type): see "Integration Messages" in `docs/guides/INTEGRATION-PATTERNS.md`.
617
576
 
618
577
  ### 9. Logging System (`/logs`)
619
578
 
@@ -9,11 +9,6 @@ function storage() {
9
9
  return globalThis[STORE_KEY];
10
10
  }
11
11
 
12
- /**
13
- * Runs fn with the time the invocation ends, as epoch milliseconds. A nested
14
- * scope can shorten the deadline and never extend it. A deadline that is not
15
- * a finite number runs fn with no limit.
16
- */
17
12
  function runWithInvocationDeadline(deadlineAt, fn) {
18
13
  if (typeof deadlineAt !== 'number' || !Number.isFinite(deadlineAt)) {
19
14
  return fn();
@@ -25,25 +20,20 @@ function runWithInvocationDeadline(deadlineAt, fn) {
25
20
  return storage().run(Object.freeze({ deadlineAt: effective }), fn);
26
21
  }
27
22
 
28
- /**
29
- * The end of a Lambda invocation, from its context. Undefined when the
30
- * context cannot tell.
31
- */
32
- function deadlineFromContext(context) {
23
+ function remainingTimeMs(context) {
33
24
  try {
34
25
  const remaining = context?.getRemainingTimeInMillis?.();
35
- return typeof remaining === 'number' && Number.isFinite(remaining)
36
- ? Date.now() + remaining
37
- : undefined;
38
- } catch {
39
- return undefined;
26
+ return typeof remaining === 'number' ? remaining : Infinity;
27
+ } catch (_) {
28
+ return Infinity;
40
29
  }
41
30
  }
42
31
 
43
- /**
44
- * Milliseconds left in the invocation. Infinity outside a deadline scope
45
- * (tests, `frigg start`, scripts).
46
- */
32
+ function deadlineFromContext(context) {
33
+ const remaining = remainingTimeMs(context);
34
+ return Number.isFinite(remaining) ? Date.now() + remaining : undefined;
35
+ }
36
+
47
37
  function remainingInvocationMs(now = Date.now()) {
48
38
  const store = storage().getStore();
49
39
  return store ? Math.max(0, store.deadlineAt - now) : Infinity;
@@ -52,5 +42,6 @@ function remainingInvocationMs(now = Date.now()) {
52
42
  module.exports = {
53
43
  deadlineFromContext,
54
44
  remainingInvocationMs,
45
+ remainingTimeMs,
55
46
  runWithInvocationDeadline,
56
47
  };
@@ -3,6 +3,7 @@ const { summarizeMessageBody } = require('../logs/summarize-event');
3
3
  const { flushSinks, hasFlushableSinks } = require('../logs/logger-runtime');
4
4
  const {
5
5
  deadlineFromContext,
6
+ remainingTimeMs,
6
7
  runWithInvocationDeadline,
7
8
  } = require('./invocation-deadline');
8
9
 
@@ -115,15 +116,6 @@ function withDeadline(ms, fn) {
115
116
  });
116
117
  }
117
118
 
118
- function remainingTimeMs(context) {
119
- try {
120
- const remaining = context?.getRemainingTimeInMillis?.();
121
- return typeof remaining === 'number' ? remaining : Infinity;
122
- } catch (_) {
123
- return Infinity;
124
- }
125
- }
126
-
127
119
  async function flushInvocation({
128
120
  telemetry,
129
121
  usageRollup,
@@ -55,15 +55,13 @@ class FetchError extends BaseError {
55
55
 
56
56
  static async create(options = {}) {
57
57
  const { response } = options;
58
- const provided = options.responseBody ?? options.body;
59
- let responseBody = provided;
60
- if (
61
- responseBody === undefined &&
62
- response &&
63
- !response.bodyUsed &&
64
- typeof response.text === 'function'
65
- ) {
66
- responseBody = (await response.text()) || provided;
58
+ let { responseBody } = options;
59
+ if (responseBody === undefined || responseBody === null) {
60
+ const canRead =
61
+ response &&
62
+ !response.bodyUsed &&
63
+ typeof response.text === 'function';
64
+ responseBody = (canRead && (await response.text())) || options.body;
67
65
  }
68
66
  const ErrorClass = typeof this === 'function' ? this : FetchError;
69
67
  return new ErrorClass({ ...options, responseBody });
@@ -14,10 +14,6 @@ function resolveTiming({ hint, waitMs, now }) {
14
14
  return { waitMs: fallbackWaitMs, retryAt: new Date(now + fallbackWaitMs) };
15
15
  }
16
16
 
17
- /**
18
- * A FetchError for a response that said a limit was hit and told us, or the
19
- * module's policy told us, when to call again. `retryAt` is that time.
20
- */
21
17
  class RateLimitError extends FetchError {
22
18
  constructor({
23
19
  hint,
@@ -871,9 +871,7 @@ class IntegrationBase {
871
871
  * something integration-level needs attention. Today this catches the
872
872
  * `CREDENTIAL_INVALIDATED` event Module fires from `markCredentialsInvalid`
873
873
  * and flips this integration's status to ERROR so the queue worker
874
- * stops processing further webhooks until the user re-authorizes. It also
875
- * catches `RATE_LIMITED`, which records one warning for the user and
876
- * changes no status: a rate limit is not an error.
874
+ * stops processing further webhooks until the user re-authorizes.
877
875
  *
878
876
  * Modules are wired to this delegate in `_appendModules()`, which runs
879
877
  * during `setIntegrationRecord()` — this covers every construction path
@@ -918,14 +916,13 @@ class IntegrationBase {
918
916
  }
919
917
 
920
918
  if (delegateString === 'RATE_LIMITED') {
921
- const moduleName = object?.moduleName ?? notifier?.name;
922
919
  this.logger.warn('Module reported a rate limit', {
923
920
  eventName: `${this.logger.name}.rate_limited`,
924
- moduleName,
921
+ moduleName: object?.moduleName,
925
922
  reason: object?.reason,
926
923
  retryAt: object?.retryAt,
927
924
  });
928
- await this._recordRateLimitMessage({ ...object, moduleName });
925
+ await this.recordRateLimitMessage.execute(this.id, object);
929
926
  }
930
927
  }
931
928
 
@@ -952,23 +949,6 @@ class IntegrationBase {
952
949
  });
953
950
  }
954
951
  }
955
-
956
- /**
957
- * Best-effort: the module's request is about to throw a RateLimitError,
958
- * and a failed warning must not change that. A rate limit is not an ERROR,
959
- * so this never touches the integration status.
960
- * @param {Object} payload - The RATE_LIMITED payload of the module.
961
- */
962
- async _recordRateLimitMessage(payload) {
963
- try {
964
- await this.recordRateLimitMessage.execute(this.id, payload);
965
- } catch (error) {
966
- this.logger.error('Failed to record rate limit message', {
967
- eventName: `${this.logger.name}.rate_limit_message_record_failed`,
968
- error,
969
- });
970
- }
971
- }
972
952
  }
973
953
 
974
954
  module.exports = { IntegrationBase };
@@ -14,7 +14,7 @@ const {
14
14
  IntegrationRepositoryInterface,
15
15
  } = require('./integration-repository-interface');
16
16
  const { validateConfigPatch } = require('./config-patch-shared');
17
- const { toMessageItem } = require('./message-item-shared');
17
+ const { messagesOfType } = require('./message-item-shared');
18
18
 
19
19
  class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
20
20
  constructor() {
@@ -95,9 +95,8 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
95
95
  async updateIntegrationMessages(
96
96
  integrationId,
97
97
  messageType,
98
- messageTitleOrItem,
99
- messageBody,
100
- messageTimestamp
98
+ item,
99
+ { keepLast }
101
100
  ) {
102
101
  const objectId = toObjectId(integrationId);
103
102
  if (!objectId) {
@@ -108,14 +107,12 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
108
107
  throw new Error(`Integration ${integrationId} not found`);
109
108
  }
110
109
  const messages = this._extractMessages(existing);
111
- const list = Array.isArray(messages[messageType]) ? [...messages[messageType]] : [];
112
- const item = toMessageItem(
113
- messageTitleOrItem,
114
- messageBody,
115
- messageTimestamp
116
- );
110
+ const list = [...messagesOfType(messages, messageType)];
117
111
  list.push({ ...item, title: item.title ?? null });
118
- const updatedMessages = { ...messages, [messageType]: list };
112
+ const updatedMessages = {
113
+ ...messages,
114
+ [messageType]: list.slice(-keepLast),
115
+ };
119
116
  await updateOne(
120
117
  this.prisma,
121
118
  'Integration',
@@ -145,10 +142,7 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
145
142
  if (!existing) {
146
143
  throw new Error(`Integration ${integrationId} not found`);
147
144
  }
148
- const messages = this._extractMessages(existing);
149
- return Array.isArray(messages[messageType])
150
- ? messages[messageType]
151
- : [];
145
+ return messagesOfType(this._extractMessages(existing), messageType);
152
146
  }
153
147
 
154
148
  async createIntegration(entities, userId, config) {
@@ -97,24 +97,21 @@ class IntegrationRepositoryInterface {
97
97
  }
98
98
 
99
99
  /**
100
- * Append a message to the stored messages of one type. Takes the
101
- * positional form (title, body, timestamp) or one message item object,
102
- * whose keys are stored as they are.
100
+ * Append a message item to the stored messages of one type
103
101
  *
104
102
  * @param {string|number} integrationId - Integration ID
105
103
  * @param {string} messageType - Type of message (errors, warnings, info, logs)
106
- * @param {string|Object} messageTitleOrItem - Message title, or the whole message item
107
- * @param {string} [messageBody] - Message body (positional form)
108
- * @param {Date} [messageTimestamp] - Message timestamp (positional form)
104
+ * @param {Object} item - Message item, stored as it is
105
+ * @param {Object} options
106
+ * @param {number} options.keepLast - How many of the newest items of the type to keep
109
107
  * @returns {Promise<boolean>} Success indicator
110
108
  * @abstract
111
109
  */
112
110
  async updateIntegrationMessages(
113
111
  integrationId,
114
112
  messageType,
115
- messageTitleOrItem,
116
- messageBody,
117
- messageTimestamp
113
+ item,
114
+ { keepLast }
118
115
  ) {
119
116
  throw new Error('Method updateIntegrationMessages must be implemented by subclass');
120
117
  }
@@ -3,7 +3,7 @@ const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
5
  const { validateConfigPatch } = require('./config-patch-shared');
6
- const { toMessageItem } = require('./message-item-shared');
6
+ const { messagesOfType } = require('./message-item-shared');
7
7
 
8
8
  /**
9
9
  * MongoDB Integration Repository Adapter
@@ -230,17 +230,16 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
230
230
  *
231
231
  * @param {string} integrationId - Integration ID
232
232
  * @param {string} messageType - Type of message (errors, warnings, info, logs)
233
- * @param {string|Object} messageTitleOrItem - Message title, or the whole message item
234
- * @param {string} [messageBody] - Message body (positional form)
235
- * @param {Date} [messageTimestamp] - Message timestamp (positional form)
233
+ * @param {Object} item - Message item, stored as it is
234
+ * @param {Object} options
235
+ * @param {number} options.keepLast - How many of the newest items of the type to keep
236
236
  * @returns {Promise<boolean>} Success indicator
237
237
  */
238
238
  async updateIntegrationMessages(
239
239
  integrationId,
240
240
  messageType,
241
- messageTitleOrItem,
242
- messageBody,
243
- messageTimestamp
241
+ item,
242
+ { keepLast }
244
243
  ) {
245
244
  // Get current integration
246
245
  const integration = await this.prisma.integration.findUnique({
@@ -252,20 +251,16 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
252
251
  }
253
252
 
254
253
  // Parse existing messages (JSON field)
255
- const messageArray = Array.isArray(integration[messageType])
256
- ? integration[messageType]
257
- : [];
254
+ const messageArray = messagesOfType(integration, messageType);
258
255
 
259
256
  // Add new message
260
- messageArray.push(
261
- toMessageItem(messageTitleOrItem, messageBody, messageTimestamp)
262
- );
257
+ messageArray.push(item);
263
258
 
264
259
  // Update messages
265
260
  await this.prisma.integration.update({
266
261
  where: { id: integrationId },
267
262
  data: {
268
- [messageType]: messageArray,
263
+ [messageType]: messageArray.slice(-keepLast),
269
264
  },
270
265
  });
271
266
 
@@ -289,9 +284,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
289
284
  throw new Error(`Integration ${integrationId} not found`);
290
285
  }
291
286
 
292
- return Array.isArray(integration[messageType])
293
- ? integration[messageType]
294
- : [];
287
+ return messagesOfType(integration, messageType);
295
288
  }
296
289
 
297
290
  /**
@@ -3,7 +3,7 @@ const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
5
  const { validateConfigPatch } = require('./config-patch-shared');
6
- const { toMessageItem } = require('./message-item-shared');
6
+ const { messagesOfType } = require('./message-item-shared');
7
7
  const { strictIntId } = require('./report-id');
8
8
 
9
9
  /**
@@ -274,17 +274,16 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
274
274
  *
275
275
  * @param {string} integrationId - Integration ID (string from application layer)
276
276
  * @param {string} messageType - Type of message (errors, warnings, info, logs)
277
- * @param {string|Object} messageTitleOrItem - Message title, or the whole message item
278
- * @param {string} [messageBody] - Message body (positional form)
279
- * @param {Date} [messageTimestamp] - Message timestamp (positional form)
277
+ * @param {Object} item - Message item, stored as it is
278
+ * @param {Object} options
279
+ * @param {number} options.keepLast - How many of the newest items of the type to keep
280
280
  * @returns {Promise<boolean>} Success indicator
281
281
  */
282
282
  async updateIntegrationMessages(
283
283
  integrationId,
284
284
  messageType,
285
- messageTitleOrItem,
286
- messageBody,
287
- messageTimestamp
285
+ item,
286
+ { keepLast }
288
287
  ) {
289
288
  const intId = this._convertId(integrationId);
290
289
 
@@ -298,20 +297,16 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
298
297
  }
299
298
 
300
299
  // Parse existing messages (JSON field)
301
- const messageArray = Array.isArray(integration[messageType])
302
- ? integration[messageType]
303
- : [];
300
+ const messageArray = messagesOfType(integration, messageType);
304
301
 
305
302
  // Add new message
306
- messageArray.push(
307
- toMessageItem(messageTitleOrItem, messageBody, messageTimestamp)
308
- );
303
+ messageArray.push(item);
309
304
 
310
305
  // Update messages
311
306
  await this.prisma.integration.update({
312
307
  where: { id: intId },
313
308
  data: {
314
- [messageType]: messageArray,
309
+ [messageType]: messageArray.slice(-keepLast),
315
310
  },
316
311
  });
317
312
 
@@ -336,9 +331,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
336
331
  throw new Error(`Integration ${integrationId} not found`);
337
332
  }
338
333
 
339
- return Array.isArray(integration[messageType])
340
- ? integration[messageType]
341
- : [];
334
+ return messagesOfType(integration, messageType);
342
335
  }
343
336
 
344
337
  /**
@@ -1,24 +1,4 @@
1
- const isItem = (value) =>
2
- value !== null && typeof value === 'object' && !Array.isArray(value);
1
+ const messagesOfType = (record, messageType) =>
2
+ Array.isArray(record[messageType]) ? record[messageType] : [];
3
3
 
4
- /**
5
- * The stored shape of a message, for IntegrationRepository
6
- * .updateIntegrationMessages(). The method takes the positional form (title,
7
- * body, timestamp) or one item object. The keys of an item object are stored
8
- * as they are, so a caller can attach what a client acts on, like a code or
9
- * a list of actions.
10
- * @param {string|Object} titleOrItem - The title, or the whole item.
11
- * @param {string} [messageBody] - Ignored when an item is given.
12
- * @param {number|Date} [messageTimestamp] - Ignored when an item is given.
13
- * @returns {Object} A new object; the given item is never returned itself.
14
- */
15
- function toMessageItem(titleOrItem, messageBody, messageTimestamp) {
16
- if (isItem(titleOrItem)) return { ...titleOrItem };
17
- return {
18
- title: titleOrItem,
19
- message: messageBody,
20
- timestamp: messageTimestamp,
21
- };
22
- }
23
-
24
- module.exports = { toMessageItem };
4
+ module.exports = { messagesOfType };
@@ -0,0 +1,24 @@
1
+ const copy = (value) => JSON.parse(JSON.stringify(value));
2
+
3
+ function withMessageRow(repo, id, stored = {}) {
4
+ const row = {
5
+ id,
6
+ errors: [],
7
+ warnings: [],
8
+ info: [],
9
+ logs: [],
10
+ ...stored,
11
+ };
12
+ repo.prisma = {
13
+ integration: {
14
+ findUnique: jest.fn(async () => copy(row)),
15
+ update: jest.fn(async ({ data }) => {
16
+ Object.assign(row, copy(data));
17
+ return copy(row);
18
+ }),
19
+ },
20
+ };
21
+ return { repo, row };
22
+ }
23
+
24
+ module.exports = { withMessageRow };
@@ -1,6 +1,5 @@
1
1
  const { v4: uuid } = require('uuid');
2
2
  const { validateConfigPatch } = require('../../repositories/config-patch-shared');
3
- const { toMessageItem } = require('../../repositories/message-item-shared');
4
3
 
5
4
  class TestIntegrationRepository {
6
5
  constructor() {
@@ -73,14 +72,15 @@ class TestIntegrationRepository {
73
72
  return results;
74
73
  }
75
74
 
76
- async updateIntegrationMessages(id, type, titleOrItem, body, timestamp) {
75
+ async updateIntegrationMessages(id, type, item, { keepLast }) {
77
76
  const rec = this.store.get(id);
78
77
  if (!rec) {
79
78
  this.operationHistory.push({ operation: 'updateMessages', id, success: false });
80
79
  return false;
81
80
  }
82
81
  if (!rec.messages[type]) rec.messages[type] = [];
83
- rec.messages[type].push(toMessageItem(titleOrItem, body, timestamp));
82
+ rec.messages[type].push(item);
83
+ rec.messages[type] = rec.messages[type].slice(-keepLast);
84
84
  this.operationHistory.push({ operation: 'updateMessages', id, type, success: true });
85
85
  return true;
86
86
  }