Skip to content

Commit 196edd7

Browse files
committed
feat: add driver selection feature to NewEnvironmentDialog and related hooks
1 parent ab706b1 commit 196edd7

File tree

5 files changed

+205
-5
lines changed

5 files changed

+205
-5
lines changed

apps/studio/src/components/new-environment-dialog.tsx

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* and navigating into the new environment.
1414
*/
1515

16-
import { useState } from 'react';
16+
import { useEffect, useState } from 'react';
1717
import type { Environment, EnvironmentType } from '@objectstack/spec/cloud';
1818
import {
1919
Dialog,
@@ -33,7 +33,7 @@ import {
3333
SelectTrigger,
3434
SelectValue,
3535
} from '@/components/ui/select';
36-
import { useProvisionEnvironment } from '@/hooks/useEnvironments';
36+
import { useDrivers, useProvisionEnvironment } from '@/hooks/useEnvironments';
3737
import { toast } from '@/hooks/use-toast';
3838

3939
const ENV_TYPES: { value: EnvironmentType; label: string; hint: string }[] = [
@@ -58,16 +58,30 @@ export function NewEnvironmentDialog({
5858
onCreated,
5959
}: NewEnvironmentDialogProps) {
6060
const { provision, provisioning } = useProvisionEnvironment();
61+
const { drivers, loading: driversLoading } = useDrivers();
6162
const [slug, setSlug] = useState('');
6263
const [displayName, setDisplayName] = useState('');
6364
const [envType, setEnvType] = useState<EnvironmentType>('development');
6465
const [region, setRegion] = useState('');
66+
const [driver, setDriver] = useState<string>('');
67+
68+
// Auto-select a sensible default once drivers load: prefer turso, then memory,
69+
// otherwise the first registered driver.
70+
useEffect(() => {
71+
if (driver || drivers.length === 0) return;
72+
const preferred =
73+
drivers.find((d) => d.name === 'turso') ??
74+
drivers.find((d) => d.name === 'memory') ??
75+
drivers[0];
76+
if (preferred) setDriver(preferred.name);
77+
}, [driver, drivers]);
6578

6679
const reset = () => {
6780
setSlug('');
6881
setDisplayName('');
6982
setEnvType('development');
7083
setRegion('');
84+
setDriver('');
7185
};
7286

7387
const handleSubmit = async (e: React.FormEvent) => {
@@ -82,6 +96,7 @@ export function NewEnvironmentDialog({
8296
displayName: displayName.trim() || undefined,
8397
envType,
8498
region: region.trim() || undefined,
99+
driver: driver || undefined,
85100
} as any);
86101
const env = (res?.environment ?? res) as Environment;
87102
toast({
@@ -164,6 +179,43 @@ export function NewEnvironmentDialog({
164179
</Select>
165180
</div>
166181

182+
<div className="grid gap-1.5">
183+
<Label>Driver</Label>
184+
<Select
185+
value={driver}
186+
onValueChange={setDriver}
187+
disabled={driversLoading || drivers.length === 0}
188+
>
189+
<SelectTrigger>
190+
<SelectValue
191+
placeholder={
192+
driversLoading
193+
? 'Loading drivers…'
194+
: drivers.length === 0
195+
? 'No drivers registered'
196+
: 'Select a driver'
197+
}
198+
/>
199+
</SelectTrigger>
200+
<SelectContent>
201+
{drivers.map((d) => (
202+
<SelectItem key={d.driverId} value={d.name}>
203+
<div className="flex flex-col">
204+
<span>{d.name}</span>
205+
<span className="text-[11px] text-muted-foreground">
206+
{d.driverId}
207+
</span>
208+
</div>
209+
</SelectItem>
210+
))}
211+
</SelectContent>
212+
</Select>
213+
<p className="text-[11px] text-muted-foreground">
214+
Where this environment's data will be stored. `memory` is ideal
215+
for tests; `turso` persists to libSQL.
216+
</p>
217+
</div>
218+
167219
<div className="grid gap-1.5">
168220
<Label htmlFor="env-region">Region (optional)</Label>
169221
<Input

apps/studio/src/hooks/useEnvironments.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,45 @@ export function useEnvironmentDetail(environmentId: string | undefined) {
128128
return { detail, loading, error };
129129
}
130130

131+
/**
132+
* Hook: list ObjectQL drivers registered on the server.
133+
*
134+
* Used by the NewEnvironmentDialog to populate the "Driver" selector. The
135+
* server exposes whatever drivers are registered via `DriverPlugin`
136+
* (`memory`, `turso`, or future `sql` drivers) — Studio does not hardcode
137+
* any particular driver.
138+
*/
139+
export function useDrivers() {
140+
const client = useClient() as any;
141+
const [drivers, setDrivers] = useState<Array<{ name: string; driverId: string }>>([]);
142+
const [loading, setLoading] = useState(false);
143+
const [error, setError] = useState<Error | null>(null);
144+
145+
useEffect(() => {
146+
if (!client?.environments?.listDrivers) return;
147+
let alive = true;
148+
setLoading(true);
149+
(async () => {
150+
try {
151+
const result = await client.environments.listDrivers();
152+
if (!alive) return;
153+
setDrivers(result?.drivers ?? []);
154+
} catch (err) {
155+
if (!alive) return;
156+
setError(err as Error);
157+
setDrivers([]);
158+
} finally {
159+
if (alive) setLoading(false);
160+
}
161+
})();
162+
return () => {
163+
alive = false;
164+
};
165+
}, [client]);
166+
167+
return { drivers, loading, error };
168+
}
169+
131170
/**
132171
* Hook: provision a new environment via the control-plane API.
133172
*/

packages/client/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,17 @@ export class ObjectStackClient {
693693
const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}/members`);
694694
return this.unwrapResponse<{ members: any[] }>(res);
695695
},
696+
697+
/**
698+
* List ObjectQL drivers registered on the server. Useful for populating a
699+
* driver selector when provisioning a new environment (memory / turso /
700+
* future sql drivers). Returned `name` is the short alias (e.g. `memory`,
701+
* `turso`); `driverId` is the full FQN (e.g. `com.objectstack.driver.memory`).
702+
*/
703+
listDrivers: async () => {
704+
const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/drivers`);
705+
return this.unwrapResponse<{ drivers: Array<{ name: string; driverId: string }>; total: number }>(res);
706+
},
696707
};
697708

698709
/**

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,15 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
318318
});
319319

320320
// ── Cloud (Environments) ─────────────────────────────────────
321+
server.get(`${prefix}/cloud/drivers`, async (req: any, res: any) => {
322+
try {
323+
const result = await dispatcher.handleCloud('/drivers', 'GET', {}, req.query, { request: req });
324+
sendResult(result, res);
325+
} catch (err: any) {
326+
errorResponse(err, res);
327+
}
328+
});
329+
321330
server.get(`${prefix}/cloud/environments`, async (req: any, res: any) => {
322331
try {
323332
const result = await dispatcher.handleCloud('/environments', 'GET', {}, req.query, { request: req });

packages/runtime/src/http-dispatcher.ts

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -939,14 +939,25 @@ export class HttpDispatcher {
939939
/**
940940
* Cloud / Environment Control-Plane routes.
941941
*
942+
* - GET /cloud/drivers → list registered ObjectQL drivers (for env provisioning)
942943
* - GET /cloud/environments → list
943-
* - POST /cloud/environments → provision
944+
* - POST /cloud/environments → provision (driver: memory | turso | <any registered driver>)
944945
* - GET /cloud/environments/:id → detail (+ db, credential, membership)
945946
* - PATCH /cloud/environments/:id → update displayName / plan / status / isDefault / metadata
946947
* - POST /cloud/environments/:id/activate → mark as active for session (stub)
947948
* - POST /cloud/environments/:id/credentials/rotate → rotate credential
948949
* - GET /cloud/environments/:id/members → list members
949950
*
951+
* Driver binding
952+
* --------------
953+
* Environments are not tied to any specific driver. At provisioning time the
954+
* caller passes `driver` (a short name such as `memory`, `turso`, or any
955+
* future `sql` / `postgres` driver). The dispatcher validates the name
956+
* against the kernel's registered driver services (`driver.<name>`) and
957+
* derives an appropriate placeholder `database_url` for the chosen driver.
958+
* If `driver` is omitted, the dispatcher auto-selects the first available
959+
* in preference order: turso → memory → any other registered driver.
960+
*
950961
* Backed by ObjectQL sys__environment / sys__environment_database /
951962
* sys__database_credential / sys__environment_member tables (registered
952963
* by `@objectstack/service-tenant`'s `createTenantPlugin`).
@@ -966,6 +977,54 @@ export class HttpDispatcher {
966977
const CRED = 'sys__database_credential';
967978
const MEM = 'sys__environment_member';
968979

980+
// Enumerate registered ObjectQL drivers. Driver services are registered
981+
// by `DriverPlugin` under the key `driver.<driver.name>` where
982+
// `driver.name` is typically the full FQN like `com.objectstack.driver.memory`.
983+
// We derive a short name by stripping the `com.objectstack.driver.` prefix.
984+
const toShortName = (driverId: string): string => {
985+
const prefix = 'com.objectstack.driver.';
986+
return driverId.startsWith(prefix) ? driverId.slice(prefix.length) : driverId;
987+
};
988+
const listRegisteredDrivers = (): Array<{ name: string; driverId: string }> => {
989+
const services = this.getServicesMap();
990+
const drivers: Array<{ name: string; driverId: string }> = [];
991+
for (const [serviceKey, svc] of Object.entries(services)) {
992+
if (!serviceKey.startsWith('driver.')) continue;
993+
const raw = serviceKey.slice('driver.'.length);
994+
if (!raw || raw === 'unknown') continue;
995+
const driverId = (svc as any)?.name ?? raw;
996+
drivers.push({ name: toShortName(driverId), driverId });
997+
}
998+
return drivers;
999+
};
1000+
1001+
const resolveDriver = (requested: string | undefined): { name: string; driverId: string } | undefined => {
1002+
const registered = listRegisteredDrivers();
1003+
if (requested) {
1004+
const wanted = String(requested).toLowerCase();
1005+
return registered.find((d) => d.name === wanted || d.driverId === wanted);
1006+
}
1007+
// Auto-pick: prefer turso, then memory, then whatever is available.
1008+
return (
1009+
registered.find((d) => d.name === 'turso') ??
1010+
registered.find((d) => d.name === 'memory') ??
1011+
registered[0]
1012+
);
1013+
};
1014+
1015+
const buildDatabaseUrl = (driverName: string, environmentId: string): string => {
1016+
const dbName = `env-${environmentId}`;
1017+
switch (driverName) {
1018+
case 'memory':
1019+
return `memory://${dbName}`;
1020+
case 'turso':
1021+
return `libsql://${dbName}.mock-turso.local`;
1022+
default:
1023+
// Generic placeholder for future SQL / postgres / mysql drivers.
1024+
return `${driverName}://${dbName}`;
1025+
}
1026+
};
1027+
9691028
const findOne = async (obj: string, where: Record<string, unknown>): Promise<any | undefined> => {
9701029
let rows = await ql.find(obj, { where } as any);
9711030
if (rows && (rows as any).value) rows = (rows as any).value;
@@ -974,6 +1033,12 @@ export class HttpDispatcher {
9741033
};
9751034

9761035
try {
1036+
// ----- /cloud/drivers ------------------------------------------
1037+
if (parts.length === 1 && parts[0] === 'drivers' && m === 'GET') {
1038+
const drivers = listRegisteredDrivers();
1039+
return { handled: true, response: this.success({ drivers, total: drivers.length }) };
1040+
}
1041+
9771042
// ----- /cloud/environments collection routes -----
9781043
if (parts.length === 1 && parts[0] === 'environments' && m === 'GET') {
9791044
const where: Record<string, unknown> = {};
@@ -995,10 +1060,34 @@ export class HttpDispatcher {
9951060
const environmentDatabaseId = randomUUID();
9961061
const credentialId = randomUUID();
9971062
const nowIso = new Date().toISOString();
998-
const driver = req.driver ?? 'turso';
1063+
1064+
// Bind environment to a driver. `req.driver` is optional — any
1065+
// registered ObjectQL driver is accepted (memory / turso / future
1066+
// sql / postgres). If omitted, pick the best default available.
1067+
const resolved = resolveDriver(req.driver);
1068+
if (!resolved) {
1069+
const available = listRegisteredDrivers().map((d) => d.name);
1070+
if (req.driver) {
1071+
return {
1072+
handled: true,
1073+
response: this.error(
1074+
`Unknown driver '${req.driver}'. Available drivers: [${available.join(', ') || 'none'}]`,
1075+
400,
1076+
),
1077+
};
1078+
}
1079+
return {
1080+
handled: true,
1081+
response: this.error(
1082+
'No ObjectQL driver is registered. Register at least one DriverPlugin (e.g. InMemoryDriver or TursoDriver).',
1083+
503,
1084+
),
1085+
};
1086+
}
1087+
const driver = resolved.name;
9991088
const region = req.region ?? 'us-east-1';
10001089
const databaseName = `env-${environmentId}`;
1001-
const databaseUrl = `libsql://${databaseName}.mock-${driver}.local`;
1090+
const databaseUrl = buildDatabaseUrl(driver, environmentId);
10021091
const plaintextSecret = `mock-token-${environmentId}`;
10031092

10041093
await ql.insert(ENV, {

0 commit comments

Comments
 (0)