← repoman internals

internal/migrationconfig

import "git.griefed.de/griefed/repoman/internal/migrationconfig"
package migrationconfig // import "git.griefed.de/griefed/repoman/internal/migrationconfig"

Package migrationconfig defines the domain model for repoman's per-namespace
migration configurations.

A MigrationConfig is the unit that:
  - has a single Forgejo target owner (where imports land)
  - has one or more Source namespaces (where imports come from)
  - has its own cron schedule (when runs fire)
  - owns a set of repositories (which repos belong to this config)

Each save creates a new MigrationConfigVersion for audit history.

Conflict analysis lives in conflicts.go and is invoked by the service layer
before persisting changes that may overlap with existing configurations.

VARIABLES

var ErrNameTaken = errors.New("migration config name already taken")
    ErrNameTaken is returned when an attempt is made to create or rename a
    config to a name that another config already uses.

var ErrNotFound = errors.New("migration config not found")
    ErrNotFound is returned when a config is not found by lookup. Callers
    compare with errors.Is.

var SupportedPlatforms = []string{
	"github",
	"gitlab",
	"gitea",
	"codeberg",
}
    SupportedPlatforms is the canonical set of source platforms. Adding
    a new platform here is necessary but not sufficient — the migration
    engine also needs to know how to talk to it. Each platform has a default
    URL (PlatformDefaultURL) that a Source may override — so self-hosted
    GitLab/Gitea is "<platform> + InstanceURL", not a separate platform.


FUNCTIONS

func FormatID(id int64) string
    FormatID is a tiny helper for callers that want a string ID for audit-log
    resource references.

func IsSupportedPlatform(platform string) bool
    IsSupportedPlatform reports whether the given platform identifier is one we
    can actually import from.

func LookupOwnerToken(ownerTokens map[string]string, platform, instanceURL, namespace string) string
    LookupOwnerToken finds the owner's account token for a source's platform,
    instance and namespace: the token stored for that exact namespace when there
    is one, otherwise the platform+instance wildcard.

    The precedence is the whole point of namespace scoping. A token narrowed
    to one org must not be handed to a source in a different org, while a token
    that names no namespace stays usable everywhere on its instance — which is
    what every token migrated from before namespaces became part of a token's
    identity looks like. Returns "" when neither is present.

func NormalizeInstanceURL(platform, instanceURL string) string
    NormalizeInstanceURL canonicalizes a source instance URL so the same
    instance always maps to the same account-token key. It trims spaces
    and a trailing slash and returns "" — the canonical "default (cloud)
    URL" key — when the URL is blank or equal (case-insensitively) to the
    platform's default URL. So a source with no override and one explicitly
    set to "https://gitlab.com/" both resolve to the cloud GitLab token,
    while "https://gitlab.corp.example" is its own key.

func OwnerTokenKey(platform, instanceURL, namespace string) string
    OwnerTokenKey is the lookup key for a config owner's account token map
    (OwnerTokens): the platform, its normalized instance URL and the namespace
    the token belongs to, joined by a NUL that can't appear in any part.
    Both the map builder (db.UserPlatformTokenRepo) and every resolver must key
    through this helper so a token stored for (platform, instance, namespace) is
    found by a source with the same three.

    A namespace of "" is the WILDCARD key: "any namespace on this platform and
    instance". Every token predating namespace scoping is stored that way,
    so a single PAT covering several orgs keeps resolving for all of them.
    Use LookupOwnerToken rather than this helper directly — it applies the
    exact-then- wildcard precedence that makes the wildcard work.

func PlatformDefaultURL(platform string) string
    PlatformDefaultURL returns the default web base URL for a platform, or ""
    for an unknown platform. Used for form placeholders and as the fallback when
    a Source provides no InstanceURL.


TYPES

type AnalyzerDeps struct {
	Configs ConfigReader
	Repos   RepoReader
}
    AnalyzerDeps bundles what the analyzer needs to read. We pass a struct
    rather than separate args so adding a new dependency in future doesn't churn
    every call site.

type ConfigReader interface {
	List(ctx context.Context) ([]*MigrationConfig, error)
}
    ConfigReader is the read-only subset of the config repository the analyzer
    needs. Tests substitute an in-memory fake.

