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.
@@ -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.