Files
2026-05-27 23:29:53 +01:00

66 lines
3.3 KiB
Markdown

# `tenancy`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exports the `provisionTenant` and `decommissionTenant` callables that downstream hosts (typically `@suaveplan/api-plugin-tenant`) invoke on their own tenant-lifecycle events. The functions live as plain async exports rather than as descriptor hooks because the `PluginDescriptor` has no tenant-lifecycle hook slots — placing tenant logic on the plugin descriptor was the upstream's choice; suaveplan routes it through the host event bus instead.
Both functions accept any `DatabaseAdapter` so they remain unit-testable against an in-process double, and both validate `tenantId` against the strict `/^[A-Za-z0-9_-]{1,64}$/` pattern before issuing any query so a hostile or malformed id cannot land in a SurrealQL `DEFINE NAMESPACE` statement.
## Features
- Three strategies: `shared`, `namespace-per-tenant`, `database-per-tenant`.
- Strict `tenantId` validation; invalid input surfaces `DATABASE.TENANT_INVALID`.
- Wraps adapter failures as `DATABASE.QUERY_FAILED` with the strategy and tenant id in `context`.
- Idempotent: every DDL uses `IF NOT EXISTS` so repeated provision calls are safe; `REMOVE` and `DELETE` are idempotent on missing rows in SurrealDB.
## Usage: Basic
```ts
import { provisionTenant, decommissionTenant } from "@suaveplan/api-plugin-database/tenancy/tenancy";
await provisionTenant(db, { strategy: "namespace-per-tenant" }, "acme");
// ...later
await decommissionTenant(db, { strategy: "namespace-per-tenant" }, "acme");
```
## Usage: Advanced
```ts
import {
provisionTenant,
decommissionTenant,
} from "@suaveplan/api-plugin-database";
eventBus.on("tenant:created", async ({ tenantId }) => {
const db = ctx.getService("db");
if (db === undefined) return;
await provisionTenant(db, config.tenancy, tenantId);
});
eventBus.on("tenant:deleted", async ({ tenantId }) => {
const db = ctx.getService("db");
if (db === undefined) return;
await decommissionTenant(db, config.tenancy, tenantId);
});
```
## API Reference
### `provisionTenant(adapter, config, tenantId): Promise<void>`
Issues the strategy-specific `DEFINE …` query against `adapter`. Throws `DatabaseError(DATABASE.TENANT_INVALID)` on bad ids; rejects with `DatabaseError(DATABASE.QUERY_FAILED)` on adapter failures.
### `decommissionTenant(adapter, config, tenantId): Promise<void>`
Mirror of `provisionTenant` — issues the strategy-specific `REMOVE …` (or bounded `DELETE …`) query.
## Implementation Notes
- The functions issue queries through `adapter.execute(sql)` with no parameters, because SurrealDB's `DEFINE NAMESPACE` and `REMOVE NAMESPACE` do not accept bind variables for the namespace name. The tenant id is validated against the regex before being interpolated into the SQL — there is no path by which an unvalidated value reaches the wire.
- The `shared` strategy assumes a single `tenant_scoped` convention table; consumers that name their tenant table differently should re-implement the decommission flow with their own table list. Issuing a wildcard `DELETE FROM *` (as upstream did) is intentionally NOT supported because it would also wipe non-tenant rows.
- The functions are exported individually so callers can wire only the half they need (e.g. provision on signup, but decommission via a soft-delete background job).