Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/24909-missing-driver-client-remedy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/service-datasource': patch
---

fix(service-datasource): a datasource whose database client is not installed (`pg`, `mysql2`, `better-sqlite3`) is refused with the install of that client, not with "Run `pnpm install && pnpm build`"

Clause-②: no

- **What was wrong.** When a boot-critical datasource could not connect because a module could not be resolved, the refusal always ended on the unbuilt-workspace remedy: "The driver package could not be LOADED at all … That is a build precondition … Run `pnpm install && pnpm build`, then start again." That is right for a monorepo checkout whose driver build output is missing. It is wrong for a project that points at PostgreSQL or MySQL without installing the database client: `pg` and `mysql2` are optional peers of `@objectstack/driver-sql` (and `better-sqlite3` an optional dependency) that the project installs itself, so neither command adds them, and a reader who followed the advice was refused again with the same text.
- **Now.** When the module that could not be resolved is one of those three clients (knex reports it as `Cannot find module 'pg'`), the refusal ends on the install of that client instead: `npm install pg` (with the `pnpm add` and `yarn add` spellings beside it), and no build step. It names no environment variable.
- **Unchanged.** A driver package whose build output is missing, or an `@objectstack/…` driver package that is not installed, still ends on `pnpm install && pnpm build`. A refused connection, a wrong DSN or a rejected password still ends on the same sentence as before. When the boot is refused, the error type, the retained connection state and the `OS_ALLOW_DRIVER_CONNECT_FAILURE` degraded boot do not change: only the closing sentence of the refusal is different.

What to do: install the client the refusal names next to the server that opens the datasource, then start again.
12 changes: 8 additions & 4 deletions content/docs/getting-started/your-first-project.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,20 @@ framework surface you install:
| Package | What it does |
|:---|:---|
| `@objectstack/spec` | The protocol: `defineStack`, `ObjectSchema`, `Field.*` builders, all Zod schemas. Your metadata imports only this. |
| `@objectstack/runtime` | The kernel that interprets metadata at runtime. Brings the database drivers with it — you do not install one separately. |
| `@objectstack/runtime` | The kernel that interprets metadata at runtime. Brings the database drivers with it — SQLite and MongoDB work as installed; PostgreSQL and MySQL also need their client package (below). |
| `@objectstack/plugin-hono-server` | The HTTP server that mounts the generated REST API. |
| `@objectstack/connector-rest` · `-openapi` · `-mcp` | The three generic connector executors — register the `rest` / `openapi` / `mcp` provider factories so declarative `connectors:` entries materialize ([ADR-0097](/docs/automation/flows)). |
| `@objectstack/cli` *(dev)* | The `os` / `objectstack` CLI: `dev`, `validate`, `build`, `start`. |
| `@objectstack/verify` *(dev)* | The test harness: `bootStack` boots this app's own stack in-process, on an in-memory database and with no server port, and returns the handle a test asserts through. `npm test` runs the starter test `note.test.ts` with it. |

No driver package is listed because none is installed directly: `@objectstack/runtime`
depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory`, and the CLI carries
them too. `objectstack dev` resolves **SQLite** by default; point `OS_DATABASE_URL` at
Postgres / MySQL / MongoDB for production — [no code changes](/docs/data-modeling/drivers).
depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory` and installs
`driver-mongodb` as an optional dependency, and the CLI carries them too.
`objectstack dev` resolves **SQLite** by default, which needs nothing more. For
production, MongoDB needs only `OS_DATABASE_URL` pointed at it. Postgres and MySQL need
one install first: their database client (`npm install pg`, or `npm install mysql2`) is
an optional peer of `driver-sql` that your project adds itself, and without it the boot
is refused. No code changes either way — see [Drivers](/docs/data-modeling/drivers).

## 3. Run it

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import type { IDataDriver } from '@objectstack/spec/contracts';
import type { DatasourceConnectPolicy } from '../contracts/connect-policy.js';
import {
GENERIC_CONNECT_FAILURE_REMEDY,
UNBUILT_WORKSPACE_REMEDY,
isUnbuiltWorkspaceFailure,
missingDriverClient,
missingDriverClientRemedy,
} from '../connect-failure-remedy.js';
import { missingSqliteWasmDriverMessage } from '../default-datasource-driver-factory.js';

Expand Down Expand Up @@ -589,6 +592,85 @@ describe('fail-fast remedy is chosen by CAUSE (#5794)', () => {
.then(() => undefined, (e: Error) => e);
expect(err!.message.endsWith(GENERIC)).toBe(true);
});

// The driver-level codes a real refused or rejected connection carries:
// neither is a module-resolution failure, so neither may reach the
// missing-client branch (#24909) any more than the unbuilt one.
it.each([
['ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:5432'],
['28P01', 'password authentication failed for user "app"'],
])('a driver error with code %s still ends on it, byte for byte', async (code, text) => {
const err = await failFast(factoryThrowing(withCode(new Error(text), code)));
expect(err.message.endsWith(GENERIC)).toBe(true);
// `\b`: "pnpm install" contains "npm install" as a substring.
expect(err.message).not.toMatch(/\bnpm install\b/);
expect(err.message).not.toContain('pnpm build');
});
});

