@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.
@@ -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: where.map(toWhereEntry),
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 strings.
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 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'] }`.
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 in a single round trip,
226
- * filtering and ordering inside Postgres instead of loading every row.
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 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
- ]);
277
+ const rows = await this.prisma.$queryRawUnsafe(pageSql, ...params);
279
278
 
280
279
  return {
281
- mappings: rows.map((row) => this._convertMappingIds(row)),
282
- total: countRows[0].total,
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
- * 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
+ * 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 (!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
- );
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.4e19d0f.0",
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.4e19d0f.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.651.4e19d0f.0",
53
- "@friggframework/test": "2.0.0--canary.651.4e19d0f.0",
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": "4e19d0f11b1d80079f6bf39531050ca23f5684a9"
93
+ "gitHead": "87132bf8f734ff6733c582630a606170e86a5dc2"
94
94
  }