Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/23543-f1-retire-mongo-driver-alias.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/spec': minor
'@objectstack/driver-mongodb': patch
---

feat(spec)!: the datasource driver spelling `mongo` is retired — an authored `driver: 'mongo'` is refused with the prescription naming `mongodb`, `OS_DATABASE_DRIVER=mongo` is no longer a driver selection, and `datasource-driver-mongo-to-mongodb` leaves the load path

Clause-②: yes (narrowing)

**BREAKING (authoring):** `mongo` was a second accepted spelling of the MongoDB driver id `mongodb` through 17: the loader rewrote an authored `datasource.driver: 'mongo'` to `mongodb` with a notice, and both boot hosts accepted `OS_DATABASE_DRIVER=mongo`. At 18 it is retired. `DriverType` refuses it by name at every door that parses a datasource (`defineStack`, `os validate`, `os build`, `os lint`, the metadata and datasource APIs): "Datasource `driver: 'mongo'` was removed in @objectstack/spec 18 (ADR-0087 D2) … Use `driver: 'mongodb'`." The refusal is case- and whitespace-insensitive, and it is published in the JSON Schema too (a `pattern` on `driver`), so a document validated against `json-schema/**` is refused as well. Both boot hosts now refuse `OS_DATABASE_DRIVER=mongo` (and a standalone stack's `databaseDriver: 'mongo'`) with their "Supported drivers" list, which names `mongodb`. The ADR-0087 D2 conversion `datasource-driver-mongo-to-mongodb` is retired from the load path (`retiredAfter: '17.7.0'`), so the authoring funnel no longer rewrites the spelling.

Migration:

- `datasource.driver: 'mongo'` → `driver: 'mongodb'`. Change the value; the driver it builds and the `config` contract it is judged against are unchanged.
- `OS_DATABASE_DRIVER=mongo` → `OS_DATABASE_DRIVER=mongodb`, or unset it and let a `mongodb://` or `mongodb+srv://` database URL select the driver. The same for `databaseDriver: 'mongo'` passed to the standalone stack.
- A script or integration that creates or updates a datasource through the metadata or datasource REST API with `driver: 'mongo'` → send `driver: 'mongodb'`.

Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. The D3 entry `datasource-driver-mongo-alias-retired` lists what the chain cannot reach: environment variables, launch scripts and API callers.

Stored metadata needs no edit. Every stored-row rehydration seam replays the retired conversion, so a `sys_metadata` datasource row written as `mongo` (the Setup → Datasources form wrote that until the canonical id was renamed to `mongodb`) keeps loading as `mongodb`. The spelling also stays on the spec's driver CONTRACT face (`resolveDriverId('mongo')` still answers `mongodb`): a reader that judges a stored body as written, such as the read-path credential redactor, keeps judging a legacy row as mongodb. Measured with the spelling deleted outright, that redactor served all ten mongodb passthrough secrets of such a row in cleartext.

`@objectstack/spec` also exports `RETIRED_DRIVER_ID_GUIDANCE` (retired spelling → its prescription), the table the refusal is derived from. `@objectstack/driver-mongodb`'s README no longer lists `mongo` as a recognised `OS_DATABASE_DRIVER` value.

<!-- adr-0087: registered datasource-driver-mongo-alias-retired -->
19 changes: 12 additions & 7 deletions content/docs/data-modeling/drivers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ libSQL data stayed untouched. Pass the token with `--database-auth-token`
| **MySQL** | `@objectstack/driver-sql` (peer: `mysql2`) | `SqlDriver` | `mysql` \| `mysql2` (supported, with three dialect caveats — see [below](#mysql-dialect-caveats)) |
| **SQLite** | `@objectstack/driver-sql` (peer: `better-sqlite3`) | `SqlDriver` | `sqlite` \| `sql` |
| **SQLite (WASM)** | `@objectstack/driver-sqlite-wasm` | `SqliteWasmDriver` | `sqlite-wasm` \| `wasm-sqlite` \| `wasm` |
| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` \| `mongo` (single-tenant only — see [below](#multi-tenancy-not-supported)) |
| **MongoDB** | `@objectstack/driver-mongodb` | `MongoDBDriver` | `mongodb` (single-tenant only — see [below](#multi-tenancy-not-supported)) |
| **Turso / libSQL** | `@objectstack/driver-turso` (optional install — see the callout above) | `TursoDriver` | `turso` \| `libsql` |
| **Memory** | `@objectstack/driver-memory` | `InMemoryDriver` | none — not a boot store (see [Memory Driver](#memory-driver)) |

Expand All @@ -100,9 +100,14 @@ driver ships that contract as a zod schema, exported from `@objectstack/spec/dat
| `mysql` \| `mysql2` \| `mariadb` | `MysqlConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `ssl`, `autoMigrate` |
| `sqlite` \| `sqlite3` | `SqliteConfigSchema` | `filename`, `autoMigrate` |
| `sqlite-wasm` \| `wasm-sqlite` | `SqliteWasmConfigSchema` | `filename`, `persist` |
| `mongo` \| `mongodb` | `MongoConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `authSource`, `options` |
| `mongodb` | `MongoConfigSchema` | `url`, `host`, `port`, `database`, `username`, `password`, `authSource`, `options` |
| `memory` \| `in-memory` \| `mingo` | `MemoryConfigSchema` | `initialData`, `strictMode`, `persistence` |

`mongo` was a second spelling of `mongodb` until 18. It is retired: an authored
`driver: 'mongo'` is refused with the prescription naming `mongodb`, and
`OS_DATABASE_DRIVER=mongo` is no longer a driver selection. `os migrate meta --from 16`
lists the edit for existing sources, and stored datasource rows keep loading.

An unrecognised key is rejected with its correction, at authoring time and in the
Setup → Datasources wizard alike:

Expand Down Expand Up @@ -185,11 +190,11 @@ ObjectStack — is what makes them secrets:

| Driver | Key | What it carries |
| :--- | :--- | :--- |
| `mongo` \| `mongodb` | `options.proxyPassword` | SOCKS5 proxy password |
| `mongo` \| `mongodb` | `options.tlsCertificateKeyFilePassword` | TLS key-file passphrase |
| `mongo` \| `mongodb` | `options.key` | TLS private key material (PEM) |
| `mongo` \| `mongodb` | `options.passphrase` | TLS key passphrase |
| `mongo` \| `mongodb` | `options.authMechanismProperties.AWS_SESSION_TOKEN` | AWS STS session token (`MONGODB-AWS` auth) |
| `mongodb` | `options.proxyPassword` | SOCKS5 proxy password |
| `mongodb` | `options.tlsCertificateKeyFilePassword` | TLS key-file passphrase |
| `mongodb` | `options.key` | TLS private key material (PEM) |
| `mongodb` | `options.passphrase` | TLS key passphrase |
| `mongodb` | `options.authMechanismProperties.AWS_SESSION_TOKEN` | AWS STS session token (`MONGODB-AWS` auth) |
| `turso` \| `libsql` | `encryptionKey` | AES-256 key for the local database file |

Unlike `password` / `authToken` (typed `never` in their schemas — the parse
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,8 +617,9 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
* A call the current schema ACCEPTS has already run the producer's load-time
* ADR-0087 D2 conversion pass when it returns. That pass runs in both of its
* modes, and no option skips it. So its result is canonical already. Handed
* to the chain as it was, a conversion the load still applies
* (`driver: 'mongo'`) had already happened: `applied` came back empty,
* to the chain as it was, a conversion the load still applies (it was
* measured on `driver: 'mongo'`, a spelling since retired from the load path
* at 18) had already happened: `applied` came back empty,
* `--write` wrote nothing, and every later load still printed the notice
* that sends the author to this command.
*
Expand Down Expand Up @@ -651,7 +652,8 @@ const AUTHORED_SOURCE_PRELUDE: readonly string[] = [
* what the author wrote. It asks in the producer's `strict: false` mode, with
* the producer's internal parameter that skips the load-time ADR-0087 D2
* conversion pass ({@link SPEC_AUTHORED_INPUT_OPTION}, #22256). Without that
* parameter a conversion the load still applies (`driver: 'mongo'`) was
* parameter a conversion the load still applies (measured on
* `driver: 'mongo'`, since retired from the load path at 18) was
* applied while the input was produced again, so the chain never listed it
* and `--write` never wrote it — the one-package defect, inside every package
* body. With it, composition runs its own rule over the authored inputs, and
Expand Down
24 changes: 20 additions & 4 deletions packages/cli/src/utils/driver-vocabulary-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,28 @@ describe('driver vocabulary parity: `os start` and `os migrate` answer alike (#6
expect(resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' }).driver).toBe('sqlite');
});

it('`mongo` and `mongodb` both select the renamed canonical id on both hosts', () => {
// `mongo` was retired as a value at 18 (#23543 F1): it left the selection
// face, so both hosts refuse it alike, each enumerating `mongodb` among the
// spellings that work. It stays on the CONTRACT face only, so a stored legacy
// `datasource.driver: 'mongo'` row keeps its contract and its redaction.
it('`mongodb` selects the canonical id on both hosts; the retired `mongo` is refused by both, naming `mongodb`', () => {
const url = URL_FOR.mongodb!;
for (const spelling of ['mongo', 'mongodb']) {
expect(cliVerdict(spelling, url)).toEqual({ accepted: true, driverId: 'mongodb' });
expect(standaloneVerdict(spelling, url)).toEqual({ accepted: true, driverId: 'mongodb' });
expect(cliVerdict('mongodb', url)).toEqual({ accepted: true, driverId: 'mongodb' });
expect(standaloneVerdict('mongodb', url)).toEqual({ accepted: true, driverId: 'mongodb' });

expect(cliVerdict('mongo', url)).toEqual({ accepted: false });
expect(standaloneVerdict('mongo', url)).toEqual({ accepted: false });
const cli = refusalMessage(() => resolveStorageDefinition(resolveDriverType('mongo', url), { databaseUrl: url, isDev: false }));
process.env.OS_DATABASE_DRIVER = 'mongo';
process.env.OS_DATABASE_URL = url;
const standalone = refusalMessage(() => resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' }));
for (const [host, message] of [['os start', cli], ['os migrate', standalone]] as const) {
expect(message, host).toContain('"mongo"');
expect(message, host).toMatch(/Supported drivers: [^.]*\bmongodb\b/);
expect(message, host).not.toMatch(/Supported drivers: [^.]*\bmongo\b(?!db)/);
}

expect(resolveDatabaseDriverId('mongo')).toBeUndefined();
expect(resolveDriverId('mongo')).toBe('mongodb');
});
});
Expand Down
71 changes: 44 additions & 27 deletions packages/cli/test/migrate-meta-composed-load-conversions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
/**
* `os migrate meta` on a COMPOSED project —
* `composeStacks([defineStack(…), …], { manifest: 'preserve' })` — whose
* package body carries a spelling the load still converts
* (`datasources[].driver: 'mongo'`) (#22256, the composed half).
* package body carries a spelling the load still converts (a flow-level retry
* policy's `retryDelayMs`, renamed `backoffMs` by `retry-policy-converged`)
* (#22256, the composed half). It was measured on `datasources[].driver:
* 'mongo'`, until that spelling was retired as a value at 18 and its
* conversion left the load path (#23543 F1).
*
* ## The defect
*
Expand Down Expand Up @@ -153,7 +156,7 @@ const json = (run: Run): any => JSON.parse(run.stdout);
const sites = (list: Array<{ conversionId: string; path: string; from?: string; to?: string }>) =>
list.map(({ conversionId, path, from, to }) => ({ conversionId, path, from, to }));
const conversionIds = (list: readonly { conversionId: string }[]) => list.map((c) => c.conversionId);
const bodyDriver = (config: any): string => config.packages[0].manifest.datasources[0].driver;
const bodyErrorHandling = (config: any): Record<string, unknown> => config.packages[0].manifest.flows[0].errorHandling;

beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'os-migrate-meta-composed-load-conversions-'));
Expand All @@ -171,9 +174,10 @@ afterAll(() => {

/**
* The service package: a stack the current schema accepts, whose one
* non-canonical spelling is a datasource driver id the load still converts
* (`retiredFromLoadPath: false`); the `url` is what makes a mongo datasource
* valid, so the call is accepted.
* non-canonical spelling is a key the load still converts (`retiredFromLoadPath`
* unset): the flow-level retry policy's `retryDelayMs`, which
* `retry-policy-converged` renames `backoffMs` before the parse, so the call is
* accepted.
*/
const SERVICE_STACK = `import { defineStack } from '@objectstack/spec';

Expand All @@ -188,7 +192,16 @@ export const ServiceStack = defineStack({
},
},
],
datasources: [{ name: 'docs', label: 'Docs', driver: 'mongo', config: { url: 'mongodb://mongo.internal:27017/docs' } }],
flows: [
{
name: 'probe_sync',
label: 'Sync',
type: 'autolaunched',
errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 2000 },
nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
},
],
});
`;

Expand All @@ -213,13 +226,15 @@ const composed = (service: string): Record<string, string> => ({
'src/app.stack.ts': APP_STACK,
});

const MONGO_CONVERSION = 'datasource-driver-mongo-to-mongodb';
const MONGO_BODY_SITE = {
conversionId: MONGO_CONVERSION,
path: 'packages[0].manifest.datasources[0].driver',
from: 'mongo',
to: 'mongodb',
const RETRY_CONVERSION = 'retry-policy-converged';
const RETRY_BODY_SITE = {
conversionId: RETRY_CONVERSION,
path: 'packages[0].manifest.flows[0].errorHandling.backoffMs',
from: 'retryDelayMs',
to: 'backoffMs',
};
const AUTHORED_DELAY = 'retryDelayMs: 2000';
const CANONICAL_DELAY = 'backoffMs: 2000';
const TIME_BODY_SITE = {
conversionId: 'time-default-utc-suffix-dropped',
path: 'packages[0].manifest.objects[0].fields.starts_at.defaultValue',
Expand All @@ -235,16 +250,16 @@ describe('an accepted input: the chain reads the package body as the author wrot
// The control for the last reading below: today the strict load converts it.
const strictBefore = await quietLoad(dir);
expect(strictBefore.stackProvenance).toBe(true);
expect(conversionIds(strictBefore.stackConversions)).toEqual([MONGO_CONVERSION]);
expect(conversionIds(strictBefore.stackConversions)).toEqual([RETRY_CONVERSION]);

const dry = json(await runMeta(dir, ['--from', '16', '--json']));
expect(sites(dry.applied)).toEqual([MONGO_BODY_SITE]);
expect(sites(dry.applied)).toEqual([RETRY_BODY_SITE]);
expect(dry.schemaValid).toBe(true);

const run = await runMeta(dir, ['--from', '16', '--write', '--json']);
expect(run.exitCode, run.stdout + run.stderr).toBeUndefined();
const out = json(run);
expect(sites(out.applied)).toEqual([MONGO_BODY_SITE]);
expect(sites(out.applied)).toEqual([RETRY_BODY_SITE]);
expect(out.write.status).toBe('written');
expect(out.write.files).toEqual([{ file: 'src/service.stack.ts', sites: 1 }]);
expect(out.write.manual).toEqual([]);
Expand All @@ -253,7 +268,7 @@ describe('an accepted input: the chain reads the package body as the author wrot
// The site and nothing else: every other file keeps its bytes.
expect(snapshot(dir)).toEqual({
...before,
'src/service.stack.ts': edit(before['src/service.stack.ts']!, "driver: 'mongo'", "driver: 'mongodb'"),
'src/service.stack.ts': edit(before['src/service.stack.ts']!, AUTHORED_DELAY, CANONICAL_DELAY),
});

const again = json(await runMeta(dir, ['--from', '16', '--json']));
Expand All @@ -270,11 +285,13 @@ describe('an accepted input: the chain reads the package body as the author wrot
const dir = writeProject(composed(SERVICE_STACK));

const authored = await quietLoad(dir, true);
expect(bodyDriver(authored.config)).toBe('mongo');
expect(bodyErrorHandling(authored.config)).toMatchObject({ retryDelayMs: 2000 });
expect(bodyErrorHandling(authored.config)).not.toHaveProperty('backoffMs');
expect(authored.stackProvenance).toBe(true);

const strict = await quietLoad(dir);
expect(bodyDriver(strict.config)).toBe('mongodb');
expect(bodyErrorHandling(strict.config)).toMatchObject({ backoffMs: 2000 });
expect(bodyErrorHandling(strict.config)).not.toHaveProperty('retryDelayMs');
}, RUN_TIMEOUT);

it('an input defined twice starts from the innermost argument, and is listed once', async () => {
Expand All @@ -284,16 +301,16 @@ describe('an accepted input: the chain reads the package body as the author wrot
'\n}));\n',
)));
const out = json(await runMeta(dir, ['--from', '16', '--json']));
expect(sites(out.applied)).toEqual([MONGO_BODY_SITE]);
expect(sites(out.applied)).toEqual([RETRY_BODY_SITE]);
expect(out.schemaValid).toBe(true);
}, RUN_TIMEOUT);
});

describe('a refused input the shim produces again, carrying a load-path spelling too', () => {
// The `time` default with the UTC suffix is refused by the current schema,
// so the shim hands the call's argument through and produces it again in
// `strict: false` mode for the composer; the driver id is the input's one
// load-path conversion.
// `strict: false` mode for the composer; the flow's retry delay key is the
// input's one load-path conversion.
const MIXED = edit(
SERVICE_STACK,
" name: { type: 'text', label: 'Name' },\n",
Expand All @@ -305,20 +322,20 @@ describe('a refused input the shim produces again, carrying a load-path spelling
const before = snapshot(dir);

const dry = json(await runMeta(dir, ['--from', '16', '--json']));
expect(sites(dry.applied)).toEqual([MONGO_BODY_SITE, TIME_BODY_SITE]);
expect(sites(dry.applied)).toEqual([RETRY_BODY_SITE, TIME_BODY_SITE]);

const run = await runMeta(dir, ['--from', '16', '--write', '--json']);
expect(run.exitCode, run.stdout + run.stderr).toBeUndefined();
const out = json(run);
expect(sites(out.applied)).toEqual([MONGO_BODY_SITE, TIME_BODY_SITE]);
expect(sites(out.applied)).toEqual([RETRY_BODY_SITE, TIME_BODY_SITE]);
expect(out.write.status).toBe('written');
expect(out.write.files).toEqual([{ file: 'src/service.stack.ts', sites: 2 }]);
expect(snapshot(dir)).toEqual({
...before,
'src/service.stack.ts': edit(
edit(MIXED, "defaultValue: '10:00Z'", "defaultValue: '10:00'"),
"driver: 'mongo'",
"driver: 'mongodb'",
AUTHORED_DELAY,
CANONICAL_DELAY,
),
});

Expand All @@ -332,7 +349,7 @@ describe('a refused input the shim produces again, carrying a load-path spelling
});

describe('control: an already-canonical composed project', () => {
const CANONICAL = edit(SERVICE_STACK, "driver: 'mongo'", "driver: 'mongodb'");
const CANONICAL = edit(SERVICE_STACK, AUTHORED_DELAY, CANONICAL_DELAY);

it('writes nothing, and its --json summary is the one the built composition gives', async () => {
const dir = writeProject(composed(CANONICAL));
Expand Down
Loading
Loading