Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b94e29a
Add blob storage framework and OCOM adapter services
May 14, 2026
f74f94d
Merge remote-tracking branch 'origin/main' into issue251/blob-storage…
May 14, 2026
96a7e93
Make ServiceBlobStorage.shutDown idempotent and harden connection str…
May 14, 2026
bcb44b7
Make blob storage shutdown idempotent; improve connection string pars…
May 14, 2026
93a6ad2
fix(pnpm-lock): remove duplicate entries for '@protobufjs/inquire@1.1.1'
May 14, 2026
58a1ccb
Merge branch 'main' into issue251/blob-storage-service
May 14, 2026
807b846
Improve connection string validation and error messages; fix ESM impo…
May 14, 2026
709c41b
refactor: use environment variables for Azurite credentials instead o…
May 14, 2026
5872c05
feat: implement managed identity authentication for blob storage with…
May 14, 2026
e3315a9
Refactor blob storage to auto-inject account name via Bicep and suppo…
May 14, 2026
4c49297
Clarify blob storage auth mode precedence and add validation helper
May 15, 2026
c5f751c
Fix managed identity layer separation - split credential consumption
May 15, 2026
bccd73a
Simplify managed identity - always use DefaultAzureCredential for SDK…
May 15, 2026
f178c8c
Decouple connection string as opt-in for SAS signing
May 15, 2026
12650a6
Add test coverage for OCOM ServiceBlobStorage options initialization
May 15, 2026
08e02b7
Fix remaining code review issues: wire connectionString through and d…
May 15, 2026
d75f682
Tighten ServiceBlobStorageOptions typing and fix typo
May 15, 2026
a61c54b
Update blob storage config documentation and resolve Azurite binary path
May 15, 2026
ee1f47b
Fix remaining Sourcery feedback: robust findRepoRoot and error messag…
May 18, 2026
68e154f
Add comprehensive documentation for blob storage architecture
May 18, 2026
6fd6d20
Refactor OCOM adapter to dual-service architecture for cleaner separa…
May 18, 2026
5f1ccd9
Restructure blob storage to register two separate framework services
May 18, 2026
5098103
refactor: rename ClientUploadServiceImpl to ServiceBlobStorageClientU…
May 18, 2026
bb80a96
chore: update Snyk ignore list for new transitive dependencies in Doc…
May 18, 2026
4ec03f6
config: update local Azurite blob storage connection string
May 18, 2026
15fc89e
test: add comprehensive coverage for ServiceBlobStorageClientUpload
May 18, 2026
87c250a
fix: address final code review feedback
May 18, 2026
8469bca
docs(service-blob-storage): improve JSDoc for public interfaces
May 18, 2026
bc3ba5d
chore(cellix/service-blob-storage): rename blob-storage.contract.ts t…
May 18, 2026
09e1d5e
chore(service-blob-storage): normalize interface filenames across cel…
May 18, 2026
4506a9e
fix(ocom/service-blob-storage): restore correct import to blob-storag…
May 18, 2026
a72abb6
Clarify SharedKey auth header documentation for client usage
May 18, 2026
616a1ee
feat(blob-storage): Implement canonical SharedKey auth headers with m…
May 18, 2026
aac058d
docs(adr-0032): Clarify why connection strings are necessary - securi…
May 18, 2026
8453025
docs: Refactor blob storage documentation - lean ADR + detailed Docus…
May 18, 2026
381d4bc
refactor: unified blob storage with single ServiceBlobStorage class a…
May 19, 2026
f9f8b84
Fix ServiceBlobStorage startup and UI test deps
May 20, 2026
f51fe39
feat(blob-storage): enhance ServiceBlobStorage to support local Azuri…
May 21, 2026
c75c541
Merge branch 'main' into 'issue251/blob-storage-service'
May 21, 2026
98c5ab4
refactor(blob-storage): remove unused service registry and update blo…
May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .snyk
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,10 @@ ignore:
reason: 'Requires upgrade of @opentelemetry/sdk-node to 0.217.0, which has type errors that break compilation. Created task to upgrade OTEL service to 2.x and resolve vulnerability that way.'
expires: '2026-07-28T00:00:00.000Z'
created: '2026-06-01T10:00:00.000Z'
sast-ignore:
'packages/cellix/service-blob-storage/src/test-support/azurite.ts':
- 'Hardcoded-Non-Cryptographic-Secret @ line 10':
reason: 'This is the standard well-known Azurite/Azure Storage Emulator test account key from official Microsoft documentation (https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite). Used only for local testing and not a real credential.'
expires: '2027-05-14T00:00:00.000Z'
created: '2026-05-14T16:00:00.000Z'

