Repository: denoland/deno
Pull request: feat(unstable): implement custom inspect for Deno.AtomicOperation

<!--
Before submitting a PR, please read https://docs.deno.com/runtime/manual/references/contributing
-->

## Summary

Adds a custom inspect method to `AtomicOperation` to provide a readable string representation of the queued operations (checks, mutations, enqueues). This improves developer experience when debugging atomic operations by showing the internal state in a human-readable format.

The implementation adds a custom `[Symbol.for("Deno.customInspect")]` method that formats the operations into a structured string showing:
- Check operations with their keys and versionstamps
- Mutation operations (set, delete, sum) with keys and values
- Enqueue operations with payloads and options

## Related Issues

Fixes #21034

The issue requested better debugging/logging support for the `AtomicOperation` class to help developers understand what operations are queued in an atomic transaction.

## Changes

- Added `[Symbol.for("Deno.customInspect")]` method to `AtomicOperation` class
- Implemented formatting for check, mutation, and enqueue operations
- Added comprehensive test coverage in `tests/unit/kv_test.ts`

## Testing

- Added comprehensive unit tests covering:
  - Empty atomic operations
  - Operations with checks only
  - Operations with mutations only (set, delete, sum)
  - Operations with enqueues only
  - Complex combinations of all operation types
  - Special value handling (Uint8Array, null, undefined, bigint)

All tests verify the expected human-readable inspection strings match the actual output from `Deno.inspect()`.

## Checklist

- ✅ Tests cover the changes
- ✅ `cargo test` passes
- ✅ `./tools/format.js` passes without changing files
- ✅ `./tools/lint.js` passes
- ✅ Referenced related issue #21034

## Release Notes