type ConflictAnalysis struct {
	OwnedConflicts  []OwnedConflict
	UnownedExisting []ExistingRepo
	SourceOverlaps  []SourceOverlap
}
    ConflictAnalysis is the result of analysing a candidate config against the
    existing state of repoman. The UI presents these three sections (Option C)
    before the operator commits the save:

      - OwnedConflicts: Forgejo repos that already belong to another config
        and would normally be claimed by this one. Operator chooses per-repo:
        keep with old / move to new.

      - UnownedExisting: Forgejo repos in the target owner that have no owning
        config (legacy or admin-orphaned). Operator chooses per-repo: assign to
        this config / leave unowned.

      - SourceOverlaps: Other configs that read from the same source (platform
        + namespace) as the candidate. SOFT info — only blocking when they ALSO
        target the same Forgejo owner.

func Analyze(
	ctx context.Context,
	deps AnalyzerDeps,
	candidate *MigrationConfig,
	excludeID *int64,
) (*ConflictAnalysis, error)
    Analyze examines the candidate config against the existing world and returns
    the three conflict categories. The candidate need NOT be persisted yet —
    analyze runs purely on read data plus the in-memory candidate.

    excludeID, when non-nil, omits that ID from comparisons. Used during EDIT:
    the candidate IS one of the existing configs, and we don't want to flag it
    conflicting with itself.

func (a *ConflictAnalysis) HasBlockers() bool
    HasBlockers reports whether the analysis contains issues that prevent saving
    entirely (as opposed to per-repo decisions). Currently: a SourceOverlap with
    the same Forgejo target owner is a blocker because it would mean two configs
    writing the same repos to the same place — pointless and race-prone.

type CreateInput struct {
	Config           *MigrationConfig
	Comment          string
	UserID           *int64
	Decisions        map[int64]Decision // empty = analyze first, no decisions yet
	OverrideBlockers bool               // true means: the operator saw blockers and chose to proceed anyway
}
    CreateInput is the payload for Service.Create. Decisions maps a
    repository_id to the operator's choice for that repo, sourced from the
    conflict-resolution UI.

type Decision string
    Decision is the operator's per-repo choice rendered by the UI. Used by the
    Save path to know what to do with each conflict.

const (
	// DecisionClaim assigns the repo to the candidate config.
	DecisionClaim Decision = "claim"

	// DecisionLeave leaves the repo with its current owner (or
	// unowned, in the UnownedExisting case).
	DecisionLeave Decision = "leave"
)
type ExistingRepo struct {
	RepositoryID int64
	ForgejoOwner string
	ForgejoName  string
}
    ExistingRepo is a Forgejo repo in the target owner that has no owning config
    (owning_migration_config_id IS NULL).

type MigrationConfig struct {
	ID                     int64
	Name                   string
	ForgejoTargetOwner     string
	ForgejoTargetOwnerType OwnerType
	NamespaceConfirmedAt   *time.Time

	Sources        []Source
	TokenOverrides map[string]string // platform → token
	Settings       Settings

	CronExpression string // empty = manual-only
	IsEnabled      bool

	LastRunAt *time.Time
	NextRunAt *time.Time

	CreatedAt            time.Time
	UpdatedAt            time.Time
	CreatedByUserID      *int64
	LastModifiedByUserID *int64

	// OwnerUserID is the user who may manage this config and whose personal
	// tokens its runs authenticate with (multi-tenant Phase B). Nil for a legacy
	// config with no resolved owner — an admin must claim it. Set from the creator
	// on create; admins may reassign.
	OwnerUserID *int64
}
    MigrationConfig mirrors the migration_configs row.

    All time fields are time.Time at the Go boundary; the repository
    implementation handles TEXT conversion. Slice and map fields are the
    in-memory shape of the JSON columns documented in the schema.

func ParseSnapshot(snapshotJSON string) (*MigrationConfig, error)
    ParseSnapshot unmarshals a version's snapshot_json back into a
    MigrationConfig. Token material is already redacted from the stored
    snapshot, so the result carries no secrets — used by the history view to
    diff successive versions. An empty string yields a zero-value config.

func (c *MigrationConfig) ActiveOutboundTargets() []string
    ActiveOutboundTargets returns the source platforms this config actually
    pushes back to: none when ImportOnly is set, otherwise the configured
    EnabledOutboundTargets. It is the single gate both engines consult so an
    import-only config can never sync, whatever stale targets a row may carry.

