graphddb 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -476
- package/dist/{chunk-BROCT574.js → chunk-7NPG5R7O.js} +1 -1
- package/dist/{chunk-CPTV3H2U.js → chunk-FMJIWFIS.js} +156 -30
- package/dist/{chunk-G5RWWBAL.js → chunk-J5A665UW.js} +23 -3
- package/dist/cli.js +781 -6
- package/dist/index.d.ts +65 -3
- package/dist/index.js +20 -4
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-BWOrWcbd.d.ts → types-B-F7jw9f.d.ts} +42 -0
- package/docs/cdc-emulator.md +203 -0
- package/docs/class-hydration.md +409 -0
- package/docs/cloudformation.md +294 -0
- package/docs/cqrs-contract.md +526 -0
- package/docs/design-patterns.md +521 -0
- package/docs/middleware.md +189 -0
- package/docs/mutation-command-derivation.md +356 -0
- package/docs/python-bridge.md +611 -0
- package/docs/spec.md +1626 -0
- package/docs/testing.md +265 -0
- package/package.json +6 -3
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# Opt-in Class Hydration for Read Results (`options.hydrate`)
|
|
2
|
+
|
|
3
|
+
> **Status.** The design below and **Phase 1** — top-level `query`
|
|
4
|
+
> `options.hydrate` — are **IMPLEMENTED**. **Phase 2** (`list` per-item
|
|
5
|
+
> hydrate) and **Phase 3** (per-relation hydrate) are **FUTURE**, described here
|
|
6
|
+
> as the planned, well-defined extensions; they are not yet shipped.
|
|
7
|
+
|
|
8
|
+
## Premise
|
|
9
|
+
|
|
10
|
+
GraphDDB's Read surface (`query` / `list` / relation traversal) returns a
|
|
11
|
+
**Typed Plain Object**. `hydrate()` (`src/hydrator/hydrator.ts`) builds a plain
|
|
12
|
+
`Record` carrying only the projected fields — it constructs no class instance,
|
|
13
|
+
and the internal `PK` / `SK` / GSI key attributes are never copied in. This is
|
|
14
|
+
the **correct default** and does not change:
|
|
15
|
+
|
|
16
|
+
- it is the CQRS Read Model shape — a projection, not a domain entity;
|
|
17
|
+
- it is JSON / OpenAPI-native (a `query` result serializes verbatim);
|
|
18
|
+
- it is what the multi-language runtime (`#48`, the Python bridge) reconstructs
|
|
19
|
+
from a declarative, serializable plan — a class instance is a *host-language*
|
|
20
|
+
notion that does not cross the bridge.
|
|
21
|
+
|
|
22
|
+
On top of that default, some callers — e.g. a persistence layer that wants to
|
|
23
|
+
read into a **semantic object** (a domain object with behavior) rather than a
|
|
24
|
+
bag of fields — need to **hydrate the read result onto a host-language object**.
|
|
25
|
+
GraphDDB provides that as an **opt-in extension**: a `hydrate` factory passed
|
|
26
|
+
in the call's `options` bag.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// default: Typed Plain Object (UNCHANGED)
|
|
30
|
+
const user = await User.query({ userId: 'alice' }, { userId: true, name: true });
|
|
31
|
+
// ^? { userId: string; name: string } | null
|
|
32
|
+
|
|
33
|
+
// opt-in: hydrate the plain object onto a host-side domain object
|
|
34
|
+
const user = await User.query(
|
|
35
|
+
{ userId: 'alice' },
|
|
36
|
+
{ userId: true, name: true },
|
|
37
|
+
{ hydrate: (raw) => new UserSemanticObject(raw) },
|
|
38
|
+
);
|
|
39
|
+
// ^? UserSemanticObject | null
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The decisive precedent is **`updatable` (#54)**: a host-only, non-serialized,
|
|
43
|
+
*return-type-changing* read option, implemented as a third-argument flag with
|
|
44
|
+
overloaded return types (`QueryResult<…> & Updatable`). `hydrate` is the same
|
|
45
|
+
*class* of option — a host-runtime read-result transform — and follows the same
|
|
46
|
+
design rules. Its only addition over `updatable` is that the new return type is
|
|
47
|
+
**caller-supplied** (`R`) rather than a fixed brand, so it propagates through a
|
|
48
|
+
generic instead of a constant.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
declarative "what to fetch" = select + key + filter → serializable (operations.json, #48)
|
|
52
|
+
host-runtime "how to read it" = consistentRead / maxDepth / updatable / hydrate / middleware (#50)
|
|
53
|
+
→ NEVER serialized; never in the SSoT / operations.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## API shape
|
|
59
|
+
|
|
60
|
+
### Position — the third-argument `options` bag (`hydrate`)
|
|
61
|
+
|
|
62
|
+
`query(key, select, options?)` already takes a third options bag with
|
|
63
|
+
`consistentRead` / `maxDepth` / `updatable`. `hydrate` is **added to that bag**,
|
|
64
|
+
*not* as a new method:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
query(
|
|
68
|
+
key,
|
|
69
|
+
select,
|
|
70
|
+
{
|
|
71
|
+
consistentRead?: boolean;
|
|
72
|
+
maxDepth?: number;
|
|
73
|
+
updatable?: boolean;
|
|
74
|
+
hydrate?: (raw: QueryResult<T, ProjectionOf<T, Sel>>) => R; // ← new
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Rationale (mirrors the issue's "API shape policy"):
|
|
80
|
+
|
|
81
|
+
- The options bag already exists and is the established place for *host-runtime*
|
|
82
|
+
read settings. Adding `hydrate` there avoids the combinatorial explosion a
|
|
83
|
+
dedicated `queryAs(Ctor, …)` method would create (every existing/future option
|
|
84
|
+
× every dedicated method).
|
|
85
|
+
- `hydrate` is a **factory** `(raw) => R`, not a constructor. A factory subsumes
|
|
86
|
+
the `queryAs(Ctor, …)` ergonomics — `hydrate: (raw) => new Ctor(raw)` is the
|
|
87
|
+
constructor case — *and* the arbitrary-mapping case (`hydrate: (raw) =>
|
|
88
|
+
toDomain(raw)`, or even a non-instance shape). So no extra method is needed.
|
|
89
|
+
|
|
90
|
+
### The factory input is the fully-resolved plain object
|
|
91
|
+
|
|
92
|
+
The factory receives the **plain object the default path already produces** —
|
|
93
|
+
the `QueryResult<T, ProjectionOf<T, Sel>>` for that exact `select` (projection +
|
|
94
|
+
resolved relations). Concretely, the factory runs **after** `hydrate()` (the
|
|
95
|
+
plain-object builder) and after relation resolution, on the value that would
|
|
96
|
+
otherwise be returned. This is what makes `hydrate`'s input type *follow the
|
|
97
|
+
projection*: `select` → `ProjectionOf<T, Sel>` → `QueryResult<T, …>` → factory
|
|
98
|
+
input (the same chain the default return type already uses). A `select` that
|
|
99
|
+
omits a field also omits it from the factory's input type, so the factory cannot
|
|
100
|
+
read an unprojected attribute by accident.
|
|
101
|
+
|
|
102
|
+
### `null` semantics (single-item reads)
|
|
103
|
+
|
|
104
|
+
For `query` (and single-value relations) the read can resolve to `null` (no
|
|
105
|
+
item). `hydrate` is applied **only to a present item** — a `null` read stays
|
|
106
|
+
`null` and the factory is **not** invoked. So the return type is
|
|
107
|
+
`R | null`, never `R` for `(raw: …null) ⇒ …`. This matches the `updatable`
|
|
108
|
+
overload's `… | null`.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Return-type propagation
|
|
113
|
+
|
|
114
|
+
`hydrate` changes the return type from the plain `QueryResult<…>` to the
|
|
115
|
+
factory's return `R`. This is a conditional/inference on the options bag,
|
|
116
|
+
identical in shape to the `updatable` overload split that already exists in
|
|
117
|
+
`ModelStatic` (`src/model/types.ts`).
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// when options carry a hydrate factory → return R (| null), inferred from the factory
|
|
121
|
+
query<Sel extends SelectSpec<T>, R>(
|
|
122
|
+
key: QueryKey<C>,
|
|
123
|
+
select: Sel & StrictSelectSpec<T, Sel>,
|
|
124
|
+
options: {
|
|
125
|
+
consistentRead?: boolean;
|
|
126
|
+
maxDepth?: number;
|
|
127
|
+
updatable?: boolean;
|
|
128
|
+
hydrate: (raw: QueryResult<T, ProjectionOf<T, Sel>>) => R; // present ⇒ this overload
|
|
129
|
+
},
|
|
130
|
+
): Promise<R | null>;
|
|
131
|
+
|
|
132
|
+
// existing overloads (no hydrate) — UNCHANGED, still return the plain object
|
|
133
|
+
query<Sel extends SelectSpec<T>>(
|
|
134
|
+
key: QueryKey<C>,
|
|
135
|
+
select: Sel & StrictSelectSpec<T, Sel>,
|
|
136
|
+
options?: { consistentRead?: boolean; maxDepth?: number; updatable?: false },
|
|
137
|
+
): Promise<QueryResult<T, ProjectionOf<T, Sel>> | null>;
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Key properties:
|
|
141
|
+
|
|
142
|
+
- `R` is inferred **from the factory**, so `hydrate: (raw) => new UserSO(raw)`
|
|
143
|
+
yields `Promise<UserSO | null>` with no annotation. This is the
|
|
144
|
+
`queryAs(Ctor, …)`-style ergonomics, expressed by inference.
|
|
145
|
+
- The factory's *input* is `QueryResult<T, ProjectionOf<T, Sel>>` — the exact
|
|
146
|
+
projection type — so the factory body is fully typed against the projected
|
|
147
|
+
fields, and `select`-driven projection inference flows straight into it
|
|
148
|
+
(the issue's "type inference: the select-derived projection type flows to hydrate's input type").
|
|
149
|
+
- **The no-`hydrate` overloads are byte-for-byte the existing ones.** The
|
|
150
|
+
default return type is unchanged; `hydrate` is purely additive.
|
|
151
|
+
|
|
152
|
+
### Interaction with `updatable`
|
|
153
|
+
|
|
154
|
+
`updatable` brands the *plain object* with `& Updatable` (a hidden key for
|
|
155
|
+
`updateItem()`). `hydrate` replaces the return value with `R`. If both are passed,
|
|
156
|
+
the **factory receives the branded+updatable plain object** (so the factory may
|
|
157
|
+
read the hidden key if it wants), but the **returned value is `R`** — the brand
|
|
158
|
+
does not survive onto an arbitrary `R` (the factory decides what `R` is, and a
|
|
159
|
+
fresh class instance is not `Updatable`). The recommended guidance: pick one —
|
|
160
|
+
either keep the plain (optionally updatable) object, or hydrate to a domain
|
|
161
|
+
object that manages its own persistence. The phased plan ships `hydrate` first
|
|
162
|
+
without combining the two return modes; combining is a documented type fork
|
|
163
|
+
(see *Design forks*).
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Application point
|
|
168
|
+
|
|
169
|
+
`hydrate` is an **after-fetch transform**, applied at the very end of the read,
|
|
170
|
+
on the fully-built plain result:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
plan(select) → execute → hydrate() [plain object]
|
|
174
|
+
→ resolveRelations() [attach relations]
|
|
175
|
+
→ (updatable: attach hidden key)
|
|
176
|
+
→ options.hydrate(plainResult) ← LAST, host-only
|
|
177
|
+
→ return
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
In `executeQuery` (`src/operations/query.ts`) this is the single point where
|
|
181
|
+
`hydratedItem` is about to be returned (after relations are merged) — the
|
|
182
|
+
factory wraps that value.
|
|
183
|
+
|
|
184
|
+
### Order vs the #50 middleware after-fetch hook
|
|
185
|
+
|
|
186
|
+
`#50` (middleware / hooks) is the **same class** of host-side runtime config: a
|
|
187
|
+
cross-cutting insertion point that is *not* serialized into the SSoT. Both act
|
|
188
|
+
after-fetch, so their ordering must be defined. Recommended contract:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
fetch → plain object built → #50 middleware after-fetch hook(s) → options.hydrate → return
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Rationale: middleware is **cross-cutting** (logging, metrics, soft-delete
|
|
195
|
+
filtering, tenant scoping) and should observe/transform the **canonical plain
|
|
196
|
+
Read Model** — the same shape regardless of whether a particular call opts into
|
|
197
|
+
`hydrate`. `hydrate` is the **per-call, innermost** transform that decides the
|
|
198
|
+
*caller's* return shape. So middleware runs on the plain object first, and
|
|
199
|
+
`hydrate` is the last step, closest to the caller. (`#50` is designed
|
|
200
|
+
separately; this spec only fixes the ordering invariant so the two compose
|
|
201
|
+
predictably. If `#50` later wants a hook that runs *after* hydration, it would
|
|
202
|
+
be a distinct, explicitly-named hook — not a reordering of this one.)
|
|
203
|
+
|
|
204
|
+
The division of responsibility: middleware = cross-cutting, shape-preserving,
|
|
205
|
+
applies to all reads; `hydrate` = per-call, shape-changing, opt-in.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Relation scope
|
|
210
|
+
|
|
211
|
+
The issue raises whether `hydrate` applies only at the top level or per
|
|
212
|
+
relation. Recommendation, with the fork stated explicitly:
|
|
213
|
+
|
|
214
|
+
### Phase 1–2 (recommended default): TOP-LEVEL ONLY
|
|
215
|
+
|
|
216
|
+
`hydrate` applies to the **whole read result** at the top level. Nested
|
|
217
|
+
relations inside the result remain **plain objects** (their plain
|
|
218
|
+
`QueryResult` shape). The factory therefore receives a `QueryResult<T, …>`
|
|
219
|
+
whose relation fields are still plain `{ items, cursor }` / `single | null`,
|
|
220
|
+
and the factory decides what to do with them (often: wrap the top-level entity
|
|
221
|
+
and leave relations as data, or map relations itself inside the factory body).
|
|
222
|
+
|
|
223
|
+
Why top-level-first:
|
|
224
|
+
|
|
225
|
+
- It is **unambiguous** about the *application unit*: exactly one factory call
|
|
226
|
+
per top-level read (one for `query`; per top-level item for `list`).
|
|
227
|
+
- It keeps the type story simple — one `R`, inferred from one factory.
|
|
228
|
+
- It does not entangle hydration with N+1 batching (below).
|
|
229
|
+
|
|
230
|
+
### Per-relation hydrate (deferred extension, fork)
|
|
231
|
+
|
|
232
|
+
A future extension could let a relation's select-position builder carry its own
|
|
233
|
+
hydrate factory (`User.relation({...}).hydrate((raw) => new OrderSO(raw))`), so
|
|
234
|
+
each relation level produces its own `R`. This is **strictly more expressive**
|
|
235
|
+
but adds real complexity:
|
|
236
|
+
|
|
237
|
+
- **Application unit.** A relation result is `{ items, cursor }` (hasMany) or
|
|
238
|
+
`single | null` (belongsTo / hasOne). Per-relation hydrate would apply the
|
|
239
|
+
factory **per item** of `items` (cursor preserved verbatim:
|
|
240
|
+
`{ items: items.map(hydrate), cursor }`), and to the single value (or `null`
|
|
241
|
+
→ not invoked) for belongsTo / hasOne — exactly mirroring the top-level
|
|
242
|
+
null-skip rule.
|
|
243
|
+
- **Timing vs N+1 BatchGet.** belongsTo / hasOne resolve via batched
|
|
244
|
+
`BatchGetItem` (`fetchBelongsToHasOneBatch` in `src/relation/traversal.ts`);
|
|
245
|
+
hasMany via `Query`. A per-relation factory must run **after** the batch
|
|
246
|
+
resolves and after that relation's own nested relations are merged — i.e. at
|
|
247
|
+
the same after-fetch point, one level down. Batching (the *fetch* strategy)
|
|
248
|
+
is unaffected: hydration is applied to the *resolved* items post-batch, so it
|
|
249
|
+
never reintroduces an N+1. The order is: batch-get → hydrate plain → resolve
|
|
250
|
+
nested → per-relation factory.
|
|
251
|
+
- **Type propagation** must thread each relation's `R` into the parent
|
|
252
|
+
`QueryResult` at that relation's position (a deeper conditional type over the
|
|
253
|
+
select tree). This is feasible but is a meaningfully larger type change than
|
|
254
|
+
the top-level overload.
|
|
255
|
+
|
|
256
|
+
**Recommendation:** ship top-level-only (Phases 1–2). Treat per-relation
|
|
257
|
+
hydrate as a separate, later issue, only if a concrete need appears — the
|
|
258
|
+
top-level factory can already map relations in its body, so per-relation
|
|
259
|
+
hydrate is an ergonomics improvement, not a capability gap.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Python-bridge non-interference (CRITICAL — hard constraint)
|
|
264
|
+
|
|
265
|
+
`hydrate` is **host-runtime-only**. It MUST NOT appear in the SSoT, the
|
|
266
|
+
serialized query/`OperationSpec`, or `operations.json`. This is the same rule
|
|
267
|
+
`#50` (middleware) and `updatable` / `consistentRead` already follow.
|
|
268
|
+
|
|
269
|
+
- **The declarative layer is "what to fetch."** `operations.json` is generated
|
|
270
|
+
from *contract definitions* (`publicQueryModel`, etc.) — key, select, filter,
|
|
271
|
+
projection, result paths, cardinality, execution plan. It is the serializable,
|
|
272
|
+
language-neutral plan the Python runtime (`#48`) replays. It already contains
|
|
273
|
+
**none** of the host-runtime call options: a `grep` of
|
|
274
|
+
`python/tests/fixtures/generated/operations.json` finds **zero** occurrences
|
|
275
|
+
of `hydrate`, `updatable`, or `consistentRead` — these are passed at call
|
|
276
|
+
time on the host, never baked into the spec.
|
|
277
|
+
- **`hydrate` is "what host object to load the result onto."** That is a
|
|
278
|
+
*host-language* concern — a TS class / domain object has no representation in
|
|
279
|
+
the serialized plan and no meaning to the Python runtime, which reconstructs
|
|
280
|
+
its own (Python) plain result from the same plan. A factory is a TS closure;
|
|
281
|
+
it is **not serializable** and is structurally incapable of crossing the
|
|
282
|
+
bridge.
|
|
283
|
+
- **Mechanically guaranteed by where it lives.** `hydrate` is only ever a
|
|
284
|
+
field of the *call-time* `options` argument (`executeQuery`'s third param /
|
|
285
|
+
`QueryOptions`), exactly like `updatable`. The contract-recording path
|
|
286
|
+
(`recordContractOp` in `DDBModel`) records only `{ keys, select }` from the
|
|
287
|
+
contract method body — it never reads the options bag — so a `hydrate` passed
|
|
288
|
+
at execution time can never reach the recorder or the generator. There is no
|
|
289
|
+
code path from `options.hydrate` to the SSoT.
|
|
290
|
+
|
|
291
|
+
**Net:** `#48` is not impeded. "What to fetch" stays declarative and
|
|
292
|
+
serializable; "what object to load it onto" stays host-side and is never
|
|
293
|
+
serialized. The design states this explicitly as an invariant: *no read-call
|
|
294
|
+
option that changes only the host return shape is ever serialized.*
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Error handling
|
|
299
|
+
|
|
300
|
+
The `hydrate` factory is caller code and may `throw` (e.g. a domain-object
|
|
301
|
+
constructor validating invariants). Contract:
|
|
302
|
+
|
|
303
|
+
- A throw from `hydrate` **propagates to the caller** as the rejection of the
|
|
304
|
+
read `Promise`, unchanged (not wrapped, not swallowed). The read itself has
|
|
305
|
+
already succeeded at this point; the failure is purely in the caller-supplied
|
|
306
|
+
transform, so surfacing it verbatim is the least-surprising behavior and lets
|
|
307
|
+
the caller distinguish it via its own error types.
|
|
308
|
+
- `hydrate` is **not** invoked for a `null` read (no item) — so a factory need
|
|
309
|
+
not be null-tolerant.
|
|
310
|
+
- For `list` (Phase 2), if hydrating one item throws, the whole `list` call
|
|
311
|
+
rejects (it is a single `Promise`); the design does **not** silently drop the
|
|
312
|
+
offending item or partially hydrate, because a partially-applied transform
|
|
313
|
+
would be a worse surprise than a clean rejection. (A caller wanting
|
|
314
|
+
per-item resilience can hydrate `result.items` itself with its own
|
|
315
|
+
try/catch — the plain path is always available by simply omitting `hydrate`.)
|
|
316
|
+
- The factory must be **synchronous** in Phase 1 (`(raw) => R`, not
|
|
317
|
+
`=> Promise<R>`). A read returning `Promise<R | null>` with a synchronous
|
|
318
|
+
factory keeps the type clean and avoids an await-in-await. Async factories are
|
|
319
|
+
a possible later extension but are intentionally out of scope (a caller can
|
|
320
|
+
`await User.query(...)` then `await asyncMap(result)` today).
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Phased implementation plan
|
|
325
|
+
|
|
326
|
+
Mirroring the issue's "phased implementation plan (query top-level first → list/relation)":
|
|
327
|
+
|
|
328
|
+
1. **Phase 1 — `query` top-level after-fetch hydrate.** Add `hydrate?:
|
|
329
|
+
(raw) => R` to `QueryOptions` (`src/operations/query.ts`) and apply it as the
|
|
330
|
+
final step in `executeQuery` (only on a present item; `null` stays `null`).
|
|
331
|
+
Add the `hydrate` overload to `ModelStatic.query` (`src/model/types.ts`) so
|
|
332
|
+
`R` propagates and the default overloads stay unchanged. **Tests:** type
|
|
333
|
+
tests proving `R` propagation (`Promise<R | null>`) and that the no-`hydrate`
|
|
334
|
+
default return type is byte-identical; a unit test proving the default
|
|
335
|
+
(no `hydrate`) returns the unchanged plain object; a generation test proving
|
|
336
|
+
`hydrate` never appears in `operations.json`. *(IMPLEMENTED — see the
|
|
337
|
+
*Phase 1 implementation* section below.)*
|
|
338
|
+
|
|
339
|
+
2. **Phase 2 (FUTURE) — `list` top-level after-fetch hydrate.** Apply the same factory
|
|
340
|
+
**per item** of `executeListInternal`'s hydrated `items`
|
|
341
|
+
(`{ items: items.map(hydrate), cursor }`; cursor untouched). Add the
|
|
342
|
+
`hydrate` overload to `ModelStatic.list`. Same test matrix, plus the
|
|
343
|
+
per-item error-rejection contract.
|
|
344
|
+
|
|
345
|
+
3. **Phase 3 (FUTURE) — relation scope (only if needed).** If per-relation hydrate is
|
|
346
|
+
wanted, add a `.hydrate(...)` to the relation select builder and thread each
|
|
347
|
+
relation's `R` through `QueryResult` and `resolveRelations` /
|
|
348
|
+
`fetchBelongsToHasOneBatch` (apply post-batch, post-nested-resolution, per
|
|
349
|
+
the *Relation scope* section). Until then, top-level `hydrate` + in-factory
|
|
350
|
+
relation mapping covers the use case.
|
|
351
|
+
|
|
352
|
+
Each phase keeps the plain-object default unchanged, adds only host-runtime
|
|
353
|
+
behavior, and adds nothing to the SSoT / `operations.json`.
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Phase 1 implementation (TS, shipped)
|
|
358
|
+
|
|
359
|
+
Phase 1 is small, non-invasive, and follows the `updatable` precedent exactly
|
|
360
|
+
(Phases 2–3 remain the plan above):
|
|
361
|
+
|
|
362
|
+
- `QueryOptions` (`src/operations/query.ts`) gains
|
|
363
|
+
`hydrate?: (raw: Record<string, unknown> | null) => unknown` — applied as the
|
|
364
|
+
final step in `executeQuery`, *only* on a present item.
|
|
365
|
+
- `ModelStatic.query` (`src/model/types.ts`) gains a leading overload that
|
|
366
|
+
infers `R` from `hydrate` and returns `Promise<R | null>`; the existing
|
|
367
|
+
no-`hydrate` overloads are unchanged.
|
|
368
|
+
|
|
369
|
+
Verified invariants (covered by the test suite):
|
|
370
|
+
|
|
371
|
+
- **Default unchanged:** a `query` with no `hydrate` returns the same plain
|
|
372
|
+
object as before (unit + type).
|
|
373
|
+
- **`R` propagates:** `hydrate: (raw) => new UserSO(raw)` types as
|
|
374
|
+
`Promise<UserSO | null>` (type test); the factory's input is the projected
|
|
375
|
+
`QueryResult` (type test).
|
|
376
|
+
- **`null` skips the factory:** a missing item returns `null`, factory not
|
|
377
|
+
called (unit).
|
|
378
|
+
- **Not serialized:** `operations.json` contains no `hydrate` (generation
|
|
379
|
+
test / `grep`).
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## Design forks surfaced
|
|
384
|
+
|
|
385
|
+
1. **Relation scope: top-level-only (recommended) vs per-relation.** Resolved in
|
|
386
|
+
favor of top-level-only for Phases 1–2, with per-relation deferred to a later
|
|
387
|
+
issue (the top-level factory can map relations in its body, so this is
|
|
388
|
+
ergonomics, not capability). Documented in *Relation scope*.
|
|
389
|
+
2. **Combining `updatable` + `hydrate`.** Phase 1 ships them as independent
|
|
390
|
+
options; the factory receives the (possibly updatable) plain object but the
|
|
391
|
+
returned `R` does not inherit the `Updatable` brand. A combined "updatable
|
|
392
|
+
domain object" return is a possible later type fork; recommendation is to use
|
|
393
|
+
one or the other per call. Documented in *Interaction with `updatable`*.
|
|
394
|
+
3. **Sync vs async factory.** Phase 1 is synchronous (`(raw) => R`); async is a
|
|
395
|
+
deferred extension. Documented in *Error handling*.
|
|
396
|
+
|
|
397
|
+
## Bottom line
|
|
398
|
+
|
|
399
|
+
`options.hydrate` is the read-side analog of `updatable` (#54): a host-runtime,
|
|
400
|
+
non-serialized, return-type-changing read option, added to the existing options
|
|
401
|
+
bag rather than as a new method. A factory `(raw: QueryResult<T, …>) => R`
|
|
402
|
+
propagates `R` to the call's return type by inference (subsuming `queryAs`),
|
|
403
|
+
applied as the **last** after-fetch step (after the #50 middleware hook, on a
|
|
404
|
+
present item only). It is **top-level scope** to start, with per-relation
|
|
405
|
+
hydrate a deferred, well-defined extension. Critically, it is **host-only and
|
|
406
|
+
never enters the SSoT / `operations.json`** — so the declarative "what to fetch"
|
|
407
|
+
stays serializable for the Python bridge (#48), and only the host-side "what
|
|
408
|
+
object to load it onto" is added. The plain Typed-Plain-Object default is
|
|
409
|
+
unchanged.
|