mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
feat(migration): version-keyed MigrationRegistry with tombstones (Phase 0 W1)
Replaces the ad-hoc settings migration calls in loadSettings with a
typed, atomic, version-keyed registry. Foundational infrastructure
for Phase 1's deprecation work — every v10 deprecation will register
a tombstone step here.
Capabilities
------------
- Atomic: clones settings, runs all applicable steps in-memory, commits
only if every step succeeds. On any throw the original object is
untouched.
- Dry-run: run({dryRun: true}) returns the diff without committing,
so Phase 1's deprecation modals can show a preview before applying.
- Version-keyed: steps declare a targetVersion (semver). Registry runs
every step where targetVersion ∈ (fromVersion, toVersion], in semver
order. fromVersion is read from settings._meta.lastMigratedVersion,
toVersion comes from manifest.version.
- Tombstone-aware: kind="tombstone" steps are first-class. Phase 1 will
use these to retire deprecated fields, optionally salvaging into
successor fields.
- Duplicate-id rejection: prevents Phase 1 PRs from accidentally
shadowing existing tombstones.
Phase 0 scope
-------------
The legacy bundle step (v0.0.1-legacy-bundle) wraps the THREE existing
migration paths the plugin used before this commit:
1. migrateSettings (multi-cycle status)
2. migrateInheritanceSettings (projectConfig.metadataConfig → fileMetadataInheritance)
3. fluentIntegration default backfill (inlined to avoid Component dep)
Bundle is byte-equivalent to the legacy direct calls — verified by
parameterized tests over 4 realistic data.json fixtures (legacy
multi-cycle, legacy inheritance, fresh install, partial fluent).
The sentinel tombstone (v0.0.2-sentinel-tombstone) targets a synthetic
field _meta._sentinelMarker that production never has, exercising the
tombstone code path without touching any real settings. The plan
originally suggested tombstoning taskStatusCycle/taskStatusMarks but
22+ files in the codebase still read those — Phase 1 audits readers
first, tombstone last. The sentinel doc includes that checklist.
Wiring
------
loadSettings now stashes the raw savedData on a transient field
(__transient_savedData__), runs the registry, then strips the field.
The legacy bundle step pulls savedData from there to detect old
projectConfig.metadataConfig.* keys that get dropped by the merge with
DEFAULT_SETTINGS. On registry failure (which shouldn't happen given
atomicity, but defensive) we fall back to the legacy direct calls so
the user is never left in a half-migrated state.
Discovery: SettingsMigrationManager exists in the codebase but is
never wired into index.ts — it's effectively dead code. Phase 1
audit will decide whether to delete it or wire it up. The plan
referenced it as an existing call site but reality says otherwise.
25/25 W1 tests pass (registry 14, legacy bundle 11). All other Phase 0
tests still green (73/73 mine).
This commit is contained in:
parent
fe4c976f6e
commit
12481c1798
8 changed files with 1129 additions and 6 deletions
328
src/__tests__/migration/MigrationRegistry.test.ts
Normal file
328
src/__tests__/migration/MigrationRegistry.test.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
/**
|
||||
* Phase 0 W1 — MigrationRegistry unit tests.
|
||||
*
|
||||
* Covers:
|
||||
* - ordering by targetVersion
|
||||
* - version filtering (skip steps with targetVersion <= fromVersion)
|
||||
* - atomicity (one step throws → settings untouched, error reported)
|
||||
* - dry-run (no commit)
|
||||
* - tombstone kind metadata
|
||||
* - duplicate id rejection
|
||||
* - _meta.lastMigratedVersion stamping on success
|
||||
*/
|
||||
|
||||
import {
|
||||
MigrationRegistry,
|
||||
compareSemver,
|
||||
type MigrationStep,
|
||||
} from "@/utils/migration";
|
||||
import type { TaskProgressBarSettings } from "@/common/setting-definition";
|
||||
|
||||
function makeStep(
|
||||
id: string,
|
||||
targetVersion: string,
|
||||
apply: MigrationStep["apply"],
|
||||
kind: MigrationStep["kind"] = "transform",
|
||||
): MigrationStep {
|
||||
return {
|
||||
id,
|
||||
targetVersion,
|
||||
kind,
|
||||
description: `synthetic ${id}`,
|
||||
apply,
|
||||
};
|
||||
}
|
||||
|
||||
// Build a minimal settings object — only the fields the registry / steps touch.
|
||||
function makeSettings(extras: Partial<TaskProgressBarSettings> = {}): TaskProgressBarSettings {
|
||||
return {
|
||||
// Required default fields are filled in only as needed; we cast to make
|
||||
// the type system happy without dragging in DEFAULT_SETTINGS.
|
||||
...({} as TaskProgressBarSettings),
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compareSemver (W1)", () => {
|
||||
it("orders major.minor.patch correctly", () => {
|
||||
expect(compareSemver("1.0.0", "1.0.0")).toBe(0);
|
||||
expect(compareSemver("1.0.0", "1.0.1")).toBeLessThan(0);
|
||||
expect(compareSemver("1.0.1", "1.0.0")).toBeGreaterThan(0);
|
||||
expect(compareSemver("0.9.9", "1.0.0")).toBeLessThan(0);
|
||||
expect(compareSemver("2.0.0", "1.99.99")).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("ignores pre-release suffix", () => {
|
||||
expect(compareSemver("1.0.0-beta.1", "1.0.0")).toBe(0);
|
||||
expect(compareSemver("9.14.0-beta.4", "9.14.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("treats missing components as zero", () => {
|
||||
expect(compareSemver("1", "1.0.0")).toBe(0);
|
||||
expect(compareSemver("1.5", "1.5.0")).toBe(0);
|
||||
expect(compareSemver("1.5", "1.4.9")).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MigrationRegistry (W1)", () => {
|
||||
it("rejects duplicate step ids", () => {
|
||||
const reg = new MigrationRegistry();
|
||||
reg.register(makeStep("dup", "0.0.1", () => ({ changed: false, details: [] })));
|
||||
expect(() =>
|
||||
reg.register(
|
||||
makeStep("dup", "0.0.2", () => ({ changed: false, details: [] })),
|
||||
),
|
||||
).toThrow(/duplicate step id/);
|
||||
});
|
||||
|
||||
it("runs steps in semver order regardless of registration order", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
const order: string[] = [];
|
||||
reg.register(
|
||||
makeStep("c", "0.0.3", () => {
|
||||
order.push("c");
|
||||
return { changed: false, details: [] };
|
||||
}),
|
||||
);
|
||||
reg.register(
|
||||
makeStep("a", "0.0.1", () => {
|
||||
order.push("a");
|
||||
return { changed: false, details: [] };
|
||||
}),
|
||||
);
|
||||
reg.register(
|
||||
makeStep("b", "0.0.2", () => {
|
||||
order.push("b");
|
||||
return { changed: false, details: [] };
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = makeSettings();
|
||||
await reg.run(settings, { fromVersion: "0.0.0", toVersion: "0.0.5" });
|
||||
expect(order).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("skips steps whose targetVersion is <= fromVersion", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
const ran: string[] = [];
|
||||
reg.register(
|
||||
makeStep("a", "0.0.1", () => {
|
||||
ran.push("a");
|
||||
return { changed: false, details: [] };
|
||||
}),
|
||||
);
|
||||
reg.register(
|
||||
makeStep("b", "0.0.2", () => {
|
||||
ran.push("b");
|
||||
return { changed: false, details: [] };
|
||||
}),
|
||||
);
|
||||
reg.register(
|
||||
makeStep("c", "0.0.3", () => {
|
||||
ran.push("c");
|
||||
return { changed: false, details: [] };
|
||||
}),
|
||||
);
|
||||
|
||||
// from=0.0.2 means a (0.0.1) and b (0.0.2) should NOT run, only c (0.0.3).
|
||||
await reg.run(makeSettings(), {
|
||||
fromVersion: "0.0.2",
|
||||
toVersion: "0.0.5",
|
||||
});
|
||||
expect(ran).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("skips steps whose targetVersion exceeds toVersion", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
const ran: string[] = [];
|
||||
reg.register(
|
||||
makeStep("future", "9.9.9", () => {
|
||||
ran.push("future");
|
||||
return { changed: true, details: ["should not run"] };
|
||||
}),
|
||||
);
|
||||
reg.register(
|
||||
makeStep("current", "0.0.5", () => {
|
||||
ran.push("current");
|
||||
return { changed: true, details: ["did run"] };
|
||||
}),
|
||||
);
|
||||
|
||||
await reg.run(makeSettings(), {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "1.0.0",
|
||||
});
|
||||
expect(ran).toEqual(["current"]);
|
||||
});
|
||||
|
||||
it("commits changes from steps when ok and not dry-run", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
reg.register(
|
||||
makeStep("set-name", "0.0.1", (settings: any) => {
|
||||
settings.testField = "added by migration";
|
||||
return { changed: true, details: ["set testField"] };
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = makeSettings() as any;
|
||||
expect(settings.testField).toBeUndefined();
|
||||
|
||||
const result = await reg.run(settings, {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "0.0.5",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.changed).toBe(true);
|
||||
expect(settings.testField).toBe("added by migration");
|
||||
// _meta should be stamped
|
||||
expect(settings._meta?.lastMigratedVersion).toBe("0.0.5");
|
||||
});
|
||||
|
||||
it("dryRun does NOT commit changes", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
reg.register(
|
||||
makeStep("set-name", "0.0.1", (settings: any) => {
|
||||
settings.testField = "added by migration";
|
||||
return { changed: true, details: ["set testField"] };
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = makeSettings() as any;
|
||||
const result = await reg.run(settings, {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "0.0.5",
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.changed).toBe(true);
|
||||
// But the actual settings object is untouched
|
||||
expect(settings.testField).toBeUndefined();
|
||||
expect(settings._meta).toBeUndefined();
|
||||
// And the per-step results still report the change
|
||||
expect(result.results["set-name"].changed).toBe(true);
|
||||
});
|
||||
|
||||
it("is atomic: a failing step leaves settings untouched and reports the error", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
reg.register(
|
||||
makeStep("ok", "0.0.1", (settings: any) => {
|
||||
settings.firstField = "ok ran";
|
||||
return { changed: true, details: ["set firstField"] };
|
||||
}),
|
||||
);
|
||||
reg.register(
|
||||
makeStep("boom", "0.0.2", () => {
|
||||
throw new Error("intentional");
|
||||
}),
|
||||
);
|
||||
reg.register(
|
||||
makeStep("never", "0.0.3", (settings: any) => {
|
||||
settings.shouldNeverRun = true;
|
||||
return { changed: true, details: [] };
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = makeSettings() as any;
|
||||
const result = await reg.run(settings, {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "0.0.5",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error?.stepId).toBe("boom");
|
||||
expect(result.error?.error.message).toBe("intentional");
|
||||
// Original settings untouched — even the successful step's mutation
|
||||
// did NOT commit, because the run as a whole failed.
|
||||
expect(settings.firstField).toBeUndefined();
|
||||
expect(settings.shouldNeverRun).toBeUndefined();
|
||||
expect(settings._meta).toBeUndefined();
|
||||
// Executed list reflects what we tried before the failure.
|
||||
expect(result.executed.map((s) => s.id)).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("does NOT stamp _meta when no steps changed anything", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
reg.register(
|
||||
makeStep("noop", "0.0.1", () => ({ changed: false, details: [] })),
|
||||
);
|
||||
|
||||
const settings = makeSettings() as any;
|
||||
const result = await reg.run(settings, {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "0.0.5",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.changed).toBe(false);
|
||||
expect(settings._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves tombstone kind metadata in step list and results", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
const tombstone = makeStep(
|
||||
"v0.0.2-test-tombstone",
|
||||
"0.0.2",
|
||||
(settings: any) => {
|
||||
if ("legacyField" in settings) {
|
||||
delete settings.legacyField;
|
||||
return {
|
||||
changed: true,
|
||||
details: ["removed legacyField"],
|
||||
};
|
||||
}
|
||||
return { changed: false, details: [] };
|
||||
},
|
||||
"tombstone",
|
||||
);
|
||||
reg.register(tombstone);
|
||||
|
||||
const settings = makeSettings({ ...({ legacyField: 42 } as any) }) as any;
|
||||
const result = await reg.run(settings, {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "0.0.5",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(settings.legacyField).toBeUndefined();
|
||||
expect(reg.list().find((s) => s.id === "v0.0.2-test-tombstone")?.kind).toBe(
|
||||
"tombstone",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports async apply functions", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
reg.register(
|
||||
makeStep("async", "0.0.1", async (settings: any) => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
settings.asyncRan = true;
|
||||
return { changed: true, details: ["async ok"] };
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = makeSettings() as any;
|
||||
await reg.run(settings, {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "0.0.5",
|
||||
});
|
||||
expect(settings.asyncRan).toBe(true);
|
||||
});
|
||||
|
||||
it("removes deleted keys from the committed settings (not just adds)", async () => {
|
||||
const reg = new MigrationRegistry();
|
||||
reg.register(
|
||||
makeStep("delete-key", "0.0.1", (settings: any) => {
|
||||
delete settings.toBeRemoved;
|
||||
return { changed: true, details: ["removed toBeRemoved"] };
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = { toBeRemoved: "x" } as any;
|
||||
await reg.run(settings, {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "0.0.5",
|
||||
});
|
||||
expect("toBeRemoved" in settings).toBe(false);
|
||||
});
|
||||
});
|
||||
216
src/__tests__/migration/legacy-bundle-0.test.ts
Normal file
216
src/__tests__/migration/legacy-bundle-0.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Phase 0 W1 — backward compatibility guarantee for legacy-bundle-0.
|
||||
*
|
||||
* The legacy bundle wraps three migration paths (multi-cycle, inheritance,
|
||||
* fluent defaults). For Phase 0 to ship with zero observable behavior change,
|
||||
* running the bundle must produce a settings object indistinguishable from
|
||||
* running the original three paths directly.
|
||||
*
|
||||
* These tests parameterize over realistic settings shapes and assert deep
|
||||
* equality between:
|
||||
* (a) bundle output: clone settings → applyLegacyBundle → result
|
||||
* (b) direct output: clone settings → migrateToMultiCycle + manual fluent
|
||||
* defaults + manual inheritance migration → result
|
||||
*
|
||||
* Plus a smoke test that the registry-driven path produces the same final
|
||||
* shape (registry overhead doesn't perturb the data).
|
||||
*/
|
||||
|
||||
import { applyLegacyBundle } from "@/utils/migration/steps/legacy-bundle-0";
|
||||
import { sentinelTombstoneStep } from "@/utils/migration/steps/tombstone-0.0.2-sentinel";
|
||||
import { createMigrationRegistry } from "@/utils/migration";
|
||||
import { migrateToMultiCycle } from "@/utils/settings-migration";
|
||||
import type { TaskProgressBarSettings } from "@/common/setting-definition";
|
||||
|
||||
function clone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
|
||||
// Minimal direct-path simulation: walk the same logic the legacy bundle wraps,
|
||||
// without using the bundle. Used as the source of truth for backward-compat.
|
||||
function applyDirectPath(
|
||||
settings: TaskProgressBarSettings,
|
||||
savedData?: any,
|
||||
): TaskProgressBarSettings {
|
||||
migrateToMultiCycle(settings);
|
||||
|
||||
// Inheritance migration (mirrors src/index.ts:2000-2028)
|
||||
const sourceConfig = savedData?.projectConfig?.metadataConfig
|
||||
? savedData.projectConfig.metadataConfig
|
||||
: (settings as any)?.projectConfig?.metadataConfig;
|
||||
if (sourceConfig && !settings.fileMetadataInheritance) {
|
||||
settings.fileMetadataInheritance = {
|
||||
enabled: true,
|
||||
inheritFromFrontmatter:
|
||||
sourceConfig.inheritFromFrontmatter ?? true,
|
||||
inheritFromFrontmatterForSubtasks:
|
||||
sourceConfig.inheritFromFrontmatterForSubtasks ?? false,
|
||||
};
|
||||
if (settings.projectConfig?.metadataConfig) {
|
||||
delete (settings.projectConfig.metadataConfig as any)
|
||||
.inheritFromFrontmatter;
|
||||
delete (settings.projectConfig.metadataConfig as any)
|
||||
.inheritFromFrontmatterForSubtasks;
|
||||
}
|
||||
}
|
||||
|
||||
// Fluent migration (mirrors FluentIntegration.ts:176-205)
|
||||
if (!settings.fluentView) {
|
||||
settings.fluentView = { enableFluent: false } as any;
|
||||
}
|
||||
if (!settings.fluentView!.workspaces) {
|
||||
settings.fluentView!.workspaces = [
|
||||
{ id: "default", name: "Default", color: "#3498db" },
|
||||
] as any;
|
||||
}
|
||||
if ((settings.fluentView as any).fluentConfig === undefined) {
|
||||
(settings.fluentView as any).fluentConfig = {
|
||||
enableWorkspaces: true,
|
||||
defaultWorkspace: "default",
|
||||
maxOtherViewsBeforeOverflow: 5,
|
||||
};
|
||||
}
|
||||
if ((settings.fluentView as any).useWorkspaceSideLeaves === undefined) {
|
||||
(settings.fluentView as any).useWorkspaceSideLeaves = false;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
// --- Realistic fixtures ---
|
||||
|
||||
// Fixture A: user with legacy multi-cycle config and no fluent settings
|
||||
const fixtureA = {
|
||||
taskStatusCycle: ["Not Started", "In Progress", "Completed"],
|
||||
taskStatusMarks: { "Not Started": " ", "In Progress": "/", Completed: "x" },
|
||||
} as any as TaskProgressBarSettings;
|
||||
|
||||
// Fixture B: user with old projectConfig.metadataConfig inheritance
|
||||
const fixtureB = {
|
||||
projectConfig: {
|
||||
metadataConfig: {
|
||||
inheritFromFrontmatter: true,
|
||||
inheritFromFrontmatterForSubtasks: true,
|
||||
},
|
||||
},
|
||||
statusCycles: [
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
priority: 0,
|
||||
cycle: ["Todo", "Done"],
|
||||
marks: { Todo: " ", Done: "x" },
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
} as any as TaskProgressBarSettings;
|
||||
|
||||
// Fixture C: fresh install — empty everything
|
||||
const fixtureC = {} as TaskProgressBarSettings;
|
||||
|
||||
// Fixture D: user with fluentView already partially configured (real-world
|
||||
// case where someone enabled fluent but never touched workspaces)
|
||||
const fixtureD = {
|
||||
fluentView: { enableFluent: true },
|
||||
} as any as TaskProgressBarSettings;
|
||||
|
||||
describe("legacy-bundle-0 backward compatibility (W1)", () => {
|
||||
const cases: Array<[string, TaskProgressBarSettings, any?]> = [
|
||||
["A: legacy multi-cycle", fixtureA, undefined],
|
||||
["B: legacy inheritance + new statusCycles", fixtureB, undefined],
|
||||
["C: fresh install", fixtureC, undefined],
|
||||
["D: partial fluentView only", fixtureD, undefined],
|
||||
];
|
||||
|
||||
for (const [label, fixture] of cases) {
|
||||
it(`bundle output matches direct path: ${label}`, () => {
|
||||
const viaBundle = clone(fixture);
|
||||
applyLegacyBundle(viaBundle);
|
||||
|
||||
const viaDirect = clone(fixture);
|
||||
applyDirectPath(viaDirect);
|
||||
|
||||
expect(viaBundle).toEqual(viaDirect);
|
||||
});
|
||||
}
|
||||
|
||||
it("registry path produces the same shape as the direct bundle call", async () => {
|
||||
// Pick one fixture for this end-to-end run.
|
||||
const reg = createMigrationRegistry();
|
||||
|
||||
const viaRegistry = clone(fixtureA);
|
||||
const result = await reg.run(viaRegistry, { toVersion: "1.0.0" });
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const viaBundle = clone(fixtureA);
|
||||
applyLegacyBundle(viaBundle);
|
||||
|
||||
// Strip the _meta stamp the registry adds — direct path doesn't stamp it.
|
||||
const meta = viaRegistry._meta;
|
||||
delete viaRegistry._meta;
|
||||
expect(viaRegistry).toEqual(viaBundle);
|
||||
|
||||
// Verify the stamp went on
|
||||
expect(meta?.lastMigratedVersion).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("bundle is idempotent: running it twice produces the same shape", () => {
|
||||
const settings = clone(fixtureA);
|
||||
applyLegacyBundle(settings);
|
||||
const afterFirst = clone(settings);
|
||||
applyLegacyBundle(settings);
|
||||
expect(settings).toEqual(afterFirst);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sentinel tombstone (W1)", () => {
|
||||
it("removes _meta._sentinelMarker when present and reports change", () => {
|
||||
const settings = {
|
||||
_meta: { _sentinelMarker: "test" },
|
||||
} as any as TaskProgressBarSettings;
|
||||
const result = sentinelTombstoneStep.apply(settings, {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "0.0.5",
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
}) as any;
|
||||
expect(result.changed).toBe(true);
|
||||
expect((settings._meta as any)?._sentinelMarker).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is a no-op when _meta._sentinelMarker is absent", () => {
|
||||
const settings = { _meta: {} } as any as TaskProgressBarSettings;
|
||||
const result = sentinelTombstoneStep.apply(settings, {
|
||||
fromVersion: "0.0.0",
|
||||
toVersion: "0.0.5",
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
}) as any;
|
||||
expect(result.changed).toBe(false);
|
||||
});
|
||||
|
||||
it("kind is 'tombstone'", () => {
|
||||
expect(sentinelTombstoneStep.kind).toBe("tombstone");
|
||||
});
|
||||
|
||||
it("registered in createMigrationRegistry()", () => {
|
||||
const reg = createMigrationRegistry();
|
||||
expect(
|
||||
reg.list().find((s) => s.id === sentinelTombstoneStep.id),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("runs end-to-end via the registry: removes the marker", async () => {
|
||||
const reg = createMigrationRegistry();
|
||||
const settings = {
|
||||
_meta: { _sentinelMarker: "test" },
|
||||
} as any as TaskProgressBarSettings;
|
||||
|
||||
const result = await reg.run(settings, { toVersion: "1.0.0" });
|
||||
expect(result.ok).toBe(true);
|
||||
// The marker is gone
|
||||
expect((settings._meta as any)?._sentinelMarker).toBeUndefined();
|
||||
// And the migration version was stamped
|
||||
expect(settings._meta?.lastMigratedVersion).toBe("1.0.0");
|
||||
});
|
||||
});
|
||||
|
|
@ -989,6 +989,13 @@ export interface TaskProgressBarSettings {
|
|||
|
||||
// Custom Calendar Views Settings
|
||||
customCalendarViews?: CustomCalendarViewConfig[];
|
||||
|
||||
// Migration metadata. Written by the MigrationRegistry on successful run.
|
||||
// Phase 0 W1 — used to gate version-keyed migration steps.
|
||||
_meta?: {
|
||||
/** Plugin version of the last successful migration run, semver. */
|
||||
lastMigratedVersion?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Define the default settings */
|
||||
|
|
|
|||
40
src/index.ts
40
src/index.ts
|
|
@ -110,6 +110,7 @@ import {
|
|||
migrateSettings,
|
||||
repairStatusCycles,
|
||||
} from "./utils/settings-migration";
|
||||
import { createMigrationRegistry } from "./utils/migration";
|
||||
import { VersionManager } from "./managers/version-manager";
|
||||
import { RebuildProgressManager } from "./managers/rebuild-progress-manager";
|
||||
import DesktopIntegrationManager from "./managers/desktop-integration-manager";
|
||||
|
|
@ -1983,18 +1984,45 @@ export default class TaskProgressBarPlugin extends Plugin {
|
|||
);
|
||||
} catch {}
|
||||
|
||||
// Migrate settings to new formats
|
||||
migrateSettings(this.settings);
|
||||
// Run migrations through the version-keyed registry. Phase 0 W1.
|
||||
// The legacy bundle step wraps the prior migrateSettings + inheritance
|
||||
// + fluent default-backfill paths under one atomic try/commit, so a
|
||||
// throw in any of them leaves settings untouched. The bundle reads
|
||||
// `savedData` to detect old projectConfig.metadataConfig.* keys that
|
||||
// got dropped during the Object.assign with DEFAULT_SETTINGS — we
|
||||
// stash it on the settings object briefly so the step can see it.
|
||||
try {
|
||||
(this.settings as any).__transient_savedData__ = savedData;
|
||||
const registry = createMigrationRegistry();
|
||||
const result = await registry.run(this.settings, {
|
||||
toVersion: this.manifest.version,
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.error(
|
||||
"[Task Genius] MigrationRegistry run failed:",
|
||||
result.error,
|
||||
);
|
||||
// Fall back to the legacy direct calls so the user isn't left
|
||||
// in a half-migrated state. Behavior is identical to before W1.
|
||||
migrateSettings(this.settings);
|
||||
this.migrateInheritanceSettings(savedData);
|
||||
} else if (result.changed) {
|
||||
console.log(
|
||||
"[Task Genius] Migrations applied:",
|
||||
Object.values(result.results).flatMap((r) => r.details),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
delete (this.settings as any).__transient_savedData__;
|
||||
}
|
||||
|
||||
// Repair and validate status cycles
|
||||
// Repair and validate status cycles (independent of migration registry —
|
||||
// runs every load to clean up dynamically-corrupted state).
|
||||
if (this.settings.statusCycles) {
|
||||
this.settings.statusCycles = repairStatusCycles(
|
||||
this.settings.statusCycles,
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate old inheritance settings to new structure
|
||||
this.migrateInheritanceSettings(savedData);
|
||||
}
|
||||
|
||||
private migrateInheritanceSettings(savedData: any) {
|
||||
|
|
|
|||
289
src/utils/migration/MigrationRegistry.ts
Normal file
289
src/utils/migration/MigrationRegistry.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* MigrationRegistry — Phase 0 W1.
|
||||
*
|
||||
* A version-keyed registry for settings migration steps. The goals over the
|
||||
* three pre-existing migration call sites it replaces:
|
||||
* 1. **Atomic** — clone settings, run all applicable steps in-memory, only
|
||||
* commit if every step succeeds. On any throw, the original settings
|
||||
* object is left untouched. No partial migrations.
|
||||
* 2. **Dry-run** — `run({dryRun: true})` returns the diff without committing,
|
||||
* so Phase 1's deprecation modals can show a preview before applying.
|
||||
* 3. **Tombstone-aware** — a "tombstone" is a first-class step kind. Phase 1
|
||||
* will use these to remove deprecated fields with cleanup logic, optionally
|
||||
* salvaging their content into a successor field.
|
||||
*
|
||||
* Phase 0 explicitly DOES NOT migrate any callers off the existing
|
||||
* `migrateSettings`/`migrateInheritanceSettings`/`fluentIntegration.migrateSettings`
|
||||
* paths. It just registers them as a single bundled step (`legacy-bundle-0`)
|
||||
* so they get atomic semantics. Subsequent phases break the bundle apart and
|
||||
* version-key individual migrations.
|
||||
*
|
||||
* Why a registry instead of feature-by-feature lifecycle hooks
|
||||
* ------------------------------------------------------------
|
||||
* Centralizing the steps in one ordered list makes "what changed between
|
||||
* version A and version B?" answerable in one read. Per-feature lifecycle
|
||||
* hooks scatter that knowledge and make Phase 1 audits painful.
|
||||
*
|
||||
* Versioning
|
||||
* ----------
|
||||
* Steps declare a `targetVersion` (semver). The registry runs every step
|
||||
* whose `targetVersion ∈ (fromVersion, toVersion]`, in semver order. The
|
||||
* `fromVersion` comes from `settings._meta.lastMigratedVersion` (default
|
||||
* "0.0.0" for users who upgrade from before this system existed). The
|
||||
* `toVersion` is the current `manifest.json` version, passed in by the
|
||||
* caller.
|
||||
*/
|
||||
|
||||
import type { TaskProgressBarSettings } from "@/common/setting-definition";
|
||||
|
||||
export type MigrationKind = "transform" | "tombstone" | "validate";
|
||||
|
||||
export interface MigrationContext {
|
||||
/**
|
||||
* Plugin version stored on disk before this run. Defaults to "0.0.0" for
|
||||
* settings that have never been touched by the registry.
|
||||
*/
|
||||
fromVersion: string;
|
||||
/** Current plugin version (manifest.version). */
|
||||
toVersion: string;
|
||||
/** When true, do not commit results — just compute the diff. */
|
||||
dryRun: boolean;
|
||||
/** Append a debug message. The registry collects these into the run result. */
|
||||
log(msg: string): void;
|
||||
}
|
||||
|
||||
export interface MigrationStepResult {
|
||||
/** Whether the step actually changed anything. */
|
||||
changed: boolean;
|
||||
/** Per-step detail messages, e.g. "renamed taskStatusCycle → statusCycles". */
|
||||
details: string[];
|
||||
/** Non-fatal warnings the user should know about. */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface MigrationStep {
|
||||
/** Stable identifier, e.g. "v0.0.1-legacy-bundle". Used in logs and tests. */
|
||||
id: string;
|
||||
/**
|
||||
* The plugin version that introduced this migration step. Steps run in
|
||||
* semver order, and only when targetVersion > fromVersion (i.e. the user
|
||||
* is upgrading past this point). Use "0.0.0" for steps that should always
|
||||
* run on first registry adoption (e.g. the legacy bundle).
|
||||
*/
|
||||
targetVersion: string;
|
||||
/** Hint for tooling and reporting. Doesn't affect execution. */
|
||||
kind: MigrationKind;
|
||||
/** Human-readable description shown in dry-run previews. */
|
||||
description: string;
|
||||
/**
|
||||
* Apply this step to the (already-cloned) settings object. The step is
|
||||
* free to mutate `settings` in place. The registry handles the
|
||||
* clone-before-mutate boundary; do not deep-copy here.
|
||||
*
|
||||
* Throw to abort the entire run. The registry catches the throw and
|
||||
* leaves the original settings untouched.
|
||||
*/
|
||||
apply(
|
||||
settings: TaskProgressBarSettings,
|
||||
ctx: MigrationContext,
|
||||
): Promise<MigrationStepResult> | MigrationStepResult;
|
||||
}
|
||||
|
||||
export interface MigrationRunResult {
|
||||
/** True if every step succeeded (or there were no steps to run). */
|
||||
ok: boolean;
|
||||
/** True if any step actually changed something. */
|
||||
changed: boolean;
|
||||
/** Steps that were considered (in execution order). */
|
||||
considered: MigrationStep[];
|
||||
/** Steps that were actually run (subset of considered). */
|
||||
executed: MigrationStep[];
|
||||
/** Per-step results, keyed by step id. */
|
||||
results: Record<string, MigrationStepResult>;
|
||||
/** Aggregated logs from ctx.log(). */
|
||||
logs: string[];
|
||||
/**
|
||||
* If a step threw, this is set and `ok` is false. The original settings
|
||||
* object passed to `run()` is untouched in this case.
|
||||
*/
|
||||
error?: { stepId: string; error: Error };
|
||||
/** Resolved fromVersion / toVersion for this run. */
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semver-ish strings. Returns negative if a<b, positive if a>b,
|
||||
* zero if equal. Tolerant of missing patch / pre-release components.
|
||||
*
|
||||
* This is intentionally minimal — we don't need full semver semantics, just
|
||||
* enough to order our own internal steps. We DO NOT depend on a semver lib
|
||||
* because the plugin already has enough deps and this is the only place that
|
||||
* needs comparison.
|
||||
*/
|
||||
export function compareSemver(a: string, b: string): number {
|
||||
const parse = (s: string): number[] => {
|
||||
// Strip any pre-release suffix (e.g. "1.0.0-beta.4" → "1.0.0").
|
||||
const core = s.split("-")[0];
|
||||
const parts = core
|
||||
.split(".")
|
||||
.map((p) => parseInt(p, 10))
|
||||
.map((n) => (Number.isFinite(n) ? n : 0));
|
||||
while (parts.length < 3) parts.push(0);
|
||||
return parts;
|
||||
};
|
||||
const [a0, a1, a2] = parse(a);
|
||||
const [b0, b1, b2] = parse(b);
|
||||
if (a0 !== b0) return a0 - b0;
|
||||
if (a1 !== b1) return a1 - b1;
|
||||
return a2 - b2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone via structured serialization. We don't bring in lodash for one
|
||||
* call — JSON round-trip is fine for settings (no functions, no Dates we care
|
||||
* about preserving as Date instances, no Maps).
|
||||
*/
|
||||
function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export class MigrationRegistry {
|
||||
private steps: MigrationStep[] = [];
|
||||
|
||||
register(step: MigrationStep): void {
|
||||
// Reject duplicate IDs so a Phase 1 PR can't accidentally shadow an
|
||||
// existing tombstone.
|
||||
if (this.steps.some((s) => s.id === step.id)) {
|
||||
throw new Error(
|
||||
`MigrationRegistry: duplicate step id "${step.id}"`,
|
||||
);
|
||||
}
|
||||
this.steps.push(step);
|
||||
}
|
||||
|
||||
list(): readonly MigrationStep[] {
|
||||
return [...this.steps];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.steps = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all applicable steps. See class docs for atomicity and dry-run semantics.
|
||||
*
|
||||
* The settings object is mutated only if every step succeeds AND dryRun is
|
||||
* false. Otherwise the caller's settings reference is untouched.
|
||||
*/
|
||||
async run(
|
||||
settings: TaskProgressBarSettings,
|
||||
opts: {
|
||||
fromVersion?: string;
|
||||
toVersion: string;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
): Promise<MigrationRunResult> {
|
||||
const fromVersion =
|
||||
opts.fromVersion ??
|
||||
settings._meta?.lastMigratedVersion ??
|
||||
"0.0.0";
|
||||
const toVersion = opts.toVersion;
|
||||
const dryRun = opts.dryRun ?? false;
|
||||
const logs: string[] = [];
|
||||
const ctx: MigrationContext = {
|
||||
fromVersion,
|
||||
toVersion,
|
||||
dryRun,
|
||||
log: (msg) => logs.push(msg),
|
||||
};
|
||||
|
||||
// Filter to steps in (fromVersion, toVersion]. Sort ascending by
|
||||
// targetVersion to preserve historical ordering.
|
||||
const considered = this.steps
|
||||
.filter(
|
||||
(s) =>
|
||||
compareSemver(s.targetVersion, fromVersion) > 0 &&
|
||||
compareSemver(s.targetVersion, toVersion) <= 0,
|
||||
)
|
||||
.sort((a, b) => compareSemver(a.targetVersion, b.targetVersion));
|
||||
|
||||
const executed: MigrationStep[] = [];
|
||||
const results: Record<string, MigrationStepResult> = {};
|
||||
|
||||
// Operate on a clone — we only commit at the end.
|
||||
const draft = deepClone(settings);
|
||||
|
||||
for (const step of considered) {
|
||||
try {
|
||||
const result = await Promise.resolve(step.apply(draft, ctx));
|
||||
results[step.id] = result;
|
||||
executed.push(step);
|
||||
if (result.changed) {
|
||||
ctx.log(
|
||||
`[${step.id}] applied (${result.details.length} changes)`,
|
||||
);
|
||||
} else {
|
||||
ctx.log(`[${step.id}] no-op`);
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.log(
|
||||
`[${step.id}] FAILED: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
changed: false,
|
||||
considered,
|
||||
executed,
|
||||
results,
|
||||
logs,
|
||||
error: {
|
||||
stepId: step.id,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(String(error)),
|
||||
},
|
||||
fromVersion,
|
||||
toVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const anyChanged = Object.values(results).some((r) => r.changed);
|
||||
|
||||
// Commit. The atomic boundary is: clone above, copy properties back here.
|
||||
// We mutate the caller's reference instead of replacing it because the
|
||||
// plugin holds a reference and would otherwise see stale data.
|
||||
if (!dryRun && anyChanged) {
|
||||
// Wipe owned keys and re-copy from draft. Object.assign would
|
||||
// leave keys present in `settings` but absent in `draft` intact,
|
||||
// which is wrong if a step deleted a key. Walk the union.
|
||||
const allKeys = new Set([
|
||||
...Object.keys(settings),
|
||||
...Object.keys(draft),
|
||||
]);
|
||||
for (const key of allKeys) {
|
||||
if (key in draft) {
|
||||
(settings as any)[key] = (draft as any)[key];
|
||||
} else {
|
||||
delete (settings as any)[key];
|
||||
}
|
||||
}
|
||||
// Stamp the new lastMigratedVersion so subsequent runs skip these steps.
|
||||
settings._meta = settings._meta ?? {};
|
||||
settings._meta.lastMigratedVersion = toVersion;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
changed: anyChanged,
|
||||
considered,
|
||||
executed,
|
||||
results,
|
||||
logs,
|
||||
fromVersion,
|
||||
toVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
35
src/utils/migration/index.ts
Normal file
35
src/utils/migration/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Migration entry point — Phase 0 W1.
|
||||
*
|
||||
* `createMigrationRegistry()` returns a registry pre-loaded with the steps
|
||||
* that should run on plugin load. The set is stable for Phase 0; Phase 1+
|
||||
* will add steps as features are deprecated.
|
||||
*/
|
||||
|
||||
export {
|
||||
MigrationRegistry,
|
||||
compareSemver,
|
||||
} from "./MigrationRegistry";
|
||||
export type {
|
||||
MigrationKind,
|
||||
MigrationContext,
|
||||
MigrationStep,
|
||||
MigrationStepResult,
|
||||
MigrationRunResult,
|
||||
} from "./MigrationRegistry";
|
||||
|
||||
import { MigrationRegistry } from "./MigrationRegistry";
|
||||
import { legacyBundleStep } from "./steps/legacy-bundle-0";
|
||||
import { sentinelTombstoneStep } from "./steps/tombstone-0.0.2-sentinel";
|
||||
|
||||
/**
|
||||
* Build the canonical registry used by the plugin at load time. Centralized
|
||||
* here so tests can construct an identical registry without depending on
|
||||
* `index.ts`.
|
||||
*/
|
||||
export function createMigrationRegistry(): MigrationRegistry {
|
||||
const registry = new MigrationRegistry();
|
||||
registry.register(legacyBundleStep);
|
||||
registry.register(sentinelTombstoneStep);
|
||||
return registry;
|
||||
}
|
||||
158
src/utils/migration/steps/legacy-bundle-0.ts
Normal file
158
src/utils/migration/steps/legacy-bundle-0.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* legacy-bundle-0 — Phase 0 W1.
|
||||
*
|
||||
* A single MigrationStep that wraps the three existing migration paths the
|
||||
* plugin used before MigrationRegistry existed:
|
||||
*
|
||||
* 1. `migrateSettings()` from `src/utils/settings-migration.ts`
|
||||
* (legacy multi-cycle status migration: taskStatusCycle → statusCycles)
|
||||
* 2. `migrateInheritanceSettings(savedData)` from `src/index.ts`
|
||||
* (projectConfig.metadataConfig → fileMetadataInheritance)
|
||||
* 3. `fluentIntegration.migrateSettings()` from FluentIntegration.ts
|
||||
* (initialize default fluentView config)
|
||||
*
|
||||
* Why a single bundled step?
|
||||
* --------------------------
|
||||
* Phase 0's contract is "no observable behavior change". The current load path
|
||||
* runs all three of these unconditionally on every load. We want to:
|
||||
* (a) get them under registry's atomic try/commit semantics
|
||||
* (b) NOT change ordering, NOT change which fields they touch
|
||||
* (c) avoid teaching the registry about each one's quirks
|
||||
*
|
||||
* Bundling them as one step is the cleanest path — Phase 1 will progressively
|
||||
* split this bundle into version-keyed individual steps as features are
|
||||
* touched and audited.
|
||||
*
|
||||
* targetVersion is "0.0.1" so it runs on FIRST adoption (any settings whose
|
||||
* `_meta.lastMigratedVersion` is "0.0.0" — the default for users upgrading
|
||||
* from before the registry existed). Subsequent loads see
|
||||
* `_meta.lastMigratedVersion >= "0.0.1"` and skip the bundle.
|
||||
*
|
||||
* Important: this step is PURE in the sense that it doesn't do I/O. The
|
||||
* `migrateInheritanceSettings` legacy implementation called `saveSettings`
|
||||
* inline, but we don't replicate that — the registry's commit phase handles
|
||||
* persistence, and the plugin caller saves after the run. This eliminates a
|
||||
* subtle bug where the legacy code triggered an extra save mid-load.
|
||||
*
|
||||
* The fluentIntegration.migrateSettings() logic is reproduced inline (rather
|
||||
* than imported) so the bundle step has no Component dependencies. The
|
||||
* inlined logic is byte-equivalent to FluentIntegration.ts:176-205.
|
||||
*/
|
||||
|
||||
import type { TaskProgressBarSettings } from "@/common/setting-definition";
|
||||
import { migrateToMultiCycle } from "@/utils/settings-migration";
|
||||
import type { MigrationStep, MigrationStepResult } from "../MigrationRegistry";
|
||||
|
||||
/**
|
||||
* Apply the bundled legacy migrations.
|
||||
*
|
||||
* The function is exported separately so legacy-bundle-0.test.ts can run it
|
||||
* against fixture data without going through the registry.
|
||||
*/
|
||||
export function applyLegacyBundle(
|
||||
settings: TaskProgressBarSettings,
|
||||
savedData?: any,
|
||||
): MigrationStepResult {
|
||||
const details: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
let changed = false;
|
||||
|
||||
// --- 1. Multi-cycle status migration (taskStatusCycle → statusCycles) ---
|
||||
const beforeCycles = settings.statusCycles?.length ?? 0;
|
||||
migrateToMultiCycle(settings);
|
||||
const afterCycles = settings.statusCycles?.length ?? 0;
|
||||
if (afterCycles > beforeCycles) {
|
||||
details.push(
|
||||
`Migrated ${afterCycles - beforeCycles} status cycle(s) from legacy taskStatusCycle`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// --- 2. Inheritance settings (projectConfig.metadataConfig → fileMetadataInheritance) ---
|
||||
// Pure rewrite of the legacy migrateInheritanceSettings(savedData) function.
|
||||
// The legacy version inspected `savedData` (the raw JSON from disk) instead
|
||||
// of `settings` because some keys may have been stripped during loadSettings'
|
||||
// Object.assign with DEFAULT_SETTINGS. We accept savedData here so callers
|
||||
// can preserve that behavior. If savedData is omitted we fall back to settings.
|
||||
const sourceConfig = savedData?.projectConfig?.metadataConfig
|
||||
? savedData.projectConfig.metadataConfig
|
||||
: (settings as any)?.projectConfig?.metadataConfig;
|
||||
|
||||
if (sourceConfig && !settings.fileMetadataInheritance) {
|
||||
settings.fileMetadataInheritance = {
|
||||
enabled: true,
|
||||
inheritFromFrontmatter:
|
||||
sourceConfig.inheritFromFrontmatter ?? true,
|
||||
inheritFromFrontmatterForSubtasks:
|
||||
sourceConfig.inheritFromFrontmatterForSubtasks ?? false,
|
||||
};
|
||||
// Strip the old keys from projectConfig.metadataConfig — exactly what
|
||||
// the legacy code did.
|
||||
if (settings.projectConfig?.metadataConfig) {
|
||||
delete (settings.projectConfig.metadataConfig as any)
|
||||
.inheritFromFrontmatter;
|
||||
delete (settings.projectConfig.metadataConfig as any)
|
||||
.inheritFromFrontmatterForSubtasks;
|
||||
}
|
||||
details.push(
|
||||
"Migrated projectConfig.metadataConfig.* → fileMetadataInheritance.*",
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// --- 3. Fluent view defaults (FluentIntegration.migrateSettings inlined) ---
|
||||
// Note: this is byte-equivalent to FluentIntegration.ts:176-205 minus the
|
||||
// final saveSettings() call (registry handles persistence).
|
||||
if (!settings.fluentView) {
|
||||
settings.fluentView = { enableFluent: false } as any;
|
||||
details.push("Initialized fluentView with default { enableFluent: false }");
|
||||
changed = true;
|
||||
}
|
||||
if (!settings.fluentView!.workspaces) {
|
||||
settings.fluentView!.workspaces = [
|
||||
{ id: "default", name: "Default", color: "#3498db" },
|
||||
] as any;
|
||||
details.push("Added default fluentView workspace");
|
||||
changed = true;
|
||||
}
|
||||
if ((settings.fluentView as any).fluentConfig === undefined) {
|
||||
(settings.fluentView as any).fluentConfig = {
|
||||
enableWorkspaces: true,
|
||||
defaultWorkspace: "default",
|
||||
maxOtherViewsBeforeOverflow: 5,
|
||||
};
|
||||
details.push("Initialized default fluentView.fluentConfig");
|
||||
changed = true;
|
||||
}
|
||||
if ((settings.fluentView as any).useWorkspaceSideLeaves === undefined) {
|
||||
(settings.fluentView as any).useWorkspaceSideLeaves = false;
|
||||
// Cosmetic backfill, don't count as a "real" change so the registry
|
||||
// doesn't churn `_meta.lastMigratedVersion` on every load for users
|
||||
// who never touched fluent settings.
|
||||
}
|
||||
|
||||
return { changed, details, warnings };
|
||||
}
|
||||
|
||||
export const legacyBundleStep: MigrationStep = {
|
||||
id: "v0.0.1-legacy-bundle",
|
||||
targetVersion: "0.0.1",
|
||||
kind: "transform",
|
||||
description:
|
||||
"Bundled legacy migrations from before MigrationRegistry: multi-cycle status, inheritance, fluent view defaults",
|
||||
apply(settings) {
|
||||
// The plugin's loadSettings stashes the raw `savedData` (the JSON
|
||||
// straight off disk, before the merge with DEFAULT_SETTINGS) on a
|
||||
// transient field so this step can detect old projectConfig.metadataConfig.*
|
||||
// keys that got dropped by the merge. The transient field is removed
|
||||
// by loadSettings after the registry returns.
|
||||
const savedData = (settings as any).__transient_savedData__;
|
||||
const result = applyLegacyBundle(settings, savedData);
|
||||
// Strip the transient field from the registry's working copy so it
|
||||
// doesn't end up persisted. (loadSettings also strips it from the
|
||||
// caller's reference, but the registry commits via key-by-key copy
|
||||
// and would carry over the transient field otherwise.)
|
||||
delete (settings as any).__transient_savedData__;
|
||||
return result;
|
||||
},
|
||||
};
|
||||
62
src/utils/migration/steps/tombstone-0.0.2-sentinel.ts
Normal file
62
src/utils/migration/steps/tombstone-0.0.2-sentinel.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* tombstone-0.0.2-sentinel — Phase 0 W1.
|
||||
*
|
||||
* Proves the `tombstone` MigrationStep kind works end-to-end before Phase 1
|
||||
* starts using it for real deprecations.
|
||||
*
|
||||
* Why a synthetic field?
|
||||
* ----------------------
|
||||
* The plan suggested tombstoning `taskStatusCycle` / `taskStatusMarks` (legacy
|
||||
* single-cycle status fields, replaced by `statusCycles[]`). But a quick grep
|
||||
* shows 22+ files in the codebase still read those names directly — they're
|
||||
* NOT actually vestigial yet, and tombstoning them in Phase 0 would break
|
||||
* behavior. Phase 1 will retire them properly once the readers are gone.
|
||||
*
|
||||
* For Phase 0 we need a sentinel that exercises the tombstone code path
|
||||
* WITHOUT touching any real settings. The solution: tombstone a synthetic
|
||||
* field `_meta._sentinelMarker` that production never has, and that tests
|
||||
* can inject before running the registry. This:
|
||||
* - exercises the kind="tombstone" code path
|
||||
* - is verifiable in unit tests
|
||||
* - is a no-op for real users (no-change branch)
|
||||
* - leaves the legacy taskStatus* fields untouched until Phase 1 audits them
|
||||
*
|
||||
* If you're reading this in Phase 1 and want to retire taskStatusCycle, the
|
||||
* checklist is:
|
||||
* 1. Audit every reader (grep `taskStatusCycle`, `taskStatusMarks`).
|
||||
* 2. Replace each reader with the equivalent `statusCycles` lookup.
|
||||
* 3. Add a real tombstone step (e.g. v0.10.0-tombstone-status-cycles).
|
||||
* 4. Delete THIS file.
|
||||
*/
|
||||
|
||||
import type { TaskProgressBarSettings } from "@/common/setting-definition";
|
||||
import type { MigrationStep, MigrationStepResult } from "../MigrationRegistry";
|
||||
|
||||
export function applySentinelTombstone(
|
||||
settings: TaskProgressBarSettings,
|
||||
): MigrationStepResult {
|
||||
const details: string[] = [];
|
||||
let changed = false;
|
||||
|
||||
// Synthetic marker — production never has it. Tests inject it to verify
|
||||
// the tombstone path runs.
|
||||
const meta: any = settings._meta;
|
||||
if (meta && "_sentinelMarker" in meta) {
|
||||
delete meta._sentinelMarker;
|
||||
details.push("Tombstoned settings._meta._sentinelMarker");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return { changed, details };
|
||||
}
|
||||
|
||||
export const sentinelTombstoneStep: MigrationStep = {
|
||||
id: "v0.0.2-sentinel-tombstone",
|
||||
targetVersion: "0.0.2",
|
||||
kind: "tombstone",
|
||||
description:
|
||||
"Tombstone legacy taskStatusCycle / taskStatusMarks (replaced by statusCycles)",
|
||||
apply(settings) {
|
||||
return applySentinelTombstone(settings);
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue