diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..fc360f8f1 --- /dev/null +++ b/.npmrc @@ -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 diff --git a/apps/api/.github/instructions/api.instructions.md b/apps/api/.github/instructions/api.instructions.md index 4c77747bc..519ac01ad 100644 --- a/apps/api/.github/instructions/api.instructions.md +++ b/apps/api/.github/instructions/api.instructions.md @@ -11,24 +11,27 @@ The `@ocom/api` package is the main Azure Functions application entry point that ### Cellix Framework - **Central Class**: `Cellix` - 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((serviceRegistry) => { - // Register all services here - serviceRegistry.registerService(new ServiceExample(...)); +Cellix.initializeInfrastructureServices((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 @@ -56,23 +59,64 @@ Cellix.initializeServices((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` - 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( + '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 \ No newline at end of file +- 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` \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 9702bbf82..6390d6100 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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:*", diff --git a/apps/api/src/cellix.test.ts b/apps/api/src/cellix.test.ts index efbe93f40..0f84d0e27 100644 --- a/apps/api/src/cellix.test.ts +++ b/apps/api/src/cellix.test.ts @@ -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(), @@ -216,7 +217,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService, 'alias-service'); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); }); @@ -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(); }); @@ -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'); }); @@ -338,7 +339,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { /* no op */ }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); }); When('an Azure Function HTTP handler is registered', () => { @@ -387,7 +388,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); }); @@ -433,7 +434,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { /* no op */ }) as Cellix; 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 }); @@ -456,7 +457,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; 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(); }); @@ -476,7 +477,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { /* no op */ }) as Cellix; 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(); }); @@ -514,7 +515,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); }); @@ -553,7 +554,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(mockService); }) as Cellix; 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(); }); @@ -580,7 +581,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(failingService); }) as Cellix; cellix.setContext(() => ({})); - cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() })); + cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() })); cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn()); }); @@ -613,7 +614,7 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => { registry.registerInfrastructureService(failingService); }) as Cellix; 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(); }); @@ -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; + 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; + }); + + 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; + 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), + }), + ); + }); + }); }); diff --git a/apps/api/src/cellix.ts b/apps/api/src/cellix.ts index 570a89ed4..cbd0d5794 100644 --- a/apps/api/src/cellix.ts +++ b/apps/api/src/cellix.ts @@ -1,4 +1,4 @@ -import { app, type HttpFunctionOptions, type HttpHandler } from '@azure/functions'; +import { app, type HttpFunctionOptions, type HttpHandler, type InvocationContext, type StorageQueueFunctionOptions, type StorageQueueHandler } from '@azure/functions'; import type { ServiceBase } from '@cellix/api-services-spec'; import api, { SpanStatusCode, type Tracer, trace } from '@opentelemetry/api'; @@ -24,7 +24,7 @@ interface InfrastructureServiceRegistry(service: T, name?: string): InfrastructureServiceRegistry; } -interface ContextBuilder { +interface ContextBuilder { /** * Defines the infrastructure context available for the application. * @@ -40,10 +40,10 @@ interface ContextBuilder { * * @throws Error - If called outside the 'infrastructure' phase. */ - setContext(contextCreator: (serviceRegistry: InitializedServiceRegistry) => ContextType): ApplicationServicesInitializer; + setContext(contextCreator: (serviceRegistry: InitializedServiceRegistry) => ContextType): ApplicationServicesInitializer; } -interface ApplicationServicesInitializer { +interface ApplicationServicesInitializer { /** * Registers the factory that creates the request-scoped application services host. * @@ -68,10 +68,10 @@ interface ApplicationServicesInitializer { * }); * ``` */ - initializeApplicationServices(factory: (infrastructureContext: ContextType) => AppHost): AzureFunctionHandlerRegistry; + initializeApplicationServices(factory: (infrastructureContext: ContextType) => AppHost): AzureFunctionHandlerRegistry; } -interface AzureFunctionHandlerRegistry { +interface AzureFunctionHandlerRegistry { /** * Registers an Azure Function HTTP endpoint. * @@ -101,8 +101,39 @@ interface AzureFunctionHandlerRegistry, - handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler, - ): AzureFunctionHandlerRegistry; + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler, + ): AzureFunctionHandlerRegistry; + /** + * Registers an Azure Function Azure Storage Queue trigger endpoint. + * + * @remarks + * The `handlerCreator` is invoked per queue message and receives the application services host and infrastructure registry. + * Use it to create a trigger-scoped handler that processes a single queue entry. + * Registration is allowed in phases `'app-services'` and `'handlers'`. + * + * @typeParam T - The expected message payload type. Defaults to `unknown`. + * @param name - Function name to bind in Azure Functions. + * @param options - Azure Functions storage queue trigger options (excluding the handler). + * @param handlerCreator - Factory that, given the app services host and infrastructure registry, returns a `StorageQueueHandler`. + * @returns The registry (for chaining). + * + * @throws Error - If called before application services are initialized. + * + * @example + * ```ts + * registerAzureFunctionQueueHandler('community-update', { queueName: 'community-update', connection: 'AzureWebJobsStorage' }, (host, infra) => { + * return async (queueEntry, ctx) => { + * const app = await host.forRequest(); + * await app.Community.Community.updateSettings({ id: queueEntry.communityId }); + * }; + * }); + * ``` + */ + registerAzureFunctionQueueHandler( + name: string, + options: Omit, 'handler'>, + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler, + ): AzureFunctionHandlerRegistry; /** * Finalizes configuration and starts the application. * @@ -144,16 +175,28 @@ interface InitializedServiceRegistry { type UninitializedServiceRegistry = InfrastructureServiceRegistry; -type RequestScopedHost = { +type RequestScopedHost = { forRequest(rawAuthHeader?: string, hints?: H): Promise; + /** + * Builds a system-scoped application services instance, e.g. for background/queue-triggered work with + * no request context. Callers should pass only the specific permissions their operation needs + * (least privilege) rather than relying on an implicit default permission set. + */ + forSystem(permissions?: P): Promise; }; -type AppHost = RequestScopedHost; +type AppHost = RequestScopedHost; -interface PendingHandler { +interface PendingHandler { name: string; options: Omit; - handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler; + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler; +} + +interface PendingQueueHandler { + name: string; + options: Omit, 'handler'>; + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler; } type Phase = 'infrastructure' | 'context' | 'app-services' | 'handlers' | 'started'; @@ -166,18 +209,18 @@ type Phase = 'infrastructure' | 'context' | 'app-services' | 'handlers' | 'start */ type ServiceKey = { prototype: T }; -export class Cellix +export class Cellix implements InfrastructureServiceRegistry, - ContextBuilder, - ApplicationServicesInitializer, - AzureFunctionHandlerRegistry, + ContextBuilder, + ApplicationServicesInitializer, + AzureFunctionHandlerRegistry, StartedApplication { private contextInternal: ContextType | undefined; - private appServicesHostInternal: RequestScopedHost | undefined; + private appServicesHostInternal: RequestScopedHost | undefined; private contextCreatorInternal: ((serviceRegistry: InitializedServiceRegistry) => ContextType) | undefined; - private appServicesHostBuilder: ((infrastructureContext: ContextType) => RequestScopedHost) | undefined; + private appServicesHostBuilder: ((infrastructureContext: ContextType) => RequestScopedHost) | undefined; private readonly tracer: Tracer; private readonly servicesInternal: Map, ServiceBase> = new Map(); /** @@ -186,7 +229,9 @@ export class Cellix * different names. */ private readonly nameMap: Map = new Map(); - private readonly pendingHandlers: Array> = []; + private readonly pendingHandlers: Array> = []; + // biome-ignore lint/suspicious/noExplicitAny: each queue handler captures a distinct T; any allows heterogeneous handlers in one array without unsafe per-field casts + private readonly pendingQueueHandlers: Array> = []; private serviceInitializedInternal = false; private phase: Phase = 'infrastructure'; @@ -226,8 +271,10 @@ export class Cellix * .startUp(); * ``` */ - public static initializeInfrastructureServices(registerServices: (registry: UninitializedServiceRegistry) => void): ContextBuilder { - const instance = new Cellix(); + public static initializeInfrastructureServices( + registerServices: (registry: UninitializedServiceRegistry) => void, + ): ContextBuilder { + const instance = new Cellix(); registerServices(instance); return instance; } @@ -253,14 +300,14 @@ export class Cellix return this; } - public setContext(contextCreator: (serviceRegistry: InitializedServiceRegistry) => ContextType): ApplicationServicesInitializer { + public setContext(contextCreator: (serviceRegistry: InitializedServiceRegistry) => ContextType): ApplicationServicesInitializer { this.ensurePhase('infrastructure'); this.contextCreatorInternal = contextCreator; this.phase = 'context'; return this; } - public initializeApplicationServices(factory: (infrastructureContext: ContextType) => RequestScopedHost): AzureFunctionHandlerRegistry { + public initializeApplicationServices(factory: (infrastructureContext: ContextType) => RequestScopedHost): AzureFunctionHandlerRegistry { this.ensurePhase('context'); if (!this.contextCreatorInternal) { throw new Error('Context creator must be set before initializing application services'); @@ -273,14 +320,25 @@ export class Cellix public registerAzureFunctionHttpHandler( name: string, options: Omit, - handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler, - ): AzureFunctionHandlerRegistry { + handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => HttpHandler, + ): AzureFunctionHandlerRegistry { this.ensurePhase('app-services', 'handlers'); this.pendingHandlers.push({ name, options, handlerCreator }); this.phase = 'handlers'; return this; } + public registerAzureFunctionQueueHandler( + name: string, + options: Omit, 'handler'>, + handlerCreator: (applicationServicesHost: RequestScopedHost, infrastructureRegistry: InitializedServiceRegistry) => StorageQueueHandler, + ): AzureFunctionHandlerRegistry { + this.ensurePhase('app-services', 'handlers'); + this.pendingQueueHandlers.push({ name, options, handlerCreator }); + this.phase = 'handlers'; + return this; + } + public startUp(): Promise> { this.ensurePhase('handlers', 'app-services'); if (!this.contextCreatorInternal) { @@ -291,17 +349,44 @@ export class Cellix return Promise.resolve(this); } + /** + * Wraps a handler creator so its construction is deferred until the first invocation, at which + * point the (by-then initialized) application services host is available. Shared by both HTTP + * and Storage Queue handler registration in {@link setupLifecycle} so lifecycle checks (e.g., + * "has application services started?") stay consistent across handler kinds. + * + * @param kindLabel - Human-readable handler kind used in the "not started yet" error message. + * @param name - The Azure Function name, used in the "not started yet" error message. + * @param handlerCreator - Factory that produces the actual per-invocation handler function. + * @returns A handler function with the same signature as the one `handlerCreator` produces. + */ + private deferHandlerCreation( + kindLabel: string, + name: string, + handlerCreator: (applicationServicesHost: AppHost, infrastructureRegistry: InitializedServiceRegistry) => (arg: Arg, context: InvocationContext) => R, + ): (arg: Arg, context: InvocationContext) => R { + return (arg: Arg, context: InvocationContext): R => { + if (!this.appServicesHostInternal) { + throw new Error(`Application not started yet (${kindLabel}: ${name})`); + } + return handlerCreator(this.appServicesHostInternal, this)(arg, context); + }; + } + private setupLifecycle(): void { - // Register function handlers (deferred execution of creators) + // Register HTTP function handlers (deferred execution of creators) for (const h of this.pendingHandlers) { app.http(h.name, { ...h.options, - handler: (request, context) => { - if (!this.appServicesHostInternal) { - throw new Error('Application not started yet'); - } - return h.handlerCreator(this.appServicesHostInternal, this)(request, context); - }, + handler: this.deferHandlerCreation('function', h.name, h.handlerCreator), + }); + } + + // Register Azure Storage Queue trigger handlers (deferred execution of creators) + for (const h of this.pendingQueueHandlers) { + app.storageQueue(h.name, { + ...h.options, + handler: this.deferHandlerCreation('queue function', h.name, h.handlerCreator), }); } @@ -392,7 +477,7 @@ export class Cellix return this.contextInternal; } - public get applicationServices(): RequestScopedHost { + public get applicationServices(): RequestScopedHost { if (!this.appServicesHostInternal) { throw new Error('Application services not initialized'); } diff --git a/apps/api/src/features/cellix.feature b/apps/api/src/features/cellix.feature index 7a2a3a8c2..d7e9038de 100644 --- a/apps/api/src/features/cellix.feature +++ b/apps/api/src/features/cellix.feature @@ -123,4 +123,22 @@ Feature: Cellix Application Bootstrap When appTerminate hook executes Then it should record the exception in the span And it should rethrow the error - And it should set span status to ERROR \ No newline at end of file + And it should set span status to ERROR + + Scenario: Registering an Azure Function Queue handler + Given a Cellix instance in app-services phase + When an Azure Function Queue handler is registered + Then it should store the queue handler configuration + And it should transition to handlers phase + And it should return the registry for chaining + + Scenario: Registering queue handler in wrong phase + Given a Cellix instance in infrastructure phase + When registerAzureFunctionQueueHandler is called + Then it should throw an error for invalid phase + + Scenario: Starting up the application with queue handlers + Given a Cellix instance with both HTTP and queue handlers configured + When startUp is called with queue handlers + Then it should register queue functions with app.storageQueue + And it should register HTTP functions with app.http diff --git a/apps/api/src/index.test.ts b/apps/api/src/index.test.ts index 578b9520d..dd0ebafa2 100644 --- a/apps/api/src/index.test.ts +++ b/apps/api/src/index.test.ts @@ -5,6 +5,7 @@ const { setContext, initializeApplicationServices, registerAzureFunctionHttpHandler, + registerAzureFunctionQueueHandler, startUp, initializeInfrastructureServices, registerEventHandlers, @@ -63,6 +64,7 @@ const { setContext: vi.fn(), initializeApplicationServices: vi.fn(), registerAzureFunctionHttpHandler: vi.fn(), + registerAzureFunctionQueueHandler: vi.fn(), startUp: vi.fn(), initializeInfrastructureServices: vi.fn(), registerEventHandlers: vi.fn(), @@ -130,10 +132,14 @@ vi.mock('./service-config/queue-storage/index.ts', () => ({ vi.mock('@ocom/graphql-handler', () => ({ graphHandlerCreator: vi.fn(), })); +vi.mock('@ocom/handler-queue-community-update', () => ({ + communityUpdateQueueHandlerCreator: vi.fn(), +})); vi.mock('@ocom/rest', () => ({ restHandlerCreator: vi.fn(), })); vi.mock('@ocom/service-queue-storage', () => ({ + communityUpdateQueueName: 'community-update', ServiceQueueStorage: vi.fn(function MockServiceQueueStorage() { const service = { startUp: vi.fn(), @@ -141,6 +147,7 @@ vi.mock('@ocom/service-queue-storage', () => ({ sendMessageToCommunityCreationQueue: vi.fn(), receiveFromImportRequestsQueue: vi.fn(), peekAtImportRequestsQueue: vi.fn(), + receiveFromCommunityUpdateQueue: vi.fn(), enableLogging: vi.fn(), }; service.enableLogging.mockReturnValue(service); @@ -166,6 +173,12 @@ describe('apps/api bootstrap', () => { }); registerAzureFunctionHttpHandler.mockReturnValue({ registerAzureFunctionHttpHandler, + registerAzureFunctionQueueHandler, + startUp, + }); + registerAzureFunctionQueueHandler.mockReturnValue({ + registerAzureFunctionHttpHandler, + registerAzureFunctionQueueHandler, startUp, }); initializeInfrastructureServices.mockReturnValue({ diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f4a297cd4..733fc945b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,13 +2,15 @@ import './service-config/otel-starter.ts'; import { type ApplicationServices, buildApplicationServicesFactory } from '@ocom/application-services'; import type { ApiContextSpec } from '@ocom/context-spec'; +import type { Domain } from '@ocom/domain'; import { RegisterEventHandlers } from '@ocom/event-handler'; import { type GraphContext, graphHandlerCreator } from '@ocom/graphql-handler'; +import { communityUpdateQueueHandlerCreator } from '@ocom/handler-queue-community-update'; import { restHandlerCreator } from '@ocom/rest'; import { ServiceApolloServer } from '@ocom/service-apollo-server'; import { ServiceBlobStorage, ServiceClientBlobStorage } from '@ocom/service-blob-storage'; import { ServiceMongoose } from '@ocom/service-mongoose'; -import { ServiceQueueStorage } from '@ocom/service-queue-storage'; +import { ServiceQueueStorage, communityUpdateQueueName } from '@ocom/service-queue-storage'; import { ServiceTokenValidation } from '@ocom/service-token-validation'; import { Cellix } from './cellix.ts'; import * as ApolloServerConfig from './service-config/apollo-server/index.ts'; @@ -20,7 +22,7 @@ import * as TokenValidationConfig from './service-config/token-validation/index. const { NODE_ENV } = process.env; const isProd = NODE_ENV === 'production'; -Cellix.initializeInfrastructureServices((serviceRegistry) => { +Cellix.initializeInfrastructureServices>((serviceRegistry) => { serviceRegistry .registerInfrastructureService(new ServiceMongoose(MongooseConfig.mongooseConnectionString, MongooseConfig.mongooseConnectOptions)) .registerInfrastructureService( @@ -68,4 +70,7 @@ Cellix.initializeInfrastructureServices((se graphHandlerCreator(infrastructureRegistry.getInfrastructureService>(ServiceApolloServer), appServicesFactory), ) .registerAzureFunctionHttpHandler('rest', { route: '{communityId}/{role}/{memberId}/{*rest}' }, restHandlerCreator) + .registerAzureFunctionQueueHandler(communityUpdateQueueName, { queueName: communityUpdateQueueName, connection: 'AzureWebJobsStorage' }, (host, infra) => + communityUpdateQueueHandlerCreator(host, infra.getInfrastructureService(ServiceQueueStorage)), + ) .startUp(); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 72c22d78b..6d3291bd7 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -16,6 +16,7 @@ { "path": "../../packages/ocom/event-handler" }, { "path": "../../packages/ocom/graphql" }, { "path": "../../packages/ocom/graphql-handler" }, + { "path": "../../packages/ocom/handler-queue-community-update" }, { "path": "../../packages/ocom/persistence" }, { "path": "../../packages/ocom/rest" }, { "path": "../../packages/ocom/service-blob-storage" }, diff --git a/apps/docs/deploy-docs.yml b/apps/docs/deploy-docs.yml index f2bdbe375..9601ffa60 100644 --- a/apps/docs/deploy-docs.yml +++ b/apps/docs/deploy-docs.yml @@ -13,7 +13,7 @@ jobs: persistCredentials: true - task: UseNode@1 inputs: - version: '22.x' + version: '22.22.2' displayName: 'Install Node.js' - task: Cache@2 displayName: 'PNPM: Restore Cache' diff --git a/packages/cellix/service-queue-storage/src/index.ts b/packages/cellix/service-queue-storage/src/index.ts index 1e24249e1..b0f114968 100644 --- a/packages/cellix/service-queue-storage/src/index.ts +++ b/packages/cellix/service-queue-storage/src/index.ts @@ -16,3 +16,4 @@ export type { RegisteredQueueService, } from './register-queues.ts'; export { createRegisteredQueueService, deriveProvisionQueues, registerQueues } from './register-queues.ts'; +export { extractQueueTriggerMetadata } from './trigger-metadata.ts'; diff --git a/packages/cellix/service-queue-storage/src/interfaces.ts b/packages/cellix/service-queue-storage/src/interfaces.ts index 98a65034d..dd522f486 100644 --- a/packages/cellix/service-queue-storage/src/interfaces.ts +++ b/packages/cellix/service-queue-storage/src/interfaces.ts @@ -218,28 +218,57 @@ export interface IQueueStorageOperations { type QueueMessageSchema = Readonly>; type PayloadFieldRef = { payloadField: TKey }; +/** + * Values treated as opaque leaves during nested-path traversal, even though some + * of them (arrays, `Date`) are technically objects. Traversal stops here instead + * of descending into array indices or built-in object internals. + */ +type PayloadLeafValue = string | number | boolean | null | undefined | readonly unknown[] | Date; + +/** Joins a dotted-path prefix with the next key, omitting the leading dot when the prefix is empty. */ +type ConcatPath = Prefix extends '' ? Key : `${Prefix}.${Key}`; + /** * Typed `$payload` helper shape used when authoring logging tags and metadata. * - * Each property access produces a `{ payloadField: 'fieldName' }` reference that - * can be stored in a queue definition and resolved later against a runtime payload. + * Each property access produces a `{ payloadField: 'a.b.c' }` reference that can + * be stored in a queue definition and resolved later against a runtime payload. + * Property access recurses into nested plain-object fields, building a dotted + * path, while still rejecting field names that do not exist on the payload type. * - * @typeParam TPayload - Queue payload type whose keys should be addressable. + * @typeParam TPayload - Queue payload type whose keys (including nested object keys) should be addressable. + * @typeParam Prefix - Internal accumulator for the dotted path built up during recursion. Do not set explicitly. * * @example * ```ts - * type CommunityCreated = { communityId: string; createdBy: string }; - * const $payload = payloadFields(); + * type CommunityUpdated = { communityId: string; eventPayload: { name: string } }; + * const $payload = payloadFields(); * * const metadata = { * communityId: $payload.communityId, - * createdBy: $payload.createdBy, + * name: $payload.eventPayload.name, // => { payloadField: 'eventPayload.name' } * }; * ``` */ -export type PayloadFieldProxy = { - [K in Extract]-?: PayloadFieldRef; +export type PayloadFieldProxy = { + [K in Extract]-?: NonNullable extends PayloadLeafValue + ? PayloadFieldRef> + : NonNullable extends object + ? PayloadFieldRef> & PayloadFieldProxy, ConcatPath> + : PayloadFieldRef>; }; + +/** Union of all valid dotted field paths (including nested object paths) for a payload type. */ +type PayloadFieldPath = TPayload extends object + ? { + [K in Extract]: NonNullable extends PayloadLeafValue + ? ConcatPath + : NonNullable extends object + ? ConcatPath | PayloadFieldPath, ConcatPath> + : ConcatPath; + }[Extract] + : never; + export type AnyLoggingFieldSpec = string | PayloadFieldRef; type QueueDefinitionBase = { queueName: string; @@ -250,7 +279,7 @@ type QueueDefinitionBase = { /** * Describes a single logging field value: either a hardcoded string or a reference - * to a top-level field on the message payload. + * to a (possibly nested) field on the message payload. * * Use the {@link $payload} proxy for clarity when extracting from the payload. * @@ -263,11 +292,14 @@ type QueueDefinitionBase = { * * // value extracted from payload.externalId at runtime * const spec: LoggingFieldSpec = $payload.externalId; + * + * // value extracted from a nested payload field at runtime + * const nestedSpec: LoggingFieldSpec = $payload.eventPayload.communityId; * ``` * - * @typeParam TPayload - Payload type whose keys may be referenced when using `{ payloadField: ... }`. + * @typeParam TPayload - Payload type whose (possibly nested) keys may be referenced when using `{ payloadField: ... }`. */ -export type LoggingFieldSpec = string | PayloadFieldRef>; +export type LoggingFieldSpec = string | PayloadFieldRef>; /** * QueueDefinition describes a single logical queue: its physical queue name, diff --git a/packages/cellix/service-queue-storage/src/logging-fields.ts b/packages/cellix/service-queue-storage/src/logging-fields.ts index 21f06e49c..53a0f8343 100644 --- a/packages/cellix/service-queue-storage/src/logging-fields.ts +++ b/packages/cellix/service-queue-storage/src/logging-fields.ts @@ -1,10 +1,30 @@ import type { AnyLoggingFieldSpec, PayloadFieldProxy } from './interfaces.ts'; +/** + * Builds a proxy that produces a `{ payloadField: '' }` reference on + * every property access, and recurses into further property accesses so that + * nested payload fields (e.g. `$payload.eventPayload.communityId`) build up a + * dotted path instead of losing the parent key. + */ +function createPayloadFieldProxy(path: string): unknown { + return new Proxy( + { payloadField: path }, + { + get(target, prop) { + if (typeof prop === 'symbol') return Reflect.get(target, prop); + if (prop === 'payloadField') return target.payloadField; + return createPayloadFieldProxy(`${path}.${prop}`); + }, + }, + ); +} + const payloadFieldProxy = new Proxy( {}, { - get(_target, prop: string) { - return { payloadField: prop }; + get(target, prop) { + if (typeof prop === 'symbol') return Reflect.get(target, prop); + return createPayloadFieldProxy(prop); }, }, ); @@ -26,8 +46,10 @@ const payloadFieldProxy = new Proxy( * * @remarks * Because this export is intentionally broad, TypeScript cannot prevent typos in - * field names here. Prefer `defineQueue()` or `payloadFields()` - * when you want payload-key validation during authoring. + * field names here, and nested chaining (e.g. `$payload.eventPayload.communityId`) + * is not type-checked for this export. Prefer `defineQueue()` or + * `payloadFields()` when you want payload-key validation during authoring, + * including for nested object fields. */ export const $payload = payloadFieldProxy as PayloadFieldProxy>; @@ -35,31 +57,50 @@ export const $payload = payloadFieldProxy as PayloadFieldProxy(); - * const metadata = { memberId: $payload.memberId, email: $payload.email }; + * const metadata = { + * memberId: $payload.memberId, + * email: $payload.email, + * communityId: $payload.eventPayload.communityId, + * }; * ``` */ export function payloadFields(): PayloadFieldProxy { return payloadFieldProxy as PayloadFieldProxy; } +/** Reads a (possibly dotted) path off a runtime payload, returning `undefined` if any segment is missing. */ +function readPayloadPath(payload: unknown, dottedPath: string): unknown { + let current = payload; + for (const segment of dottedPath.split('.')) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[segment]; + } + return current; +} + /** * Resolves a map of payload-derived logging fields against a message payload. * - * Hardcoded strings are copied as-is. Payload references are omitted when the - * referenced payload value is `undefined` or `null`. + * Hardcoded strings are copied as-is. Payload references — including nested + * dotted-path references such as `eventPayload.communityId` — are omitted when + * the referenced payload value is `undefined` or `null`, or when any segment + * along the path is missing. * * @param specs - Logging field definitions using either hardcoded strings or payload references. * @param payload - Runtime queue payload to resolve against. @@ -69,8 +110,8 @@ export function payloadFields(): PayloadFieldProxy { domain: 'community', communityId: 'community-123' } @@ -83,7 +124,7 @@ export function resolveLoggingFields(specs: Record if (typeof spec === 'string') { resolved[key] = spec; } else { - const val = (payload as Record)?.[spec.payloadField]; + const val = readPayloadPath(payload, spec.payloadField); if (val !== undefined && val !== null) { resolved[key] = String(val); } diff --git a/packages/cellix/service-queue-storage/src/payload-proxy.test.ts b/packages/cellix/service-queue-storage/src/payload-proxy.test.ts index 7f59ac047..c0178f1e3 100644 --- a/packages/cellix/service-queue-storage/src/payload-proxy.test.ts +++ b/packages/cellix/service-queue-storage/src/payload-proxy.test.ts @@ -147,4 +147,55 @@ describe('$payload proxy', () => { // @ts-expect-error nonexistentField is not part of the payload type expect(invalidPayload.nonexistentField).toBeDefined(); }); + + it('builds a dotted path when chaining into a nested object field', () => { + const scopedPayload = payloadFields<{ eventPayload: { communityId: string; name?: string } }>(); + + expect(scopedPayload.eventPayload.communityId).toEqual({ payloadField: 'eventPayload.communityId' }); + expect(scopedPayload.eventPayload.name).toEqual({ payloadField: 'eventPayload.name' }); + }); + + it('resolves nested payload field references against a nested runtime payload', () => { + const scopedPayload = payloadFields<{ eventPayload: { communityId: string } }>(); + const spec = { + domain: 'community', + communityId: scopedPayload.eventPayload.communityId, + }; + + const payload = { eventPayload: { communityId: 'community-abc' } }; + const resolved = resolveLoggingFields(spec, payload); + + expect(resolved).toEqual({ + domain: 'community', + communityId: 'community-abc', + }); + }); + + it('omits a nested payload field reference when an intermediate segment is missing', () => { + const scopedPayload = payloadFields<{ eventPayload: { communityId: string } }>(); + const spec = { + communityId: scopedPayload.eventPayload.communityId, + }; + + const resolved = resolveLoggingFields(spec, { somethingElse: true }); + + expect(resolved).toBeUndefined(); + }); + + it('omits a nested payload field reference when the leaf value is null', () => { + const scopedPayload = payloadFields<{ eventPayload: { whiteLabelDomain: string | null } }>(); + const spec = { + whiteLabelDomain: scopedPayload.eventPayload.whiteLabelDomain, + }; + + const resolved = resolveLoggingFields(spec, { eventPayload: { whiteLabelDomain: null } }); + + expect(resolved).toBeUndefined(); + }); + + it('rejects nested field names that do not exist on the payload type', () => { + const scopedPayload = payloadFields<{ eventPayload: { communityId: string } }>(); + // @ts-expect-error nonexistentField does not exist on the nested eventPayload type + expect(scopedPayload.eventPayload.nonexistentField).toBeDefined(); + }); }); diff --git a/packages/cellix/service-queue-storage/src/trigger-metadata.test.ts b/packages/cellix/service-queue-storage/src/trigger-metadata.test.ts new file mode 100644 index 000000000..00a5bf303 --- /dev/null +++ b/packages/cellix/service-queue-storage/src/trigger-metadata.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { extractQueueTriggerMetadata } from './trigger-metadata.ts'; + +describe('extractQueueTriggerMetadata', () => { + it('extracts id, popReceipt, and dequeueCount when all are present', () => { + const metadata = extractQueueTriggerMetadata({ + id: 'msg-1', + popReceipt: 'receipt-1', + dequeueCount: 2, + }); + + expect(metadata).toStrictEqual({ + id: 'msg-1', + popReceipt: 'receipt-1', + dequeueCount: 2, + }); + }); + + it('defaults id to an empty string when triggerMetadata is undefined', () => { + const metadata = extractQueueTriggerMetadata(undefined); + + expect(metadata).toStrictEqual({ id: '' }); + }); + + it('defaults id to an empty string when id is missing', () => { + const metadata = extractQueueTriggerMetadata({ popReceipt: 'receipt-1' }); + + expect(metadata).toStrictEqual({ id: '', popReceipt: 'receipt-1' }); + }); + + it('omits popReceipt and dequeueCount keys entirely when they are undefined', () => { + const metadata = extractQueueTriggerMetadata({ id: 'msg-1' }); + + expect(metadata).toStrictEqual({ id: 'msg-1' }); + expect('popReceipt' in metadata).toBe(false); + expect('dequeueCount' in metadata).toBe(false); + }); +}); diff --git a/packages/cellix/service-queue-storage/src/trigger-metadata.ts b/packages/cellix/service-queue-storage/src/trigger-metadata.ts new file mode 100644 index 000000000..f1366c2e8 --- /dev/null +++ b/packages/cellix/service-queue-storage/src/trigger-metadata.ts @@ -0,0 +1,35 @@ +import type { QueueTriggerMetadata } from './interfaces.ts'; + +/** + * Builds a {@link QueueTriggerMetadata} object from an Azure Functions storage queue trigger's + * `triggerMetadata` bag. + * + * @remarks + * Azure Functions types `InvocationContext.triggerMetadata` as an index-signature record, so callers + * would otherwise need to repeat the same bracket-access extraction (and `exactOptionalPropertyTypes`-safe + * optional key omission) in every queue handler. Centralizing it here keeps that behavior consistent + * across handlers. + * + * @param triggerMetadata - The raw `context.triggerMetadata` value from an Azure Functions storage queue trigger invocation. + * @returns Trigger metadata with `id` defaulted to `''` when absent, and `popReceipt`/`dequeueCount` omitted (rather than set to `undefined`) when not present. + * + * @example + * ```ts + * const metadata = extractQueueTriggerMetadata(context.triggerMetadata); + * await queueService.receiveFromCommunityUpdateQueue(queueEntry, metadata); + * ``` + */ +export function extractQueueTriggerMetadata(triggerMetadata: Record | undefined): QueueTriggerMetadata { + // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access + const id = (triggerMetadata?.['id'] as string) ?? ''; + // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access + const popReceipt = triggerMetadata?.['popReceipt'] as string | undefined; + // biome-ignore lint/complexity/useLiteralKeys: triggerMetadata uses an index signature type that requires bracket access + const dequeueCount = triggerMetadata?.['dequeueCount'] as number | undefined; + // exactOptionalPropertyTypes: omit keys whose values are undefined rather than passing explicit undefined + return { + id, + ...(popReceipt === undefined ? {} : { popReceipt }), + ...(dequeueCount === undefined ? {} : { dequeueCount }), + }; +} diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 7b162160a..849424bb4 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -139,5 +139,8 @@ export function createMockApplicationServicesFactory(serviceMongoose: ServiceMon forRequest: (_rawAuthHeader, hints) => { return mockApplicationServicesFactory.forRequest('Bearer test-token', hints); }, + forSystem: (permissions) => { + return mockApplicationServicesFactory.forSystem(permissions); + }, }; } diff --git a/packages/ocom/application-services/src/contexts/community/community/index.ts b/packages/ocom/application-services/src/contexts/community/community/index.ts index 945af787d..0be616206 100644 --- a/packages/ocom/application-services/src/contexts/community/community/index.ts +++ b/packages/ocom/application-services/src/contexts/community/community/index.ts @@ -8,6 +8,7 @@ import { type CommunityQueryByIdCommand, queryById } from './query-by-id.ts'; import { type CommunityUpdateSettingsCommand, updateSettings } from './update-settings.ts'; export type { CommunityUpdateSettingsCommand }; +export { CommunityNotFoundError } from './update-settings.ts'; export interface CommunityApplicationService { create: (command: CommunityCreateCommand) => Promise; diff --git a/packages/ocom/application-services/src/contexts/community/community/update-settings.ts b/packages/ocom/application-services/src/contexts/community/community/update-settings.ts index 65149a5ca..08d1fc219 100644 --- a/packages/ocom/application-services/src/contexts/community/community/update-settings.ts +++ b/packages/ocom/application-services/src/contexts/community/community/update-settings.ts @@ -1,6 +1,13 @@ import type { Domain } from '@ocom/domain'; import type { DataSources } from '@ocom/persistence'; +export class CommunityNotFoundError extends Error { + constructor(id: string) { + super(`Community not found for id: ${id}`); + this.name = 'CommunityNotFoundError'; + } +} + export interface CommunityUpdateSettingsCommand { id: string; name?: string; @@ -15,7 +22,7 @@ export const updateSettings = (dataSources: DataSources) => { await dataSources.domainDataSource.Community.Community.CommunityUnitOfWork.withScopedTransaction(async (repo) => { const community = await repo.get(command.id); if (!community) { - throw new Error(`Community not found for id ${command.id}`); + throw new CommunityNotFoundError(command.id); } if (command.name !== undefined) { @@ -34,7 +41,7 @@ export const updateSettings = (dataSources: DataSources) => { communityToReturn = await repo.save(community); }); if (!communityToReturn) { - throw new Error('community not found'); + throw new CommunityNotFoundError(command.id); } return communityToReturn; }; diff --git a/packages/ocom/application-services/src/contexts/community/index.ts b/packages/ocom/application-services/src/contexts/community/index.ts index 242066634..647db11e8 100644 --- a/packages/ocom/application-services/src/contexts/community/index.ts +++ b/packages/ocom/application-services/src/contexts/community/index.ts @@ -6,6 +6,7 @@ import { Member as MemberApi, type MemberApplicationService } from './member/ind import { Role as RoleApi, type RoleContext } from './role/index.ts'; export type { CommunityUpdateSettingsCommand } from './community/index.ts'; +export { CommunityNotFoundError } from './community/index.ts'; export interface CommunityContextApplicationService { Community: CommunityApplicationService; diff --git a/packages/ocom/application-services/src/index.ts b/packages/ocom/application-services/src/index.ts index 9e5407886..2586278ff 100644 --- a/packages/ocom/application-services/src/index.ts +++ b/packages/ocom/application-services/src/index.ts @@ -5,6 +5,7 @@ import { Service, type ServiceContextApplicationService } from './contexts/servi import { User, type UserContextApplicationService } from './contexts/user/index.ts'; export type { CommunityUpdateSettingsCommand } from './contexts/community/index.ts'; +export { CommunityNotFoundError } from './contexts/community/index.ts'; export interface ApplicationServices { Community: CommunityContextApplicationService; @@ -36,7 +37,14 @@ export type PrincipalHints = { export interface AppServicesHost { forRequest(rawAuthHeader?: string, hints?: PrincipalHints): Promise; - // forSystem: (opts?: unknown) => Promise; + /** + * Builds a system-scoped application services instance, e.g. for background/queue-triggered work with no request context. + * + * @param permissions - The specific permissions this system-triggered operation needs. Callers should + * request only the permissions required for their operation (least privilege) rather than relying on a + * shared default permission set. + */ + forSystem(permissions?: Partial): Promise; // forAzureFunction: (opts?: unknown) => Promise; } @@ -80,7 +88,22 @@ export const buildApplicationServicesFactory = (context: ApiContextSpec): Applic }; }; + const forSystem = (permissions?: Partial): Promise => { + const systemPassport = Domain.PassportFactory.forSystem({ isSystemAccount: true, ...permissions }); + const { dataSourcesFactory, blobStorageService, queueStorageService } = context; + const dataSources = dataSourcesFactory.withPassport(systemPassport); + return Promise.resolve({ + Community: Community(dataSources, blobStorageService, queueStorageService), + Service: Service(dataSources), + User: User(dataSources), + get verifiedUser(): VerifiedUser | null { + return null; + }, + }); + }; + return { forRequest, + forSystem, }; }; diff --git a/packages/ocom/domain/src/domain/contexts/passport.ts b/packages/ocom/domain/src/domain/contexts/passport.ts index 71bfaae9f..bc51ad40a 100644 --- a/packages/ocom/domain/src/domain/contexts/passport.ts +++ b/packages/ocom/domain/src/domain/contexts/passport.ts @@ -7,6 +7,8 @@ import type { PropertyPassport } from './property/property.passport.ts'; import type { ServicePassport } from './service/service.passport.ts'; import type { UserPassport } from './user/user.passport.ts'; +export type { PermissionsSpec }; + export interface Passport { get case(): CasePassport; get community(): CommunityPassport; diff --git a/packages/ocom/domain/src/domain/index.ts b/packages/ocom/domain/src/domain/index.ts index 6b321d748..9b575aed2 100644 --- a/packages/ocom/domain/src/domain/index.ts +++ b/packages/ocom/domain/src/domain/index.ts @@ -1,5 +1,5 @@ export * as Contexts from './contexts/index.ts'; -export { type Passport, PassportFactory } from './contexts/passport.ts'; +export { type Passport, PassportFactory, type PermissionsSpec } from './contexts/passport.ts'; export type { DomainExecutionContext } from './domain-execution-context.ts'; export * as Events from './events/index.ts'; export * as Services from './services/index.ts'; diff --git a/packages/ocom/handler-queue-community-update/.gitignore b/packages/ocom/handler-queue-community-update/.gitignore new file mode 100644 index 000000000..19af63935 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +coverage +*.log +.DS_Store diff --git a/packages/ocom/handler-queue-community-update/package.json b/packages/ocom/handler-queue-community-update/package.json new file mode 100644 index 000000000..ab498d7f1 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/package.json @@ -0,0 +1,41 @@ +{ + "name": "@ocom/handler-queue-community-update", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Azure Functions Storage Queue trigger handler for community update messages", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "lint": "biome lint", + "format": "biome format --write", + "format:check": "biome format .", + "prebuild": "pnpm run lint", + "build": "tsgo --build", + "watch": "tsgo --watch", + "test": "vitest run --silent --reporter=dot", + "test:coverage": "vitest run --coverage --silent --reporter=dot", + "test:watch": "vitest", + "clean": "rimraf dist" + }, + "dependencies": { + "@azure/functions": "catalog:", + "@ocom/application-services": "workspace:*", + "@ocom/service-queue-storage": "workspace:*" + }, + "devDependencies": { + "@cellix/config-typescript": "workspace:*", + "@cellix/config-vitest": "workspace:*", + "@vitest/coverage-istanbul": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/ocom/handler-queue-community-update/readme.md b/packages/ocom/handler-queue-community-update/readme.md new file mode 100644 index 000000000..3cbda4c66 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/readme.md @@ -0,0 +1,51 @@ +# @ocom/handler-queue-community-update + +Azure Functions Storage Queue trigger handler for community update messages in the Cellix framework. + +## Purpose + +This package provides the Azure Functions Storage Queue trigger handler that processes `community-update` queue messages. It integrates with `@ocom/service-queue-storage` for message validation and deserialization, and with `@ocom/application-services` to apply community settings updates. + +## Exports + +- `communityUpdateQueueHandlerCreator` — factory function that returns an Azure Functions `StorageQueueHandler` for the `community-update` queue. + +## Usage + +### Creating and registering the handler + +```typescript +import { communityUpdateQueueHandlerCreator } from '@ocom/handler-queue-community-update'; +import { ServiceQueueStorage } from '@ocom/service-queue-storage'; + +Cellix + .initializeInfrastructureServices(...) + .setContext(...) + .initializeApplicationServices(...) + .registerAzureFunctionQueueHandler( + 'community-update', + { queueName: 'community-update', connection: 'AzureWebJobsStorage' }, + (host, infra) => + communityUpdateQueueHandlerCreator( + host, + infra.getInfrastructureService(ServiceQueueStorage), + ), + ) + .startUp(); +``` + +### Handler behaviour + +On each queue trigger: + +1. Extracts trigger metadata (`id`, `popReceipt`, `dequeueCount`) from the Azure Functions context. +2. Validates and deserializes the raw queue entry via `@ocom/service-queue-storage` `receiveFromCommunityUpdateQueue`. +3. Creates a system-scoped application services instance (via `ApplicationServicesFactory.forSystem`), scoped to only the `canManageCommunitySettings` permission this handler needs. +4. Calls `Community.updateSettings` with the payload fields (`name`, `domain`, `whiteLabelDomain`, `handle`), including only the fields present in the message. +5. Logs and swallows `community not found` errors; rethrows all other errors so the Functions runtime retries the message. + +## Related packages + +- `@ocom/service-queue-storage` — queue registry, schema validation, and typed message deserialization. +- `@ocom/application-services` — application services factory providing request-scoped services (HTTP) and system-scoped services (background/queue-triggered work, e.g. via `forSystem`). +- `apps/api` — composition root that wires this handler into the Azure Functions host via Cellix. diff --git a/packages/ocom/handler-queue-community-update/src/handler.test.ts b/packages/ocom/handler-queue-community-update/src/handler.test.ts new file mode 100644 index 000000000..cbc4653d7 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/src/handler.test.ts @@ -0,0 +1,175 @@ +import type { InvocationContext } from '@azure/functions'; +import type { ApplicationServices, ApplicationServicesFactory } from '@ocom/application-services'; +import { CommunityNotFoundError } from '@ocom/application-services'; +import type { CommunityUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { communityUpdateQueueHandlerCreator } from './handler.ts'; + +function makeQueueEntry(overrides?: Partial): CommunityUpdatePayload { + return { + eventTimestamp: '2023-03-21T02:53:27.872Z', + eventUuid: 'x-event-id-001', + apiName: 'api-name-001', + eventPayload: { + communityId: 'community-abc', + ...overrides, + }, + }; +} + +function makeMockApplicationServicesFactory(updateSettings = vi.fn().mockResolvedValue(undefined)) { + const appServices = { + Community: { + Community: { + updateSettings, + }, + }, + } as unknown as ApplicationServices; + return { + forSystem: vi.fn().mockResolvedValue(appServices), + updateSettings, + }; +} + +function makeMockQueueService(receiveResult?: object) { + return { + receiveFromCommunityUpdateQueue: vi.fn().mockResolvedValue( + receiveResult ?? { + id: 'msg-1', + payload: makeQueueEntry({ + name: 'Test Community', + domain: 'test.example.com', + whiteLabelDomain: null, + handle: null, + }), + }, + ), + } as unknown as QueueStorageOperations; +} + +function makeMockInvocationContext(): InvocationContext { + return { + error: vi.fn(), + triggerMetadata: { + id: 'trigger-id-1', + popReceipt: 'pop-receipt-1', + dequeueCount: 1, + }, + } as unknown as InvocationContext; +} + +describe('communityUpdateQueueHandlerCreator', () => { + let factory: ReturnType; + let queueService: QueueStorageOperations; + let context: InvocationContext; + + beforeEach(() => { + vi.clearAllMocks(); + factory = makeMockApplicationServicesFactory(); + queueService = makeMockQueueService(); + context = makeMockInvocationContext(); + }); + + describe('handler creation', () => { + it('returns a function (the StorageQueueHandler)', () => { + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + expect(typeof handler).toBe('function'); + }); + }); + + describe('handler invocation', () => { + it('calls receiveFromCommunityUpdateQueue with payload and trigger metadata', async () => { + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + const queueEntry = makeQueueEntry(); + + await handler(queueEntry, context); + + expect(queueService.receiveFromCommunityUpdateQueue).toHaveBeenCalledWith(queueEntry, { + id: 'trigger-id-1', + popReceipt: 'pop-receipt-1', + dequeueCount: 1, + }); + }); + + it('calls updateSettings with the message payload fields', async () => { + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await handler(makeQueueEntry(), context); + + expect(factory.updateSettings).toHaveBeenCalledWith({ + id: 'community-abc', + name: 'Test Community', + domain: 'test.example.com', + whiteLabelDomain: null, + handle: null, + }); + }); + + it('calls forSystem scoped to only the community-settings permission it needs', async () => { + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await handler(makeQueueEntry(), context); + + expect(factory.forSystem).toHaveBeenCalledWith({ canManageCommunitySettings: true }); + }); + + it('defaults triggerMetadata id to empty string when not present', async () => { + const ctx = { + error: vi.fn(), + triggerMetadata: undefined, + } as unknown as InvocationContext; + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await handler(makeQueueEntry(), ctx); + + expect(queueService.receiveFromCommunityUpdateQueue).toHaveBeenCalledWith(expect.anything(), { + id: '', + popReceipt: undefined, + dequeueCount: undefined, + }); + }); + }); + + describe('error handling', () => { + it('logs an error and returns (does not rethrow) when receiveFromCommunityUpdateQueue throws', async () => { + vi.mocked(queueService.receiveFromCommunityUpdateQueue).mockRejectedValue(new Error('JSON parse error')); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler({ bad: 'payload' } as unknown as CommunityUpdatePayload, context)).resolves.toBeUndefined(); + expect(context.error).toHaveBeenCalledWith(expect.stringContaining('invalid message payload'), expect.any(Error)); + expect(factory.forSystem).not.toHaveBeenCalled(); + }); + + it('logs an error and returns (does not rethrow) when community is not found', async () => { + factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue(new CommunityNotFoundError('missing-id'))); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler(makeQueueEntry({ communityId: 'missing-id' }), context)).resolves.toBeUndefined(); + expect(context.error).toHaveBeenCalledWith(expect.stringContaining('community not found'), expect.any(CommunityNotFoundError)); + }); + + it('rethrows errors from updateSettings that are not "community not found"', async () => { + factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue(new Error('Database connection failed'))); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler(makeQueueEntry(), context)).rejects.toThrow('Database connection failed'); + expect(context.error).not.toHaveBeenCalled(); + }); + + it('rethrows non-Error throwables from updateSettings', async () => { + factory = makeMockApplicationServicesFactory(vi.fn().mockRejectedValue('unexpected string error')); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler(makeQueueEntry(), context)).rejects.toBe('unexpected string error'); + }); + + it('logs an error and rethrows when forSystem throws', async () => { + factory.forSystem.mockRejectedValue(new Error('passport factory misconfigured')); + const handler = communityUpdateQueueHandlerCreator(factory as unknown as ApplicationServicesFactory, queueService); + + await expect(handler(makeQueueEntry(), context)).rejects.toThrow('passport factory misconfigured'); + expect(context.error).toHaveBeenCalledWith(expect.stringContaining('failed to create application services'), expect.any(Error)); + expect(factory.updateSettings).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/ocom/handler-queue-community-update/src/handler.ts b/packages/ocom/handler-queue-community-update/src/handler.ts new file mode 100644 index 000000000..918647fa7 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/src/handler.ts @@ -0,0 +1,50 @@ +import type { StorageQueueHandler } from '@azure/functions'; +import type { ApplicationServicesFactory } from '@ocom/application-services'; +import { CommunityNotFoundError } from '@ocom/application-services'; +import type { CommunityUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; +import { communityUpdateQueueName, extractQueueTriggerMetadata } from '@ocom/service-queue-storage'; + +/** + * Creates an Azure Functions Storage Queue handler for processing community update messages. + * + * @param applicationServicesFactory - Factory for building system-scoped application services + * @param queueService - Queue storage operations for receiving and validating messages + * @returns An Azure Functions StorageQueueHandler for the community-update queue + *``` + */ +export const communityUpdateQueueHandlerCreator = (applicationServicesFactory: ApplicationServicesFactory, queueService: QueueStorageOperations): StorageQueueHandler => { + return async (queueEntry, context) => { + const metadata = extractQueueTriggerMetadata(context.triggerMetadata); + const { id, dequeueCount } = metadata; + let message: Awaited>; + try { + message = await queueService.receiveFromCommunityUpdateQueue(queueEntry, metadata); + } catch (err) { + context.error(`${communityUpdateQueueName}: invalid message payload (id=${id}, dequeueCount=${dequeueCount ?? 'unknown'}): ${err instanceof Error ? err.message : String(err)}`, err); + return; + } + let appServices: Awaited>; + try { + appServices = await applicationServicesFactory.forSystem({ canManageCommunitySettings: true }); + } catch (err) { + context.error(`${communityUpdateQueueName}: failed to create application services (id=${id}, dequeueCount=${dequeueCount ?? 'unknown'}): ${err instanceof Error ? err.message : String(err)}`, err); + throw err; + } + const { communityId, name, domain, whiteLabelDomain, handle } = message.payload.eventPayload; + try { + await appServices.Community.Community.updateSettings({ + id: communityId, + ...(name === undefined ? {} : { name }), + ...(domain === undefined ? {} : { domain }), + ...(whiteLabelDomain === undefined ? {} : { whiteLabelDomain }), + ...(handle === undefined ? {} : { handle }), + }); + } catch (err) { + if (err instanceof CommunityNotFoundError) { + context.error(`${communityUpdateQueueName}: community not found: ${communityId} (id=${id}, dequeueCount=${dequeueCount ?? 'unknown'})`, err); + return; + } + throw err; + } + }; +}; diff --git a/packages/ocom/handler-queue-community-update/src/index.ts b/packages/ocom/handler-queue-community-update/src/index.ts new file mode 100644 index 000000000..3f09dde21 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/src/index.ts @@ -0,0 +1 @@ +export { communityUpdateQueueHandlerCreator } from './handler.ts'; diff --git a/packages/ocom/handler-queue-community-update/tsconfig.json b/packages/ocom/handler-queue-community-update/tsconfig.json new file mode 100644 index 000000000..b010bf4e4 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@cellix/config-typescript/node", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../application-services" }, { "path": "../service-queue-storage" }] +} diff --git a/packages/ocom/handler-queue-community-update/tsconfig.vitest.json b/packages/ocom/handler-queue-community-update/tsconfig.vitest.json new file mode 100644 index 000000000..4f806efbc --- /dev/null +++ b/packages/ocom/handler-queue-community-update/tsconfig.vitest.json @@ -0,0 +1,3 @@ +{ + "extends": ["./tsconfig.json", "@cellix/config-typescript/vitest"] +} diff --git a/packages/ocom/handler-queue-community-update/turbo.json b/packages/ocom/handler-queue-community-update/turbo.json new file mode 100644 index 000000000..5f90b32dd --- /dev/null +++ b/packages/ocom/handler-queue-community-update/turbo.json @@ -0,0 +1,3 @@ +{ + "extends": ["//"] +} diff --git a/packages/ocom/handler-queue-community-update/vitest.config.ts b/packages/ocom/handler-queue-community-update/vitest.config.ts new file mode 100644 index 000000000..8d3110ee1 --- /dev/null +++ b/packages/ocom/handler-queue-community-update/vitest.config.ts @@ -0,0 +1,13 @@ +import { nodeConfig } from '@cellix/config-vitest'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig( + nodeConfig, + defineConfig({ + test: { + coverage: { + exclude: ['**/index.ts'], + }, + }, + }), +); diff --git a/packages/ocom/service-queue-storage/src/index.ts b/packages/ocom/service-queue-storage/src/index.ts index 4bd7d04e7..68dedeca5 100644 --- a/packages/ocom/service-queue-storage/src/index.ts +++ b/packages/ocom/service-queue-storage/src/index.ts @@ -1,5 +1,8 @@ -export type { QueueLoggingConfig } from '@cellix/service-queue-storage'; +export type { QueueLoggingConfig, QueueTriggerMetadata } from '@cellix/service-queue-storage'; +export { extractQueueTriggerMetadata } from '@cellix/service-queue-storage'; export type { QueueStorageOperations } from './queue-storage.contract.ts'; export { ServiceQueueStorage } from './registry.ts'; +export type { CommunityUpdatePayload } from './schemas/inbound/community-update.ts'; +export { communityUpdateQueueName } from './schemas/inbound/community-update.ts'; export type { EndUserUpdatePayload } from './schemas/inbound/end-user-update.ts'; export type { CommunityCreationPayload } from './schemas/outbound/community-creation.ts'; diff --git a/packages/ocom/service-queue-storage/src/registry.ts b/packages/ocom/service-queue-storage/src/registry.ts index aa822521c..457ea429a 100644 --- a/packages/ocom/service-queue-storage/src/registry.ts +++ b/packages/ocom/service-queue-storage/src/registry.ts @@ -1,4 +1,5 @@ import { createRegisteredQueueService, registerQueues } from '@cellix/service-queue-storage'; +import { communityUpdateQueue } from './schemas/inbound/community-update.ts'; import { endUserUpdateQueue } from './schemas/inbound/end-user-update.ts'; import { communityCreationQueue } from './schemas/outbound/community-creation.ts'; @@ -8,6 +9,7 @@ const outboundQueues = { const inboundQueues = { endUserUpdate: endUserUpdateQueue, + communityUpdate: communityUpdateQueue, }; const queues = registerQueues({ diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.sample.dat b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.sample.dat new file mode 100644 index 000000000..4be51d06d --- /dev/null +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.sample.dat @@ -0,0 +1,15 @@ +{ + "eventTimestamp": "2026-07-07T02:53:27.872Z", + "replayId": "replay-id-001", + "eventUuid": "x-event-id-001", + "apiName": "api-name-001", + "traceParent": "traceParent-001", + "traceState": "traceState-success", + "eventPayload": { + "communityId": "b00000000000000000000001", + "name": "Updated Community Name", + "domain": "updated-community-domain.com", + "whiteLabelDomain": "updated-white-label-domain.com", + "handle": "updated-community-handle" + } +} diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json new file mode 100644 index 000000000..0a9db9018 --- /dev/null +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CommunityUpdatePayload", + "description": "Message for updating settings on an existing community", + "type": "object", + "properties": { + "eventTimestamp": { + "type": "string", + "pattern": "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9].[0-9][0-9][0-9]Z$", + "examples": ["2023-03-21T02:53:27.872Z"], + "description": "Date-time value converted to UTC timezone, and represented in ISO format" + }, + "replayId": { + "type": "string", + "examples": ["replay-id-001"] + }, + "eventUuid": { + "type": "string", + "maxLength": 36, + "examples": ["x-event-id-001"], + "description": "unique event Id from the sender" + }, + "apiName": { + "type": "string", + "examples": ["api-name-001"] + }, + "traceParent": { + "type": "string", + "examples": ["traceParent-001"] + }, + "traceState": { + "type": "string", + "examples": ["traceState-success"] + }, + "eventPayload": { + "type": "object", + "properties": { + "communityId": { + "type": "string", + "description": "Identifier of the community to update (required for lookup)" + }, + "name": { + "type": "string", + "description": "Updated community name" + }, + "domain": { + "type": "string", + "description": "Updated community domain" + }, + "whiteLabelDomain": { + "type": ["string", "null"], + "description": "Updated white label domain (null to clear)" + }, + "handle": { + "type": ["string", "null"], + "description": "Updated community handle (null to clear)" + } + }, + "additionalProperties": false, + "required": ["communityId"] + } + }, + "additionalProperties": false, + "required": ["eventTimestamp", "eventUuid", "apiName", "eventPayload"] +} diff --git a/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts new file mode 100644 index 000000000..c9b0a0140 --- /dev/null +++ b/packages/ocom/service-queue-storage/src/schemas/inbound/community-update.ts @@ -0,0 +1,18 @@ +import { defineQueue } from '@cellix/service-queue-storage'; +import { type Schema as CommunityUpdatePayload, schema as communityUpdateSchema } from './community-update.schema.generated.ts'; + +export type { CommunityUpdatePayload }; + +export const communityUpdateQueueName = 'community-update' as const; + +export const communityUpdateQueue = defineQueue()(({ $payload }) => ({ + queueName: communityUpdateQueueName, + schema: communityUpdateSchema, + loggingTags: { + domain: 'community', + communityId: $payload.eventPayload.communityId, + }, + loggingMetadata: { + updateType: 'community-settings', + }, +})); diff --git a/packages/ocom/service-queue-storage/src/service.test.ts b/packages/ocom/service-queue-storage/src/service.test.ts index 937a8b38f..07143de64 100644 --- a/packages/ocom/service-queue-storage/src/service.test.ts +++ b/packages/ocom/service-queue-storage/src/service.test.ts @@ -15,7 +15,7 @@ describe('ServiceQueueStorage', () => { }; expect(serviceWithOptions.options).toMatchObject({ - provisionQueues: ['community-creation', 'end-user-update'], + provisionQueues: ['community-creation', 'end-user-update', 'community-update'], }); }, 10000); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fb196c84..5de01c779 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,7 +132,7 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - brace-expansion: 5.0.7 + brace-expansion: ^5.0.7 diff@4.0.2: 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -244,7 +244,7 @@ importers: version: 7.0.0-dev.20260428.1 '@vitest/coverage-istanbul': specifier: 'catalog:' - version: 4.1.8(supports-color@8.1.1)(vitest@4.1.8) + version: 4.1.8(vitest@4.1.8) chrome-devtools-mcp: specifier: ^1.3.0 version: 1.3.0 @@ -296,6 +296,9 @@ importers: '@ocom/context-spec': specifier: workspace:* version: link:../../packages/ocom/context-spec + '@ocom/domain': + specifier: workspace:* + version: link:../../packages/ocom/domain '@ocom/event-handler': specifier: workspace:* version: link:../../packages/ocom/event-handler @@ -305,6 +308,9 @@ importers: '@ocom/graphql-handler': specifier: workspace:* version: link:../../packages/ocom/graphql-handler + '@ocom/handler-queue-community-update': + specifier: workspace:* + version: link:../../packages/ocom/handler-queue-community-update '@ocom/persistence': specifier: workspace:* version: link:../../packages/ocom/persistence @@ -459,7 +465,7 @@ importers: version: 6.18.0 mongoose: specifier: 'catalog:' - version: 8.17.0(supports-color@8.1.1) + version: 8.17.0 devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -985,7 +991,7 @@ importers: version: 10.3.0(supports-color@8.1.1) mongoose: specifier: 'catalog:' - version: 8.17.0(supports-color@8.1.1) + version: 8.17.0 rimraf: specifier: 'catalog:' version: 6.0.1 @@ -1000,7 +1006,7 @@ importers: dependencies: '@apollo/server': specifier: 'catalog:' - version: 5.5.0(graphql@16.12.0)(supports-color@8.1.1) + version: 5.5.0(graphql@16.12.0) '@cellix/server-mongodb-memory-mock-seedwork': specifier: workspace:* version: link:../server-mongodb-memory-mock-seedwork @@ -1073,7 +1079,7 @@ importers: version: 10.3.0 mongoose: specifier: 'catalog:' - version: 8.17.0(supports-color@8.1.1) + version: 8.17.0 devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -1627,7 +1633,7 @@ importers: version: link:../../cellix/mongoose-seedwork mongoose: specifier: 'catalog:' - version: 8.17.0(supports-color@8.1.1) + version: 8.17.0 devDependencies: '@cellix/archunit-tests': specifier: workspace:* @@ -1778,7 +1784,7 @@ importers: dependencies: '@apollo/server': specifier: 'catalog:' - version: 5.5.0(graphql@16.12.0)(supports-color@8.1.1) + version: 5.5.0(graphql@16.12.0) '@apollo/utils.withrequired': specifier: ^3.0.0 version: 3.0.0 @@ -1817,6 +1823,37 @@ importers: specifier: 'catalog:' version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/ocom/handler-queue-community-update: + dependencies: + '@azure/functions': + specifier: 'catalog:' + version: 4.11.0(patch_hash=69772ce521bf6df67d814ff4f419f19b5e966a41c4ce80b5938143ad628e5645) + '@ocom/application-services': + specifier: workspace:* + version: link:../application-services + '@ocom/service-queue-storage': + specifier: workspace:* + version: link:../service-queue-storage + devDependencies: + '@cellix/config-typescript': + specifier: workspace:* + version: link:../../cellix/config-typescript + '@cellix/config-vitest': + specifier: workspace:* + version: link:../../cellix/config-vitest + '@vitest/coverage-istanbul': + specifier: 'catalog:' + version: 4.1.8(vitest@4.1.8) + rimraf: + specifier: 'catalog:' + version: 6.0.1 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/ocom/local-dev-config: dependencies: '@cellix/local-dev': @@ -1867,7 +1904,7 @@ importers: version: 6.18.0 mongoose: specifier: 'catalog:' - version: 8.17.0(supports-color@8.1.1) + version: 8.17.0 devDependencies: '@cellix/archunit-tests': specifier: workspace:* @@ -1917,7 +1954,7 @@ importers: dependencies: '@apollo/server': specifier: 'catalog:' - version: 5.5.0(graphql@16.12.0)(supports-color@8.1.1) + version: 5.5.0(graphql@16.12.0) '@cellix/api-services-spec': specifier: workspace:* version: link:../../cellix/api-services-spec @@ -1991,7 +2028,7 @@ importers: version: link:../../cellix/mongoose-seedwork mongoose: specifier: 'catalog:' - version: 8.17.0(supports-color@8.1.1) + version: 8.17.0 devDependencies: '@cellix/config-typescript': specifier: workspace:* @@ -6157,24 +6194,15 @@ packages: '@protobufjs/codegen@2.0.5': resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} - '@protobufjs/eventemitter@1.1.1': resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} - '@protobufjs/fetch@1.1.1': resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.1': - resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} - '@protobufjs/inquire@1.1.2': resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} @@ -10550,10 +10578,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.3.5: - resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} - engines: {node: 20 || >=22} - lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -10912,10 +10936,6 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -13248,10 +13268,6 @@ packages: resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -14413,10 +14429,10 @@ snapshots: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.1 + '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -14431,7 +14447,7 @@ snapshots: '@apollo/utils.logger': 3.0.0 graphql: 16.12.0 - '@apollo/server@5.5.0(graphql@16.12.0)(supports-color@8.1.1)': + '@apollo/server@5.5.0(graphql@16.12.0)': dependencies: '@apollo/cache-control-types': 1.0.3(graphql@16.12.0) '@apollo/server-gateway-interface': 2.0.0(graphql@16.12.0) @@ -14445,13 +14461,13 @@ snapshots: '@apollo/utils.withrequired': 3.0.0 '@graphql-tools/schema': 10.0.30(graphql@16.12.0) async-retry: 1.3.3 - body-parser: 2.2.2(supports-color@8.1.1) + body-parser: 2.2.2 content-type: 1.0.5 cors: 2.8.5 - finalhandler: 2.1.1(supports-color@8.1.1) + finalhandler: 2.1.1 graphql: 16.12.0 loglevel: 1.9.2 - lru-cache: 11.3.5 + lru-cache: 11.3.6 negotiator: 1.0.0 uuid: 11.1.1 whatwg-mimetype: 4.0.0 @@ -14478,7 +14494,7 @@ snapshots: '@apollo/utils.keyvaluecache@4.0.0': dependencies: '@apollo/utils.logger': 3.0.0 - lru-cache: 11.3.5 + lru-cache: 11.3.6 '@apollo/utils.logger@3.0.0': {} @@ -14865,7 +14881,7 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.6(supports-color@8.1.1)': + '@babel/core@7.29.6': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -14874,7 +14890,7 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -14923,27 +14939,27 @@ snapshots: '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.6) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 regexpu-core: 6.4.0 semver: 6.3.1 '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 debug: 4.4.3(supports-color@8.1.1) @@ -14958,31 +14974,31 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -14996,25 +15012,25 @@ snapshots: '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.3 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-replace-supers@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -15034,7 +15050,7 @@ snapshots: '@babel/helper-wrap-function@7.28.3': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -15046,7 +15062,7 @@ snapshots: '@babel/parser@7.29.2': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/parser@7.29.7': dependencies: @@ -15054,25 +15070,25 @@ snapshots: '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-transform-optional-chaining': 7.28.5(@babel/core@7.29.6) @@ -15081,64 +15097,64 @@ snapshots: '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.3(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-async-generator-functions@7.28.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.6) - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-async-to-generator@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-module-imports': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.6) @@ -15147,17 +15163,17 @@ snapshots: '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-block-scoping@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-class-properties@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: @@ -15165,7 +15181,7 @@ snapshots: '@babel/plugin-transform-class-static-block@7.28.3(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: @@ -15173,55 +15189,55 @@ snapshots: '@babel/plugin-transform-classes@7.28.4(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.6) - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-computed-properties@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/template': 7.29.7 '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-dotall-regex@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-explicit-resource-management@7.28.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6) transitivePeerDependencies: @@ -15229,17 +15245,17 @@ snapshots: '@babel/plugin-transform-exponentiation-operator@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: @@ -15247,36 +15263,36 @@ snapshots: '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-json-strings@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-logical-assignment-operators@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: @@ -15284,7 +15300,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: @@ -15292,17 +15308,17 @@ snapshots: '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: @@ -15310,39 +15326,39 @@ snapshots: '@babel/plugin-transform-named-capturing-groups-regex@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-nullish-coalescing-operator@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-numeric-separator@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-object-rest-spread@7.28.4(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.6) '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.6) - '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.6) transitivePeerDependencies: @@ -15350,12 +15366,12 @@ snapshots: '@babel/plugin-transform-optional-catch-binding@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-optional-chaining@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: @@ -15363,12 +15379,12 @@ snapshots: '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-private-methods@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: @@ -15376,7 +15392,7 @@ snapshots: '@babel/plugin-transform-private-property-in-object@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 @@ -15385,29 +15401,29 @@ snapshots: '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-constant-elements@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.29.6) transitivePeerDependencies: - supports-color '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-module-imports': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 @@ -15418,29 +15434,29 @@ snapshots: '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-regenerator@7.28.4(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-regexp-modifiers@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-runtime@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-module-imports': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.29.6) @@ -15452,12 +15468,12 @@ snapshots: '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-spread@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: @@ -15465,22 +15481,22 @@ snapshots: '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 @@ -15491,31 +15507,31 @@ snapshots: '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-property-regex@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-unicode-sets-regex@7.27.1(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.6) '@babel/helper-plugin-utils': 7.28.6 '@babel/preset-env@7.28.5(@babel/core@7.29.6)': dependencies: '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 @@ -15590,14 +15606,14 @@ snapshots: '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/types': 7.29.7 esutils: 2.0.3 '@babel/preset-react@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.6) @@ -15609,7 +15625,7 @@ snapshots: '@babel/preset-typescript@7.28.5(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-option': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.6) @@ -15650,7 +15666,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.7(supports-color@8.1.1)': + '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -16040,7 +16056,7 @@ snapshots: '@cucumber/ci-environment': 13.0.0 '@cucumber/cucumber-expressions': 19.0.0 '@cucumber/gherkin': 38.0.0 - '@cucumber/gherkin-streams': 6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.3.1))(@cucumber/messages@32.2.0) + '@cucumber/gherkin-streams': 6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0))(@cucumber/messages@32.2.0) '@cucumber/gherkin-utils': 11.0.0 '@cucumber/html-formatter': 23.0.0(@cucumber/messages@32.2.0) '@cucumber/junit-xml-formatter': 0.13.3(@cucumber/messages@32.2.0) @@ -16077,7 +16093,7 @@ snapshots: yaml: 2.8.3 yup: 1.7.1 - '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.3.1))(@cucumber/messages@32.2.0)': + '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0))(@cucumber/messages@32.2.0)': dependencies: '@cucumber/gherkin': 38.0.0 '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.3.1) @@ -16241,7 +16257,7 @@ snapshots: '@docusaurus/babel@3.10.1(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/generator': 7.29.1 '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.6) '@babel/plugin-transform-runtime': 7.28.5(@babel/core@7.29.6) @@ -16266,7 +16282,7 @@ snapshots: '@docusaurus/bundler@3.10.1(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@6.0.3)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@docusaurus/babel': 3.10.1(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@docusaurus/cssnano-preset': 3.10.1 '@docusaurus/logger': 3.10.1 @@ -17466,7 +17482,7 @@ snapshots: '@graphql-tools/graphql-tag-pluck@8.3.26(graphql@16.12.0)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/parser': 7.29.2 '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.29.6) '@babel/traverse': 7.29.0 @@ -18033,7 +18049,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.7.4 + semver: 7.8.0 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -18045,7 +18061,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.7.4 + semver: 7.8.0 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -18629,23 +18645,14 @@ snapshots: '@protobufjs/codegen@2.0.5': {} - '@protobufjs/eventemitter@1.1.0': {} - '@protobufjs/eventemitter@1.1.1': {} - '@protobufjs/fetch@1.1.0': - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.1 - '@protobufjs/fetch@1.1.1': dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.1': {} - '@protobufjs/inquire@1.1.2': {} '@protobufjs/path@1.1.2': {} @@ -19394,39 +19401,39 @@ snapshots: '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-preset@8.1.0(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.29.6) '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.29.6) '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.29.6) @@ -19438,7 +19445,7 @@ snapshots: '@svgr/core@8.1.0(typescript@6.0.3)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-preset': 8.1.0(@babel/core@7.29.6) camelcase: 6.3.0 cosmiconfig: 8.3.6(typescript@6.0.3) @@ -19454,7 +19461,7 @@ snapshots: '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@6.0.3))': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@svgr/babel-preset': 8.1.0(@babel/core@7.29.6) '@svgr/core': 8.1.0(typescript@6.0.3) '@svgr/hast-util-to-babel-ast': 8.0.0 @@ -19473,7 +19480,7 @@ snapshots: '@svgr/webpack@8.1.0(typescript@6.0.3)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/plugin-transform-react-constant-elements': 7.27.1(@babel/core@7.29.6) '@babel/preset-env': 7.28.5(@babel/core@7.29.6) '@babel/preset-react': 7.28.5(@babel/core@7.29.6) @@ -19935,25 +19942,9 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-istanbul@4.1.8(supports-color@8.1.1)(vitest@4.1.8)': - dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) - '@istanbuljs/schema': 0.1.3 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - transitivePeerDependencies: - - supports-color - '@vitest/coverage-istanbul@4.1.8(vitest@4.1.8)': dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@istanbuljs/schema': 0.1.3 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -19963,7 +19954,7 @@ snapshots: magicast: 0.5.2 obug: 2.1.1 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - supports-color @@ -20377,7 +20368,7 @@ snapshots: archunit@2.1.63: dependencies: '@zerollup/ts-helpers': 1.7.18(typescript@5.9.3) - minimatch: 10.2.4 + minimatch: 10.2.5 plantuml-parser: 0.4.0 typescript: 5.9.3 @@ -20553,7 +20544,7 @@ snapshots: babel-loader@9.2.1(@babel/core@7.29.6)(webpack@5.105.4(esbuild@0.28.1)): dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 find-cache-dir: 4.0.0 schema-utils: 4.3.3 webpack: 5.105.4(esbuild@0.28.1) @@ -20565,7 +20556,7 @@ snapshots: babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.29.6): dependencies: '@babel/compat-data': 7.29.0 - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.6) semver: 6.3.1 transitivePeerDependencies: @@ -20573,7 +20564,7 @@ snapshots: babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.6): dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.6) core-js-compat: 3.47.0 transitivePeerDependencies: @@ -20581,7 +20572,7 @@ snapshots: babel-plugin-polyfill-regenerator@0.6.5(@babel/core@7.29.6): dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) + '@babel/core': 7.29.6 '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.29.6) transitivePeerDependencies: - supports-color @@ -20642,7 +20633,7 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.2.2(supports-color@8.1.1): + body-parser@2.2.2: dependencies: bytes: 3.1.2 content-type: 1.0.5 @@ -20986,7 +20977,7 @@ snapshots: chromatic@16.10.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 chrome-devtools-mcp@1.3.0: {} @@ -21327,7 +21318,7 @@ snapshots: postcss-modules-scope: 3.2.1(postcss@8.5.10) postcss-modules-values: 4.0.0(postcss@8.5.10) postcss-value-parser: 4.2.0 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: webpack: 5.105.4(esbuild@0.28.1) @@ -22173,7 +22164,7 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.1(supports-color@8.1.1): + finalhandler@2.1.1: dependencies: debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 @@ -22369,20 +22360,20 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.1.1 - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 2.0.2 glob@13.0.0: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 glob@13.0.6: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -22401,7 +22392,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.4 + semver: 7.8.0 serialize-error: 7.0.1 global-dirs@3.0.1: @@ -23027,7 +23018,7 @@ snapshots: is-core-module@2.16.1: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -23637,8 +23628,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.3.5: {} - lru-cache@11.3.6: {} lru-cache@5.1.1: @@ -23659,8 +23648,8 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@2.1.0: @@ -23675,7 +23664,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 map-cache@0.2.2: {} @@ -24261,10 +24250,6 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.7 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 @@ -24306,7 +24291,7 @@ snapshots: https-proxy-agent: 7.0.6 mongodb: 6.21.0 new-find-package-json: 2.0.0(supports-color@8.1.1) - semver: 7.7.4 + semver: 7.8.0 tar-stream: 3.1.7 tslib: 2.8.1 yauzl: 3.2.1 @@ -24332,7 +24317,7 @@ snapshots: https-proxy-agent: 7.0.6 mongodb: 6.21.0 new-find-package-json: 2.0.0(supports-color@8.1.1) - semver: 7.7.4 + semver: 7.8.0 tar-stream: 3.1.7 tslib: 2.8.1 yauzl: 3.2.1 @@ -24392,13 +24377,13 @@ snapshots: bson: 6.10.4 mongodb-connection-string-url: 3.0.2 - mongoose@8.17.0(supports-color@8.1.1): + mongoose@8.17.0: dependencies: bson: 6.10.4 kareem: 2.6.3 mongodb: 6.18.0 mpath: 0.9.0 - mquery: 5.0.0(supports-color@8.1.1) + mquery: 5.0.0 ms: 2.1.3 sift: 17.1.3 transitivePeerDependencies: @@ -24423,7 +24408,7 @@ snapshots: mpath@0.9.0: {} - mquery@5.0.0(supports-color@8.1.1): + mquery@5.0.0: dependencies: debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: @@ -24972,7 +24957,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.3.5 + lru-cache: 11.3.6 minipass: 7.1.3 path-to-regexp@0.1.13: {} @@ -25234,7 +25219,7 @@ snapshots: cosmiconfig: 8.3.6(typescript@6.0.3) jiti: 2.6.1 postcss: 8.5.10 - semver: 7.7.4 + semver: 7.8.0 webpack: 5.105.4(esbuild@0.28.1) transitivePeerDependencies: - typescript @@ -25707,9 +25692,9 @@ snapshots: react-docgen@8.0.2: dependencies: - '@babel/core': 7.29.6(supports-color@8.1.1) - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/core': 7.29.6 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -26286,7 +26271,7 @@ snapshots: semver-diff@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 semver@5.7.2: {} @@ -26816,7 +26801,7 @@ snapshots: lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.7 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 supports-color@5.5.0: @@ -26943,13 +26928,13 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 10.5.0 - minimatch: 10.2.4 + minimatch: 10.2.5 test-exclude@8.0.0: dependencies: '@istanbuljs/schema': 0.1.3 glob: 13.0.6 - minimatch: 10.2.4 + minimatch: 10.2.5 text-decoder@1.2.3: dependencies: @@ -27002,11 +26987,6 @@ snapshots: tinyexec@1.0.4: {} - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -27320,7 +27300,7 @@ snapshots: is-yarn-global: 0.4.1 latest-version: 7.0.0 pupa: 3.3.0 - semver: 7.7.4 + semver: 7.8.0 semver-diff: 4.0.0 xdg-basedir: 5.1.0 @@ -27495,7 +27475,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@types/node': 22.19.15 '@vitest/browser-playwright': 4.1.8(playwright@1.59.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.8) - '@vitest/coverage-istanbul': 4.1.8(supports-color@8.1.1)(vitest@4.1.8) + '@vitest/coverage-istanbul': 4.1.8(vitest@4.1.8) happy-dom: 20.10.1 jsdom: 26.1.0 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b48bb6c08..223c62039 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -80,7 +80,7 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - brace-expansion: 5.0.7 + brace-expansion: ^5.0.7 'diff@4.0.2': 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1