// #24909: `@objectstack/driver-sql` reaches PostgreSQL, MySQL and native
// SQLite through knex, and knex loads a database client the PROJECT
// installs. A missing one is a missing install, not an unbuilt workspace:
// `pnpm install && pnpm build` adds no package the project never declared, so
// a scaffolded app that followed the build advice was refused again.
describe('a missing database client is answered with ITS install (#24909)', () => {
/**
* knex's own wrapper (`Client.initializeDriver`), in the shape measured on
* the real factory arms: a NEW Error that keeps the text and drops `code`.
* The genuine article is pinned on the real path in
* `missing-driver-client-boot-refusal.test.ts`.
*/
const knexMissingClient = (client: string, dialect: string) =>
new Error(
`Knex: run\n$ npm install ${client} --save\nCannot find module '${client}'\n` +
`Require stack:\n- /app/node_modules/knex/lib/dialects/${dialect}/index.js`,
);

it.each([
['pg', 'postgres'],
['mysql2', 'mysql2'],
['better-sqlite3', 'better-sqlite3'],
])('%s: the refusal ends on the install of that client and names no build', async (client, dialect) => {
const err = await failFast(factoryThrowing(knexMissingClient(client, dialect)));
const remedy = missingDriverClientRemedy(client);
expect(err.message.endsWith(remedy)).toBe(true);
// The install of THAT module, named once in the remedy (the earlier
// `npm install … --save` belongs to knex's interpolated text).
expect(remedy.split(`npm install ${client}`)).toHaveLength(2);
expect(err.message).not.toContain('pnpm build');
expect(err.message).not.toContain(UNBUILT_WORKSPACE_REMEDY);
expectNoHarmfulAdvice(err.message);
});

it("ESM's `Cannot find package 'pg'`, code intact, is the same missing install", async () => {
const err = await failFast(
factoryThrowing(withCode(new Error("Cannot find package 'pg' imported from /app/x.js"), 'ERR_MODULE_NOT_FOUND')),
);
expect(err.message.endsWith(missingDriverClientRemedy('pg'))).toBe(true);
});

it('names no environment variable and no configuration key', () => {
for (const client of ['pg', 'mysql2', 'better-sqlite3']) {
const remedy = missingDriverClientRemedy(client);
expect(remedy).not.toMatch(/\bOS_[A-Z_]+/);
expect(remedy).not.toContain('--database');
expect(remedy).not.toContain('pnpm build');
}
});

// The other side of the split: only a BARE specifier on the closed list is
// a missing client. Each of these is still the unbuilt/broken-install case.
it.each([
['the driver package itself, not installed', "Cannot find package '@objectstack/driver-sql' imported from /w/x.js"],
['the driver package, unbuilt', "Cannot find module '/w/node_modules/@objectstack/driver-sql/dist/index.mjs'"],
['a file inside an installed client', "Cannot find module '/w/node_modules/pg/lib/index.js'"],
['a subpath of an installed client', "Cannot find module 'pg/lib/native'"],
['a hard dependency of the driver (a broken install)', "Cannot find module 'knex'"],
])('%s keeps the unbuilt-workspace remedy', async (_label, text) => {
const err = await failFast(factoryThrowing(new Error(text)));
expect(err.message.endsWith(UNBUILT_WORKSPACE_REMEDY)).toBe(true);
expect(err.message).not.toMatch(/\bnpm install\b/);
});
});

// "Diagnostic classification, zero behaviour change" is the whole claim of
Expand Down Expand Up @@ -679,8 +761,40 @@ describe('isUnbuiltWorkspaceFailure (#5794)', () => {
it('classifies nothing when there was no thrown value to read', () => {
expect(isUnbuiltWorkspaceFailure(undefined)).toBe(false);
});

it('leaves a missing database client alone — an install, not an unbuilt workspace (#24909)', () => {
const knexPg = new Error("Knex: run\n$ npm install pg --save\nCannot find module 'pg'\nRequire stack:\n- /a/knex.js");
expect(isUnbuiltWorkspaceFailure(knexPg)).toBe(false);
expect(missingDriverClient(knexPg)).toBe('pg');
});
});

describe('missingDriverClient (#24909)', () => {
it('names the client only for a module-resolution failure', () => {
expect(missingDriverClient(new Error("Cannot find module 'mysql2'"))).toBe('mysql2');
// The same name in a message that is NOT a module-not-found is not this case.
expect(missingDriverClient(new Error('mysql2: connect ECONNREFUSED'))).toBeUndefined();
expect(missingDriverClient(undefined)).toBeUndefined();
});

it('reads the FIRST unresolved specifier — the wrapper words come first, the import error after', () => {
expect(
missingDriverClient(new Error("Cannot find module 'pg'\nRequire stack:\n- /w/node_modules/knex/lib/x.js")),
).toBe('pg');
expect(
missingDriverClient(new Error("Cannot find package '@objectstack/driver-sql' (… 'pg' …)")),
).toBeUndefined();
});

it('a module-not-found carrying only its code names no client', () => {
expect(missingDriverClient(withCodeOnly('MODULE_NOT_FOUND'))).toBeUndefined();
});
});

function withCodeOnly(code: string): Error {
return Object.assign(new Error('the entry point is not on disk'), { code });
}

describe('D3 credential resolution — fail-closed', () => {
const credExternal: ConnectableDatasource = {
name: 'warehouse',
Expand Down
Loading
Loading