Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 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
7c13004
refactor(blob-storage): simplify blob storage configuration and impro…
May 21, 2026
84110ad
feat(queue-storage): implement @cellix/service-queue-storage and @oco…
May 22, 2026
8bb3dc0
chore(queue-storage): add .gitignore to new packages, remove committe…
May 22, 2026
6bed75b
refactor(queue-storage): remove handler concern, simplify consumer AP…
May 22, 2026
364392d
feat: add typed queue storage services and logging
May 27, 2026
98d9bc8
refactor(queue-storage): clarify internal transport boundaries
May 27, 2026
7040907
chore: update dependencies and add snyk exception for npm vulnerabili…
May 28, 2026
a57948f
feat(queue-storage): mature typed service contract and docs
May 29, 2026
cebd408
fix: align verify-driven queue storage changes
May 29, 2026
ee0f89b
Merge branch 'main' into 'issue263/queue-storage-service'
May 29, 2026
81648f6
Merge branch 'main' into 'issue251/blob-storage-service'
May 29, 2026
975bcbd
Merge branch 'main' into issue251/blob-storage-service
May 29, 2026
27f2bda
feat(blob-storage): Refactor ServiceBlobStorage for clearer authentic…
Jun 2, 2026
21eb502
chore: update vitest and related packages to version 4.1.6 to resolve…
Jun 2, 2026
476a91a
feat: fix api archunit test and add archunit dev dependency
Jun 2, 2026
d035fd8
chore: remove unnecessary wait command from azurite script in package…
Jun 2, 2026
a673a0c
Remove outdated documentation on Blob Storage, including overview, au…
Jun 2, 2026
8281d08
feat: enhance rolldown configuration to support temporary workaround …
Jun 2, 2026
949cd5b
refactor: clean up blob storage service authentication and added publ…
Jun 2, 2026
1db96a0
refactor: streamline Azurite integration and fix e2e test setup for r…
Jun 8, 2026
f96b2e8
Merge branch 'main' into issue251/blob-storage-service
Jun 8, 2026
c9ce80e
refactor: simplify connection string credential creation and enhance …
Jun 8, 2026
8d593d2
Refine blob storage service wiring
Jun 8, 2026
fdd6913
Merge branch 'main' into issue251/blob-storage-service
Jun 9, 2026
ab281f2
Merge branch 'issue251/blob-storage-service' into issue263/queue-stor…
Jun 9, 2026
810980d
test: fix api bootstrap test failures
Jun 9, 2026
713473d
feat(api): add queue trigger handler registration
Jun 11, 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
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
8 changes: 6 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
"types": "dist/index.d.ts",
"scripts": {
"prebuild": "pnpm run lint",
"build": "tsgo --build && rolldown -c rolldown.config.ts",
"build": "tsgo --build && tsgo --noEmit --project tsconfig.rolldown.json && RUST_BACKTRACE=full rolldown -c rolldown.config.ts",
"predev": "pnpm run prepare:deploy && pnpm run sync-local-settings",
"dev": "pnpm exec portless data-access.ownercommunity.localhost --force node start-dev.mjs",
"prepare:deploy": "cellix-prepare-func-deploy",
"watch": "tsgo --watch",
"test": "vitest run --silent --reporter=dot",
"test:arch": "vitest run --config vitest.arch.config.ts",
"test:coverage": "vitest run --coverage --silent --reporter=dot",
"test:watch": "vitest",
"format": "biome format --write",
Expand All @@ -22,7 +23,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 --skipApiVersionCheck --location ../../__queuestorage__ & azurite-table --silent --location ../../__tablestorage__"
},
"dependencies": {
"@azure/functions": "catalog:",
Expand All @@ -34,10 +35,12 @@
"@ocom/graphql-handler": "workspace:*",
"@ocom/graphql": "workspace:*",
"@ocom/persistence": "workspace:*",
"@ocom/handler-queue-community-update": "workspace:*",
"@ocom/rest": "workspace:*",
"@ocom/service-apollo-server": "workspace:*",
"@ocom/service-blob-storage": "workspace:*",
"@ocom/service-mongoose": "workspace:*",
"@ocom/service-queue-storage": "workspace:*",
"@ocom/service-otel": "workspace:*",
"@ocom/service-token-validation": "workspace:*",
"@opentelemetry/api": "1.9.0"
Expand All @@ -47,6 +50,7 @@
"@cellix/config-typescript": "workspace:*",
"@cellix/config-vitest": "workspace:*",
"@vitest/coverage-istanbul": "catalog:",
"archunit": "catalog:",
"rimraf": "catalog:",
"rolldown": "1.0.0-beta.55",
"typescript": "catalog:",
Expand Down
17 changes: 16 additions & 1 deletion apps/api/rolldown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,33 @@
* packages when workspace packages are in the module graph.
*/

import { defineConfig } from 'rolldown';
/*
* Avoid VScode reporting "Cannot find name 'NodeJS'" errors in this file, which uses NodeJS types but is not compiled by TypeScript and thus does not have access to the types specified in tsconfig.json.
* rolldown.config.ts is NOT expected to be included in the TypeScript compilation, as it is used by the rolldown bundler at build time and is not part of the runtime code.
* The types specified in tsconfig.json are only applied to files that are included in the compilation, and since rolldown.config.ts is not included, it does not have access to those types.
* By adding this reference directive, we can ensure that the NodeJS types are available for use in this file without causing phantom errors in the rest of the project.
*/
/// <reference types="node" />

import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createCellixAzureFunctionsRolldownConfig } from '@cellix/config-rolldown';
import { defineConfig } from 'rolldown';

const apiDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(apiDir, '../..');
const temporaryRolldownWorkaround = {
// Remove this block once rolldown no longer panics on
// packages/cellix/service-blob-storage/dist/service-blob-storage.js.
skipAliasNamespaces: ['@azure/'],
additionalExternal: ['@ocom/service-blob-storage'],
} as const;

export default defineConfig(async () =>
createCellixAzureFunctionsRolldownConfig({
repoRoot,
appPackageName: '@apps/api',
applicationNamespaces: ['@ocom/'],
...temporaryRolldownWorkaround,
}),
);
28 changes: 28 additions & 0 deletions apps/api/src/archunit-tests/architecture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { projectFiles } from 'archunit';
import { describe, expect, it } from 'vitest';

describe('API Dependency Rules', () => {
describe('API Package', () => {
it('should not import any @cellix/service-* package directly from src/index.ts', async () => {
const violations: string[] = [];
let matchedTargetFile = false;

await projectFiles()
.inPath('src/index.ts')
.should()
.adhereTo((file) => {
matchedTargetFile = true;
const hasForbiddenImport = /from\s+['"]@cellix\/service-[^'"]+['"]/.test(file.content);
if (hasForbiddenImport) {
violations.push(`[${file.path}] Must not import from @cellix/service-* packages directly in src/index.ts`);
return false;
}
return true;
}, 'API src/index.ts must not import from @cellix/service-* packages directly')
.check();

expect(matchedTargetFile).toBe(true);
expect(violations).toStrictEqual([]);
});
});
});
219 changes: 218 additions & 1 deletion apps/api/src/cellix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const test = { for: describeFeature };
vi.mock('@azure/functions', () => ({
app: {
http: vi.fn(),
storageQueue: vi.fn(),
hook: {
appStart: vi.fn(),
appTerminate: vi.fn(),
Expand Down Expand Up @@ -172,6 +173,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 Expand Up @@ -298,6 +362,33 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
});
});

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

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

Then('it should store the queue handler configuration', () => {
expect(app.storageQueue).not.toHaveBeenCalled();
});

And('it should transition to handlers phase for queue handlers', () => {
expect(cellix.startUp).toBeDefined();
});

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

Scenario('Registering handler in wrong phase', ({ Given, When, Then }) => {
Given('a Cellix instance in infrastructure phase', () => {
cellix = Cellix.initializeInfrastructureServices(() => {
Expand All @@ -316,6 +407,120 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
});
});

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

When('registerAzureFunctionQueueHandler is called', () => {
expect(() => {
cellix.registerAzureFunctionQueueHandler('community-update', { queueName: 'community-update', connection: 'AZURE_STORAGE_CONNECTION_STRING' }, () => 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('Chaining mixed Azure Function handler registrations', ({ Given, When, Then, And }) => {
Given('a Cellix instance in app-services phase', () => {
cellix = Cellix.initializeInfrastructureServices(() => {
/* no op */
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
});

When('queue and HTTP handlers are registered in mixed order', async () => {
cellix
.registerAzureFunctionQueueHandler('queue-first', { queueName: 'queue-first', connection: 'AZURE_STORAGE_CONNECTION_STRING' }, () => vi.fn())
.registerAzureFunctionHttpHandler('http-second', { authLevel: 'anonymous' }, () => vi.fn())
.registerAzureFunctionQueueHandler('queue-third', { queueName: 'queue-third', connection: 'AZURE_STORAGE_CONNECTION_STRING' }, () => vi.fn())
.registerAzureFunctionHttpHandler('http-fourth', { authLevel: 'anonymous' }, () => vi.fn());

await cellix.startUp();
});

Then('it should allow chaining mixed handler registrations', () => {
expect(app.storageQueue).toHaveBeenCalledTimes(2);
expect(app.http).toHaveBeenCalledTimes(2);
});

And('it should register each mixed handler at startup', () => {
expect(app.storageQueue).toHaveBeenNthCalledWith(1, 'queue-first', expect.objectContaining({ queueName: 'queue-first', connection: 'AZURE_STORAGE_CONNECTION_STRING', handler: expect.any(Function) }));
expect(app.http).toHaveBeenNthCalledWith(1, 'http-second', expect.objectContaining({ authLevel: 'anonymous', handler: expect.any(Function) }));
expect(app.storageQueue).toHaveBeenNthCalledWith(2, 'queue-third', expect.objectContaining({ queueName: 'queue-third', connection: 'AZURE_STORAGE_CONNECTION_STRING', handler: expect.any(Function) }));
expect(app.http).toHaveBeenNthCalledWith(2, 'http-fourth', expect.objectContaining({ authLevel: 'anonymous', handler: expect.any(Function) }));
});
});

Scenario('Starting up directly from app-services phase', ({ Given, When, Then, And }) => {
let result: Promise<unknown>;

Given('a Cellix instance in app-services phase with all configurations', () => {
cellix = Cellix.initializeInfrastructureServices((registry) => {
registry.registerInfrastructureService(mockService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
});

When('startUp is called directly from app-services', async () => {
result = cellix.startUp();
await result;
const mockHook = app.hook.appStart as unknown as { mock: { calls: [() => Promise<void>][] } };
const appStartCallback = mockHook.mock.calls[0]?.[0];
if (appStartCallback) {
await appStartCallback();
}
});

Then('it should start without requiring any handler registrations', () => {
expect(app.http).not.toHaveBeenCalled();
expect(app.storageQueue).not.toHaveBeenCalled();
});

And('it should transition to started phase', () => {
expect(result).toBeInstanceOf(Promise);
expect(cellix.servicesInitialized).toBe(true);
});
});

Scenario('Rejecting fluent operations after startup', ({ Given, When, Then }) => {
let assertAfterStartGuards: (() => void) | undefined;

Given('a started Cellix application', async () => {
cellix = Cellix.initializeInfrastructureServices((registry) => {
registry.registerInfrastructureService(mockService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
await cellix.startUp();

assertAfterStartGuards = () => {
expect(() => cellix.setContext(() => ({}))).toThrow("Invalid operation in phase 'started'. Allowed phases: infrastructure");
expect(() => cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }))).toThrow("Invalid operation in phase 'started'. Allowed phases: context");
expect(() => cellix.registerAzureFunctionHttpHandler('another-http', { authLevel: 'anonymous' }, () => vi.fn())).toThrow("Invalid operation in phase 'started'. Allowed phases: app-services, handlers");
expect(() => cellix.registerAzureFunctionQueueHandler('another-queue', { queueName: 'another-queue', connection: 'AZURE_STORAGE_CONNECTION_STRING' }, () => vi.fn())).toThrow(
"Invalid operation in phase 'started'. Allowed phases: app-services, handlers",
);
expect(() => cellix.startUp()).toThrow("Invalid operation in phase 'started'. Allowed phases: handlers, app-services");
};
});

When('a fluent bootstrap method is called after startup', () => {
assertAfterStartGuards?.();
});

Then('it should reject further bootstrap mutations in started phase', () => {
// Assertions already executed in When step
});
});

Scenario('Starting up the application', ({ Given, When, Then, And }) => {
let result: Promise<unknown>;

Expand All @@ -324,8 +529,9 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
registry.registerInfrastructureService(mockService);
}) as Cellix<unknown, unknown>;
cellix.setContext(() => ({}));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn() }));
cellix.initializeApplicationServices(() => ({ forRequest: vi.fn(), forSystem: vi.fn() }));
cellix.registerAzureFunctionHttpHandler('test-handler', { authLevel: 'anonymous' }, () => vi.fn());
cellix.registerAzureFunctionQueueHandler('community-update', { queueName: 'community-update', connection: 'AZURE_STORAGE_CONNECTION_STRING' }, () => vi.fn());
});

When('startUp is called', async () => {
Expand Down Expand Up @@ -354,6 +560,17 @@ test.for(feature, ({ Scenario, BeforeEachScenario }) => {
expect(app.hook.appTerminate).toHaveBeenCalled();
});

And('it should register Azure Functions queue handlers with app.storageQueue', () => {
expect(app.storageQueue).toHaveBeenCalledWith(
'community-update',
expect.objectContaining({
queueName: 'community-update',
connection: 'AZURE_STORAGE_CONNECTION_STRING',
handler: expect.any(Function),
}),
);
});

And('it should transition to started phase', () => {
// We can't test private phase, but we can test that services are initialized
expect(cellix.servicesInitialized).toBe(true);
Expand Down
Loading