|
| 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 | +}); |
0 commit comments