diff --git a/src/resources/extensions/sf/preferences-migrations.ts b/src/resources/extensions/sf/preferences-migrations.ts new file mode 100644 index 000000000..fbddf8afe --- /dev/null +++ b/src/resources/extensions/sf/preferences-migrations.ts @@ -0,0 +1,124 @@ +/** + * Preferences schema versioning and forward-migration registry. + * + * Goal: when sf evolves the prefs schema (renames a field, adds a new + * required default, deprecates something), existing project PREFERENCES.md + * files keep working. Each project file declares `version: N`; this module + * defines the currently-expected version and a chain of pure functions that + * upgrade older prefs forward one step at a time. + * + * Contract: + * - Migrations are forward-only and pure (no filesystem, no IO). + * - Each migration takes prefs at version `from` and returns prefs at + * version `to = from + 1`. The chain is replayed in order. + * - Adding a new schema version: bump CURRENT_PREFERENCES_SCHEMA_VERSION, + * register a Migration with from = previous, to = new, and an `apply` + * that handles whatever structural change motivated the bump. + */ +import type { SFPreferences } from "./preferences-types.js"; + +/** + * The schema version this build of sf reads and writes natively. + * + * Bump this AT THE SAME TIME you register a new Migration entry below. + * Do not bump speculatively — projects with `version > current` get a + * "prefs from a newer sf, ignoring unknown fields" warning, which is + * the right behavior for forward compatibility but signals churn to users. + */ +export const CURRENT_PREFERENCES_SCHEMA_VERSION = 1; + +export interface Migration { + /** Schema version this migration upgrades from. */ + from: number; + /** Schema version this migration produces. Must equal from + 1. */ + to: number; + /** Human-readable note for migration logs and changelogs. */ + description: string; + /** Pure transform: prefs at version `from` → prefs at version `to`. */ + apply(prefs: SFPreferences): SFPreferences; +} + +/** + * Forward-only migration chain. Sorted by `from` ascending. Empty until + * the first real schema change ships — the framework is in place so the + * first bump (e.g. to v2) is a one-line change here, not a refactor. + */ +export const MIGRATIONS: ReadonlyArray = []; + +export interface MigrationOutcome { + preferences: SFPreferences; + /** Migrations applied in order. Empty if no upgrade was needed. */ + applied: Migration[]; + /** True iff the input was at a version newer than this build understands. */ + fromFuture: boolean; +} + +/** + * Apply forward migrations until prefs.version === CURRENT, or fail. + * If the input is at a higher version than current, return as-is and flag + * `fromFuture = true` — caller decides whether to warn or refuse. + * + * Treats `version === undefined` as version 1 (the implicit pre-versioning + * baseline) so old projects without explicit version get migrated. + */ +export function migrateForward(input: SFPreferences): MigrationOutcome { + const startVersion = input.version ?? 1; + if (startVersion > CURRENT_PREFERENCES_SCHEMA_VERSION) { + return { preferences: input, applied: [], fromFuture: true }; + } + if (startVersion === CURRENT_PREFERENCES_SCHEMA_VERSION) { + return { + preferences: { ...input, version: CURRENT_PREFERENCES_SCHEMA_VERSION }, + applied: [], + fromFuture: false, + }; + } + + let prefs = input; + const applied: Migration[] = []; + let v = startVersion; + while (v < CURRENT_PREFERENCES_SCHEMA_VERSION) { + const step = MIGRATIONS.find((m) => m.from === v); + if (!step) { + throw new Error( + `No migration registered from prefs schema v${v} → v${v + 1}. ` + + `This is a bug in sf — bumping CURRENT_PREFERENCES_SCHEMA_VERSION ` + + `requires a matching Migration entry in preferences-migrations.ts.`, + ); + } + prefs = step.apply(prefs); + applied.push(step); + v = step.to; + } + return { preferences: prefs, applied, fromFuture: false }; +} + +/** + * Compare a project's prefs against the current schema and report drift + * that isn't a hard error but worth surfacing to the user. + * + * Currently: + * - `version > current` (prefs from newer sf — fields may be silently dropped) + * - `version === undefined` (legacy file, would benefit from explicit version) + * + * Future: deprecated-key detection, missing-required-field detection. + */ +export function checkPreferencesDrift(prefs: SFPreferences): { + warnings: string[]; +} { + const warnings: string[] = []; + if (prefs.version === undefined) { + warnings.push( + "prefs file has no explicit `version` — assuming v1. Add " + + "`version: 1` (or higher) to silence this warning and pin the " + + "schema this file was authored against.", + ); + } else if (prefs.version > CURRENT_PREFERENCES_SCHEMA_VERSION) { + warnings.push( + `prefs file declares version ${prefs.version} but this sf build ` + + `understands up to v${CURRENT_PREFERENCES_SCHEMA_VERSION}. ` + + `Unknown fields will be ignored. Upgrade sf or downgrade the file.`, + ); + } + return { warnings }; +} diff --git a/src/resources/extensions/sf/preferences-validation.ts b/src/resources/extensions/sf/preferences-validation.ts index ad4c0ccf4..3f9feaeb7 100644 --- a/src/resources/extensions/sf/preferences-validation.ts +++ b/src/resources/extensions/sf/preferences-validation.ts @@ -10,6 +10,11 @@ import { normalizeStringArray } from "../shared/format-utils.js"; import type { GitPreferences } from "./git-service.js"; import { VALID_BRANCH_NAME } from "./git-service.js"; import type { DynamicRoutingConfig } from "./model-router.js"; +import { + checkPreferencesDrift, + CURRENT_PREFERENCES_SCHEMA_VERSION, + migrateForward, +} from "./preferences-migrations.js"; import { type DispatchExperimentPreferences, KNOWN_PREFERENCE_KEYS, @@ -47,6 +52,32 @@ export function validatePreferences(preferences: SFPreferences): { const warnings: string[] = []; const validated: SFPreferences = {}; + // Schema version: report drift, then migrate forward. Errors from a + // malformed migration chain bubble up so the caller can surface "your + // prefs need attention" instead of silently dropping fields. Field + // checks below run against the migrated copy. + for (const w of checkPreferencesDrift(preferences).warnings) + warnings.push(w); + let migrated: SFPreferences = preferences; + try { + const outcome = migrateForward(preferences); + migrated = outcome.preferences; + if (outcome.applied.length > 0) { + warnings.push( + `migrated prefs forward: ${outcome.applied + .map((m) => `v${m.from}→v${m.to} (${m.description})`) + .join("; ")}`, + ); + } + } catch (err) { + errors.push( + err instanceof Error + ? `prefs migration failed: ${err.message}` + : `prefs migration failed: ${String(err)}`, + ); + } + preferences = migrated; + // ─── Unknown Key Detection ────────────────────────────────────────── // Common key migration hints for pi-level settings that don't map to SF prefs const KEY_MIGRATION_HINTS: Record = { @@ -72,10 +103,19 @@ export function validatePreferences(preferences: SFPreferences): { } if (preferences.version !== undefined) { - if (preferences.version === 1) { - validated.version = 1; + if (preferences.version === CURRENT_PREFERENCES_SCHEMA_VERSION) { + validated.version = CURRENT_PREFERENCES_SCHEMA_VERSION; + } else if (preferences.version > CURRENT_PREFERENCES_SCHEMA_VERSION) { + // Already warned via checkPreferencesDrift; preserve so a later + // sf upgrade reads correctly without rewriting the file. + validated.version = preferences.version; } else { - errors.push(`unsupported version ${preferences.version}`); + // Should be unreachable: migrateForward stamps the current version + // or throws. Defend against a future bug instead of silently dropping. + errors.push( + `unsupported version ${preferences.version} (migration chain ` + + `should have produced v${CURRENT_PREFERENCES_SCHEMA_VERSION})`, + ); } }