@friggframework/core 2.0.0--canary.636.7dc8dca.0 → 2.0.0--canary.651.4e19d0f.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 +158 -0
- package/integrations/repositories/integration-mapping-repository-documentdb.js +8 -0
- package/integrations/repositories/integration-mapping-repository-interface.js +37 -0
- package/integrations/repositories/integration-mapping-repository-mongo.js +8 -0
- package/integrations/repositories/integration-mapping-repository-postgres.js +130 -1
- package/package.json +5 -5
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for IntegrationMappingRepository.queryMappings().
|
|
3
|
+
*
|
|
4
|
+
* Backend-agnostic: enforces the query shape BEFORE an adapter builds a
|
|
5
|
+
* database command, so every adapter rejects the same input. Paths come out
|
|
6
|
+
* split into identifier-only segments that adapters pass as bound
|
|
7
|
+
* parameters; nothing from the query is spliced into SQL text.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SEGMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
11
|
+
const MAX_TAKE = 500;
|
|
12
|
+
const SQL_DIRECTIONS = { asc: 'ASC', desc: 'DESC' };
|
|
13
|
+
|
|
14
|
+
const OPS_BY_FIELD = {
|
|
15
|
+
mapping: ['exists', 'notExists', 'in'],
|
|
16
|
+
sourceId: ['notStartsWith'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function validateMappingQuery(query) {
|
|
20
|
+
if (!isPlainObject(query)) {
|
|
21
|
+
throw new Error('queryMappings: query must be an object');
|
|
22
|
+
}
|
|
23
|
+
const { where = [], orderBy, skip = 0, take, omit = [] } = query;
|
|
24
|
+
if (!Array.isArray(where)) {
|
|
25
|
+
throw new Error('queryMappings: where must be an array');
|
|
26
|
+
}
|
|
27
|
+
if (!Number.isInteger(take) || take < 1 || take > MAX_TAKE) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`queryMappings: take must be an integer between 1 and ${MAX_TAKE}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (!Number.isSafeInteger(skip) || skip < 0) {
|
|
33
|
+
throw new Error('queryMappings: skip must be a non-negative integer');
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
!Array.isArray(omit) ||
|
|
37
|
+
!omit.every((key) => typeof key === 'string' && SEGMENT_REGEX.test(key))
|
|
38
|
+
) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`queryMappings: omit must be an array of top-level mapping keys matching ${SEGMENT_REGEX}`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
where: where.map(toWhereEntry),
|
|
46
|
+
orderBy: orderBy === undefined ? null : toOrderBy(orderBy),
|
|
47
|
+
skip,
|
|
48
|
+
take,
|
|
49
|
+
omit,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toOrderBy(orderBy) {
|
|
54
|
+
if (!isPlainObject(orderBy)) {
|
|
55
|
+
throw new Error('queryMappings: orderBy must be an object');
|
|
56
|
+
}
|
|
57
|
+
const { field, segments } = parsePath(orderBy.path);
|
|
58
|
+
if (field !== 'mapping') {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"queryMappings: orderBy.path must be a mapping path ('mapping.<segment>...')"
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!Object.hasOwn(SQL_DIRECTIONS, orderBy.direction)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
"queryMappings: orderBy.direction must be 'asc' or 'desc'"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return { segments, direction: SQL_DIRECTIONS[orderBy.direction] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toWhereEntry(entry) {
|
|
72
|
+
assertWhereEntryShape(entry);
|
|
73
|
+
if (!('anyOf' in entry)) return toCondition(entry);
|
|
74
|
+
|
|
75
|
+
const { anyOf } = entry;
|
|
76
|
+
if (!Array.isArray(anyOf) || anyOf.length === 0) {
|
|
77
|
+
throw new Error('queryMappings: anyOf must be a non-empty array');
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
anyOf: anyOf.map((condition) => {
|
|
81
|
+
assertWhereEntryShape(condition);
|
|
82
|
+
if ('anyOf' in condition) {
|
|
83
|
+
throw new Error('queryMappings: nested anyOf is not supported');
|
|
84
|
+
}
|
|
85
|
+
return toCondition(condition);
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertWhereEntryShape(entry) {
|
|
91
|
+
if (!isPlainObject(entry)) {
|
|
92
|
+
throw new Error('queryMappings: each where entry must be an object');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isPlainObject(value) {
|
|
97
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toCondition({ path, op, value }) {
|
|
101
|
+
const { field, segments } = parsePath(path);
|
|
102
|
+
const allowed = OPS_BY_FIELD[field];
|
|
103
|
+
if (!allowed.includes(op)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`queryMappings: op ${JSON.stringify(
|
|
106
|
+
op
|
|
107
|
+
)} is not allowed on '${path}' (allowed: ${allowed.join(', ')})`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (op === 'in') {
|
|
112
|
+
if (
|
|
113
|
+
!Array.isArray(value) ||
|
|
114
|
+
value.length === 0 ||
|
|
115
|
+
!value.every((v) => typeof v === 'string')
|
|
116
|
+
) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`queryMappings: 'in' value must be a non-empty array of strings on '${path}'`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return { field, segments, op, value };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (op === 'notStartsWith') {
|
|
125
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`queryMappings: 'notStartsWith' value must be a non-empty string on '${path}'`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { field, segments, op, value };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { field, segments, op };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `'mapping.c2h.lastStatus'` → `{ field: 'mapping', segments: ['c2h', 'lastStatus'] }`,
|
|
138
|
+
* `'sourceId'` → `{ field: 'sourceId', segments: [] }`.
|
|
139
|
+
*/
|
|
140
|
+
function parsePath(path) {
|
|
141
|
+
const [field, ...segments] =
|
|
142
|
+
typeof path === 'string' ? path.split('.') : [];
|
|
143
|
+
const valid =
|
|
144
|
+
(field === 'sourceId' && segments.length === 0) ||
|
|
145
|
+
(field === 'mapping' &&
|
|
146
|
+
segments.length > 0 &&
|
|
147
|
+
segments.every((segment) => SEGMENT_REGEX.test(segment)));
|
|
148
|
+
if (!valid) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`queryMappings: invalid path ${JSON.stringify(
|
|
151
|
+
path
|
|
152
|
+
)} (must be 'sourceId' or 'mapping.<segment>...' with segments matching ${SEGMENT_REGEX})`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return { field, segments };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { SEGMENT_REGEX, validateMappingQuery };
|
|
@@ -179,6 +179,14 @@ class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositor
|
|
|
179
179
|
return decryptedDocs.map((doc) => this._mapMapping(doc));
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Not implemented for DocumentDB yet; only the PostgreSQL adapter supports it.
|
|
184
|
+
* @throws {Error} Always
|
|
185
|
+
*/
|
|
186
|
+
async queryMappings() {
|
|
187
|
+
throw new Error('queryMappings is not supported on DocumentDB yet');
|
|
188
|
+
}
|
|
189
|
+
|
|
182
190
|
async deleteMapping(integrationId, sourceId) {
|
|
183
191
|
const filter = this._compositeFilter(integrationId, sourceId);
|
|
184
192
|
const result = await deleteOne(
|
|
@@ -52,6 +52,43 @@ 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 strings.
|
|
64
|
+
* - `{ path: 'sourceId', op: 'notStartsWith', value: string }` — a NULL
|
|
65
|
+
* sourceId matches.
|
|
66
|
+
*
|
|
67
|
+
* Only rows whose `mapping` is a JSON object can match, so rows still
|
|
68
|
+
* holding ciphertext from before an encryption opt-out never do. Adapters
|
|
69
|
+
* refuse to run while field-level encryption is enabled and still encrypts
|
|
70
|
+
* `mapping` on write; opt out with
|
|
71
|
+
* `appDefinition.encryption.disable = { IntegrationMapping: ['mapping'] }`.
|
|
72
|
+
*
|
|
73
|
+
* @param {string|number} integrationId - The integration ID
|
|
74
|
+
* @param {Object} query
|
|
75
|
+
* @param {Array<Object>} [query.where=[]] - Conditions ANDed together; an
|
|
76
|
+
* entry may be `{ anyOf: Condition[] }` (one level, ORed)
|
|
77
|
+
* @param {{path: string, direction: 'asc'|'desc'}} [query.orderBy] - A
|
|
78
|
+
* mapping path; nulls last, ties broken by id in the same direction.
|
|
79
|
+
* Without it rows are ordered by id ascending.
|
|
80
|
+
* @param {number} [query.skip=0] - Rows to skip (integer ≥ 0)
|
|
81
|
+
* @param {number} query.take - Page size (integer 1–500)
|
|
82
|
+
* @param {string[]} [query.omit=[]] - Top-level mapping keys to leave out of
|
|
83
|
+
* the returned rows; such projected rows must not be written back
|
|
84
|
+
* @returns {Promise<{mappings: Array<Object>, total: number}>} The page, and
|
|
85
|
+
* the number of rows matching `where`
|
|
86
|
+
* @abstract
|
|
87
|
+
*/
|
|
88
|
+
async queryMappings(integrationId, query) {
|
|
89
|
+
throw new Error('Method queryMappings must be implemented by subclass');
|
|
90
|
+
}
|
|
91
|
+
|
|
55
92
|
/**
|
|
56
93
|
* Delete a specific mapping
|
|
57
94
|
*
|
|
@@ -87,6 +87,14 @@ class IntegrationMappingRepositoryMongo extends IntegrationMappingRepositoryInte
|
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Not implemented for MongoDB yet; only the PostgreSQL adapter supports it.
|
|
92
|
+
* @throws {Error} Always
|
|
93
|
+
*/
|
|
94
|
+
async queryMappings() {
|
|
95
|
+
throw new Error('queryMappings is not supported on MongoDB yet');
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
/**
|
|
91
99
|
* Delete a mapping by integration and source ID
|
|
92
100
|
* Replaces: IntegrationMapping.deleteOne({ integration, sourceId })
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
const { prisma } = require('../../database/prisma');
|
|
1
|
+
const { prisma, getEncryptionConfig } = require('../../database/prisma');
|
|
2
|
+
const {
|
|
3
|
+
getFieldsToEncryptOnWrite,
|
|
4
|
+
loadCustomEncryptionSchema,
|
|
5
|
+
} = require('../../database/encryption/encryption-schema-registry');
|
|
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');
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* PostgreSQL Integration Mapping Repository Adapter
|
|
@@ -216,6 +221,130 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
|
|
|
216
221
|
return counts;
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Query one page of an integration's mappings in a single round trip,
|
|
226
|
+
* filtering and ordering inside Postgres instead of loading every row.
|
|
227
|
+
*
|
|
228
|
+
* The SQL text is fixed: every caller value, JSON paths included (as
|
|
229
|
+
* text[]), is a positional parameter, and the sort direction comes from
|
|
230
|
+
* the ASC/DESC whitelist in integration-mapping-query.js.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} integrationId
|
|
233
|
+
* @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
|
|
234
|
+
* @returns {Promise<{mappings: Array<Object>, total: number}>}
|
|
235
|
+
*/
|
|
236
|
+
async queryMappings(integrationId, query) {
|
|
237
|
+
const { where, orderBy, skip, take, omit } =
|
|
238
|
+
validateMappingQuery(query);
|
|
239
|
+
const intIntegrationId = strictIntId(integrationId);
|
|
240
|
+
this._assertMappingWrittenUnencrypted();
|
|
241
|
+
|
|
242
|
+
const params = [];
|
|
243
|
+
const bind = (v) => {
|
|
244
|
+
params.push(v);
|
|
245
|
+
return `$${params.length}`;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const whereSql = [
|
|
249
|
+
`"integrationId" = ${bind(intIntegrationId)}::int`,
|
|
250
|
+
`jsonb_typeof("mapping") = 'object'`,
|
|
251
|
+
...where.map((entry) => this._whereEntrySql(entry, bind)),
|
|
252
|
+
].join(' AND ');
|
|
253
|
+
const whereParams = [...params];
|
|
254
|
+
|
|
255
|
+
const mappingSql =
|
|
256
|
+
omit.length > 0
|
|
257
|
+
? `"mapping" - ${bind(omit)}::text[] AS "mapping"`
|
|
258
|
+
: `"mapping"`;
|
|
259
|
+
const orderSql = orderBy ? this._orderSql(orderBy, bind) : `"id" ASC`;
|
|
260
|
+
|
|
261
|
+
const pageSql = `
|
|
262
|
+
SELECT "id", "integrationId", "sourceId", ${mappingSql}, "createdAt", "updatedAt"
|
|
263
|
+
FROM "IntegrationMapping"
|
|
264
|
+
WHERE ${whereSql}
|
|
265
|
+
ORDER BY ${orderSql}
|
|
266
|
+
OFFSET ${bind(skip)}::bigint
|
|
267
|
+
LIMIT ${bind(take)}::int
|
|
268
|
+
`;
|
|
269
|
+
const countSql = `
|
|
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
|
+
]);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
mappings: rows.map((row) => this._convertMappingIds(row)),
|
|
282
|
+
total: countRows[0].total,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* queryMappings reads the stored JSON, so it needs `mapping` written as
|
|
288
|
+
* plain JSON. The app's opt-out (`encryption.disable`) is registered
|
|
289
|
+
* lazily, when the Prisma client is created, so load it before deciding.
|
|
290
|
+
* @private
|
|
291
|
+
*/
|
|
292
|
+
_assertMappingWrittenUnencrypted() {
|
|
293
|
+
if (!getEncryptionConfig().enabled) return;
|
|
294
|
+
|
|
295
|
+
const encryptsMapping = () =>
|
|
296
|
+
getFieldsToEncryptOnWrite('IntegrationMapping').includes('mapping');
|
|
297
|
+
if (encryptsMapping()) loadCustomEncryptionSchema();
|
|
298
|
+
if (encryptsMapping()) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
"queryMappings: field-level encryption still encrypts IntegrationMapping.mapping on write, so it cannot be queried. Opt out with appDefinition.encryption.disable = { IntegrationMapping: ['mapping'] }."
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* One where entry: a condition, or an anyOf group as a parenthesized OR.
|
|
307
|
+
* @private
|
|
308
|
+
*/
|
|
309
|
+
_whereEntrySql(entry, bind) {
|
|
310
|
+
if (!entry.anyOf) return this._conditionSql(entry, bind);
|
|
311
|
+
const alternatives = entry.anyOf.map((condition) =>
|
|
312
|
+
this._conditionSql(condition, bind)
|
|
313
|
+
);
|
|
314
|
+
return `(${alternatives.join(' OR ')})`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* SQL predicate for one validated queryMappings condition. `exists`
|
|
319
|
+
* treats JSON null as absent, and `notExists` is its exact negation.
|
|
320
|
+
* @private
|
|
321
|
+
*/
|
|
322
|
+
_conditionSql({ segments, op, value }, bind) {
|
|
323
|
+
if (op === 'notStartsWith') {
|
|
324
|
+
const prefix = bind(value);
|
|
325
|
+
return `("sourceId" IS NULL OR NOT starts_with("sourceId", ${prefix}::text))`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const path = `${bind(segments)}::text[]`;
|
|
329
|
+
if (op === 'in') {
|
|
330
|
+
const values = bind(value);
|
|
331
|
+
return `(jsonb_typeof("mapping" #> ${path}) = 'string' AND "mapping" #>> ${path} = ANY(${values}::text[]))`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const type = `COALESCE(jsonb_typeof("mapping" #> ${path}), 'null')`;
|
|
335
|
+
return op === 'exists' ? `${type} <> 'null'` : `${type} = 'null'`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* NULLIF folds JSON null into SQL NULL, so both sort after every value.
|
|
340
|
+
* @private
|
|
341
|
+
*/
|
|
342
|
+
_orderSql({ segments, direction }, bind) {
|
|
343
|
+
const path = bind(segments);
|
|
344
|
+
const value = `NULLIF("mapping" #> ${path}::text[], 'null'::jsonb)`;
|
|
345
|
+
return `${value} ${direction} NULLS LAST, "id" ${direction}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
219
348
|
/**
|
|
220
349
|
* Find mapping by ID
|
|
221
350
|
* @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.
|
|
4
|
+
"version": "2.0.0--canary.651.4e19d0f.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.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0--canary.651.4e19d0f.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.651.4e19d0f.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.651.4e19d0f.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": "4e19d0f11b1d80079f6bf39531050ca23f5684a9"
|
|
94
94
|
}
|