graphddb 0.2.4 → 0.2.5

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.
@@ -778,6 +778,552 @@ type AtLeastOneKey<T> = {
778
778
  */
779
779
  type PartialQueryKeyOf<C> = QueryKeyOf<C> extends infer U ? U extends Record<string, unknown> ? AtLeastOneKey<U> : never : never;
780
780
 
781
+ interface PutOptions<T = unknown> {
782
+ condition?: WriteCondition<T>;
783
+ /**
784
+ * Per-call throttle / transient-error retry override (issue #111). A
785
+ * {@link RetryPolicy} replaces the global policy for this call; `false` disables
786
+ * retry for this call. Omit to use the configured (or built-in default) policy.
787
+ */
788
+ retry?: RetryOverride;
789
+ /**
790
+ * Host-injected per-call write context (issue #50 / #139) — exposed to every
791
+ * write hook (W1–W5) as `ctx.context`. A host-language value, NEVER serialized
792
+ * into the SSoT / `operations.json` (the bridge #48 is unaffected). Absent ⇒ `{}`.
793
+ */
794
+ context?: RequestContext;
795
+ }
796
+ interface UpdateOptions<T = unknown> {
797
+ condition?: WriteCondition<T>;
798
+ /**
799
+ * Per-call throttle / transient-error retry override (issue #111). A
800
+ * {@link RetryPolicy} replaces the global policy for this call; `false` disables
801
+ * retry for this call.
802
+ */
803
+ retry?: RetryOverride;
804
+ /**
805
+ * How to re-derive a GSI key whose composing fields are changed but **not all**
806
+ * available from `{ ...key, ...changes }` (issue #115).
807
+ *
808
+ * - **unset (default)** — refuse with an explicit error rather than let the
809
+ * index silently rot. The error names the index, the changed field, and the
810
+ * missing field(s). The happy path (all composing fields available) re-derives
811
+ * in the SAME `UpdateExpression` and is unaffected by this option.
812
+ * - `'read-modify-write'` — read the current item, merge `{ ...item, ...changes }`,
813
+ * re-derive every affected GSI key from the merged image, and write back under
814
+ * an optimistic condition (the item must still exist / be unchanged). This costs
815
+ * one extra read and is NOT atomic with the original update, but always re-derives
816
+ * correctly.
817
+ */
818
+ rederive?: 'read-modify-write';
819
+ /**
820
+ * Host-injected per-call write context (issue #50 / #139) — exposed to every
821
+ * write hook (W1–W5) as `ctx.context`. NEVER serialized. Absent ⇒ `{}`.
822
+ */
823
+ context?: RequestContext;
824
+ }
825
+ interface DeleteOptions<T = unknown> {
826
+ condition?: WriteCondition<T>;
827
+ /**
828
+ * Per-call throttle / transient-error retry override (issue #111). A
829
+ * {@link RetryPolicy} replaces the global policy for this call; `false` disables
830
+ * retry for this call.
831
+ */
832
+ retry?: RetryOverride;
833
+ /**
834
+ * Host-injected per-call write context (issue #50 / #139) — exposed to every
835
+ * write hook (W1–W5) as `ctx.context`. NEVER serialized. Absent ⇒ `{}`.
836
+ */
837
+ context?: RequestContext;
838
+ }
839
+
840
+ interface PutInput {
841
+ TableName: string;
842
+ Item: Record<string, unknown>;
843
+ ConditionExpression?: string;
844
+ ExpressionAttributeNames?: Record<string, string>;
845
+ ExpressionAttributeValues?: Record<string, unknown>;
846
+ }
847
+ declare function buildPutInput(modelClass: Function, item: Record<string, unknown>, options?: PutOptions): PutInput;
848
+ declare function executePut(modelClass: Function, item: Record<string, unknown>, options?: PutOptions): Promise<void>;
849
+
850
+ interface UpdateInput {
851
+ TableName: string;
852
+ Key: Record<string, unknown>;
853
+ UpdateExpression: string;
854
+ ExpressionAttributeNames: Record<string, string>;
855
+ ExpressionAttributeValues?: Record<string, unknown>;
856
+ ConditionExpression?: string;
857
+ }
858
+ declare function buildUpdateInput(modelClass: Function, entity: Record<string, unknown>, changes: Record<string, unknown>, options?: UpdateOptions): UpdateInput;
859
+ declare function executeUpdate(modelClass: Function, entity: Record<string, unknown>, changes: Record<string, unknown>, options?: UpdateOptions): Promise<void>;
860
+
861
+ interface DeleteInput {
862
+ TableName: string;
863
+ Key: Record<string, unknown>;
864
+ ConditionExpression?: string;
865
+ ExpressionAttributeNames?: Record<string, string>;
866
+ ExpressionAttributeValues?: Record<string, unknown>;
867
+ }
868
+ declare function buildDeleteInput(modelClass: Function, keyObj: Record<string, unknown>, options?: DeleteOptions): DeleteInput;
869
+ declare function executeDelete(modelClass: Function, keyObj: Record<string, unknown>, options?: DeleteOptions): Promise<void>;
870
+
871
+ /** Result of a read operation (GetItem / Query / BatchGetItem). */
872
+ interface ExecutorResult {
873
+ items: Record<string, unknown>[];
874
+ lastEvaluatedKey?: Record<string, unknown>;
875
+ }
876
+ /**
877
+ * Result of a single structured write. `oldItem` is the pre-write image,
878
+ * present only when the caller requested it (`returnOldImage: true`, mapped to
879
+ * DynamoDB `ReturnValues: ALL_OLD`) and an item existed before the write. The
880
+ * write-capture seam (issue #72) reads it to derive INSERT vs MODIFY and the
881
+ * REMOVE OldImage.
882
+ */
883
+ interface WriteResult {
884
+ oldItem?: Record<string, unknown>;
885
+ }
886
+ /**
887
+ * A per-call retry override threaded down to {@link RetryingExecutor} (issue
888
+ * #111). `undefined` ⇒ use the globally-configured (or default) policy; a
889
+ * {@link RetryPolicy} ⇒ use that for this call; `false` ⇒ disable retry for this
890
+ * call. The default {@link DynamoExecutor} and the test `MemoryExecutor` ignore
891
+ * it — only {@link RetryingExecutor} acts on it.
892
+ */
893
+ type RetryOverride = RetryPolicy | false;
894
+ /**
895
+ * Per-call options for the READ path (issue #111). Carries the per-op retry
896
+ * override down to {@link RetryingExecutor}. Extended (rather than passing the
897
+ * override positionally) so future per-call read knobs have a home.
898
+ */
899
+ interface ReadExecOptions {
900
+ /** Per-call retry override; see {@link RetryOverride}. */
901
+ retry?: RetryOverride;
902
+ }
903
+ /** Options shared by the single-item structured write methods. */
904
+ interface WriteExecOptions {
905
+ /** Request the pre-write image (DynamoDB `ReturnValues: ALL_OLD`). */
906
+ returnOldImage?: boolean;
907
+ /** Per-call retry override (issue #111); see {@link RetryOverride}. */
908
+ retry?: RetryOverride;
909
+ }
910
+ /** Per-call options for the batch / transact paths (issue #111). */
911
+ interface BatchExecOptions {
912
+ /** Per-call retry override; see {@link RetryOverride}. */
913
+ retry?: RetryOverride;
914
+ }
915
+ /**
916
+ * One BatchGetItem sub-request: a single physical table plus its keys and an
917
+ * optional projection. Mirrors {@link import('../planner/types.js').BatchGetItemOperation}
918
+ * without the discriminant `type` field, so callers that already chunked keys
919
+ * (relation traversal) and the planner-driven read path share one entry point.
920
+ */
921
+ interface BatchGetExecInput {
922
+ tableName: string;
923
+ keys: Record<string, unknown>[];
924
+ projectionExpression?: string;
925
+ expressionAttributeNames?: Record<string, string>;
926
+ consistentRead?: boolean;
927
+ }
928
+ /** A single item in a {@link Executor.batchWrite} request, grouped by table. */
929
+ type BatchWriteExecItem = {
930
+ type: 'put';
931
+ item: Record<string, unknown>;
932
+ } | {
933
+ type: 'delete';
934
+ key: Record<string, unknown>;
935
+ };
936
+ /**
937
+ * A read-only `ConditionCheck` entry for {@link Executor.transactWrite} (issue
938
+ * #81): it asserts a `ConditionExpression` holds for the keyed item without
939
+ * mutating it. A failed assertion cancels the **whole** `TransactWriteItems`
940
+ * atomically — the foundation for referential-integrity derivation.
941
+ */
942
+ interface ConditionCheckInput {
943
+ TableName: string;
944
+ Key: Record<string, unknown>;
945
+ ConditionExpression: string;
946
+ ExpressionAttributeNames?: Record<string, string>;
947
+ ExpressionAttributeValues?: Record<string, unknown>;
948
+ }
949
+ /**
950
+ * A `{ Put | Update | Delete | ConditionCheck }` entry for
951
+ * {@link Executor.transactWrite}. The first three mutate an item; `ConditionCheck`
952
+ * (issue #81) is a read-only assertion on another item.
953
+ */
954
+ type TransactWriteExecItem = {
955
+ Put: PutInput;
956
+ } | {
957
+ Update: UpdateInput;
958
+ } | {
959
+ Delete: DeleteInput;
960
+ } | {
961
+ ConditionCheck: ConditionCheckInput;
962
+ };
963
+ /**
964
+ * The unified I/O seam (issue #76). All of GraphDDB's read and write I/O is
965
+ * funneled through one injectable executor so the engine (planner / hydrator /
966
+ * relation traversal / write paths) can run unchanged against either real
967
+ * DynamoDB ({@link DynamoExecutor}, the default) or an in-process memory store
968
+ * (`MemoryExecutor`, the test adapter).
969
+ *
970
+ * Reads take a structured {@link DynamoDBOperation}; writes take the structured
971
+ * inputs the operation layer already builds (`PutInput` / `UpdateInput` / …),
972
+ * never an AWS SDK command. This keeps the seam at the *structured operation
973
+ * boundary* (proposal option B): no expression-string parsing is required to
974
+ * implement a fake.
975
+ */
976
+ interface Executor {
977
+ /**
978
+ * Read path: GetItem / Query / BatchGetItem. The optional {@link ReadExecOptions}
979
+ * carries a per-call retry override (issue #111); the default
980
+ * {@link DynamoExecutor} and `MemoryExecutor` ignore it, only `RetryingExecutor`
981
+ * acts on it.
982
+ */
983
+ execute(operation: DynamoDBOperation, options?: ReadExecOptions): Promise<ExecutorResult>;
984
+ /**
985
+ * BatchGetItem for a single table. The caller is responsible for chunking
986
+ * keys ≤100 (the relation traversal and the planner-driven path both do).
987
+ */
988
+ batchGet(input: BatchGetExecInput, options?: ReadExecOptions): Promise<ExecutorResult>;
989
+ /** Put one item. */
990
+ put(input: PutInput, options?: WriteExecOptions): Promise<WriteResult>;
991
+ /** Update one item (applies its structured UpdateExpression). */
992
+ update(input: UpdateInput, options?: WriteExecOptions): Promise<WriteResult>;
993
+ /** Delete one item. */
994
+ delete(input: DeleteInput, options?: WriteExecOptions): Promise<WriteResult>;
995
+ /**
996
+ * BatchWriteItem for a single table (non-atomic). Keys/items are chunked ≤25
997
+ * with UnprocessedItems retry by the implementation.
998
+ */
999
+ batchWrite(tableName: string, items: BatchWriteExecItem[], options?: BatchExecOptions): Promise<void>;
1000
+ /** TransactWriteItems — all-or-nothing atomic. */
1001
+ transactWrite(items: TransactWriteExecItem[], options?: BatchExecOptions): Promise<void>;
1002
+ }
1003
+
1004
+ /**
1005
+ * Write middleware / hook public surface (issue #50 write path, #139). The merged
1006
+ * design is `docs/proposals/read-middleware.md`; this module implements the
1007
+ * **write** hook points W1–W5 and their context objects, layered on the SAME
1008
+ * registry / runtime machinery the read hooks (#138) introduced.
1009
+ *
1010
+ * A write {@link Middleware} hook is host-only, runtime-registered (via
1011
+ * `DDBModel.use`), and **never serialized** — like the read hooks it lives only on
1012
+ * the {@link import('../client/ClientManager.js').ClientManager} host-runtime
1013
+ * singleton and never touches the planner / spec generator / `operations.json`, so
1014
+ * the TS↔Python bridge (#48) is unaffected. It is **unrestricted by intent**: a
1015
+ * hook may observe, mutate `ctx`, `throw` to cancel, or (on `onError`) recover by
1016
+ * returning a value. The library does NOT validate hook logic or impose semantics
1017
+ * (proposal appendix A). The internal CDC / capture mechanism (`src/capture/`,
1018
+ * `src/cdc/`) is a SEPARATE surface — write hooks are not a substitute for it nor
1019
+ * it for them (proposal appendix C).
1020
+ *
1021
+ * @see docs/proposals/read-middleware.md (hook points W1–W5)
1022
+ */
1023
+
1024
+ /**
1025
+ * The logical write kinds a {@link WriteCtx} may carry (W1 / W2 / W5). A W1 hook
1026
+ * may REWRITE this in place — the canonical case is a `delete` → `update`
1027
+ * (soft-delete-by-rewrite); derivation then runs on the rewritten op, so the
1028
+ * stored effect is the update, not the delete (proposal W1 + examples).
1029
+ */
1030
+ type WriteKind = 'put' | 'update' | 'delete';
1031
+ /**
1032
+ * The mutable logical write input a W1 hook observes / mutates. This is the
1033
+ * declarative write payload — NOT a serialized spec — at the granularity the
1034
+ * logical op carries:
1035
+ *
1036
+ * - `put` — the full `item` is in {@link WriteInput.item};
1037
+ * - `update` — the target `key` (entity) is in {@link WriteInput.key} and the
1038
+ * field `changes` in {@link WriteInput.changes};
1039
+ * - `delete` — the target `key` is in {@link WriteInput.key}.
1040
+ *
1041
+ * A W1 hook may mutate these in place (e.g. inject `updatedBy`), and may rewrite
1042
+ * `ctx.kind` together with these fields (a `delete` → `update` sets `changes`).
1043
+ * Keeping `input` and `kind` consistent is the host's responsibility (appendix A).
1044
+ */
1045
+ interface WriteInput {
1046
+ /** The item written by a `put` (its key fields appear here). Mutable in W1. */
1047
+ item?: Record<string, unknown>;
1048
+ /** The target entity / key of an `update` / `delete`. Mutable in W1. */
1049
+ key?: Record<string, unknown>;
1050
+ /** The field changes of an `update`. Mutable in W1 (and when rewriting delete→update). */
1051
+ changes?: Record<string, unknown>;
1052
+ /** The per-call write options (`condition` / `retry` / `rederive` / …). Mutable in W1. */
1053
+ options?: Record<string, unknown>;
1054
+ }
1055
+ /**
1056
+ * W1 / W2 / W5 (logical-write-level) context. `before` (W1) may mutate
1057
+ * {@link WriteCtx.input} and {@link WriteCtx.kind} (incl. the delete→update
1058
+ * rewrite) and may `throw` to cancel the write (in a transaction, aborting ALL
1059
+ * ops); `after` (W2) observes the committed change `{ oldImage?, newImage? }`;
1060
+ * `onError` (W5) sees the logical-level failure and may recover by returning a
1061
+ * value (symmetric with the read `onError`).
1062
+ */
1063
+ interface WriteCtx extends CtxBase {
1064
+ /** The logical write kind. Mutable in W1 (incl. delete→update rewrite). */
1065
+ kind: WriteKind;
1066
+ /** The mutable logical write payload (W1). */
1067
+ input: WriteInput;
1068
+ /**
1069
+ * Present when this logical write is part of an atomic transaction
1070
+ * (`DDBModel.transaction` / `mutate({ mode: 'transaction' })`). Its `id` symbol
1071
+ * is shared by every logical op in the same atomic batch, so a hook can
1072
+ * correlate the W1/W2 ops that compose one transaction. Absent for a
1073
+ * non-transactional single-op write (and for `parallel`-mode ops, which are
1074
+ * independent).
1075
+ */
1076
+ readonly transaction?: {
1077
+ readonly id: symbol;
1078
+ };
1079
+ }
1080
+ /**
1081
+ * The committed change handed to a W2 (`write.after`) hook. A
1082
+ * `TransactWriteItems` returns no item images (spec §14), so within a transaction
1083
+ * the images are best-effort (often absent); a single-op write may carry the
1084
+ * pre-write `oldImage` when one was fetched.
1085
+ */
1086
+ interface Change {
1087
+ readonly oldImage?: Record<string, unknown>;
1088
+ readonly newImage?: Record<string, unknown>;
1089
+ }
1090
+ /** One origin entry describing a logical op that contributed to a persist batch. */
1091
+ interface PersistOrigin {
1092
+ readonly model: CtxModel;
1093
+ readonly kind: WriteKind;
1094
+ }
1095
+ /**
1096
+ * W3 / W4 / W5 (physical-persist-level) context — fires AFTER derivation, on the
1097
+ * REAL composed batch. `before` (W3) may mutate {@link PersistCtx.items} (the
1098
+ * fully composed `Put` / `Update` / `Delete` / `ConditionCheck` set: the user
1099
+ * write(s) PLUS every derived edge / counter / GSI re-derivation / referential
1100
+ * ConditionCheck) and may `throw` to abort the batch — mutations are honored on
1101
+ * the ACTUAL send. `after` (W4) observes the executor's results; `onError` (W5)
1102
+ * sees the persist-level failure and may recover by returning a value.
1103
+ *
1104
+ * In a transaction the persist hooks fire ONCE for the whole atomic batch; for a
1105
+ * non-transactional single-op write `items` is the one-element `[{ Put|Update|Delete }]`.
1106
+ */
1107
+ interface PersistCtx {
1108
+ /** The fully composed physical batch (user + derived). Mutable in W3. */
1109
+ items: TransactWriteExecItem[];
1110
+ /** The logical ops that composed this batch (one per contributing W-level op). */
1111
+ readonly origins: readonly PersistOrigin[];
1112
+ /** The host-injected per-call context (`{}` when the write passed none). */
1113
+ readonly context: RequestContext;
1114
+ /** Scratch shared across this persist op's W3 → W4 → W5 phases. */
1115
+ readonly state: Record<string, unknown>;
1116
+ /** Present (with the shared `id`) when this batch is an atomic transaction. */
1117
+ readonly transaction?: {
1118
+ readonly id: symbol;
1119
+ };
1120
+ }
1121
+ /**
1122
+ * The write half of a registered {@link import('./types.js').Middleware}. Any
1123
+ * subset of the write hook points may be set; an unset hook is skipped. All hooks
1124
+ * are async-first — the runtime always `await`s, so sync and async hooks compose
1125
+ * uniformly (proposal "sync / async").
1126
+ *
1127
+ * @see docs/proposals/read-middleware.md (hook points W1–W5)
1128
+ */
1129
+ interface WriteMiddleware {
1130
+ /** W1 — logical write, before effect derivation. May mutate `ctx.input` / `ctx.kind`; `throw` cancels (in a tx, aborts all). */
1131
+ before?(ctx: WriteCtx): void | Promise<void>;
1132
+ /** W2 — logical write, after commit. Observes the committed `{ oldImage?, newImage? }`. */
1133
+ after?(ctx: WriteCtx, change: Change): void | Promise<void>;
1134
+ persist?: {
1135
+ /** W3 — physical persist, after derivation, on the real batch. May mutate `ctx.items`; `throw` aborts the batch. */
1136
+ before?(ctx: PersistCtx): void | Promise<void>;
1137
+ /** W4 — physical persist, after the executor returns. Observes `results`. */
1138
+ after?(ctx: PersistCtx, results: unknown): void | Promise<void>;
1139
+ /**
1140
+ * W5 (persist-level) — the persist failed. Runs LIFO. **Recover** by RETURNING
1141
+ * a non-`undefined` value: the persist is treated as succeeded and the value is
1142
+ * available to W4 (the first hook that returns a value wins and short-circuits
1143
+ * the chain). Return `void` / `undefined` to decline — if no hook recovers, the
1144
+ * original error rethrows (symmetric with the read `op.onError`).
1145
+ */
1146
+ onError?(ctx: PersistCtx, err: unknown): unknown;
1147
+ };
1148
+ /**
1149
+ * W5 (logical-level) — the logical write failed. Runs LIFO. **Recover** by
1150
+ * RETURNING a non-`undefined` value: it becomes the write's resolved value in
1151
+ * place of throwing (the first hook that returns a value wins). Return `void` /
1152
+ * `undefined` to decline — if no hook recovers, the original error rethrows
1153
+ * (symmetric with the read `onError`).
1154
+ */
1155
+ onError?(ctx: WriteCtx, err: unknown): unknown;
1156
+ }
1157
+
1158
+ /**
1159
+ * Read middleware / hook public surface (issue #50, implemented for the read
1160
+ * path in #138). The merged design is `docs/proposals/read-middleware.md`; this
1161
+ * module implements the **read** hook points R1–R5 and their context objects.
1162
+ *
1163
+ * A {@link Middleware} is a host-only, runtime-registered object (registered via
1164
+ * `DDBModel.use`) that runs at fixed seams in the read pipeline. Hooks are
1165
+ * **never serialized** — they live only on the {@link import('../client/ClientManager.js').ClientManager}
1166
+ * host-runtime singleton and never touch the planner / spec generator /
1167
+ * `operations.json`, so the TS↔Python bridge (#48) is unaffected. Hooks are
1168
+ * **unrestricted by intent**: a hook may observe, mutate `ctx`, `throw` to
1169
+ * cancel, or return a replacement value. The library does NOT validate hook
1170
+ * logic or impose semantics — see the proposal appendix A.
1171
+ *
1172
+ * @see docs/proposals/read-middleware.md
1173
+ */
1174
+
1175
+ /** A raw DynamoDB item (the shape R3 sees, per-op). */
1176
+ type Item = Record<string, unknown>;
1177
+ /**
1178
+ * Host-injected, library-opaque per-call context — the value passed as
1179
+ * `{ context }` on a read. Threaded unchanged to every R2/R3 fan-out op (issue
1180
+ * #138). A read issued with no `{ context }` sees `{}`.
1181
+ */
1182
+ type RequestContext = Record<string, unknown>;
1183
+ /**
1184
+ * The `ModelStatic` shape the library hands to a hook's `ctx.model`. The
1185
+ * concrete entity type is erased at the hook boundary (a hook is registered
1186
+ * globally and may see any model), so it is the permissive
1187
+ * `ModelStatic<DDBModel>`.
1188
+ */
1189
+ type CtxModel = ModelStatic<DDBModel>;
1190
+ /** Fields shared by every read context object. */
1191
+ interface CtxBase {
1192
+ /** The model the read targets. */
1193
+ readonly model: CtxModel;
1194
+ /** The host-injected per-call context (`{}` when the read passed none). */
1195
+ readonly context: RequestContext;
1196
+ /**
1197
+ * Scratch space shared across this op's phases (R2 → R3 → R5 for one op, or
1198
+ * R1 → R4 → R5 for the request). A fresh object per request context object;
1199
+ * each fan-out op gets its own `state` (see proposal appendix D — siblings run
1200
+ * concurrently, so a shared accumulator should live on a host object via
1201
+ * `context`, updated commutatively).
1202
+ */
1203
+ readonly state: Record<string, unknown>;
1204
+ }
1205
+ /**
1206
+ * The logical read kinds an {@link ReadRequestCtx} may carry (R1 / R4 / R5).
1207
+ *
1208
+ * Only the kinds the runtime actually emits are advertised. A `query` route in
1209
+ * the read envelope delegates to {@link import('../operations/query.js').executeQuery}
1210
+ * (kind `'query'`), a `list` route to {@link import('../operations/list.js').executeList}
1211
+ * (kind `'list'`), so there is no distinct `'envelopeRoute'` kind. `batchGet`
1212
+ * (`DDBModel.batchGet`, the public multi-key read primitive) fires request-level
1213
+ * hooks since issue #142 — its R1/R4/R5 carry kind `'batchGet'`; each underlying
1214
+ * physical `BatchGetItem` op fires R2/R3/R5 with op kind `'BatchGetItem'`.
1215
+ */
1216
+ type ReadRequestKind = 'query' | 'list' | 'batchGet';
1217
+ /**
1218
+ * The mutable request-level parameters R1 may observe / mutate. This is the
1219
+ * declarative read input — `key` / `select` / and per-call read options — NOT a
1220
+ * serialized spec. A hook may mutate these in place before key-resolution /
1221
+ * planning (R1).
1222
+ *
1223
+ * For a `batchGet` read (kind `'batchGet'`, issue #142) there is no single
1224
+ * `key` / `select` — the read spans multiple models/keys — so `key` / `select`
1225
+ * are empty placeholders and the mutable batch input lives on
1226
+ * {@link ReadParams.requests}; the library re-reads `requests` after R1 runs.
1227
+ */
1228
+ interface ReadParams {
1229
+ /** The key input (PK / GSI fields). Mutable in R1. Empty `{}` for `batchGet`. */
1230
+ key: Record<string, unknown>;
1231
+ /** The select / projection spec (plain object or a `project(...)` builder). Mutable in R1. Empty `{}` for `batchGet`. */
1232
+ select: Record<string, unknown>;
1233
+ /**
1234
+ * The per-call read options (`consistentRead` / `maxDepth` / `limit` /
1235
+ * `after` / `order` / `filter` / `updatable` / …) for this read kind. Mutable
1236
+ * in R1. The library re-reads these after R1 runs.
1237
+ */
1238
+ options: Record<string, unknown>;
1239
+ /**
1240
+ * Only for a `batchGet` read (issue #142): the mutable list of batch-get
1241
+ * requests (each `{ model, keys }`). A hook may add / drop / mutate requests
1242
+ * or their keys in place before the underlying `BatchGetItem` ops are issued;
1243
+ * the library re-reads this after R1 runs. `undefined` for `query` / `list`.
1244
+ */
1245
+ requests?: unknown[];
1246
+ }
1247
+ /**
1248
+ * R1 / R4 / R5 (request-level) context. `before` (R1) may mutate
1249
+ * {@link ReadParams} and may `throw` to cancel the whole read; `afterFetch` (R4)
1250
+ * receives the final hydrated, relation-merged result and may return a
1251
+ * replacement; `onError` (R5) sees a request-level failure.
1252
+ */
1253
+ interface ReadRequestCtx extends CtxBase {
1254
+ readonly kind: ReadRequestKind;
1255
+ /** Mutable in R1 (request entry, before key-resolution / plan). */
1256
+ params: ReadParams;
1257
+ }
1258
+ /** The physical op kinds an {@link ReadOpCtx} may carry (R2 / R3 / R5). */
1259
+ type ReadOpKind = 'GetItem' | 'Query' | 'BatchGetItem';
1260
+ /**
1261
+ * R2 / R3 / R5 (op-level) context — fires for the root read AND for every
1262
+ * relation fan-out fetch (incl. nested recursion). `before` (R2) may mutate
1263
+ * {@link ReadOpCtx.operation} before it is sent and may `throw` to cancel;
1264
+ * `afterFetch` (R3) receives the op's raw items and may return a replacement
1265
+ * array; `onError` (R5) sees the op-level failure.
1266
+ */
1267
+ interface ReadOpCtx extends CtxBase {
1268
+ readonly kind: ReadOpKind;
1269
+ /** The physical operation about to be sent. Mutable in R2. */
1270
+ operation: DynamoDBOperation;
1271
+ /**
1272
+ * The relation path from the root read to this op: `[]` for the root read,
1273
+ * `['orders']` for a direct relation fetch, `['orders', 'product']` for a
1274
+ * nested one. Lets a hook scope behavior to a specific fan-out leg.
1275
+ */
1276
+ readonly relationPath: readonly string[];
1277
+ }
1278
+ /**
1279
+ * A registered read middleware object. Any subset of the read hook points may
1280
+ * be set; an unset hook is simply skipped. All hooks may be synchronous or
1281
+ * return a `Promise` — the runtime always `await`s (async-first, see proposal
1282
+ * "sync / async").
1283
+ *
1284
+ * @see docs/proposals/read-middleware.md (the write hooks W1–W5 are a later phase)
1285
+ */
1286
+ interface Middleware {
1287
+ /** Optional name (for host-side debugging / ordering introspection). */
1288
+ name?: string;
1289
+ read?: {
1290
+ /** R1 — request entry, before key-resolution / plan. May mutate `ctx.params`; `throw` cancels. */
1291
+ before?(ctx: ReadRequestCtx): void | Promise<void>;
1292
+ /** R4 — final hydrated, relation-merged result. Returns the (possibly replaced) result. */
1293
+ afterFetch?<T>(ctx: ReadRequestCtx, result: T): T | Promise<T>;
1294
+ op?: {
1295
+ /** R2 — each physical op, before send. May mutate `ctx.operation`; `throw` cancels. */
1296
+ before?(ctx: ReadOpCtx): void | Promise<void>;
1297
+ /** R3 — each physical op's raw items. Returns the (possibly replaced) items. */
1298
+ afterFetch?(ctx: ReadOpCtx, items: Item[]): Item[] | Promise<Item[]>;
1299
+ /**
1300
+ * R5 (op-level) — a physical op failed. Runs LIFO. **Recover** by RETURNING
1301
+ * an `Item[]`: those become this op's items and the read pipeline continues
1302
+ * (the first hook that returns a value wins and short-circuits the chain).
1303
+ * Return `void` / `undefined` to decline — if no hook recovers, the original
1304
+ * error rethrows. (See proposal R5 + appendix A — hooks are unrestricted.)
1305
+ */
1306
+ onError?(ctx: ReadOpCtx, err: unknown): void | Item[] | Promise<void | Item[]>;
1307
+ };
1308
+ /**
1309
+ * R5 (request-level) — the read failed. Runs LIFO. **Recover** by RETURNING a
1310
+ * value: it becomes the read's result in place of throwing (the first hook
1311
+ * that returns a non-`undefined` value wins and short-circuits the chain).
1312
+ * Return `void` / `undefined` to decline — if no hook recovers, the original
1313
+ * error rethrows. The returned value is the read's resolved value verbatim, so
1314
+ * its shape is the host's responsibility (e.g. `null` / an item for `query`, a
1315
+ * `{ items, cursor }` for `list`).
1316
+ */
1317
+ onError?(ctx: ReadRequestCtx, err: unknown): unknown;
1318
+ };
1319
+ /**
1320
+ * The write hook points W1–W5 (issue #50 write path, #139). Any subset may be
1321
+ * set; one registered object may carry read and/or write hooks. See
1322
+ * {@link WriteMiddleware}.
1323
+ */
1324
+ write?: WriteMiddleware;
1325
+ }
1326
+
781
1327
  /**
782
1328
  * A top-level `select` may be either a plain projection (`SelectableOf<T>`) or
783
1329
  * a {@link SelectBuilder} produced by `Model.project(...)`. The builder is
@@ -847,6 +1393,13 @@ type ListCallOptions<T> = {
847
1393
  * `& Updatable`, so it can be passed straight back to `updateItem()`.
848
1394
  */
849
1395
  updatable?: boolean;
1396
+ /**
1397
+ * Host-injected per-call read context (issue #50 / #138) — passed to every
1398
+ * registered read hook as `ctx.context` and threaded to every relation
1399
+ * fan-out op. A host-language value, never serialized (the bridge #48 is
1400
+ * unaffected). Absent ⇒ `{}`.
1401
+ */
1402
+ context?: RequestContext;
850
1403
  };
