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.
@@ -834,6 +834,27 @@ var RetryingExecutor = class {
834
834
  }
835
835
  };
836
836
 
837
+ // src/middleware/registry.ts
838
+ var MiddlewareRegistry = class {
839
+ list = [];
840
+ /** Register a middleware (appended last — it runs last in FIFO `before*`). */
841
+ use(mw) {
842
+ this.list.push(mw);
843
+ }
844
+ /** Remove all registered middleware (teardown / tests). */
845
+ clear() {
846
+ this.list = [];
847
+ }
848
+ /**
849
+ * A point-in-time, registration-ordered snapshot. A read takes this once at
850
+ * entry (R1) and threads it down, so its hook set is stable for the whole read
851
+ * even if middleware is registered / cleared mid-flight.
852
+ */
853
+ snapshot() {
854
+ return this.list.slice();
855
+ }
856
+ };
857
+
837
858
  // src/client/ClientManager.ts
838
859
  var ClientManager = class {
839
860
  static client = null;
@@ -853,6 +874,15 @@ var ClientManager = class {
853
874
  * read at call time by the default {@link RetryingExecutor}.
854
875
  */
855
876
  static retryPolicy = null;
877
+ /**
878
+ * The host-runtime read-middleware registry (issue #50 / #138) — a process-wide
879
+ * ordered list of {@link Middleware}, the same global-config + injectable-seam
880
+ * pattern as {@link retryPolicy} / {@link executor}. Registered via
881
+ * {@link DDBModel.use} / {@link use}, snapshotted per read, and NEVER serialized
882
+ * (it never touches the planner / spec / `operations.json`, so the bridge #48 is
883
+ * unaffected). {@link reset} clears it.
884
+ */
885
+ static middleware = new MiddlewareRegistry();
856
886
  static setClient(client) {
857
887
  this.client = client;
858
888
  this.documentClient = null;
@@ -919,12 +949,34 @@ var ClientManager = class {
919
949
  static getRetryPolicy() {
920
950
  return this.retryPolicy;
921
951
  }
952
+ /**
953
+ * Register a read {@link Middleware} (issue #50 / #138). Appended last, so its
954
+ * `before*` hooks run last (FIFO) and its `afterFetch` / `onError` hooks run
955
+ * first (LIFO). Mirrors {@link setRetryPolicy}; backs {@link DDBModel.use}.
956
+ */
957
+ static use(mw) {
958
+ this.middleware.use(mw);
959
+ }
960
+ /** Remove every registered read middleware (teardown / tests). Backs {@link DDBModel.clearMiddleware}. */
961
+ static clearMiddleware() {
962
+ this.middleware.clear();
963
+ }
964
+ /**
965
+ * A point-in-time, registration-ordered snapshot of the registered read
966
+ * middleware. A read takes this once at entry (R1) and threads it down, so the
967
+ * read's hook set stays stable even if middleware is registered / cleared
968
+ * mid-flight.
969
+ */
970
+ static getMiddleware() {
971
+ return this.middleware.snapshot();
972
+ }
922
973
  /** @internal — for testing only */
923
974
  static reset() {
924
975
  this.client = null;
925
976
  this.documentClient = null;
926
977
  this.executor = null;
927
978
  this.retryPolicy = null;
979
+ this.middleware.clear();
928
980
  }
929
981
  };
930
982
 
@@ -1440,6 +1492,69 @@ function serializeFieldValue(value, fieldMeta) {
1440
1492
  return value;
1441
1493
  }
1442
1494
 
1495
+ // src/expression/update-expression.ts
1496
+ function isPlainObject(value) {
1497
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
1498
+ }
1499
+ function flattenChanges(obj, parentPath, embeddedFieldNames) {
1500
+ const sets = [];
1501
+ const removes = [];
1502
+ for (const [fieldName, value] of Object.entries(obj)) {
1503
+ const currentPath = [...parentPath, fieldName];
1504
+ if (value === void 0) {
1505
+ removes.push({ path: currentPath });
1506
+ } else if (embeddedFieldNames.has(currentPath[0]) && isPlainObject(value)) {
1507
+ const nested = flattenChanges(value, currentPath, embeddedFieldNames);
1508
+ sets.push(...nested.sets);
1509
+ removes.push(...nested.removes);
1510
+ } else {
1511
+ sets.push({ path: currentPath, value });
1512
+ }
1513
+ }
1514
+ return { sets, removes };
1515
+ }
1516
+ function buildUpdateExpression(changes, embeddedFieldNames) {
1517
+ const { sets, removes } = flattenChanges(changes, [], embeddedFieldNames);
1518
+ if (sets.length === 0 && removes.length === 0) {
1519
+ throw new Error("No changes provided for update.");
1520
+ }
1521
+ const names = {};
1522
+ const values = {};
1523
+ let valueCounter = 0;
1524
+ const setClauses = [];
1525
+ for (const op of sets) {
1526
+ const pathExpr = op.path.map((segment) => {
1527
+ const nameKey = `#${segment}`;
1528
+ names[nameKey] = segment;
1529
+ return nameKey;
1530
+ }).join(".");
1531
+ const valueKey = `:val${valueCounter++}`;
1532
+ values[valueKey] = op.value;
1533
+ setClauses.push(`${pathExpr} = ${valueKey}`);
1534
+ }
1535
+ const removeClauses = [];
1536
+ for (const op of removes) {
1537
+ const pathExpr = op.path.map((segment) => {
1538
+ const nameKey = `#${segment}`;
1539
+ names[nameKey] = segment;
1540
+ return nameKey;
1541
+ }).join(".");
1542
+ removeClauses.push(pathExpr);
1543
+ }
1544
+ const parts = [];
1545
+ if (setClauses.length > 0) {
1546
+ parts.push(`SET ${setClauses.join(", ")}`);
1547
+ }
1548
+ if (removeClauses.length > 0) {
1549
+ parts.push(`REMOVE ${removeClauses.join(", ")}`);
1550
+ }
1551
+ return {
1552
+ expression: parts.join(" "),
1553
+ names,
1554
+ values
1555
+ };
1556
+ }
1557
+
1443
1558
  // src/metadata/segments.ts
1444
1559
  var SEGMENT_DELIMITER = "#";
1445
1560
  function segmentComplete(segment, values) {
@@ -1579,6 +1694,34 @@ function missingGsiFields(gsi2, available) {
1579
1694
  return gsi2.inputFieldNames.filter((f) => available[f] === void 0);
1580
1695
  }
1581
1696
 
1697
+ // src/hydrator/hidden-key.ts
1698
+ var GRAPHDDB_KEY = /* @__PURE__ */ Symbol("graphddb:key");
1699
+ function attachHiddenKey(result, raw) {
1700
+ const pk = raw.PK;
1701
+ if (typeof pk !== "string") {
1702
+ throw new Error(
1703
+ "Cannot attach updatable key: raw item is missing a string `PK` attribute."
1704
+ );
1705
+ }
1706
+ const sk = raw.SK;
1707
+ const value = {
1708
+ PK: pk,
1709
+ SK: typeof sk === "string" ? sk : ""
1710
+ };
1711
+ Object.defineProperty(result, GRAPHDDB_KEY, {
1712
+ value,
1713
+ enumerable: false,
1714
+ writable: false,
1715
+ configurable: false
1716
+ });
1717
+ return result;
1718
+ }
1719
+ function readHiddenKey(entity) {
1720
+ const value = entity[GRAPHDDB_KEY];
1721
+ if (value === void 0) return void 0;
1722
+ return value;
1723
+ }
1724
+
1582
1725
  // src/capture/registry.ts
1583
1726
  var ChangeCaptureRegistryImpl = class {
1584
1727
  subscribers = /* @__PURE__ */ new Set();
@@ -1648,6 +1791,278 @@ function captureWrite(record) {
1648
1791
  ChangeCaptureRegistry.emit(record);
1649
1792
  }
1650
1793
 
1794
+ // src/middleware/runtime.ts
1795
+ function opKind(operation) {
1796
+ return operation.type;
1797
+ }
1798
+ var MiddlewareRuntime = class {
1799
+ /** The frozen, registration-ordered middleware snapshot for this read. */
1800
+ chain;
1801
+ /** The host-injected per-call context (`{}` when the read passed none). */
1802
+ context;
1803
+ constructor(chain, context) {
1804
+ this.chain = chain;
1805
+ this.context = context;
1806
+ }
1807
+ /** `true` when at least one middleware is registered for this read. */
1808
+ get active() {
1809
+ return this.chain.length > 0;
1810
+ }
1811
+ /**
1812
+ * Build the request-level context (R1 / R4 / R5 share it). Pure — no hooks run
1813
+ * — so a caller can create it up front and still reference it from R5 even if
1814
+ * R1 throws mid-chain.
1815
+ */
1816
+ requestCtx(kind, model, params) {
1817
+ return { kind, model, context: this.context, state: {}, params };
1818
+ }
1819
+ /**
1820
+ * R1 — request entry, before key-resolution / plan. Builds the
1821
+ * {@link ReadRequestCtx} then runs every `read.before` FIFO; a hook may mutate
1822
+ * `ctx.params` and may `throw` to cancel the read. Returns the (possibly
1823
+ * hook-mutated) ctx so the caller can re-read `params` and reuse the SAME ctx
1824
+ * for R4 / R5 (shared `state`).
1825
+ */
1826
+ async runRequestBefore(kind, model, params) {
1827
+ const ctx = this.requestCtx(kind, model, params);
1828
+ for (const mw of this.chain) {
1829
+ const before = mw.read?.before;
1830
+ if (before) await before(ctx);
1831
+ }
1832
+ return ctx;
1833
+ }
1834
+ /**
1835
+ * R4 — final hydrated, relation-merged result. Runs every `read.afterFetch`
1836
+ * LIFO, threading each hook's return value into the next (onion), and returns
1837
+ * the final (possibly replaced) result. `ctx` is the SAME object R1 produced,
1838
+ * so a `before`/`afterFetch` pair shares `ctx.state`.
1839
+ */
1840
+ async runRequestAfter(ctx, result) {
1841
+ let current = result;
1842
+ for (let i = this.chain.length - 1; i >= 0; i--) {
1843
+ const afterFetch = this.chain[i].read?.afterFetch;
1844
+ if (afterFetch) current = await afterFetch(ctx, current);
1845
+ }
1846
+ return current;
1847
+ }
1848
+ /**
1849
+ * R5 (request-level) — runs every `read.onError` LIFO. A hook may **recover**
1850
+ * by RETURNING a non-`undefined` value: it becomes the read's resolved result
1851
+ * in place of throwing, and the FIRST such hook (in LIFO order) wins and
1852
+ * short-circuits the rest of the chain. If every hook declines (returns
1853
+ * `undefined`/`void`), the original error rethrows — preserving the
1854
+ * "failed read still rejects" default. (Proposal R5 + appendix A: onError may
1855
+ * "rethrow / recover"; hooks are unrestricted.)
1856
+ *
1857
+ * The recovered value is returned as `T` (the read's result type); its shape
1858
+ * is the host's responsibility — a hook recovering a `query` should return an
1859
+ * item / `null`, one recovering a `list` a `{ items, cursor }`.
1860
+ */
1861
+ async runRequestError(ctx, err) {
1862
+ for (let i = this.chain.length - 1; i >= 0; i--) {
1863
+ const onError = this.chain[i].read?.onError;
1864
+ if (!onError) continue;
1865
+ const recovered = await onError(ctx, err);
1866
+ if (recovered !== void 0) return recovered;
1867
+ }
1868
+ throw err;
1869
+ }
1870
+ /**
1871
+ * Drive ONE physical op (root read or any fan-out fetch) through R2 → send →
1872
+ * R3, with R5 (op-level) on failure:
1873
+ *
1874
+ * 1. R2 (`read.op.before`, FIFO) — may mutate `ctx.operation`, may `throw`;
1875
+ * 2. the caller's `send` runs against the (possibly mutated) operation;
1876
+ * 3. R3 (`read.op.afterFetch`, LIFO) — transforms the raw items (onion);
1877
+ * 4. on any throw from `send` (or R2 / R3), R5 (`read.op.onError`, LIFO) runs:
1878
+ * a hook may **recover** by RETURNING an `Item[]` (those become this op's
1879
+ * items and the pipeline continues); the FIRST hook that returns items wins
1880
+ * and short-circuits the chain. If none recovers, the original error
1881
+ * rethrows.
1882
+ *
1883
+ * Returns the (possibly R3-replaced, or R5-recovered) items. The `send` closure
1884
+ * receives the final operation so a caller that built its operation from
1885
+ * `ctx.operation` before this call still observes an R2 mutation (the caller
1886
+ * MUST send `ctx.operation`, which this method passes through).
1887
+ */
1888
+ async runOp(operation, relationPath, model, send) {
1889
+ const ctx = {
1890
+ kind: opKind(operation),
1891
+ model,
1892
+ context: this.context,
1893
+ state: {},
1894
+ operation,
1895
+ relationPath
1896
+ };
1897
+ try {
1898
+ for (const mw of this.chain) {
1899
+ const before = mw.read?.op?.before;
1900
+ if (before) await before(ctx);
1901
+ }
1902
+ let items = await send(ctx.operation);
1903
+ for (let i = this.chain.length - 1; i >= 0; i--) {
1904
+ const afterFetch = this.chain[i].read?.op?.afterFetch;
1905
+ if (afterFetch) items = await afterFetch(ctx, items);
1906
+ }
1907
+ return items;
1908
+ } catch (err) {
1909
+ for (let i = this.chain.length - 1; i >= 0; i--) {
1910
+ const onError = this.chain[i].read?.op?.onError;
1911
+ if (!onError) continue;
1912
+ const recovered = await onError(ctx, err);
1913
+ if (recovered !== void 0) return recovered;
1914
+ }
1915
+ throw err;
1916
+ }
1917
+ }
1918
+ };
1919
+ var NO_MIDDLEWARE = new MiddlewareRuntime([], {});
1920
+ var WriteRuntime = class {
1921
+ chain;
1922
+ context;
1923
+ constructor(chain, context) {
1924
+ this.chain = chain;
1925
+ this.context = context;
1926
+ }
1927
+ /** `true` when at least one middleware is registered for this write. */
1928
+ get active() {
1929
+ return this.chain.length > 0;
1930
+ }
1931
+ /**
1932
+ * `true` when at least one registered middleware sets a `write.after` (W2) hook.
1933
+ * The single-op write path (issue #139) uses this to decide whether to request
1934
+ * the pre-write image (`ReturnValues: ALL_OLD`) so W2 receives a real
1935
+ * `{ oldImage, newImage }` rather than `{}` — the cost is paid only when a W2
1936
+ * hook actually exists.
1937
+ */
1938
+ get hasWriteAfter() {
1939
+ return this.chain.some((mw) => mw.write?.after !== void 0);
1940
+ }
1941
+ /**
1942
+ * Build a W1/W2/W5 logical-write context (pure — no hooks run). The caller can
1943
+ * create it up front and reuse the SAME object across W1 → W2 / W5 (shared
1944
+ * `state`). `transaction` is set when the op is part of an atomic batch.
1945
+ */
1946
+ writeCtx(kind, model, input, transaction) {
1947
+ return transaction !== void 0 ? { kind, model, context: this.context, state: {}, input, transaction } : { kind, model, context: this.context, state: {}, input };
1948
+ }
1949
+ /**
1950
+ * W1 — logical write, before effect derivation. Runs every `write.before` FIFO;
1951
+ * a hook may mutate `ctx.input` / `ctx.kind` (incl. the delete→update rewrite)
1952
+ * and may `throw` to cancel (in a transaction, the throw propagates and aborts
1953
+ * the whole batch). The caller re-reads `ctx.kind` / `ctx.input` after this
1954
+ * returns so a rewrite flows into derivation.
1955
+ */
1956
+ async runWriteBefore(ctx) {
1957
+ for (const mw of this.chain) {
1958
+ const before = mw.write?.before;
1959
+ if (before) await before(ctx);
1960
+ }
1961
+ }
1962
+ /**
1963
+ * W2 — logical write, after commit. Runs every `write.after` LIFO with the
1964
+ * committed change. Observe-only (no return threading); the SAME ctx W1
1965
+ * produced is passed so a before/after pair shares `ctx.state`.
1966
+ */
1967
+ async runWriteAfter(ctx, change2) {
1968
+ for (let i = this.chain.length - 1; i >= 0; i--) {
1969
+ const after = this.chain[i].write?.after;
1970
+ if (after) await after(ctx, change2);
1971
+ }
1972
+ }
1973
+ /**
1974
+ * W5 (logical-level) — runs every `write.onError` LIFO. A hook may **recover**
1975
+ * by RETURNING a non-`undefined` value (the first such hook wins and
1976
+ * short-circuits the chain); the value becomes the write's resolved value.
1977
+ * Otherwise the original error rethrows (symmetric with the read `onError`).
1978
+ */
1979
+ async runWriteError(ctx, err) {
1980
+ for (let i = this.chain.length - 1; i >= 0; i--) {
1981
+ const onError = this.chain[i].write?.onError;
1982
+ if (!onError) continue;
1983
+ const recovered = await onError(ctx, err);
1984
+ if (recovered !== void 0) return recovered;
1985
+ }
1986
+ throw err;
1987
+ }
1988
+ /** Build a W3/W4/W5 persist context (pure — no hooks run). */
1989
+ persistCtx(items, origins, transaction) {
1990
+ return transaction !== void 0 ? { items, origins, context: this.context, state: {}, transaction } : { items, origins, context: this.context, state: {} };
1991
+ }
1992
+ /**
1993
+ * Drive the PHYSICAL persist of one composed batch through W3 → send → W4, with
1994
+ * W5 (persist-level) on failure:
1995
+ *
1996
+ * 1. W3 (`write.persist.before`, FIFO) — may mutate `ctx.items`, may `throw`;
1997
+ * 2. the caller's `send` runs against the (possibly mutated) `ctx.items`;
1998
+ * 3. W4 (`write.persist.after`, LIFO) — observes the executor results;
1999
+ * 4. on any throw, W5 (`write.persist.onError`, LIFO) runs: a hook may
2000
+ * **recover** by returning a value (treated as the send's result; W4 still
2001
+ * runs with it). If none recovers, the original error rethrows.
2002
+ *
2003
+ * The `send` closure receives the final `ctx.items` so a caller that sends what
2004
+ * this method passes through observes a W3 mutation on the ACTUAL send. Returns
2005
+ * the persist result (the executor's, or a W5-recovered value).
2006
+ */
2007
+ async runPersist(ctx, send) {
2008
+ let results;
2009
+ try {
2010
+ for (const mw of this.chain) {
2011
+ const before = mw.write?.persist?.before;
2012
+ if (before) await before(ctx);
2013
+ }
2014
+ results = await send(ctx.items);
2015
+ } catch (err) {
2016
+ let recovered;
2017
+ let didRecover = false;
2018
+ for (let i = this.chain.length - 1; i >= 0; i--) {
2019
+ const onError = this.chain[i].write?.persist?.onError;
2020
+ if (!onError) continue;
2021
+ const r = await onError(ctx, err);
2022
+ if (r !== void 0) {
2023
+ recovered = r;
2024
+ didRecover = true;
2025
+ break;
2026
+ }
2027
+ }
2028
+ if (!didRecover) throw err;
2029
+ results = recovered;
2030
+ }
2031
+ for (let i = this.chain.length - 1; i >= 0; i--) {
2032
+ const after = this.chain[i].write?.persist?.after;
2033
+ if (after) await after(ctx, results);
2034
+ }
2035
+ return results;
2036
+ }
2037
+ };
2038
+ var NO_WRITE_MIDDLEWARE = new WriteRuntime([], {});
2039
+
2040
+ // src/middleware/context.ts
2041
+ function buildReadRuntime(context) {
2042
+ const chain = ClientManager.getMiddleware();
2043
+ if (chain.length === 0) return NO_MIDDLEWARE;
2044
+ return new MiddlewareRuntime(chain, context ?? {});
2045
+ }
2046
+ function buildWriteRuntime(context) {
2047
+ const chain = ClientManager.getMiddleware();
2048
+ if (chain.length === 0) return NO_WRITE_MIDDLEWARE;
2049
+ return new WriteRuntime(chain, context ?? {});
2050
+ }
2051
+ var ctxModelCache = /* @__PURE__ */ new WeakMap();
2052
+ function ctxModelFor(modelClass) {
2053
+ const cached2 = ctxModelCache.get(modelClass);
2054
+ if (cached2) return cached2;
2055
+ const asModel = modelClass.asModel;
2056
+ if (typeof asModel !== "function") {
2057
+ throw new Error(
2058
+ "graphddb middleware: cannot resolve a ModelStatic for the read target (the model class has no static asModel()). This is an internal invariant."
2059
+ );
2060
+ }
2061
+ const resolved = asModel.call(modelClass);
2062
+ ctxModelCache.set(modelClass, resolved);
2063
+ return resolved;
2064
+ }
2065
+
1651
2066
  // src/operations/put.ts
1652
2067
  function buildPutInput(modelClass, item, options) {
1653
2068
  const meta = MetadataRegistry.get(modelClass);
@@ -1714,12 +2129,21 @@ function buildPutInput(modelClass, item, options) {
1714
2129
  return putInput;
1715
2130
  }
1716
2131
  async function executePut(modelClass, item, options) {
2132
+ const mw = writeRuntimeFor(options);
2133
+ if (mw) {
2134
+ await executeSingleWriteWithHooks({ kind: "put", modelClass, item, options }, mw);
2135
+ return;
2136
+ }
2137
+ await executePutCore(modelClass, item, options);
2138
+ }
2139
+ async function executePutCore(modelClass, item, options, persistVia, forceOldImage) {
1717
2140
  const putInput = buildPutInput(modelClass, item, options);
1718
- const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
1719
- const result = await ClientManager.getExecutor().put(putInput, {
2141
+ const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
2142
+ const send = (input) => ClientManager.getExecutor().put(input, {
1720
2143
  ...wantsOld ? { returnOldImage: true } : {},
1721
2144
  ...options?.retry !== void 0 ? { retry: options.retry } : {}
1722
2145
  });
2146
+ const result = persistVia ? await persistVia(putInput, send) : await send(putInput);
1723
2147
  if (ChangeCaptureRegistry.hasSubscribers()) {
1724
2148
  const oldItem = result.oldItem;
1725
2149
  captureWrite({
@@ -1733,95 +2157,163 @@ async function executePut(modelClass, item, options) {
1733
2157
  }
1734
2158
  }
1735
2159
 
1736
- // src/expression/update-expression.ts
1737
- function isPlainObject(value) {
1738
- return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
1739
- }
1740
- function flattenChanges(obj, parentPath, embeddedFieldNames) {
1741
- const sets = [];
1742
- const removes = [];
1743
- for (const [fieldName, value] of Object.entries(obj)) {
1744
- const currentPath = [...parentPath, fieldName];
1745
- if (value === void 0) {
1746
- removes.push({ path: currentPath });
1747
- } else if (embeddedFieldNames.has(currentPath[0]) && isPlainObject(value)) {
1748
- const nested = flattenChanges(value, currentPath, embeddedFieldNames);
1749
- sets.push(...nested.sets);
1750
- removes.push(...nested.removes);
1751
- } else {
1752
- sets.push({ path: currentPath, value });
1753
- }
1754
- }
1755
- return { sets, removes };
1756
- }
1757
- function buildUpdateExpression(changes, embeddedFieldNames) {
1758
- const { sets, removes } = flattenChanges(changes, [], embeddedFieldNames);
1759
- if (sets.length === 0 && removes.length === 0) {
1760
- throw new Error("No changes provided for update.");
2160
+ // src/operations/delete.ts
2161
+ function buildDeleteInput(modelClass, keyObj, options) {
2162
+ const meta = MetadataRegistry.get(modelClass);
2163
+ if (!meta.primaryKey) {
2164
+ throw new Error(
2165
+ `No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
2166
+ );
1761
2167
  }
1762
- const names = {};
1763
- const values = {};
1764
- let valueCounter = 0;
1765
- const setClauses = [];
1766
- for (const op of sets) {
1767
- const pathExpr = op.path.map((segment) => {
1768
- const nameKey = `#${segment}`;
1769
- names[nameKey] = segment;
1770
- return nameKey;
1771
- }).join(".");
1772
- const valueKey = `:val${valueCounter++}`;
1773
- values[valueKey] = op.value;
1774
- setClauses.push(`${pathExpr} = ${valueKey}`);
2168
+ const tableName = TableMapping.resolve(meta.tableName);
2169
+ const fieldMap = new Map(
2170
+ meta.fields.map((f) => [f.propertyName, f])
2171
+ );
2172
+ const keyInput = {};
2173
+ for (const fieldName of meta.primaryKey.inputFieldNames) {
2174
+ let value = keyObj[fieldName];
2175
+ const fieldMeta = fieldMap.get(fieldName);
2176
+ if (fieldMeta && value !== void 0) {
2177
+ value = serializeFieldValue(value, fieldMeta);
2178
+ }
2179
+ keyInput[fieldName] = value;
1775
2180
  }
1776
- const removeClauses = [];
1777
- for (const op of removes) {
1778
- const pathExpr = op.path.map((segment) => {
1779
- const nameKey = `#${segment}`;
1780
- names[nameKey] = segment;
1781
- return nameKey;
1782
- }).join(".");
1783
- removeClauses.push(pathExpr);
2181
+ const { pk, sk } = resolveSegmentedKey(
2182
+ meta.primaryKey.segmented,
2183
+ keyInput,
2184
+ `${modelClass.name} primary key`
2185
+ );
2186
+ const deleteInput = {
2187
+ TableName: tableName,
2188
+ Key: {
2189
+ PK: pk,
2190
+ SK: sk ?? ""
2191
+ }
2192
+ };
2193
+ if (options?.condition) {
2194
+ const condResult = buildConditionExpression(options.condition);
2195
+ deleteInput.ConditionExpression = condResult.expression;
2196
+ if (Object.keys(condResult.names).length > 0) {
2197
+ deleteInput.ExpressionAttributeNames = condResult.names;
2198
+ }
2199
+ if (Object.keys(condResult.values).length > 0) {
2200
+ deleteInput.ExpressionAttributeValues = condResult.values;
2201
+ }
1784
2202
  }
1785
- const parts = [];
1786
- if (setClauses.length > 0) {
1787
- parts.push(`SET ${setClauses.join(", ")}`);
2203
+ return deleteInput;
2204
+ }
2205
+ async function executeDelete(modelClass, keyObj, options) {
2206
+ const mw = writeRuntimeFor(options);
2207
+ if (mw) {
2208
+ await executeSingleWriteWithHooks({ kind: "delete", modelClass, key: keyObj, options }, mw);
2209
+ return;
1788
2210
  }
1789
- if (removeClauses.length > 0) {
1790
- parts.push(`REMOVE ${removeClauses.join(", ")}`);
2211
+ await executeDeleteCore(modelClass, keyObj, options);
2212
+ }
2213
+ async function executeDeleteCore(modelClass, keyObj, options, persistVia, forceOldImage) {
2214
+ const deleteInput = buildDeleteInput(modelClass, keyObj, options);
2215
+ const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
2216
+ const send = (input) => ClientManager.getExecutor().delete(input, {
2217
+ ...wantsOld ? { returnOldImage: true } : {},
2218
+ ...options?.retry !== void 0 ? { retry: options.retry } : {}
2219
+ });
2220
+ const result = persistVia ? await persistVia(deleteInput, send) : await send(deleteInput);
2221
+ if (ChangeCaptureRegistry.hasSubscribers()) {
2222
+ const oldItem = result.oldItem;
2223
+ captureWrite({
2224
+ table: deleteInput.TableName,
2225
+ model: modelClass.name,
2226
+ op: "delete",
2227
+ keys: { pk: String(deleteInput.Key.PK), sk: String(deleteInput.Key.SK ?? "") },
2228
+ ...oldItem ? { oldItem } : {}
2229
+ });
1791
2230
  }
1792
- return {
1793
- expression: parts.join(" "),
1794
- names,
1795
- values
1796
- };
1797
2231
  }
1798
2232
 
1799
- // src/hydrator/hidden-key.ts
1800
- var GRAPHDDB_KEY = /* @__PURE__ */ Symbol("graphddb:key");
1801
- function attachHiddenKey(result, raw) {
1802
- const pk = raw.PK;
1803
- if (typeof pk !== "string") {
1804
- throw new Error(
1805
- "Cannot attach updatable key: raw item is missing a string `PK` attribute."
2233
+ // src/operations/write-hooks.ts
2234
+ function writeRuntimeFor(options) {
2235
+ const mw = buildWriteRuntime(options?.context);
2236
+ return mw.active ? mw : void 0;
2237
+ }
2238
+ async function executeSingleWriteWithHooks(req, mw) {
2239
+ const input = {};
2240
+ if (req.item !== void 0) input.item = { ...req.item };
2241
+ if (req.key !== void 0) input.key = { ...req.key };
2242
+ if (req.changes !== void 0) input.changes = { ...req.changes };
2243
+ if (req.options !== void 0) input.options = { ...req.options };
2244
+ const ctx = mw.writeCtx(req.kind, ctxModelFor(req.modelClass), input);
2245
+ try {
2246
+ await mw.runWriteBefore(ctx);
2247
+ const change2 = await persistRewrittenWrite(req.modelClass, ctx, mw);
2248
+ await mw.runWriteAfter(ctx, change2);
2249
+ } catch (err) {
2250
+ await mw.runWriteError(ctx, err);
2251
+ }
2252
+ }
2253
+ async function persistRewrittenWrite(modelClass, ctx, mw) {
2254
+ const opts = ctx.input.options;
2255
+ const origin = { model: ctx.model, kind: ctx.kind };
2256
+ const forceOldImage = mw.hasWriteAfter;
2257
+ const cap = {};
2258
+ if (ctx.kind === "put") {
2259
+ await executePutCore(
2260
+ modelClass,
2261
+ ctx.input.item ?? {},
2262
+ opts,
2263
+ (input, send) => runPersist(mw, { Put: input }, origin, cap, (it) => {
2264
+ const put = it.Put;
2265
+ cap.newImage = put.Item;
2266
+ return send(put);
2267
+ }),
2268
+ forceOldImage
2269
+ );
2270
+ return change(cap, true);
2271
+ }
2272
+ if (ctx.kind === "update") {
2273
+ await executeUpdateCore(
2274
+ modelClass,
2275
+ ctx.input.key ?? {},
2276
+ ctx.input.changes ?? {},
2277
+ opts,
2278
+ (input, send) => runPersist(mw, { Update: input }, origin, cap, (it) => {
2279
+ const upd = it.Update;
2280
+ const old = cap.result?.oldItem;
2281
+ cap.newImage = { ...old ?? upd.Key, ...ctx.input.changes ?? {} };
2282
+ return send(upd);
2283
+ }),
2284
+ forceOldImage
1806
2285
  );
2286
+ return change(cap, true);
1807
2287
  }
1808
- const sk = raw.SK;
1809
- const value = {
1810
- PK: pk,
1811
- SK: typeof sk === "string" ? sk : ""
1812
- };
1813
- Object.defineProperty(result, GRAPHDDB_KEY, {
1814
- value,
1815
- enumerable: false,
1816
- writable: false,
1817
- configurable: false
1818
- });
1819
- return result;
2288
+ await executeDeleteCore(
2289
+ modelClass,
2290
+ ctx.input.key ?? {},
2291
+ opts,
2292
+ (input, send) => runPersist(
2293
+ mw,
2294
+ { Delete: input },
2295
+ origin,
2296
+ cap,
2297
+ (it) => send(it.Delete)
2298
+ ),
2299
+ forceOldImage
2300
+ );
2301
+ return change(cap, false);
1820
2302
  }
1821
- function readHiddenKey(entity) {
1822
- const value = entity[GRAPHDDB_KEY];
1823
- if (value === void 0) return void 0;
1824
- return value;
2303
+ function change(cap, withNew) {
2304
+ const out = {};
2305
+ const oldImage = cap.result?.oldItem;
2306
+ if (oldImage !== void 0) out.oldImage = oldImage;
2307
+ if (withNew && cap.newImage !== void 0) {
2308
+ out.newImage = cap.newImage;
2309
+ }
2310
+ return out;
2311
+ }
2312
+ async function runPersist(mw, item, origin, cap, send) {
2313
+ const ctx = mw.persistCtx([item], [origin]);
2314
+ const result = await mw.runPersist(ctx, async (items) => send(items[0]));
2315
+ cap.result = result;
2316
+ return result;
1825
2317
  }
1826
2318
 
1827
2319
  // src/operations/update.ts
@@ -2035,6 +2527,17 @@ async function rederiveByReadModifyWrite(modelClass, entity, changes, options, p
2035
2527
  return buildUpdateInput(modelClass, enriched, changes, guarded);
2036
2528
  }
2037
2529
  async function executeUpdate(modelClass, entity, changes, options) {
2530
+ const mw = writeRuntimeFor(options);
2531
+ if (mw) {
2532
+ await executeSingleWriteWithHooks(
2533
+ { kind: "update", modelClass, key: entity, changes, options },
2534
+ mw
2535
+ );
2536
+ return;
2537
+ }
2538
+ await executeUpdateCore(modelClass, entity, changes, options);
2539
+ }
2540
+ async function executeUpdateCore(modelClass, entity, changes, options, persistVia, forceOldImage) {
2038
2541
  let updateInput;
2039
2542
  if (options?.rederive === "read-modify-write") {
2040
2543
  const meta = MetadataRegistry.get(modelClass);
@@ -2066,11 +2569,12 @@ async function executeUpdate(modelClass, entity, changes, options) {
2066
2569
  } else {
2067
2570
  updateInput = buildUpdateInput(modelClass, entity, changes, options);
2068
2571
  }
2069
- const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
2070
- const result = await ClientManager.getExecutor().update(updateInput, {
2572
+ const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass) || forceOldImage === true;
2573
+ const send = (input) => ClientManager.getExecutor().update(input, {
2071
2574
  ...wantsOld ? { returnOldImage: true } : {},
2072
2575
  ...options?.retry !== void 0 ? { retry: options.retry } : {}
2073
2576
  });
2577
+ const result = persistVia ? await persistVia(updateInput, send) : await send(updateInput);
2074
2578
  if (ChangeCaptureRegistry.hasSubscribers()) {
2075
2579
  const oldItem = result.oldItem;
2076
2580
  const keys = { pk: String(updateInput.Key.PK), sk: String(updateInput.Key.SK ?? "") };
@@ -2090,70 +2594,6 @@ async function executeUpdate(modelClass, entity, changes, options) {
2090
2594
  }
2091
2595
  }
2092
2596
 
2093
- // src/operations/delete.ts
2094
- function buildDeleteInput(modelClass, keyObj, options) {
2095
- const meta = MetadataRegistry.get(modelClass);
2096
- if (!meta.primaryKey) {
2097
- throw new Error(
2098
- `No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
2099
- );
2100
- }
2101
- const tableName = TableMapping.resolve(meta.tableName);
2102
- const fieldMap = new Map(
2103
- meta.fields.map((f) => [f.propertyName, f])
2104
- );
2105
- const keyInput = {};
2106
- for (const fieldName of meta.primaryKey.inputFieldNames) {
2107
- let value = keyObj[fieldName];
2108
- const fieldMeta = fieldMap.get(fieldName);
2109
- if (fieldMeta && value !== void 0) {
2110
- value = serializeFieldValue(value, fieldMeta);
2111
- }
2112
- keyInput[fieldName] = value;
2113
- }
2114
- const { pk, sk } = resolveSegmentedKey(
2115
- meta.primaryKey.segmented,
2116
- keyInput,
2117
- `${modelClass.name} primary key`
2118
- );
2119
- const deleteInput = {
2120
- TableName: tableName,
2121
- Key: {
2122
- PK: pk,
2123
- SK: sk ?? ""
2124
- }
2125
- };
2126
- if (options?.condition) {
2127
- const condResult = buildConditionExpression(options.condition);
2128
- deleteInput.ConditionExpression = condResult.expression;
2129
- if (Object.keys(condResult.names).length > 0) {
2130
- deleteInput.ExpressionAttributeNames = condResult.names;
2131
- }
2132
- if (Object.keys(condResult.values).length > 0) {
2133
- deleteInput.ExpressionAttributeValues = condResult.values;
2134
- }
2135
- }
2136
- return deleteInput;
2137
- }
2138
- async function executeDelete(modelClass, keyObj, options) {
2139
- const deleteInput = buildDeleteInput(modelClass, keyObj, options);
2140
- const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
2141
- const result = await ClientManager.getExecutor().delete(deleteInput, {
2142
- ...wantsOld ? { returnOldImage: true } : {},
2143
- ...options?.retry !== void 0 ? { retry: options.retry } : {}
2144
- });
2145
- if (ChangeCaptureRegistry.hasSubscribers()) {
2146
- const oldItem = result.oldItem;
2147
- captureWrite({
2148
- table: deleteInput.TableName,
2149
- model: modelClass.name,
2150
- op: "delete",
2151
- keys: { pk: String(deleteInput.Key.PK), sk: String(deleteInput.Key.SK ?? "") },
2152
- ...oldItem ? { oldItem } : {}
2153
- });
2154
- }
2155
- }
2156
-
2157
2597
  // src/runtime/same-key-collapse.ts
2158
2598
  function collapseSameKey(items, view) {
2159
2599
  const groups = /* @__PURE__ */ new Map();
@@ -2216,12 +2656,21 @@ async function commitTransaction(items, options = {}) {
2216
2656
  `Transaction exceeds DynamoDB limit of ${MAX_TRANSACT_ITEMS} items`
2217
2657
  );
2218
2658
  }
2219
- await ClientManager.getExecutor().transactWrite(
2220
- collapsed,
2659
+ const sendBatch = (items2) => ClientManager.getExecutor().transactWrite(
2660
+ items2,
2221
2661
  options.retry !== void 0 ? { retry: options.retry } : void 0
2222
2662
  );
2223
- captureTransactItems(collapsed, options.modelBySignature ?? EMPTY_MODEL_MAP);
2224
- return collapsed;
2663
+ let committed = collapsed;
2664
+ if (options.middleware) {
2665
+ const { runtime, origins, transaction } = options.middleware;
2666
+ const persistCtx = runtime.persistCtx(collapsed, origins, transaction);
2667
+ await runtime.runPersist(persistCtx, async (items2) => sendBatch(items2));
2668
+ committed = persistCtx.items;
2669
+ } else {
2670
+ await sendBatch(collapsed);
2671
+ }
2672
+ captureTransactItems(committed, options.modelBySignature ?? EMPTY_MODEL_MAP);
2673
+ return committed;
2225
2674
  }
2226
2675
  var EMPTY_MODEL_MAP = /* @__PURE__ */ new Map();
2227
2676
  function captureTransactItems(items, modelBySignature) {
@@ -2338,6 +2787,14 @@ function resolveModelClass(model) {
2338
2787
  var TransactionContext = class {
2339
2788
  items = [];
2340
2789
  captureMeta = [];
2790
+ /**
2791
+ * The logical write ops, parallel to {@link items} but EXCLUDING `conditionCheck`
2792
+ * entries (those are read-only assertions, recorded only in `items`). Used by the
2793
+ * write-hook path (#139) to run W1 on each logical op and recompose the batch.
2794
+ * A `null` entry marks an `items` slot that has no logical op (a ConditionCheck),
2795
+ * so the eager and logical arrays can be re-aligned at commit.
2796
+ */
2797
+ logicalOps = [];
2341
2798
  get itemCount() {
2342
2799
  return this.items.length;
2343
2800
  }
@@ -2346,6 +2803,7 @@ var TransactionContext = class {
2346
2803
  const modelClass = resolveModelClass(model);
2347
2804
  const putInput = buildPutInput(modelClass, item, options);
2348
2805
  this.items.push({ Put: putInput });
2806
+ this.logicalOps.push({ kind: "put", modelClass, item, options });
2349
2807
  this.captureMeta.push({
2350
2808
  modelName: modelClass.name,
2351
2809
  op: "put",
@@ -2359,6 +2817,7 @@ var TransactionContext = class {
2359
2817
  const modelClass = resolveModelClass(model);
2360
2818
  const updateInput = buildUpdateInput(modelClass, entity, changes, options);
2361
2819
  this.items.push({ Update: updateInput });
2820
+ this.logicalOps.push({ kind: "update", modelClass, key: entity, changes, options });
2362
2821
  this.captureMeta.push({
2363
2822
  modelName: modelClass.name,
2364
2823
  op: "update",
@@ -2371,6 +2830,7 @@ var TransactionContext = class {
2371
2830
  const modelClass = resolveModelClass(model);
2372
2831
  const deleteInput = buildDeleteInput(modelClass, key2, options);
2373
2832
  this.items.push({ Delete: deleteInput });
2833
+ this.logicalOps.push({ kind: "delete", modelClass, key: key2, options });
2374
2834
  this.captureMeta.push({
2375
2835
  modelName: modelClass.name,
2376
2836
  op: "delete",
@@ -2412,11 +2872,16 @@ var TransactionContext = class {
2412
2872
  check.ExpressionAttributeValues = cond2.values;
2413
2873
  }
2414
2874
  this.items.push({ ConditionCheck: check });
2875
+ this.logicalOps.push(null);
2415
2876
  }
2416
2877
  /** @internal */
2417
2878
  getTransactItems() {
2418
2879
  return this.items;
2419
2880
  }
2881
+ /** @internal — the recorded logical write ops (issue #139 W1), `null` per ConditionCheck slot. */
2882
+ getLogicalOps() {
2883
+ return this.logicalOps;
2884
+ }
2420
2885
  /** @internal — capture descriptors for the write-capture seam (issue #72). */
2421
2886
  getCaptureMeta() {
2422
2887
  return this.captureMeta;
@@ -2429,13 +2894,47 @@ var TransactionContext = class {
2429
2894
  }
2430
2895
  }
2431
2896
  };
2432
- async function executeTransaction(fn) {
2897
+ async function executeTransaction(fn, options) {
2433
2898
  const tx = new TransactionContext();
2434
2899
  await fn(tx);
2435
- const transactItems = tx.getTransactItems();
2900
+ let transactItems = tx.getTransactItems();
2436
2901
  if (transactItems.length === 0) {
2437
2902
  return;
2438
2903
  }
2904
+ const writeMw = buildWriteRuntime(options?.context);
2905
+ if (writeMw.active) {
2906
+ const txId = { id: /* @__PURE__ */ Symbol("graphddb.transaction") };
2907
+ const logical = tx.getLogicalOps();
2908
+ const items = [...transactItems];
2909
+ const origins = [];
2910
+ const writeCtxs = [];
2911
+ const modelBySignature2 = /* @__PURE__ */ new Map();
2912
+ for (let i = 0; i < logical.length; i++) {
2913
+ const op = logical[i];
2914
+ if (op === null) continue;
2915
+ const input = {};
2916
+ if (op.item !== void 0) input.item = { ...op.item };
2917
+ if (op.key !== void 0) input.key = { ...op.key };
2918
+ if (op.changes !== void 0) input.changes = { ...op.changes };
2919
+ if (op.options !== void 0) input.options = { ...op.options };
2920
+ const ctx = writeMw.writeCtx(op.kind, ctxModelFor(op.modelClass), input, txId);
2921
+ await writeMw.runWriteBefore(ctx);
2922
+ const built = buildLogicalItem(op.modelClass, ctx);
2923
+ items[i] = built;
2924
+ modelBySignature2.set(execItemKeySignature(built), op.modelClass.name);
2925
+ origins.push({ model: ctx.model, kind: ctx.kind });
2926
+ writeCtxs.push(ctx);
2927
+ }
2928
+ transactItems = items;
2929
+ await commitTransaction(transactItems, {
2930
+ modelBySignature: modelBySignature2,
2931
+ middleware: { runtime: writeMw, origins, transaction: txId }
2932
+ });
2933
+ for (let j = writeCtxs.length - 1; j >= 0; j--) {
2934
+ await writeMw.runWriteAfter(writeCtxs[j], {});
2935
+ }
2936
+ return;
2937
+ }
2439
2938
  const modelBySignature = /* @__PURE__ */ new Map();
2440
2939
  for (const meta of tx.getCaptureMeta()) {
2441
2940
  if (meta.modelName) {
@@ -2449,6 +2948,18 @@ async function executeTransaction(fn) {
2449
2948
  modelBySignature
2450
2949
  });
2451
2950
  }
2951
+ function buildLogicalItem(modelClass, ctx) {
2952
+ const opts = ctx.input.options;
2953
+ if (ctx.kind === "put") {
2954
+ return { Put: buildPutInput(modelClass, ctx.input.item ?? {}, opts) };
2955
+ }
2956
+ if (ctx.kind === "update") {
2957
+ return {
2958
+ Update: buildUpdateInput(modelClass, ctx.input.key ?? {}, ctx.input.changes ?? {}, opts)
2959
+ };
2960
+ }
2961
+ return { Delete: buildDeleteInput(modelClass, ctx.input.key ?? {}, opts) };
2962
+ }
2452
2963
 
2453
2964
  export {
2454
2965
  isColumn,
@@ -2475,6 +2986,7 @@ export {
2475
2986
  isParam,
2476
2987
  BATCH_GET_MAX_KEYS,
2477
2988
  BATCH_WRITE_MAX_ITEMS,
2989
+ chunkArray,
2478
2990
  DynamoExecutor,
2479
2991
  DEFAULT_MAX_ATTEMPTS,
2480
2992
  DEFAULT_RETRY_POLICY,
@@ -2492,14 +3004,18 @@ export {
2492
3004
  serializeFieldValue,
2493
3005
  ChangeCaptureRegistry,
2494
3006
  captureWrite,
2495
- buildPutInput,
2496
- executePut,
3007
+ NO_MIDDLEWARE,
3008
+ buildReadRuntime,
3009
+ buildWriteRuntime,
3010
+ ctxModelFor,
2497
3011
  buildUpdateExpression,
2498
3012
  attachHiddenKey,
2499
3013
  buildUpdateInput,
2500
3014
  executeUpdate,
2501
3015
  buildDeleteInput,
2502
3016
  executeDelete,
3017
+ buildPutInput,
3018
+ executePut,
2503
3019
  MAX_TRANSACT_ITEMS,
2504
3020
  collapseSameKeyItems,
2505
3021
  commitTransaction,