1 change: 1 addition & 0 deletions apps/api/iac/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ module functionApp '../../../iac/function-app/main.bicep' = {
tags: tags
appServicePlanName: appServicePlan.outputs.appServicePlanName
storageAccountName: functionAppStorageAccountName
applicationStorageAccountName: storageAccount.outputs.storageAccountName
functionAppInstanceName: functionAppInstanceName
functionWorkerRuntime: functionWorkerRuntime
functionExtensionVersion: functionExtensionVersion
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"prestart": "pnpm run prepare:deploy && pnpm run sync-local-settings",
"start": "func start --typescript --script-root deploy/",
"sync-local-settings": "node -e \"const fs=require('node:fs'); fs.mkdirSync('deploy',{recursive:true}); if (fs.existsSync('local.settings.json')) fs.copyFileSync('local.settings.json','deploy/local.settings.json');\"",
"azurite": "azurite-blob --silent --location ../../__blobstorage__ & azurite-queue --silent --location ../../__queuestorage__ & azurite-table --silent --location ../../__tablestorage__"
"azurite": "azurite-blob --silent --skipApiVersionCheck --location ../../__blobstorage__ & azurite-queue --silent --location ../../__queuestorage__ & azurite-table --silent --location ../../__tablestorage__"
},
"dependencies": {
"@azure/functions": "catalog:",
Expand Down
63 changes: 63 additions & 0 deletions apps/api/src/cellix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,69 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
});
});

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

When('an infrastructure service is registered with a name', () => {
const result = cellix.registerInfrastructureService(mockService, 'my-service');
expect(result).toBe(cellix);
});

Then('it should be retrievable by name', () => {
const named = cellix.getInfrastructureService('my-service');
expect(named).toBe(mockService);
});
});

Scenario('Registering a duplicate service name', ({ Given, When, Then }) => {
Given('a Cellix instance with a named service registered', () => {
cellix = Cellix.initializeInfrastructureServices((registry) => {
registry.registerInfrastructureService(mockService, 'my-service');
}) as Cellix<unknown, unknown>;
});

When('another service is registered with the same name', () => {
const anotherService = new MockService();
expect(() => {
cellix.registerInfrastructureService(anotherService, 'my-service');
}).toThrow('Service name already registered: my-service');
});

Then('it should throw an error indicating duplicate name registration', () => {
// Error is already thrown in When step
});
});

