Skip to content

feat: MasterTableTenancy implements IDynamicTenantSource<string> (#377) - #385

Merged
jeremydmiller merged 1 commit into
mainfrom
feature/377-dynamic-tenant-source
Jul 30, 2026
Merged

feat: MasterTableTenancy implements IDynamicTenantSource<string> (#377)#385
jeremydmiller merged 1 commit into
mainfrom
feature/377-dynamic-tenant-source

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #377. Marks the NuGet packages as 5.8.0.

What

MasterTableTenancy already had the full dynamic-tenancy behavior (shipped in #165) but did not declare JasperFx.MultiTenancy.IDynamicTenantSource<string> — the abstraction store-agnostic admin tooling resolves through. Polecat-backed services therefore showed their tenants read-only in CritterWatch: no runtime add / disable / enable / remove.

Modeled directly on Marten's MasterTableTenancy: ITenancy, ITenancyWithMasterDatabase, IDynamicTenantSource<string> and MartenServiceCollectionExtensions.

MasterTableTenancy

  • Now : ITenancy, IDynamicTenantSource<string>, reporting Cardinality == DatabaseCardinality.DynamicMultiple. Cardinality became a public member so it satisfies both ITenancy and ITenantedSource<string>.

  • The interface members are explicit implementations adapting to the existing cancellable Polecat-native methods. Those carry an optional CancellationToken and therefore cannot implement the token-less interface signatures implicitly; explicit implementation also keeps the concrete API unambiguous for existing callers.

    IDynamicTenantSource<string> Polecat-native method
    AddTenantAsync(tenantId, connectionValue) AddDatabaseRecordAsync
    RemoveTenantAsync(tenantId) DeleteDatabaseRecordAsync
    DisableTenantAsync(tenantId) DisableTenantAsync(tenantId, token)
    EnableTenantAsync(tenantId) EnableTenantAsync(tenantId, token)
    AllDisabledAsync() AllDisabledAsync(token)
  • FindAsync throws UnknownTenantIdException for an unknown or disabled tenant — same as opening a session for it.

  • RefreshAsync clears the cache and re-reads the master table.

  • AllActive() returns the tenant database identifiers, not raw connection strings, so credentials never reach an admin dashboard. This is the same choice Marten makes (it returns MartenDatabase.Identifier). AllActiveByTenant() returns Assignment<string>(tenantId, tenantId), matching Marten.

  • The jasperfx#413 auto-assign overload Task<string> AddTenantAsync(string, CancellationToken) is deliberately left on its default interface implementation, which throws NotSupportedException. Database-per-tenant has no pool for Polecat to assign from, and CritterWatch skips sources that throw.

DI registration

AddPolecat registers the tenancy as IDynamicTenantSource<string> — but only when the configured tenancy is a dynamic source. Non-dynamic stores must keep GetServices empty; that emptiness is the signal consumers use to fall back to a read-only tenant list.

To make that probe possible while the container is still being assembled, AddPolecat(Action<StoreOptions>) now builds the StoreOptions eagerly and delegates to AddPolecat(StoreOptions) — exactly what Marten's AddMarten(Action<StoreOptions>) does. This is the one behavior change in the PR: the configure lambda now runs at registration time rather than on first IDocumentStore resolution. The lambda takes no IServiceProvider, so nothing it can legally reach has changed; the IConfigurePolecat chain and ReadJasperFxOptions still run later, at store construction, unchanged.

AddPolecat(Func<IServiceProvider, StoreOptions>) cannot probe (the options do not exist until the container is built) and is documented as such, with the one-liner for registering it manually.

Tests

  • src/Polecat.Tests/MultiTenancy/dynamic_tenant_source_tests.cs — 7 tests driving the tenancy only through the IDynamicTenantSource<string> reference against real SQL Server databases: cardinality, add→find, unknown-tenant throw, disable/enable round-trip, remove, refresh/active-set, and the NotSupportedException on auto-assign.
  • src/Polecat.Tests/DependencyInjection/dynamic_tenant_source_registration_tests.cs — 4 tests: registered (and same instance as Options.Tenancy) for master-table tenancy via both the lambda and pre-built-options overloads; empty for single-database and static MultiTenantedDatabases() stores.

Docs

New ### Store-Agnostic Runtime Tenant Management section in docs/configuration/multitenancy.md (badged 5.8), based on Marten's master-table docs: the full lifecycle through the abstraction, the DI registration, why the registration is conditional, and both caveats (auto-assign NotSupportedException; the SP-factory overload). Also notes that hard-deleting the physical database is out of scope — SQL Server needs ALTER DATABASE ... SET SINGLE_USER WITH ROLLBACK IMMEDIATE before DROP DATABASE, and that is the consumer's job (tracked on the CritterWatch side).

🤖 Generated with Claude Code

Polecat's MasterTableTenancy already had the full dynamic-tenancy behavior but did
not *declare* JasperFx.MultiTenancy.IDynamicTenantSource<string> — the abstraction
store-agnostic admin tooling (CritterWatch) resolves through. Polecat-backed
services therefore showed their tenants read-only: no runtime add / disable /
enable / remove.

Mirroring Marten's MasterTableTenancy:

* MasterTableTenancy : ITenancy, IDynamicTenantSource<string>, reporting
  Cardinality == DatabaseCardinality.DynamicMultiple. Cardinality is now a public
  member so it satisfies both ITenancy and ITenantedSource<string>.
* The interface members are explicit implementations that adapt to the existing
  cancellable Polecat-native methods (AddDatabaseRecordAsync,
  DeleteDatabaseRecordAsync, Disable/EnableTenantAsync, AllDisabledAsync), which
  carry an optional CancellationToken and so cannot implement the token-less
  interface signatures implicitly.
* FindAsync throws UnknownTenantIdException for an unknown *or* disabled tenant.
  AllActive() returns the tenant database identifiers rather than raw connection
  strings so credentials never reach an admin dashboard — same choice Marten makes.
* The jasperfx#413 auto-assign overload keeps its default NotSupportedException:
  database-per-tenant has no pool to assign from.

DI registration, mirroring MartenServiceCollectionExtensions: AddPolecat registers
the tenancy as IDynamicTenantSource<string>, but only when the configured tenancy
is a dynamic source. Non-dynamic stores must keep GetServices empty — that is the
signal consumers use to fall back to a read-only tenant list. To make the probe
possible while the container is still being assembled, AddPolecat(Action<StoreOptions>)
now builds the StoreOptions eagerly and delegates to AddPolecat(StoreOptions),
exactly as Marten's AddMarten(Action<StoreOptions>) does. The
AddPolecat(Func<IServiceProvider, StoreOptions>) overload cannot probe and is
documented accordingly.

Also bumps the package version to 5.8.0 and documents the abstraction in
docs/configuration/multitenancy.md.

Closes #377

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 518e297 into main Jul 30, 2026
7 checks passed
@jeremydmiller
jeremydmiller deleted the feature/377-dynamic-tenant-source branch July 30, 2026 01:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MasterTableTenancy should implement IDynamicTenantSource<string> (CritterWatch runtime tenant management parity)

1 participant