851
1404
  /**
852
1405
  * Public, type-inferring API surface produced by {@link DDBModel.asModel}.
@@ -914,6 +1467,8 @@ interface ModelStatic<T extends DDBModel, C = unknown> {
914
1467
  maxDepth?: number;
915
1468
  updatable?: boolean;
916
1469
  hydrate: (raw: QueryResult<T, ProjectionOf<T, Sel>>) => R;
1470
+ /** Host-injected per-call read context (issue #50 / #138); see {@link ListCallOptions.context}. */
1471
+ context?: RequestContext;
917
1472
  }): Promise<R | null>;
918
1473
  /**
919
1474
  * Fetch a single item by a unique key, requesting an **updatable** result.
@@ -927,6 +1482,8 @@ interface ModelStatic<T extends DDBModel, C = unknown> {
927
1482
  consistentRead?: boolean;
928
1483
  maxDepth?: number;
929
1484
  updatable: true;
1485
+ /** Host-injected per-call read context (issue #50 / #138); see {@link ListCallOptions.context}. */
1486
+ context?: RequestContext;
930
1487
  }): Promise<(QueryResult<T, ProjectionOf<T, Sel>> & Updatable) | null>;
931
1488
  /**
932
1489
  * Fetch a single item by a unique key. The return type contains only the
@@ -936,6 +1493,8 @@ interface ModelStatic<T extends DDBModel, C = unknown> {
936
1493
  consistentRead?: boolean;
937
1494
  maxDepth?: number;
938
1495
  updatable?: false;
1496
+ /** Host-injected per-call read context (issue #50 / #138); see {@link ListCallOptions.context}. */
1497
+ context?: RequestContext;
939
1498
  }): Promise<QueryResult<T, ProjectionOf<T, Sel>> | null>;