```markdown
### kv

- Added custom inspect for `AtomicOperation` to provide readable output for debugging atomic transactions.

Changed files:
filename: ext/kv/01_db.ts
patch: @@ -16,6 +16,7 @@ import {
 } from "ext:core/ops";
 const {
   ArrayFrom,
+  ArrayPrototypeJoin,
   ArrayPrototypeMap,
   ArrayPrototypePush,
   ArrayPrototypeReverse,
@@ -567,6 +568,122 @@ class AtomicOperation {
       "'Deno.AtomicOperation' is not a promise: did you forget to call 'commit()'",
     );
   }
+
+  [SymbolFor("Deno.privateCustomInspect")](inspect, inspectOptions) {
+    const operations = [];
+
+    // Format checks
+    for (let i = 0; i < this.#checks.length; ++i) {
+      const check = this.#checks[i];
+      const key = check[0];
+      const versionstamp = check[1];
+      const keyStr = inspect(key, inspectOptions);
+      const versionstampStr = versionstamp === null
+        ? "null"
+        : `"${versionstamp}"`;
+      ArrayPrototypePush(
+        operations,
+        `  check({ key: ${keyStr}, versionstamp: ${versionstampStr} })`,
+      );
+    }
+
+    // Format mutations
+    for (let i = 0; i < this.#mutations.length; ++i) {
+      const mutation = this.#mutations[i];
+      const key = mutation[0];
+      const type = mutation[1];
+      const rawValue = mutation[2];
+      const expireIn = mutation[3];
+      const keyStr = inspect(key, inspectOptions);
+
+      if (type === "delete") {
+        ArrayPrototypePush(operations, `  delete(${keyStr})`);
+      } else {
+        // Deserialize value for display
+        let value;
+        try {
+          if (rawValue === null) {
+            value = null;
+          } else {
+            switch (rawValue.kind) {
+              case "v8":
+                value = core.deserialize(rawValue.value, { forStorage: true });
+                break;
+              case "bytes":
+                value = rawValue.value;
+                break;
+              case "u64":
+                value = new KvU64(rawValue.value);
+                break;
+              default:
+                value = rawValue;
+            }
+          }
+        } catch {
+          // If deserialization fails, show the raw value structure
+          value = `[${rawValue?.kind || "unknown"} value]`;
+        }
+
+        const valueStr = inspect(value, inspectOptions);
+
+        if (type === "set" && expireIn !== undefined) {
+          ArrayPrototypePush(
+            operations,
+            `  set(${keyStr}, ${valueStr}, { expireIn: ${expireIn} })`,
+          );
+        } else {
+          ArrayPrototypePush(operations, `  ${type}(${keyStr}, ${valueStr})`);
+        }
+      }
+    }
+
+    // Format enqueues
+    for (let i = 0; i < this.#enqueues.length; ++i) {
+      const enqueue = this.#enqueues[i];
+      const serializedMessage = enqueue[0];
+      const delay = enqueue[1];
+      const keysIfUndelivered = enqueue[2];
+      const backoffSchedule = enqueue[3];
+
+      // Deserialize message for display
+      let message;
+      try {
+        message = core.deserialize(serializedMessage, { forStorage: true });
+      } catch {
+        message = "[serialized message]";
+      }
+
+      const messageStr = inspect(message, inspectOptions);
+
+      if (
+        delay === 0 && keysIfUndelivered.length === 0 &&
+        backoffSchedule === null
+      ) {
+        ArrayPrototypePush(operations, `  enqueue(${messageStr})`);
+      } else {
+        const options = [];
+        if (delay !== 0) ArrayPrototypePush(options, `delay: ${delay}`);
+        if (keysIfUndelivered.length > 0) {
+          const keysStr = inspect(keysIfUndelivered, inspectOptions);
+          ArrayPrototypePush(options, `keysIfUndelivered: ${keysStr}`);
+        }
+        if (backoffSchedule !== null) {
+          const scheduleStr = inspect(backoffSchedule, inspectOptions);
+          ArrayPrototypePush(options, `backoffSchedule: ${scheduleStr}`);
+        }
+        ArrayPrototypePush(
+          operations,
+          `  enqueue(${messageStr}, { ${ArrayPrototypeJoin(options, ", ")} })`,
+        );
+      }
+    }
+
+    if (operations.length === 0) {
+      return "AtomicOperation (empty)";
+    }
+
+    return `AtomicOperation\n${ArrayPrototypeJoin(operations, "\n")}`;
+  }
 }
 
 const MIN_U64 = BigInt("0");

filename: tests/unit/kv_test.ts
patch: @@ -2319,3 +2319,145 @@ Deno.test({
     await completion;
   },
 });
+
+// AtomicOperation custom inspect tests
+dbTest("AtomicOperation custom inspect - empty operation", (db) => {
+  const atomic = db.atomic();
+  const inspected = Deno.inspect(atomic);
+  assertEquals(inspected, "AtomicOperation (empty)");
+});
+
+dbTest("AtomicOperation custom inspect - with check operations", (db) => {
+  const atomic = db.atomic()
+    .check({ key: ["users", "alice"], versionstamp: "version123" })
+    .check({ key: ["posts", 42], versionstamp: null });
+
+  const inspected = Deno.inspect(atomic);
+  assert(inspected.includes("AtomicOperation"));
+  assert(
+    inspected.includes(
+      'check({ key: [ "users", "alice" ], versionstamp: "version123" })',
+    ),
+  );
+  assert(
+    inspected.includes('check({ key: [ "posts", 42 ], versionstamp: null })'),
+  );
+});
+
+dbTest("AtomicOperation custom inspect - with mutations", (db) => {
+  const atomic = db.atomic()
+    .set(["users", "bob"], { name: "Bob", age: 30 })
+    .set(["temp", "data"], "temporary", { expireIn: 60000 })
+    .delete(["old", "record"])
+    .sum(["counters", "visits"], 5n);
+
+  const inspected = Deno.inspect(atomic);
+  assert(inspected.includes("AtomicOperation"));
+  assert(
+    inspected.includes('set([ "users", "bob" ], { name: "Bob", age: 30 })'),
+  );
+  assert(
+    inspected.includes(
+      'set([ "temp", "data" ], "temporary", { expireIn: 60000 })',
+    ),
+  );
+  assert(inspected.includes('delete([ "old", "record" ])'));
+  assert(inspected.includes('sum([ "counters", "visits" ], [Deno.KvU64: 5n])'));
+});
+
+dbTest("AtomicOperation custom inspect - with enqueue operations", (db) => {
+  const atomic = db.atomic()
+    .enqueue({ type: "email", to: "user@example.com" })
+    .enqueue({ type: "reminder" }, { delay: 3600000 })
+    .enqueue(
+      { type: "notification" },
+      { keysIfUndelivered: [["failed_notifications", "batch1"]] },
+    )
+    .enqueue(
+      { type: "retry_task" },
+      {
+        delay: 1000,
+        backoffSchedule: [1000, 2000, 4000],
+        keysIfUndelivered: [["failed_tasks"]],
+      },
+    );
+
+  const inspected = Deno.inspect(atomic);
+  assert(inspected.includes("AtomicOperation"));
+  assert(
+    inspected.includes('enqueue({ type: "email", to: "user@example.com" })'),
+  );
+  assert(
+    inspected.includes('enqueue({ type: "reminder" }, { delay: 3600000 })'),
+  );
+  assert(
+    inspected.includes(
+      'keysIfUndelivered: [ [ "failed_notifications", "batch1" ] ]',
+    ),
+  );
+  assert(inspected.includes("backoffSchedule: [ 1000, 2000, 4000 ]"));
+});
+
+dbTest("AtomicOperation custom inspect - complex operation", (db) => {
+  const atomic = db.atomic()
+    .check({ key: ["users", "alice"], versionstamp: "v1" })
+    .set(["users", "alice"], { name: "Alice Updated", version: 2 })
+    .sum(["stats", "user_updates"], 1n)
+    .delete(["cache", "user_alice"])
+    .enqueue({ type: "user_updated", userId: "alice" });
+
+  const inspected = Deno.inspect(atomic);
+
+  // Verify the output contains all expected operations
+  assert(inspected.includes("AtomicOperation"));
+  assert(inspected.includes("check("));
+  assert(inspected.includes("set("));
+  assert(inspected.includes("sum("));
+  assert(inspected.includes("delete("));
+  assert(inspected.includes("enqueue("));
+
+  // Verify operations appear in the correct format
+  assert(
+    inspected.includes(
+      'check({ key: [ "users", "alice" ], versionstamp: "v1" })',
+    ),
+  );
+  assert(
+    inspected.includes(
+      'set([ "users", "alice" ], { name: "Alice Updated", version: 2 })',
+    ),
+  );
+  assert(
+    inspected.includes('sum([ "stats", "user_updates" ], [Deno.KvU64: 1n])'),
+  );
+  assert(inspected.includes('delete([ "cache", "user_alice" ])'));
+  assert(
+    inspected.includes('enqueue({ type: "user_updated", userId: "alice" })'),
+  );
+
+  // Verify the structure - should start with "AtomicOperation" and have operations indented
+  const lines = inspected.split("\n");
+  assertEquals(lines[0], "AtomicOperation");
+  // Each operation should be indented with 2 spaces
+  for (let i = 1; i < lines.length; i++) {
+    if (lines[i].trim()) { // Skip empty lines
+      assert(lines[i].startsWith("  ")); // Should start with indentation
+    }
+  }
+});
+
+dbTest("AtomicOperation custom inspect - handles special values", (db) => {
+  const uint8Array = new Uint8Array([1, 2, 3, 4]);
+  const atomic = db.atomic()
+    .set(["bytes"], uint8Array)
+    .set(["null"], null)
+    .set(["undefined"], undefined)
+    .set(["bigint"], 9007199254740991n);
+
+  const inspected = Deno.inspect(atomic);
+  assert(inspected.includes("AtomicOperation"));
+  assert(inspected.includes('set([ "bytes" ], Uint8Array(4) [ 1, 2, 3, 4 ])'));
+  assert(inspected.includes('set([ "null" ], null)'));
+  assert(inspected.includes('set([ "undefined" ], undefined)'));
+  assert(inspected.includes('set([ "bigint" ], 9007199254740991n)'));
+});