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
37 changes: 37 additions & 0 deletions .changeset/database-driver-flag-derived-from-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/spec": patch
"@objectstack/cli": patch
---

refactor(spec,cli): `--database-driver` 的可选值从共享驱动表推导,删掉 CLI 里的第二份词表 (#6969)

**无行为变更**:`os start --database-driver` / `os dev --database-driver` 接受的取值集合
与改动前**逐字相同**(`memory`、`sqlite`、`sqlite-wasm`、`postgres`、`mysql`、`mongodb`、
`turso` 七个,一个不多一个不少)。唯一可见的差别是 `--help` 里这七个值的**枚举顺序**,
说明见下。

#6345 把平台的驱动词表收敛成 `@objectstack/spec` 的一张表之后,CLI 里仍留着它的副本:
两条命令各自用手写字面量数组声明 oclif 的 `options:`(一份强制白名单),并且各自在
`description:` 的散文里把同样的 id **再抄一遍**。四份副本,一张表,正是 #6535
(`IMPORT_JOB_MAX_ROWS` 两处定义)的形状挪了个包。

现在 `@objectstack/spec` 导出 `DATABASE_DRIVER_SELECTION_IDS`——**选择面**(
`DriverVocabularyEntry.aliases`)收敛到规范拼写后的投影——两条命令连同 help 散文里的
枚举都从它派生,CLI 内不再有任何手写驱动 id 列表。

取的是选择面而**不是**配置契约面(`DRIVER_ID_ALIASES` / `resolveDriverId`):后者按设计
包含 `contractOnlyAliases`(`sqlite3`、`better-sqlite3`、`mariadb`、`inmemory`)——它们能
解析出一份存量 datasource 的 config 契约,但两个启动宿主从来都不接受它们作为启动选择。
把它们摆上 flag 会是一次**放宽**,只是穿了重构的外衣。新增用例驱动 oclif 真实 parser,
证明这四个拼写仍在 parse 阶段被拒。

这不是在修一个用户会撞到的缺陷:`database-driver-allowlist.pin.test.ts`(#6860)已经在钉
「白名单 ↔ `resolveStorageDefinition` 能解析出的驱动种类」这条一致性,而且 #6345 落地当天
就抓到过一次真回归。本次改动是结构性的——第二份定义没有了,钉子守的那条一致性也就无法
再由「改了一个文件忘了另一个」打破。该钉子**未被改动**,改后依旧全绿。

**`--help` 顺序**:枚举顺序从 CLI 手写的 `sqlite | sqlite-wasm | turso | postgres | mysql |
mongodb | memory` 变为共享表的行序 `memory | sqlite | sqlite-wasm | postgres | mysql |
mongodb | turso`。同一份 CLI 在你拼错驱动名时打印的 “Supported drivers: …” 早就用的是行序,
所以改后 `--help` 与它自己的拒绝信息终于按同一个顺序列举驱动。要保住旧顺序,就必须在
`packages/cli` 里留下一份手写的顺序列表——恰恰是本卡要删掉的东西。
117 changes: 117 additions & 0 deletions packages/cli/src/commands/database-driver-flag-derivation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #6969 — `--database-driver` states no driver vocabulary of its own.
*
* ## What this covers that `database-driver-allowlist.pin.test.ts` does not
*
* The #6860 pin asserts the flag AGREES with `resolveStorageDefinition`, and it
* still does; it is deliberately untouched by this card. But it compares SETS,
* from two derivations, and it never reads the flag's `description:` at all. Two
* things could therefore be wrong while it stayed green:
*
* 1. the flag could be re-hand-written with the same members in a different
* order, so `os start --help` and `os dev --help` stop agreeing with each
* other (oclif prints `options:` verbatim, in array order, three times per
* command — usage line, description, `<options: …>` line);
* 2. the description prose could enumerate a stale list. It did enumerate a
* hand-written one before this card, next to the array, with nothing at all
* keeping the two in step — the drift that #6860 found in the allowlist, one
* string over.
*
* ## And the direction that would be a behaviour change, not a refactor
*
* Deriving the flag from the CONFIG-CONTRACT face (`DRIVER_ID_ALIASES` /
* `resolveDriverId`) instead of the SELECTION face would offer `sqlite3`,
* `better-sqlite3`, `mariadb` and `inmemory` — spellings neither boot host has
* ever accepted as a selection (#6345 fixes the selection face as the union of
* what the two hosts accepted the day the ruling was written). The last case here
* drives oclif's real parser to prove they are still refused at parse time.
*/

import { describe, it, expect } from 'vitest';
import { Parser } from '@oclif/core';
import type { Interfaces } from '@oclif/core';
import { DATABASE_DRIVER_SELECTION_IDS, resolveDatabaseDriverId, resolveDriverId } from '@objectstack/spec/data';
import Start from './start.js';
import Dev from './dev.js';

const COMMANDS = [
{ name: 'os start', flags: Start.flags as Record<string, unknown> },
{ name: 'os dev', flags: Dev.flags as Record<string, unknown> },
] as const;

function driverFlag(flags: Record<string, unknown>): { description?: string; options?: readonly string[] } {
return flags['database-driver'] as { description?: string; options?: readonly string[] };
}

/**
* The driver list as the flag's HELP PROSE spells it — `…: a | b | c (overrides
* $OS_DATABASE_DRIVER)`. Read back out of the rendered string rather than from
* the constant that built it, so the assertion still means something if a command
* ever goes back to writing its own sentence.
*/
function enumeratedInDescription(description: string): string[] {
const match = /:\s*([^:()]+?)\s*\(overrides/.exec(description);
expect(match, `the description must still enumerate the drivers: ${description}`).toBeTruthy();
return match![1]!.split('|').map((token) => token.trim());
}

/** Spellings that resolve a config contract but are refused as a boot selection. */
const CONTRACT_ONLY_SPELLINGS = ['sqlite3', 'better-sqlite3', 'mariadb', 'inmemory'] as const;

describe('#6969 — the flag is derived from the shared driver table', () => {
it('the derived vocabulary is non-empty (guards every assertion below)', () => {
expect(DATABASE_DRIVER_SELECTION_IDS.length).toBeGreaterThan(0);
});

for (const { name, flags } of COMMANDS) {
describe(name, () => {
it('offers exactly the shared table\'s selection ids, in the table\'s order', () => {
// ORDER, not just membership: it is what `--help` prints, and the two
// commands must not describe the same flag differently.
expect(driverFlag(flags).options).toEqual([...DATABASE_DRIVER_SELECTION_IDS]);
});

it('enumerates the same drivers in its description as it enforces in `options:`', () => {
const flag = driverFlag(flags);
expect(enumeratedInDescription(flag.description!)).toEqual([...(flag.options as readonly string[])]);
});
});
}

it('start and dev publish byte-identical driver enumerations', () => {
const [start, dev] = COMMANDS.map(({ flags }) => driverFlag(flags).options);
expect(start).toEqual(dev);
});

it('hands each command its own array, so one cannot mutate the other\'s allowlist', () => {
expect(driverFlag(COMMANDS[0].flags).options).not.toBe(driverFlag(COMMANDS[1].flags).options);
});

it.each(CONTRACT_ONLY_SPELLINGS)(
'still refuses `%s` at parse time — a contract-only spelling is not a boot selection',
async (spelling) => {
// The premise, restated from the table so this cannot rot into asserting
// that a canonical id is refused: these DO resolve a config contract and
// do NOT resolve a selection.
expect(resolveDriverId(spelling), `${spelling} must still resolve a config contract`).toBeDefined();
expect(resolveDatabaseDriverId(spelling), `${spelling} must not be selectable`).toBeUndefined();

for (const { name, flags } of COMMANDS) {
// oclif owns this refusal, so there is no ADR-0112 envelope to assert on:
// the observable contract is the parse-time rejection plus a message that
// names the rejected value and the legal set. Both are asserted, because
// a bare "it threw" would also be satisfied by a flag that had lost its
// `options:` allowlist and failed for some unrelated reason.
await expect(
Parser.parse(['--database-driver', spelling], {
flags: flags as unknown as Interfaces.FlagInput,
strict: false,
}),
`${name} accepted --database-driver ${spelling}`,
).rejects.toThrow(new RegExp(`expected .*${spelling}.* to be one of`, 'i'));
}
},
);
});
15 changes: 7 additions & 8 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import os from 'os';
import path from 'path';
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
import { redactConnectionUrl } from '../utils/connection-display.js';
import { databaseDriverFlag } from '../utils/database-driver-flag.js';
import {
DEV_WATCH_IGNORED,
ServeRestartCoordinator,
Expand Down Expand Up @@ -103,14 +104,12 @@ export default class Dev extends Command {
char: 'd',
description: 'Database URL: file:./db.sqlite | libsql://... | postgres://... | mongodb://... | memory:// (overrides $OS_DATABASE_URL)',
}),
// Enforced allowlist, not a help string — see start.ts's note. Kept in
// agreement with `resolveStorageDefinition` by
// `database-driver-allowlist.pin.test.ts`, which covers both commands
// because the flag is declared once here and once there (#6860).
'database-driver': Flags.string({
description: 'Force driver kind: sqlite | sqlite-wasm | turso | postgres | mysql | mongodb | memory (overrides $OS_DATABASE_DRIVER)',
options: ['sqlite', 'sqlite-wasm', 'turso', 'postgres', 'mysql', 'mongodb', 'memory'],
}),
// Enforced allowlist, not a help string — see `utils/database-driver-flag.ts`.
// Both the choices and the enumerated list in the description come from the
// shared driver table (#6969), so this declaration and `start.ts`'s cannot
// drift from each other or from the table; `database-driver-allowlist.pin.test.ts`
// (#6860) still pins the agreement with `resolveStorageDefinition`.
'database-driver': databaseDriverFlag('Force driver kind'),
'database-auth-token': Flags.string({
description: 'Auth token for libsql/Turso connections (overrides $OS_DATABASE_AUTH_TOKEN / $TURSO_AUTH_TOKEN)',
}),
Expand Down
18 changes: 8 additions & 10 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import os from 'os';
import path from 'path';
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
import { redactConnectionUrl } from '../utils/connection-display.js';
import { databaseDriverFlag } from '../utils/database-driver-flag.js';
import { readEnvWithDeprecation } from '@objectstack/types';
import type { ResolvedProjectDatabaseUrl } from '@objectstack/runtime';

Expand Down Expand Up @@ -106,16 +107,13 @@ export default class Start extends Command {
char: 'd',
description: 'Database URL: file:./db.sqlite | libsql://... | postgres://... | mongodb://... | memory:// (overrides $OS_DATABASE_URL; defaults to file:<home>/data/objectstack.db)',
}),
// `options:` is an ENFORCED allowlist — oclif rejects anything outside it at
// parse time, before the command body runs. It must therefore offer every
// driver kind `resolveStorageDefinition` accepts, or the flag refuses a driver
// that the equivalent `OS_DATABASE_DRIVER` env var happily selects — one thing,
// two answers (#6860: `mysql` and `sqlite-wasm` were missing and unusable via
// the flag). `database-driver-allowlist.pin.test.ts` pins the agreement.
'database-driver': Flags.string({
description: 'Force driver kind when URL is ambiguous: sqlite | sqlite-wasm | turso | postgres | mysql | mongodb | memory (overrides $OS_DATABASE_DRIVER)',
options: ['sqlite', 'sqlite-wasm', 'turso', 'postgres', 'mysql', 'mongodb', 'memory'],
}),
// Choices AND the enumerated list in the description come from the shared
// driver table in `@objectstack/spec` (#6969) — this command states no driver
// vocabulary of its own. See `utils/database-driver-flag.ts` for which column
// is read and why the contract-only spellings must not be offered;
// `database-driver-allowlist.pin.test.ts` (#6860) pins the agreement with
// `resolveStorageDefinition`.
'database-driver': databaseDriverFlag('Force driver kind when URL is ambiguous'),
'database-auth-token': Flags.string({
description: 'Auth token for libsql/Turso connections (overrides $OS_DATABASE_AUTH_TOKEN / $TURSO_AUTH_TOKEN)',
}),
Expand Down
79 changes: 79 additions & 0 deletions packages/cli/src/utils/database-driver-flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The one definition of `--database-driver`'s choices, derived from the shared
* driver table (#6969).
*
* ## Why this file exists
*
* `os start` and `os dev` each declared the flag with a hand-written literal
* array — `options: ['sqlite', 'sqlite-wasm', 'turso', …]` — and each repeated
* the same ids a second time inside the flag's `description:` prose. #6345 had
* just collapsed the platform's driver vocabulary into ONE table in
* `@objectstack/spec`, so those four literals were a second, third, fourth and
* fifth statement of it living one package away. That is the shape #6535 closed
* for `IMPORT_JOB_MAX_ROWS`, moved to another package.
*
* This is NOT a drift FIX: `commands/database-driver-allowlist.pin.test.ts`
* (#6860) already asserts the flag agrees with what `resolveStorageDefinition`
* resolves, and it caught a real regression the day #6345 landed. Nothing an
* operator can reach today is wrong. The point is narrower and structural — with
* one definition, there is no second copy left to drift, so the pin guards an
* agreement that can no longer be broken by editing one file and not the other.
*
* ## Which column, and why it matters that it is this one
*
* {@link DATABASE_DRIVER_SELECTION_IDS} — the SELECTION face reduced to canonical
* spellings. Not `DRIVER_ID_ALIASES` and not `resolveDriverId`: those cover the
* CONFIG-CONTRACT face, which deliberately includes `contractOnlyAliases`
* (`sqlite3`, `better-sqlite3`, `mariadb`, `inmemory`) — spellings that resolve a
* stored datasource's config schema but that neither boot host has ever accepted
* as a selection. Offering them here would widen the flag on no ruling, and would
* be a behaviour change wearing a refactor's clothes.
*
* Every id in the derived set IS offered: no driver is withheld from the flag
* today. Should one ever need to be, it gets declared on the table's row (the
* exception belongs next to `hasLocalDefault`, where every host can see it) —
* never subtracted here, which would recreate the second definition this file
* deletes.
*/

import { Flags } from '@oclif/core';
import { DATABASE_DRIVER_SELECTION_IDS } from '@objectstack/spec/data';

/**
* The enforced `options:` allowlist for `--database-driver`, in the shared
* table's row order — the same order the hosts' "Supported drivers: …" refusal
* prints, so `--help` and the refusal an operator hits after mistyping enumerate
* drivers alike.
*
* A fresh array per read: oclif stores what it is handed on the flag definition,
* and two commands must not share one mutable instance.
*/
export function databaseDriverFlagOptions(): string[] {
return [...DATABASE_DRIVER_SELECTION_IDS];
}

/**
* Declare `--database-driver` on a command.
*
* `options:` is an ENFORCED allowlist — oclif rejects anything outside it at
* parse time, before the command body runs — so it must offer every driver kind
* a boot host can actually select, or the flag refuses a driver the equivalent
* `OS_DATABASE_DRIVER` env var happily accepts (#6860: `mysql` and `sqlite-wasm`
* were missing and unusable through the flag).
*
* `summary` is the one part a command still writes, because the two commands
* genuinely say different things (`os start` mentions the ambiguous-URL case,
* `os dev` does not). The enumerated list is appended from the same array
* `options:` gets, so the prose and the allowlist cannot disagree — the drift
* that a hand-written description list invites, and that no gate would have
* caught.
*/
export function databaseDriverFlag(summary: string) {
const options = databaseDriverFlagOptions();
return Flags.string({
description: `${summary}: ${options.join(' | ')} (overrides $OS_DATABASE_DRIVER)`,
options,
});
}
1 change: 1 addition & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"CustomPersistenceConfig (type)",
"CustomPersistenceConfigSchema (const)",
"DATABASE_DRIVER_SELECTION_ALIASES (const)",
"DATABASE_DRIVER_SELECTION_IDS (const)",
"DATA_ACTION_TO_API_OPERATION (const)",
"DATE_MACRO_ALIAS_TOKENS (const)",
"DATE_MACRO_DESCRIPTIONS (const)",
Expand Down
Loading
Loading