Scenario('Lifecycle deduplicates services registered by constructor and name', ({ Given, When, Then }) => {
Given('a Cellix instance with the same service registered by constructor and by name', () => {
cellix = Cellix.initializeInfrastructureServices((registry) => {
registry.registerInfrastructureService(mockService);
registry.registerInfrastructureService(mockService, 'alias-service');
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
});

When('the application starts', async () => {
await cellix.startUp();
// Trigger appStart hook
const mockHook = app.hook.appStart as unknown as { mock: { calls: [() => Promise<void>][] } };
const appStartCallback = mockHook.mock.calls[0]?.[0];
if (appStartCallback) {
await appStartCallback();
}
});

Then('the service startUp should be called exactly once', () => {
expect(mockService.startUp).toHaveBeenCalledTimes(1);
});
});

Scenario('Setting the infrastructure context', ({ Given, When, Then, And }) => {
let result: ReturnType<Cellix<unknown, unknown>['setContext']>;

Expand Down
100 changes: 66 additions & 34 deletions apps/api/src/cellix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@ interface InfrastructureServiceRegistry<ContextType = unknown, AppServices = unk
* Registers an infrastructure service with the application.
*
* @remarks
* Must be called during the {@link Phase | 'infrastructure'} phase. Each
* constructor key can be registered at most once.
* Must be called during the {@link Phase | 'infrastructure'} phase.
* By default, services are keyed by constructor identity (minification-safe).
* This method now has an optional `name` argument to allow registering
* multiple instances of the same constructor under distinct string keys.
*
* @typeParam T - The concrete service type.
* @param service - The service instance to register.
* @param name - Optional semantic name for the service. If provided, the
* service will be retrievable by name via getInfrastructureService(name).
* @returns The registry (for chaining).
*
* @throws Error - If called outside the infrastructure phase or the service key is already registered.
* @throws Error - If called outside the infrastructure phase, the constructor key is already registered (when name is omitted),
* or the provided name is already registered.
*/
registerInfrastructureService<T extends ServiceBase>(service: T): InfrastructureServiceRegistry<ContextType, AppServices>;
registerInfrastructureService<T extends ServiceBase>(service: T, name?: string): InfrastructureServiceRegistry<ContextType, AppServices>;
}

interface ContextBuilder<ContextType = unknown, AppServices = unknown> {
Expand Down Expand Up @@ -119,30 +124,21 @@ interface StartedApplication<ContextType = unknown> extends InitializedServiceRe

interface InitializedServiceRegistry {
/**
* Retrieves a registered infrastructure service by its constructor key.
* Retrieves a registered infrastructure service by its constructor key or by
* its semantic name.
*
* @remarks
* Services are keyed by their constructor identity (not by name), which is
* minification-safe. You must pass the same class you used when registering
* the service; base classes or interfaces will not match.
* If a string `name` was used when registering the service, pass that name
* to retrieve it. Otherwise, pass the service constructor used at
* registration time.
*
* @typeParam T - The concrete service type.
* @param serviceKey - The service class (constructor) used at registration time.
* @param serviceKeyOrName - The service class (constructor) or the string name used at registration time.
* @returns The registered service instance.
*
* @throws Error - If no service is registered for the provided key.
*
* @example
* ```ts
* // registration
* registry.registerInfrastructureService(new BlobStorageService(...));
*
* // lookup
* const blob = app.getInfrastructureService(BlobStorageService);
* await blob.startUp();
* ```
* @throws Error - If no service is registered for the provided key or name.
*/
getInfrastructureService<T extends ServiceBase>(serviceKey: ServiceKey<T>): T;
getInfrastructureService<T extends ServiceBase>(serviceKeyOrName: ServiceKey<T> | string): T;
get servicesInitialized(): boolean;
}

Expand Down Expand Up @@ -184,6 +180,12 @@ export class Cellix<ContextType, AppServices = unknown>
private appServicesHostBuilder: ((infrastructureContext: ContextType) => RequestScopedHost<AppServices, unknown>) | undefined;
private readonly tracer: Tracer;
private readonly servicesInternal: Map<ServiceKey<ServiceBase>, ServiceBase> = new Map();
/**
* Optional name-based registry for services. Names are semantic strings that
* allow multiple instances of the same constructor to coexist under
* different names.
*/
private readonly nameMap: Map<string, ServiceBase> = new Map();
private readonly pendingHandlers: Array<PendingHandler<AppServices>> = [];
private serviceInitializedInternal = false;
private phase: Phase = 'infrastructure';
Expand Down Expand Up @@ -230,13 +232,24 @@ export class Cellix<ContextType, AppServices = unknown>
return instance;
}

public registerInfrastructureService<T extends ServiceBase>(service: T): InfrastructureServiceRegistry<ContextType, AppServices> {
public registerInfrastructureService<T extends ServiceBase>(service: T, name?: string): InfrastructureServiceRegistry<ContextType, AppServices> {
this.ensurePhase('infrastructure');
const key = service.constructor as ServiceKey<ServiceBase>;
if (this.servicesInternal.has(key)) {
throw new Error(`Service already registered for constructor: ${service.constructor.name}`);
if (name == null) {
// Backwards-compatible constructor-only registration: preserve existing
// behaviour and throw if the constructor key is already present.
if (this.servicesInternal.has(key)) {
throw new Error(`Service already registered for constructor: ${service.constructor.name}`);
}
this.servicesInternal.set(key, service);
} else {
// Name-based registration: ensure name uniqueness, but allow the same
// constructor to exist under multiple names.
if (this.nameMap.has(name)) {
throw new Error(`Service name already registered: ${name}`);
}
this.nameMap.set(name, service);
}
this.servicesInternal.set(key, service);
return this;
}

Expand Down Expand Up @@ -352,10 +365,17 @@ export class Cellix<ContextType, AppServices = unknown>
}
}

public getInfrastructureService<T extends ServiceBase>(serviceKey: ServiceKey<T>): T {
const service = this.servicesInternal.get(serviceKey as ServiceKey<ServiceBase>);
public getInfrastructureService<T extends ServiceBase>(serviceKeyOrName: ServiceKey<T> | string): T {
if (typeof serviceKeyOrName === 'string') {
const named = this.nameMap.get(serviceKeyOrName);
if (!named) {
throw new Error(`Service not found: ${serviceKeyOrName}`);
}
return named as T;
}
const service = this.servicesInternal.get(serviceKeyOrName as ServiceKey<ServiceBase>);
if (!service) {
const name = (serviceKey as { name?: string }).name ?? 'UnknownService';
const name = (serviceKeyOrName as { name?: string }).name ?? 'UnknownService';
throw new Error(`Service not found: ${name}`);
}
return service as T;
Expand All @@ -381,20 +401,32 @@ export class Cellix<ContextType, AppServices = unknown>

// Service lifecycle helpers
private async startAllServicesWithTracing(): Promise<void> {
await this.iterateServicesWithTracing('start', 'startUp');
const services = this.getUniqueServicesForLifecycle();
await this.iterateServicesWithTracing(services, 'start', 'startUp');
}
private async stopAllServicesWithTracing(): Promise<void> {
await this.iterateServicesWithTracing('stop', 'shutDown');
const services = this.getUniqueServicesForLifecycle();
await this.iterateServicesWithTracing(services, 'stop', 'shutDown');
}
private getUniqueServicesForLifecycle(): ServiceBase[] {
const set = new Set<ServiceBase>();
for (const svc of this.servicesInternal.values()) {
set.add(svc);
}
for (const svc of this.nameMap.values()) {
set.add(svc);
}
return Array.from(set.values());
}
private async iterateServicesWithTracing(operationName: 'start' | 'stop', serviceMethod: 'startUp' | 'shutDown'): Promise<void> {
private async iterateServicesWithTracing(services: ServiceBase[], operationName: 'start' | 'stop', serviceMethod: 'startUp' | 'shutDown'): Promise<void> {
const operationFullName = `${operationName.charAt(0).toUpperCase() + operationName.slice(1)}Service`;
const operationActionPending = operationName === 'start' ? 'starting' : 'stopping';
const operationActionCompleted = operationName === 'start' ? 'started' : 'stopped';
await Promise.all(
Array.from(this.servicesInternal.entries()).map(([ctor, service]) =>
this.tracer.startActiveSpan(`Service ${(ctor as unknown as { name?: string }).name ?? 'Service'} ${operationName}`, async (span) => {
services.map((service) =>
this.tracer.startActiveSpan(`Service ${service.constructor.name} ${operationName}`, async (span) => {
try {
const ctorName = (ctor as unknown as { name?: string }).name ?? 'Service';
const ctorName = service.constructor?.name ?? 'Service';
console.log(`${operationFullName}: Service ${ctorName} ${operationActionPending}`);
await service[serviceMethod]();
span.setStatus({ code: SpanStatusCode.OK, message: `Service ${ctorName} ${operationActionCompleted}` });
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/features/cellix.feature
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ Feature: Cellix Application Bootstrap
When the same service type is registered again
Then it should throw an error indicating the service is already registered

Scenario: Registering a named infrastructure service
Given a Cellix instance in infrastructure phase
When an infrastructure service is registered with a name
Then it should be retrievable by name

Scenario: Registering a duplicate service name
Given a Cellix instance with a named service registered
When another service is registered with the same name
Then it should throw an error indicating duplicate name registration

Scenario: Lifecycle deduplicates services registered by constructor and name
Given a Cellix instance with the same service registered by constructor and by name
When the application starts
Then the service startUp should be called exactly once

Scenario: Setting the infrastructure context
Given a Cellix instance in infrastructure phase
When the context creator is set
Expand Down
Loading
Loading