@friggframework/core 2.0.0-next.80 → 2.0.0-next.82

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 (40) hide show
  1. package/CLAUDE.md +8 -0
  2. package/generated/prisma-mongodb/edge.js +3 -3
  3. package/generated/prisma-mongodb/index.d.ts +2 -2
  4. package/generated/prisma-mongodb/index.js +9 -9
  5. package/generated/prisma-mongodb/{query-engine-debian-openssl-3.0.x → libquery_engine-debian-openssl-3.0.x.so.node} +0 -0
  6. package/generated/prisma-mongodb/{query-engine-rhel-openssl-3.0.x → libquery_engine-rhel-openssl-3.0.x.so.node} +0 -0
  7. package/generated/prisma-mongodb/package.json +1 -1
  8. package/generated/prisma-mongodb/runtime/library.js +146 -0
  9. package/generated/prisma-mongodb/schema.prisma +7 -1
  10. package/generated/prisma-mongodb/wasm.js +3 -3
  11. package/generated/prisma-postgresql/edge.js +3 -3
  12. package/generated/prisma-postgresql/index.d.ts +6 -2
  13. package/generated/prisma-postgresql/index.js +9 -9
  14. package/generated/prisma-postgresql/{query-engine-debian-openssl-3.0.x → libquery_engine-debian-openssl-3.0.x.so.node} +0 -0
  15. package/generated/prisma-postgresql/{query-engine-rhel-openssl-3.0.x → libquery_engine-rhel-openssl-3.0.x.so.node} +0 -0
  16. package/generated/prisma-postgresql/package.json +1 -1
  17. package/generated/prisma-postgresql/runtime/library.js +146 -0
  18. package/generated/prisma-postgresql/schema.prisma +7 -1
  19. package/generated/prisma-postgresql/wasm.js +3 -3
  20. package/integrations/integration-router.js +27 -6
  21. package/integrations/repositories/process-repository-documentdb.js +68 -0
  22. package/integrations/repositories/process-repository-interface.js +46 -0
  23. package/integrations/repositories/process-repository-mongo.js +72 -0
  24. package/integrations/repositories/process-repository-postgres.js +163 -0
  25. package/integrations/repositories/process-update-ops-shared.js +112 -0
  26. package/integrations/use-cases/update-process-metrics.js +106 -102
  27. package/integrations/use-cases/update-process-state.js +58 -19
  28. package/modules/module.js +3 -1
  29. package/modules/requester/requester.js +145 -37
  30. package/modules/use-cases/get-module-instance-from-type.js +4 -1
  31. package/modules/use-cases/process-authorization-callback.js +49 -5
  32. package/package.json +5 -5
  33. package/prisma-mongodb/schema.prisma +7 -1
  34. package/prisma-postgresql/migrations/20260422120000_add_entity_data_column/migration.sql +10 -0
  35. package/prisma-postgresql/migrations/20260422120001_create_process_table/migration.sql +48 -0
  36. package/prisma-postgresql/schema.prisma +7 -1
  37. package/generated/prisma-mongodb/runtime/binary.d.ts +0 -1
  38. package/generated/prisma-mongodb/runtime/binary.js +0 -289
  39. package/generated/prisma-postgresql/runtime/binary.d.ts +0 -1
  40. package/generated/prisma-postgresql/runtime/binary.js +0 -289
@@ -6,7 +6,13 @@ generator client {
6
6
  provider = "prisma-client-js"
7
7
  output = "../generated/prisma-postgresql"
8
8
  binaryTargets = ["native", "rhel-openssl-3.0.x"] // native for local dev, rhel for Lambda deployment
9
- engineType = "binary" // Use binary engines (smaller size)
9
+ // Library engine (default since Prisma 3.x): Rust query engine loads as a
10
+ // Node-API addon inside the same process. The binary engine forks a child
11
+ // query-engine subprocess and communicates over a local HTTP/IPC pipe with
12
+ // NO client-side timeout — a zombied child wedges the Node process until
13
+ // Lambda's 900s cap. Switching to library eliminates that entire class of
14
+ // silent hangs. See friggframework/frigg#580 for the investigation.
15
+ engineType = "library"
10
16
  }
11
17
 
