@friggframework/core 2.0.0--canary.651.8ef5a7a.0 → 2.0.0--canary.653.af34bd7.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
@@ -211,52 +211,7 @@ packages/core/
211
211
  - `integration-repository-factory.js` - Creates database-specific repositories
212
212
  - `integration-repository-mongo.js` - MongoDB implementation
213
213
  - `integration-repository-postgres.js` - PostgreSQL implementation
214
- - `integration-mapping-repository-*.js` - Mapping data persistence.
215
- `queryMappings(integrationId, { where, orderBy, skip, take, omit })`
216
- returns `{ mappings, total }`: one filtered, ordered page of an
217
- integration's mappings, for callers that cannot load every row through
218
- `findMappingsByIntegration`. `where` is an array of ANDed conditions
219
- (at most 20); an entry may be `{ anyOf: [...] }`, ORed, one level deep.
220
- Conditions are `{ path: 'mapping.<segment>...', op: 'exists' | 'notExists' }`
221
- (JSON null counts as absent), `{ path: 'mapping.…', op: 'in', value: string[] }`
222
- (1–500 strings) and `{ path: 'sourceId', op: 'notStartsWith', value }`
223
- (a NULL sourceId matches). `orderBy` is `{ path: 'mapping.…', direction:
224
- 'asc' | 'desc' }`, nulls last, ties broken by id in the same direction;
225
- without it rows come in id order. `take` is 1–500. `omit` lists top-level
226
- mapping keys to leave out of the rows; never write such rows back. Path
227
- segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and
228
- DocumentDB adapters give the same pages and totals;
229
- `integration-mapping-repository-query-parity.test.js` checks that against
230
- real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` /
231
- `QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ:
232
- strings compare by the database collation on PostgreSQL and by code point
233
- on MongoDB and DocumentDB, and arrays or objects at the sort path order
234
- among themselves only on PostgreSQL (the other adapters fall back to id).
235
- The legacy `IntegrationMappingRepository` inherits the port's
236
- "not supported by this database adapter yet" error. Every adapter refuses
237
- to run while field-level encryption still encrypts
238
- `IntegrationMapping.mapping` on write (see `database/encryption/README.md`).
239
- Validation lives in `integration-mapping-query.js`; a new operator is one
240
- entry in its `OPERATORS` table, one in the Postgres adapter's
241
- `CONDITION_SQL` and one in `CONDITION_EXPRESSIONS` in
242
- `integration-mapping-query-pipeline.js`, the aggregation stages both
243
- MongoDB-protocol adapters share. Those stages may only use what Amazon
244
- DocumentDB 4.0 and 5.0 support (no `$facet`, `$getField`, `$set` or
245
- `$unset`).
246
- **Cost**: PostgreSQL answers in one SQL statement, which reads every row
247
- of the integration and evaluates the JSON paths per row, because no JSON
248
- index exists. With ~4 KB mappings on PostgreSQL 16 that is about 0.3 s per
249
- call at 10⁴ rows per integration and 2.5–3 s at 10⁵. MongoDB answers in one
250
- aggregate: the `integrationId` index narrows `$match` to the integration,
251
- the `$expr` is then evaluated per document, and `$facet` returns the page
252
- with its `$count`, so a page (after `omit`) must fit the 16 MB document
253
- limit. DocumentDB has no `$facet`: the page and the count are two
254
- concurrent aggregates, so the total does not come from the same snapshot
255
- as the page. Both sort in memory on a computed key (`allowDiskUse`). With
256
- ~4 KB mappings on MongoDB 7 that is about 0.05–0.1 s per call at 10⁴ rows
257
- per integration and 0.5–0.7 s at 10⁵ (the DocumentDB pipeline, run on
258
- MongoDB, 0.6–0.8 s). On every adapter a deep offset or an empty page past
259
- the end costs about the same as the first page.
214
+ - `integration-mapping-repository-*.js` - Mapping data persistence
260
215
  - `process-repository-*.js` - Process (long-running job) persistence.
261
216
  Implements `applyProcessUpdate(processId, ops)` — a race-safe alternative
262
217
  to `update(id, patch)` that routes increments, sets, and bounded-array
@@ -459,6 +414,43 @@ const decrypted = await cryptor.decrypt(encrypted);
459
414
  - `integration-event-dispatcher.js` - Routes events to integration handlers
460
415
  - Supports lifecycle events and user actions
461
416
 
417
+ **Queue handler delivery**: a handler dispatched from the integration queue
418
+ (any `this.events` entry reached through SQS, including `ON_WEBHOOK` and
419
+ scheduled jobs) receives `{ data, context, delivery }`. HTTP-dispatched
420
+ handlers (`{ req, res, next }`) and `this.on` events do not.
421
+
422
+ | Field | Type | Value |
423
+ |---|---|---|
424
+ | `delivery.receiveCount` | `number \| undefined` | SQS `ApproximateReceiveCount` of this delivery |
425
+ | `delivery.maxReceiveCount` | `number \| undefined` | Receives allowed before SQS moves the message to the DLQ (`FRIGG_QUEUE_MAX_RECEIVE_COUNT`) |
426
+ | `delivery.isLastAttempt` | `boolean` | `true` only when both counts are known and `receiveCount >= maxReceiveCount` |
427
+
428
+ `isLastAttempt` is `false` when either count is unknown: a local or non-SQS
429
+ invocation, or a queue whose redrive policy the stack does not own
430
+ (`ownership.queue: 'external'`). The value is information only: core still
431
+ rethrows retryable errors and discards halt errors (4xx except 408/429).
432
+
433
+ Use it to end a run or count lost work on the final try: when a retryable
434
+ error (429, 5xx, network) is about to be rethrown and `isLastAttempt` is
435
+ `true`, the message goes to the DLQ next, so mark the run failed or count the
436
+ message's records as failed before rethrowing.
437
+
438
+ ```javascript
439
+ async processBatch({ data, delivery }) {
440
+ try {
441
+ await this.syncPage(data);
442
+ } catch (error) {
443
+ if (delivery?.isLastAttempt) await this.failRun(data.processId, error);
444
+ throw error;
445
+ }
446
+ }
447
+ ```
448
+
449
+ The single source of the max receive count is
450
+ `INTEGRATION_QUEUE_MAX_RECEIVE_COUNT` in `queues/queue-delivery.js`. The
451
+ devtools integration builder uses it for the queue's `RedrivePolicy` and sets
452
+ it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
453
+
462
454
  ### 8. Error Handling (`/errors`)
