Initial Commit
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# `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).
|
||||
Reference in New Issue
Block a user