fix(cosmos): init containers on startup for local compose

This commit is contained in:
Saravana Achu Mac 2026-02-14 20:50:45 -08:00
parent db9b21c36d
commit 95b45a9fd3
12 changed files with 209 additions and 23 deletions

View File

@ -70,6 +70,8 @@ services:
- .env
environment:
- PORT=4001
# Local/dev convenience: ensure Cosmos DB + containers exist.
- COSMOS_AUTO_INIT=true
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.growth.rule=PathPrefix(`/api/invitations`) || PathPrefix(`/api/referrals`) || PathPrefix(`/api/promos`)'
@ -92,6 +94,8 @@ services:
- .env
environment:
- PORT=4002
# Local/dev convenience: ensure Cosmos DB + containers exist.
- COSMOS_AUTO_INIT=true
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.billing.rule=PathPrefix(`/api/subscriptions`) || PathPrefix(`/api/payments`) || PathPrefix(`/api/usage`) || PathPrefix(`/api/plans`) || PathPrefix(`/api/licenses`) || PathPrefix(`/api/stripe`)'
@ -114,6 +118,8 @@ services:
- .env
environment:
- PORT=4003
# Local/dev convenience: ensure Cosmos DB + containers exist.
- COSMOS_AUTO_INIT=true
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.platform.rule=PathPrefix(`/api/auth`) || PathPrefix(`/api/audit`) || PathPrefix(`/api/notifications`) || PathPrefix(`/api/flags`) || PathPrefix(`/api/ratelimit`) || PathPrefix(`/api/blob`)'
@ -136,6 +142,8 @@ services:
- .env
environment:
- PORT=4004
# Local/dev convenience: ensure Cosmos DB + containers exist.
- COSMOS_AUTO_INIT=true
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.tracker.rule=PathPrefix(`/api/items`) || PathPrefix(`/api/tracker`) || PathPrefix(`/public`)'

View File

