@friggframework/core 2.0.0--canary.651.bc0e60a.0 → 2.0.0--canary.652.60de836.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 +1 -25
- package/database/encryption/README.md +0 -16
- package/integrations/repositories/integration-mapping-repository-interface.js +0 -41
- package/integrations/repositories/integration-mapping-repository-postgres.js +0 -131
- package/package.json +5 -5
- package/database/encryption/integration-mapping-encryption.js +0 -41
- package/integrations/repositories/integration-mapping-query.js +0 -193
package/CLAUDE.md
CHANGED
|
@@ -211,31 +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_]*$`. **PostgreSQL only**: the
|
|
228
|
-
MongoDB, DocumentDB and legacy repositories inherit the port's
|
|
229
|
-
"not supported by this database adapter yet" error. It also refuses to run
|
|
230
|
-
while field-level encryption still encrypts `IntegrationMapping.mapping` on
|
|
231
|
-
write (see `database/encryption/README.md`). Validation lives in
|
|
232
|
-
`integration-mapping-query.js`; a new operator is one entry in its
|
|
233
|
-
`OPERATORS` table and one in the Postgres adapter's `CONDITION_SQL`.
|
|
234
|
-
**Cost**: one SQL statement per call, which reads every row of the
|
|
235
|
-
integration and evaluates the JSON paths per row, because no JSON index
|
|
236
|
-
exists. With ~4 KB mappings on PostgreSQL 16 that is about 0.3 s per call
|
|
237
|
-
at 10⁴ rows per integration and 2.5–3 s at 10⁵; a deep offset or an empty
|
|
238
|
-
page past the end costs about the same as the first page.
|
|
214
|
+
- `integration-mapping-repository-*.js` - Mapping data persistence
|
|
239
215
|
- `process-repository-*.js` - Process (long-running job) persistence.
|
|
240
216
|
Implements `applyProcessUpdate(processId, ops)` — a race-safe alternative
|
|
241
217
|
to `update(id, patch)` that routes increments, sets, and bounded-array
|
|
@@ -814,22 +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
|
-
`IntegrationMappingRepository.queryMappings()` (PostgreSQL) filters and sorts
|
|
818
|
-
inside the `mapping` JSON, so it refuses to run while field-level encryption
|
|
819
|
-
still encrypts `IntegrationMapping.mapping` on write. Opt the field out in the
|
|
820
|
-
app definition, together with any nested `mapping.*` path that
|
|
821
|
-
`encryption.schema` encrypts:
|
|
822
|
-
|
|
823
|
-
```javascript
|
|
824
|
-
encryption: {
|
|
825
|
-
disable: { IntegrationMapping: ['mapping'] },
|
|
826
|
-
}
|
|
827
|
-
```
|
|
828
|
-
|
|
829
|
-
The opt-out applies to writes only. Rows written encrypted before it stay
|
|
830
|
-
readable, but `queryMappings()` does not match them until they are written
|
|
831
|
-
again.
|
|
832
|
-
|
|
833
817
|
## Future Enhancements
|
|
834
818
|
|
|
835
819
|
### Planned
|
|
@@ -52,47 +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
|
-
* Without it rows are ordered by id ascending.
|
|
83
|
-
* @param {number} [query.skip=0] - Rows to skip (integer ≥ 0)
|
|
84
|
-
* @param {number} query.take - Page size (integer 1–500)
|
|
85
|
-
* @param {string[]} [query.omit=[]] - Top-level mapping keys to leave out of
|
|
86
|
-
* the returned rows; such projected rows must not be written back
|
|
87
|
-
* @returns {Promise<{mappings: Array<Object>, total: number}>} The page, and
|
|
88
|
-
* the number of rows matching `where`
|
|
89
|
-
*/
|
|
90
|
-
async queryMappings(integrationId, query) {
|
|
91
|
-
throw new Error(
|
|
92
|
-
'queryMappings is not supported by this database adapter yet'
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
55
|
/**
|
|
97
56
|
* Delete a specific mapping
|
|
98
57
|
*
|
|
@@ -1,30 +1,8 @@
|
|
|
1
1
|
const { prisma } = require('../../database/prisma');
|
|
2
|
-
const {
|
|
3
|
-
getMappingFieldsEncryptedOnWrite,
|
|
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,115 +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
|
-
this._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
|
-
/** @private */
|
|
298
|
-
_assertMappingWrittenUnencrypted() {
|
|
299
|
-
const fields = getMappingFieldsEncryptedOnWrite();
|
|
300
|
-
if (fields.length === 0) return;
|
|
301
|
-
|
|
302
|
-
const encrypted = fields
|
|
303
|
-
.map((field) => `IntegrationMapping.${field}`)
|
|
304
|
-
.join(', ');
|
|
305
|
-
const optOut = fields.map((field) => `'${field}'`).join(', ');
|
|
306
|
-
throw new Error(
|
|
307
|
-
`queryMappings: field-level encryption still encrypts ${encrypted} on write, so it cannot be queried. Opt out by adding ${optOut} to appDefinition.encryption.disable.IntegrationMapping.`
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* One where entry: a condition, or an anyOf group as a parenthesized OR.
|
|
313
|
-
* @private
|
|
314
|
-
*/
|
|
315
|
-
_whereEntrySql(entry, bind) {
|
|
316
|
-
if (!entry.anyOf) return this._conditionSql(entry, bind);
|
|
317
|
-
const alternatives = entry.anyOf.map((condition) =>
|
|
318
|
-
this._conditionSql(condition, bind)
|
|
319
|
-
);
|
|
320
|
-
return `(${alternatives.join(' OR ')})`;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* SQL predicate for one validated queryMappings condition. `exists`
|
|
325
|
-
* treats JSON null as absent, and `notExists` is its exact negation.
|
|
326
|
-
* @private
|
|
327
|
-
*/
|
|
328
|
-
_conditionSql({ field, path, op, value }, bind) {
|
|
329
|
-
const column = COLUMNS[field];
|
|
330
|
-
const operand = path
|
|
331
|
-
? jsonPathOperand(column, bind(path))
|
|
332
|
-
: { text: column };
|
|
333
|
-
return CONDITION_SQL[op]({
|
|
334
|
-
...operand,
|
|
335
|
-
value: value === undefined ? undefined : bind(value),
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* NULLIF folds JSON null into SQL NULL, so both sort after every value.
|
|
341
|
-
* @private
|
|
342
|
-
*/
|
|
343
|
-
_orderSql({ path, direction }, bind) {
|
|
344
|
-
const { json } = jsonPathOperand(COLUMNS.mapping, bind(path));
|
|
345
|
-
const value = `NULLIF(${json}, 'null'::jsonb)`;
|
|
346
|
-
const sqlDirection = SQL_DIRECTIONS[direction];
|
|
347
|
-
return `${value} ${sqlDirection} NULLS LAST, "id" ${sqlDirection}`;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
219
|
/**
|
|
351
220
|
* Find mapping by ID
|
|
352
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.
|
|
4
|
+
"version": "2.0.0--canary.652.60de836.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.652.60de836.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.652.60de836.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.652.60de836.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": "60de83654af65d71fc331c7dbfee727c4f2534ff"
|
|
94
94
|
}
|
|
@@ -1,41 +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
|
-
function encryptedMappingFields() {
|
|
25
|
-
if (!getEncryptionConfig().enabled) return [];
|
|
26
|
-
|
|
27
|
-
loadCustomEncryptionSchema();
|
|
28
|
-
return getFieldsToEncryptOnWrite('IntegrationMapping').filter(
|
|
29
|
-
(field) => field === 'mapping' || field.startsWith('mapping.')
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Test helper: forget a kept result. */
|
|
34
|
-
function resetMappingEncryptionCheck() {
|
|
35
|
-
mappingWrittenPlain = false;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
module.exports = {
|
|
39
|
-
getMappingFieldsEncryptedOnWrite,
|
|
40
|
-
resetMappingEncryptionCheck,
|
|
41
|
-
};
|
|
@@ -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 };
|