940
1499
  /**
941
1500
  * List items for a partition, symmetric with {@link ModelStatic.query}:
@@ -985,213 +1544,6 @@ interface ModelStatic<T extends DDBModel, C = unknown> {
985
1544
  deleteItem(key: DeleteKey<C>, options?: WriteOptions<T>): Promise<void>;
986
1545
  }
987
1546
 
988
- interface UpdateInput {
989
- TableName: string;
990
- Key: Record<string, unknown>;
991
- UpdateExpression: string;
992
- ExpressionAttributeNames: Record<string, string>;
993
- ExpressionAttributeValues?: Record<string, unknown>;
994
- ConditionExpression?: string;
995
- }
996
- declare function buildUpdateInput(modelClass: Function, entity: Record<string, unknown>, changes: Record<string, unknown>, options?: UpdateOptions): UpdateInput;
997
- declare function executeUpdate(modelClass: Function, entity: Record<string, unknown>, changes: Record<string, unknown>, options?: UpdateOptions): Promise<void>;
998
-
999
- interface DeleteInput {
1000
- TableName: string;
1001
- Key: Record<string, unknown>;
1002
- ConditionExpression?: string;
1003
- ExpressionAttributeNames?: Record<string, string>;
1004
- ExpressionAttributeValues?: Record<string, unknown>;
1005
- }
1006
- declare function buildDeleteInput(modelClass: Function, keyObj: Record<string, unknown>, options?: DeleteOptions): DeleteInput;
1007
- declare function executeDelete(modelClass: Function, keyObj: Record<string, unknown>, options?: DeleteOptions): Promise<void>;
1008
-
1009
- /** Result of a read operation (GetItem / Query / BatchGetItem). */
1010
- interface ExecutorResult {
1011
- items: Record<string, unknown>[];
1012
- lastEvaluatedKey?: Record<string, unknown>;
1013
- }
1014
- /**
1015
- * Result of a single structured write. `oldItem` is the pre-write image,
1016
- * present only when the caller requested it (`returnOldImage: true`, mapped to
1017
- * DynamoDB `ReturnValues: ALL_OLD`) and an item existed before the write. The
1018
- * write-capture seam (issue #72) reads it to derive INSERT vs MODIFY and the
1019
- * REMOVE OldImage.
1020
- */
1021
- interface WriteResult {
1022
- oldItem?: Record<string, unknown>;
1023
- }
1024
- /**
1025
- * A per-call retry override threaded down to {@link RetryingExecutor} (issue
1026
- * #111). `undefined` ⇒ use the globally-configured (or default) policy; a
1027
- * {@link RetryPolicy} ⇒ use that for this call; `false` ⇒ disable retry for this
1028
- * call. The default {@link DynamoExecutor} and the test `MemoryExecutor` ignore
1029
- * it — only {@link RetryingExecutor} acts on it.
1030
- */
1031
- type RetryOverride = RetryPolicy | false;
1032
- /**
1033
- * Per-call options for the READ path (issue #111). Carries the per-op retry
1034
- * override down to {@link RetryingExecutor}. Extended (rather than passing the
1035
- * override positionally) so future per-call read knobs have a home.
1036
- */
1037
- interface ReadExecOptions {
1038
- /** Per-call retry override; see {@link RetryOverride}. */
1039
- retry?: RetryOverride;
1040
- }
1041
- /** Options shared by the single-item structured write methods. */
1042
- interface WriteExecOptions {
1043
- /** Request the pre-write image (DynamoDB `ReturnValues: ALL_OLD`). */
1044
- returnOldImage?: boolean;
1045
- /** Per-call retry override (issue #111); see {@link RetryOverride}. */
1046
- retry?: RetryOverride;
1047
- }
1048
- /** Per-call options for the batch / transact paths (issue #111). */
1049
- interface BatchExecOptions {
1050
- /** Per-call retry override; see {@link RetryOverride}. */
1051
- retry?: RetryOverride;
1052
- }
1053
- /**
1054
- * One BatchGetItem sub-request: a single physical table plus its keys and an
1055
- * optional projection. Mirrors {@link import('../planner/types.js').BatchGetItemOperation}
1056
- * without the discriminant `type` field, so callers that already chunked keys
1057
- * (relation traversal) and the planner-driven read path share one entry point.
1058
- */
1059
- interface BatchGetExecInput {
1060
- tableName: string;
1061
- keys: Record<string, unknown>[];
1062
- projectionExpression?: string;
1063
- expressionAttributeNames?: Record<string, string>;
1064
- consistentRead?: boolean;
1065
- }
1066
- /** A single item in a {@link Executor.batchWrite} request, grouped by table. */
1067
- type BatchWriteExecItem = {
1068
- type: 'put';
1069
- item: Record<string, unknown>;
1070
- } | {
1071
- type: 'delete';
1072
- key: Record<string, unknown>;
1073
- };
1074
- /**
1075
- * A read-only `ConditionCheck` entry for {@link Executor.transactWrite} (issue
1076
- * #81): it asserts a `ConditionExpression` holds for the keyed item without
1077
- * mutating it. A failed assertion cancels the **whole** `TransactWriteItems`
1078
- * atomically — the foundation for referential-integrity derivation.
1079
- */
1080
- interface ConditionCheckInput {
1081
- TableName: string;
1082
- Key: Record<string, unknown>;
1083
- ConditionExpression: string;
1084
- ExpressionAttributeNames?: Record<string, string>;
1085
- ExpressionAttributeValues?: Record<string, unknown>;
1086
- }
1087
- /**
1088
- * A `{ Put | Update | Delete | ConditionCheck }` entry for
1089
- * {@link Executor.transactWrite}. The first three mutate an item; `ConditionCheck`
1090
- * (issue #81) is a read-only assertion on another item.
1091
- */
1092
- type TransactWriteExecItem = {
1093
- Put: PutInput;
1094
- } | {
1095
- Update: UpdateInput;
1096
- } | {
1097
- Delete: DeleteInput;
1098
- } | {
1099
- ConditionCheck: ConditionCheckInput;
1100
- };
1101
- /**
1102
- * The unified I/O seam (issue #76). All of GraphDDB's read and write I/O is
1103
- * funneled through one injectable executor so the engine (planner / hydrator /
1104
- * relation traversal / write paths) can run unchanged against either real
1105
- * DynamoDB ({@link DynamoExecutor}, the default) or an in-process memory store
1106
- * (`MemoryExecutor`, the test adapter).
1107
- *
1108
- * Reads take a structured {@link DynamoDBOperation}; writes take the structured
1109
- * inputs the operation layer already builds (`PutInput` / `UpdateInput` / …),
1110
- * never an AWS SDK command. This keeps the seam at the *structured operation
1111
- * boundary* (proposal option B): no expression-string parsing is required to
1112
- * implement a fake.
1113
- */
1114
- interface Executor {
1115
- /**
1116
- * Read path: GetItem / Query / BatchGetItem. The optional {@link ReadExecOptions}
1117
- * carries a per-call retry override (issue #111); the default
1118
- * {@link DynamoExecutor} and `MemoryExecutor` ignore it, only `RetryingExecutor`
1119
- * acts on it.
1120
- */
1121
- execute(operation: DynamoDBOperation, options?: ReadExecOptions): Promise<ExecutorResult>;
1122
- /**
1123
- * BatchGetItem for a single table. The caller is responsible for chunking
1124
- * keys ≤100 (the relation traversal and the planner-driven path both do).
1125
- */
1126
- batchGet(input: BatchGetExecInput, options?: ReadExecOptions): Promise<ExecutorResult>;
1127
- /** Put one item. */
1128
- put(input: PutInput, options?: WriteExecOptions): Promise<WriteResult>;
1129
- /** Update one item (applies its structured UpdateExpression). */
1130
- update(input: UpdateInput, options?: WriteExecOptions): Promise<WriteResult>;
1131
- /** Delete one item. */
1132
- delete(input: DeleteInput, options?: WriteExecOptions): Promise<WriteResult>;
1133
- /**
1134
- * BatchWriteItem for a single table (non-atomic). Keys/items are chunked ≤25
1135
- * with UnprocessedItems retry by the implementation.
1136
- */
1137
- batchWrite(tableName: string, items: BatchWriteExecItem[], options?: BatchExecOptions): Promise<void>;
1138
- /** TransactWriteItems — all-or-nothing atomic. */
1139
- transactWrite(items: TransactWriteExecItem[], options?: BatchExecOptions): Promise<void>;
1140
- }
1141
-
1142
- interface PutOptions<T = unknown> {
1143
- condition?: WriteCondition<T>;
1144
- /**
1145
- * Per-call throttle / transient-error retry override (issue #111). A
1146
- * {@link RetryPolicy} replaces the global policy for this call; `false` disables
1147
- * retry for this call. Omit to use the configured (or built-in default) policy.
1148
- */
1149
- retry?: RetryOverride;
1150
- }
1151
- interface UpdateOptions<T = unknown> {
1152
- condition?: WriteCondition<T>;
1153
- /**
1154
- * Per-call throttle / transient-error retry override (issue #111). A
1155
- * {@link RetryPolicy} replaces the global policy for this call; `false` disables
1156
- * retry for this call.
1157
- */
1158
- retry?: RetryOverride;
1159
- /**
1160
- * How to re-derive a GSI key whose composing fields are changed but **not all**
1161
- * available from `{ ...key, ...changes }` (issue #115).
1162
- *
1163
- * - **unset (default)** — refuse with an explicit error rather than let the
1164
- * index silently rot. The error names the index, the changed field, and the
1165
- * missing field(s). The happy path (all composing fields available) re-derives
1166
- * in the SAME `UpdateExpression` and is unaffected by this option.
1167
- * - `'read-modify-write'` — read the current item, merge `{ ...item, ...changes }`,
1168
- * re-derive every affected GSI key from the merged image, and write back under
1169
- * an optimistic condition (the item must still exist / be unchanged). This costs
1170
- * one extra read and is NOT atomic with the original update, but always re-derives
1171
- * correctly.
1172
- */
1173
- rederive?: 'read-modify-write';
1174
- }
1175
- interface DeleteOptions<T = unknown> {
1176
- condition?: WriteCondition<T>;
1177
- /**
1178
- * Per-call throttle / transient-error retry override (issue #111). A
1179
- * {@link RetryPolicy} replaces the global policy for this call; `false` disables
1180
- * retry for this call.
1181
- */
1182
- retry?: RetryOverride;
1183
- }
1184
-
1185
- interface PutInput {
1186
- TableName: string;
1187
- Item: Record<string, unknown>;
1188
- ConditionExpression?: string;
1189
- ExpressionAttributeNames?: Record<string, string>;
1190
- ExpressionAttributeValues?: Record<string, unknown>;
1191
- }
1192
- declare function buildPutInput(modelClass: Function, item: Record<string, unknown>, options?: PutOptions): PutInput;
1193
- declare function executePut(modelClass: Function, item: Record<string, unknown>, options?: PutOptions): Promise<void>;
1194
-
1195
1547
  type TransactWriteItemInput = {
1196
1548
  Put: PutInput;
1197
1549
  } | {
@@ -1218,9 +1570,32 @@ interface TransactCaptureMeta {
1218
1570
  };
1219
1571
  newItem?: Record<string, unknown>;
1220
1572
  }
1573
+ /**
1574
+ * One recorded logical write op of an imperative {@link TransactionContext}
1575
+ * (issue #139). Captured at `tx.put/update/delete` call time so the write hooks
1576
+ * W1 (`write.before`) can run on each op — and rebuild its physical item from the
1577
+ * possibly-mutated input — before the batch composes / commits. A `conditionCheck`
1578
+ * records NO logical op (it is a read-only assertion, not a logical write).
1579
+ */
1580
+ interface LogicalWriteOp {
1581
+ kind: WriteKind;
1582
+ modelClass: Function;
1583
+ item?: Record<string, unknown>;
1584
+ key?: Record<string, unknown>;
1585
+ changes?: Record<string, unknown>;
1586
+ options?: PutOptions & UpdateOptions & DeleteOptions;
1587
+ }
1221
1588
  declare class TransactionContext {
1222
1589
  private readonly items;
1223
1590
  private readonly captureMeta;
1591
+ /**
1592
+ * The logical write ops, parallel to {@link items} but EXCLUDING `conditionCheck`
1593
+ * entries (those are read-only assertions, recorded only in `items`). Used by the
1594
+ * write-hook path (#139) to run W1 on each logical op and recompose the batch.
1595
+ * A `null` entry marks an `items` slot that has no logical op (a ConditionCheck),
1596
+ * so the eager and logical arrays can be re-aligned at commit.
1597
+ */
1598
+ private readonly logicalOps;
1224
1599
  get itemCount(): number;
1225
1600
  put(model: ModelStatic<DDBModel>, item: Record<string, unknown>, options?: PutOptions): void;
1226
1601
  update(model: ModelStatic<DDBModel>, entity: Record<string, unknown>, changes: Record<string, unknown>, options?: UpdateOptions): void;
@@ -1242,16 +1617,35 @@ declare class TransactionContext {
1242
1617
  }): void;
1243
1618
  /** @internal */
1244
1619
  getTransactItems(): TransactWriteItemInput[];
1620
+ /** @internal — the recorded logical write ops (issue #139 W1), `null` per ConditionCheck slot. */
1621
+ getLogicalOps(): readonly (LogicalWriteOp | null)[];
1245
1622
  /** @internal — capture descriptors for the write-capture seam (issue #72). */
1246
1623
  getCaptureMeta(): TransactCaptureMeta[];
1247
1624
  private assertWithinLimit;
1248
1625
  }
1249
- declare function executeTransaction(fn: (tx: TransactionContext) => void | Promise<void>): Promise<void>;
1626
+ declare function executeTransaction(fn: (tx: TransactionContext) => void | Promise<void>, options?: {
1627
+ context?: RequestContext;
1628
+ }): Promise<void>;
1250
1629
 
1251
1630
  interface BatchGetRequest {
1252
1631
  model: ModelStatic<DDBModel>;
1253
1632
  keys: Record<string, unknown>[];
1254
1633
  }
1634
+ /**
1635
+ * Per-call options for {@link DDBModel.batchGet} (issue #142).
1636
+ */
1637
+ interface BatchGetOptions {
1638
+ /**
1639
+ * Host-injected per-call read context (issue #50 / #138 / #142) — exposed to
1640
+ * every read hook (R1/R4/R5 at the `batchGet` request level, R2/R3/R5 on each
1641
+ * underlying `BatchGetItem` op) as `ctx.context`, and threaded UNCHANGED to
1642
+ * every physical op so a global hook (e.g. a tenant-scope R2 FilterExpression
1643
+ * or an observability probe) applies across the whole multi-key batch read. A
1644
+ * host-language value, NEVER serialized into the SSoT / `operations.json` (the
1645
+ * bridge #48 is unaffected). Absent ⇒ `{}`.
1646
+ */
1647
+ context?: RequestContext;
1648
+ }
1255
1649
  interface BatchPutRequest {
1256
1650
  type: 'put';
1257
1651
  model: ModelStatic<DDBModel>;
@@ -1270,7 +1664,23 @@ declare class BatchGetResult {
1270
1664
  constructor(groups: Map<Function, Record<string, unknown>[]>);
1271
1665
  get<T extends DDBModel>(model: ModelStatic<T>): Partial<T>[];
1272
1666
  }
1273
- declare function executeBatchGet(requests: BatchGetRequest[]): Promise<BatchGetResult>;
1667
+ /**
1668
+ * The multi-key, multi-model batch-read core (issue #142). Wraps the request
1669
+ * entry in the request-level read hooks R1 (`read.before`) / R4
1670
+ * (`read.afterFetch`) / R5 (`read.onError`) with request kind `'batchGet'`, and
1671
+ * each underlying physical `BatchGetItem` op (per table/chunk) in R2/R3/R5 via
1672
+ * {@link executeBatchGetForTable}. The read middleware runtime is built ONCE here
1673
+ * from `options.context` and threaded UNCHANGED to every op, so a global hook
1674
+ * (e.g. a tenant-scope R2 FilterExpression) applies across the whole batch.
1675
+ *
1676
+ * R1 sees the mutable batch `requests` on `ctx.params.requests`; the library
1677
+ * re-reads them after R1 runs, so a hook may add / drop / mutate requests before
1678
+ * any op is issued and may `throw` to cancel the whole batch. R4 may transform /
1679
+ * replace the assembled {@link BatchGetResult}; R5 may recover by returning one.
1680
+ * With no middleware registered the shared `NO_MIDDLEWARE` runtime keeps the hot
1681
+ * path unchanged (the zero-overhead fast path).
1682
+ */
1683
+ declare function executeBatchGet(requests: BatchGetRequest[], options?: BatchGetOptions): Promise<BatchGetResult>;
1274
1684
  declare function executeBatchWrite(requests: BatchWriteRequest[]): Promise<void>;
1275
1685
 
1276
1686
  /**
@@ -4391,6 +4801,13 @@ interface ReadRouteOptions {
4391
4801
  readonly after?: string;
4392
4802
  readonly order?: 'ASC' | 'DESC';
4393
4803
  readonly filter?: Record<string, unknown>;
4804
+ /**
4805
+ * Host-injected per-call read context (issue #50 / #138). Threaded into the
4806
+ * route's underlying read (`executeQuery` / `executeList`), so the read hooks
4807
+ * R1–R5 fire for the route with this `ctx.context`. Each route runs
4808
+ * independently, so its hooks see only its own context. Absent ⇒ `{}`.
4809
+ */
4810
+ readonly context?: RequestContext;
4394
4811
  }