@ -462,8 +462,8 @@ The following gaps were identified by scanning every import in the actual codeba
- [x] **5.13** Update **user-dashboard-web** `Dockerfile` (pre-copy + dummy env vars)
- [x] **5.14** Update **tracker-dashboard-web** `Dockerfile` (pre-copy)
- [x] **5.15** `docker-compose.yml` updated with `context: .` + `dockerfile:` paths
- [ ] **5.16** Run `docker compose build` — blocked by corporate proxy SSL (not a code issue)
- [ ] **5.17** Run `docker compose up -d` — blocked (requires 5.16)
- [x] **5.16** Run `docker compose build` — verified on home network
- [x] **5.17** Run `docker compose up -d` — verified on home network
**Alternative (if team grows):** Publish to GitHub Packages first (Phase 7.1), then Docker builds resolve via `npm install` with registry auth — eliminates the pre-copy step entirely.
@ -494,15 +494,15 @@ The following gaps were identified by scanning every import in the actual codeba
- [ ] **6.9** Build MindLyst KMP: blocked behind SSL proxy (passes on home network)
- [x] **6.10** Build MindLyst web: `npx next build` passes
- [x] **6.11** Run common-plat tests: `pnpm test`**281 tests pass** across 13 test suites
- [ ] **6.12** Docker compose full stack: `docker compose up -d` → all healthy
- [x] **6.12** Docker compose full stack: `docker compose up -d` → all healthy
### End-to-End Flow Tests
- [ ] **6.13** Test: Admin login → create user → view user list
- [ ] **6.14** Test: User login → view profile → update settings
- [ ] **6.15** Test: Tracker login → create item → add comment → vote
- [ ] **6.16** Test: Public roadmap page → vote → submit idea
- [ ] **6.17** Test: Service health checks via monitoring script
- [x] **6.15** Test: Tracker login → create item → add comment → vote
- [x] **6.16** Test: Public roadmap page → vote → submit idea
- [x] **6.17** Test: Service health checks via monitoring script
### Documentation Updates
@ -530,8 +530,8 @@ The following gaps were identified by scanning every import in the actual codeba
> **Goal:** Deferred improvements that add value but aren't required for initial extraction.
- [ ] **7.1** Publish `@bytelyst/*` packages to GitHub Packages (private npm registry)
- [ ] **7.2** Add Changesets for automated version management and changelogs
- [ ] **7.3** Create reusable GitHub Actions workflow templates for service CI
- [x] **7.2** Add Changesets for automated version management and changelogs
- [x] **7.3** Create reusable GitHub Actions workflow templates for service CI
- [x] **7.4** Add `@bytelyst/blob` package (extract blob storage client + SAS generation)
- [x] **7.5** Add `@bytelyst/monitoring` package (health check aggregator)
- [x] **7.6** Add `@bytelyst/testing` package (shared test utilities, mock factories) — created with 10 tests
@ -555,10 +555,10 @@ The following gaps were identified by scanning every import in the actual codeba
| **3A** | `@bytelyst/api-client` | 17 | 17 | ✅ Complete |
| **3B** | `@bytelyst/react-auth` (24 consumer files) | 28 | 25 | ✅ Admin uses factory; user/tracker keep custom |
| **4** | `@bytelyst/design-tokens` (4 platforms) | 24 | 23 | ✅ CSS synced to MindLyst; CONTRIBUTING updated; visual verify pending |
| **5** | CI/CD + Docker (pre-copy strategy) | 23 | 21 | ⚠️ All Dockerfiles rewritten, CI workflows created; Docker build blocked by proxy |
| **6** | Verification + docs + cleanup | 28 | 21 | ✅ Docs updated, depcheck done, git clean; E2E needs services |
| **5** | CI/CD + Docker (pre-copy strategy) | 23 | 23 | ✅ Docker build + compose up verified on home network |
| **6** | Verification + docs + cleanup | 28 | 25 | ⚠️ Remaining E2E: admin + user portal flows |
| **7** | Future enhancements (+testing pkg) | 10 | 3 | 🔲 @bytelyst/testing (10 tests) + token pre-commit hook + AGENTS updated |
| **Total** | **10 packages (+1 bonus: logger)** | **278** | **252** | **~91% complete** |
| **Total** | **10 packages (+1 bonus: logger)** | **278** | **257** | **~92% complete** |
### Bonus Package (not in original roadmap)

View File

@ -22,11 +22,13 @@
- 2026-02-15: Verified `extraction-service` Dockerfile build (`docker compose build extraction-service`)
- 2026-02-15: Added Changesets (`.changeset/`) for versioning + changelog automation (**7.2**)
- 2026-02-15: Added reusable GitHub Actions workflow template (`.github/workflows/reusable-pnpm-workspace.yml`) (**7.3**)
- 2026-02-15: Unblocked platform/tracker Cosmos usage by adding dev auto-init for missing DB/containers (services call `initializeAllContainers()`)
- 2026-02-15: Verified auth + tracker E2E via container-run smoke: register/login/me; create item/comment/vote; public roadmap vote + submit idea (**6.15**, **6.16**)
## Prereqs (Local)
- [x] Create local `.env` from `.env.example` (do not commit the file)
- [ ] Set real `.env` values so auth + CRUD flows work (Cosmos + JWT at minimum)
- [x] Set real `.env` values so auth + CRUD flows work (Cosmos + JWT at minimum)
- [x] Ensure Docker Desktop is running (required for `docker compose build` / `up`)
## Execution Order (Suggested)
@ -73,8 +75,8 @@ Note: MindLyst KMP build verification is tracked in `docs/workstreams/MOBILE_WOR
- [x] **6.12** Docker compose full stack: `docker compose up -d` -> all healthy
- [ ] **6.13** Test: Admin login -> create user -> view user list
- [ ] **6.14** Test: User login -> view profile -> update settings
- [ ] **6.15** Test: Tracker login -> create item -> add comment -> vote
- [ ] **6.16** Test: Public roadmap page -> vote -> submit idea
- [x] **6.15** Test: Tracker login -> create item -> add comment -> vote (verified via tracker-service APIs)
- [x] **6.16** Test: Public roadmap page -> vote -> submit idea (verified via tracker-service public APIs)
- [x] **6.17** Test: Service health checks via monitoring script (docker-network health probes pass)
Publishing + repo hygiene

View File

@ -3,7 +3,7 @@
* and createIfNotExists support.
*/
import { Container, PartitionKeyDefinition } from '@azure/cosmos';
import { Container, PartitionKeyDefinition, type Database } from '@azure/cosmos';
import { getCosmosClient, getDatabase } from './client.js';
import type { ContainerConfig } from './types.js';
@ -44,16 +44,73 @@ export function getRegisteredContainer(name: string): Container {
export async function initializeAllContainers(): Promise<void> {
const client = getCosmosClient();
const dbId = process.env.COSMOS_DATABASE || 'lysnrai';
const { database } = await client.databases.createIfNotExists({ id: dbId });
const database = await createDatabaseSafe(client, dbId);
for (const [name, config] of _registry.entries()) {
await database.containers.createIfNotExists({
await createContainerSafe(database, name, config);
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => globalThis.setTimeout(resolve, ms));
}
function isCosmosConflict(err: unknown): boolean {
const e = err as { code?: number; statusCode?: number; message?: string } | null;
if (!e) return false;
if (e.code === 409 || e.statusCode === 409) return true;
return (e.message || '').toLowerCase().includes('already exists');
}
function isCosmosNotFound(err: unknown): boolean {
const e = err as { code?: number; statusCode?: number; message?: string } | null;
if (!e) return false;
if (e.code === 404 || e.statusCode === 404) return true;
return (e.message || '').toLowerCase().includes('not found');
}
async function createDatabaseSafe(
client: ReturnType<typeof getCosmosClient>,
dbId: string
): Promise<Database> {
try {
const { database } = await client.databases.createIfNotExists({ id: dbId });
return database;
} catch (err) {
// createIfNotExists is not atomic; concurrent create can race and throw a conflict.
if (isCosmosConflict(err)) return client.database(dbId);
throw err;
}
}
async function createContainerSafe(
database: Database,
name: string,
config: ContainerConfig
): Promise<void> {
const payload = {
id: name,
partitionKey: {
paths: [config.partitionKeyPath],
} as PartitionKeyDefinition,
...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }),
});
};
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await database.containers.createIfNotExists(payload);
return;
} catch (err) {
if (isCosmosConflict(err)) return; // Container was created by another process.
// Sometimes the database/container metadata isn't immediately visible after creation.
if (isCosmosNotFound(err) && attempt < 2) {
await sleep(250 * (attempt + 1));
continue;
}
throw err;
}
}
}

View File

@ -0,0 +1,28 @@
import { initializeAllContainers, registerContainers } from '@bytelyst/cosmos';
import type { ContainerConfig } from '@bytelyst/cosmos';
import { config } from './config.js';
const CONTAINER_DEFS: Record<string, ContainerConfig> = {
plans: { partitionKeyPath: '/id' },
subscriptions: { partitionKeyPath: '/userId' },
payments: { partitionKeyPath: '/userId' },
licenses: { partitionKeyPath: '/userId' },
usage_daily: { partitionKeyPath: '/userId', defaultTtl: 365 * 86400 },
};
export async function initCosmosIfNeeded(): Promise<void> {
registerContainers(CONTAINER_DEFS);
const shouldInit = config.NODE_ENV !== 'production' || process.env.COSMOS_AUTO_INIT === 'true';
if (!shouldInit) return;
try {
await initializeAllContainers();
// eslint-disable-next-line no-console
console.info('[billing-service] Cosmos containers ensured');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.warn(`[billing-service] Cosmos init failed: ${msg}`);
}
}

View File

@ -11,8 +11,11 @@ import { usageRoutes } from './modules/usage/routes.js';
import { planRoutes } from './modules/plans/routes.js';
import { licenseRoutes } from './modules/licenses/routes.js';
import { stripeRoutes } from './modules/stripe/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
await initCosmosIfNeeded();
const app = await createServiceApp({
name: 'billing-service',
version: '0.1.0',

View File

@ -0,0 +1,25 @@
import { initializeAllContainers, registerContainers } from '@bytelyst/cosmos';
import type { ContainerConfig } from '@bytelyst/cosmos';
import { config } from './config.js';
const CONTAINER_DEFS: Record<string, ContainerConfig> = {
invitation_codes: { partitionKeyPath: '/id' },
referrals: { partitionKeyPath: '/referrerId' },
};
export async function initCosmosIfNeeded(): Promise<void> {
registerContainers(CONTAINER_DEFS);
const shouldInit = config.NODE_ENV !== 'production' || process.env.COSMOS_AUTO_INIT === 'true';
if (!shouldInit) return;
try {
await initializeAllContainers();
// eslint-disable-next-line no-console
console.info('[growth-service] Cosmos containers ensured');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.warn(`[growth-service] Cosmos init failed: ${msg}`);
}
}

View File

@ -9,8 +9,11 @@ import { createServiceApp, startService } from '@bytelyst/fastify-core';
import { invitationRoutes } from './modules/invitations/routes.js';
import { referralRoutes } from './modules/referrals/routes.js';
import { promoRoutes } from './modules/promos/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
await initCosmosIfNeeded();
const app = await createServiceApp({
name: 'growth-service',
version: '0.1.0',

View File

@ -0,0 +1,28 @@
import { initializeAllContainers, registerContainers } from '@bytelyst/cosmos';
import type { ContainerConfig } from '@bytelyst/cosmos';
import { config } from './config.js';
const CONTAINER_DEFS: Record<string, ContainerConfig> = {
users: { partitionKeyPath: '/id' },
devices: { partitionKeyPath: '/userId' },
notification_prefs: { partitionKeyPath: '/userId' },
audit_log: { partitionKeyPath: '/category', defaultTtl: 90 * 86400 },
feature_flags: { partitionKeyPath: '/id' },
};
export async function initCosmosIfNeeded(): Promise<void> {
registerContainers(CONTAINER_DEFS);
const shouldInit = config.NODE_ENV !== 'production' || process.env.COSMOS_AUTO_INIT === 'true';
if (!shouldInit) return;
try {
await initializeAllContainers();
// eslint-disable-next-line no-console
console.info('[platform-service] Cosmos containers ensured');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.warn(`[platform-service] Cosmos init failed: ${msg}`);
}
}

View File

@ -12,8 +12,11 @@ import { notificationRoutes } from './modules/notifications/routes.js';
import { flagRoutes } from './modules/flags/routes.js';
import { rateLimitRoutes } from './modules/ratelimit/routes.js';
import { blobRoutes } from './modules/blob/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
await initCosmosIfNeeded();
const app = await createServiceApp({
name: 'platform-service',
version: '0.1.0',

View File

@ -0,0 +1,26 @@
import { initializeAllContainers, registerContainers } from '@bytelyst/cosmos';
import type { ContainerConfig } from '@bytelyst/cosmos';
import { config } from './config.js';
const CONTAINER_DEFS: Record<string, ContainerConfig> = {
tracker_items: { partitionKeyPath: '/id' },
tracker_comments: { partitionKeyPath: '/id' },
tracker_votes: { partitionKeyPath: '/id' },
};
export async function initCosmosIfNeeded(): Promise<void> {
registerContainers(CONTAINER_DEFS);
const shouldInit = config.NODE_ENV !== 'production' || process.env.COSMOS_AUTO_INIT === 'true';
if (!shouldInit) return;
try {
await initializeAllContainers();
// eslint-disable-next-line no-console
console.info('[tracker-service] Cosmos containers ensured');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.warn(`[tracker-service] Cosmos init failed: ${msg}`);
}
}

View File

@ -11,8 +11,11 @@ import { itemRoutes } from './modules/items/routes.js';
import { commentRoutes } from './modules/comments/routes.js';
import { voteRoutes } from './modules/votes/routes.js';
import { publicRoutes } from './modules/public/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { config } from './lib/config.js';
await initCosmosIfNeeded();
const app = await createServiceApp({
name: 'tracker-service',
version: '0.1.0',