feat(prefs): schema versioning with forward-migration registry

Adds the framework for evolving the prefs schema without silently breaking
projects pinned to older versions. Each PREFERENCES.md declares `version: N`;
sf declares CURRENT_PREFERENCES_SCHEMA_VERSION in code. On load:

- prefs.version === current → no-op
- prefs.version < current → run registered migrations in chain (forward only,
  pure functions). Missing migration in the chain throws — bumping the
  schema version requires a matching Migration entry, by construction.
- prefs.version > current → warn "prefs from a newer sf, fields may be
  ignored", preserve the value so a later upgrade reads correctly.
- prefs.version undefined → assume v1 (legacy file pre-versioning) and
  warn so the user adds an explicit pin.

Migration registry is empty for now (current schema version stays at 1) —
the framework is in place so the first real schema bump is a one-line
addition, not a refactor. Drift detection (`checkPreferencesDrift`) is also
the natural surface for future deprecated-key / missing-required-field
checks when CLAUDE.md / template comparisons are added.

Wired into validatePreferences() so every load path gets the new behavior
automatically — no caller changes needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-29 14:38:43 +02:00
parent dea4c2dbc1
commit 3b6cbcd79f
2 changed files with 167 additions and 3 deletions

View file

@ -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<Migration> = [];
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 };
}

View file

@ -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<string, string> = {
@ -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})`,
);
}
}