Skip to content

Commit 79228cd

Browse files
refactor(spec,cli): derive --database-driver options from the shared driver table (#6969) (#7029)
`os start` and `os dev` each hand-wrote the flag's oclif `options:` allowlist and repeated the same ids in the description prose — four copies of a vocabulary #6345 had just collapsed into one table in `@objectstack/spec`. `packages/spec` now exports `DATABASE_DRIVER_SELECTION_IDS` (the SELECTION face reduced to canonical spellings) and both commands derive choices AND prose from it. The contract-only spellings (`sqlite3`, `better-sqlite3`, `mariadb`, `inmemory`) cannot leak onto the flag: they never enter the array the projection reads. Accepted value set unchanged. The #6860 pin is untouched and green. Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91cefb8 commit 79228cd

8 files changed

Lines changed: 369 additions & 18 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
refactor(spec,cli): `--database-driver` 的可选值从共享驱动表推导,删掉 CLI 里的第二份词表 (#6969)
7+
8+
**无行为变更**`os start --database-driver` / `os dev --database-driver` 接受的取值集合
9+
与改动前**逐字相同**`memory``sqlite``sqlite-wasm``postgres``mysql``mongodb`
10+
`turso` 七个,一个不多一个不少)。唯一可见的差别是 `--help` 里这七个值的**枚举顺序**
11+
说明见下。
12+
13+
#6345 把平台的驱动词表收敛成 `@objectstack/spec` 的一张表之后,CLI 里仍留着它的副本:
14+
两条命令各自用手写字面量数组声明 oclif 的 `options:`(一份强制白名单),并且各自在
15+
`description:` 的散文里把同样的 id **再抄一遍**。四份副本,一张表,正是 #6535
16+
`IMPORT_JOB_MAX_ROWS` 两处定义)的形状挪了个包。
17+
18+
现在 `@objectstack/spec` 导出 `DATABASE_DRIVER_SELECTION_IDS`——**选择面**
19+
`DriverVocabularyEntry.aliases`)收敛到规范拼写后的投影——两条命令连同 help 散文里的
20+
枚举都从它派生,CLI 内不再有任何手写驱动 id 列表。
21+
22+
取的是选择面而**不是**配置契约面(`DRIVER_ID_ALIASES` / `resolveDriverId`):后者按设计
23+
包含 `contractOnlyAliases``sqlite3``better-sqlite3``mariadb``inmemory`)——它们能
24+
解析出一份存量 datasource 的 config 契约,但两个启动宿主从来都不接受它们作为启动选择。
25+
把它们摆上 flag 会是一次**放宽**,只是穿了重构的外衣。新增用例驱动 oclif 真实 parser,
26+
证明这四个拼写仍在 parse 阶段被拒。
27+
28+
这不是在修一个用户会撞到的缺陷:`database-driver-allowlist.pin.test.ts`#6860)已经在钉
29+
「白名单 ↔ `resolveStorageDefinition` 能解析出的驱动种类」这条一致性,而且 #6345 落地当天
30+
就抓到过一次真回归。本次改动是结构性的——第二份定义没有了,钉子守的那条一致性也就无法
31+
再由「改了一个文件忘了另一个」打破。该钉子**未被改动**,改后依旧全绿。
32+
33+
**`--help` 顺序**:枚举顺序从 CLI 手写的 `sqlite | sqlite-wasm | turso | postgres | mysql |
34+
mongodb | memory` 变为共享表的行序 `memory | sqlite | sqlite-wasm | postgres | mysql |
35+
mongodb | turso`。同一份 CLI 在你拼错驱动名时打印的 “Supported drivers: …” 早就用的是行序,
36+
所以改后 `--help` 与它自己的拒绝信息终于按同一个顺序列举驱动。要保住旧顺序,就必须在
37+
`packages/cli` 里留下一份手写的顺序列表——恰恰是本卡要删掉的东西。
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #6969 — `--database-driver` states no driver vocabulary of its own.
5+
*
6+
* ## What this covers that `database-driver-allowlist.pin.test.ts` does not
7+
*
8+
* The #6860 pin asserts the flag AGREES with `resolveStorageDefinition`, and it
9+
* still does; it is deliberately untouched by this card. But it compares SETS,
10+
* from two derivations, and it never reads the flag's `description:` at all. Two
11+
* things could therefore be wrong while it stayed green:
12+
*
13+
* 1. the flag could be re-hand-written with the same members in a different
14+
* order, so `os start --help` and `os dev --help` stop agreeing with each
15+
* other (oclif prints `options:` verbatim, in array order, three times per
16+
* command — usage line, description, `<options: …>` line);
17+
* 2. the description prose could enumerate a stale list. It did enumerate a
18+
* hand-written one before this card, next to the array, with nothing at all
19+
* keeping the two in step — the drift that #6860 found in the allowlist, one
20+
* string over.
21+
*
22+
* ## And the direction that would be a behaviour change, not a refactor
23+
*
24+
* Deriving the flag from the CONFIG-CONTRACT face (`DRIVER_ID_ALIASES` /
25+
* `resolveDriverId`) instead of the SELECTION face would offer `sqlite3`,
26+
* `better-sqlite3`, `mariadb` and `inmemory` — spellings neither boot host has
27+
* ever accepted as a selection (#6345 fixes the selection face as the union of
28+
* what the two hosts accepted the day the ruling was written). The last case here
29+
* drives oclif's real parser to prove they are still refused at parse time.
30+
*/
31+
32+
import { describe, it, expect } from 'vitest';
33+
import { Parser } from '@oclif/core';
34+
import type { Interfaces } from '@oclif/core';
35+
import { DATABASE_DRIVER_SELECTION_IDS, resolveDatabaseDriverId, resolveDriverId } from '@objectstack/spec/data';
36+
import Start from './start.js';
37+
import Dev from './dev.js';
38+
39+
const COMMANDS = [
40+
{ name: 'os start', flags: Start.flags as Record<string, unknown> },
41+
{ name: 'os dev', flags: Dev.flags as Record<string, unknown> },
42+
] as const;
43+
44+
function driverFlag(flags: Record<string, unknown>): { description?: string; options?: readonly string[] } {
45+
return flags['database-driver'] as { description?: string; options?: readonly string[] };
46+
}
47+
48+
/**
49+
* The driver list as the flag's HELP PROSE spells it — `…: a | b | c (overrides
50+
* $OS_DATABASE_DRIVER)`. Read back out of the rendered string rather than from
51+
* the constant that built it, so the assertion still means something if a command
52+
* ever goes back to writing its own sentence.
53+
*/
54+
function enumeratedInDescription(description: string): string[] {
55+
const match = /:\s*([^:()]+?)\s*\(overrides/.exec(description);
56+
expect(match, `the description must still enumerate the drivers: ${description}`).toBeTruthy();
57+
return match![1]!.split('|').map((token) => token.trim());
58+
}
59+
60+
/** Spellings that resolve a config contract but are refused as a boot selection. */
61+
const CONTRACT_ONLY_SPELLINGS = ['sqlite3', 'better-sqlite3', 'mariadb', 'inmemory'] as const;
62+
63+
describe('#6969 — the flag is derived from the shared driver table', () => {
64+
it('the derived vocabulary is non-empty (guards every assertion below)', () => {
65+
expect(DATABASE_DRIVER_SELECTION_IDS.length).toBeGreaterThan(0);
66+
});
67+
68+
for (const { name, flags } of COMMANDS) {
69+
describe(name, () => {
70+
it('offers exactly the shared table\'s selection ids, in the table\'s order', () => {
71+
// ORDER, not just membership: it is what `--help` prints, and the two
72+
// commands must not describe the same flag differently.
73+
expect(driverFlag(flags).options).toEqual([...DATABASE_DRIVER_SELECTION_IDS]);
74+
});
75+
76+
it('enumerates the same drivers in its description as it enforces in `options:`', () => {
77+
const flag = driverFlag(flags);
78+
expect(enumeratedInDescription(flag.description!)).toEqual([...(flag.options as readonly string[])]);
79+
});
80+
});
81+
}
82+
83+
it('start and dev publish byte-identical driver enumerations', () => {
84+
const [start, dev] = COMMANDS.map(({ flags }) => driverFlag(flags).options);
85+
expect(start).toEqual(dev);
86+
});
87+
88+
it('hands each command its own array, so one cannot mutate the other\'s allowlist', () => {
89+
expect(driverFlag(COMMANDS[0].flags).options).not.toBe(driverFlag(COMMANDS[1].flags).options);
90+
});
91+
92+
it.each(CONTRACT_ONLY_SPELLINGS)(
93+
'still refuses `%s` at parse time — a contract-only spelling is not a boot selection',
94+
async (spelling) => {
95+
// The premise, restated from the table so this cannot rot into asserting
96+
// that a canonical id is refused: these DO resolve a config contract and
97+
// do NOT resolve a selection.
98+
expect(resolveDriverId(spelling), `${spelling} must still resolve a config contract`).toBeDefined();
99+
expect(resolveDatabaseDriverId(spelling), `${spelling} must not be selectable`).toBeUndefined();
100+
101+
for (const { name, flags } of COMMANDS) {
102+
// oclif owns this refusal, so there is no ADR-0112 envelope to assert on:
103+
// the observable contract is the parse-time rejection plus a message that
104+
// names the rejected value and the legal set. Both are asserted, because
105+
// a bare "it threw" would also be satisfied by a flag that had lost its
106+
// `options:` allowlist and failed for some unrelated reason.
107+
await expect(
108+
Parser.parse(['--database-driver', spelling], {
109+
flags: flags as unknown as Interfaces.FlagInput,
110+
strict: false,
111+
}),
112+
`${name} accepted --database-driver ${spelling}`,
113+
).rejects.toThrow(new RegExp(`expected .*${spelling}.* to be one of`, 'i'));
114+
}
115+
},
116+
);
117+
});

packages/cli/src/commands/dev.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import os from 'os';
99
import path from 'path';
1010
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
1111
import { redactConnectionUrl } from '../utils/connection-display.js';
12+
import { databaseDriverFlag } from '../utils/database-driver-flag.js';
1213
import {
1314
DEV_WATCH_IGNORED,
1415
ServeRestartCoordinator,
@@ -103,14 +104,12 @@ export default class Dev extends Command {
103104
char: 'd',
104105
description: 'Database URL: file:./db.sqlite | libsql://... | postgres://... | mongodb://... | memory:// (overrides $OS_DATABASE_URL)',
105106
}),
106-
// Enforced allowlist, not a help string — see start.ts's note. Kept in
107-
// agreement with `resolveStorageDefinition` by
108-
// `database-driver-allowlist.pin.test.ts`, which covers both commands
109-
// because the flag is declared once here and once there (#6860).
110-
'database-driver': Flags.string({
111-
description: 'Force driver kind: sqlite | sqlite-wasm | turso | postgres | mysql | mongodb | memory (overrides $OS_DATABASE_DRIVER)',
112-
options: ['sqlite', 'sqlite-wasm', 'turso', 'postgres', 'mysql', 'mongodb', 'memory'],
113-
}),
107+
// Enforced allowlist, not a help string — see `utils/database-driver-flag.ts`.
108+
// Both the choices and the enumerated list in the description come from the
109+
// shared driver table (#6969), so this declaration and `start.ts`'s cannot
110+
// drift from each other or from the table; `database-driver-allowlist.pin.test.ts`
111+
// (#6860) still pins the agreement with `resolveStorageDefinition`.
112+
'database-driver': databaseDriverFlag('Force driver kind'),
114113
'database-auth-token': Flags.string({
115114
description: 'Auth token for libsql/Turso connections (overrides $OS_DATABASE_AUTH_TOKEN / $TURSO_AUTH_TOKEN)',
116115
}),

packages/cli/src/commands/start.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import os from 'os';
1010
import path from 'path';
1111
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
1212
import { redactConnectionUrl } from '../utils/connection-display.js';
13+
import { databaseDriverFlag } from '../utils/database-driver-flag.js';
1314
import { readEnvWithDeprecation } from '@objectstack/types';
1415
import type { ResolvedProjectDatabaseUrl } from '@objectstack/runtime';
1516

@@ -106,16 +107,13 @@ export default class Start extends Command {
106107
char: 'd',
107108
description: 'Database URL: file:./db.sqlite | libsql://... | postgres://... | mongodb://... | memory:// (overrides $OS_DATABASE_URL; defaults to file:<home>/data/objectstack.db)',
108109
}),
109-
// `options:` is an ENFORCED allowlist — oclif rejects anything outside it at
110-
// parse time, before the command body runs. It must therefore offer every
111-
// driver kind `resolveStorageDefinition` accepts, or the flag refuses a driver
112-
// that the equivalent `OS_DATABASE_DRIVER` env var happily selects — one thing,
113-
// two answers (#6860: `mysql` and `sqlite-wasm` were missing and unusable via
114-
// the flag). `database-driver-allowlist.pin.test.ts` pins the agreement.
115-
'database-driver': Flags.string({
116-
description: 'Force driver kind when URL is ambiguous: sqlite | sqlite-wasm | turso | postgres | mysql | mongodb | memory (overrides $OS_DATABASE_DRIVER)',
117-
options: ['sqlite', 'sqlite-wasm', 'turso', 'postgres', 'mysql', 'mongodb', 'memory'],
118-
}),
110+
// Choices AND the enumerated list in the description come from the shared
111+
// driver table in `@objectstack/spec` (#6969) — this command states no driver
112+
// vocabulary of its own. See `utils/database-driver-flag.ts` for which column
113+
// is read and why the contract-only spellings must not be offered;
114+
// `database-driver-allowlist.pin.test.ts` (#6860) pins the agreement with
115+
// `resolveStorageDefinition`.
116+
'database-driver': databaseDriverFlag('Force driver kind when URL is ambiguous'),
119117
'database-auth-token': Flags.string({
120118
description: 'Auth token for libsql/Turso connections (overrides $OS_DATABASE_AUTH_TOKEN / $TURSO_AUTH_TOKEN)',
121119
}),
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The one definition of `--database-driver`'s choices, derived from the shared
5+
* driver table (#6969).
6+
*
7+
* ## Why this file exists
8+
*
9+
* `os start` and `os dev` each declared the flag with a hand-written literal
10+
* array — `options: ['sqlite', 'sqlite-wasm', 'turso', …]` — and each repeated
11+
* the same ids a second time inside the flag's `description:` prose. #6345 had
12+
* just collapsed the platform's driver vocabulary into ONE table in
13+
* `@objectstack/spec`, so those four literals were a second, third, fourth and
14+
* fifth statement of it living one package away. That is the shape #6535 closed
15+
* for `IMPORT_JOB_MAX_ROWS`, moved to another package.
16+
*
17+
* This is NOT a drift FIX: `commands/database-driver-allowlist.pin.test.ts`
18+
* (#6860) already asserts the flag agrees with what `resolveStorageDefinition`
19+
* resolves, and it caught a real regression the day #6345 landed. Nothing an
20+
* operator can reach today is wrong. The point is narrower and structural — with
21+
* one definition, there is no second copy left to drift, so the pin guards an
22+
* agreement that can no longer be broken by editing one file and not the other.
23+
*
24+
* ## Which column, and why it matters that it is this one
25+
*
26+
* {@link DATABASE_DRIVER_SELECTION_IDS} — the SELECTION face reduced to canonical
27+
* spellings. Not `DRIVER_ID_ALIASES` and not `resolveDriverId`: those cover the
28+
* CONFIG-CONTRACT face, which deliberately includes `contractOnlyAliases`
29+
* (`sqlite3`, `better-sqlite3`, `mariadb`, `inmemory`) — spellings that resolve a
30+
* stored datasource's config schema but that neither boot host has ever accepted
31+
* as a selection. Offering them here would widen the flag on no ruling, and would
32+
* be a behaviour change wearing a refactor's clothes.
33+
*
34+
* Every id in the derived set IS offered: no driver is withheld from the flag
35+
* today. Should one ever need to be, it gets declared on the table's row (the
36+
* exception belongs next to `hasLocalDefault`, where every host can see it) —
37+
* never subtracted here, which would recreate the second definition this file
38+
* deletes.
39+
*/
40+
41+
import { Flags } from '@oclif/core';
42+
import { DATABASE_DRIVER_SELECTION_IDS } from '@objectstack/spec/data';
43+
44+
/**
45+
* The enforced `options:` allowlist for `--database-driver`, in the shared
46+
* table's row order — the same order the hosts' "Supported drivers: …" refusal
47+
* prints, so `--help` and the refusal an operator hits after mistyping enumerate
48+
* drivers alike.
49+
*
50+
* A fresh array per read: oclif stores what it is handed on the flag definition,
51+
* and two commands must not share one mutable instance.
52+
*/
53+
export function databaseDriverFlagOptions(): string[] {
54+
return [...DATABASE_DRIVER_SELECTION_IDS];
55+
}
56+
57+
/**
58+
* Declare `--database-driver` on a command.
59+
*
60+
* `options:` is an ENFORCED allowlist — oclif rejects anything outside it at
61+
* parse time, before the command body runs — so it must offer every driver kind
62+
* a boot host can actually select, or the flag refuses a driver the equivalent
63+
* `OS_DATABASE_DRIVER` env var happily accepts (#6860: `mysql` and `sqlite-wasm`
64+
* were missing and unusable through the flag).
65+
*
66+
* `summary` is the one part a command still writes, because the two commands
67+
* genuinely say different things (`os start` mentions the ambiguous-URL case,
68+
* `os dev` does not). The enumerated list is appended from the same array
69+
* `options:` gets, so the prose and the allowlist cannot disagree — the drift
70+
* that a hand-written description list invites, and that no gate would have
71+
* caught.
72+
*/
73+
export function databaseDriverFlag(summary: string) {
74+
const options = databaseDriverFlagOptions();
75+
return Flags.string({
76+
description: `${summary}: ${options.join(' | ')} (overrides $OS_DATABASE_DRIVER)`,
77+
options,
78+
});
79+
}

packages/spec/api-surface/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"CustomPersistenceConfig (type)",
9090
"CustomPersistenceConfigSchema (const)",
9191
"DATABASE_DRIVER_SELECTION_ALIASES (const)",
92+
"DATABASE_DRIVER_SELECTION_IDS (const)",
9293
"DATA_ACTION_TO_API_OPERATION (const)",
9394
"DATE_MACRO_ALIAS_TOKENS (const)",
9495
"DATE_MACRO_DESCRIPTIONS (const)",

0 commit comments

Comments
 (0)