12
18
  datasource db {
@@ -293,7 +293,7 @@ const config = {
293
293
  "fromEnvVar": null
294
294
  },
295
295
  "config": {
296
- "engineType": "binary"
296
+ "engineType": "library"
297
297
  },
298
298
  "binaryTargets": [
299
299
  {
@@ -330,8 +330,8 @@ const config = {
330
330
  }
331
331
  }
332
332
  },
333
- "inlineSchema": "// Frigg Framework - Prisma Schema (PostgreSQL)\n// PostgreSQL database schema for enterprise integration platform\n// Converted from MongoDB schema for relational database support\n\ngenerator client {\n provider = \"prisma-client-js\"\n output = \"../generated/prisma-postgresql\"\n binaryTargets = [\"native\", \"rhel-openssl-3.0.x\"] // native for local dev, rhel for Lambda deployment\n engineType = \"binary\" // Use binary engines (smaller size)\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n}\n\n// ============================================================================\n// USER MODELS\n// ============================================================================\n\n/// User model with discriminator pattern support\n/// Replaces Mongoose discriminators (IndividualUser, OrganizationUser)\nmodel User {\n id Int @id @default(autoincrement())\n type UserType\n\n // Timestamps\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // IndividualUser fields (nullable for organizations)\n email String?\n username String?\n hashword String? // Bcrypt hashed password (handled in application layer)\n appUserId String?\n organizationId Int?\n\n // Self-referential relation for organization membership\n organization User? @relation(\"OrgMembers\", fields: [organizationId], references: [id], onDelete: NoAction, onUpdate: NoAction)\n members User[] @relation(\"OrgMembers\")\n\n // OrganizationUser fields (nullable for individuals)\n appOrgId String?\n name String?\n\n // Relations\n tokens Token[]\n credentials Credential[]\n entities Entity[]\n integrations Integration[]\n processes Process[]\n\n @@unique([username, appUserId])\n @@index([type])\n @@index([appUserId])\n}\n\nenum UserType {\n INDIVIDUAL\n ORGANIZATION\n}\n\n// ============================================================================\n// AUTHENTICATION MODELS\n// ============================================================================\n\n/// Authentication tokens with expiration\n/// Bcrypt hashed tokens stored (handled in application layer)\nmodel Token {\n id Int @id @default(autoincrement())\n token String // Bcrypt hashed\n created DateTime @default(now())\n expires DateTime?\n userId Int\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@index([expires])\n}\n\n// ============================================================================\n// CREDENTIAL & ENTITY MODELS\n// ============================================================================\n\n/// OAuth credentials and API tokens\n/// All sensitive data encrypted with KMS at rest\nmodel Credential {\n id Int @id @default(autoincrement())\n userId Int?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n authIsValid Boolean?\n externalId String?\n\n // Dynamic OAuth fields stored as JSON (encrypted via Prisma middleware)\n // Contains: access_token, refresh_token, domain, expires_in, token_type, etc.\n data Json @default(\"{}\")\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Relations\n entities Entity[]\n\n @@index([userId])\n @@index([externalId])\n}\n\n/// External service entities (API connections)\nmodel Entity {\n id Int @id @default(autoincrement())\n credentialId Int?\n credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull)\n userId Int?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n name String?\n moduleName String?\n externalId String?\n\n data Json @default(\"{}\")\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Relations - many-to-many with implicit join tables\n integrations Integration[]\n syncs Sync[]\n\n dataIdentifiers DataIdentifier[]\n associationObjects AssociationObject[]\n\n @@index([userId])\n @@index([externalId])\n @@index([moduleName])\n @@index([credentialId])\n}\n\n// ============================================================================\n// INTEGRATION MODELS\n// ============================================================================\n\n/// Main integration configuration and state\nmodel Integration {\n id Int @id @default(autoincrement())\n userId Int?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n status IntegrationStatus @default(ENABLED)\n\n // Configuration and version\n config Json? // Integration configuration object\n version String?\n\n // Entity references (many-to-many via implicit join table)\n entities Entity[]\n\n // Message arrays (stored as JSON)\n errors Json @default(\"[]\")\n warnings Json @default(\"[]\")\n info Json @default(\"[]\")\n logs Json @default(\"[]\")\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Relations\n associations Association[]\n syncs Sync[]\n mappings IntegrationMapping[]\n processes Process[]\n\n @@index([userId])\n @@index([status])\n}\n\nenum IntegrationStatus {\n ENABLED\n NEEDS_CONFIG\n PROCESSING\n DISABLED\n ERROR\n}\n\n/// Integration-specific data mappings\n/// All mapping data encrypted with KMS\nmodel IntegrationMapping {\n id Int @id @default(autoincrement())\n integrationId Int\n integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n sourceId String?\n\n // Encrypted mapping data (handled via Prisma middleware)\n mapping Json?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([integrationId, sourceId])\n @@index([integrationId])\n @@index([sourceId])\n}\n\n// ============================================================================\n// SYNC MODELS\n// ============================================================================\n\n/// Bidirectional data synchronization tracking\nmodel Sync {\n id Int @id @default(autoincrement())\n integrationId Int?\n integration Integration? @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n\n // Entity references (many-to-many via implicit join table)\n entities Entity[]\n\n hash String\n name String\n\n // Data identifiers (extracted to separate model)\n dataIdentifiers DataIdentifier[]\n\n @@index([integrationId])\n @@index([hash])\n @@index([name])\n}\n\n/// Data identifier for sync operations\n/// Replaces nested array structure in Mongoose\nmodel DataIdentifier {\n id Int @id @default(autoincrement())\n syncId Int?\n sync Sync? @relation(fields: [syncId], references: [id], onDelete: Cascade)\n entityId Int\n entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)\n\n // Identifier data (can be any structure)\n idData Json\n\n hash String\n\n @@index([syncId])\n @@index([entityId])\n @@index([hash])\n}\n\n// ============================================================================\n// ASSOCIATION MODELS\n// ============================================================================\n\n/// Entity associations with cardinality tracking\nmodel Association {\n id Int @id @default(autoincrement())\n integrationId Int\n integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n name String\n type AssociationType\n primaryObject String\n\n // Associated objects (extracted to separate model)\n objects AssociationObject[]\n\n @@index([integrationId])\n @@index([name])\n}\n\n/// Association object entry\n/// Replaces nested array structure in Mongoose\nmodel AssociationObject {\n id Int @id @default(autoincrement())\n associationId Int\n association Association @relation(fields: [associationId], references: [id], onDelete: Cascade)\n entityId Int\n entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)\n objectType String\n objId String\n metadata Json? // Optional metadata\n\n @@index([associationId])\n @@index([entityId])\n}\n\nenum AssociationType {\n ONE_TO_MANY\n ONE_TO_ONE\n MANY_TO_ONE\n}\n\n// ============================================================================\n// PROCESS MODELS\n// ============================================================================\n\n/// Generic Process Model - tracks any long-running operation\n/// Used for: CRM syncs, data migrations, bulk operations, etc.\nmodel Process {\n id Int @id @default(autoincrement())\n\n // Core references\n userId Int\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n integrationId Int\n integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n\n // Process identification\n name String // e.g., \"zoho-crm-contact-sync\", \"pipedrive-lead-sync\"\n type String // e.g., \"CRM_SYNC\", \"DATA_MIGRATION\", \"BULK_OPERATION\"\n\n // State machine\n state String // Current state (integration-defined states)\n\n // Flexible storage\n context Json @default(\"{}\") // Process-specific data (pagination, metadata, etc.)\n results Json @default(\"{}\") // Process results and metrics\n\n // Hierarchy support - self-referential relation\n parentProcessId Int?\n parentProcess Process? @relation(\"ProcessHierarchy\", fields: [parentProcessId], references: [id], onDelete: SetNull)\n childProcesses Process[] @relation(\"ProcessHierarchy\")\n\n // Timestamps\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@index([integrationId])\n @@index([type])\n @@index([state])\n @@index([name])\n @@index([parentProcessId])\n}\n\n// ============================================================================\n// UTILITY MODELS\n// ============================================================================\n\n/// Generic state storage\nmodel State {\n id Int @id @default(autoincrement())\n state Json?\n}\n\n/// AWS API Gateway WebSocket connection tracking\nmodel WebsocketConnection {\n id Int @id @default(autoincrement())\n connectionId String?\n\n @@index([connectionId])\n}\n",
334
- "inlineSchemaHash": "a53140906632700b021910998a3bbd7b9743a1e27ef2f8459dedccffdb8a0035",
333
+ "inlineSchema": "// Frigg Framework - Prisma Schema (PostgreSQL)\n// PostgreSQL database schema for enterprise integration platform\n// Converted from MongoDB schema for relational database support\n\ngenerator client {\n provider = \"prisma-client-js\"\n output = \"../generated/prisma-postgresql\"\n binaryTargets = [\"native\", \"rhel-openssl-3.0.x\"] // native for local dev, rhel for Lambda deployment\n // Library engine (default since Prisma 3.x): Rust query engine loads as a\n // Node-API addon inside the same process. The binary engine forks a child\n // query-engine subprocess and communicates over a local HTTP/IPC pipe with\n // NO client-side timeout — a zombied child wedges the Node process until\n // Lambda's 900s cap. Switching to library eliminates that entire class of\n // silent hangs. See friggframework/frigg#580 for the investigation.\n engineType = \"library\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABASE_URL\")\n}\n\n// ============================================================================\n// USER MODELS\n// ============================================================================\n\n/// User model with discriminator pattern support\n/// Replaces Mongoose discriminators (IndividualUser, OrganizationUser)\nmodel User {\n id Int @id @default(autoincrement())\n type UserType\n\n // Timestamps\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // IndividualUser fields (nullable for organizations)\n email String?\n username String?\n hashword String? // Bcrypt hashed password (handled in application layer)\n appUserId String?\n organizationId Int?\n\n // Self-referential relation for organization membership\n organization User? @relation(\"OrgMembers\", fields: [organizationId], references: [id], onDelete: NoAction, onUpdate: NoAction)\n members User[] @relation(\"OrgMembers\")\n\n // OrganizationUser fields (nullable for individuals)\n appOrgId String?\n name String?\n\n // Relations\n tokens Token[]\n credentials Credential[]\n entities Entity[]\n integrations Integration[]\n processes Process[]\n\n @@unique([username, appUserId])\n @@index([type])\n @@index([appUserId])\n}\n\nenum UserType {\n INDIVIDUAL\n ORGANIZATION\n}\n\n// ============================================================================\n// AUTHENTICATION MODELS\n// ============================================================================\n\n/// Authentication tokens with expiration\n/// Bcrypt hashed tokens stored (handled in application layer)\nmodel Token {\n id Int @id @default(autoincrement())\n token String // Bcrypt hashed\n created DateTime @default(now())\n expires DateTime?\n userId Int\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@index([expires])\n}\n\n// ============================================================================\n// CREDENTIAL & ENTITY MODELS\n// ============================================================================\n\n/// OAuth credentials and API tokens\n/// All sensitive data encrypted with KMS at rest\nmodel Credential {\n id Int @id @default(autoincrement())\n userId Int?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n authIsValid Boolean?\n externalId String?\n\n // Dynamic OAuth fields stored as JSON (encrypted via Prisma middleware)\n // Contains: access_token, refresh_token, domain, expires_in, token_type, etc.\n data Json @default(\"{}\")\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Relations\n entities Entity[]\n\n @@index([userId])\n @@index([externalId])\n}\n\n/// External service entities (API connections)\nmodel Entity {\n id Int @id @default(autoincrement())\n credentialId Int?\n credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull)\n userId Int?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n name String?\n moduleName String?\n externalId String?\n\n data Json @default(\"{}\")\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Relations - many-to-many with implicit join tables\n integrations Integration[]\n syncs Sync[]\n\n dataIdentifiers DataIdentifier[]\n associationObjects AssociationObject[]\n\n @@index([userId])\n @@index([externalId])\n @@index([moduleName])\n @@index([credentialId])\n}\n\n// ============================================================================\n// INTEGRATION MODELS\n// ============================================================================\n\n/// Main integration configuration and state\nmodel Integration {\n id Int @id @default(autoincrement())\n userId Int?\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n status IntegrationStatus @default(ENABLED)\n\n // Configuration and version\n config Json? // Integration configuration object\n version String?\n\n // Entity references (many-to-many via implicit join table)\n entities Entity[]\n\n // Message arrays (stored as JSON)\n errors Json @default(\"[]\")\n warnings Json @default(\"[]\")\n info Json @default(\"[]\")\n logs Json @default(\"[]\")\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Relations\n associations Association[]\n syncs Sync[]\n mappings IntegrationMapping[]\n processes Process[]\n\n @@index([userId])\n @@index([status])\n}\n\nenum IntegrationStatus {\n ENABLED\n NEEDS_CONFIG\n PROCESSING\n DISABLED\n ERROR\n}\n\n/// Integration-specific data mappings\n/// All mapping data encrypted with KMS\nmodel IntegrationMapping {\n id Int @id @default(autoincrement())\n integrationId Int\n integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n sourceId String?\n\n // Encrypted mapping data (handled via Prisma middleware)\n mapping Json?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([integrationId, sourceId])\n @@index([integrationId])\n @@index([sourceId])\n}\n\n// ============================================================================\n// SYNC MODELS\n// ============================================================================\n\n/// Bidirectional data synchronization tracking\nmodel Sync {\n id Int @id @default(autoincrement())\n integrationId Int?\n integration Integration? @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n\n // Entity references (many-to-many via implicit join table)\n entities Entity[]\n\n hash String\n name String\n\n // Data identifiers (extracted to separate model)\n dataIdentifiers DataIdentifier[]\n\n @@index([integrationId])\n @@index([hash])\n @@index([name])\n}\n\n/// Data identifier for sync operations\n/// Replaces nested array structure in Mongoose\nmodel DataIdentifier {\n id Int @id @default(autoincrement())\n syncId Int?\n sync Sync? @relation(fields: [syncId], references: [id], onDelete: Cascade)\n entityId Int\n entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)\n\n // Identifier data (can be any structure)\n idData Json\n\n hash String\n\n @@index([syncId])\n @@index([entityId])\n @@index([hash])\n}\n\n// ============================================================================\n// ASSOCIATION MODELS\n// ============================================================================\n\n/// Entity associations with cardinality tracking\nmodel Association {\n id Int @id @default(autoincrement())\n integrationId Int\n integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n name String\n type AssociationType\n primaryObject String\n\n // Associated objects (extracted to separate model)\n objects AssociationObject[]\n\n @@index([integrationId])\n @@index([name])\n}\n\n/// Association object entry\n/// Replaces nested array structure in Mongoose\nmodel AssociationObject {\n id Int @id @default(autoincrement())\n associationId Int\n association Association @relation(fields: [associationId], references: [id], onDelete: Cascade)\n entityId Int\n entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)\n objectType String\n objId String\n metadata Json? // Optional metadata\n\n @@index([associationId])\n @@index([entityId])\n}\n\nenum AssociationType {\n ONE_TO_MANY\n ONE_TO_ONE\n MANY_TO_ONE\n}\n\n// ============================================================================\n// PROCESS MODELS\n// ============================================================================\n\n/// Generic Process Model - tracks any long-running operation\n/// Used for: CRM syncs, data migrations, bulk operations, etc.\nmodel Process {\n id Int @id @default(autoincrement())\n\n // Core references\n userId Int\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n integrationId Int\n integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)\n\n // Process identification\n name String // e.g., \"zoho-crm-contact-sync\", \"pipedrive-lead-sync\"\n type String // e.g., \"CRM_SYNC\", \"DATA_MIGRATION\", \"BULK_OPERATION\"\n\n // State machine\n state String // Current state (integration-defined states)\n\n // Flexible storage\n context Json @default(\"{}\") // Process-specific data (pagination, metadata, etc.)\n results Json @default(\"{}\") // Process results and metrics\n\n // Hierarchy support - self-referential relation\n parentProcessId Int?\n parentProcess Process? @relation(\"ProcessHierarchy\", fields: [parentProcessId], references: [id], onDelete: SetNull)\n childProcesses Process[] @relation(\"ProcessHierarchy\")\n\n // Timestamps\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@index([integrationId])\n @@index([type])\n @@index([state])\n @@index([name])\n @@index([parentProcessId])\n}\n\n// ============================================================================\n// UTILITY MODELS\n// ============================================================================\n\n/// Generic state storage\nmodel State {\n id Int @id @default(autoincrement())\n state Json?\n}\n\n/// AWS API Gateway WebSocket connection tracking\nmodel WebsocketConnection {\n id Int @id @default(autoincrement())\n connectionId String?\n\n @@index([connectionId])\n}\n",
334
+ "inlineSchemaHash": "f3edba5bb5e72088ff5a6505548bd90aa528e5e99e6433c2eed6eed57dcd25ab",
335
335
  "copyEngine": true
336
336
  }
