@orkestrel/scaffold 0.0.67 → 0.0.69

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 (74) hide show
  1. package/dist/bin/main.js +67 -44
  2. package/dist/bin/main.js.map +1 -1
  3. package/dist/host/agents/templates/brief.md +9 -0
  4. package/dist/host/claude/agents/orkestrel.md +4 -4
  5. package/dist/host/claude/rules/names.md +15 -0
  6. package/dist/host/claude/rules/tests.md +33 -4
  7. package/dist/host/claude/rules/workspace.md +14 -2
  8. package/dist/host/dotfiles/prettierignore +3 -0
  9. package/dist/host/guides/README.md +65 -0
  10. package/dist/host/guides/abort.md +169 -0
  11. package/dist/host/guides/agent.md +1567 -0
  12. package/dist/host/guides/brief.md +1266 -0
  13. package/dist/host/guides/browser.md +2200 -0
  14. package/dist/host/guides/budget.md +196 -0
  15. package/dist/host/guides/codec.md +519 -0
  16. package/dist/host/guides/console.md +785 -0
  17. package/dist/host/guides/contract.md +1193 -0
  18. package/dist/host/guides/csv.md +541 -0
  19. package/dist/host/guides/database.md +2518 -0
  20. package/dist/host/guides/emitter.md +233 -0
  21. package/dist/host/guides/form.md +1791 -0
  22. package/dist/host/guides/html.md +717 -0
  23. package/dist/host/guides/indexeddb.md +505 -0
  24. package/dist/host/guides/interpret.md +1029 -0
  25. package/dist/host/guides/lsp.md +515 -0
  26. package/dist/host/guides/markdown.md +964 -0
  27. package/dist/host/guides/mcp.md +5554 -0
  28. package/dist/host/guides/middleware.md +927 -0
  29. package/dist/host/guides/msg.md +440 -0
  30. package/dist/host/guides/ndjson.md +120 -0
  31. package/dist/host/guides/ollama.md +380 -0
  32. package/dist/host/guides/pool.md +280 -0
  33. package/dist/host/guides/probe.md +1210 -0
  34. package/dist/host/guides/process.md +1620 -0
  35. package/dist/host/guides/program.md +1110 -0
  36. package/dist/host/guides/qualifier.md +854 -0
  37. package/dist/host/guides/queue.md +370 -0
  38. package/dist/host/guides/rater.md +330 -0
  39. package/dist/host/guides/reason.md +1122 -0
  40. package/dist/host/guides/relation.md +373 -0
  41. package/dist/host/guides/router.md +753 -0
  42. package/dist/host/guides/scaffold.md +192 -31
  43. package/dist/host/guides/sea.md +383 -0
  44. package/dist/host/guides/server.md +752 -0
  45. package/dist/host/guides/sqlite.md +330 -0
  46. package/dist/host/guides/sse.md +187 -0
  47. package/dist/host/guides/supervisor.md +4890 -0
  48. package/dist/host/guides/table.md +1556 -0
  49. package/dist/host/guides/template.md +280 -0
  50. package/dist/host/guides/terminal.md +1145 -0
  51. package/dist/host/guides/test.md +2969 -0
  52. package/dist/host/guides/timeout.md +252 -0
  53. package/dist/host/guides/tool.md +507 -0
  54. package/dist/host/guides/toolbox.md +1038 -0
  55. package/dist/host/guides/websocket.md +282 -0
  56. package/dist/host/guides/worker.md +615 -0
  57. package/dist/host/guides/workflow.md +1507 -0
  58. package/dist/host/guides/workspace.md +595 -0
  59. package/dist/host/manifest.json +1218 -10
  60. package/dist/host/tests/policy.test.ts +279 -2
  61. package/dist/host/tests/setupPolicy.ts +445 -6
  62. package/dist/src/core/index.cjs +38 -16
  63. package/dist/src/core/index.cjs.map +1 -1
  64. package/dist/src/core/index.d.cts +33 -9
  65. package/dist/src/core/index.d.ts +33 -9
  66. package/dist/src/core/index.js +37 -17
  67. package/dist/src/core/index.js.map +1 -1
  68. package/dist/src/server/index.cjs +1750 -1567
  69. package/dist/src/server/index.cjs.map +1 -1
  70. package/dist/src/server/index.d.cts +106 -24
  71. package/dist/src/server/index.d.ts +106 -24
  72. package/dist/src/server/index.js +1751 -1570
  73. package/dist/src/server/index.js.map +1 -1
  74. package/package.json +3 -3
