Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
de32a1d
feat(#272): implement Azure Functions queue trigger handler registration
Jun 30, 2026
420cfde
fix: update brace-expansion and fast-uri versions in workspace overri…
Jun 30, 2026
655c0f1
fix(#289): address Sourcery code review feedback
Jun 30, 2026
cbdae54
Merge branch 'main' into feat/issue-272-queue-trigger-handler
Jul 1, 2026
dbcdeed
fix: update Node.js version to 22.22.2 in deploy-docs.yml
Jul 1, 2026
b6247df
fix(#289): update handler test mock to throw CommunityNotFoundError
Jul 1, 2026
7675ae1
fix(#289): add communityUpdateQueueName to service-queue-storage vi.mock
Jul 1, 2026
92c9610
fix(#289): disable pnpm verify-deps-before-run to prevent concurrent …
Jul 1, 2026
14758a2
fix(#289): use communityUpdateQueueName constant in log message prefixes
Jul 2, 2026
fe27f97
fix(#289): pass original error object to context.error for better dia…
Jul 2, 2026
ccf4982
feat: enhance system-scoped service handling with least privilege per…
Jul 7, 2026
d98a1df
feat: enhance community update handling with nested payload support a…
Jul 7, 2026
d330b84
Merge branch 'main' into feat/issue-272-queue-trigger-handler
Jul 7, 2026
ea8c755
fix: removed export for PayloadFieldPath type in interfaces.ts
Jul 7, 2026
13e50f3
feat: enhance error messages in queue handler for better diagnostics
Jul 7, 2026
61f669b
feat: add @ocom/domain dependency and enhance Cellix interfaces for i…
Jul 8, 2026
ee3f97e
Merge branch 'main' into feat/issue-272-queue-trigger-handler
Jul 8, 2026
ed769a4
feat: implement extractQueueTriggerMetadata function and update handl…
Jul 8, 2026
19a43d6
Merge branch 'main' into feat/issue-272-queue-trigger-handler
Jul 9, 2026
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
4 changes: 4 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Disable pnpm 11's auto-install on script runs. CI explicitly runs
# `pnpm install --frozen-lockfile` before turbo tasks; concurrent auto-installs
# from parallel turbo workers kill each other with SIGINT.
verify-deps-before-run=false
72 changes: 58 additions & 14 deletions apps/api/.github/instructions/api.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,27 @@ The `@ocom/api` package is the main Azure Functions application entry point that

### Cellix Framework
- **Central Class**: `Cellix<ContextType>` - The main orchestration class implementing service registry and Azure Functions integration
- **Service Registry Pattern**: All services must be registered via `registerService()` before context creation
- **Service Registry Pattern**: All services must be registered via `registerInfrastructureService()` before context creation
- **Context Creation**: Use `setContext()` to build the application context after service registration
- **Handler Registration**: Use `registerAzureFunctionHandler()` to register Azure Functions endpoints
- **HTTP Handler Registration**: Use `registerAzureFunctionHttpHandler()` to register Azure Functions HTTP endpoints
- **Queue Handler Registration**: Use `registerAzureFunctionQueueHandler()` to register Azure Functions Storage Queue trigger endpoints

### Initialization Flow
```typescript
Cellix.initializeServices<ApiContextSpec>((serviceRegistry) => {
// Register all services here
serviceRegistry.registerService(new ServiceExample(...));
Cellix.initializeInfrastructureServices<ApiContextSpec, ApplicationServices>((serviceRegistry) => {
// Register all infrastructure services here
serviceRegistry.registerInfrastructureService(new ServiceExample(...));
})
.setContext((serviceRegistry) => ({
// Build context from registered services
domainDataSource: contextBuilder(serviceRegistry.getService(ServiceExample))
domainDataSource: contextBuilder(serviceRegistry.getInfrastructureService(ServiceExample))
}))
.then((cellix) => {
// Register Azure Functions handlers
cellix.registerAzureFunctionHandler('name', { route: 'path' }, handlerCreator);
});
.initializeApplicationServices((context) => buildApplicationServicesFactory(context))
// Register Azure Functions HTTP handlers
.registerAzureFunctionHttpHandler('name', { route: 'path' }, handlerCreator)
// Register Azure Functions Storage Queue trigger handlers
.registerAzureFunctionQueueHandler('queue-name', { queueName: 'queue-name', connection: 'AzureWebJobsStorage' }, queueHandlerCreator)
.startUp();
```

## File Structure
Expand Down Expand Up @@ -56,23 +59,64 @@ Cellix.initializeServices<ApiContextSpec>((serviceRegistry) => {
- Use different configs for development vs production

### Azure Functions Integration
- Handler creators must accept context and return `HttpHandler`
- HTTP handler creators must accept `(appServicesHost, infrastructureRegistry)` and return `HttpHandler`
- Queue handler creators must accept `(appServicesHost, infrastructureRegistry)` and return `StorageQueueHandler<T>`
- Use descriptive names for function registration
- Configure routes in handler registration, not in individual handlers
- Configure routes/queue names in handler registration, not in individual handlers

#### HTTP Handler Example
```typescript
cellix.registerAzureFunctionHttpHandler(
'graphql',
{ route: 'graphql/{*segments}', methods: ['GET', 'POST'] },
(host, infra) => async (req, ctx) => {
const app = await host.forRequest(req.headers.get('authorization') ?? undefined);
return app.GraphQL.handle(req, ctx);
}
);
```

#### Queue Handler Example
```typescript
cellix.registerAzureFunctionQueueHandler<MyQueuePayload>(
'my-queue',
{ queueName: 'my-queue', connection: 'AzureWebJobsStorage' },
(host, infra) => async (queueEntry, context) => {
const queueService = infra.getInfrastructureService(ServiceQueueStorage);
// Map Azure Functions trigger metadata to QueueTriggerMetadata
const metadata = {
id: (context.triggerMetadata?.['id'] as string) ?? '',
popReceipt: context.triggerMetadata?.['popReceipt'] as string | undefined,
dequeueCount: context.triggerMetadata?.['dequeueCount'] as number | undefined,
};
// Validate and decode via the registered queue service
const message = await queueService.receiveFromMyQueue(queueEntry, metadata);
// Queue triggers have no request/auth context, so use forSystem() rather than forRequest().
// Pass only the specific permissions this operation needs (least privilege).
const appServices = await host.forSystem({ canManageMyResource: true });
// Process message.payload ...
}
);
```

### Error Handling
- Let Cellix handle service startup/shutdown errors with OpenTelemetry tracing
- Use proper error boundaries in individual handlers
- Log errors with context using the built-in tracer
- For queue handlers: catch expected errors (e.g., entity not found) and log them — do **not** rethrow, as requeuing would cause poison-message loops
- Log errors with context using `context.error(...)` provided by the Azure Functions runtime

## Key Dependencies
- `@azure/functions` - Azure Functions v4 runtime
- `@azure/identity` - Azure authentication
- OpenTelemetry integration via `@ocom/service-otel`
- Service interfaces from `@cellix/api-services-spec`
- Queue definitions and service from `@ocom/service-queue-storage`

## Development Notes
- OpenTelemetry starts automatically on module load
- Services have async `startUp()` and `shutDown()` lifecycle methods
- Context is immutable once set
- All services must extend `ServiceBase` interface
- All services must extend `ServiceBase` interface
- Queue handlers are registered via `app.storageQueue()` from `@azure/functions` under the hood
- The `connection` field in queue handler options must reference an app setting name (environment variable), not the connection string value itself
- For local development, `AzureWebJobsStorage` maps to the Azurite connection string via `local.settings.json`
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
"@cellix/mongoose-seedwork": "workspace:*",
"@ocom/application-services": "workspace:*",
"@ocom/context-spec": "workspace:*",
"@ocom/domain": "workspace:*",
"@ocom/event-handler": "workspace:*",
"@ocom/graphql": "workspace:*",
"@ocom/graphql-handler": "workspace:*",
"@ocom/handler-queue-community-update": "workspace:*",
"@ocom/persistence": "workspace:*",
"@ocom/rest": "workspace:*",
"@ocom/service-apollo-server": "workspace:*",
Expand Down
109 changes: 97 additions & 12 deletions apps/api/src/cellix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const test = { for: describeFeature };
vi.mock('@azure/functions', () => ({
app: {
http: vi.fn(),
storageQueue: vi.fn(),
hook: {
appStart: vi.fn(),
appTerminate: vi.fn(),
Expand Down Expand Up @@ -216,7 +217,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
registry.registerInfrastructureService(mockService, 'alias-service');
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
});

Expand Down Expand Up @@ -293,7 +294,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
});

When('application services factory is initialized', () => {
const result = cellixWithContext.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
const result = cellixWithContext.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
expect(result.registerAzureFunctionHttpHandler).toBeDefined();
});

Expand Down Expand Up @@ -323,7 +324,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {

When('initializeApplicationServices is called', () => {
expect(() => {
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
}).toThrow('Context creator must be set before initializing application services');
});

Expand All @@ -338,7 +339,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
/* no op */
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
});

When('an Azure Function HTTP handler is registered', () => {
Expand Down Expand Up @@ -387,7 +388,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
registry.registerInfrastructureService(mockService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
});

Expand Down Expand Up @@ -433,7 +434,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
/* no op */
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
// Simulate missing context creator
Object.defineProperty(cellix, 'contextCreatorInternal', { value: null });
Expand All @@ -456,7 +457,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
registry.registerInfrastructureService(mockService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
await cellix.startUp();
});
Expand All @@ -476,7 +477,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
/* no op */
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
await cellix.startUp();
});
Expand Down Expand Up @@ -514,7 +515,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
registry.registerInfrastructureService(mockService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
});

Expand Down Expand Up @@ -553,7 +554,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
registry.registerInfrastructureService(mockService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
await cellix.startUp();
});
Expand All @@ -580,7 +581,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
registry.registerInfrastructureService(failingService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
});

Expand Down Expand Up @@ -613,7 +614,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
registry.registerInfrastructureService(failingService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
await cellix.startUp();
});
Expand All @@ -637,4 +638,88 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
// The span is mocked, so we can't easily verify this without more complex mocking
});
});

