graphddb 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -476
- package/dist/{chunk-BROCT574.js → chunk-7NPG5R7O.js} +1 -1
- package/dist/{chunk-CPTV3H2U.js → chunk-FMJIWFIS.js} +156 -30
- package/dist/{chunk-G5RWWBAL.js → chunk-J5A665UW.js} +23 -3
- package/dist/cli.js +781 -6
- package/dist/index.d.ts +65 -3
- package/dist/index.js +20 -4
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -2
- package/dist/{types-BWOrWcbd.d.ts → types-B-F7jw9f.d.ts} +42 -0
- package/docs/cdc-emulator.md +203 -0
- package/docs/class-hydration.md +409 -0
- package/docs/cloudformation.md +294 -0
- package/docs/cqrs-contract.md +526 -0
- package/docs/design-patterns.md +521 -0
- package/docs/middleware.md +189 -0
- package/docs/mutation-command-derivation.md +356 -0
- package/docs/python-bridge.md +611 -0
- package/docs/spec.md +1626 -0
- package/docs/testing.md +265 -0
- package/package.json +6 -3
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
# GraphDDB CQRS Query / Command Contract Specification
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The contract layer defines storage-independent public interfaces over GraphDDB
|
|
6
|
+
models, organized as one layering:
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
Contract ← the public, storage-independent interface
|
|
10
|
+
↓
|
|
11
|
+
Runtime ← the execution engine (batch / retry / transaction / cursor / composition)
|
|
12
|
+
↓
|
|
13
|
+
Storage ← the implementation (DynamoDB today)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A contract is the public interface of a CQRS (logical) service. The **Query**
|
|
17
|
+
side is the read API (a read model resolved from storage); the **Command** side
|
|
18
|
+
is the write API (resolving to write operations / transactions). A contract is
|
|
19
|
+
the single source of truth (SSoT) for runtime generation, multi-language
|
|
20
|
+
bindings, OpenAPI generation, execution-plan generation, and static analysis.
|
|
21
|
+
The serialized SSoT is the `contracts` map in the operations document
|
|
22
|
+
(layered on top of the existing `queries` / `commands` / `transactions`).
|
|
23
|
+
|
|
24
|
+
### Canonical concepts
|
|
25
|
+
|
|
26
|
+
| Concept | Definition |
|
|
27
|
+
| ----------------- | ----------------------------------- |
|
|
28
|
+
| **Model** | Data structure (private to a context) |
|
|
29
|
+
| **QueryModel** | Public read contract — keyed (Key) |
|
|
30
|
+
| **QueryMethod** | A read use case (a projection) |
|
|
31
|
+
| **CommandModel** | Public write contract — keyed (Key) |
|
|
32
|
+
| **CommandMethod** | A write use case |
|
|
33
|
+
| **Runtime** | Execution engine |
|
|
34
|
+
| **Storage** | Implementation |
|
|
35
|
+
|
|
36
|
+
A QueryModel / CommandModel is identified by its **Key** (the access pattern)
|
|
37
|
+
and holds **one or more Methods**, each a distinct use case. The Key is the
|
|
38
|
+
access pattern; the Method is the use case (e.g. `get` and `summary` over the
|
|
39
|
+
same key, differing only in projection).
|
|
40
|
+
|
|
41
|
+
Scope is **Query** and **Command** contracts only. Workflow / Event
|
|
42
|
+
orchestration is out of scope (a Runtime concern).
|
|
43
|
+
|
|
44
|
+
## Architecture
|
|
45
|
+
|
|
46
|
+
- **Model** defines the data structure. It is the private implementation of a
|
|
47
|
+
bounded context and does **not** define public access patterns.
|
|
48
|
+
- **Query / Command Contracts** define the public API.
|
|
49
|
+
- **Runtime** performs execution — batching, retries, transactions, per-key
|
|
50
|
+
cursors, and cross-contract composition — not business logic and not storage
|
|
51
|
+
shape.
|
|
52
|
+
- **Storage** is the implementation behind the Runtime.
|
|
53
|
+
|
|
54
|
+
Language targets (TypeScript / Python / OpenAPI / …) are **renderers** of the
|
|
55
|
+
same contract. In 0.2.0 a method is declared as a **descriptor** in a descriptor
|
|
56
|
+
map (no closure body); the two formal method shapes are the generation source:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// read route descriptor — exactly one of query | list
|
|
60
|
+
type QueryMethod = { query | list: Model; key; select; options? };
|
|
61
|
+
// write route descriptor — exactly one of create | update | remove
|
|
62
|
+
type CommandMethod = { create | update | remove: Model; key; input?; condition?; result?; mode? };
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`GraphQL contrast`: `query` routes are **parallel independent reads** (no
|
|
66
|
+
cross-route consistency); a command's `mode: 'transaction'` is **atomic
|
|
67
|
+
all-or-nothing**, while `mode: 'parallel'` is **non-atomic per-field partial
|
|
68
|
+
success**.
|
|
69
|
+
|
|
70
|
+
## Query Contract
|
|
71
|
+
|
|
72
|
+
A Query Contract (`QueryModel`) defines a public read interface. It is keyed —
|
|
73
|
+
its Key *is* the access pattern — and behaves as a **virtual Model**: the
|
|
74
|
+
records it returns have an identity (its Key) other contracts can join against
|
|
75
|
+
and batch-resolve. A QueryModel holds one or more Methods, each a read use case
|
|
76
|
+
over that same Key, differing in projection (and params).
|
|
77
|
+
|
|
78
|
+
### Query Key
|
|
79
|
+
|
|
80
|
+
In 0.2.0 a method's Key is declared **inline in its descriptor** via the `key`
|
|
81
|
+
map (each field a `param.*()`); there is no separate key-type generic or key-
|
|
82
|
+
field list argument. The access pattern — primary key or GSI — is resolved from
|
|
83
|
+
the `Model` plus the `key` fields named in the descriptor:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const UserByEmail = publicQueryModel({
|
|
87
|
+
get: { query: User, key: { email: param.string() }, select: { userId: true, email: true } },
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Query Definition
|
|
92
|
+
|
|
93
|
+
A method is a single declarative read **descriptor** — `{ query | list: Model,
|
|
94
|
+
key, select, options? }`, with exactly one of `query` / `list`. `select` and
|
|
95
|
+
`options` are separate keys (no `select` nested inside `options`).
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
export const ArticleById = publicQueryModel({
|
|
99
|
+
get: { query: Article, key: { articleId: param.string() },
|
|
100
|
+
select: { articleId: true, title: true, body: true },
|
|
101
|
+
options: { consistentRead: false } },
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A single descriptor declares the per-key shape; the array capability
|
|
106
|
+
(`get(keys[])`) is derived by the contract.
|
|
107
|
+
|
|
108
|
+
### Method names are use cases, not operations
|
|
109
|
+
|
|
110
|
+
A method is named for its use case (`get`, `summary`, `recent`, `disable`),
|
|
111
|
+
never for the storage op it runs. Whether it resolves internally to a `query`
|
|
112
|
+
(point lookup) or a `list` (partition read) is the Runtime's concern and is
|
|
113
|
+
invisible to callers and generated bindings. The internal op determines the
|
|
114
|
+
derived `resolution` / `inputArity` / `cardinality` facts, which are
|
|
115
|
+
bookkeeping, not part of the public method name.
|
|
116
|
+
|
|
117
|
+
### Derived facts: resolution and input arity
|
|
118
|
+
|
|
119
|
+
Per method, two facts are **derived** from the internal op (never
|
|
120
|
+
hand-written):
|
|
121
|
+
|
|
122
|
+
- `resolution`: `'point'` | `'range'`.
|
|
123
|
+
- `inputArity`: `'single'` | `'either'` (`'array'` is reserved, not produced by
|
|
124
|
+
the current resolvers).
|
|
125
|
+
|
|
126
|
+
The resolvers:
|
|
127
|
+
|
|
128
|
+
- a `list` (partition `Query`) → `resolution: 'range'`, `inputArity: 'single'`;
|
|
129
|
+
- a `query` resolving to the **full base-table primary key** (a unique row) →
|
|
130
|
+
`resolution: 'point'`, `inputArity: 'either'` (a key array coalesces to one
|
|
131
|
+
`BatchGetItem`);
|
|
132
|
+
- a `query` resolving to a **full, unique GSI** → `resolution: 'point'`,
|
|
133
|
+
`inputArity: 'single'`: it is a per-key GSI `Query` (a `BatchGetItem` cannot
|
|
134
|
+
read a GSI), so a key array would be an N+1 fan-out;
|
|
135
|
+
- a `query` resolving to **anything else** (a partial PK prefix, a non-unique
|
|
136
|
+
GSI, or a partial GSI prefix) is a multi-row partition `Query` and is treated
|
|
137
|
+
as `resolution: 'range'`, `inputArity: 'single'` (it is not a point — a unique
|
|
138
|
+
row is the only point).
|
|
139
|
+
|
|
140
|
+
### Derived arity (no author tagging)
|
|
141
|
+
|
|
142
|
+
A method's literal arity is **derived** from its descriptor (`query` vs `list`,
|
|
143
|
+
and whether the `key` resolves a unique row) and **build-verified**. There is no
|
|
144
|
+
author-side arity wrapper — the descriptor's `query` / `list` discriminator and
|
|
145
|
+
the resolved key carry the same information:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// base-table PK point — a key array coalesces to one BatchGetItem
|
|
149
|
+
get: { query: Article, key: { articleId: param.string() }, select: { articleId: true, title: true } },
|
|
150
|
+
|
|
151
|
+
// range list — a partition Query, single key only
|
|
152
|
+
list: { list: Member, key: { groupId: param.string() }, select: { userId: true } },
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A `query` resolving to a full base-table primary key is `inputArity: 'either'`
|
|
156
|
+
(a key array coalesces to one `BatchGetItem`); a `query` resolving a unique GSI
|
|
157
|
+
is `'single'` (a per-key GSI Query, not coalescible); a `list` is always
|
|
158
|
+
`'single'`.
|
|
159
|
+
|
|
160
|
+
### Cardinality matrix
|
|
161
|
+
|
|
162
|
+
A method's result shape is determined by its internal `resolution` and the
|
|
163
|
+
input cardinality (single key vs. array), both derived. The four combinations:
|
|
164
|
+
|
|
165
|
+
| Input | Resolution | Per key | Result shape |
|
|
166
|
+
| -------------- | ---------- | ---------- | ------------------------------------------- |
|
|
167
|
+
| `key` (single) | `point` | 0..1 item | `Item \| null` |
|
|
168
|
+
| `key` (single) | `range` | 0..M items | `Connection<Item>` (`{ items, cursor }`) |
|
|
169
|
+
| `keys[]` (N) | `point` | 0..1 each | `Map<keyId, Item \| null>` |
|
|
170
|
+
| `keys[]` (N) | `range` | — | rejected (range is `inputArity: 'single'`) |
|
|
171
|
+
|
|
172
|
+
A batched `point` result is **keyed, not flat**: a `Map` keyed by the canonical
|
|
173
|
+
key identity (a field-sorted JSON string, byte-identical across TS and Python),
|
|
174
|
+
where every input key is present with an explicit `null` for a miss (a
|
|
175
|
+
`BatchGetItem` neither preserves order nor returns absent keys).
|
|
176
|
+
|
|
177
|
+
### Pagination — per-key cursors
|
|
178
|
+
|
|
179
|
+
A `range` connection's `cursor` is a **per-key envelope**: the inner page cursor
|
|
180
|
+
(the base64url DynamoDB `LastEvaluatedKey`) wrapped together with the canonical
|
|
181
|
+
identity of the owning key, then base64url-encoded as a single opaque string.
|
|
182
|
+
On resume, a supplied `after` is decoded and **verified to belong to the key
|
|
183
|
+
being read** — a cursor minted for one key, fed back for another, is rejected.
|
|
184
|
+
The single-key range form is wrapped too, so cursor handling never branches on
|
|
185
|
+
input arity.
|
|
186
|
+
|
|
187
|
+
## Command Contract
|
|
188
|
+
|
|
189
|
+
A Command Contract (`CommandModel`) is the write counterpart of a QueryModel,
|
|
190
|
+
fully symmetric: keyed (its Key is the target identity) and holding one or more
|
|
191
|
+
write-use-case Methods. A command method is a single declarative write
|
|
192
|
+
**descriptor** — `{ create | update | remove: Model, key, input?, condition?,
|
|
193
|
+
result?, mode? }`, with exactly one of `create` / `update` / `remove`.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
export const UserCommands = publicCommandModel({
|
|
197
|
+
disable: { update: User, key: { userId: param.string() },
|
|
198
|
+
input: { status: 'disabled', disableReason: param.string() },
|
|
199
|
+
result: { select: { userId: true, status: true } } },
|
|
200
|
+
enable: { update: User, key: { userId: param.string() }, input: { status: 'active' } },
|
|
201
|
+
changeRole: { update: User, key: { userId: param.string() }, input: { role: param.string() } },
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Result and condition
|
|
206
|
+
|
|
207
|
+
- `result: { select, options? }` — the post-write read-back projection. Omit it
|
|
208
|
+
→ the method returns `void`.
|
|
209
|
+
- `condition` — a `WriteCondition` write gate: the write applies only if the
|
|
210
|
+
persisted item matches. It accepts the **full declarative operator subset** of a
|
|
211
|
+
read filter (`eq`/`ne`/`gt`/`ge`/`lt`/`le`/`between`/`in`/`beginsWith`/
|
|
212
|
+
`contains`/`notContains`/`size`/`attributeType` + `and`/`or`/`not`), the legacy
|
|
213
|
+
existence primitives (`notExists` / `attributeExists` / `attributeNotExists`),
|
|
214
|
+
**or** a raw `cond\`…\`` fragment whose value slots may be `param.*` bindings:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
export const UserCommands = publicCommandModel({
|
|
218
|
+
// declarative operators in a write condition
|
|
219
|
+
promote: { update: User, key: { userId: param.string() },
|
|
220
|
+
input: { role: 'admin' },
|
|
221
|
+
condition: { and: [{ status: { ne: 'banned' } },
|
|
222
|
+
{ version: { ge: param.number() } }] } },
|
|
223
|
+
// raw `cond` escape hatch, value slot bound to a param
|
|
224
|
+
bump: { update: User, key: { userId: param.string() },
|
|
225
|
+
input: { version: param.number() },
|
|
226
|
+
condition: cond`${User.col.version} = ${param.number()}` },
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Constraint:** a `cond` used as the **whole** condition is fully supported. A
|
|
231
|
+
`cond` **nested inside** a declarative `and` / `or` / `not` group is **rejected
|
|
232
|
+
at build time** on this serializable surface (the serialized `expr` tree carries
|
|
233
|
+
no raw fragment) — move it to the top level or express the group declaratively.
|
|
234
|
+
- `key` may be a single object **or** an array of objects (bulk).
|
|
235
|
+
|
|
236
|
+
### Mode (atomicity) — the `mode` descriptor key
|
|
237
|
+
|
|
238
|
+
A command method declares its atomicity per method via `mode`, replacing the old
|
|
239
|
+
`batch` option:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
// default — one atomic TransactWriteItems (condition-capable, ≤25)
|
|
243
|
+
disable: { update: User, key: { userId: param.string() },
|
|
244
|
+
input: { status: 'disabled' }, mode: 'transaction' },
|
|
245
|
+
|
|
246
|
+
// non-atomic — BatchWriteItem (chunked + UnprocessedItems retry), per-op partial success
|
|
247
|
+
addMany: { create: GroupMembership, key: { groupId: param.string(), userId: param.string() },
|
|
248
|
+
input: { role: param.string() }, mode: 'parallel' },
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
- `mode: 'transaction'` (**DEFAULT**) — one `TransactWriteItems`: writes applied
|
|
252
|
+
**atomically**, all-or-nothing rollback, throws on failure, and
|
|
253
|
+
condition-capable (`WriteCondition`: the full declarative operator subset, the
|
|
254
|
+
existence primitives, or a `cond` fragment). More than 25 items
|
|
255
|
+
is a hard error (never split).
|
|
256
|
+
- `mode: 'parallel'` — **non-atomic**: independent ops run in parallel; each
|
|
257
|
+
alias surfaces **per-op partial success** in the result object as `{ ok }` |
|
|
258
|
+
`{ error }`. Ordering of dependent ops is derived from `$.alias.field`
|
|
259
|
+
references.
|
|
260
|
+
|
|
261
|
+
`GraphQL contrast`: `mode: 'transaction'` is atomic all-or-nothing; `mode:
|
|
262
|
+
'parallel'` is non-atomic per-field partial success.
|
|
263
|
+
|
|
264
|
+
#### The 25-item limit is a hard error, never a silent split
|
|
265
|
+
|
|
266
|
+
`TransactWriteItems` is capped at **25 items** and is atomic. A
|
|
267
|
+
`mode: 'transaction'` method called with more than 25 keys is **rejected** with a
|
|
268
|
+
clear error rather than truncated or split — splitting would break the
|
|
269
|
+
all-or-nothing guarantee. A caller needing per-key independence at higher volume
|
|
270
|
+
must declare `mode: 'parallel'`, which is chunked automatically.
|
|
271
|
+
|
|
272
|
+
## External Query / Cross-Contract Composition
|
|
273
|
+
|
|
274
|
+
A query method may compose **another query contract's** method as a composed
|
|
275
|
+
read dependency, declared with the `query($ => ({...}))` builder; a cross-
|
|
276
|
+
fragment reference uses `$.alias.field`:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
export const AccountAccess = publicQueryModel({
|
|
280
|
+
get: query($ => ({
|
|
281
|
+
account: { query: Account, key: { accountId: param.string() },
|
|
282
|
+
select: { accountId: true } },
|
|
283
|
+
billing: { query: BillingPlan, key: { accountId: $.account.billingAccountId },
|
|
284
|
+
select: { plan: true } },
|
|
285
|
+
})),
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
A `$.alias.field` reference is recorded as a declarative composition edge: the
|
|
290
|
+
fragment whose key reads `$.alias.field` is resolved after, and batched against,
|
|
291
|
+
the producing fragment. The binding is declarative — a `$`-rooted dotted field
|
|
292
|
+
path only (a bare field, index, wildcard, or empty path is rejected, and the
|
|
293
|
+
segments `__proto__` / `constructor` / `prototype` are forbidden), so no
|
|
294
|
+
arbitrary JS reaches a key binding.
|
|
295
|
+
|
|
296
|
+
This is **build-time-resolved, in-process** contract chaining — relation
|
|
297
|
+
chaining where the link is a contract reference instead of a model relation.
|
|
298
|
+
There is no protocol, transport, or service discovery; the "boundary" crossed is
|
|
299
|
+
logical. The runtime collects every bound key produced by the parent step and
|
|
300
|
+
resolves the referenced contract **once, batched** (DataLoader-style), across
|
|
301
|
+
all parent records — N parents collapse to one `BatchGetItem`. The composed
|
|
302
|
+
child **must** be `point`; a `range` child would be an N+1 fan-out and is
|
|
303
|
+
rejected at build time.
|
|
304
|
+
|
|
305
|
+
The serializer recovers the referenced contract's name by identity and emits a
|
|
306
|
+
`compose` node. An independent set of sibling compositions forms one
|
|
307
|
+
concurrency-eligible stage executed after the parent read; the staging,
|
|
308
|
+
concurrency bound (16), and per-request chunk size (100, the `BatchGetItem`
|
|
309
|
+
limit) are serialized as a composition plan the runtimes **honor**, not
|
|
310
|
+
re-derive.
|
|
311
|
+
|
|
312
|
+
## N+1 Safety (static restriction)
|
|
313
|
+
|
|
314
|
+
Whether a resolution is N+1-safe depends on two facts: the **resolution kind**
|
|
315
|
+
(`point` coalesces; `range` is one request per partition key) and the **input
|
|
316
|
+
cardinality** (a `range` method is one query for one key but N for N keys).
|
|
317
|
+
Output cardinality alone is insufficient, so both `resolution` and `inputArity`
|
|
318
|
+
are decided once on the producer side and **serialized as decided facts** — every
|
|
319
|
+
runtime honors them rather than re-deriving the policy.
|
|
320
|
+
|
|
321
|
+
### The rule
|
|
322
|
+
|
|
323
|
+
A `range` resolution is allowed only with single-key input. There is
|
|
324
|
+
intentionally **no bounded-fan-out opt-in**; a caller needing "just these few"
|
|
325
|
+
loops in application code, keeping the N visible at the call site. `point`
|
|
326
|
+
resolutions are always allowed (they coalesce). The following are build-time
|
|
327
|
+
errors, raised on the contract build path:
|
|
328
|
+
|
|
329
|
+
- **array into a `range` method** — a `range` method whose `inputArity` is not
|
|
330
|
+
`'single'`;
|
|
331
|
+
- **array into a unique-GSI `point` method** — a `point` method that resolves via
|
|
332
|
+
a unique GSI (a per-key `Query`) but whose `inputArity` is not `'single'`;
|
|
333
|
+
- **list under a list** — a `range` child nested under a parent step whose
|
|
334
|
+
per-key output cardinality is `'many'`;
|
|
335
|
+
- **`range` composed child under a many-yielding parent** — the cross-contract
|
|
336
|
+
form of the above.
|
|
337
|
+
|
|
338
|
+
Enforcement is layered: the generated binding types encode `inputArity` (a
|
|
339
|
+
`'single'` method's array argument is `never`, so an array is a `tsc` error),
|
|
340
|
+
the build-time SSoT checker rejects the four forms above, and the runtime is a
|
|
341
|
+
final backstop that rejects an array fed into any `'single'` method.
|
|
342
|
+
|
|
343
|
+
## Context boundary enforcement
|
|
344
|
+
|
|
345
|
+
A Model is the private implementation of its bounded context; a Contract is its
|
|
346
|
+
published interface. Within its own context a contract resolves its own Models
|
|
347
|
+
directly. Across a context boundary it may depend on another context **only
|
|
348
|
+
through that context's published Contract** (an External Query / Command);
|
|
349
|
+
reaching directly into a foreign Model is a **build-time error**.
|
|
350
|
+
|
|
351
|
+
Ownership is declared in a `contexts` map (context name → `{ models, contracts }`).
|
|
352
|
+
A method op against a Model owned by a context different from the contract's own
|
|
353
|
+
owning context is the violation. The check is purely additive and fires only
|
|
354
|
+
when **both** the contract and the touched Model have declared, different
|
|
355
|
+
owners: a contract in no declared context is unconstrained, and an undeclared
|
|
356
|
+
Model has no owner to protect. The check runs on the build path (where the
|
|
357
|
+
recorded op `entity` refs and the `contexts` map are both available), not in the
|
|
358
|
+
single-model lint framework.
|
|
359
|
+
|
|
360
|
+
## Build-time hardening of method bodies
|
|
361
|
+
|
|
362
|
+
A method body is evaluated at definition time with throwing sentinels in place
|
|
363
|
+
of `keys` / `params`, recorded through a model recorder, and subjected to the
|
|
364
|
+
same hardening `defineTransaction` applies:
|
|
365
|
+
|
|
366
|
+
1. **Throwing sentinel Proxy** — any property / method access other than the
|
|
367
|
+
internal brand reads or primitive coercion throws at the access site.
|
|
368
|
+
2. **Per-access coercion ledger** — a faithful reference is stored as the Proxy
|
|
369
|
+
object and never coerced, so any coercion of a `keys` / `params` field proves
|
|
370
|
+
the body consumed it into a transform / branch / interpolation, and the build
|
|
371
|
+
is rejected.
|
|
372
|
+
3. **Differential evaluation** — the body runs twice with disjoint per-field
|
|
373
|
+
markers; every recorded leaf must be a faithful reference or a byte-identical
|
|
374
|
+
literal across both passes.
|
|
375
|
+
4. **Consumed-field check** — a `params` field the body accessed but that never
|
|
376
|
+
reached the recorded operation (a silent value branch) is rejected.
|
|
377
|
+
|
|
378
|
+
A body must resolve to **exactly one** model operation; zero or more than one is
|
|
379
|
+
rejected (multiple writes belong in a transaction). A query model rejects a body
|
|
380
|
+
that resolves to a write op, and a command model rejects a body that resolves to
|
|
381
|
+
a read op. So any non-declarative body — a transform, a value branch, a
|
|
382
|
+
coercion, or arbitrary JS over `keys` / `params` — is a build error, never
|
|
383
|
+
silently emitted as a wrong spec.
|
|
384
|
+
|
|
385
|
+
## SSoT Schema
|
|
386
|
+
|
|
387
|
+
The contract layer is serialized as a `contracts` map layered on top of the
|
|
388
|
+
existing operation specs. Each contract method references an existing operation
|
|
389
|
+
spec (in `queries` / `commands` / `transactions`) by name and adds the decided
|
|
390
|
+
facts. The existing operation-spec shapes are unchanged, and both `contracts`
|
|
391
|
+
and `contexts` are **absent** when the input declares none (a contract-free
|
|
392
|
+
input produces a byte-identical pre-contract document).
|
|
393
|
+
|
|
394
|
+
```jsonc
|
|
395
|
+
{
|
|
396
|
+
"version": "1.0",
|
|
397
|
+
"contracts": {
|
|
398
|
+
// point query: single OR array (array → BatchGetItem)
|
|
399
|
+
"ArticleById": {
|
|
400
|
+
"kind": "query",
|
|
401
|
+
"key": { "fields": ["articleId"] },
|
|
402
|
+
"methods": {
|
|
403
|
+
"get": {
|
|
404
|
+
"resolution": "point",
|
|
405
|
+
"inputArity": "either",
|
|
406
|
+
"cardinality": "one",
|
|
407
|
+
"operation": "ArticleById__get"
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
// range list: single key only (partition Query, not batchable)
|
|
413
|
+
"ArticleByCategory": {
|
|
414
|
+
"kind": "query",
|
|
415
|
+
"key": { "fields": ["categoryId"] },
|
|
416
|
+
"methods": {
|
|
417
|
+
"list": {
|
|
418
|
+
"resolution": "range",
|
|
419
|
+
"inputArity": "single",
|
|
420
|
+
"cardinality": "many",
|
|
421
|
+
"operation": "ArticleByCategory__list"
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
// composition / External Query: child must be `point`
|
|
427
|
+
"AccountAccess": {
|
|
428
|
+
"kind": "query",
|
|
429
|
+
"key": { "fields": ["accountId"] },
|
|
430
|
+
"methods": {
|
|
431
|
+
"get": {
|
|
432
|
+
"resolution": "point",
|
|
433
|
+
"inputArity": "either",
|
|
434
|
+
"cardinality": "one",
|
|
435
|
+
"operation": "AccountAccess__get",
|
|
436
|
+
"compose": [
|
|
437
|
+
{
|
|
438
|
+
"as": "billing",
|
|
439
|
+
"contract": "BillingPlanQueries",
|
|
440
|
+
"method": "get",
|
|
441
|
+
"context": "billing",
|
|
442
|
+
"bind": { "accountId": "$.billingAccountId" },
|
|
443
|
+
"resolution": "point",
|
|
444
|
+
"cardinality": "one"
|
|
445
|
+
}
|
|
446
|
+
],
|
|
447
|
+
"compositionPlan": { "stages": [[0]], "concurrency": 16, "batchChunkSize": 100 }
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
// command: single → one write; array → transaction
|
|
453
|
+
"UserCommands": {
|
|
454
|
+
"kind": "command",
|
|
455
|
+
"key": { "fields": ["userId"] },
|
|
456
|
+
"methods": {
|
|
457
|
+
"disable": {
|
|
458
|
+
"inputArity": "either",
|
|
459
|
+
"result": "entity",
|
|
460
|
+
"single": { "mode": "op", "operation": "UserCommands__disable" },
|
|
461
|
+
"batch": { "mode": "transaction", "transaction": "UserCommands__disable__batch" }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
"contexts": {
|
|
468
|
+
"billing": { "models": ["BillingPlan"], "contracts": ["BillingPlanQueries"] }
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
// existing specs, unchanged shape, referenced by `operation` / `transaction`
|
|
472
|
+
"queries": { "ArticleById__get": { /* … */ } },
|
|
473
|
+
"commands": { "UserCommands__disable": { /* … */ } },
|
|
474
|
+
"transactions": { "UserCommands__disable__batch": { /* forEach over keys */ } }
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### Serialized types
|
|
479
|
+
|
|
480
|
+
- `ContractSpec` — `{ kind, key: { fields }, methods }`.
|
|
481
|
+
- `QueryContractMethodSpec` — `{ resolution, inputArity, cardinality, operation,
|
|
482
|
+
compose?, compositionPlan? }`.
|
|
483
|
+
- `ComposeSpec` — `{ as, contract, method, context?, bind, resolution,
|
|
484
|
+
cardinality }`. A composed child's `resolution` must be `'point'`.
|
|
485
|
+
- `CommandContractMethodSpec` — `{ inputArity, result, single, batch? }`, where a
|
|
486
|
+
resolution target is `{ mode: 'op', operation }`, `{ mode: 'transaction',
|
|
487
|
+
transaction }`, or `{ mode: 'parallel', operation }`.
|
|
488
|
+
- `ContextSpec` — `{ models, contracts }` (sorted name lists).
|
|
489
|
+
|
|
490
|
+
## Public API surface
|
|
491
|
+
|
|
492
|
+
Definition DSL (`src/define/contract.ts`):
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
publicQueryModel({ alias: { query | list: Model, key, select, options? } })
|
|
496
|
+
publicCommandModel({ alias: { create | update | remove: Model, key, input, condition?, result?, mode? } })
|
|
497
|
+
query($ => ({ alias: descriptor, ... })) // composite read; cross-fragment refs $.alias.field
|
|
498
|
+
mutation($ => ({ alias: descriptor, ... })) // composite write; cross-fragment refs $.alias.field
|
|
499
|
+
param.string() / param.number() / ... // descriptor key/input parameters
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Runtime (`src/runtime/*`):
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
executeQueryMethod(contract, methodName, key | keys[], params?) // contract-runtime
|
|
506
|
+
executeCommandMethod(contract, methodName, key | keys[], params?) // command-runtime
|
|
507
|
+
encodePerKeyCursor / decodePerKeyCursor / serializeContractKey // per-key-cursor
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Build / static analysis (`src/spec/*`):
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
buildContracts(contracts) // → { contracts, queries, commands, transactions }
|
|
514
|
+
buildContexts(contexts) // → context-ownership specs
|
|
515
|
+
assertContractN1Safe(name, spec, isGsiPoint?)
|
|
516
|
+
assertContractBoundaries(...) // context-boundary lint
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
## Query vs Command
|
|
520
|
+
|
|
521
|
+
| Aspect | Query | Command |
|
|
522
|
+
| ------------ | ---------------------- | -------------------------------- |
|
|
523
|
+
| Direction | Read | Write |
|
|
524
|
+
| Side effects | None | State mutation |
|
|
525
|
+
| Composition | May compose other contracts | Composes internal write ops |
|
|
526
|
+
| Boundary | Public read contract | Public write contract |
|