@friggframework/core 2.0.0--canary.651.bc0e60a.0 → 2.0.0--canary.651.8ef5a7a.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 CHANGED
@@ -224,18 +224,39 @@ packages/core/
224
224
  'asc' | 'desc' }`, nulls last, ties broken by id in the same direction;
225
225
  without it rows come in id order. `take` is 1–500. `omit` lists top-level
226
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.
227
+ segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and
228
+ DocumentDB adapters give the same pages and totals;
229
+ `integration-mapping-repository-query-parity.test.js` checks that against
230
+ real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` /
231
+ `QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ:
232
+ strings compare by the database collation on PostgreSQL and by code point
233
+ on MongoDB and DocumentDB, and arrays or objects at the sort path order
234
+ among themselves only on PostgreSQL (the other adapters fall back to id).
235
+ The legacy `IntegrationMappingRepository` inherits the port's
236
+ "not supported by this database adapter yet" error. Every adapter refuses
237
+ to run while field-level encryption still encrypts
238
+ `IntegrationMapping.mapping` on write (see `database/encryption/README.md`).
239
+ Validation lives in `integration-mapping-query.js`; a new operator is one
240
+ entry in its `OPERATORS` table, one in the Postgres adapter's
241
+ `CONDITION_SQL` and one in `CONDITION_EXPRESSIONS` in
242
+ `integration-mapping-query-pipeline.js`, the aggregation stages both
243
+ MongoDB-protocol adapters share. Those stages may only use what Amazon
244
+ DocumentDB 4.0 and 5.0 support (no `$facet`, `$getField`, `$set` or
245
+ `$unset`).
246
+ **Cost**: PostgreSQL answers in one SQL statement, which reads every row
247
+ of the integration and evaluates the JSON paths per row, because no JSON
248
+ index exists. With ~4 KB mappings on PostgreSQL 16 that is about 0.3 s per
249
+ call at 10⁴ rows per integration and 2.5–3 s at 10⁵. MongoDB answers in one
250
+ aggregate: the `integrationId` index narrows `$match` to the integration,
251
+ the `$expr` is then evaluated per document, and `$facet` returns the page
252
+ with its `$count`, so a page (after `omit`) must fit the 16 MB document
253
+ limit. DocumentDB has no `$facet`: the page and the count are two
254
+ concurrent aggregates, so the total does not come from the same snapshot
255
+ as the page. Both sort in memory on a computed key (`allowDiskUse`). With
256
+ ~4 KB mappings on MongoDB 7 that is about 0.05–0.1 s per call at 10⁴ rows
257
+ per integration and 0.5–0.7 s at 10⁵ (the DocumentDB pipeline, run on
258
+ MongoDB, 0.6–0.8 s). On every adapter a deep offset or an empty page past
259
+ the end costs about the same as the first page.
239
260
  - `process-repository-*.js` - Process (long-running job) persistence.
240
261
  Implements `applyProcessUpdate(processId, ops)` — a race-safe alternative
241
262
  to `update(id, patch)` that routes increments, sets, and bounded-array
@@ -1,5 +1,9 @@
1
1
  const { Cryptor } = require('../encrypt/Cryptor');
2
- const { getEncryptedFields, loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry');
2
+ const {
3
+ getEncryptedFields,
4
+ getFieldsToEncryptOnWrite,
5
+ loadCustomEncryptionSchema,
6
+ } = require('./encryption/encryption-schema-registry');
3
7
 
4
8
  /**
5
9
  * Encryption service specifically for DocumentDB repositories
@@ -107,7 +111,7 @@ class DocumentDBEncryptionService {
107
111
  }
108
112
 
109
113
  // Get encrypted fields from registry
110
- const encryptedFieldsConfig = getEncryptedFields(modelName);
114
+ const encryptedFieldsConfig = getFieldsToEncryptOnWrite(modelName);
111
115
  if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
112
116
  return document;
113
117
  }
@@ -166,11 +166,12 @@ async function findManyDrained(client, collection, filter = {}, options = {}) {
166
166
  return drainCursor(client, collection, first);
167
167
  }
168
168
 
169
- async function aggregateDrained(client, collection, pipeline) {
169
+ async function aggregateDrained(client, collection, pipeline, options = {}) {
170
170
  const first = await client.$runCommandRaw({
171
171
  aggregate: collection,
172
172
  pipeline,
173
173
  cursor: { batchSize: DRAIN_BATCH_SIZE },
174
+ ...options,
174
175
  });
175
176
  return drainCursor(client, collection, first);
176
177
  }
@@ -814,11 +814,12 @@ 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:
817
+ The integration mapping repositories' `queryMappings()` (PostgreSQL, MongoDB
818
+ and DocumentDB) filters and sorts inside the `mapping` JSON, so it refuses to
819
+ run while field-level encryption still encrypts `IntegrationMapping.mapping` on
820
+ write. Opt the field out in the app definition, together with any nested
821
+ `mapping.*` path that `encryption.schema` encrypts; the Prisma extension and
822
+ `DocumentDBEncryptionService` both honor the opt-out:
822
823
 
823
824
  ```javascript
