Skip to content

Commit cb3b6f1

Browse files
committed
Add MongoDB and PostgreSQL support to StandaloneStack driver detection
1 parent 52c9f0b commit cb3b6f1

5 files changed

Lines changed: 236 additions & 20 deletions

File tree

examples/app-crm/e2e/_acceptance.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,56 @@ export function runDriverAcceptance(opts: { label: string; baseURL?: string }) {
6363

6464
await ctx.dispose();
6565
});
66+
67+
test('analytics aggregate returns measure values per dimension', async () => {
68+
// Regression guard for a 3-layer bug where:
69+
// 1. engine.aggregate() called driver.find() instead of driver.aggregate(),
70+
// so groupBy / aggregations were silently ignored.
71+
// 2. resolveMeasure() did not accept `${field}_${type}` aliases (e.g.
72+
// 'amount_sum' for measure 'amount' of type 'sum'), so the measure
73+
// was dropped from the SELECT.
74+
// Without this test the failure mode is silent (200 OK, rows present,
75+
// but every row is missing the measure value), and dashboard charts
76+
// render empty axes with no bars.
77+
const ctx = await request.newContext({ baseURL: BASE_URL });
78+
79+
const grouped = await ctx.post('/api/v1/analytics/query', {
80+
data: {
81+
cube: 'opportunity',
82+
measures: ['amount_sum'],
83+
dimensions: ['stage'],
84+
},
85+
});
86+
expect(grouped.ok()).toBe(true);
87+
const groupedBody = await grouped.json();
88+
const groupedRows: Array<Record<string, unknown>> =
89+
groupedBody?.data?.rows ?? groupedBody?.rows ?? [];
90+
expect(groupedRows.length).toBeGreaterThan(0);
91+
for (const row of groupedRows) {
92+
expect(row).toHaveProperty('stage');
93+
expect(typeof row.amount_sum).toBe('number');
94+
expect(row.amount_sum as number).toBeGreaterThan(0);
95+
}
96+
97+
const total = await ctx.post('/api/v1/analytics/query', {
98+
data: {
99+
cube: 'opportunity',
100+
measures: ['amount_sum'],
101+
},
102+
});
103+
expect(total.ok()).toBe(true);
104+
const totalBody = await total.json();
105+
const totalRows: Array<Record<string, unknown>> =
106+
totalBody?.data?.rows ?? totalBody?.rows ?? [];
107+
expect(totalRows).toHaveLength(1);
108+
expect(typeof totalRows[0].amount_sum).toBe('number');
109+
const groupedSum = groupedRows.reduce(
110+
(acc, r) => acc + ((r.amount_sum as number) || 0),
111+
0,
112+
);
113+
expect(totalRows[0].amount_sum).toBe(groupedSum);
114+
115+
await ctx.dispose();
116+
});
66117
});
67118
}