func (c *MigrationConfig) EffectiveOwnerUserID() *int64
    EffectiveOwnerUserID returns the user whose account this config's runs
    authenticate as and who may manage it: the explicit OwnerUserID when set,
    else the original CreatedByUserID, else nil (a legacy/CLI-created config
    no one has claimed). Token resolution and owner-routing key on THIS,
    not the immutable creator alone, so an admin reassigning the owner actually
    moves credential lookup with it. Nil-safe receiver.

func (c *MigrationConfig) ImportsAsPullMirror() bool
    ImportsAsPullMirror reports whether newly imported repos should be created
    as Forgejo pull mirrors. True when the operator opted in via MirrorOnImport,
    OR when outbound sync is active — an outbound config relays source→Forgejo→
    targets, so the Forgejo copy must itself track the source. It is the single
    gate the engine consults so the two reasons to pull-mirror never diverge.

func (c *MigrationConfig) IsConfirmed() bool
    IsConfirmed reports whether the operator has explicitly confirmed the
    namespace pinning for this config. Until confirmed, real runs must redirect
    through the confirmation flow.

func (c *MigrationConfig) IsOwnedBy(userID int64) bool
    IsOwnedBy reports whether userID owns this config. Nil-safe; a config with
    no owner (legacy) is owned by nobody.

func (c *MigrationConfig) ResolveSourceToken(src Source, ownerFallback string) string
    ResolveSourceToken picks the access token for one INBOUND source,
    in precedence order: the per-source token → the supplied owner fallback (the
    config owner's account token for this source's platform+instance+namespace,
    see OwnerTokenKey). The owner fallback is the reference model's primary
    source of token VALUES; Source.Token is a legacy slot that only carries a
    value on a config saved before the reference model and not yet re-saved
    (a save routes it into the account store and blanks it). It is the single
    resolution rule shared by the migrate engine and the issue-sync webhook
    ingress, so the two can never authenticate differently. Nil-safe receiver.

    TokenOverrides is deliberately NOT consulted here. It is the OUTBOUND
    per-config credential (resolveOutboundToken) and the config form presents
    it as such — "tokens this config uses to push repositories back out".
    Reading it for inbound made a write-scoped outbound token silently outrank
    the account token for every source on that platform, so a run failed with
    an invalid-token error while the source row's "Test connection" — which
    resolves the account token directly — reported success. It also could not be
    keyed correctly for inbound: TokenOverrides is keyed by platform ALONE, with
    no instance URL and no namespace, so one entry applied to two GitHub orgs
    and to github.com plus a GitHub Enterprise host alike. Don't put it back.

func (c *MigrationConfig) Validate() error
    Validate checks the config for internal consistency. Called by the service
    layer before persisting any change. Returns the first problem encountered;
    the caller should surface it to the operator.

    Rules:
      - Name not empty, no whitespace-only
      - ForgejoTargetOwner not empty
      - ForgejoTargetOwnerType is a valid OwnerType
      - Sources: each platform is supported, each Source has a non-empty
        namespace, each OwnerType is valid. The same platform MAY appear more
        than once (each source has its own URL override and token); the GoRunner
        iterates sources independently.
      - Settings.EnabledOutboundTargets entries are supported platforms

type OwnedConflict struct {
	RepositoryID     int64
	ForgejoOwner     string
	ForgejoName      string
	OwningConfigID   int64
	OwningConfigName string
}
    OwnedConflict is a Forgejo repo that already belongs to another migration
    config and would be claimed by the candidate.

type OwnerType string
    OwnerType is "user" or "org" — both for Forgejo targets and source
    namespaces. Validated at the DB CHECK constraint level too.

const (
	OwnerUser OwnerType = "user"
	OwnerOrg  OwnerType = "org"
)
func (o OwnerType) Valid() bool
    Valid reports whether the OwnerType is one of the accepted values.

