graphddb 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -470
- package/docs/cdc-emulator.md +203 -0
- package/docs/class-hydration.md +409 -0
- package/docs/cqrs-contract.md +526 -0
- package/docs/design-patterns.md +521 -0
- package/docs/middleware.md +189 -0
- package/docs/mutation-command-derivation.md +356 -0
- package/docs/python-bridge.md +611 -0
- package/docs/spec.md +1626 -0
- package/docs/testing.md +265 -0
- package/package.json +3 -2
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# GraphDDB Middleware / Hooks
|
|
2
|
+
|
|
3
|
+
GraphDDB lets you register **host-side middleware** that runs at fixed points
|
|
4
|
+
around reads and writes. Use it for cross-cutting concerns — logging, metrics,
|
|
5
|
+
tracing, tenant / authorization scoping, soft-delete, result post-processing —
|
|
6
|
+
without embedding any of it in your query or model definitions.
|
|
7
|
+
|
|
8
|
+
## Principles
|
|
9
|
+
|
|
10
|
+
- **Host-only, never serialized.** Middleware lives on the host runtime
|
|
11
|
+
(`ClientManager`) and never touches the planner, the spec generator, or
|
|
12
|
+
`operations.json`. The declarative "what to read/write" stays serializable and
|
|
13
|
+
portable across the TypeScript and Python runtimes; "what to do around it" is
|
|
14
|
+
host-specific.
|
|
15
|
+
- **Unrestricted, your responsibility.** A hook may observe, mutate the
|
|
16
|
+
in-flight input / operation / result, cancel (by throwing), or recover from an
|
|
17
|
+
error (by returning a value). The library validates nothing and imposes no
|
|
18
|
+
semantics. If a hook breaks an invariant (e.g. removes a derived
|
|
19
|
+
`ConditionCheck` in a write persist hook), that is the host's responsibility.
|
|
20
|
+
Observation is the common use, not a restriction.
|
|
21
|
+
- **Separate from CDC / capture.** The internal change-data-capture mechanism is
|
|
22
|
+
a different surface. Middleware is not a substitute for CDC, nor vice versa;
|
|
23
|
+
they coexist.
|
|
24
|
+
|
|
25
|
+
## Registration
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { DDBModel } from 'graphddb';
|
|
29
|
+
|
|
30
|
+
DDBModel.use(middleware); // register (call as many times as you like)
|
|
31
|
+
DDBModel.clearMiddleware(); // remove all (e.g. in test teardown)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Each registered object may set any subset of read and/or write hooks. Order
|
|
35
|
+
matters: `before` hooks run in registration order (FIFO); `after` / `onError`
|
|
36
|
+
hooks run in reverse (LIFO), so the first-registered middleware is the outermost
|
|
37
|
+
wrapper — onion nesting without a `next()` callback. Every hook may be
|
|
38
|
+
synchronous or return a `Promise`; the runtime always awaits.
|
|
39
|
+
|
|
40
|
+
### Per-call context
|
|
41
|
+
|
|
42
|
+
Pass `{ context }` on any read or write to make request-scoped values (tenant,
|
|
43
|
+
actor, trace id) available to hooks as `ctx.context`. It is threaded unchanged to
|
|
44
|
+
every relation fan-out operation. A call with no `context` sees `{}`.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
await User.query(key, select, { context: { tenantId, actor } });
|
|
48
|
+
await User.put(item, { context: { actor } });
|
|
49
|
+
await DDBModel.batchGet(requests, { context: { tenantId } });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`context` is an ordinary host-language value and is **never** serialized.
|
|
53
|
+
|
|
54
|
+
## The interface
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
interface Middleware {
|
|
58
|
+
name?: string;
|
|
59
|
+
read?: {
|
|
60
|
+
before?(ctx: ReadRequestCtx): void | Promise<void>; // R1
|
|
61
|
+
afterFetch?<T>(ctx: ReadRequestCtx, result: T): T | Promise<T>; // R4
|
|
62
|
+
op?: {
|
|
63
|
+
before?(ctx: ReadOpCtx): void | Promise<void>; // R2
|
|
64
|
+
afterFetch?(ctx: ReadOpCtx, items: Item[]): Item[] | Promise<Item[]>; // R3
|
|
65
|
+
onError?(ctx: ReadOpCtx, err: unknown): void | Item[] | Promise<void | Item[]>; // R5
|
|
66
|
+
};
|
|
67
|
+
onError?(ctx: ReadRequestCtx, err: unknown): unknown; // R5
|
|
68
|
+
};
|
|
69
|
+
write?: {
|
|
70
|
+
before?(ctx: WriteCtx): void | Promise<void>; // W1
|
|
71
|
+
after?(ctx: WriteCtx, change: Change): void | Promise<void>; // W2
|
|
72
|
+
persist?: {
|
|
73
|
+
before?(ctx: PersistCtx): void | Promise<void>; // W3
|
|
74
|
+
after?(ctx: PersistCtx, results: unknown): void | Promise<void>; // W4
|
|
75
|
+
onError?(ctx: PersistCtx, err: unknown): unknown; // W5
|
|
76
|
+
};
|
|
77
|
+
onError?(ctx: WriteCtx, err: unknown): unknown; // W5
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
All context objects share `{ model, context, state }` (`state` is per-operation
|
|
83
|
+
scratch; for cross-operation accumulation under concurrent fan-out, keep the
|
|
84
|
+
accumulator on a host object referenced via `context`).
|
|
85
|
+
|
|
86
|
+
## Read hooks
|
|
87
|
+
|
|
88
|
+
A read flows: request entry → one or more physical operations (the root read
|
|
89
|
+
**plus every relation fan-out fetch**) → hydration + relation merge → result.
|
|
90
|
+
|
|
91
|
+
| ID | Hook | Fires | Receives / may do |
|
|
92
|
+
|----|------|-------|-------------------|
|
|
93
|
+
| **R1** | `read.before(ctx)` | once, at request entry (before key-resolution / planning) | mutate `ctx.params` (`key` / `select` / `options`, or `requests` for `batchGet`); `throw` to cancel |
|
|
94
|
+
| **R2** | `read.op.before(ctx)` | per physical op, before send — incl. every relation fan-out fetch | mutate `ctx.operation` (the `DynamoDBOperation`, e.g. inject a `FilterExpression`); `throw` to cancel; inspect `ctx.relationPath` |
|
|
95
|
+
| **R3** | `read.op.afterFetch(ctx, items)` | per physical op, with its raw items | return the (possibly filtered / replaced) `Item[]` |
|
|
96
|
+
| **R4** | `read.afterFetch(ctx, result)` | once, on the final hydrated, relation-merged result | return the (possibly transformed) result |
|
|
97
|
+
| **R5** | `read.op.onError` / `read.onError` | on an op-level / request-level error | observe, rethrow, or **recover** by returning a value |
|
|
98
|
+
|
|
99
|
+
Context types: `ReadRequestCtx` (R1/R4/R5) carries `kind: 'query' | 'list' |
|
|
100
|
+
'batchGet'` and the mutable `params`. `ReadOpCtx` (R2/R3/R5) carries `kind:
|
|
101
|
+
'GetItem' | 'Query' | 'BatchGetItem'`, the mutable `operation`, and
|
|
102
|
+
`relationPath` (`[]` for the root read, `['orders']` for a direct relation,
|
|
103
|
+
`['orders', 'product']` for a nested one).
|
|
104
|
+
|
|
105
|
+
### `DDBModel.batchGet`
|
|
106
|
+
|
|
107
|
+
The multi-key read primitive fires the same hooks: R1/R4/R5 with kind
|
|
108
|
+
`'batchGet'` (the mutable batch input is `ctx.params.requests`), and R2/R3/R5 per
|
|
109
|
+
underlying `BatchGetItem` op (one per ≤100-key chunk). Note DynamoDB
|
|
110
|
+
`BatchGetItem` has no server-side `FilterExpression`, so an R2 hook scopes a batch
|
|
111
|
+
read by narrowing the key set or setting a `ProjectionExpression`.
|
|
112
|
+
|
|
113
|
+
## Write hooks
|
|
114
|
+
|
|
115
|
+
A write flows: logical write → effect derivation (edges, counter `ADD`s, GSI key
|
|
116
|
+
re-derivation, referential `ConditionCheck`s) → the composed physical batch →
|
|
117
|
+
commit. Hooks attach at two granularities — the **logical** op (before
|
|
118
|
+
derivation) and the **physical** persist (the real composed batch).
|
|
119
|
+
|
|
120
|
+
| ID | Hook | Fires | Receives / may do |
|
|
121
|
+
|----|------|-------|-------------------|
|
|
122
|
+
| **W1** | `write.before(ctx)` | per logical write, before derivation | mutate `ctx.input` and `ctx.kind` (incl. `delete` → `update` soft-delete-by-rewrite — derivation then runs on the rewritten op); `throw` to cancel (in a transaction, aborts **all** ops) |
|
|
123
|
+
| **W2** | `write.after(ctx, change)` | per logical write, after commit | observe `change.{ oldImage, newImage }` (single-op writes carry a real old image; transactions return none) |
|
|
124
|
+
| **W3** | `write.persist.before(ctx)` | once per physical persist (the whole atomic batch in a transaction), after derivation | mutate `ctx.items` (the composed `Put` / `Update` / `Delete` / `ConditionCheck` set — user write(s) **plus** every derived effect); `throw` to abort the batch — mutations are honored on the actual send |
|
|
125
|
+
| **W4** | `write.persist.after(ctx, results)` | once per physical persist, after the executor returns | observe `results` |
|
|
126
|
+
| **W5** | `write.persist.onError` / `write.onError` | on a persist-level / logical-level error | observe, rethrow, or **recover** by returning a value |
|
|
127
|
+
|
|
128
|
+
Context types: `WriteCtx` (W1/W2/W5) carries the mutable `kind` and `input`, plus
|
|
129
|
+
an optional `transaction.id` shared across the ops of one atomic batch.
|
|
130
|
+
`PersistCtx` (W3/W4/W5) carries the mutable `items` and the `origins` of the
|
|
131
|
+
batch.
|
|
132
|
+
|
|
133
|
+
Atomicity: in `DDBModel.transaction` / `mutate({ mode: 'transaction' })`, persist
|
|
134
|
+
hooks fire **once** for the whole atomic batch and any `before` `throw` aborts the
|
|
135
|
+
entire transaction; in `parallel` mode each op is independent.
|
|
136
|
+
|
|
137
|
+
## Cancellation & recovery
|
|
138
|
+
|
|
139
|
+
- **Cancel** — `throw` from any `before` hook aborts that read/write (a transaction
|
|
140
|
+
`before` throw aborts the whole batch).
|
|
141
|
+
- **Recover** — return a value from an `onError` hook to suppress the error and
|
|
142
|
+
substitute a result (the first hook in LIFO order that returns a non-`undefined`
|
|
143
|
+
value wins; if none does, the original error rethrows). Read `read.onError`
|
|
144
|
+
returns the read's result; `read.op.onError` returns the op's `Item[]`; write
|
|
145
|
+
`onError` / `persist.onError` return the resolved value.
|
|
146
|
+
|
|
147
|
+
## Examples
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// Tenant scope on every read, including relation fan-out (R2)
|
|
151
|
+
DDBModel.use({ read: { op: { before(ctx) {
|
|
152
|
+
addFilter(ctx.operation, 'tenantId', ctx.context.tenantId);
|
|
153
|
+
} } } });
|
|
154
|
+
|
|
155
|
+
// Inject audit fields on every write (W1) — flows through derivation
|
|
156
|
+
DDBModel.use({ write: { before(ctx) {
|
|
157
|
+
if (ctx.kind !== 'delete') ctx.input.item = { ...ctx.input.item, updatedBy: ctx.context.actor };
|
|
158
|
+
} } });
|
|
159
|
+
|
|
160
|
+
// Soft delete by rewrite (W1) — stored as an update
|
|
161
|
+
DDBModel.use({ write: { before(ctx) {
|
|
162
|
+
if (ctx.kind === 'delete') { ctx.kind = 'update'; ctx.input.changes = { deletedAt: Date.now() }; }
|
|
163
|
+
} } });
|
|
164
|
+
|
|
165
|
+
// Redact non-admin reads (R4) and audit the real atomic batch (W3)
|
|
166
|
+
DDBModel.use({ read: { afterFetch: (ctx, r) => ctx.context.actor?.isAdmin ? r : redact(r) } });
|
|
167
|
+
DDBModel.use({ write: { persist: { before(ctx) { auditLog(ctx.items, ctx.context); } } } });
|
|
168
|
+
|
|
169
|
+
// Latency metric using per-request scratch (R1 → R4) and metrics on error (R5)
|
|
170
|
+
DDBModel.use({ read: {
|
|
171
|
+
before(ctx) { ctx.state.t0 = performance.now(); },
|
|
172
|
+
afterFetch(ctx, r) { metric(`read.${ctx.kind}`, performance.now() - (ctx.state.t0 as number)); return r; },
|
|
173
|
+
onError(ctx) { count(`read.${ctx.kind}.error`); /* return nothing → rethrow */ },
|
|
174
|
+
} });
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Python parity
|
|
178
|
+
|
|
179
|
+
The Python runtime exposes the same hook points (R1–R5, W1–W5), interface shape,
|
|
180
|
+
ordering, atomicity, `context` injection, and `onError` recovery, registered
|
|
181
|
+
through the Python runtime's equivalent of `DDBModel.use`. Because hooks are
|
|
182
|
+
host-only and never serialized, the two runtimes implement them independently
|
|
183
|
+
with no shared serialized surface; TS↔Python conformance is unaffected.
|
|
184
|
+
|
|
185
|
+
## See also
|
|
186
|
+
|
|
187
|
+
- [`cqrs-contract.md`](./cqrs-contract.md) — the public Query / Command contracts.
|
|
188
|
+
- [`python-bridge.md`](./python-bridge.md) — the TypeScript→Python runtime parity.
|
|
189
|
+
- [`cdc-emulator.md`](./cdc-emulator.md) — the (separate) change-data-capture mechanism.
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# Deriving Command Execution Plans from Mutations + Model Semantics
|
|
2
|
+
|
|
3
|
+
This document is the normative specification of GraphDDB's command-derivation
|
|
4
|
+
layer **as implemented**. The query side compiles a GraphQL-like selection +
|
|
5
|
+
model metadata into a complete read execution plan; the write side mirrors it:
|
|
6
|
+
a **mutation** (an internal write-plan composition DSL) + **model write
|
|
7
|
+
semantics** compiles into a **command execution plan**, so the author does not
|
|
8
|
+
hand-write the write procedure.
|
|
9
|
+
|
|
10
|
+
## Premise
|
|
11
|
+
|
|
12
|
+
The query side already does this: a GraphQL-like selection + model metadata
|
|
13
|
+
compiles to a complete **read execution plan** (point/range, relations,
|
|
14
|
+
composition, parallel `executionPlan`). The write side now does the same — it
|
|
15
|
+
compiles a **mutation** + model semantics into a **command execution plan** —
|
|
16
|
+
instead of the author hand-writing the write procedure.
|
|
17
|
+
|
|
18
|
+
This holds only to the extent the model carries write semantics. A mutation
|
|
19
|
+
alone is just the *input shape*; it cannot decide what happens in storage. The
|
|
20
|
+
decision lives in the model:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
GraphQL mutation = external input shape (syntax)
|
|
24
|
+
graphddb model = update target / relations / constraints / effects / access (semantics)
|
|
25
|
+
mutation + model → command execution plan
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Where the model is silent, the plan cannot be derived and must fall back to an
|
|
29
|
+
explicit, declarative action — never arbitrary procedure.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Background — the read/write symmetry
|
|
34
|
+
|
|
35
|
+
### The read side is fully derived (the template)
|
|
36
|
+
|
|
37
|
+
`select` tree + model metadata → read plan: `point`/`range` resolution,
|
|
38
|
+
relation traversal (`hasMany`→Query, `belongsTo`/`hasOne`→BatchGet), composition
|
|
39
|
+
(External Query), and the `executionPlan` parallel groups — all derived by the
|
|
40
|
+
planner and honored by both runtimes. This is the template the command side
|
|
41
|
+
mirrors.
|
|
42
|
+
|
|
43
|
+
### The model layer carries READ semantics plus an additive WRITE vocabulary
|
|
44
|
+
|
|
45
|
+
Present in the read/core model layer (`src/decorators/`, `src/metadata/types.ts`):
|
|
46
|
+
|
|
47
|
+
- Entity: `@model` (table), `@key` (segmented PK/SK), `@gsi` (`unique` flag).
|
|
48
|
+
- Fields: `@string` / `@number` / `@boolean` / `@datetime` / `@binary` / sets /
|
|
49
|
+
`@list` / `@map` / `@embedded`, and `@literal(...)` (finite value set). Field
|
|
50
|
+
options: `format`, `readonly`, `serialize`/`deserialize`.
|
|
51
|
+
- Relations: `@hasMany` / `@belongsTo` / `@hasOne` with `keyBinding`, limit/order
|
|
52
|
+
— these are read-traversal declarations.
|
|
53
|
+
|
|
54
|
+
On top of that, the write-semantics vocabulary is declared additively (it does
|
|
55
|
+
**not** change the read-side declarations):
|
|
56
|
+
|
|
57
|
+
- **`Model.writes` / `entityWrites`** (`src/define/entity-writes.ts`) — the
|
|
58
|
+
entity's reusable *save contract*: lifecycle effects, referential integrity,
|
|
59
|
+
uniqueness, derived updates, edge effects, outbox events, idempotency.
|
|
60
|
+
- **`edgeWrites` / `deriveEdgeWriteItems`** (`src/relation/`) — the write side
|
|
61
|
+
of an adjacency edge: from a declared edge, derive the lifecycle-distinguished
|
|
62
|
+
adjacency `Put`/`Delete` transaction items.
|
|
63
|
+
- **`mutation` / `definePlan`** (`src/define/mutation.ts`) — the internal
|
|
64
|
+
write-plan composition DSL.
|
|
65
|
+
|
|
66
|
+
### Command derivation, not hand-authored ops
|
|
67
|
+
|
|
68
|
+
Earlier, the contract command layer (`publicCommandModel`, issue #64) had the
|
|
69
|
+
author *write* the operation: a method body resolved to a single `put` /
|
|
70
|
+
`update` / `delete` (`CommandSpec`) or a hand-built `defineTransaction`
|
|
71
|
+
(`TransactWriteItems`). The command compiler now derives the plan from declared
|
|
72
|
+
write semantics: an **intent → derive ops** step exists, turning the procedural
|
|
73
|
+
DSL into a *derivable contract*.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Mapping onto DynamoDB transaction primitives
|
|
78
|
+
|
|
79
|
+
The derivation maps cleanly onto the DynamoDB transaction primitives the runtime
|
|
80
|
+
already emits. Each write semantic has a known DynamoDB realization that fits the
|
|
81
|
+
existing `CommandSpec` / `TransactionSpec` / `executionPlan` SSoT:
|
|
82
|
+
|
|
83
|
+
| Derived effect | DynamoDB realization | Reuses |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| put the entity item | `PutItem` (`attribute_not_exists(PK)` for create) | `CommandSpec` (#64) |
|
|
86
|
+
| create adjacency edge(s) | extra `Put` item(s) in one `TransactWriteItems` | `TransactionSpec` (#64) |
|
|
87
|
+
| `requires X exists` | `ConditionCheck` item (`attribute_exists`) in the tx | tx + extended condition subset |
|
|
88
|
+
| uniqueness (`unique field per parent`) | a `UNIQUE#…` guard `Put` w/ `attribute_not_exists` | tx + uniqueness declaration |
|
|
89
|
+
| counter / derived update | `UpdateItem` `ADD`/`SET` in the tx | tx + derived-update declaration |
|
|
90
|
+
| domain event | outbox `Put` item in the tx, drained to Streams/`cdc` | tx + event declaration + `src/cdc/` |
|
|
91
|
+
| idempotency | client-token guard item (`attribute_not_exists`) | tx + idempotency declaration |
|
|
92
|
+
|
|
93
|
+
A multi-effect create (item + edge + counter + uniqueness + event) is one
|
|
94
|
+
**atomic `TransactWriteItems`** — exactly what `defineTransaction` serializes,
|
|
95
|
+
with the new pieces being *declarations* the compiler turns into items. The
|
|
96
|
+
≤25-item limit and the "no per-item condition in `BatchWriteItem`" rules (#64)
|
|
97
|
+
apply unchanged.
|
|
98
|
+
|
|
99
|
+
Two capabilities the derivation depends on, both now implemented:
|
|
100
|
+
|
|
101
|
+
1. **Extended condition subset.** Writes support the full declarative operator
|
|
102
|
+
subset of a read filter (`gt`/`ge`/`lt`/`le`/`ne`/`between`/`in`/`beginsWith`/
|
|
103
|
+
`contains`/`notContains`/`size`/`attributeType` + `and`/`or`/`not`), the
|
|
104
|
+
existence primitives (`attribute_exists` / `attribute_not_exists` / `notExists`),
|
|
105
|
+
equality, and the raw `cond` escape hatch (#114) — plus a **`ConditionCheck`
|
|
106
|
+
transaction item type** (a read-only assertion on another item). This is what
|
|
107
|
+
referential integrity needs.
|
|
108
|
+
2. **Edge write side.** `@hasMany`/`belongsTo` describe how to *read* an
|
|
109
|
+
adjacency; `edgeWrites` / `deriveEdgeWriteItems` derive the inverse — the
|
|
110
|
+
adjacency `Put`/`Delete` items — from a declared edge.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Specification
|
|
115
|
+
|
|
116
|
+
### 1. Positioning — mutation is an INTERNAL write-plan composition DSL
|
|
117
|
+
|
|
118
|
+
**The mutation is NOT a public API.** GraphQL is not exposed externally. A
|
|
119
|
+
`mutation` is an **internal DSL for declaring a set of updates that must execute
|
|
120
|
+
atomically** — a *write-plan composition language* used as the *implementation*
|
|
121
|
+
of a Command. The only externally-exposed surface is the fixed **Command IF**
|
|
122
|
+
(`publicCommandModel`, #64): params in, result out.
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
Public Command IF (external, fixed) = what other services depend on. ← the only public surface
|
|
126
|
+
↑ implemented by
|
|
127
|
+
Mutation (internal DSL) = declares an atomic write plan by COMPOSING write fragments
|
|
128
|
+
├─ fragment: create(Post) ┐
|
|
129
|
+
└─ fragment: create(AuditLog) ┘ → compiler MERGES into ONE TransactWriteItems
|
|
130
|
+
Model semantics (entityWrites) = the semantics of each write fragment
|
|
131
|
+
Compiler = mutation AST + model → one CommandPlan / TransactionSpec
|
|
132
|
+
Runtime = atomic execution (TransactWriteItems)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Key consequences:
|
|
136
|
+
|
|
137
|
+
- A mutation's **top-level fields are write *fragments*, not executed function
|
|
138
|
+
calls.** `mutation CreatePost { createPost(...) createAuditLog(...) }` does NOT
|
|
139
|
+
run `createPost` then `createAuditLog` sequentially — both fragments are
|
|
140
|
+
expanded and **composed at compile time into one atomic plan**.
|
|
141
|
+
- Atomicity is achieved by **declaring multiple fragments in one mutation**, NOT
|
|
142
|
+
by a handler doing `await A(); await B()`. Sequential awaits in a handler are
|
|
143
|
+
the anti-pattern this replaces.
|
|
144
|
+
- Each fragment's substance is derived from **model semantics** (§2). The
|
|
145
|
+
compiler composes fragments and validates them together (§3).
|
|
146
|
+
- The CQRS split: **Query → Read Plan**; **Mutation → Command Plan** — the
|
|
147
|
+
mutation is internal; the Command IF is the boundary.
|
|
148
|
+
- `GraphQL contrast`: composing fragments in one mutation under the default
|
|
149
|
+
`mode: 'transaction'` is **atomic all-or-nothing**; `mode: 'parallel'` is
|
|
150
|
+
**non-atomic**, surfacing **per-field partial success** (`{ ok }` | `{ error }`
|
|
151
|
+
per alias); on the read side `query` routes are **parallel independent reads**
|
|
152
|
+
with no cross-route consistency.
|
|
153
|
+
|
|
154
|
+
### 2. Model write-semantics = reusable *save contract* (`entityWrites`)
|
|
155
|
+
|
|
156
|
+
`Model.writes` declares the **write-side invariants/effects required for this
|
|
157
|
+
entity to be consistent on DynamoDB** — a *reusable save contract*, NOT a
|
|
158
|
+
mutation and NOT business logic. It stays **declarative** (no arbitrary
|
|
159
|
+
procedure). Every leaf binds to an explicit path root (`$.input.*` = mutation
|
|
160
|
+
input, `$.entity.*` = the written entity), so the source of each value is
|
|
161
|
+
unambiguous.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
static readonly writes = entityWrites<PostModel>((w) => ({
|
|
165
|
+
create: w.lifecycle({
|
|
166
|
+
requires: [ w.exists(() => UserModel, { userId: '$.input.userId' }) ], // → ConditionCheck (#84)
|
|
167
|
+
unique: [ w.unique({ name: 'postTitlePerUser',
|
|
168
|
+
scope: ['$.input.userId'], fields: ['$.input.title'] }) ], // → guard (#86)
|
|
169
|
+
edges: [ w.putEdge(() => UserModel, 'posts') ], // → adjacency Put (#82/#85)
|
|
170
|
+
derive: [ w.increment(() => UserModel, { userId: '$.input.userId' }, 'postCount', 1) ], // (#85)
|
|
171
|
+
emits: [ w.event('PostCreated', { postId: '$.entity.postId', userId: '$.input.userId' }) ], // (#87)
|
|
172
|
+
idempotency: w.idempotentBy('$.input.requestId'), // → guard (#87)
|
|
173
|
+
}),
|
|
174
|
+
remove: w.lifecycle({
|
|
175
|
+
edges: [ w.deleteEdge(() => UserModel, 'posts') ],
|
|
176
|
+
derive: [ w.increment(() => UserModel, { userId: '$.entity.userId' }, 'postCount', -1) ],
|
|
177
|
+
}),
|
|
178
|
+
}));
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Design properties:
|
|
182
|
+
|
|
183
|
+
- **`writes` is the entity's reusable *default* save-semantics, not the mutation.**
|
|
184
|
+
The mutation explicitly *adopts* it (`use:`), separating "reusable save rule"
|
|
185
|
+
from "public operation IF" (§3).
|
|
186
|
+
- **`unique` is explicit `{ name, scope, fields }`** — the guard-item key shape
|
|
187
|
+
matters on DynamoDB; a bare `per:` is too weak for composite scope /
|
|
188
|
+
normalization / case-insensitive uniqueness.
|
|
189
|
+
- **Edge effects are lifecycle-distinguished** — `putEdge` (onCreate),
|
|
190
|
+
`deleteEdge` (onDelete), and **`onUpdateKeyChange`** (when a foreign key like
|
|
191
|
+
`post.userId` changes: delete the old edge + create the new one), because
|
|
192
|
+
delete/update need the **old** value.
|
|
193
|
+
|
|
194
|
+
### Responsibility boundary (what may live in `Model.writes`)
|
|
195
|
+
|
|
196
|
+
`Model.writes` holds ONLY the persistence/relational/integrity contract:
|
|
197
|
+
|
|
198
|
+
> referential integrity · uniqueness · edge create/delete · derived counter
|
|
199
|
+
> updates · basic create/delete lifecycle · outbox event · idempotency guard
|
|
200
|
+
|
|
201
|
+
It does **NOT** hold business flow, approval/state transitions, external API
|
|
202
|
+
calls, notifications, payment, complex authorization, UI concerns, or use-case
|
|
203
|
+
branching. Those are not "how this entity stays consistent on DynamoDB."
|
|
204
|
+
|
|
205
|
+
**The mutation internal is persistence/records ONLY.** Business logic lives
|
|
206
|
+
**outside** the mutation — in the application/service that calls the Command IF,
|
|
207
|
+
or in a cross-cutting middleware layer (a separate concern; see #50). There is
|
|
208
|
+
no business-logic "hook" inside the model or the mutation: needing one is a
|
|
209
|
+
smell that logic leaked into the wrong layer. The split:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
Caller / app layer = business logic, authorization, external I/O, orchestration
|
|
213
|
+
↓ calls
|
|
214
|
+
Command IF (public) = fixed external surface; implemented by a mutation
|
|
215
|
+
Mutation (internal) = composes write fragments into one atomic plan — RECORDS ONLY
|
|
216
|
+
Model.writes = common save semantics (DynamoDB consistency contract)
|
|
217
|
+
Command Compiler = fragment composition + cross-fragment validation → TransactWriteItems
|
|
218
|
+
Middleware (#50) = cross-cutting insertion points, designed separately/later
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Note: a *declarative* guard on the current persisted value — e.g. "only writable
|
|
222
|
+
while `status == 'draft'`" — is a **condition on the write** (the `{ field: value }`
|
|
223
|
+
/ `attribute_exists` condition subset, #81/#84), which is records-level and stays
|
|
224
|
+
inside. Genuine business logic (multi-step flows, external effects) does not.
|
|
225
|
+
|
|
226
|
+
### 3. The command compiler (compose fragments → one atomic CommandPlan)
|
|
227
|
+
|
|
228
|
+
A **mutation is internal**: it COMPOSES one or more **write fragments** in a
|
|
229
|
+
descriptor alias-map — each alias an **intent** (`create` / `update` / `remove`)
|
|
230
|
+
+ target `Model` + `key` + `input` (with optional `condition` / `result` /
|
|
231
|
+
`use`). The intent selects the lifecycle from the target's `writes`; `use:` is
|
|
232
|
+
**optional** (defaults to the target model's own `writes`) and is given only to
|
|
233
|
+
override with a custom write contract — and it takes the whole `writes` set,
|
|
234
|
+
never `writes.create` (that would double-specify the intent). A cross-fragment
|
|
235
|
+
data dependency is a `$.alias.field` reference. The compiler merges ALL
|
|
236
|
+
fragments into ONE atomic plan. The **public surface is the Command IF**
|
|
237
|
+
(`publicCommandModel`, #64), whose method is *implemented by* the mutation.
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
// ── internal write-plan composition (NOT public) ──
|
|
241
|
+
const CreatePost = mutation($ => ({
|
|
242
|
+
// The INTENT (`create`) already selects the lifecycle. `use:` is OPTIONAL and
|
|
243
|
+
// defaults to the target's own `writes`; the compiler picks writes.create for
|
|
244
|
+
// create / writes.update for update / writes.remove for remove.
|
|
245
|
+
post: { create: PostModel, key: { postId: param.string() }, // fragment 1 → PostModel.writes.create
|
|
246
|
+
input: { userId: param.string(), title: param.string(), body: param.string() } },
|
|
247
|
+
audit: { create: AuditLogModel, key: { id: param.string() }, // fragment 2 — composed atomically
|
|
248
|
+
input: { action: 'post.created', actorId: $.post.userId, postId: $.post.postId } },
|
|
249
|
+
}));
|
|
250
|
+
|
|
251
|
+
// Override only when a non-default save contract is wanted:
|
|
252
|
+
// post: { create: PostModel, use: CustomPostWrites, key: {...}, input: { … } }
|
|
253
|
+
// `use:` takes the WHOLE writes set (not writes.create) — the intent picks the
|
|
254
|
+
// lifecycle, so writing `use: PostModel.writes.create` would double-specify it.
|
|
255
|
+
|
|
256
|
+
// ── public Command IF (external, fixed — the only public surface) ──
|
|
257
|
+
export const PostCommands = publicCommandModel({
|
|
258
|
+
create: mutation($ => ({
|
|
259
|
+
post: { create: PostModel, key: { postId: param.string() },
|
|
260
|
+
input: { requestId: param.string().optional(), userId: param.string(),
|
|
261
|
+
title: param.string(), body: param.string() },
|
|
262
|
+
result: { select: { postId: true, title: true } } }, // return = read projection
|
|
263
|
+
})),
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Compilation (`compileFragment` for a single fragment;
|
|
268
|
+
`compileMutationPlan` for the N-fragment atomic merge, #90):
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
Mutation = { alias₁: fragment₁, alias₂: fragment₂, … } (each: intent + target Model + key + use: saveContract + input)
|
|
272
|
+
→ for each fragment, derive its items from the adopted save contract:
|
|
273
|
+
[ ConditionCheck(requires), Put(entity, attribute_not_exists), Put(unique guards),
|
|
274
|
+
Put(edge) / Delete(old edge)+Put(new edge) on key change,
|
|
275
|
+
Update(derived), Put(outbox event), Put(idempotency guard) ]
|
|
276
|
+
→ CROSS-FRAGMENT composition + validation:
|
|
277
|
+
• merge all fragments' items into ONE TransactWriteItems
|
|
278
|
+
• reject same-item conflicts (two writes to the same key in one tx — DynamoDB rejects)
|
|
279
|
+
• resolve cross-fragment data deps (e.g. fragment₂ reads fragment₁'s `$.alias.field`)
|
|
280
|
+
• enforce ≤25 items (hard error, never split — atomicity)
|
|
281
|
+
• dedupe/validate idempotency + outbox items
|
|
282
|
+
→ emit one CommandSpec (single, single-fragment, no-tx-needed) OR TransactionSpec (atomic)
|
|
283
|
+
→ attach executionPlan (#70); resolve return selection as a read projection
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
At runtime, every write path — single command, multi-fragment mutation, and the
|
|
287
|
+
declarative `defineTransaction` — routes through the **single shared
|
|
288
|
+
`commitTransaction` orchestration** (`src/runtime/transaction-commit.ts`, #97):
|
|
289
|
+
op-aware collapse of same-physical-key collisions, the ≤25 enforcement, the
|
|
290
|
+
atomic commit, and the CDC write-capture loop all live in that one place, so no
|
|
291
|
+
caller carries its own collapse / limit / commit / capture logic.
|
|
292
|
+
|
|
293
|
+
Naming: `mutation($ => ({...}))` = the INTERNAL composition DSL (not public);
|
|
294
|
+
`publicCommandModel({ alias: mutation(...) })` = the public Command IF;
|
|
295
|
+
`CommandPlan` = internal execution plan; `entityWrites` = model save-semantics.
|
|
296
|
+
Fragment intents are the descriptor keys `create` / `update` / `remove`
|
|
297
|
+
(`remove`, not `del`).
|
|
298
|
+
|
|
299
|
+
This is the **same SSoT** as #64 (`CommandSpec`/`TransactionSpec`) and #70
|
|
300
|
+
(`executionPlan`) — the compiler *populates* it from the adopted save contract
|
|
301
|
+
instead of the author hand-writing it. N+1 safety, ≤25-item atomicity,
|
|
302
|
+
condition-subset rules, and TS↔Python conformance all carry over unchanged.
|
|
303
|
+
|
|
304
|
+
### 4. What is NOT derivable lives OUTSIDE the mutation (no in-model hook)
|
|
305
|
+
|
|
306
|
+
Some logic is genuinely not derivable from declarative model semantics:
|
|
307
|
+
|
|
308
|
+
- **Business state transitions** (multi-step flows beyond a declarative write
|
|
309
|
+
condition).
|
|
310
|
+
- **External calls** (payment, email, notification).
|
|
311
|
+
- **Compensating transactions** (partial-failure recovery across systems).
|
|
312
|
+
- **Complex authorization** (org hierarchy, delegation, time-boxed grants).
|
|
313
|
+
|
|
314
|
+
**This logic does NOT get a hook inside the model or the mutation.** Embedding a
|
|
315
|
+
"policy/action hook" in the contract would blur the layer boundary the CQRS
|
|
316
|
+
split exists to keep clean. The mutation internal is **persistence/records
|
|
317
|
+
only**. Non-derivable business logic belongs **outside**:
|
|
318
|
+
|
|
319
|
+
- in the **application/service** that calls the Command IF (it does authz,
|
|
320
|
+
orchestration, external I/O *around* the command), and/or
|
|
321
|
+
- in a **cross-cutting middleware layer** — a separate concern, deferred and
|
|
322
|
+
tracked under **#50**, NOT part of this derivation layer.
|
|
323
|
+
|
|
324
|
+
Needing a business-logic hook *inside* a mutation is a smell that logic leaked
|
|
325
|
+
into the persistence layer. (A declarative guard on the current persisted value
|
|
326
|
+
is a *write condition*, not business logic — it stays inside, per §2.)
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Design summary
|
|
331
|
+
|
|
332
|
+
- **The external IF is GraphQL-like `query` | `mutation`**, forking internally
|
|
333
|
+
to read-plan vs command-plan compilers (CQRS). The mutation is the syntax; the
|
|
334
|
+
model is the semantics.
|
|
335
|
+
- **The command is a derivable contract, not a procedure.** The model
|
|
336
|
+
write-semantics vocabulary (lifecycle / edge-effects / constraints / derived
|
|
337
|
+
updates / events / idempotency) lets the compiler derive the plan. (Authz and
|
|
338
|
+
other business logic are NOT model write-semantics — they live outside the
|
|
339
|
+
mutation; see §4.)
|
|
340
|
+
- **The compile target is the SSoT** built for the contract layer
|
|
341
|
+
(`CommandSpec` / `TransactionSpec` / `executionPlan`) — no new execution
|
|
342
|
+
substrate.
|
|
343
|
+
- **The two enabling primitives are in place**: the extended write condition
|
|
344
|
+
subset (`attribute_exists` + a `ConditionCheck` transaction item type), and
|
|
345
|
+
the edge write side (adjacency Put/Delete derived from a declared edge).
|
|
346
|
+
- **Everything stays declarative + build-verified + conformance-locked**,
|
|
347
|
+
exactly as the query/command contract layer is.
|
|
348
|
+
|
|
349
|
+
`mutation + model semantics → command plan` turns the command interface from a
|
|
350
|
+
hand-written procedural DSL into a **derivable contract**, symmetric with the
|
|
351
|
+
query side. The work is not in execution (the `TransactWriteItems` patterns and
|
|
352
|
+
the SSoT already exist) — it is in the **model's declarative write-semantics
|
|
353
|
+
vocabulary**, plus the two primitives (`ConditionCheck` / `attribute_exists`,
|
|
354
|
+
and edge write-effects). The irreducible non-derivable logic (state machines,
|
|
355
|
+
external I/O, compensation, complex authz) stays outside the mutation, never
|
|
356
|
+
arbitrary procedure inside it.
|