@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.
Files changed (82) hide show
  1. package/README.md +220 -733
  2. package/dist/core/application/application.d.ts +9 -1
  3. package/dist/core/application/exceptions.d.ts +28 -0
  4. package/dist/core/application/registry.d.ts +33 -0
  5. package/dist/core/application/start.d.ts +75 -0
  6. package/dist/core/application/types/application.interface.d.ts +229 -7
  7. package/dist/core/blueprint/types/blueprint.interface.d.ts +66 -11
  8. package/dist/core/blueprint/types/slice.interface.d.ts +7 -2
  9. package/dist/core/command/command.d.ts +25 -0
  10. package/dist/core/command/exceptions.d.ts +33 -0
  11. package/dist/core/command/types/command.interface.d.ts +108 -0
  12. package/dist/core/entity/event.d.ts +12 -2
  13. package/dist/core/environment/environment.d.ts +4 -1
  14. package/dist/core/exception/exception.d.ts +4 -0
  15. package/dist/core/exception/exported.d.ts +4 -0
  16. package/dist/core/json.d.ts +10 -0
  17. package/dist/core/logger/exceptions.d.ts +4 -0
  18. package/dist/core/logger/logger.d.ts +106 -0
  19. package/dist/core/output/exceptions.d.ts +8 -0
  20. package/dist/core/output/openapi-sink.d.ts +83 -0
  21. package/dist/core/output/output.d.ts +19 -0
  22. package/dist/core/output/problem.d.ts +15 -0
  23. package/dist/core/output/select.d.ts +14 -0
  24. package/dist/core/output/stage.d.ts +24 -0
  25. package/dist/core/output/status.d.ts +17 -0
  26. package/dist/core/output/types/output.interface.d.ts +171 -0
  27. package/dist/core/property/abstract-property.d.ts +8 -7
  28. package/dist/core/property/define-property.d.ts +8 -7
  29. package/dist/core/property/property.d.ts +26 -15
  30. package/dist/core/property/types/abstract-property-constructor.type.d.ts +8 -7
  31. package/dist/core/property/types/define-property.interface.d.ts +5 -4
  32. package/dist/core/property/types/optional-property.type.d.ts +5 -5
  33. package/dist/core/property/types/property-collection.type.d.ts +2 -2
  34. package/dist/core/property/types/property-constructor.type.d.ts +5 -1
  35. package/dist/core/property/types/property-options.interface.d.ts +17 -8
  36. package/dist/core/property/types/property-type.interface.d.ts +7 -3
  37. package/dist/core/property/types/property.interface.d.ts +5 -4
  38. package/dist/core/record/abstract-record.d.ts +12 -4
  39. package/dist/core/record/types/plugin.interface.d.ts +13 -3
  40. package/dist/core/record/types/record.interface.d.ts +20 -8
  41. package/dist/core/repository/exceptions.d.ts +4 -0
  42. package/dist/core/repository/in-memory.d.ts +3 -0
  43. package/dist/core/repository/provider.d.ts +6 -0
  44. package/dist/core/repository/query.d.ts +9 -0
  45. package/dist/core/repository/references.d.ts +4 -2
  46. package/dist/core/repository/repository.d.ts +4 -2
  47. package/dist/core/repository/state.d.ts +4 -0
  48. package/dist/core/repository/types/query.interface.d.ts +67 -0
  49. package/dist/core/repository/types/repository.interface.d.ts +13 -13
  50. package/dist/core/schema/types/schema.interface.d.ts +11 -4
  51. package/dist/core/symbols/meta.d.ts +2 -0
  52. package/dist/core/use-case/exceptions.d.ts +10 -0
  53. package/dist/core/use-case/scope.d.ts +24 -0
  54. package/dist/core/use-case/steps/describe.d.ts +13 -0
  55. package/dist/core/use-case/steps/references.d.ts +27 -0
  56. package/dist/core/use-case/steps/run.d.ts +20 -0
  57. package/dist/core/use-case/steps/types/steps.interface.d.ts +153 -0
  58. package/dist/core/use-case/steps/validate.d.ts +17 -0
  59. package/dist/core/use-case/use-case.d.ts +79 -13
  60. package/dist/experimental.d.ts +6 -0
  61. package/dist/experimental.js +123 -0
  62. package/dist/index-ewm6r4nw.js +5335 -0
  63. package/dist/index-hn82hm31.js +1651 -0
  64. package/dist/index.d.ts +34 -12
  65. package/dist/index.js +343 -4142
  66. package/dist/std/blueprint-plugin.d.ts +10 -10
  67. package/dist/std/blueprint.d.ts +10 -10
  68. package/dist/std/entity.d.ts +2 -2
  69. package/dist/std/properties/contact.d.ts +4 -4
  70. package/dist/std/properties/enum.d.ts +1 -1
  71. package/dist/std/properties/headless.d.ts +1 -1
  72. package/dist/std/properties/identity.d.ts +2 -2
  73. package/dist/std/properties/password.d.ts +1 -1
  74. package/dist/std/properties/primitives.d.ts +5 -5
  75. package/dist/std/properties/time.d.ts +2 -2
  76. package/dist/std/record-plugins.d.ts +12 -12
  77. package/dist/std/record.d.ts +1 -1
  78. package/dist/std/stages.d.ts +2 -0
  79. package/dist/testing/index.d.ts +2 -0
  80. package/dist/testing/index.js +502 -0
  81. package/dist/testing/provider-contract.d.ts +22 -0
  82. 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` record created from an HTTP body through Standard Schema, saved by a repository that reads `User.options.unique`, returned with its password masked; and an error that keeps the layers it crossed. This is the code of `examples/interop/quickstart.ts`, run by `bun run test:interop` (there the import is the local source instead of `"@afora/core"`).
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
- guard,
47
- listOf,
55
+ outputOf,
48
56
  Password,
49
- recordOf,
50
- type StandardSchemaV1,
57
+ repositoryOf,
58
+ useCaseOf,
51
59
  } from "@afora/core";
60
+ import { hono } from "@afora/hono";
52
61
 
53
- // Domain: a record with invariants, a sensitive key and unique intent
54
- const Address = blueprintOf((ctx) => ({ street: ctx.string, zip: ctx.string }));
55
-
56
- const UserBlueprint = blueprintOf((ctx) => ({
57
- email: ctx.email, // trimmed and lowercased
58
- slug: ctx.slug, // slugified; unique by declaration
59
- password: ctx.password, // only an argon2 hash is accepted; always masked
60
- address: Address,
61
- tags: listOf(ctx.string, { maxItems: 5 }),
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
- // Input: the HTTP body carries a plain password, which never enters the record
70
- const SignUpBody = blueprintOf((ctx) => ({
71
- email: ctx.email,
72
- slug: ctx.slug,
73
- plainPassword: ctx.string,
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.Repository,
79
+ layer: ExceptionLayers.UseCase,
81
80
  name: "UserAlreadyExists",
82
- message: "A user with this email or slug already exists.",
81
+ message: "A user with this email already exists.",
83
82
  });
84
83
 
85
- class UserRepository {
86
- readonly #users: User[] = [];
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
- type SignUpData = StandardSchemaV1.InferOutput<typeof SignUpBody>;
128
- ```
129
-
130
- What it does:
131
-
132
- ```ts
133
- // POST /users with { email: " Jane@Example.COM ", slug: "Olá Mundo", plainPassword: "Str0ng!Pass", ... }
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
- const Post = entityOf(
221
- blueprintOf((ctx) => ({ title: ctx.text, author: User })),
222
- );
223
-
224
- toOpenAPI(User); // { type: "object", properties: { id: {..., readOnly: true }, ... } }
225
-
226
- const document = {
227
- openapi: "3.1.0",
228
- info: { title: "Blog", version: "1.0.0" },
229
- paths: {},
230
- components: toOpenAPI({ User, Post }), // Post.author is { $ref: "#/components/schemas/User" }
231
- };
232
- ```
233
-
234
- What `Property.with` (or `ctx.<property>(options)`) gives, `description`, `examples`, bounds and the rest, is kept. Sensitive keys (`Password`, `options.sensitive`) are `writeOnly`, the identity of an entity (`id`, `createdAt`, `updatedAt`, `deletedAt`) is `readOnly`, optional keys are left out of `required`, and every object has `additionalProperties: false`, since blueprints reject undeclared keys. Records not named among the models are inline; `refPrefix` changes where a `$ref` points (`"#/components/schemas/"` by default). A model that is not a field throws `InvalidOpenAPIModel`.
235
-
236
- ## Concepts
237
-
238
- ### Properties
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
- ```ts
243
- import { Type } from "typebox";
244
- import { propertyOf } from "@afora/core";
245
-
246
- const Name = propertyOf({
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
- new Name(" Alice ").value; // "Alice"
254
- new Name("a"); // throws: Invalid value for property "name": ...
255
- Name.demo().value; // "Jane"
256
- ```
257
-
258
- Pass `optional: true` to accept `undefined`, `sensitive: true` to have records mask it (as `Password` does) and `unique: true` to have records list its key in `options.unique` (as `Slug` does). `Std` ships `Id`, `Slug`, `Email`, `Phone`, `Url`, `Ip`, `Password`, strings, numbers, booleans and date properties.
259
-
260
- `Property.with(options)` gives a subclass narrowed and documented by `options`. In a blueprint, every property of the context can be called for the same thing, as `ctx.number({ min: 1 })`; used as it is, `ctx.number` is still the class:
261
-
262
- ```ts
263
- import { blueprintOf, Integer } from "@afora/core";
264
-
265
- const Age = Integer.with({ min: 0, max: 150, description: "In years" });
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
- A second argument describes the keys:
141
+ export const api = Api.build();
388
142
 
389
- ```ts
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
- Keys are resolved in the order of the blueprint.
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
- class User extends entityOf(
425
- blueprintOf((ctx) => ({ name: ctx.string })),
426
- { change: ["name"] },
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
- const user = new User({ name: "Jane" });
430
- user.id; // "019a…" (UUID v7)
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
- // Loaded from a database: the identity given is validated and kept
436
- new User({ id, createdAt, updatedAt, deletedAt, name: "Jane" });
155
+ { "name": "Ana", "email": "nope" }
156
+ -> 400 application/problem+json { ..., "issues": [{ "path": ["email"], ... }, { "path": ["plainPassword"], ... }] }
437
157
 
438
- // As any record, an entity is a field of a record or of another entity
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
- The identity keys are read-only: `set` throws `ImmutableEntityKey` for them, and they get no `change.<key>` method. The blueprint cannot declare them, nor the members of an entity (`destroy`, `isDeleted`, `equals`, `onDestroy`, `emit`, `pullEvents`): `entityOf` throws `ReservedEntityKey`. `user.equals(other)` compares entities of the same declaration by `id`. `isEntityClass` and `isEntity` tell entities apart from records. `entityBuilderOf({ identity, generateId, now })` makes an `entityOf` with your own identity fields and clock.
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
- const UserBlueprint = blueprintOf((ctx) => ({ name: ctx.string }));
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
- // The annotation breaks TypeScript's circular inference through the base class.
475
- // The handler parameter is still inferred as User.
476
- const UserCreated: EventDefinition = eventOf(
477
- "user.created",
478
- User,
479
- async (user) => {
480
- console.log(user.id);
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
- const UserUpdated: EventDefinition = eventOf(
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
- const UserDeleted: EventDefinition = eventOf(
491
- "user.deleted",
492
- User,
493
- async (user) => {
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
- const user = new User({ name: "Jane" });
499
- user.change.name("Ann");
500
- user.announce();
501
- for (const event of user.pullEvents()) {
502
- await event.handle();
503
- }
504
- ```
505
-
506
- `emit` is protected and synchronous: it queues without executing the handler. `pullEvents()` immediately empties that instance's queue and returns occurrences with `name`, `entity`, and `handle(): Promise<void>`, in emission order, including duplicates. Each occurrence references the original entity and sees its state when handled, without making a snapshot. Events are excluded from `toJSON()` and JSON serialization. Records have no event API.
507
-
508
- Each lifecycle option accepts a definition, a readonly list, or a function returning either. Functions are resolved only during their cycle, so definitions may follow the entity class but must exist before constructing instances. `onCreate` queues after base construction and validation only when **both** input `id` and `createdAt` are `undefined`. Supplying either identity field, as when loading persisted data, suppresses creation; parsing plain input follows the same rule. `onUpdate` queues after each valid `set` (including `change.<key>` and equal values) stores the value and `updatedAt`, before `onSetAll` and `onSet`. `onDelete` queues after `deletedAt` is stored, before `onDestroy`. Invalid operations and repeated deletion do not queue lifecycle events. If an existing hook throws after a mutation, its queued events remain.
509
-
510
- The consuming layer decides when to drain and execute events. `handle()` propagates handler failures and can be called more than once. There is no automatic execution, dispatcher, retry, or execution-once guarantee.
511
-
512
-
513
- ### Plugins
514
-
515
- A plugin carries keys, options and behavior that records and entities share, with no identity of its own. It is made from a record class: the keys of its blueprint, its `sensitive`, `unique` and `change` keys, the methods and getters its class declares, and its `onSet` and `onSetAll`. A record or an entity lists plugins in `options.plugins`, and gets all of that:
516
-
517
- ```ts
518
- import {
519
- blueprintOf,
520
- entityOf,
521
- hasPlugin,
522
- recordOf,
523
- recordPluginOf,
524
- type RecordEffects,
525
- } from "@afora/core";
526
-
527
- class SluggedRecord extends recordOf(
528
- blueprintOf((ctx) => ({ slug: ctx.slug })), // Slug: always unique
529
- { change: ["slug"] },
530
- ) {
531
- get path(): string {
532
- return `/${this.slug}`;
533
- }
534
-
535
- protected override onSet(): RecordEffects<SluggedRecord> {
536
- return { slug: (value) => console.log(`moved to /${value}`) };
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
- | Plugin | Keys | Adds |
573
- | ----------------- | ---------------------------------------- | ------------------------------------------------ |
574
- | `publishable` | `publishedAt?` | `publish(at?)`, `unpublish()`, `isPublished` |
575
- | `archivable` | `archivedAt?` | `archive(at?)`, `restore()`, `isArchived` |
576
- | `taggable` | `tags?` (strings) | `tag(...tags)`, `untag(...tags)`, `hasTag(tag)` |
577
- | `ownable` | `ownerId` (an `Id`) | `isOwnedBy(id)`, `transferTo(id)` |
578
- | `authenticatable` | `email` (unique), `password` (sensitive) | `change.password(hash)`, `verifyPassword(plain)` |
579
-
580
- Your own catalog is `recordPluginCatalogOf(Slugged, ...StdRecordPlugins.list)`, given to `recordBuilderOf(catalog)` or to `entityBuilderOf({ ..., plugins: [catalog] })`. A later catalog replaces a plugin of an earlier one that has the same name.
581
-
582
- **Serialization.** A plugin is a descriptor in JSON (`plugin.toJSON()`): its name, version, keys, `sensitive`, `unique` and `change` keys, member names and requirements. Methods are listed by name, never serialized as code, so a catalog resolves a descriptor back to the plugin that implements it:
583
-
584
- ```ts
585
- const json = JSON.stringify(Post.plugins);
586
- // [{"kind":"record-plugin","name":"slugged","version":"1.0.0","keys":["slug"],...}]
587
-
588
- const plugins = catalog.resolveAll(JSON.parse(json));
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 constructor validates its arguments and exposes their parsed values as read-only `this.input`. Implement `execute` as an async prototype method, not an arrow-function field. It must return the value shape of the response blueprint, including record/entity instances for those fields. The framework wraps the method automatically to validate the response and collect events; no `super.execute()` call is needed. Each instance allows one execution attempt, including failures; concurrent or subsequent calls reject with `UseCaseAlreadyExecuted`.
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
- class UsersRepository extends RepositoryOf(Users) {
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
- async countAll() {
744
- return this.provider.count(this.context);
745
- }
260
+ export async function hashPassword(plain: string) {
261
+ return (await Password.hash(plain)).value;
746
262
  }
747
263
  ```
748
264
 
749
- As a type, `RepositoryOf<typeof User, ["findById", "create"]>` is the repository with those methods.
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
- ```ts
754
- class Register extends useCaseOf({
755
- name: "Register",
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
- const Blog = applicationOf({
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
- Blog.entities.User; // the declarations, by name
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
- It checks the set as a whole when declared: one object under two names (`DuplicateApplicationDeclaration`), an entity or record held at any depth (fields, lists, optionals, nested blueprints) that is not registered (`UnregisteredApplicationReference`), and a repository whose entity, `relations` or `environmentDependencies` are not (`UnregisteredRepositoryDependency`). A declaration of the wrong kind throws `InvalidApplicationDeclaration`.
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
- `extends: [Auth]`, or `Blog.extend(Billing)`, which returns a new application, inherits declarations; the extended ones stay usable alone and are never changed. The same object under the same name is registered once; one name for different objects, or one object under two names, throws `ApplicationConflict` naming both origins. References may cross applications (a `Post` of `Blog` holding the `User` of `Auth`), and validation runs on the resolved set.
280
+ ## What you get
789
281
 
790
- Repositories are module-level and may be shared, so an application does not change them: `app.repositories.Users` shares the declaration of `Users` but reads and writes through the `provider` of the application (or of the last application it extends that has one), unless `Users` declared its own. Two applications with different providers do not share data. Without a `provider`, `app.repositories.Users` is `Users` itself, and records, entities and repositories work the same outside any application.
282
+ Each concept has a page in [docs/](docs/README.md), in the order they build on one another:
791
283
 
792
- `app.describe()` gives a JSON-serializable graph, with the `from` of each declaration (the application that registered it): properties (name, flags, JSON Schema and `lineage`), blueprints, records and entities (fields, `options`, plugin descriptors), environments, and repositories (entity, actions, `relations` and `environments` by name). A field is a reference by name (`{ kind: "entity", ref: "User" }`) when it holds a registered declaration. A property's `lineage` comes from `propertyLineageOf(Property)`: `Integer.with({ min: 0 })` is `{ kind: "refined", base, options }`, `optionalOf` is `{ kind: "optional", base }` and `enumOf` is `{ kind: "factory", factory: "enumOf", args: { values } }`. A root property (declared with `propertyOf`) has no lineage and is identified by its `name`; one with custom handlers is described with what is known (name and schema), never an error.
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
- ### Exceptions
297
+ ## Standard Schema
795
298
 
796
- Every error the library raises is an `Exception` that knows its `layer` (`ExceptionLayers`: `Schema`, `Property`, `Blueprint`, `Record`, `Entity`, `UseCase`, `Repository`, `Environment`, `Event`, `Command`, `Output`), its `name` and its `source` (who raised it). Passing it up keeps the whole `trace`:
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
- ```ts
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
- guard(
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
- // or wrap it in an exception of your own
822
- throw new UserNotCreated({ source: "CreateUser", cause: exception });
823
- ```
305
+ ## Example
824
306
 
825
- `exception.trace` lists each step from the origin, `exception.origin` is the first one raised, and `toJSON()`/`toString()` print all of it.
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
- ## Packages
336
+ ## Contributing
855
337
 
856
- `@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).
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
- ## Scripts
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 build:packages # build the core, then packages/codegen and packages/mcp
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
- ## Contributing
364
+ ## Versioning
878
365
 
879
- 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.
366
+ What is stable, what may break and how deprecations work is in [VERSIONING.md](VERSIONING.md).
880
367
 
881
368
  ## License
882
369