@friggframework/core 2.0.0-next.111 → 2.0.0-next.113

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.
Files changed (74) hide show
  1. package/.eslintrc.json +18 -1
  2. package/CLAUDE.md +101 -13
  3. package/README.md +19 -22
  4. package/core/CLAUDE.md +28 -13
  5. package/core/Worker.js +32 -37
  6. package/core/create-handler.js +97 -197
  7. package/core/index.js +8 -1
  8. package/core/invocation-scope.js +225 -0
  9. package/core/secrets-to-env.js +7 -6
  10. package/database/config.js +0 -7
  11. package/database/documentdb-encryption-service.js +6 -2
  12. package/database/documentdb-utils.js +2 -1
  13. package/database/encryption/README.md +25 -1
  14. package/database/encryption/encryption-schema-registry.js +3 -0
  15. package/database/encryption/integration-mapping-encryption.js +97 -0
  16. package/database/encryption/logger.js +5 -2
  17. package/database/encryption/prisma-encryption-extension.js +20 -9
  18. package/errors/fetch-error.js +48 -65
  19. package/handlers/app-definition-loader.js +38 -2
  20. package/handlers/app-handler-helpers.js +18 -4
  21. package/handlers/backend-utils.js +10 -13
  22. package/handlers/database-migration-handler.js +10 -1
  23. package/handlers/integration-event-dispatcher.js +2 -2
  24. package/handlers/routers/integration-webhook-routers.js +13 -6
  25. package/handlers/routers/websocket.js +27 -7
  26. package/handlers/workers/db-migration.js +12 -1
  27. package/handlers/workers/dlq-processor.js +64 -38
  28. package/index.js +19 -1
  29. package/integrations/integration-base.js +56 -38
  30. package/integrations/integration-router.js +21 -20
  31. package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
  32. package/integrations/repositories/integration-mapping-query.js +193 -0
  33. package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
  34. package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
  35. package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
  36. package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
  37. package/integrations/use-cases/delete-integration-for-user.js +24 -13
  38. package/jest.config.js +2 -0
  39. package/logs/.eslintrc.json +26 -1
  40. package/logs/__fixtures__/events.js +82 -0
  41. package/logs/__fixtures__/matchers.js +45 -0
  42. package/logs/__fixtures__/secrets.js +59 -0
  43. package/logs/__fixtures__/vectors.js +329 -0
  44. package/logs/__fixtures__/with-env.js +45 -0
  45. package/logs/context.js +109 -0
  46. package/logs/contract-keys.js +67 -0
  47. package/logs/debug-shims.js +46 -0
  48. package/logs/denied-keys.js +21 -0
  49. package/logs/index.js +14 -3
  50. package/logs/jest-logger-setup.js +25 -0
  51. package/logs/levels.js +71 -0
  52. package/logs/logger-runtime.js +137 -0
  53. package/logs/logger.js +134 -42
  54. package/logs/record.js +276 -0
  55. package/logs/redact.js +328 -0
  56. package/logs/serialize.js +329 -0
  57. package/logs/sinks.js +93 -0
  58. package/logs/summarize-event.js +163 -0
  59. package/modules/module.js +32 -19
  60. package/modules/requester/oauth-2.js +28 -22
  61. package/modules/requester/requester.js +69 -6
  62. package/package.json +5 -6
  63. package/queues/queue-delivery.js +41 -0
  64. package/queues/queuer-util.js +1 -15
  65. package/telemetry/bind-telemetry-context.js +4 -0
  66. package/telemetry/instrument-handler.js +19 -2
  67. package/telemetry/no-op-telemetry.js +4 -0
  68. package/telemetry/otel-telemetry.js +25 -6
  69. package/telemetry/telemetry-context.js +19 -8
  70. package/telemetry/telemetry-runtime.js +17 -0
  71. package/telemetry/telemetry-service.js +1 -0
  72. package/types/errors/index.d.ts +24 -10
  73. package/types/logs/index.d.ts +61 -2
  74. package/user/repositories/user-repository-documentdb.js +10 -42
@@ -1,5 +1,8 @@
1
1
  const { createHandler } = require('@friggframework/core');
2
- const { createWebsocketConnectionRepository } = require('../../database/websocket-connection-repository-factory');
2
+ const { createWebsocketConnectionRepository } = require('../../websocket/repositories/websocket-connection-repository-factory');
3
+ const { getLogger } = require('../../logs');
4
+
5
+ const log = getLogger('frigg.websocket');
3
6
 
4
7
  const websocketConnectionRepository = createWebsocketConnectionRepository();
5
8
 
@@ -11,10 +14,16 @@ const handleWebSocketConnection = async (event, context) => {
11
14
  try {
12
15
  const connectionId = event.requestContext.connectionId;
13
16
  await websocketConnectionRepository.createConnection(connectionId);
14
- console.log(`Stored new connection: ${connectionId}`);
17
+ log.info('Stored new connection', {
18
+ eventName: 'frigg.websocket.connected',
19
+ connectionId,
20
+ });
15
21
  return { statusCode: 200, body: 'Connected.' };
16
22
  } catch (error) {
17
- console.error('Error storing connection:', error);
23
+ log.error('Error storing connection', {
24
+ eventName: 'frigg.websocket.connect_failed',
25
+ error,
26
+ });
18
27
  return { statusCode: 500, body: 'Error connecting.' };
19
28
  }
20
29
 
@@ -23,17 +32,28 @@ const handleWebSocketConnection = async (event, context) => {
23
32
  try {
24
33
  const connectionId = event.requestContext.connectionId;
25
34
  await websocketConnectionRepository.deleteConnection(connectionId);
26
- console.log(`Removed connection: ${connectionId}`);
35
+ log.info('Removed connection', {
36
+ eventName: 'frigg.websocket.disconnected',
37
+ connectionId,
38
+ });
27
39
  return { statusCode: 200, body: 'Disconnected.' };
28
40
  } catch (error) {
29
- console.error('Error removing connection:', error);
41
+ log.error('Error removing connection', {
42
+ eventName: 'frigg.websocket.disconnect_failed',
43
+ error,
44
+ });
30
45
  return { statusCode: 500, body: 'Error disconnecting.' };
31
46
  }
32
47
 
33
48
  case 'MESSAGE':
34
49
  // Handle incoming message
35
- const message = JSON.parse(event.body);
36
- console.log('Received message:', message);
50
+ // Parsed only to keep the old failure on a body that is not JSON.
51
+ JSON.parse(event.body);
52
+ log.info('Received message', {
53
+ eventName: 'frigg.websocket.message_received',
54
+ connectionId: event.requestContext.connectionId,
55
+ bodyLength: event.body?.length ?? 0,
56
+ });
37
57
 
38
58
  // Process the message and send a response
39
59
  const responseMessage = { message: 'Message received' };
@@ -55,6 +55,9 @@ const {
55
55
 
56
56
  // Inject prisma-runner as dependency
57
57
  const prismaRunner = require('../../database/utils/prisma-runner');
58
+ const { getLogger } = require('../../logs');
59
+
60
+ const log = getLogger('frigg.database.migration');
58
61
 
59
62
  // Use S3 repository for migration status tracking (no User table dependency)
60
63
  const bucketName = process.env.S3_BUCKET_NAME || process.env.MIGRATION_STATUS_BUCKET;
@@ -144,7 +147,6 @@ exports.handler = async (event, context) => {
144
147
  console.log('========================================');
145
148
  console.log('Database Migration Lambda Started');
146
149
  console.log('========================================');
147
- console.log('Event:', JSON.stringify(event, null, 2));
148
150
  console.log('Context:', JSON.stringify({
149
151
  requestId: context.requestId,
150
152
  functionName: context.functionName,
@@ -157,6 +159,15 @@ exports.handler = async (event, context) => {
157
159
  // Check for action parameter (direct invocation for status checks)
158
160
  const action = event.action || 'migrate'; // Default to migration
159
161
 
162
+ // targetStage, not stage: the record's own stage field would win.
163
+ log.info('Database migration invoked', {
164
+ eventName: 'frigg.database.migration.invoked',
165
+ migrationId,
166
+ dbType,
167
+ targetStage: stage,
168
+ action,
169
+ });
170
+
160
171
  // Handle checkStatus action
161
172
  if (action === 'checkStatus') {
162
173
  console.log(`\n========================================`);
@@ -5,6 +5,18 @@
5
5
  * This handler MUST NOT throw. If it throws, the message goes back to
6
6
  * the DLQ and creates an infinite loop. All errors are caught and logged.
7
7
  */
8
+ const crypto = require('node:crypto');
9
+ const { getLogger } = require('../../logs');
10
+ const {
11
+ summarizeLambdaEvent,
12
+ toScopeInvocation,
13
+ } = require('../../logs/summarize-event');
14
+ const {
15
+ runInvocationScope,
16
+ runMessageScope,
17
+ } = require('../../core/invocation-scope');
18
+
19
+ const log = getLogger('frigg.queue.dlq');
8
20
 
9
21
  function extractQueueName(eventSourceARN) {
10
22
  if (!eventSourceARN) return 'UNKNOWN';
@@ -12,50 +24,64 @@ function extractQueueName(eventSourceARN) {
12
24
  return parts[parts.length - 1] || 'UNKNOWN';
13
25
  }
14
26
 
15
- function parseMessageBody(body) {
27
+ // The body can hold credentials, so only its size and digest are logged.
28
+ function describeBody(body) {
29
+ const text = typeof body === 'string' ? body : String(body ?? '');
30
+ let parsed = true;
16
31
  try {
17
- const parsed = JSON.parse(body);
18
- return {
19
- event: parsed.event || 'UNKNOWN',
20
- integrationId: parsed.data?.integrationId || null,
21
- processId: parsed.data?.processId || null,
22
- data: parsed.data,
23
- };
24
- } catch (error) {
25
- console.warn('[DLQ] Failed to parse message body', { error: error.message, body });
26
- return {
27
- event: 'UNKNOWN',
28
- integrationId: null,
29
- processId: null,
30
- rawBody: body,
31
- };
32
+ JSON.parse(text);
33
+ } catch {
34
+ parsed = false;
32
35
  }
36
+ return {
37
+ parsed,
38
+ bodyLength: Buffer.byteLength(text),
39
+ bodySha256: crypto.createHash('sha256').update(text).digest('hex'),
40
+ };
33
41
  }
34
42
 
35
- async function dlqProcessor(event) {
43
+ function logRecord(record) {
44
+ const { parsed, ...body } = describeBody(record.body);
45
+ if (!parsed) {
46
+ log.warn('DLQ message body is not JSON', {
47
+ eventName: 'frigg.queue.dlq.body_unparsed',
48
+ ...body,
49
+ });
50
+ }
51
+ // messageId, receiveCount and the body ids come from the message scope.
52
+ log.error('Message reached the DLQ', {
53
+ eventName: 'frigg.queue.dlq.message_failed',
54
+ sentTimestamp: record.attributes?.SentTimestamp,
55
+ sourceQueue: extractQueueName(record.eventSourceARN),
56
+ ...body,
57
+ });
58
+ }
59
+
60
+ async function dlqProcessor(event, context) {
36
61
  if (!event?.Records?.length) return { batchItemFailures: [] };
37
62
 
38
- for (const record of event.Records) {
39
- try {
40
- const parsed = parseMessageBody(record.body);
41
-
42
- console.error('[DLQ] Failed message', {
43
- messageId: record.messageId,
44
- event: parsed.event,
45
- integrationId: parsed.integrationId,
46
- processId: parsed.processId,
47
- receiveCount: record.attributes?.ApproximateReceiveCount,
48
- sentTimestamp: record.attributes?.SentTimestamp,
49
- sourceQueue: extractQueueName(record.eventSourceARN),
50
- ...(parsed.rawBody !== undefined && { rawBody: parsed.rawBody }),
51
- });
52
- } catch (error) {
53
- console.error('[DLQ] Error processing DLQ record', {
54
- messageId: record.messageId,
55
- error: error.message,
56
- });
57
- }
58
- }
63
+ await runInvocationScope(
64
+ {
65
+ requestId: context?.awsRequestId,
66
+ handlerName: 'dlqProcessor',
67
+ invocation: toScopeInvocation(summarizeLambdaEvent(event)),
68
+ },
69
+ async () => {
70
+ for (const record of event.Records) {
71
+ await runMessageScope(record, async () => {
72
+ try {
73
+ logRecord(record);
74
+ } catch (error) {
75
+ log.error('DLQ record failed', {
76
+ eventName: 'frigg.queue.dlq.record_failed',
77
+ error,
78
+ });
79
+ }
80
+ });
81
+ }
82
+ },
83
+ { shouldUseDatabase: false, context }
84
+ );
59
85
 
60
86
  return { batchItemFailures: [] };
61
87
  }
package/index.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  Worker,
12
12
  loadInstalledModules,
13
13
  createHandler,
14
+ runInvocationScope,
14
15
  } = require('./core/index');
15
16
  const {
16
17
  prisma,
@@ -78,7 +79,17 @@ const {
78
79
  } = require('./telemetry/index');
79
80
  const { createUsageRepository } = require('./usage/index');
80
81
  const { TimeoutCatcher } = require('./lambda/index');
81
- const { debug, initDebugLog, flushDebugLog } = require('./logs/index');
82
+ const {
83
+ getLogger,
84
+ createMemorySink,
85
+ resetLoggerForTests,
86
+ serializeError,
87
+ redactValue,
88
+ toSanitizedSurrogate,
89
+ debug,
90
+ initDebugLog,
91
+ flushDebugLog,
92
+ } = require('./logs/index');
82
93
  const {
83
94
  Credential,
84
95
  Entity,
@@ -108,6 +119,7 @@ module.exports = {
108
119
  Worker,
109
120
  loadInstalledModules,
110
121
  createHandler,
122
+ runInvocationScope,
111
123
 
112
124
  // database
113
125
  prisma,
@@ -175,6 +187,12 @@ module.exports = {
175
187
  TimeoutCatcher,
176
188
 
177
189
  // logs
190
+ getLogger,
191
+ createMemorySink,
192
+ resetLoggerForTests,
193
+ serializeError,
194
+ redactValue,
195
+ toSanitizedSurrogate,
178
196
  debug,
179
197
  initDebugLog,
180
198
  flushDebugLog,
@@ -21,6 +21,7 @@ const { validateExtensionBinding } = require('./extension');
21
21
  const { getTelemetry } = require('../telemetry/telemetry-runtime');
22
22
  const { instrumentHandler } = require('../telemetry/instrument-handler');
23
23
  const { bindTelemetryContext } = require('../telemetry/bind-telemetry-context');
24
+ const { getLogger } = require('../logs');
24
25
 
25
26
  const constantsToBeMigrated = {
26
27
  defaultEvents: {
@@ -64,6 +65,10 @@ class IntegrationBase {
64
65
  this.getTelemetryContext()
65
66
  );
66
67
 
68
+ logger = getLogger(
69
+ `integration.${this.constructor.Definition?.name ?? 'unknown'}`
70
+ ).child(() => this.getLoggerBindings());
71
+
67
72
  static getOptionDetails() {
68
73
  const options = new Options({
69
74
  module: Object.values(this.Definition.modules)[0], // This is a placeholder until we revamp the frontend
@@ -254,6 +259,14 @@ class IntegrationBase {
254
259
  };
255
260
  }
256
261
 
262
+ // Stage and appName are record resource fields; binding them would
263
+ // only land in droppedKeys.
264
+ getLoggerBindings() {
265
+ const { integrationId, integrationType, userId, version } =
266
+ this.getTelemetryContext();
267
+ return { integrationId, integrationType, userId, version };
268
+ }
269
+
257
270
  assertHydrated(message = 'Integration instance is not hydrated') {
258
271
  if (!this.isHydrated) {
259
272
  throw new Error(message);
@@ -411,15 +424,15 @@ class IntegrationBase {
411
424
  */
412
425
  async reconcileAuthStatus(authPassed) {
413
426
  if (!authPassed) {
414
- console.log(
415
- `[Frigg] Integration ${this.id} failed to authenticate`
416
- );
427
+ this.logger.warn('Integration failed to authenticate', {
428
+ eventName: `${this.logger.name}.auth_failed`,
429
+ });
417
430
  await this.persistStatus('ERROR');
418
431
  }
419
432
  if (authPassed && this.status === 'ERROR') {
420
- console.log(
421
- `[Frigg] auth confirmed for integration ${this.id} — clearing ERROR → ENABLED`
422
- );
433
+ this.logger.info('Auth confirmed, clearing ERROR', {
434
+ eventName: `${this.logger.name}.auth_confirmed`,
435
+ });
423
436
  await this.persistStatus('ENABLED');
424
437
  }
425
438
  }
@@ -615,12 +628,22 @@ class IntegrationBase {
615
628
  this[key] = module;
616
629
  }
617
630
 
631
+ // The raw error can echo a request with its credentials, and messages
632
+ // reach end users, so only a fixed text is stored.
618
633
  addError(error) {
619
634
  if (!this.messages.errors) {
620
635
  this.messages.errors = [];
621
636
  }
622
- this.messages.errors.push(error);
637
+ this.messages.errors.push({
638
+ title: 'Integration Error',
639
+ message: `Integration ${this.id} hit an error. Contact support.`,
640
+ timestamp: Date.now(),
641
+ });
623
642
  this.status = 'ERROR';
643
+ this.logger.error('Integration error recorded', {
644
+ eventName: `${this.logger.name}.error_recorded`,
645
+ error,
646
+ });
624
647
  }
625
648
 
626
649
  addWarning(warning) {
@@ -639,9 +662,10 @@ class IntegrationBase {
639
662
  async persistStatus(status) {
640
663
  await this.updateIntegrationStatus.execute(this.id, status);
641
664
  this.status = status;
642
- console.log(
643
- `[Frigg] Integration ${this.id} status changed to ${status}`
644
- );
665
+ this.logger.info('Integration status changed', {
666
+ eventName: `${this.logger.name}.status_changed`,
667
+ integrationStatus: status,
668
+ });
645
669
  }
646
670
 
647
671
  /**
@@ -753,10 +777,14 @@ class IntegrationBase {
753
777
  // it wins. Warn if the binding tried to wire an override that's now ignored.
754
778
  if (this.events[eventName]) {
755
779
  if (typeof handlers[eventName] === 'string') {
756
- console.warn(
757
- `[Frigg] Integration "${integrationName}" binding "${bindingName}": ` +
758
- `handler "${handlers[eventName]}" for event "${eventName}" is ignored because ` +
759
- `this.events["${eventName}"] was already set (subclass constructor or earlier merge)`
780
+ this.logger.warn(
781
+ 'Binding handler is ignored: the event is already set',
782
+ {
783
+ eventName: `${this.logger.name}.extension_handler_shadowed`,
784
+ bindingName,
785
+ handler: handlers[eventName],
786
+ event: eventName,
787
+ }
760
788
  );
761
789
  }
762
790
  continue;
@@ -858,19 +886,12 @@ class IntegrationBase {
858
886
  if (this.status === 'ERROR') return;
859
887
 
860
888
  const moduleName = notifier?.name;
861
- const detail =
862
- object?.reason || object?.statusCode
863
- ? ` (status ${object?.statusCode ?? '?'}: ${
864
- object?.reason ?? 'no reason given'
865
- })`
866
- : '';
867
- console.log(
868
- `[Frigg] Module ${
869
- moduleName || '?'
870
- } reported invalid credentials for integration ${
871
- this.id
872
- } — marking ERROR${detail}`
873
- );
889
+ this.logger.warn('Module reported invalid credentials, marking ERROR', {
890
+ eventName: `${this.logger.name}.credentials_invalidated`,
891
+ moduleName,
892
+ statusCode: object?.statusCode,
893
+ reason: object?.reason,
894
+ });
874
895
  await this._recordCredentialRejection(
875
896
  moduleName,
876
897
  object?.statusCode
@@ -881,13 +902,10 @@ class IntegrationBase {
881
902
 
882
903
  if (delegateString === 'CREDENTIAL_VALIDATED') {
883
904
  if (this.status !== 'ERROR') return;
884
- console.log(
885
- `[Frigg] Module ${
886
- notifier?.name || '?'
887
- } reported valid credentials for integration ${
888
- this.id
889
- } — clearing ERROR → ENABLED`
890
- );
905
+ this.logger.info('Module reported valid credentials, clearing ERROR', {
906
+ eventName: `${this.logger.name}.credentials_validated`,
907
+ moduleName: notifier?.name,
908
+ });
891
909
  await this.persistStatus('ENABLED');
892
910
  }
893
911
  }
@@ -909,10 +927,10 @@ class IntegrationBase {
909
927
  Date.now()
910
928
  );
911
929
  } catch (error) {
912
- console.error(
913
- `[Frigg] Failed to record credential rejection for integration ${this.id}:`,
914
- error
915
- );
930
+ this.logger.error('Failed to record credential rejection', {
931
+ eventName: `${this.logger.name}.credential_rejection_record_failed`,
932
+ error,
933
+ });
916
934
  }
917
935
  }
918
936
  }
@@ -2,6 +2,7 @@ const express = require('express');
2
2
  const { get } = require('../assertions');
3
3
  const Boom = require('@hapi/boom');
4
4
  const catchAsyncError = require('express-async-handler');
5
+ const { getLogger } = require('../logs');
5
6
  const {
6
7
  createIntegrationRepository,
7
8
  } = require('./repositories/integration-repository-factory');
@@ -70,6 +71,8 @@ const {
70
71
  ProcessAuthorizationCallback,
71
72
  } = require('../modules/use-cases/process-authorization-callback');
72
73
 
74
+ const log = getLogger('frigg.integrations');
75
+
73
76
  function createIntegrationRouter() {
74
77
  const { integrations: integrationClasses, userConfig } =
75
78
  loadAppDefinition();
@@ -536,29 +539,27 @@ function setEntityRoutes(router, authenticateUser, useCases) {
536
539
  params.data && typeof params.data === 'object'
537
540
  ? Object.keys(params.data)
538
541
  : [];
539
- console.log(
540
- `[Frigg] POST /api/authorize userId=${userId} entityType=${params.entityType} dataKeys=${JSON.stringify(dataKeys)}`
541
- );
542
+ log.debug('Authorize requested', {
543
+ userId,
544
+ entityType: params.entityType,
545
+ dataKeys,
546
+ });
542
547
 
543
- try {
544
- const entityDetails =
545
- await processAuthorizationCallback.execute(
546
- userId,
547
- params.entityType,
548
- params.data
549
- );
548
+ const entityDetails = await processAuthorizationCallback.execute(
549
+ userId,
550
+ params.entityType,
551
+ params.data
552
+ );
550
553
 
551
- console.log(
552
- `[Frigg] POST /api/authorize success userId=${userId} entityType=${params.entityType} credentialId=${entityDetails?.credential_id} entityId=${entityDetails?.entity_id}`
553
- );
554
+ log.info('Entity authorized', {
555
+ eventName: 'frigg.integrations.authorized',
556
+ userId,
557
+ entityType: params.entityType,
558
+ credentialId: entityDetails?.credential_id,
559
+ entityId: entityDetails?.entity_id,
560
+ });
554
561
 
555
- res.json(entityDetails);
556
- } catch (err) {
557
- console.error(
558
- `[Frigg] POST /api/authorize failed userId=${userId} entityType=${params.entityType} error=${err?.message || err}`
559
- );
560
- throw err;
561
- }
562
+ res.json(entityDetails);
562
563
  })
563
564
  );
564
565
 
@@ -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 };