diff --git a/.changeset/database-driver-flag-derived-from-table.md b/.changeset/database-driver-flag-derived-from-table.md new file mode 100644 index 0000000000..70c8268ced --- /dev/null +++ b/.changeset/database-driver-flag-derived-from-table.md @@ -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` 里留下一份手写的顺序列表——恰恰是本卡要删掉的东西。 diff --git a/packages/cli/src/commands/database-driver-flag-derivation.test.ts b/packages/cli/src/commands/database-driver-flag-derivation.test.ts new file mode 100644 index 0000000000..d0c048f346 --- /dev/null +++ b/packages/cli/src/commands/database-driver-flag-derivation.test.ts @@ -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, `` 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 }, + { name: 'os dev', flags: Dev.flags as Record }, +] as const; + +function driverFlag(flags: Record): { 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')); + } + }, + ); +}); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 13ba7450fa..07f7167b75 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -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, @@ -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)', }), diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 3d4c346b5f..15c31c402b 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -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'; @@ -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:/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)', }), diff --git a/packages/cli/src/utils/database-driver-flag.ts b/packages/cli/src/utils/database-driver-flag.ts new file mode 100644 index 0000000000..2f6e14d61b --- /dev/null +++ b/packages/cli/src/utils/database-driver-flag.ts @@ -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, + }); +} diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 7c4cd0c8de..61609f5ae1 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -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)", diff --git a/packages/spec/src/data/driver/config-registry.test.ts b/packages/spec/src/data/driver/config-registry.test.ts index 8beee07094..e95cd67b78 100644 --- a/packages/spec/src/data/driver/config-registry.test.ts +++ b/packages/spec/src/data/driver/config-registry.test.ts @@ -5,10 +5,13 @@ import { describe, it, expect } from 'vitest'; import { DatasourceSchema } from '../datasource.zod'; import { BUILTIN_DRIVER_IDS, + DATABASE_DRIVER_SELECTION_ALIASES, + DATABASE_DRIVER_SELECTION_IDS, DRIVER_CONFIG_SCHEMAS, DRIVER_ID_ALIASES, getDriverConfigJsonSchemaById, getDriverConfigSchema, + resolveDatabaseDriverId, resolveDriverId, validateDriverConfig, } from './config-registry.zod'; @@ -160,3 +163,62 @@ describe('DatasourceSchema × driver config (#4410)', () => { expect(paths).toContain('external'); }); }); + +/** + * `DATABASE_DRIVER_SELECTION_IDS` — the boot-flag face (#6969). + * + * These are PROPERTIES of the projection, not a restatement of it: re-deriving + * the same `.filter()` here and asserting equality would only pin that the table + * equals itself. What each case pins is a way the projection could be wrong, and + * the first one is the way that actually matters — reading the CONFIG-CONTRACT + * column instead of the SELECTION column silently widens every boot host's flag. + */ +describe('DATABASE_DRIVER_SELECTION_IDS — what a boot flag may offer (#6969)', () => { + it('offers no contract-only spelling, whatever the derivation is rewritten to read', () => { + // The wrong-column guard. `sqlite3` / `better-sqlite3` / `mariadb` / + // `inmemory` resolve a config CONTRACT and are refused as a SELECTION, so a + // projection built from `DRIVER_ID_ALIASES` (or from `resolveDriverId`) would + // pass every other case in this file while widening `--database-driver` on + // both hosts. Derived from the same two functions the hosts use, so a fifth + // contract-only alias added to the table is covered the day it lands. + const contractOnly = Object.keys(DRIVER_ID_ALIASES).filter( + (alias) => resolveDriverId(alias) !== undefined && resolveDatabaseDriverId(alias) === undefined, + ); + expect(contractOnly.length, 'the table must still HAVE contract-only aliases for this to test anything') + .toBeGreaterThan(0); + for (const alias of contractOnly) { + expect(DATABASE_DRIVER_SELECTION_IDS, `${alias} must not be offered as a boot selection`) + .not.toContain(alias); + } + }); + + it('lists only canonical spellings — every entry resolves to itself', () => { + expect(DATABASE_DRIVER_SELECTION_IDS.length).toBeGreaterThan(0); + for (const id of DATABASE_DRIVER_SELECTION_IDS) { + expect(resolveDatabaseDriverId(id), id).toBe(id); + } + expect(new Set(DATABASE_DRIVER_SELECTION_IDS).size).toBe(DATABASE_DRIVER_SELECTION_IDS.length); + }); + + it('withholds no driver a boot host can select', () => { + // The other direction: every selection alias collapses onto a canonical id, + // and every one of those ids is offered. An id reachable through + // `OS_DATABASE_DRIVER=pg` but missing from the flag is #6860 exactly. + const canonicalFromAliases = new Set( + DATABASE_DRIVER_SELECTION_ALIASES.map((alias) => resolveDatabaseDriverId(alias)), + ); + expect(canonicalFromAliases).toEqual(new Set(DATABASE_DRIVER_SELECTION_IDS)); + }); + + it('is the selectable subset of the ids the platform ships a contract for', () => { + // Equal contents today, different questions (see the export's docstring): + // nothing shipped is currently withheld from the flag, and this states that + // out loud so the day one IS withheld, the change is deliberate and visible + // here rather than inferred from a diff. + expect([...DATABASE_DRIVER_SELECTION_IDS].sort()).toEqual([...BUILTIN_DRIVER_IDS].sort()); + }); + + it('is frozen, so a consumer cannot mutate the vocabulary it was handed', () => { + expect(Object.isFrozen(DATABASE_DRIVER_SELECTION_IDS)).toBe(true); + }); +}); diff --git a/packages/spec/src/data/driver/config-registry.zod.ts b/packages/spec/src/data/driver/config-registry.zod.ts index f6e1f9f060..1c1017200c 100644 --- a/packages/spec/src/data/driver/config-registry.zod.ts +++ b/packages/spec/src/data/driver/config-registry.zod.ts @@ -258,6 +258,64 @@ export function resolveDatabaseDriverId(driver: unknown): BuiltinDriverId | unde return DATABASE_DRIVER_ALIASES[driver.trim().toLowerCase()]; } +/** + * The CANONICAL driver ids a boot flag may offer as its choices — the selection + * face, reduced to one spelling per driver (#6969). + * + * ## Which question this answers, and why it is not {@link BUILTIN_DRIVER_IDS} + * + * The two have equal contents today and answer different questions, which is the + * distinction #6345 was written to keep visible: + * + * - {@link BUILTIN_DRIVER_IDS} — "which ids does the platform ship a CONFIG + * CONTRACT for". That is what {@link DRIVER_CONFIG_SCHEMAS} is keyed by, and + * what a metadata gate consults for a stored `datasource.driver`. + * - this — "which ids may an operator SELECT at boot, spelled canonically". + * That is what `os start --database-driver` / `os dev --database-driver` + * enumerate in their oclif `options:` allowlist. + * + * A driver that ships a contract but must not be bootable would belong in the + * first and not the second. Nothing is in that position today; the point of the + * separate name is that the day one is, the flag does not widen by inheritance. + * + * ## Derived from the SELECTION column, so the contract-only spellings cannot leak + * + * Built by filtering {@link DATABASE_DRIVER_SELECTION_ALIASES} — the + * {@link DriverVocabularyEntry.aliases} face — down to the spellings that resolve + * to THEMSELVES. Ten of its seventeen entries are dropped by that filter (`pg`, + * `mingo`, `sql`, `wasm`, `libsql`, …): they select a driver, but under another + * driver's canonical name, and an allowlist that offered both would be advertising + * one driver twice. + * + * {@link DriverVocabularyEntry.contractOnlyAliases} (`sqlite3`, `better-sqlite3`, + * `mariadb`, `inmemory`) cannot appear here at all — not because they are filtered + * out, but because they never enter the array this reads. Deriving a boot flag from + * {@link DRIVER_ID_ALIASES} instead would have offered all four, which is a + * WIDENING of what both hosts accept, dressed as a refactor: neither host has ever + * accepted `--database-driver sqlite3`, and #6345's ruling fixes the selection face + * as the union of what they accepted the day it was written. + * + * ## Order + * + * Table row order, inherited from {@link DATABASE_DRIVER_SELECTION_ALIASES} — the + * same order the hosts' "Supported drivers: …" refusal already prints, so a flag's + * `--help` and that flag's own refusal message enumerate drivers alike. Deliberately + * NOT a second (e.g. alphabetical) opinion about presentation: the table publishes + * one order and everything derived from it publishes that one. + * + * ## If an id must ever be withheld from the flag + * + * Declare it on the ROW — a column saying so, next to `hasLocalDefault` — and let + * this projection read it. Never subtract it at the consumer: a boot host that + * removes an id the table still advertises is exactly the second definition this + * export exists to delete. + */ +export const DATABASE_DRIVER_SELECTION_IDS: readonly BuiltinDriverId[] = Object.freeze( + DATABASE_DRIVER_SELECTION_ALIASES.filter( + (alias): alias is BuiltinDriverId => resolveDatabaseDriverId(alias) === alias, + ), +); + /** Canonical id → whether it can be selected with no database URL at all. */ const DRIVER_LOCAL_DEFAULT: Readonly> = Object.freeze( Object.fromEntries(