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/docs/spec.md ADDED
@@ -0,0 +1,1626 @@
1
+ # GraphDDB — Interface Specification
2
+
3
+ GraphDDB is a TypeScript library for modeling **graph-shaped data on Amazon
4
+ DynamoDB** using the single-table / adjacency-list pattern. You declare entities
5
+ and their access patterns (primary key, GSIs) and relations between them with
6
+ TC39 decorators, and query them with a GraphQL-style "key + selection" API. The
7
+ library resolves the access pattern, projects only the requested attributes,
8
+ traverses relations (in parallel, bounded), and hydrates the raw items back into
9
+ typed objects.
10
+
11
+ This document is the **interface specification** for library consumers. Every
12
+ signature, type, default, and behavior below is taken from the current source.
13
+
14
+ > Version: `0.1.0`. Module type: ESM. Node `>=22`. Peer dependencies:
15
+ > `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` (`^3.0.0`).
16
+
17
+ ### Related specifications
18
+
19
+ This document is the **core API reference**. The following subsystems have their
20
+ own specs:
21
+
22
+ - [`cqrs-contract.md`](./cqrs-contract.md) — the CQRS Query / Command contracts.
23
+ - [`python-bridge.md`](./python-bridge.md) — multi-language TypeScript→Python code
24
+ generation and runtime.
25
+ - [`design-patterns.md`](./design-patterns.md) — the ten canonical DynamoDB
26
+ access-pattern designs (RFC #118 §1) and how each maps to a graphddb feature.
27
+ - [`cdc-emulator.md`](./cdc-emulator.md) — the Change Data Capture emulator for
28
+ local development and tests.
29
+ - [`testing.md`](./testing.md) — the in-memory test adapter (`graphddb/testing`).
30
+ - [`middleware.md`](./middleware.md) — host-side read & write middleware / hooks
31
+ (`DDBModel.use`): read R1–R5, write W1–W5, registration, per-call `context`,
32
+ ordering, cancellation / recovery, and Python parity.
33
+
34
+ For a comparison against other DynamoDB libraries, see
35
+ [`../benchmark/RESULTS.md`](../benchmark/RESULTS.md).
36
+
37
+ ---
38
+
39
+ ## Table of contents
40
+
41
+ 1. [Concepts and architecture](#1-concepts-and-architecture)
42
+ 2. [Connection and client configuration](#2-connection-and-client-configuration)
43
+ 3. [Defining entities](#3-defining-entities)
44
+ 4. [Field types and the type system](#4-field-types-and-the-type-system)
45
+ 5. [Keys and GSIs](#5-keys-and-gsis)
46
+ 6. [Embedded objects](#6-embedded-objects)
47
+ 7. [Relations](#7-relations)
48
+ 8. [The model API (`asModel`)](#8-the-model-api-asmodel)
49
+ 9. [Selecting fields (projection)](#9-selecting-fields-projection)
50
+ 10. [`query` — fetch a single item](#10-query--fetch-a-single-item)
51
+ 11. [`list` — query a partition](#11-list--query-a-partition)
52
+ 12. [Pagination and ordering](#12-pagination-and-ordering)
53
+ 13. [`filter` — server-side FilterExpression](#13-filter--server-side-filterexpression)
54
+ 14. [Post-load filtering](#14-post-load-filtering)
55
+ 15. [`cond` and `Model.col` — raw escape hatch](#15-cond-and-modelcol--raw-escape-hatch)
56
+ 16. [Relation traversal](#16-relation-traversal)
57
+ 17. [`explain` — execution plan](#17-explain--execution-plan)
58
+ 18. [Create / Put](#18-create--put)
59
+ 19. [Update](#19-update)
60
+ 20. [Delete](#20-delete)
61
+ 21. [Conditional writes](#21-conditional-writes)
62
+ 22. [The unified envelope — `DDBModel.query` / `DDBModel.mutate`](#22-the-unified-envelope--ddbmodelquery--ddbmodelmutate)
63
+ 23. [Batch operations](#23-batch-operations)
64
+ 24. [The Linter and design rules](#24-the-linter-and-design-rules)
65
+ 25. [Runtime responsibilities (pipeline)](#25-runtime-responsibilities-pipeline)
66
+ 26. [Error reference](#26-error-reference)
67
+ 27. [Public API surface](#27-public-api-surface)
68
+
69
+ The running examples use the `examples/user-permissions` model
70
+ (`Group`, `User`, `GroupMembership`, `Permission`) — a single-table,
71
+ adjacency-list permissions graph.
72
+
73
+ ---
74
+
75
+ ## 1. Concepts and architecture
76
+
77
+ **Single table, adjacency list.** All entities map to one DynamoDB table with a
78
+ composite key `PK` (partition) / `SK` (sort). Each entity declares a `prefix`
79
+ that is prepended to its partition key (`<prefix>#<pk>`), and a key mapping
80
+ function that produces `{ pk, sk }`. Different entity types can share a partition
81
+ (e.g. a `GROUP#eng` partition holds the group `META` item, its `USER#…`
82
+ memberships, and its `PERM#…` permissions); they are distinguished by their `SK`
83
+ shape. GSIs follow the same pattern with attributes `<indexName>PK` /
84
+ `<indexName>SK`.
85
+
86
+ **GraphQL-style separation.** A read is split into a **key** (the access-pattern
87
+ input, dynamic values) and a **selection** (which fields, statically declared).
88
+ This mirrors GraphQL's arguments + selection set.
89
+
90
+ **Declarative metadata, no `reflect-metadata`.** Decorators record metadata into
91
+ a module-level collector at class-definition time; the `@model` class decorator
92
+ drains it into a `MetadataRegistry` entry. Key/GSI definitions are stored as
93
+ static class properties and finalized lazily on first metadata access. The
94
+ library does not depend on `reflect-metadata`.
95
+
96
+ **Execution pipeline.** A read flows through these stages (each is a public
97
+ module):
98
+
99
+ ```
100
+ key + select
101
+ → key-resolver (which access pattern: PK / GSI, full or partial)
102
+ → planner (build DynamoDBOperation: GetItem vs Query, projection, filter)
103
+ → executor (issue GetItem / Query / BatchGetItem via the AWS SDK)
104
+ → hydrator (raw item → typed object: Date restore, embedded rebuild, selected fields only)
105
+ → relation traversal (resolve nested relations, bounded-parallel)
106
+ ```
107
+
108
+ Arbitrary post-load TypeScript filtering is not part of this pipeline; it is
109
+ applied by the caller on the already-typed result (`result.items.filter(...)`,
110
+ §14).
111
+
112
+ ---
113
+
114
+ ## 2. Connection and client configuration
115
+
116
+ GraphDDB uses a process-global client (`ClientManager`) and an optional
117
+ table-name remap (`TableMapping`). Configure them via the static methods on
118
+ `DDBModel` before issuing any operation.
119
+
120
+ ```ts
121
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
122
+ import { DDBModel } from 'graphddb';
123
+
124
+ DDBModel.setClient(new DynamoDBClient({ region: 'us-east-1' }));
125
+
126
+ // Optional: remap declared table names → physical table names (e.g. per stage).
127
+ DDBModel.setTableMapping({ UserPermissions: 'UserPermissions-prod' });
128
+ ```
129
+
130
+ | Method | Signature | Behavior |
131
+ | --- | --- | --- |
132
+ | `DDBModel.setClient` | `(client: DynamoDBClient) => void` | Stores the client globally and discards any cached document client. |
133
+ | `DDBModel.setTableMapping` | `(mapping: Record<string, string>) => void` | Replaces the declared-name → physical-name map. |
134
+
135
+ - The low-level marshalling uses a `DynamoDBDocumentClient` (`@aws-sdk/lib-dynamodb`),
136
+ created lazily from the configured client and cached. Values you pass and
137
+ receive are plain JS (un-marshalled).
138
+ - If no client is set, the first operation throws:
139
+ `DynamoDB client is not configured. Call DDBModel.setClient(new DynamoDBClient({...})) first.`
140
+ - `TableMapping.resolve(name)` returns the mapped physical name, or the declared
141
+ name unchanged when no mapping entry exists.
142
+
143
+ ---
144
+
145
+ ## 3. Defining entities
146
+
147
+ An entity is a class extending `DDBModel`, annotated with `@model(...)` and field
148
+ decorators. Key/GSI definitions are `static` class properties.
149
+
150
+ ```ts
151
+ import { DDBModel, model, string, datetime, key, k, gsi, hasMany } from 'graphddb';
152
+
153
+ @model({ table: 'UserPermissions', prefix: 'USER' })
154
+ class UserModel extends DDBModel {
155
+ static readonly keys = key<{ userId: string }>((c) => ({
156
+ pk: k`USER#${c.userId}`,
157
+ sk: k`PROFILE`,
158
+ }));
159
+
160
+ static readonly emailIndex = gsi<{ email: string }>(
161
+ 'GSI1',
162
+ (c) => ({ pk: k`EMAIL#${c.email}`, sk: k`PROFILE` }),
163
+ { unique: true },
164
+ );
165
+
166
+ @string userId!: string;
167
+ @string name!: string;
168
+ @string email!: string;
169
+ @string status!: string;
170
+ @datetime createdAt!: Date;
171
+
172
+ @hasMany(() => GroupMembershipModel, { userId: 'userId' }, {
173
+ limit: { default: 20, max: 100 },
174
+ })
175
+ groups!: GroupMembershipModel[];
176
+ }
177
+ ```
178
+
179
+ ### `@model(options)`
180
+
181
+ ```ts
182
+ interface ModelOptions {
183
+ table: string; // declared table name (resolved through TableMapping)
184
+ prefix?: string; // PK prefix; derived from the class name when omitted
185
+ }
186
+ ```
187
+
188
+ - Applied to the class, it drains all field / embedded / relation metadata
189
+ collected by the field decorators above it and registers an `EntityMetadata`
190
+ entry.
191
+ - **PK prefix rule** (`derivePrefix`):
192
+ - explicit `prefix: 'USER'` → stored as `USER#` (a `#` is appended);
193
+ - omitted → derived from the class name, stripping a trailing `Model`
194
+ (`UserModel` → `User#`); the class name must be available or registration
195
+ throws.
196
+ - Registration runs the **linter** (see §24); error-severity rule violations
197
+ throw at finalization time.
198
+
199
+ ### `EntityMetadata`
200
+
201
+ The registered shape (exported as a type) is:
202
+
203
+ ```ts
204
+ interface EntityMetadata {
205
+ tableName: string;
206
+ prefix: string; // includes the trailing '#'
207
+ fields: FieldMetadata[];
208
+ primaryKey: KeyDefinition | null;
209
+ gsiDefinitions: GsiDefinition[];
210
+ relations: RelationMetadata[];
211
+ aggregates: AggregateMetadata[]; // scalar @aggregate fields (Epic #118 §5.2)
212
+ embeddedFields: EmbeddedMetadata[];
213
+ }
214
+ ```
215
+
216
+ `MetadataRegistry.get(class)` returns it (finalizing key/GSI/lint on first call)
217
+ and throws if the class was never decorated with `@model`.
218
+
219
+ `aggregates` holds the scalar `@aggregate` fields (a `counter` / latest preset —
220
+ e.g. `postCount!: number ← count()`, `lastPostAt!: string ← max('createdAt')`;
221
+ Epic #118 §5.2, issue #122). It is its own array, distinct from both `fields` (a
222
+ plain stored attribute) and `relations` (a navigation): an aggregate carries a
223
+ source binding like a relation but surfaces a scalar value like a field. The
224
+ `@aggregate` `count()` counter is maintained end-to-end (an atomic `ADD ±1` on the
225
+ counter row, composed into the same transaction as the source write, in both the
226
+ TS runtime and the Python mirror; #141). A `max(field)` aggregate is recorded in
227
+ the IR but loud-rejected on the synchronous path (it needs the stream path, #130;
228
+ see §"Maintained relations" below).
229
+
230
+ ---
231
+
232
+ ## 4. Field types and the type system
233
+
234
+ ### Semantic field decorators
235
+
236
+ Each scalar attribute is annotated with a **semantic decorator** that maps to a
237
+ DynamoDB attribute type and is **bound to the declared TypeScript field type**.
238
+ The decorator only type-checks against a field whose declared type is assignable
239
+ to the decorator's type — e.g. `@string age!: number` is a **compile error**,
240
+ while `@string name!: string` and optional `@string name?: string` pass.
241
+
242
+ | Decorator | DynamoDB type (`DynamoType`) | Bound TS type | Notes |
243
+ | --- | --- | --- | --- |
244
+ | `@string` | `S` | `string` | |
245
+ | `@number` | `N` | `number` | |
246
+ | `@boolean` | `BOOL` | `boolean` | |
247
+ | `@datetime` | `S` | `Date` | Serialized to ISO 8601; default `{ format: 'datetime' }`. |
248
+ | `@binary` | `B` | `Uint8Array` | |
249
+ | `@stringSet` | `SS` | `Set<string>` | |
250
+ | `@numberSet` | `NS` | `Set<number>` | |
251
+ | `@list` | `L` | `unknown[]` | |
252
+ | `@map` | `M` | `Record<string, unknown>` | |
253
+ | `@field(type, opts?)` | (explicit) | (untyped) | **Deprecated**, low-level escape hatch; not type-bound. |
254
+
255
+ Semantic decorators are **dual-mode**: apply directly (`@string name`) or as a
256
+ factory with options (`@string({ readonly: true }) id`).
257
+
258
+ ### `FieldOptions`
259
+
260
+ ```ts
261
+ interface FieldOptions {
262
+ format?: 'datetime' | 'date';
263
+ readonly?: boolean;
264
+ serialize?: (value: unknown) => unknown;
265
+ deserialize?: (value: unknown) => unknown;
266
+ }
267
+ ```
268
+
269
+ - `@datetime` defaults to `format: 'datetime'`.
270
+ - `readonly: true` fields can be written by `putItem` but cause `updateItem` to
271
+ **throw** if included in the changes (see §19).
272
+ - `serialize` / `deserialize` override the default conversion (see §25).
273
+
274
+ ### Serialization / hydration of `Date`
275
+
276
+ - **On write** (`serializeFieldValue`): a custom `serialize` wins; otherwise a
277
+ `Date` with `format: 'datetime'` → `value.toISOString()`, with `format: 'date'`
278
+ → `value.toISOString().split('T')[0]` (date-only `YYYY-MM-DD`).
279
+ - **On read** (hydrator): a custom `deserialize` wins; otherwise an ISO string
280
+ with `format: 'datetime'` → `new Date(value)`, with `format: 'date'` →
281
+ `new Date(value + 'T00:00:00.000Z')`.
282
+
283
+ ### Derived projection / input types
284
+
285
+ The type layer derives several mapped types from an entity `T`:
286
+
287
+ - `SelectableOf<T>` — the selection spec: scalar → `true`, embedded → nested
288
+ `SelectableOf`, `hasMany` (`E[]`) / `belongsTo`·`hasOne` (`E | null`) → a
289
+ `RelationSpec<E>`. Functions are excluded.
290
+ - `EntityInput<T>` — the `put` input: scalars kept with their declared type,
291
+ embedded recursed, **relations excluded** (relations are not written by `put`).
292
+ - `ColumnMap<T>` — one `Column` per scalar field for `Model.col` (relations and
293
+ embedded excluded).
294
+
295
+ ---
296
+
297
+ ## 5. Keys and GSIs
298
+
299
+ Access patterns are declared as **static class properties** assigned the result
300
+ of `key(...)` / `gsi(...)`. The builder receives a typed **column accessor** `c`
301
+ (one `Column` per input field) and returns `{ pk, sk }`, where each of `pk` / `sk`
302
+ is built from the `` k`…` `` tagged template — a **structured key segment**.
303
+
304
+ A `` k`USER#${c.userId}` `` template keeps its literal parts and the interpolated
305
+ column references **structurally** (the columns are never stringified). This
306
+ structure — not an opaque mapping closure — is the single source of truth for a
307
+ model's key: both the runtime (concrete values) and the Python-bridge spec
308
+ generator (`{field}` templates) read it directly, with no symbolic evaluation.
309
+
310
+ `k` accepts **only typed column references** (`c.field`) as interpolations; a bare
311
+ string interpolation throws at definition time, so a key field is always named via
312
+ a refactor-safe column reference.
313
+
314
+ ### `key`
315
+
316
+ ```ts
317
+ function key<T extends Record<string, unknown>>(
318
+ builder: (c: { readonly [K in keyof T]-?: Column<T[K], T> }) => KeyStructure,
319
+ ): KeyDefinitionMarker<T>;
320
+
321
+ // k — the tagged-template segment builder
322
+ function k(literals: TemplateStringsArray, ...columns: Column[]): KeySegment;
323
+
324
+ interface KeyStructure {
325
+ readonly pk: SegmentSpec; // single segment or an ordered list
326
+ readonly sk?: SegmentSpec;
327
+ }
328
+ type SegmentSpec = KeySegment | KeySegment[];
329
+ ```
330
+
331
+ ```ts
332
+ // Composite primary key (PK segment + single SK segment):
333
+ static readonly keys = key<{ groupId: string; userId: string }>((c) => ({
334
+ pk: k`GROUP#${c.groupId}`,
335
+ sk: k`USER#${c.userId}`,
336
+ }));
337
+
338
+ // Multi-segment sort key — pass an array of segments:
339
+ static readonly keys = key<{ groupId: string; resource: string; action: string }>(
340
+ (c) => ({
341
+ pk: k`GROUP#${c.groupId}`,
342
+ sk: [k`PERM#${c.resource}`, k`${c.action}`],
343
+ }),
344
+ );
345
+ ```
346
+
347
+ - The **type parameter `T`** is the key input type (it drives compile-time key
348
+ checking for `query` / `list`). The accessor `c` exposes one `Column` per field
349
+ of `T`.
350
+ - The input field names are collected from the column references across the PK
351
+ then SK segments, in first-seen order (e.g. `['groupId', 'userId']`). This set
352
+ is what the resolver matches query keys against and what the type layer uses to
353
+ derive key types.
354
+ - The `pk` is later prefixed (`<prefix>#<pk>`). A **multi-segment** SK (or PK) is
355
+ joined with the delimiter `#`.
356
+ - An entity has at most one `key` (primary key). Type-level: `key`'s input type
357
+ `T` becomes `PrimaryKeyOf<C>`.
358
+
359
+ **Partial keys and `begins_with`.** Segments are a **truncation boundary**. When a
360
+ read supplies only a *contiguous prefix* of the SK segments (e.g. `{ groupId,
361
+ resource }` against the `[PERM#{resource}, {action}]` SK above), the SK condition
362
+ compiles to a `begins_with` at the first incomplete segment, closed with that
363
+ segment's leading literal followed by a trailing `#` so the prefix cannot bleed
364
+ into a longer sibling token (`PERM#read#` does not match `PERM#readonly`). A
365
+ *non-contiguous* selection (a later segment supplied while an earlier one is
366
+ missing, or only some fields of a multi-field segment) is a resolve error.
367
+
368
+ ### `gsi`
369
+
370
+ ```ts
371
+ function gsi<T extends Record<string, unknown>>(
372
+ indexName: string,
373
+ builder: (c: { readonly [K in keyof T]-?: Column<T[K], T> }) => KeyStructure,
374
+ options?: { unique?: boolean },
375
+ ): GsiDefinitionMarker<T, boolean>;
376
+ ```
377
+
378
+ ```ts
379
+ static readonly emailIndex = gsi<{ email: string }>(
380
+ 'GSI1',
381
+ (c) => ({ pk: k`EMAIL#${c.email}`, sk: k`PROFILE` }),
382
+ { unique: true },
383
+ );
384
+ ```
385
+
386
+ - The builder uses the same `` k`…` `` structured-segment form as `key`.
387
+ - `unique: true` declares that the index resolves to a **single item**, which
388
+ makes it usable by `query()`. Non-unique (default) GSIs are list-only.
389
+ - Multiple GSIs are allowed. The GSI attribute names written/queried are
390
+ `<indexName>PK` / `<indexName>SK`.
391
+
392
+ ### Derived key types
393
+
394
+ From the model class `C` (the constructor, supplied automatically by `asModel`):
395
+
396
+ | Type | Contents | Used by |
397
+ | --- | --- | --- |
398
+ | `PrimaryKeyOf<C>` | PK input only | `deleteItem()` key |
399
+ | `UniqueQueryKeyOf<C>` | PK + **unique** GSI inputs | `query()` key (must resolve to one item) |
400
+ | `QueryKeyOf<C>` | PK + **all** GSI inputs | (constraint label) |
401
+ | `PartialQueryKeyOf<C>` | "at least one field" relaxation of `QueryKeyOf` | `list()` / `explain()` key |
402
+
403
+ `PartialQueryKeyOf` permits a **partial** key (the partition-key subset; the
404
+ sort-key tail is optional), because `list`/`explain` issue a Query against a
405
+ partition. `query()` stays strict.
406
+
407
+ ### Access-pattern resolution (`resolveKey`)
408
+
409
+ Given the set of fields present in the supplied key, the resolver matches in this
410
+ **priority order**:
411
+
412
+ 1. **Exact PK match** — query fields equal the PK input field set → PK access,
413
+ full key.
414
+ 2. **Exact GSI match** — equal to a GSI input field set → that GSI (carrying its
415
+ `unique` flag).
416
+ 3. **Partial PK match** — a non-empty strict subset of the PK input fields that
417
+ still produces a valid (non-`undefined`) partition key → PK access, partial.
418
+ 4. **Partial GSI match** — same, for a GSI → that GSI, partial (treated as
419
+ non-unique), with a `begins_with` range condition on the sort key.
420
+
421
+ If nothing matches, the resolver **throws** `No access pattern found for fields
422
+ [...]` listing the available patterns. "Produces a valid PK" is checked by
423
+ calling the mapping function with placeholder values and rejecting outputs that
424
+ are `undefined`/`null` or that stringify to contain `"undefined"`.
425
+
426
+ > Example (from `user-permissions`): `GroupMembership` has PK input
427
+ > `[groupId, userId]` and `GSI1` input `[userId, groupId]` (with `groupId`
428
+ > optional). Querying with `{ userId }` is an **exact GSI match** (priority 2)
429
+ > and resolves to `GSI1`, not a partial PK match.
430
+
431
+ ---
432
+
433
+ ## 6. Embedded objects
434
+
435
+ An embedded object is a nested non-`DDBModel` structure stored inline as a
436
+ DynamoDB Map (`M`) attribute, declared with `@embedded`.
437
+
438
+ ```ts
439
+ @embedded(() => AddressModel)
440
+ address!: AddressModel;
441
+ ```
442
+
443
+ - The decorator records `{ propertyName, modelFactory }` (a lazy `() => Class`).
444
+ - On `put`, an embedded value present on the item is written as-is under its
445
+ property name (in addition to scalar fields).
446
+ - On `update`, top-level keys of an embedded object are flattened into nested
447
+ `SET`/`REMOVE` paths (`#emb.#field = …`) — see §19.
448
+ - On read, the hydrator reconstructs the embedded object from the Map, recursing
449
+ into nested embedded selects.
450
+ - Embedded fields are part of `SelectableOf` (nested `{ field: true }`
451
+ projection) and `EntityInput`, but are **excluded** from `ColumnMap` /
452
+ `FilterInput` (filters target scalar fields only).
453
+
454
+ ---
455
+
456
+ ## 7. Relations
457
+
458
+ Relations connect entities along their key bindings. Three decorators:
459
+
460
+ ```ts
461
+ function hasMany(
462
+ targetFactory: () => Constructor,
463
+ keyBinding: Record<string, string>, // { targetField: parentSourceField }
464
+ options?: RelationOptions,
465
+ ): FieldDecorator;
466
+
467
+ function belongsTo(targetFactory, keyBinding, options?: RelationOptions): FieldDecorator;
468
+ function hasOne(targetFactory, keyBinding): FieldDecorator;
469
+ ```
470
+
471
+ | Type | Field shape | Resolved via | Cardinality |
472
+ | --- | --- | --- | --- |
473
+ | `hasMany` | `Target[]` | Query against the target's partition | many |
474
+ | `belongsTo` | `Target \| null` | `BatchGetItem` (N parents → one batch) | one |
475
+ | `hasOne` | `Target \| null` | `BatchGetItem` | one |
476
+
477
+ ```ts
478
+ @hasMany(() => GroupMembershipModel, { groupId: 'groupId' }, {
479
+ limit: { default: 50, max: 200 },
480
+ })
481
+ members!: GroupMembershipModel[];
482
+
483
+ @belongsTo(() => GroupModel, { groupId: 'groupId' })
484
+ group!: GroupModel | null;
485
+ ```
486
+
487
+ - **`keyBinding`** maps `{ targetField: sourceField }`: the relation's query key
488
+ is built by reading `sourceField` from the (raw) parent item and binding it to
489
+ `targetField` on the target. The bound target fields must form a resolvable
490
+ access pattern on the target entity (enforced by the `missing-gsi` linter rule;
491
+ see §24).
492
+ - **`RelationOptions`** (accepted by `hasMany` and `belongsTo`):
493
+ ```ts
494
+ interface RelationOptions {
495
+ limit?: { default: number; max: number };
496
+ order?: 'ASC' | 'DESC';
497
+ // ── maintained access path (Epic #118; optional) ──
498
+ pattern?: RelationPattern; // e.g. 'embeddedSnapshot' | 'samePartition' | …
499
+ read?: { maxItems?: number; order?: 'ASC' | 'DESC' };
500
+ write?: {
501
+ maintainedOn?: string[]; // cross-entity triggers, e.g. ['Post.created']
502
+ consistency?: 'transactional' | 'eventual';
503
+ updateMode?: 'mutation' | 'stream';
504
+ };
505
+ projection?: Record<string, ProjectionTransform | string>;
506
+ }
507
+ ```
508
+ `limit.default` is used when the relation select supplies no `limit`. `order`
509
+ is the default scan direction. (The `max` is consumed by the `require-limit`
510
+ linter rule; it is not separately enforced as a runtime cap in the traversal.)
511
+ - `hasOne` takes **no** options. `belongsTo` accepts `RelationOptions` (it carries
512
+ no `limit`/`order` read semantics, but takes the maintenance options below for a
513
+ single-row `embeddedSnapshot`).
514
+ - `targetFactory` is a thunk (`() => Class`) to allow circular references.
515
+
516
+ ### Maintained relations (Epic #118)
517
+
518
+ > **See also:** [`design-patterns.md`](./design-patterns.md) maps the ten
519
+ > canonical DynamoDB access-pattern designs (RFC #118 §1) to the graphddb feature
520
+ > that implements each, with minimal code and the per-phase support status.
521
+
522
+ Omitting `pattern` leaves a relation a plain **read-only navigation** (the
523
+ historical behaviour, fully backward compatible). Declaring `pattern` turns the
524
+ relation into a **maintained access path**: a single mutation on the *source*
525
+ entity keeps a denormalized shape on the *owner* row in sync.
526
+
527
+ ```ts
528
+ @hasMany(
529
+ () => PostModel,
530
+ { threadId: 'threadId' },
531
+ {
532
+ pattern: 'embeddedSnapshot',
533
+ read: { maxItems: 3, order: 'DESC' },
534
+ write: { maintainedOn: ['Post.created'] },
535
+ projection: { postId: 'postId', textPreview: preview('$.entity.body', 120) },
536
+ },
537
+ )
538
+ latestPosts!: PostModel[]; // a collection embeddedSnapshot
539
+ ```
540
+
541
+ Shared vocabulary (confirmed across the epic):
542
+
543
+ - **`pattern`** — the named maintenance preset. `embeddedSnapshot` (a `hasMany`
544
+ collection or a `belongsTo` single-row mirror) and `samePartition` are declared
545
+ on the relation decorators (Phase 1). The view presets that live on a **separate
546
+ view model** — `materializedView` / `sparseView` — are declared with `@model({ kind })`
547
+ + `@maintainedFrom` (§7.1 below). The **versioned** presets ride the `@hasOne` /
548
+ `@hasMany` `pattern` discriminated union (`versionedLatest` / `versionedHistory`).
549
+ External projection is being redesigned as a typed-consumer-IF contract (#153) and
550
+ has no decorator. Unknown values on a relation are accepted by the open-ended union
551
+ but not lowered.
552
+ - **`write.maintainedOn`** — cross-entity triggers `"<Entity>.<event>"`. The
553
+ Entity segment is the **logical** entity name (`PostModel → 'Post'`; or the
554
+ `@model({ prefix })` value sans `#`), and the event is one of
555
+ `created | updated | removed`.
556
+ - **`write.consistency`** — `transactional` (same `TransactWriteItems`) or
557
+ `eventual`. **`write.updateMode`** — `mutation` (synchronous, same transaction)
558
+ or `stream` (asynchronous outbox→CDC-drain; §7.2). Both are now realized.
559
+ - **`projection`** — the **function-form IR** mapping each captured owner
560
+ attribute to how the source value is projected: `preview('$.entity.body', 120)`
561
+ (the `preview` / `identity` helpers), or a bare string for the identity
562
+ shorthand (`postId: 'postId'`). Projection sources are the written source payload
563
+ (`$.entity.*` / `$.input.*`).
564
+
565
+ **Synchronous (`updateMode: 'mutation'`)** maintainers lower to a maintenance write
566
+ folded into the **same** `TransactWriteItems` as the source write (atomic with it):
567
+ a `belongsTo` snapshot re-projects the single mirrored row in place; a `hasMany`
568
+ collection appends a projected item (a `list_append`). On the synchronous path a
569
+ collection is **append-only** — `read.maxItems` trim and `removed`-driven splices
570
+ need a read-modify-write and are therefore the **stream** path (§7.2). Two
571
+ maintainers targeting the same owner row in one mutation is a loud reject ("1
572
+ mutation × 1 target row = 1 effect"). The maintenance graph is built by
573
+ `buildMaintenanceGraph()` (`src/relation/maintenance-graph.ts`) and injected at
574
+ compile time (`src/spec/mutation-command.ts`). A runnable example is
575
+ `examples/embedded-snapshot-pattern/`.
576
+
577
+ The scalar **`@aggregate` counter** (RFC §5.2 `pattern: 'counter'`) is the
578
+ maintenance-pipeline sibling of the snapshot/collection maintainers (§3). A
579
+ `count()` counter is maintained end-to-end synchronously (an atomic, concurrency-
580
+ safe `ADD ±1` composed into the source write's transaction); a `max(field)`
581
+ counter needs a conditional `SET` and so is the **stream** path (a loud reject on
582
+ the synchronous `mutation` path — `src/spec/mutation-command.ts`). A runnable
583
+ example is `examples/aggregate-counter/`.
584
+
585
+ ### 7.1 Maintained view models — `@model({ kind })` + `@maintainedFrom`; versioned `pattern` union (Phase 2/3)
586
+
587
+ `embeddedSnapshot` / `counter` maintain a shape **on a row of an existing entity**
588
+ (keyed by the source's own key binding). The materialized-data patterns that need
589
+ a **distinct view model** — one whose rows exist only to serve a read — are
590
+ declared **decorator-declaratively** (issue #152): a maintained view is its own
591
+ `@model({ kind: 'materializedView' | 'sparseView' })`, fed by class-level
592
+ `@maintainedFrom` decorators. Each lowers to the **same maintenance IR** the relation
593
+ decorators produce (via `src/relation/maintenance-view-adapter.ts`), so the
594
+ maintenance graph, the stream drain (§7.2), and the rebuild planner (§7.3) consume
595
+ them identically — the IR is byte-invariant versus the previous builder.
596
+
597
+ **`@maintainedFrom(source, (self, source) => options)`** — a class-level, **stackable**
598
+ view maintainer. The callback is symbolic: `source.<field>` is a typed reference to a
599
+ source field (lowering to the payload-rooted value the IR reads, typed `keyof Source`),
600
+ `self.<field>` names a view field (the `collection.field` position), and `keyBind` /
601
+ `project` KEYS are typed `keyof Self` names. `on` is the event list (`created`/
602
+ `updated`/`removed`) — the source is the first argument, so no `'Entity.event'` string
603
+ is resolved. **Declaration order is meaningless**: two declarations writing the same
604
+ projection target, the same `collection.field`, or a different view-key field SET are a
605
+ loud build error (no silent last-wins). A `sparseView` requires every declaration to
606
+ carry a `when` membership predicate (built with `whenMember(source.field, op, value?)`),
607
+ mutually exclusive with `collection`.
608
+
609
+ ```ts
610
+ @model({ table: 'AppTable', prefix: 'UTL', kind: 'materializedView' })
611
+ @maintainedFrom(() => PostModel, (self, source) => ({
612
+ keyBind: { userId: source.userId },
613
+ on: ['created', 'removed'],
614
+ project: { postId: source.postId, textPreview: preview(source.body, 120) },
615
+ collection: { field: self.recentPosts, maxItems: 3, order: 'DESC', orderBy: source.createdAt },
616
+ updateMode: 'stream',
617
+ }))
618
+ @maintainedFrom(() => ThreadModel, (_self, source) => ({
619
+ keyBind: { userId: source.userId }, on: ['updated'],
620
+ project: { threadTitle: source.title }, updateMode: 'stream',
621
+ }))
622
+ class UserThreadListModel extends DDBModel { /* keys, fields */ }
623
+ ```
624
+
625
+ **Versioned (`@hasOne` / `@hasMany` `pattern` union)** — versioned reuses the core
626
+ snapshot effect, so it rides the relation `pattern` discriminator on the SOURCE
627
+ (revision) model rather than a dedicated builder. `@hasOne(() => LatestModel,
628
+ (self, source) => ({ pattern: 'versionedLatest', on, project }))` is the single
629
+ overwritten latest pointer; `@hasMany(() => HistoryModel, (self, source) => ({ pattern:
630
+ 'versionedHistory', on, project }))` is the append-only history. Cardinality is type-
631
+ enforced (`versionedLatest` only on `@hasOne`, `versionedHistory` only on `@hasMany`).
632
+ The latest/history key binding is derived from each target model's own `key` fields
633
+ (latest omits the version discriminator, history includes it). Both lower to the SAME
634
+ composed snapshot×2 IR. (A self-co-located form is a deferred follow-up.)
635
+
636
+ External projection's old `defineProjection` / `ProjectionSinkDrain` runtime was
637
+ **removed in 0.4.0** and is being redesigned as a typed-consumer-IF contract (#153).
638
+
639
+ ### 7.2 Stream maintenance — outbox → CDC drain (`updateMode: 'stream'`, #130)
640
+
641
+ A maintainer declaring `write: { updateMode: 'stream' }` is **not** composed into
642
+ the source `TransactWriteItems`. Instead, the source write atomically emits a
643
+ maintenance-outbox row (`MAINT_OUTBOX_PK_PREFIX = 'OUTBOX#MAINT#'`); a host-side
644
+ CDC consumer then applies the owner-row write asynchronously. The stream path is
645
+ what realizes the operations a single atomic transaction cannot express:
646
+
647
+ - collection **trim** (`maxItems`) and `removed`-driven **splice** (read-modify-write);
648
+ - the **`max(field)`** counter (a conditional `SET` whose failed guard must not roll
649
+ back the legitimate source write);
650
+ - **sparse-view membership** (a row must be `PUT` when its predicate holds and
651
+ `DELETE`d when it flips — a branch a single transaction item cannot take).
652
+
653
+ `createMaintenanceDrain({ models })` (and `createMaintenanceDrainHandler`)
654
+ returns a `ChangeHandler` for the CDC emulator (`docs/cdc-emulator.md`) /
655
+ DynamoDB Streams that de-duplicates per event id (at-least-once) and applies
656
+ counters (`ADD` / conditional `max`), snapshots (`SET`), collections
657
+ (read-modify-write order+trim), and membership (put/delete). The drain derives the
658
+ view IR from the same declarative `models` (the view models, by their `@model({ kind })`
659
+ + `@maintainedFrom`) — no separate `views` argument. External-sink projection delivery
660
+ is the consumer's responsibility (redesign #153).
661
+
662
+ ### 7.3 Repair / rebuild planner (`createMaintenanceRebuilder`, #131)
663
+
664
+ `createMaintenanceRebuilder({ models, views })` re-derives a maintained owner row
665
+ from its **source rows** to detect and repair drift:
666
+
667
+ - `.detectDrift(OwnerModel, relationProperty, ownerKey)` → a `DriftReport`
668
+ (`{ kind, drifted, expected, actual }`).
669
+ - `.rebuild(OwnerModel, relationProperty, ownerKey)` → a `RebuildResult`
670
+ (`{ ...DriftReport, repaired }`), writing the **absolute** re-derived value
671
+ (a count is re-derived as a `SET`, not an `ADD`).
672
+
673
+ It re-uses the shared re-derivation rules (`src/relation/maintenance-projection.ts`)
674
+ so a rebuilt value is byte-consistent with what the drain converges to. It **loudly
675
+ rejects** two cases it cannot faithfully reconstruct: an **unordered bounded
676
+ collection** (`maxItems` with no `read.order` — the trimmed subset depends on stream
677
+ arrival order) and a **sparse-view membership** maintainer (rebuild it by re-running
678
+ the source events through the drain).
679
+
680
+ ---
681
+
682
+ ## 8. The model API (`asModel`)
683
+
684
+ Operations are issued through a **model object** obtained from
685
+ `Class.asModel()`, which returns a `ModelStatic<T, C>` whose key and select types
686
+ are precisely inferred from the class.
687
+
688
+ ```ts
689
+ export const User = UserModel.asModel();
690
+ export const Group = GroupModel.asModel();
691
+ export const GroupMembership = GroupMembershipModel.asModel();
692
+ ```
693
+
694
+ > Call `asModel()` with **no** explicit type argument. Its generic is the
695
+ > constructor type (`C extends new (...) => DDBModel`), inferred from the static
696
+ > receiver; passing `asModel<UserModel>()` would wrongly supply the instance
697
+ > type.
698
+
699
+ `ModelStatic<T, C>` exposes:
700
+
701
+ | Member | Kind | Summary |
702
+ | --- | --- | --- |
703
+ | `col` | `ColumnMap<T>` | typed column refs for `cond` (§15) |
704
+ | `project(select)` | builder factory | top-level select builder (§9, §14) |
705
+ | `relation(select)` | builder factory | relation select builder (§9, §14) |
706
+ | `query(key, select, options?)` | read | single item by unique key (§10) |
707
+ | `list(key, select, options?)` | read | partition query (§11) |
708
+ | `explain(key, options?)` | plan | execution plan, no I/O (§17) |
709
+ | `putItem(item, options?)` | write | create/overwrite (§18) |
710
+ | `updateItem(entity, changes, options?)` | write | partial update (§19) |
711
+ | `deleteItem(key, options?)` | write | delete (§20) |
712
+
713
+ The static, class-level methods (`setClient`, `setTableMapping`, `query`,
714
+ `mutate`, `batchGet`, `batchWrite`, `asModel`) live on `DDBModel` itself. The
715
+ unified-envelope `DDBModel.query` / `DDBModel.mutate` (§22) drive multi-route
716
+ reads and lifecycle-aware writes.
717
+
718
+ ---
719
+
720
+ ## 9. Selecting fields (projection)
721
+
722
+ A **selection** describes which fields to return. Scalars are marked `true`,
723
+ embedded objects are nested selections, and relations carry their own selection
724
+ plus pagination/filter.
725
+
726
+ ```ts
727
+ // SelectableOf<UserModel> form
728
+ {
729
+ userId: true,
730
+ name: true,
731
+ groups: { // relation: plain object form
732
+ select: { groupId: true, role: true },
733
+ limit: 20,
734
+ filter: { role: 'admin' }, // server-side (§13)
735
+ },
736
+ }
737
+ ```
738
+
739
+ ### Strictness
740
+
741
+ Selections are **exact**: an unknown field key is a **compile error**
742
+ (`StrictSelect` maps unknown keys to `never`). This applies at the top level and
743
+ recursively into nested relation and embedded selects. The result type contains
744
+ **only** the selected fields, computed by `QueryResult<T, S>`.
745
+
746
+ ### Relation selection forms
747
+
748
+ A relation field accepts either:
749
+
750
+ 1. a plain object `{ select, limit?, after?, order?, filter? }` (`RelationSelect`), or
751
+ 2. a **model-bound builder** `Target.relation({ … }).filter(…).limit(…)…`.
752
+
753
+ The builder form pins the relation's target type, so it structurally rejects
754
+ another model's builder (closes #33). Both forms accept the same declarative
755
+ server-side `filter` (§13); arbitrary post-load filtering is done on the result
756
+ (§14).
757
+
758
+ ### Implicit key projection
759
+
760
+ When a relation is selected, the parent's **key-binding source fields** must be
761
+ read from the raw item to build the relation's query key. If such a field is not
762
+ already projected, it is added to the projection automatically
763
+ (`getImplicitKeyFields`) so traversal works without you having to select it.
764
+ These implicit attributes are not added to the hydrated result unless you
765
+ selected them.
766
+
767
+ ---
768
+
769
+ ## 10. `query` — fetch a single item
770
+
771
+ ```ts
772
+ query<Sel>(
773
+ key: UniqueQueryKeyOf<C>,
774
+ select: Sel,
775
+ options?: { consistentRead?: boolean; maxDepth?: number },
776
+ ): Promise<QueryResult<T, Sel> | null>;
777
+ ```
778
+
779
+ Fetches a single item by a **full unique key** (PK or a unique GSI). Returns the
780
+ projected object, or `null` if not found.
781
+
782
+ ```ts
783
+ const alice = await User.query(
784
+ { email: 'alice@example.com' }, // unique GSI
785
+ {
786
+ userId: true, name: true, email: true,
787
+ groups: { select: { groupId: true, role: true }, limit: 20 },
788
+ },
789
+ { maxDepth: 2 },
790
+ );
791
+ ```
792
+
793
+ Behavior:
794
+
795
+ - `key` must match the PK input set or a **unique** GSI input set. Resolving to a
796
+ **non-unique** GSI throws: `Cannot use query() with non-unique GSI '...'. Use
797
+ list() instead.`
798
+ - `options.maxDepth` defaults to **1**. Relation depth beyond `maxDepth` throws
799
+ (see §16).
800
+ - `options.consistentRead` is honored for PK queries only. (DynamoDB forbids
801
+ consistent reads on GSIs; the planner throws if `consistentRead` is requested
802
+ on a GSI — see §13/§25.)
803
+ - The top-level `select` may be a `Model.project(...)` builder; its server-side
804
+ `filter` is applied during the read.
805
+ - Internally, a full-key PK read with no server-side filter is a `GetItem`;
806
+ otherwise it is a `Query` (§25).
807
+
808
+ ---
809
+
810
+ ## 11. `list` — query a partition
811
+
812
+ ```ts
813
+ list<Sel>(
814
+ key: PartialQueryKeyOf<C>,
815
+ select: Sel, // plain projection or a project(...) builder
816
+ options?: {
817
+ limit?: number;
818
+ after?: string; // pagination cursor
819
+ order?: 'ASC' | 'DESC';
820
+ filter?: FilterInput<T>; // server-side FilterExpression (§13)
821
+ },
822
+ ): Promise<{ items: QueryResult<T, Sel>[]; cursor: string | null }>;
823
+ ```
824
+
825
+ The signature is symmetric with `query(key, select, options?)`: `select` is a
826
+ **separate 2nd argument**, not a key inside the `options` object.
827
+
828
+ Issues a DynamoDB **Query** against a partition (PK or GSI) and returns the
829
+ projected items plus a pagination `cursor`.
830
+
831
+ ```ts
832
+ const admins = await GroupMembership.list(
833
+ { groupId: 'eng' },
834
+ { userId: true, role: true },
835
+ { filter: { role: 'admin' } },
836
+ );
837
+ admins.items; // [{ userId, role }, …]
838
+ admins.cursor; // string | null
839
+ ```
840
+
841
+ Behavior:
842
+
843
+ - `key` may be a **partial** key (partition-key subset). A partial GSI key adds a
844
+ `begins_with` range condition on the GSI sort key.
845
+ - `filter` is the declarative server-side `FilterExpression` (§13). It is
846
+ **not** RCU-reducing and is applied **after** `limit` (§12, §13).
847
+ - When `select` is a `project(...)` builder, an explicit `options` value
848
+ (`filter` / `after` / `limit` / `order`) **takes precedence** over the
849
+ builder's value of the same name.
850
+ - `query()` differs from `list()` in three ways: `list` accepts partial keys,
851
+ always uses Query (never GetItem), and returns an array + cursor instead of a
852
+ single nullable item.
853
+
854
+ ---
855
+
856
+ ## 12. Pagination and ordering
857
+
858
+ - **Cursor.** `cursor` (returned by `list`) is the base64url-encoded DynamoDB
859
+ `LastEvaluatedKey`. Pass it back as `after` to continue. `null` means the last
860
+ page. `encodeCursor` / `decodeCursor` are exported for advanced use.
861
+ - **Order.** `order: 'ASC' | 'DESC'` sets the Query `ScanIndexForward`
862
+ (`ASC` → `true`). Default is `ASC`.
863
+ - **`limit` vs `filter`.** DynamoDB applies `Limit` to the **read**, before the
864
+ `FilterExpression`. Consequently:
865
+ - a page may return **fewer than `limit`** items even when more matches exist
866
+ further in the partition;
867
+ - an entirely filtered-out page may still return a **non-null `cursor`**;
868
+ - the cursor reflects the **pre-filter** read position. Post-load filtering on
869
+ the result (`result.items.filter(...)`, §14) likewise does not change the
870
+ cursor.
871
+
872
+ ---
873
+
874
+ ## 13. `filter` — server-side FilterExpression
875
+
876
+ `filter` is a **declarative, server-side** DynamoDB `FilterExpression`, modeled
877
+ on AWS AppSync's `ModelFilterInput`. It is typed against the **full entity**
878
+ (`FilterInput<T>`), so it may reference attributes that are **not in the
879
+ projection**, and operators are constrained per field type.
880
+
881
+ ```ts
882
+ type FilterInput<T> =
883
+ | ({ [K in ScalarKeys<T>]?: T[K] | FieldCondition<T[K]> } & {
884
+ and?: readonly (FilterInput<T> | RawCondition<T>)[];
885
+ or?: readonly (FilterInput<T> | RawCondition<T>)[];
886
+ not?: FilterInput<T> | RawCondition<T>;
887
+ })
888
+ | RawCondition<T>; // a whole `cond`…`` fragment (§15)
889
+ ```
890
+
891
+ A field key takes either an **equality shorthand** (a bare value) or an
892
+ **operator object**. Multiple field keys, and multiple operators within one
893
+ field object, are joined with implicit `AND`.
894
+
895
+ ### Operators and their type constraints
896
+
897
+ | Operator | Expression | Valid on |
898
+ | --- | --- | --- |
899
+ | `eq` | `#f = :v` | all scalars |
900
+ | `ne` | `#f <> :v` | all scalars |
901
+ | `in` | `#f IN (:v0, …)` | all scalars (`readonly V[]`) |
902
+ | `attributeExists` | `attribute_exists(#f)` / `attribute_not_exists(#f)` (when `false`) | all scalars (`boolean`) |
903
+ | `attributeType` | `attribute_type(#f, :v)` | all scalars (`string` type code) |
904
+ | `gt` `ge` `lt` `le` | `#f > / >= / < / <= :v` | string, number, Date |
905
+ | `between` | `#f BETWEEN :lo AND :hi` | string, number, Date (`readonly [V, V]`) |
906
+ | `beginsWith` | `begins_with(#f, :v)` | **string only** |
907
+ | `contains` | `contains(#f, :v)` | **string only** |
908
+ | `notContains` | `NOT contains(#f, :v)` | **string only** |
909
+ | `size` | `size(#f) = :v` | **string only** (`number`) |
910
+
911
+ Type binding (`FieldCondition<V>`): string → common + comparable + string-only;
912
+ number → common + comparable; Date → common + comparable; boolean / other →
913
+ common only. So `beginsWith` on a numeric field, or `gt: 'x'` on a number field,
914
+ is a **compile error**.
915
+
916
+ ### Logical groups
917
+
918
+ ```ts
919
+ filter: { or: [{ role: 'admin' }, { role: 'viewer' }] }
920
+ filter: { and: [{ role: { attributeExists: true } }, { not: { role: 'viewer' } }] }
921
+ ```
922
+
923
+ ### Compilation and semantics
924
+
925
+ - Compiled by `compileFilterExpression` into a `FilterExpression` with `#`-aliased
926
+ names (one alias per distinct column, reused) and fresh `:`-aliased,
927
+ **parameterized** values (serialized per field type — e.g. `Date` → ISO). There
928
+ is **no literal interpolation**: the compiler is injection-free.
929
+ - The compiled filter is attached **independently** of the
930
+ `KeyConditionExpression` and `ProjectionExpression`. Its names/values are merged
931
+ by the executor.
932
+ - **RCU is not reduced.** DynamoDB reads matching keys first, then filters; a
933
+ filter does not lower read capacity, and `limit` applies before the filter
934
+ (§12).
935
+ - A field given an empty / no-op operator object compiles to nothing; an entirely
936
+ empty filter is skipped.
937
+ - On a **`belongsTo` / `hasOne`** relation (resolved via `BatchGetItem`, which has
938
+ no server-side filter), a declarative `filter` is evaluated **client-side**
939
+ against the resolved item (`evaluateFilter`). On **`hasMany`** relations and at
940
+ the top level, the filter is pushed **server-side**.
941
+
942
+ ---
943
+
944
+ ## 14. Post-load filtering
945
+
946
+ For logic the server-side `filter` (§13) cannot express, call
947
+ `result.items.filter(...)` on the already-typed projection. Because `query` /
948
+ `list` return a fully typed `QueryResult<T, S>`, this stays type-safe (selected
949
+ fields carry their declared scalar types; an unselected field is a compile error).
950
+ There is **no built-in post-load predicate** — the former `refine` builder was
951
+ removed (see CHANGELOG), keeping the select API fully declarative and serializable.
952
+
953
+ ---
954
+
955
+ ## 15. `cond` and `Model.col` — raw escape hatch
956
+
957
+ For conditions the declarative operators cannot express, use the `cond` tagged
958
+ template. Column names **must** be embedded via `Model.col.<field>` references
959
+ (bare strings are rejected); values are embedded directly and parameterized.
960
+
961
+ ```ts
962
+ import { cond } from 'graphddb';
963
+
964
+ filter: cond`${GroupMembership.col.role} = ${'admin'}`
965
+ filter: cond`${User.col.age} > ${18} and attribute_exists(${User.col.email})`
966
+ ```
967
+
968
+ - `Model.col` is a `ColumnMap<T>`: one `Column<V, M>` per scalar field, carrying
969
+ the declared value type and **branded to the owning model**. Passing another
970
+ model's column into this model's `cond` is a compile error. Relations and
971
+ embedded objects are excluded from `col`.
972
+ - A `RawCondition<M>` produced by `cond` is assignable wherever a
973
+ `FilterInput<M>` of the **same** model is expected — as a whole filter, inside
974
+ `and` / `or`, or as `not`.
975
+ - `cond` is **not read-only**: it is equally a valid `WriteCondition<M>` (issue
976
+ #114-B), so it may be used as a write `condition` on `putItem` / `updateItem` /
977
+ `deleteItem`, on a transaction item / `ConditionCheck`, and in the declarative
978
+ `mutation(...)` / public command `condition` (where its value slots may carry
979
+ `param.*` bindings — see [cqrs-contract](./cqrs-contract.md)).
980
+ - **Nesting constraint (serializable surfaces):** a `cond` used as a **whole**
981
+ condition is fully supported everywhere. A `cond` **nested inside** a
982
+ declarative `and` / `or` / `not` group works **in-process** (TS/memory), but
983
+ a serializable (public-contract / Python-bridge) command **rejects it at
984
+ build time** with an explicit error — the serialized `expr` tree carries no
985
+ raw fragment. Move the `cond` to the top level, or express the group
986
+ declaratively.
987
+ - At compile time the column refs become `#`-aliased names and values become
988
+ `:`-aliased parameters, sharing the same allocators as the declarative filter
989
+ compiler so aliases never collide. The escape hatch is therefore also
990
+ injection-free.
991
+ - `Column` references are plain attribute names (refactor-safe); they are not
992
+ bare strings, so renaming the field through the type system surfaces breaks.
993
+
994
+ ---
995
+
996
+ ## 16. Relation traversal
997
+
998
+ When a selection includes relation fields, the runtime resolves them after the
999
+ parent items are read and hydrated.
1000
+
1001
+ ### Depth control
1002
+
1003
+ - `maxDepth` (a `query` / `explain` option) bounds traversal depth, **default 1**
1004
+ (parent → direct relations). The top-level item is depth 1; its relations are
1005
+ validated against `maxDepth`.
1006
+ - Selecting relations deeper than `maxDepth` throws at runtime
1007
+ (`validateDepth`): `Relation traversal depth exceeded: '<field>' at depth N
1008
+ exceeds maxDepth M. Set maxDepth: N to allow this.`
1009
+ - Static analysis: the `relation-depth` linter rule **warns** at registration if
1010
+ an entity's relation graph can exceed the default depth of 1 (see §24).
1011
+
1012
+ ### Resolution mechanics
1013
+
1014
+ - **`hasMany`** resolves via `executeListInternal` (a Query) against the target's
1015
+ partition, using the key built from `keyBinding`. The relation's effective
1016
+ `limit` is `selectSpec.limit ?? rel.options.limit.default ?? 20`; `order` is
1017
+ `selectSpec.order ?? rel.options.order ?? 'ASC'`. A declarative `filter` is
1018
+ pushed **server-side**.
1019
+ - **`belongsTo` / `hasOne`** resolve via **`BatchGetItem`**: the runtime collects
1020
+ one query key per parent that has a complete binding, **deduplicates** the
1021
+ resulting DynamoDB keys, splits them into chunks of `BATCH_GET_MAX_KEYS` (100),
1022
+ and matches results back to each parent slot. This collapses the N+1 problem
1023
+ into a single batch (N memberships → one `BatchGetItem`, not N `GetItem`s).
1024
+ Their declarative `filter` runs **client-side** (§13).
1025
+ - Each relation also recurses into its own nested relations (subject to
1026
+ `maxDepth`).
1027
+
1028
+ ### Parallel execution
1029
+
1030
+ Independent traversal work is dispatched **concurrently with a bounded fan-out**
1031
+ (`mapWithConcurrency`), capped by `RELATION_TRAVERSAL_CONCURRENCY` (**16**):
1032
+
1033
+ - sibling relations of the same parent (e.g. `members` and `permissions`) resolve
1034
+ in parallel — each writes a distinct property, so completion order is
1035
+ irrelevant;
1036
+ - per-item nested resolution of `belongsTo`/`hasOne` batches runs in parallel;
1037
+ - `BatchGetItem` chunks (>100 keys) are issued in parallel (`Promise.all`),
1038
+ preserving input order on flatten.
1039
+
1040
+ DynamoDB is HTTP-based with an SDK-managed connection pool, so there is no
1041
+ per-connection serialization; the cap bounds in-flight requests to avoid throughput
1042
+ spikes. Set the constant to `Infinity` to disable bounding. Residual throttling is
1043
+ handled by UnprocessedKeys retry (§23).
1044
+
1045
+ ---
1046
+
1047
+ ## 17. `explain` — execution plan
1048
+
1049
+ ```ts
1050
+ explain<Sel>(
1051
+ key: PartialQueryKeyOf<C>,
1052
+ options?: {
1053
+ select?: Sel;
1054
+ limit?: number;
1055
+ after?: string;
1056
+ order?: 'ASC' | 'DESC';
1057
+ consistentRead?: boolean;
1058
+ filter?: FilterInput<T>;
1059
+ },
1060
+ ): ExecutionPlan; // synchronous, no I/O
1061
+ ```
1062
+
1063
+ Returns the planned DynamoDB operations **without** executing anything — useful
1064
+ for inspecting access patterns and relation fan-out in tests.
1065
+
1066
+ ```ts
1067
+ const plan = GroupMembership.explain(
1068
+ { groupId: 'eng' },
1069
+ { select: { userId: true, role: true }, limit: 10 },
1070
+ );
1071
+ ```
1072
+
1073
+ `ExecutionPlan`:
1074
+
1075
+ ```ts
1076
+ interface ExecutionPlan { operations: DynamoDBOperation[]; }
1077
+
1078
+ type DynamoDBOperation = GetItemOperation | QueryOperation | BatchGetItemOperation;
1079
+
1080
+ interface DynamoDBOperationBase {
1081
+ tableName: string;
1082
+ projectionExpression?: string;
1083
+ expressionAttributeNames?: Record<string, string>;
1084
+ consistentRead?: boolean;
1085
+ filterExpression?: string;
1086
+ filterExpressionAttributeNames?: Record<string, string>;
1087
+ filterExpressionAttributeValues?: Record<string, unknown>;
1088
+ }
1089
+
1090
+ interface GetItemOperation extends DynamoDBOperationBase {
1091
+ type: 'GetItem';
1092
+ keyCondition: Record<string, unknown>; // { PK, SK }
1093
+ }
1094
+
1095
+ interface QueryOperation extends DynamoDBOperationBase {
1096
+ type: 'Query';
1097
+ indexName?: string; // present for GSI queries
1098
+ keyCondition: Record<string, unknown>;
1099
+ rangeCondition?: { operator: 'begins_with'; key: string; value: unknown };
1100
+ scanIndexForward?: boolean; // ASC → true
1101
+ limit?: number;
1102
+ exclusiveStartKey?: Record<string, unknown>;
1103
+ }
1104
+
1105
+ interface BatchGetItemOperation extends DynamoDBOperationBase {
1106
+ type: 'BatchGetItem';
1107
+ keys: Record<string, unknown>[];
1108
+ }
1109
+ ```
1110
+
1111
+ - The first operation is the main read (`GetItem` or `Query`); subsequent
1112
+ operations are the relation plan: `hasMany` → a `Query` per relation,
1113
+ `belongsTo`/`hasOne` → `BatchGetItem` operation(s) using placeholder keys
1114
+ (sized by the estimated parent count, chunked at 100). Relation operations
1115
+ recurse for nested relations.
1116
+ - For a partial GSI key, the planner emits a `rangeCondition`
1117
+ (`begins_with` on the GSI sort key).
1118
+ - `consistentRead` only appears on PK operations.
1119
+
1120
+ ---
1121
+
1122
+ ## 18. Create / Put
1123
+
1124
+ ```ts
1125
+ putItem(item: EntityInput<T>, options?: PutOptions): Promise<void>;
1126
+ ```
1127
+
1128
+ ```ts
1129
+ await User.putItem({
1130
+ userId: 'alice', name: 'Alice', email: 'alice@example.com',
1131
+ status: 'active', createdAt: new Date(),
1132
+ });
1133
+ ```
1134
+
1135
+ Behavior:
1136
+
1137
+ - The input type `EntityInput<T>` includes scalar and embedded fields and
1138
+ **excludes relations** (relations are not persisted by `putItem`).
1139
+ - `putItem` / `updateItem` / `deleteItem` are the **raw base operations**. For
1140
+ lifecycle-aware single writes, go through `DDBModel.mutate(...)` (§22) rather
1141
+ than the raw primitives.
1142
+ - The runtime serializes each declared field, computes `{ pk, sk }` from the
1143
+ primary key, and writes `{ PK: '<prefix><pk>', SK: String(sk), ...fields }`.
1144
+ For every GSI, it also writes `<indexName>PK` / `<indexName>SK`. Embedded
1145
+ values present on the item are written under their property name.
1146
+ - Requires a primary key; throws `No primary key defined for '<Class>'.` if
1147
+ missing.
1148
+ - The low-level command is `PutCommand`. `putItem` overwrites an existing item
1149
+ unless a condition is supplied (§21).
1150
+
1151
+ `buildPutInput(modelClass, item, options?)` is exported to build the raw
1152
+ `PutInput` without executing (used by transactions/batch).
1153
+
1154
+ ---
1155
+
1156
+ ## 19. Update
1157
+
1158
+ `updateItem` has two overloads, distinguished by their first argument:
1159
+
1160
+ ```ts
1161
+ // 1. Explicit base-table primary key (canonical).
1162
+ updateItem(key: PrimaryKeyOf<C>, changes: Partial<T>, options?: UpdateOptions): Promise<void>;
1163
+
1164
+ // 2. An entity read with `{ updatable: true }`, carrying a hidden resolved key.
1165
+ updateItem(entity: Partial<T> & Updatable, changes: Partial<T>, options?: UpdateOptions): Promise<void>;
1166
+ ```
1167
+
1168
+ ```ts
1169
+ // Explicit-key form — no read required:
1170
+ await User.updateItem(
1171
+ { userId: 'alice' }, // PrimaryKeyOf<C> — identifies the item
1172
+ { status: 'suspended' }, // changes
1173
+ );
1174
+
1175
+ // Updatable-entity form — read, mutate, write back:
1176
+ const user = await User.query({ userId: 'alice' }, { name: true }, { updatable: true });
1177
+ if (user) await User.updateItem(user, { name: 'Alice B.' });
1178
+ ```
1179
+
1180
+ `changes` supplies the attributes to modify (the same `UpdateExpression` rules in
1181
+ both forms).
1182
+
1183
+ ### GSI key re-derivation (issue #115)
1184
+
1185
+ `putItem` derives every GSI key attribute (`<index>PK` / `<index>SK`) from the
1186
+ item's fields. `updateItem` mirrors that contract: when a change touches a field
1187
+ that composes a GSI key, the affected index key is **re-derived and SET in the
1188
+ same `UpdateExpression`** — the index follows the row instead of silently rotting
1189
+ (a stale key that makes GSI queries miss). The rules:
1190
+
1191
+ 1. **Affected indexes only.** An index is re-derived only when `changes` touches at
1192
+ least one of its composing fields; unrelated indexes are never modified, so a
1193
+ routine update is unchanged.
1194
+ 2. **Available values = `{ ...key, ...changes }`.** When every composing field of
1195
+ an affected index is available (from the update key's named fields plus the
1196
+ changes), the key re-derives inline — no extra read, fully atomic.
1197
+ 3. **Otherwise throw.** When a composing field is missing (e.g. `GSI1PK =
1198
+ f(tenantId, status)` but only `status` is changed and `tenantId` is absent),
1199
+ `updateItem` refuses with an explicit error naming the index and the missing
1200
+ field(s) — the same partial-input discipline the base-table key uses. Opt into
1201
+ `{ rederive: 'read-modify-write' }` to read the current item, merge, re-derive
1202
+ from the full image, and write back under an optimistic condition.
1203
+
1204
+ Transaction updates (`defineTransaction` … `tx.update`) apply the same rule: the
1205
+ affected index keys are re-derived into the update at build time, or the build
1206
+ throws when a composing field is unavailable.
1207
+
1208
+ ### Key resolution (3-stage)
1209
+
1210
+ DynamoDB `UpdateItem` targets an item by its **base-table** `{ PK, SK }` only, so
1211
+ GSI-based updates are impossible — the explicit-key overload is therefore
1212
+ `PrimaryKeyOf<C>`, not a GSI/unique key. `updateItem` resolves the target key in
1213
+ a deliberate priority order (`resolveUpdateKey`), where the most reliable source
1214
+ wins and an unresolvable key **throws** rather than silently composing a wrong one
1215
+ (e.g. `PK: "USER#undefined"`):
1216
+
1217
+ 1. **Hidden resolved key.** A read taken with `{ updatable: true }` carries its
1218
+ already-resolved `{ PK, SK }` as a **non-enumerable Symbol** property
1219
+ (`GRAPHDDB_KEY`). When present it is used as-is — so the write works even for a
1220
+ **partial `select`** (key fields not projected) or a **GSI-based** read (whose
1221
+ result carries no base-table key fields). The result type is branded
1222
+ `& Updatable` so it can be passed straight back to `updateItem()`.
1223
+ 2. **Native key fields.** Only when **every** PK/SK input field is present and
1224
+ non-`undefined`; the key is then derived from the structured segments. This is
1225
+ the explicit-key path (`updateItem({ userId }, changes)`) and the full-entity path.
1226
+ 3. **Throw.** Neither holds: report the missing field(s) — `Cannot resolve update
1227
+ key for '<Class>': missing key field(s) [...]. Provide an explicit key
1228
+ (updateItem({ ... }, changes)), select all key fields, or query with { updatable:
1229
+ true }.`
1230
+
1231
+ > Because the hidden key is keyed by a Symbol and is `enumerable: false`, it is
1232
+ > invisible to `JSON.stringify` / `Object.keys` / `for...in` / object spread. A
1233
+ > spread, clone, or JSON round-trip drops it (and the compile-time `& Updatable`
1234
+ > brand) — by design — so `updateItem` falls through to stage 2/3 rather than writing
1235
+ > to the wrong item. **Pass an updatable entity as-is; do not copy it.** This is a
1236
+ > host-side runtime behaviour only — it is not part of the serialized query
1237
+ > (`operations.json`) and does not affect the Python bridge.
1238
+ >
1239
+ > `TransactionContext.update` shares `buildUpdateInput`, so it inherits the same
1240
+ > 3-stage resolution.
1241
+
1242
+ Behavior:
1243
+
1244
+ - The runtime builds an `UpdateExpression` from `changes`:
1245
+ - a field set to a value → `SET #field = :val`;
1246
+ - a field set to `undefined` → `REMOVE #field`;
1247
+ - for an **embedded** field whose value is a plain object, top-level keys are
1248
+ flattened into nested dotted paths (`SET #emb.#k = :val` / `REMOVE #emb.#k`).
1249
+ Nested update therefore patches individual sub-keys rather than replacing the
1250
+ whole map.
1251
+ - **Readonly fields** (`@string({ readonly: true })`, etc.) included in `changes`
1252
+ cause a throw: `Cannot update readonly field '<f>' on '<Class>'. Readonly
1253
+ fields can only be set during put.`
1254
+ - Empty changes (no sets, no removes) throw `No changes provided for update.`
1255
+ - Requires a primary key (throws otherwise). The low-level command is
1256
+ `UpdateCommand`.
1257
+
1258
+ `buildUpdateInput(...)` is exported for transactions/batch.
1259
+
1260
+ ---
1261
+
1262
+ ## 20. Delete
1263
+
1264
+ ```ts
1265
+ deleteItem(key: PrimaryKeyOf<C>, options?: DeleteOptions): Promise<void>;
1266
+ ```
1267
+
1268
+ ```ts
1269
+ await GroupMembership.deleteItem({ groupId: 'eng', userId: 'bob' });
1270
+ ```
1271
+
1272
+ - The key is the **primary key only** (`PrimaryKeyOf<C>`); GSI keys are not
1273
+ accepted for `delete`.
1274
+ - Computes `{ PK, SK }` and issues `DeleteCommand`. Requires a primary key.
1275
+
1276
+ `buildDeleteInput(...)` is exported for transactions/batch.
1277
+
1278
+ ---
1279
+
1280
+ ## 21. Conditional writes
1281
+
1282
+ `putItem`, `updateItem`, and `deleteItem` accept an optional `condition` typed
1283
+ as `WriteCondition<T>` (issue #114) — the **same declarative operator subset as
1284
+ the read-side `FilterInput<T>` (§13)**, with the identical per-field-type
1285
+ constraints, plus the legacy existence primitives:
1286
+
1287
+ ```ts
1288
+ interface PutOptions<T> { condition?: WriteCondition<T>; }
1289
+ interface UpdateOptions<T> { condition?: WriteCondition<T>; }
1290
+ interface DeleteOptions<T> { condition?: WriteCondition<T>; }
1291
+ ```
1292
+
1293
+ Supported condition shapes (`WriteCondition<T>`):
1294
+
1295
+ - **Declarative field operators** — every operator from the filter table (§13):
1296
+ `eq` (equality shorthand), `ne`, `gt` / `ge` / `lt` / `le`, `between`, `in`,
1297
+ `beginsWith`, `contains`, `notContains`, `size`, `attributeType` — bound to the
1298
+ field's declared type (e.g. `beginsWith` on a numeric field is a compile
1299
+ error).
1300
+ - **Logical groups** — `and` / `or` (arrays) and `not` (single), composing the
1301
+ above.
1302
+ - **Legacy existence primitives** — `{ notExists: true }` →
1303
+ `attribute_not_exists(PK)` (a create-only whole-row guard); `attributeExists` /
1304
+ `attributeNotExists` for a named attribute.
1305
+ - **Raw `cond` escape hatch (§15)** — a whole `cond\`…\`` fragment is also a valid
1306
+ `WriteCondition<T>`, for conditions the declarative subset cannot express. (See
1307
+ the nesting constraint below.)
1308
+
1309
+ ```ts
1310
+ // Create only if absent
1311
+ await User.putItem(item, { condition: { notExists: true } });
1312
+
1313
+ // Optimistic lock (equality shorthand)
1314
+ await User.updateItem({ userId: 'alice' }, { status: 'x', version: 4 },
1315
+ { condition: { version: 3 } });
1316
+
1317
+ // Comparison / range / logical groups now work on writes
1318
+ await User.updateItem({ userId: 'alice' }, { status: 'x' },
1319
+ { condition: { and: [{ version: { ge: 3 } }, { status: { ne: 'banned' } }] } });
1320
+
1321
+ // Raw escape hatch on a write
1322
+ await User.updateItem({ userId: 'alice' }, { status: 'x' },
1323
+ { condition: cond`${User.col.version} >= ${3}` });
1324
+ ```
1325
+
1326
+ Condition names/values use a dedicated allocator (a `cond_` prefix) so they never
1327
+ collide with the update expression's names/values. A failed condition surfaces as
1328
+ the AWS SDK's `ConditionalCheckFailedException`.
1329
+
1330
+ ---
1331
+
1332
+ ## 22. The unified envelope — `DDBModel.query` / `DDBModel.mutate`
1333
+
1334
+ Multi-route reads and lifecycle-aware writes are issued through a single
1335
+ in-process **unified envelope**: a **descriptor map** keyed by caller-chosen
1336
+ aliases, where each value is a route descriptor naming a model and an operation.
1337
+ The result is an object with the same aliases.
1338
+
1339
+ ### Read envelope — `DDBModel.query(map)`
1340
+
1341
+ `DDBModel.query(map)` runs each alias route **independently and in parallel**
1342
+ (there is no cross-route consistency — these are parallel independent reads):
1343
+
1344
+ ```ts
1345
+ const { user, members } = await DDBModel.query({
1346
+ user: { query: User, key: { userId: 'u1' }, select: { name: true } },
1347
+ members: { list: GroupMembership, key: { groupId: 'eng' }, select: { role: true }, options: { limit: 20 } },
1348
+ });
1349
+ // user: Item | null ; members: { items, cursor }
1350
+ ```
1351
+
1352
+ A **read route descriptor** is `{ query | list: Model, key, select, options? }`
1353
+ (exactly one of `query` / `list`):
1354
+
1355
+ - `query` route → resolves to a single `Item | null` (unique key, §10).
1356
+ - `list` route → resolves to `{ items, cursor }` (partition query, §11).
1357
+ - `options` for `query`: `{ consistentRead?, maxDepth? }`; for `list`:
1358
+ `{ limit?, after?, order?, filter? }`.
1359
+
1360
+ ### Write envelope — `DDBModel.mutate(map, { mode })`
1361
+
1362
+ `DDBModel.mutate(map, { mode })` performs lifecycle-aware writes. This is the
1363
+ path for **single** writes too — the raw primitives `putItem` / `updateItem` /
1364
+ `deleteItem` (§18–20) are base ops, not lifecycle-aware.
1365
+
1366
+ ```ts
1367
+ const res = await DDBModel.mutate({
1368
+ a: { create: GroupMembership, key: { groupId: 'eng', userId: 'u1' }, input: { role: 'admin' } },
1369
+ b: { update: User, key: { userId: 'u1' }, input: { name: 'Ann' }, condition: { status: 'active' }, result: { select: { name: true } } },
1370
+ }, { mode: 'transaction' });
1371
+ ```
1372
+
1373
+ A **write route descriptor** is `{ create | update | remove: Model, key, input?, condition?, result? }`:
1374
+
1375
+ - `key`: a **single** object **or an array** of objects (bulk). In
1376
+ `transaction` mode bulk compiles to `TransactWriteItems`; in `parallel` mode it
1377
+ compiles to `BatchWriteItem` (chunked, with `UnprocessedItems` retry).
1378
+ - `condition`: a `WriteCondition` write gate — the full declarative operator
1379
+ subset of `FilterInput` (`eq`/`ne`/comparisons/`between`/`in`/string ops/`size`/
1380
+ `attributeType` + `and`/`or`/`not`), the legacy existence primitives, or a whole
1381
+ `cond` fragment (§15, §21).
1382
+ - `result: { select, options? }`: read-back of the written item(s); **omit** →
1383
+ the route returns `void`.
1384
+
1385
+ ### Modes
1386
+
1387
+ - **`mode: 'transaction'`** (DEFAULT) — one atomic `TransactWriteItems`:
1388
+ all-or-nothing with rollback, **throws** on failure. More than 25 items errors
1389
+ (it is **never** split, to preserve atomicity).
1390
+ - **`mode: 'parallel'`** — non-atomic. Each op (alias) reports **partial
1391
+ success** in the result object as `{ ok }` | `{ error }`. Independent ops run in
1392
+ parallel; ops that reference another via `$.alias.field` are ordered after
1393
+ their dependency.
1394
+
1395
+ ### GraphQL contrast
1396
+
1397
+ `transaction` = atomic, all-or-nothing (mirrors a single all-or-nothing
1398
+ mutation); `parallel` = non-atomic per-field partial success (each field
1399
+ succeeds or fails on its own); `query` routes are parallel independent reads with
1400
+ no cross-route consistency.
1401
+
1402
+ ---
1403
+
1404
+ ## 23. Batch operations
1405
+
1406
+ ```ts
1407
+ DDBModel.batchGet(requests: BatchGetRequest[]): Promise<BatchGetResult>;
1408
+ DDBModel.batchWrite(requests: BatchWriteRequest[]): Promise<void>;
1409
+ ```
1410
+
1411
+ ```ts
1412
+ interface BatchGetRequest { model: ModelStatic<T>; keys: Record<string, unknown>[]; }
1413
+
1414
+ type BatchWriteRequest =
1415
+ | { type: 'put'; model; item: Record<string, unknown>; options?: PutOptions }
1416
+ | { type: 'delete'; model; key: Record<string, unknown>; options?: DeleteOptions };
1417
+ ```
1418
+
1419
+ ```ts
1420
+ const result = await DDBModel.batchGet([
1421
+ { model: User, keys: [{ userId: 'alice' }, { userId: 'bob' }] },
1422
+ ]);
1423
+ const users = result.get(User); // Partial<UserModel>[]
1424
+
1425
+ await DDBModel.batchWrite([
1426
+ { type: 'put', model: User, item: { userId: 'dave', /* … */ } },
1427
+ { type: 'delete', model: GroupMembership, key: { groupId: 'eng', userId: 'bob' } },
1428
+ ]);
1429
+ ```
1430
+
1431
+ Behavior and limits:
1432
+
1433
+ - **`batchGet`** groups keys by physical table, fetches all scalar fields,
1434
+ hydrates them (merging back any embedded maps), and returns a `BatchGetResult`
1435
+ whose `.get(model)` yields the items for that model (`Partial<T>[]`). Keys are
1436
+ matched back to items by `PK::SK`.
1437
+ - **`batchWrite`** groups by table, builds put/delete inputs, and writes.
1438
+ - **Chunking:** keys/items are split into chunks of `BATCH_GET_MAX_KEYS = 100`
1439
+ (get) and `BATCH_WRITE_MAX_ITEMS = 25` (write) and issued per chunk.
1440
+ - **Retry:** unprocessed keys/items are retried with exponential backoff
1441
+ (`50·2^(n-1)` ms, capped at 1000 ms) up to `BATCH_MAX_RETRY_ATTEMPTS = 10`
1442
+ retries, after which it throws (rather than looping forever) — typically a sign
1443
+ of sustained throttling.
1444
+ - Item-level `condition` options on `batchWrite` puts/deletes are accepted in the
1445
+ request type but DynamoDB BatchWrite does not support per-item conditions; use
1446
+ transactions for conditional atomic writes.
1447
+
1448
+ ---
1449
+
1450
+ ## 24. The Linter and design rules
1451
+
1452
+ GraphDDB runs a **linter over entity metadata at registration time** (when an
1453
+ entity's metadata is first finalized). Error-severity findings **throw**;
1454
+ warning-severity findings are logged with `console.warn` (prefix
1455
+ `[graphddb] <ruleId>: …`).
1456
+
1457
+ ### Default rule set (`createDefaultLinter`)
1458
+
1459
+ | Rule | Severity | Fires when |
1460
+ | --- | --- | --- |
1461
+ | `no-scan` | error | the entity has **no** primary key and **no** GSI (all reads would be a full scan). |
1462
+ | `require-limit` | warning | a `hasMany` relation is missing `limit.default` or `limit.max` (risk of unbounded RCU). |
1463
+ | `gsi-ambiguity` | error | two access patterns (PK + GSIs) have a subset relationship on their input field sets, so one query input could match both. |
1464
+ | `missing-gsi` | error | a relation's `keyBinding` target fields do not resolve to any access pattern on the target entity, or the target is unregistered. |
1465
+ | `relation-depth` | warning | the entity's relation graph can exceed the default `maxDepth` of 1 (deep traversal needs explicit `maxDepth`). |
1466
+ | `same-partition-preset` | error | a `pattern: 'samePartition'` relation does not lower to a valid same-partition `begins_with` access pattern (Epic #118 #123). |
1467
+ | `missing-context` | error | a maintained relation's `projection` reads a source attribute not present on the source payload (Phase 1 = payload-同梱 only; Epic #118 #126). |
1468
+ | `400kb` | warning | a maintained `embeddedSnapshot` collection's worst-case materialized size risks the 400 KB item cap (Epic #118 #126). |
1469
+ | `hot-partition` | warning | a maintained `embeddedSnapshot` concentrates every source write onto one owner ("root") row — a hot partition (Epic #118 #126). |
1470
+ | `fan-out` | warning | one source lifecycle event would fan out a maintenance write onto many destination rows in a single mutation (Epic #118 #126). |
1471
+ | `multi-maintainer-same-row` | error | two+ maintained relations write the **same** owner row under the **same** trigger ("1 mutation × 1 target row = 1 effect"; Epic #118 #126). |
1472
+
1473
+ `query-boundary` (error: a non-unique GSI is unsafe for `query()`) exists and is
1474
+ exported but is **not** in the default set — non-unique GSIs are valid for
1475
+ `list()`, and the `query()`-on-non-unique-GSI case is enforced at runtime by the
1476
+ resolver instead.
1477
+
1478
+ ### Customizing
1479
+
1480
+ ```ts
1481
+ import { MetadataRegistry, Linter, noScanRule, requireLimitRule } from 'graphddb';
1482
+
1483
+ const linter = new Linter();
1484
+ linter.addRule(noScanRule);
1485
+ linter.addRule(requireLimitRule);
1486
+ MetadataRegistry.linter = linter; // set BEFORE defining/registering models
1487
+ // MetadataRegistry.resetLinter(); // restore the default set
1488
+ ```
1489
+
1490
+ > The `user-permissions` example installs a custom linter without `gsi-ambiguity`
1491
+ > on purpose: `GroupMembership` uses `[userId]` as its GSI input, a subset of its
1492
+ > PK input `[groupId, userId]`. The runtime resolves this unambiguously (exact
1493
+ > GSI match outranks partial PK match), but the static rule would flag the subset.
1494
+
1495
+ A standalone `validateGsiAmbiguity(primaryKey, gsiDefinitions)` helper is also
1496
+ exported (throws on a subset overlap).
1497
+
1498
+ ---
1499
+
1500
+ ## 25. Runtime responsibilities (pipeline)
1501
+
1502
+ What the runtime owns, end to end:
1503
+
1504
+ 1. **Key resolution** (`resolveKey`): pick the access pattern from the supplied
1505
+ key fields, by the priority order in §5 (exact PK → exact GSI → partial PK →
1506
+ partial GSI), or throw.
1507
+ 2. **Planning** (`plan`):
1508
+ - builds the `KeyCondition` (`{ PK, SK }` for PK; `{ <gsi>PK, <gsi>SK }` for
1509
+ GSI; partial keys produce just the partition key, GSI partials produce a
1510
+ `begins_with` range condition);
1511
+ - **GetItem vs Query**: emits a `GetItem` only when the access pattern is a
1512
+ **full PK** (non-partial, with a present sort key) **and** no server-side
1513
+ filter is requested — because `GetItem` does not support `FilterExpression`,
1514
+ a requested filter forces a `Query`. Everything else is a `Query`;
1515
+ - builds the `ProjectionExpression` (scalars → aliased names; embedded →
1516
+ nested `#a.#b`; relations excluded; plus implicit key fields);
1517
+ - compiles the declarative `filter` into a `FilterExpression`;
1518
+ - **`consistentRead`**: rejected for GSI access (`consistentRead is not
1519
+ supported for GSI queries. Use a primary key query instead.`); attached only
1520
+ to PK operations.
1521
+ 3. **Execution** (`execute`): dispatches `GetItem` / `Query` / `BatchGetItem` to
1522
+ the document client, merging projection + filter names/values, and returns
1523
+ `{ items, lastEvaluatedKey? }`. Query key conditions and `begins_with` ranges
1524
+ are aliased (`#k0 = :k0`, `begins_with(#kN, :kN)`).
1525
+ 4. **Hydration** (`hydrate`): per the selection, converts ISO strings back to
1526
+ `Date` (per `format`) and rebuilds embedded maps. It copies only the fields
1527
+ named in the selection, so the hydrated result contains only selected fields;
1528
+ internal `PK`/`SK`/GSI key attributes are not included (they are simply never
1529
+ copied, not actively stripped).
1530
+ 5. **Relation traversal** (§16): resolves nested relations (bounded-parallel),
1531
+ `hasMany` via Query (server-side filter), `belongsTo`/`hasOne` via batched
1532
+ `BatchGetItem` (client-side filter).
1533
+
1534
+ The cursor is derived from `LastEvaluatedKey` and reflects the pre-filter read
1535
+ position (§12). Post-load TypeScript filtering is not part of this pipeline (§14).
1536
+
1537
+ ---
1538
+
1539
+ ## 26. Error reference
1540
+
1541
+ | Condition | Message (abridged) | Where |
1542
+ | --- | --- | --- |
1543
+ | No client configured | `DynamoDB client is not configured. Call DDBModel.setClient(new DynamoDBClient({...})) first.` | first op |
1544
+ | Class not `@model`-decorated | `No metadata registered for '<name>'. Did you apply @model()?` | metadata access |
1545
+ | No matching access pattern | `No access pattern found for fields […]. Available: …` | key resolution |
1546
+ | `query()` on non-unique GSI | `Cannot use query() with non-unique GSI '<i>'. Use list() instead.` | `query` |
1547
+ | `consistentRead` on GSI | `consistentRead is not supported for GSI queries…` | planner |
1548
+ | Relation depth exceeded | `Relation traversal depth exceeded: '<f>' at depth N exceeds maxDepth M…` | traversal |
1549
+ | No primary key | `No primary key defined for '<Class>'…` | put/update/delete |
1550
+ | Readonly field updated | `Cannot update readonly field '<f>' on '<Class>'…` | update |
1551
+ | Empty update | `No changes provided for update.` | update |
1552
+ | Transaction over limit | `Transaction exceeds DynamoDB limit of 25 items` | transaction |
1553
+ | Invalid model reference | `Invalid model reference. Use ModelStatic from DDBModel.asModel().` | tx/batch |
1554
+ | Batch retry exhausted | `BatchGet exceeded the maximum of 10 retry attempts with N key(s) still unprocessed for table "…"…` / `BatchWrite exceeded the maximum of 10 retry attempts with N item(s) still unprocessed for table "…"…` | batch |
1555
+ | Lint error(s) at registration | `Entity lint errors:\n<ruleId>: <message>…` | registration |
1556
+ | Unknown filter operator | `Unknown filter operator '<op>' on field '<f>'` | filter compile/eval |
1557
+
1558
+ ---
1559
+
1560
+ ## 27. Public API surface
1561
+
1562
+ The package entry point (`graphddb`) exports:
1563
+
1564
+ **Base class & model API**
1565
+ `DDBModel`, type `ModelStatic`.
1566
+
1567
+ **Decorators & key builders**
1568
+ `model`, `field`, `string`, `number`, `boolean`, `datetime`, `binary`,
1569
+ `stringSet`, `numberSet`, `list`, `map`, `literal`, `key`, `k`, `isKeySegment`,
1570
+ `gsi`, `embedded`, `hasMany`, `belongsTo`, `hasOne`, `aggregate`, `count`, `max`;
1571
+ types `ModelOptions`, `KeyDefinitionMarker`, `KeySegment`, `KeySlot`,
1572
+ `SegmentSpec`, `KeyStructure`, `SegmentedKey`, `GsiOptions`,
1573
+ `GsiDefinitionMarker`. (`aggregate` / `count` / `max` are the Epic #118 §5.2
1574
+ scalar-aggregate declaration surface; their runtime is #141.)
1575
+
1576
+ **Maintained access path (Epic #118)**
1577
+ `buildMaintenanceGraph` and the `preview` / `identity` projection helpers (the
1578
+ function-form projection IR for relation `projection` options); maintenance IR
1579
+ types (`MaintainTrigger`, `MaintainEffect`, `SnapshotEffect`, `CollectionEffect`,
1580
+ `ProjectionTransform`, …) and relation-option types (`RelationPattern`,
1581
+ `RelationReadOptions`, `RelationWriteOptions`, `RelationProjection`,
1582
+ `RelationOptions`, `AggregateValue`, `AggregateOptions`, `AggregateMetadata`).
1583
+
1584
+ **Client**
1585
+ `ClientManager`, `TableMapping`.
1586
+
1587
+ **Type-safe query/select types**
1588
+ `SelectableOf`, `RelationSelect`, `RelationSpec`, `QueryResult`, `PrimaryKeyOf`,
1589
+ `QueryKeyOf`, `UniqueQueryKeyOf`, `PartialQueryKeyOf`, `QueryOptions`,
1590
+ `ListOptions`, `FilterInput`, `SelectBuilder`, `RelationBuilder`, `SelectOf`,
1591
+ `Updatable`.
1592
+
1593
+ **Filter / select runtime**
1594
+ `cond`, `isColumn`, `isSelectBuilder`; types `RawCondition`, `CondSlot`, `Column`,
1595
+ `ColumnMap`.
1596
+
1597
+ **Metadata**
1598
+ `MetadataRegistry`, `derivePrefix`, `validateGsiAmbiguity`; entity-metadata
1599
+ types.
1600
+
1601
+ **Linter**
1602
+ `Linter`, `createDefaultLinter`, `noScanRule`, `requireLimitRule`,
1603
+ `queryBoundaryRule`, `gsiAmbiguityRule`, `missingGsiRule`, `relationDepthRule`,
1604
+ plus the Epic #118 maintenance/preset rules (`samePartitionPresetRule`,
1605
+ `missingContextRule`, `embeddedSnapshotSizeRule`, `hotPartitionRule`,
1606
+ `fanOutRule`, `multiMaintainerSameRowRule`); types `LintRule`, `LintResult`. The
1607
+ default set (`createDefaultLinter`) includes every rule **except**
1608
+ `queryBoundaryRule`; the maintenance rules are no-ops for relations that declare
1609
+ no `write.maintainedOn`.
1610
+
1611
+ **Lower-level building blocks** (for advanced/transaction/batch use)
1612
+ `executePut`, `executeUpdate`, `executeDelete`, `executeQuery`, `executeList`,
1613
+ `executeExplain`, `executeTransaction`, `executeBatchGet`, `executeBatchWrite`,
1614
+ `buildPutInput`, `buildUpdateInput`, `buildDeleteInput`, `serializeFieldValue`,
1615
+ `TransactionContext`, `BatchGetResult`, `attachModelClass`, `MAX_TRANSACT_ITEMS`
1616
+ (25), `BATCH_GET_MAX_KEYS` (100), `BATCH_WRITE_MAX_ITEMS` (25); expression
1617
+ builders `buildUpdateExpression`, `buildConditionExpression`,
1618
+ `compileFilterExpression`, `evaluateFilter`; planner `resolveKey`,
1619
+ `buildProjection`, `plan`; executor `execute`; `hydrate`; pagination
1620
+ `encodeCursor` / `decodeCursor`; relation `detectRelationFields`,
1621
+ `getImplicitKeyFields`, `validateDepth`, `resolveRelations`.
1622
+
1623
+ ---
1624
+
1625
+ *This specification reflects the implementation as of the current source tree.
1626
+ For runnable end-to-end usage, see `examples/user-permissions/`.*