type OwnershipWriter interface {
	AssignOwnership(ctx context.Context, repositoryID int64, configID *int64) error
}
    OwnershipWriter is the small write surface needed when the operator applies
    per-repo Decisions. Implementation lives in internal/db.

    AssignOwnership sets owning_migration_config_id for the given repo;
    pass nil to unassign. The interface is decoupled from the full repository
    repository (which doesn't exist yet — the migration engine will build it) so
    the service only sees what it needs.

type RepoReader interface {
	// ListByForgejoOwner returns repos in the given Forgejo owner.
	// Used to detect both OwnedConflicts and UnownedExisting.
	ListByForgejoOwner(ctx context.Context, owner string) ([]*RepoSummary, error)
}
    RepoReader is the read-only subset of the repositories repository the
    analyzer needs. Defined here (rather than imported from internal/db) to
    avoid an import cycle and to keep the analyzer independent of any specific
    persistence layer.

    The methods correspond to one-line SQL queries; implementations are trivial.

type RepoSummary struct {
	ID                      int64
	ForgejoOwner            string
	ForgejoName             string
	OwningMigrationConfigID *int64
}
    RepoSummary is the minimum a RepoReader returns. Not the full row — the
    analyzer only needs identity and current owner.

type Repository interface {
	// Create inserts a new config and returns the assigned ID. The
	// caller has already called Validate.
	Create(ctx context.Context, config *MigrationConfig) (int64, error)

	// Update replaces the row identified by config.ID. Implementations
	// also write a snapshot of the PRE-UPDATE state into
	// migration_config_versions so the history is preserved.
	Update(ctx context.Context, config *MigrationConfig, comment string, modifierUserID *int64) error

	// GetByID returns the config or ErrNotFound.
	GetByID(ctx context.Context, id int64) (*MigrationConfig, error)

	// GetByName is the lookup the CLI uses (`repoman run --config NAME`).
	GetByName(ctx context.Context, name string) (*MigrationConfig, error)

	// List returns all configs, ordered by name. No pagination — the
	// expected count is small (up to a few dozen).
	List(ctx context.Context) ([]*MigrationConfig, error)

	// Delete removes a config. Repositories that pointed at it have
	// owning_migration_config_id set to NULL via ON DELETE SET NULL.
	Delete(ctx context.Context, id int64) error

	// MarkConfirmed sets namespace_confirmed_at to `at`. Called when
	// the operator passes through the first-run confirmation flow.
	MarkConfirmed(ctx context.Context, id int64, at time.Time) error

	// UpdateScheduleTimestamps records last_run_at and next_run_at.
	// Called by the scheduler after each run completes / is queued.
	UpdateScheduleTimestamps(ctx context.Context, id int64, lastRun, nextRun *time.Time) error
}
    Repository is the persistence interface this package depends on.
    Implementations live in internal/db; tests substitute an in-memory fake.

type SaveResult struct {
	ConfigID  int64
	Conflicts *ConflictAnalysis
}
    SaveResult is what the service returns after a Create/Update. Conflicts is
    non-nil if the analyzer found overlaps the operator must resolve; in that
    case the config was NOT persisted and the handler should re-render the form
    with the conflict list.

    On the success path Conflicts is nil and ConfigID is the persisted row id.

type Service struct {
	Configs Repository
	Repos   RepoReader
	Owners  OwnershipWriter
	Tx      TxRunner
	Now     func() time.Time
}
    Service is the application-layer orchestrator for migration configurations.
    It composes:

      - Repository — durable storage of configs and their versions
      - ConflictAnalyzer — detects overlaps with existing state
      - per-repo Decisions — the operator's choice for each conflicting repo

    HTTP handlers and CLI commands depend on Service rather than on Repository
    directly, so the conflict-resolution flow is consistent across both
    interfaces.

func NewService(configs Repository, repos RepoReader, owners OwnershipWriter) *Service
    NewService constructs a Service with sensible defaults. The Tx field
    defaults to a non-transactional pass-through (sound only for in-memory
    fakes); callers backed by a real database must override it with
    db.ConfigTxRunner so config + ownership writes are atomic.

func (s *Service) Confirm(ctx context.Context, configID int64) error
    Confirm marks a config's namespace as confirmed by the operator. Called from
    the first-run-confirm page after a real (non-dry) run is requested for a
    config whose namespace_confirmed_at is NULL.

    The actual run still has to be triggered separately; Confirm only flips the
    bit so the next trigger doesn't bounce back to the confirmation page.

func (s *Service) Create(ctx context.Context, in CreateInput) (*SaveResult, error)
    Create validates the candidate, runs the conflict analyzer, and either
    persists (when Decisions resolve every owned-conflict) or returns the
    analysis for the operator to act on.

    Flow:
     1. Validate the config syntactically. Failure → error, no persistence.
     2. Analyze conflicts.
     3. If blockers present and !OverrideBlockers → return analysis, not
        persisted.
     4. If OwnedConflicts exist that have no Decision in input → return
        analysis, not persisted. Operator must choose for every conflict.
     5. Otherwise persist the config, then apply Decisions atomically:
        - DecisionClaim → AssignOwnership(repoID, &newConfigID) - DecisionLeave
        → no change (OwnedConflict) OR no change (UnownedExisting)

    Note on UnownedExisting: per Option C the operator may choose to claim or
    leave each. Decisions for UnownedExisting follow the same schema. A missing
    Decision for an UnownedExisting defaults to leave (do nothing) — this is the
    conservative default that does not surprise the operator.

func (s *Service) Delete(ctx context.Context, id int64) error
    Delete removes a config. ON DELETE SET NULL on repositories.
    owning_migration_config_id means previously-owned repos become unowned
    (visible in inventory but no longer managed).

func (s *Service) Get(ctx context.Context, id int64) (*MigrationConfig, error)
    Get is a thin pass-through, included so handlers don't need a separate
    dependency on Repository for read paths.

func (s *Service) GetByName(ctx context.Context, name string) (*MigrationConfig, error)
    GetByName same.

func (s *Service) List(ctx context.Context) ([]*MigrationConfig, error)
    List same.

func (s *Service) Update(ctx context.Context, in UpdateInput) (*SaveResult, error)
    Update is the edit-flow analogue of Create. It runs the analyzer with
    excludeID set to the config's own ID so self-overlap doesn't count, then
    either applies the change or returns the analysis.

type Settings struct {
	// ForcePrivacyOverrideOnTargets enforces the SOURCE's visibility on an
	// outbound target that already exists, on every run, instead of only carrying
	// it over when repoman first creates the repo there
	// (ensureTargetRepo → RepoSpec.Private). Both engines honour it: the GoRunner
	// passes source.RepoMetadata.Private through the metadata sync, and BuildArgs
	// exports it for the bash ScriptRunner.
	//
	// Default OFF, and deliberately so: it changes who can SEE a repository on
	// another platform, so a target deliberately made public or private on the
	// mirror keeps that until an operator opts in. Every enforcement is logged.
	//
	// It also propagates an archived FORGEJO repo to every target — marking each
	// one archived and taking down its push mirror (migrate.standDownArchivedOutbound).
	// Forgejo is the trigger, not the source: once the hub's copy is archived
	// nothing new is committed or released there, so freezing the mirrors costs no
	// future sync. A SOURCE can be archived while the hub is still live and
	// collecting releases from CI, which is why that flag must not drive it. The
	// stand-down runs last in a repo's outbound sequence so the final releases land
	// before a target turns read-only, and it never UNarchives.
	ForcePrivacyOverrideOnTargets bool `json:"force_privacy_override_on_targets,omitempty"`
	ForceArchiveOverrideOnTargets bool `json:"force_archive_override_on_targets,omitempty"`

	// PreserveNamespace controls whether sub-org structure is preserved
	// on import. Defaults to true; set false to flatten everything to
	// the target owner's top level.
	PreserveNamespace bool `json:"preserve_namespace,omitempty"`

	// ImportOnly, when true, makes the config import to Forgejo ONLY and push
	// back to no source platform — regardless of EnabledOutboundTargets. It is
	// the safe default for a new config; turn it off to opt into selective
	// outbound sync. See MigrationConfig.ActiveOutboundTargets.
	ImportOnly bool `json:"import_only,omitempty"`

	// EnabledOutboundTargets is the list of source platforms this config also
	// pushes back to (push mirrors), so a user can keep selected source copies
	// in sync long-term. Empty = no outbound sync. Each entry must be a
	// configured source platform. Ignored entirely when ImportOnly is true.
	EnabledOutboundTargets []string `json:"enabled_outbound_targets,omitempty"`

	// MirrorOnImport, when true, creates each newly imported repo as a Forgejo
	// PULL mirror of its source, so Forgejo re-pulls from the source on an
	// interval and the destination stays current without re-runs. It only takes
	// effect at first import — Forgejo cannot convert an already-existing repo
	// into a mirror — so a repo already present in the target is still skipped.
	// Independent of outbound push mirrors; see ImportsAsPullMirror.
	MirrorOnImport bool `json:"mirror_on_import,omitempty"`

	// DryRun makes the run preview-only — no writes to Forgejo or to
	// outbound targets. The corresponding `repoman run --dry-run`
	// CLI flag overrides this on a per-invocation basis.
	DryRun bool `json:"dry_run,omitempty"`

	// SyncIssues opts this config into the issue hub: during a (non-dry) run,
	// each imported/already-present repo has its source issues + comments
	// imported into the Forgejo hub and kept in sync. Off by default; see
	// docs/issue-hub.md and internal/issuesync.
	SyncIssues bool `json:"sync_issues,omitempty"`
}
    Settings holds per-config knobs that today live in the bash script's .env
    file. We split them out as a typed struct (rather than an untyped map) so
    the UI form has a stable shape and so adding a new setting requires touching
    exactly one spot.

    All boolean fields default to false; all string fields default to empty.
    Migrate-engine code reads zero values as "off / not set".

type Source struct {
	Platform    string    `json:"platform"`               // "github", "gitlab", "gitea", "codeberg"
	Namespace   string    `json:"namespace"`              // owner name on that platform
	OwnerType   OwnerType `json:"owner_type"`             // user | org
	InstanceURL string    `json:"instance_url,omitempty"` // URL override; empty = platform default (PlatformDefaultURL)
	Token       string    `json:"token,omitempty"`        // per-source token; empty = fall back to per-platform override then global. SECRET — sources_json is encrypted at rest.
}
    Source is one entry in MigrationConfig.Sources — a (platform, namespace)
    pair the config reads from. The same platform may appear more than once
    (e.g. two GitHub orgs with different tokens); each source carries its own
    optional URL override and token.

type SourceOverlap struct {
	OtherConfigID     int64
	OtherConfigName   string
	Platform          string
	Namespace         string
	SameForgejoTarget bool
}
    SourceOverlap describes another config that reads from the same source
    (platform, namespace) as the candidate.

    SameForgejoTarget=true means both configs also write to the same Forgejo
    target owner — the dangerous case. SameForgejoTarget=false means the
    same source feeds different targets, which is legitimate (e.g. one config
    archives to a backup org).

type Tx struct {
	Configs Repository
	Owners  OwnershipWriter
}
    Tx bundles the repositories that must commit together when a config is
    saved: the config row itself (Configs) and the ownership reassignments the
    operator's Decisions imply (Owners). A TxRunner hands the service a Tx whose
    two repos share one database transaction so a partial save is impossible
    (Bug #2).

type TxRunner interface {
	RunInTx(ctx context.Context, fn func(Tx) error) error
}
    TxRunner runs fn inside a single transaction, committing when fn returns
    nil and rolling back otherwise. The production implementation lives
    in internal/db (ConfigTxRunner); tests supply an in-memory fake with
    snapshot/restore semantics.

type UpdateInput struct {
	Config           *MigrationConfig
	Comment          string
	UserID           *int64
	Decisions        map[int64]Decision
	OverrideBlockers bool
}
    UpdateInput mirrors CreateInput for the edit flow. The Config.ID must match
    an existing row; otherwise Update returns ErrNotFound.

type Version struct {
	ID                int64
	MigrationConfigID int64
	SnapshotJSON      string
	Comment           string
	CreatedAt         time.Time
	CreatedByUserID   *int64
}
    Version is one historical snapshot of a MigrationConfig. The SnapshotJSON
    contains the entire config row at the time of the save, serialised by the
    Repository's Update method.

type VersionRepository interface {
	// Create inserts a snapshot. Typically called by the Repository
	// implementation as part of Update; exposed here so direct callers
	// (e.g. import-from-bash migration) can record initial versions.
	Create(ctx context.Context, configID int64, snapshotJSON, comment string, userID *int64, at time.Time) (int64, error)

	// ListForConfig returns all versions for a given config, newest first.
	ListForConfig(ctx context.Context, configID int64, limit int) ([]*Version, error)

	// GetByID returns one version snapshot.
	GetByID(ctx context.Context, id int64) (*Version, error)
}
    VersionRepository persists the per-config version history.

    Created as a separate interface from Repository because version reads
    are independent of config reads and the methods don't need to share a
    transaction in either direction.