graphddb 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -476
- package/dist/{chunk-BROCT574.js → chunk-7NPG5R7O.js} +1 -1
- package/dist/{chunk-CPTV3H2U.js → chunk-FMJIWFIS.js} +156 -30
- package/dist/{chunk-G5RWWBAL.js → chunk-J5A665UW.js} +23 -3
- package/dist/cli.js +781 -6
- package/dist/index.d.ts +65 -3
- package/dist/index.js +20 -4
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-BWOrWcbd.d.ts → types-B-F7jw9f.d.ts} +42 -0
- package/docs/cdc-emulator.md +203 -0
- package/docs/class-hydration.md +409 -0
- package/docs/cloudformation.md +294 -0
- package/docs/cqrs-contract.md +526 -0
- package/docs/design-patterns.md +521 -0
- package/docs/middleware.md +189 -0
- package/docs/mutation-command-derivation.md +356 -0
- package/docs/python-bridge.md +611 -0
- package/docs/spec.md +1626 -0
- package/docs/testing.md +265 -0
- package/package.json +6 -3
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/`.*
|