diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..4d12fb1 --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,65 @@ +name: mutation + +on: + pull_request: + paths: + - "pluginkit/**.go" + - "pluginkit/.gremlins.yaml" + - "!pluginkit/graphwire/**" + - ".github/workflows/mutation.yml" + +permissions: + contents: read + +jobs: + pluginkit: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: pluginkit + env: + BASE: origin/${{ github.base_ref }} + GIT_CONFIG_COUNT: "3" + GIT_CONFIG_KEY_0: diff.relative + GIT_CONFIG_VALUE_0: "true" + GIT_CONFIG_KEY_1: diff.noprefix + GIT_CONFIG_VALUE_1: "false" + GIT_CONFIG_KEY_2: diff.context + GIT_CONFIG_VALUE_2: "0" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: pluginkit/go.mod + cache-dependency-path: pluginkit/go.mod + - id: changed + run: | + changed="$(git diff --merge-base --name-only --diff-filter=d "$BASE" -- . ':(exclude)graphwire' | awk '/\.go$/ && !/_test\.go$/')" + if [ -z "$changed" ]; then + echo "No Go source changed under pluginkit, so there is nothing to mutate." + echo "run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + for file in $changed; do + test -f "$file" || { echo "The diff names $file, which gremlins cannot find from pluginkit."; exit 1; } + done + echo "run=true" >> "$GITHUB_OUTPUT" + - if: steps.changed.outputs.run == 'false' + run: | + ulimit -v 12582912 + go run github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0 unleash --config .gremlins.yaml --dry-run . + - if: steps.changed.outputs.run == 'true' + run: | + ulimit -v 12582912 + go run github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0 unleash --config .gremlins.yaml --diff "$BASE" -o gremlins.json . + - if: always() && steps.changed.outputs.run == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: gremlins-report + path: pluginkit/gremlins.json + retention-days: 7 + if-no-files-found: ignore diff --git a/pluginkit/.gremlins.yaml b/pluginkit/.gremlins.yaml new file mode 100644 index 0000000..794d812 --- /dev/null +++ b/pluginkit/.gremlins.yaml @@ -0,0 +1,10 @@ +unleash: + workers: 3 + output-statuses: lct + exclude-files: + - '^graphwire/' +mutants: + invert-logical: + enabled: true + invert-loopctrl: + enabled: true diff --git a/pluginkit/CHANGELOG.md b/pluginkit/CHANGELOG.md index 474318e..6304af7 100644 --- a/pluginkit/CHANGELOG.md +++ b/pluginkit/CHANGELOG.md @@ -6,6 +6,8 @@ Releases of this module are tagged `pluginkit/vX.Y.Z`. Releases up to ## Unreleased - The module needs Go 1.27.3. +- `NewHostWithMigrationStore` returns a host whose `Migrate` and `Start` skip each plugin a `MigrationStore` records as current, by the version its `MigrationVersioner` reports. +- An optional `MigratorOf` lookup gives that host the `Migrator` of a plugin that ships its schema as data. ## 0.7.0 - 2026-10-15 diff --git a/pluginkit/host.go b/pluginkit/host.go index 4fed594..c42d96c 100644 --- a/pluginkit/host.go +++ b/pluginkit/host.go @@ -10,22 +10,50 @@ import ( "time" ) +var ( + // errNoMigrationStore refuses a host built to skip migrations with no store to read them from. + errNoMigrationStore = errors.New("pluginkit: the migration store is nil") + // errEmptyMigrationVersion refuses a migration version that holds nothing to compare. + errEmptyMigrationVersion = errors.New("the version is empty") +) + // Host starts and stops a fixed set of plugins. type Host struct { - plugins []Plugin + plugins []Plugin + store MigrationStore + migratorOf MigratorOf } // NewHost returns a [Host] managing plugins. It panics when two // plugins share an ID. func NewHost(plugins ...Plugin) *Host { + if err := duplicateID(plugins); err != nil { + panic(err.Error()) + } + return &Host{plugins: plugins} +} + +// NewHostWithMigrationStore returns a [Host] whose Migrate skips every plugin store records as current. +func NewHostWithMigrationStore(store MigrationStore, migratorOf MigratorOf, plugins ...Plugin) (*Host, error) { + if store == nil { + return nil, errNoMigrationStore + } + if err := duplicateID(plugins); err != nil { + return nil, err + } + return &Host{plugins: plugins, store: store, migratorOf: migratorOf}, nil +} + +// duplicateID returns an error naming the first id two plugins share. +func duplicateID(plugins []Plugin) error { seen := make(map[string]struct{}, len(plugins)) for _, p := range plugins { if _, ok := seen[p.ID()]; ok { - panic(fmt.Sprintf("pluginkit: duplicate id %q", p.ID())) + return fmt.Errorf("pluginkit: duplicate id %q", p.ID()) } seen[p.ID()] = struct{}{} } - return &Host{plugins: plugins} + return nil } // Start migrates and starts every plugin in order, stopping the started ones within stopGrace when one fails. @@ -51,29 +79,106 @@ func (h *Host) rollBack(ctx context.Context, stopGrace time.Duration, index int) return h.stopDownFrom(stopping, index) } -// Migrate applies the schema of every [Migrator] plugin in registration order, stopping at the first failure. +// Migrate applies the schema of every [Migrator] plugin in order, past those its store records as current. func (h *Host) Migrate(ctx context.Context) error { + if h.store == nil { + return h.migrateEvery(ctx) + } + versions, err := h.readVersions(ctx) + if err != nil { + return err + } for _, p := range h.plugins { - migrator, ok := p.(Migrator) - if !ok { - continue - } - if err := safeCall(ctx, p.ID(), "migrate", migrator.Migrate); err != nil { + if err := h.migrateUnlessCurrent(ctx, p, versions); err != nil { return err } } return nil } +// readVersions returns every migration version the store records, in one read. +func (h *Host) readVersions(ctx context.Context) (map[string]string, error) { + var versions map[string]string + err := guarded(ctx, "read the migration versions", func(ctx context.Context) error { + var err error + versions, err = h.store.Versions(ctx) + return err + }) + return versions, err +} + +// migrateUnlessCurrent applies the schema of p unless versions holds its version, then records the one it applied. +func (h *Host) migrateUnlessCurrent(ctx context.Context, p Plugin, versions map[string]string) error { + migrator, ok, err := h.migratorFor(ctx, p) + if err != nil || !ok { + return err + } + versioner, ok := migrator.(MigrationVersioner) + if !ok { + return safeCall(ctx, p.ID(), "migrate", migrator.Migrate) + } + version, err := migrationVersion(ctx, p.ID(), versioner) + if err != nil || version == versions[p.ID()] { + return err + } + if err := safeCall(ctx, p.ID(), "migrate", migrator.Migrate); err != nil { + return err + } + return safeCall(ctx, p.ID(), "record", func(ctx context.Context) error { + return h.store.Record(ctx, p.ID(), version) + }) +} + +// migratorFor returns the [Migrator] the lookup gives for p, or p itself when the lookup gives none. +func (h *Host) migratorFor(ctx context.Context, p Plugin) (migrator Migrator, ok bool, err error) { + if h.migratorOf != nil { + err = safeCall(ctx, p.ID(), "migrator lookup", func(context.Context) error { + migrator, ok = h.migratorOf(p) + return nil + }) + if err != nil || ok { + return migrator, ok, err + } + } + migrator, ok = p.(Migrator) + return migrator, ok, nil +} + +// migrationVersion returns the migration version versioner reports for the plugin id, refusing an empty one. +func migrationVersion(ctx context.Context, id string, versioner MigrationVersioner) (string, error) { + var version string + err := safeCall(ctx, id, "migration version", func(context.Context) error { + var err error + if version, err = versioner.MigrationVersion(); err == nil && version == "" { + err = errEmptyMigrationVersion + } + return err + }) + return version, err +} + +// migrateEvery applies the schema of every [Migrator] plugin in registration order, stopping at the first failure. +func (h *Host) migrateEvery(ctx context.Context) error { + return callEach(ctx, h.plugins, "migrate", Migrator.Migrate) +} + // Seed asks every [Seeder] plugin to fill its schema in registration order, // stopping at the first failure. func (h *Host) Seed(ctx context.Context) error { - for _, p := range h.plugins { - seeder, ok := p.(Seeder) + return callEach(ctx, h.plugins, "seed", Seeder.Seed) +} + +// callEach calls step on every plugin that is a T, in registration order, stopping at the first failure. +func callEach[T any]( + ctx context.Context, plugins []Plugin, operation string, step func(T, context.Context) error, +) error { + for _, p := range plugins { + capable, ok := p.(T) if !ok { continue } - if err := safeCall(ctx, p.ID(), "seed", seeder.Seed); err != nil { + call := func(ctx context.Context) error { return step(capable, ctx) } + if err := safeCall(ctx, p.ID(), operation, call); err != nil { return err } } @@ -123,14 +228,19 @@ func (h *Host) stopDownFrom(ctx context.Context, index int) error { // safeCall runs fn, wrapping any returned error and converting any panic into an error tagged with the // plugin id and operation. -func safeCall(ctx context.Context, id, operation string, fn func(context.Context) error) (err error) { +func safeCall(ctx context.Context, id, operation string, fn func(context.Context) error) error { + return guarded(ctx, id+" "+operation, fn) +} + +// guarded runs fn, wrapping any returned error and converting any panic into an error tagged with name. +func guarded(ctx context.Context, name string, fn func(context.Context) error) (err error) { defer func() { if recovered := recover(); recovered != nil { - err = fmt.Errorf("pluginkit: %s %s panicked: %v", id, operation, recovered) + err = fmt.Errorf("pluginkit: %s panicked: %v", name, recovered) } }() if err := fn(ctx); err != nil { - return fmt.Errorf("pluginkit: %s %s: %w", id, operation, err) + return fmt.Errorf("pluginkit: %s: %w", name, err) } return nil } diff --git a/pluginkit/pluginkit.go b/pluginkit/pluginkit.go index 55c3d0a..40f27ab 100644 --- a/pluginkit/pluginkit.go +++ b/pluginkit/pluginkit.go @@ -18,12 +18,29 @@ type Plugin interface { Stop(ctx context.Context) error } -// Migrator is implemented by plugins that own database schema, which -// the host migrates before starting any plugin. +// Migrator is implemented by plugins that own database schema, which a host migrates before starting any +// plugin, and which a host with a [MigrationStore] skips while its recorded version is current. type Migrator interface { Migrate(ctx context.Context) error } +// MigrationVersioner is implemented by a [Migrator] whose schema reports a version a host can compare. +type MigrationVersioner interface { + // MigrationVersion returns the version of the schema, which a host compares as an opaque string. + MigrationVersion() (string, error) +} + +// MigrationStore is an application's record of the migration version each plugin last applied. +type MigrationStore interface { + // Versions returns every recorded version, keyed by plugin id, in one read. + Versions(ctx context.Context) (map[string]string, error) + // Record stores version as the one plugin id has now applied. + Record(ctx context.Context, id, version string) error +} + +// MigratorOf returns the [Migrator] that applies a plugin's schema for it, and false when the plugin's own applies. +type MigratorOf func(Plugin) (Migrator, bool) + // Seeder is implemented by plugins that can fill their own schema with // development data, which the host asks for outside the start path. type Seeder interface { diff --git a/pluginkit/store_test.go b/pluginkit/store_test.go new file mode 100644 index 0000000..83727f6 --- /dev/null +++ b/pluginkit/store_test.go @@ -0,0 +1,440 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit_test + +import ( + "context" + "errors" + "maps" + "slices" + "testing" + + "github.com/gopherium/framework/pluginkit" +) + +var ( + _ pluginkit.MigrationStore = (*memoryStore)(nil) + _ pluginkit.MigrationVersioner = (*versionedPlugin)(nil) + _ pluginkit.MigrationVersioner = (*versionOnlyPlugin)(nil) + _ interface { + Migrate(context.Context) error + Seed(context.Context) error + Stop(context.Context) error + } = (*pluginkit.Host)(nil) +) + +// errStoreOffline is the failure a store answers when it cannot be reached. +var errStoreOffline = errors.New("store offline") + +// memoryStore is a migration store held in a map, adding each call it gets to calls. +type memoryStore struct { + versions map[string]string + readErr error + readPanic bool + recordErr error + recordPanic bool + calls *[]string +} + +// Versions returns a copy of the recorded versions. +func (s *memoryStore) Versions(context.Context) (map[string]string, error) { + *s.calls = append(*s.calls, "versions") + if s.readPanic { + panic("boom") + } + return maps.Clone(s.versions), s.readErr +} + +// Record stores version for id. +func (s *memoryStore) Record(_ context.Context, id, version string) error { + *s.calls = append(*s.calls, "record "+id+" "+version) + if s.recordPanic { + panic("boom") + } + if s.recordErr != nil { + return s.recordErr + } + s.versions[id] = version + return nil +} + +// versionedPlugin is a migrating plugin that reports the version of its schema. +type versionedPlugin struct { + migratingPlugin + version string + versionErr error + versionPanic bool +} + +// MigrationVersion returns the version the plugin was given. +func (v *versionedPlugin) MigrationVersion() (string, error) { + *v.calls = append(*v.calls, v.id+" version") + if v.versionPanic { + panic("boom") + } + return v.version, v.versionErr +} + +// versionOnlyPlugin reports a migration version but owns no schema. +type versionOnlyPlugin struct { + fakePlugin +} + +// MigrationVersion returns a version for a schema the plugin does not have. +func (v *versionOnlyPlugin) MigrationVersion() (string, error) { + *v.calls = append(*v.calls, v.id+" version") + return "1", nil +} + +// schemaMigrator applies a plugin's schema on its behalf and reports its version. +type schemaMigrator struct { + id string + version string + calls *[]string +} + +// Migrate records that the schema of the plugin it stands for was applied. +func (s *schemaMigrator) Migrate(context.Context) error { + *s.calls = append(*s.calls, s.id+" schema migrate") + return nil +} + +// MigrationVersion returns the version the migrator was given. +func (s *schemaMigrator) MigrationVersion() (string, error) { + return s.version, nil +} + +// versioned returns a migrating plugin with id whose schema reports version. +func versioned(id, version string, calls *[]string) *versionedPlugin { + return &versionedPlugin{migratingPlugin: *migrating(id, calls), version: version} +} + +// migrating returns a migrating plugin with id that reports no version. +func migrating(id string, calls *[]string) *migratingPlugin { + return &migratingPlugin{fakePlugin: fakePlugin{id: id, calls: calls}} +} + +// lookupFor returns a lookup that gives a schema migrator at version for the plugin id and nothing for any other. +func lookupFor(id, version string, calls *[]string) pluginkit.MigratorOf { + return func(p pluginkit.Plugin) (pluginkit.Migrator, bool) { + if p.ID() != id { + return nil, false + } + return &schemaMigrator{id: id, version: version, calls: calls}, true + } +} + +// storeHost returns a host over plugins whose migrations store keeps and whose migrators lookup gives. +func storeHost( + t *testing.T, store *memoryStore, lookup pluginkit.MigratorOf, plugins ...pluginkit.Plugin, +) *pluginkit.Host { + t.Helper() + host, err := pluginkit.NewHostWithMigrationStore(store, lookup, plugins...) + if err != nil { + t.Fatalf("NewHostWithMigrationStore() error = %v, want nil", err) + } + return host +} + +// migrateTimes runs the migrations of host boots times and fails t on the first error. +func migrateTimes(t *testing.T, host *pluginkit.Host, boots int) { + t.Helper() + for range boots { + if err := host.Migrate(t.Context()); err != nil { + t.Fatalf("Migrate() error = %v, want nil", err) + } + } +} + +// wantCalls fails t unless calls is want. +func wantCalls(t *testing.T, calls, want []string) { + t.Helper() + if !slices.Equal(want, calls) { + t.Errorf("calls = %v, want %v", calls, want) + } +} + +// wantFailure fails t unless err has the text want. +func wantFailure(t *testing.T, err error, want string) { + t.Helper() + if err == nil || err.Error() != want { + t.Errorf("error = %v, want %q", err, want) + } +} + +func TestNewHostWithMigrationStoreRefusesANilStore(t *testing.T) { + t.Parallel() + + var calls []string + host, err := pluginkit.NewHostWithMigrationStore(nil, nil, &fakePlugin{id: "alpha", calls: &calls}) + + wantFailure(t, err, "pluginkit: the migration store is nil") + if host != nil { + t.Error("NewHostWithMigrationStore() returned a host for a nil store, want nil") + } +} + +func TestNewHostWithMigrationStoreRefusesDuplicateIDs(t *testing.T) { + t.Parallel() + + var calls []string + store := &memoryStore{calls: &calls} + host, err := pluginkit.NewHostWithMigrationStore(store, nil, + &fakePlugin{id: "feed", calls: &calls}, &fakePlugin{id: "feed", calls: &calls}) + + wantFailure(t, err, `pluginkit: duplicate id "feed"`) + if host != nil { + t.Error("NewHostWithMigrationStore() returned a host for two plugins with one id, want nil") + } +} + +func TestNewHostPanicsWithTheDuplicateIDText(t *testing.T) { + t.Parallel() + + defer func() { + if recovered := recover(); recovered != `pluginkit: duplicate id "feed"` { + t.Errorf("NewHost() panicked with %v, want the duplicate id text", recovered) + } + }() + + var calls []string + pluginkit.NewHost(&fakePlugin{id: "feed", calls: &calls}, &fakePlugin{id: "feed", calls: &calls}) +} + +func TestHostWithMigrationStoreMigratesOnlyWhatIsNotCurrent(t *testing.T) { + t.Parallel() + + one := func(plugin func(c *[]string) pluginkit.Plugin) func(c *[]string) []pluginkit.Plugin { + return func(c *[]string) []pluginkit.Plugin { return []pluginkit.Plugin{plugin(c)} } + } + alphaAt := func(version string) func(c *[]string) pluginkit.Plugin { + return func(c *[]string) pluginkit.Plugin { return versioned("alpha", version, c) } + } + alphaMigrating := func(c *[]string) pluginkit.Plugin { return migrating("alpha", c) } + alphaPlain := func(c *[]string) pluginkit.Plugin { return &fakePlugin{id: "alpha", calls: c} } + alphaVersionOnly := func(c *[]string) pluginkit.Plugin { + return &versionOnlyPlugin{fakePlugin: fakePlugin{id: "alpha", calls: c}} + } + cases := []struct { + name string + recorded map[string]string + lookupID string + plugins func(calls *[]string) []pluginkit.Plugin + boots int + want []string + }{ + {"plugins the store has not seen, with one read of the store", map[string]string{}, "", + func(c *[]string) []pluginkit.Plugin { + return []pluginkit.Plugin{versioned("alpha", "1", c), versioned("beta", "1", c)} + }, 1, []string{ + "versions", "alpha version", "alpha migrate", "record alpha 1", + "beta version", "beta migrate", "record beta 1", + }}, + {"a plugin whose recorded version is current", map[string]string{"alpha": "3"}, "", + one(alphaAt("3")), 1, []string{"versions", "alpha version"}}, + {"a plugin whose version changed", map[string]string{"alpha": "2"}, "", + one(alphaAt("3")), 1, []string{"versions", "alpha version", "alpha migrate", "record alpha 3"}}, + {"a recorded version no plugin holds", map[string]string{"gone": "9"}, "", + one(alphaAt("1")), 1, []string{"versions", "alpha version", "alpha migrate", "record alpha 1"}}, + {"the boot after it recorded", map[string]string{}, "", one(alphaAt("1")), 2, []string{ + "versions", "alpha version", "alpha migrate", "record alpha 1", "versions", "alpha version", + }}, + {"a migrator with no version, on every boot", map[string]string{}, "", + one(alphaMigrating), 2, []string{"versions", "alpha migrate", "versions", "alpha migrate"}}, + {"a plugin that is no migrator", map[string]string{}, "", one(alphaPlain), 1, []string{"versions"}}, + {"a version from a plugin that is no migrator", map[string]string{}, "", + one(alphaVersionOnly), 1, []string{"versions"}}, + {"the lookup's migrator before the plugin's own", map[string]string{}, "alpha", + one(alphaMigrating), 1, []string{"versions", "alpha schema migrate", "record alpha 4"}}, + {"a lookup's migrator whose version is current", map[string]string{"alpha": "4"}, "alpha", + one(alphaPlain), 1, []string{"versions"}}, + {"the plugin's own Migrate when the lookup has none", map[string]string{}, "other", + one(alphaMigrating), 1, []string{"versions", "alpha migrate"}}, + {"a plugin neither the lookup nor itself gives a migrator", map[string]string{}, "other", + one(alphaPlain), 1, []string{"versions"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var calls []string + store := &memoryStore{versions: tc.recorded, calls: &calls} + host := storeHost(t, store, lookupFor(tc.lookupID, "4", &calls), tc.plugins(&calls)...) + + migrateTimes(t, host, tc.boots) + + wantCalls(t, calls, tc.want) + }) + } +} + +func TestHostWithMigrationStoreStopsAtAFailureNamingThePlugin(t *testing.T) { + t.Parallel() + + errUnreadable := errors.New("migrations folder unreadable") + errSchema := errors.New("schema exploded") + asked := []string{"versions", "alpha version"} + migrated := []string{"versions", "alpha version", "alpha migrate"} + recorded := []string{"versions", "alpha version", "alpha migrate", "record alpha 1"} + cases := []struct { + name string + fail func(store *memoryStore, plugin *versionedPlugin) + err string + cause error + calls []string + }{ + {"an empty version", func(_ *memoryStore, p *versionedPlugin) { p.version = "" }, + "pluginkit: alpha migration version: the version is empty", nil, asked}, + {"a version the plugin cannot tell", + func(_ *memoryStore, p *versionedPlugin) { p.version, p.versionErr = "", errUnreadable }, + "pluginkit: alpha migration version: migrations folder unreadable", errUnreadable, asked}, + {"a version that panics", func(_ *memoryStore, p *versionedPlugin) { p.versionPanic = true }, + "pluginkit: alpha migration version panicked: boom", nil, asked}, + {"a failed migration", func(_ *memoryStore, p *versionedPlugin) { p.migrateErr = errSchema }, + "pluginkit: alpha migrate: schema exploded", errSchema, migrated}, + {"a panicking migration", func(_ *memoryStore, p *versionedPlugin) { p.migratePanic = true }, + "pluginkit: alpha migrate panicked: boom", nil, migrated}, + {"a failed record", func(s *memoryStore, _ *versionedPlugin) { s.recordErr = errStoreOffline }, + "pluginkit: alpha record: store offline", errStoreOffline, recorded}, + {"a panicking record", func(s *memoryStore, _ *versionedPlugin) { s.recordPanic = true }, + "pluginkit: alpha record panicked: boom", nil, recorded}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var calls []string + store := &memoryStore{versions: map[string]string{}, calls: &calls} + lead := versioned("alpha", "1", &calls) + tc.fail(store, lead) + host := storeHost(t, store, nil, lead, versioned("beta", "1", &calls)) + + err := host.Migrate(t.Context()) + + wantFailure(t, err, tc.err) + if tc.cause != nil && !errors.Is(err, tc.cause) { + t.Errorf("Migrate() error = %v, want %v in its chain", err, tc.cause) + } + wantCalls(t, calls, tc.calls) + }) + } +} + +func TestHostWithMigrationStoreReportsAStoreReadFailureBeforeAnyMigration(t *testing.T) { + t.Parallel() + + var calls []string + store := &memoryStore{readErr: errStoreOffline, calls: &calls} + host := storeHost(t, store, nil, versioned("alpha", "1", &calls), migrating("beta", &calls)) + + err := host.Migrate(t.Context()) + + wantFailure(t, err, "pluginkit: read the migration versions: store offline") + if !errors.Is(err, errStoreOffline) { + t.Errorf("Migrate() error = %v, want %v in its chain", err, errStoreOffline) + } + wantCalls(t, calls, []string{"versions"}) +} + +func TestHostWithMigrationStoreRecoversAStoreReadPanic(t *testing.T) { + t.Parallel() + + var calls []string + store := &memoryStore{readPanic: true, calls: &calls} + host := storeHost(t, store, nil, versioned("alpha", "1", &calls)) + + err := host.Migrate(t.Context()) + + wantFailure(t, err, "pluginkit: read the migration versions panicked: boom") + wantCalls(t, calls, []string{"versions"}) +} + +func TestHostWithMigrationStoreRecoversALookupPanicNamingThePlugin(t *testing.T) { + t.Parallel() + + var calls []string + store := &memoryStore{versions: map[string]string{}, calls: &calls} + lookup := func(pluginkit.Plugin) (pluginkit.Migrator, bool) { panic("boom") } + host := storeHost(t, store, lookup, versioned("alpha", "1", &calls), versioned("beta", "1", &calls)) + + err := host.Migrate(t.Context()) + + wantFailure(t, err, "pluginkit: alpha migrator lookup panicked: boom") + wantCalls(t, calls, []string{"versions"}) +} + +// contextStore is a migration store that adds the caller value of each context it gets to seen. +type contextStore struct { + seen *[]any +} + +// Versions records the caller value of ctx and returns no versions. +func (s *contextStore) Versions(ctx context.Context) (map[string]string, error) { + *s.seen = append(*s.seen, ctx.Value(callerKey{})) + return map[string]string{}, nil +} + +// Record records the caller value of ctx. +func (s *contextStore) Record(ctx context.Context, _, _ string) error { + *s.seen = append(*s.seen, ctx.Value(callerKey{})) + return nil +} + +// versionedContextMigrator is a context migrator whose schema reports a version. +type versionedContextMigrator struct { + contextMigrator +} + +// MigrationVersion returns a version no store has recorded. +func (v *versionedContextMigrator) MigrationVersion() (string, error) { + return "1", nil +} + +func TestHostWithMigrationStoreHandsTheCallerContextToTheStoreAndEveryMigrator(t *testing.T) { + t.Parallel() + + var calls []string + var seen []any + host, err := pluginkit.NewHostWithMigrationStore(&contextStore{seen: &seen}, nil, + &versionedContextMigrator{contextMigrator{fakePlugin: fakePlugin{id: "alpha", calls: &calls}, seen: &seen}}, + &contextMigrator{fakePlugin: fakePlugin{id: "beta", calls: &calls}, seen: &seen}) + if err != nil { + t.Fatalf("NewHostWithMigrationStore() error = %v, want nil", err) + } + ctx := context.WithValue(t.Context(), callerKey{}, "caller") + + if err := host.Migrate(ctx); err != nil { + t.Fatalf("Migrate() error = %v, want nil", err) + } + + if want := []any{"caller", "caller", "caller", "caller"}; !slices.Equal(want, seen) { + t.Errorf("context values seen = %v, want %v from the read, both migrations and the record", seen, want) + } +} + +func TestHostWithMigrationStoreStartSkipsCurrentMigrations(t *testing.T) { + t.Parallel() + + var calls []string + store := &memoryStore{versions: map[string]string{"alpha": "2"}, calls: &calls} + host := storeHost(t, store, nil, versioned("alpha", "2", &calls), &fakePlugin{id: "beta", calls: &calls}) + + if err := host.Start(t.Context(), stopGrace); err != nil { + t.Fatalf("Start() error = %v, want nil", err) + } + + wantCalls(t, calls, []string{"versions", "alpha version", "alpha start", "beta start"}) +} + +func TestHostWithoutAStoreNeverAsksAVersion(t *testing.T) { + t.Parallel() + + var calls []string + host := pluginkit.NewHost(versioned("alpha", "1", &calls), &fakePlugin{id: "beta", calls: &calls}) + + if err := host.Migrate(t.Context()); err != nil { + t.Fatalf("Migrate() error = %v, want nil", err) + } + + wantCalls(t, calls, []string{"alpha migrate"}) +}