Skip to content

多环境运行时路由:让创建后的环境真正承载 metadata 与 data API #1201

@hotlong

Description

@hotlong

Context(为什么要做)

目前 POST /api/v1/cloud/environments 会在 control plane 中插入 sys__environment 行,但这些仅是元数据登记HttpDispatcher.dispatch() 中的 /meta/*/data/* 分支始终调用 kernel 上全局唯一的 objectql service — 所有环境的业务数据都写到同一个物理 DB,环境隔离仅停留在表面。

本次补齐 ADR‑0002(docs/adr/0002-environment-database-isolation.md)规定的"Session → Routing"链路:

request → hostname/header/session → sys__environment → sys__database_credential → env-scoped IDataDriver

Design Decisions(已与用户确认)

决策点 选择
Env 识别(主) sys__environment.hostname 精确匹配——创建时平台自动写入 {org}-{env}.{rootDomain},自定义域名直接 UPDATE 同一字段;一次等值查询搞定,不做字符串切分
物理 DB driver 参数分派:memory MemoryDriver、sqlite LocalSQLiteAdapter、turso TursoAdapter
Kernel 隔离粒度 共享 Kernel,按请求切换 ObjectQL driver

关键改动点

1. Spec — packages/spec/src/cloud/environment.zod.ts

SysEnvironmentSchema 中:

  • 删除 customDomain (之前草稿字段)
  • 新增:
hostname: z.string().optional().describe(
  'Canonical hostname for this environment (e.g. acme-dev.objectstack.app or api.acme.com). UNIQUE. Auto-set on creation; can be overridden for custom domains.',
),

2. packages/services/service-tenant/src/tenant-plugin.ts

SysEnvironment object 增加 hostname 列(type: 'text', unique: true)。

3. 新建 packages/runtime/src/environment-registry.ts

EnvironmentDriverRegistry — 按 environmentId 懒加载并缓存 IDataDriver

export interface EnvironmentDriverRegistry {
  resolveByHostname(host: string): Promise<{ environmentId: string; driver: IDataDriver } | null>;
  resolveById(environmentId: string): Promise<IDataDriver | null>;
  invalidate(environmentId: string): void;
}
  • resolveByHostname(host):
    1. 缓存命中 → 直接返回
    2. SELECT id, database_url, database_driver FROM sys__environment WHERE hostname = ? LIMIT 1
    3. sys__database_credential active secret → encryptor.decrypt()
    4. database_driver 构造 driver(memory / sqlite / turso)
    5. Map<host, { environmentId, driver, expiresAt }> LRU(TTL 5 min)
  • resolveById(envId) 同上,查 WHERE id = ?
  • 并发防护:Map<key, Promise<...>>(同一 key 第二次请求挂载到同一 Promise)
  • 复用:NoopSecretEncryptor、adapter 类均来自 packages/services/service-tenant/src/environment-provisioning.ts

4. 扩展 packages/runtime/src/http-dispatcher.ts

4.1 HttpProtocolContext 扩展(line 19)

export interface HttpProtocolContext {
  request: any;
  response?: any;
  environmentId?: string;   // 新增
  dataDriver?: IDataDriver; // 新增
}

4.2 新增 resolveEnvironmentContext(context, path),在 dispatch() 入口调用

跳过路径:/auth/cloud/health/discovery/meta

优先级:

  1. request.headers.hostenvRegistry.resolveByHostname(host) — 命中则直接注入
  2. request.headers['x-environment-id']envRegistry.resolveById(id)
  3. authService.api.getSession()session.activeEnvironmentIdresolveById()
  4. session.activeOrganizationId → 查 sys__environment WHERE organization_id=? AND is_default=true

未命中 → context.dataDriver 留 undefined,handleData 返回 428 Precondition Required: environment not resolved

4.3 callData(action, params, dataDriver?: IDataDriver)(line 86)

传入时优先于 this.getObjectQLService()

4.4 handleData()(line 599)

所有 callData(action, params)callData(action, params, context.dataDriver)handleMetadata 不变(control plane)。

4.5 HttpDispatcher 构造函数

constructor(kernel: ObjectKernel, envRegistry?: EnvironmentDriverRegistry)

向后兼容;未传时 lazy 从 kernel 读。

5. handleCloud POST /environments(line 1091)接入真实 provisioning

删除 mock buildDatabaseUrl() + 手写 ql.insert(CRED, ...) 块,改为:

const orgRow = await ql.findOne('sys__organization', { where: { id: organizationId } });
const computedHostname = req.hostname ?? `${orgRow.slug}-${req.slug}.${rootDomain}`;

const provisioning = new EnvironmentProvisioningService({
  controlPlaneDriver: ql,
  adapters: [
    new LocalSQLiteEnvironmentDatabaseAdapter(),
    process.env.TURSO_API_TOKEN
      ? new TursoEnvironmentDatabaseAdapter({ apiToken: ..., organization: ... })
      : new MockEnvironmentDatabaseAdapter('turso'),
  ],
  encryptor: new NoopSecretEncryptor(),
});

const result = await provisioning.provisionEnvironment({
  organizationId, slug, displayName, envType, driver,
  hostname: computedHostname,  // 新增传入
  region, isDefault, createdBy, storageLimitMb,
});

EnvironmentProvisioningService.provisionEnvironment() 中在 sys__environment insert 时带上 hostname

6. PATCH /cloud/environments/:id(自定义域名绑定)

已有的 update 路由直接支持,用户传 { hostname: 'api.acme.com' } 即可;envRegistry.invalidate(id) 让缓存失效。

7. /cloud/environments/:id/activate 真正落地

await authService?.api?.updateUser?.({
  headers: _context.request.headers,
  body: { activeEnvironmentId: id },
});

better-auth 配置增加 additionalFields: { activeEnvironmentId: { type: 'string', required: false } }

8. Control-plane vs Data-plane 路由规则

路径 Driver
/meta/* control plane(kernel 默认)
/cloud/* control plane
/auth/* better-auth 自管
/data/* context.dataDriver;未解析 → 428

涉及文件

文件 操作
packages/spec/src/cloud/environment.zod.ts 新增 hostname 字段
packages/services/service-tenant/src/tenant-plugin.ts SysEnvironment 增加 hostname 列(UNIQUE)
packages/services/service-tenant/src/environment-provisioning.ts provisionEnvironment() 入参加 hostname,insert 时写入
packages/runtime/src/environment-registry.ts 新建EnvironmentDriverRegistry
packages/runtime/src/http-dispatcher.ts 扩展 context、dispatch 中间件、callData 增参、handleCloud 接真实 provisioning、activate 落地
packages/runtime/src/index.ts 导出 EnvironmentDriverRegistry
apps/server/server/index.ts better-auth activeEnvironmentId additionalField;构造并传入 envRegistry
packages/runtime/src/__tests__/environment-routing.test.ts 新建:端到端隔离测试

已存在可直接复用(无需大改)

  • EnvironmentProvisioningServiceLocalSQLiteEnvironmentDatabaseAdapterTursoEnvironmentDatabaseAdapterMockEnvironmentDatabaseAdapterNoopSecretEncryptorpackages/services/service-tenant/src/environment-provisioning.ts

风险

  1. 并发防护resolveByHostname / resolveByIdMap<key, Promise> 防重复构造 driver
  2. handleCloud 双重 insert:直接委托给 EnvironmentProvisioningService;删除手写 insert 块
  3. better-auth activeEnvironmentId:标准 org plugin 无此字段,需 additionalFields 扩展
  4. memory driver 跨请求:进程重启后丢失,仅开发用途;文档标注
  5. 开发环境 hostname:localhost 不支持子域,回退 X-Environment-Id header 即可;或用 lvh.me 通配到 127.0.0.1

Verification

# 启动 dev server
pnpm dev

# 1. 创建两个 sqlite 环境
curl -X POST localhost:3000/api/v1/cloud/environments \
  -d '{"organizationId":"ORG","slug":"dev","driver":"sqlite"}'
# → hostname 自动写入: acme-dev.objectstack.app

curl -X POST localhost:3000/api/v1/cloud/environments \
  -d '{"organizationId":"ORG","slug":"staging","driver":"sqlite"}'
# → hostname: acme-staging.objectstack.app

# 2. 通过 X-Environment-Id 写数据(本地 dev 场景)
curl -X POST localhost:3000/api/v1/data/contact \
  -H "X-Environment-Id: $ENV_DEV" -d '{"name":"Alice"}'
curl -X POST localhost:3000/api/v1/data/contact \
  -H "X-Environment-Id: $ENV_STAGING" -d '{"name":"Bob"}'

# 3. 验证隔离
curl localhost:3000/api/v1/data/contact -H "X-Environment-Id: $ENV_DEV"
# → [Alice]
curl localhost:3000/api/v1/data/contact -H "X-Environment-Id: $ENV_STAGING"
# → [Bob]

# 4. 自定义域名(绑定后通过 hostname 直接路由)
curl -X PATCH localhost:3000/api/v1/cloud/environments/$ENV_DEV \
  -d '{"hostname":"api.acme.test"}'
# /etc/hosts: 127.0.0.1 api.acme.test
curl http://api.acme.test:3000/api/v1/data/contact   # → [Alice]

# 5. 单测
pnpm --filter @objectstack/runtime test environment-routing

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions