graphddb 0.4.2 → 0.4.3
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 +136 -470
- package/docs/cdc-emulator.md +203 -0
- package/docs/class-hydration.md +409 -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 +3 -2
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# CDC Emulator
|
|
2
|
+
|
|
3
|
+
The CDC (Change Data Capture) emulator produces DynamoDB-Streams-equivalent change events from GraphDDB writes and delivers them to a consumer under a deterministic, fault-injectable delivery contract. It exists to drive and verify stream-based logic — incremental aggregation in particular (see the [Aggregate Tree Pattern](../examples/aggregate-tree-pattern/README.md)) — locally and in CI.
|
|
4
|
+
|
|
5
|
+
The emulator is **dev/test only**. DynamoDB Local does not support Streams, so there is no standard way to exercise stream consumers off-cloud. The emulator fills that gap. Its `ChangeEvent` type and delivery contract mirror real DynamoDB Streams (`NEW_AND_OLD_IMAGES`), so a consumer written against the emulator runs unchanged against real Streams in production.
|
|
6
|
+
|
|
7
|
+
The emulator is responsible only for **producing, delivering, faulting, and replaying change events**. It performs no aggregation; aggregate logic lives entirely in the consumer.
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
| In scope | Out of scope |
|
|
12
|
+
|---|---|
|
|
13
|
+
| Capturing writes and producing `ChangeEvent`s | Running aggregations (consumer's responsibility) |
|
|
14
|
+
| OldImage / NewImage attachment | Faithful reproduction of real Streams internals (shard splits, iterator age) |
|
|
15
|
+
| Per-shard ordering and batched delivery | Cross-region / GSI streams, TTL-delete events |
|
|
16
|
+
| Fault injection (duplicate, reorder, delay, drop, partial-batch failure) | A user-facing public write hook |
|
|
17
|
+
| Record / replay | Production use |
|
|
18
|
+
|
|
19
|
+
## The `ChangeEvent` type
|
|
20
|
+
|
|
21
|
+
`ChangeEvent` is owned by the `cdc` module; `core` never references it. The shape mirrors a DynamoDB Streams record under the `NEW_AND_OLD_IMAGES` view, which is the only view supported — incremental aggregation requires both images.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
type StreamViewType = 'NEW_AND_OLD_IMAGES';
|
|
25
|
+
type ChangeEventName = 'INSERT' | 'MODIFY' | 'REMOVE';
|
|
26
|
+
|
|
27
|
+
interface ChangeEvent<T = Record<string, unknown>> {
|
|
28
|
+
eventName: ChangeEventName;
|
|
29
|
+
table: string;
|
|
30
|
+
model?: string; // resolved model name when known
|
|
31
|
+
keys: { pk: string; sk: string };
|
|
32
|
+
oldImage?: T; // present for MODIFY / REMOVE
|
|
33
|
+
newImage?: T; // present for INSERT / MODIFY
|
|
34
|
+
approximateCreationTime: string; // ISO 8601, derived from the clock
|
|
35
|
+
sequenceNumber: string; // monotonic within a shard, zero-padded
|
|
36
|
+
shardId: string; // partition = hash(pk)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ChangeBatch<T = Record<string, unknown>> {
|
|
40
|
+
records: ChangeEvent<T>[]; // same-shard records in sequenceNumber order
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface BatchResult {
|
|
44
|
+
batchItemFailures?: string[]; // sequenceNumbers the consumer failed to process
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ChangeHandler = (batch: ChangeBatch) => Promise<BatchResult | void>;
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`eventName` is derived from the write: a `delete` yields `REMOVE`; a `put`/`update` yields `MODIFY` when a prior image existed, otherwise `INSERT`. `sequenceNumber` is the per-shard monotonic counter rendered as a 20-digit zero-padded string so lexicographic order equals numeric order. `approximateCreationTime` is derived from the active clock (virtual or real).
|
|
51
|
+
|
|
52
|
+
A consumer derives a delta from `oldImage`/`newImage` and may use `approximateCreationTime` as the source change time.
|
|
53
|
+
|
|
54
|
+
## Capture seam
|
|
55
|
+
|
|
56
|
+
GraphDDB captures writes through an internal, non-exported seam in `core`. The write paths (`executePut`/`Update`/`Delete`/`batch`/`transaction`) call `captureWrite(record)` **after a successful write**. The seam carries only a minimal `RawWriteRecord` (`table`, `model?`, `op`, `keys`, `newItem?`, `oldItem?`); `core` knows nothing of images, sequence numbers, or shards.
|
|
57
|
+
|
|
58
|
+
- The seam is `@internal` and is **not** re-exported from `src/index.ts`. It is the private internal mechanism, distinct from any user-facing hook (which is out of scope). The dependency direction is always `core ← cdc`; `core` does not import the cdc module, so there is no cycle.
|
|
59
|
+
- `ChangeCaptureRegistry` (core-owned, `@internal`) supports multiple subscribers via `register(subscriber): Unsubscribe`. With zero subscribers the seam is a no-op, so there is zero overhead when nothing is listening.
|
|
60
|
+
- A pre-write image (`ReturnValues: ALL_OLD`) is requested only when the model is stream-enabled **and** a subscriber is live, so the OldImage cost is never paid when nobody is listening.
|
|
61
|
+
- Writes that did not actually land (e.g. `ConditionalCheckFailedException`) are not captured.
|
|
62
|
+
- Capture failures are isolated: a throwing subscriber neither fails the originating write nor blocks other subscribers (errors are swallowed at the seam).
|
|
63
|
+
|
|
64
|
+
The cdc module registers as one subscriber and maps each `RawWriteRecord` to a `ChangeEvent`, assigning `shardId`, `sequenceNumber`, and `approximateCreationTime`. Stream enablement is opt-in per model: each class in `opts.models` is stream-enabled on construction (`ChangeCaptureRegistry.enableStream`) and disabled on `close()`. When `models` is provided, records whose resolved model name is not in the set are dropped; a record carrying no model name is emitted.
|
|
65
|
+
|
|
66
|
+
## Delivery contract
|
|
67
|
+
|
|
68
|
+
- **Sharding.** `shardId = hash(pk) % shardCount` (FNV-1a, default `shardCount` 8). All events for one partition key land in the same shard.
|
|
69
|
+
- **Per-shard ordering.** Within a shard, records are delivered in ascending `sequenceNumber`. Order across shards is independent.
|
|
70
|
+
- **Batching.** Records are delivered in batches of up to `batchSize` (default 100) per shard.
|
|
71
|
+
- **At-least-once.** Duplicate delivery is permitted and is reproducible via fault injection.
|
|
72
|
+
- **Checkpoint / resume.** Each shard tracks the last successfully checkpointed sequence number, available via `checkpoints()`. The checkpoint advances only over the **contiguous prefix** of acked sequences (`ReportBatchItemFailures` semantics): it never moves past an un-acked record awaiting redelivery, so a resume cannot skip a record.
|
|
73
|
+
- **Partial-batch failure.** The consumer returns the `sequenceNumber`s it failed to process in `BatchResult.batchItemFailures`. Successful records are checkpointed; failed records are redelivered, re-sorted into ascending order so per-shard ordering is preserved.
|
|
74
|
+
- **Dead-letter queue.** A record that fails more than `maxRetries` times (default 3) is moved to the dead-letter buffer, available via `deadLetters()`.
|
|
75
|
+
|
|
76
|
+
## Modes
|
|
77
|
+
|
|
78
|
+
The `mode` option selects when events are delivered.
|
|
79
|
+
|
|
80
|
+
| Mode | Use | Delivery timing |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `inline` | Unit tests | Delivered automatically after each capture. The capture seam is synchronous; the async delivery is chained onto an internal promise that callers await via `flush()`. |
|
|
83
|
+
| `queued` | Time-dependent behavior (throttle, sweep period, staleness) | Buffered. Delivery is driven by `pump()` or by advancing the virtual clock with `advanceClock()`. |
|
|
84
|
+
| `record` | Capture a log without delivering | Events are recorded but not delivered; obtain the log with `record()`. |
|
|
85
|
+
| `replay` | Replay-equivalence verification | Delivery is driven by `replay(log, opts?)`. |
|
|
86
|
+
|
|
87
|
+
Every mode records events into an internal log, so `record()` and `replay()` are available regardless of mode.
|
|
88
|
+
|
|
89
|
+
## Virtual clock
|
|
90
|
+
|
|
91
|
+
The `clock` option is `'real'` or `'virtual'`; the default is `'virtual'` for `queued` mode and `'real'` otherwise. Under the virtual clock, time advances only through `advanceClock(ms)`, which moves the clock forward and delivers every record whose due time has arrived. `now()` returns the current virtual time in milliseconds since the emulator epoch (`2020-01-01T00:00:00Z`), and `approximateCreationTime` is derived from it. This makes throttle period, sweep period, and staleness behavior deterministic without depending on wall-clock time. `advanceClock()` throws if the clock is not virtual.
|
|
92
|
+
|
|
93
|
+
## Fault injection
|
|
94
|
+
|
|
95
|
+
Faults are configured via `fault(spec)` (the spec is merged into the current one). All randomness routes through a seeded PRNG (`seed`, default 1), so a given seed reproduces the exact same delivery — including duplicates, reorderings, delays, and failures.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
interface FaultSpec {
|
|
99
|
+
duplicate?: number; // probability a delivered record is delivered again
|
|
100
|
+
reorder?: boolean; // shuffle within-shard order on delivery
|
|
101
|
+
delay?: { min: number; max: number }; // per-record virtual-time delivery delay (ms); requires queued mode
|
|
102
|
+
dropThenRedeliver?: number; // probability a record is dropped on first delivery, then redelivered
|
|
103
|
+
partialBatchFailure?: number; // probability a record is reported as a partial-batch failure
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`reorder` deliberately violates the within-shard ordering guarantee to exercise a consumer's invariant detection. `delay` withholds a record until the virtual clock reaches its due time.
|
|
108
|
+
|
|
109
|
+
### Concurrent-recompute injection
|
|
110
|
+
|
|
111
|
+
To stage a mark-versus-recompute race (the false-clean race), the harness arms two hooks:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
emulator.injectConcurrentRecompute({ ref: 'Parent#p1' }); // which ref to race
|
|
115
|
+
emulator.onConcurrentRecompute((ref, event) => { /* run the recompute */ });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Once both are set, the emulator invokes the registered recompute callback for each record immediately before that record reaches the consumer, deterministically interleaving the recompute with the consumer's mark. `injectConcurrentRecompute` alone records the ref; `onConcurrentRecompute` supplies the actual recompute and arms the race.
|
|
119
|
+
|
|
120
|
+
## API
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
createCdcEmulator(opts?: CdcEmulatorOptions): CdcEmulator
|
|
124
|
+
|
|
125
|
+
class CdcEmulator {
|
|
126
|
+
subscribe(handler: ChangeHandler): Unsubscribe;
|
|
127
|
+
flush(): Promise<void>; // await inline-mode deliveries
|
|
128
|
+
pump(maxBatches?: number): Promise<void>;
|
|
129
|
+
advanceClock(ms: number): Promise<void>;
|
|
130
|
+
now(): number;
|
|
131
|
+
fault(spec: FaultSpec): void;
|
|
132
|
+
injectConcurrentRecompute(ref: ConcurrentRecomputeRef): void;
|
|
133
|
+
onConcurrentRecompute(hook: (ref, event) => void | Promise<void>): void;
|
|
134
|
+
record(): EventLog;
|
|
135
|
+
replay(log: EventLog, opts?: ReplayOptions): Promise<void>;
|
|
136
|
+
checkpoints(): Record<ShardId, string>;
|
|
137
|
+
deadLetters(): ChangeEvent[];
|
|
138
|
+
reset(): void; // reset delivery state and re-seed (keeps subscription + opt-in)
|
|
139
|
+
close(): void; // detach from the seam, disable model streaming
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`subscribe` registers the single consumer; when `startingPosition` is `'LATEST'`, records buffered before the subscription are dropped. `record()` returns `{ seed, events }`. `replay(log, opts)` re-shards the log by `hash(pk)`, optionally shuffles and duplicates it (`ReplayOptions = { shuffle?: boolean; duplicate?: number }`), then re-sorts each shard into ascending `sequenceNumber` order before delivering — so cross-shard reordering and at-least-once chaos are modelled while the within-shard ordering guarantee that aggregation depends on is preserved.
|
|
144
|
+
|
|
145
|
+
## Options
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
interface CdcEmulatorOptions {
|
|
149
|
+
models?: Function[]; // stream-enabled model classes (opt-in; default none)
|
|
150
|
+
mode?: CdcMode; // default 'inline'
|
|
151
|
+
clock?: ClockMode; // default 'virtual' for queued, 'real' otherwise
|
|
152
|
+
batchSize?: number; // default 100
|
|
153
|
+
maxRetries?: number; // default 3
|
|
154
|
+
startingPosition?: 'TRIM_HORIZON' | 'LATEST'; // default 'TRIM_HORIZON'
|
|
155
|
+
seed?: number; // fault / shard determinism; default 1
|
|
156
|
+
shardCount?: number; // default 8
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
A `ModelStatic` (from `DDBModel.asModel()`) or a raw decorated class may be passed in `models`; a `ModelStatic` is unwrapped to its inner class so the stream opt-in and model-name filter align with the class the write paths key on.
|
|
161
|
+
|
|
162
|
+
## Connecting to a consumer
|
|
163
|
+
|
|
164
|
+
The emulator emits generic `ChangeEvent`s; wiring them to an aggregate is the consumer's job. A consumer subscribes, derives the work to do from each event, and reports per-record outcomes. Below, a consumer rebuilds a leaf-sum aggregate idempotently from `newImage`:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const cdc = createCdcEmulator({ models: [CounterModel], mode: 'queued', seed: 1 });
|
|
168
|
+
|
|
169
|
+
const latestByLeaf = new Map<string, number>();
|
|
170
|
+
cdc.subscribe(async (batch) => {
|
|
171
|
+
for (const e of batch.records) {
|
|
172
|
+
const key = `${e.keys.pk}|${e.keys.sk}`;
|
|
173
|
+
if (e.eventName === 'REMOVE') latestByLeaf.delete(key);
|
|
174
|
+
else latestByLeaf.set(key, Number((e.newImage as { value: number }).value));
|
|
175
|
+
}
|
|
176
|
+
// return { batchItemFailures: [...] } to redeliver specific records
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await Counter.putItem({ siteId: 's1', leafId: 'a', value: 10 });
|
|
180
|
+
await cdc.advanceClock(1); // drive delivery under the virtual clock
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Sweep scheduling lives outside the emulator: the emulator handles only change production and delivery. In production, swap `createCdcEmulator` for a real Streams consumer; the consumer code is unchanged because the `ChangeEvent` type and delivery contract match.
|
|
184
|
+
|
|
185
|
+
### Recording and replaying
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const recorder = createCdcEmulator({ models: [CounterModel], mode: 'record', seed: 4 });
|
|
189
|
+
await Counter.putItem({ siteId: 's1', leafId: 'a', value: 5 });
|
|
190
|
+
await Counter.updateItem({ siteId: 's1', leafId: 'a' }, { value: 9 });
|
|
191
|
+
const log = recorder.record();
|
|
192
|
+
recorder.close();
|
|
193
|
+
|
|
194
|
+
const replayer = createCdcEmulator({ mode: 'replay', seed: 4 });
|
|
195
|
+
replayer.subscribe(/* idempotent, key-addressed consumer */);
|
|
196
|
+
await replayer.replay(log, { shuffle: true, duplicate: 0.5 });
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Because the consumer is idempotent and the replay preserves per-shard ordering, the final aggregate equals a full recompute from source regardless of the global delivery order or duplicates (replay equivalence).
|
|
200
|
+
|
|
201
|
+
## Related
|
|
202
|
+
|
|
203
|
+
- Pattern: [Aggregate Tree Pattern](../examples/aggregate-tree-pattern/README.md)
|
|
@@ -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.
|