Skip to content

Commit 8d0f0cd

Browse files
Claudehotlong
andauthored
feat: implement ADR-0002 environment routing with hostname resolution
- 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>
1 parent 22eb82d commit 8d0f0cd

6 files changed

Lines changed: 460 additions & 13 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Contracts } from '@objectstack/spec';
4+
type IDataDriver = Contracts.IDataDriver;
5+
import { TursoDriver } from '@objectstack/driver-turso';
6+
import { InMemoryDriver } from '@objectstack/driver-memory';
7+
import { NoopSecretEncryptor } from '@objectstack/service-tenant/environment-provisioning';
8+
import { LocalSQLiteDriver } from '@objectstack/driver-sql/local-sqlite';
9+
10+
/**
11+
* Environment-scoped driver registry with LRU caching.
12+
*
13+
* Resolves environments by hostname or ID, lazily instantiates data drivers,
14+
* and caches them with TTL to avoid re-querying control plane on every request.
15+
*
16+
* Implements ADR-0002 environment routing: request → hostname/header/session →
17+
* sys__environment → sys__database_credential → env-scoped IDataDriver.
18+
*/
19+
export interface EnvironmentDriverRegistry {
20+
/**
21+
* Resolve environment by hostname (e.g. "acme-dev.objectstack.app").
22+
* Returns { environmentId, driver } if found, null otherwise.
23+
* Caches result with TTL.
24+
*/
25+
resolveByHostname(host: string): Promise<{ environmentId: string; driver: IDataDriver } | null>;
26+
27+
/**
28+
* Resolve environment by ID.
29+
* Returns driver if found, null otherwise.
30+
* Caches result with TTL.
31+
*/
32+
resolveById(environmentId: string): Promise<IDataDriver | null>;
33+
34+
/**
35+
* Invalidate cached driver for given environment.
36+
* Call this when environment is updated (e.g. hostname change, credential rotation).
37+
*/
38+
invalidate(environmentId: string): void;
39+
}
40+
41+
interface CacheEntry {
42+
environmentId: string;
43+
driver: IDataDriver;
44+
expiresAt: number;
45+
}
46+
47+
/**
48+
* Default implementation of EnvironmentDriverRegistry with LRU caching.
49+
*/
50+
export class DefaultEnvironmentDriverRegistry implements EnvironmentDriverRegistry {
51+
private readonly controlPlaneDriver: IDataDriver;
52+
private readonly encryptor: NoopSecretEncryptor;
53+
private readonly cacheTTL: number;
54+
private readonly hostnameCache = new Map<string, CacheEntry>();
55+
private readonly idCache = new Map<string, CacheEntry>();
56+
private readonly pendingResolves = new Map<string, Promise<CacheEntry | null>>();
57+
58+
constructor(config: {
59+
controlPlaneDriver: IDataDriver;
60+
encryptor?: NoopSecretEncryptor;
61+
cacheTTLMs?: number;
62+
}) {
63+
this.controlPlaneDriver = config.controlPlaneDriver;
64+
this.encryptor = config.encryptor ?? new NoopSecretEncryptor();
65+
this.cacheTTL = config.cacheTTLMs ?? 5 * 60 * 1000; // 5 minutes default
66+
}
67+
68+
async resolveByHostname(host: string): Promise<{ environmentId: string; driver: IDataDriver } | null> {
69+
// Check cache first
70+
const cached = this.hostnameCache.get(host);
71+
if (cached && cached.expiresAt > Date.now()) {
72+
return { environmentId: cached.environmentId, driver: cached.driver };
73+
}
74+
75+
// Prevent concurrent lookups for same hostname
76+
const cacheKey = `host:${host}`;
77+
const pending = this.pendingResolves.get(cacheKey);
78+
if (pending) {
79+
const result = await pending;
80+
return result ? { environmentId: result.environmentId, driver: result.driver } : null;
81+
}
82+
83+
// Resolve from control plane
84+
const resolvePromise = this.fetchAndCacheByHostname(host);
85+
this.pendingResolves.set(cacheKey, resolvePromise);
86+
87+
try {
88+
const entry = await resolvePromise;
89+
return entry ? { environmentId: entry.environmentId, driver: entry.driver } : null;
90+
} finally {
91+
this.pendingResolves.delete(cacheKey);
92+
}
93+
}
94+
95+
async resolveById(environmentId: string): Promise<IDataDriver | null> {
96+
// Check cache first
97+
const cached = this.idCache.get(environmentId);
98+
if (cached && cached.expiresAt > Date.now()) {
99+
return cached.driver;
100+
}
101+
102+
// Prevent concurrent lookups for same ID
103+
const cacheKey = `id:${environmentId}`;
104+
const pending = this.pendingResolves.get(cacheKey);
105+
if (pending) {
106+
const result = await pending;
107+
return result?.driver ?? null;
108+
}
109+
110+
// Resolve from control plane
111+
const resolvePromise = this.fetchAndCacheById(environmentId);
112+
this.pendingResolves.set(cacheKey, resolvePromise);
113+
114+
try {
115+
const entry = await resolvePromise;
116+
return entry?.driver ?? null;
117+
} finally {
118+
this.pendingResolves.delete(cacheKey);
119+
}
120+
}
121+
122+
invalidate(environmentId: string): void {
123+
// Remove from ID cache
124+
this.idCache.delete(environmentId);
125+
126+
// Remove from hostname cache (need to find entry by environmentId)
127+
for (const [hostname, entry] of this.hostnameCache.entries()) {
128+
if (entry.environmentId === environmentId) {
129+
this.hostnameCache.delete(hostname);
130+
}
131+
}
132+
}
133+
134+
private async fetchAndCacheByHostname(host: string): Promise<CacheEntry | null> {
135+
try {
136+
// Query control plane: SELECT ... FROM sys__environment WHERE hostname = ? LIMIT 1
137+
const result = await this.controlPlaneDriver.find('environment', {
138+
where: { hostname: host },
139+
limit: 1,
140+
});
141+
142+
const rows = Array.isArray(result) ? result : (result as any)?.value ?? [];
143+
const envRow = rows[0];
144+
145+
if (!envRow) {
146+
return null;
147+
}
148+
149+
const entry = await this.buildCacheEntry(envRow);
150+
if (entry) {
151+
this.hostnameCache.set(host, entry);
152+
this.idCache.set(entry.environmentId, entry);
153+
}
154+
155+
return entry;
156+
} catch (error) {
157+
console.error(`[EnvironmentRegistry] Failed to resolve hostname ${host}:`, error);
158+
return null;
159+
}
160+
}
161+
162+
private async fetchAndCacheById(environmentId: string): Promise<CacheEntry | null> {
163+
try {
164+
// Query control plane: SELECT ... FROM sys__environment WHERE id = ? LIMIT 1
165+
const result = await this.controlPlaneDriver.find('environment', {
166+
where: { id: environmentId },
167+
limit: 1,
168+
});
169+
170+
const rows = Array.isArray(result) ? result : (result as any)?.value ?? [];
171+
const envRow = rows[0];
172+
173+
if (!envRow) {
174+
return null;
175+
}
176+
177+
const entry = await this.buildCacheEntry(envRow);
178+
if (entry) {
179+
this.idCache.set(environmentId, entry);
180+
if (envRow.hostname) {
181+
this.hostnameCache.set(envRow.hostname, entry);
182+
}
183+
}
184+
185+
return entry;
186+
} catch (error) {
187+
console.error(`[EnvironmentRegistry] Failed to resolve environment ID ${environmentId}:`, error);
188+
return null;
189+
}
190+
}
191+
192+
private async buildCacheEntry(envRow: any): Promise<CacheEntry | null> {
193+
const environmentId = envRow.id;
194+
const databaseUrl = envRow.database_url;
195+
const databaseDriver = envRow.database_driver;
196+
197+
if (!databaseUrl || !databaseDriver) {
198+
console.warn(`[EnvironmentRegistry] Environment ${environmentId} missing database_url or database_driver`);
199+
return null;
200+
}
201+
202+
// Fetch active credential
203+
const credResult = await this.controlPlaneDriver.find('database_credential', {
204+
where: { environment_id: environmentId, status: 'active' },
205+
limit: 1,
206+
});
207+
208+
const credRows = Array.isArray(credResult) ? credResult : (credResult as any)?.value ?? [];
209+
const credRow = credRows[0];
210+
211+
if (!credRow) {
212+
console.warn(`[EnvironmentRegistry] No active credential for environment ${environmentId}`);
213+
return null;
214+
}
215+
216+
// Decrypt secret
217+
const plaintextSecret = await Promise.resolve(
218+
this.encryptor.decrypt(credRow.secret_ciphertext),
219+
);
220+
221+
// Instantiate driver based on driver type
222+
const driver = this.createDriver(databaseDriver, databaseUrl, plaintextSecret);
223+
224+
return {
225+
environmentId,
226+
driver,
227+
expiresAt: Date.now() + this.cacheTTL,
228+
};
229+
}
230+
231+
private createDriver(driverType: string, databaseUrl: string, authToken: string): IDataDriver {
232+
switch (driverType) {
233+
case 'memory': {
234+
// Memory driver: URL format is memory://dbname or memory://
235+
const dbName = databaseUrl.replace('memory://', '') || 'default';
236+
return new InMemoryDriver({
237+
name: `com.objectstack.driver.memory.${dbName}`,
238+
persistence: 'file', // Use file persistence for environments
239+
});
240+
}
241+
242+
case 'sqlite': {
243+
// SQLite driver: URL format is file:./path/to/db.db
244+
const filePath = databaseUrl.replace('file:', '');
245+
return new LocalSQLiteDriver({
246+
name: 'com.objectstack.driver.sql',
247+
url: filePath,
248+
});
249+
}
250+
251+
case 'turso': {
252+
// Turso driver: URL format is libsql://hostname
253+
return new TursoDriver({
254+
name: 'com.objectstack.driver.turso',
255+
url: databaseUrl,
256+
authToken,
257+
});
258+
}
259+
260+
default:
261+
throw new Error(`[EnvironmentRegistry] Unsupported driver type: ${driverType}`);
262+
}
263+
}
264+
}
265+
266+
/**
267+
* Create a default environment driver registry instance.
268+
*/
269+
export function createEnvironmentDriverRegistry(
270+
controlPlaneDriver: IDataDriver,
271+
options?: {
272+
encryptor?: NoopSecretEncryptor;
273+
cacheTTLMs?: number;
274+
},
275+
): EnvironmentDriverRegistry {
276+
return new DefaultEnvironmentDriverRegistry({
277+
controlPlaneDriver,
278+
encryptor: options?.encryptor,
279+
cacheTTLMs: options?.cacheTTLMs,
280+
});
281+
}

0 commit comments

Comments
 (0)