@@ -0,0 +1,373 @@
1
+ # Relation
2
+
3
+ > A small, declarative ORM layer over the `@orkestrel/database` tables: a table's
4
+ > relations named once, then records loaded or found with their related rows already
5
+ > attached, batched so a direct relation costs one query across the whole record set and
6
+ > a `through` relation two.
7
+
8
+ The relationships (`belongs` / `many` / `one` / `through` / `morph`) cover the foreign-key shapes; nested includes recurse through the registry; `link` / `unlink` / `links` manage a many-to-many junction without hand-writing join rows. The layer stays thin above the typed store: resolution is define-time — each relation is precomputed once into a flat `ResolvedRelation`, and nothing is inferred while loading — and the loaded relation properties are deliberately loose (`Row | readonly Row[] | undefined`) rather than typed to each exact target row. The typed half is the table reached through `model.table`, and relation loading is the looser convenience on top. No write-cascades, no lazy proxies, no query builder of its own. Source: [`src/core`](../src/core). Surfaced through the `@src/core` barrel.
9
+
10
+ ## Surface
11
+
12
+ Create a manager over a database and its relation map, then reach a typed model and load with relations attached:
13
+
14
+ ```ts
15
+ import { createRelationManager, belongsTo, hasMany, hasThrough } from '@orkestrel/relation'
16
+
17
+ const manager = createRelationManager({
18
+ database: db, // a typed DatabaseInterface from createDatabase(...)
19
+ relations: {
20
+ accounts: {
21
+ classification: belongsTo('classificationId', 'classifications'), // foreign key on accounts → one classification
22
+ contacts: hasMany('accountId'), // foreign key on contacts → many contacts back here
23
+ representatives: hasThrough('accountReps', 'accountId', 'repId', 'representatives'), // many-to-many through a junction
24
+ },
25
+ contacts: { account: belongsTo('accountId', 'accounts') }, // so contacts can nest-load its account
26
+ },
27
+ })
28
+
29
+ const accounts = manager.model('accounts') // a typed Model; only the relations you asked for are loaded
30
+ const acme = await accounts.load('acc1', { contacts: true, classification: true })
31
+
32
+ acme?.name // ✅ the base row is the table's row type
33
+ acme?.contacts // the relation property — broad (Row | readonly Row[] | undefined); narrow at the use site
34
+ ```
35
+
36
+ `model(name)` is checked against the database's declared tables, so a typo is a compile error. The model's own table (`model.table`) carries that table's row type; the attached related rows are the broad `Row` — narrow them where you read them.
37
+
38
+ ### Factory & manager
39
+
40
+ | API | Kind | Summary |
41
+ | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
42
+ | `createRelationManager` | function | Creates a `RelationManagerInterface` over a database and its relation map. |
43
+ | `RelationManager` | class | Resolves a `RelationsShape` once at construction and vends a typed `ModelInterface` per declared table. |
44
+ | `Model` | class | Pairs a typed table with relation-aware `load` / `find` and junction management. |
45
+
46
+ ### Builders
47
+
48
+ | API | Kind | Summary |
49
+ | ------------ | -------- | --------------------------------------------------------------------------------------------------------------- |
50
+ | `belongsTo` | function | Builds a `belongs` relation — a foreign key on the owning table points at the related row (single). |
51
+ | `hasMany` | function | Builds a `many` relation — a foreign key on the related table points back at the owning row (array). |
52
+ | `hasOne` | function | Builds a `one` relation — like `hasMany`, but a single related row. |
53
+ | `hasThrough` | function | Builds a `through` relation — a junction table links the two sides (many-to-many). |
54
+ | `hasMorph` | function | Builds a `morph` relation — a polymorphic foreign key plus a discriminator column on the related table (array). |
55
+
56
+ ### Resolution
57
+
58
+ | API | Kind | Summary |
59
+ | ---------------------- | -------- | ----------------------------------------------------------------------------- |
60
+ | `resolveRelation` | function | Resolves one raw `Relation` value into a flat `ResolvedRelation`. |
61
+ | `resolveRelationMap` | function | Resolves every entry of a `RelationMap` into a name → `ResolvedRelation` map. |
62
+ | `isRelationDescriptor` | function | Narrows a value to a `RelationDescriptor` (the object form of a relation). |
63
+
64
+ ### Row helpers
65
+
66
+ | API | Kind | Summary |
67
+ | --------------- | -------- | ---------------------------------------------------------------------- |
68
+ | `readColumn` | function | Reads one column off any record, whatever its declared type. |
69
+ | `countAttached` | function | Counts the related rows one relation attached across a record set. |
70
+ | `indexRows` | function | Indexes rows by the string form of one column, for keyed lookups. |
71
+ | `groupRows` | function | Groups rows by the string form of one column, for one-to-many lookups. |
72
+
73
+ ### Errors
74
+
75
+ | API | Kind | Summary |
76
+ | ----------------- | -------- | --------------------------------------------------------------------------------------------------- |
77
+ | `RelationError` | class | Represents an error thrown by the relations layer, carrying a machine-readable `RelationErrorCode`. |
78
+ | `isRelationError` | function | Narrows an unknown caught value to a `RelationError`. |
79
+
80
+ ### Types
81
+
82
+ A `Shape` cell holds an interface's data members as bare names in braces, `?` marking an optional member and `plus` introducing its call-signature members, and a type alias's own type literal with a union's arms escaped as `\|`. An extended interface's name comes before `plus`, with the members it adds after.
83
+
84
+ | Type | Kind | Shape | Summary |
85
+ | -------------------------- | --------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
+ | `Relationship` | type | `'belongs' \| 'many' \| 'one' \| 'through' \| 'morph'` | Enumerates the relationships a relation can declare. |
87
+ | `RelationDescriptor` | interface | `{ relationship?, column?, key?, through?, source?, target?, tag?, label?, model? }` | Represents the object form of a relation. |
88
+ | `Relation` | type | `string \| readonly string[] \| RelationDescriptor` | Represents a single relation definition. |
89
+ | `RelationMap` | type | `Readonly<Record<string, Relation>>` | Holds a model's relations, keyed by relation name. |
90
+ | `RelationsShape` | type | `{ readonly [K in keyof T]?: RelationMap }` | Holds per-table relation maps — the declarative input to `createRelationManager`. |
91
+ | `ResolvedRelation` | type | `ResolvedBelongs \| ResolvedMany \| ResolvedOne \| ResolvedThrough \| ResolvedMorph` | Represents a relation resolved at define-time into a flat, ready-to-load form. |
92
+ | `ResolvedBelongs` | interface | `{ relationship, name, model, column }` | Represents a `belongs` relation resolved at define-time — the foreign key sits on the owning table. |
93
+ | `ResolvedMany` | interface | `{ relationship, name, model, key }` | Represents a `many` relation resolved at define-time — the foreign key sits on the related table. |
94
+ | `ResolvedOne` | interface | `{ relationship, name, model, key }` | Represents a `one` relation resolved at define-time — `ResolvedMany`'s foreign key, one row. |
95
+ | `ResolvedThrough` | interface | `{ relationship, name, model, through, source, target }` | Represents a `through` relation resolved at define-time — a junction table links the two sides. |
96
+ | `ResolvedMorph` | interface | `{ relationship, name, model, key, tag, label }` | Represents a `morph` relation resolved at define-time — a polymorphic foreign key and its discriminator. |
97
+ | `RelationErrorCode` | type | `'INVALID' \| 'UNKNOWN_RELATION' \| 'NOT_THROUGH'` | Names a machine-readable `RelationError` code. |
98
+ | `Include` | interface | `{ [relation] }` | Selects which relations to populate when loading — and, recursively, their own. |
99
+ | `Loaded` | type | `T & Readonly<LoadedMap>` | Represents a row with its loaded relation properties attached. |
100
+ | `LoadedMap` | type | `Record<string, Row \| readonly Row[] \| undefined>` | Holds the relation properties attached to a `Loaded` row — each relation name mapped to its loaded related row(s), or `undefined` when a `belongs` / `one` relation misses. |
101
+ | `RelationContext` | interface | `{ resolved, primary }` | Holds a related model's resolved relations and primary-key column, for nested loading. |
102
+ | `FindOptions` | interface | `OperationOptions plus { limit?, offset?, sort?, direction? }` | Configures pagination, ordering, and cancellation for `find`. |
103
+ | `ModelEventMap` | type | `{ load, link, unlink }` | Declares the push observation surface of a `ModelInterface` — the eager-load + junction-management moments a fire-and-forget observer (logging, metrics, a sync layer) subscribes to. |
104
+ | `ModelInterface` | interface | `{ emitter, name, table, relations } plus load, find, link, unlink, links` | Represents a typed table paired with relation-aware loading and junction management. |
105
+ | `RelationManagerOptions` | interface | `{ database, relations?, model? }` | Configures `createRelationManager`. |
106
+ | `RelationManagerInterface` | interface | `{ count } plus model, names, has` | Vends a typed `ModelInterface` per table. |
107
+
108
+ ## Methods
109
+
110
+ The public methods of each behavioral interface — one table per type, keyed by its backticked name, every call-signature member listed (its `readonly` data members, for example `emitter` / `name` / `table` / `relations` / `count`, stay in the Surface rows earlier — `Model`'s `emitter` is the typed push observation surface, see [Observing](#observing)). `Model` and `RelationManager` each implement their interface exactly, so this doubles as the per-instance method surface (`.claude/rules/documentation.md` § Parity).
111
+
112
+ #### `ModelInterface`
113
+
114
+ `load` / `find` batch-load (one query for a direct relation, two for `through`, regardless of result size); `link` / `unlink` / `links` manage a `through` relation's junction rows. Every method accepts the database package's optional `OperationOptions` abort signal; `find` carries the same signal in `FindOptions`.
115
+
116
+ | Method | Returns | Summary |
117
+ | -------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
118
+ | `load` | `Promise<Loaded<T> \| undefined>` | Loads one record by key with the chosen relations populated, or a positional array of records for an array of keys. |
119
+ | `find` | `Promise<readonly Loaded<T>[]>` | Finds many records, paged and sorted through `FindOptions`, with the chosen relations populated. |
120
+ | `link` | `Promise<void>` | Inserts a missing junction row for a `through` relation. |
121
+ | `unlink` | `Promise<void>` | Removes every matching junction row for a `through` relation inside one transaction. |
122
+ | `links` | `Promise<readonly Key[]>` | Lists the related keys reachable through a `through` relation. |
123
+
124
+ #### `RelationManagerInterface`
125
+
126
+ `model(name)` vends the typed model for a declared table; `names()` lists the table names that carry resolved relations, and `has(name)` reports whether a given table does.
127
+
128
+ | Method | Returns | Summary |
129
+ | ------- | ----------------------------- | ----------------------------------------------------------- |
130
+ | `model` | `ModelInterface<RowOf<T[K]>>` | Returns the typed model for a declared table. |
131
+ | `names` | `readonly string[]` | Lists the names of every model carrying resolved relations. |
132
+ | `has` | `boolean` | Reports whether a model carries resolved relations. |
133
+
134
+ ## Contract
135
+
136
+ These invariants hold across `src/core` ↔ `relation.md`:
137
+
138
+ 1. **Doc ↔ source bijection.** Every `function` / `class` / `interface` / `type` row in the `## Surface` tables is a real export of the relations source tree, and every export appears as a Surface row — exhaustive, both directions (`.claude/rules/documentation.md` § Parity).
139
+ 2. **Layered on the typed database and validated at construction.** A manager is built over a `DatabaseInterface`; `model(name)` is checked against the database's declared tables and returns a model whose `table` is that table's typed `TableInterface` (a declared table with no relation entry yields a relation-less model — still fully usable for typed CRUD). Related tables are fetched by runtime name at the broad `Row` type. Every resolved target table and `through` junction must appear in `database.export()`; an undeclared table throws `RelationError('INVALID', ...)` when the manager is created, before any operation runs.
140
+ 3. **Batch loading — no N+1.** A direct included relation uses one query over the distinct foreign keys for the entire record set; a `through` relation uses two queries, one for the junction rows and one for the target rows. Related rows are grouped in memory and attached, so either query count is independent of how many parents were loaded. Nested includes recurse through the registry, and each nested level is batched again under the same rule.
141
+ 4. **Resolution is define-time.** Each raw `Relation` is resolved once at construction into a flat `ResolvedRelation` — a union discriminated on `relationship` whose every arm declares its own columns required, so a loader reads them after one narrow and never against an absent member; nothing is inferred during loading. The builders set an explicit `relationship` (so `many` and `one`, otherwise identical as `{ key }`, are unambiguous); a hand-written descriptor with no `relationship` infers one from the fields present (`through` → `through`, `tag` → `morph`, `column` → `belongs`, else `key` → `one`), and a malformed one throws `INVALID` at define-time.
142
+ 5. **Total, loose `Loaded`.** `Loaded<T>` is the base row (the table's row type) intersected with the broad relation bag `Readonly<LoadedMap>` (`Row | readonly Row[] | undefined` per relation); a missed `belongs` / `one` is `undefined`, a missed `many` / `through` / `morph` is `[]`. Through-only operations (`link` / `unlink` / `links`) throw `NOT_THROUGH` on any other relationship and `UNKNOWN_RELATION` for a relation the model never declared. `link` is idempotent for sequential calls: an existing `(key, target)` pair causes no write and no event. Concurrent `link` calls for the same pair may each insert. `unlink` removes all matching rows inside one database transaction, so a fault rolls the whole removal back. On a driver without a native transaction the database's floor snapshots the whole store, so a rollback restores every table to the scope's start.
143
+ 6. **Observation is a pure side-channel.** A `Model` owns a typed `emitter` (`ModelEventMap` — `load(name, count)` / `link(key, relation)` / `unlink(key, relation)`); `RelationManager` is event-free by design (a stateless registry has no observable lifecycle). Every event is emitted directly (the `.claude/rules/patterns.md` § Stateful emitters convention: the emitter isolates a listener throw, routing it to its own `error` handler — the `error` option, surfaced as `(error, event)`, never a domain event — itself re-entrancy-guarded) strictly after the load resolves and after the junction operation completes. `load` fires once per relation (carrying the count of rows attached across the record set — no N+1 in the events), so a buggy observer can corrupt neither the batched eager-load nor a junction write (proven by the emit-safety tests). An idempotent no-op `link` emits nothing.
144
+ 7. **Cooperative cancellation.** Every model operation accepts the database package's abort option. Query reads pass the signal to `records`. The keyed `get` read takes no options in the database package, so `load` checks the signal before it and again before each relation in the population walk; a batched `get` already in flight runs to completion. Writes pass it to their database mutation or transaction. An aborted signal surfaces unchanged as the database package's `ABORTED` error.
145
+ 8. **Doc ↔ source method bijection.** Every behavioral interface's `## Methods` table lists exactly its public methods (call-signature members) — exhaustive, both directions — and each implementing class (`Model` / `RelationManager`) exposes the same public methods, no more (`.claude/rules/documentation.md` § Parity). A renamed / added / removed method breaks the gate until the table is reconciled.
146
+
147
+ Typing each loaded relation property to its exact target row (Prisma-style) is a deliberate, documented deferral — like the database guide's deferred pieces. The `Model` is **observable** — it owns a typed `emitter` (`ModelEventMap`) carrying its eager-load + junction moments (see [Observing](#observing)); `RelationManager` stays event-free by design (a stateless registry that merely vends models has no observable lifecycle of its own). Still out of scope: write-cascades; they are additive and leave the documented surface unchanged.
148
+
149
+ ## Patterns
150
+
151
+ ### Defining relations
152
+
153
+ Declare every relationship with its builder, then load a record with the chosen relations
154
+ attached:
155
+
156
+ ```ts
157
+ import {
158
+ createRelationManager,
159
+ belongsTo,
160
+ hasMany,
161
+ hasOne,
162
+ hasThrough,
163
+ hasMorph,
164
+ } from '@orkestrel/relation'
165
+
166
+ const manager = createRelationManager({
167
+ database: db,
168
+ relations: {
169
+ accounts: {
170
+ classification: belongsTo('classificationId', 'classifications'), // foreign key on accounts
171
+ contacts: hasMany('accountId'), // foreign key on contacts → accounts
172
+ profile: hasOne('accountId', 'profiles'), // single, foreign key on profiles
173
+ representatives: hasThrough('accountReps', 'accountId', 'repId', 'representatives'), // through a junction
174
+ notes: hasMorph('entityId', 'entityType', 'account', 'notes'), // polymorphic
175
+ },
176
+ contacts: { account: belongsTo('accountId', 'accounts') },
177
+ },
178
+ })
179
+
180
+ const acme = await manager.model('accounts').load('acc1', { contacts: true, classification: true })
181
+ ```
182
+
183
+ The shorthands cover the common relationships — a bare string is a `belongsTo` column, and a one-element array is a `hasMany` key (both unambiguous, so they need no `relationship`):
184
+
185
+ ```ts
186
+ relations: { accounts: { classification: 'classificationId', contacts: ['accountId'] } }
187
+ ```
188
+
189
+ Reach for the builders for everything else: they set an explicit `relationship`, which is what disambiguates `many` from `one` (a raw `{ key }` descriptor infers `one`).
190
+
191
+ The relationships, and where each foreign key lives:
192
+
193
+ | Relationship | Builder | Foreign key location | Returns |
194
+ | ------------ | ------------ | -------------------- | --------------------- |
195
+ | `belongs` | `belongsTo` | the owning table | single or `undefined` |
196
+ | `many` | `hasMany` | the related table | array |
197
+ | `one` | `hasOne` | the related table | single or `undefined` |
198
+ | `through` | `hasThrough` | the junction table | array |
199
+ | `morph` | `hasMorph` | the related table | array |
200
+
201
+ ### Resolving relations directly
202
+
203
+ `resolveRelation` / `resolveRelationMap` / `isRelationDescriptor` are what `createRelationManager` calls internally to turn a raw `RelationMap` into one `ResolvedRelation` per entry at construction — reach for them directly when validating a relation map before wiring a manager, or when testing a descriptor's inferred `relationship`:
204
+
205
+ ```ts
206
+ import { isRelationDescriptor, resolveRelation, resolveRelationMap } from '@orkestrel/relation'
207
+
208
+ const resolved = resolveRelation('classification', belongsTo('classificationId', 'classifications'))
209
+ resolved.relationship // 'belongs'
210
+
211
+ const map = resolveRelationMap({ contacts: hasMany('accountId') })
212
+ map.get('contacts')?.relationship // 'many'
213
+
214
+ isRelationDescriptor(belongsTo('classificationId')) // true — the object form
215
+ ```
216
+
217
+ `ResolvedRelation` is a union discriminated on `relationship`, so narrow on that member
218
+ before reading an arm's own columns. Each arm declares its columns required —
219
+ `ResolvedThrough` carries `through` / `source` / `target`, `ResolvedMorph` carries `key` /
220
+ `tag` / `label` — because `resolveRelation` refuses a descriptor missing one:
221
+
222
+ ```ts
223
+ import { resolveRelation, hasThrough } from '@orkestrel/relation'
224
+
225
+ const resolved = resolveRelation('reps', hasThrough('accountReps', 'accountId', 'repId'))
226
+ if (resolved.relationship === 'through') {
227
+ resolved.through // 'accountReps' — a required string on this arm
228
+ resolved.source // 'accountId'
229
+ resolved.target // 'repId'
230
+ }
231
+ ```
232
+
233
+ Catch a malformed relation with `isRelationError`, branching on its machine-readable `code`:
234
+
235
+ ```ts
236
+ import { isRelationError } from '@orkestrel/relation'
237
+
238
+ try {
239
+ resolveRelation('bad', {})
240
+ } catch (error) {
241
+ if (isRelationError(error)) error.code // 'INVALID'
242
+ }
243
+ ```
244
+
245
+ ### The registry surface
246
+
247
+ Beyond `model(name)`, a `RelationManager` exposes `names()` (every table name with resolved relations) and `has(name)` (whether a given table has any):
248
+
249
+ ```ts
250
+ manager.names() // for example ['accounts', 'contacts']
251
+ manager.has('accounts') // true
252
+ manager.has('unrelated_table') // false
253
+ ```
254
+
255
+ ### Loading
256
+
257
+ `load` mirrors the table's keyed-read overload: a single key returns one record (or `undefined`), an array of keys returns a positional array of records (each slot `undefined` for a missing key) — and either way the relation queries are batched in one pass. `find` runs the table's query (sorted / paged) and attaches relations to the page.
258
+
259
+ ```ts
260
+ const accounts = manager.model('accounts')
261
+
262
+ // One record, with the chosen relations attached:
263
+ const acme = await accounts.load('acc1', { contacts: true, classification: true })
264
+
265
+ // Many records — sorted and paged — with relations attached to the page:
266
+ const page = await accounts.find(
267
+ { representatives: true },
268
+ { sort: 'name', direction: 'ascending', limit: 10 },
269
+ )
270
+
271
+ // Nested includes — load a relation's own relations (recurses through the registry):
272
+ const deep = await accounts.load('acc1', { contacts: { account: true } })
273
+
274
+ // Batch by key array: an array in, an array out — and this direct-relation fetch is
275
+ // still one query across all parents (no N+1), not one per key.
276
+ const [a, b] = await accounts.load(['acc1', 'acc2'], { contacts: true })
277
+ ```
278
+
279
+ ### Typed table access
280
+
281
+ For writes and plain queries, drop through to `model.table` — the full typed `TableInterface` for that model's table. This is the typed half; eager loading is the looser convenience layered on it.
282
+
283
+ ```ts
284
+ const accounts = manager.model('accounts')
285
+ await accounts.table.set({ id: 'acc4', name: 'New Corp', classificationId: 'cls1' }) // fully typed
286
+ await accounts.table
287
+ .query()
288
+ .condition({ column: 'name', operator: 'starts', values: ['A'], connector: 'and' })
289
+ .collect()
290
+ ```
291
+
292
+ ### Through management
293
+
294
+ A `through` relation's junction rows are managed by key — no need to model the junction table yourself or hand-write join rows. `link` / `unlink` / `links` resolve the relation's junction table + its source/target columns from the define-time `ResolvedRelation`, and throw `NOT_THROUGH` if pointed at a non-`through` relation.
295
+
296
+ ```ts
297
+ await accounts.link('acc1', 'representatives', 'rep3') // insert a junction row (accountId=acc1, repId=rep3)
298
+ await accounts.link('acc1', 'representatives', 'rep3') // already linked, sequential call: no write and no event
299
+ await accounts.unlink('acc1', 'representatives', 'rep1') // remove all matches atomically
300
+ const repIds = await accounts.links('acc1', 'representatives') // the related keys reachable through the junction
301
+ ```
302
+
303
+ Pass an abort signal through the same operation option used by the database package:
304
+
305
+ ```ts
306
+ const controller = new AbortController()
307
+ const pending = accounts.load(
308
+ 'acc1',
309
+ { contacts: true, classification: true },
310
+ {
311
+ signal: controller.signal,
312
+ },
313
+ )
314
+ controller.abort('stop loading')
315
+ await pending
316
+ ```
317
+
318
+ ### Observing
319
+
320
+ Each `Model` exposes a typed `emitter` carrying its eager-load + junction moments for fire-and-forget observers — logging, metrics, a sync layer. Subscribe through `model.emitter.on(...)`. **Emitting is observation-only**: every event fires strictly after the load resolves and after the junction operation completes, so a listener can never change what a load does (and a throwing one can't corrupt it). The `RelationManager` is event-free by design — a stateless registry that merely vends models has no observable lifecycle of its own; observe the per-model handle instead.
321
+
322
+ ```ts
323
+ const accounts = manager.model('accounts')
324
+ accounts.emitter.on('load', (name, count) => metrics.record(`relation.${name}`, count))
325
+ accounts.emitter.on('link', (key, relation) => sync.push(key, relation))
326
+ ```
327
+
328
+ `model(name)` returns a fresh handle on every call, and each handle owns its own emitter. Retain the handle you subscribed to; a later `manager.model('accounts')` is a different observer surface. To observe every handle a manager vends, pass the listeners once as the manager's `model` option:
329
+
330
+ ```ts
331
+ const manager = createRelationManager({
332
+ database: db,
333
+ relations: { accounts: { contacts: hasMany('accountId') } },
334
+ model: {
335
+ on: { load: (name, count) => metrics.record(`relation.${name}`, count) },
336
+ error: (error, event) => metrics.fault(event, error),
337
+ },
338
+ })
339
+ ```
340
+
341
+ The event vocabulary:
342
+
343
+ | Entity | Event map | Events |
344
+ | ------- | --------------- | --------------------------------------------------------------------- |
345
+ | `Model` | `ModelEventMap` | `load(name, count)` · `link(key, relation)` · `unlink(key, relation)` |
346
+
347
+ `load` fires once per relation an eager-load resolves (`load` / `find`, including each nested relation) — carrying the relation name and the count of related rows attached across the whole record set (the batched load has no N+1, and neither do its events — it is not one event per record); `link` / `unlink` fire after a junction row is inserted / removed, carrying the owning key + the relation name. Reads of the base rows are the table's concern (observe `model.table.emitter` for per-row `write` / `remove`).
348
+
349
+ **Listener isolation.** A listener throw never escapes into the load: the emitter isolates it and routes it to its own `error` handler (the `error` option, surfaced as `(error, event)`), never to a domain event — so a buggy observer is isolated yet not silently lost. The `error` handler runs in its own try/catch, so even a throwing handler can't recurse or escape; with no handler, the throw is swallowed silently. Because every emit sits after its transition and is isolated, a buggy observer **cannot corrupt the batched eager-load or a junction write** — the load still resolves correctly and the junction row is still written — proven by the emit-safety tests. A `Model` is reached through the `RelationManager`, which threads the `model.error` option into every handle it vends, so set that option to receive a `Model` listener throw; leave it unset and the throw is swallowed silently.
350
+
351
+ ### Practices
352
+
353
+ - **Define all related models up front** — nested includes resolve through the registry, so a relation you want to nest-load must have its own entry.
354
+ - **Use the builders** — `belongsTo` / `hasMany` / `hasOne` / `hasThrough` / `hasMorph` set an explicit `relationship`; the string / array shorthands are unambiguous, but a raw `{ key }` resolves to `one`.
355
+ - **Reach for typed CRUD through `model.table`** — the table is fully typed; relation loading is the looser layer on top.
356
+ - **Request only the relations you use** — each direct relation adds one query; each `through` relation adds two.
357
+ - **Use `link` / `unlink` / `links` for `through` joins** rather than writing junction rows by hand.
358
+ - **Observe, don't drive** — subscribe to `model.emitter` (`load` / `link` / `unlink`) for metrics or a sync layer (see [Observing](#observing)); emitting is a pure side-channel, so a listener never changes what a load does (and a throwing one can't corrupt it).
359
+
360
+ ## Tests
361
+
362
+ - [`tests/guides.test.ts`](../tests/guides.test.ts) — the `## Surface` ↔ `src/core` bijection (value + type exports), the `ModelInterface` / `RelationManagerInterface` ↔ `Model` / `RelationManager` method bijections, and the equality gate: every `Summary` cell against its declaration's description paragraph, the titled `Defining relations` fence against the `@example` block of that title (pinned so the titled pair cannot be retired silently), and the README pitch against this guide's tagline. It also runs the guide's value-claiming fences and asserts the values their comments claim.
363
+ - [`tests/src/core/helpers.test.ts`](../tests/src/core/helpers.test.ts) — `resolveRelation` (shorthands, builders, inference, errors, a wrong-typed descriptor member), `resolveRelationMap`, `readColumn`, `countAttached`, `indexRows`, `groupRows`.
364
+ - [`tests/src/core/validators.test.ts`](../tests/src/core/validators.test.ts) — `isRelationDescriptor` over the object form, the builders' output, the string / array shorthands, a member holding the wrong type, and a `relationship` outside the union.
365
+ - [`tests/src/core/RelationManager.test.ts`](../tests/src/core/RelationManager.test.ts) — the manager-level surface: the registry (`count` / `names` / `has`), the typed `model(name)` accessor, and construction-time `INVALID` errors for undeclared relation and junction tables.
366
+ - [`tests/src/core/Model.test.ts`](../tests/src/core/Model.test.ts) — `Model` behavior: `load` / `find` populating each relationship (batched, no N+1), nested `includes`, the loaded relation accessors, idempotent `link`, atomic `unlink`, cooperative cancellation, `links`, and the `emitter` (`ModelEventMap`): `load(name, count)` fires once per relation (the attached count, including nested relations — not one per record), `link` / `unlink` carry the owning key + relation, `on?` wiring through the manager's `model` option, and emit safety (a throwing `load` / `link` observer can't corrupt the load or junction write — the emitter isolates it and routes it to the `model.error` handler when the manager carries one).
367
+ - [`tests/src/core/factories.test.ts`](../tests/src/core/factories.test.ts) — `createRelationManager` wires up a working, typed manager end to end.
368
+
369
+ ## See also
370
+
371
+ - [`database.md`](database.md) — the database, tables, and query layer relations build on.
372
+ - [`AGENTS.md`](../AGENTS.md) — the coding contract and its rule map.
373
+ - [`README.md`](README.md) — the guides index.