Skip to content

[WIP] Add environment routing for carrying metadata and data API#1202

Merged
hotlong merged 7 commits intomainfrom
claude/adr-0002-implement-environment-routing
Apr 21, 2026
Merged

[WIP] Add environment routing for carrying metadata and data API#1202
hotlong merged 7 commits intomainfrom
claude/adr-0002-implement-environment-routing

Conversation

@Claude
Copy link
Copy Markdown
Contributor

@Claude Claude AI commented Apr 21, 2026

Thanks for asking me to work on this. I will get started on it and keep this PR's description up to date as I form a plan and make progress.


This section details on the original issue you should resolve

<issue_title>多环境运行时路由:让创建后的环境真正承载 metadata 与 data API</issue_title>
<issue_description>

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

</issue_description>

Comments on the Issue (you are @claude[agent] in this section)

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-demo Ready Ready Preview, Comment Apr 21, 2026 5:36am
spec Ready Ready Preview, Comment Apr 21, 2026 5:36am

Request Review

- Add hostname field to EnvironmentSchema and SysEnvironment object
- Create EnvironmentDriverRegistry for lazy driver instantiation with LRU cache
- Extend HttpProtocolContext with environmentId and dataDriver fields
- Implement resolveEnvironmentContext() with 4-tier precedence (hostname, header, session, default)
- Update handleData() to use environment-scoped drivers
- Modify handleCloud POST /environments to auto-compute hostname
- Export EnvironmentDriverRegistry from runtime package

Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/8385b77f-f7ed-4241-8e09-25c65d8d98b0

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…ar deps

