@afora/core 0.0.2 → 0.0.4
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/README.md +220 -733
- package/dist/core/application/application.d.ts +9 -1
- package/dist/core/application/exceptions.d.ts +28 -0
- package/dist/core/application/registry.d.ts +33 -0
- package/dist/core/application/start.d.ts +75 -0
- package/dist/core/application/types/application.interface.d.ts +229 -7
- package/dist/core/blueprint/types/blueprint.interface.d.ts +66 -11
- package/dist/core/blueprint/types/slice.interface.d.ts +7 -2
- package/dist/core/command/command.d.ts +25 -0
- package/dist/core/command/exceptions.d.ts +33 -0
- package/dist/core/command/types/command.interface.d.ts +108 -0
- package/dist/core/entity/event.d.ts +12 -2
- package/dist/core/environment/environment.d.ts +4 -1
- package/dist/core/exception/exception.d.ts +4 -0
- package/dist/core/exception/exported.d.ts +4 -0
- package/dist/core/json.d.ts +10 -0
- package/dist/core/logger/exceptions.d.ts +4 -0
- package/dist/core/logger/logger.d.ts +106 -0
- package/dist/core/output/exceptions.d.ts +8 -0
- package/dist/core/output/openapi-sink.d.ts +83 -0
- package/dist/core/output/output.d.ts +19 -0
- package/dist/core/output/problem.d.ts +15 -0
- package/dist/core/output/select.d.ts +14 -0
- package/dist/core/output/stage.d.ts +24 -0
- package/dist/core/output/status.d.ts +17 -0
- package/dist/core/output/types/output.interface.d.ts +171 -0
- package/dist/core/property/abstract-property.d.ts +8 -7
- package/dist/core/property/define-property.d.ts +8 -7
- package/dist/core/property/property.d.ts +26 -15
- package/dist/core/property/types/abstract-property-constructor.type.d.ts +8 -7
- package/dist/core/property/types/define-property.interface.d.ts +5 -4
- package/dist/core/property/types/optional-property.type.d.ts +5 -5
- package/dist/core/property/types/property-collection.type.d.ts +2 -2
- package/dist/core/property/types/property-constructor.type.d.ts +5 -1
- package/dist/core/property/types/property-options.interface.d.ts +17 -8
- package/dist/core/property/types/property-type.interface.d.ts +7 -3
- package/dist/core/property/types/property.interface.d.ts +5 -4
- package/dist/core/record/abstract-record.d.ts +12 -4
- package/dist/core/record/types/plugin.interface.d.ts +13 -3
- package/dist/core/record/types/record.interface.d.ts +20 -8
- package/dist/core/repository/exceptions.d.ts +4 -0
- package/dist/core/repository/in-memory.d.ts +3 -0
- package/dist/core/repository/provider.d.ts +6 -0
- package/dist/core/repository/query.d.ts +9 -0
- package/dist/core/repository/references.d.ts +4 -2
- package/dist/core/repository/repository.d.ts +4 -2
- package/dist/core/repository/state.d.ts +4 -0
- package/dist/core/repository/types/query.interface.d.ts +67 -0
- package/dist/core/repository/types/repository.interface.d.ts +13 -13
- package/dist/core/schema/types/schema.interface.d.ts +11 -4
- package/dist/core/symbols/meta.d.ts +2 -0
- package/dist/core/use-case/exceptions.d.ts +10 -0
- package/dist/core/use-case/scope.d.ts +24 -0
- package/dist/core/use-case/steps/describe.d.ts +13 -0
- package/dist/core/use-case/steps/references.d.ts +27 -0
- package/dist/core/use-case/steps/run.d.ts +20 -0
- package/dist/core/use-case/steps/types/steps.interface.d.ts +153 -0
- package/dist/core/use-case/steps/validate.d.ts +17 -0
- package/dist/core/use-case/use-case.d.ts +79 -13
- package/dist/experimental.d.ts +6 -0
- package/dist/experimental.js +123 -0
- package/dist/index-ewm6r4nw.js +5335 -0
- package/dist/index-hn82hm31.js +1651 -0
- package/dist/index.d.ts +34 -12
- package/dist/index.js +343 -4142
- package/dist/std/blueprint-plugin.d.ts +10 -10
- package/dist/std/blueprint.d.ts +10 -10
- package/dist/std/entity.d.ts +2 -2
- package/dist/std/properties/contact.d.ts +4 -4
- package/dist/std/properties/enum.d.ts +1 -1
- package/dist/std/properties/headless.d.ts +1 -1
- package/dist/std/properties/identity.d.ts +2 -2
- package/dist/std/properties/password.d.ts +1 -1
- package/dist/std/properties/primitives.d.ts +5 -5
- package/dist/std/properties/time.d.ts +2 -2
- package/dist/std/record-plugins.d.ts +12 -12
- package/dist/std/record.d.ts +1 -1
- package/dist/std/stages.d.ts +2 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +502 -0
- package/dist/testing/provider-contract.d.ts +22 -0
- package/package.json +28 -8
package/README.md
CHANGED
|
@@ -10,14 +10,17 @@ afora is not a general-purpose validator. It is for code that models a domain, a
|
|
|
10
10
|
- **Typed change namespace and side effects.** `change: ["password"]` generates `change.password(value)` (also destructurable, as `const { change } = user`), which re-validates the value and runs `onSetAll` and `onSet`.
|
|
11
11
|
- **Exceptions that know their layer.** Each error carries a layer (`Property`, `Record`, `UseCase`, `Output`...) and a `trace` that grows as it goes up.
|
|
12
12
|
- **Standard Schema V1 on every field.** Properties, blueprints, lists and record classes work as is in Hono, tRPC and TanStack Form, and `safeParse` never throws for an invalid value.
|
|
13
|
+
- **Commands and outputs.** `commandOf` groups use cases behind one execution boundary (context, transactions, event policies); `outputOf` turns commands into operations and pipes them to a sink: an HTTP API with `@afora/hono`, an OpenAPI 3.1 document, or MCP tools, with problem+json errors and masked responses.
|
|
14
|
+
- **The whole application in JSON.** An `afora.json` generates the code (`@afora/codegen`), use cases included, as data (steps), and `extractSpec(app)` gives back the `afora.json` of an application written by hand.
|
|
15
|
+
- **Repositories on any store.** A provider contract with typed queries, soft deletion and transactions, a runner-agnostic kit to test one (`@afora/core/testing`), in memory by default and on Drizzle ORM with `@afora/drizzle`.
|
|
13
16
|
|
|
14
17
|
## Install
|
|
15
18
|
|
|
16
19
|
```bash
|
|
17
|
-
bun add @afora/core # or: npm install @afora/core
|
|
20
|
+
bun add @afora/core typebox # or: npm install @afora/core typebox
|
|
18
21
|
```
|
|
19
22
|
|
|
20
|
-
Requirements: [Bun](https://bun.sh) >= 1.4.4 or Node.js >= 22. The package ships ESM with TypeScript declarations; TypeScript >= 5.5 is an optional peer dependency.
|
|
23
|
+
Requirements: [Bun](https://bun.sh) >= 1.4.4 or Node.js >= 22. TypeBox (`typebox`, version `^1`) is a required peer dependency, shared with your application and afora's companion packages. The package ships ESM with TypeScript declarations; TypeScript >= 5.5 is an optional peer dependency.
|
|
21
24
|
|
|
22
25
|
`Password` hashes with argon2id: with `Bun.password` on Bun, with native `node:crypto` argon2 on a Node that has it (24.7+), and otherwise it throws `PasswordHasherRequired` until you call `configure({ hasher })`. `Id.generate()` makes a portable, monotonic UUID v7 on any runtime. afora imports no `node:crypto` (or `Buffer`) at load, so it loads in browsers and Workers; the native hasher is looked up on first use through `process.getBuiltinModule` (Node 22.3+). The native hasher derives argon2 version 19 only; a hash of another version throws `UnsupportedPasswordHash` (verify it with `Bun.password`, or `configure({ hasher })`).
|
|
23
26
|
|
|
@@ -34,795 +37,274 @@ configure({ hasher, idGenerator: () => myUuidV7() }); // both optional; `resetRu
|
|
|
34
37
|
|
|
35
38
|
## Quick start
|
|
36
39
|
|
|
37
|
-
A `User`
|
|
40
|
+
A `User` entity with a sensitive key and a unique key, a repository, a use case written as steps, a command that wraps it in a transaction, and an output that serves the command as an HTTP API with an OpenAPI document. Install what it uses:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bun add @afora/core @afora/hono hono typebox
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Save this as `quickstart.ts` and run `bun quickstart.ts`. It is the code of `examples/interop/quickstart.ts`, run by `bun run test:interop` (there the imports resolve to the local sources).
|
|
38
47
|
|
|
39
48
|
```ts
|
|
40
|
-
import { sValidator } from "@hono/standard-validator";
|
|
41
|
-
import { Hono } from "hono";
|
|
42
49
|
import {
|
|
43
50
|
blueprintOf,
|
|
51
|
+
commandOf,
|
|
44
52
|
ExceptionLayers,
|
|
53
|
+
entityOf,
|
|
45
54
|
exceptionOf,
|
|
46
|
-
|
|
47
|
-
listOf,
|
|
55
|
+
outputOf,
|
|
48
56
|
Password,
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
repositoryOf,
|
|
58
|
+
useCaseOf,
|
|
51
59
|
} from "@afora/core";
|
|
60
|
+
import { hono } from "@afora/hono";
|
|
52
61
|
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}));
|
|
63
|
-
|
|
64
|
-
export class User extends recordOf(UserBlueprint, {
|
|
65
|
-
unique: ["email"], // `slug` is unique already, as a Slug
|
|
66
|
-
change: ["password"], // generates change.password(value)
|
|
67
|
-
}) {}
|
|
62
|
+
// Entity: invariants, a sensitive key and a unique key
|
|
63
|
+
export class User extends entityOf(
|
|
64
|
+
blueprintOf((ctx) => ({
|
|
65
|
+
name: ctx.text,
|
|
66
|
+
email: ctx.email, // trimmed and lowercased
|
|
67
|
+
password: ctx.password, // only an argon2 hash is accepted; always masked
|
|
68
|
+
})),
|
|
69
|
+
{ unique: ["email"] },
|
|
70
|
+
) {}
|
|
68
71
|
|
|
69
|
-
//
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
address: Address,
|
|
75
|
-
tags: listOf(ctx.string, { maxItems: 5 }),
|
|
76
|
-
}));
|
|
72
|
+
// Repository: only the actions you choose; the provider is in memory by default
|
|
73
|
+
const Users = repositoryOf({
|
|
74
|
+
entity: User,
|
|
75
|
+
actions: ["findByEmail", "create"],
|
|
76
|
+
});
|
|
77
77
|
|
|
78
|
-
// Repository: enforces what User.options.unique declares
|
|
79
78
|
const UserAlreadyExists = exceptionOf({
|
|
80
|
-
layer: ExceptionLayers.
|
|
79
|
+
layer: ExceptionLayers.UseCase,
|
|
81
80
|
name: "UserAlreadyExists",
|
|
82
|
-
message: "A user with this email
|
|
81
|
+
message: "A user with this email already exists.",
|
|
83
82
|
});
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
save(user: User): User {
|
|
89
|
-
for (const key of User.options.unique) {
|
|
90
|
-
if (this.#users.some((saved) => saved[key] === user[key])) {
|
|
91
|
-
throw new UserAlreadyExists({ source: "UserRepository" });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
this.#users.push(user);
|
|
95
|
-
return user;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Use case and HTTP route
|
|
100
|
-
const users = new UserRepository();
|
|
101
|
-
|
|
102
|
-
async function signUp({ plainPassword, ...data }: SignUpData) {
|
|
103
|
-
const password = await Password.hash(plainPassword);
|
|
104
|
-
return users.save(new User({ ...data, password: password.value }));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export const app = new Hono().post(
|
|
108
|
-
"/users",
|
|
109
|
-
sValidator("json", SignUpBody), // any Standard Schema consumer works the same
|
|
110
|
-
async (c) => {
|
|
111
|
-
const user = await signUp(c.req.valid("json"));
|
|
112
|
-
return c.json(user.toJSON(), 201); // the password comes out masked
|
|
113
|
-
},
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
// Errors keep the layers they crossed
|
|
117
|
-
export function changePassword(user: User, value: string) {
|
|
118
|
-
return guard(
|
|
119
|
-
{ layer: ExceptionLayers.Output, source: "PATCH /password" },
|
|
120
|
-
() =>
|
|
121
|
-
guard({ layer: ExceptionLayers.UseCase, source: "ChangePassword" }, () =>
|
|
122
|
-
user.change.password(value),
|
|
123
|
-
),
|
|
124
|
-
);
|
|
84
|
+
async function hashPassword(plain: string) {
|
|
85
|
+
return (await Password.hash(plain)).value;
|
|
125
86
|
}
|
|
126
87
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// 201 { "email": "jane@example.com", "slug": "ola-mundo", "password": "********", ... }
|
|
135
|
-
// the same slug again: UserAlreadyExists (layer "repository"), thrown by the repository
|
|
136
|
-
|
|
137
|
-
// a body with email "nope" and tags "x": 400 from the validator, with the issues
|
|
138
|
-
// [{ path: ["email"], code: "invalid_value", ... }, { path: ["tags"], code: "invalid_type", ... }]
|
|
139
|
-
|
|
140
|
-
changePassword(user, "plain text"); // not an argon2 hash, so it throws; exception.trace is:
|
|
141
|
-
// property:password -> record:User -> use-case:ChangePassword -> output:PATCH /password
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
## Standard Schema
|
|
145
|
-
|
|
146
|
-
Every field implements Standard Schema V1 (`vendor: "afora"`), so any library that takes one (Hono, tRPC, TanStack Form...) takes it as is. `validate` is synchronous, never throws for an invalid value, and reports every issue with its `path` (e.g. `["addresses", 0, "zip"]`).
|
|
147
|
-
|
|
148
|
-
| Field | `~standard.validate` gives |
|
|
149
|
-
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
|
|
150
|
-
| property class (`Slug`, `propertyOf`, `optionalOf(Property)`) | the handled value: `Slug` turns `"Olá Mundo"` into `"ola-mundo"` |
|
|
151
|
-
| blueprint (and `optionalOf(blueprint)`, which accepts `undefined`) | the handled object, nested records as instances |
|
|
152
|
-
| list (`listOf`) | a frozen array of the handled items |
|
|
153
|
-
| record class (`recordOf`, subclasses included) | an instance of the class; a plain object for a nested record field becomes one too |
|
|
154
|
-
|
|
155
|
-
```ts
|
|
156
|
-
import { sValidator } from "@hono/standard-validator";
|
|
157
|
-
import { FieldApi, FormApi } from "@tanstack/form-core"; // see examples/interop for the full setup
|
|
158
|
-
import { initTRPC } from "@trpc/server";
|
|
159
|
-
import { Hono } from "hono";
|
|
160
|
-
import { blueprintOf, Email, listOf, recordOf } from "@afora/core";
|
|
161
|
-
|
|
162
|
-
const Address = blueprintOf((ctx) => ({ street: ctx.string, zip: ctx.string }));
|
|
163
|
-
const UserBlueprint = blueprintOf((ctx) => ({
|
|
164
|
-
name: ctx.string,
|
|
165
|
-
email: ctx.email,
|
|
166
|
-
slug: ctx.slug,
|
|
167
|
-
addresses: listOf(Address),
|
|
168
|
-
}));
|
|
169
|
-
class User extends recordOf(UserBlueprint) {}
|
|
170
|
-
|
|
171
|
-
// Hono: `c.req.valid("json")` is the handled object; an invalid body is a 400 with the issues
|
|
172
|
-
new Hono().post("/users", sValidator("json", UserBlueprint), (c) =>
|
|
173
|
-
c.json(c.req.valid("json")),
|
|
174
|
-
);
|
|
175
|
-
|
|
176
|
-
// tRPC: the resolver gets a `User`; an invalid input is `BAD_REQUEST`, its cause holding the issues
|
|
177
|
-
const t = initTRPC.create();
|
|
178
|
-
t.procedure.input(User).mutation(({ input }) => input.name);
|
|
179
|
-
|
|
180
|
-
// TanStack Form: a property validates a field, a blueprint validates the form
|
|
181
|
-
new FieldApi({ form, name: "email", validators: { onChange: Email } });
|
|
182
|
-
new FormApi({ defaultValues, validators: { onChange: UserBlueprint } });
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
Without a framework, `safeParse` (on properties, blueprints and record classes) takes `unknown` and returns `{ value }` or `{ issues }`, never both and never throwing for an invalid value:
|
|
186
|
-
|
|
187
|
-
```ts
|
|
188
|
-
const result = UserBlueprint.safeParse(body);
|
|
189
|
-
if (result.issues) {
|
|
190
|
-
for (const issue of result.issues) {
|
|
191
|
-
console.log(issue.path, issue.code, issue.message); // ["addresses", 0, "zip"] "invalid_value" ...
|
|
192
|
-
}
|
|
193
|
-
} else {
|
|
194
|
-
result.value; // handled and frozen
|
|
195
|
-
}
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
`code` is one of `required`, `invalid_type`, `invalid_value`, `undeclared_key`, `too_few_items`, `too_many_items` and `not_record`; `source` names who rejected the value, and for a property issue `cause` keeps the exception it raised.
|
|
199
|
-
|
|
200
|
-
`Record.safeParse` builds the instance through the class constructor, so a subclass that normalizes its input, or throws its own exception, is honored: what `new User(value)` accepts, `User.safeParse(value)` accepts, and an error thrown by the constructor becomes an issue (code `invalid_value`, path `[]`) instead of escaping. An instance of the class is returned as it is.
|
|
201
|
-
|
|
202
|
-
The input type is that of the blueprint (a record field takes an instance or a plain object). `StandardSchemaV1.InferOutput<typeof User>` is the instance type of the class `recordOf` returns. For a subclass that adds members (`class User extends recordOf(...) { get initials() {} }`) that is the base record instance type, because TypeScript cannot type the statics of a subclass; at runtime the value is an instance of the subclass, so narrow it with `instanceof User`. Each class has one stable `~standard` object.
|
|
203
|
-
|
|
204
|
-
The real-library tests (Hono, tRPC, TanStack Form) are in `examples/interop`: `bun run test:interop`.
|
|
205
|
-
|
|
206
|
-
## OpenAPI
|
|
207
|
-
|
|
208
|
-
`toOpenAPI` turns any field (a property, blueprint, list, record or entity) into an OpenAPI 3.1 schema, which is plain JSON Schema. Given named models, it gives the `components` of a document, and a record named among them is a `$ref` wherever another model holds it:
|
|
209
|
-
|
|
210
|
-
```ts
|
|
211
|
-
import { blueprintOf, entityOf, toOpenAPI } from "@afora/core";
|
|
212
|
-
|
|
213
|
-
const User = entityOf(
|
|
214
|
-
blueprintOf((ctx) => ({
|
|
215
|
-
name: ctx.text({ max: 80, description: "Full name" }),
|
|
216
|
-
email: ctx.email({ examples: ["jane@example.com"] }),
|
|
217
|
-
password: ctx.password,
|
|
88
|
+
// Use case: the steps are data, so they can also live in an afora.json
|
|
89
|
+
class Register extends useCaseOf({
|
|
90
|
+
name: "Register",
|
|
91
|
+
blueprint: blueprintOf((ctx) => ({
|
|
92
|
+
name: ctx.text,
|
|
93
|
+
email: ctx.email,
|
|
94
|
+
plainPassword: ctx.string,
|
|
218
95
|
})),
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
A property is a class that validates and normalizes one value, through `preHandle -> validation -> afterHandle`; the current step is exposed on `property.definition[State]`.
|
|
96
|
+
response: blueprintOf(() => ({ user: User })),
|
|
97
|
+
dependencies: { users: Users },
|
|
98
|
+
errors: [UserAlreadyExists],
|
|
99
|
+
steps: [
|
|
100
|
+
{ find: "users.findByEmail", args: ["$input.email"], as: "existing" },
|
|
101
|
+
{ ensure: "absent", value: "$existing", error: UserAlreadyExists },
|
|
102
|
+
{
|
|
103
|
+
run: "hashPassword",
|
|
104
|
+
fn: hashPassword,
|
|
105
|
+
args: ["$input.plainPassword"],
|
|
106
|
+
as: "hash",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
build: User,
|
|
110
|
+
values: { name: "$input.name", email: "$input.email", password: "$hash" },
|
|
111
|
+
as: "user",
|
|
112
|
+
},
|
|
113
|
+
{ create: "users", value: "$user", as: "saved" },
|
|
114
|
+
{ return: { user: "$saved" } },
|
|
115
|
+
],
|
|
116
|
+
}) {}
|
|
241
117
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
name: "name",
|
|
248
|
-
schema: Type.String({ minLength: 3 }),
|
|
249
|
-
preHandle: (value) => value.trim(),
|
|
250
|
-
default: "Jane", // used by Name.demo()
|
|
118
|
+
// Command: the execution boundary (here, one transaction per call)
|
|
119
|
+
const Accounts = commandOf({
|
|
120
|
+
name: "Accounts",
|
|
121
|
+
useCases: { register: Register },
|
|
122
|
+
transaction: true,
|
|
251
123
|
});
|
|
252
124
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const Product = blueprintOf((ctx) => ({
|
|
268
|
-
name: ctx.text({ max: 80, description: "Shown in the catalog" }),
|
|
269
|
-
price: ctx.number({ min: 1, examples: [1990] }),
|
|
270
|
-
sku: ctx.string({ pattern: /^[A-Z]{3}-\d{4}$/, examples: ["MUG-0001"] }),
|
|
271
|
-
stock: ctx.optional.integer({ min: 0 }),
|
|
272
|
-
status: ctx.enumOf(["draft", "published"]).with({ deprecated: true }),
|
|
273
|
-
}));
|
|
274
|
-
|
|
275
|
-
Product.schema.toString(); // JSON Schema, with each description, example and bound
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
Every property takes `title`, `description`, `examples`, `deprecated`, `readOnly` and `writeOnly`, which go to its JSON Schema as they are (for OpenAPI and other docs) and change nothing in validation, and `default`, its demo value, which is not written to the schema; without `default`, the first of `examples` is the demo. Strings also take `min` and `max` (`minLength`/`maxLength`) and `pattern` (a string or a `RegExp`, whose flags are not kept); numbers and integers take `min`, `max`, `exclusiveMin`, `exclusiveMax` and `multipleOf`. Constraints only narrow a property: `ctx.text({ min: 0 })` keeps the `minLength: 1` of `Text`, and a pattern given to `Phone` is checked along with its own, under `allOf`. Handlers, flags (`sensitive`, `unique`) and statics are kept, so `ctx.id({ description })` still has `generate()` and `ctx.slug({ max: 40 })` is still unique. Factories such as `ctx.enumOf` return a class, so use `.with` on what they return.
|
|
279
|
-
|
|
280
|
-
`enumOf` declares a property that holds one of a list of strings or numbers; its demo is the first value unless `default` is given. In a blueprint it is `ctx.enumOf`:
|
|
281
|
-
|
|
282
|
-
```ts
|
|
283
|
-
import { blueprintOf, enumOf } from "@afora/core";
|
|
284
|
-
|
|
285
|
-
const Status = enumOf(["draft", "published"], { name: "status" });
|
|
286
|
-
|
|
287
|
-
new Status("draft").value; // "draft", typed "draft" | "published"
|
|
288
|
-
new Status("archived"); // throws: Invalid value for property "status": ...
|
|
289
|
-
|
|
290
|
-
const Post = blueprintOf((ctx) => ({
|
|
291
|
-
status: ctx.enumOf(["draft", "published"]),
|
|
292
|
-
tags: ctx.listOf(ctx.enumOf(["news", "tech"]), { minItems: 1 }),
|
|
293
|
-
level: ctx.optionalOf(ctx.enumOf([1, 2, 3])), // or ctx.enumOf([1, 2, 3], { optional: true })
|
|
294
|
-
}));
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
### Blueprints
|
|
298
|
-
|
|
299
|
-
A blueprint picks properties from `Std` and its sources, plugins or other blueprints. A key can also hold another blueprint, validated as a nested object, and `join` merges blueprints:
|
|
300
|
-
|
|
301
|
-
```ts
|
|
302
|
-
import { blueprintOf, listOf, optionalOf } from "@afora/core";
|
|
303
|
-
|
|
304
|
-
const Address = blueprintOf((ctx) => ({ street: ctx.string, zip: ctx.string }));
|
|
305
|
-
const Timestamps = blueprintOf((ctx) => ({ createdAt: ctx.createdAt }));
|
|
306
|
-
|
|
307
|
-
const User = blueprintOf((ctx) => ({
|
|
308
|
-
name: ctx.string,
|
|
309
|
-
address: Address, // nested object
|
|
310
|
-
billing: optionalOf(Address), // optional nested object
|
|
311
|
-
addresses: listOf(Address), // array of nested objects
|
|
312
|
-
tags: listOf(ctx.string, { maxItems: 5 }), // array of values
|
|
313
|
-
}));
|
|
314
|
-
|
|
315
|
-
const Admin = blueprintOf([User], (ctx) => ({
|
|
316
|
-
name: ctx.name, // picked from User
|
|
317
|
-
role: ctx.string,
|
|
318
|
-
}));
|
|
319
|
-
|
|
320
|
-
const Customer = User.join(Timestamps); // every field of both
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
`ctx.optional.*` and `ctx.list.*` mirror every field: `ctx.list.string` is `listOf(ctx.string)` and `ctx.list.optional.string` an optional list of strings. Every context also has `ctx.listOf`, `ctx.optionalOf` and `ctx.propertyOf`, the same functions as the exports, to make fields inline: `ctx.listOf(ctx.string, { maxItems: 5 })`, `ctx.optionalOf(Address)`. `optional`, `list`, `listOf`, `optionalOf` and `propertyOf` are reserved, so no plugin can declare them. Factories such as `ctx.enumOf` are not mirrored under `optional` and `list`, which would lose their literal types; wrap what they return in `ctx.listOf` or `ctx.optionalOf`.
|
|
324
|
-
|
|
325
|
-
`join` takes any number of blueprints (`A.join(B, C)` is `A.join(B).join(C)`); a later one replaces a key of an earlier one, like plugins do. `listOf` takes a property, a blueprint or another list, and `optionalOf` makes it an optional key.
|
|
326
|
-
|
|
327
|
-
`blueprintOf` is `blueprintBuilderOf(Std)`. To build blueprints from other properties, without `Std`, make a builder of your own:
|
|
328
|
-
|
|
329
|
-
```ts
|
|
330
|
-
import { blueprintBuilderOf, blueprintPluginOf } from "@afora/core";
|
|
331
|
-
|
|
332
|
-
const contentBlueprintOf = blueprintBuilderOf(
|
|
333
|
-
blueprintPluginOf("content", { title: Title, body: Body }),
|
|
334
|
-
);
|
|
335
|
-
const Post = contentBlueprintOf((ctx) => ({
|
|
336
|
-
title: ctx.title,
|
|
337
|
-
body: ctx.body,
|
|
338
|
-
}));
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
A plugin can also take factories, functions that make a field from their arguments, as `Std` does with `enumOf`: `blueprintPluginOf("content", { title: Title }, { enumOf })` gives `ctx.enumOf`. A factory replaces a field with the same name.
|
|
342
|
-
|
|
343
|
-
### Slicing
|
|
344
|
-
|
|
345
|
-
`sliceOf(blueprint, selector)` gives a new blueprint with only the fields `selector` names, recursively into nested blueprints, records and lists. Optional fields stay optional and lists stay lists. A property is kept with its flags (`sensitive`, `unique`, ...); a nested record stays a record, with the `sensitive`, `unique` and `change` keys it declares among the keys kept, so it still masks its sensitive keys and is still `writeOnly` in `toOpenAPI`. The selector is a mask, `true` keeping a field whole and an object keeping only its keys, or a blueprint or record class, whose keys, at any depth, are the mask.
|
|
346
|
-
|
|
347
|
-
```ts
|
|
348
|
-
import { sliceOf, sliceValue } from "@afora/core";
|
|
349
|
-
|
|
350
|
-
const Card = sliceOf(Person, { name: true, address: { city: true } });
|
|
351
|
-
const Same = sliceOf(Person, CardBlueprint); // keys of another blueprint or record class
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
A nested record or entity the selector names whole (`true`, or the same class in a blueprint or record selector) is kept as it is. One sliced to some of its keys becomes a plain record of those keys, not an entity: its identity keys (`id`, `createdAt`, ...) are in it only if the selector names them, and it has no events, `destroy` or plugin members. A sliced record field still accepts an instance of the original record.
|
|
355
|
-
|
|
356
|
-
A key the blueprint does not have is a type error and throws `UndeclaredBlueprintProperty`. The types `SliceMask` and `ValueMask` describe a selector mask, and `SlicedBlueprint` and `SlicedValue` the results.
|
|
357
|
-
|
|
358
|
-
`sliceValue(value, selector)` does the same to data: it strips the keys the selector does not name, through objects and lists, and does not validate. A record instance is read by its `toJSON`, so what is sensitive stays masked; a plain object is copied as it is. A key missing from the value is left out.
|
|
359
|
-
|
|
360
|
-
```ts
|
|
361
|
-
sliceValue(post, { title: true, author: { name: true } }); // { title, author: { name } }
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
### Records
|
|
365
|
-
|
|
366
|
-
A record implements a blueprint: it keeps the blueprint and a value of its shape, each key handled by its field (at any depth) and read as a member of the record (`user.name`). Its constructor takes the value inferred from the blueprint (and throws on an invalid one), the protected `set` changes one key, and `onSet` declares the side effect of a change to each key:
|
|
367
|
-
|
|
368
|
-
```ts
|
|
369
|
-
import { recordOf, type RecordEffects } from "@afora/core";
|
|
370
|
-
|
|
371
|
-
class User extends recordOf(UserBlueprint) {
|
|
372
|
-
protected override onSet(): RecordEffects<User> {
|
|
373
|
-
return {
|
|
374
|
-
name: (value, previous) => console.log(`${previous} -> ${value}`),
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const user = new User({
|
|
380
|
-
name: " Jane ",
|
|
381
|
-
address: { street: "Main St", zip: "01310-100" },
|
|
125
|
+
// Output: the commands as an HTTP API with an OpenAPI document
|
|
126
|
+
const Api = outputOf({
|
|
127
|
+
name: "Api",
|
|
128
|
+
commands: [Accounts],
|
|
129
|
+
statuses: { UserAlreadyExists: 409 },
|
|
130
|
+
pipe: [
|
|
131
|
+
hono({
|
|
132
|
+
basePath: "/api",
|
|
133
|
+
docs: {
|
|
134
|
+
path: "/openapi.json",
|
|
135
|
+
info: { title: "Accounts", version: "1.0.0" },
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
],
|
|
382
139
|
});
|
|
383
|
-
user.name; // "Jane"
|
|
384
|
-
user.blueprint; // UserBlueprint
|
|
385
|
-
```
|
|
386
140
|
|
|
387
|
-
|
|
141
|
+
export const api = Api.build();
|
|
388
142
|
|
|
389
|
-
|
|
390
|
-
class Account extends recordOf(AccountBlueprint, {
|
|
391
|
-
sensitive: ["token"], // masked in toJSON; a sensitive property (Password) always is, at any depth
|
|
392
|
-
unique: ["email"], // listed for repositories to enforce; a unique property (Slug) key always is
|
|
393
|
-
change: ["password"], // generates a typed change.password(value)
|
|
394
|
-
}) {}
|
|
395
|
-
|
|
396
|
-
Account.options; // { sensitive: ["token", "password"], unique: ["email"], change: ["password"] }
|
|
397
|
-
new Account(value).change.password(hash); // same as set("password", hash), returns the record
|
|
143
|
+
if (import.meta.main) Bun.serve({ fetch: api.fetch });
|
|
398
144
|
```
|
|
399
145
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
**Masking.** A key holding a property declared `sensitive`, such as `Password` (optional, or a list of them), is always sensitive. `toJSON` (and so `JSON.stringify`) replaces each leaf of a sensitive value (strings, numbers, booleans; objects and lists leaf by leaf) with `MASK` (`"********"`, the same whatever the length, so none leaks), and does the same for every `sensitive` property at any depth, inside nested blueprints and lists too. Reading a key still gives the real value (`account.password` is the hash, for `verify` and repositories).
|
|
403
|
-
|
|
404
|
-
**Uniqueness.** A key holding a property declared `unique`, such as `Slug` (optional or not), is always unique; a list of them is not, since uniqueness applies to a key's value. `unique` only records the intent: a record never compares itself with other records, so two records with the same `email` both build. `options.unique` names the keys a repository (or a unique index in the database) must enforce. `Slug` slugifies its value: `" Olá Mundo! "` becomes `"ola-mundo"`.
|
|
405
|
-
|
|
406
|
-
**Changes.** The keys are read-only and only change through `set`, which only the record itself (or a `change.<key>` method) can call. `set` validates the value, stores it, then runs `onSetAll(change)` and then the effect of the key in `onSet`. `change` is `{ key, value, previous }`, and checking `change.key` narrows the rest (annotate it as `RecordChange<User>`).
|
|
407
|
-
|
|
408
|
-
**Rejections.** An invalid value throws in the `Record` layer, naming the key in `details` (e.g. `{ key: "address.zip" }`). So does a key the blueprint does not declare, at any depth, with `UndeclaredRecordProperty` (e.g. `{ key: "addresses.0.extra" }`): the value is rejected, never trimmed. A key cannot take the name of a member of the record (`blueprint`, `options`, `set`, `toJSON`, `onSet`, `onSetAll` or, when the record has `change` keys, `change`), nor of one every object inherits (`toString`, `valueOf`, `hasOwnProperty`, `__proto__`... all of `Object.prototype`, listed in `RESERVED_RECORD_KEYS`): `recordOf` throws `ReservedRecordKey`. Nor can a class extending the record declare a member with the name of a key, which would hide it: its first record throws `ReservedRecordKey`.
|
|
409
|
-
|
|
410
|
-
**Spread caveat.** The value of a record lives in a `WeakMap`, and each key is a getter on the record's prototype, not an own property. So `Object.keys(user)` is `["blueprint", "options"]` and `{ ...user }` holds no key of the blueprint. For plain data use `user.toJSON()` (frozen, sensitive values masked), or `blueprint.safeParse(value)` to handle plain input into a plain object. To build a record from another one, pass a plain object of its keys, not the record itself, whose `blueprint` and `options` would be undeclared keys.
|
|
411
|
-
|
|
412
|
-
### Entities
|
|
413
|
-
|
|
414
|
-
An entity is a record with identity, ready to persist. `entityOf` takes a blueprint and the options of `recordOf`, and adds four keys to it, from `std`:
|
|
415
|
-
|
|
416
|
-
- `id`: an `Id` (UUID v7), generated when the value has none and always listed in `options.unique`.
|
|
417
|
-
- `createdAt`: generated as now when the value has none.
|
|
418
|
-
- `updatedAt`: absent until the first change, then set to now by each `set` or `change.<key>`, before `onSetAll` and `onSet` run.
|
|
419
|
-
- `deletedAt`: set by `destroy()`, which then runs `onDestroy`. A destroyed entity (`isDeleted`) no longer changes: `set`, `change.<key>` and `destroy` throw `DeletedEntity`.
|
|
420
|
-
|
|
421
|
-
```ts
|
|
422
|
-
import { blueprintOf, entityOf, listOf, optionalOf } from "@afora/core";
|
|
146
|
+
What it does:
|
|
423
147
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
) {}
|
|
148
|
+
```
|
|
149
|
+
POST /api/accounts/register { "name": "Ana", "email": " Ana@Example.COM ", "plainPassword": "Str0ng!Pass" }
|
|
150
|
+
-> 200 { "user": { "id": "...", "name": "Ana", "email": "ana@example.com", "password": "********", ... } }
|
|
428
151
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
user.updatedAt; // undefined
|
|
432
|
-
user.change.name("Ann").updatedAt; // now
|
|
433
|
-
user.destroy().isDeleted; // true
|
|
152
|
+
the same email again
|
|
153
|
+
-> 409 application/problem+json { "title": "UserAlreadyExists", "layer": "use-case", ... }
|
|
434
154
|
|
|
435
|
-
|
|
436
|
-
|
|
155
|
+
{ "name": "Ana", "email": "nope" }
|
|
156
|
+
-> 400 application/problem+json { ..., "issues": [{ "path": ["email"], ... }, { "path": ["plainPassword"], ... }] }
|
|
437
157
|
|
|
438
|
-
|
|
439
|
-
class Post extends entityOf(
|
|
440
|
-
blueprintOf((ctx) => ({
|
|
441
|
-
title: ctx.string,
|
|
442
|
-
author: User,
|
|
443
|
-
reviewer: optionalOf(User),
|
|
444
|
-
coauthors: listOf(User),
|
|
445
|
-
})),
|
|
446
|
-
) {}
|
|
447
|
-
new Post({ title: "Hi", author: { name: "Jane" }, coauthors: [user] }); // author gets its own id
|
|
158
|
+
GET /openapi.json -> the OpenAPI 3.1 document of the route
|
|
448
159
|
```
|
|
449
160
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
Entities can queue domain events for an external consumer. `eventOf` accepts an entity class (including subclasses) and an async handler returning `Promise<void>`. Definitions are immutable; incompatible entity instances are rejected when emitting.
|
|
453
|
-
|
|
454
|
-
```ts
|
|
455
|
-
import {
|
|
456
|
-
blueprintOf,
|
|
457
|
-
entityOf,
|
|
458
|
-
eventOf,
|
|
459
|
-
type EventDefinition,
|
|
460
|
-
} from "@afora/core";
|
|
161
|
+
### The same app as an afora.json
|
|
461
162
|
|
|
462
|
-
|
|
463
|
-
class User extends entityOf(UserBlueprint, {
|
|
464
|
-
change: ["name"],
|
|
465
|
-
onCreate: () => UserCreated,
|
|
466
|
-
onUpdate: () => [UserUpdated],
|
|
467
|
-
onDelete: () => UserDeleted,
|
|
468
|
-
}) {
|
|
469
|
-
announce(): void {
|
|
470
|
-
this.emit(UserUpdated);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
163
|
+
Everything above, except the hashing function, is data, so it can be written as an `afora.json` and generated. `@afora/codegen` writes the TypeScript you would have written by hand (the classes above, plus an `applicationOf`), committed and reviewable. This is `examples/interop/quickstart.afora.json`, which the same test validates and generates.
|
|
473
164
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
"
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"version": 1,
|
|
168
|
+
"name": "accounts",
|
|
169
|
+
"output": { "dir": "src/domain" },
|
|
170
|
+
"provider": "inMemory",
|
|
171
|
+
"entities": {
|
|
172
|
+
"User": {
|
|
173
|
+
"fields": { "name": "text", "email": "email", "password": "password" },
|
|
174
|
+
"unique": ["email"]
|
|
175
|
+
}
|
|
481
176
|
},
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
"user.updated",
|
|
485
|
-
User,
|
|
486
|
-
async (user) => {
|
|
487
|
-
console.log(user.name);
|
|
177
|
+
"repositories": {
|
|
178
|
+
"Users": { "entity": "User", "actions": ["findByEmail", "create"] }
|
|
488
179
|
},
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
console.log(user.deletedAt);
|
|
180
|
+
"exceptions": {
|
|
181
|
+
"UserAlreadyExists": {
|
|
182
|
+
"layer": "use-case",
|
|
183
|
+
"message": "A user with this email already exists."
|
|
184
|
+
}
|
|
495
185
|
},
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
export const Slugged = recordPluginOf("slugged", SluggedRecord, {
|
|
541
|
-
version: "1.0.0",
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
class Post extends entityOf(
|
|
545
|
-
blueprintOf((ctx) => ({ title: ctx.string })),
|
|
546
|
-
{ plugins: [Slugged] },
|
|
547
|
-
) {}
|
|
548
|
-
|
|
549
|
-
const post = new Post({ title: "Hi", slug: "Olá Mundo" });
|
|
550
|
-
post.path; // "/ola-mundo"
|
|
551
|
-
post.change.slug("hello"); // sets updatedAt, then logs "moved to /hello"
|
|
552
|
-
Post.options.unique; // ["id", "slug"]: repositories get findBySlug
|
|
553
|
-
hasPlugin(post, Slugged); // true, and narrows post to the plugin's record
|
|
554
|
-
```
|
|
555
|
-
|
|
556
|
-
- **Behavior.** The plugin's methods run on the record they are applied to, so `this.set` is that record's (an entity's sets `updatedAt` and queues `onUpdate`). Its `onSetAll` and `onSet` effects run for changes to its keys, before those of the record. The constructor of the plugin's class does not run for the record, and its methods cannot use private (`#`) members.
|
|
557
|
-
- **Conflicts.** A plugin key or member cannot be one the record, or another plugin, already has (an entity's identity included): declaring the record throws `RecordPluginConflict`. Nor can two different plugins share a name. A plugin's class cannot replace a member every record has (`toJSON`, `set`...), and cannot be an entity (plugins have no identity): `recordPluginOf` throws `InvalidRecordPlugin`.
|
|
558
|
-
- **Composition.** A plugin's class can declare plugins itself: they are applied with it, each once, and listed in `plugin.requires`. `Post.plugins` lists them all.
|
|
559
|
-
- **Tracking.** `hasPlugin(value, plugin)` (or `plugin.is(value)`) tells a record, an entity or a class of them having the plugin, through `requires` and subclasses too. `hasPlugin(value, "slugged")` checks by name. `pluginsOf(value)` lists them.
|
|
560
|
-
|
|
561
|
-
**Standard plugins.** `recordOf` and `entityOf` pick from the plugins of `StdRecordPlugins` when `options.plugins` is a function, the way `blueprintOf` picks from `Std`:
|
|
562
|
-
|
|
563
|
-
```ts
|
|
564
|
-
class Article extends entityOf(
|
|
565
|
-
blueprintOf((ctx) => ({ title: ctx.string })),
|
|
566
|
-
{
|
|
567
|
-
plugins: (ctx) => [ctx.publishable, ctx.taggable, ctx.ownable],
|
|
186
|
+
"useCases": {
|
|
187
|
+
"Register": {
|
|
188
|
+
"input": {
|
|
189
|
+
"fields": {
|
|
190
|
+
"name": "text",
|
|
191
|
+
"email": "email",
|
|
192
|
+
"plainPassword": "string"
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
"response": { "fields": { "user": "User" } },
|
|
196
|
+
"dependencies": { "users": "Users" },
|
|
197
|
+
"errors": ["UserAlreadyExists"],
|
|
198
|
+
"implementation": {
|
|
199
|
+
"steps": [
|
|
200
|
+
{
|
|
201
|
+
"find": "users.findByEmail",
|
|
202
|
+
"args": ["$input.email"],
|
|
203
|
+
"as": "existing"
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"ensure": "absent",
|
|
207
|
+
"value": "$existing",
|
|
208
|
+
"error": "UserAlreadyExists"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"run": "hashPassword",
|
|
212
|
+
"fn": { "import": "../hash-password.ts#hashPassword" },
|
|
213
|
+
"args": ["$input.plainPassword"],
|
|
214
|
+
"as": "hash"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"build": "User",
|
|
218
|
+
"values": {
|
|
219
|
+
"name": "$input.name",
|
|
220
|
+
"email": "$input.email",
|
|
221
|
+
"password": "$hash"
|
|
222
|
+
},
|
|
223
|
+
"as": "user"
|
|
224
|
+
},
|
|
225
|
+
{ "create": "users", "value": "$user", "as": "saved" },
|
|
226
|
+
{ "return": { "user": "$saved" } }
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
}
|
|
568
230
|
},
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
class Rebuilt extends entityOf(PostBlueprint, { plugins }) {}
|
|
590
|
-
|
|
591
|
-
catalog.resolve("slugged"); // by name
|
|
592
|
-
catalog.resolve("slugged@1.0.0"); // by name and version
|
|
593
|
-
```
|
|
594
|
-
|
|
595
|
-
`resolve` throws `UnknownRecordPlugin` when the catalog has no plugin of that name. It throws `IncompatibleRecordPlugin` when the catalog's plugin does not satisfy the reference: a different major version (minor, below `1.0.0`), an older version, or keys, options, members or requirements the descriptor lists that it no longer has. Typing is lost once plugins go through JSON, since they are only known at runtime.
|
|
596
|
-
|
|
597
|
-
### UseCases
|
|
598
|
-
|
|
599
|
-
`useCaseOf` declares an abstract operation with input and response blueprints and a collection of concrete UseCase classes as dependencies. This combines the **Command** pattern (an instance holds the operation's arguments) with **method injection** (dependency functions are supplied to `execute`).
|
|
600
|
-
|
|
601
|
-
```ts
|
|
602
|
-
import { blueprintOf, entityOf, useCaseOf } from "@afora/core";
|
|
603
|
-
|
|
604
|
-
const UserInput = blueprintOf((ctx) => ({ name: ctx.string }));
|
|
605
|
-
class User extends entityOf(UserInput) {}
|
|
606
|
-
const UserResponse = blueprintOf(() => ({ user: User }));
|
|
607
|
-
|
|
608
|
-
class CreateUser extends useCaseOf({
|
|
609
|
-
name: "CreateUser",
|
|
610
|
-
blueprint: UserInput,
|
|
611
|
-
response: UserResponse,
|
|
612
|
-
}) {
|
|
613
|
-
override async execute() {
|
|
614
|
-
return { user: new User(this.input) };
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
class RegisterUser extends useCaseOf({
|
|
619
|
-
name: "RegisterUser",
|
|
620
|
-
blueprint: UserInput,
|
|
621
|
-
dependencies: { createUser: CreateUser },
|
|
622
|
-
response: UserResponse,
|
|
623
|
-
}) {
|
|
624
|
-
override async execute({ createUser = this.dependencies.createUser } = {}) {
|
|
625
|
-
return await createUser(this.input);
|
|
231
|
+
"commands": {
|
|
232
|
+
"Accounts": { "useCases": { "register": "Register" }, "transaction": true }
|
|
233
|
+
},
|
|
234
|
+
"outputs": {
|
|
235
|
+
"Api": {
|
|
236
|
+
"commands": ["Accounts"],
|
|
237
|
+
"statuses": { "UserAlreadyExists": 409 },
|
|
238
|
+
"pipe": [
|
|
239
|
+
{
|
|
240
|
+
"sink": "hono",
|
|
241
|
+
"options": {
|
|
242
|
+
"basePath": "/api",
|
|
243
|
+
"docs": {
|
|
244
|
+
"path": "/openapi.json",
|
|
245
|
+
"info": { "title": "Accounts", "version": "1.0.0" }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
]
|
|
250
|
+
}
|
|
626
251
|
}
|
|
627
252
|
}
|
|
628
|
-
|
|
629
|
-
const operation = new RegisterUser({ name: "Ana" });
|
|
630
|
-
const { user } = await operation.execute();
|
|
631
|
-
for (const event of operation.pullEvents()) await event.handle();
|
|
632
|
-
|
|
633
|
-
// Partial overrides replace direct dependencies; other dependencies resolve normally.
|
|
634
|
-
await new RegisterUser({ name: "Ana" }).execute({
|
|
635
|
-
createUser: async (input) => ({ user: new User(input) }),
|
|
636
|
-
});
|
|
637
253
|
```
|
|
638
254
|
|
|
639
|
-
The
|
|
640
|
-
|
|
641
|
-
Dependencies default to `{}`. Each dependency function creates a fresh child, resolves its dependencies and returns its validated response. Await all dependency calls before returning. Functions are usable only during execution. Use the destructured defaults shown above to keep both no-argument calls and partial overrides inferred in TypeScript. `execute(deps = this.dependencies)` also supports no-argument calls, but its inferred override type requires the complete dependency map. During execution, `this.dependencies` exposes the resolved functions, including overrides. Overrides apply only to direct dependencies, and their inputs and responses are validated against the declared child. There is no global container.
|
|
642
|
-
|
|
643
|
-
Events from managed children move to the parent when each child finishes, including on failure and when the parent's response omits the child result. Then the validated parent response is traversed through declared records, entities, nested blueprints and lists. Repeated object references are visited once; separately emitted duplicate events are preserved. Child events follow completion order, followed by remaining response events. No handlers run automatically. `pullEvents()` immediately drains the queue, which remains available after failure. Invalid responses are not drained. Overrides contribute events from their returned entities; hidden operations inside an override must expose their events separately.
|
|
644
|
-
|
|
645
|
-
`UseCaseInput`, `UseCaseResponse` and `UseCaseDependencies` extract a declaration's types. `isUseCase` identifies instances; `isUseCaseClass` identifies concrete declarations with a prototype `execute`. UseCases carry their own kind and cannot be used as fields of ordinary data blueprints. Validation errors carry `details.issues`; errors crossing an execution boundary preserve their cause and gain a `UseCase` trace frame using the declared name.
|
|
646
|
-
|
|
647
|
-
### Repositories
|
|
648
|
-
|
|
649
|
-
`repositoryOf` makes a repository of an entity with the methods you choose: `findBy<Key>` for a unique key (`id`, a key in `options.unique`, or one holding a `unique` property such as `Slug`), giving the entity or `undefined`; `findManyBy<Key>` for any key that is not unique, giving a list (a unique key has `findBy<Key>` instead, so `findManyByEmail` is rejected by type and at declaration); `create`, `update`, `delete` and `count`. Every method is async.
|
|
650
|
-
|
|
651
|
-
```ts
|
|
652
|
-
import {
|
|
653
|
-
blueprintOf,
|
|
654
|
-
entityOf,
|
|
655
|
-
environmentOf,
|
|
656
|
-
repositoryOf,
|
|
657
|
-
RepositoryOf,
|
|
658
|
-
} from "@afora/core";
|
|
659
|
-
|
|
660
|
-
class User extends entityOf(
|
|
661
|
-
blueprintOf((ctx) => ({
|
|
662
|
-
name: ctx.string,
|
|
663
|
-
email: ctx.email,
|
|
664
|
-
slug: ctx.slug,
|
|
665
|
-
})),
|
|
666
|
-
{ unique: ["email"] },
|
|
667
|
-
) {}
|
|
668
|
-
|
|
669
|
-
const Users = repositoryOf({
|
|
670
|
-
entity: User,
|
|
671
|
-
actions: [
|
|
672
|
-
"findById",
|
|
673
|
-
"findByEmail",
|
|
674
|
-
"findBySlug",
|
|
675
|
-
"findManyByName",
|
|
676
|
-
"create",
|
|
677
|
-
"update",
|
|
678
|
-
"delete",
|
|
679
|
-
"count",
|
|
680
|
-
],
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
const user = await Users.create({
|
|
684
|
-
name: "Ana",
|
|
685
|
-
email: "ana@example.com",
|
|
686
|
-
slug: "ana",
|
|
687
|
-
});
|
|
688
|
-
await Users.findByEmail("ana@example.com"); // User | undefined
|
|
689
|
-
await Users.findManyByName("Ana"); // User[]
|
|
690
|
-
// repositoryOf({ entity: User, actions: ["findByName"] }) does not compile, and throws: name is not unique
|
|
691
|
-
```
|
|
692
|
-
|
|
693
|
-
`create` takes an entity or the input that builds one. `delete` takes an entity or an id, calls `destroy()` (so `onDelete` is queued) and gives the entity. Entities read back are rebuilt from their stored value, with their identity, so they do not queue `onCreate` again.
|
|
694
|
-
|
|
695
|
-
**Providers.** A repository reads and writes through an `IRepositoryProvider`: `findOne`, `findMany`, `insert`, `update`, `remove` and `count`, on plain unmasked values, each getting a `context` with the `entity`, the repository `name`, its `unique` keys and `env`. A provider enforces `context.unique` (`RepositoryUniqueViolation`) and rejects an unknown id on `update` and `remove` (`RepositoryEntityNotFound`). By default each repository gets its own `inMemoryProvider()`; repositories given the same provider share the table of their entity. `repositoryOf` checks that the provider has the methods its actions call.
|
|
696
|
-
|
|
697
|
-
**Environments.** `environmentOf(blueprint)` reads the declared variables from `process.env` (or `{ source }`), validates them on the first `load()` and keeps the value; a missing or rejected variable throws `InvalidEnvironment`. Pass one, or a list (merged in order), as `environmentDependencies`: the provider gets them as `context.env`, read on the first call, not on declaration.
|
|
698
|
-
|
|
699
|
-
```ts
|
|
700
|
-
const Database = environmentOf(
|
|
701
|
-
blueprintOf((ctx) => ({ DATABASE_URL: ctx.url })),
|
|
702
|
-
);
|
|
703
|
-
const Posts = repositoryOf({
|
|
704
|
-
entity: Post,
|
|
705
|
-
actions: ["findById"],
|
|
706
|
-
provider: postgres,
|
|
707
|
-
environmentDependencies: [Database],
|
|
708
|
-
});
|
|
709
|
-
```
|
|
710
|
-
|
|
711
|
-
A variable is converted when its property does not take a string: to `true` or `false` (from `"true"` and `"false"`) for a boolean, and to a number for a number, e.g. `PORT: ctx.integer` reads `"3000"` as `3000`, and `DEBUG: ctx.boolean` reads `"true"` as `true`. A property that takes a string keeps the variable as is, so `"10"` stays a string for `ctx.string`. A value that does not convert is left for the blueprint to reject.
|
|
712
|
-
|
|
713
|
-
**References.** An entity a record holds (as a field, in a list, in a blueprint, or in a record that is not an entity) is stored as its id, not as a copy, and loaded back when the record is read, so it is always current. List the repositories that load them in `relations`: one for each entity class held, at any depth (the class itself, or a subclass). A missing, extra, duplicated or invalid relation throws `InvalidRepositoryDeclaration`. A stored id with no entity throws `InvalidRepositoryData`, caused by `RepositoryEntityNotFound`, even in an optional field. Reads load by batch (one `findIn` of the provider for each relation, when it has one, or a `findOne` for each distinct id), and the same id is the same instance within a read. Writes do not cascade: the entity is not checked or saved, so persist it through its own repository. Entities loaded this way do not queue `onCreate` again, and a use case collects the events of an entity only when it goes through that entity's own repository. `findBy<Key>` and `findManyBy<Key>` take the entity or its id (a list of them for a list field).
|
|
714
|
-
|
|
715
|
-
```ts
|
|
716
|
-
class PasswordRecovery extends entityOf(
|
|
717
|
-
blueprintOf((ctx) => ({ user: User, token: ctx.string })),
|
|
718
|
-
{ unique: ["token"] },
|
|
719
|
-
) {}
|
|
720
|
-
|
|
721
|
-
const Recoveries = repositoryOf({
|
|
722
|
-
entity: PasswordRecovery,
|
|
723
|
-
actions: ["findByToken", "findManyByUser", "create"],
|
|
724
|
-
relations: [Users], // stored as { user: "<id>", token: "..." }
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
await Recoveries.findManyByUser(user); // or findManyByUser(user.id)
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
**Extending.** `RepositoryOf(repository)` is the class of a repository, to extend. Its instances share the entity, actions, provider and environment of the repository, and can use the protected `provider`, `context`, `env`, `dataOf(entity)`, and the async `hydrate(data)` and `hydrateMany(list)`, which also load the entities a stored value holds. `create`, `update`, `delete` and `count` are overridden as methods. `findBy<Key>` and `findManyBy<Key>` are named after the entity's keys, so TypeScript types them as properties: override them with a field, which can still call `super`.
|
|
255
|
+
The `run` step names a function written by hand, imported relative to the output directory. Put it in `src/hash-password.ts`:
|
|
731
256
|
|
|
732
257
|
```ts
|
|
733
|
-
|
|
734
|
-
override findByEmail = async (email: string) =>
|
|
735
|
-
super.findByEmail(email.trim().toLowerCase());
|
|
736
|
-
|
|
737
|
-
override async create(...args: Parameters<typeof Users.create>) {
|
|
738
|
-
const user = await super.create(...args);
|
|
739
|
-
// ...
|
|
740
|
-
return user;
|
|
741
|
-
}
|
|
258
|
+
import { Password } from "@afora/core";
|
|
742
259
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
260
|
+
export async function hashPassword(plain: string) {
|
|
261
|
+
return (await Password.hash(plain)).value;
|
|
746
262
|
}
|
|
747
263
|
```
|
|
748
264
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
**In use cases.** A repository (or an instance of a class extending one) and an environment are dependencies of a use case, next to other use cases. The use case gets the repository's methods, usable only during execution; the events of the entities they take and give move to the use case. In a class extending a repository, the methods call each other through the use case too (`this.create` inside `revokeAllOf`): their events move to it, and what `execute` replaces is replaced there as well. Only the events of entities of the repository's own class move; the entities it holds (`post.author`) leave theirs to their own repository. Two limits: `#private` members do not work in the methods the use case calls, and an arrow field calling `this.other` does not go through the use case (`super.other` does); a repository used by importing it is not tracked. It gets an environment's variables. `execute` replaces some methods of a repository with an object, and an environment with variables its blueprint accepts.
|
|
265
|
+
Then generate, telling codegen where the `hono` stage comes from, and serve the application:
|
|
752
266
|
|
|
753
|
-
```
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
blueprint: UserInput,
|
|
757
|
-
response: UserResponse,
|
|
758
|
-
dependencies: { users: Users, database: Database },
|
|
759
|
-
}) {
|
|
760
|
-
override async execute({ users = this.dependencies.users } = {}) {
|
|
761
|
-
return { user: await users.create(this.input) };
|
|
762
|
-
}
|
|
763
|
-
}
|
|
267
|
+
```bash
|
|
268
|
+
bun add -d @afora/codegen
|
|
269
|
+
bunx afora-codegen generate afora.json --stages @afora/hono # writes src/domain/*.ts
|
|
764
270
|
```
|
|
765
271
|
|
|
766
|
-
### Applications
|
|
767
|
-
|
|
768
|
-
`applicationOf` gathers the declarations of an app in one named, immutable manifest, so everything runs in one context and can be inspected:
|
|
769
|
-
|
|
770
272
|
```ts
|
|
771
|
-
|
|
772
|
-
name: "blog",
|
|
773
|
-
properties: { Age, Status },
|
|
774
|
-
blueprints: { Address },
|
|
775
|
-
entities: { User, Post },
|
|
776
|
-
environments: { Database },
|
|
777
|
-
repositories: { Users, Posts },
|
|
778
|
-
provider: inMemoryProvider(), // the default of the repositories that declared none
|
|
779
|
-
});
|
|
273
|
+
import { App } from "./src/domain";
|
|
780
274
|
|
|
781
|
-
|
|
782
|
-
Blog.repositories.Users; // typed; bound to Blog's provider
|
|
783
|
-
Blog.repository(Users); // the same, from the original
|
|
275
|
+
Bun.serve({ fetch: App.outputs.Api.build().fetch });
|
|
784
276
|
```
|
|
785
277
|
|
|
786
|
-
|
|
278
|
+
`generate` writes one file per kind (`entities.ts`, `repositories.ts`, `use-cases.ts`, `commands.ts`, `outputs.ts`...), an `application.ts` and an `index.ts`, each starting with a banner saying not to edit it. `afora-codegen extract` goes the other way and gives the `afora.json` of an `Application`. The guide is in [docs/afora-json.md](docs/afora-json.md).
|
|
787
279
|
|
|
788
|
-
|
|
280
|
+
## What you get
|
|
789
281
|
|
|
790
|
-
|
|
282
|
+
Each concept has a page in [docs/](docs/README.md), in the order they build on one another:
|
|
791
283
|
|
|
792
|
-
|
|
284
|
+
- [Properties](docs/properties.md): one validated and normalized value, with flags for `sensitive` and `unique`.
|
|
285
|
+
- [Blueprints](docs/blueprints.md): an object shape built from properties, lists, optionals and nested blueprints.
|
|
286
|
+
- [Records](docs/records.md): a class that holds the handled value, masks sensitive keys and generates `change.<key>`; plugins; experimental slicing.
|
|
287
|
+
- [Entities](docs/entities.md): a record with identity, timestamps, `destroy()` and domain events.
|
|
288
|
+
- [Repositories](docs/repositories.md): typed async methods for an entity, queries, soft deletion, providers, transactions and relations.
|
|
289
|
+
- [Use cases](docs/use-cases.md): the Command pattern with method injection, written as code or as steps.
|
|
290
|
+
- [Commands](docs/commands.md): use cases under operation names, with a context, a transaction and an event policy.
|
|
291
|
+
- [Outputs](docs/outputs.md): commands through stages into a sink, such as an HTTP API or an OpenAPI document.
|
|
292
|
+
- [Applications](docs/applications.md): a named, checked manifest of every declaration, and `describe()`.
|
|
293
|
+
- [Exceptions](docs/exceptions.md): errors that know their layer and keep a trace.
|
|
294
|
+
- [Environments](docs/environments.md): validated environment variables.
|
|
295
|
+
- [Standard Schema](docs/standard-schema.md), [OpenAPI](docs/openapi.md) and the [afora.json guide](docs/afora-json.md).
|
|
793
296
|
|
|
794
|
-
|
|
297
|
+
## Standard Schema
|
|
795
298
|
|
|
796
|
-
Every
|
|
299
|
+
Every field (a property, blueprint, list or record class) implements Standard Schema V1 (`vendor: "afora"`), so Hono, tRPC, TanStack Form and any other library that takes one takes it as is. `validate` is synchronous, never throws for an invalid value and reports every issue with its `path`. Without a framework, `safeParse` returns `{ value }` or `{ issues }`. See [docs/standard-schema.md](docs/standard-schema.md); the real-library tests are in `examples/interop`.
|
|
797
300
|
|
|
798
|
-
|
|
799
|
-
import {
|
|
800
|
-
ExceptionLayers,
|
|
801
|
-
exceptionOf,
|
|
802
|
-
guard,
|
|
803
|
-
InvalidPropertyValue,
|
|
804
|
-
} from "@afora/core";
|
|
805
|
-
|
|
806
|
-
const UserNotCreated = exceptionOf({
|
|
807
|
-
layer: ExceptionLayers.UseCase,
|
|
808
|
-
name: "UserNotCreated",
|
|
809
|
-
message: "Could not create the user.",
|
|
810
|
-
});
|
|
301
|
+
## Packages
|
|
811
302
|
|
|
812
|
-
|
|
813
|
-
{ layer: ExceptionLayers.UseCase, source: "CreateUser" },
|
|
814
|
-
() => new Name(input).value,
|
|
815
|
-
(exception, pass) => {
|
|
816
|
-
if (exception.is(InvalidPropertyValue)) return "Jane"; // resolve it here
|
|
817
|
-
return pass(); // or send it up, with this layer added to the trace
|
|
818
|
-
},
|
|
819
|
-
);
|
|
303
|
+
`@afora/codegen` generates afora code from an `afora.json` (and extracts the `afora.json` of an `Application` back), and `@afora/mcp` is an MCP server that lets an agent build a domain with it: see [packages/codegen](packages/codegen/README.md) and [packages/mcp](packages/mcp/README.md). `@afora/hono` is a `hono` sink for outputs: it serves the operations as a Hono app, with problem+json errors, masked responses and an optional OpenAPI document ([packages/hono](packages/hono/README.md)). `@afora/drizzle` is a repository provider on Drizzle ORM (SQLite and Postgres) that passes the provider contract kit: see [packages/drizzle](packages/drizzle/README.md).
|
|
820
304
|
|
|
821
|
-
|
|
822
|
-
throw new UserNotCreated({ source: "CreateUser", cause: exception });
|
|
823
|
-
```
|
|
305
|
+
## Example
|
|
824
306
|
|
|
825
|
-
`
|
|
307
|
+
`examples/blog` is a blog (users with the `authenticatable` plugin, posts with soft deletion, use cases written as steps, a Hono API on Drizzle and SQLite) whose whole `src/domain` is generated from one `afora.json` by `@afora/codegen`. Only the database, the schema, the provider (made from a `Database` environment with `providerFrom` when the server calls `App.start()`), a password hashing function and the server are hand-written. Its tests check that the committed code is what the spec generates, drive the API end to end and extract the spec back from the application: `bun run test:blog`.
|
|
826
308
|
|
|
827
309
|
## Performance
|
|
828
310
|
|
|
@@ -851,18 +333,21 @@ A rough guide, not a verdict. "Partial" means it is possible but not what the li
|
|
|
851
333
|
|
|
852
334
|
Where the others win: Zod and Valibot are faster, have far larger ecosystems and documentation, and have integrations and code generators for most tools; Valibot is modular and small; Effect Schema fits a codebase already built on Effect. afora gives up those to model entities, masking and layers in the type you already validate with.
|
|
853
335
|
|
|
854
|
-
##
|
|
336
|
+
## Contributing
|
|
855
337
|
|
|
856
|
-
|
|
338
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org) (`feat(repository): …`, `fix: …`), checked by commitlint in a Husky `commit-msg` hook; the `pre-commit` hook runs Biome and Knip. CI runs lint, Knip, type checks, unit and interop tests, the build and the Node smoke test on every push and pull request. Pushing a `v*` tag that matches `package.json` publishes to npm with provenance.
|
|
857
339
|
|
|
858
|
-
|
|
340
|
+
Scripts:
|
|
859
341
|
|
|
860
342
|
```bash
|
|
861
343
|
bun install
|
|
862
344
|
bun test # unit tests
|
|
863
345
|
bun check # type check
|
|
864
346
|
bun run test:types # build, then type-check the packed package on TypeScript, bundler and nodenext resolution
|
|
347
|
+
bun run test:type-perf # build, then budget the editor cost per edit and snapshot hovers, completions and errors
|
|
348
|
+
bun run test:api # build, then snapshot the public surface (exports and declarations) and the afora.json schema
|
|
865
349
|
bun run test:interop # Hono, tRPC and TanStack Form tests (examples/interop), including the quick start
|
|
350
|
+
bun run test:blog # the generated blog (examples/blog): drift, API and extraction
|
|
866
351
|
bun run smoke # build, then run the package on plain Node
|
|
867
352
|
bun run bench # benchmark against TypeBox, Zod, Valibot and ArkType
|
|
868
353
|
bun run lint # biome lint
|
|
@@ -871,12 +356,14 @@ bun run fmt # biome format + fix
|
|
|
871
356
|
bun run build # build dist/ (ESM + .d.ts)
|
|
872
357
|
bun run test:codegen # tests of packages/codegen
|
|
873
358
|
bun run test:mcp # tests of packages/mcp
|
|
874
|
-
bun run
|
|
359
|
+
bun run test:hono # tests of packages/hono
|
|
360
|
+
bun run test:drizzle # tests of packages/drizzle (SQLite and PGlite)
|
|
361
|
+
bun run build:packages # build the core, then packages/codegen, packages/mcp, packages/hono and packages/drizzle
|
|
875
362
|
```
|
|
876
363
|
|
|
877
|
-
##
|
|
364
|
+
## Versioning
|
|
878
365
|
|
|
879
|
-
|
|
366
|
+
What is stable, what may break and how deprecations work is in [VERSIONING.md](VERSIONING.md).
|
|
880
367
|
|
|
881
368
|
## License
|
|
882
369
|
|