Scenario('Registering an Azure Function Queue handler', ({ Given, When, Then, And }) => {
Given('a Cellix instance in app-services phase', () => {
cellix = Cellix.initializeInfrastructureServices(() => {
/* no op */
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
});

When('an Azure Function Queue handler is registered', () => {
const result = cellix.registerAzureFunctionQueueHandler('test-queue-handler', { queueName: 'test-queue', connection: 'AzureWebJobsStorage' }, () => vi.fn());
expect(result).toBe(cellix);
});

Then('it should store the queue handler configuration', () => {
// We can't directly test private state, but we can test that startup registers handlers
expect(app.storageQueue).not.toHaveBeenCalled(); // Not called until startup
});

And('it should transition to handlers phase', () => {
// Phase transition is internal, but we can test that startup works
expect(cellix.startUp).toBeDefined();
});

And('it should return the registry for chaining', () => {
// Already verified in When step
});
});

Scenario('Registering queue handler in wrong phase', ({ Given, When, Then }) => {
Given('a Cellix instance in infrastructure phase', () => {
cellix = Cellix.initializeInfrastructureServices(() => {
/* no op */
}) as Cellix<unknown, unknown>;
});

When('registerAzureFunctionQueueHandler is called', () => {
expect(() => {
cellix.registerAzureFunctionQueueHandler('test-queue-handler', { queueName: 'test-queue', connection: 'AzureWebJobsStorage' }, () => vi.fn());
}).toThrow("Invalid operation in phase 'infrastructure'. Allowed phases: app-services, handlers");
});

Then('it should throw an error for invalid phase', () => {
// Error is already thrown in When step
});
});

Scenario('Starting up the application with queue handlers', ({ Given, When, Then, And }) => {
Given('a Cellix instance with both HTTP and queue handlers configured', () => {
cellix = Cellix.initializeInfrastructureServices((registry) => {
registry.registerInfrastructureService(mockService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-http-handler', { authLevel: 'anonymous' }, () => vi.fn());
cellix.registerAzureFunctionQueueHandler('test-queue-handler', { queueName: 'test-queue', connection: 'AzureWebJobsStorage' }, () => vi.fn());
});

When('startUp is called with queue handlers', async () => {
await cellix.startUp();
});

Then('it should register queue functions with app.storageQueue', () => {
expect(app.storageQueue).toHaveBeenCalledWith(
'test-queue-handler',
expect.objectContaining({
queueName: 'test-queue',
connection: 'AzureWebJobsStorage',
handler: expect.any(Function),
}),
);
});

And('it should register HTTP functions with app.http', () => {
expect(app.http).toHaveBeenCalledWith(
'test-http-handler',
expect.objectContaining({
authLevel: 'anonymous',
handler: expect.any(Function),
}),
);
});
});
});
Loading
Loading