From 95b45a9fd3eb65e449f4a7f97040cdee4538ff32 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 14 Feb 2026 20:50:45 -0800 Subject: [PATCH] fix(cosmos): init containers on startup for local compose --- docker-compose.yml | 8 ++ docs/ROADMAP.md | 22 +++--- .../ALL_OTHER_WORKSTREAMS_REMAINING.md | 8 +- packages/cosmos/src/containers.ts | 75 ++++++++++++++++--- .../billing-service/src/lib/cosmos-init.ts | 28 +++++++ services/billing-service/src/server.ts | 3 + .../growth-service/src/lib/cosmos-init.ts | 25 +++++++ services/growth-service/src/server.ts | 3 + .../platform-service/src/lib/cosmos-init.ts | 28 +++++++ services/platform-service/src/server.ts | 3 + .../tracker-service/src/lib/cosmos-init.ts | 26 +++++++ services/tracker-service/src/server.ts | 3 + 12 files changed, 209 insertions(+), 23 deletions(-) create mode 100644 services/billing-service/src/lib/cosmos-init.ts create mode 100644 services/growth-service/src/lib/cosmos-init.ts create mode 100644 services/platform-service/src/lib/cosmos-init.ts create mode 100644 services/tracker-service/src/lib/cosmos-init.ts diff --git a/docker-compose.yml b/docker-compose.yml index 342e6e86..a44e3458 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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`)' diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 217edf49..edc1da75 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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) diff --git a/docs/workstreams/ALL_OTHER_WORKSTREAMS_REMAINING.md b/docs/workstreams/ALL_OTHER_WORKSTREAMS_REMAINING.md index 4a27195e..91633b1c 100644 --- a/docs/workstreams/ALL_OTHER_WORKSTREAMS_REMAINING.md +++ b/docs/workstreams/ALL_OTHER_WORKSTREAMS_REMAINING.md @@ -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 diff --git a/packages/cosmos/src/containers.ts b/packages/cosmos/src/containers.ts index 7940c5b8..943aa37e 100644 --- a/packages/cosmos/src/containers.ts +++ b/packages/cosmos/src/containers.ts @@ -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 { 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({ - id: name, - partitionKey: { - paths: [config.partitionKeyPath], - } as PartitionKeyDefinition, - ...(config.defaultTtl != null && { defaultTtl: config.defaultTtl }), - }); + await createContainerSafe(database, name, config); + } +} + +function sleep(ms: number): Promise { + 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, + dbId: string +): Promise { + 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 { + 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; + } } } diff --git a/services/billing-service/src/lib/cosmos-init.ts b/services/billing-service/src/lib/cosmos-init.ts new file mode 100644 index 00000000..065468aa --- /dev/null +++ b/services/billing-service/src/lib/cosmos-init.ts @@ -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 = { + plans: { partitionKeyPath: '/id' }, + subscriptions: { partitionKeyPath: '/userId' }, + payments: { partitionKeyPath: '/userId' }, + licenses: { partitionKeyPath: '/userId' }, + usage_daily: { partitionKeyPath: '/userId', defaultTtl: 365 * 86400 }, +}; + +export async function initCosmosIfNeeded(): Promise { + 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}`); + } +} diff --git a/services/billing-service/src/server.ts b/services/billing-service/src/server.ts index b94f4c6a..66f9b31a 100644 --- a/services/billing-service/src/server.ts +++ b/services/billing-service/src/server.ts @@ -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', diff --git a/services/growth-service/src/lib/cosmos-init.ts b/services/growth-service/src/lib/cosmos-init.ts new file mode 100644 index 00000000..244070b5 --- /dev/null +++ b/services/growth-service/src/lib/cosmos-init.ts @@ -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 = { + invitation_codes: { partitionKeyPath: '/id' }, + referrals: { partitionKeyPath: '/referrerId' }, +}; + +export async function initCosmosIfNeeded(): Promise { + 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}`); + } +} diff --git a/services/growth-service/src/server.ts b/services/growth-service/src/server.ts index d28bf62e..135a0fd6 100644 --- a/services/growth-service/src/server.ts +++ b/services/growth-service/src/server.ts @@ -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', diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts new file mode 100644 index 00000000..65df1488 --- /dev/null +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -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 = { + 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 { + 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}`); + } +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 49a7355f..a4fc7bdc 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -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', diff --git a/services/tracker-service/src/lib/cosmos-init.ts b/services/tracker-service/src/lib/cosmos-init.ts new file mode 100644 index 00000000..ed46287f --- /dev/null +++ b/services/tracker-service/src/lib/cosmos-init.ts @@ -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 = { + tracker_items: { partitionKeyPath: '/id' }, + tracker_comments: { partitionKeyPath: '/id' }, + tracker_votes: { partitionKeyPath: '/id' }, +}; + +export async function initCosmosIfNeeded(): Promise { + 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}`); + } +} diff --git a/services/tracker-service/src/server.ts b/services/tracker-service/src/server.ts index c39b2139..6088dc08 100644 --- a/services/tracker-service/src/server.ts +++ b/services/tracker-service/src/server.ts @@ -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',