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:
parent
dea4c2dbc1
commit
3b6cbcd79f
2 changed files with 167 additions and 3 deletions
124
src/resources/extensions/sf/preferences-migrations.ts
Normal file
124
src/resources/extensions/sf/preferences-migrations.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue