@friggframework/core 2.0.0--canary.636.7dc8dca.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.
@@ -0,0 +1,176 @@
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 MAX_IN_VALUES = 500;
13
+ const MAX_CONDITIONS = 20;
14
+ const SQL_DIRECTIONS = { asc: 'ASC', desc: 'DESC' };
15
+
16
+ const OPS_BY_FIELD = {
17
+ mapping: ['exists', 'notExists', 'in'],
18
+ sourceId: ['notStartsWith'],
19
+ };
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 (!Object.hasOwn(SQL_DIRECTIONS, orderBy.direction)) {
77
+ throw new Error(
78
+ "queryMappings: orderBy.direction must be 'asc' or 'desc'"
79
+ );
80
+ }
81
+ return { segments, direction: SQL_DIRECTIONS[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
+ function toCondition({ path, op, value }) {
114
+ const { field, segments } = parsePath(path);
115
+ const allowed = OPS_BY_FIELD[field];
116
+ if (!allowed.includes(op)) {
117
+ throw new Error(
118
+ `queryMappings: op ${JSON.stringify(
119
+ op
120
+ )} is not allowed on '${path}' (allowed: ${allowed.join(', ')})`
121
+ );
122
+ }
123
+
124
+ if (op === 'in') {
125
+ if (
126
+ !Array.isArray(value) ||
127
+ value.length === 0 ||
128
+ !value.every((v) => typeof v === 'string')
129
+ ) {
130
+ throw new Error(
131
+ `queryMappings: 'in' value must be a non-empty array of strings on '${path}'`
132
+ );
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
+ }
139
+ return { field, segments, op, value };
140
+ }
141
+
142
+ if (op === 'notStartsWith') {
143
+ if (typeof value !== 'string' || value.length === 0) {
144
+ throw new Error(
145
+ `queryMappings: 'notStartsWith' value must be a non-empty string on '${path}'`
146
+ );
147
+ }
148
+ return { field, segments, op, value };
149
+ }
150
+
151
+ return { field, segments, op };
152
+ }
153
+
154
+ /**
155
+ * `'mapping.c2h.lastStatus'` → `{ field: 'mapping', segments: ['c2h', 'lastStatus'] }`,
156
+ * `'sourceId'` → `{ field: 'sourceId', segments: [] }`.
157
+ */
158
+ function parsePath(path) {
159
+ const [field, ...segments] =
160
+ typeof path === 'string' ? path.split('.') : [];
161
+ const valid =
162
+ (field === 'sourceId' && segments.length === 0) ||
163
+ (field === 'mapping' &&
164
+ segments.length > 0 &&
165
+ segments.every((segment) => SEGMENT_REGEX.test(segment)));
166
+ if (!valid) {
167
+ throw new Error(
168
+ `queryMappings: invalid path ${JSON.stringify(
169
+ path
170
+ )} (must be 'sourceId' or 'mapping.<segment>...' with segments matching ${SEGMENT_REGEX})`
171
+ );
172
+ }
173
+ return { field, segments };
174
+ }
175
+
176
+ 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,51 @@ 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
+ * 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
+ *
64
+ * Paths address the `mapping` JSON by identifier-only segments
65
+ * (`'mapping.c2h.lastStatus'`), or the `sourceId` column. Conditions:
66
+ * - `{ path: 'mapping.…', op: 'exists' | 'notExists' }` — JSON null counts
67
+ * as absent; notExists is the exact negation of exists.
68
+ * - `{ path: 'mapping.…', op: 'in', value: string[] }` — matches JSON
69
+ * strings; 1–500 values.
70
+ * - `{ path: 'sourceId', op: 'notStartsWith', value: string }` — a NULL
71
+ * sourceId matches.
72
+ *
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.
79
+ *
80
+ * @param {string|number} integrationId - The integration ID
81
+ * @param {Object} query
82
+ * @param {Array<Object>} [query.where=[]] - Conditions ANDed together; an
83
+ * entry may be `{ anyOf: Condition[] }` (one level, ORed). At most 20
84
+ * conditions, anyOf members included.
85
+ * @param {{path: string, direction: 'asc'|'desc'}} [query.orderBy] - A
86
+ * mapping path; nulls last, ties broken by id in the same direction.
87
+ * Without it rows are ordered by id ascending.
88
+ * @param {number} [query.skip=0] - Rows to skip (integer ≥ 0)
89
+ * @param {number} query.take - Page size (integer 1–500)
90
+ * @param {string[]} [query.omit=[]] - Top-level mapping keys to leave out of
91
+ * the returned rows; such projected rows must not be written back
92
+ * @returns {Promise<{mappings: Array<Object>, total: number}>} The page, and
93
+ * the number of rows matching `where`
94
+ * @abstract
95
+ */
96
+ async queryMappings(integrationId, query) {
97
+ throw new Error('Method queryMappings must be implemented by subclass');
98
+ }
99
+
55
100
  /**
56
101
  * Delete a specific mapping
57
102
  *
@@ -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,18 @@
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');
11
+
12
+ const encryptedMappingFields = () =>
13
+ getFieldsToEncryptOnWrite('IntegrationMapping').filter(
14
+ (field) => field === 'mapping' || field.startsWith('mapping.')
15
+ );
6
16
 
7
17
  /**
8
18
  * PostgreSQL Integration Mapping Repository Adapter
@@ -216,6 +226,155 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
216
226
  return counts;
217
227
  }
218
228
 
229
+ /**
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.
234
+ *
235
+ * The SQL text is fixed: every caller value, JSON paths included (as
236
+ * text[]), is a positional parameter, and the sort direction comes from
237
+ * the ASC/DESC whitelist in integration-mapping-query.js.
238
+ *
239
+ * @param {string} integrationId
240
+ * @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
241
+ * @returns {Promise<{mappings: Array<Object>, total: number}>}
242
+ */
243
+ async queryMappings(integrationId, query) {
244
+ const { where, orderBy, skip, take, omit } =
245
+ validateMappingQuery(query);
246
+ const intIntegrationId = strictIntId(integrationId);
247
+ this._assertMappingWrittenUnencrypted();
248
+
249
+ const params = [];
250
+ const bind = (v) => {
251
+ params.push(v);
252
+ return `$${params.length}`;
253
+ };
254
+
255
+ const whereSql = [
256
+ `"integrationId" = ${bind(intIntegrationId)}::int`,
257
+ `jsonb_typeof("mapping") = 'object'`,
258
+ ...where.map((entry) => this._whereEntrySql(entry, bind)),
259
+ ].join(' AND ');
260
+ const whereParams = [...params];
261
+
262
+ const mappingSql =
263
+ omit.length > 0
264
+ ? `"mapping" - ${bind(omit)}::text[] AS "mapping"`
265
+ : `"mapping"`;
266
+ const orderSql = orderBy ? this._orderSql(orderBy, bind) : `"id" ASC`;
267
+
268
+ const pageSql = `
269
+ SELECT "id", "integrationId", "sourceId", ${mappingSql}, "createdAt", "updatedAt",
270
+ (COUNT(*) OVER ())::int AS "__total"
271
+ FROM "IntegrationMapping"
272
+ WHERE ${whereSql}
273
+ ORDER BY ${orderSql}
274
+ OFFSET ${bind(skip)}::bigint
275
+ LIMIT ${bind(take)}::int
276
+ `;
277
+ const rows = await this.prisma.$queryRawUnsafe(pageSql, ...params);
278
+
279
+ return {
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),
286
+ };
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.
311
+ * @private
312
+ */
313
+ _assertMappingWrittenUnencrypted() {
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
+ }
330
+ }
331
+
332
+ this._mappingWrittenUnencrypted = true;
333
+ }
334
+
335
+ /**
336
+ * One where entry: a condition, or an anyOf group as a parenthesized OR.
337
+ * @private
338
+ */
339
+ _whereEntrySql(entry, bind) {
340
+ if (!entry.anyOf) return this._conditionSql(entry, bind);
341
+ const alternatives = entry.anyOf.map((condition) =>
342
+ this._conditionSql(condition, bind)
343
+ );
344
+ return `(${alternatives.join(' OR ')})`;
345
+ }
346
+
347
+ /**
348
+ * SQL predicate for one validated queryMappings condition. `exists`
349
+ * treats JSON null as absent, and `notExists` is its exact negation.
350
+ * @private
351
+ */
352
+ _conditionSql({ segments, op, value }, bind) {
353
+ if (op === 'notStartsWith') {
354
+ const prefix = bind(value);
355
+ return `("sourceId" IS NULL OR NOT starts_with("sourceId", ${prefix}::text))`;
356
+ }
357
+
358
+ const path = `${bind(segments)}::text[]`;
359
+ if (op === 'in') {
360
+ const values = bind(value);
361
+ return `(jsonb_typeof("mapping" #> ${path}) = 'string' AND "mapping" #>> ${path} = ANY(${values}::text[]))`;
362
+ }
363
+
364
+ const type = `COALESCE(jsonb_typeof("mapping" #> ${path}), 'null')`;
365
+ return op === 'exists' ? `${type} <> 'null'` : `${type} = 'null'`;
366
+ }
367
+
368
+ /**
369
+ * NULLIF folds JSON null into SQL NULL, so both sort after every value.
370
+ * @private
371
+ */
372
+ _orderSql({ segments, direction }, bind) {
373
+ const path = bind(segments);
374
+ const value = `NULLIF("mapping" #> ${path}::text[], 'null'::jsonb)`;
375
+ return `${value} ${direction} NULLS LAST, "id" ${direction}`;
376
+ }
377
+
219
378
  /**
220
379
  * Find mapping by ID
221
380
  * @param {string} id - Mapping ID (string from application layer)
@@ -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.636.7dc8dca.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.636.7dc8dca.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.636.7dc8dca.0",
53
- "@friggframework/test": "2.0.0--canary.636.7dc8dca.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": "7dc8dca491feccbc4bc9933b971ac8044155efb3"
93
+ "gitHead": "87132bf8f734ff6733c582630a606170e86a5dc2"
94
94
  }