@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.
@@ -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.636.7dc8dca.0",
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.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.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": "7dc8dca491feccbc4bc9933b971ac8044155efb3"
93
+ "gitHead": "4e19d0f11b1d80079f6bf39531050ca23f5684a9"
94
94
  }