824
825
  encryption: {
@@ -21,6 +21,23 @@ function getMappingFieldsEncryptedOnWrite() {
21
21
  return fields;
22
22
  }
23
23
 
24
+ /**
25
+ * @throws {Error} When field-level encryption still encrypts `mapping`, or a
26
+ * nested `mapping.*` path, on write, naming the opt-out that lifts it
27
+ */
28
+ function assertMappingWrittenUnencrypted() {
29
+ const fields = getMappingFieldsEncryptedOnWrite();
30
+ if (fields.length === 0) return;
31
+
32
+ const encrypted = fields
33
+ .map((field) => `IntegrationMapping.${field}`)
34
+ .join(', ');
35
+ const optOut = fields.map((field) => `'${field}'`).join(', ');
36
+ throw new Error(
37
+ `queryMappings: field-level encryption still encrypts ${encrypted} on write, so it cannot be queried. Opt out by adding ${optOut} to appDefinition.encryption.disable.IntegrationMapping.`
38
+ );
39
+ }
40
+
24
41
  function encryptedMappingFields() {
25
42
  if (!getEncryptionConfig().enabled) return [];
26
43
 
@@ -36,6 +53,7 @@ function resetMappingEncryptionCheck() {
36
53
  }
37
54
 
38
55
  module.exports = {
56
+ assertMappingWrittenUnencrypted,
39
57
  getMappingFieldsEncryptedOnWrite,
40
58
  resetMappingEncryptionCheck,
41
59
  };
@@ -0,0 +1,152 @@
1
+ const SORT_KEY = '__sortKey';
2
+ const MONGO_DIRECTIONS = { asc: 1, desc: -1 };
3
+ const SORT_TYPE_RANKS = [
4
+ ['string'],
5
+ ['int', 'long', 'double', 'decimal'],
6
+ ['bool'],
7
+ ['array'],
8
+ ['object'],
9
+ ];
10
+ const SCALAR_TYPES = SORT_TYPE_RANKS.slice(0, 3).flat();
11
+
12
+ const isType = (expression, type) => ({ $eq: [{ $type: expression }, type] });
13
+ const isAbsent = (expression) => ({
14
+ $eq: [{ $ifNull: [expression, null] }, null],
15
+ });
16
+ const mappingField = (segments) => `$mapping.${segments.join('.')}`;
17
+
18
+ const CONDITION_EXPRESSIONS = {
19
+ exists: (operand) => ({ $ne: [{ $ifNull: [operand, null] }, null] }),
20
+ notExists: (operand) => isAbsent(operand),
21
+ in: (operand, values) => ({
22
+ $and: [
23
+ isType(operand, 'string'),
24
+ { $in: [operand, { $literal: values }] },
25
+ ],
26
+ }),
27
+ notStartsWith: (operand, prefix) => ({
28
+ $cond: [
29
+ isType(operand, 'string'),
30
+ {
31
+ $ne: [
32
+ { $substrCP: [operand, 0, [...prefix].length] },
33
+ { $literal: prefix },
34
+ ],
35
+ },
36
+ true,
37
+ ],
38
+ }),
39
+ };
40
+
41
+ /**
42
+ * Aggregation stages that answer a validated queryMappings query on the
43
+ * MongoDB wire protocol.
44
+ *
45
+ * @param {*} integrationId - The value IntegrationMapping.integrationId is stored as
46
+ * @param {ReturnType<import('./integration-mapping-query').validateMappingQuery>} query
47
+ * @returns {{match: Object, page: Object[], sort: Object}} `match` selects
48
+ * the matching documents; `page` sorts, skips and limits them; `sort` is
49
+ * the $sort stage inside `page`
50
+ */
51
+ function buildMappingQueryStages(
52
+ integrationId,
53
+ { where, orderBy, skip, take, omit }
54
+ ) {
55
+ const match = {
56
+ $match: {
57
+ integrationId,
58
+ $expr: {
59
+ $and: [
60
+ isType('$mapping', 'object'),
61
+ ...where.map(entryExpression),
62
+ ],
63
+ },
64
+ },
65
+ };
66
+ const sort = { $sort: sortSpec(orderBy) };
67
+ const page = [
68
+ ...(orderBy
69
+ ? [{ $addFields: { [SORT_KEY]: sortKey(orderBy.path) } }]
70
+ : []),
71
+ ...(omit.length > 0 ? [{ $project: omitProjection(omit) }] : []),
72
+ sort,
73
+ ...(skip > 0 ? [{ $skip: skip }] : []),
74
+ { $limit: take },
75
+ ];
76
+ return { match, page, sort };
77
+ }
78
+
79
+ function entryExpression(entry) {
80
+ if (!entry.anyOf) return conditionExpression(entry);
81
+ return { $or: entry.anyOf.map(conditionExpression) };
82
+ }
83
+
84
+ function conditionExpression({ field, path, op, value }) {
85
+ const operand = field === 'sourceId' ? '$sourceId' : resolvePath(path);
86
+ return CONDITION_EXPRESSIONS[op](operand, value);
87
+ }
88
+
89
+ /**
90
+ * The value at a mapping path; null when an enclosing value is not an object.
91
+ */
92
+ function resolvePath(segments) {
93
+ const enclosing = segments
94
+ .slice(0, -1)
95
+ .map((_, i) =>
96
+ isType(mappingField(segments.slice(0, i + 1)), 'object')
97
+ );
98
+ if (enclosing.length === 0) return mappingField(segments);
99
+ return {
100
+ $cond: [
101
+ enclosing.length === 1 ? enclosing[0] : { $and: enclosing },
102
+ mappingField(segments),
103
+ null,
104
+ ],
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Orders values like jsonb: strings < numbers < booleans < arrays < objects,
110
+ * scalars by value within their type. Null and absent values sort last in
111
+ * both directions.
112
+ */
113
+ function sortKey(path) {
114
+ const type = { $type: '$$value' };
115
+ return {
116
+ $let: {
117
+ vars: { value: resolvePath(path) },
118
+ in: {
119
+ missing: isAbsent('$$value'),
120
+ rank: {
121
+ $switch: {
122
+ branches: SORT_TYPE_RANKS.map((types, i) => ({
123
+ case: { $in: [type, types] },
124
+ then: i + 1,
125
+ })),
126
+ default: 0,
127
+ },
128
+ },
129
+ value: {
130
+ $cond: [{ $in: [type, SCALAR_TYPES] }, '$$value', null],
131
+ },
132
+ },
133
+ },
134
+ };
135
+ }
136
+
137
+ function sortSpec(orderBy) {
138
+ if (!orderBy) return { _id: 1 };
139
+ const direction = MONGO_DIRECTIONS[orderBy.direction];
140
+ return {
141
+ [`${SORT_KEY}.missing`]: 1,
142
+ [`${SORT_KEY}.rank`]: direction,
143
+ [`${SORT_KEY}.value`]: direction,
144
+ _id: direction,
145
+ };
146
+ }
147
+
148
+ function omitProjection(omit) {
149
+ return Object.fromEntries(omit.map((key) => [`mapping.${key}`, 0]));
150
+ }
151
+
152
+ module.exports = { buildMappingQueryStages };
@@ -8,6 +8,7 @@ const {
8
8
  updateOne,
9
9
  deleteOne,
10
10
  deleteMany,
11
+ aggregate,
11
12
  aggregateDrained,
12
13
  } = require('../../database/documentdb-utils');
13
14
  const {
@@ -16,6 +17,20 @@ const {
16
17
  const {
17
18
  DocumentDBEncryptionService,
18
19
  } = require('../../database/documentdb-encryption-service');
20
+ const {
21
+ assertMappingWrittenUnencrypted,
22
+ } = require('../../database/encryption/integration-mapping-encryption');
23
+ const { validateMappingQuery } = require('./integration-mapping-query');
24
+ const {
25
+ buildMappingQueryStages,
26
+ } = require('./integration-mapping-query-pipeline');
27
+
28
+ function storedIntegrationId(id) {
29
+ if (!['string', 'number'].includes(typeof id) || id === '') {
30
+ throw new TypeError(`Invalid ID: ${id}`);
31
+ }
32
+ return String(id);
33
+ }
19
34
 
20
35
  class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositoryInterface {
21
36
  constructor() {
@@ -179,6 +194,44 @@ class IntegrationMappingRepositoryDocumentDB extends IntegrationMappingRepositor
179
194
  return decryptedDocs.map((doc) => this._mapMapping(doc));
180
195
  }
181
196
 
197
+ /**
198
+ * @param {string} integrationId
199
+ * @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
200
+ * @returns {Promise<{mappings: Array<Object>, total: number}>}
201
+ */
202
+ async queryMappings(integrationId, query) {
203
+ const validated = validateMappingQuery(query);
204
+ const stored = storedIntegrationId(integrationId);
205
+ assertMappingWrittenUnencrypted();
206
+ const { match, page, sort } = buildMappingQueryStages(
207
+ stored,
208
+ validated
209
+ );
210
+
211
+ const [docs, counts] = await Promise.all([
212
+ aggregateDrained(
213
+ this.prisma,
214
+ 'IntegrationMapping',
215
+ [match, ...page, sort],
216
+ { allowDiskUse: true }
217
+ ),
218
+ aggregate(this.prisma, 'IntegrationMapping', [
219
+ match,
220
+ { $count: 'total' },
221
+ ]),
222
+ ]);
223
+ const decryptedDocs = await Promise.all(
224
+ docs.map((doc) =>
225
+ this.encryptionService.decryptFields('IntegrationMapping', doc)
226
+ )
227
+ );
228
+
229
+ return {
230
+ mappings: decryptedDocs.map((doc) => this._mapMapping(doc)),
231
+ total: counts[0]?.total ?? 0,
232
+ };
233
+ }
234
+
182
235
  async deleteMapping(integrationId, sourceId) {
183
236
  const filter = this._compositeFilter(integrationId, sourceId);
184
237
  const result = await deleteOne(
@@ -79,13 +79,17 @@ class IntegrationMappingRepositoryInterface {
79
79
  * conditions, anyOf members included.
80
80
  * @param {{path: string, direction: 'asc'|'desc'}} [query.orderBy] - A
81
81
  * mapping path; nulls last, ties broken by id in the same direction.
82
- * Without it rows are ordered by id ascending.
82
+ * Values order string < number < boolean < array < object. Strings
83
+ * compare by the database collation on PostgreSQL and by code point on
84
+ * MongoDB and DocumentDB; arrays and objects order among themselves
85
+ * only on PostgreSQL. Without it rows are ordered by id ascending.
83
86
  * @param {number} [query.skip=0] - Rows to skip (integer ≥ 0)
84
87
  * @param {number} query.take - Page size (integer 1–500)
85
88
  * @param {string[]} [query.omit=[]] - Top-level mapping keys to leave out of
86
89
  * the returned rows; such projected rows must not be written back
87
90
  * @returns {Promise<{mappings: Array<Object>, total: number}>} The page, and
88
- * the number of rows matching `where`
91
+ * the number of rows matching `where` (counted by a separate command on
92
+ * DocumentDB, so not from the page's snapshot)
89
93
  */
90
94
  async queryMappings(integrationId, query) {
91
95
  throw new Error(
@@ -1,7 +1,25 @@
1
1
  const { prisma } = require('../../database/prisma');
2
+ const {
3
+ assertMappingWrittenUnencrypted,
4
+ } = require('../../database/encryption/integration-mapping-encryption');
2
5
  const {
3
6
  IntegrationMappingRepositoryInterface,
4
7
  } = require('./integration-mapping-repository-interface');
8
+ const { validateMappingQuery } = require('./integration-mapping-query');
9
+ const {
10
+ buildMappingQueryStages,
11
+ } = require('./integration-mapping-query-pipeline');
12
+
13
+ const OBJECT_ID_REGEX = /^[0-9a-fA-F]{24}$/;
14
+
15
+ const fromRawDate = (raw) => new Date(raw.$date);
16
+
17
+ function strictObjectId(id) {
18
+ if (typeof id !== 'string' || !OBJECT_ID_REGEX.test(id)) {
19
+ throw new TypeError(`Invalid ID: ${id} is not an ObjectId`);
20
+ }
21
+ return id;
22
+ }
5
23
 
6
24
  /**
7
25
  * MongoDB Integration Mapping Repository Adapter
@@ -155,6 +173,57 @@ class IntegrationMappingRepositoryMongo extends IntegrationMappingRepositoryInte
155
173
  return counts;
156
174
  }
157
175
 
176
+ /**
177
+ * @param {string} integrationId
178
+ * @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
179
+ * @returns {Promise<{mappings: Array<Object>, total: number}>}
180
+ */
181
+ async queryMappings(integrationId, query) {
182
+ const validated = validateMappingQuery(query);
183
+ const objectId = strictObjectId(integrationId);
184
+ assertMappingWrittenUnencrypted();
185
+ const { match, page } = buildMappingQueryStages(
186
+ { $oid: objectId },
187
+ validated
188
+ );
189
+
190
+ const result = await this.prisma.$runCommandRaw({
191
+ aggregate: 'IntegrationMapping',
192
+ pipeline: [
193
+ match,
194
+ {
195
+ $facet: {
196
+ mappings: page,
197
+ total: [{ $count: 'total' }],
198
+ },
199
+ },
200
+ ],
201
+ cursor: {},
202
+ allowDiskUse: true,
203
+ });
204
+ const [{ mappings, total }] = result.cursor.firstBatch;
205
+
206
+ return {
207
+ mappings: mappings.map((doc) => this._fromRawMapping(doc)),
208
+ total: total[0]?.total ?? 0,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * A raw aggregate document, in the shape findMappingsByIntegration returns.
214
+ * @private
215
+ */
216
+ _fromRawMapping(doc) {
217
+ return {
218
+ id: doc._id.$oid,
219
+ integrationId: doc.integrationId.$oid,
220
+ sourceId: doc.sourceId ?? null,
221
+ mapping: doc.mapping ?? null,
222
+ createdAt: fromRawDate(doc.createdAt),
223
+ updatedAt: fromRawDate(doc.updatedAt),
224
+ };
225
+ }
226
+
158
227
  /**
159
228
  * Find mapping by ID
160
229
  * @param {string} id - Mapping ID
@@ -1,6 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const {
3
- getMappingFieldsEncryptedOnWrite,
3
+ assertMappingWrittenUnencrypted,
4
4
  } = require('../../database/encryption/integration-mapping-encryption');
5
5
  const {
6
6
  IntegrationMappingRepositoryInterface,
@@ -247,7 +247,7 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
247
247
  const { where, orderBy, skip, take, omit } =
248
248
  validateMappingQuery(query);
249
249
  const intIntegrationId = strictIntId(integrationId);
250
- this._assertMappingWrittenUnencrypted();
250
+ assertMappingWrittenUnencrypted();
251
251
 
252
252
  const params = [];
253
253
  const bind = (v) => {
@@ -294,20 +294,6 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
294
294
  };
295
295
  }
296
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
297
  /**
312
298
  * One where entry: a condition, or an anyOf group as a parenthesized OR.
313
299
  * @private
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.bc0e60a.0",
4
+ "version": "2.0.0--canary.651.8ef5a7a.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.bc0e60a.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.651.bc0e60a.0",
53
- "@friggframework/test": "2.0.0--canary.651.bc0e60a.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.651.8ef5a7a.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.651.8ef5a7a.0",
53
+ "@friggframework/test": "2.0.0--canary.651.8ef5a7a.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": "bc0e60a39a4ee32316d346a02ef8da77d4d41b1f"
93
+ "gitHead": "8ef5a7a4c048da70b48afa1469fe63a71d9f1240"
94
94
  }