- Replace static imports with dynamic imports for drivers
- Add NoopSecretEncryptor to runtime package (avoid cross-package dependency)
- Export SecretEncryptor interface from runtime
- Use correct SqlDriver configuration for SQLite (better-sqlite3 client)

Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/8385b77f-f7ed-4241-8e09-25c65d8d98b0

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
@hotlong hotlong marked this pull request as ready for review April 21, 2026 05:37
Copilot AI review requested due to automatic review settings April 21, 2026 05:37
@hotlong hotlong merged commit cf6244a into main Apr 21, 2026
16 checks passed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces multi-environment data-plane routing so /data/* requests can be resolved to an environment (via hostname/header/session) and dispatched against an environment-scoped database/driver, while keeping control-plane routes (/meta/*, /cloud/*) on the global kernel.

Changes:

  • Add hostname to the environment spec and system environment object schema (unique, used for routing).
  • Introduce a runtime EnvironmentDriverRegistry with TTL caching and dynamic driver loading.
  • Extend HttpDispatcher to resolve environment context per request and require an environment for data-plane requests when the registry is enabled.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
pnpm-lock.yaml Adds workspace links for driver packages referenced by runtime registry logic.
packages/spec/src/cloud/environment.zod.ts Adds optional hostname to Environment + ProvisionEnvironmentRequest schemas.
packages/services/service-tenant/src/objects/sys-environment.object.ts Adds hostname field (unique) + index for sys environment rows.
packages/services/service-tenant/src/environment-provisioning.ts Persists hostname when provisioning environments.
packages/runtime/tsup.config.ts Adds a runtime-specific tsup config and marks driver packages as external.
packages/runtime/src/index.ts Exports the new environment registry types/constructors.
packages/runtime/src/http-dispatcher.ts Adds env resolution + enforces env presence for /data/* when enabled; plumbs env context into data calls.
packages/runtime/src/environment-registry.ts New registry that resolves env → driver via control-plane lookups + caches results.
packages/runtime/package.json Adds driver packages as devDependencies and updates build config path.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

*/
private async callData(action: string, params: any): Promise<any> {
private async callData(action: string, params: any, dataDriver?: any): Promise<any> {
const protocol = await this.resolveService('protocol');
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callData() currently prefers the kernel-level protocol service for get/query. That service will still be backed by the kernel’s global ObjectQL/driver, so data-plane isolation won’t apply when protocol is registered, even if an env-scoped driver/engine is passed in. To make routing effective, make the protocol layer accept an env-scoped backend (or bypass protocol for env-scoped requests and route everything through the env-scoped ObjectQL/driver).

Suggested change
const protocol = await this.resolveService('protocol');
const useEnvScopedBackend = dataDriver != null;
const protocol = useEnvScopedBackend ? null : await this.resolveService('protocol');

Copilot uses AI. Check for mistakes.
Comment on lines +191 to +195
const host = context.request?.headers?.host || context.request?.headers?.['Host'];
if (host) {
// Strip port if present (e.g., "localhost:3000" → "localhost")
const hostname = host.split(':')[0];
const result = await this.envRegistry.resolveByHostname(hostname);
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hostname parsing via host.split(':')[0] will mis-handle IPv6 host headers (e.g. [::1]:3000) and can also behave unexpectedly for non-standard host formats. Use a more robust host/port split (e.g., WHATWG URL parsing with a dummy scheme, or regex that preserves bracketed IPv6) before calling resolveByHostname().

Copilot uses AI. Check for mistakes.
Comment on lines +242 to +249
// Instantiate driver based on driver type
const driver = await this.createDriver(databaseDriver, databaseUrl, plaintextSecret);

return {
environmentId,
driver,
expiresAt: Date.now() + this.cacheTTL,
};
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createDriver() returns an IDataDriver but never calls driver.connect(). Both SqlDriver and InMemoryDriver require connect() to perform setup (e.g., ensure SQLite directories exist, initialize persistence). Without this, env-scoped requests can fail at first query. Ensure drivers are connected before caching/returning them, and consider disconnecting them on invalidate()/eviction to avoid leaking resources.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to 96
private async callData(action: string, params: any, dataDriver?: any): Promise<any> {
const protocol = await this.resolveService('protocol');
const qlService = await this.getObjectQLService();
const qlService = dataDriver ?? await this.getObjectQLService();
const ql = qlService ?? await this.resolveService('objectql');

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

callData() treats the optional dataDriver as if it were an ObjectQL engine (insert/find/update/delete), but EnvironmentDriverRegistry (and the context comment) imply this value is an IDataDriver (which exposes create/find/update(id, ...), etc.). Passing an IDataDriver here will crash at runtime (e.g., ql.insert is not a function) or silently route to the wrong backend. Align the types/abstraction: either pass an env-scoped ObjectQL instance (and rename the param accordingly) or adapt IDataDriver to the engine API before using it here.

Copilot uses AI. Check for mistakes.
Comment on lines +1289 to +1303
// Compute hostname if not provided
// Format: {org-slug}-{env-slug}.{rootDomain}
// For now, use a simple format. In production, fetch org.slug from database.
let computedHostname = req.hostname;
if (!computedHostname) {
// Try to look up organization slug
try {
const orgRow = await findOne('sys__organization', { id: req.organizationId });
const orgSlug = orgRow?.slug || req.organizationId;
const rootDomain = getEnv('ROOT_DOMAIN', 'objectstack.app');
computedHostname = `${orgSlug}-${req.slug}.${rootDomain}`;
} catch {
// Fallback if sys__organization doesn't exist
computedHostname = `${req.organizationId}-${req.slug}.objectstack.app`;
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says “In production, fetch org.slug from database”, but the code already attempts to load sys__organization and use its slug. Update the comment to reflect the current behavior (or remove it) to avoid misleading future edits.

Copilot uses AI. Check for mistakes.
Comment on lines +255 to +261
case 'memory': {
// Memory driver: URL format is memory://dbname or memory://
const { InMemoryDriver } = await import('@objectstack/driver-memory');
return new InMemoryDriver({
persistence: 'file', // Use file persistence for environments
});
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The memory driver branch always uses persistence: 'file' with the driver’s default file path (.objectstack/data/memory-driver.json). That path is shared across all InMemoryDriver instances, so multiple environments will read/write the same persisted file, breaking environment isolation. Use an environment-specific persistence path derived from databaseUrl/environmentId (or disable persistence entirely) so each environment remains isolated.

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +91
async resolveByHostname(host: string): Promise<{ environmentId: string; driver: IDataDriver } | null> {
// Check cache first
const cached = this.hostnameCache.get(host);
if (cached && cached.expiresAt > Date.now()) {
return { environmentId: cached.environmentId, driver: cached.driver };
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveByHostname() caches/looks up hostnames as-is. Incoming Host headers are case-insensitive and may include a trailing dot; without normalization, the same environment can miss cache and fail resolution. Normalize host (e.g., trim(), toLowerCase(), strip trailing '.') before querying/caching.

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +14
/**
* Environment-scoped driver registry with LRU caching.
*
* Resolves environments by hostname or ID, lazily instantiates data drivers,
* and caches them with TTL to avoid re-querying control plane on every request.
*
* Implements ADR-0002 environment routing: request → hostname/header/session →
* sys__environment → sys__database_credential → env-scoped IDataDriver.
*/
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file header/docstring claims “LRU caching”, but the implementation is a plain Map with TTL and no size bound or LRU eviction policy. Either implement an actual LRU (size limit + recency updates) or update the documentation to avoid promising behavior that doesn’t exist.

Copilot uses AI. Check for mistakes.
Comment on lines 26 to +31
"zod": "^4.3.6"
},
"devDependencies": {
"@objectstack/driver-memory": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@objectstack/driver-turso": "workspace:*",
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@objectstack/runtime dynamically imports @objectstack/driver-* at runtime (and marks them as external in tsup), but these packages are listed under devDependencies. When @objectstack/runtime is installed as a dependency, devDependencies won’t be present, so environment routing will fail at runtime. Move these to dependencies (or peerDependencies/optionalDependencies with a clear runtime error when missing).

Suggested change
"zod": "^4.3.6"
},
"devDependencies": {
"@objectstack/driver-memory": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@objectstack/driver-turso": "workspace:*",
"@objectstack/driver-memory": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@objectstack/driver-turso": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file size/l

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants