Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .c8rc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
"reporter": ["text-summary", "lcov"],
"report-dir": "./coverage",
"clean": true,
"check-coverage": false,
"lines": 0,
"branches": 0,
"functions": 0,
"statements": 0
"check-coverage": true,
"lines": 30,
"branches": 45,
"functions": 45,
"statements": 30
}
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,58 @@ This project adheres to [Semantic Versioning](https://semver.org/).

---

## [0.2.2] — 2026-06-01

Feature and hardening release. Adds a dependency-resilience degradation
policy and a billing webhook replay fallback, closes several edge-case
failure modes found in a second audit, and activates the coverage gate.
No breaking changes: every new behavior is off by default or preserves the
prior contract. Now 555 unit + 358 integration + 123 e2e.

### Added

- **Dependency resilience policy**. `ResilienceService.run()` is one typed,
observable contract for what happens when a backing dependency (Redis,
Postgres, Stripe) is unavailable: `fail-open` returns a fallback,
`fail-closed` throws `DependencyUnavailableException` (503 + `Retry-After`).
Configure it per dependency under `config.resilience`, and every
degradation can emit a `DependencyDegraded` event plus an OpenTelemetry
span event for alerting. Adopted in `QuotaService` and
`RateLimitMiddleware`.
- **Billing replay past Stripe's retrieval window**. When Stripe reports an
event is gone (`resource_missing`), `BillingService.retrieveEvent()`
reconstructs it from a PII-free, structurally-faithful copy the webhook
controller persists in `stripe_processed_events.payload`
(`toReplayablePayload`), so `tenant:billing:replay` works on events older
than Stripe's ~30-day window.
- **Reference docs**. New Configuration, Exceptions, Troubleshooting, and
Resilience pages on the docs site.

### Fixed

- **Circuit breaker state survives a restart**. Persisted OPEN state is now
restored from Redis on process start, so a known-down tenant DB fails fast
across a deploy instead of being probed back to life.
- **Unified Redis-outage handling in quotas**. `QuotaService.consume/track`
route through the resilience policy, ending the silent `return 0` and the
raw ioredis throw on a Redis outage.
- **Smaller correctness and hardening fixes**: `SchemaPgDriver` logs evicted
connection-release failures instead of swallowing them;
`assertSafeIdentifier` guards the backup/restore schema name; dead cache
key removed from the feature-flag service; `SqlImportService` lazy-loads
its logger so it is unit-testable; `reportUsage` idempotency-key JSDoc
corrected.

### Changed

- **Coverage gate is live**. `check-coverage` is enforced on the unit run
(`test:coverage`). The integration coverage run is report-only because it
executes the compiled `build/` and c8 does not attribute that execution
back to `src`.
- Test suite grew to 555 unit + 358 integration + 123 e2e.

---

## [0.2.1] — 2026-05-17

Hardening release. Three production-affecting bug fixes uncovered by
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ flags, metrics, and Stripe billing.
[![AdonisJS](https://img.shields.io/badge/AdonisJS-7-5a45ff)](https://adonisjs.com)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-%E2%89%A514-336791)](https://www.postgresql.org)
[![Redis](https://img.shields.io/badge/Redis-%E2%89%A56-DC382D)](https://redis.io)
[![Tests](https://img.shields.io/badge/tests-505%20unit%20%2B%20355%20integration%20%2B%20123%20e2e-brightgreen)](./tests)
[![Tests](https://img.shields.io/badge/tests-555%20unit%20%2B%20358%20integration%20%2B%20123%20e2e-brightgreen)](./tests)
[![Docs](https://img.shields.io/badge/docs-published-C26A4B)](https://arcoders.github.io/Adonisjs-lasagna-saas-tenancy/)
[![License](https://img.shields.io/badge/License-MIT-blue)](./LICENSE)

Expand All @@ -45,8 +45,9 @@ and runs 123 tests against it.
| Feature | What it gives you |
|---|---|
| **Schema isolation** | Each tenant gets its own `tenant_<uuid>` PostgreSQL schema, provisioned and routed automatically. |
| **Circuit breaker** | Opossum wraps every tenant DB call. One bad schema can't take down the others. |
| **Lifecycle hooks + 23 typed events** | Declarative `before` / `after` hooks wired into commands and jobs. 13 tenant-lifecycle events + 10 billing events. |
| **Circuit breaker** | Opossum wraps every tenant DB call; OPEN state is restored from Redis on restart so a known-down tenant DB fails fast across deploys. One bad schema can't take down the others. |
| **Dependency resilience** | Per-dependency fail-open/fail-closed degradation policy via `ResilienceService`. Emits `DependencyDegraded` for alerting and returns a typed 503 (`DependencyUnavailableException`) when fail-closed. |
| **Lifecycle hooks + 25 typed events** | Declarative `before` / `after` hooks wired into commands and jobs. 14 tenant/quota-lifecycle + 10 billing + 1 resilience event. |
| **Contextual logging** | `tenantId` rides along through HTTP and queue jobs via `AsyncLocalStorage`. |
| **`tenant:doctor`** | Ten built-in checks, `--fix` for auto-recovery, `--json` for CI, `--watch` for a live TUI. |
| **Plans and quotas** | Declarative plans, rolling counters, snapshot usage, an `enforceQuota()` middleware that returns 429 and emits `TenantQuotaExceeded`. |
Expand Down
15 changes: 14 additions & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,14 @@ export default defineConfig({
{ text: 'Background jobs', link: '/docs/jobs' },
{ text: 'Health & metrics', link: '/docs/health' },
{ text: 'Read replicas', link: '/docs/read-replicas' },
{ text: 'Resilience', link: '/docs/resilience' },
{ text: 'Contextual logging', link: '/docs/contextual-logging' },
{ text: 'Routing', link: '/docs/routing' },
{ text: 'Testing', link: '/docs/testing' },
{ text: 'Admin REST API', link: '/docs/admin-rest-api' },
{ text: 'Configuration', link: '/docs/configuration' },
{ text: 'Exceptions', link: '/docs/exceptions' },
{ text: 'Troubleshooting', link: '/docs/gotchas' },
{ text: 'Deployment', link: '/docs/deployment' },
{ text: 'Cookbook', link: '/docs/cookbook/' },
{ text: 'Comparison vs stancl', link: '/docs/comparison' },
Expand All @@ -91,7 +95,7 @@ export default defineConfig({
{ text: 'Showcase', link: '/showcase' },
{ text: 'Sponsor', link: '/sponsor' },
{
text: 'v0.2.1',
text: 'v0.2.2',
items: [
{ text: 'Changelog', link: `${REPO}/blob/master/CHANGELOG.md` },
{ text: 'Release notes', link: '/docs/release-notes' },
Expand Down Expand Up @@ -165,6 +169,7 @@ export default defineConfig({
{ text: 'Background jobs', link: '/docs/jobs' },
{ text: 'Health & metrics', link: '/docs/health' },
{ text: 'Read replicas', link: '/docs/read-replicas' },
{ text: 'Resilience', link: '/docs/resilience' },
{ text: 'Contextual logging', link: '/docs/contextual-logging' },
{ text: 'Testing', link: '/docs/testing' },
{ text: 'Admin REST API', link: '/docs/admin-rest-api' },
Expand All @@ -188,6 +193,14 @@ export default defineConfig({
},
],
},
{
text: 'API Reference',
items: [
{ text: 'Configuration', link: '/docs/configuration' },
{ text: 'Exceptions', link: '/docs/exceptions' },
{ text: 'Troubleshooting', link: '/docs/gotchas' },
],
},
{
text: 'Reference',
items: [
Expand Down
2 changes: 1 addition & 1 deletion docs/.vitepress/theme/components/HomeLayered.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const pillars: Pillar[] = [
<section class="hl-hero">
<div class="hl-hero__copy">
<p class="hl-eyebrow">
<PhStar :size="14" weight="fill" /> AdonisJS · v0.2.1
<PhStar :size="14" weight="fill" /> AdonisJS · v0.2.2
</p>
<h1 class="hl-title">
The only multi-tenant layer
Expand Down
2 changes: 1 addition & 1 deletion docs/data/comparison.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "comparison.schema",
"package": "@adonisjs-lasagna/saas-tenancy",
"version": "0.2.1",
"version": "0.2.2",
"compared": "stancl/tenancy v3 (Tenancy for Laravel)",
"categories": [
{ "id": "identification", "label": "Tenant identification" },
Expand Down
174 changes: 174 additions & 0 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
---
title: Configuration reference
description: Every config/multitenancy.ts option, with its type, default, and meaning, in one place. Covers the resilience degradation policy, isolation, circuit breaker, quotas, backups, billing, replicas, and doctor thresholds.
---

# Configuration reference

All configuration lives in `config/multitenancy.ts`, wrapped in `defineConfig()`
so TypeScript checks the shape at build time. The stub the installer copies is a
good starting point; this page is the exhaustive reference.

```ts
import { defineConfig } from '@adonisjs-lasagna/saas-tenancy'

export default defineConfig({
// …see the sections below
})
```

::: tip
`getConfig()` throws until `MultitenancyProvider` has booted. That's the
intended guard. Read config at request or job time, not at module top-level.
:::

## Core

| Key | Type | Default | Meaning |
|---|---|---|---|
| `backofficeSchemaName` | `string` | | PG schema holding shared/satellite data. |
| `backofficeConnectionName` | `string` | | Lucid connection used for the backoffice schema. |
| `centralSchemaName` | `string` | | Schema for central/global (non-tenant) tables. |
| `centralConnectionName` | `string` | | Lucid connection for the central schema. |
| `tenantConnectionNamePrefix` | `string` | | Prefix for per-tenant Lucid connection names (`<prefix><tenantId>`). |
| `tenantSchemaPrefix` | `string` | | Prefix for per-tenant schema names (`<prefix><tenantId>`). |
| `schemaCacheTtl` | `number` | | TTL (seconds) for cached schema-existence probes. |
| `ignorePaths` | `string[]` | | Request paths that skip tenant resolution (health checks, the Stripe webhook, and so on). |

## Tenant resolution

| Key | Type | Default | Meaning |
|---|---|---|---|
| `resolverStrategy` | `'subdomain' \| 'header' \| 'path' \| 'domain-or-subdomain' \| 'request-data'` | | How the tenant id is read from the request. |
| `resolverChain` | `string[]` | | Ordered resolver names; first hit wins. **Overrides** `resolverStrategy`. |
| `tenantHeaderKey` | `string` | | Header name read by the `header` resolver. |
| `baseDomain` | `string` | | Apex domain used to parse subdomains. |
| `requestData.queryKey` | `string` | `'tenant_id'` | Query-string key for the `request-data` resolver. |
| `requestData.bodyKey` | `string` | `'tenant_id'` | Body key for the `request-data` resolver. |

::: warning Always resolve via the helper
Never read the tenant header directly. Call `resolveTenantId(request)`, which
honours `resolverStrategy` and `resolverChain` so a strategy change doesn't
silently bypass your code. See [Troubleshooting](./gotchas).
:::

## Isolation

```ts
isolation: { driver: 'schema-pg' }
```

| Key | Type | Default | Meaning |
|---|---|---|---|
| `isolation.driver` | `'schema-pg' \| 'database-pg' \| 'rowscope-pg' \| 'sqlite-memory'` | `'schema-pg'` | Isolation strategy. |
| `isolation.templateConnectionName` | `string` | `'tenant'` | Connection whose config is cloned per tenant (`schema-pg`/`database-pg`). |
| `isolation.tenantDatabasePrefix` | `string` | `'tenant_'` | Per-tenant database name prefix (`database-pg`). |
| `isolation.rowScopeTables` | `string[]` | | Tenant-scoped tables (`rowscope-pg`) for `destroy`/`reset`. |
| `isolation.rowScopeColumn` | `string` | `'tenant_id'` | Tenant id column (`rowscope-pg`). |
| `isolation.rowScopeMode` | `'strict' \| 'allowGlobal'` | `'strict'` | `strict` throws on an unscoped query outside `tenancy.run()`. This is the safe default. |

## Resilience (degradation policy)

Decides, per backing dependency, whether an outage fails **open** (skip the
check, stay available) or **closed** (return `503`). Consumed by
`ResilienceService`; emits a `DependencyDegraded` event on every degradation.

```ts
resilience: {
redis: { quota: 'fail-open', rateLimit: 'fail-closed' },
observe: true,
}
```

| Key | Type | Default | Meaning |
|---|---|---|---|
| `resilience.defaultPolicy` | `'fail-open' \| 'fail-closed'` | `'fail-closed'` | Fallback policy for anything not overridden. |
| `resilience.redis.quota` | `'fail-open' \| 'fail-closed'` | `'fail-open'` | `QuotaService.consume/track` on a Redis outage. Fail-open returns `0` (no enforcement); fail-closed throws `DependencyUnavailableException`. |
| `resilience.redis.rateLimit` | `'fail-open' \| 'fail-closed'` | `'fail-closed'` | `RateLimitMiddleware` (the per-route `failOpen` option still wins where set). |
| `resilience.redis.cache` | `'fail-open' \| 'fail-closed'` | `'fail-open'` | Cache bootstrapper. |
| `resilience.redis.metrics` | `'fail-open' \| 'fail-closed'` | `'fail-open'` | `MetricsService` counters. |
| `resilience.observe` | `boolean` | `true` | Emit `DependencyDegraded` + log + OTel span event on degradation. |

::: warning Fail-open is silent enforcement loss
`fail-open` for quotas means a Redis outage stops enforcing limits. That's the
right default for availability, but subscribe to `DependencyDegraded` so you
**know** it's happening. Choose `fail-closed` where correctness beats uptime.
:::

## Circuit breaker

| Key | Type | Meaning |
|---|---|---|
| `circuitBreaker.threshold` | `number` | Error-percentage threshold to open. |
| `circuitBreaker.resetTimeout` | `number` | ms in OPEN before probing (HALF_OPEN). |
| `circuitBreaker.rollingCountTimeout` | `number` | ms window for the rolling error stats. |
| `circuitBreaker.volumeThreshold` | `number` | Minimum requests in the window before the breaker can trip. |

Open/closed state is persisted to Redis and **restored on restart** so a
known-down tenant DB isn't hammered with timeouts after a deploy.

## Queue, cache, backup

| Key | Type | Default | Meaning |
|---|---|---|---|
| `queue.tenantQueuePrefix` | `string` | | BullMQ queue-name prefix per tenant. |
| `queue.defaultConcurrency` | `number` | | Default worker concurrency. |
| `queue.attempts` | `number` | | Default job retry attempts. |
| `queue.redis` | `{ host, port, username?, password?, db? }` | | Dedicated Redis for queues (separate DB from `cache.redis`). |
| `cache.ttl` | `number` | | Default cache TTL (seconds). |
| `cache.redis` | `{ host, port, username?, password?, db? }` | | Dedicated Redis for the cache. |
| `backup.storagePath` | `string` | | Local dir for `.dump` archives + `backup.json` sidecar. |
| `backup.metadataTtl` | `number` | | TTL (seconds) for backup metadata in Redis. |
| `backup.pgConnection` | `{ host, port, user, password, database }` | | Connection used by `pg_dump`/`pg_restore`/`psql`. |
| `backup.s3` | `{ enabled, bucket, region, endpoint?, accessKeyId, secretAccessKey }` | | Optional S3 offload (peer dep `@aws-sdk/client-s3`). |
| `backup.retention` | `BackupRetentionConfig` | | Tiered retention (`tiers`, `defaultTier`, `getTier`). |

## Plans & billing

| Key | Type | Default | Meaning |
|---|---|---|---|
| `plans.defaultPlan` | `string` | | Plan applied when nothing else resolves. |
| `plans.definitions` | `Record<string, { limits: Record<string, number> }>` | | Named plans and their quota limits. |
| `plans.getPlan` | `(tenant) => string \| undefined` | | Host callback to resolve a tenant's plan. |
| `plans.storage` | `'config-only' \| 'tenant_plans' \| 'auto'` | `'auto'` | Where the tenant→plan assignment lives. |
| `plans.emitTracked` | `boolean` | `false` | Emit `QuotaTracked` on every `track`/`consume` (enables the Stripe metering bridge). |
| `billing` | `BillingConfig` | | Stripe satellite. See the [Billing](./satellites/billing) page for the full block. |

## Impersonation, maintenance, soft delete

| Key | Type | Default | Meaning |
|---|---|---|---|
| `impersonation.secret` | `string` | | HMAC secret (≥ 32 chars). Without it, `start()` throws. |
| `impersonation.defaultDuration` | `number` | `3600` | Session length (seconds, min 60). |
| `impersonation.maxDuration` | `number` | `86400` | Hard upper bound (seconds). |
| `impersonation.headerName` | `string` | `x-impersonation-token` | Header read by the middleware. |
| `impersonation.cookieName` | `string` | `__impersonation` | Cookie fallback name. |
| `maintenance.defaultMessage` | `string` | | Default body for `TenantMaintenanceException`. |
| `maintenance.retryAfterSeconds` | `number` | `600` | `Retry-After` on the 503. |
| `maintenance.bypassToken` / `bypassHeader` | `string` | `x-tenant-bypass-maintenance` | Shared-secret bypass. Rotate often. |
| `softDelete.retentionDays` | `number` | `30` | Days a soft-deleted tenant's schema survives before `tenant:purge-expired` drops it. |

## Doctor thresholds

`doctor` overrides the built-in `tenant:doctor` check thresholds (all optional):
`queueStalledMinutes` (10), `replicaLagWarnSeconds` (30), `replicaLagErrorSeconds`
(120), `longQueryWarnSeconds` (30), `longQueryErrorSeconds` (120),
`poolSaturationWarnRatio` (0.9).

## Read replicas

```ts
tenantReadReplicas: { hosts: [{ host: 'replica-1' }], strategy: 'sticky' }
```

| Key | Type | Default | Meaning |
|---|---|---|---|
| `tenantReadReplicas.hosts` | `{ host, port?, user?, password?, name? }[]` | | Pool of read replicas. |
| `tenantReadReplicas.strategy` | `'round-robin' \| 'random' \| 'sticky'` | `'round-robin'` | How a replica is chosen per request. |
| `tenantReadReplicas.connectionSuffix` | `string` | `'_read'` | Suffix for the registered replica connection name. |

::: warning No automatic lag failover
Replica selection does **not** check lag or health. Reads can be stale, and a
down replica isn't auto-skipped. Route latency-sensitive reads to the primary,
or add your own health gate. See [Troubleshooting](./gotchas).
:::
Loading
Loading