@zerotal/arch 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/tenancy.md
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Multi-Tenancy
|
|
3
|
+
description: Resolve the active tenant from each request and isolate its data, files, and cache automatically across the call stack.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Multi-Tenancy
|
|
7
|
+
|
|
8
|
+
`@zerotal/tenancy` gives Zerotal applications first-class multi-tenancy. It resolves the active tenant from every incoming request and makes that tenant available everywhere in the call stack — ORM queries, storage paths, and cache keys — without any manual thread-through.
|
|
9
|
+
|
|
10
|
+
## Choosing a strategy
|
|
11
|
+
|
|
12
|
+
Two isolation strategies are supported. Pick by how strongly tenant data must be separated.
|
|
13
|
+
|
|
14
|
+
- **`single-database` (default)** — every tenant-owned table has a `tenant_id` column. The `Tenantable` mixin appends `WHERE tenant_id = ?` to every query. Simplest to operate; the right choice for most SaaS apps.
|
|
15
|
+
- **`multi-database`** — each tenant gets its own database connection, opened by a `connect` factory you supply. Strongest isolation; suited to enterprise customers with hard data-separation requirements.
|
|
16
|
+
|
|
17
|
+
| Strategy | How isolation works | When to use |
|
|
18
|
+
| ----------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
|
19
|
+
| `single-database` | `Tenantable` appends `WHERE tenant_id = ?` to every query on tenant-owned models. | Most SaaS apps; simpler to operate. |
|
|
20
|
+
| `multi-database` | Each tenant has its own connection; the ORM routes queries to it automatically inside the boundary. | Strong isolation; large enterprise customers. |
|
|
21
|
+
|
|
22
|
+
## Getting Started
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# in your project root
|
|
26
|
+
bun add @zerotal/tenancy
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package owns the `tenants` registry and the `tenant_members` pivot tables — they are provisioned automatically on boot. You do **not** define a tenant model or write a migration for them.
|
|
30
|
+
|
|
31
|
+
## Register the provider
|
|
32
|
+
|
|
33
|
+
Supply the config with `TenancyProvider.withConfig(...)`, then add the provider to the array in `bootstrap/providers.ts`:
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// bootstrap/providers.ts
|
|
37
|
+
import { TenancyProvider } from "@zerotal/tenancy";
|
|
38
|
+
import tenancyConfig from "../config/tenancy.ts";
|
|
39
|
+
|
|
40
|
+
const providers = [
|
|
41
|
+
// …your other providers
|
|
42
|
+
TenancyProvider.withConfig(tenancyConfig),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export default providers;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Registering the provider switches on the following hooks (in lifecycle order):
|
|
49
|
+
|
|
50
|
+
- `onRegister` — binds the `Tenancy` service as a singleton under the `"tenancy"` key (the engine behind the `Tenant` facade) and registers the schema concern that provisions the `tenants` and `tenant_members` tables on boot.
|
|
51
|
+
- `onBooted` — configures `TenancyMiddleware` with the resolved config, wires every `Tenantable` model into the live ORM context, and (for `multi-database`) registers the per-tenant connection resolver.
|
|
52
|
+
|
|
53
|
+
> **Warning** — `withConfig(...)` must be called before the provider is registered, or `onRegister` throws `TenancyNotConfiguredError`. Calling `TenancyMiddleware` before the provider has booted throws the same error.
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
Create `config/tenancy.ts`. Use the `TenancyConfig()` helper so every field stays type-checked while defaults are filled in:
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
// config/tenancy.ts
|
|
61
|
+
import { TenancyConfig, SubdomainResolver, HeaderResolver } from "@zerotal/tenancy";
|
|
62
|
+
import { env } from "zerotal";
|
|
63
|
+
|
|
64
|
+
export default TenancyConfig({
|
|
65
|
+
strategy: env("TENANCY_STRATEGY", "single-database"), // 'single-database' | 'multi-database'
|
|
66
|
+
tenantColumn: "tenant_id", // FK column appended by Tenantable
|
|
67
|
+
|
|
68
|
+
resolvers: [
|
|
69
|
+
new SubdomainResolver("myapp.com"),
|
|
70
|
+
// Fallback: accept an X-Tenant-ID header for API clients
|
|
71
|
+
new HeaderResolver("X-Tenant-ID"),
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Field | Required | Default | Description |
|
|
77
|
+
| -------------- | ------------------- | ------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
78
|
+
| `resolvers` | yes | — | Resolvers tried in order until one returns a non-null identifier. All-null means a 404. |
|
|
79
|
+
| `strategy` | no | `"single-database"` | `"single-database"` or `"multi-database"`. |
|
|
80
|
+
| `tenantColumn` | no | `"tenant_id"` | FK column appended by `Tenantable` in the single-database strategy. |
|
|
81
|
+
| `connect` | multi-database only | — | `(tenant: MultiDbTenant) => SQLInstance` factory opening each tenant's connection (pooled per tenant). |
|
|
82
|
+
|
|
83
|
+
> **Note** — The tenant registry is owned by the package. There is no `findTenant` callback and no app-level tenant model to wire up — reach tenants through the `Tenant` facade instead.
|
|
84
|
+
|
|
85
|
+
## Tenant resolvers
|
|
86
|
+
|
|
87
|
+
Resolvers are tried in order. The first one that returns a result wins.
|
|
88
|
+
|
|
89
|
+
### SubdomainResolver
|
|
90
|
+
|
|
91
|
+
Extracts the tenant slug from the leftmost subdomain.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// config/tenancy.ts
|
|
95
|
+
import { SubdomainResolver } from "@zerotal/tenancy";
|
|
96
|
+
|
|
97
|
+
new SubdomainResolver("myapp.com");
|
|
98
|
+
// acme.myapp.com → slug: 'acme'
|
|
99
|
+
// www.acme.myapp.com → slug: 'acme'
|
|
100
|
+
// myapp.com → null (no subdomain)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### HeaderResolver
|
|
104
|
+
|
|
105
|
+
Reads a custom request header — useful for API clients that can't use subdomains.
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
// config/tenancy.ts
|
|
109
|
+
import { HeaderResolver } from "@zerotal/tenancy";
|
|
110
|
+
|
|
111
|
+
new HeaderResolver("X-Tenant-ID");
|
|
112
|
+
// Request: GET /api/users X-Tenant-ID: acme
|
|
113
|
+
// → slug: 'acme'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### PathResolver
|
|
117
|
+
|
|
118
|
+
Extracts the tenant slug from a URL path segment.
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
// config/tenancy.ts
|
|
122
|
+
import { PathResolver } from "@zerotal/tenancy";
|
|
123
|
+
|
|
124
|
+
new PathResolver({ segment: 0 });
|
|
125
|
+
// GET /acme/dashboard → slug: 'acme'
|
|
126
|
+
|
|
127
|
+
new PathResolver({ prefix: "/tenants", segment: 0 });
|
|
128
|
+
// GET /tenants/acme/settings → slug: 'acme' (prefix stripped, then segment 0)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### RouteParamResolver
|
|
132
|
+
|
|
133
|
+
Reads the slug from a named route parameter. Pair it with a `/:tenancy` group
|
|
134
|
+
prefix — the router captures the segment into `http.params` before middleware
|
|
135
|
+
runs, so the resolver just reads it:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
// config/tenancy.ts
|
|
139
|
+
import { RouteParamResolver } from "@zerotal/tenancy";
|
|
140
|
+
|
|
141
|
+
new RouteParamResolver({ param: "tenancy" }); // default param name
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// bootstrap/app.ts
|
|
146
|
+
.fileBasedRouting({
|
|
147
|
+
dir: basePath("app/routes"),
|
|
148
|
+
prefix: "/:tenancy", // GET /acme/dashboard → params.tenancy = "acme"
|
|
149
|
+
middleware: [TenancyMiddleware],
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Use this instead of `PathResolver` when the tenant segment is part of your route
|
|
154
|
+
definitions rather than a fixed position in the URL — it survives a prefix change
|
|
155
|
+
that would break a positional lookup.
|
|
156
|
+
|
|
157
|
+
### AuthResolver
|
|
158
|
+
|
|
159
|
+
Resolves the tenant from the **signed-in user** rather than the URL. The user
|
|
160
|
+
carries its tenant foreign key (`tenantId` by default), and the resolver reads it
|
|
161
|
+
off `http.user`:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
// config/tenancy.ts
|
|
165
|
+
import { RouteParamResolver, AuthResolver } from "@zerotal/tenancy";
|
|
166
|
+
|
|
167
|
+
resolvers: [
|
|
168
|
+
new RouteParamResolver({ param: "tenancy" }),
|
|
169
|
+
new AuthResolver(), // falls back to the logged-in user's tenantId
|
|
170
|
+
];
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Order matters: put it **after** the URL-based resolvers so an explicit slug in
|
|
174
|
+
the path still wins, with the user as the fallback for tenant-scoped routes that
|
|
175
|
+
carry no slug.
|
|
176
|
+
|
|
177
|
+
Requests with no authenticated user — login, signup, a public marketing page —
|
|
178
|
+
resolve to `null` here, and with `strict: false` (the default) fall through to
|
|
179
|
+
the default connection rather than failing.
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// config/tenancy.ts — a non-default column
|
|
183
|
+
new AuthResolver({ column: "organisationId" });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
> **Note** — `AuthResolver` reads `http.user` rather than importing
|
|
187
|
+
> `@zerotal/auth`, so tenancy stays decoupled from the auth package. Anything
|
|
188
|
+
> that populates `http.user` works, including your own middleware.
|
|
189
|
+
|
|
190
|
+
### Custom resolver
|
|
191
|
+
|
|
192
|
+
Implement the `TenantResolver` interface — return a `{ identifier }` result, or `null` to defer to the next resolver:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// app/tenancy/CookieResolver.ts
|
|
196
|
+
import type { TenantResolver, TenantResolverResult } from "@zerotal/tenancy";
|
|
197
|
+
|
|
198
|
+
export class CookieResolver implements TenantResolver {
|
|
199
|
+
resolve(request: Request): TenantResolverResult | null {
|
|
200
|
+
const cookie = request.headers.get("cookie") ?? "";
|
|
201
|
+
const match = cookie.match(/tenant=([^;]+)/);
|
|
202
|
+
return match ? { identifier: match[1]! } : null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Registering TenancyMiddleware
|
|
208
|
+
|
|
209
|
+
`TenancyMiddleware` resolves the tenant and opens the [TenantContext](#tenantcontext-access-the-tenant-anywhere) boundary. Apply it globally or to specific route groups.
|
|
210
|
+
|
|
211
|
+
### Global
|
|
212
|
+
|
|
213
|
+
With `TenancyProvider` already registered, add the middleware to the global pipeline with `.use()` in `bootstrap/app.ts`:
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
// bootstrap/app.ts
|
|
217
|
+
import { TenancyMiddleware } from "@zerotal/tenancy";
|
|
218
|
+
|
|
219
|
+
Application.create({ providers }).use([TenancyMiddleware]);
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Route group only
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// routes/web.ts
|
|
226
|
+
import { TenancyMiddleware } from "@zerotal/tenancy";
|
|
227
|
+
|
|
228
|
+
Router.group({ domain: "{tenant}.myapp.com" }, () => {
|
|
229
|
+
Router.get("/dashboard", DashboardController, "index");
|
|
230
|
+
Router.get("/settings", SettingsController, "index");
|
|
231
|
+
}).use([TenancyMiddleware]);
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The middleware throws an [error](/docs/errors) the framework's exception handler renders by content negotiation:
|
|
235
|
+
|
|
236
|
+
| Situation | Error | Status | Message |
|
|
237
|
+
| -------------------------- | --------------------------- | ------ | --------------------------------- |
|
|
238
|
+
| No resolver matched | `TenantNotFoundError` | 404 | `This account doesn’t exist.` |
|
|
239
|
+
| Slug not in the registry | `TenantNotFoundError` | 404 | `This account doesn’t exist.` |
|
|
240
|
+
| `isActive` is false | `TenantInactiveError` | 403 | `This account has been disabled.` |
|
|
241
|
+
| Middleware ran before boot | `TenancyNotConfiguredError` | 500 | configuration error |
|
|
242
|
+
|
|
243
|
+
### Requiring a tenant on a route
|
|
244
|
+
|
|
245
|
+
`TenancyMiddleware` _resolves_ a tenant; `EnsureTenancyMiddleware` _insists_ there is
|
|
246
|
+
one. Use it on routes that make no sense without a tenant — it throws
|
|
247
|
+
`TenantNotFoundError` when the context is empty rather than letting a query run
|
|
248
|
+
unscoped:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
// routes/web.ts
|
|
252
|
+
import { TenancyMiddleware, EnsureTenancyMiddleware } from "@zerotal/tenancy";
|
|
253
|
+
|
|
254
|
+
Router.group({ middleware: [TenancyMiddleware, EnsureTenancyMiddleware] }, () => {
|
|
255
|
+
Router.get("/billing", BillingController, "index");
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Register it after `TenancyMiddleware`, never instead of it: on its own there is
|
|
260
|
+
nothing to resolve, so every request fails.
|
|
261
|
+
|
|
262
|
+
## ORM scoping — Tenantable
|
|
263
|
+
|
|
264
|
+
Compose the `Tenantable` mixin via [`Model.using`](/docs/orm/index#composing-model-mixins) onto any model that belongs to a tenant. Two things happen automatically:
|
|
265
|
+
|
|
266
|
+
1. Every query on the model receives `WHERE tenant_id = <current tenant id>` (skipped outside a tenant context).
|
|
267
|
+
2. `create()` / `save()` inject `tenant_id` on new records, so you never accidentally write cross-tenant data.
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
// app/models/Project.ts
|
|
271
|
+
import { Model, column, table } from "@zerotal/orm";
|
|
272
|
+
import { Tenantable } from "@zerotal/tenancy";
|
|
273
|
+
|
|
274
|
+
@(table("projects").withTimestamps())
|
|
275
|
+
export class Project extends Model.using(Tenantable) {
|
|
276
|
+
@column() name!: string;
|
|
277
|
+
@column() tenantId!: number;
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
// in a controller — inside a TenancyMiddleware boundary (tenant id = 7):
|
|
283
|
+
const projects = await Project.all();
|
|
284
|
+
// → SELECT * FROM projects WHERE tenant_id = 7
|
|
285
|
+
|
|
286
|
+
const project = await Project.create({ name: "Alpha" });
|
|
287
|
+
// → INSERT INTO projects (tenant_id, name) VALUES (7, 'Alpha')
|
|
288
|
+
|
|
289
|
+
await Project.where("name", "Alpha").delete();
|
|
290
|
+
// → DELETE FROM projects WHERE tenant_id = 7 AND name = 'Alpha'
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Custom column name
|
|
294
|
+
|
|
295
|
+
Override the static `tenantColumn` field (default `tenant_id`):
|
|
296
|
+
|
|
297
|
+
```typescript
|
|
298
|
+
// app/models/Invoice.ts
|
|
299
|
+
import { Model, column, table } from "@zerotal/orm";
|
|
300
|
+
import { Tenantable } from "@zerotal/tenancy";
|
|
301
|
+
|
|
302
|
+
@(table("invoices").withTimestamps())
|
|
303
|
+
export class Invoice extends Model.using(Tenantable) {
|
|
304
|
+
protected static tenantColumn = "organisation_id";
|
|
305
|
+
|
|
306
|
+
@column() amount!: number;
|
|
307
|
+
}
|
|
308
|
+
// → SELECT * FROM invoices WHERE organisation_id = 7
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Bypassing tenancy
|
|
312
|
+
|
|
313
|
+
### Single query
|
|
314
|
+
|
|
315
|
+
```typescript
|
|
316
|
+
// in an admin controller — load every project across all tenants:
|
|
317
|
+
const all = await Project.query().withoutTenancy().get();
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`withoutTenancy()` removes only the tenant scope. To strip every global scope instead:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
// in a controller
|
|
324
|
+
const all = await Project.query().withoutGlobalScopes().get();
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Entire background job
|
|
328
|
+
|
|
329
|
+
Wrap the work in a tenant boundary with `TenantContext.run()` (or `Tenant.run()` via the facade) to scope every query inside it to a specific tenant:
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
// app/jobs/SendReminders.ts
|
|
333
|
+
import { TenantContext, Tenant } from "@zerotal/tenancy";
|
|
334
|
+
|
|
335
|
+
const tenants = await Tenant.all();
|
|
336
|
+
|
|
337
|
+
for (const tenant of tenants) {
|
|
338
|
+
await TenantContext.run(tenant, async () => {
|
|
339
|
+
const overdue = await Invoice.where("due_date", "<", today).get();
|
|
340
|
+
// send reminders…
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## TenantContext — access the tenant anywhere
|
|
346
|
+
|
|
347
|
+
Once `TenancyMiddleware` has run, the active tenant is available anywhere in the async call chain:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
// anywhere downstream of TenancyMiddleware
|
|
351
|
+
import { TenantContext } from "@zerotal/tenancy";
|
|
352
|
+
|
|
353
|
+
// Throws NoActiveTenantError if called outside a tenant boundary:
|
|
354
|
+
const tenant = TenantContext.get();
|
|
355
|
+
|
|
356
|
+
// Safe version — returns undefined outside a boundary:
|
|
357
|
+
const maybeTenant = TenantContext.tryGet();
|
|
358
|
+
|
|
359
|
+
// Shortcuts:
|
|
360
|
+
const id = TenantContext.id(); // number | null
|
|
361
|
+
const slug = TenantContext.slug(); // string | null
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The `Tenant` facade exposes the same current-tenant accessors plus tenant CRUD and membership (see [References](#references)):
|
|
365
|
+
|
|
366
|
+
```typescript
|
|
367
|
+
// in a controller
|
|
368
|
+
import { Tenant } from "@zerotal/tenancy";
|
|
369
|
+
|
|
370
|
+
const tenant = Tenant.current(); // the active tenant, or null
|
|
371
|
+
if (Tenant.check()) {
|
|
372
|
+
/* there is an active, enabled tenant */
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Controllers also receive the resolved tenant directly on the request context:
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
// in a controller
|
|
380
|
+
async index(ctx: HttpContext): Promise<void> {
|
|
381
|
+
const tenant = (ctx as any).tenant as Tenant;
|
|
382
|
+
ctx.json({ tenantName: tenant.name });
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## Tenant membership
|
|
387
|
+
|
|
388
|
+
The package maintains a `tenant_members` pivot linking your authenticated [User](/docs/authentication) model to tenants, with an admin flag. `Tenancy.create()` makes the current user the first admin; `update()` and `delete()` are admin-gated (use the `force*` variants to bypass the check from trusted server code).
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
// in a controller
|
|
392
|
+
import { Tenant } from "@zerotal/tenancy";
|
|
393
|
+
|
|
394
|
+
await Tenant.create({ slug: "acme", name: "Acme Inc" }); // current user becomes admin
|
|
395
|
+
await Tenant.addMember(userId, { admin: true });
|
|
396
|
+
const team = await Tenant.members(); // hydrated User models
|
|
397
|
+
await Tenant.update({ name: "Acme Corp" }); // throws TenantForbiddenError unless admin
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
> **Note** — Membership requires an authenticatable User model. Compose `Authenticatable` (from `@zerotal/auth`) on it, or `Tenancy` throws `TenancyConfigError` when a membership method runs.
|
|
401
|
+
|
|
402
|
+
## Tenant-scoped storage
|
|
403
|
+
|
|
404
|
+
`tenantDisk()` wraps any [storage](/docs/storage) disk and prefixes every path with `tenants/<slug>/` for the active tenant:
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
// in a controller — inside a tenant boundary (slug = 'acme')
|
|
408
|
+
import { tenantDisk } from "@zerotal/tenancy";
|
|
409
|
+
|
|
410
|
+
// Saves to: /storage/tenants/acme/avatars/alice.jpg
|
|
411
|
+
await tenantDisk().put("avatars/alice.jpg", buffer);
|
|
412
|
+
|
|
413
|
+
// Reads from: /storage/tenants/acme/avatars/alice.jpg
|
|
414
|
+
const file = await tenantDisk().get("avatars/alice.jpg");
|
|
415
|
+
|
|
416
|
+
// Use a named disk:
|
|
417
|
+
await tenantDisk("s3").put("reports/q4.pdf", pdf);
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
> **Warning** — `tenantDisk()` calls `TenantContext.get()` eagerly, so it throws `NoActiveTenantError` outside a tenant boundary. Requires `zerotal/storage` to be installed.
|
|
421
|
+
|
|
422
|
+
A key that tries to climb out of its tenant's folder is rejected with
|
|
423
|
+
`TenantStoragePathError` before it reaches the driver:
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
await tenantDisk().put("../other-tenant/secrets.txt", buffer);
|
|
427
|
+
// throws TenantStoragePathError
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The check lives at the prefixing layer deliberately. `LocalDriver` confines paths to
|
|
431
|
+
the _disk root_, not to the tenant directory, so a key containing `..` resolved to a
|
|
432
|
+
sibling tenant's folder and passed the driver's own guard — a cross-tenant read that
|
|
433
|
+
looked like a normal write. Catch it if you surface upload errors to users; otherwise
|
|
434
|
+
the exception handler renders it like any other.
|
|
435
|
+
|
|
436
|
+
## Tenant-scoped cache
|
|
437
|
+
|
|
438
|
+
`tenantCache()` wraps the [cache](/docs/cache) and prefixes every key with `tenant:<slug>:`:
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
// in a controller — inside a tenant boundary (slug = 'acme')
|
|
442
|
+
import { tenantCache } from "@zerotal/tenancy";
|
|
443
|
+
|
|
444
|
+
// Key stored as: tenant:acme:dashboard:stats
|
|
445
|
+
await tenantCache().set("dashboard:stats", data, 300);
|
|
446
|
+
const stats = await tenantCache().get<Stats>("dashboard:stats");
|
|
447
|
+
|
|
448
|
+
await tenantCache().forget("dashboard:stats");
|
|
449
|
+
|
|
450
|
+
// Use a named store:
|
|
451
|
+
const redisCache = tenantCache("redis");
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## Multi-database strategy
|
|
455
|
+
|
|
456
|
+
Set `strategy: "multi-database"` and supply a `connect` factory. `TenancyProvider` builds the per-tenant connection pool and registers an ORM connection resolver, so every model query inside a tenant boundary is routed to that tenant's database automatically — app models stay plain:
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
// config/tenancy.ts
|
|
460
|
+
import { TenancyConfig, SubdomainResolver } from "@zerotal/tenancy";
|
|
461
|
+
import { SQL } from "bun";
|
|
462
|
+
|
|
463
|
+
export default TenancyConfig({
|
|
464
|
+
strategy: "multi-database",
|
|
465
|
+
resolvers: [new SubdomainResolver("myapp.com")],
|
|
466
|
+
connect: (tenant) => new SQL(`file:./storage/tenants/${tenant.database}`),
|
|
467
|
+
});
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
// in a controller — Project.all() automatically uses the active tenant's connection:
|
|
472
|
+
const projects = await Project.all();
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
The `database` column on each tenant record names its connection target. You rarely touch `TenantManager` directly — it is exported only for raw, out-of-ORM access (a manual migration or bulk import):
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
// app/jobs/MigrateTenant.ts — raw connection for the active tenant:
|
|
479
|
+
import { TenantManager } from "@zerotal/tenancy";
|
|
480
|
+
import { SQL } from "bun";
|
|
481
|
+
|
|
482
|
+
const manager = new TenantManager({
|
|
483
|
+
connect: (tenant) => new SQL(`file:./storage/tenants/${tenant.database}`),
|
|
484
|
+
});
|
|
485
|
+
const conn = manager.connection(); // active tenant's SQLInstance
|
|
486
|
+
await conn`SELECT * FROM projects`;
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
> **Danger** — App models that belong to a tenant must be composed with `Tenantable`; the package-owned `TenantModel` is deliberately not, because the tenant registry lives in the central/platform database and bootstraps the tenant context rather than being scoped within one.
|
|
490
|
+
|
|
491
|
+
## Testing
|
|
492
|
+
|
|
493
|
+
Set your suite up once as described in [Testing](/docs/testing). Tenancy has one
|
|
494
|
+
test that matters more than all the others: **a tenant must not see another
|
|
495
|
+
tenant's rows.** Write it first, and write it as a negative.
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
// tests/tenancy/isolation.test.ts
|
|
499
|
+
import { test, expect } from "bun:test";
|
|
500
|
+
import { Tenancy, TenantContext } from "@zerotal/tenancy";
|
|
501
|
+
import { createApp } from "../helpers.ts";
|
|
502
|
+
import { Post } from "../../app/models/Post.ts";
|
|
503
|
+
|
|
504
|
+
test("a tenant cannot read another tenant's posts", async () => {
|
|
505
|
+
const app = await createApp();
|
|
506
|
+
const acme = await Tenancy.create({ slug: "acme", name: "Acme" });
|
|
507
|
+
const globex = await Tenancy.create({ slug: "globex", name: "Globex" });
|
|
508
|
+
|
|
509
|
+
await Tenancy.run(acme, () => Post.create({ title: "Acme secret" }));
|
|
510
|
+
|
|
511
|
+
const seen = await Tenancy.run(globex, () => Post.query().get());
|
|
512
|
+
|
|
513
|
+
expect(seen).toHaveLength(0); // the assertion the whole feature exists for
|
|
514
|
+
await app.close();
|
|
515
|
+
});
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
**Assert the count, not just the absence of a specific row.** `toHaveLength(0)`
|
|
519
|
+
fails loudly if scoping breaks; `expect(seen.find(...)).toBeUndefined()` passes
|
|
520
|
+
when the query returns everything but that one title.
|
|
521
|
+
|
|
522
|
+
**Test the resolver separately from the scoping.** They fail differently — a
|
|
523
|
+
broken resolver serves the wrong tenant's data correctly, which no isolation test
|
|
524
|
+
catches:
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
// tests/tenancy/resolver.test.ts
|
|
528
|
+
const res = await app.get("/dashboard", { Host: "acme.example.test" });
|
|
529
|
+
|
|
530
|
+
expect(res.json().tenant).toBe("acme");
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
**Leaking context between tests is the common failure.** A test that sets a
|
|
534
|
+
tenant and throws leaves it set, and the next test passes or fails for reasons
|
|
535
|
+
that have nothing to do with it:
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
// tests/tenancy/isolation.test.ts
|
|
539
|
+
afterEach(() => {
|
|
540
|
+
expect(TenantContext.tryGet()).toBeUndefined(); // catches a leak at its source
|
|
541
|
+
});
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
> **Warning** — Under the multi-database strategy every tenant a test touches
|
|
545
|
+
> opens a connection that stays open for the life of the process. On Windows an
|
|
546
|
+
> open handle is an exclusive lock, so a teardown that removes the data directory
|
|
547
|
+
> fails with `EBUSY` while every assertion passes. Close tenant connections in
|
|
548
|
+
> your teardown before deleting files.
|
|
549
|
+
|
|
550
|
+
## References
|
|
551
|
+
|
|
552
|
+
### Tenant facade
|
|
553
|
+
|
|
554
|
+
Resolved from the container binding `"tenancy"`; the `Tenant` value is the facade and the `Tenant` type is the tenant record shape.
|
|
555
|
+
|
|
556
|
+
| Method | Signature | Description |
|
|
557
|
+
| ----------------- | -------------------------------------------------- | ------------------------------------------------------- |
|
|
558
|
+
| `current` | `current(): Tenant \| null` | The active tenant, or null outside a boundary. |
|
|
559
|
+
| `check` | `check(): boolean` | True when there is an active, enabled tenant. |
|
|
560
|
+
| `id` | `id(): number \| null` | Active tenant's id. |
|
|
561
|
+
| `slug` | `slug(): string \| null` | Active tenant's slug. |
|
|
562
|
+
| `run` | `run<T>(tenant: Tenant, cb: () => T): T` | Run `cb` with `tenant` active. |
|
|
563
|
+
| `forId` | `forId<T>(id: number, cb): Promise<T>` | Load tenant by id and run `cb` in its context. |
|
|
564
|
+
| `find` | `find(slug: string): Promise<TenantModel \| null>` | Look up a tenant by slug. |
|
|
565
|
+
| `all` | `all(): Promise<TenantModel[]>` | Every tenant in the registry. |
|
|
566
|
+
| `create` | `create(data): Promise<TenantModel>` | Create a tenant; current user becomes admin. |
|
|
567
|
+
| `update` | `update(data): Promise<TenantModel>` | Update current tenant (admin-gated). |
|
|
568
|
+
| `delete` | `delete(): Promise<void>` | Delete current tenant + memberships (admin-gated). |
|
|
569
|
+
| `forceUpdate` | `forceUpdate(data): Promise<TenantModel>` | Update bypassing the admin check. |
|
|
570
|
+
| `forceDelete` | `forceDelete(): Promise<void>` | Delete bypassing the admin check. |
|
|
571
|
+
| `member` | `member<T>(): Promise<T \| null>` | Current user as a member, or null. |
|
|
572
|
+
| `members` | `members<T>(): Promise<T[]>` | All members, hydrated as User models. |
|
|
573
|
+
| `memberCount` | `memberCount(): Promise<number>` | Number of members in the current tenant. |
|
|
574
|
+
| `isMember` | `isMember(userId?): Promise<boolean>` | Whether a user belongs to the current tenant. |
|
|
575
|
+
| `isMemberAdmin` | `isMemberAdmin(userId?): Promise<boolean>` | Whether a user is an admin of the current tenant. |
|
|
576
|
+
| `addMember` | `addMember(userId, opts?): Promise<void>` | Add a member (idempotent); `{ admin: true }` for admin. |
|
|
577
|
+
| `removeMember` | `removeMember(userId): Promise<void>` | Remove a member. |
|
|
578
|
+
| `promote` | `promote(userId): Promise<void>` | Grant admin rights. |
|
|
579
|
+
| `demote` | `demote(userId): Promise<void>` | Revoke admin rights. |
|
|
580
|
+
| `onTenantDeleted` | `onTenantDeleted(hook): void` | Register a cleanup hook fired after deletion. |
|
|
581
|
+
|
|
582
|
+
### TenantContext
|
|
583
|
+
|
|
584
|
+
| Member | Signature | Description |
|
|
585
|
+
| -------- | ---------------------------------------- | ---------------------------------------------------- |
|
|
586
|
+
| `run` | `run<T>(tenant: Tenant, cb: () => T): T` | Execute `cb` inside a tenant boundary. |
|
|
587
|
+
| `get` | `get(): Tenant` | Active tenant; throws `NoActiveTenantError` if none. |
|
|
588
|
+
| `tryGet` | `tryGet(): Tenant \| undefined` | Active tenant, or undefined outside a boundary. |
|
|
589
|
+
| `id` | `id(): number \| null` | Active tenant's id. |
|
|
590
|
+
| `slug` | `slug(): string \| null` | Active tenant's slug. |
|
|
591
|
+
|
|
592
|
+
### TenantManager (multi-database)
|
|
593
|
+
|
|
594
|
+
| Method | Signature | Description |
|
|
595
|
+
| --------------- | ------------------------------------ | ------------------------------------------------- |
|
|
596
|
+
| `connection` | `connection(): SQLInstance` | Connection for the active tenant; throws if none. |
|
|
597
|
+
| `connectionFor` | `connectionFor(tenant): SQLInstance` | Connection for a specific tenant. |
|
|
598
|
+
| `warmUp` | `warmUp(tenant): void` | Pre-open a tenant's connection. |
|
|
599
|
+
| `evict` | `evict(tenant): void` | Close and drop a tenant's connection. |
|
|
600
|
+
| `closeAll` | `closeAll(): void` | Close every open connection. |
|
|
601
|
+
|
|
602
|
+
## Next steps
|
|
603
|
+
|
|
604
|
+
- [ORM](/docs/orm/index) — how `Tenantable` composes onto your models via `Model.using`.
|
|
605
|
+
- [Storage](/docs/storage) — the disks `tenantDisk()` wraps.
|
|
606
|
+
- [Cache](/docs/cache) — the stores `tenantCache()` wraps.
|
|
607
|
+
- [Middleware](/docs/middleware) — where `TenancyMiddleware` runs in the pipeline.
|
|
608
|
+
- [Authentication](/docs/authentication) — the User model that tenant membership links to.
|