diff --git a/packages/runtime/package.json b/packages/runtime/package.json index a9490d45e..723fd14b2 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -14,7 +14,7 @@ } }, "scripts": { - "build": "tsup --config ../../tsup.config.ts", + "build": "tsup --config tsup.config.ts", "dev": "tsc -w", "test": "vitest run" }, @@ -26,6 +26,9 @@ "zod": "^4.3.6" }, "devDependencies": { + "@objectstack/driver-memory": "workspace:*", + "@objectstack/driver-sql": "workspace:*", + "@objectstack/driver-turso": "workspace:*", "typescript": "^6.0.2", "vitest": "^4.1.4" }, diff --git a/packages/runtime/src/environment-registry.ts b/packages/runtime/src/environment-registry.ts new file mode 100644 index 000000000..24cf8a96c --- /dev/null +++ b/packages/runtime/src/environment-registry.ts @@ -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. + */ +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; + + /** + * 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; + decrypt(ciphertext: string): Promise | 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(); + private readonly idCache = new Map(); + private readonly pendingResolves = new Map>(); + + 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 }; + } + + // 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 { + // 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 { + 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 { + 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 { + 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, + }; + } + + private async createDriver(driverType: string, databaseUrl: string, authToken: string): Promise { + // 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 + }); + } + + 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, + }); +} diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 6594e38ab..5a0ba6e91 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -19,6 +19,8 @@ function randomUUID(): string { export interface HttpProtocolContext { request: any; response?: any; + environmentId?: string; // Resolved environment ID + dataDriver?: any; // IDataDriver - Resolved environment-scoped driver } export interface HttpDispatcherResult { @@ -41,9 +43,11 @@ export interface HttpDispatcherResult { */ export class HttpDispatcher { private kernel: any; // Casting to any to access dynamic props like services, graphql + private envRegistry?: any; // EnvironmentDriverRegistry - constructor(kernel: ObjectKernel) { + constructor(kernel: ObjectKernel, envRegistry?: any) { this.kernel = kernel; + this.envRegistry = envRegistry; } private success(data: any, meta?: any) { @@ -82,10 +86,12 @@ export class HttpDispatcher { /** * Direct data service dispatch — replaces broker.call('data.*'). * Tries protocol service first (supports expand/populate), falls back to ObjectQL. + * + * @param dataDriver - Optional environment-scoped driver to use instead of kernel default */ - private async callData(action: string, params: any): Promise { + private async callData(action: string, params: any, dataDriver?: any): Promise { const protocol = await this.resolveService('protocol'); - const qlService = await this.getObjectQLService(); + const qlService = dataDriver ?? await this.getObjectQLService(); const ql = qlService ?? await this.resolveService('objectql'); if (action === 'create') { @@ -157,6 +163,106 @@ export class HttpDispatcher { throw { statusCode: 400, message: `Unknown data action: ${action}` }; } + /** + * Resolve environment context for incoming request. + * + * Precedence: + * 1. request.headers.host → envRegistry.resolveByHostname(host) + * 2. request.headers['x-environment-id'] → envRegistry.resolveById(id) + * 3. session.activeEnvironmentId → envRegistry.resolveById(id) + * 4. session.activeOrganizationId → find default environment → envRegistry.resolveById(id) + * + * Skip for paths: /auth, /cloud, /health, /discovery, /meta + */ + private async resolveEnvironmentContext(context: HttpProtocolContext, path: string): Promise { + // Skip environment resolution for control-plane routes + const skipPaths = ['/auth', '/cloud', '/health', '/discovery', '/meta']; + if (skipPaths.some(p => path.startsWith(p))) { + return; + } + + // If no environment registry, skip + if (!this.envRegistry) { + return; + } + + try { + // 1. Try hostname resolution + 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); + if (result) { + context.environmentId = result.environmentId; + context.dataDriver = result.driver; + return; + } + } + + // 2. Try X-Environment-Id header + const envIdHeader = context.request?.headers?.['x-environment-id'] || context.request?.headers?.['X-Environment-Id']; + if (envIdHeader) { + const driver = await this.envRegistry.resolveById(envIdHeader); + if (driver) { + context.environmentId = envIdHeader; + context.dataDriver = driver; + return; + } + } + + // 3. Try session.activeEnvironmentId + try { + const authService: any = await this.getService(CoreServiceName.enum.auth); + const sessionData = await authService?.api?.getSession?.({ + headers: context.request?.headers, + }); + + const activeEnvironmentId = sessionData?.session?.activeEnvironmentId; + if (activeEnvironmentId) { + const driver = await this.envRegistry.resolveById(activeEnvironmentId); + if (driver) { + context.environmentId = activeEnvironmentId; + context.dataDriver = driver; + return; + } + } + + // 4. Try default environment for organization + const activeOrganizationId = sessionData?.session?.activeOrganizationId; + if (activeOrganizationId) { + // Query control plane for default environment + const qlService = await this.getObjectQLService(); + const ql = qlService ?? await this.resolveService('objectql'); + if (ql) { + let rows = await ql.find('sys__environment', { + where: { + organization_id: activeOrganizationId, + is_default: true + }, + limit: 1 + } as any); + if (rows && (rows as any).value) rows = (rows as any).value; + if (Array.isArray(rows) && rows[0]) { + const defaultEnv = rows[0]; + const driver = await this.envRegistry.resolveById(defaultEnv.id); + if (driver) { + context.environmentId = defaultEnv.id; + context.dataDriver = driver; + return; + } + } + } + } + } catch (sessionError) { + // Session resolution failed, continue without environment context + console.debug('[HttpDispatcher] Session resolution failed:', sessionError); + } + } catch (error) { + console.error('[HttpDispatcher] Environment resolution failed:', error); + } + } + /** * Generates the discovery JSON response for the API root. * @@ -599,27 +705,35 @@ export class HttpDispatcher { async handleData(path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise { const parts = path.replace(/^\/+/, '').split('/'); const objectName = parts[0]; - + if (!objectName) { return { handled: true, response: this.error('Object name required', 400) }; } + // Check if environment is resolved for data-plane requests + if (!_context.dataDriver && this.envRegistry) { + return { + handled: true, + response: this.error('Environment not resolved. Please specify X-Environment-Id header or ensure hostname maps to an environment.', 428) + }; + } + const m = method.toUpperCase(); // 1. Custom Actions (query, batch) if (parts.length > 1) { const action = parts[1]; - + // POST /data/:object/query if (action === 'query' && m === 'POST') { // Spec: returns FindDataResponse = { object, records, total?, hasMore? } - const result = await this.callData('query', { object: objectName, ...body }); + const result = await this.callData('query', { object: objectName, ...body }, _context.dataDriver); return { handled: true, response: this.success(result) }; } // POST /data/:object/batch if (action === 'batch' && m === 'POST') { - const result = await this.callData('batch', { object: objectName, ...body }); + const result = await this.callData('batch', { object: objectName, ...body }, _context.dataDriver); return { handled: true, response: this.success(result) }; } @@ -633,7 +747,7 @@ export class HttpDispatcher { if (select != null) allowedParams.select = select; if (expand != null) allowedParams.expand = expand; // Spec: returns GetDataResponse = { object, id, record } - const result = await this.callData('get', { object: objectName, id, ...allowedParams }); + const result = await this.callData('get', { object: objectName, id, ...allowedParams }, _context.dataDriver); return { handled: true, response: this.success(result) }; } @@ -641,7 +755,7 @@ export class HttpDispatcher { if (parts.length === 2 && m === 'PATCH') { const id = parts[1]; // Spec: returns UpdateDataResponse = { object, id, record } - const result = await this.callData('update', { object: objectName, id, data: body }); + const result = await this.callData('update', { object: objectName, id, data: body }, _context.dataDriver); return { handled: true, response: this.success(result) }; } @@ -649,7 +763,7 @@ export class HttpDispatcher { if (parts.length === 2 && m === 'DELETE') { const id = parts[1]; // Spec: returns DeleteDataResponse = { object, id, deleted } - const result = await this.callData('delete', { object: objectName, id }); + const result = await this.callData('delete', { object: objectName, id }, _context.dataDriver); return { handled: true, response: this.success(result) }; } } else { @@ -697,20 +811,20 @@ export class HttpDispatcher { } // Spec: returns FindDataResponse = { object, records, total?, hasMore? } - const result = await this.callData('query', { object: objectName, query: normalized }); + const result = await this.callData('query', { object: objectName, query: normalized }, _context.dataDriver); return { handled: true, response: this.success(result) }; } // POST /data/:object (Create) if (m === 'POST') { // Spec: returns CreateDataResponse = { object, id, record } - const result = await this.callData('create', { object: objectName, data: body }); + const result = await this.callData('create', { object: objectName, data: body }, _context.dataDriver); const res = this.success(result); res.status = 201; return { handled: true, response: res }; } } - + return { handled: false }; } @@ -1095,6 +1209,7 @@ export class HttpDispatcher { databaseDriver: row.database_driver, storageLimitMb: row.storage_limit_mb, provisionedAt: row.provisioned_at, + hostname: row.hostname, }; }; @@ -1171,6 +1286,23 @@ export class HttpDispatcher { const region = req.region ?? 'us-east-1'; let plaintextSecret = `mock-token-${environmentId}`; + // 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`; + } + } + // Insert environment row in `provisioning` state first so the // UI can show a "Provisioning…" indicator while the driver // handshake runs in the background. Status transitions to @@ -1200,6 +1332,7 @@ export class HttpDispatcher { database_driver: driver, storage_limit_mb: req.storageLimitMb ?? 1024, provisioned_at: null, + hostname: computedHostname, }); // Fire-and-forget the provisioning work so the POST returns @@ -2033,6 +2166,10 @@ export class HttpDispatcher { async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise { const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths + // ── Environment Resolution ── + // Resolve environment context for data-plane requests before routing + await this.resolveEnvironmentContext(context, cleanPath); + // 0. Discovery Endpoint (GET /discovery or GET /) // Standard route: /discovery (protocol-compliant) // Legacy route: / (empty path, for backward compatibility — MSW strips base URL) diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index e6cd96746..ec03fc640 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -20,6 +20,17 @@ export { HttpDispatcher } from './http-dispatcher.js'; export type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js'; export { MiddlewareManager } from './middleware.js'; +// Export Environment Registry +export { + DefaultEnvironmentDriverRegistry, + createEnvironmentDriverRegistry, + NoopSecretEncryptor, +} from './environment-registry.js'; +export type { + EnvironmentDriverRegistry, + SecretEncryptor, +} from './environment-registry.js'; + // Re-export from @objectstack/rest export { RestServer, diff --git a/packages/runtime/tsup.config.ts b/packages/runtime/tsup.config.ts new file mode 100644 index 000000000..88aacc8c5 --- /dev/null +++ b/packages/runtime/tsup.config.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + splitting: false, + sourcemap: true, + clean: true, + dts: true, + format: ['esm', 'cjs'], + target: 'es2020', + // Mark driver packages as external so they are resolved at runtime, not bundled + external: [ + '@objectstack/driver-memory', + '@objectstack/driver-sql', + '@objectstack/driver-turso', + ], +}); diff --git a/packages/services/service-tenant/src/environment-provisioning.ts b/packages/services/service-tenant/src/environment-provisioning.ts index fac150a57..9f718e6b7 100644 --- a/packages/services/service-tenant/src/environment-provisioning.ts +++ b/packages/services/service-tenant/src/environment-provisioning.ts @@ -398,6 +398,7 @@ export class EnvironmentProvisioningService { databaseDriver: driver, storageLimitMb, provisionedAt: nowIso, + hostname: parsed.hostname, }; const credential: DatabaseCredential = { @@ -432,6 +433,7 @@ export class EnvironmentProvisioningService { storage_limit_mb: environment.storageLimitMb, provisioned_at: environment.provisionedAt, metadata: environment.metadata ? JSON.stringify(environment.metadata) : null, + hostname: environment.hostname, }); await this.config.controlPlaneDriver.create('database_credential', { diff --git a/packages/services/service-tenant/src/objects/sys-environment.object.ts b/packages/services/service-tenant/src/objects/sys-environment.object.ts index 8e40f6407..759bc5f6d 100644 --- a/packages/services/service-tenant/src/objects/sys-environment.object.ts +++ b/packages/services/service-tenant/src/objects/sys-environment.object.ts @@ -163,6 +163,14 @@ export const SysEnvironment = ObjectSchema.create({ required: false, description: 'JSON-serialized free-form metadata (feature flags, tags, …).', }), + + hostname: Field.text({ + label: 'Hostname', + required: false, + maxLength: 255, + unique: true, + description: '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.', + }), }, indexes: [ @@ -172,6 +180,7 @@ export const SysEnvironment = ObjectSchema.create({ { fields: ['status'] }, { fields: ['env_type'] }, { fields: ['database_driver'] }, + { fields: ['hostname'], unique: true }, ], enable: { diff --git a/packages/spec/src/cloud/environment.zod.ts b/packages/spec/src/cloud/environment.zod.ts index 7e794e020..ae110eaef 100644 --- a/packages/spec/src/cloud/environment.zod.ts +++ b/packages/spec/src/cloud/environment.zod.ts @@ -121,6 +121,16 @@ export const EnvironmentSchema = z.object({ /** Free-form metadata (feature flags, tags, …). */ metadata: z.record(z.string(), z.unknown()).optional().describe('Free-form metadata'), + + /** + * 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. + * Used for environment resolution via hostname matching. + */ + 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.'), }); export type Environment = z.infer; @@ -297,6 +307,7 @@ export const ProvisionEnvironmentRequestSchema = z.object({ isDefault: z.boolean().optional().describe('Mark as the organization default environment'), createdBy: z.string().describe('User ID that initiated the provisioning'), metadata: z.record(z.string(), z.unknown()).optional().describe('Free-form metadata'), + hostname: z.string().optional().describe('Canonical hostname for this environment (auto-generated if omitted)'), }); export type ProvisionEnvironmentRequest = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84fd61325..242cb5fe7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1173,6 +1173,15 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@objectstack/driver-memory': + specifier: workspace:* + version: link:../plugins/driver-memory + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../plugins/driver-sql + '@objectstack/driver-turso': + specifier: workspace:* + version: link:../plugins/driver-turso typescript: specifier: ^6.0.2 version: 6.0.2