examples/app-crm/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
"@objectstack/spec": "workspace:*",
2424
"@objectstack/runtime": "workspace:*",
2525
"@objectstack/service-analytics": "workspace:*",
26-
"@objectstack/service-automation": "workspace:*"
26+
"@objectstack/service-automation": "workspace:*",
27+
"@objectstack/driver-mongodb": "workspace:*",
28+
"pg": "^8.13.1"
2729
},
2830
"devDependencies": {
2931
"@objectstack/cli": "workspace:*",

packages/runtime/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"devDependencies": {
3030
"@objectstack/driver-memory": "workspace:*",
31+
"@objectstack/driver-mongodb": "workspace:*",
3132
"@objectstack/driver-sql": "workspace:*",
3233
"@objectstack/driver-turso": "workspace:*",
3334
"@objectstack/metadata": "workspace:*",

packages/runtime/src/standalone-stack.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@
88
* artifact is available. No authentication, no Studio data, no control
99
* plane — REST routes are served unauthenticated.
1010
*
11-
* Auto-detects the appropriate driver from the database URL:
11+
* Auto-detects the appropriate driver from the database URL scheme:
1212
* - `memory://*` → InMemoryDriver
1313
* - `libsql://`, `https://` → TursoDriver
14-
* - `file:` / anything else → SqlDriver (better-sqlite3)
14+
* - `postgres[ql]://`, `pg://` → SqlDriver (pg)
15+
* - `mongodb[+srv]://` → MongoDBDriver (peer-dep `@objectstack/driver-mongodb`)
16+
* - `file:` / no scheme → SqlDriver (better-sqlite3)
17+
*
18+
* Unknown URL schemes throw — we never silently fall back to sqlite, since
19+
* that historically created bogus directories on disk (e.g. `mongodb:/`)
20+
* when an unsupported URL was treated as a file path.
1521
*/
1622

1723
import { resolve as resolvePath } from 'node:path';
@@ -22,7 +28,7 @@ import { z } from 'zod';
2228
export const StandaloneStackConfigSchema = z.object({
2329
databaseUrl: z.string().optional(),
2430
databaseAuthToken: z.string().optional(),
25-
databaseDriver: z.enum(['sqlite', 'turso', 'memory', 'postgres']).optional(),
31+
databaseDriver: z.enum(['sqlite', 'turso', 'memory', 'postgres', 'mongodb']).optional(),
2632
projectId: z.string().optional(),
2733
artifactPath: z.string().optional(),
2834
});
@@ -34,6 +40,22 @@ export interface StandaloneStackResult {
3440
api: { enableProjectScoping: false; projectResolution: 'none' };
3541
}
3642

43+
type ResolvedDriverKind = 'memory' | 'turso' | 'postgres' | 'mongodb' | 'sqlite';
44+
45+
function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind {
46+
if (/^memory:\/\//i.test(dbUrl)) return 'memory';
47+
if (/^(libsql|https?):\/\//i.test(dbUrl)) return 'turso';
48+
if (/^(postgres(ql)?|pg):\/\//i.test(dbUrl)) return 'postgres';
49+
if (/^mongodb(\+srv)?:\/\//i.test(dbUrl)) return 'mongodb';
50+
if (/^file:/i.test(dbUrl)) return 'sqlite';
51+
// Bare path without a scheme — treat as a sqlite file path.
52+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(dbUrl)) return 'sqlite';
53+
throw new Error(
54+
`[StandaloneStack] Unsupported database URL scheme: ${dbUrl}. ` +
55+
`Supported schemes: memory://, libsql://, https://, postgres://, pg://, mongodb://, mongodb+srv://, file:`
56+
);
57+
}
58+
3759
export async function createStandaloneStack(config?: StandaloneStackConfig): Promise<StandaloneStackResult> {
3860
const cfg = StandaloneStackConfigSchema.parse(config ?? {});
3961

@@ -55,22 +77,51 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro
5577
const dbAuthToken = cfg.databaseAuthToken
5678
?? process.env.OS_DATABASE_AUTH_TOKEN?.trim()
5779
?? process.env.TURSO_AUTH_TOKEN?.trim();
58-
const dbDriver = cfg.databaseDriver
59-
?? process.env.OS_DATABASE_DRIVER?.trim()
60-
?? (/^(libsql|https?):\/\//i.test(dbUrl) ? 'turso' : 'sqlite');
80+
const explicitDriver = cfg.databaseDriver
81+
?? (process.env.OS_DATABASE_DRIVER?.trim() as ResolvedDriverKind | undefined);
82+
const dbDriver: ResolvedDriverKind = explicitDriver ?? detectDriverFromUrl(dbUrl);
6183

6284
let driverPlugin: any;
63-
if (dbDriver === 'memory' || dbUrl.startsWith('memory://')) {
85+
if (dbDriver === 'memory') {
6486
const { InMemoryDriver } = await import('@objectstack/driver-memory');
6587
driverPlugin = new DriverPlugin(new InMemoryDriver());
66-
} else if (dbDriver === 'turso' || /^(libsql|https?):\/\//i.test(dbUrl)) {
88+
} else if (dbDriver === 'turso') {
6789
const { TursoDriver } = await import('@objectstack/driver-turso');
6890
driverPlugin = new DriverPlugin(
6991
new TursoDriver({ url: dbUrl, authToken: dbAuthToken }) as any,
7092
);
93+
} else if (dbDriver === 'postgres') {
94+
const { SqlDriver } = await import('@objectstack/driver-sql');
95+
driverPlugin = new DriverPlugin(
96+
new SqlDriver({
97+
client: 'pg',
98+
connection: dbUrl,
99+
pool: { min: 0, max: 5 },
100+
}) as any,
101+
);
102+
} else if (dbDriver === 'mongodb') {
103+
// MongoDB driver is an optional peer dependency. Importing it lazily
104+
// avoids forcing every standalone consumer to install the mongo SDK.
105+
let MongoDBDriver: any;
106+
try {
107+
({ MongoDBDriver } = await import('@objectstack/driver-mongodb' as any));
108+
} catch (err: any) {
109+
throw new Error(
110+
`[StandaloneStack] mongodb URL detected but @objectstack/driver-mongodb is not installed. ` +
111+
`Add it as a dependency or pass an explicit driverPlugin. (${err?.message ?? err})`
112+
);
113+
}
114+
driverPlugin = new DriverPlugin(new MongoDBDriver({ url: dbUrl }) as any);
71115
} else {
116+
// sqlite
72117
const { SqlDriver } = await import('@objectstack/driver-sql');
73118
const filename = dbUrl.replace(/^file:(\/\/)?/, '');
119+
if (!filename || /^[a-z][a-z0-9+.-]*:\/\//i.test(filename)) {
120+
throw new Error(
121+
`[StandaloneStack] sqlite driver was selected but the URL does not look like a file path: "${dbUrl}". ` +
122+
`Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.`
123+
);
124+
}
74125
mkdirSync(resolvePath(filename, '..'), { recursive: true });
75126
driverPlugin = new DriverPlugin(
76127
new SqlDriver({

0 commit comments

Comments
 (0)