463
455
 
464
456
  **Purpose**: Standardized error types with proper HTTP semantics.
@@ -707,6 +699,7 @@ Use test doubles from `@friggframework/test` package for consistent mocking.
707
699
  - `SECRET_ARN` - AWS Secrets Manager ARN for auto-injection
708
700
  - `DEBUG` - Debug logging pattern
709
701
  - `LOG_LEVEL` - Logging level (debug, info, warn, error)
702
+ - `FRIGG_QUEUE_MAX_RECEIVE_COUNT` - Set by devtools on integration queue workers whose queue the stack owns; feeds `delivery.maxReceiveCount`
710
703
 
711
704
  ## Version Information
712
705
 
package/core/CLAUDE.md CHANGED
@@ -52,9 +52,10 @@ const handler = createHandler({
52
52
  **Usage Pattern**:
53
53
  ```javascript
54
54
  class MyWorker extends Worker {
55
- async _run(params, context = {}) {
55
+ async _run(params, context = {}, delivery) {
56
56
  // Your job processing logic here
57
57
  // params are already JSON.parsed from SQS message body
58
+ // delivery is { receiveCount, maxReceiveCount, isLastAttempt } (see ../CLAUDE.md "Queue handler delivery")
58
59
  }
59
60
 
60
61
  _validateParams(params) {
package/core/Worker.js CHANGED
@@ -2,6 +2,7 @@ const { SQSClient, GetQueueUrlCommand, SendMessageCommand } = require('@aws-sdk/
2
2
  const _ = require('lodash');
3
3
  const { RequiredPropertyError } = require('../errors');
4
4
  const { get } = require('../assertions');
5
+ const { readQueueDelivery } = require('../queues/queue-delivery');
5
6
 
6
7
  const sqs = new SQSClient({ region: process.env.AWS_REGION });
7
8
 
@@ -42,7 +43,7 @@ class Worker {
42
43
  try {
43
44
  const runParams = JSON.parse(record.body);
44
45
  this._validateParams(runParams);
45
- await this._run(runParams, context);
46
+ await this._run(runParams, context, readQueueDelivery(record));
46
47
  console.log(`[Worker] record success`, {
47
48
  messageId: record.messageId,
48
49
  event: runParams?.event,
@@ -1,9 +1,5 @@
1
1
  const { Cryptor } = require('../encrypt/Cryptor');
2
- const {
3
- getEncryptedFields,
4
- getFieldsToEncryptOnWrite,
5
- loadCustomEncryptionSchema,
6
- } = require('./encryption/encryption-schema-registry');
2
+ const { getEncryptedFields, loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry');
7
3
 
8
4
  /**
9
5
  * Encryption service specifically for DocumentDB repositories
@@ -111,7 +107,7 @@ class DocumentDBEncryptionService {
111
107
  }
112
108
 
113
109
  // Get encrypted fields from registry
114
- const encryptedFieldsConfig = getFieldsToEncryptOnWrite(modelName);
110
+ const encryptedFieldsConfig = getEncryptedFields(modelName);
115
111
  if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
116
112
  return document;
117
113
  }
@@ -166,12 +166,11 @@ async function findManyDrained(client, collection, filter = {}, options = {}) {
166
166
  return drainCursor(client, collection, first);
167
167
  }
168
168
 
169
- async function aggregateDrained(client, collection, pipeline, options = {}) {
169
+ async function aggregateDrained(client, collection, pipeline) {
170
170
  const first = await client.$runCommandRaw({
171
171
  aggregate: collection,
172
172
  pipeline,
173
173
  cursor: { batchSize: DRAIN_BATCH_SIZE },
174
- ...options,
175
174
  });
176
175
  return drainCursor(client, collection, first);
177
176
  }
@@ -814,23 +814,6 @@ export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
814
814
  ❌ Query on encrypted fields (not supported)
815
815
  ❌ Manually decrypt data (use extension)
816
816
 
817
- The integration mapping repositories' `queryMappings()` (PostgreSQL, MongoDB
818
- and DocumentDB) filters and sorts inside the `mapping` JSON, so it refuses to
819
- run while field-level encryption still encrypts `IntegrationMapping.mapping` on
820
- write. Opt the field out in the app definition, together with any nested
821
- `mapping.*` path that `encryption.schema` encrypts; the Prisma extension and
822
- `DocumentDBEncryptionService` both honor the opt-out:
823
-
824
- ```javascript
825
- encryption: {
826
- disable: { IntegrationMapping: ['mapping'] },
827
- }
828
- ```
829
-
830
- The opt-out applies to writes only. Rows written encrypted before it stay
831
- readable, but `queryMappings()` does not match them until they are written
832
- again.
833
-
834
817
  ## Future Enhancements
835
818
 
836
819
  ### Planned
@@ -164,7 +164,7 @@ const createQueueWorker = (integrationClass) => {
164
164
  const integrationName = integrationClass.Definition.name;
165
165
 
166
166
  class QueueWorker extends Worker {
167
- async _run(params, context) {
167
+ async _run(params, context, delivery) {
168
168
  const logCtx = {
169
169
  integration: integrationName,
170
170
  event: params.event,
@@ -254,6 +254,7 @@ const createQueueWorker = (integrationClass) => {
254
254
  event: params.event,
255
255
  data: params.data,
256
256
  context: context,
257
+ delivery,
257
258
  });
258
259
  console.log(
259
260
  `[QueueWorker] ${params.event} dispatched ok`,
@@ -18,9 +18,9 @@ class IntegrationEventDispatcher {
18
18
  );
19
19
  }
20
20
 
21
- async dispatchJob({ event, data, context }) {
21
+ async dispatchJob({ event, data, context, delivery }) {
22
22
  return this._dispatch(event, (instance, handler) =>
23
- handler.call(instance, { data, context })
23
+ handler.call(instance, { data, context, delivery })
24
24
  );
25
25
  }
26
26
 
@@ -8,7 +8,6 @@ const {
8
8
  updateOne,
9
9
  deleteOne,
10
10
  deleteMany,
11
- aggregate,
12
11
  aggregateDrained,
13
12
  } = require('../../database/documentdb-utils');
14
13
  const {
@@ -17,20 +16,6 @@ const {
17
16
  const {
18
17
  DocumentDBEncryptionService,
19
18
  } = require('../../database/documentdb-encryption-service');
20
- const {
21
- assertMappingWrittenUnencrypted,
22
- } = require('../../database/encryption/integration-mapping-encryption');
23
- const { validateMappingQuery } = require('./integration-mapping-query');
24
- const {
25
- buildMappingQueryStages,
26
- } = require('./integration-mapping-query-pipeline');
27
-
28
- function storedIntegrationId(id) {
29
- if (!['string', 'number'].includes(typeof id) || id === '') {
30
- throw new TypeError(`Invalid ID: ${id}`);
31
- }
32
- return String(id);
33
- }
34
19
 
35
20
  class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositoryInterface {
36
21
  constructor() {
@@ -194,44 +179,6 @@ class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositor
194
179
  return decryptedDocs.map((doc) => this._mapMapping(doc));
195
180
  }
196
181
 
197
- /**
198
- * @param {string} integrationId
199
- * @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
200
- * @returns {Promise<{mappings: Array<Object>, total: number}>}
201
- */
202
- async queryMappings(integrationId, query) {
203
- const validated = validateMappingQuery(query);
204
- const stored = storedIntegrationId(integrationId);
205
- assertMappingWrittenUnencrypted();
206
- const { match, page, sort } = buildMappingQueryStages(
207
- stored,
208
- validated
209
- );
210
-
211
- const [docs, counts] = await Promise.all([
212
- aggregateDrained(
213
- this.prisma,
214
- 'IntegrationMapping',
215
- [match, ...page, sort],
216
- { allowDiskUse: true }
217
- ),
218
- aggregate(this.prisma, 'IntegrationMapping', [
219
- match,
220
- { $count: 'total' },
221
- ]),
222
- ]);
223
- const decryptedDocs = await Promise.all(
224
- docs.map((doc) =>
225
- this.encryptionService.decryptFields('IntegrationMapping', doc)
226
- )
227
- );
228
-
229
- return {
230
- mappings: decryptedDocs.map((doc) => this._mapMapping(doc)),
231
- total: counts[0]?.total ?? 0,
232
- };
233
- }
234
-
235
182
  async deleteMapping(integrationId, sourceId) {
236
183
  const filter = this._compositeFilter(integrationId, sourceId);
237
184
  const result = await deleteOne(
@@ -52,51 +52,6 @@ class IntegrationMappingRepositoryInterface {
52
52
  );
53
53
  }
54
54
 
55
- /**
56
- * Query one filtered, ordered page of an integration's mappings without
57
- * loading every row. Rows have the same shape as findMappingsByIntegration.
58
- *
59
- * Paths address the `mapping` JSON by identifier-only segments
60
- * (`'mapping.c2h.lastStatus'`), or the `sourceId` column. Conditions:
61
- * - `{ path: 'mapping.…', op: 'exists' | 'notExists' }` — JSON null counts
62
- * as absent; notExists is the exact negation of exists.
63
- * - `{ path: 'mapping.…', op: 'in', value: string[] }` — matches JSON
64
- * strings; 1–500 values.
65
- * - `{ path: 'sourceId', op: 'notStartsWith', value: string }` — a NULL
66
- * sourceId matches.
67
- *
68
- * Only rows whose `mapping` is a JSON object can match, so rows whose
69
- * whole `mapping` is still ciphertext from before an encryption opt-out
70
- * never do. Adapters refuse to run while field-level encryption is enabled
71
- * and still encrypts `mapping`, or a path inside it, on write; opt out with
72
- * `appDefinition.encryption.disable = { IntegrationMapping: ['mapping'] }`
73
- * plus any nested `mapping.…` path a custom schema encrypts.
74
- *
75
- * @param {string|number} integrationId - The integration ID
76
- * @param {Object} query
77
- * @param {Array<Object>} [query.where=[]] - Conditions ANDed together; an
78
- * entry may be `{ anyOf: Condition[] }` (one level, ORed). At most 20
79
- * conditions, anyOf members included.
80
- * @param {{path: string, direction: 'asc'|'desc'}} [query.orderBy] - A
81
- * mapping path; nulls last, ties broken by id in the same direction.
82
- * Values order string < number < boolean < array < object. Strings
83
- * compare by the database collation on PostgreSQL and by code point on
84
- * MongoDB and DocumentDB; arrays and objects order among themselves
85
- * only on PostgreSQL. Without it rows are ordered by id ascending.
86
- * @param {number} [query.skip=0] - Rows to skip (integer ≥ 0)
87
- * @param {number} query.take - Page size (integer 1–500)
88
- * @param {string[]} [query.omit=[]] - Top-level mapping keys to leave out of
89
- * the returned rows; such projected rows must not be written back
90
- * @returns {Promise<{mappings: Array<Object>, total: number}>} The page, and
91
- * the number of rows matching `where` (counted by a separate command on
92
- * DocumentDB, so not from the page's snapshot)
93
- */
94
- async queryMappings(integrationId, query) {
95
- throw new Error(
96
- 'queryMappings is not supported by this database adapter yet'
97
- );
98
- }
99
-
100
55
  /**
101
56
  * Delete a specific mapping
102
57
  *
@@ -1,25 +1,7 @@
1
1
  const { prisma } = require('../../database/prisma');
2
- const {
3
- assertMappingWrittenUnencrypted,
4
- } = require('../../database/encryption/integration-mapping-encryption');
5
2
  const {
6
3
  IntegrationMappingRepositoryInterface,
7
4
  } = require('./integration-mapping-repository-interface');
8
- const { validateMappingQuery } = require('./integration-mapping-query');
9
- const {
10
- buildMappingQueryStages,
11
- } = require('./integration-mapping-query-pipeline');
12
-
13
- const OBJECT_ID_REGEX = /^[0-9a-fA-F]{24}$/;
14
-
15
- const fromRawDate = (raw) => new Date(raw.$date);
16
-
17
- function strictObjectId(id) {
18
- if (typeof id !== 'string' || !OBJECT_ID_REGEX.test(id)) {
19
- throw new TypeError(`Invalid ID: ${id} is not an ObjectId`);
20
- }
21
- return id;
22
- }
23
5
 
24
6
  /**
25
7
  * MongoDB Integration Mapping Repository Adapter
@@ -173,57 +155,6 @@ class IntegrationMappingRepositoryMongo extends IntegrationMappingRepositoryInte
173
155
  return counts;
174
156
  }
175
157
 
176
- /**
177
- * @param {string} integrationId
178
- * @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
179
- * @returns {Promise<{mappings: Array<Object>, total: number}>}
180
- */
181
- async queryMappings(integrationId, query) {
182
- const validated = validateMappingQuery(query);
183
- const objectId = strictObjectId(integrationId);
184
- assertMappingWrittenUnencrypted();
185
- const { match, page } = buildMappingQueryStages(
186
- { $oid: objectId },
187
- validated
188
- );
189
-
190
- const result = await this.prisma.$runCommandRaw({
191
- aggregate: 'IntegrationMapping',
192
- pipeline: [
193
- match,
194
- {
195
- $facet: {
196
- mappings: page,
197
- total: [{ $count: 'total' }],
198
- },
199
- },
200
- ],
201
- cursor: {},
202
- allowDiskUse: true,
203
- });
204
- const [{ mappings, total }] = result.cursor.firstBatch;
205
-
206
- return {
207
- mappings: mappings.map((doc) => this._fromRawMapping(doc)),
208
- total: total[0]?.total ?? 0,
209
- };
210
- }
211
-
212
- /**
213
- * A raw aggregate document, in the shape findMappingsByIntegration returns.
214
- * @private
215
- */
216
- _fromRawMapping(doc) {
217
- return {
218
- id: doc._id.$oid,
219
- integrationId: doc.integrationId.$oid,
220
- sourceId: doc.sourceId ?? null,
221
- mapping: doc.mapping ?? null,
222
- createdAt: fromRawDate(doc.createdAt),
223
- updatedAt: fromRawDate(doc.updatedAt),
224
- };
225
- }
226
-
227
158
  /**
228
159
  * Find mapping by ID
229
160
  * @param {string} id - Mapping ID
@@ -1,30 +1,8 @@
1
1
  const { prisma } = require('../../database/prisma');
2
- const {
3
- assertMappingWrittenUnencrypted,
4
- } = require('../../database/encryption/integration-mapping-encryption');
5
2
  const {
6
3
  IntegrationMappingRepositoryInterface,
7
4
  } = require('./integration-mapping-repository-interface');
8
5
  const { strictIntId } = require('./report-id');
9
- const { validateMappingQuery } = require('./integration-mapping-query');
10
-
11
- const COLUMNS = { mapping: '"mapping"', sourceId: '"sourceId"' };
12
- const SQL_DIRECTIONS = { asc: 'ASC', desc: 'DESC' };
13
-
14
- const jsonPathOperand = (column, path) => ({
15
- json: `${column} #> ${path}::text[]`,
16
- text: `${column} #>> ${path}::text[]`,
17
- });
18
- const jsonType = (json) => `COALESCE(jsonb_typeof(${json}), 'null')`;
19
-
20
- const CONDITION_SQL = {
21
- exists: ({ json }) => `${jsonType(json)} <> 'null'`,
22
- notExists: ({ json }) => `${jsonType(json)} = 'null'`,
23
- in: ({ json, text, value }) =>
24
- `(jsonb_typeof(${json}) = 'string' AND ${text} = ANY(${value}::text[]))`,
25
- notStartsWith: ({ text, value }) =>
26
- `(${text} IS NULL OR NOT starts_with(${text}, ${value}::text))`,
27
- };
28
6
 
