diff --git a/.changeset/adr-0062-default-driver-construction.md b/.changeset/adr-0062-default-driver-construction.md new file mode 100644 index 0000000000..313a9236d3 --- /dev/null +++ b/.changeset/adr-0062-default-driver-construction.md @@ -0,0 +1,21 @@ +--- +"@objectstack/runtime": patch +--- + +refactor(runtime): build the standalone default driver via the shared datasource factory (ADR-0062 follow-up) + +`createStandaloneStack` now constructs its `default` driver for the user-facing +kinds (memory / better-sqlite3 / postgres / mongodb) through the **same** +`createDefaultDatasourceDriverFactory` used for declared and runtime-admin +datasources — one "driver kind → instance" construction path instead of two +hand-mirrored ones. Adding a dialect or changing connection/pool defaults now +happens in a single place. URL→config translation, filesystem prep (`mkdir`), +and pre-engine `DriverPlugin` registration stay in the stack (unchanged); the +factory only constructs the driver. The pure-JS WASM sqlite driver stays bespoke +in the stack — it's the standalone-specific, CI-safe default and not a +user-creatable datasource type, so it has a single construction site already. + +No behavior change: the same driver instances are built for the same inputs +(verified by a per-kind connect + CRUD round-trip test and a real `os dev` boot). +Adds `@objectstack/service-datasource` as a runtime dependency (no cycle — that +package depends only on core/spec). diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 956ffa975f..cfeec8d971 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -32,6 +32,7 @@ "@objectstack/plugin-security": "workspace:*", "@objectstack/rest": "workspace:*", "@objectstack/service-cluster": "workspace:*", + "@objectstack/service-datasource": "workspace:*", "@objectstack/service-i18n": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", diff --git a/packages/runtime/src/standalone-stack.test.ts b/packages/runtime/src/standalone-stack.test.ts index 1cee081820..bddf906ade 100644 --- a/packages/runtime/src/standalone-stack.test.ts +++ b/packages/runtime/src/standalone-stack.test.ts @@ -121,3 +121,62 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005 expect(r.roles!.map((x: any) => x.name).sort()).toEqual(['contributor', 'manager']); }, BOOT_TIMEOUT); }); + +// ADR-0062 (Variant A) — the standalone `default` driver's CONSTRUCTION is +// unified: the user-facing kinds (memory / better-sqlite3 / postgres / mongodb) +// go through the SAME `createDefaultDatasourceDriverFactory` used for +// declared/runtime datasources, so there is one "driver kind → instance" path. +// The pure-JS WASM sqlite driver stays bespoke (it's the standalone-specific +// CI-safe default, not a user-creatable datasource type — its only construction +// site). These tests extract the constructed driver from the stack's +// `DriverPlugin` and exercise it directly (connect → syncSchema → create → +// find), proving the right driver is built per kind AND that it actually +// connects + does I/O — without booting the full kernel (the MetadataPlugin +// file-artifact boot doesn't play well with vitest's module runner, and isn't +// what this test is about). postgres/mongodb need a live server, so they're +// covered by the factory's own usage + the runtime-admin path. +describe('createStandaloneStack — default driver construction unified via the factory (ADR-0062)', () => { + let dir: string; + beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'os-standalone-driver-')); }); + afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + const NOTE = { name: 'note', fields: { id: { type: 'text' }, title: { type: 'text' } } }; + + async function driverRoundTrip( + cfg: Parameters[0], + ): Promise<{ kind: string | undefined; titles: string[] }> { + const stack = await createStandaloneStack(cfg); + const plugin = stack.plugins.find( + (p: any) => p?.driver && typeof p.driver.find === 'function', + ) as { driver: any } | undefined; + const driver = plugin!.driver; + const kind = driver?.constructor?.name as string | undefined; + await driver.connect?.(); + try { + await driver.syncSchema('note', NOTE); + await driver.create('note', { id: 'n1', title: 'hello-driver' }); + const rows = (await driver.find('note', {})) as Array<{ title?: string }>; + return { kind, titles: rows.map((r) => r.title as string) }; + } finally { + try { await driver.disconnect?.(); } catch { /* noop */ } + } + } + + it('memory:// → InMemoryDriver (factory), connects + round-trips', async () => { + const r = await driverRoundTrip({ databaseUrl: 'memory://default-driver' }); + expect(r.kind).toMatch(/InMemoryDriver$/); + expect(r.titles).toContain('hello-driver'); + }, BOOT_TIMEOUT); + + it('file: → better-sqlite3 SqlDriver (factory), connects + round-trips', async () => { + const r = await driverRoundTrip({ databaseUrl: `file:${join(dir, 'better.db')}` }); + expect(r.kind).toMatch(/SqlDriver$/); + expect(r.titles).toContain('hello-driver'); + }, BOOT_TIMEOUT); + + it('databaseDriver:sqlite-wasm → SqliteWasmDriver (bespoke), connects + round-trips', async () => { + const r = await driverRoundTrip({ databaseDriver: 'sqlite-wasm', databaseUrl: `file:${join(dir, 'wasm.db')}` }); + expect(r.kind).toMatch(/SqliteWasmDriver$/); + expect(r.titles).toContain('hello-driver'); + }, BOOT_TIMEOUT); +}); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 9ae1a75a97..91baf10ab6 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -155,63 +155,78 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro ?? (process.env.OS_DATABASE_DRIVER?.trim() as ResolvedDriverKind | undefined); const dbDriver: ResolvedDriverKind = explicitDriver ?? detectDriverFromUrl(dbUrl); + // Build the default driver. The user-facing kinds (memory / postgres / + // better-sqlite3 / mongodb) go through the SHARED datasource driver factory + // (ADR-0062) — the SAME `create({driver,config})` used for declared/runtime + // datasources — so adding a dialect or changing connection/pool defaults + // happens in ONE place instead of being mirrored here by hand. This stack + // still owns what's standalone-specific: URL→config translation, filesystem + // prep (`mkdir`), and `DriverPlugin` registration (pre-engine — unchanged). let driverPlugin: any; - if (dbDriver === 'memory') { - const { InMemoryDriver } = await import('@objectstack/driver-memory'); - driverPlugin = new DriverPlugin(new InMemoryDriver()); - } else if (dbDriver === 'postgres') { - const { SqlDriver } = await import('@objectstack/driver-sql'); - driverPlugin = new DriverPlugin( - new SqlDriver({ - client: 'pg', - connection: dbUrl, - pool: { min: 0, max: 5 }, - }) as any, - ); - } else if (dbDriver === 'mongodb') { - // MongoDB driver is an optional peer dependency. Importing it lazily - // avoids forcing every standalone consumer to install the mongo SDK. - let MongoDBDriver: any; - try { - ({ MongoDBDriver } = await import('@objectstack/driver-mongodb' as any)); - } catch (err: any) { - throw new Error( - `[StandaloneStack] mongodb URL detected but @objectstack/driver-mongodb is not installed. ` + - `Add it as a dependency or pass an explicit driverPlugin. (${err?.message ?? err})` - ); - } - driverPlugin = new DriverPlugin(new MongoDBDriver({ url: dbUrl }) as any); - } else if (dbDriver === 'sqlite-wasm') { + if (dbDriver === 'sqlite-wasm') { + // The pure-JS WASM sqlite driver is the standalone-specific, CI-safe + // (no native build) default — NOT a user-creatable runtime datasource + // type, so it isn't part of the shared factory's surface. Construct it + // directly here (this is its only construction site, so no duplication). const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm' as any); const filename = dbUrl .replace(/^wasm-sqlite:(\/\/)?/i, '') - .replace(/^file:(\/\/)?/i, ''); - if (filename && filename !== ':memory:') { + .replace(/^file:(\/\/)?/i, '') || ':memory:'; + if (filename !== ':memory:') { mkdirSync(resolvePath(filename, '..'), { recursive: true }); } driverPlugin = new DriverPlugin( new SqliteWasmDriver({ - filename: filename || ':memory:', - persist: filename && filename !== ':memory:' ? 'on-write' : undefined, + filename, + persist: filename !== ':memory:' ? 'on-write' : undefined, }) as any, ); } else { - // sqlite - const { SqlDriver } = await import('@objectstack/driver-sql'); - const filename = dbUrl.replace(/^file:(\/\/)?/, ''); - if (!filename || /^[a-z][a-z0-9+.-]*:\/\//i.test(filename)) { - throw new Error( - `[StandaloneStack] sqlite driver was selected but the URL does not look like a file path: "${dbUrl}". ` + - `Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` - ); + const { createDefaultDatasourceDriverFactory } = await import('@objectstack/service-datasource'); + let driverId: string; + let driverConfig: Record; + if (dbDriver === 'memory') { + driverId = 'memory'; + driverConfig = {}; + } else if (dbDriver === 'postgres') { + // Factory applies the pg pool default ({ min: 0, max: 5 }) internally. + driverId = 'postgres'; + driverConfig = { url: dbUrl }; + } else if (dbDriver === 'mongodb') { + driverId = 'mongodb'; + driverConfig = { url: dbUrl }; + } else { + // sqlite (better-sqlite3) + driverId = 'sqlite'; + const filename = dbUrl.replace(/^file:(\/\/)?/, ''); + if (!filename || /^[a-z][a-z0-9+.-]*:\/\//i.test(filename)) { + throw new Error( + `[StandaloneStack] sqlite driver was selected but the URL does not look like a file path: "${dbUrl}". ` + + `Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` + ); + } + mkdirSync(resolvePath(filename, '..'), { recursive: true }); + driverConfig = { filename }; + } + + let driverHandle: { driver?: unknown } | unknown; + try { + driverHandle = await createDefaultDatasourceDriverFactory().create({ driver: driverId, config: driverConfig }); + } catch (err: any) { + // Preserve the actionable hint the bespoke path gave for the optional + // mongo peer dep (the factory throws a generic "not installed" message). + if (dbDriver === 'mongodb') { + throw new Error( + `[StandaloneStack] mongodb URL detected but @objectstack/driver-mongodb is not installed. ` + + `Add it as a dependency or pass an explicit driverPlugin. (${err?.message ?? err})` + ); + } + throw err; } - mkdirSync(resolvePath(filename, '..'), { recursive: true }); + // The factory returns a handle whose `.driver` is the concrete engine + // driver (falls back to the handle itself for structural drivers). driverPlugin = new DriverPlugin( - new SqlDriver({ - client: 'better-sqlite3', - connection: { filename }, - useNullAsDefault: true, - }), + ((driverHandle as { driver?: unknown })?.driver ?? driverHandle) as any, ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1e5f2e0ad..628a520471 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1683,6 +1683,9 @@ importers: '@objectstack/service-cluster': specifier: workspace:* version: link:../services/service-cluster + '@objectstack/service-datasource': + specifier: workspace:* + version: link:../services/service-datasource '@objectstack/service-i18n': specifier: workspace:* version: link:../services/service-i18n @@ -1699,9 +1702,6 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: - '@objectstack/service-datasource': - specifier: workspace:* - version: link:../services/service-datasource typescript: specifier: ^6.0.3 version: 6.0.3