@friggframework/core 2.0.0--canary.651.4e19d0f.0 → 2.0.0--canary.651.87132bf.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/integrations/repositories/integration-mapping-query.js +19 -1
- package/integrations/repositories/integration-mapping-repository-interface.js +15 -7
- package/integrations/repositories/integration-mapping-repository-postgres.js +57 -27
- package/integrations/repositories/integration-mapping-repository.js +11 -0
- package/package.json +5 -5
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
const SEGMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
11
11
|
const MAX_TAKE = 500;
|
|
12
|
+
const MAX_IN_VALUES = 500;
|
|
13
|
+
const MAX_CONDITIONS = 20;
|
|
12
14
|
const SQL_DIRECTIONS = { asc: 'ASC', desc: 'DESC' };
|
|
13
15
|
|
|
14
16
|
const OPS_BY_FIELD = {
|
|
@@ -41,8 +43,19 @@ function validateMappingQuery(query) {
|
|
|
41
43
|
);
|
|
42
44
|
}
|
|
43
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
|
+
|
|
44
57
|
return {
|
|
45
|
-
where:
|
|
58
|
+
where: whereEntries,
|
|
46
59
|
orderBy: orderBy === undefined ? null : toOrderBy(orderBy),
|
|
47
60
|
skip,
|
|
48
61
|
take,
|
|
@@ -118,6 +131,11 @@ function toCondition({ path, op, value }) {
|
|
|
118
131
|
`queryMappings: 'in' value must be a non-empty array of strings on '${path}'`
|
|
119
132
|
);
|
|
120
133
|
}
|
|
134
|
+
if (value.length > MAX_IN_VALUES) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`queryMappings: 'in' value must have at most ${MAX_IN_VALUES} strings on '${path}'`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
121
139
|
return { field, segments, op, value };
|
|
122
140
|
}
|
|
123
141
|
|
|
@@ -56,24 +56,32 @@ class IntegrationMappingRepositoryInterface {
|
|
|
56
56
|
* Query one filtered, ordered page of an integration's mappings without
|
|
57
57
|
* loading every row. Rows have the same shape as findMappingsByIntegration.
|
|
58
58
|
*
|
|
59
|
+
* Cost: each call is O(rows of the integration × mapping size) in the
|
|
60
|
+
* database, because JSON paths are evaluated per row and no JSON index
|
|
61
|
+
* exists. That is fine at ~10⁴ rows per integration and takes seconds per
|
|
62
|
+
* call at 10⁵+.
|
|
63
|
+
*
|
|
59
64
|
* Paths address the `mapping` JSON by identifier-only segments
|
|
60
65
|
* (`'mapping.c2h.lastStatus'`), or the `sourceId` column. Conditions:
|
|
61
66
|
* - `{ path: 'mapping.…', op: 'exists' | 'notExists' }` — JSON null counts
|
|
62
67
|
* as absent; notExists is the exact negation of exists.
|
|
63
|
-
* - `{ path: 'mapping.…', op: 'in', value: string[] }` — matches JSON
|
|
68
|
+
* - `{ path: 'mapping.…', op: 'in', value: string[] }` — matches JSON
|
|
69
|
+
* strings; 1–500 values.
|
|
64
70
|
* - `{ path: 'sourceId', op: 'notStartsWith', value: string }` — a NULL
|
|
65
71
|
* sourceId matches.
|
|
66
72
|
*
|
|
67
|
-
* Only rows whose `mapping` is a JSON object can match, so rows
|
|
68
|
-
*
|
|
69
|
-
* refuse to run while field-level encryption is enabled
|
|
70
|
-
* `mapping
|
|
71
|
-
* `appDefinition.encryption.disable = { IntegrationMapping: ['mapping'] }
|
|
73
|
+
* Only rows whose `mapping` is a JSON object can match, so rows whose
|
|
74
|
+
* whole `mapping` is still ciphertext from before an encryption opt-out
|
|
75
|
+
* never do. Adapters refuse to run while field-level encryption is enabled
|
|
76
|
+
* and still encrypts `mapping`, or a path inside it, on write; opt out with
|
|
77
|
+
* `appDefinition.encryption.disable = { IntegrationMapping: ['mapping'] }`
|
|
78
|
+
* plus any nested `mapping.…` path a custom schema encrypts.
|
|
72
79
|
*
|
|
73
80
|
* @param {string|number} integrationId - The integration ID
|
|
74
81
|
* @param {Object} query
|
|
75
82
|
* @param {Array<Object>} [query.where=[]] - Conditions ANDed together; an
|
|
76
|
-
* entry may be `{ anyOf: Condition[] }` (one level, ORed)
|
|
83
|
+
* entry may be `{ anyOf: Condition[] }` (one level, ORed). At most 20
|
|
84
|
+
* conditions, anyOf members included.
|
|
77
85
|
* @param {{path: string, direction: 'asc'|'desc'}} [query.orderBy] - A
|
|
78
86
|
* mapping path; nulls last, ties broken by id in the same direction.
|
|
79
87
|
* Without it rows are ordered by id ascending.
|
|
@@ -9,6 +9,11 @@ const {
|
|
|
9
9
|
const { strictIntId } = require('./report-id');
|
|
10
10
|
const { validateMappingQuery } = require('./integration-mapping-query');
|
|
11
11
|
|
|
12
|
+
const encryptedMappingFields = () =>
|
|
13
|
+
getFieldsToEncryptOnWrite('IntegrationMapping').filter(
|
|
14
|
+
(field) => field === 'mapping' || field.startsWith('mapping.')
|
|
15
|
+
);
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
18
|
* PostgreSQL Integration Mapping Repository Adapter
|
|
14
19
|
* Handles persistence of integration mappings used for data transformation
|
|
@@ -222,8 +227,10 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
|
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
/**
|
|
225
|
-
* Query one page of an integration's mappings
|
|
226
|
-
*
|
|
230
|
+
* Query one page of an integration's mappings, filtering and ordering
|
|
231
|
+
* inside Postgres instead of loading every row. The total rides on the
|
|
232
|
+
* page as a window count, so one statement reads the matching rows once;
|
|
233
|
+
* only an empty page after the first needs a second, count-only statement.
|
|
227
234
|
*
|
|
228
235
|
* The SQL text is fixed: every caller value, JSON paths included (as
|
|
229
236
|
* text[]), is a positional parameter, and the sort direction comes from
|
|
@@ -259,47 +266,70 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
|
|
|
259
266
|
const orderSql = orderBy ? this._orderSql(orderBy, bind) : `"id" ASC`;
|
|
260
267
|
|
|
261
268
|
const pageSql = `
|
|
262
|
-
SELECT "id", "integrationId", "sourceId", ${mappingSql}, "createdAt", "updatedAt"
|
|
269
|
+
SELECT "id", "integrationId", "sourceId", ${mappingSql}, "createdAt", "updatedAt",
|
|
270
|
+
(COUNT(*) OVER ())::int AS "__total"
|
|
263
271
|
FROM "IntegrationMapping"
|
|
264
272
|
WHERE ${whereSql}
|
|
265
273
|
ORDER BY ${orderSql}
|
|
266
274
|
OFFSET ${bind(skip)}::bigint
|
|
267
275
|
LIMIT ${bind(take)}::int
|
|
268
276
|
`;
|
|
269
|
-
const
|
|
270
|
-
SELECT COUNT(*)::int AS "total"
|
|
271
|
-
FROM "IntegrationMapping"
|
|
272
|
-
WHERE ${whereSql}
|
|
273
|
-
`;
|
|
274
|
-
|
|
275
|
-
const [rows, countRows] = await Promise.all([
|
|
276
|
-
this.prisma.$queryRawUnsafe(pageSql, ...params),
|
|
277
|
-
this.prisma.$queryRawUnsafe(countSql, ...whereParams),
|
|
278
|
-
]);
|
|
277
|
+
const rows = await this.prisma.$queryRawUnsafe(pageSql, ...params);
|
|
279
278
|
|
|
280
279
|
return {
|
|
281
|
-
mappings: rows.map((row) =>
|
|
282
|
-
|
|
280
|
+
mappings: rows.map((row) => {
|
|
281
|
+
const mapping = this._convertMappingIds(row);
|
|
282
|
+
delete mapping.__total;
|
|
283
|
+
return mapping;
|
|
284
|
+
}),
|
|
285
|
+
total: await this._totalMatches(rows, skip, whereSql, whereParams),
|
|
283
286
|
};
|
|
284
287
|
}
|
|
285
288
|
|
|
286
289
|
/**
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
+
* An empty page is either past the end (count again) or, at skip 0,
|
|
291
|
+
* proof that nothing matches.
|
|
292
|
+
* @private
|
|
293
|
+
*/
|
|
294
|
+
async _totalMatches(pageRows, skip, whereSql, whereParams) {
|
|
295
|
+
if (pageRows.length > 0) return pageRows[0].__total;
|
|
296
|
+
if (skip === 0) return 0;
|
|
297
|
+
|
|
298
|
+
const [{ total }] = await this.prisma.$queryRawUnsafe(
|
|
299
|
+
`SELECT COUNT(*)::int AS "total" FROM "IntegrationMapping" WHERE ${whereSql}`,
|
|
300
|
+
...whereParams
|
|
301
|
+
);
|
|
302
|
+
return total;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* queryMappings reads the stored JSON, so it needs `mapping`, and every
|
|
307
|
+
* path inside it, written as plain JSON. The app's opt-out
|
|
308
|
+
* (`encryption.disable`) is registered lazily, when the Prisma client is
|
|
309
|
+
* created, so load it before deciding. The stage, the keys and the app
|
|
310
|
+
* definition do not change at runtime, so a passed check is kept.
|
|
290
311
|
* @private
|
|
291
312
|
*/
|
|
292
313
|
_assertMappingWrittenUnencrypted() {
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
314
|
+
if (this._mappingWrittenUnencrypted) return;
|
|
315
|
+
|
|
316
|
+
if (getEncryptionConfig().enabled) {
|
|
317
|
+
if (encryptedMappingFields().length > 0) {
|
|
318
|
+
loadCustomEncryptionSchema();
|
|
319
|
+
}
|
|
320
|
+
const fields = encryptedMappingFields();
|
|
321
|
+
if (fields.length > 0) {
|
|
322
|
+
const encrypted = fields
|
|
323
|
+
.map((field) => `IntegrationMapping.${field}`)
|
|
324
|
+
.join(', ');
|
|
325
|
+
const optOut = fields.map((field) => `'${field}'`).join(', ');
|
|
326
|
+
throw new Error(
|
|
327
|
+
`queryMappings: field-level encryption still encrypts ${encrypted} on write, so it cannot be queried. Opt out by adding ${optOut} to appDefinition.encryption.disable.IntegrationMapping.`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
302
330
|
}
|
|
331
|
+
|
|
332
|
+
this._mappingWrittenUnencrypted = true;
|
|
303
333
|
}
|
|
304
334
|
|
|
305
335
|
/**
|
|
@@ -82,6 +82,17 @@ class IntegrationMappingRepository extends IntegrationMappingRepositoryInterface
|
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Not implemented on this legacy repository; the adapters from
|
|
87
|
+
* createIntegrationMappingRepository() support it.
|
|
88
|
+
* @throws {Error} Always
|
|
89
|
+
*/
|
|
90
|
+
async queryMappings() {
|
|
91
|
+
throw new Error(
|
|
92
|
+
'queryMappings is not supported on the legacy IntegrationMappingRepository; use createIntegrationMappingRepository()'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
85
96
|
/**
|
|
86
97
|
* Delete a mapping by integration and source ID
|
|
87
98
|
* Replaces: IntegrationMapping.deleteOne({ integration, sourceId })
|
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.
|
|
4
|
+
"version": "2.0.0--canary.651.87132bf.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.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.651.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.651.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0--canary.651.87132bf.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.651.87132bf.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.651.87132bf.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": "
|
|
93
|
+
"gitHead": "87132bf8f734ff6733c582630a606170e86a5dc2"
|
|
94
94
|
}
|