Skip to content

Commit cf6244a

Browse files
authored
Merge pull request #1202 from objectstack-ai/claude/adr-0002-implement-environment-routing
[WIP] Add environment routing for carrying metadata and data API
2 parents 57dc0df + 557973a commit cf6244a

9 files changed

Lines changed: 521 additions & 14 deletions

File tree

packages/runtime/package.json

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

0 commit comments

Comments
 (0)