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):
- 缓存命中 → 直接返回
SELECT id, database_url, database_driver FROM sys__environment WHERE hostname = ? LIMIT 1
- 读
sys__database_credential active secret → encryptor.decrypt()
- 按
database_driver 构造 driver(memory / sqlite / turso)
- 写
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
优先级:
request.headers.host → envRegistry.resolveByHostname(host) — 命中则直接注入
request.headers['x-environment-id'] → envRegistry.resolveById(id)
authService.api.getSession() → session.activeEnvironmentId → resolveById()
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 |
新建:端到端隔离测试 |
已存在可直接复用(无需大改):
EnvironmentProvisioningService、LocalSQLiteEnvironmentDatabaseAdapter、TursoEnvironmentDatabaseAdapter、MockEnvironmentDatabaseAdapter、NoopSecretEncryptor — packages/services/service-tenant/src/environment-provisioning.ts
风险
- 并发防护:
resolveByHostname / resolveById 用 Map<key, Promise> 防重复构造 driver
- handleCloud 双重 insert:直接委托给
EnvironmentProvisioningService;删除手写 insert 块
- better-auth activeEnvironmentId:标准 org plugin 无此字段,需
additionalFields 扩展
- memory driver 跨请求:进程重启后丢失,仅开发用途;文档标注
- 开发环境 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
Context(为什么要做)
目前
POST /api/v1/cloud/environments会在 control plane 中插入sys__environment行,但这些仅是元数据登记。HttpDispatcher.dispatch()中的/meta/*和/data/*分支始终调用 kernel 上全局唯一的objectqlservice — 所有环境的业务数据都写到同一个物理 DB,环境隔离仅停留在表面。本次补齐 ADR‑0002(
docs/adr/0002-environment-database-isolation.md)规定的"Session → Routing"链路:Design Decisions(已与用户确认)
sys__environment.hostname精确匹配——创建时平台自动写入{org}-{env}.{rootDomain},自定义域名直接 UPDATE 同一字段;一次等值查询搞定,不做字符串切分driver参数分派:memoryMemoryDriver、sqliteLocalSQLiteAdapter、tursoTursoAdapter关键改动点
1. Spec —
packages/spec/src/cloud/environment.zod.ts在
SysEnvironmentSchema中:customDomain(之前草稿字段)2.
packages/services/service-tenant/src/tenant-plugin.tsSysEnvironmentobject 增加hostname列(type: 'text',unique: true)。3. 新建
packages/runtime/src/environment-registry.tsEnvironmentDriverRegistry— 按environmentId懒加载并缓存IDataDriver。resolveByHostname(host):SELECT id, database_url, database_driver FROM sys__environment WHERE hostname = ? LIMIT 1sys__database_credentialactive secret →encryptor.decrypt()database_driver构造 driver(memory / sqlite / turso)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.ts4. 扩展
packages/runtime/src/http-dispatcher.ts4.1
HttpProtocolContext扩展(line 19)4.2 新增
resolveEnvironmentContext(context, path),在dispatch()入口调用跳过路径:
/auth、/cloud、/health、/discovery、/meta优先级:
request.headers.host→envRegistry.resolveByHostname(host)— 命中则直接注入request.headers['x-environment-id']→envRegistry.resolveById(id)authService.api.getSession()→session.activeEnvironmentId→resolveById()session.activeOrganizationId→ 查sys__environment WHERE organization_id=? AND is_default=true未命中 →
context.dataDriver留 undefined,handleData返回428 Precondition Required: environment not resolved4.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构造函数向后兼容;未传时 lazy 从 kernel 读。
5.
handleCloud POST /environments(line 1091)接入真实 provisioning删除 mock
buildDatabaseUrl()+ 手写ql.insert(CRED, ...)块,改为:EnvironmentProvisioningService.provisionEnvironment()中在sys__environmentinsert 时带上hostname。6.
PATCH /cloud/environments/:id(自定义域名绑定)已有的 update 路由直接支持,用户传
{ hostname: 'api.acme.com' }即可;envRegistry.invalidate(id)让缓存失效。7.
/cloud/environments/:id/activate真正落地better-auth 配置增加
additionalFields: { activeEnvironmentId: { type: 'string', required: false } }。8. Control-plane vs Data-plane 路由规则
/meta/*/cloud/*/auth/*/data/*context.dataDriver;未解析 → 428涉及文件
packages/spec/src/cloud/environment.zod.tshostname字段packages/services/service-tenant/src/tenant-plugin.tsSysEnvironment增加hostname列(UNIQUE)packages/services/service-tenant/src/environment-provisioning.tsprovisionEnvironment()入参加hostname,insert 时写入packages/runtime/src/environment-registry.tsEnvironmentDriverRegistrypackages/runtime/src/http-dispatcher.tspackages/runtime/src/index.tsEnvironmentDriverRegistryapps/server/server/index.tsactiveEnvironmentIdadditionalField;构造并传入 envRegistrypackages/runtime/src/__tests__/environment-routing.test.ts已存在可直接复用(无需大改):
EnvironmentProvisioningService、LocalSQLiteEnvironmentDatabaseAdapter、TursoEnvironmentDatabaseAdapter、MockEnvironmentDatabaseAdapter、NoopSecretEncryptor—packages/services/service-tenant/src/environment-provisioning.ts风险
resolveByHostname/resolveById用Map<key, Promise>防重复构造 driverEnvironmentProvisioningService;删除手写 insert 块additionalFields扩展X-Environment-Idheader 即可;或用lvh.me通配到 127.0.0.1Verification