337
337
  config.dirname = '/'
@@ -507,7 +507,8 @@ function setEntityRoutes(router, authenticateUser, useCases) {
507
507
  const params = checkRequiredParams(req.query, ['entityType']);
508
508
  const module = await getModuleInstanceFromType.execute(
509
509
  userId,
510
- params.entityType
510
+ params.entityType,
511
+ { state: req.query.state }
511
512
  );
512
513
  const areRequirementsValid =
513
514
  module.validateAuthorizationRequirements();
@@ -530,13 +531,33 @@ function setEntityRoutes(router, authenticateUser, useCases) {
530
531
  'data',
531
532
  ]);
532
533
 
533
- const entityDetails = await processAuthorizationCallback.execute(
534
- userId,
535
- params.entityType,
536
- params.data
534
+ const dataKeys =
535
+ params.data && typeof params.data === 'object'
536
+ ? Object.keys(params.data)
537
+ : [];
538
+ console.log(
539
+ `[Frigg] POST /api/authorize userId=${userId} entityType=${params.entityType} dataKeys=${JSON.stringify(dataKeys)}`
537
540
  );
538
541
 
539
- res.json(entityDetails);
542
+ try {
543
+ const entityDetails =
544
+ await processAuthorizationCallback.execute(
545
+ userId,
546
+ params.entityType,
547
+ params.data
548
+ );
549
+
550
+ console.log(
551
+ `[Frigg] POST /api/authorize success userId=${userId} entityType=${params.entityType} credentialId=${entityDetails?.credential_id} entityId=${entityDetails?.entity_id}`
552
+ );
553
+
554
+ res.json(entityDetails);
555
+ } catch (err) {
556
+ console.error(
557
+ `[Frigg] POST /api/authorize failed userId=${userId} entityType=${params.entityType} error=${err?.message || err}`
558
+ );
559
+ throw err;
560
+ }
540
561
  })
