@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -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.