Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
}
},
"scripts": {
"build": "tsup --config ../../tsup.config.ts",
"build": "tsup --config tsup.config.ts",
"dev": "tsc -w",
"test": "vitest run"
},
Expand All @@ -26,6 +26,9 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@objectstack/driver-memory": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@objectstack/driver-turso": "workspace:*",
Comment on lines 26 to +31
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.
"typescript": "^6.0.2",
"vitest": "^4.1.4"
},
Expand Down
306 changes: 306 additions & 0 deletions packages/runtime/src/environment-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Contracts } from '@objectstack/spec';
type IDataDriver = Contracts.IDataDriver;

/**
* 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.
*/
Comment on lines +6 to +14
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.
export interface EnvironmentDriverRegistry {
/**
* Resolve environment by hostname (e.g. "acme-dev.objectstack.app").
* Returns { environmentId, driver } if found, null otherwise.
* Caches result with TTL.
*/
resolveByHostname(host: string): Promise<{ environmentId: string; driver: IDataDriver } | null>;

/**
* Resolve environment by ID.
* Returns driver if found, null otherwise.
* Caches result with TTL.
*/
resolveById(environmentId: string): Promise<IDataDriver | null>;

/**
* Invalidate cached driver for given environment.
* Call this when environment is updated (e.g. hostname change, credential rotation).
*/
invalidate(environmentId: string): void;
}

interface CacheEntry {
environmentId: string;
driver: IDataDriver;
expiresAt: number;
}

/**
* Secret encryptor interface - must match service-tenant NoopSecretEncryptor
*/
export interface SecretEncryptor {
readonly keyId: string;
encrypt(plaintext: string): Promise<string> | string;
decrypt(ciphertext: string): Promise<string> | string;
}

/**
* No-op encryptor used in development / tests. **Never** use in production.
*/
export class NoopSecretEncryptor implements SecretEncryptor {
readonly keyId = 'noop';
encrypt(plaintext: string): string {
return plaintext;
}
decrypt(ciphertext: string): string {
return ciphertext;
}
}

/**
* Default implementation of EnvironmentDriverRegistry with LRU caching.
*/
export class DefaultEnvironmentDriverRegistry implements EnvironmentDriverRegistry {
private readonly controlPlaneDriver: IDataDriver;
private readonly encryptor: SecretEncryptor;
private readonly cacheTTL: number;
private readonly hostnameCache = new Map<string, CacheEntry>();
private readonly idCache = new Map<string, CacheEntry>();
private readonly pendingResolves = new Map<string, Promise<CacheEntry | null>>();

constructor(config: {
controlPlaneDriver: IDataDriver;
encryptor?: SecretEncryptor;
cacheTTLMs?: number;
}) {
this.controlPlaneDriver = config.controlPlaneDriver;
this.encryptor = config.encryptor ?? new NoopSecretEncryptor();
this.cacheTTL = config.cacheTTLMs ?? 5 * 60 * 1000; // 5 minutes default
}

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 };
}
Comment on lines +86 to +91
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.

// Prevent concurrent lookups for same hostname
const cacheKey = `host:${host}`;
const pending = this.pendingResolves.get(cacheKey);
if (pending) {
const result = await pending;
return result ? { environmentId: result.environmentId, driver: result.driver } : null;
}

// Resolve from control plane
const resolvePromise = this.fetchAndCacheByHostname(host);
this.pendingResolves.set(cacheKey, resolvePromise);

try {
const entry = await resolvePromise;
return entry ? { environmentId: entry.environmentId, driver: entry.driver } : null;
} finally {
this.pendingResolves.delete(cacheKey);
}
}

async resolveById(environmentId: string): Promise<IDataDriver | null> {
// Check cache first
const cached = this.idCache.get(environmentId);
if (cached && cached.expiresAt > Date.now()) {
return cached.driver;
}

// Prevent concurrent lookups for same ID
const cacheKey = `id:${environmentId}`;
const pending = this.pendingResolves.get(cacheKey);
if (pending) {
const result = await pending;
return result?.driver ?? null;
}

// Resolve from control plane
const resolvePromise = this.fetchAndCacheById(environmentId);
this.pendingResolves.set(cacheKey, resolvePromise);

try {
const entry = await resolvePromise;
return entry?.driver ?? null;
} finally {
this.pendingResolves.delete(cacheKey);
}
}