541
562
  );
542
563
 
@@ -14,6 +14,7 @@ const {
14
14
  const {
15
15
  DocumentDBEncryptionService,
16
16
  } = require('../../database/documentdb-encryption-service');
17
+ const { validateOps } = require('./process-update-ops-shared');
17
18
 
18
19
  class ProcessRepositoryDocumentDB extends ProcessRepositoryInterface {
19
20
  constructor() {
@@ -155,6 +156,73 @@ class ProcessRepositoryDocumentDB extends ProcessRepositoryInterface {
155
156
  return this._mapProcess(decryptedProcess);
156
157
  }
157
158
 
159
+ /**
160
+ * Atomic process update — race-safe counterpart to `update()`.
161
+ *
162
+ * Uses DocumentDB's native $inc / $set / $push operators (Mongo-wire
163
+ * compatible) via findAndModify so increments, sets, and pushes land
164
+ * in one server-side write. Contention on the same document
165
+ * serializes at the DB level.
166
+ *
167
+ * DocumentDB compatibility notes:
168
+ * - $inc: supported since v3.6.
169
+ * - $set with dot-path: supported.
170
+ * - $push with $each + negative $slice: supported since v4.0.
171
+ * Clusters still on v3.6 must upgrade before using pushSlice.
172
+ *
173
+ * Process documents have no encrypted fields today; if that changes,
174
+ * the set-by-path payload here MUST route through
175
+ * `encryptionService.encryptFields` for any affected paths.
176
+ *
177
+ * @param {string} processId
178
+ * @param {import('./process-repository-interface').ProcessUpdateOps} ops
179
+ * @returns {Promise<Object|null>}
180
+ */
181
+ async applyProcessUpdate(processId, ops) {
182
+ const normalized = validateOps(ops);
183
+ const objectId = toObjectId(processId);
184
+
185
+ const update = {};
186
+ const $set = {};
187
+
188
+ if (Object.keys(normalized.increment).length > 0) {
189
+ update.$inc = { ...normalized.increment };
190
+ }
191
+ for (const [path, value] of Object.entries(normalized.set)) {
192
+ $set[path] = value;
193
+ }
194
+ if (normalized.newState !== null) {
195
+ $set.state = normalized.newState;
196
+ }
197
+ $set.updatedAt = new Date();
198
+ update.$set = $set;
199
+
200
+ if (Object.keys(normalized.pushSlice).length > 0) {
201
+ update.$push = {};
202
+ for (const [path, spec] of Object.entries(normalized.pushSlice)) {
203
+ update.$push[path] = {
204
+ $each: spec.values,
205
+ $slice: -spec.keepLast,
206
+ };
207
+ }
208
+ }
209
+
210
+ const result = await this.prisma.$runCommandRaw({
211
+ findAndModify: 'Process',
212
+ query: { _id: objectId },
213
+ update,
214
+ new: true,
215
+ });
216
+
217
+ const doc = result && result.value;
218
+ if (!doc) return null;
219
+ const decrypted = await this.encryptionService.decryptFields(
220
+ 'Process',
221
+ doc
222
+ );
223
+ return this._mapProcess(decrypted);
224
+ }
225
+
158
226
  async findByIntegrationAndType(integrationId, type) {
159
227
  const integrationObjectId = toObjectId(integrationId);
160
228
  const filter = {
@@ -47,6 +47,52 @@ class ProcessRepositoryInterface {
47
47
  throw new Error('Method update() must be implemented');
48
48
  }
49
49
 
50
+ /**
51
+ * Apply atomic mutations to a process record.
52
+ *
53
+ * Race-safe counterpart to `update()`. Where `update()` takes full
54
+ * JSON blobs and does read-modify-write at the ORM layer (clobber-
55
+ * prone under concurrent writers), `applyProcessUpdate()` describes
56
+ * the intent declaratively and each backend uses its native atomic
57
+ * primitive:
58
+ * - PostgreSQL: `jsonb_set` chain inside a single UPDATE ... RETURNING
59
+ * - MongoDB: `$inc` / `$set` / `$push` via findAndModify
60
+ * - DocumentDB: same operator set as MongoDB (with version caveats)
61
+ *
62
+ * All paths are dot-delimited and rooted in `context` or `results`
63
+ * (e.g. `context.processedRecords`,
64
+ * `results.aggregateData.totalSynced`). Paths MUST match
65
+ * `^(context|results)(\\.[a-zA-Z_][a-zA-Z0-9_]*)+$` — validated by
66
+ * each adapter before any SQL/command generation.
67
+ *
68
+ * Intended primary callers: UpdateProcessMetrics and
69
+ * UpdateProcessState. Other callers can use this directly when they
70
+ * need race-free cumulative updates.
71
+ *
72
+ * @typedef {Object} ProcessUpdateOps
73
+ * @property {Object.<string, number>} [increment] - Atomic numeric
74
+ * increments keyed by dot-path. e.g.
75
+ * `{ 'context.processedRecords': 1, 'results.aggregateData.totalSynced': 1 }`
76
+ * @property {Object.<string, *>} [set] - Atomic whole-subtree set
77
+ * keyed by dot-path. Replaces the value at the path (NOT deep
78
+ * merge). e.g. `{ 'context.fetchDone': true }`
79
+ * @property {Object.<string, {values: Array, keepLast: number}>} [pushSlice]
80
+ * Atomic array push with bounded retention (sliding window of the
81
+ * last `keepLast` items). Keys are dot-paths pointing to arrays.
82
+ * e.g. `{ 'results.aggregateData.errors': { values: [err], keepLast: 100 } }`
83
+ * @property {string} [newState] - Top-level `state` column update.
84
+ * Written alongside the JSON mutations in the same UPDATE so state
85
+ * + counters move together.
86
+ *
87
+ * @param {string} processId - Process ID to update
88
+ * @param {ProcessUpdateOps} ops - Atomic operations to apply
89
+ * @returns {Promise<Object|null>} Updated process record (post-
90
+ * mutation) or null if the process does not exist.
91
+ */
92
+ async applyProcessUpdate(processId, ops) {
93
+ throw new Error('Method applyProcessUpdate() must be implemented');
94
+ }
95
+
50
96
  /**
51
97
  * Find processes by integration and type
52
98
  * @param {string} integrationId - Integration ID
@@ -1,5 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ProcessRepositoryInterface } = require('./process-repository-interface');
3
+ const { validateOps } = require('./process-update-ops-shared');
3
4
 
4
5
  /**
5
6
  * MongoDB Process Repository Adapter
@@ -92,6 +93,77 @@ class ProcessRepositoryMongo extends ProcessRepositoryInterface {
92
93
  return this._toPlainObject(process);
93
94
  }
94
95
 
96
+ /**
97
+ * Atomic process update — race-safe counterpart to `update()`.
98
+ *
99
+ * Uses `findAndModify` via `$runCommandRaw` so increments, sets, and
100
+ * pushes land in one server-side write. Contention on the same
101
+ * document serializes at the MongoDB level; no Node-side read-
102
+ * modify-write. Returns the post-update document.
103
+ *
104
+ * @param {string} processId
105
+ * @param {import('./process-repository-interface').ProcessUpdateOps} ops
106
+ * @returns {Promise<Object|null>}
107
+ */
108
+ async applyProcessUpdate(processId, ops) {
109
+ const normalized = validateOps(ops);
110
+
111
+ const update = {};
112
+ const $set = {};
113
+
114
+ if (Object.keys(normalized.increment).length > 0) {
115
+ update.$inc = { ...normalized.increment };
116
+ }
117
+ for (const [path, value] of Object.entries(normalized.set)) {
118
+ $set[path] = value;
119
+ }
120
+ if (normalized.newState !== null) {
121
+ $set.state = normalized.newState;
122
+ }
123
+ $set.updatedAt = new Date();
124
+ update.$set = $set;
125
+
126
+ if (Object.keys(normalized.pushSlice).length > 0) {
127
+ update.$push = {};
128
+ for (const [path, spec] of Object.entries(normalized.pushSlice)) {
129
+ update.$push[path] = {
130
+ $each: spec.values,
131
+ $slice: -spec.keepLast,
132
+ };
133
+ }
134
+ }
135
+
136
+ const result = await this.prisma.$runCommandRaw({
137
+ findAndModify: 'Process',
138
+ query: { _id: { $oid: processId } },
139
+ update,
140
+ new: true,
141
+ });
142
+
143
+ const doc = result && result.value;
144
+ if (!doc) return null;
145
+ return this._toPlainObject(this._hydrateRawMongoDoc(doc));
146
+ }
147
+
148
+ /**
149
+ * Shape a raw Mongo document (as returned by $runCommandRaw) to match
150
+ * Prisma's `findUnique` output so the existing `_toPlainObject` works
151
+ * without modification. EJSON round-trips give us `{$oid, $date}` wrappers
152
+ * that need unwrapping.
153
+ * @private
154
+ */
155
+ _hydrateRawMongoDoc(doc) {
156
+ const hydrated = { ...doc };
157
+ if (doc._id) hydrated.id = doc._id.$oid ?? doc._id;
158
+ for (const field of ['createdAt', 'updatedAt']) {
159
+ const raw = doc[field];
160
+ if (raw && typeof raw === 'object' && raw.$date) {
161
+ hydrated[field] = new Date(raw.$date);
162
+ }
163
+ }
164
+ return hydrated;
165
+ }
166
+
95
167
  /**
96
168
  * Find processes by integration and type
97
169
  * @param {string} integrationId - Integration ID
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
2
2
  const {
3
3
  ProcessRepositoryInterface,
4
4
  } = require('./process-repository-interface');
5
+ const { validateOps, splitPath } = require('./process-update-ops-shared');
5
6
 
6
7
  /**
7
8
  * PostgreSQL Process Repository Adapter
@@ -108,6 +109,168 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
108
109
  return this._toPlainObject(process);
109
110
  }
110
111
 
112
+ /**
113
+ * Atomic process update — race-safe counterpart to `update()`.
114
+ *
115
+ * Compiles the `ProcessUpdateOps` into ONE `UPDATE "Process" ...
116
+ * RETURNING *` statement with nested `jsonb_set` calls for every
117
+ * context/results mutation. Postgres applies row-level locking
118
+ * during UPDATE, so concurrent callers on the same row serialize at
119
+ * the DB without any read-modify-write in Node.
120
+ *
121
+ * Path segments have been regex-validated upstream (see
122
+ * process-update-ops-shared.js); they are embedded directly into
123
+ * the SQL string. All values go through positional parameters.
124
+ *
125
+ * @param {string} processId
126
+ * @param {ProcessUpdateOps} ops
127
+ * @returns {Promise<Object|null>}
128
+ */
129
+ async applyProcessUpdate(processId, ops) {
130
+ const normalized = validateOps(ops);
131
+ const id = this._convertId(processId);
132
+
133
+ // Build the SQL expression for each JSON column. We start each
134
+ // column's expression from the column itself and wrap it in
135
+ // jsonb_set(...) calls — one wrap per operation targeting that
136
+ // column. If no op targets a column, we omit that SET clause so
137
+ // we don't issue a pointless self-assignment.
138
+ const params = [];
139
+ /** @type {(v:unknown)=>string} positional placeholder, 1-indexed */
140
+ const bind = (v) => {
141
+ params.push(v);
142
+ return `$${params.length}`;
143
+ };
144
+
145
+ const columnExpressions = this._buildColumnExpressions(
146
+ normalized,
147
+ bind
148
+ );
149
+ const setClauses = [];
150
+ for (const [column, expr] of Object.entries(columnExpressions)) {
151
+ setClauses.push(`"${column}" = ${expr}`);
152
+ }
153
+ if (normalized.newState !== null) {
154
+ setClauses.push(`"state" = ${bind(normalized.newState)}`);
155
+ }
156
+ setClauses.push(`"updatedAt" = NOW()`);
157
+
158
+ const idPlaceholder = bind(id);
159
+ const sql = `
160
+ UPDATE "Process"
161
+ SET ${setClauses.join(', ')}
162
+ WHERE "id" = ${idPlaceholder}
163
+ RETURNING *
164
+ `;
165
+
166
+ const rows = await this.prisma.$queryRawUnsafe(sql, ...params);
167
+ if (!rows || rows.length === 0) return null;
168
+ return this._toPlainObject(rows[0]);
169
+ }
170
+
171
+ /**
172
+ * Returns a map of column → SQL expression with all jsonb_set wraps
173
+ * applied. Used only by applyProcessUpdate.
174
+ * @private
175
+ */
176
+ _buildColumnExpressions(ops, bind) {
177
+ const byColumn = { context: null, results: null };
178
+
179
+ // Seed with the column itself (wrapped with COALESCE so that
180
+ // a NULL column doesn't break jsonb_set).
181
+ const seed = (col) =>
182
+ byColumn[col] ??
183
+ (byColumn[col] = `COALESCE("${col}", '{}'::jsonb)`);
184
+
185
+ /**
186
+ * Postgres `jsonb_set(target, path, value, create_missing=true)`
187
+ * only creates the LEAF segment if missing — intermediate segments
188
+ * that don't exist as objects cause the call to return `target`
189
+ * unchanged (silent no-op). For a path like `context.a.b.c` on a
190
+ * doc where `context.a` is missing, we'd bail on the write.
191
+ *
192
+ * This helper wraps `prev` in a chain of `jsonb_set` calls that
193
+ * ensure each intermediate prefix path is an object, preserving
194
+ * its contents if it's already present:
195
+ *
196
+ * ensureParents(prev, ['a','b','c'])
197
+ * ⇒ jsonb_set(
198
+ * jsonb_set(prev, '{a}', COALESCE(prev#>'{a}', '{}'::jsonb), true),
199
+ * '{a,b}', COALESCE(${that}#>'{a,b}', '{}'::jsonb), true)
200
+ *
201
+ * The caller then wraps this result with its own `jsonb_set` for
202
+ * the leaf segment. Depth-1 paths skip this entirely (no parents
203
+ * to synthesize).
204
+ */
205
+ const ensureParents = (prevExpr, segments) => {
206
+ let cur = prevExpr;
207
+ for (let i = 1; i < segments.length; i++) {
208
+ const parentPath = `'{${segments.slice(0, i).join(',')}}'`;
209
+ cur = `jsonb_set(${cur}, ${parentPath}, COALESCE(${cur} #> ${parentPath}, '{}'::jsonb), true)`;
210
+ }
211
+ return cur;
212
+ };
213
+
214
+ const wrapIncrement = (col, segments, delta) => {
215
+ const textPath = `'{${segments.join(',')}}'`;
216
+ const jsonbPath = `'{${segments.join(',')}}'`;
217
+ const prev = seed(col);
218
+ const guarded = ensureParents(prev, segments);
219
+ const nextValue = `to_jsonb(COALESCE((${guarded} #>> ${textPath})::numeric, 0) + ${bind(delta)})`;
220
+ byColumn[col] = `jsonb_set(${guarded}, ${jsonbPath}, ${nextValue}, true)`;
221
+ };
222
+
223
+ const wrapSet = (col, segments, value) => {
224
+ const jsonbPath = `'{${segments.join(',')}}'`;
225
+ const prev = seed(col);
226
+ const guarded = ensureParents(prev, segments);
227
+ // $n::jsonb — values are serialized to JSON by Prisma when
228
+ // passed as a parameter, then cast back into jsonb.
229
+ byColumn[col] = `jsonb_set(${guarded}, ${jsonbPath}, ${bind(JSON.stringify(value))}::jsonb, true)`;
230
+ };
231
+
232
+ const wrapPushSlice = (col, segments, spec) => {
233
+ const jsonbPath = `'{${segments.join(',')}}'`;
234
+ const prev = seed(col);
235
+ const guarded = ensureParents(prev, segments);
236
+ // Construct the sliced array in a CTE to evaluate `${newArr}`
237
+ // exactly ONCE (vs. the inline form that Postgres would still
238
+ // execute correctly but expand three times). Order is
239
+ // explicitly preserved by `jsonb_agg(... ORDER BY idx)`;
240
+ // without the ORDER BY, aggregate order is implementation-
241
+ // defined even with WITH ORDINALITY.
242
+ const sliced = `(
243
+ WITH combined AS (
244
+ SELECT COALESCE((${guarded} #> ${jsonbPath}), '[]'::jsonb) || ${bind(JSON.stringify(spec.values))}::jsonb AS arr
245
+ )
246
+ SELECT COALESCE(jsonb_agg(elem ORDER BY idx), '[]'::jsonb)
247
+ FROM combined,
248
+ jsonb_array_elements((SELECT arr FROM combined)) WITH ORDINALITY AS t(elem, idx)
249
+ WHERE idx > GREATEST(0, jsonb_array_length((SELECT arr FROM combined)) - ${bind(spec.keepLast)})
250
+ )`;
251
+ byColumn[col] = `jsonb_set(${guarded}, ${jsonbPath}, ${sliced}, true)`;
252
+ };
253
+
254
+ for (const [path, delta] of Object.entries(ops.increment)) {
255
+ const { column, segments } = splitPath(path);
256
+ wrapIncrement(column, segments, delta);
257
+ }
258
+ for (const [path, value] of Object.entries(ops.set)) {
259
+ const { column, segments } = splitPath(path);
260
+ wrapSet(column, segments, value);
261
+ }
262
+ for (const [path, spec] of Object.entries(ops.pushSlice)) {
263
+ const { column, segments } = splitPath(path);
264
+ wrapPushSlice(column, segments, spec);
265
+ }
266
+
267
+ const result = {};
268
+ for (const [col, expr] of Object.entries(byColumn)) {
269
+ if (expr !== null) result[col] = expr;
270
+ }
271
+ return result;
272
+ }
273
+
111
274
  /**
112
275
  * Find processes by integration and type
113
276
  * @param {string} integrationId - Integration ID
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Shared helpers for ProcessRepository.applyProcessUpdate() validation.
3
+ *
4
+ * These utilities are backend-agnostic: they enforce invariants on the
5
+ * `ProcessUpdateOps` shape BEFORE each adapter emits any SQL or database
6
+ * command. Keeping validation here means any bug we fix (e.g. tighter
7
+ * path regex, size cap) fixes all three adapters in one place.
8
+ *
9
+ * Imported by the Postgres, MongoDB, and DocumentDB adapters.
10
+ */
11
+
12
+ /**
13
+ * Allowed dot-path shape. Root must be `context` or `results`, and each
14
+ * segment after the first must be a JS-identifier-style token. Numeric
15
+ * segments (array indices) and bracket syntax are intentionally
16
+ * disallowed — array element mutation is exclusively handled via
17
+ * `pushSlice`, which targets a whole array at a path.
18
+ */
19
+ const PATH_REGEX = /^(context|results)(\.[a-zA-Z_][a-zA-Z0-9_]*)+$/;
20
+
21
+ /**
22
+ * Normalizes and validates a `ProcessUpdateOps` object. Returns a frozen
23
+ * copy with defaults applied and every key pre-validated. Throws synchronously
24
+ * on any shape error so adapters can fail fast before touching the DB.
25
+ *
26
+ * @param {Object} ops
27
+ * @returns {{
28
+ * increment: Record<string, number>,
29
+ * set: Record<string, unknown>,
30
+ * pushSlice: Record<string, { values: unknown[]; keepLast: number }>,
31
+ * newState: string|null,
32
+ * }}
33
+ */
34
+ function validateOps(ops) {
35
+ if (!ops || typeof ops !== 'object' || Array.isArray(ops)) {
36
+ throw new Error('applyProcessUpdate: ops must be an object');
37
+ }
38
+
39
+ const increment = ops.increment || {};
40
+ const set = ops.set || {};
41
+ const pushSlice = ops.pushSlice || {};
42
+ const newState = ops.newState ?? null;
43
+
44
+ for (const [path, delta] of Object.entries(increment)) {
45
+ assertPath(path, 'increment');
46
+ if (typeof delta !== 'number' || !Number.isFinite(delta)) {
47
+ throw new Error(
48
+ `applyProcessUpdate: increment['${path}'] must be a finite number, got ${typeof delta}`
49
+ );
50
+ }
51
+ }
52
+
53
+ for (const path of Object.keys(set)) {
54
+ assertPath(path, 'set');
55
+ }
56
+
57
+ for (const [path, spec] of Object.entries(pushSlice)) {
58
+ assertPath(path, 'pushSlice');
59
+ if (
60
+ !spec ||
61
+ typeof spec !== 'object' ||
62
+ !Array.isArray(spec.values) ||
63
+ typeof spec.keepLast !== 'number' ||
64
+ !Number.isInteger(spec.keepLast) ||
65
+ spec.keepLast <= 0
66
+ ) {
67
+ throw new Error(
68
+ `applyProcessUpdate: pushSlice['${path}'] must be { values: [], keepLast: positive integer }`
69
+ );
70
+ }
71
+ }
72
+
73
+ if (newState !== null && typeof newState !== 'string') {
74
+ throw new Error('applyProcessUpdate: newState must be a string');
75
+ }
76
+
77
+ const hasAnyOp =
78
+ Object.keys(increment).length > 0 ||
79
+ Object.keys(set).length > 0 ||
80
+ Object.keys(pushSlice).length > 0 ||
81
+ newState !== null;
82
+ if (!hasAnyOp) {
83
+ throw new Error(
84
+ 'applyProcessUpdate: at least one of increment/set/pushSlice/newState must be provided'
85
+ );
86
+ }
87
+
88
+ return Object.freeze({ increment, set, pushSlice, newState });
89
+ }
90
+
91
+ function assertPath(path, opName) {
92
+ if (!PATH_REGEX.test(path)) {
93
+ throw new Error(
94
+ `applyProcessUpdate: invalid path '${path}' in ${opName} (must match ${PATH_REGEX})`
95
+ );
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Splits a validated path into `{ column, segments }`.
101
+ * `'context.pagination.pageCount'` → `{ column: 'context', segments: ['pagination', 'pageCount'] }`.
102
+ */
103
+ function splitPath(path) {
104
+ const [column, ...segments] = path.split('.');
105
+ return { column, segments };
106
+ }
107
+
108
+ module.exports = {
109
+ PATH_REGEX,
110
+ validateOps,
111
+ splitPath,
112
+ };