diff --git a/apps/api/src/services/channel-spine-provisioning.test.ts b/apps/api/src/services/channel-spine-provisioning.test.ts index f7777420..5601da15 100644 --- a/apps/api/src/services/channel-spine-provisioning.test.ts +++ b/apps/api/src/services/channel-spine-provisioning.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { channelSpineDefaults } from "./channel-spine-provisioning.service.js"; +const repoRoot = join(import.meta.dir, "../../../.."); +const productionCompose = readFileSync( + join(repoRoot, "compose.production.yml"), + "utf8", +); + /** * New workspaces inherit the deployment's defaults; existing ones never do. * The env module snapshots at import, so this asserts the parsing contract @@ -14,6 +22,17 @@ describe("channelSpineDefaults", () => { }); }); +describe("production channel-spine defaults", () => { + test("passes explicit provider and revision defaults to every API replica", () => { + expect(productionCompose).toContain( + "CHANNEL_SPINE_DEFAULT_PROVIDERS: ${CHANNEL_SPINE_DEFAULT_PROVIDERS:-}", + ); + expect(productionCompose).toContain( + "CHANNEL_SPINE_DEFAULT_REVISION: ${CHANNEL_SPINE_DEFAULT_REVISION:-}", + ); + }); +}); + describe("provider list parsing", () => { // Mirrors the parsing in channelSpineDefaults, which reads a snapshotted // env value; this pins the shape a deployment is expected to write. diff --git a/apps/api/src/services/tenant.service.ts b/apps/api/src/services/tenant.service.ts index 3377b654..426d6b59 100644 --- a/apps/api/src/services/tenant.service.ts +++ b/apps/api/src/services/tenant.service.ts @@ -1,5 +1,6 @@ import { getTenantSchemaName, + reconcileChannelSpineConcurrentIndexes, reconcileTenantSchema, type TenantDatabase as TenantDatabaseType, } from "@wateaminbox/database"; @@ -67,6 +68,13 @@ export async function createTenantSchema(companyId: string): Promise { const schemaName = getSchemaName(companyId); await sql`SELECT setup_tenant_schema(${schemaName})`.execute(baseTenantDb); await reconcileTenantSchema(baseTenantDb, schemaName); + // The provider-spine uniqueness indexes use CREATE INDEX CONCURRENTLY and + // deliberately do not live in a tenant migration. When this deployment + // seeds neutral defaults, provisioning must build them before returning or + // every new workspace would be flagged on but fail readiness indefinitely. + if (env.CHANNEL_SPINE_DEFAULT_PROVIDERS.trim()) { + await reconcileChannelSpineConcurrentIndexes(baseTenantDb, schemaName); + } } export async function dropTenantSchema(companyId: string): Promise { diff --git a/compose.production.yml b/compose.production.yml index 4dde33e9..54326c53 100644 --- a/compose.production.yml +++ b/compose.production.yml @@ -163,6 +163,10 @@ services: PORT: "4445" APP_URL: https://${APP_DOMAIN:?set APP_DOMAIN} CORS_ORIGINS: https://${APP_DOMAIN:?set APP_DOMAIN} + # Hosted deployments may opt new workspaces into provider-neutral + # messaging. Empty defaults preserve the self-hosted legacy path. + CHANNEL_SPINE_DEFAULT_PROVIDERS: ${CHANNEL_SPINE_DEFAULT_PROVIDERS:-} + CHANNEL_SPINE_DEFAULT_REVISION: ${CHANNEL_SPINE_DEFAULT_REVISION:-} JWT_SECRET_FILE: /run/secrets/jwt_secret # Envelope key for provider credentials (a Telegram bot token, and any # future token-based adapter). Optional: a linked-device-only host stores