invalidate(environmentId: string): void {
// Remove from ID cache
this.idCache.delete(environmentId);

// Remove from hostname cache (need to find entry by environmentId)
for (const [hostname, entry] of this.hostnameCache.entries()) {
if (entry.environmentId === environmentId) {
this.hostnameCache.delete(hostname);
}
}
}

private async fetchAndCacheByHostname(host: string): Promise<CacheEntry | null> {
try {
// Query control plane: SELECT ... FROM sys__environment WHERE hostname = ? LIMIT 1
const result = await this.controlPlaneDriver.find('environment', {
object: 'environment',
where: { hostname: host },
limit: 1,
});

const rows = Array.isArray(result) ? result : (result as any)?.value ?? [];
const envRow = rows[0];

if (!envRow) {
return null;
}

const entry = await this.buildCacheEntry(envRow);
if (entry) {
this.hostnameCache.set(host, entry);
this.idCache.set(entry.environmentId, entry);
}

return entry;
} catch (error) {
console.error(`[EnvironmentRegistry] Failed to resolve hostname ${host}:`, error);
return null;
}
}

private async fetchAndCacheById(environmentId: string): Promise<CacheEntry | null> {
try {
// Query control plane: SELECT ... FROM sys__environment WHERE id = ? LIMIT 1
const result = await this.controlPlaneDriver.find('environment', {
object: 'environment',
where: { id: environmentId },
limit: 1,
});

const rows = Array.isArray(result) ? result : (result as any)?.value ?? [];
const envRow = rows[0];

if (!envRow) {
return null;
}

const entry = await this.buildCacheEntry(envRow);
if (entry) {
this.idCache.set(environmentId, entry);
if (envRow.hostname) {
this.hostnameCache.set(envRow.hostname, entry);
}
}

return entry;
} catch (error) {
console.error(`[EnvironmentRegistry] Failed to resolve environment ID ${environmentId}:`, error);
return null;
}
}

private async buildCacheEntry(envRow: any): Promise<CacheEntry | null> {
const environmentId = envRow.id;
const databaseUrl = envRow.database_url;
const databaseDriver = envRow.database_driver;

if (!databaseUrl || !databaseDriver) {
console.warn(`[EnvironmentRegistry] Environment ${environmentId} missing database_url or database_driver`);
return null;
}

// Fetch active credential
const credResult = await this.controlPlaneDriver.find('database_credential', {
object: 'database_credential',
where: { environment_id: environmentId, status: 'active' },
limit: 1,
});

const credRows = Array.isArray(credResult) ? credResult : (credResult as any)?.value ?? [];
const credRow = credRows[0];

if (!credRow) {
console.warn(`[EnvironmentRegistry] No active credential for environment ${environmentId}`);
return null;
}

// Decrypt secret
const plaintextSecret = await Promise.resolve(
this.encryptor.decrypt(credRow.secret_ciphertext),
);

// Instantiate driver based on driver type
const driver = await this.createDriver(databaseDriver, databaseUrl, plaintextSecret);

return {
environmentId,
driver,
expiresAt: Date.now() + this.cacheTTL,
};
Comment on lines +242 to +249
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.
}

private async createDriver(driverType: string, databaseUrl: string, authToken: string): Promise<IDataDriver> {
// Dynamic import drivers to avoid circular dependencies
switch (driverType) {
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
});
}
Comment on lines +255 to +261
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.

case 'sqlite': {
// SQLite driver: URL format is file:./path/to/db.db
const filePath = databaseUrl.replace('file:', '');
const { SqlDriver } = await import('@objectstack/driver-sql');
return new SqlDriver({
client: 'better-sqlite3',
connection: {
filename: filePath,
},
useNullAsDefault: true,
});
}

case 'turso': {
// Turso driver: URL format is libsql://hostname
const { TursoDriver } = await import('@objectstack/driver-turso');
return new TursoDriver({
url: databaseUrl,
authToken,
});
}

default:
throw new Error(`[EnvironmentRegistry] Unsupported driver type: ${driverType}`);
}
}
}

/**
* Create a default environment driver registry instance.
*/
export function createEnvironmentDriverRegistry(
controlPlaneDriver: IDataDriver,
options?: {
encryptor?: SecretEncryptor;
cacheTTLMs?: number;
},
): EnvironmentDriverRegistry {
return new DefaultEnvironmentDriverRegistry({
controlPlaneDriver,
encryptor: options?.encryptor,
cacheTTLMs: options?.cacheTTLMs,
});
}
Loading
Loading