29
7
  /**
30
8
  * PostgreSQL Integration Mapping Repository Adapter
@@ -238,101 +216,6 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
238
216
  return counts;
239
217
  }
240
218
 
241
- /**
242
- * @param {string} integrationId
243
- * @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
244
- * @returns {Promise<{mappings: Array<Object>, total: number}>}
245
- */
246
- async queryMappings(integrationId, query) {
247
- const { where, orderBy, skip, take, omit } =
248
- validateMappingQuery(query);
249
- const intIntegrationId = strictIntId(integrationId);
250
- assertMappingWrittenUnencrypted();
251
-
252
- const params = [];
253
- const bind = (v) => {
254
- params.push(v);
255
- return `$${params.length}`;
256
- };
257
-
258
- const whereSql = [
259
- `"integrationId" = ${bind(intIntegrationId)}::int`,
260
- `jsonb_typeof("mapping") = 'object'`,
261
- ...where.map((entry) => this._whereEntrySql(entry, bind)),
262
- ].join(' AND ');
263
- const orderSql = orderBy ? this._orderSql(orderBy, bind) : `"id" ASC`;
264
- const mappingSql =
265
- omit.length > 0
266
- ? `"mapping" - ${bind(omit)}::text[] AS "mapping"`
267
- : `"mapping"`;
268
-
269
- const sql = `
270
- WITH "matched" AS (
271
- SELECT "id", "integrationId", "sourceId", "mapping", "createdAt", "updatedAt"
272
- FROM "IntegrationMapping"
273
- WHERE ${whereSql}
274
- ),
275
- "page" AS (
276
- SELECT * FROM "matched"
277
- ORDER BY ${orderSql}
278
- OFFSET ${bind(skip)}::bigint
279
- LIMIT ${bind(take)}::int
280
- )
281
- SELECT "id", "integrationId", "sourceId", ${mappingSql}, "createdAt", "updatedAt",
282
- (SELECT COUNT(*)::int FROM "matched") AS "__total"
283
- FROM (VALUES (1)) AS "one"
284
- LEFT JOIN "page" ON true
285
- ORDER BY ${orderSql}
286
- `;
287
- const rows = await this.prisma.$queryRawUnsafe(sql, ...params);
288
-
289
- return {
290
- mappings: rows
291
- .filter((row) => row.id !== null)
292
- .map(({ __total, ...row }) => this._convertMappingIds(row)),
293
- total: rows[0].__total,
294
- };
295
- }
296
-
297
- /**
298
- * One where entry: a condition, or an anyOf group as a parenthesized OR.
299
- * @private
300
- */
301
- _whereEntrySql(entry, bind) {
302
- if (!entry.anyOf) return this._conditionSql(entry, bind);
303
- const alternatives = entry.anyOf.map((condition) =>
304
- this._conditionSql(condition, bind)
305
- );
306
- return `(${alternatives.join(' OR ')})`;
307
- }
308
-
309
- /**
310
- * SQL predicate for one validated queryMappings condition. `exists`
311
- * treats JSON null as absent, and `notExists` is its exact negation.
312
- * @private
313
- */
314
- _conditionSql({ field, path, op, value }, bind) {
315
- const column = COLUMNS[field];
316
- const operand = path
317
- ? jsonPathOperand(column, bind(path))
318
- : { text: column };
319
- return CONDITION_SQL[op]({
320
- ...operand,
321
- value: value === undefined ? undefined : bind(value),
322
- });
323
- }
324
-
325
- /**
326
- * NULLIF folds JSON null into SQL NULL, so both sort after every value.
327
- * @private
328
- */
329
- _orderSql({ path, direction }, bind) {
330
- const { json } = jsonPathOperand(COLUMNS.mapping, bind(path));
331
- const value = `NULLIF(${json}, 'null'::jsonb)`;
332
- const sqlDirection = SQL_DIRECTIONS[direction];
333
- return `${value} ${sqlDirection} NULLS LAST, "id" ${sqlDirection}`;
334
- }
335
-
336
219
  /**
337
220
  * Find mapping by ID
338
221
  * @param {string} id - Mapping ID (string from application layer)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.651.8ef5a7a.0",
4
+ "version": "2.0.0--canary.653.af34bd7.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -48,9 +48,9 @@
48
48
  }
49
49
  },
50
50
  "devDependencies": {
51
- "@friggframework/eslint-config": "2.0.0--canary.651.8ef5a7a.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.651.8ef5a7a.0",
53
- "@friggframework/test": "2.0.0--canary.651.8ef5a7a.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.653.af34bd7.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.653.af34bd7.0",
53
+ "@friggframework/test": "2.0.0--canary.653.af34bd7.0",
54
54
  "@prisma/client": "^6.19.3",
55
55
  "@types/lodash": "4.17.15",
56
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -90,5 +90,5 @@
90
90
  "publishConfig": {
91
91
  "access": "public"
92
92
  },
93
- "gitHead": "8ef5a7a4c048da70b48afa1469fe63a71d9f1240"
93
+ "gitHead": "af34bd7c677948dcd9f4a7a8dc0a59b038c35c48"
94
94
  }
@@ -0,0 +1,41 @@
1
+ const INTEGRATION_QUEUE_MAX_RECEIVE_COUNT = 3;
2
+ const QUEUE_MAX_RECEIVE_COUNT_ENV = 'FRIGG_QUEUE_MAX_RECEIVE_COUNT';
3
+
4
+ const toPositiveInteger = (value) => {
5
+ const number = Number(value);
6
+ return Number.isInteger(number) && number > 0 ? number : undefined;
7
+ };
8
+
9
+ /**
10
+ * @typedef {Object} QueueDelivery
11
+ * @property {number|undefined} receiveCount SQS ApproximateReceiveCount of this delivery.
12
+ * @property {number|undefined} maxReceiveCount Receives allowed before the queue dead-letters the message.
13
+ * @property {boolean} isLastAttempt True only when both counts are known and receiveCount >= maxReceiveCount.
14
+ */
15
+
16
+ /**
17
+ * @param {Object} record SQS event record
18
+ * @param {Object} [env=process.env]
19
+ * @returns {QueueDelivery}
20
+ */
21
+ const readQueueDelivery = (record, env = process.env) => {
22
+ const receiveCount = toPositiveInteger(
23
+ record?.attributes?.ApproximateReceiveCount
24
+ );
25
+ const maxReceiveCount = toPositiveInteger(env[QUEUE_MAX_RECEIVE_COUNT_ENV]);
26
+
27
+ return {
28
+ receiveCount,
29
+ maxReceiveCount,
30
+ isLastAttempt:
31
+ receiveCount !== undefined &&
32
+ maxReceiveCount !== undefined &&
33
+ receiveCount >= maxReceiveCount,
34
+ };
35
+ };
36
+
37
+ module.exports = {
38
+ INTEGRATION_QUEUE_MAX_RECEIVE_COUNT,
39
+ QUEUE_MAX_RECEIVE_COUNT_ENV,
40
+ readQueueDelivery,
41
+ };
@@ -1,59 +0,0 @@
1
- const { getEncryptionConfig } = require('../prisma');
2
- const {
3
- getFieldsToEncryptOnWrite,
4
- loadCustomEncryptionSchema,
5
- } = require('./encryption-schema-registry');
6
-
7
- let mappingWrittenPlain = false;
8
-
9
- /**
10
- * The `IntegrationMapping` fields, `mapping` itself or a nested `mapping.*`
11
- * path, that field-level encryption encrypts on write. An empty result is
12
- * kept for the life of the process.
13
- *
14
- * @returns {string[]} Empty when every mapping path is written as plain JSON
15
- */
16
- function getMappingFieldsEncryptedOnWrite() {
17
- if (mappingWrittenPlain) return [];
18
-
19
- const fields = encryptedMappingFields();
20
- mappingWrittenPlain = fields.length === 0;
21
- return fields;
22
- }
23
-
24
- /**
25
- * @throws {Error} When field-level encryption still encrypts `mapping`, or a
26
- * nested `mapping.*` path, on write, naming the opt-out that lifts it
27
- */
28
- function assertMappingWrittenUnencrypted() {
29
- const fields = getMappingFieldsEncryptedOnWrite();
30
- if (fields.length === 0) return;
31
-
32
- const encrypted = fields
33
- .map((field) => `IntegrationMapping.${field}`)
34
- .join(', ');
35
- const optOut = fields.map((field) => `'${field}'`).join(', ');
36
- throw new Error(
37
- `queryMappings: field-level encryption still encrypts ${encrypted} on write, so it cannot be queried. Opt out by adding ${optOut} to appDefinition.encryption.disable.IntegrationMapping.`
38
- );
39
- }
40
-
41
- function encryptedMappingFields() {
42
- if (!getEncryptionConfig().enabled) return [];
43
-
44
- loadCustomEncryptionSchema();
45
- return getFieldsToEncryptOnWrite('IntegrationMapping').filter(
46
- (field) => field === 'mapping' || field.startsWith('mapping.')
47
- );
48
- }
49
-
50
- /** Test helper: forget a kept result. */
51
- function resetMappingEncryptionCheck() {
52
- mappingWrittenPlain = false;
53
- }
54
-
55
- module.exports = {
56
- assertMappingWrittenUnencrypted,
57
- getMappingFieldsEncryptedOnWrite,
58
- resetMappingEncryptionCheck,
59
- };
@@ -1,152 +0,0 @@
1
- const SORT_KEY = '__sortKey';
2
- const MONGO_DIRECTIONS = { asc: 1, desc: -1 };
3
- const SORT_TYPE_RANKS = [
4
- ['string'],
5
- ['int', 'long', 'double', 'decimal'],
6
- ['bool'],
7
- ['array'],
8
- ['object'],
9
- ];
10
- const SCALAR_TYPES = SORT_TYPE_RANKS.slice(0, 3).flat();
11
-
12
- const isType = (expression, type) => ({ $eq: [{ $type: expression }, type] });
13
- const isAbsent = (expression) => ({
14
- $eq: [{ $ifNull: [expression, null] }, null],
15
- });
16
- const mappingField = (segments) => `$mapping.${segments.join('.')}`;
17
-
18
- const CONDITION_EXPRESSIONS = {
19
- exists: (operand) => ({ $ne: [{ $ifNull: [operand, null] }, null] }),
20
- notExists: (operand) => isAbsent(operand),
21
- in: (operand, values) => ({
22
- $and: [
23
- isType(operand, 'string'),
24
- { $in: [operand, { $literal: values }] },
25
- ],
26
- }),
27
- notStartsWith: (operand, prefix) => ({
28
- $cond: [
29
- isType(operand, 'string'),
30
- {
31
- $ne: [
32
- { $substrCP: [operand, 0, [...prefix].length] },
33
- { $literal: prefix },
34
- ],
35
- },
36
- true,
37
- ],
38
- }),
39
- };
40
-
41
- /**
42
- * Aggregation stages that answer a validated queryMappings query on the
43
- * MongoDB wire protocol.
44
- *
45
- * @param {*} integrationId - The value IntegrationMapping.integrationId is stored as
46
- * @param {ReturnType<import('./integration-mapping-query').validateMappingQuery>} query
47
- * @returns {{match: Object, page: Object[], sort: Object}} `match` selects
48
- * the matching documents; `page` sorts, skips and limits them; `sort` is
49
- * the $sort stage inside `page`
50
- */
51
- function buildMappingQueryStages(
52
- integrationId,
53
- { where, orderBy, skip, take, omit }
54
- ) {
55
- const match = {
56
- $match: {
57
- integrationId,
58
- $expr: {
59
- $and: [
60
- isType('$mapping', 'object'),
61
- ...where.map(entryExpression),
62
- ],
63
- },
64
- },
65
- };
66
- const sort = { $sort: sortSpec(orderBy) };
67
- const page = [
68
- ...(orderBy
69
- ? [{ $addFields: { [SORT_KEY]: sortKey(orderBy.path) } }]
70
- : []),
71
- ...(omit.length > 0 ? [{ $project: omitProjection(omit) }] : []),
72
- sort,
73
- ...(skip > 0 ? [{ $skip: skip }] : []),
74
- { $limit: take },
75
- ];
76
- return { match, page, sort };
77
- }
78
-
79
- function entryExpression(entry) {
80
- if (!entry.anyOf) return conditionExpression(entry);
81
- return { $or: entry.anyOf.map(conditionExpression) };
82
- }
83
-
84
- function conditionExpression({ field, path, op, value }) {
85
- const operand = field === 'sourceId' ? '$sourceId' : resolvePath(path);
86
- return CONDITION_EXPRESSIONS[op](operand, value);
87
- }
88
-
89
- /**
90
- * The value at a mapping path; null when an enclosing value is not an object.
91
- */
92
- function resolvePath(segments) {
93
- const enclosing = segments
94
- .slice(0, -1)
95
- .map((_, i) =>
96
- isType(mappingField(segments.slice(0, i + 1)), 'object')
97
- );
98
- if (enclosing.length === 0) return mappingField(segments);
99
- return {
100
- $cond: [
101
- enclosing.length === 1 ? enclosing[0] : { $and: enclosing },
102
- mappingField(segments),
103
- null,
104
- ],
105
- };
106
- }
107
-
108
- /**
109
- * Orders values like jsonb: strings < numbers < booleans < arrays < objects,
110
- * scalars by value within their type. Null and absent values sort last in
111
- * both directions.
112
- */
113
- function sortKey(path) {
114
- const type = { $type: '$$value' };
115
- return {
116
- $let: {
117
- vars: { value: resolvePath(path) },
118
- in: {
119
- missing: isAbsent('$$value'),
120
- rank: {
121
- $switch: {
122
- branches: SORT_TYPE_RANKS.map((types, i) => ({
123
- case: { $in: [type, types] },
124
- then: i + 1,
125
- })),
126
- default: 0,
127
- },
128
- },
129
- value: {
130
- $cond: [{ $in: [type, SCALAR_TYPES] }, '$$value', null],
131
- },
132
- },
133
- },
134
- };
135
- }
136
-
137
- function sortSpec(orderBy) {
138
- if (!orderBy) return { _id: 1 };
139
- const direction = MONGO_DIRECTIONS[orderBy.direction];
140
- return {
141
- [`${SORT_KEY}.missing`]: 1,
142
- [`${SORT_KEY}.rank`]: direction,
143
- [`${SORT_KEY}.value`]: direction,
144
- _id: direction,
145
- };
146
- }
147
-
148
- function omitProjection(omit) {
149
- return Object.fromEntries(omit.map((key) => [`mapping.${key}`, 0]));
150
- }
151
-
152
- module.exports = { buildMappingQueryStages };
@@ -1,193 +0,0 @@
1
- const SEGMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
2
- const MAX_TAKE = 500;
3
- const MAX_IN_VALUES = 500;
4
- const MAX_CONDITIONS = 20;
5
- const DIRECTIONS = ['asc', 'desc'];
6
-
7
- const OPERATORS = {
8
- exists: { fields: ['mapping'] },
9
- notExists: { fields: ['mapping'] },
10
- in: { fields: ['mapping'], value: toStringList },
11
- notStartsWith: { fields: ['sourceId'], value: toPrefix },
12
- };
13
-
14
- /**
15
- * Checks a queryMappings query and normalizes it for an adapter.
16
- *
17
- * @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
18
- * @returns {{where: Array<Object>, orderBy: ({path: string[], direction: 'asc'|'desc'}|null), skip: number, take: number, omit: string[]}}
19
- * @throws {Error} When the query does not fit that shape
20
- */
21
- function validateMappingQuery(query) {
22
- if (!isPlainObject(query)) {
23
- throw new Error('queryMappings: query must be an object');
24
- }
25
- const { where = [], orderBy, skip = 0, take, omit = [] } = query;
26
- if (!Array.isArray(where)) {
27
- throw new Error('queryMappings: where must be an array');
28
- }
29
- if (!Number.isInteger(take) || take < 1 || take > MAX_TAKE) {
30
- throw new Error(
31
- `queryMappings: take must be an integer between 1 and ${MAX_TAKE}`
32
- );
33
- }
34
- if (!Number.isSafeInteger(skip) || skip < 0) {
35
- throw new Error('queryMappings: skip must be a non-negative integer');
36
- }
37
- if (
38
- !Array.isArray(omit) ||
39
- !omit.every((key) => typeof key === 'string' && SEGMENT_REGEX.test(key))
40
- ) {
41
- throw new Error(
42
- `queryMappings: omit must be an array of top-level mapping keys matching ${SEGMENT_REGEX}`
43
- );
44
- }
45
-
46
- const whereEntries = where.map(toWhereEntry);
47
- const conditionCount = whereEntries.reduce(
48
- (count, entry) => count + (entry.anyOf ? entry.anyOf.length : 1),
49
- 0
50
- );
51
- if (conditionCount > MAX_CONDITIONS) {
52
- throw new Error(
53
- `queryMappings: where must have at most ${MAX_CONDITIONS} conditions, anyOf members included`
54
- );
55
- }
56
-
57
- return {
58
- where: whereEntries,
59
- orderBy: orderBy === undefined ? null : toOrderBy(orderBy),
60
- skip,
61
- take,
62
- omit,
63
- };
64
- }
65
-
66
- function toOrderBy(orderBy) {
67
- if (!isPlainObject(orderBy)) {
68
- throw new Error('queryMappings: orderBy must be an object');
69
- }
70
- const { field, segments } = parsePath(orderBy.path);
71
- if (field !== 'mapping') {
72
- throw new Error(
73
- "queryMappings: orderBy.path must be a mapping path ('mapping.<segment>...')"
74
- );
75
- }
76
- if (!DIRECTIONS.includes(orderBy.direction)) {
77
- throw new Error(
78
- "queryMappings: orderBy.direction must be 'asc' or 'desc'"
79
- );
80
- }
81
- return { path: segments, direction: orderBy.direction };
82
- }
83
-
84
- function toWhereEntry(entry) {
85
- assertWhereEntryShape(entry);
86
- if (!('anyOf' in entry)) return toCondition(entry);
87
-
88
- const { anyOf } = entry;
89
- if (!Array.isArray(anyOf) || anyOf.length === 0) {
90
- throw new Error('queryMappings: anyOf must be a non-empty array');
91
- }
92
- return {
93
- anyOf: anyOf.map((condition) => {
94
- assertWhereEntryShape(condition);
95
- if ('anyOf' in condition) {
96
- throw new Error('queryMappings: nested anyOf is not supported');
97
- }
98
- return toCondition(condition);
99
- }),
100
- };
101
- }
102
-
103
- function assertWhereEntryShape(entry) {
104
- if (!isPlainObject(entry)) {
105
- throw new Error('queryMappings: each where entry must be an object');
106
- }
107
- }
108
-
109
- function isPlainObject(value) {
110
- return value !== null && typeof value === 'object' && !Array.isArray(value);
111
- }
112
-
113
- /**
114
- * `{ path: 'mapping.c2h.lastStatus', op: 'in', value: ['failed'] }` →
115
- * `{ field: 'mapping', path: ['c2h', 'lastStatus'], op: 'in', value: ['failed'] }`.
116
- * A `sourceId` condition has no `path`; an op without a value has no `value`.
117
- */
118
- function toCondition({ path, op, value }) {
119
- const { field, segments } = parsePath(path);
120
- const operator = Object.hasOwn(OPERATORS, op) ? OPERATORS[op] : null;
121
- if (!operator?.fields.includes(field)) {
122
- throw new Error(
123
- `queryMappings: op ${JSON.stringify(
124
- op
125
- )} is not allowed on '${path}' (allowed: ${operatorsOn(field).join(
126
- ', '
127
- )})`
128
- );
129
- }
130
- return {
131
- field,
132
- ...(segments.length > 0 && { path: segments }),
133
- op,
134
- ...(operator.value && { value: operator.value(value, path) }),
135
- };
136
- }
137
-
138
- function operatorsOn(field) {
139
- return Object.keys(OPERATORS).filter((op) =>
140
- OPERATORS[op].fields.includes(field)
141
- );
142
- }
143
-
144
- function toStringList(value, path) {
145
- if (
146
- !Array.isArray(value) ||
147
- value.length === 0 ||
148
- !value.every((v) => typeof v === 'string')
149
- ) {
150
- throw new Error(
151
- `queryMappings: 'in' value must be a non-empty array of strings on '${path}'`
152
- );
153
- }
154
- if (value.length > MAX_IN_VALUES) {
155
- throw new Error(
156
- `queryMappings: 'in' value must have at most ${MAX_IN_VALUES} strings on '${path}'`
157
- );
158
- }
159
- return value;
160
- }
161
-
162
- function toPrefix(value, path) {
163
- if (typeof value !== 'string' || value.length === 0) {
164
- throw new Error(
165
- `queryMappings: 'notStartsWith' value must be a non-empty string on '${path}'`
166
- );
167
- }
168
- return value;
169
- }
170
-
171
- /**
172
- * `'mapping.c2h.lastStatus'` → `{ field: 'mapping', segments: ['c2h', 'lastStatus'] }`,
173
- * `'sourceId'` → `{ field: 'sourceId', segments: [] }`.
174
- */
175
- function parsePath(path) {
176
- const [field, ...segments] =
177
- typeof path === 'string' ? path.split('.') : [];
178
- const valid =
179
- (field === 'sourceId' && segments.length === 0) ||
180
- (field === 'mapping' &&
181
- segments.length > 0 &&
182
- segments.every((segment) => SEGMENT_REGEX.test(segment)));
183
- if (!valid) {
184
- throw new Error(
185
- `queryMappings: invalid path ${JSON.stringify(
186
- path
187
- )} (must be 'sourceId' or 'mapping.<segment>...' with segments matching ${SEGMENT_REGEX})`
188
- );
189
- }
190
- return { field, segments };
191
- }
192
-
193
- module.exports = { validateMappingQuery };