Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ playwright-report/
# Vendor Reference Code
activepieces-reference/
n8n-reference/

# TypeScript Build Info
*.tsbuildinfo
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@nexiom/cache": "workspace:*",
"@nexiom/connections": "workspace:*",
"@nexiom/database": "workspace:*",
"@nexiom/dbmanager": "workspace:*",
"@nexiom/identity": "workspace:*",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.10",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { TriggerModule } from '../modules/trigger/trigger.module';
import { EmailModule } from '../modules/email/email.module';
import { StorageResolverModule } from '../modules/storage-resolver/storage-resolver.module';
import { CacheModule } from '@nexiom/cache';
import { DbManagerModule } from '../modules/dbmanager/dbmanager.module';

@Module({
imports: [
Expand Down Expand Up @@ -82,6 +83,7 @@ import { CacheModule } from '@nexiom/cache';
ConnectionsModule,
TriggerModule,
StorageResolverModule,
DbManagerModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
73 changes: 0 additions & 73 deletions apps/api/src/db/create-inbound-gateway.ts

This file was deleted.

28 changes: 24 additions & 4 deletions apps/api/src/modules/connections/connectors.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
EncryptionService,
AppCredentialError,
} from '@nexiom/connections';
import { DB_MANAGER } from '../dbmanager/dbmanager.module';
import {
InternalServerErrorException,
NotFoundException,
Expand Down Expand Up @@ -85,6 +86,7 @@ describe('ConnectorsService', () => {
{ provide: ConfigService, useValue: mockConfigService },
{ provide: EncryptionService, useValue: mockEncryptionService },
{ provide: DATABASE_CONNECTION, useValue: mockDb as unknown },
{ provide: DB_MANAGER, useValue: { applyPlan: vi.fn() } },
],
}).compile();

Expand Down Expand Up @@ -561,7 +563,7 @@ describe('ConnectorsService', () => {
).rejects.toThrow(InternalServerErrorException);
});

it('should throw InternalServerErrorException and abort if schema creation execute() fails', async () => {
it('should throw InternalServerErrorException and abort if DB manager applyPlan fails', async () => {
// Mock appConnection insert succeeding:
mockDbInsert.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
Expand All @@ -582,11 +584,29 @@ describe('ConnectorsService', () => {
}),
});

// Mock the raw schema execute command failing:
mockDb.execute.mockRejectedValueOnce(new Error('schema create failed'));
// We need to re-mock the DB_MANAGER for this specific test
const failingDbManager = {
applyPlan: vi.fn().mockRejectedValue(new Error('applyPlan failed')),
};
const moduleFail: TestingModule = await Test.createTestingModule({
providers: [
ConnectorsService,
{ provide: ProviderRegistryService, useValue: mockProviderRegistry },
{
provide: ConfigService,
useValue: { get: vi.fn().mockReturnValue('mock-region-context') },
},
{ provide: EncryptionService, useValue: mockEncryptionService },
{ provide: DATABASE_CONNECTION, useValue: mockDb as unknown },
{ provide: DB_MANAGER, useValue: failingDbManager },
],
}).compile();
Comment on lines +591 to +603

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test will fail due to missing regionContext mock.

The ConfigService mock at line 595 returns undefined for all get() calls, causing the regionContext validation (lines 290-298 in the service) to throw InternalServerErrorException before applyPlan is ever called. This means the test isn't actually exercising the applyPlan failure path.

🐛 Proposed fix
         { provide: ProviderRegistryService, useValue: mockProviderRegistry },
-        { provide: ConfigService, useValue: { get: vi.fn() } },
+        { provide: ConfigService, useValue: { get: vi.fn().mockReturnValue('us-east-1') } },
         { provide: EncryptionService, useValue: mockEncryptionService },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const moduleFail: TestingModule = await Test.createTestingModule({
providers: [
ConnectorsService,
{ provide: ProviderRegistryService, useValue: mockProviderRegistry },
{ provide: ConfigService, useValue: { get: vi.fn() } },
{ provide: EncryptionService, useValue: mockEncryptionService },
{ provide: DATABASE_CONNECTION, useValue: mockDb as unknown },
{ provide: DB_MANAGER, useValue: failingDbManager },
],
}).compile();
const moduleFail: TestingModule = await Test.createTestingModule({
providers: [
ConnectorsService,
{ provide: ProviderRegistryService, useValue: mockProviderRegistry },
{ provide: ConfigService, useValue: { get: vi.fn().mockReturnValue('us-east-1') } },
{ provide: EncryptionService, useValue: mockEncryptionService },
{ provide: DATABASE_CONNECTION, useValue: mockDb as unknown },
{ provide: DB_MANAGER, useValue: failingDbManager },
],
}).compile();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/connections/connectors.service.spec.ts` around lines 591
- 600, The test's ConfigService mock returns undefined for get(), causing
regionContext validation in ConnectorsService to throw before applyPlan runs;
update the TestingModule providers to supply a ConfigService mock whose get()
returns a valid regionContext object (matching the shape checked by the service)
so regionContext validation (the code around regionContext in ConnectorsService)
passes and the test can exercise applyPlan and the failingDbManager path; keep
the rest of the mocks (mockProviderRegistry, mockEncryptionService,
DATABASE_CONNECTION, DB_MANAGER) unchanged.


const failingService =
moduleFail.get<ConnectorsService>(ConnectorsService);

await expect(
service.storeOAuthConnection({
failingService.storeOAuthConnection({
tenantId: 'tenant-123',
providerName: 'salesforce',
externalId: 'salesforce-tms',
Expand Down
27 changes: 16 additions & 11 deletions apps/api/src/modules/connections/connectors.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ import {
DATABASE_CONNECTION,
type DrizzleDb,
} from '@nexiom/database';
import { sql } from 'drizzle-orm';
import * as crypto from 'crypto';
import { DatabaseManager, SchemaPlan } from '@nexiom/dbmanager';
import { DB_MANAGER } from '../dbmanager/dbmanager.module';
import * as crypto from 'node:crypto';

/** Encrypted value blob stored in app_connection.value — mirrors Activepieces BaseOAuth2ConnectionValue */
export interface ConnectionValueBlob {
Expand Down Expand Up @@ -55,6 +56,7 @@ export class ConnectorsService {

constructor(
@Inject(DATABASE_CONNECTION) private readonly db: DrizzleDb,
@Inject(DB_MANAGER) private readonly dbManager: DatabaseManager,
private readonly providerRegistry: ProviderRegistryService,
private readonly configService: ConfigService,
) {}
Expand Down Expand Up @@ -297,7 +299,7 @@ export class ConnectorsService {
}

try {
await this.db.transaction(async (tx) => {
const workspaceSchemaName = await this.db.transaction(async (tx) => {
// 1. Insert or update the business connection metadata
const [connection] = await tx
.insert(appConnections)
Expand Down Expand Up @@ -340,27 +342,30 @@ export class ConnectorsService {
const sanitizedProvider = providerName.replaceAll(/[^a-z0-9]/g, '');
const finalProviderToken = sanitizedProvider || 'unknown';
const safeToken = finalProviderToken.substring(0, 40);
const workspaceSchemaName = `ws_${safeToken}_${hashedSuffix}`;
const schemaName = `ws_${safeToken}_${hashedSuffix}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Verify schema name length constraints.

The generated schemaName pattern ws_${safeToken}_${hashedSuffix} could be up to 3 + 40 + 1 + 16 = 60 characters. PostgreSQL limits identifier names to 63 bytes by default, so this is safe, but worth confirming the constraint is intentional.

📝 Consider adding a constant for documentation
+        // Max schema name: ws_ (3) + provider (40) + _ (1) + hash (16) = 60 chars (< PG 63-byte limit)
         const schemaName = `ws_${safeToken}_${hashedSuffix}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const schemaName = `ws_${safeToken}_${hashedSuffix}`;
// Max schema name: ws_ (3) + provider (40) + _ (1) + hash (16) = 60 chars (< PG 63-byte limit)
const schemaName = `ws_${safeToken}_${hashedSuffix}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/connections/connectors.service.ts` at line 345, The
schemaName construction using `schemaName = \`ws_${safeToken}_${hashedSuffix}\``
may approach PostgreSQL's 63-byte identifier limit (max ~60 here) so add an
explicit constraint/check: define a constant like MAX_PG_IDENTIFIER_LENGTH = 63
(and a derived MAX_SCHEMA_NAME_LEN) and ensure `safeToken` and/or `hashedSuffix`
are truncated or validated before composing `schemaName` (or throw a clear
error); update the code paths that set `safeToken`/`hashedSuffix` (references:
`schemaName`, `safeToken`, `hashedSuffix` in connectors.service) and add a short
comment documenting the length rationale.


await tx
.insert(connectionStorageRegistry)
.values({
connectionId: connection.id,
workspaceId: workspaceSchemaName,
workspaceId: schemaName,
databaseHostId: 'primary-cluster', // Can be parameterized later for regional sharding
regionContext: finalRegionContext,
})
.onConflictDoNothing({
target: connectionStorageRegistry.connectionId,
}); // Already provisioned

// 3. Actually create the physical PostgreSQL schema on the cluster
// Using sql.raw here is safe because workspaceSchemaName is strictly internally generated
// from a regex-sanitized string and a UUID slice, not user input.
await tx.execute(
sql`CREATE SCHEMA IF NOT EXISTS "${sql.raw(workspaceSchemaName)}"`,
);
return schemaName;
});

// 3. Apply the initial schema plan (NAMESPACE_ONLY) outside the transaction
// so the DDL runs on its own connection and does not silently escape the
// Drizzle tx scope (db.$client vs the transaction's dedicated connection).
await this.dbManager.applyPlan(
workspaceSchemaName,
SchemaPlan.NAMESPACE_ONLY,
);
Comment on lines +362 to +368

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the structure of the connectors.service.ts file
fd connectors.service.ts --type f

Repository: pramodnarayana/nexiom

Length of output: 119


🏁 Script executed:

# Search for the storeOAuthConnection function and surrounding context
rg -n "storeOAuthConnection" --type=ts -A 70 | head -100

Repository: pramodnarayana/nexiom

Length of output: 9517


🏁 Script executed:

# Search for applyPlan function definition
rg -n "applyPlan\s*\(" --type=ts -B 2 -A 10

Repository: pramodnarayana/nexiom

Length of output: 4011


🏁 Script executed:

# Search for PENDING_PROVISIONING or similar provisioning statuses
rg -n "PENDING_PROVISIONING|ProvisioningStatus|ConnectionStatus" --type=ts

Repository: pramodnarayana/nexiom

Length of output: 1328


🏁 Script executed:

# Check for retry mechanisms and error handling patterns
rg -n "retry|Retry|RETRY" --type=ts -A 3 | head -80

Repository: pramodnarayana/nexiom

Length of output: 6145


🏁 Script executed:

# Search for connectionStorageRegistry definition and usage
rg -n "connectionStorageRegistry" --type=ts -B 2 -A 5 | head -100

Repository: pramodnarayana/nexiom

Length of output: 9413


🏁 Script executed:

# Get the full storeOAuthConnection function to see complete error handling
sed -n '278,385p' apps/api/src/modules/connections/connectors.service.ts

Repository: pramodnarayana/nexiom

Length of output: 3523


🏁 Script executed:

# Check the full AppConnectionStatus enum definition
cat packages/database/src/schema/tenant.ts | head -30

Repository: pramodnarayana/nexiom

Length of output: 1323


🏁 Script executed:

# Search for background jobs or reconciliation mechanisms
rg -n "reconcil|background|cron|job|cleanup" --type=ts -i | grep -E "(reconcil|background|cron|cleanup)" | head -40

Repository: pramodnarayana/nexiom

Length of output: 4646


🏁 Script executed:

# Check if there's any mechanism to handle orphaned connections
rg -n "orphan|dangling|incomplete|unprovisioned" --type=ts -i

Repository: pramodnarayana/nexiom

Length of output: 1251


🏁 Script executed:

# Look for error recovery or compensating transaction patterns
rg -n "compensat|rollback|cleanup|recover" --type=ts -i | head -30

Repository: pramodnarayana/nexiom

Length of output: 2937


Fix partial failure vulnerability: applyPlan failure after transaction commits orphans metadata.

If applyPlan fails after the transaction commits, appConnections will be persisted with ACTIVE status while the schema remains unprovisioned. The system exposes this inconsistency with no recovery mechanism.

Implement one of:

  1. Compensating transaction: Delete the connectionStorageRegistry row on applyPlan failure
  2. Retry with backoff: Retry applyPlan before throwing (it is idempotent via CREATE SCHEMA IF NOT EXISTS)
  3. Provisioning status: Add a PENDING_SCHEMA status to AppConnectionStatus and mark the connection during provisioning, with a background reconciler to clean up stale entries

} catch (error) {
if (error instanceof HttpException) {
throw error;
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/modules/dbmanager/dbmanager.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Module, Global } from '@nestjs/common';
import { SqlDatabaseManager } from '@nexiom/dbmanager';
import { DbModule } from '../../db/db.module';
import { DATABASE_CONNECTION } from '@nexiom/database';
import type { DrizzleDb } from '@nexiom/database';

export const DB_MANAGER = 'DATABASE_MANAGER';

@Global()
@Module({
imports: [DbModule],
providers: [
{
provide: DB_MANAGER,
useFactory: (drizzleDb: DrizzleDb) => {
return new SqlDatabaseManager(drizzleDb);
},
inject: [DATABASE_CONNECTION],
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
],
exports: [DB_MANAGER],
})
export class DbManagerModule {}
3 changes: 3 additions & 0 deletions apps/api/src/modules/trigger/trigger-executor.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ describe('TriggerExecutorService', () => {
service = new TriggerExecutorService(
db as unknown as import('@nexiom/database').DrizzleDb,
redis as unknown as import('ioredis').Redis,
{
applyPlan: vi.fn(),
} as unknown as import('@nexiom/dbmanager').DatabaseManager,
);
});

Expand Down
12 changes: 11 additions & 1 deletion apps/api/src/modules/trigger/trigger-executor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Injectable, Inject, Logger } from '@nestjs/common';
import type { Trigger, TriggerContext } from '@nexiom/connections';
import type { DrizzleDb } from '@nexiom/database';
import { DATABASE_CONNECTION } from '@nexiom/database';
import { DatabaseManager, SchemaPlan } from '@nexiom/dbmanager';
import { DB_MANAGER } from '../dbmanager/dbmanager.module';
import type { Redis } from 'ioredis';
import { createHash, randomUUID } from 'node:crypto';
import { RedisBackedTriggerStore } from './redis-trigger-store';
Expand Down Expand Up @@ -59,6 +61,7 @@ export class TriggerExecutorService {
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: DrizzleDb,
@Inject('REDIS_CLIENT') private readonly redis: Redis,
@Inject(DB_MANAGER) private readonly dbManager: DatabaseManager,
) {}

// ─── Polling ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -138,6 +141,13 @@ export class TriggerExecutorService {
async runOnEnable(params: TriggerRunParams): Promise<void> {
const context = this.buildContext(params);
try {
// 1. Ensure the gateway tables are provisioned lazily
await this.dbManager.applyPlan(
params.workspaceId,
SchemaPlan.GATEWAY_ACTIVE,
);

// 2. Invoke the trigger enablement logic (e.g. Subscribe to webhook)
await params.trigger.onEnable?.(context);
this.logger.log('onEnable completed', {
appName: params.appName,
Expand Down Expand Up @@ -428,7 +438,7 @@ export class TriggerExecutorService {
) {
const sorted = Object.keys(record as Record<string, unknown>)
.filter((k) => !VOLATILE_KEYS.has(k))
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
.sort((a, b) => (a ?? '').localeCompare(b ?? ''))
.reduce<Record<string, unknown>>((acc, k) => {
acc[k] = (record as Record<string, unknown>)[k];
return acc;
Expand Down
8 changes: 5 additions & 3 deletions apps/api/src/modules/trigger/trigger.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { DlqProcessorService } from './dlq-processor.service';
import { WebhooksController } from './webhooks.controller';
import { DATABASE_CONNECTION } from '@nexiom/database';
import type { DrizzleDb } from '@nexiom/database';
import { DB_MANAGER } from '../dbmanager/dbmanager.module';
import { DatabaseManager } from '@nexiom/dbmanager';

/**
* Wires all trigger-related services.
Expand All @@ -23,9 +25,9 @@ import type { DrizzleDb } from '@nexiom/database';
PieceRegistryService,
{
provide: TriggerExecutorService,
useFactory: (db: DrizzleDb, redis: Redis) =>
new TriggerExecutorService(db, redis),
inject: [DATABASE_CONNECTION, REDIS_CLIENT],
useFactory: (db: DrizzleDb, redis: Redis, dbManager: DatabaseManager) =>
new TriggerExecutorService(db, redis, dbManager),
inject: [DATABASE_CONNECTION, REDIS_CLIENT, DB_MANAGER],
},
{
provide: PollerService,
Expand Down
59 changes: 59 additions & 0 deletions docs/architecture/schema_lifecycle_and_provisioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Architecture: Schema Lifecycle & Lazy Provisioning

This document defines the timing and triggers for database schema creation within the FluxNex 6-layer pipeline. To ensure maximum scalability, we follow a Lazy Provisioning strategy—nothing is created until there is active intent from the user.

1. Provisioning Lifecycle Summary
| Lifecycle Event | Physical Action | Component Responsible | Status |
| --------------- | --------------- | ----------------------- | ------ |
| App Authentication | Create Schema Namespace (ws_{id}) | api-gateway / dbmanager | Empty Silo |
| Route Activation | Create Kernel Tables (Ingest/Logs) | dbmanager (Atlas) | Active Silo |
| Object Selection | Initialize Unified Replica (replica_entity) | dbmanager (Atlas) | Data Ready |

2. Event-Driven Provisioning Details
Stage 1: Connection Handshake (The "Namespace" Event)
Trigger: User successfully completes an OAuth or API Key handshake.
Database Action: CREATE SCHEMA IF NOT EXISTS "ws_sf_101".
Registry Action: Insert row into public.connection_storage_registry.
Result: A physical "Wall" is established. The schema is empty, incurring zero index or metadata overhead in the Postgres catalog.
Stage 2: Route Activation (The "Kernel" Event)
Trigger: User creates a Route (e.g., Salesforce → QuickBooks) and clicks "Save & Activate."
Database Action: The dbmanager uses Atlas to deploy the Pipeline Kernel tables into the connection's schema.
Tables Created: * `inbound_gateway`: Stores raw ingestion data.
*(Note: `sync_log`, `sync_cursor`, and `outbound_gateway` are planned additions for future Gateway integration).*
Stage 3: Object Mapping (The "Storage Initialization" Event)
Trigger: User selects a specific object (e.g., "Accounts") in the Mapping UI.
Database Action: The system ensures the structured state store is ready. It executes the SQL to create:
replica_entity: The unified JSONB state store for ALL objects in this connection.
GIN Indexes: Applied to the data column to enable high-speed search across all fields and custom objects.
Optimization: This table is created once per connection and handles all object types (Account, Contact, etc.) through the entity_type column.

3. The 6-Layer Readiness Matrix
This matrix shows which physical tables must be "ready" before a specific layer worker can process a job.
| Layer | Dependency | Physical Location | Why? |
| ----- | ---------- | ----------------- | ---- |
| L1: Source Gateway | inbound_gateway | ws_source | To log the "evidence" of the raw webhook. |
| L2: Universal Replica | replica_entity | ws_source | To store the parsed source-of-truth. |
| L3: Normalization | normalized_entity | ws_source | To store the platform-standard JSON. |
| L4: Outbound Prep | field_mapping | public | To read the rules defined by the user. |
| L5: Delivery Engine | N/A | N/A | Pure I/O. Does not write to tenant schemas. |
| L6: Dest. Gateway | outbound_gateway | ws_dest | To log the destination API response. |

4. Engineering Implementation: The "Ensure" Pattern
Because workers are asynchronous, a webhook (L1) might arrive before the dbmanager has finished creating the tables. To prevent crashes, all workers follow the Ensure Pattern:
// packages/engine/src/workers/base.worker.ts
async function processLayerJob(connectionId: string, payload: any) {
// 1. Resolve Schema Name
const schema = await storageResolver.resolve(connectionId);

// 2. JIT Check (The "Ensure" step)
// If the tables are missing, the worker triggers a fast schema plan apply
await dbmanager.applyPlan(schema, SchemaPlan.GATEWAY_ACTIVE);

// 3. Perform Business Logic
await db.withSchema(schema).insert(...);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

1. Benefits of this Timing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix section numbering in the heading.

Line 56 resets numbering to 1. after section 4. This should continue the sequence (likely 5.) to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/architecture/schema_lifecycle_and_provisioning.md` at line 56, The "1.
Benefits of this Timing" heading restarts numbering; update the ordered list
item label to continue the sequence (change "1." to "5.") so the section follows
the previous "4." heading; look for the heading text "Benefits of this Timing"
in the document and adjust its numeric prefix accordingly.

Catalog Leanliness: We avoid the "Postgres Metadata Bloat" that occurs when thousands of unused tables exist in the system.
Speed: App connection is instantaneous because it only creates a namespace.
Isolation: The architecture guarantees that no worker ever attempts to write to a "Shared" schema; it is always scoped to the connection-specific workspace.
Loading