@friggframework/core 2.0.0--canary.654.70623e2.0 → 2.0.0--canary.654.6d3a665.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 +88 -1
- package/core/CLAUDE.md +2 -1
- package/core/Worker.js +2 -1
- package/database/documentdb-encryption-service.js +6 -2
- package/database/documentdb-utils.js +2 -1
- package/database/encryption/README.md +20 -0
- package/database/encryption/integration-mapping-encryption.js +97 -0
- package/database/encryption/prisma-encryption-extension.js +20 -9
- package/handlers/backend-utils.js +2 -1
- package/handlers/integration-event-dispatcher.js +2 -2
- package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
- package/integrations/repositories/integration-mapping-query.js +193 -0
- package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
- package/logs/record.js +32 -7
- package/package.json +5 -5
- package/queues/queue-delivery.js +41 -0
package/CLAUDE.md
CHANGED
|
@@ -211,7 +211,56 @@ 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
|
|
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
|
+
and returns rows decrypted like `findMappingsByIntegration`. PostgreSQL and
|
|
240
|
+
MongoDB decrypt through `decryptQueriedMappings`
|
|
241
|
+
(`database/encryption/integration-mapping-encryption.js`), a no-op while
|
|
242
|
+
encryption is off or lists no `IntegrationMapping` field besides `mapping`.
|
|
243
|
+
Validation lives in `integration-mapping-query.js`; a new operator is one
|
|
244
|
+
entry in its `OPERATORS` table, one in the Postgres adapter's
|
|
245
|
+
`CONDITION_SQL` and one in `CONDITION_EXPRESSIONS` in
|
|
246
|
+
`integration-mapping-query-pipeline.js`, the aggregation stages both
|
|
247
|
+
MongoDB-protocol adapters share. Those stages may only use what Amazon
|
|
248
|
+
DocumentDB 4.0 and 5.0 support (no `$facet`, `$getField`, `$set` or
|
|
249
|
+
`$unset`).
|
|
250
|
+
**Cost**: PostgreSQL answers in one SQL statement, which reads every row
|
|
251
|
+
of the integration and evaluates the JSON paths per row, because no JSON
|
|
252
|
+
index exists. With ~4 KB mappings on PostgreSQL 16 that is about 0.3 s per
|
|
253
|
+
call at 10⁴ rows per integration and 2.5–3 s at 10⁵. MongoDB answers in one
|
|
254
|
+
aggregate: the `integrationId` index narrows `$match` to the integration,
|
|
255
|
+
the `$expr` is then evaluated per document, and `$facet` returns the page
|
|
256
|
+
with its `$count`, so a page (after `omit`) must fit the 16 MB document
|
|
257
|
+
limit. DocumentDB has no `$facet`: the page and the count are two
|
|
258
|
+
concurrent aggregates, so the total does not come from the same snapshot
|
|
259
|
+
as the page. Both sort in memory on a computed key (`allowDiskUse`). With
|
|
260
|
+
~4 KB mappings on MongoDB 7 that is about 0.05–0.1 s per call at 10⁴ rows
|
|
261
|
+
per integration and 0.5–0.7 s at 10⁵ (the DocumentDB pipeline, run on
|
|
262
|
+
MongoDB, 0.6–0.8 s). On every adapter a deep offset or an empty page past
|
|
263
|
+
the end costs about the same as the first page.
|
|
215
264
|
- `process-repository-*.js` - Process (long-running job) persistence.
|
|
216
265
|
Implements `applyProcessUpdate(processId, ops)` — a race-safe alternative
|
|
217
266
|
to `update(id, patch)` that routes increments, sets, and bounded-array
|
|
@@ -414,6 +463,43 @@ const decrypted = await cryptor.decrypt(encrypted);
|
|
|
414
463
|
- `integration-event-dispatcher.js` - Routes events to integration handlers
|
|
415
464
|
- Supports lifecycle events and user actions
|
|
416
465
|
|
|
466
|
+
**Queue handler delivery**: a handler dispatched from the integration queue
|
|
467
|
+
(any `this.events` entry reached through SQS, including `ON_WEBHOOK` and
|
|
468
|
+
scheduled jobs) receives `{ data, context, delivery }`. HTTP-dispatched
|
|
469
|
+
handlers (`{ req, res, next }`) and `this.on` events do not.
|
|
470
|
+
|
|
471
|
+
| Field | Type | Value |
|
|
472
|
+
|---|---|---|
|
|
473
|
+
| `delivery.receiveCount` | `number \| undefined` | SQS `ApproximateReceiveCount` of this delivery |
|
|
474
|
+
| `delivery.maxReceiveCount` | `number \| undefined` | Receives allowed before SQS moves the message to the DLQ (`FRIGG_QUEUE_MAX_RECEIVE_COUNT`) |
|
|
475
|
+
| `delivery.isLastAttempt` | `boolean` | `true` only when both counts are known and `receiveCount >= maxReceiveCount` |
|
|
476
|
+
|
|
477
|
+
`isLastAttempt` is `false` when either count is unknown: a local or non-SQS
|
|
478
|
+
invocation, or a queue whose redrive policy the stack does not own
|
|
479
|
+
(`ownership.queue: 'external'`). The value is information only: core still
|
|
480
|
+
rethrows retryable errors and discards halt errors (4xx except 408/429).
|
|
481
|
+
|
|
482
|
+
Use it to end a run or count lost work on the final try: when a retryable
|
|
483
|
+
error (429, 5xx, network) is about to be rethrown and `isLastAttempt` is
|
|
484
|
+
`true`, the message goes to the DLQ next, so mark the run failed or count the
|
|
485
|
+
message's records as failed before rethrowing.
|
|
486
|
+
|
|
487
|
+
```javascript
|
|
488
|
+
async processBatch({ data, delivery }) {
|
|
489
|
+
try {
|
|
490
|
+
await this.syncPage(data);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (delivery?.isLastAttempt) await this.failRun(data.processId, error);
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
The single source of the max receive count is
|
|
499
|
+
`INTEGRATION_QUEUE_MAX_RECEIVE_COUNT` in `queues/queue-delivery.js`. The
|
|
500
|
+
devtools integration builder uses it for the queue's `RedrivePolicy` and sets
|
|
501
|
+
it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
|
|
502
|
+
|
|
417
503
|
### 8. Error Handling (`/errors`)
|
|
418
504
|
|
|
419
505
|
**Purpose**: Standardized error types with proper HTTP semantics.
|
|
@@ -663,6 +749,7 @@ Use test doubles from `@friggframework/test` package for consistent mocking.
|
|
|
663
749
|
|
|
664
750
|
- `SECRET_ARN` - AWS Secrets Manager ARN for auto-injection
|
|
665
751
|
- `FRIGG_LOG_LEVEL` - Minimum log level (`TRACE` … `FATAL`, default `INFO`; `DEBUG` on local runs)
|
|
752
|
+
- `FRIGG_QUEUE_MAX_RECEIVE_COUNT` - Set by devtools on integration queue workers whose queue the stack owns; feeds `delivery.maxReceiveCount`
|
|
666
753
|
|
|
667
754
|
## Version Information
|
|
668
755
|
|
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 }
|
|
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
|
const { runMessageScope } = require('./invocation-scope');
|
|
6
7
|
const { getLogger } = require('../logs');
|
|
7
8
|
|
|
@@ -35,7 +36,7 @@ class Worker {
|
|
|
35
36
|
try {
|
|
36
37
|
const runParams = JSON.parse(record.body);
|
|
37
38
|
this._validateParams(runParams);
|
|
38
|
-
await this._run(runParams, context);
|
|
39
|
+
await this._run(runParams, context, readQueueDelivery(record));
|
|
39
40
|
log.debug('Record succeeded', { eventName: 'frigg.worker.record_succeeded' });
|
|
40
41
|
} catch (error) {
|
|
41
42
|
if (error.isHaltError) {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
2
|
-
const {
|
|
2
|
+
const {
|
|
3
|
+
getEncryptedFields,
|
|
4
|
+
getFieldsToEncryptOnWrite,
|
|
5
|
+
loadCustomEncryptionSchema,
|
|
6
|
+
} = require('./encryption/encryption-schema-registry');
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Encryption service specifically for DocumentDB repositories
|
|
@@ -107,7 +111,7 @@ class DocumentDBEncryptionService {
|
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
// Get encrypted fields from registry
|
|
110
|
-
const encryptedFieldsConfig =
|
|
114
|
+
const encryptedFieldsConfig = getFieldsToEncryptOnWrite(modelName);
|
|
111
115
|
if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
|
|
112
116
|
return document;
|
|
113
117
|
}
|
|
@@ -166,11 +166,12 @@ 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) {
|
|
169
|
+
async function aggregateDrained(client, collection, pipeline, options = {}) {
|
|
170
170
|
const first = await client.$runCommandRaw({
|
|
171
171
|
aggregate: collection,
|
|
172
172
|
pipeline,
|
|
173
173
|
cursor: { batchSize: DRAIN_BATCH_SIZE },
|
|
174
|
+
...options,
|
|
174
175
|
});
|
|
175
176
|
return drainCursor(client, collection, first);
|
|
176
177
|
}
|
|
@@ -818,6 +818,26 @@ export AES_KEY=$(openssl rand -hex 16) # Generate 32-char key
|
|
|
818
818
|
❌ Query on encrypted fields (not supported)
|
|
819
819
|
❌ Manually decrypt data (use extension)
|
|
820
820
|
|
|
821
|
+
The integration mapping repositories' `queryMappings()` (PostgreSQL, MongoDB
|
|
822
|
+
and DocumentDB) filters and sorts inside the `mapping` JSON, so it refuses to
|
|
823
|
+
run while field-level encryption still encrypts `IntegrationMapping.mapping` on
|
|
824
|
+
write. Opt the field out in the app definition, together with any nested
|
|
825
|
+
`mapping.*` path that `encryption.schema` encrypts; the Prisma extension and
|
|
826
|
+
`DocumentDBEncryptionService` both honor the opt-out:
|
|
827
|
+
|
|
828
|
+
```javascript
|
|
829
|
+
encryption: {
|
|
830
|
+
disable: { IntegrationMapping: ['mapping'] },
|
|
831
|
+
}
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
The opt-out applies to writes only. Rows written encrypted before it stay
|
|
835
|
+
readable, but `queryMappings()` does not match them until they are written
|
|
836
|
+
again. It returns the rows it matches decrypted, like
|
|
837
|
+
`findMappingsByIntegration()`: a nested `mapping.*` path written encrypted
|
|
838
|
+
before its opt-out reads plain, though conditions and ordering on that path
|
|
839
|
+
still see the ciphertext until the row is written again.
|
|
840
|
+
|
|
821
841
|
## Future Enhancements
|
|
822
842
|
|
|
823
843
|
### Planned
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { getEncryptionConfig } = require('../prisma');
|
|
2
|
+
const { Cryptor } = require('../../encrypt/Cryptor');
|
|
3
|
+
const {
|
|
4
|
+
getFieldsToDecryptOnRead,
|
|
5
|
+
getFieldsToEncryptOnWrite,
|
|
6
|
+
loadCustomEncryptionSchema,
|
|
7
|
+
} = require('./encryption-schema-registry');
|
|
8
|
+
const {
|
|
9
|
+
createFieldEncryptionService,
|
|
10
|
+
} = require('./prisma-encryption-extension');
|
|
11
|
+
|
|
12
|
+
let plainMappingEncryption = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `IntegrationMapping` fields, `mapping` itself or a nested `mapping.*`
|
|
16
|
+
* path, that field-level encryption encrypts on write. An empty result is
|
|
17
|
+
* kept for the life of the process.
|
|
18
|
+
*
|
|
19
|
+
* @returns {string[]} Empty when every mapping path is written as plain JSON
|
|
20
|
+
*/
|
|
21
|
+
function getMappingFieldsEncryptedOnWrite() {
|
|
22
|
+
return mappingEncryption().encryptedOnWrite;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @throws {Error} When field-level encryption still encrypts `mapping`, or a
|
|
27
|
+
* nested `mapping.*` path, on write, naming the opt-out that lifts it
|
|
28
|
+
*/
|
|
29
|
+
function assertMappingWrittenUnencrypted() {
|
|
30
|
+
const fields = getMappingFieldsEncryptedOnWrite();
|
|
31
|
+
if (fields.length === 0) return;
|
|
32
|
+
|
|
33
|
+
const encrypted = fields
|
|
34
|
+
.map((field) => `IntegrationMapping.${field}`)
|
|
35
|
+
.join(', ');
|
|
36
|
+
const optOut = fields.map((field) => `'${field}'`).join(', ');
|
|
37
|
+
throw new Error(
|
|
38
|
+
`queryMappings: field-level encryption still encrypts ${encrypted} on write, so it cannot be queried. Opt out by adding ${optOut} to appDefinition.encryption.disable.IntegrationMapping.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decrypts rows that `queryMappings` read around the Prisma encryption
|
|
44
|
+
* extension, the way reads through the extension do: every
|
|
45
|
+
* `IntegrationMapping` field the schema lists, opted-out paths included, so a
|
|
46
|
+
* path written encrypted before its opt-out comes back plain.
|
|
47
|
+
*
|
|
48
|
+
* @param {Object[]} rows - Rows whose `mapping` is a JSON object
|
|
49
|
+
* @returns {Promise<Object[]>} `rows` itself when encryption is off or the
|
|
50
|
+
* schema lists no field besides `mapping`
|
|
51
|
+
*/
|
|
52
|
+
async function decryptQueriedMappings(rows) {
|
|
53
|
+
const { decryptor } = mappingEncryption();
|
|
54
|
+
if (!decryptor) return rows;
|
|
55
|
+
return decryptor.decryptFieldsInBulk('IntegrationMapping', rows);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mappingEncryption() {
|
|
59
|
+
if (plainMappingEncryption) return plainMappingEncryption;
|
|
60
|
+
|
|
61
|
+
const encryption = currentMappingEncryption();
|
|
62
|
+
if (encryption.encryptedOnWrite.length === 0) {
|
|
63
|
+
plainMappingEncryption = encryption;
|
|
64
|
+
}
|
|
65
|
+
return encryption;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function currentMappingEncryption() {
|
|
69
|
+
const config = getEncryptionConfig();
|
|
70
|
+
if (!config.enabled) return { encryptedOnWrite: [], decryptor: null };
|
|
71
|
+
|
|
72
|
+
loadCustomEncryptionSchema();
|
|
73
|
+
const encryptedOnWrite = getFieldsToEncryptOnWrite(
|
|
74
|
+
'IntegrationMapping'
|
|
75
|
+
).filter((field) => field === 'mapping' || field.startsWith('mapping.'));
|
|
76
|
+
const decryptsBesidesMapping = getFieldsToDecryptOnRead(
|
|
77
|
+
'IntegrationMapping'
|
|
78
|
+
).some((field) => field !== 'mapping');
|
|
79
|
+
const decryptor = decryptsBesidesMapping
|
|
80
|
+
? createFieldEncryptionService(
|
|
81
|
+
new Cryptor({ shouldUseAws: config.method === 'kms' })
|
|
82
|
+
)
|
|
83
|
+
: null;
|
|
84
|
+
return { encryptedOnWrite, decryptor };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Test helper: forget a kept result. */
|
|
88
|
+
function resetMappingEncryptionCheck() {
|
|
89
|
+
plainMappingEncryption = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
assertMappingWrittenUnencrypted,
|
|
94
|
+
decryptQueriedMappings,
|
|
95
|
+
getMappingFieldsEncryptedOnWrite,
|
|
96
|
+
resetMappingEncryptionCheck,
|
|
97
|
+
};
|
|
@@ -10,6 +10,24 @@ const {
|
|
|
10
10
|
} = require('./encryption-schema-registry');
|
|
11
11
|
const { FieldEncryptionService } = require('./field-encryption-service');
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The FieldEncryptionService the extension runs, with field paths from the
|
|
15
|
+
* encryption schema registry.
|
|
16
|
+
*
|
|
17
|
+
* @param {import('../../encrypt/Cryptor').Cryptor} cryptor
|
|
18
|
+
* @returns {FieldEncryptionService}
|
|
19
|
+
*/
|
|
20
|
+
function createFieldEncryptionService(cryptor) {
|
|
21
|
+
return new FieldEncryptionService({
|
|
22
|
+
cryptor,
|
|
23
|
+
schema: {
|
|
24
|
+
getEncryptedFields,
|
|
25
|
+
getFieldsToEncryptOnWrite,
|
|
26
|
+
getFieldsToDecryptOnRead,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
13
31
|
function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
14
32
|
if (!enabled) {
|
|
15
33
|
return (client) => client;
|
|
@@ -21,14 +39,7 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
|
21
39
|
);
|
|
22
40
|
}
|
|
23
41
|
|
|
24
|
-
const encryptionService =
|
|
25
|
-
cryptor,
|
|
26
|
-
schema: {
|
|
27
|
-
getEncryptedFields,
|
|
28
|
-
getFieldsToEncryptOnWrite,
|
|
29
|
-
getFieldsToDecryptOnRead,
|
|
30
|
-
},
|
|
31
|
-
});
|
|
42
|
+
const encryptionService = createFieldEncryptionService(cryptor);
|
|
32
43
|
|
|
33
44
|
return {
|
|
34
45
|
name: 'frigg-field-encryption',
|
|
@@ -227,4 +238,4 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
|
227
238
|
};
|
|
228
239
|
}
|
|
229
240
|
|
|
230
|
-
module.exports = { createEncryptionExtension };
|
|
241
|
+
module.exports = { createEncryptionExtension, createFieldEncryptionService };
|
|
@@ -165,7 +165,7 @@ const createQueueWorker = (integrationClass) => {
|
|
|
165
165
|
const integrationName = integrationClass.Definition.name;
|
|
166
166
|
|
|
167
167
|
class QueueWorker extends Worker {
|
|
168
|
-
async _run(params, context) {
|
|
168
|
+
async _run(params, context, delivery) {
|
|
169
169
|
const logCtx = {
|
|
170
170
|
integration: integrationName,
|
|
171
171
|
event: params.event,
|
|
@@ -255,6 +255,7 @@ const createQueueWorker = (integrationClass) => {
|
|
|
255
255
|
event: params.event,
|
|
256
256
|
data: params.data,
|
|
257
257
|
context: context,
|
|
258
|
+
delivery,
|
|
258
259
|
});
|
|
259
260
|
console.log(
|
|
260
261
|
`[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
|
|
|
@@ -0,0 +1,152 @@
|
|
|
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 };
|
|
@@ -0,0 +1,193 @@
|
|
|
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.outbound.status', op: 'in', value: ['failed'] }` →
|
|
115
|
+
* `{ field: 'mapping', path: ['outbound', 'status'], 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.outbound.status'` → `{ field: 'mapping', segments: ['outbound', 'status'] }`,
|
|
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 };
|
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
updateOne,
|
|
9
9
|
deleteOne,
|
|
10
10
|
deleteMany,
|
|
11
|
+
aggregate,
|
|
11
12
|
aggregateDrained,
|
|
12
13
|
} = require('../../database/documentdb-utils');
|
|
13
14
|
const {
|
|
@@ -16,6 +17,20 @@ const {
|
|
|
16
17
|
const {
|
|
17
18
|
DocumentDBEncryptionService,
|
|
18
19
|
} = 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
|
+
}
|
|
19
34
|
|
|
20
35
|
class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositoryInterface {
|
|
21
36
|
constructor() {
|
|
@@ -179,6 +194,44 @@ class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositor
|
|
|
179
194
|
return decryptedDocs.map((doc) => this._mapMapping(doc));
|
|
180
195
|
}
|
|
181
196
|
|
|
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
|
+
|
|
182
235
|
async deleteMapping(integrationId, sourceId) {
|
|
183
236
|
const filter = this._compositeFilter(integrationId, sourceId);
|
|
184
237
|
const result = await deleteOne(
|
|
@@ -52,6 +52,54 @@ 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
|
+
* and are decrypted the same way.
|
|
59
|
+
*
|
|
60
|
+
* Paths address the `mapping` JSON by identifier-only segments
|
|
61
|
+
* (`'mapping.outbound.status'`), or the `sourceId` column. Conditions:
|
|
62
|
+
* - `{ path: 'mapping.…', op: 'exists' | 'notExists' }` — JSON null counts
|
|
63
|
+
* as absent; notExists is the exact negation of exists.
|
|
64
|
+
* - `{ path: 'mapping.…', op: 'in', value: string[] }` — matches JSON
|
|
65
|
+
* strings; 1–500 values.
|
|
66
|
+
* - `{ path: 'sourceId', op: 'notStartsWith', value: string }` — a NULL
|
|
67
|
+
* sourceId matches.
|
|
68
|
+
*
|
|
69
|
+
* Only rows whose `mapping` is a JSON object can match, so rows whose
|
|
70
|
+
* whole `mapping` is still ciphertext from before an encryption opt-out
|
|
71
|
+
* never do. A nested path still encrypted from before its opt-out comes
|
|
72
|
+
* back plain, but conditions and orderBy on it see the ciphertext.
|
|
73
|
+
* Adapters refuse to run while field-level encryption is enabled
|
|
74
|
+
* and still encrypts `mapping`, or a path inside it, on write; opt out with
|
|
75
|
+
* `appDefinition.encryption.disable = { IntegrationMapping: ['mapping'] }`
|
|
76
|
+
* plus any nested `mapping.…` path a custom schema encrypts.
|
|
77
|
+
*
|
|
78
|
+
* @param {string|number} integrationId - The integration ID
|
|
79
|
+
* @param {Object} query
|
|
80
|
+
* @param {Array<Object>} [query.where=[]] - Conditions ANDed together; an
|
|
81
|
+
* entry may be `{ anyOf: Condition[] }` (one level, ORed). At most 20
|
|
82
|
+
* conditions, anyOf members included.
|
|
83
|
+
* @param {{path: string, direction: 'asc'|'desc'}} [query.orderBy] - A
|
|
84
|
+
* mapping path; nulls last, ties broken by id in the same direction.
|
|
85
|
+
* Values order string < number < boolean < array < object. Strings
|
|
86
|
+
* compare by the database collation on PostgreSQL and by code point on
|
|
87
|
+
* MongoDB and DocumentDB; arrays and objects order among themselves
|
|
88
|
+
* only on PostgreSQL. Without it rows are ordered by id ascending.
|
|
89
|
+
* @param {number} [query.skip=0] - Rows to skip (integer ≥ 0)
|
|
90
|
+
* @param {number} query.take - Page size (integer 1–500)
|
|
91
|
+
* @param {string[]} [query.omit=[]] - Top-level mapping keys to leave out of
|
|
92
|
+
* the returned rows; such projected rows must not be written back
|
|
93
|
+
* @returns {Promise<{mappings: Array<Object>, total: number}>} The page, and
|
|
94
|
+
* the number of rows matching `where` (counted by a separate command on
|
|
95
|
+
* DocumentDB, so not from the page's snapshot)
|
|
96
|
+
*/
|
|
97
|
+
async queryMappings(integrationId, query) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'queryMappings is not supported by this database adapter yet'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
55
103
|
/**
|
|
56
104
|
* Delete a specific mapping
|
|
57
105
|
*
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const {
|
|
3
|
+
assertMappingWrittenUnencrypted,
|
|
4
|
+
decryptQueriedMappings,
|
|
5
|
+
} = require('../../database/encryption/integration-mapping-encryption');
|
|
2
6
|
const {
|
|
3
7
|
IntegrationMappingRepositoryInterface,
|
|
4
8
|
} = require('./integration-mapping-repository-interface');
|
|
9
|
+
const { validateMappingQuery } = require('./integration-mapping-query');
|
|
10
|
+
const {
|
|
11
|
+
buildMappingQueryStages,
|
|
12
|
+
} = require('./integration-mapping-query-pipeline');
|
|
13
|
+
|
|
14
|
+
const OBJECT_ID_REGEX = /^[0-9a-fA-F]{24}$/;
|
|
15
|
+
|
|
16
|
+
const fromRawDate = (raw) => new Date(raw.$date);
|
|
17
|
+
|
|
18
|
+
function strictObjectId(id) {
|
|
19
|
+
if (typeof id !== 'string' || !OBJECT_ID_REGEX.test(id)) {
|
|
20
|
+
throw new TypeError(`Invalid ID: ${id} is not an ObjectId`);
|
|
21
|
+
}
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
5
24
|
|
|
6
25
|
/**
|
|
7
26
|
* MongoDB Integration Mapping Repository Adapter
|
|
@@ -155,6 +174,59 @@ class IntegrationMappingRepositoryMongo extends IntegrationMappingRepositoryInte
|
|
|
155
174
|
return counts;
|
|
156
175
|
}
|
|
157
176
|
|
|
177
|
+
/**
|
|
178
|
+
* @param {string} integrationId
|
|
179
|
+
* @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
|
|
180
|
+
* @returns {Promise<{mappings: Array<Object>, total: number}>}
|
|
181
|
+
*/
|
|
182
|
+
async queryMappings(integrationId, query) {
|
|
183
|
+
const validated = validateMappingQuery(query);
|
|
184
|
+
const objectId = strictObjectId(integrationId);
|
|
185
|
+
assertMappingWrittenUnencrypted();
|
|
186
|
+
const { match, page } = buildMappingQueryStages(
|
|
187
|
+
{ $oid: objectId },
|
|
188
|
+
validated
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const result = await this.prisma.$runCommandRaw({
|
|
192
|
+
aggregate: 'IntegrationMapping',
|
|
193
|
+
pipeline: [
|
|
194
|
+
match,
|
|
195
|
+
{
|
|
196
|
+
$facet: {
|
|
197
|
+
mappings: page,
|
|
198
|
+
total: [{ $count: 'total' }],
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
cursor: {},
|
|
203
|
+
allowDiskUse: true,
|
|
204
|
+
});
|
|
205
|
+
const [{ mappings, total }] = result.cursor.firstBatch;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
mappings: await decryptQueriedMappings(
|
|
209
|
+
mappings.map((doc) => this._fromRawMapping(doc))
|
|
210
|
+
),
|
|
211
|
+
total: total[0]?.total ?? 0,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A raw aggregate document, in the shape findMappingsByIntegration returns.
|
|
217
|
+
* @private
|
|
218
|
+
*/
|
|
219
|
+
_fromRawMapping(doc) {
|
|
220
|
+
return {
|
|
221
|
+
id: doc._id.$oid,
|
|
222
|
+
integrationId: doc.integrationId.$oid,
|
|
223
|
+
sourceId: doc.sourceId ?? null,
|
|
224
|
+
mapping: doc.mapping ?? null,
|
|
225
|
+
createdAt: fromRawDate(doc.createdAt),
|
|
226
|
+
updatedAt: fromRawDate(doc.updatedAt),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
158
230
|
/**
|
|
159
231
|
* Find mapping by ID
|
|
160
232
|
* @param {string} id - Mapping ID
|
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const {
|
|
3
|
+
assertMappingWrittenUnencrypted,
|
|
4
|
+
decryptQueriedMappings,
|
|
5
|
+
} = require('../../database/encryption/integration-mapping-encryption');
|
|
2
6
|
const {
|
|
3
7
|
IntegrationMappingRepositoryInterface,
|
|
4
8
|
} = require('./integration-mapping-repository-interface');
|
|
5
9
|
const { strictIntId } = require('./report-id');
|
|
10
|
+
const { validateMappingQuery } = require('./integration-mapping-query');
|
|
11
|
+
|
|
12
|
+
const COLUMNS = { mapping: '"mapping"', sourceId: '"sourceId"' };
|
|
13
|
+
const SQL_DIRECTIONS = { asc: 'ASC', desc: 'DESC' };
|
|
14
|
+
|
|
15
|
+
const jsonPathOperand = (column, path) => ({
|
|
16
|
+
json: `${column} #> ${path}::text[]`,
|
|
17
|
+
text: `${column} #>> ${path}::text[]`,
|
|
18
|
+
});
|
|
19
|
+
const jsonType = (json) => `COALESCE(jsonb_typeof(${json}), 'null')`;
|
|
20
|
+
|
|
21
|
+
const CONDITION_SQL = {
|
|
22
|
+
exists: ({ json }) => `${jsonType(json)} <> 'null'`,
|
|
23
|
+
notExists: ({ json }) => `${jsonType(json)} = 'null'`,
|
|
24
|
+
in: ({ json, text, value }) =>
|
|
25
|
+
`(jsonb_typeof(${json}) = 'string' AND ${text} = ANY(${value}::text[]))`,
|
|
26
|
+
notStartsWith: ({ text, value }) =>
|
|
27
|
+
`(${text} IS NULL OR NOT starts_with(${text}, ${value}::text))`,
|
|
28
|
+
};
|
|
6
29
|
|
|
7
30
|
/**
|
|
8
31
|
* PostgreSQL Integration Mapping Repository Adapter
|
|
@@ -216,6 +239,101 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
|
|
|
216
239
|
return counts;
|
|
217
240
|
}
|
|
218
241
|
|
|
242
|
+
/**
|
|
243
|
+
* @param {string} integrationId
|
|
244
|
+
* @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
|
|
245
|
+
* @returns {Promise<{mappings: Array<Object>, total: number}>}
|
|
246
|
+
*/
|
|
247
|
+
async queryMappings(integrationId, query) {
|
|
248
|
+
const { where, orderBy, skip, take, omit } =
|
|
249
|
+
validateMappingQuery(query);
|
|
250
|
+
const intIntegrationId = strictIntId(integrationId);
|
|
251
|
+
assertMappingWrittenUnencrypted();
|
|
252
|
+
|
|
253
|
+
const params = [];
|
|
254
|
+
const bind = (v) => {
|
|
255
|
+
params.push(v);
|
|
256
|
+
return `$${params.length}`;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const whereSql = [
|
|
260
|
+
`"integrationId" = ${bind(intIntegrationId)}::int`,
|
|
261
|
+
`jsonb_typeof("mapping") = 'object'`,
|
|
262
|
+
...where.map((entry) => this._whereEntrySql(entry, bind)),
|
|
263
|
+
].join(' AND ');
|
|
264
|
+
const orderSql = orderBy ? this._orderSql(orderBy, bind) : `"id" ASC`;
|
|
265
|
+
const mappingSql =
|
|
266
|
+
omit.length > 0
|
|
267
|
+
? `"mapping" - ${bind(omit)}::text[] AS "mapping"`
|
|
268
|
+
: `"mapping"`;
|
|
269
|
+
|
|
270
|
+
const sql = `
|
|
271
|
+
WITH "matched" AS (
|
|
272
|
+
SELECT "id", "integrationId", "sourceId", "mapping", "createdAt", "updatedAt"
|
|
273
|
+
FROM "IntegrationMapping"
|
|
274
|
+
WHERE ${whereSql}
|
|
275
|
+
),
|
|
276
|
+
"page" AS (
|
|
277
|
+
SELECT * FROM "matched"
|
|
278
|
+
ORDER BY ${orderSql}
|
|
279
|
+
OFFSET ${bind(skip)}::bigint
|
|
280
|
+
LIMIT ${bind(take)}::int
|
|
281
|
+
)
|
|
282
|
+
SELECT "id", "integrationId", "sourceId", ${mappingSql}, "createdAt", "updatedAt",
|
|
283
|
+
(SELECT COUNT(*)::int FROM "matched") AS "__total"
|
|
284
|
+
FROM (VALUES (1)) AS "one"
|
|
285
|
+
LEFT JOIN "page" ON true
|
|
286
|
+
ORDER BY ${orderSql}
|
|
287
|
+
`;
|
|
288
|
+
const rows = await this.prisma.$queryRawUnsafe(sql, ...params);
|
|
289
|
+
const mappings = await decryptQueriedMappings(
|
|
290
|
+
rows
|
|
291
|
+
.filter((row) => row.id !== null)
|
|
292
|
+
.map(({ __total, ...row }) => this._convertMappingIds(row))
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
return { mappings, total: rows[0].__total };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* One where entry: a condition, or an anyOf group as a parenthesized OR.
|
|
300
|
+
* @private
|
|
301
|
+
*/
|
|
302
|
+
_whereEntrySql(entry, bind) {
|
|
303
|
+
if (!entry.anyOf) return this._conditionSql(entry, bind);
|
|
304
|
+
const alternatives = entry.anyOf.map((condition) =>
|
|
305
|
+
this._conditionSql(condition, bind)
|
|
306
|
+
);
|
|
307
|
+
return `(${alternatives.join(' OR ')})`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* SQL predicate for one validated queryMappings condition. `exists`
|
|
312
|
+
* treats JSON null as absent, and `notExists` is its exact negation.
|
|
313
|
+
* @private
|
|
314
|
+
*/
|
|
315
|
+
_conditionSql({ field, path, op, value }, bind) {
|
|
316
|
+
const column = COLUMNS[field];
|
|
317
|
+
const operand = path
|
|
318
|
+
? jsonPathOperand(column, bind(path))
|
|
319
|
+
: { text: column };
|
|
320
|
+
return CONDITION_SQL[op]({
|
|
321
|
+
...operand,
|
|
322
|
+
value: value === undefined ? undefined : bind(value),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* NULLIF folds JSON null into SQL NULL, so both sort after every value.
|
|
328
|
+
* @private
|
|
329
|
+
*/
|
|
330
|
+
_orderSql({ path, direction }, bind) {
|
|
331
|
+
const { json } = jsonPathOperand(COLUMNS.mapping, bind(path));
|
|
332
|
+
const value = `NULLIF(${json}, 'null'::jsonb)`;
|
|
333
|
+
const sqlDirection = SQL_DIRECTIONS[direction];
|
|
334
|
+
return `${value} ${sqlDirection} NULLS LAST, "id" ${sqlDirection}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
219
337
|
/**
|
|
220
338
|
* Find mapping by ID
|
|
221
339
|
* @param {string} id - Mapping ID (string from application layer)
|
package/logs/record.js
CHANGED
|
@@ -181,6 +181,8 @@ function buildRecord({
|
|
|
181
181
|
|
|
182
182
|
const dropped = new Set();
|
|
183
183
|
const callSiteKeys = [];
|
|
184
|
+
// Call-site keys merged into the scope invocation; the size cap must reach them too.
|
|
185
|
+
const invocationDetailKeys = [];
|
|
184
186
|
const place = ({ entries, dropped: droppedHere }, isCallSite) => {
|
|
185
187
|
for (const key of droppedHere) dropped.add(key);
|
|
186
188
|
for (const [key, value] of entries) {
|
|
@@ -190,7 +192,13 @@ function buildRecord({
|
|
|
190
192
|
isPlainObject(record[key]) &&
|
|
191
193
|
isPlainObject(value)
|
|
192
194
|
) {
|
|
193
|
-
|
|
195
|
+
const merged = extendInvocation(record[key], value);
|
|
196
|
+
if (isCallSite) {
|
|
197
|
+
for (const detailKey of Object.keys(value)) {
|
|
198
|
+
if (!(detailKey in record[key])) invocationDetailKeys.push(detailKey);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
record[key] = merged;
|
|
194
202
|
} else if (!sameValue(record[key], value)) {
|
|
195
203
|
// A repeat of the winning value is no conflict.
|
|
196
204
|
dropped.add(key);
|
|
@@ -213,13 +221,30 @@ function buildRecord({
|
|
|
213
221
|
let size = byteLength(record);
|
|
214
222
|
|
|
215
223
|
if (size > MAX_RECORD_BYTES) {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
224
|
+
const candidates = [
|
|
225
|
+
...callSiteKeys
|
|
226
|
+
.filter((key) => key !== 'error')
|
|
227
|
+
.map((key) => ({
|
|
228
|
+
name: key,
|
|
229
|
+
value: record[key],
|
|
230
|
+
remove: () => delete record[key],
|
|
231
|
+
})),
|
|
232
|
+
...invocationDetailKeys.map((key) => ({
|
|
233
|
+
name: `${INVOCATION_KEY}.${key}`,
|
|
234
|
+
value: record[INVOCATION_KEY][key],
|
|
235
|
+
remove: () => {
|
|
236
|
+
const rest = { ...record[INVOCATION_KEY] };
|
|
237
|
+
delete rest[key];
|
|
238
|
+
record[INVOCATION_KEY] = rest;
|
|
239
|
+
},
|
|
240
|
+
})),
|
|
241
|
+
];
|
|
242
|
+
const bySize = candidates
|
|
243
|
+
.map((candidate) => [candidate, Buffer.byteLength(JSON.stringify(candidate.value))])
|
|
219
244
|
.sort((a, b) => b[1] - a[1]);
|
|
220
|
-
for (const [
|
|
221
|
-
|
|
222
|
-
dropped.add(
|
|
245
|
+
for (const [candidate] of bySize) {
|
|
246
|
+
candidate.remove();
|
|
247
|
+
dropped.add(candidate.name);
|
|
223
248
|
setDroppedKeys();
|
|
224
249
|
size = byteLength(record);
|
|
225
250
|
if (size <= MAX_RECORD_BYTES) break;
|
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.654.
|
|
4
|
+
"version": "2.0.0--canary.654.6d3a665.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@friggframework/eslint-config": "2.0.0--canary.654.
|
|
51
|
-
"@friggframework/prettier-config": "2.0.0--canary.654.
|
|
52
|
-
"@friggframework/test": "2.0.0--canary.654.
|
|
50
|
+
"@friggframework/eslint-config": "2.0.0--canary.654.6d3a665.0",
|
|
51
|
+
"@friggframework/prettier-config": "2.0.0--canary.654.6d3a665.0",
|
|
52
|
+
"@friggframework/test": "2.0.0--canary.654.6d3a665.0",
|
|
53
53
|
"@prisma/client": "^6.19.3",
|
|
54
54
|
"@types/lodash": "4.17.15",
|
|
55
55
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -89,5 +89,5 @@
|
|
|
89
89
|
"publishConfig": {
|
|
90
90
|
"access": "public"
|
|
91
91
|
},
|
|
92
|
-
"gitHead": "
|
|
92
|
+
"gitHead": "6d3a6653a3227177ec6f96b5e2657ee67a0a6841"
|
|
93
93
|
}
|
|
@@ -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
|
+
};
|