@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
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Package Development
|
|
3
|
+
description: Build a first-party @zerotal package that auto-discovers, merges config, and passes the linter like a native one.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Package Development
|
|
7
|
+
|
|
8
|
+
Every first-party feature — ORM, auth, cache, mail, queues — is a package under
|
|
9
|
+
`packages/*`, and they all follow the same shape. That uniformity is what lets the
|
|
10
|
+
framework auto-discover providers, merge config, and wire conventions without any
|
|
11
|
+
per-package glue.
|
|
12
|
+
|
|
13
|
+
This page documents the patterns so your own packages feel like native ones, and
|
|
14
|
+
so the package linter passes. Scaffold a new package and you get the whole skeleton
|
|
15
|
+
for free:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# in your project root
|
|
19
|
+
bun zt make:package billing
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Anatomy of a package
|
|
23
|
+
|
|
24
|
+
A package is a small, conventional directory tree. The cache package is a good
|
|
25
|
+
reference:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
# packages/cache/
|
|
29
|
+
packages/cache/
|
|
30
|
+
├── package.json
|
|
31
|
+
└── src/
|
|
32
|
+
├── index.ts # public API barrel
|
|
33
|
+
├── config.ts # CacheConfig() factory + CacheConfigShape
|
|
34
|
+
├── errors.ts # typed errors extending ZerotalError
|
|
35
|
+
├── CacheManager.ts # the implementation
|
|
36
|
+
├── provider/
|
|
37
|
+
│ └── CacheProvider.ts # the ServiceProvider (must live here)
|
|
38
|
+
├── facades/
|
|
39
|
+
│ └── Cache.ts # optional static facade
|
|
40
|
+
├── drivers/ # implementation details
|
|
41
|
+
└── commands/
|
|
42
|
+
└── index.ts # CLI commands barrel
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Two locations are enforced by convention: **providers live at `src/provider/`**,
|
|
46
|
+
and **the config factory lives at `src/config.ts`**. The linter flags anything
|
|
47
|
+
else.
|
|
48
|
+
|
|
49
|
+
## package.json
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
// packages/cache/package.json
|
|
53
|
+
{
|
|
54
|
+
"name": "@zerotal/cache",
|
|
55
|
+
"version": "0.0.1",
|
|
56
|
+
"maturity": "stable",
|
|
57
|
+
"type": "module",
|
|
58
|
+
"main": "./src/index.ts",
|
|
59
|
+
"types": "./src/index.ts",
|
|
60
|
+
"exports": {
|
|
61
|
+
".": "./src/index.ts",
|
|
62
|
+
"./commands": "./src/commands/index.ts"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun --format esm",
|
|
66
|
+
"test": "bun test",
|
|
67
|
+
"typecheck": "tsc --noEmit"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"@zerotal/core": "workspace:*"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Required by the linter: `"type": "module"` and an `"exports"` (or `"main"`) entry.
|
|
76
|
+
Depend on other Zerotal packages with `workspace:*`. The `maturity` field
|
|
77
|
+
(`experimental` | `beta` | `stable`) declares the package's compatibility promise —
|
|
78
|
+
see [Support policy](/docs/support-policy#maturity-levels) for what each level
|
|
79
|
+
commits to, and state the same level in the package's README and CHANGELOG so it is
|
|
80
|
+
visible from npm. A package cannot be more mature than what it depends on. Subpath
|
|
81
|
+
exports like `./commands` keep CLI code out of the main bundle until it's needed.
|
|
82
|
+
|
|
83
|
+
## The public barrel
|
|
84
|
+
|
|
85
|
+
Everything a consumer should import is re-exported from `index.ts`. Keep
|
|
86
|
+
implementation files internal; export the manager, the provider, the config
|
|
87
|
+
factory and its shape type, any facade, and the typed error vocabulary.
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// packages/cache/src/index.ts
|
|
91
|
+
export { CacheManager } from "./CacheManager.ts";
|
|
92
|
+
export { CacheProvider } from "./provider/CacheProvider.ts";
|
|
93
|
+
export { Cache } from "./facades/Cache.ts";
|
|
94
|
+
|
|
95
|
+
// Config factory + its shape
|
|
96
|
+
export { CacheConfig } from "./config.ts";
|
|
97
|
+
export type { CacheConfigShape } from "./config.ts";
|
|
98
|
+
|
|
99
|
+
// Typed error vocabulary
|
|
100
|
+
export * from "./errors.ts";
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## The provider
|
|
104
|
+
|
|
105
|
+
The provider is the only thing the application boots. It binds your services into
|
|
106
|
+
the container and registers any commands. It must live at `src/provider/` and
|
|
107
|
+
declare both `static provides` and `static environments`.
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
// packages/cache/src/provider/CacheProvider.ts
|
|
111
|
+
import { ServiceProvider } from "@zerotal/core";
|
|
112
|
+
import type { AppEnvironment } from "@zerotal/core";
|
|
113
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
114
|
+
import { CacheManager } from "../CacheManager.ts";
|
|
115
|
+
|
|
116
|
+
// Make the binding token type-safe everywhere via declaration merging.
|
|
117
|
+
declare module "@zerotal/core" {
|
|
118
|
+
interface ContainerBindings {
|
|
119
|
+
cache: CacheManager;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class CacheProvider extends ServiceProvider {
|
|
124
|
+
static override provides = ["cache"] as const;
|
|
125
|
+
static override environments: AppEnvironment[] = ["web", "console", "test", "repl"];
|
|
126
|
+
|
|
127
|
+
override onRegister(): void {
|
|
128
|
+
this.app.container.singleton("cache", () => {
|
|
129
|
+
const config = this.app.container.makeSync("config") as ConfigManager;
|
|
130
|
+
const driver = config.get<string>("cache.driver", "sqlite");
|
|
131
|
+
return new CacheManager(/* … built from config … */);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
override async onBooted(): Promise<void> {
|
|
136
|
+
// Pre-resolve so the facade (makeSync) works after boot.
|
|
137
|
+
await this.app.container.make("cache");
|
|
138
|
+
|
|
139
|
+
// Register CLI commands lazily — the import only runs when invoked.
|
|
140
|
+
const runner = this.app.container.tryMake("commands");
|
|
141
|
+
runner?.registerLazy("cache:clear", () =>
|
|
142
|
+
import("../commands/CacheClearCommand.ts").then((m) => m.CacheClearCommand),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The hooks a provider typically uses, in lifecycle order:
|
|
149
|
+
|
|
150
|
+
- `onRegister` — container bindings (usually a lazy singleton).
|
|
151
|
+
- `onBooted` — pre-resolve the singleton so a facade works, and `registerLazy()`
|
|
152
|
+
any CLI commands.
|
|
153
|
+
|
|
154
|
+
Key patterns:
|
|
155
|
+
|
|
156
|
+
- **`declare module "@zerotal/core"`** to add your token to `ContainerBindings`,
|
|
157
|
+
so `container.make("cache")` is typed across the whole codebase.
|
|
158
|
+
- **`static provides`** lists the tokens you bind — required for the array form of
|
|
159
|
+
`app.defer([CacheProvider])` and used by the linter.
|
|
160
|
+
- **`static environments`** filters out the provider in modes it shouldn't run in.
|
|
161
|
+
- **`static dependsOn`** declares other packages' providers yours needs at boot —
|
|
162
|
+
e.g. `static dependsOn = [FlowProvider]`. They're pulled in and booted first
|
|
163
|
+
automatically, so an app installing your package never has to know the ordering.
|
|
164
|
+
See [Declaring dependencies](/docs/providers#declaring-dependencies).
|
|
165
|
+
- **Read config inside the factory**, not at module load, so overrides are applied
|
|
166
|
+
and the binding stays lazy.
|
|
167
|
+
- **`registerLazy()`** for commands keeps cold-start cheap.
|
|
168
|
+
|
|
169
|
+
See [Service Providers](/docs/providers) for every lifecycle hook.
|
|
170
|
+
|
|
171
|
+
## The config factory
|
|
172
|
+
|
|
173
|
+
Each package exposes an `XConfig()` factory in `src/config.ts`: an `XConfigShape`
|
|
174
|
+
interface, a `defaults` object, and a factory that deep-merges user overrides onto
|
|
175
|
+
the defaults. The factory parameter must be `Partial<XConfigShape>`.
|
|
176
|
+
|
|
177
|
+
**All config factories must merge with [`deepMerge`](/docs/helpers#objects-deepmerge)** —
|
|
178
|
+
`return deepMerge(defaults, options)`. This is the one canonical merge strategy:
|
|
179
|
+
do not hand-roll per-field `??` defaulting or manual nested spreads, and do not
|
|
180
|
+
reach for `Object.assign`/`{ ...defaults, ...options }` (a shallow spread silently
|
|
181
|
+
drops sibling keys inside nested objects). A single `deepMerge` call guarantees a
|
|
182
|
+
user who overrides one deep key keeps every other default, returns a fresh object
|
|
183
|
+
that never aliases the shared `defaults`, and is safe against prototype pollution.
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// packages/cache/src/config.ts
|
|
187
|
+
import { deepMerge } from "@zerotal/core";
|
|
188
|
+
|
|
189
|
+
export interface CacheConfigShape {
|
|
190
|
+
/** Which cache driver to use. Default: 'sqlite' */
|
|
191
|
+
driver: "sqlite" | "redis" | "memory";
|
|
192
|
+
/** Key prefix prepended to all cache keys. Default: 'zerotal:' */
|
|
193
|
+
prefix: string;
|
|
194
|
+
/** Default TTL in seconds. Default: 3600 */
|
|
195
|
+
ttl: number;
|
|
196
|
+
sqlite: { path: string };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const defaults: CacheConfigShape = {
|
|
200
|
+
driver: "sqlite",
|
|
201
|
+
prefix: "zerotal:",
|
|
202
|
+
ttl: 3600,
|
|
203
|
+
sqlite: { path: ":memory:" },
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @example
|
|
208
|
+
* import { CacheConfig } from '@zerotal/cache';
|
|
209
|
+
* export default CacheConfig({ driver: 'memory', ttl: 600 });
|
|
210
|
+
*/
|
|
211
|
+
export function CacheConfig(options: Partial<CacheConfigShape> = {}): CacheConfigShape {
|
|
212
|
+
return deepMerge(defaults, options);
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
| Field | Required | Default | Description |
|
|
217
|
+
| ------------- | -------- | ------------ | -------------------------------------------- |
|
|
218
|
+
| `driver` | no | `"sqlite"` | Which cache driver backs the manager. |
|
|
219
|
+
| `prefix` | no | `"zerotal:"` | Key prefix prepended to every cache key. |
|
|
220
|
+
| `ttl` | no | `3600` | Default time-to-live in seconds. |
|
|
221
|
+
| `sqlite.path` | no | `":memory:"` | SQLite file path; `:memory:` for in-process. |
|
|
222
|
+
|
|
223
|
+
Register the namespace for **typed config dot-paths** by augmenting `ConfigRegistry`
|
|
224
|
+
(the config analogue of `ContainerBindings`) at the bottom of `config.ts`:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
// packages/cache/src/config.ts
|
|
228
|
+
declare module "@zerotal/core" {
|
|
229
|
+
interface ConfigRegistry {
|
|
230
|
+
cache: CacheConfigShape;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
That makes `config("cache.ttl")` resolve to `number` (and autocomplete) in any app
|
|
236
|
+
that imports your package. See
|
|
237
|
+
[Configuration → Typed dot-paths](/docs/config-system#typed-dot-paths).
|
|
238
|
+
|
|
239
|
+
Using [`deepMerge`](/docs/helpers#objects-deepmerge) means a user who overrides
|
|
240
|
+
`sqlite.path` keeps the default `driver`, `prefix`, and `ttl` — overrides are
|
|
241
|
+
specific, not wholesale. The app then writes a tiny `config/cache.ts`:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// config/cache.ts
|
|
245
|
+
import { CacheConfig } from "@zerotal/cache";
|
|
246
|
+
export default CacheConfig({ driver: "memory" });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Document every option's default in the shape's JSDoc — that's what surfaces to
|
|
250
|
+
developers and what the docs render.
|
|
251
|
+
|
|
252
|
+
> **Warning** — Arrays replace, they don't merge. If an option is an array, a user
|
|
253
|
+
> setting it replaces your default array outright (no concat or de-dupe). Document
|
|
254
|
+
> that on the field, and prefer a name-keyed nested **object** (e.g. `stores`,
|
|
255
|
+
> `disks`) over an array whenever users should be able to add entries without
|
|
256
|
+
> losing the built-ins. See
|
|
257
|
+
> [Objects — `deepMerge()`](/docs/helpers#objects-deepmerge) for the full rule.
|
|
258
|
+
|
|
259
|
+
## Typed errors
|
|
260
|
+
|
|
261
|
+
Packages define their own error vocabulary, and every error **extends
|
|
262
|
+
`ZerotalError`** (never the native `Error`). This lets the exception handler render
|
|
263
|
+
them consistently and keeps HTTP status mapping in one place.
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
// packages/cache/src/errors.ts
|
|
267
|
+
import { ZerotalError } from "@zerotal/core";
|
|
268
|
+
|
|
269
|
+
export class CacheConnectionError extends ZerotalError {
|
|
270
|
+
constructor(driver: string) {
|
|
271
|
+
super(`Cache driver '${driver}' failed to connect.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
For HTTP-facing errors, extend one of the built-in `HttpError` subclasses
|
|
277
|
+
(`NotFoundError`, `ConflictError`, …) so the right status is returned
|
|
278
|
+
automatically. See [Errors](/docs/errors) for the full hierarchy.
|
|
279
|
+
|
|
280
|
+
## Facades
|
|
281
|
+
|
|
282
|
+
A facade is a thin static proxy that resolves your binding from the container, so
|
|
283
|
+
consumers can write `Cache.get(key)` instead of `container.make("cache")`. Build
|
|
284
|
+
one with `createFacade()` — it infers its type from the binding token — and export
|
|
285
|
+
it from the barrel.
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// packages/cache/src/facades/Cache.ts
|
|
289
|
+
import { createFacade } from "@zerotal/core";
|
|
290
|
+
|
|
291
|
+
export const Cache = createFacade("cache");
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
function createFacade<K extends keyof ContainerBindings>(key: K): ContainerBindings[K];
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Facades rely on the binding being pre-resolved, which is why providers call
|
|
299
|
+
`await container.make(token)` in `onBooted()`.
|
|
300
|
+
|
|
301
|
+
> **Warning** — Accessing a facade before `Application.boot()` finishes throws
|
|
302
|
+
> `FacadeAccessedBeforeBootError`. Never call one at module scope on import — only
|
|
303
|
+
> inside request handlers, commands, or other post-boot code.
|
|
304
|
+
|
|
305
|
+
## Macros
|
|
306
|
+
|
|
307
|
+
A macro adds a static method to a core class that your package doesn't own, so
|
|
308
|
+
consumers call `Router.flow(...)` as though it shipped with the router. Register
|
|
309
|
+
it in `onRegister()` — that runs before route files load, so the method exists by
|
|
310
|
+
the time an app calls it:
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
// packages/flow/src/FlowProvider.ts — inside onRegister():
|
|
314
|
+
Router.macro("flow", flowRoute);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
The call is untyped on its own; augment the matching interface so consumers get
|
|
318
|
+
completion and type-checking:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
// packages/flow/src/types.ts
|
|
322
|
+
declare module "@zerotal/core" {
|
|
323
|
+
interface RouterMacros {
|
|
324
|
+
flow(path: string, page: typeof Component, middleware?: MiddlewareClass[]): void;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
This is the mechanism behind `Router.flow()`, and `Str.macro()` follows the same
|
|
330
|
+
shape for string helpers. Reach for it only when the method genuinely belongs on
|
|
331
|
+
the core class — a plain export from your barrel is simpler and easier to trace.
|
|
332
|
+
|
|
333
|
+
## Contributing conventions
|
|
334
|
+
|
|
335
|
+
If your package introduces a new `app/*` directory that should auto-register at
|
|
336
|
+
boot (the way `app/models` and `app/policies` do), contribute a **concern
|
|
337
|
+
descriptor** from your provider's `onRegister()`. Core stays unaware of your
|
|
338
|
+
package — discovery is push-based.
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
// packages/webhooks/src/provider/WebhooksProvider.ts
|
|
342
|
+
import type { ConcernDescriptor } from "@zerotal/core";
|
|
343
|
+
|
|
344
|
+
export const webhooksConcern: ConcernDescriptor = {
|
|
345
|
+
name: "webhooks", // also the config key for path overrides
|
|
346
|
+
order: 70, // lower runs first (models=10, observers=20, …)
|
|
347
|
+
dir: "app/webhooks", // scanned relative to the project root
|
|
348
|
+
register(exports, ctx) {
|
|
349
|
+
for (const exported of Object.values(exports)) {
|
|
350
|
+
// …inspect and register each exported class…
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// in your provider:
|
|
356
|
+
override onRegister(): void {
|
|
357
|
+
this.app.registerConcern?.(webhooksConcern);
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
A descriptor with `dir` + `register` scans a directory and processes each module;
|
|
362
|
+
one with `run` fires a single hook after its files load (use it for one-shot setup
|
|
363
|
+
like auto-migration). Users can disable discovery or remap directories via
|
|
364
|
+
`config/app.ts` → `conventions`. See [Conventions](/docs/conventions) for the full
|
|
365
|
+
model.
|
|
366
|
+
|
|
367
|
+
> **Tip** — Optional-chain the call (`this.app.registerConcern?.(...)`) so unit
|
|
368
|
+
> tests that construct the provider with a minimal app stub don't need to implement
|
|
369
|
+
> it.
|
|
370
|
+
|
|
371
|
+
## Registering a dev process
|
|
372
|
+
|
|
373
|
+
If your package ships a companion process — a worker, a listener, a watcher —
|
|
374
|
+
declare it and `bun zt dev` runs it beside the server in its own tab. Otherwise
|
|
375
|
+
every user of your package has to remember a second terminal, and there is no way
|
|
376
|
+
for you to help them.
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
// packages/webhooks/src/provider/WebhooksProvider.ts
|
|
380
|
+
import type { DevProcessDefinition } from "@zerotal/core";
|
|
381
|
+
|
|
382
|
+
override devProcesses(): DevProcessDefinition[] {
|
|
383
|
+
return [
|
|
384
|
+
{
|
|
385
|
+
name: "webhooks", // identity, and what --only / --without take
|
|
386
|
+
command: ["stripe", "listen"], // raw argv…
|
|
387
|
+
enabled: () => this._configured(), // resolved once, at startup
|
|
388
|
+
restart: "on-failure", // or "always" / "never"
|
|
389
|
+
},
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
`command` takes three forms, and the one you want is usually the first:
|
|
395
|
+
|
|
396
|
+
| Form | Runs |
|
|
397
|
+
| ---------------------- | ----------------------------------------------------------------------- |
|
|
398
|
+
| `"queue:work"` | A `zt` command, through the app's own entrypoint |
|
|
399
|
+
| `["stripe", "listen"]` | Raw argv, for a tool that is not a `zt` command |
|
|
400
|
+
| `() => [...]` | The same, computed at startup from config you can only read once booted |
|
|
401
|
+
|
|
402
|
+
Use `run: async (signal) => …` instead of `command` for work with no separate
|
|
403
|
+
binary; the signal aborts on shutdown and on a restart. Set `after: "server"` for
|
|
404
|
+
a process that talks to the server, so it does not spend its restart budget
|
|
405
|
+
against a closed port before the server has bound.
|
|
406
|
+
|
|
407
|
+
Two things worth knowing:
|
|
408
|
+
|
|
409
|
+
- **`enabled` is resolved once, at startup.** A process cannot flicker in and out
|
|
410
|
+
of the deck while dev mode is running, and one that throws while probing
|
|
411
|
+
contributes nothing rather than failing dev mode for everyone else. Use it to
|
|
412
|
+
keep a tab off screen when it would have nothing to do — the queue worker sits
|
|
413
|
+
out under the `sync` driver for exactly this reason.
|
|
414
|
+
- **Your `name` is not private.** An app can replace your process by registering
|
|
415
|
+
the same name, or drop it with `app.dev.disable`. That is deliberate: they know
|
|
416
|
+
their setup better than you do.
|
|
417
|
+
|
|
418
|
+
See [Dev mode and the deck](/docs/commands#dev-mode-and-the-deck) for what the
|
|
419
|
+
user sees.
|
|
420
|
+
|
|
421
|
+
> **Your provider must be active in `web`.** Dev mode boots the app as `web` to
|
|
422
|
+
> ask providers what to run, so a provider whose `static environments` excludes
|
|
423
|
+
> it is never asked — and contributes nothing, silently.
|
|
424
|
+
|
|
425
|
+
## Registering a doctor check
|
|
426
|
+
|
|
427
|
+
`bun zt doctor` is what a developer (or an agent) runs to find out whether an app
|
|
428
|
+
is wired correctly. Contribute the checks only your package can make:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
// packages/webhooks/src/provider/WebhooksProvider.ts
|
|
432
|
+
import type { DoctorCheck } from "@zerotal/core";
|
|
433
|
+
|
|
434
|
+
override doctorChecks(): DoctorCheck[] {
|
|
435
|
+
return [
|
|
436
|
+
{
|
|
437
|
+
id: "webhooks-secret",
|
|
438
|
+
label: "Webhooks",
|
|
439
|
+
run: () => {
|
|
440
|
+
const secret = this.app.container.makeSync("config").get("webhooks.secret");
|
|
441
|
+
if (secret) return { status: "ok", message: "signing secret configured" };
|
|
442
|
+
return {
|
|
443
|
+
status: "warn",
|
|
444
|
+
message: "no signing secret — every delivery will be rejected unverified.",
|
|
445
|
+
fix: "Set WEBHOOKS_SECRET in .env.",
|
|
446
|
+
};
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
];
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Keep findings machine-readable, and put the resolution in `fix`. The intended
|
|
454
|
+
last step of an agent's task is `zt doctor`, and "looks fine to me" is not a
|
|
455
|
+
result it can act on. A check that throws is reported as that check failing,
|
|
456
|
+
never as the doctor failing.
|
|
457
|
+
|
|
458
|
+
`app.registerDoctorCheck()` does the same thing imperatively from `onRegister()`.
|
|
459
|
+
Prefer the method — it keeps your checks next to your other contributions.
|
|
460
|
+
|
|
461
|
+
## Tests
|
|
462
|
+
|
|
463
|
+
Every package must ship at least one `*.test.ts` file — the linter treats their
|
|
464
|
+
absence as a high-severity violation. Co-locate tests next to the code they cover
|
|
465
|
+
(`CacheManager.test.ts` beside `CacheManager.ts`) and run them with `bun test`.
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
// packages/cache/src/config.test.ts
|
|
469
|
+
import { test, expect } from "bun:test";
|
|
470
|
+
import { CacheConfig } from "./config.ts";
|
|
471
|
+
|
|
472
|
+
test("CacheConfig deep-merges overrides", () => {
|
|
473
|
+
const cfg = CacheConfig({ sqlite: { path: "./cache.db" } });
|
|
474
|
+
expect(cfg.driver).toBe("sqlite"); // default preserved
|
|
475
|
+
expect(cfg.sqlite.path).toBe("./cache.db"); // override applied
|
|
476
|
+
});
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
## The package linter
|
|
480
|
+
|
|
481
|
+
`bun zt lint:packages` audits every package against the conventions on this
|
|
482
|
+
page and fails CI on violations. What it checks:
|
|
483
|
+
|
|
484
|
+
| Rule | Severity | Requirement |
|
|
485
|
+
| ---------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
486
|
+
| `provider-location` | high | Providers must live at `src/provider/`. |
|
|
487
|
+
| `provider-provides` | medium | A provider that binds a token must declare `static provides`. |
|
|
488
|
+
| `provider-environments` | medium | Every provider declares `static environments`. |
|
|
489
|
+
| `config-factory` | medium | `src/config.ts` must export an `XConfig(...)` factory. |
|
|
490
|
+
| `config-casing` | high | The factory name must be PascalCase. |
|
|
491
|
+
| `config-partial` | high | Its parameter must be `Partial<…Shape>`. |
|
|
492
|
+
| `config-deepmerge` | medium | The factory must merge with `deepMerge(defaults, options)`. |
|
|
493
|
+
| `error-base` | medium | Error classes extend `ZerotalError`, not `Error` (client-bundle code under `client/` is exempt — it defines its own native-`Error` base). |
|
|
494
|
+
| `tests` | high | The package ships at least one `*.test.ts(x)`. |
|
|
495
|
+
| `package-json` / `esm` / `exports` | varies | Valid `package.json`, `"type": "module"`, and `"exports"`/`"main"`. |
|
|
496
|
+
|
|
497
|
+
Run it before opening a PR:
|
|
498
|
+
|
|
499
|
+
```bash
|
|
500
|
+
# in your project root
|
|
501
|
+
bun zt lint:packages
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
## Build checklist
|
|
505
|
+
|
|
506
|
+
1. `bun zt make:package <name>` to scaffold the skeleton.
|
|
507
|
+
2. Implement the service; keep internals out of the barrel.
|
|
508
|
+
3. Add the provider at `src/provider/`, with `provides` + `environments` and a
|
|
509
|
+
`declare module` augmentation for its token.
|
|
510
|
+
4. Add `src/config.ts` with an `XConfig()` factory using `deepMerge`, documenting
|
|
511
|
+
each default in JSDoc.
|
|
512
|
+
5. Make errors extend `ZerotalError`.
|
|
513
|
+
6. Contribute a concern descriptor if you introduce a new `app/*` directory.
|
|
514
|
+
7. Export everything public from `src/index.ts`.
|
|
515
|
+
8. Write tests.
|
|
516
|
+
9. `bun zt lint:packages` until clean.
|
|
517
|
+
|
|
518
|
+
## References
|
|
519
|
+
|
|
520
|
+
| Member | Signature | Description |
|
|
521
|
+
| --------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
522
|
+
| `ServiceProvider` | `abstract class ServiceProvider` | Base class for providers; override lifecycle hooks like `onRegister`. |
|
|
523
|
+
| `static provides` | `static provides: readonly string[]` | Tokens the provider binds; used by `app.defer()` and the linter. |
|
|
524
|
+
| `static environments` | `static environments: AppEnvironment[]` | Environments the provider runs in (`web`, `console`, `test`, `repl`). |
|
|
525
|
+
| `static dependsOn` | `static dependsOn: ProviderClass[]` | Other providers this one needs — pulled in and booted first. |
|
|
526
|
+
| `createFacade` | `createFacade<K extends keyof ContainerBindings>(key: K): ContainerBindings[K]` | Build a lazy static facade over a container binding. |
|
|
527
|
+
| `deepMerge` | `deepMerge<T extends object>(base: T, override: Partial<T>): T` | Canonical deep-merge for config factories. |
|
|
528
|
+
| `ZerotalError` | `class ZerotalError extends Error` | Base for all package error vocabularies. |
|
|
529
|
+
| `registerConcern` | `registerConcern(descriptor: ConcernDescriptor): this` | Register an auto-discovery concern from a provider. |
|
|
530
|
+
|
|
531
|
+
## Next steps
|
|
532
|
+
|
|
533
|
+
- [Service Providers](/docs/providers) — provider lifecycle in depth.
|
|
534
|
+
- [Service Container](/docs/container) — bindings, singletons, and facades.
|
|
535
|
+
- [Conventions](/docs/conventions) — the auto-discovery system.
|
|
536
|
+
- [Configuration](/docs/config-system) — how config files are loaded and merged.
|