@friggframework/core 2.0.0--canary.651.4e19d0f.0 → 2.0.0--canary.651.9aaeff6.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
@@ -211,7 +211,31 @@ 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
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.
215
239
  - `process-repository-*.js` - Process (long-running job) persistence.
216
240
  Implements `applyProcessUpdate(processId, ops)` — a race-safe alternative
217
241
  to `update(id, patch)` that routes increments, sets, and bounded-array
@@ -814,6 +814,22 @@ 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
+
817
833
  ## Future Enhancements
818
834
 
819
835
  ### Planned
@@ -0,0 +1,41 @@
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,21 +1,23 @@
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
1
  const SEGMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
2
  const MAX_TAKE = 500;
12
- const SQL_DIRECTIONS = { asc: 'ASC', desc: 'DESC' };
3
+ const MAX_IN_VALUES = 500;
4
+ const MAX_CONDITIONS = 20;
5
+ const DIRECTIONS = ['asc', 'desc'];
13
6
 
14
- const OPS_BY_FIELD = {
15
- mapping: ['exists', 'notExists', 'in'],
16
- sourceId: ['notStartsWith'],
7
+ const OPERATORS = {
8
+ exists: { fields: ['mapping'] },
9
+ notExists: { fields: ['mapping'] },
10
+ in: { fields: ['mapping'], value: toStringList },
11
+ notStartsWith: { fields: ['sourceId'], value: toPrefix },
17
12
  };
18
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
+ */
19
21
  function validateMappingQuery(query) {
20
22
  if (!isPlainObject(query)) {
21
23
  throw new Error('queryMappings: query must be an object');
@@ -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,
@@ -60,12 +73,12 @@ function toOrderBy(orderBy) {
60
73
  "queryMappings: orderBy.path must be a mapping path ('mapping.<segment>...')"
61
74
  );
62
75
  }
63
- if (!Object.hasOwn(SQL_DIRECTIONS, orderBy.direction)) {
76
+ if (!DIRECTIONS.includes(orderBy.direction)) {
64
77
  throw new Error(
65
78
  "queryMappings: orderBy.direction must be 'asc' or 'desc'"
66
79
  );
67
80
  }
68
- return { segments, direction: SQL_DIRECTIONS[orderBy.direction] };
81
+ return { path: segments, direction: orderBy.direction };
69
82
  }
70
83
 
71
84
  function toWhereEntry(entry) {
@@ -97,40 +110,62 @@ function isPlainObject(value) {
97
110
  return value !== null && typeof value === 'object' && !Array.isArray(value);
98
111
  }
99
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
+ */
100
118
  function toCondition({ path, op, value }) {
101
119
  const { field, segments } = parsePath(path);
102
- const allowed = OPS_BY_FIELD[field];
103
- if (!allowed.includes(op)) {
120
+ const operator = Object.hasOwn(OPERATORS, op) ? OPERATORS[op] : null;
121
+ if (!operator?.fields.includes(field)) {
104
122
  throw new Error(
105
123
  `queryMappings: op ${JSON.stringify(
106
124
  op
107
- )} is not allowed on '${path}' (allowed: ${allowed.join(', ')})`
125
+ )} is not allowed on '${path}' (allowed: ${operatorsOn(field).join(
126
+ ', '
127
+ )})`
108
128
  );
109
129
  }
130
+ return {
131
+ field,
132
+ ...(segments.length > 0 && { path: segments }),
133
+ op,
134
+ ...(operator.value && { value: operator.value(value, path) }),
135
+ };
136
+ }
110
137
 
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
- }
138
+ function operatorsOn(field) {
139
+ return Object.keys(OPERATORS).filter((op) =>
140
+ OPERATORS[op].fields.includes(field)
141
+ );
142
+ }
123
143
 
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 };
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
+ );
131
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
+ }
132
161
 
133
- return { field, segments, op };
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;
134
169
  }
135
170
 
136
171
  /**
@@ -155,4 +190,4 @@ function parsePath(path) {
155
190
  return { field, segments };
156
191
  }
157
192
 
158
- module.exports = { SEGMENT_REGEX, validateMappingQuery };
193
+ module.exports = { validateMappingQuery };
@@ -179,14 +179,6 @@ 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
-
190
182
  async deleteMapping(integrationId, sourceId) {
191
183
  const filter = this._compositeFilter(integrationId, sourceId);
192
184
  const result = await deleteOne(
@@ -60,20 +60,23 @@ class IntegrationMappingRepositoryInterface {
60
60
  * (`'mapping.c2h.lastStatus'`), or the `sourceId` column. Conditions:
61
61
  * - `{ path: 'mapping.…', op: 'exists' | 'notExists' }` — JSON null counts
62
62
  * as absent; notExists is the exact negation of exists.
63
- * - `{ path: 'mapping.…', op: 'in', value: string[] }` — matches JSON strings.
63
+ * - `{ path: 'mapping.…', op: 'in', value: string[] }` — matches JSON
64
+ * strings; 1–500 values.
64
65
  * - `{ path: 'sourceId', op: 'notStartsWith', value: string }` — a NULL
65
66
  * sourceId matches.
66
67
  *
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'] }`.
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.
72
74
  *
73
75
  * @param {string|number} integrationId - The integration ID
74
76
  * @param {Object} query
75
77
  * @param {Array<Object>} [query.where=[]] - Conditions ANDed together; an
76
- * entry may be `{ anyOf: Condition[] }` (one level, ORed)
78
+ * entry may be `{ anyOf: Condition[] }` (one level, ORed). At most 20
79
+ * conditions, anyOf members included.
77
80
  * @param {{path: string, direction: 'asc'|'desc'}} [query.orderBy] - A
78
81
  * mapping path; nulls last, ties broken by id in the same direction.
79
82
  * Without it rows are ordered by id ascending.
@@ -83,10 +86,11 @@ class IntegrationMappingRepositoryInterface {
83
86
  * the returned rows; such projected rows must not be written back
84
87
  * @returns {Promise<{mappings: Array<Object>, total: number}>} The page, and
85
88
  * the number of rows matching `where`
86
- * @abstract
87
89
  */
88
90
  async queryMappings(integrationId, query) {
89
- throw new Error('Method queryMappings must be implemented by subclass');
91
+ throw new Error(
92
+ 'queryMappings is not supported by this database adapter yet'
93
+ );
90
94
  }
91
95
 
92
96
  /**
@@ -87,14 +87,6 @@ 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
-
98
90
  /**
99
91
  * Delete a mapping by integration and source ID
100
92
  * Replaces: IntegrationMapping.deleteOne({ integration, sourceId })
@@ -1,14 +1,31 @@
1
- const { prisma, getEncryptionConfig } = require('../../database/prisma');
1
+ const { prisma } = require('../../database/prisma');
2
2
  const {
3
- getFieldsToEncryptOnWrite,
4
- loadCustomEncryptionSchema,
5
- } = require('../../database/encryption/encryption-schema-registry');
3
+ getMappingFieldsEncryptedOnWrite,
4
+ } = require('../../database/encryption/integration-mapping-encryption');
6
5
  const {
7
6
  IntegrationMappingRepositoryInterface,
8
7
  } = require('./integration-mapping-repository-interface');
9
8
  const { strictIntId } = require('./report-id');
10
9
  const { validateMappingQuery } = require('./integration-mapping-query');
11
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
+
12
29
  /**
13
30
  * PostgreSQL Integration Mapping Repository Adapter
14
31
  * Handles persistence of integration mappings used for data transformation
@@ -222,13 +239,6 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
222
239
  }
223
240
 
224
241
  /**
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
242
  * @param {string} integrationId
233
243
  * @param {Object} query - See IntegrationMappingRepositoryInterface.queryMappings
234
244
  * @returns {Promise<{mappings: Array<Object>, total: number}>}
@@ -250,56 +260,52 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
250
260
  `jsonb_typeof("mapping") = 'object'`,
251
261
  ...where.map((entry) => this._whereEntrySql(entry, bind)),
252
262
  ].join(' AND ');
253
- const whereParams = [...params];
254
-
263
+ const orderSql = orderBy ? this._orderSql(orderBy, bind) : `"id" ASC`;
255
264
  const mappingSql =
256
265
  omit.length > 0
257
266
  ? `"mapping" - ${bind(omit)}::text[] AS "mapping"`
258
267
  : `"mapping"`;
259
- const orderSql = orderBy ? this._orderSql(orderBy, bind) : `"id" ASC`;
260
268
 
261
- const pageSql = `
262
- SELECT "id", "integrationId", "sourceId", ${mappingSql}, "createdAt", "updatedAt"
263
- FROM "IntegrationMapping"
264
- WHERE ${whereSql}
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
265
285
  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
286
  `;
274
-
275
- const [rows, countRows] = await Promise.all([
276
- this.prisma.$queryRawUnsafe(pageSql, ...params),
277
- this.prisma.$queryRawUnsafe(countSql, ...whereParams),
278
- ]);
287
+ const rows = await this.prisma.$queryRawUnsafe(sql, ...params);
279
288
 
280
289
  return {
281
- mappings: rows.map((row) => this._convertMappingIds(row)),
282
- total: countRows[0].total,
290
+ mappings: rows
291
+ .filter((row) => row.id !== null)
292
+ .map(({ __total, ...row }) => this._convertMappingIds(row)),
293
+ total: rows[0].__total,
283
294
  };
284
295
  }
285
296
 
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
- */
297
+ /** @private */
292
298
  _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
- }
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
+ );
303
309
  }
304
310
 
305
311
  /**
@@ -319,30 +325,26 @@ class IntegrationMappingRepositoryPostgres extends IntegrationMappingRepositoryI
319
325
  * treats JSON null as absent, and `notExists` is its exact negation.
320
326
  * @private
321
327
  */
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'`;
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
+ });
336
337
  }
337
338
 
338
339
  /**
339
340
  * NULLIF folds JSON null into SQL NULL, so both sort after every value.
340
341
  * @private
341
342
  */
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}`;
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}`;
346
348
  }
347
349
 
348
350
  /**
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.9aaeff6.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.9aaeff6.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.651.9aaeff6.0",
53
+ "@friggframework/test": "2.0.0--canary.651.9aaeff6.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": "9aaeff6108b5ebfc7de1d69bbba78e3eb793e7d8"
94
94
  }