4395
4812
  /** One read route: a `query` (point/unique) or a `list` (partition) descriptor. */
4396
4813
  interface ReadRouteDescriptor {
@@ -4447,6 +4864,13 @@ interface MutateOptions {
4447
4864
  * built-in default policy.
4448
4865
  */
4449
4866
  readonly retry?: RetryOverride;
4867
+ /**
4868
+ * Host-injected per-call write context (issue #50 / #139) — exposed to every
4869
+ * write hook (W1–W5) as `ctx.context`. In `transaction` mode every composed op
4870
+ * shares it and the persist hooks fire once for the atomic batch; in `parallel`
4871
+ * mode each independent op sees it. NEVER serialized. Absent ⇒ `{}`.
4872
+ */
4873
+ readonly context?: RequestContext;
4450
4874
  }
4451
4875
  /** A per-alias result in `parallel` mode: success carries the read-back, else error. */
4452
4876
  type ParallelOpResult = {
@@ -4548,7 +4972,33 @@ declare abstract class DDBModel {
4548
4972
  * for that call (`false` disables retry).
4549
4973
  */
4550
4974
  static setRetryPolicy(policy: RetryPolicy | null): void;
4551
- static transaction(fn: (tx: TransactionContext) => void | Promise<void>): Promise<void>;
4975
+ /**
4976
+ * Register a {@link Middleware} (issue #50; read path #138, write path #139).
4977
+ * One registered object may carry read and/or write hooks.
4978
+ *
4979
+ * - **Read** R1–R5 — request entry (`read.before`), each physical op before send
4980
+ * / after raw fetch (`read.op.before` / `read.op.afterFetch`, INCLUDING every
4981
+ * relation fan-out fetch), the final hydrated result (`read.afterFetch`), and on
4982
+ * error (`read.op.onError` / `read.onError`).
4983
+ * - **Write** W1–W5 — the logical write before effect derivation (`write.before`,
4984
+ * may mutate `ctx.input` / `ctx.kind` incl. a delete→update rewrite), after commit
4985
+ * (`write.after`), the physical persist on the fully composed batch
4986
+ * (`write.persist.before` / `write.persist.after`, fires ONCE per atomic
4987
+ * transaction), and on error (`write.persist.onError` / `write.onError`).
4988
+ *
4989
+ * Hooks may observe, mutate `ctx`, `throw` to cancel, or (on `onError`) recover by
4990
+ * returning a value; the library imposes no semantics. Ordering: `before*` runs
4991
+ * first-registered-first (FIFO); `after*` / `onError` run last-registered-first
4992
+ * (LIFO). Mirrors {@link setRetryPolicy} — a host-runtime global stored on
4993
+ * {@link ClientManager}; never serialized (the bridge #48 is unaffected). Per-call
4994
+ * host context flows in via the read's / write's `{ context }` option.
4995
+ */
4996
+ static use(mw: Middleware): void;
4997
+ /** Remove every registered middleware (read AND write; teardown / tests). See {@link use}. */
4998
+ static clearMiddleware(): void;
4999
+ static transaction(fn: (tx: TransactionContext) => void | Promise<void>, options?: {
5000
+ context?: RequestContext;
5001
+ }): Promise<void>;
4552
5002
  /**
4553
5003
  * In-process multi-route READ (issue #101 — the unified envelope). Each alias
4554
5004
  * route (`{ query | list: Model, key, select, options? }`) runs **independently
@@ -4576,7 +5026,19 @@ declare abstract class DDBModel {
4576
5026
  static mutate<const E extends WriteEnvelope>(envelope: E, options: {
4577
5027
  mode: 'parallel';
4578
5028
  }): Promise<MutateParallelResult<E>>;
4579
- static batchGet(requests: BatchGetRequest[]): Promise<BatchGetResult>;
5029
+ /**
5030
+ * Multi-key, multi-model batch read. Each {@link BatchGetRequest} contributes its
5031
+ * keys to a chunked `BatchGetItem` per physical table; results are re-grouped by
5032
+ * model on the returned {@link BatchGetResult}.
5033
+ *
5034
+ * Fires the read hooks (issue #142): request-level R1/R4/R5 with kind
5035
+ * `'batchGet'`, and op-level R2/R3/R5 for each underlying physical `BatchGetItem`
5036
+ * op. The optional `{ context }` is exposed to every hook as `ctx.context` and
5037
+ * threaded UNCHANGED to every op (absent ⇒ `{}`), so a global hook (e.g. a
5038
+ * tenant-scope R2 FilterExpression) applies across the whole batch. With no
5039
+ * middleware registered the read path is unchanged (zero-overhead fast path).
5040
+ */
5041
+ static batchGet(requests: BatchGetRequest[], options?: BatchGetOptions): Promise<BatchGetResult>;
4580
5042
  static batchWrite(requests: BatchWriteRequest[]): Promise<void>;
4581
5043
  static asModel<C extends new (...args: never[]) => DDBModel>(this: C): ModelStatic<InstanceType<C>, C>;
4582
5044
  }
@@ -4820,4 +5282,4 @@ interface CdcEmulatorOptions {
4820
5282
  }
4821
5283
  type ShardId = string;
4822
5284
 
4823
- export { type QuerySpec as $, type ParamDescriptor as A, type BatchGetExecInput as B, type ChangeEvent as C, type DynamoDBOperation as D, type Executor as E, type FaultSpec as F, type DefinitionMap as G, type WriteDefinitionOptions as H, type PartialQueryKeyOf as I, type StrictSelectSpec as J, type EntityInput as K, type UniqueQueryKeyOf as L, type ModelStatic as M, type EntityRef as N, type OperationDefinition as O, type PutInput as P, type ConditionInput as Q, type ReadExecOptions as R, type SelectableOf as S, type TransactWriteExecItem as T, type UpdateInput as U, type QueryModelContract as V, type WriteExecOptions as W, type QueryMethodSpec as X, type CommandModelContract as Y, type CommandMethodSpec as Z, type ContractSpec as _, type ExecutorResult as a, type FilterInput as a$, type CommandSpec as a0, type ContextSpec as a1, type OperationsDocument as a2, type AnyOperationDefinition as a3, type BridgeBundle as a4, type ConditionSpec as a5, type BatchDeleteRequest as a6, type BatchGetRequest as a7, BatchGetResult as a8, type BatchPutRequest as a9, type ContractFromRef as aA, type ContractInputArity as aB, type ContractItem as aC, type ContractKeyFieldRef as aD, type ContractKeyInput as aE, type ContractKeyRef as aF, type ContractKeySpec as aG, type ContractKind as aH, type ContractMethodOp as aI, type ContractParamRef as aJ, type ContractQueryParams as aK, type ContractResolution as aL, DEFAULT_MAX_ATTEMPTS as aM, DEFAULT_RETRY_POLICY as aN, type DeleteOptions as aO, type DeriveEffect as aP, type DerivedEdgeWrite as aQ, type DerivedUpdate as aR, type DescriptorBinding as aS, ENTITY_WRITES_MARKER as aT, type EdgeEffect as aU, type EffectPath as aV, type EmitEffect as aW, type EntityWritesDefinition as aX, type EntityWritesShape as aY, type ExecutableCommandContract as aZ, type ExecutableQueryContract as a_, type BatchResult as aa, type BatchWriteRequest as ab, CONTRACT_RANGE_FANOUT_CONCURRENCY as ac, type CdcMode as ad, type ChangeBatch as ae, type ChangeEventName as af, type ClockMode as ag, type Column as ah, type ColumnMap as ai, type CommandContractMethodSpec as aj, type CommandInputShape as ak, type CommandMethod as al, type CommandPlan as am, type CommandResolutionTarget as an, type CommandResultKind as ao, type CommandSelectShape as ap, type CompiledFragment as aq, type ComposeSpec as ar, type CondSlot as as, type ConditionCheckInput as at, type Connection as au, type ContractCallSignature as av, type ContractCardinality as aw, type ContractCommandParams as ax, type ContractCommandResult as ay, type ContractComposeNode as az, type WriteResult as b, type SegmentSpec as b$, type FilterSpec as b0, type FragmentInput as b1, type GsiDefinitionMarker as b2, type GsiOptions as b3, type IdempotencyEffect as b4, type InProcessWriteDescriptor as b5, type InputArity as b6, type KeyDefinitionMarker as b7, type KeySegment as b8, type KeySlot as b9, type OperationSpec as bA, type ParallelOpResult as bB, type ParamKind as bC, type ParamSpec as bD, type ParamStructure as bE, type PlannedCommandMethod as bF, type PutOptions as bG, type QueryContractMethodSpec as bH, type QueryEnvelopeResult as bI, type QueryKeyOf as bJ, type QueryMethod as bK, type QueryResult as bL, type RangeConditionSpec as bM, type ReadEnvelope as bN, type ReadOperationType as bO, type ReadRouteDescriptor as bP, type ReadRouteOptions as bQ, type ReadRouteResult as bR, type RecordedCompose as bS, type RelationBuilder as bT, type RelationSelect as bU, type RelationSpec as bV, type RequiresEffect as bW, type Resolution as bX, type RetryInfo as bY, type RetryOperationKind as bZ, SPEC_VERSION as b_, type KeyStructure as ba, type KeyedResult as bb, LIFECYCLE_CONTRACT_MARKER as bc, type LifecycleContract as bd, type LifecycleEffects as be, type LiteralParam as bf, type ManifestEntity as bg, type ManifestField as bh, type ManifestFieldType as bi, type ManifestGsi as bj, type ManifestKey as bk, type ManifestRelation as bl, type ManifestTable as bm, type ModelRef as bn, type MutateMode as bo, type MutateOptions as bp, type MutateParallelResult as bq, type MutateTransactionResult as br, type MutationBody as bs, type MutationDescriptorMap as bt, type MutationFragment as bu, type MutationInputProxy as bv, type MutationInputRef as bw, type MutationIntent as bx, type NumberParam as by, type OperationKind as bz, type DeleteInput as c, mintContractParamRef as c$, type SelectBuilder as c0, type SelectOf as c1, type StartingPosition as c2, type StreamViewType as c3, type StringParam as c4, TransactionContext as c5, type TransactionItemType as c6, type UniqueEffect as c7, type Updatable as c8, type UpdateOptions as c9, executeTransaction as cA, executeUpdate as cB, from as cC, getEntityWrites as cD, gsi as cE, isColumn as cF, isCommandModelContract as cG, isCommandPlan as cH, isContractComposeNode as cI, isContractFromRef as cJ, isContractKeyFieldRef as cK, isContractKeyRef as cL, isContractParamRef as cM, isEntityWritesDefinition as cN, isKeySegment as cO, isLifecycleContract as cP, isMutationFragment as cQ, isMutationInputRef as cR, isParam as cS, isPlannedCommandMethod as cT, isQueryModelContract as cU, isRetryableError as cV, isRetryableTransactionCancellation as cW, k as cX, key as cY, lifecyclePhaseForIntent as cZ, mintContractKeyFieldRef as c_, type WhenSpec as ca, type WriteDescriptor as cb, type WriteEnvelope as cc, type WriteLifecyclePhase as cd, type WriteOperationType as ce, type WriteRecorder as cf, type WriteResultProjection as cg, attachModelClass as ch, buildDeleteInput as ci, buildPutInput as cj, buildUpdateInput as ck, compileFragment as cl, compileMutationPlan as cm, compileSingleFragmentPlan as cn, cond as co, contractOfMethodSpec as cp, definePlan as cq, entityWrites as cr, executeBatchGet as cs, executeBatchWrite as ct, executeCommandMethod as cu, executeDelete as cv, executeKeyedBatchGet as cw, executePut as cx, executeQueryMethod as cy, executeRangeFanout as cz, type BatchWriteExecItem as d, mutation as d0, param as d1, publicCommandModel as d2, publicQueryModel as d3, query as d4, resolveLifecycle as d5, wholeKeysSentinel as d6, type BatchExecOptions as e, DDBModel as f, type PrimaryKeyOf as g, type RetryPolicy as h, type ExecutionPlanSpec as i, type SegmentedKey as j, type SelectBuilderSpec as k, type RawCondition as l, type TransactionSpec as m, type Manifest as n, type RetryOverride as o, type ExecutionPlan as p, type ResolvedKey as q, type CdcEmulatorOptions as r, type ChangeHandler as s, type Unsubscribe as t, type ConcurrentRecomputeRef as u, type EventLog as v, type ReplayOptions as w, type ShardId as x, type TransactionItemSpec as y, type Param as z };
5285
+ export { type EntityRef as $, type ConcurrentRecomputeRef as A, type BatchGetExecInput as B, type ChangeEvent as C, type DynamoDBOperation as D, type Executor as E, type FaultSpec as F, type EventLog as G, type ReplayOptions as H, type Item as I, type ShardId as J, type TransactionItemSpec as K, type Param as L, type ModelStatic as M, type ParamDescriptor as N, type DefinitionMap as O, type PutInput as P, type OperationDefinition as Q, type ReadExecOptions as R, type SelectableOf as S, type TransactWriteExecItem as T, type UpdateInput as U, type WriteDefinitionOptions as V, type WriteExecOptions as W, type PartialQueryKeyOf as X, type StrictSelectSpec as Y, type EntityInput as Z, type UniqueQueryKeyOf as _, type ExecutorResult as a, type DerivedUpdate as a$, type ConditionInput as a0, type QueryModelContract as a1, type QueryMethodSpec as a2, type CommandModelContract as a3, type CommandMethodSpec as a4, type ContractSpec as a5, type QuerySpec as a6, type CommandSpec as a7, type ContextSpec as a8, type OperationsDocument as a9, type ComposeSpec as aA, type CondSlot as aB, type ConditionCheckInput as aC, type Connection as aD, type ContractCallSignature as aE, type ContractCardinality as aF, type ContractCommandParams as aG, type ContractCommandResult as aH, type ContractComposeNode as aI, type ContractFromRef as aJ, type ContractInputArity as aK, type ContractItem as aL, type ContractKeyFieldRef as aM, type ContractKeyInput as aN, type ContractKeyRef as aO, type ContractKeySpec as aP, type ContractKind as aQ, type ContractMethodOp as aR, type ContractParamRef as aS, type ContractQueryParams as aT, type ContractResolution as aU, type CtxBase as aV, DEFAULT_MAX_ATTEMPTS as aW, DEFAULT_RETRY_POLICY as aX, type DeleteOptions as aY, type DeriveEffect as aZ, type DerivedEdgeWrite as a_, type AnyOperationDefinition as aa, type BridgeBundle as ab, type ConditionSpec as ac, type BatchDeleteRequest as ad, type BatchGetOptions as ae, type BatchGetRequest as af, BatchGetResult as ag, type BatchPutRequest as ah, type BatchResult as ai, type BatchWriteRequest as aj, CONTRACT_RANGE_FANOUT_CONCURRENCY as ak, type CdcMode as al, type Change as am, type ChangeBatch as an, type ChangeEventName as ao, type ClockMode as ap, type Column as aq, type ColumnMap as ar, type CommandContractMethodSpec as as, type CommandInputShape as at, type CommandMethod as au, type CommandPlan as av, type CommandResolutionTarget as aw, type CommandResultKind as ax, type CommandSelectShape as ay, type CompiledFragment as az, type WriteResult as b, type ReadOpKind as b$, type DescriptorBinding as b0, ENTITY_WRITES_MARKER as b1, type EdgeEffect as b2, type EffectPath as b3, type EmitEffect as b4, type EntityWritesDefinition as b5, type EntityWritesShape as b6, type ExecutableCommandContract as b7, type ExecutableQueryContract as b8, type FilterInput as b9, type MutateParallelResult as bA, type MutateTransactionResult as bB, type MutationBody as bC, type MutationDescriptorMap as bD, type MutationFragment as bE, type MutationInputProxy as bF, type MutationInputRef as bG, type MutationIntent as bH, type NumberParam as bI, type OperationKind as bJ, type OperationSpec as bK, type ParallelOpResult as bL, type ParamKind as bM, type ParamSpec as bN, type ParamStructure as bO, type PersistCtx as bP, type PersistOrigin as bQ, type PlannedCommandMethod as bR, type PutOptions as bS, type QueryContractMethodSpec as bT, type QueryEnvelopeResult as bU, type QueryKeyOf as bV, type QueryMethod as bW, type QueryResult as bX, type RangeConditionSpec as bY, type ReadEnvelope as bZ, type ReadOpCtx as b_, type FilterSpec as ba, type FragmentInput as bb, type GsiDefinitionMarker as bc, type GsiOptions as bd, type IdempotencyEffect as be, type InProcessWriteDescriptor as bf, type InputArity as bg, type KeyDefinitionMarker as bh, type KeySegment as bi, type KeySlot as bj, type KeyStructure as bk, type KeyedResult as bl, LIFECYCLE_CONTRACT_MARKER as bm, type LifecycleContract as bn, type LifecycleEffects as bo, type LiteralParam as bp, type ManifestEntity as bq, type ManifestField as br, type ManifestFieldType as bs, type ManifestGsi as bt, type ManifestKey as bu, type ManifestRelation as bv, type ManifestTable as bw, type ModelRef as bx, type MutateMode as by, type MutateOptions as bz, type DeleteInput as c, isContractFromRef as c$, type ReadOperationType as c0, type ReadRouteDescriptor as c1, type ReadRouteOptions as c2, type ReadRouteResult as c3, type RecordedCompose as c4, type RelationBuilder as c5, type RelationSelect as c6, type RelationSpec as c7, type RequiresEffect as c8, type Resolution as c9, buildDeleteInput as cA, buildPutInput as cB, buildUpdateInput as cC, compileFragment as cD, compileMutationPlan as cE, compileSingleFragmentPlan as cF, cond as cG, contractOfMethodSpec as cH, definePlan as cI, entityWrites as cJ, executeBatchGet as cK, executeBatchWrite as cL, executeCommandMethod as cM, executeDelete as cN, executeKeyedBatchGet as cO, executePut as cP, executeQueryMethod as cQ, executeRangeFanout as cR, executeTransaction as cS, executeUpdate as cT, from as cU, getEntityWrites as cV, gsi as cW, isColumn as cX, isCommandModelContract as cY, isCommandPlan as cZ, isContractComposeNode as c_, type RetryInfo as ca, type RetryOperationKind as cb, SPEC_VERSION as cc, type SegmentSpec as cd, type SelectBuilder as ce, type SelectOf as cf, type StartingPosition as cg, type StreamViewType as ch, type StringParam as ci, TransactionContext as cj, type TransactionItemType as ck, type UniqueEffect as cl, type Updatable as cm, type UpdateOptions as cn, type WhenSpec as co, type WriteCtx as cp, type WriteDescriptor as cq, type WriteEnvelope as cr, type WriteInput as cs, type WriteKind as ct, type WriteLifecyclePhase as cu, type WriteMiddleware as cv, type WriteOperationType as cw, type WriteRecorder as cx, type WriteResultProjection as cy, attachModelClass as cz, type BatchWriteExecItem as d, isContractKeyFieldRef as d0, isContractKeyRef as d1, isContractParamRef as d2, isEntityWritesDefinition as d3, isKeySegment as d4, isLifecycleContract as d5, isMutationFragment as d6, isMutationInputRef as d7, isParam as d8, isPlannedCommandMethod as d9, isQueryModelContract as da, isRetryableError as db, isRetryableTransactionCancellation as dc, k as dd, key as de, lifecyclePhaseForIntent as df, mintContractKeyFieldRef as dg, mintContractParamRef as dh, mutation as di, param as dj, publicCommandModel as dk, publicQueryModel as dl, query as dm, resolveLifecycle as dn, wholeKeysSentinel as dp, type BatchExecOptions as e, DDBModel as f, type PrimaryKeyOf as g, type RequestContext as h, type Middleware as i, type ReadRequestKind as j, type CtxModel as k, type ReadParams as l, type ReadRequestCtx as m, type RetryPolicy as n, type ExecutionPlanSpec as o, type SegmentedKey as p, type SelectBuilderSpec as q, type RawCondition as r, type TransactionSpec as s, type Manifest as t, type RetryOverride as u, type ExecutionPlan as v, type ResolvedKey as w, type CdcEmulatorOptions as x, type ChangeHandler as y, type Unsubscribe as z };